checkpoint
need polishing
This commit is contained in:
1 parent
0351726eeb
commit
5a457fa99f
11 files changed
+1610
-90
No files matched your search
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCreateLiveBlog } from '@/queries/live-blogs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface CreateLiveBlogProps {
|
||||
onSuccess?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CreateLiveBlog({ onSuccess, className }: CreateLiveBlogProps) {
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
status: 'draft' as 'draft' | 'live' | 'ended' | 'archived',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const createMutation = useCreateLiveBlog();
|
||||
|
||||
const generateSlug = (title: string) => {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/--+/g, '-')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const title = e.target.value;
|
||||
setFormData({
|
||||
...formData,
|
||||
title,
|
||||
slug: formData.slug || generateSlug(title),
|
||||
});
|
||||
if (errors.title) setErrors({ ...errors, title: '' });
|
||||
};
|
||||
|
||||
const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({ ...formData, slug: e.target.value });
|
||||
if (errors.slug) setErrors({ ...errors, slug: '' });
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = 'Title is required';
|
||||
}
|
||||
|
||||
if (!formData.slug.trim()) {
|
||||
newErrors.slug = 'Slug is required';
|
||||
} else if (!/^[a-z0-9-]+$/.test(formData.slug)) {
|
||||
newErrors.slug = 'Slug can only contain lowercase letters, numbers, and hyphens';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
const result = await createMutation.mutateAsync(formData);
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
status: 'draft',
|
||||
});
|
||||
setErrors({});
|
||||
|
||||
// Navigate to the new live blog
|
||||
navigate({ to: `/admin/live-blogs/${result.slug}` });
|
||||
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (error) {
|
||||
console.error('Failed to create live blog:', error);
|
||||
setErrors({ submit: 'Failed to create live blog. Please try again.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plus className="w-5 h-5" />
|
||||
Create New Live Blog
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={handleTitleChange}
|
||||
placeholder="Enter live blog title"
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-sm text-destructive">{errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">Slug *</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={formData.slug}
|
||||
onChange={handleSlugChange}
|
||||
placeholder="URL-friendly identifier"
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
{errors.slug && (
|
||||
<p className="text-sm text-destructive">{errors.slug}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This will be used in the URL: /live-blogs/your-slug-here
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Optional description for this live blog"
|
||||
rows={3}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Initial Status</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value: 'draft' | 'live' | 'ended' | 'archived') =>
|
||||
setFormData({ ...formData, status: value })
|
||||
}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft (Not visible to public)</SelectItem>
|
||||
<SelectItem value="live">Live (Visible and accepting updates)</SelectItem>
|
||||
<SelectItem value="ended">Ended (Visible but not accepting updates)</SelectItem>
|
||||
<SelectItem value="archived">Archived (Hidden from public)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can change this later in the settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errors.submit && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<p className="text-sm text-destructive">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Live Blog'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useLiveBlog, useDeleteLiveBlogUpdate } from '@/queries/live-blogs';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
useLiveBlog,
|
||||
useDeleteLiveBlogUpdate,
|
||||
useUpdateLiveBlog,
|
||||
useDeleteLiveBlog
|
||||
} from '@/queries/live-blogs';
|
||||
import { UpdatePublisher } from './UpdatePublisher';
|
||||
import { LiveBlogUpdate } from '@/components/features/live-blog/LiveBlogUpdate';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { LiveBlogUpdate as ApiLiveBlogUpdate, UpdateLiveBlogDto } from '@/lib/api';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
Archive,
|
||||
Eye,
|
||||
@@ -26,9 +34,15 @@ interface LiveBlogManagerProps {
|
||||
|
||||
export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProps) {
|
||||
const [activeTab, setActiveTab] = useState('updates');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editForm, setEditForm] = useState<UpdateLiveBlogDto>({});
|
||||
|
||||
const { data: liveBlog, isLoading, error } = useLiveBlog(slug);
|
||||
const { data: liveBlog, isLoading, error, refetch } = useLiveBlog(slug);
|
||||
const deleteUpdateMutation = useDeleteLiveBlogUpdate();
|
||||
const updateLiveBlogMutation = useUpdateLiveBlog();
|
||||
const deleteLiveBlogMutation = useDeleteLiveBlog();
|
||||
|
||||
|
||||
|
||||
const handleDeleteUpdate = async (update: ApiLiveBlogUpdate) => {
|
||||
if (!liveBlog) return;
|
||||
@@ -50,6 +64,66 @@ export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProp
|
||||
console.log('Editing update:', update);
|
||||
};
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (!liveBlog) return;
|
||||
|
||||
setIsEditing(true);
|
||||
setEditForm({
|
||||
title: liveBlog.title,
|
||||
slug: liveBlog.slug,
|
||||
description: liveBlog.description || '',
|
||||
status: liveBlog.status,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
setEditForm({});
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!liveBlog) return;
|
||||
|
||||
try {
|
||||
await updateLiveBlogMutation.mutateAsync({
|
||||
id: liveBlog.id,
|
||||
dto: editForm,
|
||||
});
|
||||
setIsEditing(false);
|
||||
setEditForm({});
|
||||
} catch (error) {
|
||||
console.error('Failed to update live blog:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (newStatus: 'draft' | 'live' | 'ended' | 'archived') => {
|
||||
if (!liveBlog) return;
|
||||
|
||||
try {
|
||||
await updateLiveBlogMutation.mutateAsync({
|
||||
id: liveBlog.id,
|
||||
dto: { status: newStatus },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to update status:', error);
|
||||
alert(`Failed to update status: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLiveBlog = async () => {
|
||||
if (!liveBlog) return;
|
||||
|
||||
if (confirm('Are you sure you want to delete this live blog? This action cannot be undone and will delete all updates.')) {
|
||||
try {
|
||||
await deleteLiveBlogMutation.mutateAsync(liveBlog.id);
|
||||
if (onBack) onBack();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete live blog:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
@@ -201,67 +275,211 @@ export function LiveBlogManager({ slug, onBack, className }: LiveBlogManagerProp
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Live Blog Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<h4 className="font-medium mb-2">Status Management</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Control the live blog status and visibility
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Go Live
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Pause className="w-4 h-4 mr-2" />
|
||||
Pause
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
End
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Archive className="w-4 h-4 mr-2" />
|
||||
Archive
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<h4 className="font-medium mb-2">Analytics</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total Views:</span>
|
||||
<div className="font-medium">{liveBlog.viewCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total Updates:</span>
|
||||
<div className="font-medium">{liveBlog.updates?.length || 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Created:</span>
|
||||
<div className="font-medium">
|
||||
{new Date(liveBlog.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Last Updated:</span>
|
||||
<div className="font-medium">
|
||||
{new Date(liveBlog.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Live Blog Settings</CardTitle>
|
||||
{!isEditing ? (
|
||||
<Button variant="outline" size="sm" onClick={handleStartEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleCancelEdit}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveEdit}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={editForm.title || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={editForm.slug || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
|
||||
placeholder="URL-friendly identifier"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={editForm.description || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={editForm.status}
|
||||
onValueChange={(value: 'draft' | 'live' | 'ended' | 'archived') =>
|
||||
setEditForm({ ...editForm, status: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="live">Live</SelectItem>
|
||||
<SelectItem value="ended">Ended</SelectItem>
|
||||
<SelectItem value="archived">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<h4 className="font-medium mb-2">Status Management</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Control the live blog status and visibility
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{liveBlog.status !== 'live' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange('live')}
|
||||
disabled={updateLiveBlogMutation.isPending}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Go Live'}
|
||||
</Button>
|
||||
)}
|
||||
{liveBlog.status === 'live' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange('ended')}
|
||||
disabled={updateLiveBlogMutation.isPending}
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
{updateLiveBlogMutation.isPending ? 'Updating...' : 'End Live Session'}
|
||||
</Button>
|
||||
)}
|
||||
{liveBlog.status !== 'archived' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange('archived')}
|
||||
disabled={updateLiveBlogMutation.isPending}
|
||||
>
|
||||
<Archive className="w-4 h-4 mr-2" />
|
||||
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Archive'}
|
||||
</Button>
|
||||
)}
|
||||
{liveBlog.status === 'archived' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange('draft')}
|
||||
disabled={updateLiveBlogMutation.isPending}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
{updateLiveBlogMutation.isPending ? 'Updating...' : 'Unarchive'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<h4 className="font-medium mb-2">Live Blog Information</h4>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Title:</span>
|
||||
<span className="font-medium">{liveBlog.title}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Slug:</span>
|
||||
<span className="font-medium">{liveBlog.slug}</span>
|
||||
</div>
|
||||
{liveBlog.description && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Description:</span>
|
||||
<span className="font-medium text-right max-w-xs">{liveBlog.description}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Status:</span>
|
||||
<Badge className={getStatusColor(liveBlog.status)} variant="outline">
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusIcon(liveBlog.status)}
|
||||
{liveBlog.status}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<h4 className="font-medium mb-2">Analytics</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total Views:</span>
|
||||
<div className="font-medium">{liveBlog.viewCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total Updates:</span>
|
||||
<div className="font-medium">{liveBlog.updates?.length || 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Created:</span>
|
||||
<div className="font-medium">
|
||||
{new Date(liveBlog.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Last Updated:</span>
|
||||
<div className="font-medium">
|
||||
{new Date(liveBlog.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-destructive/20 rounded-lg">
|
||||
<h4 className="font-medium mb-2 text-destructive">Danger Zone</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Once you delete a live blog, there is no going back. Please be certain.
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDeleteLiveBlog}
|
||||
disabled={deleteLiveBlogMutation.isPending}
|
||||
>
|
||||
{deleteLiveBlogMutation.isPending ? 'Deleting...' : 'Delete Live Blog'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { CreateLiveBlog } from '@/components/admin/live-blog/CreateLiveBlog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
export function CreateLiveBlogComponent() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<Link to="/live-blogs">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Live Blogs
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<CreateLiveBlog
|
||||
onSuccess={() => {
|
||||
// Success is handled by navigation in the component
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-medium mb-2">Tips for creating a successful live blog:</h3>
|
||||
<ul className="text-sm text-muted-foreground space-y-1 list-disc pl-5">
|
||||
<li>Choose a clear, descriptive title that tells readers what to expect</li>
|
||||
<li>Use a URL-friendly slug (lowercase, hyphens, no spaces)</li>
|
||||
<li>Start with a draft status to prepare content before going live</li>
|
||||
<li>Use the description to provide context for readers</li>
|
||||
<li>Remember: you can always edit these details later</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,119 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
}
|
||||
@@ -179,6 +179,26 @@ export interface UpdateLiveBlogUpdateDto {
|
||||
strapiId?: string;
|
||||
}
|
||||
|
||||
export interface CreateLiveBlogDto {
|
||||
title: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
status?: 'draft' | 'live' | 'ended' | 'archived';
|
||||
authorId?: string;
|
||||
categoryId?: string;
|
||||
strapiId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLiveBlogDto {
|
||||
title?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
status?: 'draft' | 'live' | 'ended' | 'archived';
|
||||
authorId?: string;
|
||||
categoryId?: string;
|
||||
strapiId?: string;
|
||||
}
|
||||
|
||||
// Live Blog API Functions
|
||||
export async function fetchLiveBlogs(params: FindLiveBlogsParams = {}): Promise<LiveBlogsResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
@@ -282,3 +302,40 @@ export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string)
|
||||
throw new Error('Failed to delete live blog update');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLiveBlog(dto: CreateLiveBlogDto): Promise<LiveBlog> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create live blog');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update live blog');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteLiveBlog(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete live blog');
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export function useLiveBlog(slug: string) {
|
||||
queryKey: ['liveBlog', slug],
|
||||
queryFn: () => api.fetchLiveBlogBySlug(slug),
|
||||
enabled: !!slug,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,4 +87,55 @@ export function useDeleteLiveBlogUpdate() {
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Live Blog CRUD Mutations
|
||||
export function useCreateLiveBlog() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (dto: api.CreateLiveBlogDto) => api.createLiveBlog(dto),
|
||||
onSuccess: () => {
|
||||
// Invalidate all live blogs queries
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateLiveBlog() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: api.UpdateLiveBlogDto }) =>
|
||||
api.updateLiveBlog(id, dto),
|
||||
onSuccess: (data, variables) => {
|
||||
// Force immediate refetch of all live blog queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['liveBlog'],
|
||||
refetchType: 'active'
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['liveBlogs'],
|
||||
refetchType: 'active'
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['recentLiveBlogs'],
|
||||
refetchType: 'active'
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteLiveBlog() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteLiveBlog(id),
|
||||
onSuccess: () => {
|
||||
// Invalidate all live blogs queries
|
||||
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
+22
-11
@@ -5,6 +5,7 @@ import { ArticleDetailComponent } from './components/routes/ArticleDetailCompone
|
||||
import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent'
|
||||
import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent'
|
||||
import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent'
|
||||
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent'
|
||||
import './styles.css'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
@@ -23,17 +24,20 @@ const rootRoute = createRootRoute({
|
||||
<h1 className="text-3xl font-bold">
|
||||
<Link to="/" className="hover:underline">Placebo.mk</Link>
|
||||
</h1>
|
||||
<nav className="flex gap-4">
|
||||
<Link to="/" className="text-sm font-medium hover:underline">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/articles" className="text-sm font-medium hover:underline">
|
||||
Articles
|
||||
</Link>
|
||||
<Link to="/live-blogs" className="text-sm font-medium hover:underline">
|
||||
Live
|
||||
</Link>
|
||||
</nav>
|
||||
<nav className="flex gap-4">
|
||||
<Link to="/" className="text-sm font-medium hover:underline">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/articles" className="text-sm font-medium hover:underline">
|
||||
Articles
|
||||
</Link>
|
||||
<Link to="/live-blogs" className="text-sm font-medium hover:underline">
|
||||
Live
|
||||
</Link>
|
||||
<Link to="/admin/live-blogs/create" className="text-sm font-medium hover:underline text-primary">
|
||||
+ New Live Blog
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -177,6 +181,12 @@ const liveBlogAdminRoute = createRoute({
|
||||
},
|
||||
})
|
||||
|
||||
const createLiveBlogRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/admin/live-blogs/create',
|
||||
component: CreateLiveBlogComponent,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
articlesRoute,
|
||||
@@ -184,6 +194,7 @@ const routeTree = rootRoute.addChildren([
|
||||
liveBlogsRoute,
|
||||
liveBlogDetailRoute,
|
||||
liveBlogAdminRoute,
|
||||
createLiveBlogRoute,
|
||||
])
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
Reference in new issue
Block a user