first commit
This commit is contained in:
107
client/src/App.tsx
Normal file
107
client/src/App.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from '@/hooks/useAuth';
|
||||
import { ToastProvider } from '@/components/Toast';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import RegisterPage from '@/pages/RegisterPage';
|
||||
import FeedPage from '@/pages/FeedPage';
|
||||
import UploadPage from '@/pages/UploadPage';
|
||||
import CatPage from '@/pages/CatPage';
|
||||
import ProfilePage from '@/pages/ProfilePage';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function AnimatedRoutes() {
|
||||
const location = useLocation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
el.classList.remove('page-enter-active');
|
||||
el.classList.add('page-enter');
|
||||
requestAnimationFrame(() => {
|
||||
el.classList.remove('page-enter');
|
||||
el.classList.add('page-enter-active');
|
||||
});
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="page-enter-active">
|
||||
<Routes location={location}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
path="/feed"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FeedPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/upload"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<UploadPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cat/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CatPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/feed" replace />} />
|
||||
<Route path="*" element={<Navigate to="/feed" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Navbar />
|
||||
<main>
|
||||
<AnimatedRoutes />
|
||||
</main>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
27
client/src/api/client.ts
Normal file
27
client/src/api/client.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import axios from 'axios';
|
||||
import { getToken, clearAuth } from '@/lib/auth';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
clearAuth();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
64
client/src/api/endpoints.ts
Normal file
64
client/src/api/endpoints.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import api from './client';
|
||||
import { User } from '@/lib/auth';
|
||||
|
||||
export interface Cat {
|
||||
id: number;
|
||||
image_url: string;
|
||||
caption: string;
|
||||
created_at: string;
|
||||
user_id: number;
|
||||
username: string;
|
||||
user_points: number;
|
||||
likes_count: number;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function register(username: string, password: string) {
|
||||
return api.post<AuthResponse>('/auth/register', { username, password });
|
||||
}
|
||||
|
||||
export function login(username: string, password: string) {
|
||||
return api.post<AuthResponse>('/auth/login', { username, password });
|
||||
}
|
||||
|
||||
export function getMe() {
|
||||
return api.get<{ user: User }>('/auth/me');
|
||||
}
|
||||
|
||||
export function getCats(page = 1) {
|
||||
return api.get<{ cats: Cat[]; total: number; page: number; totalPages: number }>('/cats', { params: { page } });
|
||||
}
|
||||
|
||||
export function getUserCats(userId: number) {
|
||||
return api.get<{ cats: Cat[] }>(`/cats/user/${userId}`);
|
||||
}
|
||||
|
||||
export function getCat(id: number) {
|
||||
return api.get<{ cat: Cat }>(`/cats/${id}`);
|
||||
}
|
||||
|
||||
export function uploadCat(formData: FormData) {
|
||||
return api.post<{ cat: Cat }>('/cats', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteCat(id: number) {
|
||||
return api.delete(`/cats/${id}`);
|
||||
}
|
||||
|
||||
export function likeCat(catId: number) {
|
||||
return api.post<{ liked: boolean; likesCount: number; ownerPoints: number }>(`/likes/${catId}`);
|
||||
}
|
||||
|
||||
export function unlikeCat(catId: number) {
|
||||
return api.delete<{ liked: boolean; likesCount: number; ownerPoints: number }>(`/likes/${catId}`);
|
||||
}
|
||||
|
||||
export function getLikeStatus(catId: number) {
|
||||
return api.get<{ liked: boolean }>(`/likes/${catId}/status`);
|
||||
}
|
||||
152
client/src/components/CatCard.tsx
Normal file
152
client/src/components/CatCard.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState } from 'react';
|
||||
import { Cat } from '@/api/endpoints';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useLikeCat, useUnlikeCat } from '@/hooks/useLikes';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Heart, MessageCircle } from 'lucide-react';
|
||||
import { useToast } from '@/components/Toast';
|
||||
|
||||
interface CatCardProps {
|
||||
cat: Cat;
|
||||
onOpen: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function CatCard({ cat, onOpen }: CatCardProps) {
|
||||
const { user, updateUser } = useAuth();
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(cat.likes_count);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [showHeart, setShowHeart] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const likeMutation = useLikeCat(cat.id);
|
||||
const unlikeMutation = useUnlikeCat(cat.id);
|
||||
const isOwner = user && cat.user_id === user.id;
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setShowHeart(true);
|
||||
setTimeout(() => setShowHeart(false), 600);
|
||||
if (!liked && !isOwner) {
|
||||
handleLikeAction();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLikeAction = async () => {
|
||||
if (isOwner || !user) return;
|
||||
setAnimating(true);
|
||||
setTimeout(() => setAnimating(false), 400);
|
||||
|
||||
if (liked) {
|
||||
setLiked(false);
|
||||
setLikeCount((c) => c - 1);
|
||||
try {
|
||||
const res = await unlikeMutation.mutateAsync();
|
||||
if (res.ownerPoints !== undefined && cat.user_id === user.id) {
|
||||
updateUser({ ...user, points: res.ownerPoints });
|
||||
}
|
||||
} catch {
|
||||
setLiked(true);
|
||||
setLikeCount((c) => c + 1);
|
||||
}
|
||||
} else {
|
||||
setLiked(true);
|
||||
setLikeCount((c) => c + 1);
|
||||
toast('Liked', 'like');
|
||||
try {
|
||||
await likeMutation.mutateAsync();
|
||||
} catch {
|
||||
setLiked(false);
|
||||
setLikeCount((c) => c - 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLike = () => {
|
||||
handleLikeAction();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in border-b pb-6 mb-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className="text-[10px] bg-foreground text-background">
|
||||
{cat.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium">@{cat.username}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(cat.created_at).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div
|
||||
className="relative bg-muted rounded-sm overflow-hidden cursor-pointer mb-3"
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onClick={() => onOpen(cat.id)}
|
||||
>
|
||||
<img
|
||||
src={cat.image_url}
|
||||
alt={cat.caption || 'Cat photo'}
|
||||
className="w-full max-h-[500px] object-contain select-none"
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
{showHeart && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<Heart className="heart-burst h-20 w-20 text-white" strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleLike}
|
||||
disabled={isOwner || !user}
|
||||
className={`like-btn ${animating ? 'heart-animate' : ''}`}
|
||||
>
|
||||
<Heart
|
||||
className={`h-5 w-5 ${
|
||||
liked ? 'fill-[#e00] text-[#e00]' : 'text-foreground'
|
||||
}`}
|
||||
strokeWidth={liked ? 2 : 1.5}
|
||||
/>
|
||||
</button>
|
||||
<button onClick={() => onOpen(cat.id)} className="hover:text-muted-foreground transition-colors">
|
||||
<MessageCircle className="h-5 w-5" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Likes */}
|
||||
<div className="mb-1">
|
||||
<span className="text-sm font-semibold">{likeCount.toLocaleString()} likes</span>
|
||||
</div>
|
||||
|
||||
{/* Caption */}
|
||||
{cat.caption && (
|
||||
<div className="mb-0.5">
|
||||
<span className="text-sm">
|
||||
<span className="font-semibold mr-1.5">@{cat.username}</span>
|
||||
{cat.caption}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment link */}
|
||||
<button
|
||||
onClick={() => onOpen(cat.id)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors mt-0.5"
|
||||
>
|
||||
View comments
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
client/src/components/CatModal.tsx
Normal file
184
client/src/components/CatModal.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCat, useDeleteCat } from '@/hooks/useCats';
|
||||
import { useLikeStatus, useLikeCat, useUnlikeCat } from '@/hooks/useLikes';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Heart, MessageCircle, X, Trash2 } from 'lucide-react';
|
||||
|
||||
interface CatModalProps {
|
||||
catId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CatModal({ catId, onClose }: CatModalProps) {
|
||||
const { user, updateUser } = useAuth();
|
||||
const { data, isLoading } = useCat(catId);
|
||||
const { data: likeData, refetch: refetchLike } = useLikeStatus(catId);
|
||||
const likeMutation = useLikeCat(catId);
|
||||
const unlikeMutation = useUnlikeCat(catId);
|
||||
const deleteMutation = useDeleteCat();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(0);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (likeData) setLiked(likeData.liked);
|
||||
}, [likeData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.cat) setLikeCount(data.cat.likes_count);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, []);
|
||||
|
||||
const cat = data?.cat;
|
||||
const isOwner = cat && user && cat.user_id === user.id;
|
||||
|
||||
const handleLike = async () => {
|
||||
if (isOwner || !user || !cat) return;
|
||||
setAnimating(true);
|
||||
setTimeout(() => setAnimating(false), 400);
|
||||
|
||||
if (liked) {
|
||||
setLiked(false);
|
||||
setLikeCount((c) => c - 1);
|
||||
try {
|
||||
const res = await unlikeMutation.mutateAsync();
|
||||
if (res.ownerPoints !== undefined && cat.user_id === user.id) {
|
||||
updateUser({ ...user, points: res.ownerPoints });
|
||||
}
|
||||
refetchLike();
|
||||
} catch {
|
||||
setLiked(true);
|
||||
setLikeCount((c) => c + 1);
|
||||
}
|
||||
} else {
|
||||
setLiked(true);
|
||||
setLikeCount((c) => c + 1);
|
||||
toast('Liked', 'like');
|
||||
try {
|
||||
await likeMutation.mutateAsync();
|
||||
refetchLike();
|
||||
} catch {
|
||||
setLiked(false);
|
||||
setLikeCount((c) => c - 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isOwner || !cat) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(cat.id);
|
||||
toast('Deleted', 'success');
|
||||
onClose();
|
||||
} catch {
|
||||
toast('Failed to delete', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || !cat) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-foreground/30 border-t-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="animate-scale-in flex max-h-[85vh] w-full max-w-3xl bg-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="flex-1 bg-muted flex items-center justify-center min-h-[400px]">
|
||||
<img
|
||||
src={cat.image_url}
|
||||
alt={cat.caption || 'Cat photo'}
|
||||
className="max-h-[85vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Details panel */}
|
||||
<div className="flex w-[320px] flex-col border-l">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className="text-[10px] bg-foreground text-background">
|
||||
{cat.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium">@{cat.username}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isOwner && (
|
||||
<button onClick={handleDelete} className="p-1 text-muted-foreground hover:text-destructive transition-colors">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="p-1 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Caption */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Avatar className="h-7 w-7 shrink-0">
|
||||
<AvatarFallback className="text-[10px] bg-foreground text-background">
|
||||
{cat.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold mr-1.5">@{cat.username}</span>
|
||||
{cat.caption || 'No caption'}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{new Date(cat.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="border-t px-4 py-3">
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<button
|
||||
onClick={handleLike}
|
||||
disabled={isOwner || !user}
|
||||
className={`like-btn ${animating ? 'heart-animate' : ''}`}
|
||||
>
|
||||
<Heart
|
||||
className={`h-5 w-5 ${
|
||||
liked ? 'fill-[#e00] text-[#e00]' : 'text-foreground'
|
||||
}`}
|
||||
strokeWidth={liked ? 2 : 1.5}
|
||||
/>
|
||||
</button>
|
||||
<button className="hover:text-muted-foreground transition-colors">
|
||||
<MessageCircle className="h-5 w-5" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{likeCount.toLocaleString()} likes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
client/src/components/FeedSidebar.tsx
Normal file
68
client/src/components/FeedSidebar.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useCats } from '@/hooks/useCats';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Trophy } from 'lucide-react';
|
||||
|
||||
export default function FeedSidebar() {
|
||||
const { user } = useAuth();
|
||||
const { data } = useCats(1);
|
||||
|
||||
const usersMap = new Map<number, { username: string; points: number }>();
|
||||
if (data?.cats) {
|
||||
for (const cat of data.cats) {
|
||||
if (!usersMap.has(cat.user_id)) {
|
||||
usersMap.set(cat.user_id, { username: cat.username, points: cat.user_points });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const topUsers = Array.from(usersMap.entries())
|
||||
.sort((a, b) => b[1].points - a[1].points)
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{user && (
|
||||
<Link to="/profile" className="flex items-center gap-3 group">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="bg-foreground text-background text-sm">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium group-hover:underline">{user.username}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.points} pts</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<Trophy className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Leaderboard</h3>
|
||||
</div>
|
||||
{topUsers.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No users yet</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{topUsers.map(([id, u], i) => (
|
||||
<Link key={id} to={`/profile/${id}`} className="flex items-center gap-2.5 group">
|
||||
<span className={`w-4 text-center text-xs font-mono font-bold ${
|
||||
i === 0 ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}>{i + 1}</span>
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-[9px] bg-foreground text-background">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs flex-1 truncate group-hover:underline">{u.username}</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{u.points}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
client/src/components/Navbar.tsx
Normal file
43
client/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { LogOut, Plus, Home, User } from 'lucide-react';
|
||||
|
||||
export default function Navbar() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 border-b bg-white">
|
||||
<div className="mx-auto flex h-12 max-w-5xl items-center justify-between px-4">
|
||||
<Link to="/feed" className="text-base font-semibold tracking-tight">
|
||||
Catstagram
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Link to="/feed" className="flex h-9 w-9 items-center justify-center rounded hover:bg-secondary transition-colors">
|
||||
<Home className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link to="/upload" className="flex h-9 w-9 items-center justify-center rounded hover:bg-secondary transition-colors">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link to="/profile" className="flex h-9 w-9 items-center justify-center rounded hover:bg-secondary transition-colors">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-[9px] bg-foreground text-background">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { logout(); navigate('/login'); }}
|
||||
className="flex h-9 w-9 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
20
client/src/components/ProtectedRoute.tsx
Normal file
20
client/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
21
client/src/components/StoryCircle.tsx
Normal file
21
client/src/components/StoryCircle.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { User } from '@/lib/auth';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
||||
interface StoryCircleProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function StoryCircle({ user }: StoryCircleProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 w-14 shrink-0">
|
||||
<Avatar className="h-12 w-12 border border-border">
|
||||
<AvatarFallback className="bg-foreground text-background text-sm">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[10px] text-muted-foreground truncate w-full text-center">
|
||||
{user.username}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
client/src/components/Toast.tsx
Normal file
65
client/src/components/Toast.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'like';
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType>(null!);
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
const iconMap: Record<ToastType, string> = {
|
||||
success: '✓',
|
||||
error: '✕',
|
||||
info: '·',
|
||||
like: '♥',
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const addToast = useCallback((message: string, type: ToastType = 'info') => {
|
||||
const id = nextId++;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 2200);
|
||||
}, []);
|
||||
|
||||
const remove = (id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast: addToast }}>
|
||||
{children}
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-1.5">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="animate-slide-up flex items-center gap-2 bg-foreground text-background px-3 py-2 text-sm shadow-lg"
|
||||
>
|
||||
<span className="opacity-70">{iconMap[t.type]}</span>
|
||||
<span>{t.message}</span>
|
||||
<button onClick={() => remove(t.id)} className="ml-2 opacity-50 hover:opacity-100">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
37
client/src/components/ui/avatar.tsx
Normal file
37
client/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} />
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
46
client/src/components/ui/button.tsx
Normal file
46
client/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring 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 shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
44
client/src/components/ui/card.tsx
Normal file
44
client/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
|
||||
)
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
21
client/src/components/ui/input.tsx
Normal file
21
client/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
18
client/src/components/ui/label.tsx
Normal file
18
client/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
7
client/src/components/ui/skeleton.tsx
Normal file
7
client/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
75
client/src/hooks/useAuth.tsx
Normal file
75
client/src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import { User, getToken, setToken, setUser, getUser, clearAuth } from '@/lib/auth';
|
||||
import { register as apiRegister, login as apiLogin, getMe } from '@/api/endpoints';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
register: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateUser: (user: User) => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextType>(null!);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUserState] = useState<User | null>(getUser);
|
||||
const [token, setTokenState] = useState<string | null>(getToken);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const t = getToken();
|
||||
if (t) {
|
||||
getMe()
|
||||
.then((res) => {
|
||||
setUserState(res.data.user);
|
||||
setUser(res.data.user);
|
||||
})
|
||||
.catch(() => clearAuth())
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const res = await apiLogin(username, password);
|
||||
setToken(res.data.token);
|
||||
setUserState(res.data.user);
|
||||
setToken(res.data.token);
|
||||
setUser(res.data.user);
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (username: string, password: string) => {
|
||||
const res = await apiRegister(username, password);
|
||||
setToken(res.data.token);
|
||||
setUserState(res.data.user);
|
||||
setToken(res.data.token);
|
||||
setUser(res.data.user);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearAuth();
|
||||
setTokenState(null);
|
||||
setUserState(null);
|
||||
}, []);
|
||||
|
||||
const updateUser = useCallback((u: User) => {
|
||||
setUserState(u);
|
||||
setUser(u);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, isLoading, login, register, logout, updateUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be inside AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
45
client/src/hooks/useCats.ts
Normal file
45
client/src/hooks/useCats.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getCats, getUserCats, getCat, uploadCat, deleteCat } from '@/api/endpoints';
|
||||
|
||||
export function useCats(page: number) {
|
||||
return useQuery({
|
||||
queryKey: ['cats', page],
|
||||
queryFn: () => getCats(page).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserCats(userId: number) {
|
||||
return useQuery({
|
||||
queryKey: ['cats', 'user', userId],
|
||||
queryFn: () => getUserCats(userId).then((r) => r.data),
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCat(id: number) {
|
||||
return useQuery({
|
||||
queryKey: ['cat', id],
|
||||
queryFn: () => getCat(id).then((r) => r.data),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadCat() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (formData: FormData) => uploadCat(formData).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['cats'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCat() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteCat(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['cats'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
46
client/src/hooks/useLikes.ts
Normal file
46
client/src/hooks/useLikes.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { likeCat, unlikeCat, getLikeStatus } from '@/api/endpoints';
|
||||
import { useAuth } from './useAuth';
|
||||
|
||||
export function useLikeStatus(catId: number) {
|
||||
const { user } = useAuth();
|
||||
return useQuery({
|
||||
queryKey: ['likeStatus', catId],
|
||||
queryFn: () => getLikeStatus(catId).then((r) => r.data),
|
||||
enabled: !!user && !!catId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLikeCat(catId: number) {
|
||||
const qc = useQueryClient();
|
||||
const { updateUser, user } = useAuth();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => likeCat(catId).then((r) => r.data),
|
||||
onSuccess: (data) => {
|
||||
qc.invalidateQueries({ queryKey: ['likeStatus', catId] });
|
||||
qc.invalidateQueries({ queryKey: ['cat', catId] });
|
||||
qc.invalidateQueries({ queryKey: ['cats'] });
|
||||
if (user && data.ownerPoints !== undefined) {
|
||||
updateUser({ ...user, points: data.ownerPoints });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnlikeCat(catId: number) {
|
||||
const qc = useQueryClient();
|
||||
const { updateUser, user } = useAuth();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => unlikeCat(catId).then((r) => r.data),
|
||||
onSuccess: (data) => {
|
||||
qc.invalidateQueries({ queryKey: ['likeStatus', catId] });
|
||||
qc.invalidateQueries({ queryKey: ['cat', catId] });
|
||||
qc.invalidateQueries({ queryKey: ['cats'] });
|
||||
if (user && data.ownerPoints !== undefined) {
|
||||
updateUser({ ...user, points: data.ownerPoints });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
139
client/src/index.css
Normal file
139
client/src/index.css
Normal file
@@ -0,0 +1,139 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #0a0a0a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0a0a0a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0a0a0a;
|
||||
--primary: #0a0a0a;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f5f5f5;
|
||||
--secondary-foreground: #0a0a0a;
|
||||
--muted: #f7f7f7;
|
||||
--muted-foreground: #8a8a8a;
|
||||
--accent: #f0f0f0;
|
||||
--accent-foreground: #0a0a0a;
|
||||
--destructive: #e00;
|
||||
--destructive-foreground: #fff;
|
||||
--border: #e8e8e8;
|
||||
--input: #e8e8e8;
|
||||
--ring: #0a0a0a;
|
||||
--radius: 0;
|
||||
|
||||
--accent-coral: #ff6b35;
|
||||
--accent-coral-light: #fff4f0;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Heart animation */
|
||||
@keyframes heart-pop {
|
||||
0% { transform: scale(1); }
|
||||
25% { transform: scale(1.25); }
|
||||
50% { transform: scale(0.9); }
|
||||
75% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.heart-animate {
|
||||
animation: heart-pop 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
/* Double tap heart burst */
|
||||
@keyframes heart-burst {
|
||||
0% { transform: scale(0) rotate(-15deg); opacity: 1; }
|
||||
60% { transform: scale(1.3) rotate(5deg); opacity: 0.8; }
|
||||
100% { transform: scale(1.8) rotate(0deg); opacity: 0; }
|
||||
}
|
||||
|
||||
.heart-burst {
|
||||
animation: heart-burst 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Fade in */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Scale in */
|
||||
@keyframes scale-in {
|
||||
from { opacity: 0; transform: scale(0.97); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Slide up for toasts */
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* Skeleton shimmer */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.skeleton-shimmer {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #fafafa 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Page transition */
|
||||
.page-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
.page-enter-active {
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Like button press */
|
||||
.like-btn {
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.like-btn:active {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
/* Hide scrollbar for stories */
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Terax-style divider */
|
||||
.divider-clean {
|
||||
height: 1px;
|
||||
background: #e8e8e8;
|
||||
margin: 24px 0;
|
||||
}
|
||||
35
client/src/lib/auth.ts
Normal file
35
client/src/lib/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const TOKEN_KEY = 'catstagram_token';
|
||||
const USER_KEY = 'catstagram_user';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
points: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function getUser(): User | null {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}
|
||||
|
||||
export function setUser(user: User): void {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
6
client/src/lib/utils.ts
Normal file
6
client/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
10
client/src/main.tsx
Normal file
10
client/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
161
client/src/pages/CatPage.tsx
Normal file
161
client/src/pages/CatPage.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useCat, useDeleteCat } from '@/hooks/useCats';
|
||||
import { useLikeStatus, useLikeCat, useUnlikeCat } from '@/hooks/useLikes';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Heart, ArrowLeft, Trash2, MessageCircle } from 'lucide-react';
|
||||
|
||||
export default function CatPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const catId = parseInt(id!);
|
||||
const navigate = useNavigate();
|
||||
const { user, updateUser } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data, isLoading, isError } = useCat(catId);
|
||||
const { data: likeData, refetch: refetchLike } = useLikeStatus(catId);
|
||||
const likeMutation = useLikeCat(catId);
|
||||
const unlikeMutation = useUnlikeCat(catId);
|
||||
const deleteMutation = useDeleteCat();
|
||||
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
const cat = data?.cat;
|
||||
const isOwner = cat && user && cat.user_id === user.id;
|
||||
const isLiked = likeData?.liked ?? false;
|
||||
|
||||
useEffect(() => { setLiked(isLiked); }, [isLiked]);
|
||||
|
||||
const handleLike = async () => {
|
||||
if (isOwner || !user || !cat) return;
|
||||
setAnimating(true);
|
||||
setTimeout(() => setAnimating(false), 400);
|
||||
|
||||
if (isLiked) {
|
||||
setLiked(false);
|
||||
try {
|
||||
const res = await unlikeMutation.mutateAsync();
|
||||
if (res.ownerPoints !== undefined && cat.user_id === user.id) {
|
||||
updateUser({ ...user, points: res.ownerPoints });
|
||||
}
|
||||
refetchLike();
|
||||
} catch { setLiked(true); }
|
||||
} else {
|
||||
setLiked(true);
|
||||
toast('Liked', 'like');
|
||||
try {
|
||||
await likeMutation.mutateAsync();
|
||||
refetchLike();
|
||||
} catch { setLiked(false); }
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isOwner || !cat) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(cat.id);
|
||||
toast('Deleted', 'success');
|
||||
navigate('/feed');
|
||||
} catch {
|
||||
toast('Failed to delete', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-8">
|
||||
<Skeleton className="mb-4 h-5 w-20" />
|
||||
<Skeleton className="aspect-[4/3] w-full" />
|
||||
<div className="mt-4 space-y-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !cat) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-muted-foreground">Cat not found</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => navigate('/feed')}>Go to feed</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-8 animate-fade-in">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="mb-6 flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
|
||||
{/* Image */}
|
||||
<div className="bg-muted mb-5">
|
||||
<img src={cat.image_url} alt={cat.caption || 'Cat photo'} className="w-full max-h-[60vh] object-contain mx-auto" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className="text-[10px] bg-foreground text-background">
|
||||
{cat.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium">@{cat.username}</span>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<button onClick={handleDelete} className="text-muted-foreground hover:text-destructive transition-colors">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleLike}
|
||||
disabled={isOwner || !user}
|
||||
className={`like-btn ${animating ? 'heart-animate' : ''}`}
|
||||
>
|
||||
<Heart
|
||||
className={`h-5 w-5 ${isLiked ? 'fill-[#e00] text-[#e00]' : 'text-foreground'}`}
|
||||
strokeWidth={isLiked ? 2 : 1.5}
|
||||
/>
|
||||
</button>
|
||||
<button className="hover:text-muted-foreground transition-colors">
|
||||
<MessageCircle className="h-5 w-5" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-semibold block">{cat.likes_count.toLocaleString()} likes</span>
|
||||
|
||||
{cat.caption && (
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold mr-1.5">@{cat.username}</span>
|
||||
{cat.caption}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(cat.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-muted-foreground">🏆 {cat.user_points} points</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
client/src/pages/FeedPage.tsx
Normal file
109
client/src/pages/FeedPage.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { useCats } from '@/hooks/useCats';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import CatCard from '@/components/CatCard';
|
||||
import FeedSidebar from '@/components/FeedSidebar';
|
||||
import StoryCircle from '@/components/StoryCircle';
|
||||
import CatModal from '@/components/CatModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
function FeedSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center gap-2.5 mb-3">
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<Skeleton className="aspect-[4/3] w-full" />
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FeedPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [modalCatId, setModalCatId] = useState<number | null>(null);
|
||||
const { data, isLoading, isError } = useCats(page);
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8">
|
||||
<div className="flex gap-12">
|
||||
{/* Main feed */}
|
||||
<div className="flex-1 max-w-[520px] mx-auto lg:mx-0">
|
||||
{/* Stories */}
|
||||
<div className="mb-6 pb-6 border-b overflow-hidden">
|
||||
<div className="flex gap-4 overflow-x-auto scrollbar-none">
|
||||
{user && <StoryCircle user={user} />}
|
||||
{data?.cats
|
||||
.filter((c, i, arr) => arr.findIndex((x) => x.user_id === c.user_id) === i)
|
||||
.slice(0, 7)
|
||||
.map((c) => (
|
||||
<StoryCircle key={c.user_id} user={{ id: c.user_id, username: c.username, points: c.user_points }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && <FeedSkeleton />}
|
||||
|
||||
{isError && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<p className="text-muted-foreground">Failed to load cats</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => setPage(1)}>Try again</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.cats.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<p className="text-2xl font-semibold mb-1">No cats yet</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">Be the first to upload a cat photo</p>
|
||||
<Button onClick={() => window.location.href = '/upload'}>Upload</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.cats.length > 0 && (
|
||||
<>
|
||||
{data.cats.map((cat) => (
|
||||
<CatCard key={cat.id} cat={cat} onOpen={setModalCatId} />
|
||||
))}
|
||||
|
||||
{data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4 mt-8 pb-8">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">{data.page} / {data.totalPages}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((p) => p + 1)} disabled={page >= data.totalPages}>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="hidden lg:block w-[280px] shrink-0">
|
||||
<div className="sticky top-20">
|
||||
<FeedSidebar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalCatId && (
|
||||
<CatModal catId={modalCatId} onClose={() => setModalCatId(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
client/src/pages/LoginPage.tsx
Normal file
69
client/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { user, login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (user) return <Navigate to="/feed" replace />;
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm animate-fade-in">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Catstagram</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
required
|
||||
autoFocus
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
required
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full h-10" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
No account?{' '}
|
||||
<Link to="/register" className="font-medium underline-offset-4 hover:underline">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
client/src/pages/ProfilePage.tsx
Normal file
152
client/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useUserCats } from '@/hooks/useCats';
|
||||
import CatModal from '@/components/CatModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Heart, Plus, ArrowLeft } from 'lucide-react';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { id: paramId } = useParams<{ id?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user: me } = useAuth();
|
||||
const [modalCatId, setModalCatId] = useState<number | null>(null);
|
||||
|
||||
const profileUserId = paramId ? parseInt(paramId) : me?.id;
|
||||
const isMe = !paramId || (me && profileUserId === me.id);
|
||||
|
||||
const { data, isLoading } = useUserCats(profileUserId ?? 0);
|
||||
|
||||
const profileUser = isMe ? me : data?.cats?.[0]
|
||||
? { id: data.cats[0].user_id, username: data.cats[0].username, points: data.cats[0].user_points }
|
||||
: null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-10">
|
||||
<div className="flex items-center gap-6 mb-10">
|
||||
<Skeleton className="h-16 w-16 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-0.5">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-square" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profileUser && !isMe) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-muted-foreground">User not found</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => navigate('/feed')}>Go to feed</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cats = data?.cats ?? [];
|
||||
const initials = profileUser?.username?.charAt(0).toUpperCase() || '?';
|
||||
const totalLikes = cats.reduce((sum, c) => sum + c.likes_count, 0);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-10">
|
||||
{!isMe && (
|
||||
<button onClick={() => navigate(-1)} className="mb-6 flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Profile header */}
|
||||
<div className="flex items-start gap-8 mb-10">
|
||||
<Avatar className="h-16 w-16 shrink-0">
|
||||
<AvatarFallback className="bg-foreground text-background text-lg font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h1 className="text-xl font-medium">@{profileUser?.username}</h1>
|
||||
{/* Edit profile button - only for own profile, navigates to profile settings or does nothing */}
|
||||
{isMe && (
|
||||
<Button variant="outline" size="sm" className="text-xs h-7 rounded-sm">
|
||||
Edit profile
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm mb-4">
|
||||
<div><span className="font-semibold">{cats.length}</span> <span className="text-muted-foreground">posts</span></div>
|
||||
<div><span className="font-semibold">{totalLikes}</span> <span className="text-muted-foreground">likes</span></div>
|
||||
<div><span className="font-semibold">{profileUser?.points ?? 0}</span> <span className="text-muted-foreground">pts</span></div>
|
||||
</div>
|
||||
|
||||
{isMe && (
|
||||
<Link to="/upload">
|
||||
<Button size="sm" className="text-xs h-7 rounded-sm gap-1">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New post
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-6 border-t pt-4 mb-4">
|
||||
<button className="text-xs font-semibold tracking-wider uppercase text-foreground">Posts</button>
|
||||
<button className="text-xs font-semibold tracking-wider uppercase text-muted-foreground hover:text-foreground transition-colors">Saved</button>
|
||||
<button className="text-xs font-semibold tracking-wider uppercase text-muted-foreground hover:text-foreground transition-colors">Liked</button>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{cats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center border-t">
|
||||
<p className="text-base font-medium mb-1">No cats yet</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{isMe ? 'Share your first cat photo' : 'This user has no cats yet'}
|
||||
</p>
|
||||
{isMe && (
|
||||
<Link to="/upload">
|
||||
<Button size="sm">Upload</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-0.5">
|
||||
{cats.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setModalCatId(cat.id)}
|
||||
className="group relative aspect-square overflow-hidden bg-muted"
|
||||
>
|
||||
<img
|
||||
src={cat.image_url}
|
||||
alt={cat.caption || 'Cat photo'}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-1 text-white text-sm font-medium">
|
||||
<Heart className="h-4 w-4 fill-white" strokeWidth={2} />
|
||||
<span>{cat.likes_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalCatId && (
|
||||
<CatModal catId={modalCatId} onClose={() => setModalCatId(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
client/src/pages/RegisterPage.tsx
Normal file
86
client/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { user, register } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (user) return <Navigate to="/feed" replace />;
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== confirm) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(username, password);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm animate-fade-in">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Catstagram</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Create your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
minLength={3}
|
||||
required
|
||||
autoFocus
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
minLength={4}
|
||||
required
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
required
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full h-10" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="font-medium underline-offset-4 hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
client/src/pages/UploadPage.tsx
Normal file
139
client/src/pages/UploadPage.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useUploadCat } from '@/hooks/useCats';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ArrowLeft, Upload, X } from 'lucide-react';
|
||||
|
||||
export default function UploadPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user, updateUser } = useAuth();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [caption, setCaption] = useState('');
|
||||
const uploadMutation = useUploadCat();
|
||||
const { toast } = useToast();
|
||||
|
||||
const onDrop = useCallback((accepted: File[]) => {
|
||||
const f = accepted[0];
|
||||
if (f) {
|
||||
setFile(f);
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
setPreview(URL.createObjectURL(f));
|
||||
}
|
||||
}, [preview]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp'] },
|
||||
maxFiles: 1,
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('image', file);
|
||||
fd.append('caption', caption);
|
||||
|
||||
try {
|
||||
const result = await uploadMutation.mutateAsync(fd);
|
||||
if (user) updateUser({ ...user, points: (user.points || 0) + 10 });
|
||||
toast('+10 points', 'success');
|
||||
navigate(`/cat/${result.cat.id}`);
|
||||
} catch {
|
||||
toast('Upload failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const clearFile = () => {
|
||||
setFile(null);
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
setPreview(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-4 py-8">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="mb-6 flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
|
||||
<h1 className="text-xl font-medium mb-6">New post</h1>
|
||||
|
||||
{!preview ? (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border border-dashed p-16 text-center cursor-pointer transition-colors ${
|
||||
isDragActive ? 'bg-secondary border-foreground' : 'hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="mb-4">
|
||||
<Upload className="mx-auto h-6 w-6 text-muted-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="text-sm font-medium mb-1">
|
||||
{isDragActive ? 'Drop your cat here' : 'Drag photo here'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">or click to browse</p>
|
||||
<Button variant="outline" size="sm">Select file</Button>
|
||||
<p className="mt-3 text-xs text-muted-foreground">JPG, PNG, GIF, WebP · Max 10MB</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="relative bg-muted">
|
||||
<img src={preview} alt="Preview" className="max-h-96 w-full object-contain" />
|
||||
<button
|
||||
onClick={clearFile}
|
||||
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center bg-background border"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-7 w-7 shrink-0 mt-0.5">
|
||||
<AvatarFallback className="text-[10px] bg-foreground text-background">
|
||||
{user?.username?.charAt(0).toUpperCase() || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium mb-1">{user?.username}</p>
|
||||
<textarea
|
||||
placeholder="Write a caption..."
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
maxLength={200}
|
||||
rows={2}
|
||||
className="w-full resize-none text-sm bg-transparent outline-none placeholder:text-muted-foreground border-b pb-1"
|
||||
/>
|
||||
<p className="text-right text-[11px] text-muted-foreground mt-1">{caption.length}/200</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-muted-foreground">+10 points</span>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
{uploadMutation.isPending ? 'Sharing...' : 'Share'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadMutation.isError && (
|
||||
<p className="mt-3 text-sm text-destructive text-center">Failed to upload. Try again.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user