admin dashboard
This commit is contained in:
parent
2ff76ffda5
commit
7c7bb45446
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@ -16,6 +16,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-query": "^5.90.14",
|
"@tanstack/react-query": "^5.90.14",
|
||||||
"@tanstack/react-router": "^1.144.0",
|
"@tanstack/react-router": "^1.144.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
@ -3433,6 +3434,16 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-query": "^5.90.14",
|
"@tanstack/react-query": "^5.90.14",
|
||||||
"@tanstack/react-router": "^1.144.0",
|
"@tanstack/react-router": "^1.144.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -22,9 +22,9 @@ export function ArticleTicker() {
|
|||||||
<div className="overflow-hidden flex-1 relative">
|
<div className="overflow-hidden flex-1 relative">
|
||||||
<div className="flex animate-marquee whitespace-nowrap">
|
<div className="flex animate-marquee whitespace-nowrap">
|
||||||
{articles.map((article, index) => (
|
{articles.map((article, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={`${article.id}-${index}`}
|
key={`${article.id}-${index}`}
|
||||||
to={`/articles/${article.id}`}
|
to={`/articles/${article.id}` as any}
|
||||||
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
|
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
|
||||||
>
|
>
|
||||||
{article.title || 'No title'}
|
{article.title || 'No title'}
|
||||||
@ -32,9 +32,9 @@ export function ArticleTicker() {
|
|||||||
))}
|
))}
|
||||||
{/* Duplicate for seamless scrolling */}
|
{/* Duplicate for seamless scrolling */}
|
||||||
{articles.map((article, index) => (
|
{articles.map((article, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={`dup-${article.id}-${index}`}
|
key={`dup-${article.id}-${index}`}
|
||||||
to={`/articles/${article.id}`}
|
to={`/articles/${article.id}` as any}
|
||||||
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
|
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
|
||||||
>
|
>
|
||||||
{article.title || 'No title'}
|
{article.title || 'No title'}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
useLiveBlog,
|
useLiveBlog,
|
||||||
useDeleteLiveBlogUpdate,
|
useDeleteLiveBlogUpdate,
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export function LiveBlogTicker({
|
|||||||
const [scrollPosition, setScrollPosition] = useState(0);
|
const [scrollPosition, setScrollPosition] = useState(0);
|
||||||
const tickerRef = useRef<HTMLDivElement>(null);
|
const tickerRef = useRef<HTMLDivElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const animationRef = useRef<number>();
|
const animationRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
// Calculate total width needed for scrolling
|
// Calculate total width needed for scrolling
|
||||||
const [totalWidth, setTotalWidth] = useState(0);
|
const [totalWidth, setTotalWidth] = useState(0);
|
||||||
@ -55,7 +55,7 @@ export function LiveBlogTicker({
|
|||||||
|
|
||||||
// Reset when scrolled past content
|
// Reset when scrolled past content
|
||||||
if (newPos > totalWidth) {
|
if (newPos > totalWidth) {
|
||||||
newPos = -tickerRef.current?.offsetWidth || 0;
|
newPos = -(tickerRef.current?.offsetWidth || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return newPos;
|
return newPos;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
|
||||||
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
|
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useLiveBlogStream } from '@/hooks/useLiveBlogStream';
|
import { useLiveBlogStream } from '@/hooks/useLiveBlogStream';
|
||||||
import { useLiveBlog, useLiveBlogUpdates } from '@/queries/live-blogs';
|
import { useLiveBlog, useLiveBlogUpdates } from '@/queries/live-blogs';
|
||||||
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
|
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { usePinnedLiveBlogs } from '@/queries/live-blogs';
|
import { usePinnedLiveBlogs } from '@/queries/live-blogs';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { LiveBlog } from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@ -49,11 +49,49 @@ export function PinnedLiveBlogSidebar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !pinnedBlogs || pinnedBlogs.length === 0) {
|
if (isLoading) {
|
||||||
return null; // Don't show sidebar if no pinned blogs
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Pin className="w-4 h-4" />
|
||||||
|
Live Coverage
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="animate-pulse space-y-2">
|
||||||
|
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||||
|
<div className="h-3 bg-muted rounded w-1/2"></div>
|
||||||
|
<div className="h-2 bg-muted rounded w-1/4"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayBlogs = pinnedBlogs.slice(0, maxItems);
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Pin className="w-4 h-4" />
|
||||||
|
Live Coverage
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
Error loading live coverage
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayBlogs = (pinnedBlogs || []).slice(0, maxItems);
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@ -99,99 +137,100 @@ export function PinnedLiveBlogSidebar({
|
|||||||
<Pin className="w-4 h-4" />
|
<Pin className="w-4 h-4" />
|
||||||
Live Coverage
|
Live Coverage
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{pinnedBlogs.length} pinned
|
{(pinnedBlogs || []).length} pinned
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="space-y-4">
|
{displayBlogs.length === 0 ? (
|
||||||
{displayBlogs.map((blog) => {
|
<div className="py-6 text-center">
|
||||||
const latestUpdate = getLatestUpdate(blog);
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No pinned live blogs at the moment
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Check back later for live coverage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{displayBlogs.map((blog) => {
|
||||||
|
const latestUpdate = getLatestUpdate(blog);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={blog.id}
|
key={blog.id}
|
||||||
to="/live-blogs/$slug"
|
to="/live-blogs/$slug"
|
||||||
params={{ slug: blog.slug }}
|
params={{ slug: blog.slug }}
|
||||||
className="block group"
|
className="block p-3 rounded-lg border hover:bg-accent/50 transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="p-3 rounded-lg border hover:border-primary/50 hover:bg-accent/50 transition-colors">
|
<div className="flex items-start justify-between mb-2">
|
||||||
{/* Header */}
|
<div className="flex-1">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<h4 className="font-medium text-sm mb-1 group-hover:text-primary transition-colors line-clamp-2">
|
||||||
<div className="flex-1">
|
{blog.title}
|
||||||
<h3 className="font-medium text-sm leading-tight group-hover:text-primary transition-colors line-clamp-2">
|
</h4>
|
||||||
{blog.title}
|
{blog.description && (
|
||||||
</h3>
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{blog.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{blog.description && (
|
<Badge
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
className={`${getStatusColor(blog.status)} text-xs`}
|
||||||
{blog.description}
|
variant="outline"
|
||||||
</p>
|
>
|
||||||
)}
|
<div className="flex items-center gap-1">
|
||||||
|
{getStatusIcon(blog.status)}
|
||||||
|
{blog.status}
|
||||||
|
</div>
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Badge
|
{/* Latest update preview */}
|
||||||
className={getStatusColor(blog.status)}
|
{latestUpdate && (
|
||||||
variant="outline"
|
<div className="mt-2 p-2 bg-muted/50 rounded text-xs">
|
||||||
size="sm"
|
<div className="flex items-center gap-1 text-muted-foreground mb-1">
|
||||||
>
|
<Clock className="w-3 h-3" />
|
||||||
<div className="flex items-center gap-1">
|
<span>{formatTime(latestUpdate.createdAt)}</span>
|
||||||
{getStatusIcon(blog.status)}
|
</div>
|
||||||
{blog.status}
|
<p className="line-clamp-2">{latestUpdate.content}</p>
|
||||||
</div>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Latest update preview */}
|
|
||||||
{latestUpdate && (
|
|
||||||
<div className="mt-2 p-2 bg-muted/50 rounded text-xs">
|
|
||||||
<div className="flex items-center gap-1 text-muted-foreground mb-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
<span>{formatTime(latestUpdate.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="line-clamp-2">{latestUpdate.content}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="flex items-center justify-between mt-3 text-xs text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Eye className="w-3 h-3" />
|
|
||||||
<span>{blog.viewCount}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<MessageSquare className="w-3 h-3" />
|
<MessageSquare className="w-3 h-3" />
|
||||||
<span>{blog.updates?.length || 0}</span>
|
<span>{blog.updates?.length || 0} updates</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
<span>{blog.viewCount} views</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 text-primary">
|
{/* View all button */}
|
||||||
<span className="text-xs">View live</span>
|
{(pinnedBlogs || []).length > maxItems && (
|
||||||
<ChevronRight className="w-3 h-3" />
|
<Button
|
||||||
</div>
|
variant="ghost"
|
||||||
</div>
|
size="sm"
|
||||||
</div>
|
className="w-full mt-4"
|
||||||
</Link>
|
asChild
|
||||||
);
|
>
|
||||||
})}
|
<Link to="/live-blogs">
|
||||||
</div>
|
View all pinned blogs
|
||||||
|
<ChevronRight className="w-3 h-3 ml-1" />
|
||||||
{pinnedBlogs.length > maxItems && (
|
</Link>
|
||||||
<div className="mt-4 pt-3 border-t">
|
|
||||||
<Link
|
|
||||||
to="/live-blogs"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-between">
|
|
||||||
<span>View all pinned blogs</span>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
260
frontend/src/components/routes/AdminDashboardComponent.tsx
Normal file
260
frontend/src/components/routes/AdminDashboardComponent.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import { useLiveBlogs } from '@/queries/live-blogs';
|
||||||
|
import { useArticles } from '@/queries/articles';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { mk } from 'date-fns/locale';
|
||||||
|
|
||||||
|
export function AdminDashboardComponent() {
|
||||||
|
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({ limit: 50 });
|
||||||
|
const { data: articlesData, isLoading: loadingArticles } = useArticles({ limit: 50 });
|
||||||
|
|
||||||
|
const liveBlogs = liveBlogsData?.data || [];
|
||||||
|
const articles = articlesData?.data || [];
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'published':
|
||||||
|
case 'live':
|
||||||
|
return 'bg-green-100 text-green-800 border-green-200';
|
||||||
|
case 'draft':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||||
|
case 'archived':
|
||||||
|
case 'ended':
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
default:
|
||||||
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'published': return 'Објавено';
|
||||||
|
case 'draft': return 'Нацрт';
|
||||||
|
case 'archived': return 'Архивирано';
|
||||||
|
case 'live': return 'Во живо';
|
||||||
|
case 'ended': return 'Завршено';
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-8 space-y-8">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Администраторски панел</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Управување со сите написи и live блогови
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/">Назад кон сајтот</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Live Blogs Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span>Live блогови</span>
|
||||||
|
<Badge variant="outline" className="ml-2">
|
||||||
|
{liveBlogs.length || 0}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Сите live блогови со статус и датум на креирање
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loadingLiveBlogs ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
|
||||||
|
</div>
|
||||||
|
) : liveBlogs.length === 0 ? (
|
||||||
|
<div className="text-center py-8 border rounded-lg">
|
||||||
|
<p className="text-muted-foreground">Нема live блогови</p>
|
||||||
|
<Button asChild variant="outline" className="mt-4">
|
||||||
|
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{liveBlogs.map((blog) => (
|
||||||
|
<div
|
||||||
|
key={blog.id}
|
||||||
|
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Link
|
||||||
|
to={`/admin/live-blogs/${blog.slug}` as any}
|
||||||
|
className="font-medium hover:text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{blog.title}
|
||||||
|
</Link>
|
||||||
|
<Badge variant="outline" className={getStatusColor(blog.status)}>
|
||||||
|
{getStatusText(blog.status)}
|
||||||
|
</Badge>
|
||||||
|
{blog.isPinned && (
|
||||||
|
<Badge variant="outline" className="bg-yellow-100 text-yellow-800 border-yellow-200">
|
||||||
|
Закачено
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span>Слаг: {blog.slug}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
Креирано: {format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Прегледи: {blog.viewCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-4">
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link to={`/admin/live-blogs/${blog.slug}` as any}>Уреди</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" variant="ghost">
|
||||||
|
<Link to={`/live-blogs/${blog.slug}` as any} target="_blank">
|
||||||
|
Преглед
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Articles Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span>Написи</span>
|
||||||
|
<Badge variant="outline" className="ml-2">
|
||||||
|
{articles.length || 0}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Сите написи со статус и датум на креирање
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loadingArticles ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
|
||||||
|
</div>
|
||||||
|
) : articles.length === 0 ? (
|
||||||
|
<div className="text-center py-8 border rounded-lg">
|
||||||
|
<p className="text-muted-foreground">Нема написи</p>
|
||||||
|
<Button asChild variant="outline" className="mt-4">
|
||||||
|
<Link to="/">Креирај нов напис</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{articles.map((article) => (
|
||||||
|
<div
|
||||||
|
key={article.id}
|
||||||
|
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Link
|
||||||
|
to={`/articles/${article.id}` as any}
|
||||||
|
className="font-medium hover:text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{article.title}
|
||||||
|
</Link>
|
||||||
|
<Badge variant="outline" className={getStatusColor(article.status)}>
|
||||||
|
{getStatusText(article.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span>Слаг: {article.slug}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
Креирано: {format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Прегледи: {article.views}</span>
|
||||||
|
</div>
|
||||||
|
{article.excerpt && (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{article.excerpt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-4">
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link to={`/articles/${article.id}` as any}>Уреди</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" variant="ghost">
|
||||||
|
<Link to={`/articles/${article.id}` as any} target="_blank">
|
||||||
|
Преглед
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{liveBlogs.filter(b => b.status === 'live').length || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Активни live блогови</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{articles.filter(a => a.status === 'published').length || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Објавени написи</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{liveBlogs.filter(b => b.isPinned).length || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Закачени live блогови</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
||||||
|
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Вкупно прегледи</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -33,9 +33,9 @@ export function ArticlesComponent() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{data?.data.map((article) => (
|
{data?.data.map((article) => (
|
||||||
<Link
|
<Link
|
||||||
key={article.id}
|
key={article.id}
|
||||||
to={`/articles/${article.id}`}
|
to={`/articles/${article.id}` as any}
|
||||||
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
|
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
|
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
|
||||||
|
|||||||
@ -36,9 +36,9 @@ export function LiveBlogsComponent() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{data?.data.map((liveBlog) => (
|
{data?.data.map((liveBlog) => (
|
||||||
<Link
|
<Link
|
||||||
key={liveBlog.id}
|
key={liveBlog.id}
|
||||||
to={`/live-blogs/${liveBlog.slug}`}
|
to={`/live-blogs/${liveBlog.slug}` as any}
|
||||||
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
|
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export function useLiveBlogStream(
|
|||||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||||
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastEventIdRef = useRef<string | null>(null);
|
const lastEventIdRef = useRef<string | null>(null);
|
||||||
const optionsRef = useRef(options);
|
const optionsRef = useRef(options);
|
||||||
const reconnectAttemptsRef = useRef(reconnectAttempts);
|
const reconnectAttemptsRef = useRef(reconnectAttempts);
|
||||||
|
|||||||
@ -118,6 +118,7 @@ export interface LiveBlog {
|
|||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
status: 'draft' | 'live' | 'ended' | 'archived';
|
status: 'draft' | 'live' | 'ended' | 'archived';
|
||||||
|
isPinned: boolean;
|
||||||
strapiId: string | null;
|
strapiId: string | null;
|
||||||
authorId: string | null;
|
authorId: string | null;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
@ -194,6 +195,7 @@ export interface UpdateLiveBlogDto {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
status?: 'draft' | 'live' | 'ended' | 'archived';
|
status?: 'draft' | 'live' | 'ended' | 'archived';
|
||||||
|
isPinned?: boolean;
|
||||||
authorId?: string;
|
authorId?: string;
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
strapiId?: string;
|
strapiId?: string;
|
||||||
|
|||||||
@ -66,7 +66,7 @@ export function useCreateLiveBlogUpdate() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ liveBlogId, dto }: { liveBlogId: string; dto: api.CreateLiveBlogUpdateDto }) =>
|
mutationFn: ({ liveBlogId, dto }: { liveBlogId: string; dto: api.CreateLiveBlogUpdateDto }) =>
|
||||||
api.createLiveBlogUpdate(liveBlogId, dto),
|
api.createLiveBlogUpdate(liveBlogId, dto),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
// Invalidate live blog queries
|
// Invalidate live blog queries
|
||||||
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
|
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
|
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
|
||||||
@ -83,7 +83,7 @@ export function useUpdateLiveBlogUpdate() {
|
|||||||
updateId: string;
|
updateId: string;
|
||||||
dto: api.UpdateLiveBlogUpdateDto
|
dto: api.UpdateLiveBlogUpdateDto
|
||||||
}) => api.updateLiveBlogUpdate(liveBlogId, updateId, dto),
|
}) => api.updateLiveBlogUpdate(liveBlogId, updateId, dto),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
// Invalidate live blog queries
|
// Invalidate live blog queries
|
||||||
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
|
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
|
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
|
||||||
@ -97,7 +97,7 @@ export function useDeleteLiveBlogUpdate() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ liveBlogId, updateId }: { liveBlogId: string; updateId: string }) =>
|
mutationFn: ({ liveBlogId, updateId }: { liveBlogId: string; updateId: string }) =>
|
||||||
api.deleteLiveBlogUpdate(liveBlogId, updateId),
|
api.deleteLiveBlogUpdate(liveBlogId, updateId),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
// Invalidate live blog queries
|
// Invalidate live blog queries
|
||||||
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
|
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
|
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent'
|
|||||||
import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent'
|
import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent'
|
||||||
import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent'
|
import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent'
|
||||||
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent'
|
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent'
|
||||||
|
import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent'
|
||||||
import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker'
|
import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker'
|
||||||
import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar'
|
import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
@ -26,20 +27,23 @@ const rootRoute = createRootRoute({
|
|||||||
<h1 className="text-3xl font-bold">
|
<h1 className="text-3xl font-bold">
|
||||||
<Link to="/" className="hover:underline">Placebo.mk</Link>
|
<Link to="/" className="hover:underline">Placebo.mk</Link>
|
||||||
</h1>
|
</h1>
|
||||||
<nav className="flex gap-4">
|
<nav className="flex gap-4">
|
||||||
<Link to="/" className="text-sm font-medium hover:underline">
|
<Link to="/" className="text-sm font-medium hover:underline">
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/articles" className="text-sm font-medium hover:underline">
|
<Link to="/articles" className="text-sm font-medium hover:underline">
|
||||||
Articles
|
Articles
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/live-blogs" className="text-sm font-medium hover:underline">
|
<Link to="/live-blogs" className="text-sm font-medium hover:underline">
|
||||||
Live
|
Live
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/admin/live-blogs/create" className="text-sm font-medium hover:underline text-primary">
|
<Link to="/admin" className="text-sm font-medium hover:underline text-primary">
|
||||||
+ New Live Blog
|
Admin
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
<Link to="/admin/live-blogs/create" className="text-sm font-medium hover:underline text-primary">
|
||||||
|
+ New Live Blog
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -247,6 +251,12 @@ const createLiveBlogRoute = createRoute({
|
|||||||
component: CreateLiveBlogComponent,
|
component: CreateLiveBlogComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const adminDashboardRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/admin',
|
||||||
|
component: AdminDashboardComponent,
|
||||||
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
articlesRoute,
|
articlesRoute,
|
||||||
@ -255,6 +265,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
liveBlogDetailRoute,
|
liveBlogDetailRoute,
|
||||||
liveBlogAdminRoute,
|
liveBlogAdminRoute,
|
||||||
createLiveBlogRoute,
|
createLiveBlogRoute,
|
||||||
|
adminDashboardRoute,
|
||||||
])
|
])
|
||||||
|
|
||||||
export const router = createRouter({ routeTree })
|
export const router = createRouter({ routeTree })
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user