live blog implemented
This commit is contained in:
1 parent
e8893c1aae
commit
0351726eeb
36 files changed
+7759
-253
No files matched your search
@@ -0,0 +1,263 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useCreateLiveBlogUpdate, useUpdateLiveBlogUpdate, useLiveBlogUpdates } from '@/queries/live-blogs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { LiveBlogUpdate, CreateLiveBlogUpdateDto } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Send, Pin, Calendar, Edit } from 'lucide-react';
|
||||
|
||||
interface UpdatePublisherProps {
|
||||
liveBlogId: string;
|
||||
authorId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UpdatePublisher({ liveBlogId, authorId, className }: UpdatePublisherProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
const [scheduledAt, setScheduledAt] = useState('');
|
||||
const [isScheduleMode, setIsScheduleMode] = useState(false);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const createMutation = useCreateLiveBlogUpdate();
|
||||
const updateMutation = useUpdateLiveBlogUpdate();
|
||||
|
||||
const [editingUpdate, setEditingUpdate] = useState<LiveBlogUpdate | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!content.trim()) return;
|
||||
|
||||
const updateData: CreateLiveBlogUpdateDto = {
|
||||
content: content.trim(),
|
||||
isPinned,
|
||||
authorId,
|
||||
...(isScheduleMode && scheduledAt ? { scheduledAt } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingUpdate) {
|
||||
// Update existing update
|
||||
await updateMutation.mutateAsync({
|
||||
liveBlogId,
|
||||
updateId: editingUpdate.id,
|
||||
dto: {
|
||||
content: content.trim(),
|
||||
isPinned,
|
||||
},
|
||||
});
|
||||
setEditingUpdate(null);
|
||||
} else {
|
||||
// Create new update
|
||||
await createMutation.mutateAsync({
|
||||
liveBlogId,
|
||||
dto: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setContent('');
|
||||
setIsPinned(false);
|
||||
setScheduledAt('');
|
||||
setIsScheduleMode(false);
|
||||
textareaRef.current?.focus();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to save update:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (update: LiveBlogUpdate) => {
|
||||
setContent(update.content);
|
||||
setIsPinned(update.isPinned);
|
||||
setEditingUpdate(update);
|
||||
setIsScheduleMode(false);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingUpdate(null);
|
||||
setContent('');
|
||||
setIsPinned(false);
|
||||
setIsScheduleMode(false);
|
||||
};
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Get minimum date for scheduling (now + 5 minutes)
|
||||
const minScheduleDate = new Date().toISOString().slice(0, 16);
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{editingUpdate ? 'Edit Update' : 'Publish Update'}
|
||||
{editingUpdate && (
|
||||
<span className="text-xs bg-muted px-2 py-1 rounded-full">
|
||||
ID: {editingUpdate.id.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Content textarea */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">
|
||||
{editingUpdate ? 'Edit content' : 'Update content'}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
ref={textareaRef}
|
||||
placeholder="What's the latest update?"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{/* Pin toggle */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="pin"
|
||||
checked={isPinned}
|
||||
onCheckedChange={setIsPinned}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Label htmlFor="pin" className="flex items-center gap-2">
|
||||
<Pin className="w-4 h-4" />
|
||||
Pin to top
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Schedule toggle */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="schedule"
|
||||
checked={isScheduleMode}
|
||||
onCheckedChange={setIsScheduleMode}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Label htmlFor="schedule" className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Schedule
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule date/time picker */}
|
||||
{isScheduleMode && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scheduleAt">Schedule for (minimum 5 minutes from now)</Label>
|
||||
<input
|
||||
id="scheduleAt"
|
||||
type="datetime-local"
|
||||
min={minScheduleDate}
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="w-full p-2 border rounded-md"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!content.trim() || isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
'Saving...'
|
||||
) : editingUpdate ? (
|
||||
'Update'
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{isScheduleMode ? 'Schedule' : 'Publish'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{editingUpdate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancelEdit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Recent updates for editing */}
|
||||
{editingUpdate === null && (
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<h4 className="text-sm font-medium mb-3">Recent Updates (Click to edit)</h4>
|
||||
<RecentUpdates
|
||||
liveBlogId={liveBlogId}
|
||||
onEdit={handleEdit}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface RecentUpdatesProps {
|
||||
liveBlogId: string;
|
||||
onEdit: (update: LiveBlogUpdate) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function RecentUpdates({ liveBlogId, onEdit, isLoading }: RecentUpdatesProps) {
|
||||
const { data: updatesData } = useLiveBlogUpdates(liveBlogId, 1, 5);
|
||||
const updates = updatesData?.data || [];
|
||||
|
||||
if (updates.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">No updates yet. Start by publishing your first update!</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{updates.map((update) => (
|
||||
<div
|
||||
key={update.id}
|
||||
className={cn(
|
||||
'p-3 rounded border cursor-pointer hover:bg-muted/50 transition-colors',
|
||||
isLoading && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
onClick={() => onEdit(update)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{update.isPinned && <Pin className="w-3 h-3 text-primary" />}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(update.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<Edit className="w-3 h-3 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm line-clamp-2">{update.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user