mirror of
https://github.com/arkorty/Osborne.git
synced 2026-03-18 00:57:14 +00:00
recordinggggggggggggg...
This commit is contained in:
@@ -22,10 +22,10 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { CommentsPanel } from "@/components/RightPanel";
|
import { CommentsPanel } from "@/components/RightPanel";
|
||||||
import { CodeEditor, CodeEditorRef } from "@/components/Editor";
|
import { CodeEditor, CodeEditorRef } from "@/components/Editor";
|
||||||
import { LeftPanel } from "@/components/LeftPanel";
|
import { LeftPanel } from "@/components/LeftPanel";
|
||||||
|
import RecordingPopup from "@/components/RecordingPopup";
|
||||||
import {
|
import {
|
||||||
getThemeById,
|
getThemeById,
|
||||||
getNextTheme,
|
getNextTheme,
|
||||||
@@ -229,6 +229,8 @@ const Room = () => {
|
|||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const [fileSizeError, setFileSizeError] = useState<string | null>(null);
|
const [fileSizeError, setFileSizeError] = useState<string | null>(null);
|
||||||
const [purgeError, setPurgeError] = useState<string | null>(null);
|
const [purgeError, setPurgeError] = useState<string | null>(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<Array<{fileName: string; progress: number; status: 'uploading' | 'completed' | 'error'}>>([]);
|
||||||
|
const [isRecordingOpen, setIsRecordingOpen] = useState(false);
|
||||||
|
|
||||||
// Detect mobile screen size
|
// Detect mobile screen size
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -719,6 +721,14 @@ const Room = () => {
|
|||||||
const maxFileSize = 10 * 1024 * 1024; // 10MB in bytes
|
const maxFileSize = 10 * 1024 * 1024; // 10MB in bytes
|
||||||
const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || "http://localhost:8090";
|
const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || "http://localhost:8090";
|
||||||
|
|
||||||
|
// Initialize progress for all files
|
||||||
|
const initialProgress = Array.from(files).map(file => ({
|
||||||
|
fileName: file.name,
|
||||||
|
progress: 0,
|
||||||
|
status: 'uploading' as const
|
||||||
|
}));
|
||||||
|
setUploadProgress(initialProgress);
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
|
|
||||||
@@ -726,6 +736,9 @@ const Room = () => {
|
|||||||
if (file.size > maxFileSize) {
|
if (file.size > maxFileSize) {
|
||||||
const fileSizeInMB = (file.size / (1024 * 1024)).toFixed(2);
|
const fileSizeInMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||||
setFileSizeError(`File "${file.name}" (${fileSizeInMB}MB) exceeds 10MB limit`);
|
setFileSizeError(`File "${file.name}" (${fileSizeInMB}MB) exceeds 10MB limit`);
|
||||||
|
setUploadProgress(prev => prev.map(p =>
|
||||||
|
p.fileName === file.name ? { ...p, status: 'error' as const } : p
|
||||||
|
));
|
||||||
continue; // Skip this file and continue with others
|
continue; // Skip this file and continue with others
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,27 +749,51 @@ const Room = () => {
|
|||||||
formData.append("roomCode", roomCode!);
|
formData.append("roomCode", roomCode!);
|
||||||
formData.append("uploadedBy", currentUser.name);
|
formData.append("uploadedBy", currentUser.name);
|
||||||
|
|
||||||
// Upload file to HTTP server
|
// Use XMLHttpRequest for progress tracking
|
||||||
const response = await fetch(`${httpUrl}/upload`, {
|
await new Promise<void>((resolve, reject) => {
|
||||||
method: "POST",
|
const xhr = new XMLHttpRequest();
|
||||||
body: formData,
|
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const progress = Math.round((e.loaded / e.total) * 100);
|
||||||
|
setUploadProgress(prev => prev.map(p =>
|
||||||
|
p.fileName === file.name ? { ...p, progress } : p
|
||||||
|
));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
xhr.addEventListener('load', () => {
|
||||||
throw new Error(`Upload failed: ${response.statusText}`);
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
setUploadProgress(prev => prev.map(p =>
|
||||||
|
p.fileName === file.name ? { ...p, progress: 100, status: 'completed' as const } : p
|
||||||
|
));
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Upload failed: ${xhr.statusText}`));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const mediaFile: MediaFile = await response.json();
|
xhr.addEventListener('error', () => {
|
||||||
|
reject(new Error('Upload failed'));
|
||||||
|
});
|
||||||
|
|
||||||
// Don't add to local state here - the WebSocket broadcast will handle it
|
xhr.open('POST', `${httpUrl}/upload`);
|
||||||
// This prevents duplicate entries when the server broadcasts the upload
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
|
||||||
console.log("File uploaded successfully:", mediaFile);
|
console.log("File uploaded successfully:", file.name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error uploading file:", error);
|
console.error("Error uploading file:", error);
|
||||||
// You could show a toast notification here
|
setUploadProgress(prev => prev.map(p =>
|
||||||
|
p.fileName === file.name ? { ...p, status: 'error' as const } : p
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear progress after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
setUploadProgress([]);
|
||||||
|
}, 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileDelete = async (fileId: string) => {
|
const handleFileDelete = async (fileId: string) => {
|
||||||
@@ -810,7 +847,7 @@ const Room = () => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between p-1 w-full">
|
<div className="flex flex-row items-center justify-between p-1 w-full">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1 mr-1">
|
||||||
<BetterHoverCard
|
<BetterHoverCard
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
@@ -857,7 +894,7 @@ const Room = () => {
|
|||||||
return to home
|
return to home
|
||||||
</BetterHoverCard>
|
</BetterHoverCard>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1 overflow-x-auto scrollbar-hide">
|
||||||
<BetterHoverCard
|
<BetterHoverCard
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
@@ -892,6 +929,19 @@ const Room = () => {
|
|||||||
>
|
>
|
||||||
{`switch to ${getThemeById(getNextTheme(currentThemeId)?.id)?.name}`}
|
{`switch to ${getThemeById(getNextTheme(currentThemeId)?.id)?.name}`}
|
||||||
</BetterHoverCard>
|
</BetterHoverCard>
|
||||||
|
<BetterHoverCard
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
className="bg-red-500 px-2 py-0 h-5 text-xs rounded-sm hover:bg-red-600 btn-micro"
|
||||||
|
onClick={() => setIsRecordingOpen(true)}
|
||||||
|
>
|
||||||
|
record
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
contentClassName="py-1 px-2 w-auto text-xs border-foreground"
|
||||||
|
>
|
||||||
|
record audio
|
||||||
|
</BetterHoverCard>
|
||||||
|
|
||||||
{/* Panel Controls for mobile and when panels are hidden due to width */}
|
{/* Panel Controls for mobile and when panels are hidden due to width */}
|
||||||
{(isMobile || !showSidePanels) && (
|
{(isMobile || !showSidePanels) && (
|
||||||
@@ -1034,6 +1084,49 @@ const Room = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Upload Progress Overlay */}
|
||||||
|
{uploadProgress.length > 0 && (
|
||||||
|
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="max-w-md animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg text-center">
|
||||||
|
Uploading Files
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{uploadProgress.map((upload, index) => (
|
||||||
|
<div key={index} className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<span className="truncate max-w-[200px]" title={upload.fileName}>
|
||||||
|
{upload.fileName}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs ${
|
||||||
|
upload.status === 'completed' ? 'text-green-600' :
|
||||||
|
upload.status === 'error' ? 'text-destructive' :
|
||||||
|
'text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
{upload.status === 'completed' ? '✓' :
|
||||||
|
upload.status === 'error' ? '✗' :
|
||||||
|
`${upload.progress}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ${
|
||||||
|
upload.status === 'completed' ? 'bg-green-600' :
|
||||||
|
upload.status === 'error' ? 'bg-destructive' :
|
||||||
|
'bg-primary'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${upload.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Custom Popup for non-critical messages */}
|
{/* Custom Popup for non-critical messages */}
|
||||||
{popupMessage && (
|
{popupMessage && (
|
||||||
<div className="fixed top-4 right-4 z-50">
|
<div className="fixed top-4 right-4 z-50">
|
||||||
@@ -1140,40 +1233,32 @@ const Room = () => {
|
|||||||
|
|
||||||
{/* Content Warning Modal */}
|
{/* Content Warning Modal */}
|
||||||
<ContentWarningModal />
|
<ContentWarningModal />
|
||||||
|
|
||||||
|
{/* Recording Popup */}
|
||||||
|
<RecordingPopup
|
||||||
|
isOpen={isRecordingOpen}
|
||||||
|
onClose={() => setIsRecordingOpen(false)}
|
||||||
|
onFileUpload={handleFileUpload}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HoverCardProvider>
|
</HoverCardProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SkeletonMirror = () => {
|
const LoadingOverlay = () => (
|
||||||
return (
|
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
<div className="relative min-h-screen">
|
<Card className="max-w-xs animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||||
<div className="flex flex-col items-center p-4 relative z-10">
|
<CardContent className="flex flex-col items-center justify-center p-6">
|
||||||
<div className="w-full max-w-6xl bg-inherit backdrop-blur-sm bg-opacity-0">
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
||||||
<div className="flex flex-row items-center justify-between">
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
<div className="flex items-center gap-2">
|
</CardContent>
|
||||||
<Skeleton className="w-[6.3rem] h-[2.25rem] rounded bg-chart-3" />
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="w-20 h-6 rounded bg-chart-2" />
|
);
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Skeleton className="w-full min-h-[80vh] p-4 bg-muted border border-border" />
|
|
||||||
<div className="mt-4 flex justify-end items-center">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Skeleton className="w-10 h-10 rounded bg-chart-1" />
|
|
||||||
<Skeleton className="w-10 h-10 rounded bg-destructive" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const RoomWrapper = () => (
|
const RoomWrapper = () => (
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||||
<Suspense fallback={<SkeletonMirror />}>
|
<Suspense fallback={<LoadingOverlay />}>
|
||||||
<div className={`${jetbrainsMono.variable} font-sans`}>
|
<div className={`${jetbrainsMono.variable} font-sans`}>
|
||||||
<Room />
|
<Room />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ export const AnimatedAvatar: React.FC<AnimatedAvatarProps> = ({ className }) =>
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-8 h-8 rounded-full',
|
'w-8 h-8 rounded-full',
|
||||||
'bg-gradient-to-r from-purple-400 via-pink-500 to-red-500',
|
'bg-gradient-to-r from-primary to-muted',
|
||||||
'bg-[length:200%_200%]',
|
'bg-[length:200%_200%]',
|
||||||
'animate-gradient-flow',
|
'animate-gradient-flow',
|
||||||
|
'animate-[spin_8s_linear_infinite]',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { MediaModal } from '@/components/MediaModal';
|
|||||||
import { AnimatedAvatar } from '@/components/AnimatedAvatar';
|
import { AnimatedAvatar } from '@/components/AnimatedAvatar';
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
Circle,
|
|
||||||
Upload,
|
Upload,
|
||||||
File,
|
File,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
@@ -268,13 +267,40 @@ export const LeftPanel: React.FC<LeftPanelProps> = ({
|
|||||||
if (playingAudio === fileId) setPlayingAudio(null);
|
if (playingAudio === fileId) setPlayingAudio(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = (file: MediaFile) => {
|
const handleDownload = async (file: MediaFile) => {
|
||||||
|
try {
|
||||||
|
// Fetch the file as a blob to force download
|
||||||
|
const response = await fetch(getFileUrl(file));
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Create object URL for the blob
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Create download link
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = getFileUrl(file);
|
link.href = url;
|
||||||
link.download = file.name;
|
link.download = file.name;
|
||||||
|
link.style.display = 'none';
|
||||||
|
|
||||||
|
// Append to body, click, and remove
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
// Clean up object URL
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading file:', error);
|
||||||
|
// Fallback to direct link
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = getFileUrl(file);
|
||||||
|
link.download = file.name;
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -461,7 +487,7 @@ export const LeftPanel: React.FC<LeftPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
activeUsers.map((user) => {
|
activeUsers.map((user) => {
|
||||||
const { status, color } = getStatusIndicator(user);
|
const { status } = getStatusIndicator(user);
|
||||||
const isCurrentUser = currentUser && user.id === currentUser.id;
|
const isCurrentUser = currentUser && user.id === currentUser.id;
|
||||||
return (
|
return (
|
||||||
<Card key={user.id} className="bg-background border-border">
|
<Card key={user.id} className="bg-background border-border">
|
||||||
@@ -479,11 +505,6 @@ export const LeftPanel: React.FC<LeftPanelProps> = ({
|
|||||||
{user.name.charAt(0).toUpperCase()}
|
{user.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Circle
|
|
||||||
size={8}
|
|
||||||
className="absolute -bottom-0.5 -right-0.5 border-2 border-background rounded-full"
|
|
||||||
style={{ color, fill: color }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
|||||||
@@ -148,13 +148,40 @@ export const MediaPanel: React.FC<MediaPanelProps> = ({
|
|||||||
if (playingVideo === fileId) setPlayingVideo(null);
|
if (playingVideo === fileId) setPlayingVideo(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = (file: MediaFile) => {
|
const handleDownload = async (file: MediaFile) => {
|
||||||
|
try {
|
||||||
|
// Fetch the file as a blob to force download
|
||||||
|
const response = await fetch(file.url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Create object URL for the blob
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Create download link
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = file.url;
|
link.href = url;
|
||||||
link.download = file.name;
|
link.download = file.name;
|
||||||
|
link.style.display = 'none';
|
||||||
|
|
||||||
|
// Append to body, click, and remove
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
// Clean up object URL
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading file:', error);
|
||||||
|
// Fallback to direct link
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = file.url;
|
||||||
|
link.download = file.name;
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
|
|||||||
254
client/components/RecordingPopup.tsx
Normal file
254
client/components/RecordingPopup.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Mic, Pause, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface RecordingPopupProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onFileUpload: (files: FileList) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecordingPopup: React.FC<RecordingPopupProps> = ({ isOpen, onClose, onFileUpload }) => {
|
||||||
|
const [position, setPosition] = useState({ x: 100, y: 100 });
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [recordingTime, setRecordingTime] = useState(0);
|
||||||
|
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
|
||||||
|
const [recordedChunks, setRecordedChunks] = useState<Blob[]>([]);
|
||||||
|
const popupRef = useRef<HTMLDivElement>(null);
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragStart({
|
||||||
|
x: e.clientX - position.x,
|
||||||
|
y: e.clientY - position.y,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
const touch = e.touches[0];
|
||||||
|
setDragStart({
|
||||||
|
x: touch.clientX - position.x,
|
||||||
|
y: touch.clientY - position.y,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
const newX = e.clientX - dragStart.x;
|
||||||
|
const newY = e.clientY - dragStart.y;
|
||||||
|
|
||||||
|
// Keep popup within viewport bounds
|
||||||
|
const maxX = window.innerWidth - 128; // popup width (w-32 = 8rem = 128px)
|
||||||
|
const maxY = window.innerHeight - 120; // popup height (smaller now)
|
||||||
|
|
||||||
|
setPosition({
|
||||||
|
x: Math.max(0, Math.min(newX, maxX)),
|
||||||
|
y: Math.max(0, Math.min(newY, maxY)),
|
||||||
|
});
|
||||||
|
}, [isDragging, dragStart]);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback((e: TouchEvent) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
e.preventDefault(); // Prevent scrolling while dragging
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const newX = touch.clientX - dragStart.x;
|
||||||
|
const newY = touch.clientY - dragStart.y;
|
||||||
|
|
||||||
|
// Keep popup within viewport bounds
|
||||||
|
const maxX = window.innerWidth - 128; // popup width (w-32 = 8rem = 128px)
|
||||||
|
const maxY = window.innerHeight - 120; // popup height (smaller now)
|
||||||
|
|
||||||
|
setPosition({
|
||||||
|
x: Math.max(0, Math.min(newX, maxX)),
|
||||||
|
y: Math.max(0, Math.min(newY, maxY)),
|
||||||
|
});
|
||||||
|
}, [isDragging, dragStart]);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', handleTouchEnd);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const recorder = new MediaRecorder(stream);
|
||||||
|
const chunks: Blob[] = [];
|
||||||
|
|
||||||
|
recorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) {
|
||||||
|
chunks.push(e.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onstop = () => {
|
||||||
|
setRecordedChunks(chunks);
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
};
|
||||||
|
|
||||||
|
setMediaRecorder(recorder);
|
||||||
|
setRecordedChunks([]);
|
||||||
|
recorder.start();
|
||||||
|
setIsRecording(true);
|
||||||
|
setRecordingTime(0);
|
||||||
|
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
setRecordingTime(prev => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error starting recording:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
setIsRecording(false);
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
}, [mediaRecorder]);
|
||||||
|
|
||||||
|
const downloadRecording = () => {
|
||||||
|
if (recordedChunks.length > 0) {
|
||||||
|
const blob = new Blob(recordedChunks, { type: 'audio/webm' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `recording-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.webm`;
|
||||||
|
a.style.display = 'none';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-upload recording when it stops
|
||||||
|
useEffect(() => {
|
||||||
|
if (recordedChunks.length > 0 && !isRecording) {
|
||||||
|
const blob = new Blob(recordedChunks, { type: 'audio/webm' });
|
||||||
|
const fileName = `recording-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.webm`;
|
||||||
|
const file = new File([blob], fileName, { type: 'audio/webm' });
|
||||||
|
|
||||||
|
// Create a proper FileList
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(file);
|
||||||
|
const fileList = dt.files;
|
||||||
|
|
||||||
|
// Upload the recording
|
||||||
|
onFileUpload(fileList);
|
||||||
|
|
||||||
|
// Clear recorded chunks to prevent re-upload
|
||||||
|
setRecordedChunks([]);
|
||||||
|
}
|
||||||
|
}, [recordedChunks, isRecording, onFileUpload]);
|
||||||
|
|
||||||
|
// Clean up recording when popup is closed
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen && isRecording) {
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
}, [isOpen, isRecording, stopRecording]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={popupRef}
|
||||||
|
className="fixed z-50 w-32 bg-card border border-border rounded-3xl shadow-lg"
|
||||||
|
style={{
|
||||||
|
left: position.x,
|
||||||
|
top: position.y,
|
||||||
|
cursor: isDragging ? 'grabbing' : 'grab',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-2 py-1 cursor-grab active:cursor-grabbing"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={isRecording ? stopRecording : startRecording}
|
||||||
|
size="sm"
|
||||||
|
className={`rounded-full p-2 ${isRecording ? 'bg-gray-500 hover:bg-gray-600' : 'bg-red-500 hover:bg-red-600'}`}
|
||||||
|
>
|
||||||
|
{isRecording ? (
|
||||||
|
<Pause className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Mic className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="ui-font text-foreground">
|
||||||
|
{formatTime(recordingTime)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded-full"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recordedChunks.length > 0 && (
|
||||||
|
<div className="px-2 pb-1">
|
||||||
|
<Button
|
||||||
|
onClick={downloadRecording}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Download Recording
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecordingPopup;
|
||||||
@@ -409,6 +409,16 @@ func handleFileServe(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
http.Error(w, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers to force download
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
|
||||||
// Serve the file
|
// Serve the file
|
||||||
http.ServeFile(w, r, filePath)
|
http.ServeFile(w, r, filePath)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user