mirror of
https://github.com/arkorty/Osborne.git
synced 2026-03-18 00:57:14 +00:00
init
This commit is contained in:
225
client/components/CodeEditor.tsx
Normal file
225
client/components/CodeEditor.tsx
Normal 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';
|
||||
179
client/components/CommentsPanel.tsx
Normal file
179
client/components/CommentsPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
26
client/components/ConnectionStatus.tsx
Normal file
26
client/components/ConnectionStatus.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
420
client/components/LeftPanel.tsx
Normal file
420
client/components/LeftPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
205
client/components/MediaModal.tsx
Normal file
205
client/components/MediaModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
311
client/components/MediaPanel.tsx
Normal file
311
client/components/MediaPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
177
client/components/UsersPanel.tsx
Normal file
177
client/components/UsersPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
149
client/components/ui/background-beams.tsx
Normal file
149
client/components/ui/background-beams.tsx
Normal 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";
|
||||
36
client/components/ui/badge.tsx
Normal file
36
client/components/ui/badge.tsx
Normal 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 };
|
||||
57
client/components/ui/button.tsx
Normal file
57
client/components/ui/button.tsx
Normal 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 };
|
||||
76
client/components/ui/card.tsx
Normal file
76
client/components/ui/card.tsx
Normal 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 }
|
||||
29
client/components/ui/hover-card.tsx
Normal file
29
client/components/ui/hover-card.tsx
Normal 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 }
|
||||
70
client/components/ui/input-otp.tsx
Normal file
70
client/components/ui/input-otp.tsx
Normal 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 }
|
||||
60
client/components/ui/input.tsx
Normal file
60
client/components/ui/input.tsx
Normal 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 };
|
||||
15
client/components/ui/skeleton.tsx
Normal file
15
client/components/ui/skeleton.tsx
Normal 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 }
|
||||
23
client/components/ui/textarea.tsx
Normal file
23
client/components/ui/textarea.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user