first commit

This commit is contained in:
2026-05-29 10:23:25 +03:00
commit e4b25fe4b7
48 changed files with 9112 additions and 0 deletions

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐱</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Catstagram</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4322
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
client/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "catstagram-client",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@tailwindcss/postcss": "^4.3.0",
"@tanstack/react-query": "^5.60.0",
"axios": "^1.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.0",
"react-router-dom": "^7.0.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"@tailwindcss/postcss": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}

5
client/postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

107
client/src/App.tsx Normal file
View 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
View 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;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

25
client/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/endpoints.ts","./src/components/catcard.tsx","./src/components/catmodal.tsx","./src/components/feedsidebar.tsx","./src/components/navbar.tsx","./src/components/protectedroute.tsx","./src/components/storycircle.tsx","./src/components/toast.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/skeleton.tsx","./src/hooks/useauth.tsx","./src/hooks/usecats.ts","./src/hooks/uselikes.ts","./src/lib/auth.ts","./src/lib/utils.ts","./src/pages/catpage.tsx","./src/pages/feedpage.tsx","./src/pages/loginpage.tsx","./src/pages/profilepage.tsx","./src/pages/registerpage.tsx","./src/pages/uploadpage.tsx"],"version":"5.9.3"}

25
client/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3040',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:3040',
changeOrigin: true,
},
},
},
});