Merge branch 'redesignNo1'
This commit is contained in:
commit
c2c77ac92a
@ -6,6 +6,7 @@ import {
|
||||
IsUUID,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
ArticleStatus,
|
||||
@ -371,6 +372,18 @@ export class CreateLiveBlogUpdateDto {
|
||||
@IsString()
|
||||
content: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
image?: string;
|
||||
@ -389,6 +402,14 @@ export class UpdateLiveBlogUpdateDto {
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
image?: string;
|
||||
@ -400,8 +421,4 @@ export class UpdateLiveBlogUpdateDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
import { Zap } from 'lucide-react'
|
||||
|
||||
export function ArticleTicker() {
|
||||
const { data } = useQuery({
|
||||
@ -13,29 +14,28 @@ export function ArticleTicker() {
|
||||
if (articles.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden bg-muted/50 border-y">
|
||||
<div className="container mx-auto max-w-6xl px-4">
|
||||
<div className="py-2 flex items-center gap-4">
|
||||
<span className="text-sm font-semibold text-primary whitespace-nowrap">
|
||||
Latest:
|
||||
</span>
|
||||
<div className="overflow-hidden flex-1 relative">
|
||||
<div className="overflow-hidden bg-foreground text-background border-b-4 border-accent">
|
||||
<div className="py-2 flex items-center">
|
||||
<div className="flex-shrink-0 px-4 py-1 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider flex items-center gap-2 border-r-4 border-background z-10">
|
||||
<Zap className="w-4 h-4" />
|
||||
Топ вести
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="flex animate-marquee whitespace-nowrap">
|
||||
{articles.map((article, index) => (
|
||||
<Link
|
||||
key={`${article.id}-${index}`}
|
||||
to={`/articles/${article.id}`}
|
||||
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
|
||||
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
|
||||
>
|
||||
{article.title || 'No title'}
|
||||
</Link>
|
||||
))}
|
||||
{/* Duplicate for seamless scrolling */}
|
||||
{articles.map((article, index) => (
|
||||
<Link
|
||||
key={`dup-${article.id}-${index}`}
|
||||
to={`/articles/${article.id}`}
|
||||
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
|
||||
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
|
||||
>
|
||||
{article.title || 'No title'}
|
||||
</Link>
|
||||
@ -44,6 +44,5 @@ export function ArticleTicker() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useActiveLiveBlogs } from '@/queries/live-blogs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Play, Pause, ChevronRight, Radio } from 'lucide-react';
|
||||
|
||||
@ -9,7 +8,7 @@ interface LiveBlogTickerProps {
|
||||
className?: string;
|
||||
maxItems?: number;
|
||||
autoScroll?: boolean;
|
||||
scrollSpeed?: number; // pixels per second
|
||||
scrollSpeed?: number;
|
||||
}
|
||||
|
||||
export function LiveBlogTicker({
|
||||
@ -25,7 +24,6 @@ export function LiveBlogTicker({
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const animationRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Calculate total width needed for scrolling
|
||||
const [totalWidth, setTotalWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@ -35,7 +33,6 @@ export function LiveBlogTicker({
|
||||
}
|
||||
}, [activeBlogs]);
|
||||
|
||||
// Auto-scroll animation
|
||||
useEffect(() => {
|
||||
if (!autoScroll || isPaused || !activeBlogs || activeBlogs.length === 0) {
|
||||
if (animationRef.current) {
|
||||
@ -53,7 +50,6 @@ export function LiveBlogTicker({
|
||||
setScrollPosition(prev => {
|
||||
let newPos = prev + (scrollSpeed * delta) / 1000;
|
||||
|
||||
// Reset when scrolled past content
|
||||
if (newPos > totalWidth) {
|
||||
newPos = -(tickerRef.current?.offsetWidth || 0);
|
||||
}
|
||||
@ -75,7 +71,7 @@ export function LiveBlogTicker({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`bg-muted/50 border rounded-lg p-3 ${className}`}>
|
||||
<div className={`border-brutal-sm bg-card p-3 ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-pulse h-4 w-24 bg-muted rounded"></div>
|
||||
@ -87,7 +83,7 @@ export function LiveBlogTicker({
|
||||
}
|
||||
|
||||
if (!activeBlogs || activeBlogs.length === 0) {
|
||||
return null; // Don't show ticker if no active blogs
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayBlogs = activeBlogs.slice(0, maxItems);
|
||||
@ -97,56 +93,48 @@ export function LiveBlogTicker({
|
||||
};
|
||||
|
||||
const handleBlogClick = () => {
|
||||
// Reset scroll position when user interacts
|
||||
setScrollPosition(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-background border rounded-lg overflow-hidden ${className}`}>
|
||||
{/* Ticker header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="w-4 h-4 text-primary" />
|
||||
<span className="font-medium text-sm">Live Now</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{activeBlogs.length} active
|
||||
</Badge>
|
||||
<div className={`border-brutal-sm bg-card overflow-hidden ${className}`}>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b-2 border-foreground/10 bg-secondary">
|
||||
<div className="flex items-center gap-3">
|
||||
<Radio className="w-4 h-4 text-accent animate-pulse" />
|
||||
<span className="font-body text-sm font-bold uppercase tracking-wider">LIVE</span>
|
||||
<span className="px-2 py-0.5 bg-accent text-foreground text-xs font-body font-bold uppercase">
|
||||
{activeBlogs.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{autoScroll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="brutal"
|
||||
size="sm"
|
||||
onClick={handlePauseToggle}
|
||||
className="h-7 px-2"
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{isPaused ? (
|
||||
<Play className="w-3 h-3" />
|
||||
) : (
|
||||
<Pause className="w-3 h-3" />
|
||||
)}
|
||||
<span className="ml-1 text-xs">{isPaused ? 'Play' : 'Pause'}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Link to="/live-blogs">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2">
|
||||
<span className="text-xs">View All</span>
|
||||
<Button variant="brutal" size="sm" className="h-7 px-2 text-xs">
|
||||
Сите
|
||||
<ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticker content */}
|
||||
<div
|
||||
ref={tickerRef}
|
||||
className="relative h-12 overflow-hidden"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent)',
|
||||
WebkitMaskImage: 'linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
@ -156,76 +144,63 @@ export function LiveBlogTicker({
|
||||
transition: isPaused ? 'transform 0.3s ease' : 'none'
|
||||
}}
|
||||
>
|
||||
{displayBlogs.map((blog, index) => (
|
||||
{displayBlogs.map((blog) => (
|
||||
<React.Fragment key={blog.id}>
|
||||
<Link
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
onClick={handleBlogClick}
|
||||
className="inline-flex items-center gap-2 px-4 py-1 rounded-full hover:bg-accent transition-colors group"
|
||||
className="inline-flex items-center gap-2 px-4 py-1 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
<span className="font-medium text-sm group-hover:text-primary transition-colors">
|
||||
<span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
|
||||
{blog.title}
|
||||
</span>
|
||||
{blog.updates && blog.updates.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{blog.updates.length} updates
|
||||
</Badge>
|
||||
<span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
|
||||
{blog.updates.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Separator (except after last item) */}
|
||||
{index < displayBlogs.length - 1 && (
|
||||
<div className="mx-2 text-muted-foreground">•</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Duplicate content for seamless looping */}
|
||||
{displayBlogs.map((blog, index) => (
|
||||
{displayBlogs.map((blog) => (
|
||||
<React.Fragment key={`${blog.id}-dup`}>
|
||||
<Link
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
onClick={handleBlogClick}
|
||||
className="inline-flex items-center gap-2 px-4 py-1 rounded-full hover:bg-accent transition-colors group"
|
||||
className="inline-flex items-center gap-2 px-4 py-1 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
<span className="font-medium text-sm group-hover:text-primary transition-colors">
|
||||
<span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
|
||||
{blog.title}
|
||||
</span>
|
||||
{blog.updates && blog.updates.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{blog.updates.length} updates
|
||||
</Badge>
|
||||
<span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
|
||||
{blog.updates.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{index < displayBlogs.length - 1 && (
|
||||
<div className="mx-2 text-muted-foreground">•</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
{autoScroll && totalWidth > 0 && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="h-1 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-1 bg-foreground/10">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
className="h-full bg-accent transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(100, (scrollPosition / totalWidth) * 100 * 2)}%`
|
||||
}}
|
||||
|
||||
@ -214,7 +214,7 @@ export function LiveBlogViewer({ slug, className }: LiveBlogViewerProps) {
|
||||
|
||||
<div
|
||||
ref={updatesContainerRef}
|
||||
className="max-h-[600px] overflow-y-auto px-6 pb-6"
|
||||
className="px-6 pb-6"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{updates.length === 0 && !updatesLoading ? (
|
||||
|
||||
@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { fetchHeroArticle } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar, User, ArrowRight } from 'lucide-react';
|
||||
import { Calendar, User, ArrowRight, Eye } from 'lucide-react';
|
||||
|
||||
export function HeroArticle() {
|
||||
const { data: article, isLoading, error } = useQuery({
|
||||
@ -12,23 +12,24 @@ export function HeroArticle() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-8 animate-pulse">
|
||||
<div className="h-8 bg-muted rounded w-3/4 mb-4"></div>
|
||||
<div className="border-brutal bg-card p-0 animate-pulse">
|
||||
<div className="h-80 bg-muted"></div>
|
||||
<div className="p-8">
|
||||
<div className="h-10 bg-muted rounded w-3/4 mb-4"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/2 mb-6"></div>
|
||||
<div className="h-64 bg-muted rounded mb-6"></div>
|
||||
<div className="h-4 bg-muted rounded w-full mb-2"></div>
|
||||
<div className="h-4 bg-muted rounded w-5/6 mb-6"></div>
|
||||
<div className="h-10 bg-muted rounded w-32"></div>
|
||||
<div className="h-20 bg-muted rounded mb-6"></div>
|
||||
<div className="h-12 bg-muted rounded w-40"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-8 text-center">
|
||||
<div className="text-red-500 mb-4">Error loading hero article</div>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
Try Again
|
||||
<div className="border-brutal bg-card p-8 text-center">
|
||||
<div className="text-destructive text-xl font-display mb-4">ERROR</div>
|
||||
<Button variant="brutal" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@ -36,115 +37,105 @@ export function HeroArticle() {
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" x2="8" y1="13" y2="13" />
|
||||
<line x1="16" x2="8" y1="17" y2="17" />
|
||||
<line x1="10" x2="8" y1="9" y2="9" />
|
||||
</svg>
|
||||
<div className="border-brutal bg-card p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 border-4 border-foreground mb-6">
|
||||
<span className="font-display text-4xl">?</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-4">No Hero Article Set</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
<h2 className="text-3xl font-display mb-4">NO HERO ARTICLE</h2>
|
||||
<p className="font-body text-muted-foreground mb-4">
|
||||
Mark an article as "Hero" in the admin panel to feature it here.
|
||||
</p>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This space will showcase your most important story.
|
||||
<div className="font-body text-xs uppercase tracking-wider text-muted-foreground mt-8 border-t-2 border-foreground/20 pt-4">
|
||||
This space will showcase your most important story
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-card overflow-hidden hover:shadow-lg transition-shadow duration-300">
|
||||
{/* Featured Image */}
|
||||
<article className="group border-brutal bg-card hover:shadow-brutal transition-all duration-200 animate-fade-in-up">
|
||||
{article.featuredImage && (
|
||||
<div className="relative h-64 md:h-80 overflow-hidden">
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 z-10">
|
||||
<span className="inline-block px-4 py-2 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider border-b-2 border-r-2 border-foreground">
|
||||
Прекршени Вести
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-72 md:h-96 overflow-hidden">
|
||||
<img
|
||||
src={article.featuredImage}
|
||||
alt={article.title}
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
<div className="absolute bottom-4 left-6 right-6">
|
||||
<span className="inline-block px-3 py-1 bg-primary text-primary-foreground text-xs font-semibold rounded-full mb-2">
|
||||
Featured Story
|
||||
</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-foreground/80 via-foreground/20 to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 md:p-8">
|
||||
<h2 className="text-2xl md:text-3xl font-bold mb-4 line-clamp-2">
|
||||
<h2 className="text-3xl md:text-4xl font-display leading-tight mb-4 line-clamp-2 group-hover:text-accent transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
|
||||
{/* Meta Information */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground mb-6">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<div className="flex flex-wrap items-center gap-4 font-body text-sm uppercase tracking-wider text-muted-foreground mb-6 pb-4 border-b-2 border-foreground/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>
|
||||
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{article.author && (
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-4 w-4" />
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
<span>{article.author.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>{article.views} views</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Excerpt */}
|
||||
{article.excerpt && (
|
||||
<p className="text-muted-foreground mb-6 line-clamp-3">
|
||||
<p className="text-muted-foreground mb-6 line-clamp-3 font-body">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{article.tags && article.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{article.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1 text-xs rounded-full bg-secondary text-secondary-foreground"
|
||||
className="px-3 py-1 text-xs font-body uppercase tracking-wider border-2 border-foreground bg-background"
|
||||
>
|
||||
{tag}
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Read More Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between pt-4 border-t-2 border-foreground/10">
|
||||
<Link to={`/articles/${article.id}`}>
|
||||
<Button className="gap-2">
|
||||
<Button variant="brutalAccent" className="gap-2">
|
||||
Read Full Story
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Social shares count */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">
|
||||
{article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares}
|
||||
<div className="font-body text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<span className="font-bold text-foreground">
|
||||
{(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)}
|
||||
</span> shares
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as api from '@/lib/api'
|
||||
import { SocialShareButtons } from '@/components/features/social-share'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
export function LatestArticlesGrid() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@ -11,17 +12,13 @@ export function LatestArticlesGrid() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className="p-6 rounded-xl border bg-card animate-pulse">
|
||||
<div className="h-48 bg-muted rounded-lg mb-4"></div>
|
||||
<div className="h-4 bg-muted rounded mb-2"></div>
|
||||
<div key={i} className="border-brutal-sm bg-card p-4 animate-pulse">
|
||||
<div className="h-40 bg-muted mb-4"></div>
|
||||
<div className="h-6 bg-muted rounded mb-2"></div>
|
||||
<div className="h-4 bg-muted rounded mb-2 w-3/4"></div>
|
||||
<div className="h-3 bg-muted rounded mb-4 w-1/2"></div>
|
||||
<div className="flex justify-between">
|
||||
<div className="h-3 bg-muted rounded w-1/4"></div>
|
||||
<div className="h-3 bg-muted rounded w-1/4"></div>
|
||||
</div>
|
||||
<div className="h-3 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -30,9 +27,9 @@ export function LatestArticlesGrid() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-red-200 bg-red-50 p-8 text-center">
|
||||
<div className="text-red-500 mb-2">Грешка при вчитување на статии</div>
|
||||
<p className="text-sm text-red-600">Обидете се повторно</p>
|
||||
<div className="border-brutal bg-destructive/10 p-8 text-center">
|
||||
<div className="text-destructive text-2xl font-display mb-2">ГРЕШКА</div>
|
||||
<p className="font-body text-sm text-destructive">Обидете се повторно</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -41,94 +38,85 @@ export function LatestArticlesGrid() {
|
||||
|
||||
if (articles.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-muted p-8 text-center">
|
||||
<div className="text-muted-foreground mb-2">Нема објавени статии</div>
|
||||
<p className="text-sm text-muted-foreground">Проверете подоцна</p>
|
||||
<div className="border-brutal bg-card p-8 text-center">
|
||||
<div className="font-display text-2xl mb-2">НЕМА СТАТИИ</div>
|
||||
<p className="font-body text-sm text-muted-foreground">Проверете подоцна</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">Најнови статии</h2>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between border-b-4 border-foreground pb-4">
|
||||
<h2 className="text-3xl md:text-4xl font-display">Најнови</h2>
|
||||
<Link
|
||||
to="/archive"
|
||||
className="text-sm text-primary hover:underline flex items-center gap-1"
|
||||
className="font-body text-sm uppercase tracking-wider border-2 border-foreground px-4 py-2 hover:bg-accent hover:border-accent transition-all duration-150 flex items-center gap-2"
|
||||
>
|
||||
Види сите
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
Сите
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{articles.map((article) => (
|
||||
<div
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{articles.map((article, index) => (
|
||||
<article
|
||||
key={article.id}
|
||||
className="group p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
|
||||
className={`group border-brutal-sm bg-card hover:shadow-brutal transition-all duration-150 hover:-translate-y-1 animate-fade-in-up stagger-${Math.min(index + 1, 12)}`}
|
||||
>
|
||||
<Link
|
||||
to={`/articles/${article.id}`}
|
||||
className="block mb-4"
|
||||
className="block"
|
||||
>
|
||||
{article.featuredImage ? (
|
||||
<div className="relative h-48 overflow-hidden rounded-lg mb-4">
|
||||
<div className="relative h-40 overflow-hidden border-b-2 border-foreground">
|
||||
<img
|
||||
src={article.featuredImage}
|
||||
alt={article.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="absolute inset-0 bg-foreground/0 group-hover:bg-foreground/10 transition-colors duration-300" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-48 bg-gradient-to-br from-primary/10 to-primary/5 rounded-lg mb-4 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-primary/30">
|
||||
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
|
||||
<path d="M18 14h-8" />
|
||||
<path d="M15 18h-5" />
|
||||
<path d="M10 6h8v4h-8V6Z" />
|
||||
</svg>
|
||||
<div className="h-40 bg-secondary border-b-2 border-foreground flex items-center justify-center relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'repeating-linear-gradient(45deg, currentColor 0, currentColor 1px, transparent 0, transparent 50%)', backgroundSize: '10px 10px' }}></div>
|
||||
<span className="font-display text-4xl text-foreground/30">N</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2 line-clamp-2 group-hover:text-primary transition-colors">
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-display leading-tight mb-2 line-clamp-2 group-hover:text-accent transition-colors">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
{article.excerpt && (
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
<p className="text-muted-foreground text-xs font-body line-clamp-2 mb-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="px-4 pb-4 border-t-2 border-foreground/10 pt-3 mt-auto">
|
||||
<div className="flex items-center justify-between font-body text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<span>
|
||||
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{article.views} прегледи</span>
|
||||
</div>
|
||||
|
||||
{article.category && (
|
||||
<Link
|
||||
to={`/${article.category.slug}`}
|
||||
className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
className="px-2 py-0.5 border border-foreground bg-background text-foreground text-[10px] hover:bg-accent hover:border-accent transition-colors"
|
||||
>
|
||||
{article.category.name}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<SocialShareButtons
|
||||
articleId={article.id}
|
||||
title={article.title}
|
||||
@ -140,6 +128,7 @@ export function LatestArticlesGrid() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { fetchPinnedLiveBlogs } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar, Eye, MessageSquare, Pin } from 'lucide-react';
|
||||
|
||||
@ -15,25 +14,27 @@ export function PinnedLiveBlogsSidebar() {
|
||||
switch (status) {
|
||||
case 'live':
|
||||
return (
|
||||
<Badge variant="default" className="bg-green-500 hover:bg-green-600">
|
||||
<span className="px-2 py-0.5 bg-accent text-foreground text-xs font-body font-bold uppercase">
|
||||
LIVE
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
case 'ended':
|
||||
return (
|
||||
<Badge variant="outline" className="border-gray-400 text-gray-400">
|
||||
<span className="px-2 py-0.5 border-2 border-foreground/40 text-foreground/40 text-xs font-body font-bold uppercase">
|
||||
ENDED
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
case 'archived':
|
||||
return (
|
||||
<Badge variant="outline" className="border-gray-500 text-gray-500">
|
||||
<span className="px-2 py-0.5 border-2 border-foreground/30 text-foreground/30 text-xs font-body font-bold uppercase">
|
||||
ARCHIVED
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline">DRAFT</Badge>
|
||||
<span className="px-2 py-0.5 border-2 border-foreground text-foreground text-xs font-body font-bold uppercase">
|
||||
DRAFT
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -48,10 +49,10 @@ export function PinnedLiveBlogsSidebar() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6">
|
||||
<div className="border-brutal-sm bg-card p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Pin className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Pinned Live Blogs</h3>
|
||||
<Pin className="h-5 w-5" />
|
||||
<h3 className="text-xl font-display">Pinned Live</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
@ -67,14 +68,14 @@ export function PinnedLiveBlogsSidebar() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6">
|
||||
<div className="border-brutal-sm bg-card p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Pin className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Pinned Live Blogs</h3>
|
||||
<Pin className="h-5 w-5" />
|
||||
<h3 className="text-xl font-display">Pinned Live</h3>
|
||||
</div>
|
||||
<div className="text-center py-4">
|
||||
<div className="text-red-500 text-sm mb-2">Error loading live blogs</div>
|
||||
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
|
||||
<div className="text-destructive font-body text-sm mb-2">ERROR</div>
|
||||
<Button variant="brutal" size="sm" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
@ -84,15 +85,15 @@ export function PinnedLiveBlogsSidebar() {
|
||||
|
||||
if (!liveBlogs || liveBlogs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6">
|
||||
<div className="border-brutal-sm bg-card p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Pin className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Pinned Live Blogs</h3>
|
||||
<Pin className="h-5 w-5" />
|
||||
<h3 className="text-xl font-display">Pinned Live</h3>
|
||||
</div>
|
||||
<div className="text-center py-6">
|
||||
<div className="text-muted-foreground mb-2">No pinned live blogs</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pin live blogs from the admin panel to feature them here.
|
||||
<div className="text-center py-6 border-2 border-dashed border-foreground/20 p-4">
|
||||
<div className="font-body text-muted-foreground mb-2">No pinned live blogs</div>
|
||||
<p className="font-body text-xs text-muted-foreground">
|
||||
Pin live blogs from the admin panel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -100,44 +101,40 @@ export function PinnedLiveBlogsSidebar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="border-brutal-sm bg-card p-6">
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b-2 border-foreground/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Pin className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Pinned Live Blogs</h3>
|
||||
<Pin className="h-5 w-5 text-accent" />
|
||||
<h3 className="text-xl font-display">Pinned Live</h3>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{liveBlogs.length} pinned
|
||||
</Badge>
|
||||
<span className="px-2 py-1 border-2 border-foreground bg-foreground text-background text-xs font-body font-bold uppercase">
|
||||
{liveBlogs.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Live Blogs List */}
|
||||
<div className="space-y-4">
|
||||
{liveBlogs.map((liveBlog) => (
|
||||
<Link
|
||||
key={liveBlog.id}
|
||||
to={`/live-blogs/${liveBlog.id}`}
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: liveBlog.slug }}
|
||||
className="block group"
|
||||
>
|
||||
<div className="p-4 rounded-lg border hover:border-primary/50 hover:bg-accent/50 transition-colors group-hover:shadow-sm">
|
||||
{/* Title and Status */}
|
||||
<div className="p-4 border-2 border-foreground/10 hover:border-foreground hover:shadow-brutal-sm transition-all duration-150 group-hover:-translate-y-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-medium line-clamp-2 group-hover:text-primary transition-colors">
|
||||
<h4 className="font-body text-sm font-bold leading-tight line-clamp-2 group-hover:text-accent transition-colors">
|
||||
{liveBlog.title}
|
||||
</h4>
|
||||
{getStatusBadge(liveBlog.status)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{liveBlog.description && (
|
||||
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
|
||||
<p className="text-xs font-body text-muted-foreground mb-3 line-clamp-2">
|
||||
{liveBlog.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta Information */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs font-body text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{formatDate(liveBlog.createdAt)}</span>
|
||||
@ -145,33 +142,31 @@ export function PinnedLiveBlogsSidebar() {
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
<span>{liveBlog.viewCount} views</span>
|
||||
<span>{liveBlog.viewCount}</span>
|
||||
</div>
|
||||
|
||||
{liveBlog.updates && liveBlog.updates.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
<span>{liveBlog.updates.length} updates</span>
|
||||
<span>{liveBlog.updates.length}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{liveBlog.author && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
by {liveBlog.author.name}
|
||||
{liveBlog.author.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Featured Image (small) */}
|
||||
{liveBlog.featuredImage && (
|
||||
<div className="mt-3">
|
||||
<div className="relative h-20 rounded-md overflow-hidden">
|
||||
<div className="relative h-20 border-2 border-foreground/10 overflow-hidden">
|
||||
<img
|
||||
src={liveBlog.featuredImage}
|
||||
alt={liveBlog.title}
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -180,11 +175,10 @@ export function PinnedLiveBlogsSidebar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View All Link */}
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="mt-6 pt-4 border-t-2 border-foreground/10">
|
||||
<Link to="/live-blogs" className="block">
|
||||
<Button variant="outline" className="w-full justify-center">
|
||||
View All Live Blogs
|
||||
<Button variant="brutal" className="w-full justify-center">
|
||||
Сите Live
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,17 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Button } from '../ui/button';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { Menu, X, Zap } from 'lucide-react';
|
||||
|
||||
const mkMonths = ['Јануари', 'Февруари', 'Март', 'Април', 'Мај', 'Јуни', 'Јули', 'Август', 'Септември', 'Октомври', 'Ноември', 'Декември'];
|
||||
const mkWeekdays = ['Понеделник', 'Вторник', 'Среда', 'Четврток', 'Петок', 'Сабота', 'Недела'];
|
||||
|
||||
const formatDateMk = () => {
|
||||
const d = new Date();
|
||||
return `${mkWeekdays[d.getDay()]}, ${d.getDate()} ${mkMonths[d.getMonth()]} ${d.getFullYear()}`;
|
||||
};
|
||||
|
||||
export function Header() {
|
||||
const { user, logout, isAuthenticated, hasRole } = useAuth();
|
||||
@ -24,29 +31,54 @@ export function Header() {
|
||||
{ to: '/art', label: 'Уметност' },
|
||||
{ to: '/science', label: 'Наука' },
|
||||
{ to: '/archive', label: 'Архива' },
|
||||
{ to: '/live-blogs', label: 'Live' },
|
||||
{ to: '/live-blogs', label: 'LIVE' },
|
||||
];
|
||||
|
||||
const adminLinks = [
|
||||
{ to: '/admin', label: 'Admin' },
|
||||
{ to: '/admin/live-blogs/create', label: '+ New Live Blog' },
|
||||
{ to: '/admin/live-blogs/create', label: '+ New Live' },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="border-b sticky top-0 z-50 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<header className="sticky top-0 z-50 border-b-4 border-foreground bg-[hsl(var(--background))]">
|
||||
<div className="border-b-2 border-foreground/20">
|
||||
<div className="container mx-auto max-w-6xl px-4">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="hidden md:flex items-center gap-2 text-xs font-mono uppercase tracking-wider text-muted-foreground">
|
||||
<Zap className="w-3 h-3" />
|
||||
<span>Сатирични вести од Македонија</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
|
||||
{formatDateMk()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto max-w-6xl px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl md:text-3xl font-bold">
|
||||
<Link to="/" className="hover:underline">Placebo.mk</Link>
|
||||
<Link to="/" className="group">
|
||||
<h1 className="text-4xl md:text-5xl font-display tracking-tight">
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-1">P</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-75">l</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-100">a</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-150">c</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-200">e</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-250">b</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-300">o</span>
|
||||
<span className="text-accent inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-350">.</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-1 delay-400">m</span>
|
||||
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-500">k</span>
|
||||
</h1>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-4">
|
||||
{navLinks.map((link) => (
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navLinks.map((link, index) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="text-sm font-medium hover:underline transition-colors"
|
||||
className="px-4 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-transparent hover:border-foreground hover:bg-accent hover:text-foreground transition-all duration-150"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
@ -60,7 +92,7 @@ export function Header() {
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="text-sm font-medium hover:underline text-primary transition-colors"
|
||||
className="px-3 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-accent bg-accent text-foreground hover:bg-foreground hover:text-accent transition-all duration-150"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
@ -68,37 +100,37 @@ export function Header() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-3 ml-4 pl-4 border-l-2 border-foreground/20">
|
||||
<span className="font-body text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{user?.username}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="brutal"
|
||||
size="sm"
|
||||
onClick={logout}
|
||||
className="text-xs"
|
||||
>
|
||||
Logout
|
||||
OUT
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/auth" className="text-sm font-medium hover:underline text-primary transition-colors">
|
||||
Login / Register
|
||||
<Link to="/auth" className="px-4 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent transition-all duration-150 ml-2">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="ml-4 pl-4 border-l-2 border-foreground/20">
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="brutal"
|
||||
size="icon"
|
||||
onClick={toggleMobileMenu}
|
||||
className="h-9 w-9"
|
||||
className="h-10 w-10"
|
||||
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
@ -110,15 +142,15 @@ export function Header() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden mt-4 pb-4 border-t pt-4 animate-in slide-in-from-top-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
{navLinks.map((link) => (
|
||||
<div className="md:hidden mt-4 pt-4 border-t-4 border-foreground animate-scale-in">
|
||||
<div className="flex flex-col gap-2">
|
||||
{navLinks.map((link, index) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="text-sm font-medium hover:underline py-2 transition-colors"
|
||||
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground hover:bg-accent hover:border-accent transition-all duration-150"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
{link.label}
|
||||
@ -128,13 +160,13 @@ export function Header() {
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
{(hasRole('admin') || hasRole('contributor')) && (
|
||||
<div className="border-t pt-3 mt-2">
|
||||
<p className="text-xs text-muted-foreground mb-2">Admin</p>
|
||||
<div className="pt-3 mt-2 border-t-4 border-foreground">
|
||||
<p className="font-body text-xs uppercase tracking-wider text-muted-foreground mb-2 pl-2">Admin</p>
|
||||
{adminLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="text-sm font-medium hover:underline text-primary py-2 block transition-colors"
|
||||
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-accent bg-accent hover:bg-foreground hover:text-accent transition-all duration-150 block"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
{link.label}
|
||||
@ -143,21 +175,20 @@ export function Header() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-3 mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Logged in as: {user?.username}
|
||||
<div className="pt-3 mt-2 border-t-4 border-foreground">
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<span className="font-body text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{user?.username}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="brutal"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
logout();
|
||||
closeMobileMenu();
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Logout
|
||||
OUT
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -165,10 +196,10 @@ export function Header() {
|
||||
) : (
|
||||
<Link
|
||||
to="/auth"
|
||||
className="text-sm font-medium hover:underline text-primary py-2 transition-colors"
|
||||
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent transition-all duration-150 mt-2"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
Login / Register
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -4,13 +4,10 @@ import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle, us
|
||||
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
|
||||
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() {
|
||||
// State for confirmation dialog and filters
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete');
|
||||
const [itemToDelete, setItemToDelete] = useState<{
|
||||
@ -40,10 +37,6 @@ export function AdminDashboardComponent() {
|
||||
const liveBlogs = liveBlogsData?.data || [];
|
||||
const articles = articlesData?.data || [];
|
||||
|
||||
// No need to filter items - API already filters based on showArchived state
|
||||
const filteredLiveBlogs = liveBlogs;
|
||||
const filteredArticles = articles;
|
||||
|
||||
const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
|
||||
setItemToDelete({ type, id, title });
|
||||
setDialogType('delete');
|
||||
@ -82,7 +75,7 @@ export function AdminDashboardComponent() {
|
||||
} else {
|
||||
await deleteLiveBlogMutation.mutateAsync(itemToDelete.id);
|
||||
}
|
||||
} else { // archive
|
||||
} else {
|
||||
if (itemToDelete.type === 'article') {
|
||||
await archiveArticleMutation.mutateAsync(itemToDelete.id);
|
||||
} else {
|
||||
@ -121,14 +114,14 @@ export function AdminDashboardComponent() {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
case 'live':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
return 'bg-green-500 text-white border-2 border-foreground';
|
||||
case 'draft':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
return 'bg-yellow-400 text-black border-2 border-foreground';
|
||||
case 'archived':
|
||||
case 'ended':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
return 'bg-gray-400 text-white border-2 border-foreground';
|
||||
default:
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
return 'bg-blue-500 text-white border-2 border-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
@ -144,110 +137,125 @@ export function AdminDashboardComponent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-8 space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-8">
|
||||
<div className="border-b-4 border-foreground pb-6">
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Администраторски панел</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<h1 className="text-4xl font-display uppercase tracking-tight">Администраторски панел</h1>
|
||||
<p className="font-body text-muted-foreground mt-1">
|
||||
Управување со сите написи и live блогови
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Button
|
||||
variant={showArchived ? "default" : "outline"}
|
||||
variant={showArchived ? 'brutal' : 'brutalOutline'}
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
>
|
||||
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Button asChild variant="brutalAccent">
|
||||
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Button asChild variant="brutalOutline">
|
||||
<Link to="/">Назад кон сајтот</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!showArchived && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="border-brutal bg-card p-5">
|
||||
<div className="text-4xl font-display">
|
||||
{liveBlogs.filter(b => b.status === 'live').length || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Активни live блогови</p>
|
||||
</div>
|
||||
<div className="border-brutal bg-card p-5">
|
||||
<div className="text-4xl font-display">
|
||||
{articles.filter(a => a.status === 'published').length || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Објавени написи</p>
|
||||
</div>
|
||||
<div className="border-brutal bg-card p-5">
|
||||
<div className="text-4xl font-display">
|
||||
{liveBlogs.filter(b => b.isPinned).length || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Закачени live блогови</p>
|
||||
</div>
|
||||
<div className="border-brutal bg-card p-5">
|
||||
<div className="text-4xl font-display">
|
||||
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
||||
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Вкупни прегледи</p>
|
||||
</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>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{filteredLiveBlogs.length || 0}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Сите live блогови со статус и датум на креирање
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border-brutal bg-card">
|
||||
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-display uppercase">
|
||||
{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}
|
||||
</h2>
|
||||
<span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
|
||||
{liveBlogs.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
|
||||
{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>
|
||||
) : filteredLiveBlogs.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
) : liveBlogs.length === 0 ? (
|
||||
<div className="text-center py-8 border-2 border-dashed border-foreground/30">
|
||||
<p className="text-muted-foreground">
|
||||
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredLiveBlogs.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">
|
||||
liveBlogs.map((blog) => (
|
||||
<div key={blog.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<Link
|
||||
to="/admin/live-blogs/$slug"
|
||||
params={{ slug: blog.slug }}
|
||||
className="font-medium hover:text-primary hover:underline"
|
||||
className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
|
||||
>
|
||||
{blog.title}
|
||||
</Link>
|
||||
<Badge variant="outline" className={getStatusColor(blog.status)}>
|
||||
<span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(blog.status)}`}>
|
||||
{getStatusText(blog.status)}
|
||||
</Badge>
|
||||
</span>
|
||||
{blog.isPinned && (
|
||||
<Badge variant="outline" className="bg-yellow-100 text-yellow-800 border-yellow-200">
|
||||
<span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
|
||||
Закачено
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
|
||||
<span>Слаг: {blog.slug}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Креирано: {format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })}
|
||||
</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">
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<Button asChild size="sm" variant="brutalOutline">
|
||||
<Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">
|
||||
Преглед
|
||||
</Link>
|
||||
<Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">Преглед</Link>
|
||||
</Button>
|
||||
{showArchived ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="brutalOutline"
|
||||
onClick={() => handlePublishClick('liveBlog', blog.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
@ -256,7 +264,7 @@ export function AdminDashboardComponent() {
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="brutalOutline"
|
||||
onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
@ -274,109 +282,91 @@ export function AdminDashboardComponent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Articles Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>{showArchived ? 'Архивирани написи' : 'Написи'}</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{filteredArticles.length || 0}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Сите написи со статус и датум на креирање
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border-brutal bg-card">
|
||||
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-display uppercase">
|
||||
{showArchived ? 'Архивирани написи' : 'Написи'}
|
||||
</h2>
|
||||
<span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
|
||||
{articles.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
|
||||
{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>
|
||||
) : filteredArticles.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
) : articles.length === 0 ? (
|
||||
<div className="text-center py-8 border-2 border-dashed border-foreground/30">
|
||||
<p className="text-muted-foreground">
|
||||
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link to="/">Креирај нов напис</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredArticles.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">
|
||||
articles.map((article) => (
|
||||
<div key={article.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<Link
|
||||
to="/articles/$id"
|
||||
params={{ id: article.id }}
|
||||
className="font-medium hover:text-primary hover:underline"
|
||||
className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
|
||||
>
|
||||
{article.title}
|
||||
</Link>
|
||||
<Badge variant="outline" className={getStatusColor(article.status)}>
|
||||
<span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(article.status)}`}>
|
||||
{getStatusText(article.status)}
|
||||
</Badge>
|
||||
</span>
|
||||
{article.isHero && (
|
||||
<Badge variant="default" className="bg-yellow-500 hover:bg-yellow-600">
|
||||
<span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
|
||||
★ Hero
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
|
||||
<span>Слаг: {article.slug}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Креирано: {format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}
|
||||
</span>
|
||||
<span>{format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}</span>
|
||||
<span>•</span>
|
||||
<span>Прегледи: {article.views}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Shares: {(article.facebookShares || 0) + (article.twitterShares || 0) +
|
||||
(article.whatsappShares || 0) + (article.telegramShares || 0)}
|
||||
</span>
|
||||
<span>Shares: {(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)}</span>
|
||||
</div>
|
||||
{article.excerpt && (
|
||||
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
|
||||
<p className="mt-2 text-sm font-body text-muted-foreground line-clamp-2">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<div className="flex gap-1 flex-shrink-0 flex-col">
|
||||
<div className="flex gap-1">
|
||||
<Button asChild size="sm" variant="brutalOutline">
|
||||
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">
|
||||
Преглед
|
||||
</Link>
|
||||
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">Преглед</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={article.isHero ? "default" : "outline"}
|
||||
variant={article.isHero ? 'brutal' : 'brutalOutline'}
|
||||
onClick={() => handleSetHero(article.id, !article.isHero)}
|
||||
disabled={isProcessing || updateArticleMutation.isPending}
|
||||
>
|
||||
{article.isHero ? '★ Hero' : 'Set as Hero'}
|
||||
{article.isHero ? '★ Hero' : 'Set Hero'}
|
||||
</Button>
|
||||
{showArchived ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="brutalOutline"
|
||||
onClick={() => handlePublishClick('article', article.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
@ -385,7 +375,7 @@ export function AdminDashboardComponent() {
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="brutalOutline"
|
||||
onClick={() => handleArchiveClick('article', article.id, article.title)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
@ -403,111 +393,53 @@ export function AdminDashboardComponent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats - Only show when not viewing archived items */}
|
||||
{!showArchived && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Social Media Analytics - Only show when not viewing archived items */}
|
||||
{!showArchived && (
|
||||
<div className="mt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Social Media Analytics</CardTitle>
|
||||
<CardDescription>Share statistics for articles</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Total Shares Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
<div className="border-brutal bg-card p-6">
|
||||
<h2 className="text-2xl font-display uppercase mb-6">Social Media Analytics</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||
<div className="border-brutal-sm bg-background p-4">
|
||||
<div className="text-3xl font-display">
|
||||
{articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Facebook Shares</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Facebook Shares</p>
|
||||
</div>
|
||||
<div className="border-brutal-sm bg-background p-4">
|
||||
<div className="text-3xl font-display">
|
||||
{articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Twitter Shares</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Twitter Shares</p>
|
||||
</div>
|
||||
<div className="border-brutal-sm bg-background p-4">
|
||||
<div className="text-3xl font-display">
|
||||
{articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">WhatsApp Shares</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">WhatsApp Shares</p>
|
||||
</div>
|
||||
<div className="border-brutal-sm bg-background p-4">
|
||||
<div className="text-3xl font-display">
|
||||
{articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Telegram Shares</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Telegram Shares</p>
|
||||
</div>
|
||||
<div className="border-brutal-sm bg-background p-4">
|
||||
<div className="text-3xl font-display">
|
||||
{articles.reduce((sum, a) =>
|
||||
sum + (a.facebookShares || 0) + (a.twitterShares || 0) +
|
||||
(a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Total Shares</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Total Shares</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Shared Articles */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Top Shared Articles</h3>
|
||||
<h3 className="text-xl font-display uppercase mb-4">Top Shared Articles</h3>
|
||||
<div className="space-y-3">
|
||||
{articles
|
||||
.filter(a => a.status === 'published')
|
||||
@ -529,44 +461,39 @@ export function AdminDashboardComponent() {
|
||||
: '0.00';
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div key={article.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
to="/articles/$id"
|
||||
params={{ id: article.id }}
|
||||
className="font-medium hover:text-primary hover:underline"
|
||||
className="font-display text-lg hover:underline hover:text-accent-foreground"
|
||||
>
|
||||
{article.title}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-3 text-xs font-body text-muted-foreground mt-2 flex-wrap">
|
||||
<span>Views: {article.views}</span>
|
||||
<span>•</span>
|
||||
<span>Shares: {totalShares}</span>
|
||||
<span>•</span>
|
||||
<span>Share Rate: {shareRate}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs">
|
||||
<div className="flex items-center gap-4 mt-2 text-xs flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
Facebook: {article.facebookShares || 0}
|
||||
<span className="w-2 h-2 bg-blue-600"></span>
|
||||
FB: {article.facebookShares || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-sky-500 rounded-full"></span>
|
||||
Twitter: {article.twitterShares || 0}
|
||||
<span className="w-2 h-2 bg-sky-500"></span>
|
||||
X: {article.twitterShares || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
WhatsApp: {article.whatsappShares || 0}
|
||||
<span className="w-2 h-2 bg-green-500"></span>
|
||||
WA: {article.whatsappShares || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-cyan-500 rounded-full"></span>
|
||||
Telegram: {article.telegramShares || 0}
|
||||
<span className="w-2 h-2 bg-cyan-500"></span>
|
||||
TG: {article.telegramShares || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -577,29 +504,25 @@ export function AdminDashboardComponent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{showConfirmDialog && itemToDelete && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="border-brutal bg-background p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-2xl font-display uppercase mb-3">
|
||||
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
<p className="font-body text-muted-foreground mb-6">
|
||||
{dialogType === 'delete'
|
||||
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
|
||||
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCancelAction} disabled={isProcessing}>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="brutalOutline" onClick={handleCancelAction} disabled={isProcessing}>
|
||||
Откажи
|
||||
</Button>
|
||||
<Button
|
||||
variant={dialogType === 'delete' ? 'destructive' : 'default'}
|
||||
variant={dialogType === 'delete' ? 'destructive' : 'brutal'}
|
||||
onClick={handleConfirmAction}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
|
||||
@ -38,7 +38,8 @@ export function LiveBlogsComponent() {
|
||||
{data?.data.map((liveBlog) => (
|
||||
<Link
|
||||
key={liveBlog.id}
|
||||
to={`/live-blogs/${liveBlog.slug}`}
|
||||
to="/live-blogs/$slug"
|
||||
params={{ slug: liveBlog.slug }}
|
||||
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">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { Article } from '@/lib/api';
|
||||
import type { Article } from '@/lib/api';
|
||||
|
||||
interface SocialMetaTagsProps {
|
||||
article: Article;
|
||||
|
||||
@ -1,25 +1,25 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-body text-sm font-medium uppercase tracking-wider ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border-2 border-foreground bg-background hover:bg-accent hover:text-accent-foreground hover:border-accent",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
brutal: "border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent shadow-brutal-sm",
|
||||
brutalAccent: "border-2 border-accent bg-accent text-foreground hover:bg-foreground hover:text-accent shadow-brutal-accent",
|
||||
brutalOutline: "border-2 border-foreground bg-transparent hover:bg-foreground hover:text-accent",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
default: "h-11 px-6 py-2",
|
||||
sm: "h-9 px-4 py-1",
|
||||
lg: "h-14 px-8 text-base",
|
||||
icon: "h-12 w-12",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"border-brutal bg-card text-card-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -22,7 +22,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
className={cn("flex flex-col space-y-1.5 p-6 border-b-2 border-foreground/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
"text-2xl font-display leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -49,7 +49,7 @@ const CardDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-sm font-body text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@ -69,7 +69,7 @@ const CardFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
className={cn("flex items-center p-6 pt-0 border-t-2 border-foreground/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@ -1,51 +1,51 @@
|
||||
/* @tailwind base; */
|
||||
/* @tailwind components; */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
--background: 35 20% 95%;
|
||||
--foreground: 0 0% 4%;
|
||||
--card: 35 20% 95%;
|
||||
--card-foreground: 0 0% 4%;
|
||||
--popover: 35 20% 95%;
|
||||
--popover-foreground: 0 0% 4%;
|
||||
--primary: 0 0% 4%;
|
||||
--primary-foreground: 35 20% 95%;
|
||||
--secondary: 35 15% 88%;
|
||||
--secondary-foreground: 0 0% 4%;
|
||||
--muted: 35 10% 90%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
--accent: 70 100% 50%;
|
||||
--accent-foreground: 0 0% 4%;
|
||||
--destructive: 0 80% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 4%;
|
||||
--input: 0 0% 4%;
|
||||
--ring: 70 100% 50%;
|
||||
--radius: 0px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--background: 0 0% 8%;
|
||||
--foreground: 35 20% 95%;
|
||||
--card: 0 0% 12%;
|
||||
--card-foreground: 35 20% 95%;
|
||||
--popover: 0 0% 12%;
|
||||
--popover-foreground: 35 20% 95%;
|
||||
--primary: 35 20% 95%;
|
||||
--primary-foreground: 0 0% 8%;
|
||||
--secondary: 0 0% 15%;
|
||||
--secondary-foreground: 35 20% 95%;
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 0 0% 60%;
|
||||
--accent: 70 100% 50%;
|
||||
--accent-foreground: 0 0% 8%;
|
||||
--destructive: 0 80% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 35 20% 95%;
|
||||
--input: 35 20% 95%;
|
||||
--ring: 70 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -73,6 +73,18 @@ export interface Article {
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isHero?: boolean;
|
||||
isPinned?: boolean;
|
||||
facebookShares?: number;
|
||||
twitterShares?: number;
|
||||
whatsappShares?: number;
|
||||
telegramShares?: number;
|
||||
ogTitle?: string;
|
||||
ogDescription?: string;
|
||||
ogImage?: string;
|
||||
twitterTitle?: string;
|
||||
twitterDescription?: string;
|
||||
twitterImage?: string;
|
||||
}
|
||||
|
||||
export interface ArticlesResponse {
|
||||
@ -126,6 +138,7 @@ export interface UpdateArticleDto {
|
||||
videoUrl?: string;
|
||||
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
|
||||
videoCaption?: string;
|
||||
isHero?: boolean;
|
||||
}
|
||||
|
||||
export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> {
|
||||
@ -360,8 +373,12 @@ export async function fetchLiveBlogs(params: FindLiveBlogsParams = {}): Promise<
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchLiveBlogBySlug(slug: string): Promise<LiveBlog> {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/slug/${slug}`);
|
||||
export async function fetchLiveBlogBySlug(slugOrId: string): Promise<LiveBlog> {
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(slugOrId);
|
||||
const endpoint = isUuid
|
||||
? `${API_BASE_URL}/live-blogs/${slugOrId}`
|
||||
: `${API_BASE_URL}/live-blogs/slug/${slugOrId}`;
|
||||
const response = await authFetch(endpoint);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch live blog');
|
||||
}
|
||||
@ -616,6 +633,7 @@ export interface FindCommentsParams {
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
parentCommentId?: string;
|
||||
parentId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
@ -663,15 +681,20 @@ interface BackendComment {
|
||||
// Recursive function to map comment and its replies
|
||||
function mapBackendComment(comment: BackendComment): Comment {
|
||||
const mappedComment: Comment = {
|
||||
...comment,
|
||||
id: comment.id,
|
||||
content: comment.content,
|
||||
articleId: comment.articleId,
|
||||
liveBlogId: comment.liveBlogId,
|
||||
parentCommentId: comment.parentId,
|
||||
// Ensure reactions object exists
|
||||
userId: comment.userId,
|
||||
isVisible: comment.isVisible,
|
||||
reactions: {
|
||||
likes: comment.likeCount || 0,
|
||||
dislikes: comment.dislikeCount || 0,
|
||||
},
|
||||
// Recursively map replies if they exist
|
||||
replies: comment.replies?.map(mapBackendComment) || [],
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
};
|
||||
return mappedComment;
|
||||
}
|
||||
|
||||
@ -11,19 +11,20 @@ import { AuthPage } from './components/routes/AuthPage'
|
||||
import { SportComponent } from './components/routes/SportComponent'
|
||||
import { ArtComponent } from './components/routes/ArtComponent'
|
||||
import { ScienceComponent } from './components/routes/ScienceComponent'
|
||||
import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker'
|
||||
import { ProtectedRoute } from './components/auth/ProtectedRoute'
|
||||
import { Header } from './components/layout/Header'
|
||||
import { HeroArticle } from './components/home/HeroArticle'
|
||||
import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar'
|
||||
import { LatestArticlesGrid } from './components/home/LatestArticlesGrid'
|
||||
import { Button } from './components/ui/button'
|
||||
import { Zap, Search, Users } from 'lucide-react'
|
||||
import './styles.css'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: 'Placebo.mk - Sarcastic News from Macedonia',
|
||||
title: 'Placebo.mk - Сатирични вести од Македонија',
|
||||
description: 'Latest news and articles from Macedonia with a sarcastic twist',
|
||||
},
|
||||
],
|
||||
@ -31,14 +32,48 @@ const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col">
|
||||
<Header />
|
||||
<ArticleTicker />
|
||||
|
||||
<main className="flex-1 container mx-auto max-w-6xl px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className="border-t mt-12">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
© 2025 Placebo.mk. Sarcastic news from Macedonia.
|
||||
<footer className="border-t-4 border-foreground bg-foreground text-background">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-12">
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h3 className="font-display text-3xl mb-4">Placebo.mk</h3>
|
||||
<p className="font-body text-sm text-background/70">
|
||||
Непристојни сатрирични вести и коментари за локални и глобални настани во Македонија.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-body text-sm font-bold uppercase tracking-wider mb-4 text-accent">Категории</h4>
|
||||
<ul className="space-y-2 font-body text-sm">
|
||||
<li><Link to="/sport" className="hover:text-accent transition-colors">Спорт</Link></li>
|
||||
<li><Link to="/art" className="hover:text-accent transition-colors">Уметност</Link></li>
|
||||
<li><Link to="/science" className="hover:text-accent transition-colors">Наука</Link></li>
|
||||
<li><Link to="/archive" className="hover:text-accent transition-colors">Архива</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-body text-sm font-bold uppercase tracking-wider mb-4 text-accent">Следете не</h4>
|
||||
<div className="flex gap-4">
|
||||
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
|
||||
<Zap className="w-5 h-5" />
|
||||
</a>
|
||||
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
|
||||
<Search className="w-5 h-5" />
|
||||
</a>
|
||||
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
|
||||
<Users className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 pt-8 border-t border-background/20 text-center font-body text-xs uppercase tracking-wider">
|
||||
© 2025 Placebo.mk — Сите права се заштитени. Или не се.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@ -49,129 +84,74 @@ const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: () => (
|
||||
<div className="space-y-6">
|
||||
{/* Article Ticker at the top */}
|
||||
<ArticleTicker />
|
||||
|
||||
{/* Live Blog Ticker below article ticker */}
|
||||
<LiveBlogTicker className="mt-4" />
|
||||
|
||||
<div>
|
||||
<div className="py-8 md:py-12">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Hero Section with Pinned Live Blogs Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
|
||||
{/* Hero Article - 2/3 width */}
|
||||
<div className="lg:col-span-2">
|
||||
<HeroArticle />
|
||||
</div>
|
||||
|
||||
{/* Pinned Live Blogs Sidebar - 1/3 width */}
|
||||
<div className="lg:col-span-1">
|
||||
<PinnedLiveBlogsSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latest Articles Grid - 4x3 */}
|
||||
<div className="mb-12">
|
||||
<LatestArticlesGrid />
|
||||
</div>
|
||||
|
||||
{/* Brand Introduction */}
|
||||
<div className="rounded-xl border bg-card p-8 text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" x2="8" y1="13" y2="13" />
|
||||
<line x1="16" x2="8" y1="17" y2="17" />
|
||||
<line x1="10" x2="8" y1="9" y2="9" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
|
||||
Placebo<span className="text-primary">.mk</span>
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Unapologetically sarcastic news and commentary on local and global affairs in Macedonia. Because sometimes the truth hurts more than fiction.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features grid */}
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-12">
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
|
||||
<path d="M18 14h-8" />
|
||||
<path d="M15 18h-5" />
|
||||
<path d="M10 6h8v4h-8V6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Latest Articles</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Freshly brewed sarcasm on current events, politics, and everything in between.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Filter</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Live Coverage</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Real-time updates on breaking news with our live blogging system. No delays, just facts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Call to action */}
|
||||
<div className="rounded-xl border bg-card p-8 text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">Ready for some unfiltered truth?</h2>
|
||||
<p className="text-muted-foreground mb-6 max-w-2xl mx-auto">
|
||||
Dive into our articles or follow live coverage of breaking news as it happens.
|
||||
<div className="mt-16 border-4 border-foreground p-8 bg-foreground text-background animate-fade-in-up">
|
||||
<div className="text-center">
|
||||
<h2 className="text-4xl md:text-6xl font-display mb-4">Placebo.mk</h2>
|
||||
<p className="font-body text-lg max-w-2xl mx-auto text-background/80 mb-8">
|
||||
Непристојно сатрирични вести и коментари за локални и глобални настани во Македонија.
|
||||
Затоа што понекогаш вистината боли повеќе од фикцијата.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
to="/archive"
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-6 py-2"
|
||||
>
|
||||
<Link to="/archive">
|
||||
<Button variant="brutalAccent" className="gap-2">
|
||||
Прелистај архива
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
to="/live-blogs"
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-6 py-2"
|
||||
>
|
||||
View Live Blogs
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<Link to="/live-blogs">
|
||||
<Button variant="brutalOutline" className="gap-2 text-background border-background hover:bg-background hover:text-foreground">
|
||||
<Zap className="w-4 h-4" />
|
||||
Live Блогови
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 mt-16">
|
||||
<div className="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-1">
|
||||
<div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
|
||||
<Zap className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-display mb-2">Најнови вести</h3>
|
||||
<p className="font-body text-sm text-muted-foreground">
|
||||
Свежо подготвена сатира за тековни настани, политика и сè помеѓу тоа.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-2">
|
||||
<div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
|
||||
<span className="font-display text-2xl">⚠</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-display mb-2">Без филтер</h3>
|
||||
<p className="font-body text-sm text-muted-foreground">
|
||||
Не правиме нијанси. Не правиме дипломатски јазик. Само искрени (и малку лоши) коментари.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-3">
|
||||
<div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
|
||||
<Users className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-display mb-2">Live Покривање</h3>
|
||||
<p className="font-body text-sm text-muted-foreground">
|
||||
Ажурирања во реално време за разбивачки вести со нашиот систем за live blogging. Нема одложувања, само факти.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@ -209,7 +189,6 @@ const articleDetailRoute = createRoute({
|
||||
return <ArticleDetailComponent id={id} />
|
||||
},
|
||||
loader: async ({ params }) => {
|
||||
// Fetch article data for meta tags
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/api/v1/articles/${params.id}`)
|
||||
if (!response.ok) {
|
||||
return { article: null }
|
||||
@ -229,7 +208,6 @@ const articleDetailRoute = createRoute({
|
||||
}
|
||||
}
|
||||
|
||||
// Use article's social metadata if available, otherwise generate from article data
|
||||
const ogTitle = article.ogTitle || article.title
|
||||
const ogDescription = article.ogDescription || article.excerpt || 'Latest news from Placebo.mk'
|
||||
const ogImage = article.ogImage || article.featuredImage || '/placeholder-image.jpg'
|
||||
@ -238,37 +216,28 @@ const articleDetailRoute = createRoute({
|
||||
const twitterImage = article.twitterImage || article.featuredImage || '/placeholder-image.jpg'
|
||||
|
||||
const metaTags = [
|
||||
// Basic SEO
|
||||
{ title: `${article.title} - Placebo.mk` },
|
||||
{ name: 'description', content: ogDescription },
|
||||
|
||||
// Open Graph tags
|
||||
{ property: 'og:title', content: ogTitle },
|
||||
{ property: 'og:description', content: ogDescription },
|
||||
{ property: 'og:image', content: ogImage },
|
||||
{ property: 'og:url', content: typeof window !== 'undefined' ? window.location.href : '' },
|
||||
{ property: 'og:type', content: 'article' },
|
||||
{ property: 'og:locale', content: 'mk_MK' },
|
||||
|
||||
// Twitter Card tags
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
{ name: 'twitter:title', content: twitterTitle },
|
||||
{ name: 'twitter:description', content: twitterDescription },
|
||||
{ name: 'twitter:image', content: twitterImage },
|
||||
|
||||
// Article-specific tags
|
||||
{ property: 'article:published_time', content: article.createdAt },
|
||||
{ property: 'article:modified_time', content: article.updatedAt },
|
||||
]
|
||||
|
||||
// Add author if available
|
||||
if (article.author?.name) {
|
||||
metaTags.push({ property: 'article:author', content: article.author.name })
|
||||
}
|
||||
|
||||
// Add tags if available
|
||||
if (article.tags && article.tags.length > 0) {
|
||||
article.tags.forEach(tag => {
|
||||
article.tags.forEach((tag: string) => {
|
||||
metaTags.push({ property: 'article:tag', content: tag })
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,72 +1,162 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap');
|
||||
|
||||
@theme {
|
||||
--color-primary: oklch(0.647 0.22 0.23);
|
||||
--color-primary-foreground: oklch(0.985 0.002 0);
|
||||
--color-secondary: oklch(0.97 0.002 0);
|
||||
--color-secondary-foreground: oklch(0.205 0.02 266.5);
|
||||
--color-muted: oklch(0.97 0 0);
|
||||
--color-muted-foreground: oklch(0.556 0 0);
|
||||
--color-accent: oklch(0.97 0 0);
|
||||
--color-accent-foreground: oklch(0.205 0.02 266.5);
|
||||
--color-primary: oklch(0.08 0 0);
|
||||
--color-primary-foreground: oklch(0.98 0 0);
|
||||
--color-secondary: oklch(0.93 0.02 50);
|
||||
--color-secondary-foreground: oklch(0.08 0 0);
|
||||
--color-muted: oklch(0.91 0.01 50);
|
||||
--color-muted-foreground: oklch(0.45 0.01 50);
|
||||
--color-accent: oklch(0.93 0.8 100);
|
||||
--color-accent-foreground: oklch(0.08 0 0);
|
||||
--color-destructive: oklch(0.55 0.22 25);
|
||||
--color-destructive-foreground: oklch(0.985 0.002 0);
|
||||
--color-border: oklch(0.9 0 0);
|
||||
--color-input: oklch(0.9 0 0);
|
||||
--color-ring: oklch(0.647 0.22 0.23);
|
||||
--radius: 0.5rem;
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--color-destructive-foreground: oklch(0.98 0 0);
|
||||
--color-border: oklch(0.08 0 0);
|
||||
--color-input: oklch(0.08 0 0);
|
||||
--color-ring: oklch(0.93 0.8 100);
|
||||
--radius: 0px;
|
||||
--font-display: "Bebas Neue", sans-serif;
|
||||
--font-body: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
--background: 35 20% 95%;
|
||||
--foreground: 0 0% 4%;
|
||||
--card: 35 20% 95%;
|
||||
--card-foreground: 0 0% 4%;
|
||||
--popover: 35 20% 95%;
|
||||
--popover-foreground: 0 0% 4%;
|
||||
--primary: 0 0% 4%;
|
||||
--primary-foreground: 35 20% 95%;
|
||||
--secondary: 35 15% 88%;
|
||||
--secondary-foreground: 0 0% 4%;
|
||||
--muted: 35 10% 90%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
--accent: 70 100% 50%;
|
||||
--accent-foreground: 0 0% 4%;
|
||||
--destructive: 0 80% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 4%;
|
||||
--input: 0 0% 4%;
|
||||
--ring: 70 100% 50%;
|
||||
--radius: 0px;
|
||||
|
||||
--shadow-brutal: 4px 4px 0px 0px oklch(0.08 0 0);
|
||||
--shadow-brutal-sm: 2px 2px 0px 0px oklch(0.08 0 0);
|
||||
--shadow-brutal-lg: 6px 6px 0px 0px oklch(0.08 0 0);
|
||||
--shadow-brutal-accent: 4px 4px 0px 0px oklch(0.93 0.8 100);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--background: 0 0% 8%;
|
||||
--foreground: 35 20% 95%;
|
||||
--card: 0 0% 12%;
|
||||
--card-foreground: 35 20% 95%;
|
||||
--popover: 0 0% 12%;
|
||||
--popover-foreground: 35 20% 95%;
|
||||
--primary: 35 20% 95%;
|
||||
--primary-foreground: 0 0% 8%;
|
||||
--secondary: 0 0% 15%;
|
||||
--secondary-foreground: 35 20% 95%;
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 0 0% 60%;
|
||||
--accent: 70 100% 50%;
|
||||
--accent-foreground: 0 0% 8%;
|
||||
--destructive: 0 80% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 35 20% 95%;
|
||||
--input: 35 20% 95%;
|
||||
--ring: 70 100% 50%;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: var(--font-body);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
0%, 100% {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
50% {
|
||||
border-color: hsl(var(--accent));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
@ -76,18 +166,185 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
@keyframes glitch {
|
||||
0% {
|
||||
clip-path: inset(40% 0 61% 0);
|
||||
transform: translate(-2px, 2px);
|
||||
}
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
20% {
|
||||
clip-path: inset(92% 0 1% 0);
|
||||
transform: translate(1px, -1px);
|
||||
}
|
||||
40% {
|
||||
clip-path: inset(43% 0 1% 0);
|
||||
transform: translate(-1px, 2px);
|
||||
}
|
||||
60% {
|
||||
clip-path: inset(25% 0 58% 0);
|
||||
transform: translate(2px, 1px);
|
||||
}
|
||||
80% {
|
||||
clip-path: inset(54% 0 7% 0);
|
||||
transform: translate(-2px, -1px);
|
||||
}
|
||||
100% {
|
||||
clip-path: inset(58% 0 43% 0);
|
||||
transform: translate(2px, 2px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: slideInLeft 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.4s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
display: flex;
|
||||
animation: marquee 30s linear infinite;
|
||||
animation: marquee 25s linear infinite;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.stagger-1 { animation-delay: 0.05s; }
|
||||
.stagger-2 { animation-delay: 0.1s; }
|
||||
.stagger-3 { animation-delay: 0.15s; }
|
||||
.stagger-4 { animation-delay: 0.2s; }
|
||||
.stagger-5 { animation-delay: 0.25s; }
|
||||
.stagger-6 { animation-delay: 0.3s; }
|
||||
.stagger-7 { animation-delay: 0.35s; }
|
||||
.stagger-8 { animation-delay: 0.4s; }
|
||||
.stagger-9 { animation-delay: 0.45s; }
|
||||
.stagger-10 { animation-delay: 0.5s; }
|
||||
.stagger-11 { animation-delay: 0.55s; }
|
||||
.stagger-12 { animation-delay: 0.6s; }
|
||||
|
||||
.font-display {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.font-body {
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.border-brutal {
|
||||
border: 3px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.border-brutal-sm {
|
||||
border: 2px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.border-brutal-accent {
|
||||
border: 3px solid hsl(var(--accent));
|
||||
border-left-width: 6px;
|
||||
}
|
||||
|
||||
.shadow-brutal {
|
||||
box-shadow: var(--shadow-brutal);
|
||||
}
|
||||
|
||||
.shadow-brutal-sm {
|
||||
box-shadow: var(--shadow-brutal-sm);
|
||||
}
|
||||
|
||||
.shadow-brutal-lg {
|
||||
box-shadow: var(--shadow-brutal-lg);
|
||||
}
|
||||
|
||||
.shadow-brutal-accent {
|
||||
box-shadow: var(--shadow-brutal-accent);
|
||||
}
|
||||
|
||||
.hover\:shadow-brutal:hover {
|
||||
box-shadow: var(--shadow-brutal);
|
||||
transform: translate(-2px, -2px);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.hover\:shadow-brutal-accent:hover {
|
||||
box-shadow: var(--shadow-brutal-accent);
|
||||
transform: translate(-2px, -2px);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.bg-noise {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bg-noise::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.text-outline {
|
||||
-webkit-text-stroke: 1px hsl(var(--foreground));
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.text-outline-hover:hover {
|
||||
-webkit-text-stroke: 1px hsl(var(--foreground));
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border));
|
||||
border: 2px solid hsl(var(--secondary));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user