Feed and user Feed done

This commit is contained in:
MisbehavedNinjaRadiator 2025-06-30 18:50:04 +02:00
parent 205b2d70c4
commit cbab45f0de
29 changed files with 331 additions and 10621 deletions

View file

@ -1,26 +0,0 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
// use like this: onClick={() => navigate(`/feed/${username}#${post.id}`)}
function ScrollToAnchor() {
const location = useLocation();
const lastHash = useRef('');
useEffect(() => {
if (location.hash) {
lastHash.current = location.hash.slice(1);
}
if (lastHash.current && document.getElementById(lastHash.current)) {
setTimeout(() => {
document
.getElementById(lastHash.current)
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
lastHash.current = '';
}, 100);
}
}, [location]);
return null;
}
export default ScrollToAnchor;

View file

@ -1,29 +1,45 @@
import { Avatar, Box, Typography } from "@mui/material";
import api from "../api/axios";
import { useState,useEffect } from "react";
import { useState, useEffect } from "react";
interface UserAvatarProps {
username: string|null;
username: string | null;
src?: string;
size?: number | string;
onClick?: () => void;
}
export default function UserAvatar({ username, size = 40 }: UserAvatarProps) {
const[pb, setPb] = useState<string>();
export default function UserAvatar({
username,
size = 40,
onClick,
}: UserAvatarProps) {
const [pb, setPb] = useState<string>();
useEffect(() => {
useEffect(() => {
(async () => {
try {
const res = await api.get(`/profile/getProfilePicture/${username}`)
setPb((res.data as { url: string }).url);
} catch (error) {
console.log(error);
const res = await api.get(`/profile/getProfilePicture/${username}`);
setPb((res.data as { url: string }).url);
} catch (error: any) {
if (error.status !== 404) {
console.log(error);
}
}
})();
}, [username]);
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, maxWidth: "600px"}}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
maxWidth: "600px",
cursor: onClick ? "pointer" : "default",
}}
onClick={onClick}
>
<Avatar
src={pb}
alt={username || "avatar"}
@ -39,10 +55,14 @@ export default function UserAvatar({ username, size = 40 }: UserAvatarProps) {
<Typography
component="span"
fontWeight={500}
sx={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{username}
</Typography>
</Box>
);
}
}

View file

@ -0,0 +1,15 @@
import { useNavigate } from "react-router-dom";
import ButtonRotkehlchen from "./buttonRotkehlchen/ButtonRotkehlchen";
export default function LogInButton() {
const navigate = useNavigate();
return (
<ButtonRotkehlchen
style={"primary"}
label={"Login"}
type={"button"}
onClick={() => navigate("/login")}
/>
);
}

View file

@ -0,0 +1,15 @@
import { useNavigate } from "react-router-dom";
import ButtonRotkehlchen from "./buttonRotkehlchen/ButtonRotkehlchen";
export default function LogInButton() {
const navigate = useNavigate();
return (
<ButtonRotkehlchen
style={"secondary"}
label={"Sign Up"}
type={"button"}
onClick={() => navigate("/login")}
/>
);
}

View file

