Merge branch 'redesignNo1'

This commit is contained in:
echo 2026-02-16 20:45:29 +01:00
commit c2c77ac92a
17 changed files with 1199 additions and 1030 deletions

View File

@ -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;
}

View File

@ -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>
)
}

View File

@ -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)}%`
}}

View File

@ -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 ? (

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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}
>
@ -613,4 +536,4 @@ export function AdminDashboardComponent() {
)}
</div>
);
}
}

View File

@ -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">

View File

@ -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;

View File

@ -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: {

View File

@ -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}
/>
))

View File

@ -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%;
}
}

View File

@ -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;
}

View File

@ -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 })
})
}

View File

@ -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 @@
}
}
@keyframes glitch {
0% {
clip-path: inset(40% 0 61% 0);
transform: translate(-2px, 2px);
}
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 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));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
.animate-marquee {
display: flex;
animation: marquee 30s linear infinite;
width: max-content;
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));
}