first commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
server/data.db
|
||||
server/uploads/*
|
||||
!server/uploads/.gitkeep
|
||||
.env
|
||||
13
client/index.html
Normal file
13
client/index.html
Normal 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
4322
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
client/package.json
Normal file
40
client/package.json
Normal 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
5
client/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
25
client/tsconfig.json
Normal file
25
client/tsconfig.json
Normal 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"]
|
||||
}
|
||||
1
client/tsconfig.tsbuildinfo
Normal file
1
client/tsconfig.tsbuildinfo
Normal 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
25
client/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
327
package-lock.json
generated
Normal file
327
package-lock.json
generated
Normal file
@@ -0,0 +1,327 @@
|
||||
{
|
||||
"name": "catstagram",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "catstagram",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
package.json
Normal file
14
package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "catstagram",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||||
"dev:server": "cd server && npm run dev",
|
||||
"dev:client": "cd client && npm run dev",
|
||||
"build": "cd client && npm run build",
|
||||
"start": "cd server && npm start"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.0.0"
|
||||
}
|
||||
}
|
||||
1872
server/package-lock.json
generated
Normal file
1872
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
server/package.json
Normal file
28
server/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "catstagram-server",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.0",
|
||||
"express": "^4.21.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"sql.js": "^1.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
94
server/src/db.ts
Normal file
94
server/src/db.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import initSqlJs, { Database as SqlJsDatabase } from 'sql.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dbPath = path.join(__dirname, '..', 'data.db');
|
||||
|
||||
let db: SqlJsDatabase;
|
||||
|
||||
export async function initDb(): Promise<SqlJsDatabase> {
|
||||
const SQL = await initSqlJs();
|
||||
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const buffer = fs.readFileSync(dbPath);
|
||||
db = new SQL.Database(buffer);
|
||||
} else {
|
||||
db = new SQL.Database();
|
||||
}
|
||||
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
points INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS cats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
image_url TEXT NOT NULL,
|
||||
caption TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS likes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cat_id INTEGER NOT NULL REFERENCES cats(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
UNIQUE(cat_id, user_id)
|
||||
)
|
||||
`);
|
||||
|
||||
saveDb();
|
||||
return db;
|
||||
}
|
||||
|
||||
export function saveDb(): void {
|
||||
const data = db.export();
|
||||
const buffer = Buffer.from(data);
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
}
|
||||
|
||||
export function getDb(): SqlJsDatabase {
|
||||
return db;
|
||||
}
|
||||
|
||||
export function queryOne(sql: string, params: any[] = []): any {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.bind(params);
|
||||
const result = stmt.step() ? stmt.getAsObject() : null;
|
||||
stmt.free();
|
||||
return result;
|
||||
}
|
||||
|
||||
export function queryAll(sql: string, params: any[] = []): any[] {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.bind(params);
|
||||
const results: any[] = [];
|
||||
while (stmt.step()) {
|
||||
results.push(stmt.getAsObject());
|
||||
}
|
||||
stmt.free();
|
||||
return results;
|
||||
}
|
||||
|
||||
export function execute(sql: string, params: any[] = []): { changes: number; lastInsertRowid: number } {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.bind(params);
|
||||
stmt.step();
|
||||
const lastId = db.exec('SELECT last_insert_rowid() AS id')[0]?.values[0][0] || 0;
|
||||
const changes = db.getRowsModified();
|
||||
stmt.free();
|
||||
saveDb();
|
||||
return { changes, lastInsertRowid: Number(lastId) };
|
||||
}
|
||||
37
server/src/index.ts
Normal file
37
server/src/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { initDb } from './db.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import catRoutes from './routes/cats.js';
|
||||
import likeRoutes from './routes/likes.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function main() {
|
||||
await initDb();
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || '3040');
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/uploads', express.static(path.join(__dirname, '..', 'uploads')));
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/cats', catRoutes);
|
||||
app.use('/api/likes', likeRoutes);
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Catstagram server running on http://localhost:${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
31
server/src/middleware/auth.ts
Normal file
31
server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'catstagram-dev-secret-key-change-in-production';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
userId?: number;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export function generateToken(userId: number, username: string): string {
|
||||
return jwt.sign({ userId, username }, JWT_SECRET, { expiresIn: '7d' });
|
||||
}
|
||||
|
||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = header.split(' ')[1];
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { userId: number; username: string };
|
||||
req.userId = decoded.userId;
|
||||
req.username = decoded.username;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
58
server/src/routes/auth.ts
Normal file
58
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { queryOne, execute } from '../db';
|
||||
import { generateToken, authMiddleware, AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/register', (req: Request, res: Response) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username and password required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3 || password.length < 4) {
|
||||
res.status(400).json({ error: 'Username min 3 chars, password min 4 chars' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = queryOne('SELECT id FROM users WHERE username = ?', [username]);
|
||||
if (existing) {
|
||||
res.status(409).json({ error: 'Username already taken' });
|
||||
return;
|
||||
}
|
||||
|
||||
const password_hash = bcrypt.hashSync(password, 10);
|
||||
execute('INSERT INTO users (username, password_hash) VALUES (?, ?)', [username, password_hash]);
|
||||
|
||||
const user = queryOne('SELECT id, username, points FROM users WHERE username = ?', [username]);
|
||||
|
||||
const token = generateToken(user.id, user.username);
|
||||
res.status(201).json({ token, user });
|
||||
});
|
||||
|
||||
router.post('/login', (req: Request, res: Response) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
const user = queryOne('SELECT id, username, password_hash, points FROM users WHERE username = ?', [username]);
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = generateToken(user.id, user.username);
|
||||
res.json({ token, user: { id: user.id, username: user.username, points: user.points } });
|
||||
});
|
||||
|
||||
router.get('/me', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||
const user = queryOne('SELECT id, username, points, created_at FROM users WHERE id = ?', [req.userId]);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ user });
|
||||
});
|
||||
|
||||
export default router;
|
||||
133
server/src/routes/cats.ts
Normal file
133
server/src/routes/cats.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Router, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { queryOne, queryAll, execute } from '../db';
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const uploadsDir = path.join(__dirname, '..', '..', 'uploads');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadsDir),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, allowed.includes(ext));
|
||||
},
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (req: AuthRequest, res: Response) => {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const limit = 12;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const cats = queryAll(
|
||||
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
|
||||
u.username, u.points AS user_points,
|
||||
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count
|
||||
FROM cats c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
const countResult = queryOne('SELECT COUNT(*) as count FROM cats');
|
||||
const total = countResult?.count || 0;
|
||||
|
||||
res.json({ cats, total, page, totalPages: Math.ceil(total / limit) });
|
||||
});
|
||||
|
||||
router.get('/user/:userId', (req: AuthRequest, res: Response) => {
|
||||
const userId = parseInt(req.params.userId);
|
||||
const cats = queryAll(
|
||||
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
|
||||
u.username, u.points AS user_points,
|
||||
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count
|
||||
FROM cats c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.user_id = ?
|
||||
ORDER BY c.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.json({ cats });
|
||||
});
|
||||
|
||||
router.get('/:id', (req: AuthRequest, res: Response) => {
|
||||
const cat = queryOne(
|
||||
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
|
||||
u.username, u.points AS user_points,
|
||||
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count
|
||||
FROM cats c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.id = ?`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (!cat) {
|
||||
res.status(404).json({ error: 'Cat not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ cat });
|
||||
});
|
||||
|
||||
router.post('/', authMiddleware, upload.single('image'), (req: AuthRequest, res: Response) => {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Image required (jpg, png, gif, webp, max 10MB)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const caption = (req.body.caption || '').trim();
|
||||
const image_url = `/uploads/${req.file.filename}`;
|
||||
|
||||
execute(
|
||||
'INSERT INTO cats (user_id, image_url, caption) VALUES (?, ?, ?)',
|
||||
[req.userId, image_url, caption]
|
||||
);
|
||||
|
||||
execute('UPDATE users SET points = points + 10 WHERE id = ?', [req.userId]);
|
||||
|
||||
const cat = queryOne(
|
||||
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
|
||||
u.username, u.points AS user_points,
|
||||
0 AS likes_count
|
||||
FROM cats c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.id = (SELECT MAX(id) FROM cats WHERE user_id = ?)`,
|
||||
[req.userId]
|
||||
);
|
||||
|
||||
res.status(201).json({ cat });
|
||||
});
|
||||
|
||||
router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||
const cat = queryOne('SELECT user_id FROM cats WHERE id = ?', [req.params.id]);
|
||||
if (!cat) {
|
||||
res.status(404).json({ error: 'Cat not found' });
|
||||
return;
|
||||
}
|
||||
if (cat.user_id !== req.userId) {
|
||||
res.status(403).json({ error: 'Not your cat' });
|
||||
return;
|
||||
}
|
||||
|
||||
execute('DELETE FROM likes WHERE cat_id = ?', [req.params.id]);
|
||||
execute('DELETE FROM cats WHERE id = ?', [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
70
server/src/routes/likes.ts
Normal file
70
server/src/routes/likes.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { queryOne, execute } from '../db';
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/:catId', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||
const catId = parseInt(req.params.catId);
|
||||
const userId = req.userId!;
|
||||
|
||||
const cat = queryOne('SELECT user_id FROM cats WHERE id = ?', [catId]);
|
||||
if (!cat) {
|
||||
res.status(404).json({ error: 'Cat not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (cat.user_id === userId) {
|
||||
res.status(400).json({ error: 'Cannot like your own cat' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = queryOne('SELECT id FROM likes WHERE cat_id = ? AND user_id = ?', [catId, userId]);
|
||||
if (existing) {
|
||||
res.status(409).json({ error: 'Already liked' });
|
||||
return;
|
||||
}
|
||||
|
||||
execute('INSERT INTO likes (cat_id, user_id) VALUES (?, ?)', [catId, userId]);
|
||||
execute('UPDATE users SET points = points + 1 WHERE id = ?', [cat.user_id]);
|
||||
|
||||
const countResult = queryOne('SELECT COUNT(*) as count FROM likes WHERE cat_id = ?', [catId]);
|
||||
const owner = queryOne('SELECT points FROM users WHERE id = ?', [cat.user_id]);
|
||||
|
||||
res.json({ liked: true, likesCount: countResult?.count || 0, ownerPoints: owner?.points || 0 });
|
||||
});
|
||||
|
||||
router.delete('/:catId', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||
const catId = parseInt(req.params.catId);
|
||||
const userId = req.userId!;
|
||||
|
||||
const cat = queryOne('SELECT user_id FROM cats WHERE id = ?', [catId]);
|
||||
if (!cat) {
|
||||
res.status(404).json({ error: 'Cat not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = queryOne('SELECT id FROM likes WHERE cat_id = ? AND user_id = ?', [catId, userId]);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Not liked yet' });
|
||||
return;
|
||||
}
|
||||
|
||||
execute('DELETE FROM likes WHERE id = ?', [existing.id]);
|
||||
execute('UPDATE users SET points = MAX(0, points - 1) WHERE id = ?', [cat.user_id]);
|
||||
|
||||
const countResult = queryOne('SELECT COUNT(*) as count FROM likes WHERE cat_id = ?', [catId]);
|
||||
const owner = queryOne('SELECT points FROM users WHERE id = ?', [cat.user_id]);
|
||||
|
||||
res.json({ liked: false, likesCount: countResult?.count || 0, ownerPoints: owner?.points || 0 });
|
||||
});
|
||||
|
||||
router.get('/:catId/status', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||
const catId = parseInt(req.params.catId);
|
||||
const userId = req.userId!;
|
||||
|
||||
const liked = queryOne('SELECT id FROM likes WHERE cat_id = ? AND user_id = ?', [catId, userId]);
|
||||
res.json({ liked: !!liked });
|
||||
});
|
||||
|
||||
export default router;
|
||||
15
server/tsconfig.json
Normal file
15
server/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user