diff --git a/src/App.css b/src/App.css index 4dd6b49..58ee96a 100644 --- a/src/App.css +++ b/src/App.css @@ -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; +} + diff --git a/src/App.jsx b/src/App.jsx index 64891cd..d515918 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { 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() { 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() { 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() { /> } /> + 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} + /> + } + /> } /> diff --git a/src/api/client.js b/src/api/client.js index ba440de..9eb50db 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -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) => diff --git a/src/components/LikeButton.jsx b/src/components/LikeButton.jsx new file mode 100644 index 0000000..6db0c8f --- /dev/null +++ b/src/components/LikeButton.jsx @@ -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 ( + + ); +} diff --git a/src/components/PostCard.jsx b/src/components/PostCard.jsx index 6aaafd3..7c381a6 100644 --- a/src/components/PostCard.jsx +++ b/src/components/PostCard.jsx @@ -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 {imageFilename ? : null} +
+ +
)} {error ?

{error}

: null} diff --git a/src/components/PostList.jsx b/src/components/PostList.jsx index 7959dae..9cb6861 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, 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 ? ( diff --git a/src/components/ProfileEditor.jsx b/src/components/ProfileEditor.jsx index 90975c4..19d19c7 100644 --- a/src/components/ProfileEditor.jsx +++ b/src/components/ProfileEditor.jsx @@ -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 }) { Name 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" /> + + +
+ + +

Setting a new password signs you out on all devices.

+
- + setError(err.message || "Unable to update like.")} + />
diff --git a/src/pages/ProfilePage.jsx b/src/pages/ProfilePage.jsx index 6210abd..c704ecd 100644 --- a/src/pages/ProfilePage.jsx +++ b/src/pages/ProfilePage.jsx @@ -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 ? (

{isOwnProfile ? "You haven't created any posts yet." : "No posts found for this user."}

) : ( - + showToast(err.message || "Unable to update like.", "error")} /> )}
diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx new file mode 100644 index 0000000..a332294 --- /dev/null +++ b/src/pages/SettingsPage.jsx @@ -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 ( +
+
+

Account control

+

Settings

+
+ +
+

Active sessions

+

Devices currently signed in to your account.

+ {error ?

{error}

: null} + {loading ? ( +
+ ); +} diff --git a/src/validation/auth.js b/src/validation/auth.js index 849f391..0e5a267 100644 --- a/src/validation/auth.js +++ b/src/validation/auth.js @@ -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;