Feed done.

This commit is contained in:
MisbehavedNinjaRadiator 2025-06-29 18:06:46 +02:00 committed by MisbehavedNinjaRadiator
parent c3a7776fa5
commit d11f92e11a
7 changed files with 267 additions and 43 deletions

View file

@ -13,7 +13,6 @@ export const uploadPost = async (req: Request, res: Response) => {
const user: JwtPayload = req.user!; // Get the user from the request const user: JwtPayload = req.user!; // Get the user from the request
const { description, status, tags } = uploadPostSchema.parse(req.body); const { description, status, tags } = uploadPostSchema.parse(req.body);
const BUCKET = "images"; // Name of the bucket where the images are stored const BUCKET = "images"; // Name of the bucket where the images are stored
try { try {
const uploads = await Promise.all( const uploads = await Promise.all(
files.map(async (file) => { files.map(async (file) => {
@ -58,15 +57,16 @@ export const uploadPost = async (req: Request, res: Response) => {
})), })),
}, },
}, },
}); }); // create a new post in the database
// create a new post in the database
res.status(StatusCodes.CREATED).json({ res.status(StatusCodes.CREATED).json({
message: "Upload successful", message: "Upload successful",
post: post, post: post,
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: "Upload failed" }); res
.status(StatusCodes.INTERNAL_SERVER_ERROR)
.json({ error: "Upload failed" });
} }
}; };
@ -104,6 +104,20 @@ export const getPost = async (req: Request, res: Response) => {
postId: postId, postId: postId,
}, },
}); });
let hasLiked = false;
if (user) {
const like = await prisma.like.findUnique({
where: {
postId_userId: {
postId: postId,
userId: user.sub!,
},
},
});
if (like) {
hasLiked = true;
}
}
if (!postObject) { if (!postObject) {
res.status(StatusCodes.NOT_FOUND).json({ res.status(StatusCodes.NOT_FOUND).json({
error: "Post not found", error: "Post not found",
@ -162,6 +176,7 @@ export const getPost = async (req: Request, res: Response) => {
updatedAt: postObject.updatedAt, updatedAt: postObject.updatedAt,
images: images.filter((image) => image !== null), // filter out the null images images: images.filter((image) => image !== null), // filter out the null images
following: isFollowing, following: isFollowing,
hasLiked,
}); });
return; return;
} catch (err: any) { } catch (err: any) {
@ -262,8 +277,8 @@ export const like = async (req: Request, res: Response) => {
}); });
if (alreadyLiked) { if (alreadyLiked) {
res.status(StatusCodes.CONFLICT).json({ res.status(StatusCodes.CONFLICT).json({
error: "Already following", error: "Already liked",
details: [{ message: "You are already following this User" }], details: [{ message: "You already liked this User" }],
}); });
return; return;
} }

