add likes and friends

This commit is contained in:
Sven laptop
2026-07-31 15:14:46 +02:00
parent 374fc9fb67
commit b9fcce3d61
13 changed files with 555 additions and 53 deletions
+82
View File
@@ -1312,3 +1312,85 @@ textarea {
padding: 0.9rem; padding: 0.9rem;
} }
/* Settings Page */
.settings-page {
max-width: 680px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
.settings-section {
margin-bottom: 2rem;
}
.settings-action-row {
margin-top: 0.75rem;
}
.session-list {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.session-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.9rem;
}
.session-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.session-device {
font-weight: 700;
display: flex;
align-items: center;
gap: 0.5rem;
}
.session-current {
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #fff;
background: var(--accent);
border-radius: 1rem;
padding: 0.15rem 0.55rem;
}
.session-meta {
font-size: 0.8rem;
color: var(--muted);
}
.danger-zone {
border-top: 2px solid var(--error);
padding-top: 1.25rem;
}
.account-delete-form {
margin-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.field-divider {
height: 1px;
background: var(--line);
margin: 0.5rem 0;
}
.hint-text {
margin-top: -0.5rem;
}
+24
View File
@@ -12,6 +12,7 @@ import { LoginPage } from "./pages/LoginPage";
import { PostPage } from "./pages/PostPage"; import { PostPage } from "./pages/PostPage";
import { ProfilePage } from "./pages/ProfilePage"; import { ProfilePage } from "./pages/ProfilePage";
import { RegisterPage } from "./pages/RegisterPage"; import { RegisterPage } from "./pages/RegisterPage";
import { SettingsPage } from "./pages/SettingsPage";
import { clearAuthSession, getAuthSession, saveAuthSession } from "./utils/cookie"; import { clearAuthSession, getAuthSession, saveAuthSession } from "./utils/cookie";
import "./App.css"; import "./App.css";
@@ -222,6 +223,8 @@ function App() {
showToast={showToast} showToast={showToast}
getProfile={(id) => apiClient.getProfile(session.token, id)} getProfile={(id) => apiClient.getProfile(session.token, id)}
fetchImage={loadProfileImage} fetchImage={loadProfileImage}
likePost={(id) => apiClient.likePost(session.token, id)}
unlikePost={(id) => apiClient.unlikePost(session.token, id)}
/> />
} }
/> />
@@ -231,6 +234,8 @@ function App() {
<PostPage <PostPage
getPost={(id) => apiClient.getPost(session.token, id)} getPost={(id) => apiClient.getPost(session.token, id)}
loadImage={(filename) => apiClient.postImage(session.token, filename)} loadImage={(filename) => apiClient.postImage(session.token, filename)}
likePost={(id) => apiClient.likePost(session.token, id)}
unlikePost={(id) => apiClient.unlikePost(session.token, id)}
/> />
} }
/> />
@@ -246,6 +251,7 @@ function App() {
<ProfilePage <ProfilePage
user={currentUser} user={currentUser}
myProfile={() => apiClient.myProfile(session.token)} myProfile={() => apiClient.myProfile(session.token)}
authMe={() => apiClient.authMe(session.token)}
getProfile={(id) => apiClient.getProfile(session.token, id)} getProfile={(id) => apiClient.getProfile(session.token, id)}
loadMyPosts={() => apiClient.myPosts(session.token)} loadMyPosts={() => apiClient.myPosts(session.token)}
loadAllPosts={() => apiClient.posts(session.token)} loadAllPosts={() => apiClient.posts(session.token)}
@@ -259,6 +265,8 @@ function App() {
updateProfile={(data) => apiClient.updateProfile(session.token, data)} updateProfile={(data) => apiClient.updateProfile(session.token, data)}
fetchImage={(url) => apiClient.fetchImage(session.token, url)} fetchImage={(url) => apiClient.fetchImage(session.token, url)}
onProfileUpdate={setUserProfile} onProfileUpdate={setUserProfile}
likePost={(id) => apiClient.likePost(session.token, id)}
unlikePost={(id) => apiClient.unlikePost(session.token, id)}
/> />
} }
/> />
@@ -268,6 +276,7 @@ function App() {
<ProfilePage <ProfilePage
user={currentUser} user={currentUser}
myProfile={() => apiClient.myProfile(session.token)} myProfile={() => apiClient.myProfile(session.token)}
authMe={() => apiClient.authMe(session.token)}
getProfile={(id) => apiClient.getProfile(session.token, id)} getProfile={(id) => apiClient.getProfile(session.token, id)}
loadMyPosts={() => apiClient.myPosts(session.token)} loadMyPosts={() => apiClient.myPosts(session.token)}
loadAllPosts={() => apiClient.posts(session.token)} loadAllPosts={() => apiClient.posts(session.token)}
@@ -279,6 +288,8 @@ function App() {
onLogout={logout} onLogout={logout}
showToast={showToast} showToast={showToast}
fetchImage={(url) => apiClient.fetchImage(session.token, url)} fetchImage={(url) => apiClient.fetchImage(session.token, url)}
likePost={(id) => apiClient.likePost(session.token, id)}
unlikePost={(id) => apiClient.unlikePost(session.token, id)}
/> />
} }
/> />
@@ -296,6 +307,19 @@ function App() {
/> />
} }
/> />
<Route
path="/settings"
element={
<SettingsPage
sessions={() => apiClient.sessions(session.token)}
deleteAllSessions={() => apiClient.deleteAllSessions(session.token)}
deleteSession={(id) => apiClient.deleteSession(session.token, id)}
deleteAccount={(password) => apiClient.deleteAccount(session.token, password)}
onAccountDeleted={logout}
showToast={showToast}
/>
}
/>
<Route path="*" element={<Navigate to="/feed" replace />} /> <Route path="*" element={<Navigate to="/feed" replace />} />
</Routes> </Routes>
<ToastContainer toasts={toasts} onRemove={removeToast} /> <ToastContainer toasts={toasts} onRemove={removeToast} />
+33 -9
View File
@@ -68,6 +68,16 @@ export const apiClient = {
request(`/profiles/${encodeURIComponent(id)}`, { token }), request(`/profiles/${encodeURIComponent(id)}`, { token }),
posts: (token) => request("/posts", { token }), posts: (token) => request("/posts", { token }),
getPost: (token, id) => request(`/posts/${encodeURIComponent(id)}`, { token }), getPost: (token, id) => request(`/posts/${encodeURIComponent(id)}`, { token }),
likePost: (token, id) =>
request(`/posts/like?id=${encodeURIComponent(id)}`, {
method: "POST",
token,
}),
unlikePost: (token, id) =>
request(`/posts/like?id=${encodeURIComponent(id)}`, {
method: "DELETE",
token,
}),
myPosts: (token) => request("/posts/me", { token }), myPosts: (token) => request("/posts/me", { token }),
createPost: (token, { title, text, image }) => { createPost: (token, { title, text, image }) => {
const formData = new FormData(); const formData = new FormData();
@@ -103,7 +113,7 @@ export const apiClient = {
method: "DELETE", method: "DELETE",
token, token,
}), }),
updateProfile: (token, { name, username, image, removeImage }) => { updateProfile: (token, { name, username, email, password, currentPassword, privateFlag, image, removeImage }) => {
const formData = new FormData(); const formData = new FormData();
if (name !== undefined) { if (name !== undefined) {
formData.append("name", name); formData.append("name", name);
@@ -111,6 +121,18 @@ export const apiClient = {
if (username !== undefined) { if (username !== undefined) {
formData.append("username", username); formData.append("username", username);
} }
if (email !== undefined) {
formData.append("email", email);
}
if (password !== undefined) {
formData.append("password", password);
}
if (currentPassword !== undefined) {
formData.append("current_password", currentPassword);
}
if (privateFlag !== undefined) {
formData.append("private", String(privateFlag));
}
if (image) { if (image) {
formData.append("image", image); formData.append("image", image);
} }
@@ -119,14 +141,16 @@ export const apiClient = {
} }
return request("/profiles/me", { method: "PUT", token, formData }); return request("/profiles/me", { method: "PUT", token, formData });
}, },
createRelationship: (token, me, them) => deleteAccount: (token, password) =>
request( request("/profiles/me", { method: "DELETE", token, body: { password } }),
`/create_relationship?me=${encodeURIComponent(me)}&them=${encodeURIComponent(them)}`, sessions: (token) => request("/auth/sessions", { token }),
{ deleteAllSessions: (token) =>
method: "POST", request("/auth/sessions", { method: "DELETE", token }),
token, deleteSession: (token, id) =>
}, request(`/auth/sessions/${encodeURIComponent(id)}`, {
), method: "DELETE",
token,
}),
friends: (token) => request("/friends", { token }), friends: (token) => request("/friends", { token }),
friendRequests: (token) => request("/friends/requests", { token }), friendRequests: (token) => request("/friends/requests", { token }),
sendFriendRequest: (token, to) => sendFriendRequest: (token, to) =>
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
export function LikeButton({ postId, count = 0, liked = false, onLike, onUnlike, onError }) {
const [isLiked, setIsLiked] = useState(Boolean(liked));
const [likeCount, setLikeCount] = useState(Number(count) || 0);
const [busy, setBusy] = useState(false);
useEffect(() => {
setIsLiked(Boolean(liked));
setLikeCount(Number(count) || 0);
}, [liked, count]);
async function handleClick() {
if (busy) return;
if (typeof onLike !== "function" || typeof onUnlike !== "function") return;
const wasLiked = isLiked;
setBusy(true);
setIsLiked(!wasLiked);
setLikeCount((current) => current + (wasLiked ? -1 : 1));
try {
if (wasLiked) {
await onUnlike(postId);
} else {
await onLike(postId);
}
} catch (error) {
setIsLiked(wasLiked);
setLikeCount((current) => current + (wasLiked ? 1 : -1));
if (typeof onError === "function") {
onError(error);
}
} finally {
setBusy(false);
}
}
return (
<button
type="button"
className={`like-btn ${isLiked ? "like-btn-active" : ""}`}
onClick={handleClick}
disabled={busy}
aria-label={isLiked ? "Unlike" : "Like"}
aria-pressed={isLiked}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill={isLiked ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
<span className="like-count">{likeCount}</span>
</button>
);
}
+12 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { AuthorAvatar } from "./AuthorAvatar"; import { AuthorAvatar } from "./AuthorAvatar";
import { Lightbox } from "./Lightbox"; import { Lightbox } from "./Lightbox";
import { LikeButton } from "./LikeButton";
function getImageFilename(post) { function getImageFilename(post) {
if (!post) return null; if (!post) return null;
@@ -158,7 +159,7 @@ function AuthenticatedImage({ filename, alt, loadImage }) {
); );
} }
export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete, getProfile, fetchImage }) { export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete, getProfile, fetchImage, likePost, unlikePost, onLikeError }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [form, setForm] = useState({ title: post.title || "", text: post.text || "", image: null, removeImage: false }); const [form, setForm] = useState({ title: post.title || "", text: post.text || "", image: null, removeImage: false });
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -278,6 +279,16 @@ export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete, get
</div> </div>
</Link> </Link>
{imageFilename ? <AuthenticatedImage filename={imageFilename} alt={post.title || "Post image"} loadImage={loadImage} /> : null} {imageFilename ? <AuthenticatedImage filename={imageFilename} alt={post.title || "Post image"} loadImage={loadImage} /> : null}
<div className="post-engagement">
<LikeButton
postId={post.id}
count={post.like_count}
liked={post.liked_by_me}
onLike={likePost}
onUnlike={unlikePost}
onError={onLikeError}
/>
</div>
</> </>
)} )}
{error ? <p className="error-text">{error}</p> : null} {error ? <p className="error-text">{error}</p> : null}
+4 -1
View File
@@ -3,7 +3,7 @@ import { PostCard } from "./PostCard";
const POSTS_PER_BATCH = 6; const POSTS_PER_BATCH = 6;
export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete, getProfile, fetchImage }) { export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete, getProfile, fetchImage, likePost, unlikePost, onLikeError }) {
const [visibleCount, setVisibleCount] = useState(POSTS_PER_BATCH); const [visibleCount, setVisibleCount] = useState(POSTS_PER_BATCH);
const loadMoreRef = useRef(null); const loadMoreRef = useRef(null);
@@ -46,6 +46,9 @@ export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete, ge
onDelete={onDelete} onDelete={onDelete}
getProfile={getProfile} getProfile={getProfile}
fetchImage={fetchImage} fetchImage={fetchImage}
likePost={likePost}
unlikePost={unlikePost}
onLikeError={onLikeError}
/> />
))} ))}
{visibleCount < posts.length ? ( {visibleCount < posts.length ? (
+112 -11
View File
@@ -1,8 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { validateEmail, validatePasswordRule } from "../validation/auth";
export function ProfileEditor({ profile, onSave, onCancel }) { export function ProfileEditor({ profile, onSave, onCancel }) {
const [name, setName] = useState(profile?.name || ""); const [name, setName] = useState(profile?.name || "");
const [username, setUsername] = useState(profile?.username || ""); const [username, setUsername] = useState(profile?.username || "");
const [email, setEmail] = useState(profile?.email || "");
const [privateFlag, setPrivateFlag] = useState(Boolean(profile?.private));
const [newPassword, setNewPassword] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [image, setImage] = useState(null); const [image, setImage] = useState(null);
const [removeImage, setRemoveImage] = useState(false); const [removeImage, setRemoveImage] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -12,16 +17,39 @@ export function ProfileEditor({ profile, onSave, onCancel }) {
event.preventDefault(); event.preventDefault();
setError(""); setError("");
if (!name.trim()) { const trimmedName = name.trim();
setError("Name is required."); const trimmedUsername = username.trim();
const normalizedEmail = email.trim().toLowerCase();
if (trimmedName.length < 1 || trimmedName.length > 100) {
setError("Name must be between 1 and 100 characters.");
return; return;
} }
if (/\d/.test(name)) { if (trimmedUsername.length > 50 || /\s/.test(trimmedUsername)) {
setError("Name must not contain numbers."); setError("Username must be 150 characters and cannot contain whitespace.");
return; return;
} }
const emailError = email.trim() ? validateEmail(email, { checkDisposable: true }) : null;
if (emailError) {
setError(emailError);
return;
}
if (newPassword && !currentPassword) {
setError("Enter your current password to set a new password.");
return;
}
if (newPassword) {
const passwordError = validatePasswordRule(newPassword, trimmedUsername, normalizedEmail);
if (passwordError) {
setError(passwordError);
return;
}
}
if (image && removeImage) { if (image && removeImage) {
setError("Cannot upload and remove an image at the same time."); setError("Cannot upload and remove an image at the same time.");
return; return;
@@ -32,14 +60,46 @@ export function ProfileEditor({ profile, onSave, onCancel }) {
return; return;
} }
const nameChanged = trimmedName !== (profile?.name || "");
const usernameChanged = trimmedUsername !== (profile?.username || "");
const emailChanged = normalizedEmail !== (profile?.email || "");
const privateChanged = privateFlag !== Boolean(profile?.private);
const passwordChanged = Boolean(newPassword);
const imageChanged = Boolean(image);
const removeChanged = removeImage;
if (!nameChanged && !usernameChanged && !emailChanged && !privateChanged && !passwordChanged && !imageChanged && !removeChanged) {
setError("No changes to save.");
return;
}
const data = {};
if (nameChanged) {
data.name = trimmedName;
}
if (usernameChanged) {
data.username = trimmedUsername;
}
if (emailChanged) {
data.email = normalizedEmail;
}
if (privateChanged) {
data.privateFlag = privateFlag;
}
if (passwordChanged) {
data.password = newPassword;
data.currentPassword = currentPassword;
}
if (imageChanged) {
data.image = image;
}
if (removeChanged) {
data.removeImage = true;
}
setSaving(true); setSaving(true);
try { try {
await onSave({ await onSave(data);
name: name.trim(),
username: username.trim() || undefined,
image: image || undefined,
removeImage: removeImage || undefined,
});
} catch (saveError) { } catch (saveError) {
setError(saveError.message || "Unable to save profile."); setError(saveError.message || "Unable to save profile.");
} finally { } finally {
@@ -64,7 +124,7 @@ export function ProfileEditor({ profile, onSave, onCancel }) {
<span>Name</span> <span>Name</span>
<input <input
value={name} value={name}
onChange={(event) => setName(event.target.value.replace(/\d/g, ""))} onChange={(event) => setName(event.target.value)}
required required
maxLength={100} maxLength={100}
/> />
@@ -78,6 +138,47 @@ export function ProfileEditor({ profile, onSave, onCancel }) {
placeholder="Optional" placeholder="Optional"
/> />
</label> </label>
<label>
<span>Email</span>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
maxLength={254}
/>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={privateFlag}
onChange={(event) => setPrivateFlag(event.target.checked)}
/>
Private account only friends can see your posts and full profile
</label>
<div className="field-divider" />
<label>
<span>Current password</span>
<input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
maxLength={128}
/>
</label>
<label>
<span>New password (optional)</span>
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
maxLength={128}
title="Use at least 12 characters with a lowercase letter, uppercase letter, number, and special character."
/>
</label>
<p className="muted hint-text">Setting a new password signs you out on all devices.</p>
<div className="field-divider" />
<label> <label>
<span>Profile picture</span> <span>Profile picture</span>
<input <input
+9
View File
@@ -53,6 +53,15 @@ export function Shell({ user, onLogout, children, fetchImage }) {
</span> </span>
Profile Profile
</NavLink> </NavLink>
<NavLink to="/settings" className="nav-link">
<span className="nav-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</span>
Settings
</NavLink>
</nav> </nav>
<div className="account-card"> <div className="account-card">
+2 -2
View File
@@ -45,7 +45,7 @@ function FeedSkeleton() {
); );
} }
export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUserId, showToast, getProfile, fetchImage }) { export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUserId, showToast, getProfile, fetchImage, likePost, unlikePost }) {
const [posts, setPosts] = useState([]); const [posts, setPosts] = useState([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -96,7 +96,7 @@ export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUs
<p className="muted">When others share posts, they will show up here.</p> <p className="muted">When others share posts, they will show up here.</p>
</div> </div>
) : ( ) : (
<PostList posts={posts} currentUserId={currentUserId} loadImage={loadImage} onEdit={handleEdit} onDelete={handleDelete} getProfile={getProfile} fetchImage={fetchImage} /> <PostList posts={posts} currentUserId={currentUserId} loadImage={loadImage} onEdit={handleEdit} onDelete={handleDelete} getProfile={getProfile} fetchImage={fetchImage} likePost={likePost} unlikePost={unlikePost} onLikeError={(err) => showToast(err.message || "Unable to update like.", "error")} />
)} )}
</section> </section>
); );
+10 -25
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { Lightbox } from "../components/Lightbox"; import { Lightbox } from "../components/Lightbox";
import { LikeButton } from "../components/LikeButton";
function getImageFilename(post) { function getImageFilename(post) {
if (!post) return null; if (!post) return null;
@@ -104,21 +105,17 @@ function AuthenticatedImage({ filename, alt, loadImage }) {
); );
} }
export function PostPage({ getPost, loadImage }) { export function PostPage({ getPost, loadImage, likePost, unlikePost }) {
const { postId } = useParams(); const { postId } = useParams();
const [post, setPost] = useState(null); const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(0);
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true); setLoading(true);
setError(""); setError("");
return getPost(postId).then((data) => { return getPost(postId).then((data) => {
setPost(data); setPost(data);
setLikeCount(Math.floor(Math.random() * 24) + 3);
setLiked(false);
}).catch((err) => { }).catch((err) => {
setError(err.message || "Unable to load post."); setError(err.message || "Unable to load post.");
}).finally(() => setLoading(false)); }).finally(() => setLoading(false));
@@ -128,15 +125,6 @@ export function PostPage({ getPost, loadImage }) {
load(); load();
}, [load]); }, [load]);
function handleLike() {
if (liked) {
setLikeCount((c) => c - 1);
} else {
setLikeCount((c) => c + 1);
}
setLiked((prev) => !prev);
}
if (loading) { if (loading) {
return ( return (
<section className="post-page"> <section className="post-page">
@@ -205,17 +193,14 @@ export function PostPage({ getPost, loadImage }) {
</div> </div>
<div className="post-engagement"> <div className="post-engagement">
<button <LikeButton
type="button" postId={post.id}
className={`like-btn ${liked ? "like-btn-active" : ""}`} count={post.like_count}
onClick={handleLike} liked={post.liked_by_me}
aria-label={liked ? "Unlike" : "Like"} onLike={likePost}
> onUnlike={unlikePost}
<svg width="20" height="20" viewBox="0 0 24 24" fill={liked ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> onError={(err) => setError(err.message || "Unable to update like.")}
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> />
</svg>
<span className="like-count">{likeCount}</span>
</button>
</div> </div>
</article> </article>
</section> </section>
+17 -3
View File
@@ -36,6 +36,7 @@ function getInitials(name) {
export function ProfilePage({ export function ProfilePage({
user, user,
myProfile, myProfile,
authMe,
getProfile, getProfile,
loadMyPosts, loadMyPosts,
loadAllPosts, loadAllPosts,
@@ -55,6 +56,8 @@ export function ProfilePage({
updateProfile, updateProfile,
fetchImage, fetchImage,
onProfileUpdate, onProfileUpdate,
likePost,
unlikePost,
}) { }) {
const { userId } = useParams(); const { userId } = useParams();
const isOwnProfile = !userId || String(userId) === String(currentUserId); const isOwnProfile = !userId || String(userId) === String(currentUserId);
@@ -82,7 +85,18 @@ export function ProfilePage({
if (myProfile) { if (myProfile) {
try { try {
const me = await myProfile(); const me = await myProfile();
setProfileData(me); let merged = me;
if (authMe) {
try {
const auth = await authMe();
if (auth?.email) {
merged = { ...me, email: auth.email };
}
} catch {
// Keep /profiles/me data
}
}
setProfileData(merged);
} catch { } catch {
// Keep default user prop // Keep default user prop
} }
@@ -130,7 +144,7 @@ export function ProfilePage({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [isOwnProfile, userId, myProfile, getProfile, loadMyPosts, loadAllPosts, friends, friendRequests]); }, [isOwnProfile, userId, myProfile, authMe, getProfile, loadMyPosts, loadAllPosts, friends, friendRequests]);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -423,7 +437,7 @@ export function ProfilePage({
) : posts.length === 0 ? ( ) : posts.length === 0 ? (
<p className="muted">{isOwnProfile ? "You haven't created any posts yet." : "No posts found for this user."}</p> <p className="muted">{isOwnProfile ? "You haven't created any posts yet." : "No posts found for this user."}</p>
) : ( ) : (
<PostList posts={posts} currentUserId={currentUserId} loadImage={loadImage} onEdit={handleEdit} onDelete={handleDelete} getProfile={getProfile} fetchImage={fetchImage} /> <PostList posts={posts} currentUserId={currentUserId} loadImage={loadImage} onEdit={handleEdit} onDelete={handleDelete} getProfile={getProfile} fetchImage={fetchImage} likePost={likePost} unlikePost={unlikePost} onLikeError={(err) => showToast(err.message || "Unable to update like.", "error")} />
)} )}
</div> </div>
</section> </section>
+191
View File
@@ -0,0 +1,191 @@
import { useCallback, useEffect, useState } from "react";
function getSessions(data) {
return Array.isArray(data) ? data : data.sessions || data.items || [];
}
function formatDateTime(timestamp) {
if (!timestamp) return "Unknown";
try {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return String(timestamp);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
} catch {
return String(timestamp);
}
}
export function SettingsPage({ sessions, deleteAllSessions, deleteSession, deleteAccount, onAccountDeleted, showToast }) {
const [sessionList, setSessionList] = useState([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState(null);
const [logAllBusy, setLogAllBusy] = useState(false);
const [error, setError] = useState("");
const [deletePassword, setDeletePassword] = useState("");
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState("");
const refresh = useCallback(() => {
setLoading(true);
setError("");
return sessions()
.then((data) => setSessionList(getSessions(data)))
.catch((loadError) => setError(loadError.message || "Unable to load sessions."))
.finally(() => setLoading(false));
}, [sessions]);
useEffect(() => {
refresh();
}, [refresh]);
async function handleLogoutAll() {
if (!window.confirm("Log out every other device? This keeps your current session active.")) {
return;
}
setLogAllBusy(true);
try {
await deleteAllSessions();
showToast("Logged out all other devices.", "success");
await refresh();
} catch (actionError) {
showToast(actionError.message || "Unable to log out other devices.", "error");
} finally {
setLogAllBusy(false);
}
}
async function handleLogoutSession(id) {
setBusyId(id);
try {
await deleteSession(id);
showToast("Session ended.", "success");
await refresh();
} catch (actionError) {
showToast(actionError.message || "Unable to end that session.", "error");
} finally {
setBusyId(null);
}
}
async function handleDeleteAccount(event) {
event.preventDefault();
setDeleteError("");
if (!deletePassword) {
setDeleteError("Enter your password to confirm account deletion.");
return;
}
if (!window.confirm("This permanently deletes your account, posts, and pictures. This cannot be undone. Continue?")) {
return;
}
setDeleting(true);
try {
await deleteAccount(deletePassword);
showToast("Your account has been deleted.", "success");
onAccountDeleted();
} catch (actionError) {
setDeleteError(actionError.message || "Unable to delete account.");
} finally {
setDeleting(false);
}
}
return (
<section className="settings-page">
<header className="page-head">
<p className="eyebrow">Account control</p>
<h2>Settings</h2>
</header>
<div className="settings-section">
<h3 className="section-title">Active sessions</h3>
<p className="muted">Devices currently signed in to your account.</p>
{error ? <p className="error-text" role="alert">{error}</p> : null}
{loading ? (
<div className="session-list" aria-hidden="true">
{[1, 2].map((i) => (
<div key={i} className="panel skeleton-card skeleton-friend">
<div className="skeleton-lines">
<div className="skeleton skeleton-line" />
<div className="skeleton skeleton-line short" />
</div>
</div>
))}
</div>
) : sessionList.length === 0 ? (
<p className="muted">No active sessions found.</p>
) : (
<ul className="session-list">
{sessionList.map((session) => (
<li key={session.id} className="session-row panel">
<div className="session-details">
<span className="session-device">
{session.device_name || "Unknown device"}
{session.current ? <span className="session-current">current</span> : null}
</span>
<span className="session-meta">
Created {formatDateTime(session.created_at || session.createdAt)}
{session.last_used_at ? ` · Last used ${formatDateTime(session.last_used_at)}` : ""}
</span>
</div>
{!session.current ? (
<button
type="button"
className="btn-profile-secondary friend-action"
disabled={busyId === session.id}
onClick={() => handleLogoutSession(session.id)}
>
Log out
</button>
) : null}
</li>
))}
</ul>
)}
{sessionList.length > 1 ? (
<div className="settings-action-row">
<button
type="button"
className="btn-profile-secondary"
disabled={logAllBusy}
onClick={handleLogoutAll}
>
{logAllBusy ? "Logging out..." : "Log out all other devices"}
</button>
</div>
) : null}
</div>
<div className="settings-section danger-zone">
<h3 className="section-title">Danger zone</h3>
<p className="muted">
Deleting your account permanently removes your posts, images, and profile picture.
</p>
<form onSubmit={handleDeleteAccount} className="post-form account-delete-form">
<label>
<span>Password</span>
<input
type="password"
value={deletePassword}
onChange={(event) => {
setDeletePassword(event.target.value);
setDeleteError("");
}}
autoComplete="current-password"
maxLength={128}
/>
</label>
<button type="submit" className="btn-profile-danger" disabled={deleting}>
{deleting ? "Deleting..." : "Delete account"}
</button>
</form>
{deleteError ? <p className="error-text" role="alert">{deleteError}</p> : null}
</div>
</section>
);
}
+5 -1
View File
@@ -87,7 +87,7 @@ function validateUsername(username) {
return null; return null;
} }
function validateEmail(email, { checkDisposable = false } = {}) { export function validateEmail(email, { checkDisposable = false } = {}) {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
if ( if (
hasControlCharacter(email) || hasControlCharacter(email) ||
@@ -163,6 +163,10 @@ export function getLoginFieldErrors(form) {
}; };
} }
export function validatePasswordRule(password, username, email) {
return validatePassword(password, username, email);
}
export function validateRegistration(form) { export function validateRegistration(form) {
const errors = getRegistrationFieldErrors(form); const errors = getRegistrationFieldErrors(form);
return errors.name || errors.username || errors.email || errors.password || null; return errors.name || errors.username || errors.email || errors.password || null;