auth checkpoint
auth dependecies not instaled in dev container
This commit is contained in:
1 parent
7378d37b36
commit
42002f8e6f
50 files changed
+3680
-636
No files matched your search
@@ -0,0 +1,94 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Label } from '../ui/label';
|
||||
|
||||
interface LoginFormProps {
|
||||
onSuccess?: () => void;
|
||||
onSwitchToRegister?: () => void;
|
||||
}
|
||||
|
||||
export function LoginForm({ onSuccess, onSwitchToRegister }: LoginFormProps) {
|
||||
const { login, isLoading } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your credentials to access your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</Button>
|
||||
|
||||
{onSwitchToRegister && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToRegister}
|
||||
className="text-primary hover:underline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Register here
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from '@tanstack/react-router';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requiredRole?: 'admin' | 'contributor' | 'user';
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({
|
||||
children,
|
||||
requiredRole,
|
||||
redirectTo = '/'
|
||||
}: ProtectedRouteProps) {
|
||||
const { isAuthenticated, user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="mt-4 text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={redirectTo} />;
|
||||
}
|
||||
|
||||
if (requiredRole && user?.role !== requiredRole) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Label } from '../ui/label';
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSuccess?: () => void;
|
||||
onSwitchToLogin?: () => void;
|
||||
}
|
||||
|
||||
export function RegisterForm({ onSuccess, onSwitchToLogin }: RegisterFormProps) {
|
||||
const { register, isLoading } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await register(username, email, password);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Register</CardTitle>
|
||||
<CardDescription>
|
||||
Create a new account to start commenting and reacting
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Choose a username"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Choose a password (min 6 characters)"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Creating account...' : 'Register'}
|
||||
</Button>
|
||||
|
||||
{onSwitchToLogin && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToLogin}
|
||||
className="text-primary hover:underline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Login here
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { useComments, useCreateComment } from '../../../queries/comments';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Textarea } from '../../ui/textarea';
|
||||
import { Card, CardContent } from '../../ui/card';
|
||||
import { format } from 'date-fns';
|
||||
import { mk } from 'date-fns/locale';
|
||||
|
||||
interface CommentSectionProps {
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
}
|
||||
|
||||
export function CommentSection({ articleId, liveBlogId }: CommentSectionProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { data: commentsData, isLoading } = useComments({
|
||||
articleId,
|
||||
liveBlogId,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const createCommentMutation = useCreateComment();
|
||||
|
||||
const comments = commentsData?.data || [];
|
||||
|
||||
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!newComment.trim() || !isAuthenticated) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createCommentMutation.mutateAsync({
|
||||
content: newComment,
|
||||
articleId,
|
||||
liveBlogId,
|
||||
});
|
||||
setNewComment('');
|
||||
} catch (error) {
|
||||
console.error('Failed to post comment:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-12 pt-8 border-t">
|
||||
<h2 className="text-2xl font-bold mb-6">Коментари</h2>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<Card className="mb-8">
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSubmitComment}>
|
||||
<Textarea
|
||||
placeholder="Што мислите за овој напис?"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
className="min-h-[100px] mb-4"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!newComment.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Поставување...' : 'Постави коментар'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="mb-8">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Најавете се за да можете да коментирате
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a href="/auth">Најави се</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : comments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Сè уште нема коментари. Бидете првиот што ќе коментира!
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{comments.map((comment) => (
|
||||
<Card key={comment.id}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{comment.user?.username || 'Анонимен корисник'}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{format(new Date(comment.createdAt), 'dd MMMM yyyy, HH:mm', { locale: mk })}
|
||||
</div>
|
||||
</div>
|
||||
{comment.user?.role === 'admin' && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary">
|
||||
Администратор
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{comment.content}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { useReactionCounts, useUserReaction, useAddReaction } from '../../../queries/comments';
|
||||
import { Button } from '../../ui/button';
|
||||
import { ThumbsUp, ThumbsDown } from 'lucide-react';
|
||||
|
||||
interface ReactionButtonsProps {
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
commentId?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ReactionButtons({
|
||||
articleId,
|
||||
liveBlogId,
|
||||
commentId,
|
||||
compact = false
|
||||
}: ReactionButtonsProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: counts } = useReactionCounts(articleId, liveBlogId, commentId);
|
||||
const { data: userReaction } = useUserReaction(articleId, liveBlogId, commentId);
|
||||
const addReactionMutation = useAddReaction();
|
||||
|
||||
const userReactionType = userReaction?.type;
|
||||
|
||||
const handleReaction = async (type: 'like' | 'dislike') => {
|
||||
if (!isAuthenticated) {
|
||||
window.location.href = '/auth';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await addReactionMutation.mutateAsync({
|
||||
type,
|
||||
articleId,
|
||||
liveBlogId,
|
||||
commentId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to add reaction:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const likes = counts?.likes || 0;
|
||||
const dislikes = counts?.dislikes || 0;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleReaction('like')}
|
||||
className={`h-8 px-2 ${userReactionType === 'like' ? 'text-primary' : ''}`}
|
||||
>
|
||||
<ThumbsUp className="w-4 h-4 mr-1" />
|
||||
{likes > 0 && <span>{likes}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleReaction('dislike')}
|
||||
className={`h-8 px-2 ${userReactionType === 'dislike' ? 'text-destructive' : ''}`}
|
||||
>
|
||||
<ThumbsDown className="w-4 h-4 mr-1" />
|
||||
{dislikes > 0 && <span>{dislikes}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={userReactionType === 'like' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleReaction('like')}
|
||||
className="gap-2"
|
||||
>
|
||||
<ThumbsUp className="w-4 h-4" />
|
||||
<span>Допаѓа ми</span>
|
||||
{likes > 0 && (
|
||||
<span className="ml-1 bg-primary/20 px-2 py-0.5 rounded-full text-xs">
|
||||
{likes}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={userReactionType === 'dislike' ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleReaction('dislike')}
|
||||
className="gap-2"
|
||||
>
|
||||
<ThumbsDown className="w-4 h-4" />
|
||||
<span>Не ми се допаѓа</span>
|
||||
{dislikes > 0 && (
|
||||
<span className="ml-1 bg-destructive/20 px-2 py-0.5 rounded-full text-xs">
|
||||
{dislikes}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export function Header() {
|
||||
const { user, logout, isAuthenticated, hasRole } = useAuth();
|
||||
|
||||
return (
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">
|
||||
<Link to="/" className="hover:underline">Placebo.mk</Link>
|
||||
</h1>
|
||||
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link to="/" className="text-sm font-medium hover:underline">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/articles" className="text-sm font-medium hover:underline">
|
||||
Articles
|
||||
</Link>
|
||||
<Link to="/live-blogs" className="text-sm font-medium hover:underline">
|
||||
Live
|
||||
</Link>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
{(hasRole('admin') || hasRole('contributor')) && (
|
||||
<>
|
||||
<Link to="/admin" className="text-sm font-medium hover:underline text-primary">
|
||||
Admin
|
||||
</Link>
|
||||
<Link to="/admin/live-blogs/create" className="text-sm font-medium hover:underline text-primary">
|
||||
+ New Live Blog
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{user?.username}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={logout}
|
||||
className="text-xs"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/auth" className="text-sm font-medium hover:underline text-primary">
|
||||
Login / Register
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { YouTubeEmbed } from '@/components/ui/youtube-embed'
|
||||
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils'
|
||||
import { CommentSection } from '@/components/features/comments/CommentSection'
|
||||
import { ReactionButtons } from '@/components/features/comments/ReactionButtons'
|
||||
|
||||
export function ArticleDetailComponent({ id }: { id: string }) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@@ -181,6 +183,17 @@ export function ArticleDetailComponent({ id }: { id: string }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
<div className="mt-8 pt-8 border-t">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold">Што мислите за овој напис?</h3>
|
||||
<ReactionButtons articleId={data.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<CommentSection articleId={data.id} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useState } from 'react';
|
||||
import { LoginForm } from '../auth/LoginForm';
|
||||
import { RegisterForm } from '../auth/RegisterForm';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Navigate } from '@tanstack/react-router';
|
||||
|
||||
export function AuthPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">
|
||||
Welcome to <span className="text-primary">Placebo.mk</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{isLogin
|
||||
? 'Login to comment, react, and access admin features'
|
||||
: 'Join our community of sarcastic news enthusiasts'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLogin ? (
|
||||
<LoginForm
|
||||
onSuccess={() => window.location.href = '/'}
|
||||
onSwitchToRegister={() => setIsLogin(false)}
|
||||
/>
|
||||
) : (
|
||||
<RegisterForm
|
||||
onSuccess={() => window.location.href = '/'}
|
||||
onSwitchToLogin={() => setIsLogin(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-8 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
By {isLogin ? 'logging in' : 'registering'}, you agree to our{' '}
|
||||
<a href="#" className="text-primary hover:underline">Terms of Service</a>{' '}
|
||||
and{' '}
|
||||
<a href="#" className="text-primary hover:underline">Privacy Policy</a>.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Need help?{' '}
|
||||
<a href="mailto:support@placebo.mk" className="text-primary hover:underline">
|
||||
Contact support
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import * as api from '../lib/api';
|
||||
import { User } from '@/types';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
register: (username: string, email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
hasRole: (role: string) => boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeAuth = () => {
|
||||
const storedToken = localStorage.getItem('token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
try {
|
||||
setToken(storedToken);
|
||||
setUser(JSON.parse(storedUser));
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored user:', error);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
const response = await api.login({ username, password });
|
||||
localStorage.setItem('token', response.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(response.user));
|
||||
setToken(response.access_token);
|
||||
setUser(response.user);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const register = async (username: string, email: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
const response = await api.register({ username, email, password });
|
||||
localStorage.setItem('token', response.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(response.user));
|
||||
setToken(response.access_token);
|
||||
setUser(response.user);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
api.logout();
|
||||
};
|
||||
|
||||
const isAuthenticated = !!user && !!token;
|
||||
|
||||
const hasRole = (role: string) => {
|
||||
if (!user) return false;
|
||||
return user.role === role;
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
token,
|
||||
isLoading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
isAuthenticated,
|
||||
hasRole,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
+279
-41
@@ -4,6 +4,42 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/
|
||||
console.log('API_BASE_URL:', API_BASE_URL);
|
||||
console.log('VITE_API_URL env:', import.meta.env.VITE_API_URL);
|
||||
|
||||
// Helper function to get auth headers
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Enhanced fetch wrapper
|
||||
async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const headers = getAuthHeaders();
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle 401 unauthorized
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/auth';
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -110,7 +146,7 @@ export async function fetchArticles(params: FindArticlesParams = {}): Promise<Ar
|
||||
const url = `${API_BASE_URL}/articles?${searchParams}`;
|
||||
console.log('Fetching from:', url);
|
||||
|
||||
const response = await fetch(url);
|
||||
const response = await authFetch(url);
|
||||
console.log('Response status:', response.status, 'ok:', response.ok);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -123,7 +159,7 @@ export async function fetchArticles(params: FindArticlesParams = {}): Promise<Ar
|
||||
}
|
||||
|
||||
export async function fetchArticleBySlug(slug: string): Promise<Article> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/slug/${slug}`);
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/slug/${slug}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch article');
|
||||
}
|
||||
@@ -131,7 +167,7 @@ export async function fetchArticleBySlug(slug: string): Promise<Article> {
|
||||
}
|
||||
|
||||
export async function fetchArticleById(id: string): Promise<Article> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/${id}`);
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch article');
|
||||
}
|
||||
@@ -139,11 +175,8 @@ export async function fetchArticleById(id: string): Promise<Article> {
|
||||
}
|
||||
|
||||
export async function createArticle(dto: CreateArticleDto): Promise<Article> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -153,11 +186,8 @@ export async function createArticle(dto: CreateArticleDto): Promise<Article> {
|
||||
}
|
||||
|
||||
export async function updateArticle(id: string, dto: UpdateArticleDto): Promise<Article> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/${id}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -167,7 +197,7 @@ export async function updateArticle(id: string, dto: UpdateArticleDto): Promise<
|
||||
}
|
||||
|
||||
export async function deleteArticle(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/${id}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -176,7 +206,7 @@ export async function deleteArticle(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function archiveArticle(id: string): Promise<Article> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/${id}/archive`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/${id}/archive`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -186,7 +216,7 @@ export async function archiveArticle(id: string): Promise<Article> {
|
||||
}
|
||||
|
||||
export async function publishArticle(id: string, status: 'draft' | 'published' = 'published'): Promise<Article> {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/${id}/publish?status=${status}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/articles/${id}/publish?status=${status}`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -323,7 +353,7 @@ export async function fetchLiveBlogs(params: FindLiveBlogsParams = {}): Promise<
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs?${searchParams}`);
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs?${searchParams}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch live blogs');
|
||||
}
|
||||
@@ -331,7 +361,7 @@ export async function fetchLiveBlogs(params: FindLiveBlogsParams = {}): Promise<
|
||||
}
|
||||
|
||||
export async function fetchLiveBlogBySlug(slug: string): Promise<LiveBlog> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/slug/${slug}`);
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/slug/${slug}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch live blog');
|
||||
}
|
||||
@@ -339,7 +369,7 @@ export async function fetchLiveBlogBySlug(slug: string): Promise<LiveBlog> {
|
||||
}
|
||||
|
||||
export async function fetchLiveBlogById(id: string): Promise<LiveBlog> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`);
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch live blog');
|
||||
}
|
||||
@@ -351,7 +381,7 @@ export async function fetchLiveBlogUpdates(
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<LiveBlogUpdatesResponse> {
|
||||
const response = await fetch(
|
||||
const response = await authFetch(
|
||||
`${API_BASE_URL}/live-blogs/${liveBlogId}/updates?page=${page}&limit=${limit}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -361,7 +391,7 @@ export async function fetchLiveBlogUpdates(
|
||||
}
|
||||
|
||||
export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/recent`);
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/recent`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch recent live blogs');
|
||||
}
|
||||
@@ -369,7 +399,7 @@ export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> {
|
||||
}
|
||||
|
||||
export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/featured`);
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/featured`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch pinned live blogs');
|
||||
}
|
||||
@@ -377,7 +407,7 @@ export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> {
|
||||
}
|
||||
|
||||
export async function fetchActiveLiveBlogs(): Promise<LiveBlog[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/active`);
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/active`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch active live blogs');
|
||||
}
|
||||
@@ -389,11 +419,8 @@ export async function createLiveBlogUpdate(
|
||||
liveBlogId: string,
|
||||
dto: CreateLiveBlogUpdateDto
|
||||
): Promise<LiveBlogUpdate> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -407,11 +434,8 @@ export async function updateLiveBlogUpdate(
|
||||
updateId: string,
|
||||
dto: UpdateLiveBlogUpdateDto
|
||||
): Promise<LiveBlogUpdate> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -421,7 +445,7 @@ export async function updateLiveBlogUpdate(
|
||||
}
|
||||
|
||||
export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -430,11 +454,8 @@ export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string)
|
||||
}
|
||||
|
||||
export async function createLiveBlog(dto: CreateLiveBlogDto): Promise<LiveBlog> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -444,11 +465,8 @@ export async function createLiveBlog(dto: CreateLiveBlogDto): Promise<LiveBlog>
|
||||
}
|
||||
|
||||
export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -458,7 +476,7 @@ export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promis
|
||||
}
|
||||
|
||||
export async function deleteLiveBlog(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -467,7 +485,7 @@ export async function deleteLiveBlog(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function archiveLiveBlog(id: string): Promise<LiveBlog> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/archive`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}/archive`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -477,7 +495,7 @@ export async function archiveLiveBlog(id: string): Promise<LiveBlog> {
|
||||
}
|
||||
|
||||
export async function publishLiveBlog(id: string, status: 'draft' | 'live' | 'ended' = 'draft'): Promise<LiveBlog> {
|
||||
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/publish?status=${status}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}/publish?status=${status}`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -485,3 +503,223 @@ export async function publishLiveBlog(id: string, status: 'draft' | 'live' | 'en
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Auth Types
|
||||
import type { User, LoginDto, RegisterDto, AuthResponse } from '@/types';
|
||||
|
||||
// Auth API Functions
|
||||
export async function login(dto: LoginDto): Promise<AuthResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Login failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function register(dto: RegisterDto): Promise<AuthResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Registration failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getProfile(): Promise<User> {
|
||||
const response = await authFetch(`${API_BASE_URL}/users/profile`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch profile');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
// Comment Types
|
||||
export interface Comment {
|
||||
id: string;
|
||||
content: string;
|
||||
articleId: string | null;
|
||||
liveBlogId: string | null;
|
||||
parentCommentId: string | null;
|
||||
userId: string;
|
||||
isVisible: boolean;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
reactions?: {
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateCommentDto {
|
||||
content: string;
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
parentCommentId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCommentDto {
|
||||
content?: string;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
export interface FindCommentsParams {
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
parentCommentId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface CommentsResponse {
|
||||
data: Comment[];
|
||||
total: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ReactionCounts {
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
}
|
||||
|
||||
export interface CreateReactionDto {
|
||||
type: 'like' | 'dislike';
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
commentId?: string;
|
||||
}
|
||||
|
||||
// Comment API Functions
|
||||
export async function fetchComments(params: FindCommentsParams = {}): Promise<CommentsResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
if (typeof value === 'number') {
|
||||
searchParams.append(key, value.toString());
|
||||
} else {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const response = await authFetch(`${API_BASE_URL}/comments?${searchParams}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch comments');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createComment(dto: CreateCommentDto): Promise<Comment> {
|
||||
const response = await authFetch(`${API_BASE_URL}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create comment');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateComment(id: string, dto: UpdateCommentDto): Promise<Comment> {
|
||||
const response = await authFetch(`${API_BASE_URL}/comments/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update comment');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteComment(id: string): Promise<void> {
|
||||
const response = await authFetch(`${API_BASE_URL}/comments/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete comment');
|
||||
}
|
||||
}
|
||||
|
||||
export async function addReaction(dto: CreateReactionDto): Promise<void> {
|
||||
const response = await authFetch(`${API_BASE_URL}/comments/reactions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add reaction');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReactionCounts(
|
||||
articleId?: string,
|
||||
liveBlogId?: string,
|
||||
commentId?: string
|
||||
): Promise<ReactionCounts> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (articleId) searchParams.append('articleId', articleId);
|
||||
if (liveBlogId) searchParams.append('liveBlogId', liveBlogId);
|
||||
if (commentId) searchParams.append('commentId', commentId);
|
||||
|
||||
const url = `${API_BASE_URL}/comments/reactions/counts${searchParams.toString() ? `?${searchParams}` : ''}`;
|
||||
const response = await authFetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch reaction counts');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getUserReaction(
|
||||
articleId?: string,
|
||||
liveBlogId?: string,
|
||||
commentId?: string
|
||||
): Promise<{ type: string | null }> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (articleId) searchParams.append('articleId', articleId);
|
||||
if (liveBlogId) searchParams.append('liveBlogId', liveBlogId);
|
||||
if (commentId) searchParams.append('commentId', commentId);
|
||||
|
||||
const url = `${API_BASE_URL}/comments/reactions/user${searchParams.toString() ? `?${searchParams}` : ''}`;
|
||||
const response = await authFetch(url);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return { type: null };
|
||||
}
|
||||
throw new Error('Failed to fetch user reaction');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -3,13 +3,16 @@ import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { router } from './routes'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as api from '../lib/api';
|
||||
|
||||
export function useLogin() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ username, password }: { username: string; password: string }) =>
|
||||
api.login({ username, password }),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['auth', 'user'], data.user);
|
||||
queryClient.setQueryData(['auth', 'token'], data.access_token);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegister() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ username, email, password }: { username: string; email: string; password: string }) =>
|
||||
api.register({ username, email, password }),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['auth', 'user'], data.user);
|
||||
queryClient.setQueryData(['auth', 'token'], data.access_token);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfile() {
|
||||
return useQuery({
|
||||
queryKey: ['auth', 'user'],
|
||||
queryFn: api.getProfile,
|
||||
enabled: false, // We'll manually trigger this when needed
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.logout,
|
||||
onSuccess: () => {
|
||||
queryClient.removeQueries({ queryKey: ['auth'] });
|
||||
queryClient.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as api from '../lib/api';
|
||||
|
||||
export function useComments(params: api.FindCommentsParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['comments', params],
|
||||
queryFn: () => api.fetchComments(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateComment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.createComment,
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['comments', { articleId: variables.articleId }]
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['comments', { liveBlogId: variables.liveBlogId }]
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateComment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: api.UpdateCommentDto }) =>
|
||||
api.updateComment(id, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteComment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.deleteComment,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReactionCounts(
|
||||
articleId?: string,
|
||||
liveBlogId?: string,
|
||||
commentId?: string
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['reactions', 'counts', { articleId, liveBlogId, commentId }],
|
||||
queryFn: () => api.getReactionCounts(articleId, liveBlogId, commentId),
|
||||
enabled: !!articleId || !!liveBlogId || !!commentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserReaction(
|
||||
articleId?: string,
|
||||
liveBlogId?: string,
|
||||
commentId?: string
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['reactions', 'user', { articleId, liveBlogId, commentId }],
|
||||
queryFn: () => api.getUserReaction(articleId, liveBlogId, commentId),
|
||||
enabled: !!articleId || !!liveBlogId || !!commentId,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddReaction() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.addReaction,
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate both counts and user reaction queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['reactions', 'counts', {
|
||||
articleId: variables.articleId,
|
||||
liveBlogId: variables.liveBlogId,
|
||||
commentId: variables.commentId
|
||||
}]
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['reactions', 'user', {
|
||||
articleId: variables.articleId,
|
||||
liveBlogId: variables.liveBlogId,
|
||||
commentId: variables.commentId
|
||||
}]
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
+27
-28
@@ -1,4 +1,4 @@
|
||||
import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router'
|
||||
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
|
||||
import { ArticleTicker } from './components/ArticleTicker'
|
||||
import { ArticlesComponent } from './components/routes/ArticlesComponent'
|
||||
import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent'
|
||||
@@ -7,8 +7,11 @@ import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailCompo
|
||||
import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent'
|
||||
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent'
|
||||
import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent'
|
||||
import { AuthPage } from './components/routes/AuthPage'
|
||||
import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker'
|
||||
import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar'
|
||||
import { ProtectedRoute } from './components/auth/ProtectedRoute'
|
||||
import { Header } from './components/layout/Header'
|
||||
import './styles.css'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
@@ -22,30 +25,7 @@ const rootRoute = createRootRoute({
|
||||
}),
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-4">
|
||||
<h1 className="text-3xl font-bold">
|
||||
<Link to="/" className="hover:underline">Placebo.mk</Link>
|
||||
</h1>
|
||||
<nav className="flex gap-4">
|
||||
<Link to="/" className="text-sm font-medium hover:underline">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/articles" className="text-sm font-medium hover:underline">
|
||||
Articles
|
||||
</Link>
|
||||
<Link to="/live-blogs" className="text-sm font-medium hover:underline">
|
||||
Live
|
||||
</Link>
|
||||
<Link to="/admin" className="text-sm font-medium hover:underline text-primary">
|
||||
Admin
|
||||
</Link>
|
||||
<Link to="/admin/live-blogs/create" className="text-sm font-medium hover:underline text-primary">
|
||||
+ New Live Blog
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<Header />
|
||||
|
||||
<main className="flex-1 container mx-auto max-w-6xl px-4 py-8">
|
||||
<Outlet />
|
||||
@@ -236,25 +216,43 @@ const liveBlogDetailRoute = createRoute({
|
||||
},
|
||||
})
|
||||
|
||||
const authRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/auth',
|
||||
component: AuthPage,
|
||||
})
|
||||
|
||||
const liveBlogAdminRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/admin/live-blogs/$slug',
|
||||
component: () => {
|
||||
const { slug } = liveBlogAdminRoute.useParams()
|
||||
return <LiveBlogAdminComponent slug={slug} />
|
||||
return (
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<LiveBlogAdminComponent slug={slug} />
|
||||
</ProtectedRoute>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const createLiveBlogRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/admin/live-blogs/create',
|
||||
component: CreateLiveBlogComponent,
|
||||
component: () => (
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<CreateLiveBlogComponent />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
})
|
||||
|
||||
const adminDashboardRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/admin',
|
||||
component: AdminDashboardComponent,
|
||||
component: () => (
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<AdminDashboardComponent />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
@@ -263,6 +261,7 @@ const routeTree = rootRoute.addChildren([
|
||||
articleDetailRoute,
|
||||
liveBlogsRoute,
|
||||
liveBlogDetailRoute,
|
||||
authRoute,
|
||||
liveBlogAdminRoute,
|
||||
createLiveBlogRoute,
|
||||
adminDashboardRoute,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export type UserRole = 'admin' | 'contributor' | 'user';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LoginDto {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterDto {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
statusCode: number;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './auth';
|
||||
@@ -13,6 +13,12 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
// For npm workspaces - look for dependencies in parent node_modules
|
||||
preserveSymlinks: true,
|
||||
},
|
||||
// For npm workspaces - optimize dependencies from root
|
||||
optimizeDeps: {
|
||||
include: ['react', 'react-dom', 'react-markdown'],
|
||||
},
|
||||
server: {
|
||||
host: true, // Listen on all addresses
|
||||
|
||||
Reference in new issue
Block a user