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;
}
/* 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 { 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} />
+32 -8
View File
@@ -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",
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) =>
+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 { 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}
+4 -1
View File
@@ -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 ? (
+112 -11
View File
@@ -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 150 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
+9
View File
@@ -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">
+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 [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
View File
@@ -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>
+17 -3
View File
@@ -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>
+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;
}
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;