View file

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { styled } from '@mui/material/styles'; import { styled, StyledEngineProvider } from '@mui/material/styles';
import Card from '@mui/material/Card'; import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader'; import CardHeader from '@mui/material/CardHeader';
import CardMedia from '@mui/material/CardMedia'; import CardMedia from '@mui/material/CardMedia';
@ -15,6 +15,10 @@ import ShareIcon from '@mui/icons-material/Share';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import api from "../api/axios"; import api from "../api/axios";
import { Url } from 'url';
import { LogLevel } from 'vite';
import "./post.css"
interface ExpandMoreProps extends IconButtonProps { interface ExpandMoreProps extends IconButtonProps {
expand: boolean; expand: boolean;
@ -30,6 +34,7 @@ interface PostResponse {
user: { user: {
id: string; id: string;
name: string; name: string;
profilePicture: Url;
}; };
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@ -39,6 +44,7 @@ interface PostResponse {
url: string; url: string;
}[]; }[];
following: boolean; following: boolean;
hasLiked: boolean; // <-- add this
} }
const ExpandMore = styled((props: ExpandMoreProps) => { const ExpandMore = styled((props: ExpandMoreProps) => {
@ -55,17 +61,19 @@ const ExpandMore = styled((props: ExpandMoreProps) => {
export default function Post({ postId }: PostProps) { export default function Post({ postId }: PostProps) {
const [expanded, setExpanded] = React.useState(false); const [expanded, setExpanded] = React.useState(false);
const [post, setPost] = React.useState<PostResponse | null>(null); const [post, setPost] = React.useState<PostResponse | null>(null);
const [currentImage, setCurrentImage] = React.useState(0);
const [like, setLike] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
getPostbyID(); getPostbyID();
// eslint-disable-next-line
}, [postId]); }, [postId]);
async function getPostbyID(): Promise<void> { async function getPostbyID(): Promise<void> {
try { try {
const response = await api.get<PostResponse>(`/posts/getPost/{postId}?postId=${postId}`); const response = await api.get<PostResponse>(`/posts/getPost/{postId}?postId=${postId}`);
//const response = await api.get<PostResponse>(`http://localhost:3001/api/posts/getPost/{postId}?postId=${postId}`);
setPost(response.data); setPost(response.data);
setLike(response.data.hasLiked); // <-- initialize like state
setCurrentImage(0);
} catch (error) { } catch (error) {
console.error("Failed to fetch post:", error); console.error("Failed to fetch post:", error);
} }
@ -73,7 +81,7 @@ export default function Post({ postId }: PostProps) {
if (!post) { if (!post) {
return ( return (
<Card sx={{ maxWidth: 365, margin: 2, width:'100%'}}> <Card sx={{ maxWidth: 400, width: '100%', margin: 2 }}>
<CardContent> <CardContent>
<Typography>Loading...</Typography> <Typography>Loading...</Typography>
</CardContent> </CardContent>
@ -81,8 +89,42 @@ export default function Post({ postId }: PostProps) {
); );
} }
const images = post.images || [];
const hasMultipleImages = images.length > 1;
const handlePrev = (e: React.MouseEvent) => {
e.stopPropagation();
setCurrentImage((prev) => (prev === 0 ? images.length - 1 : prev - 1));
};
const handleNext = (e: React.MouseEvent) => {
e.stopPropagation();
setCurrentImage((prev) => (prev === images.length - 1 ? 0 : prev + 1));
};
const handleLike = async () => {
try {
if (!like) {
await api.post(`/posts/like/${postId}`);
setLike(true);
setPost((prev) =>
prev ? { ...prev, likes: prev.likes + 1 } : prev
);
} else {
await api.delete(`/posts/removeLike/${postId}`);
setLike(false);
setPost((prev) =>
prev ? { ...prev, likes: prev.likes - 1 } : prev
);
}
} catch (error) {
console.error("Failed to update like:", error);
}
};
return ( return (
<Card sx={{ maxWidth: 345, margin: 2 }}> <StyledEngineProvider injectFirst>
<Card className="body-l" sx={{ maxWidth: 600, width: '100%', margin: 2 }}>
<CardHeader <CardHeader
avatar={ avatar={
<Avatar sx={{ bgcolor: red[500] }} aria-label="user"> <Avatar sx={{ bgcolor: red[500] }} aria-label="user">
@ -95,39 +137,59 @@ export default function Post({ postId }: PostProps) {
</IconButton> </IconButton>
} }
title={post.user.name} title={post.user.name}
subheader={new Date(post.createdAt).toLocaleString()}
/> />
{post.images && post.images.length > 0 && ( {images.length > 0 && (
<CardMedia <div className="post-image-carousel">
component="img" <CardMedia
height="194" component="img"
image={post.images[0].url} image={images[currentImage].url}
alt={post.images[0].originalName} alt={images[currentImage].originalName}
/> className="post-image"
/>
{hasMultipleImages && (
<>
<IconButton
aria-label="previous image"
onClick={handlePrev}
className="post-carousel-arrow left"
size="small"
>
{"<"}
</IconButton>
<IconButton
aria-label="next image"
onClick={handleNext}
className="post-carousel-arrow right"
size="small"
>
{">"}
</IconButton>
<div className="post-image-counter">
{currentImage + 1} / {images.length}
</div>
</>
)}
</div>
)} )}
<CardContent> <CardContent>
<Typography variant="body1" sx={{ fontWeight: 600 }}> <Typography variant="body1" sx={{ fontWeight: 600 }}>
{post.description} {post.description}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Status: {post.status}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Likes: {post.likes}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Tags: {post.tags.join(", ")} Tags: {post.tags.join(", ")}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Following: {post.following ? "Ja" : "Nein"}
</Typography>
</CardContent> </CardContent>
<CardActions disableSpacing> <CardActions disableSpacing>
<IconButton aria-label="add to favorites"> <IconButton aria-label="like" onClick={handleLike}>
<FavoriteIcon /> <FavoriteIcon
</IconButton> className="post-like-icon"
<IconButton aria-label="share"> sx={{
<ShareIcon /> color: like ? "#d32f2f" : "#fff",
stroke: !like ? "grey" : "none",
strokeWidth: !like ? 2 : 0
}}
/>
<span className="post-like-count">{post.likes}</span>
</IconButton> </IconButton>
<ExpandMore <ExpandMore
expand={expanded} expand={expanded}
@ -140,14 +202,22 @@ export default function Post({ postId }: PostProps) {
</CardActions> </CardActions>
<Collapse in={expanded} timeout="auto" unmountOnExit> <Collapse in={expanded} timeout="auto" unmountOnExit>
<CardContent> <CardContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Following: {post.following ? "Ja" : "Nein"}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Status: {post.status}
</Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Erstellt am: {new Date(post.createdAt).toLocaleString()} Erstellt am: {new Date(post.createdAt).toLocaleString()}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Zuletzt aktualisiert: {new Date(post.updatedAt).toLocaleString()} Zuletzt aktualisiert: {new Date(post.updatedAt).toLocaleString()}
</Typography> </Typography>
</CardContent> </CardContent>
</Collapse> </Collapse>
</Card> </Card>
</StyledEngineProvider>
); );
} }

View file

@ -3,6 +3,12 @@ import Post from "../Post";
import "./feed.css"; import "./feed.css";
import api from "../../api/axios"; import api from "../../api/axios";
import { create } from "axios"; import { create } from "axios";
import WelcomeMessage from "./welcomeMessage/welcomeMessage";
import { useAuth } from "../../api/Auth";
import ButtonRotkehlchen from "../ButtonRotkehlchen";
import { useNavigate } from "react-router-dom";
interface PostListItem { interface PostListItem {
id: string; id: string;
@ -17,6 +23,8 @@ function Feed() {
const [nextCursor, setNextCursor] = useState<string | null>(null); const [nextCursor, setNextCursor] = useState<string | null>(null);
const feedRef = useRef<HTMLDivElement | null>(null); const feedRef = useRef<HTMLDivElement | null>(null);
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const { user } = useAuth();
const navigate = useNavigate();
const fetchPosts = async () => { const fetchPosts = async () => {
if (loading || !hasMore) return; if (loading || !hasMore) return;
@ -24,8 +32,11 @@ function Feed() {
try { try {
let url = `/feed?limit=${PAGE_SIZE}`; let url = `/feed?limit=${PAGE_SIZE}`;
if (nextCursor) { if (nextCursor) {
url = `/feed?createdAt=${encodeURIComponent(nextCursor)}&limit=${PAGE_SIZE}`; url = `/feed?createdAt=${encodeURIComponent(
nextCursor
)}&limit=${PAGE_SIZE}`;
} }
interface FeedResponse { interface FeedResponse {
posts: PostListItem[]; posts: PostListItem[];
nextCursor: string | null; nextCursor: string | null;
@ -64,7 +75,24 @@ function Feed() {
}, [loading, hasMore, nextCursor]); }, [loading, hasMore, nextCursor]);
return ( return (
<div className="feedContainer"> <div className={user ? "loggedInfeedContainer" : "feedContainer"}>
{!user && (
<div className="welcome-for-logged-out">
<WelcomeMessage />
<ButtonRotkehlchen
style={"secondary"}
label={"Sign Up"}
type={"button"}
onClick={() => navigate("/register")}
/>
<ButtonRotkehlchen
style={"primary"}
label={"Login"}
type={"button"}
onClick={() => navigate("/login")}
/>
</div>
)}
<main className="feedContent" ref={feedRef}> <main className="feedContent" ref={feedRef}>
{posts.length === 0 && !loading && <div>Keine Posts gefunden.</div>} {posts.length === 0 && !loading && <div>Keine Posts gefunden.</div>}
{posts.map((post) => ( {posts.map((post) => (

View file

@ -1,19 +1,20 @@
.feedContainer { .feedContainer {
display: flex; min-height: 100vh;
flex-direction: column; /* Subtract he */
min-height: calc(100vh - var(--Header-height));
background-color: #f9f9f9;
} }
.feedContent { .feedContent {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 1rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 1rem; width: 100%;
height: 80vh; max-width: 800px;
margin: 0 auto;
padding: 1rem 0;
} }
.loading { .loading {
width: 100%; width: 100%;
@ -26,8 +27,21 @@
/* Desktop responsive behavior */ /* Desktop responsive behavior */
@media (min-width: 768px) { @media (min-width: 768px) {
.feedContainer {
display: grid;
grid-template-columns: 40% 60%;
}
.loggedInfeedContainer {
}
.welcome-for-logged-out {
position: sticky;
top: 2rem;
align-self: flex-start;
}
.feedContent { .feedContent {
width: 400px; width: 100%;
max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 0; padding: 2rem 0;
} }

View file

@ -0,0 +1,27 @@
.welcome-title {
color: var(--Rotkehlchen-orange-default);
font-size: 4rem;
font-weight: bolder;
margin-bottom: 0;
}
.welcome-text {
text-align: center;
color: var( --Rotkehlchen-gray);
}
.desktop-welcome-text{
display: none;
}
@media (min-width: 768px) {
.welcome-text{
display: none;
}
.desktop-welcome-text {
display: block;
text-align: center;
color: var(--Rotkehlchen-gray);
font-size: 1.5rem;
margin-top: 1rem;
}
}

View file

@ -0,0 +1,16 @@
import React from "react";
import "./welcomeMessage.css";
export default function WelcomeMessage() {
return (
<div className="welcome-message">
<h1 className="welcome-title">Welcome!</h1>
<p className="welcome-text">
Become a part of our big, bird loving community!
</p>
<p className="desktop-welcome-text">
Exchange pictures and information about birds and be part of our big bird loving community! Pellentesque vulputate a enim ac feugiat. Donec dictum magna sit amet arcu commodo, quis vehicula nunc commodo. Pellentesque varius congue varius.
</p>
</div>
);
}

View file

@ -0,0 +1,54 @@
.css-izap9d-MuiTypography-root {
font-family: "Inter"
}
.post-image-carousel {
position: relative;
width: 100%;
}
.post-image {
width: 100%;
height: auto;
object-fit: contain;
max-height: 700px;
}
.post-carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: var(--Rotkehlchen-gray);
}
.post-carousel-arrow.left {
left: 8px;
}
.post-carousel-arrow.right {
right: 8px;
}
.post-image-counter {
position: absolute;
bottom: 8px;
right: 16px;
background: rgba(0,0,0,0.5);
color: white;
border-radius: 8px;
padding: 2px 8px;
font-size: 0.9rem;
}
.post-like-icon {
font-size: 44px;
}
.post-like-count {
margin-left: 8px;
font-family: "Inter", sans-serif;
color: grey;
}