added friends endpoints and added features for viewing
This commit is contained in:
+4
-1
@@ -22,4 +22,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
|
||||
# Other
|
||||
.env
|
||||
.opencode/*
|
||||
+496
-1
@@ -21,7 +21,7 @@
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.25rem;
|
||||
padding: clamp(1.5rem, 4vw, 4rem);
|
||||
box-shadow: 0 22px 50px rgba(31, 42, 43, 0.12);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: rise 600ms ease-out;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,16 @@
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
border: 1px solid var(--ink);
|
||||
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(31, 42, 43, 0.15);
|
||||
}
|
||||
|
||||
.cta:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.cta.primary {
|
||||
@@ -69,6 +79,77 @@
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.landing-features {
|
||||
width: min(920px, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
animation: rise 600ms ease-out 200ms both;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin: 0 auto 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent-dark);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-family: var(--display);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.landing-social {
|
||||
width: min(920px, 100%);
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
text-align: center;
|
||||
animation: rise 600ms ease-out 400ms both;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.landing-social p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.landing-social strong {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
@@ -383,6 +464,7 @@ button:disabled {
|
||||
|
||||
.post-card > .post-top,
|
||||
.post-card > .post-copy,
|
||||
.post-card > .post-copy-link,
|
||||
.post-card > .inline-actions,
|
||||
.post-card > .post-form,
|
||||
.post-card > .error-text,
|
||||
@@ -762,6 +844,179 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200px 0; }
|
||||
100% { background-position: calc(200px + 100%) 0; }
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from { transform: translateY(-12px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
from { transform: translateY(0); opacity: 1; }
|
||||
to { transform: translateY(-12px); opacity: 0; }
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--line) 0%, #e8e2d4 40%, var(--line) 80%);
|
||||
background-size: 200px 100%;
|
||||
animation: shimmer 1.4s ease-in-out infinite;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-lines {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-line.short {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.skeleton-line.title {
|
||||
height: 1rem;
|
||||
width: 45%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-md);
|
||||
animation: toast-in 300ms ease-out;
|
||||
min-width: 200px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.toast.leaving {
|
||||
animation: toast-out 250ms ease-in forwards;
|
||||
}
|
||||
|
||||
.toast-success { background: var(--ok); }
|
||||
.toast-error { background: var(--error); }
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.post-card {
|
||||
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.post-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.post-card:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
background: rgba(31, 42, 43, 0.5);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
animation: rise 200ms ease-out;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
width: min(480px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-family: var(--display);
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0.3rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.profile-avatar-image {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 4px 14px rgba(196, 90, 61, 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -817,3 +1072,243 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
/* Lightbox */
|
||||
.lightbox-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
cursor: zoom-out;
|
||||
animation: rise 200ms ease-out;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: 0.5rem;
|
||||
cursor: default;
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
padding: 0.3rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.lightbox-close:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.post-image-clickable {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Post-copy link reset */
|
||||
.post-copy-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.post-copy-link:hover h3 {
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
|
||||
.post-copy-link > .post-copy {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Post Detail Page */
|
||||
.post-page {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 2rem;
|
||||
}
|
||||
|
||||
.post-page-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
margin-bottom: 1rem;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.post-page-back:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.post-detail-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.post-detail-card .post-image {
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.post-engagement {
|
||||
margin-top: 1.2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.like-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 2rem;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 160ms ease;
|
||||
}
|
||||
|
||||
.like-btn:hover {
|
||||
color: #e25555;
|
||||
border-color: #e25555;
|
||||
background: rgba(226, 85, 85, 0.06);
|
||||
}
|
||||
|
||||
.like-btn-active {
|
||||
color: #e25555;
|
||||
border-color: #e25555;
|
||||
background: rgba(226, 85, 85, 0.1);
|
||||
}
|
||||
|
||||
.like-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Friends Page */
|
||||
.friends-page {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 2rem;
|
||||
}
|
||||
|
||||
.friends-section {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.friends-section .section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
padding: 0 0.35rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.friend-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.friend-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.friend-row:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.friend-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.friend-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.friend-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.friend-name {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.friend-info:hover .friend-name {
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
|
||||
.friend-handle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.friend-actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.friend-action {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.friend-action:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.skeleton-friend {
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
+76
-11
@@ -1,11 +1,15 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { apiClient } from "./api/client";
|
||||
import { apiClient, setUnauthorizedHandler } from "./api/client";
|
||||
import { Shell } from "./components/Shell";
|
||||
import { ToastContainer } from "./components/Toast";
|
||||
import { useToast } from "./components/useToast";
|
||||
import { CreatePage } from "./pages/CreatePage";
|
||||
import { FeedPage } from "./pages/FeedPage";
|
||||
import { FriendsPage } from "./pages/FriendsPage";
|
||||
import { LandingPage } from "./pages/LandingPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { PostPage } from "./pages/PostPage";
|
||||
import { ProfilePage } from "./pages/ProfilePage";
|
||||
import { RegisterPage } from "./pages/RegisterPage";
|
||||
import { clearAuthSession, getAuthSession, saveAuthSession } from "./utils/cookie";
|
||||
@@ -32,6 +36,7 @@ function App() {
|
||||
expiresAt: null,
|
||||
});
|
||||
const [userProfile, setUserProfile] = useState(null);
|
||||
const { toasts, showToast, removeToast } = useToast();
|
||||
|
||||
const isAuthenticated = Boolean(session.token);
|
||||
|
||||
@@ -108,6 +113,17 @@ function App() {
|
||||
setUserProfile(null);
|
||||
}, []);
|
||||
|
||||
// Immediately log out when the backend rejects an authenticated request with 401
|
||||
useEffect(() => {
|
||||
if (initializing) {
|
||||
return;
|
||||
}
|
||||
setUnauthorizedHandler(() => {
|
||||
logout();
|
||||
});
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, [initializing, logout]);
|
||||
|
||||
// Fetch user profile whenever logged-in token changes (e.g. after fresh login)
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -160,12 +176,15 @@ function App() {
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginPage onLogin={authActions.onLogin} />} />
|
||||
<Route path="/register" element={<RegisterPage onRegister={authActions.onRegister} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginPage onLogin={authActions.onLogin} />} />
|
||||
<Route path="/register" element={<RegisterPage onRegister={authActions.onRegister} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,10 +192,23 @@ function App() {
|
||||
id: session.user?.id,
|
||||
name: userProfile?.name,
|
||||
username: userProfile?.username,
|
||||
profile_link: userProfile?.profile_link || userProfile?.profileLink || null,
|
||||
};
|
||||
|
||||
const loadProfileImage = (url) => apiClient.fetchImage(session.token, url);
|
||||
|
||||
const friendProps = {
|
||||
friends: () => apiClient.friends(session.token),
|
||||
friendRequests: () => apiClient.friendRequests(session.token),
|
||||
sendFriendRequest: (them) => apiClient.sendFriendRequest(session.token, them),
|
||||
acceptFriendRequest: (them) => apiClient.acceptFriendRequest(session.token, them),
|
||||
declineFriendRequest: (them) => apiClient.declineFriendRequest(session.token, them),
|
||||
cancelFriendRequest: (them) => apiClient.cancelFriendRequest(session.token, them),
|
||||
removeFriend: (id) => apiClient.removeFriend(session.token, id),
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell user={currentUser} onLogout={logout}>
|
||||
<Shell user={currentUser} onLogout={logout} fetchImage={loadProfileImage}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/feed"
|
||||
@@ -187,13 +219,25 @@ function App() {
|
||||
deletePost={(id) => apiClient.deletePost(session.token, id)}
|
||||
loadImage={(filename) => apiClient.postImage(session.token, filename)}
|
||||
currentUserId={session.user.id}
|
||||
showToast={showToast}
|
||||
getProfile={(id) => apiClient.getProfile(session.token, id)}
|
||||
fetchImage={loadProfileImage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/post/:postId"
|
||||
element={
|
||||
<PostPage
|
||||
getPost={(id) => apiClient.getPost(session.token, id)}
|
||||
loadImage={(filename) => apiClient.postImage(session.token, filename)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/create"
|
||||
element={
|
||||
<CreatePage createPost={(data) => apiClient.createPost(session.token, data)} />
|
||||
<CreatePage createPost={(data) => apiClient.createPost(session.token, data)} showToast={showToast} />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -205,12 +249,16 @@ function App() {
|
||||
getProfile={(id) => apiClient.getProfile(session.token, id)}
|
||||
loadMyPosts={() => apiClient.myPosts(session.token)}
|
||||
loadAllPosts={() => apiClient.posts(session.token)}
|
||||
createRelationship={(them) => apiClient.createRelationship(session.token, session.user.id, them)}
|
||||
{...friendProps}
|
||||
editPost={(id, data) => apiClient.editPost(session.token, id, data)}
|
||||
deletePost={(id) => apiClient.deletePost(session.token, id)}
|
||||
loadImage={(filename) => apiClient.postImage(session.token, filename)}
|
||||
currentUserId={session.user.id}
|
||||
onLogout={logout}
|
||||
showToast={showToast}
|
||||
updateProfile={(data) => apiClient.updateProfile(session.token, data)}
|
||||
fetchImage={(url) => apiClient.fetchImage(session.token, url)}
|
||||
onProfileUpdate={setUserProfile}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -223,17 +271,34 @@ function App() {
|
||||
getProfile={(id) => apiClient.getProfile(session.token, id)}
|
||||
loadMyPosts={() => apiClient.myPosts(session.token)}
|
||||
loadAllPosts={() => apiClient.posts(session.token)}
|
||||
createRelationship={(them) => apiClient.createRelationship(session.token, session.user.id, them)}
|
||||
{...friendProps}
|
||||
editPost={(id, data) => apiClient.editPost(session.token, id, data)}
|
||||
deletePost={(id) => apiClient.deletePost(session.token, id)}
|
||||
loadImage={(filename) => apiClient.postImage(session.token, filename)}
|
||||
currentUserId={session.user.id}
|
||||
onLogout={logout}
|
||||
showToast={showToast}
|
||||
fetchImage={(url) => apiClient.fetchImage(session.token, url)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/friends"
|
||||
element={
|
||||
<FriendsPage
|
||||
{...friendProps}
|
||||
acceptRequest={(from) => apiClient.acceptFriendRequest(session.token, from)}
|
||||
declineRequest={(from) => apiClient.declineFriendRequest(session.token, from)}
|
||||
removeFriend={(id) => apiClient.removeFriend(session.token, id)}
|
||||
getProfile={(id) => apiClient.getProfile(session.token, id)}
|
||||
fetchImage={loadProfileImage}
|
||||
showToast={showToast}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/feed" replace />} />
|
||||
</Routes>
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
+112
-14
@@ -2,6 +2,12 @@ import { getAuthSession } from "../utils/cookie";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
let unauthorizedHandler = null;
|
||||
|
||||
export function setUnauthorizedHandler(handler) {
|
||||
unauthorizedHandler = handler;
|
||||
}
|
||||
|
||||
async function request(path, { method = "GET", token, body, formData } = {}) {
|
||||
if (!API_BASE_URL) {
|
||||
throw new Error("VITE_API_BASE_URL ontbreekt.");
|
||||
@@ -32,12 +38,15 @@ async function request(path, { method = "GET", token, body, formData } = {}) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && token && token !== "active-session" && unauthorizedHandler) {
|
||||
unauthorizedHandler();
|
||||
}
|
||||
const error = new Error(
|
||||
response.status === 401
|
||||
? "Invalid email or password"
|
||||
: response.status === 400
|
||||
? payload.message || payload.error || "Please check your input."
|
||||
: "Something went wrong. Please try again.",
|
||||
? token && token !== "active-session"
|
||||
? "Your session has expired. Please log in again."
|
||||
: "Invalid email or password"
|
||||
: payload.message || payload.error || "Something went wrong. Please try again.",
|
||||
);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
@@ -47,14 +56,18 @@ async function request(path, { method = "GET", token, body, formData } = {}) {
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
login: (credentials) => request("/auth/login", { method: "POST", body: credentials }),
|
||||
login: (credentials) =>
|
||||
request("/auth/login", { method: "POST", body: credentials }),
|
||||
register: (data) => request("/auth/register", { method: "POST", body: data }),
|
||||
authMe: (token) => request("/auth/me", { token }),
|
||||
authVerify: (token) => request("/auth/verify", { token }),
|
||||
logoutApi: () => request("/auth/logout", { method: "POST" }).catch(() => ({})),
|
||||
logoutApi: () =>
|
||||
request("/auth/logout", { method: "POST" }).catch(() => ({})),
|
||||
myProfile: (token) => request("/profiles/me", { token }),
|
||||
getProfile: (token, id) => request(`/profiles/${encodeURIComponent(id)}`, { token }),
|
||||
getProfile: (token, id) =>
|
||||
request(`/profiles/${encodeURIComponent(id)}`, { token }),
|
||||
posts: (token) => request("/posts", { token }),
|
||||
getPost: (token, id) => request(`/posts/${encodeURIComponent(id)}`, { token }),
|
||||
myPosts: (token) => request("/posts/me", { token }),
|
||||
createPost: (token, { title, text, image }) => {
|
||||
const formData = new FormData();
|
||||
@@ -79,17 +92,93 @@ export const apiClient = {
|
||||
if (removeImage !== undefined) {
|
||||
formData.append("remove_image", String(removeImage));
|
||||
}
|
||||
return request(`/posts/edit?id=${encodeURIComponent(id)}`, { method: "PUT", token, formData });
|
||||
return request(`/posts/edit?id=${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
token,
|
||||
formData,
|
||||
});
|
||||
},
|
||||
deletePost: (token, id) =>
|
||||
request(`/posts/delete?id=${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
updateProfile: (token, { name, username, image, removeImage }) => {
|
||||
const formData = new FormData();
|
||||
if (name !== undefined) {
|
||||
formData.append("name", name);
|
||||
}
|
||||
if (username !== undefined) {
|
||||
formData.append("username", username);
|
||||
}
|
||||
if (image) {
|
||||
formData.append("image", image);
|
||||
}
|
||||
if (removeImage !== undefined) {
|
||||
formData.append("remove_image", String(removeImage));
|
||||
}
|
||||
return request("/profiles/me", { method: "PUT", token, formData });
|
||||
},
|
||||
deletePost: (token, id) => request(`/posts/delete?id=${encodeURIComponent(id)}`, { method: "DELETE", token }),
|
||||
createRelationship: (token, me, them) =>
|
||||
request(`/create_relationship?me=${encodeURIComponent(me)}&them=${encodeURIComponent(them)}`, {
|
||||
request(
|
||||
`/create_relationship?me=${encodeURIComponent(me)}&them=${encodeURIComponent(them)}`,
|
||||
{
|
||||
method: "POST",
|
||||
token,
|
||||
},
|
||||
),
|
||||
friends: (token) => request("/friends", { token }),
|
||||
friendRequests: (token) => request("/friends/requests", { token }),
|
||||
sendFriendRequest: (token, to) =>
|
||||
request(`/friends/request?to=${encodeURIComponent(to)}`, {
|
||||
method: "POST",
|
||||
token,
|
||||
}),
|
||||
postImage: async (token, filename) => {
|
||||
acceptFriendRequest: (token, from) =>
|
||||
request(`/friends/accept?from=${encodeURIComponent(from)}`, {
|
||||
method: "POST",
|
||||
token,
|
||||
}),
|
||||
declineFriendRequest: (token, from) =>
|
||||
request(`/friends/decline?from=${encodeURIComponent(from)}`, {
|
||||
method: "POST",
|
||||
token,
|
||||
}),
|
||||
cancelFriendRequest: (token, to) =>
|
||||
request(`/friends/cancel?to=${encodeURIComponent(to)}`, {
|
||||
method: "POST",
|
||||
token,
|
||||
}),
|
||||
removeFriend: (token, id) =>
|
||||
request(`/friends/remove?id=${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
fetchImage: async (token, url) => {
|
||||
const headers = {};
|
||||
const effectiveToken = token && token !== "active-session" ? token : getAuthSession().token || null;
|
||||
if (effectiveToken && effectiveToken !== "active-session") {
|
||||
headers.Authorization = `Bearer ${effectiveToken}`;
|
||||
}
|
||||
let fullUrl = url;
|
||||
if (url && !url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
fullUrl = `${API_BASE_URL}${url.startsWith("/") ? "" : "/"}${url}`;
|
||||
}
|
||||
const response = await fetch(fullUrl, { headers, credentials: "include" });
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && unauthorizedHandler) {
|
||||
unauthorizedHandler();
|
||||
}
|
||||
throw new Error("Unable to load image.");
|
||||
}
|
||||
return response.blob();
|
||||
},
|
||||
postImage: async (token, filename) => {
|
||||
const headers = {};
|
||||
const effectiveToken =
|
||||
token && token !== "active-session"
|
||||
? token
|
||||
: getAuthSession().token || null;
|
||||
|
||||
if (effectiveToken && effectiveToken !== "active-session") {
|
||||
headers.Authorization = `Bearer ${effectiveToken}`;
|
||||
@@ -104,18 +193,24 @@ export const apiClient = {
|
||||
|
||||
if (target.startsWith("http://") || target.startsWith("https://")) {
|
||||
if (target.includes("/posts/image/")) {
|
||||
const imagePart = target.substring(target.indexOf("/posts/image/") + "/posts/image/".length);
|
||||
const imagePart = target.substring(
|
||||
target.indexOf("/posts/image/") + "/posts/image/".length,
|
||||
);
|
||||
const cleanName = encodeURIComponent(decodeURIComponent(imagePart));
|
||||
url = `${API_BASE_URL}/posts/image/${cleanName}`;
|
||||
} else {
|
||||
url = target;
|
||||
}
|
||||
} else if (target.includes("/posts/image/")) {
|
||||
const imagePart = target.substring(target.indexOf("/posts/image/") + "/posts/image/".length);
|
||||
const imagePart = target.substring(
|
||||
target.indexOf("/posts/image/") + "/posts/image/".length,
|
||||
);
|
||||
const cleanName = encodeURIComponent(decodeURIComponent(imagePart));
|
||||
url = `${API_BASE_URL}/posts/image/${cleanName}`;
|
||||
} else {
|
||||
const cleanName = encodeURIComponent(decodeURIComponent(target.split("/").pop()));
|
||||
const cleanName = encodeURIComponent(
|
||||
decodeURIComponent(target.split("/").pop()),
|
||||
);
|
||||
url = `${API_BASE_URL}/posts/image/${cleanName}`;
|
||||
}
|
||||
} else {
|
||||
@@ -127,6 +222,9 @@ export const apiClient = {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && unauthorizedHandler) {
|
||||
unauthorizedHandler();
|
||||
}
|
||||
throw new Error("Unable to load image.");
|
||||
}
|
||||
return response.blob();
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const profileCache = new Map();
|
||||
|
||||
function getInitials(name) {
|
||||
if (!name) return "?";
|
||||
const cleaned = String(name).replace(/^@/, "");
|
||||
return cleaned.split(" ").filter(Boolean).slice(0, 2).map((part) => part[0]).join("").toUpperCase() || "?";
|
||||
}
|
||||
|
||||
export function AuthorAvatar({ authorId, name, getProfile, fetchImage, profileLink, className, size }) {
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const objectUrlRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const currentId = String(authorId);
|
||||
|
||||
if (!currentId || currentId === "undefined" || !fetchImage) {
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileLink) {
|
||||
loadImage(profileLink);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getProfile) {
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileCache.has(currentId)) {
|
||||
const cached = profileCache.get(currentId);
|
||||
if (cached) {
|
||||
loadImage(cached);
|
||||
} else {
|
||||
setLoaded(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
getProfile(currentId)
|
||||
.then((profile) => {
|
||||
if (!active) return;
|
||||
const link = profile?.profile_link || profile?.profileLink || profile?.avatar || null;
|
||||
if (link) {
|
||||
profileCache.set(currentId, link);
|
||||
loadImage(link);
|
||||
} else {
|
||||
profileCache.set(currentId, null);
|
||||
setLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
profileCache.set(currentId, null);
|
||||
setLoaded(true);
|
||||
});
|
||||
|
||||
function loadImage(link) {
|
||||
fetchImage(link)
|
||||
.then((blob) => {
|
||||
if (!active) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
objectUrlRef.current = url;
|
||||
setImageUrl(url);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setLoaded(true);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [authorId, getProfile, fetchImage, profileLink]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dim = size || "2.4rem";
|
||||
|
||||
if (imageUrl && loaded) {
|
||||
return (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={name || "Avatar"}
|
||||
className={className}
|
||||
style={{ width: dim, height: dim, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
width: dim,
|
||||
height: dim,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, var(--accent), #e4a15c)",
|
||||
color: "#fff",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
fontWeight: 800,
|
||||
fontSize: `calc(${dim} * 0.35)`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function Lightbox({ src, alt, onClose }) {
|
||||
useEffect(() => {
|
||||
if (!src) return;
|
||||
function handleKey(event) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKey);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [src, onClose]);
|
||||
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
<div className="lightbox-overlay" onClick={onClose} role="dialog" aria-label="Image lightbox">
|
||||
<button type="button" className="lightbox-close" onClick={onClose} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<img src={src} alt={alt || "Image"} className="lightbox-image" onClick={(event) => event.stopPropagation()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+29
-30
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AuthorAvatar } from "./AuthorAvatar";
|
||||
import { Lightbox } from "./Lightbox";
|
||||
|
||||
function getImageFilename(post) {
|
||||
if (!post) return null;
|
||||
@@ -57,18 +59,6 @@ function getAuthorUsername(post) {
|
||||
return post.author_username || post.author?.username || null;
|
||||
}
|
||||
|
||||
function getInitials(name) {
|
||||
if (!name) return "?";
|
||||
const cleaned = name.replace(/^@/, "");
|
||||
return cleaned
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase() || "?";
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return null;
|
||||
try {
|
||||
@@ -89,6 +79,7 @@ function formatDate(timestamp) {
|
||||
function AuthenticatedImage({ filename, alt, loadImage }) {
|
||||
const [source, setSource] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -147,21 +138,27 @@ function AuthenticatedImage({ filename, alt, loadImage }) {
|
||||
return <p className="muted">{error}</p>;
|
||||
}
|
||||
|
||||
return source ? (
|
||||
<img
|
||||
className="post-image"
|
||||
src={source}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => {
|
||||
setError("Image unavailable.");
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
return (
|
||||
<>
|
||||
{source ? (
|
||||
<img
|
||||
className="post-image post-image-clickable"
|
||||
src={source}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
onError={() => {
|
||||
setError("Image unavailable.");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Lightbox src={lightboxOpen ? source : null} alt={alt} onClose={() => setLightboxOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete }) {
|
||||
export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete, getProfile, fetchImage }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState({ title: post.title || "", text: post.text || "", image: null, removeImage: false });
|
||||
const [error, setError] = useState("");
|
||||
@@ -206,7 +203,7 @@ export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete }) {
|
||||
<article className="panel post-card">
|
||||
<div className="post-top post-author">
|
||||
<Link to={profilePath} className="author-link" title={`View ${authorName}'s profile`}>
|
||||
<div className="author-avatar">{getInitials(authorName)}</div>
|
||||
<AuthorAvatar authorId={authorId} name={authorName} getProfile={getProfile} fetchImage={fetchImage} className="author-avatar" />
|
||||
<div className="author-meta">
|
||||
<span className="author-name">{authorName}</span>
|
||||
{authorUsername && !authorName.startsWith("@") ? (
|
||||
@@ -274,10 +271,12 @@ export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete }) {
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className="post-copy">
|
||||
{post.title ? <h3>{post.title}</h3> : null}
|
||||
<p>{post.text ?? post.content}</p>
|
||||
</div>
|
||||
<Link to={`/post/${post.id}`} className="post-copy-link">
|
||||
<div className="post-copy">
|
||||
{post.title ? <h3>{post.title}</h3> : null}
|
||||
<p>{post.text ?? post.content}</p>
|
||||
</div>
|
||||
</Link>
|
||||
{imageFilename ? <AuthenticatedImage filename={imageFilename} alt={post.title || "Post image"} loadImage={loadImage} /> : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -28,7 +28,12 @@ export function PostComposer({ onCreate }) {
|
||||
return (
|
||||
<section className="panel composer-card">
|
||||
<div className="composer-heading">
|
||||
<div className="author-avatar">+</div>
|
||||
<div className="author-avatar">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Share with your community</h3>
|
||||
<p className="muted">Add a title, a thought, or an image.</p>
|
||||
@@ -62,8 +67,8 @@ export function PostComposer({ onCreate }) {
|
||||
</label>
|
||||
<button type="submit">Publish post</button>
|
||||
</form>
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
{message ? <p className="success-text">{message}</p> : null}
|
||||
{error ? <p className="error-text" role="alert">{error}</p> : null}
|
||||
{message ? <p className="success-text" aria-live="polite">{message}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PostCard } from "./PostCard";
|
||||
|
||||
const POSTS_PER_BATCH = 6;
|
||||
|
||||
export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete }) {
|
||||
export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete, getProfile, fetchImage }) {
|
||||
const [visibleCount, setVisibleCount] = useState(POSTS_PER_BATCH);
|
||||
const loadMoreRef = useRef(null);
|
||||
|
||||
@@ -44,6 +44,8 @@ export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete })
|
||||
loadImage={loadImage}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
getProfile={getProfile}
|
||||
fetchImage={fetchImage}
|
||||
/>
|
||||
))}
|
||||
{visibleCount < posts.length ? (
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export function ProfileEditor({ profile, onSave, onCancel }) {
|
||||
const [name, setName] = useState(profile?.name || "");
|
||||
const [username, setUsername] = useState(profile?.username || "");
|
||||
const [image, setImage] = useState(null);
|
||||
const [removeImage, setRemoveImage] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (/\d/.test(name)) {
|
||||
setError("Name must not contain numbers.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (image && removeImage) {
|
||||
setError("Cannot upload and remove an image at the same time.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (image && !image.type.startsWith("image/")) {
|
||||
setError("Only image files are accepted.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
username: username.trim() || undefined,
|
||||
image: image || undefined,
|
||||
removeImage: removeImage || undefined,
|
||||
});
|
||||
} catch (saveError) {
|
||||
setError(saveError.message || "Unable to save profile.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal-dialog panel" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Edit Profile</h3>
|
||||
<button type="button" className="modal-close" onClick={onCancel} aria-label="Close">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="post-form">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value.replace(/\d/g, ""))}
|
||||
required
|
||||
maxLength={100}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value.replace(/\s/g, ""))}
|
||||
maxLength={50}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Profile picture</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => {
|
||||
setImage(event.target.files?.[0] || null);
|
||||
setRemoveImage(false);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeImage}
|
||||
onChange={(event) => {
|
||||
setRemoveImage(event.target.checked);
|
||||
if (event.target.checked) setImage(null);
|
||||
}}
|
||||
/>
|
||||
Remove current picture
|
||||
</label>
|
||||
<div className="inline-actions">
|
||||
<button type="submit" disabled={saving}>
|
||||
{saving ? "Saving..." : "Save changes"}
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{error ? <p className="error-text" role="alert">{error}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+37
-20
@@ -1,20 +1,7 @@
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
import { AuthorAvatar } from "./AuthorAvatar";
|
||||
|
||||
function getInitials(name) {
|
||||
if (!name) return "?";
|
||||
const cleaned = String(name).replace(/^@/, "");
|
||||
return (
|
||||
cleaned
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase() || "?"
|
||||
);
|
||||
}
|
||||
|
||||
export function Shell({ user, onLogout, children }) {
|
||||
export function Shell({ user, onLogout, children, fetchImage }) {
|
||||
const displayName = user?.name || (user?.username ? `@${user.username}` : "Your Account");
|
||||
const displaySub = user?.username && user?.name ? `@${user.username}` : "";
|
||||
|
||||
@@ -27,20 +14,50 @@ export function Shell({ user, onLogout, children }) {
|
||||
<p className="tagline">Archive your social world.</p>
|
||||
|
||||
<nav>
|
||||
<NavLink to="/feed" className="nav-link">
|
||||
<span className="nav-icon">⌂</span> Feed
|
||||
<NavLink to="/feed" className="nav-link" end>
|
||||
<span className="nav-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
</span>
|
||||
Feed
|
||||
</NavLink>
|
||||
<NavLink to="/friends" 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">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</span>
|
||||
Friends
|
||||
</NavLink>
|
||||
<NavLink to="/create" className="nav-link create-link">
|
||||
<span className="nav-icon">+</span> Create
|
||||
<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="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="16" />
|
||||
<line x1="8" y1="12" x2="16" y2="12" />
|
||||
</svg>
|
||||
</span>
|
||||
Create
|
||||
</NavLink>
|
||||
<NavLink to="/profile" className="nav-link">
|
||||
<span className="nav-icon">◎</span> Profile
|
||||
<span className="nav-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</span>
|
||||
Profile
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
<div className="account-card">
|
||||
<Link to="/profile" className="account-info-row" title="Go to your profile">
|
||||
<div className="account-avatar">{getInitials(displayName)}</div>
|
||||
<AuthorAvatar authorId={user?.id} name={displayName} profileLink={user?.profile_link} fetchImage={fetchImage} className="account-avatar" size="2.3rem" />
|
||||
<div className="account-details">
|
||||
<span className="account-name">{displayName}</span>
|
||||
<span className="account-sub">{displaySub}</span>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export function ToastContainer({ toasts }) {
|
||||
if (!toasts.length) return null;
|
||||
|
||||
return (
|
||||
<div className="toast-container" role="status" aria-live="polite">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`toast ${toast.leaving ? "leaving" : ""} toast-${toast.type}`}
|
||||
role="alert"
|
||||
>
|
||||
<span className="toast-icon">
|
||||
{toast.type === "success" ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : toast.type === "error" ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
let toastId = 0;
|
||||
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
setToasts((current) =>
|
||||
current.map((t) => (t.id === id ? { ...t, leaving: true } : t)),
|
||||
);
|
||||
setTimeout(() => {
|
||||
setToasts((current) => current.filter((t) => t.id !== id));
|
||||
}, 280);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((message, type) => {
|
||||
const id = ++toastId;
|
||||
setToasts((current) => [...current, { id, message, type: type || "default" }]);
|
||||
setTimeout(() => removeToast(id), 3500);
|
||||
return id;
|
||||
}, [removeToast]);
|
||||
|
||||
return { toasts, showToast, removeToast };
|
||||
}
|
||||
@@ -7,9 +7,16 @@
|
||||
--panel: #fffcf6;
|
||||
--accent: #c45a3d;
|
||||
--accent-dark: #9a3e27;
|
||||
--accent-light: rgba(196, 90, 61, 0.1);
|
||||
--accent-glow: rgba(196, 90, 61, 0.25);
|
||||
--line: #d8d1c4;
|
||||
--ok: #2d8a58;
|
||||
--ok-light: rgba(45, 138, 88, 0.1);
|
||||
--error: #b24343;
|
||||
--error-light: rgba(178, 67, 67, 0.1);
|
||||
--shadow-sm: 0 4px 12px rgba(31, 42, 43, 0.04);
|
||||
--shadow-md: 0 8px 24px rgba(31, 42, 43, 0.05);
|
||||
--shadow-lg: 0 22px 50px rgba(31, 42, 43, 0.12);
|
||||
|
||||
--display: "Fraunces", serif;
|
||||
--body: "Manrope", sans-serif;
|
||||
@@ -33,3 +40,41 @@ body {
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PostComposer } from "../components/PostComposer";
|
||||
|
||||
export function CreatePage({ createPost }) {
|
||||
export function CreatePage({ createPost, showToast }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleCreate(data) {
|
||||
await createPost(data);
|
||||
showToast("Post created!", "success");
|
||||
navigate("/feed");
|
||||
}
|
||||
|
||||
|
||||
+36
-5
@@ -2,7 +2,15 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { PostList } from "../components/PostList";
|
||||
|
||||
function getPosts(data) {
|
||||
return Array.isArray(data) ? data : data.posts || data.items || [];
|
||||
const posts = Array.isArray(data) ? data : data.posts || data.items || [];
|
||||
return posts.sort((a, b) => {
|
||||
const aDate = a.created_at || a.createdAt;
|
||||
const bDate = b.created_at || b.createdAt;
|
||||
if (!aDate && !bDate) return 0;
|
||||
if (!aDate) return 1;
|
||||
if (!bDate) return -1;
|
||||
return new Date(bDate) - new Date(aDate);
|
||||
});
|
||||
}
|
||||
|
||||
function getAuthorId(post) {
|
||||
@@ -16,7 +24,28 @@ function postsFromOthers(posts, currentUserId) {
|
||||
return posts.filter((post) => String(getAuthorId(post)) !== String(currentUserId));
|
||||
}
|
||||
|
||||
export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUserId }) {
|
||||
function FeedSkeleton() {
|
||||
return (
|
||||
<div className="post-list" aria-hidden="true">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="panel skeleton-card">
|
||||
<div className="skeleton-author">
|
||||
<div className="skeleton skeleton-avatar" />
|
||||
<div className="skeleton-lines">
|
||||
<div className="skeleton skeleton-line" />
|
||||
<div className="skeleton skeleton-line short" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="skeleton skeleton-line title" />
|
||||
<div className="skeleton skeleton-line" />
|
||||
<div className="skeleton skeleton-line short" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUserId, showToast, getProfile, fetchImage }) {
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -39,11 +68,13 @@ export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUs
|
||||
|
||||
async function handleEdit(id, data) {
|
||||
await editPost(id, data);
|
||||
showToast("Post updated.", "success");
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
await deletePost(id);
|
||||
showToast("Post deleted.", "success");
|
||||
await refresh();
|
||||
}
|
||||
|
||||
@@ -56,16 +87,16 @@ export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUs
|
||||
</div>
|
||||
<span className="feed-status">Explore community posts</span>
|
||||
</header>
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
{error ? <p className="error-text" role="alert">{error}</p> : null}
|
||||
{loading ? (
|
||||
<p className="post-loading">Loading community feed...</p>
|
||||
<FeedSkeleton />
|
||||
) : posts.length === 0 ? (
|
||||
<div className="panel empty-feed-panel">
|
||||
<h3>No posts yet</h3>
|
||||
<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} />
|
||||
<PostList posts={posts} currentUserId={currentUserId} loadImage={loadImage} onEdit={handleEdit} onDelete={handleDelete} getProfile={getProfile} fetchImage={fetchImage} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AuthorAvatar } from "../components/AuthorAvatar";
|
||||
|
||||
function getList(data) {
|
||||
return Array.isArray(data) ? data : data.friends || data.items || data.requests || [];
|
||||
}
|
||||
|
||||
function getDisplayName(person) {
|
||||
return person?.name || (person?.username ? `@${person.username}` : "User");
|
||||
}
|
||||
|
||||
function getHandle(person) {
|
||||
return person?.username || null;
|
||||
}
|
||||
|
||||
export function FriendsPage({
|
||||
friends,
|
||||
friendRequests,
|
||||
acceptRequest,
|
||||
declineRequest,
|
||||
removeFriend,
|
||||
getProfile,
|
||||
fetchImage,
|
||||
showToast,
|
||||
}) {
|
||||
const [friendsList, setFriendsList] = useState([]);
|
||||
const [requestsList, setRequestsList] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
return Promise.all([friends(), friendRequests()])
|
||||
.then(([friendsData, requestsData]) => {
|
||||
setFriendsList(getList(friendsData));
|
||||
setRequestsList(getList(requestsData));
|
||||
})
|
||||
.catch((loadError) => setError(loadError.message || "Unable to load friends."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [friends, friendRequests]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function handleAccept(person) {
|
||||
setBusyId(person.id);
|
||||
try {
|
||||
await acceptRequest(person.id);
|
||||
showToast(`You are now friends with ${getDisplayName(person)}.`, "success");
|
||||
await refresh();
|
||||
} catch (actionError) {
|
||||
showToast(actionError.message || "Unable to accept request.", "error");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecline(person) {
|
||||
setBusyId(person.id);
|
||||
try {
|
||||
await declineRequest(person.id);
|
||||
showToast(`Friend request from ${getDisplayName(person)} declined.`, "success");
|
||||
await refresh();
|
||||
} catch (actionError) {
|
||||
showToast(actionError.message || "Unable to decline request.", "error");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(person) {
|
||||
if (!window.confirm(`Remove ${getDisplayName(person)} from your friends?`)) {
|
||||
return;
|
||||
}
|
||||
setBusyId(person.id);
|
||||
try {
|
||||
await removeFriend(person.id);
|
||||
showToast(`${getDisplayName(person)} removed from friends.`, "success");
|
||||
await refresh();
|
||||
} catch (actionError) {
|
||||
showToast(actionError.message || "Unable to remove friend.", "error");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function PersonRow({ person, children }) {
|
||||
const name = getDisplayName(person);
|
||||
const handle = getHandle(person);
|
||||
return (
|
||||
<li className="friend-row panel">
|
||||
<Link to={`/profile/${person.id}`} className="friend-info">
|
||||
<AuthorAvatar authorId={person.id} name={name} profileLink={person.profile_link || person.profileLink} getProfile={getProfile} fetchImage={fetchImage} className="friend-avatar" />
|
||||
<div className="friend-details">
|
||||
<span className="friend-name">{name}</span>
|
||||
{handle && !name.startsWith("@") ? <span className="friend-handle">@{handle}</span> : null}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="friend-actions">{children}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="friends-page">
|
||||
<header className="page-head">
|
||||
<p className="eyebrow">Your social circle</p>
|
||||
<h2>Friends</h2>
|
||||
</header>
|
||||
|
||||
{error ? <p className="error-text" role="alert">{error}</p> : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="friend-list" aria-hidden="true">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="panel skeleton-card skeleton-friend">
|
||||
<div className="skeleton-author">
|
||||
<div className="skeleton skeleton-avatar" />
|
||||
<div className="skeleton-lines">
|
||||
<div className="skeleton skeleton-line" />
|
||||
<div className="skeleton skeleton-line short" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="friends-section">
|
||||
<h3 className="section-title">
|
||||
Friend Requests
|
||||
{requestsList.length > 0 ? <span className="count-badge">{requestsList.length}</span> : null}
|
||||
</h3>
|
||||
{requestsList.length === 0 ? (
|
||||
<p className="muted">No incoming friend requests.</p>
|
||||
) : (
|
||||
<ul className="friend-list">
|
||||
{requestsList.map((person) => (
|
||||
<PersonRow key={person.id} person={person}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-profile-primary friend-action"
|
||||
disabled={busyId === person.id}
|
||||
onClick={() => handleAccept(person)}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-profile-danger friend-action"
|
||||
disabled={busyId === person.id}
|
||||
onClick={() => handleDecline(person)}
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
</PersonRow>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="friends-section">
|
||||
<h3 className="section-title">
|
||||
Your Friends
|
||||
{friendsList.length > 0 ? <span className="count-badge">{friendsList.length}</span> : null}
|
||||
</h3>
|
||||
{friendsList.length === 0 ? (
|
||||
<p className="muted">You have no friends yet. Send a request from a profile page.</p>
|
||||
) : (
|
||||
<ul className="friend-list">
|
||||
{friendsList.map((person) => (
|
||||
<PersonRow key={person.id} person={person}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-profile-secondary friend-action"
|
||||
disabled={busyId === person.id}
|
||||
onClick={() => handleRemove(person)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</PersonRow>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
function FeatureCard({ icon, title, text }) {
|
||||
return (
|
||||
<article className="feature-card">
|
||||
<div className="feature-icon" dangerouslySetInnerHTML={{ __html: icon }} />
|
||||
<h3>{title}</h3>
|
||||
<p>{text}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function LandingPage() {
|
||||
return (
|
||||
<div className="landing">
|
||||
@@ -19,6 +29,30 @@ export function LandingPage() {
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="landing-features">
|
||||
<FeatureCard
|
||||
icon='<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
|
||||
title="Jouw Profiel"
|
||||
text="Creëer een uniek profiel en laat zien wie je bent. Volg andere makers en bouw je netwerk."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon='<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
|
||||
title="Deel Verhalen"
|
||||
text="Publiceer berichten met tekst en afbeeldingen. Deel je gedachten, projecten en inspiratie."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon='<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>'
|
||||
title="Community"
|
||||
text="Ontdek berichten van andere makers in de community feed. Reageer, volg en raak geïnspireerd."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="landing-social">
|
||||
<p>
|
||||
<strong>Filing Cabinet</strong> — Archive your social world. Een plek voor makers, netwerken en verhalen.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Lightbox } from "../components/Lightbox";
|
||||
|
||||
function getImageFilename(post) {
|
||||
if (!post) return null;
|
||||
let img = post.image_link || post.imageLink || post.image || post.image_filename || post.imageFilename || post.image_url || post.imageUrl || post.image_path || post.imagePath || post.img || post.photo || post.picture || post.filename || post.file_name || post.fileName || post.file || post.filepath || post.file_path || post.media || post.media_url || post.media_filename || post.media_link || post.attachment || post.url || post.link || null;
|
||||
if (typeof img === "object" && img !== null) {
|
||||
img = img.url || img.filename || img.src || img.path || img.name || img.link || null;
|
||||
}
|
||||
if (!img) {
|
||||
const text = post.text || post.content || post.body || post.description || "";
|
||||
const match = text.match(/(?:(?:https?:\/\/[^\s]+|API_BASE)?\/posts\/image\/[^\s"'<>)]+)/i) || text.match(/https?:\/\/[^\s]+\.(?:png|jpe?g|gif|webp|svg|avif)/i);
|
||||
if (match) img = match[0];
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
function getAuthorName(post) {
|
||||
return post.author?.name || post.author_name || (post.author_username ? `@${post.author_username}` : "User");
|
||||
}
|
||||
|
||||
function getAuthorUsername(post) {
|
||||
return post.author_username || post.author?.username || null;
|
||||
}
|
||||
|
||||
function getInitials(name) {
|
||||
if (!name) return "?";
|
||||
const cleaned = String(name).replace(/^@/, "");
|
||||
return cleaned.split(" ").filter(Boolean).slice(0, 2).map((part) => part[0]).join("").toUpperCase() || "?";
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return null;
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return String(timestamp);
|
||||
return new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" }).format(date);
|
||||
} catch {
|
||||
return String(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
function AuthenticatedImage({ filename, alt, loadImage }) {
|
||||
const [source, setSource] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let objectUrl = "";
|
||||
setError("");
|
||||
if (!filename) {
|
||||
setSource("");
|
||||
return;
|
||||
}
|
||||
if (typeof loadImage === "function") {
|
||||
loadImage(filename).then((result) => {
|
||||
if (!active) return;
|
||||
if (result instanceof Blob) {
|
||||
objectUrl = URL.createObjectURL(result);
|
||||
setSource(objectUrl);
|
||||
} else if (typeof result === "string") {
|
||||
setSource(result);
|
||||
}
|
||||
setError("");
|
||||
}).catch(() => {
|
||||
if (!active) return;
|
||||
if (typeof filename === "string") {
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || "";
|
||||
let directUrl = filename;
|
||||
if (directUrl.startsWith("API_BASE")) {
|
||||
directUrl = directUrl.replace(/^API_BASE/, apiBase);
|
||||
} else if (directUrl.includes("/posts/image/")) {
|
||||
const imagePart = directUrl.substring(directUrl.indexOf("/posts/image/") + "/posts/image/".length);
|
||||
directUrl = `${apiBase}/posts/image/${encodeURIComponent(decodeURIComponent(imagePart))}`;
|
||||
} else if (!directUrl.startsWith("http://") && !directUrl.startsWith("https://")) {
|
||||
directUrl = `${apiBase}/posts/image/${encodeURIComponent(decodeURIComponent(directUrl.split("/").pop()))}`;
|
||||
}
|
||||
setSource(directUrl);
|
||||
setError("");
|
||||
} else {
|
||||
setError("Image unavailable.");
|
||||
}
|
||||
});
|
||||
} else if (typeof filename === "string") {
|
||||
setSource(filename);
|
||||
}
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [filename, loadImage]);
|
||||
|
||||
if (error) return <p className="muted">{error}</p>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{source ? (
|
||||
<img className="post-image post-image-clickable" src={source} alt={alt} loading="lazy" decoding="async" onClick={() => setLightboxOpen(true)} onError={() => setError("Image unavailable.")} />
|
||||
) : null}
|
||||
<Lightbox src={lightboxOpen ? source : null} alt={alt} onClose={() => setLightboxOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostPage({ getPost, loadImage }) {
|
||||
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));
|
||||
}, [postId, getPost]);
|
||||
|
||||
useEffect(() => {
|
||||
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">
|
||||
<div className="panel skeleton-card">
|
||||
<div className="skeleton-author">
|
||||
<div className="skeleton skeleton-avatar" />
|
||||
<div className="skeleton-lines">
|
||||
<div className="skeleton skeleton-line" />
|
||||
<div className="skeleton skeleton-line short" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="skeleton skeleton-line title" />
|
||||
<div className="skeleton skeleton-line" />
|
||||
<div className="skeleton skeleton-line" />
|
||||
<div className="skeleton skeleton-line short" style={{ marginTop: "0.8rem" }} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="post-page">
|
||||
<p className="error-text" role="alert">{error}</p>
|
||||
<Link to="/feed" className="cta secondary" style={{ display: "inline-block", marginTop: "1rem", textDecoration: "none" }}>Back to feed</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!post) return null;
|
||||
|
||||
const imageFilename = getImageFilename(post);
|
||||
const authorId = post.author_id ?? post.authorId ?? post.author?.id;
|
||||
const authorName = getAuthorName(post);
|
||||
const authorUsername = getAuthorUsername(post);
|
||||
const formattedDate = formatDate(post.created_at || post.createdAt);
|
||||
const profilePath = authorId ? `/profile/${authorId}` : "/profile";
|
||||
|
||||
return (
|
||||
<section className="post-page">
|
||||
<Link to="/feed" className="post-page-back">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12" />
|
||||
<polyline points="12 19 5 12 12 5" />
|
||||
</svg>
|
||||
Back to feed
|
||||
</Link>
|
||||
|
||||
<article className="panel post-detail-card">
|
||||
<div className="post-top post-author">
|
||||
<Link to={profilePath} className="author-link">
|
||||
<div className="author-avatar">{getInitials(authorName)}</div>
|
||||
<div className="author-meta">
|
||||
<span className="author-name">{authorName}</span>
|
||||
{authorUsername && !authorName.startsWith("@") ? <span className="author-handle">@{authorUsername}</span> : null}
|
||||
</div>
|
||||
</Link>
|
||||
{formattedDate ? <span className="post-date">{formattedDate}</span> : null}
|
||||
</div>
|
||||
|
||||
{imageFilename ? <AuthenticatedImage filename={imageFilename} alt={post.title || "Post image"} loadImage={loadImage} /> : null}
|
||||
|
||||
<div className="post-copy">
|
||||
{post.title ? <h3>{post.title}</h3> : null}
|
||||
<p>{post.text ?? post.content}</p>
|
||||
</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>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+263
-33
@@ -1,9 +1,22 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { PostList } from "../components/PostList";
|
||||
import { ProfileEditor } from "../components/ProfileEditor";
|
||||
|
||||
function getPosts(data) {
|
||||
return Array.isArray(data) ? data : data.posts || data.items || [];
|
||||
const posts = Array.isArray(data) ? data : data.posts || data.items || [];
|
||||
return posts.sort((a, b) => {
|
||||
const aDate = a.created_at || a.createdAt;
|
||||
const bDate = b.created_at || b.createdAt;
|
||||
if (!aDate && !bDate) return 0;
|
||||
if (!aDate) return 1;
|
||||
if (!bDate) return -1;
|
||||
return new Date(bDate) - new Date(aDate);
|
||||
});
|
||||
}
|
||||
|
||||
function getList(data) {
|
||||
return Array.isArray(data) ? data : data.friends || data.items || data.requests || [];
|
||||
}
|
||||
|
||||
function getInitials(name) {
|
||||
@@ -26,12 +39,22 @@ export function ProfilePage({
|
||||
getProfile,
|
||||
loadMyPosts,
|
||||
loadAllPosts,
|
||||
createRelationship,
|
||||
friends,
|
||||
friendRequests,
|
||||
sendFriendRequest,
|
||||
acceptFriendRequest,
|
||||
declineFriendRequest,
|
||||
cancelFriendRequest,
|
||||
removeFriend,
|
||||
editPost,
|
||||
deletePost,
|
||||
loadImage,
|
||||
currentUserId,
|
||||
onLogout,
|
||||
showToast,
|
||||
updateProfile,
|
||||
fetchImage,
|
||||
onProfileUpdate,
|
||||
}) {
|
||||
const { userId } = useParams();
|
||||
const isOwnProfile = !userId || String(userId) === String(currentUserId);
|
||||
@@ -40,14 +63,18 @@ export function ProfilePage({
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [following, setFollowing] = useState(false);
|
||||
const [followMessage, setFollowMessage] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [profileImageUrl, setProfileImageUrl] = useState("");
|
||||
const [friendStatus, setFriendStatus] = useState("none");
|
||||
const [friendBusy, setFriendBusy] = useState(false);
|
||||
const [friendMessage, setFriendMessage] = useState("");
|
||||
const profileImageUrlRef = useRef("");
|
||||
const [chatNotice, setChatNotice] = useState("");
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setFollowMessage("");
|
||||
setFriendMessage("");
|
||||
setChatNotice("");
|
||||
|
||||
try {
|
||||
@@ -74,6 +101,22 @@ export function ProfilePage({
|
||||
setProfileData({ name: "User Profile", username: null });
|
||||
}
|
||||
|
||||
setFriendStatus("none");
|
||||
if (friends && friendRequests) {
|
||||
try {
|
||||
const [friendsData, requestsData] = await Promise.all([friends(), friendRequests()]);
|
||||
const friendIds = getList(friendsData).map((p) => String(p.id));
|
||||
const requestIds = getList(requestsData).map((p) => String(p.id));
|
||||
if (friendIds.includes(String(userId))) {
|
||||
setFriendStatus("friends");
|
||||
} else if (requestIds.includes(String(userId))) {
|
||||
setFriendStatus("incoming");
|
||||
}
|
||||
} catch {
|
||||
// Friendship status unavailable; fall back to "Add friend"
|
||||
}
|
||||
}
|
||||
|
||||
const allPostsData = await loadAllPosts();
|
||||
const allPosts = getPosts(allPostsData);
|
||||
const userPosts = allPosts.filter((post) => {
|
||||
@@ -87,26 +130,145 @@ export function ProfilePage({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isOwnProfile, userId, myProfile, getProfile, loadMyPosts, loadAllPosts]);
|
||||
}, [isOwnProfile, userId, myProfile, getProfile, loadMyPosts, loadAllPosts, friends, friendRequests]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
async function handleFollow() {
|
||||
if (!userId) return;
|
||||
try {
|
||||
if (createRelationship) {
|
||||
await createRelationship(userId);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const link = profileData?.profile_link || profileData?.profileLink || profileData?.avatar || null;
|
||||
|
||||
if (link && fetchImage) {
|
||||
fetchImage(link)
|
||||
.then((blob) => {
|
||||
if (!active) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
profileImageUrlRef.current = url;
|
||||
setProfileImageUrl(url);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setProfileImageUrl("");
|
||||
});
|
||||
} else {
|
||||
setProfileImageUrl("");
|
||||
}
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (profileImageUrlRef.current) {
|
||||
URL.revokeObjectURL(profileImageUrlRef.current);
|
||||
profileImageUrlRef.current = "";
|
||||
}
|
||||
setFollowing(true);
|
||||
const nameOrHandle = profileData?.name || (profileData?.username ? `@${profileData.username}` : "this user");
|
||||
setFollowMessage(`You are now following ${nameOrHandle}!`);
|
||||
} catch {
|
||||
// Unbinded preview fallback
|
||||
setFollowing(true);
|
||||
const nameOrHandle = profileData?.name || (profileData?.username ? `@${profileData.username}` : "this user");
|
||||
setFollowMessage(`Follow relationship recorded for ${nameOrHandle}`);
|
||||
};
|
||||
}, [profileData, fetchImage]);
|
||||
|
||||
async function handleSaveProfile(data) {
|
||||
await updateProfile(data);
|
||||
const updated = await myProfile();
|
||||
setProfileData(updated);
|
||||
if (onProfileUpdate) {
|
||||
onProfileUpdate(updated);
|
||||
}
|
||||
setEditing(false);
|
||||
showToast("Profile updated.", "success");
|
||||
}
|
||||
|
||||
async function handleSendFriendRequest() {
|
||||
if (!userId) return;
|
||||
setFriendBusy(true);
|
||||
const nameOrHandle = profileData?.name || (profileData?.username ? `@${profileData.username}` : "this user");
|
||||
try {
|
||||
if (sendFriendRequest) {
|
||||
await sendFriendRequest(userId);
|
||||
}
|
||||
setFriendStatus("requested");
|
||||
setFriendMessage(`Friend request sent to ${nameOrHandle}.`);
|
||||
showToast(`Friend request sent to ${nameOrHandle}`, "success");
|
||||
} catch (err) {
|
||||
setFriendMessage(err.message || "Unable to send friend request.");
|
||||
showToast(err.message || "Unable to send friend request.", "error");
|
||||
} finally {
|
||||
setFriendBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelFriendRequest() {
|
||||
if (!userId) return;
|
||||
setFriendBusy(true);
|
||||
try {
|
||||
if (cancelFriendRequest) {
|
||||
await cancelFriendRequest(userId);
|
||||
}
|
||||
setFriendStatus("none");
|
||||
setFriendMessage("Friend request cancelled.");
|
||||
showToast("Friend request cancelled.", "success");
|
||||
} catch (err) {
|
||||
setFriendMessage(err.message || "Unable to cancel friend request.");
|
||||
showToast(err.message || "Unable to cancel friend request.", "error");
|
||||
} finally {
|
||||
setFriendBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAcceptFriendRequest() {
|
||||
if (!userId) return;
|
||||
setFriendBusy(true);
|
||||
const nameOrHandle = profileData?.name || (profileData?.username ? `@${profileData.username}` : "this user");
|
||||
try {
|
||||
if (acceptFriendRequest) {
|
||||
await acceptFriendRequest(userId);
|
||||
}
|
||||
setFriendStatus("friends");
|
||||
setFriendMessage(`You and ${nameOrHandle} are now friends.`);
|
||||
showToast(`You are now friends with ${nameOrHandle}`, "success");
|
||||
} catch (err) {
|
||||
setFriendMessage(err.message || "Unable to accept friend request.");
|
||||
showToast(err.message || "Unable to accept friend request.", "error");
|
||||
} finally {
|
||||
setFriendBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeclineFriendRequest() {
|
||||
if (!userId) return;
|
||||
setFriendBusy(true);
|
||||
try {
|
||||
if (declineFriendRequest) {
|
||||
await declineFriendRequest(userId);
|
||||
}
|
||||
setFriendStatus("none");
|
||||
setFriendMessage("Friend request declined.");
|
||||
showToast("Friend request declined.", "success");
|
||||
} catch (err) {
|
||||
setFriendMessage(err.message || "Unable to decline friend request.");
|
||||
showToast(err.message || "Unable to decline friend request.", "error");
|
||||
} finally {
|
||||
setFriendBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveFriend() {
|
||||
if (!userId) return;
|
||||
const nameOrHandle = profileData?.name || (profileData?.username ? `@${profileData.username}` : "this user");
|
||||
if (!window.confirm(`Remove ${nameOrHandle} from your friends?`)) {
|
||||
return;
|
||||
}
|
||||
setFriendBusy(true);
|
||||
try {
|
||||
if (removeFriend) {
|
||||
await removeFriend(userId);
|
||||
}
|
||||
setFriendStatus("none");
|
||||
setFriendMessage(`${nameOrHandle} removed from friends.`);
|
||||
showToast(`${nameOrHandle} removed from friends.`, "success");
|
||||
} catch (err) {
|
||||
setFriendMessage(err.message || "Unable to remove friend.");
|
||||
showToast(err.message || "Unable to remove friend.", "error");
|
||||
} finally {
|
||||
setFriendBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,11 +278,13 @@ export function ProfilePage({
|
||||
|
||||
async function handleEdit(id, data) {
|
||||
await editPost(id, data);
|
||||
showToast("Post updated.", "success");
|
||||
await loadData();
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
await deletePost(id);
|
||||
showToast("Post deleted.", "success");
|
||||
await loadData();
|
||||
}
|
||||
|
||||
@@ -136,7 +300,13 @@ export function ProfilePage({
|
||||
|
||||
<article className="panel profile-card">
|
||||
<div className="profile-header-content">
|
||||
<div className="profile-avatar-large">{getInitials(displayName)}</div>
|
||||
<div className="profile-avatar-large">
|
||||
{profileImageUrl ? (
|
||||
<img src={profileImageUrl} alt={displayName} className="profile-avatar-image" />
|
||||
) : (
|
||||
getInitials(displayName)
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-details">
|
||||
<h3 className="profile-name">{displayName}</h3>
|
||||
{displayUsername ? <p className="profile-username">@{displayUsername}</p> : null}
|
||||
@@ -148,8 +318,12 @@ export function ProfilePage({
|
||||
<button
|
||||
type="button"
|
||||
className="btn-profile-secondary"
|
||||
onClick={() => setChatNotice("Profile editing feature is ready.")}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ verticalAlign: "middle", marginRight: "0.3rem" }}>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit Profile
|
||||
</button>
|
||||
{onLogout ? (
|
||||
@@ -160,31 +334,87 @@ export function ProfilePage({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={following ? "btn-profile-following" : "btn-profile-primary"}
|
||||
onClick={handleFollow}
|
||||
>
|
||||
{following ? "✓ Following" : "+ Follow"}
|
||||
</button>
|
||||
{friendStatus === "none" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-profile-primary"
|
||||
disabled={friendBusy}
|
||||
onClick={handleSendFriendRequest}
|
||||
>
|
||||
+ Add Friend
|
||||
</button>
|
||||
) : null}
|
||||
{friendStatus === "requested" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-profile-following"
|
||||
disabled={friendBusy}
|
||||
onClick={handleCancelFriendRequest}
|
||||
title="Cancel your outgoing friend request"
|
||||
>
|
||||
Request Sent
|
||||
</button>
|
||||
) : null}
|
||||
{friendStatus === "incoming" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-profile-primary"
|
||||
disabled={friendBusy}
|
||||
onClick={handleAcceptFriendRequest}
|
||||
>
|
||||
Accept Request
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-profile-danger"
|
||||
disabled={friendBusy}
|
||||
onClick={handleDeclineFriendRequest}
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{friendStatus === "friends" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-profile-following"
|
||||
disabled={friendBusy}
|
||||
onClick={handleRemoveFriend}
|
||||
title="Click to remove this friend"
|
||||
>
|
||||
✓ Friends
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-profile-secondary"
|
||||
onClick={handleChat}
|
||||
title="Unbinded button for future chat implementation"
|
||||
>
|
||||
💬 Chat
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ verticalAlign: "middle", marginRight: "0.3rem" }}>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
Chat
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{followMessage ? <p className="success-text profile-notice">{followMessage}</p> : null}
|
||||
{friendMessage ? <p className="success-text profile-notice">{friendMessage}</p> : null}
|
||||
{chatNotice ? <p className="info-text profile-notice">{chatNotice}</p> : null}
|
||||
</article>
|
||||
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
{error ? <p className="error-text" role="alert">{error}</p> : null}
|
||||
|
||||
{editing && isOwnProfile ? (
|
||||
<ProfileEditor
|
||||
profile={profileData}
|
||||
onSave={handleSaveProfile}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="profile-posts-section">
|
||||
<h3 className="section-title">{isOwnProfile ? "Your posts" : `Posts by ${displayName}`}</h3>
|
||||
@@ -193,7 +423,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} />
|
||||
<PostList posts={posts} currentUserId={currentUserId} loadImage={loadImage} onEdit={handleEdit} onDelete={handleDelete} getProfile={getProfile} fetchImage={fetchImage} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -150,8 +150,8 @@ export function getRegistrationFieldErrors(form) {
|
||||
}
|
||||
|
||||
function validateLoginPassword(password) {
|
||||
if (password.length < 1 || password.length > 128) {
|
||||
return "Password must be between 1 and 128 characters.";
|
||||
if (password.length < 12 || password.length > 128) {
|
||||
return "Password must be at least 12 characters.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user