add likes and friends
This commit is contained in:
+82
@@ -1312,3 +1312,85 @@ textarea {
|
||||
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
@@ -12,6 +12,7 @@ import { LoginPage } from "./pages/LoginPage";
|
||||
import { PostPage } from "./pages/PostPage";
|
||||
import { ProfilePage } from "./pages/ProfilePage";
|
||||
import { RegisterPage } from "./pages/RegisterPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { clearAuthSession, getAuthSession, saveAuthSession } from "./utils/cookie";
|
||||
import "./App.css";
|
||||
|
||||
@@ -222,6 +223,8 @@ function App() {
|
||||
showToast={showToast}
|
||||
getProfile={(id) => apiClient.getProfile(session.token, id)}
|
||||
fetchImage={loadProfileImage}
|
||||
likePost={(id) => apiClient.likePost(session.token, id)}
|
||||
unlikePost={(id) => apiClient.unlikePost(session.token, id)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -231,6 +234,8 @@ function App() {
|
||||
<PostPage
|
||||
getPost={(id) => apiClient.getPost(session.token, id)}
|
||||
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
|
||||
user={currentUser}
|
||||
myProfile={() => apiClient.myProfile(session.token)}
|
||||
authMe={() => apiClient.authMe(session.token)}
|
||||
getProfile={(id) => apiClient.getProfile(session.token, id)}
|
||||
loadMyPosts={() => apiClient.myPosts(session.token)}
|
||||
loadAllPosts={() => apiClient.posts(session.token)}
|
||||
@@ -259,6 +265,8 @@ function App() {
|
||||
updateProfile={(data) => apiClient.updateProfile(session.token, data)}
|
||||
fetchImage={(url) => apiClient.fetchImage(session.token, url)}
|
||||
onProfileUpdate={setUserProfile}
|
||||
likePost={(id) => apiClient.likePost(session.token, id)}
|
||||
unlikePost={(id) => apiClient.unlikePost(session.token, id)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -268,6 +276,7 @@ function App() {
|
||||
<ProfilePage
|
||||
user={currentUser}
|
||||
myProfile={() => apiClient.myProfile(session.token)}
|
||||
authMe={() => apiClient.authMe(session.token)}
|
||||
getProfile={(id) => apiClient.getProfile(session.token, id)}
|
||||
loadMyPosts={() => apiClient.myPosts(session.token)}
|
||||
loadAllPosts={() => apiClient.posts(session.token)}
|
||||
@@ -279,6 +288,8 @@ function App() {
|
||||
onLogout={logout}
|
||||
showToast={showToast}
|
||||
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 />} />
|
||||
</Routes>
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
|
||||
+33
-9
@@ -68,6 +68,16 @@ export const apiClient = {
|
||||
request(`/profiles/${encodeURIComponent(id)}`, { token }),
|
||||
posts: (token) => request("/posts", { 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 }),
|
||||
createPost: (token, { title, text, image }) => {
|
||||
const formData = new FormData();
|
||||
@@ -103,7 +113,7 @@ export const apiClient = {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
updateProfile: (token, { name, username, image, removeImage }) => {
|
||||
updateProfile: (token, { name, username, email, password, currentPassword, privateFlag, image, removeImage }) => {
|
||||
const formData = new FormData();
|
||||
if (name !== undefined) {
|
||||
formData.append("name", name);
|
||||
@@ -111,6 +121,18 @@ export const apiClient = {
|
||||
if (username !== undefined) {
|
||||
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) {
|
||||
formData.append("image", image);
|
||||
}
|
||||
@@ -119,14 +141,16 @@ export const apiClient = {
|
||||
}
|
||||
return request("/profiles/me", { method: "PUT", token, formData });
|
||||
},
|
||||
createRelationship: (token, me, them) =>
|
||||
request(
|
||||
`/create_relationship?me=${encodeURIComponent(me)}&them=${encodeURIComponent(them)}`,
|
||||
{
|
||||
method: "POST",
|
||||
token,
|
||||
},
|
||||
),
|
||||
deleteAccount: (token, password) =>
|
||||
request("/profiles/me", { method: "DELETE", token, body: { password } }),
|
||||
sessions: (token) => request("/auth/sessions", { token }),
|
||||
deleteAllSessions: (token) =>
|
||||
request("/auth/sessions", { method: "DELETE", token }),
|
||||
deleteSession: (token, id) =>
|
||||
request(`/auth/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
friends: (token) => request("/friends", { token }),
|
||||
friendRequests: (token) => request("/friends/requests", { token }),
|
||||
sendFriendRequest: (token, to) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AuthorAvatar } from "./AuthorAvatar";
|
||||
import { Lightbox } from "./Lightbox";
|
||||
import { LikeButton } from "./LikeButton";
|
||||
|
||||
function getImageFilename(post) {
|
||||
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 [form, setForm] = useState({ title: post.title || "", text: post.text || "", image: null, removeImage: false });
|
||||
const [error, setError] = useState("");
|
||||
@@ -278,6 +279,16 @@ export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete, get
|
||||
</div>
|
||||
</Link>
|
||||
{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}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PostCard } from "./PostCard";
|
||||
|
||||
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 loadMoreRef = useRef(null);
|
||||
|
||||
@@ -46,6 +46,9 @@ export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete, ge
|
||||
onDelete={onDelete}
|
||||
getProfile={getProfile}
|
||||
fetchImage={fetchImage}
|
||||
likePost={likePost}
|
||||
unlikePost={unlikePost}
|
||||
onLikeError={onLikeError}
|
||||
/>
|
||||
))}
|
||||
{visibleCount < posts.length ? (
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { validateEmail, validatePasswordRule } from "../validation/auth";
|
||||
|
||||
export function ProfileEditor({ profile, onSave, onCancel }) {
|
||||
const [name, setName] = useState(profile?.name || "");
|
||||
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 [removeImage, setRemoveImage] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -12,16 +17,39 @@ export function ProfileEditor({ profile, onSave, onCancel }) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Name is required.");
|
||||
const trimmedName = name.trim();
|
||||
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;
|
||||
}
|
||||
|
||||
if (/\d/.test(name)) {
|
||||
setError("Name must not contain numbers.");
|
||||
if (trimmedUsername.length > 50 || /\s/.test(trimmedUsername)) {
|
||||
setError("Username must be 1–50 characters and cannot contain whitespace.");
|
||||
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) {
|
||||
setError("Cannot upload and remove an image at the same time.");
|
||||
return;
|
||||
@@ -32,14 +60,46 @@ export function ProfileEditor({ profile, onSave, onCancel }) {
|
||||
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);
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
username: username.trim() || undefined,
|
||||
image: image || undefined,
|
||||
removeImage: removeImage || undefined,
|
||||
});
|
||||
await onSave(data);
|
||||
} catch (saveError) {
|
||||
setError(saveError.message || "Unable to save profile.");
|
||||
} finally {
|
||||
@@ -64,7 +124,7 @@ export function ProfileEditor({ profile, onSave, onCancel }) {
|
||||
<span>Name</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value.replace(/\d/g, ""))}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
maxLength={100}
|
||||
/>
|
||||
@@ -78,6 +138,47 @@ export function ProfileEditor({ profile, onSave, onCancel }) {
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</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>
|
||||
<span>Profile picture</span>
|
||||
<input
|
||||
|
||||
@@ -53,6 +53,15 @@ export function Shell({ user, onLogout, children, fetchImage }) {
|
||||
</span>
|
||||
Profile
|
||||
</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>
|
||||
|
||||
<div className="account-card">
|
||||
|
||||
@@ -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 [error, setError] = useState("");
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
|
||||
+10
-25
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Lightbox } from "../components/Lightbox";
|
||||
import { LikeButton } from "../components/LikeButton";
|
||||
|
||||
function getImageFilename(post) {
|
||||
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 [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));
|
||||
@@ -128,15 +125,6 @@ export function PostPage({ getPost, loadImage }) {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function handleLike() {
|
||||
if (liked) {
|
||||
setLikeCount((c) => c - 1);
|
||||
} else {
|
||||
setLikeCount((c) => c + 1);
|
||||
}
|
||||
setLiked((prev) => !prev);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="post-page">
|
||||
@@ -205,17 +193,14 @@ export function PostPage({ getPost, loadImage }) {
|
||||
</div>
|
||||
|
||||
<div className="post-engagement">
|
||||
<button
|
||||
type="button"
|
||||
className={`like-btn ${liked ? "like-btn-active" : ""}`}
|
||||
onClick={handleLike}
|
||||
aria-label={liked ? "Unlike" : "Like"}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill={liked ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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>
|
||||
<LikeButton
|
||||
postId={post.id}
|
||||
count={post.like_count}
|
||||
liked={post.liked_by_me}
|
||||
onLike={likePost}
|
||||
onUnlike={unlikePost}
|
||||
onError={(err) => setError(err.message || "Unable to update like.")}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -36,6 +36,7 @@ function getInitials(name) {
|
||||
export function ProfilePage({
|
||||
user,
|
||||
myProfile,
|
||||
authMe,
|
||||
getProfile,
|
||||
loadMyPosts,
|
||||
loadAllPosts,
|
||||
@@ -55,6 +56,8 @@ export function ProfilePage({
|
||||
updateProfile,
|
||||
fetchImage,
|
||||
onProfileUpdate,
|
||||
likePost,
|
||||
unlikePost,
|
||||
}) {
|
||||
const { userId } = useParams();
|
||||
const isOwnProfile = !userId || String(userId) === String(currentUserId);
|
||||
@@ -82,7 +85,18 @@ export function ProfilePage({
|
||||
if (myProfile) {
|
||||
try {
|
||||
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 {
|
||||
// Keep default user prop
|
||||
}
|
||||
@@ -130,7 +144,7 @@ export function ProfilePage({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isOwnProfile, userId, myProfile, getProfile, loadMyPosts, loadAllPosts, friends, friendRequests]);
|
||||
}, [isOwnProfile, userId, myProfile, authMe, getProfile, loadMyPosts, loadAllPosts, friends, friendRequests]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -423,7 +437,7 @@ export function ProfilePage({
|
||||
) : posts.length === 0 ? (
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ function validateUsername(username) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateEmail(email, { checkDisposable = false } = {}) {
|
||||
export function validateEmail(email, { checkDisposable = false } = {}) {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
if (
|
||||
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) {
|
||||
const errors = getRegistrationFieldErrors(form);
|
||||
return errors.name || errors.username || errors.email || errors.password || null;
|
||||
|
||||
Reference in New Issue
Block a user