Share with your community
Add a title, a thought, or an image.
@@ -62,8 +67,8 @@ export function PostComposer({ onCreate }) {
- {error ?
{error}
: null}
- {message ?
{message}
: null}
+ {error ?
{error}
: null}
+ {message ?
{message}
: null}
);
}
diff --git a/src/components/PostList.jsx b/src/components/PostList.jsx
index 7581fcf..7959dae 100644
--- a/src/components/PostList.jsx
+++ b/src/components/PostList.jsx
@@ -3,7 +3,7 @@ import { PostCard } from "./PostCard";
const POSTS_PER_BATCH = 6;
-export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete }) {
+export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete, getProfile, fetchImage }) {
const [visibleCount, setVisibleCount] = useState(POSTS_PER_BATCH);
const loadMoreRef = useRef(null);
@@ -44,6 +44,8 @@ export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete })
loadImage={loadImage}
onEdit={onEdit}
onDelete={onDelete}
+ getProfile={getProfile}
+ fetchImage={fetchImage}
/>
))}
{visibleCount < posts.length ? (
diff --git a/src/components/ProfileEditor.jsx b/src/components/ProfileEditor.jsx
new file mode 100644
index 0000000..90975c4
--- /dev/null
+++ b/src/components/ProfileEditor.jsx
@@ -0,0 +1,116 @@
+import { useState } from "react";
+
+export function ProfileEditor({ profile, onSave, onCancel }) {
+ const [name, setName] = useState(profile?.name || "");
+ const [username, setUsername] = useState(profile?.username || "");
+ const [image, setImage] = useState(null);
+ const [removeImage, setRemoveImage] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ async function handleSubmit(event) {
+ event.preventDefault();
+ setError("");
+
+ if (!name.trim()) {
+ setError("Name is required.");
+ return;
+ }
+
+ if (/\d/.test(name)) {
+ setError("Name must not contain numbers.");
+ return;
+ }
+
+ if (image && removeImage) {
+ setError("Cannot upload and remove an image at the same time.");
+ return;
+ }
+
+ if (image && !image.type.startsWith("image/")) {
+ setError("Only image files are accepted.");
+ return;
+ }
+
+ setSaving(true);
+ try {
+ await onSave({
+ name: name.trim(),
+ username: username.trim() || undefined,
+ image: image || undefined,
+ removeImage: removeImage || undefined,
+ });
+ } catch (saveError) {
+ setError(saveError.message || "Unable to save profile.");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
event.stopPropagation()}>
+
+
Edit Profile
+
+
+
+ {error ?
{error}
: null}
+
+
+ );
+}
diff --git a/src/components/Shell.jsx b/src/components/Shell.jsx
index 90a2de4..8aaab6c 100644
--- a/src/components/Shell.jsx
+++ b/src/components/Shell.jsx
@@ -1,20 +1,7 @@
import { Link, NavLink } from "react-router-dom";
+import { AuthorAvatar } from "./AuthorAvatar";
-function getInitials(name) {
- if (!name) return "?";
- const cleaned = String(name).replace(/^@/, "");
- return (
- cleaned
- .split(" ")
- .filter(Boolean)
- .slice(0, 2)
- .map((part) => part[0])
- .join("")
- .toUpperCase() || "?"
- );
-}
-
-export function Shell({ user, onLogout, children }) {
+export function Shell({ user, onLogout, children, fetchImage }) {
const displayName = user?.name || (user?.username ? `@${user.username}` : "Your Account");
const displaySub = user?.username && user?.name ? `@${user.username}` : "";
@@ -27,20 +14,50 @@ export function Shell({ user, onLogout, children }) {
Archive your social world.
-
{getInitials(displayName)}
+
{displayName}
{displaySub}
diff --git a/src/components/Toast.jsx b/src/components/Toast.jsx
new file mode 100644
index 0000000..6af9b44
--- /dev/null
+++ b/src/components/Toast.jsx
@@ -0,0 +1,36 @@
+export function ToastContainer({ toasts }) {
+ if (!toasts.length) return null;
+
+ return (
+
+ {toasts.map((toast) => (
+
+
+ {toast.type === "success" ? (
+
+ ) : toast.type === "error" ? (
+
+ ) : (
+
+ )}
+
+ {toast.message}
+
+ ))}
+
+ );
+}
diff --git a/src/components/useToast.js b/src/components/useToast.js
new file mode 100644
index 0000000..769a29c
--- /dev/null
+++ b/src/components/useToast.js
@@ -0,0 +1,25 @@
+import { useCallback, useState } from "react";
+
+let toastId = 0;
+
+export function useToast() {
+ const [toasts, setToasts] = useState([]);
+
+ const removeToast = useCallback((id) => {
+ setToasts((current) =>
+ current.map((t) => (t.id === id ? { ...t, leaving: true } : t)),
+ );
+ setTimeout(() => {
+ setToasts((current) => current.filter((t) => t.id !== id));
+ }, 280);
+ }, []);
+
+ const showToast = useCallback((message, type) => {
+ const id = ++toastId;
+ setToasts((current) => [...current, { id, message, type: type || "default" }]);
+ setTimeout(() => removeToast(id), 3500);
+ return id;
+ }, [removeToast]);
+
+ return { toasts, showToast, removeToast };
+}
diff --git a/src/index.css b/src/index.css
index 0667000..1d93e25 100644
--- a/src/index.css
+++ b/src/index.css
@@ -7,9 +7,16 @@
--panel: #fffcf6;
--accent: #c45a3d;
--accent-dark: #9a3e27;
+ --accent-light: rgba(196, 90, 61, 0.1);
+ --accent-glow: rgba(196, 90, 61, 0.25);
--line: #d8d1c4;
--ok: #2d8a58;
+ --ok-light: rgba(45, 138, 88, 0.1);
--error: #b24343;
+ --error-light: rgba(178, 67, 67, 0.1);
+ --shadow-sm: 0 4px 12px rgba(31, 42, 43, 0.04);
+ --shadow-md: 0 8px 24px rgba(31, 42, 43, 0.05);
+ --shadow-lg: 0 22px 50px rgba(31, 42, 43, 0.12);
--display: "Fraunces", serif;
--body: "Manrope", sans-serif;
@@ -33,3 +40,41 @@ body {
#root {
min-height: 100vh;
}
+
+:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+::selection {
+ background: var(--accent);
+ color: #fff;
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--line);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--muted);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/src/pages/CreatePage.jsx b/src/pages/CreatePage.jsx
index 69a3865..ec1c0f1 100644
--- a/src/pages/CreatePage.jsx
+++ b/src/pages/CreatePage.jsx
@@ -1,11 +1,12 @@
import { useNavigate } from "react-router-dom";
import { PostComposer } from "../components/PostComposer";
-export function CreatePage({ createPost }) {
+export function CreatePage({ createPost, showToast }) {
const navigate = useNavigate();
async function handleCreate(data) {
await createPost(data);
+ showToast("Post created!", "success");
navigate("/feed");
}
diff --git a/src/pages/FeedPage.jsx b/src/pages/FeedPage.jsx
index d9cd561..d44655e 100644
--- a/src/pages/FeedPage.jsx
+++ b/src/pages/FeedPage.jsx
@@ -2,7 +2,15 @@ import { useCallback, useEffect, useState } from "react";
import { PostList } from "../components/PostList";
function getPosts(data) {
- return Array.isArray(data) ? data : data.posts || data.items || [];
+ const posts = Array.isArray(data) ? data : data.posts || data.items || [];
+ return posts.sort((a, b) => {
+ const aDate = a.created_at || a.createdAt;
+ const bDate = b.created_at || b.createdAt;
+ if (!aDate && !bDate) return 0;
+ if (!aDate) return 1;
+ if (!bDate) return -1;
+ return new Date(bDate) - new Date(aDate);
+ });
}
function getAuthorId(post) {
@@ -16,7 +24,28 @@ function postsFromOthers(posts, currentUserId) {
return posts.filter((post) => String(getAuthorId(post)) !== String(currentUserId));
}
-export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUserId }) {
+function FeedSkeleton() {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+}
+
+export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUserId, showToast, getProfile, fetchImage }) {
const [posts, setPosts] = useState([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
@@ -39,11 +68,13 @@ export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUs
async function handleEdit(id, data) {
await editPost(id, data);
+ showToast("Post updated.", "success");
await refresh();
}
async function handleDelete(id) {
await deletePost(id);
+ showToast("Post deleted.", "success");
await refresh();
}
@@ -56,16 +87,16 @@ export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUs
Explore community posts
- {error ?
{error}
: null}
+ {error ?
{error}
: null}
{loading ? (
-
Loading community feed...
+
) : posts.length === 0 ? (
No posts yet
When others share posts, they will show up here.
) : (
-
+
)}
);
diff --git a/src/pages/FriendsPage.jsx b/src/pages/FriendsPage.jsx
new file mode 100644
index 0000000..91b9bfa
--- /dev/null
+++ b/src/pages/FriendsPage.jsx
@@ -0,0 +1,194 @@
+import { useCallback, useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { AuthorAvatar } from "../components/AuthorAvatar";
+
+function getList(data) {
+ return Array.isArray(data) ? data : data.friends || data.items || data.requests || [];
+}
+
+function getDisplayName(person) {
+ return person?.name || (person?.username ? `@${person.username}` : "User");
+}
+
+function getHandle(person) {
+ return person?.username || null;
+}
+
+export function FriendsPage({
+ friends,
+ friendRequests,
+ acceptRequest,
+ declineRequest,
+ removeFriend,
+ getProfile,
+ fetchImage,
+ showToast,
+}) {
+ const [friendsList, setFriendsList] = useState([]);
+ const [requestsList, setRequestsList] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [busyId, setBusyId] = useState(null);
+
+ const refresh = useCallback(() => {
+ setLoading(true);
+ setError("");
+ return Promise.all([friends(), friendRequests()])
+ .then(([friendsData, requestsData]) => {
+ setFriendsList(getList(friendsData));
+ setRequestsList(getList(requestsData));
+ })
+ .catch((loadError) => setError(loadError.message || "Unable to load friends."))
+ .finally(() => setLoading(false));
+ }, [friends, friendRequests]);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ async function handleAccept(person) {
+ setBusyId(person.id);
+ try {
+ await acceptRequest(person.id);
+ showToast(`You are now friends with ${getDisplayName(person)}.`, "success");
+ await refresh();
+ } catch (actionError) {
+ showToast(actionError.message || "Unable to accept request.", "error");
+ } finally {
+ setBusyId(null);
+ }
+ }
+
+ async function handleDecline(person) {
+ setBusyId(person.id);
+ try {
+ await declineRequest(person.id);
+ showToast(`Friend request from ${getDisplayName(person)} declined.`, "success");
+ await refresh();
+ } catch (actionError) {
+ showToast(actionError.message || "Unable to decline request.", "error");
+ } finally {
+ setBusyId(null);
+ }
+ }
+
+ async function handleRemove(person) {
+ if (!window.confirm(`Remove ${getDisplayName(person)} from your friends?`)) {
+ return;
+ }
+ setBusyId(person.id);
+ try {
+ await removeFriend(person.id);
+ showToast(`${getDisplayName(person)} removed from friends.`, "success");
+ await refresh();
+ } catch (actionError) {
+ showToast(actionError.message || "Unable to remove friend.", "error");
+ } finally {
+ setBusyId(null);
+ }
+ }
+
+ function PersonRow({ person, children }) {
+ const name = getDisplayName(person);
+ const handle = getHandle(person);
+ return (
+
+
+
+
+ {name}
+ {handle && !name.startsWith("@") ? @{handle} : null}
+
+
+ {children}
+
+ );
+ }
+
+ return (
+
+
+ Your social circle
+ Friends
+
+
+ {error ? {error}
: null}
+
+ {loading ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : (
+ <>
+
+
+ Friend Requests
+ {requestsList.length > 0 ? {requestsList.length} : null}
+
+ {requestsList.length === 0 ? (
+
No incoming friend requests.
+ ) : (
+
+ {requestsList.map((person) => (
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ Your Friends
+ {friendsList.length > 0 ? {friendsList.length} : null}
+
+ {friendsList.length === 0 ? (
+
You have no friends yet. Send a request from a profile page.
+ ) : (
+
+ {friendsList.map((person) => (
+
+
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx
index 195f368..eb44177 100644
--- a/src/pages/LandingPage.jsx
+++ b/src/pages/LandingPage.jsx
@@ -1,5 +1,15 @@
import { Link } from "react-router-dom";
+function FeatureCard({ icon, title, text }) {
+ return (
+
+
+ {title}
+ {text}
+
+ );
+}
+
export function LandingPage() {
return (
@@ -19,6 +29,30 @@ export function LandingPage() {
+
+
+
+
+
+
+
+
);
}
diff --git a/src/pages/PostPage.jsx b/src/pages/PostPage.jsx
new file mode 100644
index 0000000..8f565a3
--- /dev/null
+++ b/src/pages/PostPage.jsx
@@ -0,0 +1,223 @@
+import { useCallback, useEffect, useState } from "react";
+import { Link, useParams } from "react-router-dom";
+import { Lightbox } from "../components/Lightbox";
+
+function getImageFilename(post) {
+ if (!post) return null;
+ let img = post.image_link || post.imageLink || post.image || post.image_filename || post.imageFilename || post.image_url || post.imageUrl || post.image_path || post.imagePath || post.img || post.photo || post.picture || post.filename || post.file_name || post.fileName || post.file || post.filepath || post.file_path || post.media || post.media_url || post.media_filename || post.media_link || post.attachment || post.url || post.link || null;
+ if (typeof img === "object" && img !== null) {
+ img = img.url || img.filename || img.src || img.path || img.name || img.link || null;
+ }
+ if (!img) {
+ const text = post.text || post.content || post.body || post.description || "";
+ const match = text.match(/(?:(?:https?:\/\/[^\s]+|API_BASE)?\/posts\/image\/[^\s"'<>)]+)/i) || text.match(/https?:\/\/[^\s]+\.(?:png|jpe?g|gif|webp|svg|avif)/i);
+ if (match) img = match[0];
+ }
+ return img;
+}
+
+function getAuthorName(post) {
+ return post.author?.name || post.author_name || (post.author_username ? `@${post.author_username}` : "User");
+}
+
+function getAuthorUsername(post) {
+ return post.author_username || post.author?.username || null;
+}
+
+function getInitials(name) {
+ if (!name) return "?";
+ const cleaned = String(name).replace(/^@/, "");
+ return cleaned.split(" ").filter(Boolean).slice(0, 2).map((part) => part[0]).join("").toUpperCase() || "?";
+}
+
+function formatDate(timestamp) {
+ if (!timestamp) return null;
+ try {
+ const date = new Date(timestamp);
+ if (Number.isNaN(date.getTime())) return String(timestamp);
+ return new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" }).format(date);
+ } catch {
+ return String(timestamp);
+ }
+}
+
+function AuthenticatedImage({ filename, alt, loadImage }) {
+ const [source, setSource] = useState("");
+ const [error, setError] = useState("");
+ const [lightboxOpen, setLightboxOpen] = useState(false);
+
+ useEffect(() => {
+ let active = true;
+ let objectUrl = "";
+ setError("");
+ if (!filename) {
+ setSource("");
+ return;
+ }
+ if (typeof loadImage === "function") {
+ loadImage(filename).then((result) => {
+ if (!active) return;
+ if (result instanceof Blob) {
+ objectUrl = URL.createObjectURL(result);
+ setSource(objectUrl);
+ } else if (typeof result === "string") {
+ setSource(result);
+ }
+ setError("");
+ }).catch(() => {
+ if (!active) return;
+ if (typeof filename === "string") {
+ const apiBase = import.meta.env.VITE_API_BASE_URL || "";
+ let directUrl = filename;
+ if (directUrl.startsWith("API_BASE")) {
+ directUrl = directUrl.replace(/^API_BASE/, apiBase);
+ } else if (directUrl.includes("/posts/image/")) {
+ const imagePart = directUrl.substring(directUrl.indexOf("/posts/image/") + "/posts/image/".length);
+ directUrl = `${apiBase}/posts/image/${encodeURIComponent(decodeURIComponent(imagePart))}`;
+ } else if (!directUrl.startsWith("http://") && !directUrl.startsWith("https://")) {
+ directUrl = `${apiBase}/posts/image/${encodeURIComponent(decodeURIComponent(directUrl.split("/").pop()))}`;
+ }
+ setSource(directUrl);
+ setError("");
+ } else {
+ setError("Image unavailable.");
+ }
+ });
+ } else if (typeof filename === "string") {
+ setSource(filename);
+ }
+ return () => {
+ active = false;
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ };
+ }, [filename, loadImage]);
+
+ if (error) return
{error}
;
+
+ return (
+ <>
+ {source ? (
+

setLightboxOpen(true)} onError={() => setError("Image unavailable.")} />
+ ) : null}
+
setLightboxOpen(false)} />
+ >
+ );
+}
+
+export function PostPage({ getPost, loadImage }) {
+ const { postId } = useParams();
+ const [post, setPost] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [liked, setLiked] = useState(false);
+ const [likeCount, setLikeCount] = useState(0);
+
+ const load = useCallback(() => {
+ setLoading(true);
+ setError("");
+ return getPost(postId).then((data) => {
+ setPost(data);
+ setLikeCount(Math.floor(Math.random() * 24) + 3);
+ setLiked(false);
+ }).catch((err) => {
+ setError(err.message || "Unable to load post.");
+ }).finally(() => setLoading(false));
+ }, [postId, getPost]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ function handleLike() {
+ if (liked) {
+ setLikeCount((c) => c - 1);
+ } else {
+ setLikeCount((c) => c + 1);
+ }
+ setLiked((prev) => !prev);
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+ Back to feed
+
+ );
+ }
+
+ if (!post) return null;
+
+ const imageFilename = getImageFilename(post);
+ const authorId = post.author_id ?? post.authorId ?? post.author?.id;
+ const authorName = getAuthorName(post);
+ const authorUsername = getAuthorUsername(post);
+ const formattedDate = formatDate(post.created_at || post.createdAt);
+ const profilePath = authorId ? `/profile/${authorId}` : "/profile";
+
+ return (
+
+
+
+ Back to feed
+
+
+
+
+
+
{getInitials(authorName)}
+
+ {authorName}
+ {authorUsername && !authorName.startsWith("@") ? @{authorUsername} : null}
+
+
+ {formattedDate ?
{formattedDate} : null}
+
+
+ {imageFilename ? : null}
+
+
+ {post.title ?
{post.title}
: null}
+
{post.text ?? post.content}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/ProfilePage.jsx b/src/pages/ProfilePage.jsx
index 227438b..6210abd 100644
--- a/src/pages/ProfilePage.jsx
+++ b/src/pages/ProfilePage.jsx
@@ -1,9 +1,22 @@
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { PostList } from "../components/PostList";
+import { ProfileEditor } from "../components/ProfileEditor";
function getPosts(data) {
- return Array.isArray(data) ? data : data.posts || data.items || [];
+ const posts = Array.isArray(data) ? data : data.posts || data.items || [];
+ return posts.sort((a, b) => {
+ const aDate = a.created_at || a.createdAt;
+ const bDate = b.created_at || b.createdAt;
+ if (!aDate && !bDate) return 0;
+ if (!aDate) return 1;
+ if (!bDate) return -1;
+ return new Date(bDate) - new Date(aDate);
+ });
+}
+
+function getList(data) {
+ return Array.isArray(data) ? data : data.friends || data.items || data.requests || [];
}
function getInitials(name) {
@@ -26,12 +39,22 @@ export function ProfilePage({
getProfile,
loadMyPosts,
loadAllPosts,
- createRelationship,
+ friends,
+ friendRequests,
+ sendFriendRequest,
+ acceptFriendRequest,
+ declineFriendRequest,
+ cancelFriendRequest,
+ removeFriend,
editPost,
deletePost,
loadImage,
currentUserId,
onLogout,
+ showToast,
+ updateProfile,
+ fetchImage,
+ onProfileUpdate,
}) {
const { userId } = useParams();
const isOwnProfile = !userId || String(userId) === String(currentUserId);
@@ -40,14 +63,18 @@ export function ProfilePage({
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
- const [following, setFollowing] = useState(false);
- const [followMessage, setFollowMessage] = useState("");
+ const [editing, setEditing] = useState(false);
+ const [profileImageUrl, setProfileImageUrl] = useState("");
+ const [friendStatus, setFriendStatus] = useState("none");
+ const [friendBusy, setFriendBusy] = useState(false);
+ const [friendMessage, setFriendMessage] = useState("");
+ const profileImageUrlRef = useRef("");
const [chatNotice, setChatNotice] = useState("");
const loadData = useCallback(async () => {
setLoading(true);
setError("");
- setFollowMessage("");
+ setFriendMessage("");
setChatNotice("");
try {
@@ -74,6 +101,22 @@ export function ProfilePage({
setProfileData({ name: "User Profile", username: null });
}
+ setFriendStatus("none");
+ if (friends && friendRequests) {
+ try {
+ const [friendsData, requestsData] = await Promise.all([friends(), friendRequests()]);
+ const friendIds = getList(friendsData).map((p) => String(p.id));
+ const requestIds = getList(requestsData).map((p) => String(p.id));
+ if (friendIds.includes(String(userId))) {
+ setFriendStatus("friends");
+ } else if (requestIds.includes(String(userId))) {
+ setFriendStatus("incoming");
+ }
+ } catch {
+ // Friendship status unavailable; fall back to "Add friend"
+ }
+ }
+
const allPostsData = await loadAllPosts();
const allPosts = getPosts(allPostsData);
const userPosts = allPosts.filter((post) => {
@@ -87,26 +130,145 @@ export function ProfilePage({
} finally {
setLoading(false);
}
- }, [isOwnProfile, userId, myProfile, getProfile, loadMyPosts, loadAllPosts]);
+ }, [isOwnProfile, userId, myProfile, getProfile, loadMyPosts, loadAllPosts, friends, friendRequests]);
useEffect(() => {
loadData();
}, [loadData]);
- async function handleFollow() {
- if (!userId) return;
- try {
- if (createRelationship) {
- await createRelationship(userId);
+ useEffect(() => {
+ let active = true;
+ const link = profileData?.profile_link || profileData?.profileLink || profileData?.avatar || null;
+
+ if (link && fetchImage) {
+ fetchImage(link)
+ .then((blob) => {
+ if (!active) return;
+ const url = URL.createObjectURL(blob);
+ profileImageUrlRef.current = url;
+ setProfileImageUrl(url);
+ })
+ .catch(() => {
+ if (!active) return;
+ setProfileImageUrl("");
+ });
+ } else {
+ setProfileImageUrl("");
+ }
+
+ return () => {
+ active = false;
+ if (profileImageUrlRef.current) {
+ URL.revokeObjectURL(profileImageUrlRef.current);
+ profileImageUrlRef.current = "";
}
- setFollowing(true);
- const nameOrHandle = profileData?.name || (profileData?.username ? `@${profileData.username}` : "this user");
- setFollowMessage(`You are now following ${nameOrHandle}!`);
- } catch {
- // Unbinded preview fallback
- setFollowing(true);
- const nameOrHandle = profileData?.name || (profileData?.username ? `@${profileData.username}` : "this user");
- setFollowMessage(`Follow relationship recorded for ${nameOrHandle}`);
+ };
+ }, [profileData, fetchImage]);
+
+ async function handleSaveProfile(data) {
+ await updateProfile(data);
+ const updated = await myProfile();
+ setProfileData(updated);
+ if (onProfileUpdate) {
+ onProfileUpdate(updated);
+ }
+ setEditing(false);
+ showToast("Profile updated.", "success");
+ }
+
+ async function handleSendFriendRequest() {
+ if (!userId) return;
+ setFriendBusy(true);
+ const nameOrHandle = profileData?.name || (profileData?.username ? `@${profileData.username}` : "this user");
+ try {
+ if (sendFriendRequest) {
+ await sendFriendRequest(userId);
+ }
+ setFriendStatus("requested");
+ setFriendMessage(`Friend request sent to ${nameOrHandle}.`);
+ showToast(`Friend request sent to ${nameOrHandle}`, "success");
+ } catch (err) {
+ setFriendMessage(err.message || "Unable to send friend request.");
+ showToast(err.message || "Unable to send friend request.", "error");
+ } finally {
+ setFriendBusy(false);
+ }
+ }
+
+ async function handleCancelFriendRequest() {
+ if (!userId) return;
+ setFriendBusy(true);
+ try {
+ if (cancelFriendRequest) {
+ await cancelFriendRequest(userId);
+ }
+ setFriendStatus("none");
+ setFriendMessage("Friend request cancelled.");
+ showToast("Friend request cancelled.", "success");
+ } catch (err) {
+ setFriendMessage(err.message || "Unable to cancel friend request.");
+ showToast(err.message || "Unable to cancel friend request.", "error");
+ } finally {
+ setFriendBusy(false);
+ }
+ }
+
+ async function handleAcceptFriendRequest() {
+ if (!userId) return;
+ setFriendBusy(true);
+ const nameOrHandle = profileData?.name || (profileData?.username ? `@${profileData.username}` : "this user");
+ try {
+ if (acceptFriendRequest) {
+ await acceptFriendRequest(userId);
+ }
+ setFriendStatus("friends");
+ setFriendMessage(`You and ${nameOrHandle} are now friends.`);
+ showToast(`You are now friends with ${nameOrHandle}`, "success");
+ } catch (err) {
+ setFriendMessage(err.message || "Unable to accept friend request.");
+ showToast(err.message || "Unable to accept friend request.", "error");
+ } finally {
+ setFriendBusy(false);
+ }
+ }
+
+ async function handleDeclineFriendRequest() {
+ if (!userId) return;
+ setFriendBusy(true);
+ try {
+ if (declineFriendRequest) {
+ await declineFriendRequest(userId);
+ }
+ setFriendStatus("none");
+ setFriendMessage("Friend request declined.");
+ showToast("Friend request declined.", "success");
+ } catch (err) {
+ setFriendMessage(err.message || "Unable to decline friend request.");
+ showToast(err.message || "Unable to decline friend request.", "error");
+ } finally {
+ setFriendBusy(false);
+ }
+ }
+
+ async function handleRemoveFriend() {
+ if (!userId) return;
+ const nameOrHandle = profileData?.name || (profileData?.username ? `@${profileData.username}` : "this user");
+ if (!window.confirm(`Remove ${nameOrHandle} from your friends?`)) {
+ return;
+ }
+ setFriendBusy(true);
+ try {
+ if (removeFriend) {
+ await removeFriend(userId);
+ }
+ setFriendStatus("none");
+ setFriendMessage(`${nameOrHandle} removed from friends.`);
+ showToast(`${nameOrHandle} removed from friends.`, "success");
+ } catch (err) {
+ setFriendMessage(err.message || "Unable to remove friend.");
+ showToast(err.message || "Unable to remove friend.", "error");
+ } finally {
+ setFriendBusy(false);
}
}
@@ -116,11 +278,13 @@ export function ProfilePage({
async function handleEdit(id, data) {
await editPost(id, data);
+ showToast("Post updated.", "success");
await loadData();
}
async function handleDelete(id) {
await deletePost(id);
+ showToast("Post deleted.", "success");
await loadData();
}
@@ -136,7 +300,13 @@ export function ProfilePage({
-
{getInitials(displayName)}
+
+ {profileImageUrl ? (
+

+ ) : (
+ getInitials(displayName)
+ )}
+
{displayName}
{displayUsername ?
@{displayUsername}
: null}
@@ -148,8 +318,12 @@ export function ProfilePage({
{onLogout ? (
@@ -160,31 +334,87 @@ export function ProfilePage({
>
) : (
<>
-
+ {friendStatus === "none" ? (
+
+ ) : null}
+ {friendStatus === "requested" ? (
+
+ ) : null}
+ {friendStatus === "incoming" ? (
+ <>
+
+
+ >
+ ) : null}
+ {friendStatus === "friends" ? (
+
+ ) : null}
>
)}
- {followMessage ? {followMessage}
: null}
+ {friendMessage ? {friendMessage}
: null}
{chatNotice ? {chatNotice}
: null}
- {error ? {error}
: null}
+ {error ? {error}
: null}
+
+ {editing && isOwnProfile ? (
+ setEditing(false)}
+ />
+ ) : null}
{isOwnProfile ? "Your posts" : `Posts by ${displayName}`}
@@ -193,7 +423,7 @@ export function ProfilePage({
) : posts.length === 0 ? (
{isOwnProfile ? "You haven't created any posts yet." : "No posts found for this user."}
) : (
-
+
)}
diff --git a/src/validation/auth.js b/src/validation/auth.js
index 76a8fc2..849f391 100644
--- a/src/validation/auth.js
+++ b/src/validation/auth.js
@@ -150,8 +150,8 @@ export function getRegistrationFieldErrors(form) {
}
function validateLoginPassword(password) {
- if (password.length < 1 || password.length > 128) {
- return "Password must be between 1 and 128 characters.";
+ if (password.length < 12 || password.length > 128) {
+ return "Password must be at least 12 characters.";
}
return null;
}