@ -1,130 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
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, useParams } from "react-router-dom";
interface PostListItem {
id: string;
createdAt: string;
description: string;
}
interface FeedProps {
username?: string;
}
function Feed({ username }: FeedProps) {
const [posts, setPosts] = useState<PostListItem[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
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;
setLoading(true);
try {
let url: string;
if (username) {
url = `/posts/getUserPosts/${encodeURIComponent(username)}`;
const response = await api.get<{ posts: PostListItem[] }>(url);
setPosts(response.data.posts);
setHasMore(false);
} else {
url = `/feed?limit=${PAGE_SIZE}`;
if (nextCursor) {
url = `/feed?createdAt=${encodeURIComponent(nextCursor)}&limit=${PAGE_SIZE}`;
}
interface FeedResponse {
posts: PostListItem[];
nextCursor: string | null;
}
const response = await api.get<FeedResponse>(url);
const { posts: newPosts, nextCursor: newCursor } = response.data;
setPosts((prev) => [...prev, ...newPosts]);
setNextCursor(newCursor);
setHasMore(!!newCursor && newPosts.length > 0);
}
} catch (error) {
console.error("Error fetching posts:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPosts();
}, []);
useEffect(() => {
if (username) return;
const onScroll = () => {
if (loading || !hasMore) return;
if (
window.innerHeight + window.scrollY >=
document.body.offsetHeight - 100
) {
fetchPosts();
}
};
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
}, [loading, hasMore, nextCursor, username]);
useEffect(() => {
setPosts([]);
setNextCursor(null);
setHasMore(true);
fetchPosts();
}, [username]);
return (
<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) => (
<div id={post.id} key={post.id}>
<Post postId={post.id} />
</div>
))}
{loading && <div className="loading">Loading more posts...</div>}
{!hasMore && <div>No more posts</div>}
</main>
</div>
);
}
export function UserFeedRoute() {
const { user } = useParams<{ user: string }>();
return <Feed username={user} />;
}
export default Feed;

View file

@ -1,50 +0,0 @@
.feedContainer {
min-height: 100vh;
/* Subtract he */
}
.feedContent {
flex: 1;
overflow-y: auto;
display: flex;
flex-wrap: wrap;
justify-content: center;
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 1rem 0;
}
.loading {
width: 100%;
text-align: center;
margin-top: 1rem;
font-weight: bold;
color: #333;
}
/* Desktop responsive behavior */
@media (min-width: 768px) {
.feedContainer {
display: grid;
grid-template-columns: 40% 60%;
}
.welcome-for-logged-out {
position: sticky;
top: 5rem;
left: 5rem;
right: 1rem;
align-self: center;
align-self: flex-start;
}
.feedContent {
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 2rem 0;
}
}

View file

@ -1,16 +0,0 @@
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,26 @@
import { useNavigate } from "react-router-dom";
import "./naggingFooter.css";
import LogInButton from "../buttons/LogInButton";
import SignUpButton from "../buttons/SignUpButton";
export default function NaggingFooter() {
const navigate = useNavigate();
return (
<div className="footer-container">
<img
className="header-icon header-icon-feather"
src="/assets/icons/BirdIconO.ico"
alt="featherIcon"
onClick={() => navigate("/")}
/>
<p className="header-title" onClick={() => navigate("/")}>
Feather Feed
</p>
<div className="footer-spacer" />
<div className="signup-button">
<SignUpButton />
</div>
<LogInButton />
</div>
);
}

View file

@ -0,0 +1,61 @@
.footer-container {
position: fixed;
left: 0;
bottom: 0;
width: 100vw;
min-height: 32px;
max-height: 15vh;
background-color: var(--Rotkehlchen-gray);
z-index: 2000;
display: flex;
flex-wrap: nowrap;
min-width: 0;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
}
@media (min-width: 768px) {
.footer-container {
display: none;
}
}
.header-title {
white-space: nowrap;
font-size: 1.5rem;
font-weight: bold;
margin-left: 0.3rem;
margin-right: 0;
flex-shrink: 0;
}
.footer-spacer {
flex: 1 1 2rem;
min-width: 0.5rem;
max-width: 2.5rem;
}
.signup-button,
.footer-container button,
.header-icon-feather {
flex-shrink: 0;
}
.signup-button {
white-space: nowrap;
font-size: 0.95rem;
margin-right: 0.2rem;
padding-right: 0;
}
.footer-container button {
margin: 0 !important;
padding: 8px;
margin-right: 6px !important;
}
.header-icon-feather {
width: 30px;
margin: 0px !important;
margin-left: 6px !important;
}

View file

