checkpoint

need polishing
This commit is contained in:
dimitar committed 2026-01-29 05:13:52 +01:00
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>
);
}
+24
View File
@@ -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 }
+119
View File
@@ -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,
}