This commit is contained in:
Arkaprabha Chakraborty
2025-10-30 11:04:17 +05:30
commit 02a102481e
57 changed files with 15238 additions and 0 deletions

View File

@@ -0,0 +1,225 @@
import React, { useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
import Editor from '@monaco-editor/react';
import type { ThemeConfig } from '@/lib/themes';
interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
onSelectionChange?: (lineStart: number, lineEnd: number) => void;
language?: string;
className?: string;
themeConfig?: ThemeConfig;
}
export interface CodeEditorRef {
selectLines: (startLine: number, endLine?: number) => void;
focus: () => void;
}
export const CodeEditor = forwardRef<CodeEditorRef, CodeEditorProps>(({
value,
onChange,
onSelectionChange,
language = 'plaintext',
className = '',
themeConfig
}, ref) => {
const editorRef = useRef<any>(null);
const monacoRef = useRef<any>(null);
const [editorReady, setEditorReady] = React.useState(false);
// Expose methods to parent component
useImperativeHandle(ref, () => ({
selectLines: (startLine: number, endLine?: number) => {
if (editorRef.current) {
const actualEndLine = endLine || startLine;
const selection = {
startLineNumber: startLine,
startColumn: 1,
endLineNumber: actualEndLine,
endColumn: editorRef.current.getModel()?.getLineMaxColumn(actualEndLine) || 1,
};
editorRef.current.setSelection(selection);
editorRef.current.revealLineInCenter(startLine);
editorRef.current.focus();
}
},
focus: () => {
if (editorRef.current) {
editorRef.current.focus();
}
},
}), []);
const handleEditorDidMount = (editor: any, monaco: any) => {
editorRef.current = editor;
monacoRef.current = monaco;
setEditorReady(true);
// Add selection change listener
if (onSelectionChange) {
editor.onDidChangeCursorSelection((e: any) => {
const selection = e.selection;
const startLine = selection.startLineNumber;
const endLine = selection.endLineNumber;
onSelectionChange(startLine, endLine);
});
}
};
const handleEditorChange = (value: string | undefined) => {
onChange(value || '');
};
// Ensure Monaco theme is always set after both editor and themeConfig are ready
useEffect(() => {
if (!editorReady || !themeConfig || !monacoRef.current) return;
// Convert HSL to hex for Monaco editor
const hslToHex = (hsl: string): string => {
const match = hsl.match(/hsl\((\d+),\s*(\d+)%\,\s*(\d+)%\)/);
if (!match) return '#000000';
const h = parseInt(match[1]) / 360;
const s = parseInt(match[2]) / 100;
const l = parseInt(match[3]) / 100;
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
const toHex = (c: number) => {
const hex = Math.round(c * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
const defineThemeFromConfig = (config: ThemeConfig) => {
const themeName = `osborne-${config.id}`;
const backgroundColor = hslToHex(config.colors.background);
const foregroundColor = hslToHex(config.colors.foreground);
const cardColor = hslToHex(config.colors.card);
const borderColor = hslToHex(config.colors.border);
const mutedColor = hslToHex(config.colors.muted);
const primaryColor = hslToHex(config.colors.primary);
monacoRef.current.editor.defineTheme(themeName, {
base: config.type === 'dark' ? 'vs-dark' : 'vs',
inherit: true,
rules: [
{ token: '', foreground: foregroundColor.substring(1) },
{ token: 'comment', foreground: config.type === 'dark' ? '6A9955' : '008000', fontStyle: 'italic' },
{ token: 'string', foreground: config.type === 'dark' ? 'CE9178' : 'A31515' },
{ token: 'number', foreground: config.type === 'dark' ? 'B5CEA8' : '098658' },
{ token: 'keyword', foreground: primaryColor.substring(1), fontStyle: 'bold' },
{ token: 'type', foreground: config.type === 'dark' ? '4EC9B0' : '267F99' },
{ token: 'function', foreground: config.type === 'dark' ? 'DCDCAA' : '795E26' },
{ token: 'variable', foreground: config.type === 'dark' ? '9CDCFE' : '001080' },
],
colors: {
'editor.background': backgroundColor,
'editor.foreground': foregroundColor,
'editor.lineHighlightBackground': cardColor,
'editor.selectionBackground': primaryColor + '40',
'editorLineNumber.foreground': hslToHex(config.colors.mutedForeground),
'editorLineNumber.activeForeground': foregroundColor,
'editorGutter.background': backgroundColor,
'editor.inactiveSelectionBackground': mutedColor,
'editorWhitespace.foreground': borderColor,
'editorCursor.foreground': foregroundColor,
'editorIndentGuide.background': borderColor,
'editorIndentGuide.activeBackground': primaryColor,
'editor.findMatchBackground': primaryColor + '60',
'editor.findMatchHighlightBackground': primaryColor + '30',
}
});
return themeName;
};
const themeName = defineThemeFromConfig(themeConfig);
monacoRef.current.editor.setTheme(themeName);
}, [themeConfig, editorReady]);
return (
<div className={`border border-border overflow-hidden ${className}`}>
<Editor
height="100%"
language={language}
value={value}
onChange={handleEditorChange}
onMount={handleEditorDidMount}
options={{
// Line numbers
lineNumbers: 'on',
lineNumbersMinChars: 3,
// Font settings
fontFamily: 'JetBrains Mono, Consolas, Monaco, "Courier New", monospace',
fontSize: 12,
fontWeight: '400',
// Editor behavior
wordWrap: 'on',
wrappingIndent: 'indent',
automaticLayout: true,
scrollBeyondLastLine: false,
minimap: { enabled: false },
// Visual settings
renderWhitespace: 'selection',
renderLineHighlight: 'line',
cursorBlinking: 'phase',
cursorStyle: 'line',
cursorSmoothCaretAnimation: "on",
// Remove unnecessary features for a pastebin
quickSuggestions: false,
suggestOnTriggerCharacters: false,
acceptSuggestionOnEnter: 'off',
tabCompletion: 'off',
wordBasedSuggestions: 'off',
parameterHints: { enabled: false },
hover: { enabled: false },
// Disable some advanced features
folding: false,
glyphMargin: false,
contextmenu: true,
// Scrolling
smoothScrolling: true,
scrollbar: {
vertical: 'auto',
horizontal: 'auto',
verticalScrollbarSize: 10,
horizontalScrollbarSize: 10,
},
// Selection
selectOnLineNumbers: true,
selectionHighlight: false,
occurrencesHighlight: 'off',
// Placeholder-like behavior
domReadOnly: false,
readOnly: false,
}}
loading={
<div className="flex items-center justify-center h-full bg-background text-muted-foreground">
<div className="animate-pulse">Loading editor...</div>
</div>
}
/>
</div>
);
});
CodeEditor.displayName = 'CodeEditor';

View File

@@ -0,0 +1,179 @@
import React, { useState, useEffect } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { MessageSquare } from 'lucide-react';
interface Comment {
id: string;
lineNumber: number | null;
lineRange?: string;
author: string;
authorId?: string;
content: string;
timestamp: Date;
}
interface User {
id: string;
name: string;
color: string;
lastSeen: Date;
isTyping?: boolean;
currentLine?: number;
}
interface CommentsPageProps {
isVisible: boolean;
onToggle: () => void;
selectedLineStart?: number;
selectedLineEnd?: number;
onCommentSelect?: (lineNumber: number, lineRange?: string) => void;
comments?: Comment[];
onAddComment?: (content: string, lineNumber?: number, lineRange?: string) => void;
currentUser?: User | null;
}
export const CommentsPanel: React.FC<CommentsPageProps> = ({
isVisible,
selectedLineStart,
selectedLineEnd,
onCommentSelect,
comments = [],
onAddComment,
currentUser
}) => {
const [newComment, setNewComment] = useState('');
const [selectedLine, setSelectedLine] = useState<number | null>(null);
// Update selected line when editor selection changes
useEffect(() => {
if (selectedLineStart && selectedLineEnd) {
if (selectedLineStart === selectedLineEnd) {
setSelectedLine(selectedLineStart);
} else {
setSelectedLine(selectedLineStart); // Use start line for range selections
}
} else {
setSelectedLine(null);
}
}, [selectedLineStart, selectedLineEnd]);
const handleAddComment = () => {
if (newComment.trim() && onAddComment && currentUser) {
const lineRange = selectedLineStart && selectedLineEnd && selectedLineStart !== selectedLineEnd
? `${selectedLineStart}-${selectedLineEnd}`
: undefined;
onAddComment(newComment.trim(), selectedLine || undefined, lineRange);
setNewComment('');
}
};
const formatTime = (date: Date) => {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
if (!isVisible) {
return null;
}
return (
<div className="fixed right-0 top-0 h-full w-80 bg-card border-l border-border shadow-lg z-40 flex flex-col">
{/* Comments List */}
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{comments.length === 0 ? (
<div className="text-center text-muted-foreground py-4">
<MessageSquare size={20} className="mx-auto mb-1 opacity-50" />
<p className="text-xs">No comments yet</p>
<p className="text-xs">Add a comment to get started</p>
</div>
) : (
comments
.sort((a, b) => {
// Comments with line numbers come first, sorted by line number
// Comments without line numbers come last
if (a.lineNumber === null && b.lineNumber === null) return 0;
if (a.lineNumber === null) return 1;
if (b.lineNumber === null) return -1;
return a.lineNumber - b.lineNumber;
})
.map((comment) => (
<Card
key={comment.id}
className="border-border hover:shadow-md transition-shadow cursor-pointer"
onClick={() => {
if (comment.lineNumber && onCommentSelect) {
onCommentSelect(comment.lineNumber, comment.lineRange);
}
}}
>
<CardHeader className="pb-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-foreground">
{comment.author}
</span>
{comment.lineNumber !== null && (
<Badge
variant="secondary"
className="text-xs bg-primary/10 text-primary hover:bg-primary/20 px-1 py-0"
>
{comment.lineRange || `Line ${comment.lineNumber}`}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatTime(comment.timestamp)}
</span>
</div>
</CardHeader>
<CardContent className="pt-0">
<p className="text-xs text-foreground whitespace-pre-wrap">
{comment.content}
</p>
</CardContent>
</Card>
))
)}
</div>
{/* Add Comment Form */}
<div className="border-t border-border p-2 bg-muted/20">
<div className="space-y-1">
<Textarea
placeholder={
selectedLine
? `Add a comment on ${selectedLineStart && selectedLineEnd && selectedLineStart !== selectedLineEnd
? `lines ${selectedLineStart}-${selectedLineEnd}`
: `line ${selectedLine}`
}...`
: "Add a general comment..."
}
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="min-h-[60px] bg-background border-border text-foreground placeholder:text-muted-foreground resize-none text-xs"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAddComment();
}
// Shift+Enter will naturally add a new line due to default behavior
}}
/>
<div className="text-xs text-muted-foreground text-center">
<kbd className="px-1 py-0 text-xs bg-muted border border-border rounded">
Shift
</kbd>
{' + '}
<kbd className="px-1 py-0 text-xs bg-muted border border-border rounded">
Enter
</kbd>
{' for new line'}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { Wifi, WifiOff } from 'lucide-react';
interface ConnectionStatusProps {
isConnected: boolean;
className?: string;
}
export const ConnectionStatus: React.FC<ConnectionStatusProps> = ({
isConnected,
className = ''
}) => {
return (
<div className={`flex items-center justify-center px-2 py-1 rounded-md text-xs font-medium transition-colors ${
isConnected
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
} ${className}`}>
{isConnected ? (
<Wifi size={14} />
) : (
<WifiOff size={14} />
)}
</div>
);
};

View File

@@ -0,0 +1,420 @@
import React, { useState, useRef, useEffect } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { MediaModal } from '@/components/MediaModal';
import {
Users,
Circle,
Upload,
File,
ImageIcon,
Video,
Music,
FileText,
Download,
Play,
Pause,
Trash2
} from 'lucide-react';
interface ActiveUser {
id: string;
name: string;
color: string;
lastSeen: Date;
isTyping?: boolean;
currentLine?: number;
}
interface MediaFile {
id: string;
name: string;
type: string;
size: number;
url: string;
uploadedAt: Date;
uploadedBy: string;
}
interface LeftPanelProps {
isVisible: boolean;
isConnected: boolean;
className?: string;
users?: ActiveUser[];
mediaFiles?: MediaFile[];
onFileUpload?: (files: FileList) => void;
onFileDelete?: (fileId: string) => void;
onModalStateChange?: (isOpen: boolean) => void;
}
export const LeftPanel: React.FC<LeftPanelProps> = ({
isVisible,
className = '',
users = [],
mediaFiles = [],
onFileDelete,
onModalStateChange
}) => {
const [activeUsers, setActiveUsers] = useState<ActiveUser[]>(users);
const [localMediaFiles, setLocalMediaFiles] = useState<MediaFile[]>(mediaFiles);
// Update local state when props change
useEffect(() => {
setActiveUsers(users);
}, [users]);
useEffect(() => {
setLocalMediaFiles(mediaFiles);
}, [mediaFiles]);
const [playingAudio, setPlayingAudio] = useState<string | null>(null);
const [modalFile, setModalFile] = useState<MediaFile | null>(null);
const audioRefs = useRef<{ [key: string]: HTMLAudioElement }>({});
// Helper function to get the correct file URL using HTTP server
const getFileUrl = (file: MediaFile) => {
const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || 'http://localhost:8081';
return file.url.startsWith('http') ? file.url : `${httpUrl}${file.url}`;
};
// Helper function to handle modal state changes
const handleModalChange = (file: MediaFile | null) => {
setModalFile(file);
if (onModalStateChange) {
onModalStateChange(file !== null);
}
};
// Users Panel Functions
const getStatusIndicator = (user: ActiveUser) => {
const timeDiff = Date.now() - user.lastSeen.getTime();
if (timeDiff < 60000) { // Less than 1 minute
return { status: 'online', color: 'rgb(184, 187, 38)' }; // success color
} else if (timeDiff < 300000) { // Less than 5 minutes
return { status: 'away', color: 'rgb(250, 189, 47)' }; // warning color
} else {
return { status: 'offline', color: 'rgb(146, 131, 116)' }; // muted color
}
};
const formatLastSeen = (date: Date) => {
const timeDiff = Date.now() - date.getTime();
if (timeDiff < 60000) {
return 'Just now';
} else if (timeDiff < 3600000) {
const minutes = Math.floor(timeDiff / 60000);
return `${minutes}m ago`;
} else {
const hours = Math.floor(timeDiff / 3600000);
return `${hours}h ago`;
}
};
// Media Panel Functions
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatTimeAgo = (date: Date) => {
const timeDiff = Date.now() - date.getTime();
if (timeDiff < 60000) {
return 'Just now';
} else if (timeDiff < 3600000) {
const minutes = Math.floor(timeDiff / 60000);
return `${minutes}m ago`;
} else {
const hours = Math.floor(timeDiff / 3600000);
return `${hours}h ago`;
}
};
const getFileIcon = (type: string) => {
if (type.startsWith('image/')) return <ImageIcon size={16} className="text-blue-500" />;
if (type.startsWith('video/')) return <Video size={16} className="text-purple-500" />;
if (type.startsWith('audio/')) return <Music size={16} className="text-green-500" />;
if (type.includes('text') || type.includes('json') || type.includes('xml'))
return <FileText size={16} className="text-yellow-500" />;
return <File size={16} className="text-gray-500" />;
};
const getFileTypeColor = (type: string) => {
if (type.startsWith('image/')) return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
if (type.startsWith('video/')) return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300';
if (type.startsWith('audio/')) return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300';
};
const handlePlayAudio = (fileId: string, url: string) => {
// Stop any currently playing audio
if (playingAudio && audioRefs.current[playingAudio]) {
audioRefs.current[playingAudio].pause();
}
if (playingAudio === fileId) {
setPlayingAudio(null);
} else {
if (!audioRefs.current[fileId]) {
audioRefs.current[fileId] = new Audio(url);
audioRefs.current[fileId].addEventListener('ended', () => {
setPlayingAudio(null);
});
}
audioRefs.current[fileId].play();
setPlayingAudio(fileId);
}
};
const handleDeleteFile = (fileId: string) => {
// Call parent delete handler if available
if (onFileDelete) {
onFileDelete(fileId);
}
// Don't update local state here - let the parent's WebSocket update flow through props
// setLocalMediaFiles(prev => prev.filter(file => file.id !== fileId));
// Clean up audio/video refs
if (audioRefs.current[fileId]) {
audioRefs.current[fileId].pause();
delete audioRefs.current[fileId];
}
if (playingAudio === fileId) setPlayingAudio(null);
};
const handleDownload = (file: MediaFile) => {
const link = document.createElement('a');
link.href = getFileUrl(file);
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
if (!isVisible) {
return null;
}
return (
<div className={`w-64 flex flex-col space-y-4 ${className}`}>
{/* Users Panel */}
<div className="bg-card border border-border rounded-md shadow-lg">
<CardContent className="p-2 space-y-3 max-h-64 overflow-y-auto">
{activeUsers.length === 0 ? (
<div className="text-center text-muted-foreground py-4">
<Users size={20} className="mx-auto mb-2 opacity-50" />
<p className="text-xs">No active users</p>
</div>
) : (
activeUsers.map((user) => {
const { status, color } = getStatusIndicator(user);
return (
<Card key={user.id} className="bg-background border-border">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<div className="relative">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium"
style={{ backgroundColor: user.color }}
>
{user.name.charAt(0).toUpperCase()}
</div>
<Circle
size={8}
className="absolute -bottom-0.5 -right-0.5 border-2 border-background rounded-full"
style={{ color, fill: color }}
/>
</div>
<div>
<p className="text-sm font-medium text-foreground">
{user.name}
</p>
<p className="text-xs text-muted-foreground">
{formatLastSeen(user.lastSeen)}
</p>
</div>
</div>
<Badge
variant="outline"
className="text-xs"
style={{ borderColor: user.color, color: user.color }}
>
{status}
</Badge>
</div>
{user.currentLine && (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
Line {user.currentLine}
</span>
{user.isTyping && (
<div className="flex space-x-1">
<div
className="w-1 h-1 rounded-full animate-pulse"
style={{ backgroundColor: user.color }}
/>
<div
className="w-1 h-1 rounded-full animate-pulse"
style={{
backgroundColor: user.color,
animationDelay: '0.1s'
}}
/>
<div
className="w-1 h-1 rounded-full animate-pulse"
style={{
backgroundColor: user.color,
animationDelay: '0.2s'
}}
/>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
})
)}
</CardContent>
<div className="px-4 pb-3 border-t border-border bg-muted/50 rounded-b-md">
<div className="flex items-center justify-between text-xs text-muted-foreground pt-3">
<span>
{activeUsers.filter(u => getStatusIndicator(u).status === 'online').length} online
</span>
<span>
{activeUsers.filter(u => u.isTyping).length} typing
</span>
</div>
</div>
</div>
{/* Media Panel */}
<div className="bg-card border border-border rounded-md shadow-lg">
<CardContent className="p-2 space-y-3 max-h-64 overflow-y-auto">
{localMediaFiles.length === 0 ? (
<div className="text-center text-muted-foreground py-4">
<Upload size={20} className="mx-auto mb-2 opacity-50" />
<p className="text-xs">No files uploaded</p>
<p className="text-xs">Use upload button in toolbar</p>
</div>
) : (
localMediaFiles.map((file) => (
<Card key={file.id} className="bg-background border-border">
<CardContent className="p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-2 flex-1 min-w-0">
{getFileIcon(file.type)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)} {formatTimeAgo(file.uploadedAt)}
</p>
</div>
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDeleteFile(file.id)}
>
<Trash2 size={12} />
</Button>
</div>
{/* Media preview/player */}
{file.type.startsWith('image/') && (
<div className="mt-2">
<div
className="w-full h-24 bg-cover bg-center rounded border cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundImage: `url(${getFileUrl(file)})` }}
onClick={() => handleModalChange(file)}
title="Click to view full size"
/>
</div>
)}
{file.type.startsWith('video/') && (
<div className="mt-2">
<div
className="w-full h-24 bg-muted rounded border flex items-center justify-center cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => handleModalChange(file)}
title="Click to play video"
>
<Play size={20} className="text-muted-foreground" />
</div>
</div>
)}
{file.type.startsWith('audio/') && (
<div className="mt-2 flex items-center space-x-2">
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePlayAudio(file.id, getFileUrl(file))}
>
{playingAudio === file.id ? (
<Pause size={12} />
) : (
<Play size={12} />
)}
</Button>
<div className="flex-1 h-2 bg-muted rounded-full">
<div className="h-2 bg-primary rounded-full w-0"></div>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex items-center justify-between mt-2">
<Badge
variant="secondary"
className={`text-xs ${getFileTypeColor(file.type)}`}
>
{file.type.split('/')[1] || 'file'}
</Badge>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
onClick={() => handleDownload(file)}
>
<Download size={12} className="mr-1" />
Download
</Button>
</div>
</CardContent>
</Card>
))
)}
</CardContent>
<div className="px-4 pb-3 border-t border-border bg-muted/50 rounded-b-md">
<div className="flex items-center justify-between text-xs text-muted-foreground pt-3">
<span>{localMediaFiles.length} files</span>
<span>
{formatFileSize(localMediaFiles.reduce((total, file) => total + file.size, 0))} total
</span>
</div>
</div>
</div>
{/* Media Modal */}
<MediaModal
file={modalFile}
isOpen={modalFile !== null}
onClose={() => handleModalChange(null)}
onDelete={onFileDelete}
getFileUrl={getFileUrl}
/>
</div>
);
};

View File

@@ -0,0 +1,205 @@
import React from 'react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Download,
Trash2,
X,
File,
ImageIcon,
Video,
Music,
FileText
} from 'lucide-react';
interface MediaFile {
id: string;
name: string;
type: string;
size: number;
url: string;
uploadedAt: Date;
uploadedBy: string;
}
interface MediaModalProps {
file: MediaFile | null;
isOpen: boolean;
onClose: () => void;
onDelete?: (fileId: string) => void;
getFileUrl: (file: MediaFile) => string;
}
export const MediaModal: React.FC<MediaModalProps> = ({
file,
isOpen,
onClose,
onDelete,
getFileUrl
}) => {
if (!isOpen || !file) return null;
const getFileIcon = (type: string) => {
if (type.startsWith('image/')) return <ImageIcon size={20} className="text-blue-500" />;
if (type.startsWith('video/')) return <Video size={20} className="text-purple-500" />;
if (type.startsWith('audio/')) return <Music size={20} className="text-green-500" />;
if (type.includes('text') || type.includes('json') || type.includes('xml')) return <FileText size={20} className="text-yellow-500" />;
return <File size={20} className="text-gray-500" />;
};
const getFileTypeColor = (type: string) => {
if (type.startsWith('image/')) return 'text-blue-600 bg-blue-100 dark:bg-blue-900 dark:text-blue-300';
if (type.startsWith('video/')) return 'text-purple-600 bg-purple-100 dark:bg-purple-900 dark:text-purple-300';
if (type.startsWith('audio/')) return 'text-green-600 bg-green-100 dark:bg-green-900 dark:text-green-300';
return 'text-gray-600 bg-gray-100 dark:bg-gray-900 dark:text-gray-300';
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatTimeAgo = (date: Date) => {
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) return 'just now';
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
return `${Math.floor(diffInSeconds / 86400)}d ago`;
};
const handleDownload = (file: MediaFile) => {
const link = document.createElement('a');
link.href = getFileUrl(file);
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
return (
<div
className="fixed inset-0 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={onClose}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<Card className="relative max-w-[95vw] max-h-[95vh] w-auto h-auto bg-card border-border shadow-xl flex flex-col">
<div
className="relative flex flex-col h-full"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<CardHeader className="flex flex-row items-center justify-between p-4 border-b border-border">
<div className="flex items-center space-x-2">
{getFileIcon(file.type)}
<div>
<h3 className="text-lg font-semibold text-foreground truncate">
{file.name}
</h3>
<p className="text-sm text-muted-foreground">
{formatFileSize(file.size)} {formatTimeAgo(file.uploadedAt)}
</p>
</div>
</div>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 hover:bg-muted"
onClick={onClose}
>
<X size={16} />
</Button>
</CardHeader>
{/* Media Content */}
<CardContent className="p-0 flex-1 flex items-center justify-center">
<div className="relative w-full h-full flex items-center justify-center min-h-[300px] max-h-[75vh] bg-muted/50">
{file.type.startsWith('image/') && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getFileUrl(file)}
alt={file.name}
className="max-w-full max-h-full object-contain"
style={{
width: 'auto',
height: 'auto',
maxWidth: 'min(90vw, 1200px)',
maxHeight: 'min(75vh, 800px)'
}}
/>
)}
{file.type.startsWith('video/') && (
<video
src={getFileUrl(file)}
controls
autoPlay
className="max-w-full max-h-full object-contain"
style={{
width: 'auto',
height: 'auto',
maxWidth: 'min(90vw, 1200px)',
maxHeight: 'min(75vh, 800px)'
}}
/>
)}
</div>
</CardContent>
{/* Footer */}
<div className="p-4 border-t border-border bg-muted/50 flex-shrink-0">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="flex items-center space-x-4">
<Badge
variant="secondary"
className={`text-xs ${getFileTypeColor(file.type)}`}
>
{file.type.split('/')[1] || 'file'}
</Badge>
<span className="text-sm text-muted-foreground">
Uploaded by {file.uploadedBy}
</span>
</div>
<div className="flex space-x-2">
<Button
size="sm"
variant="outline"
onClick={() => handleDownload(file)}
>
<Download size={14} className="mr-2" />
Download
</Button>
{onDelete && (
<Button
size="sm"
variant="outline"
className="hover:bg-destructive hover:text-destructive-foreground"
onClick={() => {
onDelete(file.id);
onClose();
}}
>
<Trash2 size={14} className="mr-2" />
Delete
</Button>
)}
</div>
</div>
</div>
</div>
</Card>
</div>
);
};

View File

@@ -0,0 +1,311 @@
import React, { useState, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Upload,
File,
ImageIcon,
Video,
Music,
FileText,
Download,
Play,
Pause,
Trash2,
X
} from 'lucide-react';
interface MediaFile {
id: string;
name: string;
type: string;
size: number;
url: string;
uploadedAt: Date;
uploadedBy: string;
}
interface MediaPanelProps {
isVisible: boolean;
className?: string;
}
export const MediaPanel: React.FC<MediaPanelProps> = ({
isVisible,
className = ''
}) => {
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([
// Mock data for demonstration
{
id: '1',
name: 'demo-video.mp4',
type: 'video/mp4',
size: 12456789,
url: 'https://www.w3schools.com/html/mov_bbb.mp4',
uploadedAt: new Date(Date.now() - 300000),
uploadedBy: 'Alice'
},
{
id: '2',
name: 'background-music.mp3',
type: 'audio/mpeg',
size: 3456789,
url: 'https://www.soundjay.com/misc/sounds/bell-ringing-05.wav',
uploadedAt: new Date(Date.now() - 600000),
uploadedBy: 'Bob'
},
{
id: '3',
name: 'screenshot.png',
type: 'image/png',
size: 234567,
url: 'https://picsum.photos/800/600',
uploadedAt: new Date(Date.now() - 900000),
uploadedBy: 'You'
}
]);
const [playingAudio, setPlayingAudio] = useState<string | null>(null);
const [playingVideo, setPlayingVideo] = useState<string | null>(null);
const audioRefs = useRef<{ [key: string]: HTMLAudioElement }>({});
const videoRefs = useRef<{ [key: string]: HTMLVideoElement }>({});
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatTimeAgo = (date: Date) => {
const timeDiff = Date.now() - date.getTime();
if (timeDiff < 60000) {
return 'Just now';
} else if (timeDiff < 3600000) {
const minutes = Math.floor(timeDiff / 60000);
return `${minutes}m ago`;
} else {
const hours = Math.floor(timeDiff / 3600000);
return `${hours}h ago`;
}
};
const getFileIcon = (type: string) => {
if (type.startsWith('image/')) return <ImageIcon size={16} className="text-blue-500" />;
if (type.startsWith('video/')) return <Video size={16} className="text-purple-500" />;
if (type.startsWith('audio/')) return <Music size={16} className="text-green-500" />;
if (type.includes('text') || type.includes('json') || type.includes('xml'))
return <FileText size={16} className="text-yellow-500" />;
return <File size={16} className="text-gray-500" />;
};
const getFileTypeColor = (type: string) => {
if (type.startsWith('image/')) return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
if (type.startsWith('video/')) return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300';
if (type.startsWith('audio/')) return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300';
};
const handlePlayAudio = (fileId: string, url: string) => {
// Stop any currently playing audio
if (playingAudio && audioRefs.current[playingAudio]) {
audioRefs.current[playingAudio].pause();
}
if (playingAudio === fileId) {
setPlayingAudio(null);
} else {
if (!audioRefs.current[fileId]) {
audioRefs.current[fileId] = new Audio(url);
audioRefs.current[fileId].addEventListener('ended', () => {
setPlayingAudio(null);
});
}
audioRefs.current[fileId].play();
setPlayingAudio(fileId);
}
};
const handlePlayVideo = (fileId: string) => {
if (playingVideo === fileId) {
setPlayingVideo(null);
} else {
setPlayingVideo(fileId);
}
};
const handleDeleteFile = (fileId: string) => {
setMediaFiles(prev => prev.filter(file => file.id !== fileId));
// Clean up audio/video refs
if (audioRefs.current[fileId]) {
audioRefs.current[fileId].pause();
delete audioRefs.current[fileId];
}
if (playingAudio === fileId) setPlayingAudio(null);
if (playingVideo === fileId) setPlayingVideo(null);
};
const handleDownload = (file: MediaFile) => {
const link = document.createElement('a');
link.href = file.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
if (!isVisible) {
return null;
}
return (
<div className={`w-64 bg-card border border-border rounded-md shadow-lg flex flex-col ${className}`}>
{/* Header */}
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Media Files</CardTitle>
</CardHeader>
{/* Files List */}
<CardContent className="p-0 flex-1">
<div className="max-h-80 overflow-y-auto p-4 space-y-3">
{mediaFiles.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<Upload size={24} className="mx-auto mb-2 opacity-50" />
<p className="text-sm">No files uploaded</p>
<p className="text-xs">Use upload button in toolbar</p>
</div>
) : (
mediaFiles.map((file) => (
<Card key={file.id} className="bg-background border-border">
<CardContent className="p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-2 flex-1 min-w-0">
{getFileIcon(file.type)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)} {formatTimeAgo(file.uploadedAt)}
</p>
</div>
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDeleteFile(file.id)}
>
<Trash2 size={12} />
</Button>
</div>
<div className="flex items-center justify-between mb-2">
<Badge
variant="secondary"
className={`text-xs ${getFileTypeColor(file.type)}`}
>
{file.type.split('/')[1] || 'file'}
</Badge>
<span className="text-xs text-muted-foreground">
by {file.uploadedBy}
</span>
</div>
{/* Media preview/player */}
{file.type.startsWith('image/') && (
<div className="mt-2">
<div
className="w-full h-24 bg-cover bg-center rounded border"
style={{ backgroundImage: `url(${file.url})` }}
/>
</div>
)}
{file.type.startsWith('video/') && (
<div className="mt-2">
{playingVideo === file.id ? (
<div className="relative">
<video
ref={(el) => {
if (el) videoRefs.current[file.id] = el;
}}
src={file.url}
controls
className="w-full h-24 rounded border"
onEnded={() => setPlayingVideo(null)}
/>
<Button
size="sm"
variant="ghost"
className="absolute top-1 right-1 h-6 w-6 p-0 bg-black/50 hover:bg-black/70"
onClick={() => setPlayingVideo(null)}
>
<X size={12} className="text-white" />
</Button>
</div>
) : (
<div
className="w-full h-24 bg-muted rounded border flex items-center justify-center cursor-pointer hover:bg-muted/80"
onClick={() => handlePlayVideo(file.id)}
>
<Play size={20} className="text-muted-foreground" />
</div>
)}
</div>
)}
{file.type.startsWith('audio/') && (
<div className="mt-2 flex items-center space-x-2">
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePlayAudio(file.id, file.url)}
>
{playingAudio === file.id ? (
<Pause size={12} />
) : (
<Play size={12} />
)}
</Button>
<div className="flex-1 h-2 bg-muted rounded-full">
<div className="h-2 bg-primary rounded-full w-0"></div>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex justify-end mt-2">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
onClick={() => handleDownload(file)}
>
<Download size={12} className="mr-1" />
Download
</Button>
</div>
</CardContent>
</Card>
))
)}
</div>
</CardContent>
{/* Footer with file count */}
<div className="p-3 border-t border-border bg-muted/50 rounded-b-md">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{mediaFiles.length} files</span>
<span>
{formatFileSize(mediaFiles.reduce((total, file) => total + file.size, 0))} total
</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,177 @@
import React, { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Users, Circle } from 'lucide-react';
interface ActiveUser {
id: string;
name: string;
color: string;
lastSeen: Date;
isTyping?: boolean;
currentLine?: number;
}
interface ActiveUsersPanelProps {
isVisible: boolean;
className?: string;
}
export const ActiveUsersPanel: React.FC<ActiveUsersPanelProps> = ({
isVisible,
className = ''
}) => {
const [activeUsers] = useState<ActiveUser[]>([
// Mock data for demonstration
{
id: '1',
name: 'You',
color: '#b8bb26',
lastSeen: new Date(),
isTyping: false,
currentLine: 15,
},
{
id: '2',
name: 'Alice',
color: '#fb4934',
lastSeen: new Date(Date.now() - 30000), // 30 seconds ago
isTyping: true,
currentLine: 8,
},
{
id: '3',
name: 'Bob',
color: '#83a598',
lastSeen: new Date(Date.now() - 120000), // 2 minutes ago
isTyping: false,
currentLine: 23,
},
]);
const getStatusIndicator = (user: ActiveUser) => {
const timeDiff = Date.now() - user.lastSeen.getTime();
if (timeDiff < 60000) { // Less than 1 minute
return { status: 'online', color: 'rgb(184, 187, 38)' }; // success color
} else if (timeDiff < 300000) { // Less than 5 minutes
return { status: 'away', color: 'rgb(250, 189, 47)' }; // warning color
} else {
return { status: 'offline', color: 'rgb(146, 131, 116)' }; // muted color
}
};
const formatLastSeen = (date: Date) => {
const timeDiff = Date.now() - date.getTime();
if (timeDiff < 60000) {
return 'Just now';
} else if (timeDiff < 3600000) {
const minutes = Math.floor(timeDiff / 60000);
return `${minutes}m ago`;
} else {
const hours = Math.floor(timeDiff / 3600000);
return `${hours}h ago`;
}
};
if (!isVisible) {
return null;
}
return (
<div className={`w-64 bg-card border border-border rounded-md shadow-lg flex flex-col ${className}`}>
{/* Users List */}
<div className="flex-1 overflow-y-auto p-2 space-y-2 max-h-96">
{activeUsers.length === 0 ? (
<div className="text-center text-muted-foreground py-6">
<Users size={20} className="mx-auto mb-1 opacity-50" />
<p className="text-xs">No users</p>
</div>
) : (
activeUsers.map((user) => {
const { status, color } = getStatusIndicator(user);
return (
<Card key={user.id} className="bg-background border-border">
<CardContent className="p-2">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center space-x-2">
<div className="relative">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-white text-xs font-medium"
style={{ backgroundColor: user.color }}
>
{user.name.charAt(0).toUpperCase()}
</div>
<Circle
size={6}
className="absolute -bottom-0.5 -right-0.5 border border-background rounded-full"
style={{ color, fill: color }}
/>
</div>
<div>
<p className="text-xs font-medium text-foreground">
{user.name}
</p>
<p className="text-xs text-muted-foreground">
{formatLastSeen(user.lastSeen)}
</p>
</div>
</div>
<Badge
variant="outline"
className="text-xs px-1 py-0 h-4"
style={{ borderColor: user.color, color: user.color }}
>
{status}
</Badge>
</div>
{user.currentLine && (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
L{user.currentLine}
</span>
{user.isTyping && (
<div className="flex space-x-1">
<div
className="w-1 h-1 rounded-full animate-pulse"
style={{ backgroundColor: user.color }}
/>
<div
className="w-1 h-1 rounded-full animate-pulse"
style={{
backgroundColor: user.color,
animationDelay: '0.1s'
}}
/>
<div
className="w-1 h-1 rounded-full animate-pulse"
style={{
backgroundColor: user.color,
animationDelay: '0.2s'
}}
/>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
})
)}
</div>
{/* Footer with total count */}
<div className="p-2 border-t border-border bg-muted/50 rounded-b-md">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{activeUsers.filter(u => getStatusIndicator(u).status === 'online').length} online
</span>
<span>
{activeUsers.filter(u => u.isTyping).length} typing
</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,149 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
export const BackgroundBeams = React.memo(
({ className }: { className?: string }) => {
const paths = [
"M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875",
"M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867",
"M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859",
"M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851",
"M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843",
"M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835",
"M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827",
"M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819",
"M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811",
"M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803",
"M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795",
"M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787",
"M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779",
"M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771",
"M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763",
"M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755",
"M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747",
"M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739",
"M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731",
"M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723",
"M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715",
"M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707",
"M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699",
"M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691",
"M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683",
"M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675",
"M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667",
"M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659",
"M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651",
"M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643",
"M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635",
"M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627",
"M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619",
"M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611",
"M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603",
"M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595",
"M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587",
"M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579",
"M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571",
"M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563",
"M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555",
"M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547",
"M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539",
"M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531",
"M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523",
"M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515",
"M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507",
"M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499",
"M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491",
"M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483",
];
return (
<div
className={cn(
"absolute h-full w-full inset-0 [mask-size:40px] [mask-repeat:no-repeat] bg-gradient bg-gradient-to-br from-pink-100 via-blue-100 to-purple-100 flex items-center justify-center",
className,
)}
>
<svg
className=" z-0 h-full w-full pointer-events-none absolute "
width="100%"
height="100%"
viewBox="0 0 696 316"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483M-30 -589C-30 -589 38 -184 502 -57C966 70 1034 475 1034 475M-23 -597C-23 -597 45 -192 509 -65C973 62 1041 467 1041 467M-16 -605C-16 -605 52 -200 516 -73C980 54 1048 459 1048 459M-9 -613C-9 -613 59 -208 523 -81C987 46 1055 451 1055 451M-2 -621C-2 -621 66 -216 530 -89C994 38 1062 443 1062 443M5 -629C5 -629 73 -224 537 -97C1001 30 1069 435 1069 435M12 -637C12 -637 80 -232 544 -105C1008 22 1076 427 1076 427M19 -645C19 -645 87 -240 551 -113C1015 14 1083 419 1083 419"
stroke="url(#paint0_radial_242_278)"
strokeOpacity="0.3"
strokeWidth="0.5"
></path>
{paths.map((path, index) => (
<motion.path
key={`path-` + index}
d={path}
stroke={`url(#linearGradient-${index})`}
strokeOpacity="0.9"
strokeWidth="0.7"
></motion.path>
))}
<defs>
{paths.map((path, index) => (
<motion.linearGradient
id={`linearGradient-${index}`}
key={`gradient-${index}`}
initial={{
x1: "0%",
x2: "0%",
y1: "0%",
y2: "0%",
}}
animate={{
x1: ["0%", "100%"],
x2: ["0%", "95%"],
y1: ["0%", "100%"],
y2: ["0%", `${93 + Math.random() * 8}%`],
}}
transition={{
duration: Math.random() * 10 + 10,
ease: "easeInOut",
repeat: Infinity,
delay: 0,
}}
>
<stop stopColor="#FFC107" stopOpacity="0"></stop>{" "}
{/* New start color (amber) */}
<stop stopColor="#FF5722"></stop>{" "}
{/* Mid gradient color (deep orange) */}
<stop offset="32.5%" stopColor="#FF9800"></stop>{" "}
{/* Another midpoint color (orange) */}
<stop
offset="100%"
stopColor="#4CAF50"
stopOpacity="0"
></stop>{" "}
{/* End color (green) */}
</motion.linearGradient>
))}
<radialGradient
id="paint0_radial_242_278"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(352 34) rotate(90) scale(555 1560.62)"
>
<stop offset="0.0666667" stopColor="var(--neutral-300)"></stop>
<stop offset="0.243243" stopColor="var(--neutral-300)"></stop>
<stop offset="0.43594" stopColor="white" stopOpacity="0"></stop>
</radialGradient>
</defs>
</svg>
</div>
);
},
);
BackgroundBeams.displayName = "BackgroundBeams";

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground",
success:
"border-transparent bg-green-500 text-white shadow hover:bg-green-600",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-10 px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
" border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out 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",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,70 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { cn } from "@/lib/utils"
import { MinusIcon } from "@radix-ui/react-icons"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first: first:border-l last:",
isActive && "z-10 ring-1 ring-ring",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<MinusIcon />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -0,0 +1,60 @@
// Input component extends from shadcnui - https://ui.shadcn.com/docs/components/input
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useMotionTemplate, useMotionValue, motion } from "framer-motion";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
const radius = 100;
const [visible, setVisible] = React.useState(false);
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
function handleMouseMove({ currentTarget, clientX, clientY }: any) {
const { left, top } = currentTarget.getBoundingClientRect();
mouseX.set(clientX - left);
mouseY.set(clientY - top);
}
return (
<motion.div
style={{
background: useMotionTemplate`
radial-gradient(
${visible ? radius + "px" : "0px"} circle at ${mouseX}px ${mouseY}px,
var(--blue-500),
transparent 80%
)
`,
}}
onMouseMove={handleMouseMove}
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
className="p-[2px] transition duration-300 group/input"
>
<input
type={type}
className={cn(
`flex h-10 w-full border-none bg-gray-50 dark:bg-zinc-800 text-black dark:text-white shadow-input px-3 py-2 text-sm file:border-0 file:bg-transparent
file:text-sm file:font-medium placeholder:text-neutral-400 dark:placeholder-text-neutral-600
focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-neutral-400 dark:focus-visible:ring-neutral-600
disabled:cursor-not-allowed disabled:opacity-50
dark:shadow-[0px_0px_1px_1px_var(--neutral-700)]
group-hover/input:shadow-none transition duration-400
`,
className,
)}
ref={ref}
{...props}
/>
</motion.div>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }