add likes and friends
This commit is contained in:
+82
@@ -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
@@ -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} />
|
||||||
|
|||||||
+32
-8
@@ -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 }),
|
||||||
|
deleteSession: (token, id) =>
|
||||||
|
request(`/auth/sessions/${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
token,
|
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) =>
|
||||||
|
|||||||
@@ -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 { 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}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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 1–50 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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user