@ -6,25 +6,23 @@ import CardMedia from "@mui/material/CardMedia";
import CardContent from "@mui/material/CardContent";
import CardActions from "@mui/material/CardActions";
import Collapse from "@mui/material/Collapse";
import Avatar from "@mui/material/Avatar";
import IconButton, { IconButtonProps } from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import { red } from "@mui/material/colors";
import FavoriteIcon from "@mui/icons-material/Favorite";
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 api from "../../api/axios";
import { Url } from "url";
import { LogLevel } from "vite";
import "./post.css";
import UserAvatar from "./UserAvatar";
import UserAvatar from "../UserAvatar";
import { useNavigate } from "react-router-dom";
import { useEffect, useRef, useCallback } from "react";
interface ExpandMoreProps extends IconButtonProps {
expand: boolean;
}
interface PostProps {
postId: string;
autoScroll?: boolean;
}
interface PostResponse {
description: string;
@ -58,17 +56,17 @@ const ExpandMore = styled((props: ExpandMoreProps) => {
transform: expand ? "rotate(180deg)" : "rotate(0deg)",
}));
export default function Post({ postId }: PostProps) {
export default function Post({ postId, autoScroll }: 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);
const navigate = useNavigate();
const postRef = useRef<HTMLDivElement>(null);
const scrollTimeoutsRef = useRef<number[]>([]);
React.useEffect(() => {
getPostbyID();
}, [postId]);
async function getPostbyID(): Promise<void> {
// Using useCallback to memoize the function and avoid dependency issues
const getPostbyID = useCallback(async (): Promise<void> => {
try {
const response = await api.get<PostResponse>(
`/posts/getPost/{postId}?postId=${postId}`
@ -79,7 +77,43 @@ export default function Post({ postId }: PostProps) {
} catch (error) {
console.error("Failed to fetch post:", error);
}
}
}, [postId]);
// Auto-scroll effect with cleanup for timeouts
useEffect(() => {
if (autoScroll && post) {
console.log("Preparing to scroll to post:", postId);
// Clear any existing timeouts first
scrollTimeoutsRef.current.forEach(clearTimeout);
scrollTimeoutsRef.current = [];
// Using forEach instead of map since we don't need to return values
[50, 100, 300, 500, 1000, 1500].forEach((delay) => {
const timeoutId = window.setTimeout(() => {
if (postRef.current) {
console.log(`Scrolling attempt at ${delay}ms`);
postRef.current.scrollIntoView({
behavior: "auto",
block: "start",
});
}
}, delay);
scrollTimeoutsRef.current.push(timeoutId);
});
}
// Clean up timeouts when component unmounts or dependencies change
return () => {
scrollTimeoutsRef.current.forEach(clearTimeout);
scrollTimeoutsRef.current = [];
};
}, [autoScroll, post, postId]);
// Fetch post data when postId changes
useEffect(() => {
getPostbyID();
}, [getPostbyID]); // Now getPostbyID is a dependency
if (!post) {
return (
@ -122,9 +156,19 @@ export default function Post({ postId }: PostProps) {
return (
<StyledEngineProvider injectFirst>
<Card className="body-l" sx={{ maxWidth: 600, width: "100%", margin: 2 }}>
<Card
ref={postRef}
className="body-l"
sx={{ maxWidth: 600, width: "100%", margin: 2 }}
>
<CardHeader
avatar={<UserAvatar username={post.user.name} size={60} />}
avatar={
<UserAvatar
username={post.user.name}
size={60}
onClick={() => navigate(`/profile/${post.user.name}`)}
/>
}
/>
{images.length > 0 && (
<div className="post-image-carousel">
@ -187,20 +231,16 @@ export default function Post({ postId }: PostProps) {
</CardActions>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<CardContent>
<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>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Status: {post.status}
{post.tags && post.tags.length > 0 && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Tags: {post.tags.join(", ")}
</Typography>
)}
<Typography variant="body2" color="text.secondary">
Created at: {new Date(post.createdAt).toLocaleString()}
</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()}
Last updated: {new Date(post.updatedAt).toLocaleString()}
</Typography>
</CardContent>
</Collapse>

View file

@ -5,7 +5,7 @@ import { useState } from "react";
import "./bio.css";
import IconButton from "@mui/material/IconButton";
import EditSquareIcon from "@mui/icons-material/EditSquare";
import ButtonPrimary from "../ButtonRotkehlchen";
import ButtonPrimary from "../buttons/buttonRotkehlchen/ButtonRotkehlchen";
import api from "../../api/axios";
export default function BioTextField({ ownAccount, bioText, setBio } : { ownAccount: boolean, bioText: string | undefined, setBio: (bio: string) => void }) {

View file

@ -14,7 +14,7 @@ import {
import CloseIcon from "@mui/icons-material/Close";
import EditSquareIcon from "@mui/icons-material/EditSquare";
import "./changeAvatarDialog.css";
import ButtonRotkehlchen from "../ButtonRotkehlchen";
import ButtonRotkehlchen from "../buttons/buttonRotkehlchen/ButtonRotkehlchen";
import Username from "./Username";
import "./username.css";
import api from "../../api/axios";
@ -55,17 +55,17 @@ export default function AvatarDialog({
console.log("Saving profile picture:", file);
if (file) {
formData.append("image", file);
const response = await api.post(
const response = await api.post<{ url: string }>(
"/profile/uploadProfilePicture",
formData
);
console.log("Profile picture saved:", response.data);
setUserData((prevData) => (prevData ?{
setUserData((prevData) => (prevData ? {
...prevData,
profilePictureUrl: response.data.url
} : null));
}
setOpen(false); // Close the dialog after saving
setOpen(false);
} catch (error) {
console.error("Error saving profile picture:", error);
}

View file

@ -7,6 +7,16 @@ import { useEffect, useState } from "react";
import { UserProfile } from "../../types/UserProfile";
import { useNavigate } from "react-router-dom";
type Post = {
id: string;
description: string;
createdAt: string;
};
type PostImagesResponse = {
images: { url: string }[];
};
export default function StandardImageList({ user }: { user: UserProfile }) {
const navigate = useNavigate();
const [images, setImages] = useState<
@ -22,17 +32,15 @@ export default function StandardImageList({ user }: { user: UserProfile }) {
const fetchUserPosts = async () => {
try {
const response = await api.get(`/posts/getUserPosts/${user.username}`);
const response = await api.get<{ posts: Post[] }>(
`/posts/getUserPosts/${user.username}`
);
const posts = response.data.posts;
const fetchedImages = await Promise.all(
posts.map(
async (post: {
id: string;
description: string;
createdAt: string;
}) => {
async (post: Post) => {
try {
const response = await api.get(
const response = await api.get<PostImagesResponse>(
`/posts/getPost/{postId}?postId=${post.id}`
);
if (response.data && response.data.images.length > 0) {
@ -42,6 +50,7 @@ export default function StandardImageList({ user }: { user: UserProfile }) {
id: post.id,
description: post.description || "",
createdAt: post.createdAt,
};
}
} catch (error) {
@ -51,7 +60,12 @@ export default function StandardImageList({ user }: { user: UserProfile }) {
)
);
console.log("Fetched images:", fetchedImages);
setImages(fetchedImages.filter((image) => image !== undefined));
setImages(
fetchedImages.filter(
(image): image is { imageUrl: string; id: string; description: string; createdAt: string } =>
image !== undefined
)
);
} catch (error) {
console.error("Error fetching user posts:", error);
}
@ -68,10 +82,7 @@ export default function StandardImageList({ user }: { user: UserProfile }) {
<img
src={item.imageUrl}
alt={item.description}
onClick={
() => navigate("/feed")
// anchor to post that was clicked
}
onClick={() => navigate(`/feed/${user.username}#${item.id}`)}
loading="lazy"
/>
) : (

View file

@ -0,0 +1,15 @@
import "./welcomeMessage.css";
export default function WelcomeMessage() {
return (
<div className="welcome-message">
<h1 className="welcome-title">Welcome!</h1>
<p className="welcome-text">Join our bird-loving community!</p>
<p className="desktop-welcome-text">
Exchange pictures, share your experiences, and discover interesting
facts about birds from all over the world. Celebrate
the beauty and diversity of birds with us!
</p>
</div>
);
}