admin dashboard

This commit is contained in:
echo 2026-01-29 11:27:45 +01:00
parent 2ff76ffda5
commit 7c7bb45446
15 changed files with 440 additions and 116 deletions

View File

@ -16,6 +16,7 @@
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.14",
"@tanstack/react-router": "^1.144.0",
"date-fns": "^4.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
@ -3433,6 +3434,16 @@
"devOptional": true,
"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": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@ -23,6 +23,7 @@
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.14",
"@tanstack/react-router": "^1.144.0",
"date-fns": "^4.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},

View File

@ -24,7 +24,7 @@ export function ArticleTicker() {
{articles.map((article, index) => (
<Link
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"
>
{article.title || 'No title'}
@ -34,7 +34,7 @@ export function ArticleTicker() {
{articles.map((article, index) => (
<Link
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"
>
{article.title || 'No title'}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import {
useLiveBlog,
useDeleteLiveBlogUpdate,

View File

@ -23,7 +23,7 @@ export function LiveBlogTicker({
const [scrollPosition, setScrollPosition] = useState(0);
const tickerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<number>();
const animationRef = useRef<number | undefined>(undefined);
// Calculate total width needed for scrolling
const [totalWidth, setTotalWidth] = useState(0);
@ -55,7 +55,7 @@ export function LiveBlogTicker({
// Reset when scrolled past content
if (newPos > totalWidth) {
newPos = -tickerRef.current?.offsetWidth || 0;
newPos = -(tickerRef.current?.offsetWidth || 0);
}
return newPos;

View File

@ -1,4 +1,4 @@
import React from 'react';
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';

View File

@ -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 { useLiveBlog, useLiveBlogUpdates } from '@/queries/live-blogs';
import type { LiveBlogUpdate as ApiLiveBlogUpdate } from '@/lib/api';

View File

@ -1,9 +1,9 @@
import React from 'react';
import { Link } from '@tanstack/react-router';
import { usePinnedLiveBlogs } from '@/queries/live-blogs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import type { LiveBlog } from '@/lib/api';
import {
Clock,
MessageSquare,
@ -49,11 +49,49 @@ export function PinnedLiveBlogSidebar({
);
}
if (error || !pinnedBlogs || pinnedBlogs.length === 0) {
return null; // Don't show sidebar if no pinned blogs
if (isLoading) {
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) => {
switch (status) {
@ -100,12 +138,23 @@ export function PinnedLiveBlogSidebar({
Live Coverage
</CardTitle>
<Badge variant="outline" className="text-xs">
{pinnedBlogs.length} pinned
{(pinnedBlogs || []).length} pinned
</Badge>
</div>
</CardHeader>
<CardContent className="pt-0">
{displayBlogs.length === 0 ? (
<div className="py-6 text-center">
<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);
@ -115,27 +164,23 @@ export function PinnedLiveBlogSidebar({
key={blog.id}
to="/live-blogs/$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">
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h3 className="font-medium text-sm leading-tight group-hover:text-primary transition-colors line-clamp-2">
<h4 className="font-medium text-sm mb-1 group-hover:text-primary transition-colors line-clamp-2">
{blog.title}
</h3>
</h4>
{blog.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
<p className="text-xs text-muted-foreground line-clamp-2">
{blog.description}
</p>
)}
</div>
<Badge
className={getStatusColor(blog.status)}
className={`${getStatusColor(blog.status)} text-xs`}
variant="outline"
size="sm"
>
<div className="flex items-center gap-1">
{getStatusIcon(blog.status)}
@ -156,23 +201,14 @@ export function PinnedLiveBlogSidebar({
)}
{/* 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 className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
<span>{blog.updates?.length || 0}</span>
</div>
</div>
<div className="flex items-center gap-1 text-primary">
<span className="text-xs">View live</span>
<ChevronRight className="w-3 h-3" />
<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>
</Link>
@ -180,18 +216,21 @@ export function PinnedLiveBlogSidebar({
})}
</div>
{pinnedBlogs.length > maxItems && (
<div className="mt-4 pt-3 border-t">
<Link
to="/live-blogs"
className="w-full"
{/* View all button */}
{(pinnedBlogs || []).length > maxItems && (
<Button
variant="ghost"
size="sm"
className="w-full mt-4"
asChild
>
<Button variant="ghost" size="sm" className="w-full justify-between">
<span>View all pinned blogs</span>
<ChevronRight className="w-4 h-4" />
</Button>
<Link to="/live-blogs">
View all pinned blogs
<ChevronRight className="w-3 h-3 ml-1" />
</Link>
</div>
</Button>
)}
</>
)}
</CardContent>
</Card>

View 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>
);
}

View File

@ -35,7 +35,7 @@ export function ArticlesComponent() {
{data?.data.map((article) => (
<Link
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"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2">

View File

@ -38,7 +38,7 @@ export function LiveBlogsComponent() {
{data?.data.map((liveBlog) => (
<Link
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"
>
<div className="flex items-start justify-between mb-4">

View File

@ -22,7 +22,7 @@ export function useLiveBlogStream(
const [reconnectAttempts, setReconnectAttempts] = useState(0);
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 optionsRef = useRef(options);
const reconnectAttemptsRef = useRef(reconnectAttempts);

View File

@ -118,6 +118,7 @@ export interface LiveBlog {
slug: string;
description: string | null;
status: 'draft' | 'live' | 'ended' | 'archived';
isPinned: boolean;
strapiId: string | null;
authorId: string | null;
categoryId: string | null;
@ -194,6 +195,7 @@ export interface UpdateLiveBlogDto {
slug?: string;
description?: string;
status?: 'draft' | 'live' | 'ended' | 'archived';
isPinned?: boolean;
authorId?: string;
categoryId?: string;
strapiId?: string;

View File

@ -66,7 +66,7 @@ export function useCreateLiveBlogUpdate() {
return useMutation({
mutationFn: ({ liveBlogId, dto }: { liveBlogId: string; dto: api.CreateLiveBlogUpdateDto }) =>
api.createLiveBlogUpdate(liveBlogId, dto),
onSuccess: (data, variables) => {
onSuccess: (_data, variables) => {
// Invalidate live blog queries
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
@ -83,7 +83,7 @@ export function useUpdateLiveBlogUpdate() {
updateId: string;
dto: api.UpdateLiveBlogUpdateDto
}) => api.updateLiveBlogUpdate(liveBlogId, updateId, dto),
onSuccess: (data, variables) => {
onSuccess: (_data, variables) => {
// Invalidate live blog queries
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });
@ -97,7 +97,7 @@ export function useDeleteLiveBlogUpdate() {
return useMutation({
mutationFn: ({ liveBlogId, updateId }: { liveBlogId: string; updateId: string }) =>
api.deleteLiveBlogUpdate(liveBlogId, updateId),
onSuccess: (data, variables) => {
onSuccess: (_data, variables) => {
// Invalidate live blog queries
queryClient.invalidateQueries({ queryKey: ['liveBlog', variables.liveBlogId] });
queryClient.invalidateQueries({ queryKey: ['liveBlogUpdates', variables.liveBlogId] });

View File

@ -6,6 +6,7 @@ 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 { AdminDashboardComponent } from './components/routes/AdminDashboardComponent'
import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker'
import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar'
import './styles.css'
@ -36,6 +37,9 @@ const rootRoute = createRootRoute({
<Link to="/live-blogs" className="text-sm font-medium hover:underline">
Live
</Link>
<Link to="/admin" className="text-sm font-medium hover:underline text-primary">
Admin
</Link>
<Link to="/admin/live-blogs/create" className="text-sm font-medium hover:underline text-primary">
+ New Live Blog
</Link>
@ -247,6 +251,12 @@ const createLiveBlogRoute = createRoute({
component: CreateLiveBlogComponent,
})
const adminDashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/admin',
component: AdminDashboardComponent,
})
const routeTree = rootRoute.addChildren([
indexRoute,
articlesRoute,
@ -255,6 +265,7 @@ const routeTree = rootRoute.addChildren([
liveBlogDetailRoute,
liveBlogAdminRoute,
createLiveBlogRoute,
adminDashboardRoute,
])
export const router = createRouter({ routeTree })