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 { description, status, tags } = uploadPostSchema.parse(req.body);
const BUCKET = "images"; // Name of the bucket where the images are stored
try {
const uploads = await Promise.all(
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({
message: "Upload successful",
post: post,
});
} catch (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,
},
});
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) {
res.status(StatusCodes.NOT_FOUND).json({
error: "Post not found",
@ -162,6 +176,7 @@ export const getPost = async (req: Request, res: Response) => {
updatedAt: postObject.updatedAt,
images: images.filter((image) => image !== null), // filter out the null images
following: isFollowing,
hasLiked,
});
return;
} catch (err: any) {
@ -262,8 +277,8 @@ export const like = async (req: Request, res: Response) => {
});
if (alreadyLiked) {
res.status(StatusCodes.CONFLICT).json({
error: "Already following",
details: [{ message: "You are already following this User" }],
error: "Already liked",
details: [{ message: "You already liked this User" }],
});
return;
}

View file

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

View file

@ -3,6 +3,12 @@ import Post from "../Post";
import "./feed.css";
import api from "../../api/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 {
id: string;
@ -17,6 +23,8 @@ function Feed() {
const [nextCursor, setNextCursor] = useState<string | null>(null);
const feedRef = useRef<HTMLDivElement | null>(null);
const PAGE_SIZE = 10;
const { user } = useAuth();
const navigate = useNavigate();
const fetchPosts = async () => {
if (loading || !hasMore) return;
@ -24,8 +32,11 @@ function Feed() {
try {
let url = `/feed?limit=${PAGE_SIZE}`;
if (nextCursor) {
url = `/feed?createdAt=${encodeURIComponent(nextCursor)}&limit=${PAGE_SIZE}`;
url = `/feed?createdAt=${encodeURIComponent(
nextCursor
)}&limit=${PAGE_SIZE}`;
}
interface FeedResponse {
posts: PostListItem[];
nextCursor: string | null;
@ -64,7 +75,24 @@ function Feed() {
}, [loading, hasMore, nextCursor]);
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}>
{posts.length === 0 && !loading && <div>Keine Posts gefunden.</div>}
{posts.map((post) => (

View file

@ -1,19 +1,20 @@
.feedContainer {
display: flex;
flex-direction: column;
min-height: calc(100vh - var(--Header-height));
background-color: #f9f9f9;
min-height: 100vh;
/* Subtract he */
}
.feedContent {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
height: 80vh;
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 1rem 0;
}
.loading {
width: 100%;
@ -26,8 +27,21 @@
/* Desktop responsive behavior */
@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 {
width: 400px;
width: 100%;
max-width: 800px;
margin: 0 auto;
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;
}