added friends endpoints and added features for viewing

This commit is contained in:
Sven laptop
2026-07-31 14:43:58 +02:00
parent b712c6ec07
commit 374fc9fb67
21 changed files with 1894 additions and 122 deletions
+4 -1
View File
@@ -22,4 +22,7 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.env
# Other
.env
.opencode/*
+496 -1
View File
@@ -21,7 +21,7 @@
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 1.25rem; border-radius: 1.25rem;
padding: clamp(1.5rem, 4vw, 4rem); 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; animation: rise 600ms ease-out;
} }
@@ -57,6 +57,16 @@
border-radius: 999px; border-radius: 999px;
font-weight: 700; font-weight: 700;
border: 1px solid var(--ink); 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 { .cta.primary {
@@ -69,6 +79,77 @@
background: rgba(255, 255, 255, 0.6); 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 { .auth-page {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
@@ -383,6 +464,7 @@ button:disabled {
.post-card > .post-top, .post-card > .post-top,
.post-card > .post-copy, .post-card > .post-copy,
.post-card > .post-copy-link,
.post-card > .inline-actions, .post-card > .inline-actions,
.post-card > .post-form, .post-card > .post-form,
.post-card > .error-text, .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) { @media (max-width: 900px) {
.shell { .shell {
grid-template-columns: 1fr; 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
View File
@@ -1,11 +1,15 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; 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 { Shell } from "./components/Shell";
import { ToastContainer } from "./components/Toast";
import { useToast } from "./components/useToast";
import { CreatePage } from "./pages/CreatePage"; import { CreatePage } from "./pages/CreatePage";
import { FeedPage } from "./pages/FeedPage"; import { FeedPage } from "./pages/FeedPage";
import { FriendsPage } from "./pages/FriendsPage";
import { LandingPage } from "./pages/LandingPage"; import { LandingPage } from "./pages/LandingPage";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
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 { clearAuthSession, getAuthSession, saveAuthSession } from "./utils/cookie"; import { clearAuthSession, getAuthSession, saveAuthSession } from "./utils/cookie";
@@ -32,6 +36,7 @@ function App() {
expiresAt: null, expiresAt: null,
}); });
const [userProfile, setUserProfile] = useState(null); const [userProfile, setUserProfile] = useState(null);
const { toasts, showToast, removeToast } = useToast();
const isAuthenticated = Boolean(session.token); const isAuthenticated = Boolean(session.token);
@@ -108,6 +113,17 @@ function App() {
setUserProfile(null); 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) // Fetch user profile whenever logged-in token changes (e.g. after fresh login)
useEffect(() => { useEffect(() => {
let active = true; let active = true;
@@ -160,12 +176,15 @@ function App() {
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<Routes> <>
<Route path="/" element={<LandingPage />} /> <Routes>
<Route path="/login" element={<LoginPage onLogin={authActions.onLogin} />} /> <Route path="/" element={<LandingPage />} />
<Route path="/register" element={<RegisterPage onRegister={authActions.onRegister} />} /> <Route path="/login" element={<LoginPage onLogin={authActions.onLogin} />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="/register" element={<RegisterPage onRegister={authActions.onRegister} />} />
</Routes> <Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
); );
} }
@@ -173,10 +192,23 @@ function App() {
id: session.user?.id, id: session.user?.id,
name: userProfile?.name, name: userProfile?.name,
username: userProfile?.username, 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 ( return (
<Shell user={currentUser} onLogout={logout}> <Shell user={currentUser} onLogout={logout} fetchImage={loadProfileImage}>
<Routes> <Routes>
<Route <Route
path="/feed" path="/feed"
@@ -187,13 +219,25 @@ function App() {
deletePost={(id) => apiClient.deletePost(session.token, id)} deletePost={(id) => apiClient.deletePost(session.token, id)}
loadImage={(filename) => apiClient.postImage(session.token, filename)} loadImage={(filename) => apiClient.postImage(session.token, filename)}
currentUserId={session.user.id} 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 <Route
path="/create" path="/create"
element={ element={
<CreatePage createPost={(data) => apiClient.createPost(session.token, data)} /> <CreatePage createPost={(data) => apiClient.createPost(session.token, data)} showToast={showToast} />
} }
/> />
<Route <Route
@@ -205,12 +249,16 @@ function App() {
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)}
createRelationship={(them) => apiClient.createRelationship(session.token, session.user.id, them)} {...friendProps}
editPost={(id, data) => apiClient.editPost(session.token, id, data)} editPost={(id, data) => apiClient.editPost(session.token, id, data)}
deletePost={(id) => apiClient.deletePost(session.token, id)} deletePost={(id) => apiClient.deletePost(session.token, id)}
loadImage={(filename) => apiClient.postImage(session.token, filename)} loadImage={(filename) => apiClient.postImage(session.token, filename)}
currentUserId={session.user.id} currentUserId={session.user.id}
onLogout={logout} 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)} 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)}
createRelationship={(them) => apiClient.createRelationship(session.token, session.user.id, them)} {...friendProps}
editPost={(id, data) => apiClient.editPost(session.token, id, data)} editPost={(id, data) => apiClient.editPost(session.token, id, data)}
deletePost={(id) => apiClient.deletePost(session.token, id)} deletePost={(id) => apiClient.deletePost(session.token, id)}
loadImage={(filename) => apiClient.postImage(session.token, filename)} loadImage={(filename) => apiClient.postImage(session.token, filename)}
currentUserId={session.user.id} currentUserId={session.user.id}
onLogout={logout} 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 />} /> <Route path="*" element={<Navigate to="/feed" replace />} />
</Routes> </Routes>
<ToastContainer toasts={toasts} onRemove={removeToast} />
</Shell> </Shell>
); );
} }
+112 -14
View File
@@ -2,6 +2,12 @@ import { getAuthSession } from "../utils/cookie";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; 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 } = {}) { async function request(path, { method = "GET", token, body, formData } = {}) {
if (!API_BASE_URL) { if (!API_BASE_URL) {
throw new Error("VITE_API_BASE_URL ontbreekt."); 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(() => ({})); const payload = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
if (response.status === 401 && token && token !== "active-session" && unauthorizedHandler) {
unauthorizedHandler();
}
const error = new Error( const error = new Error(
response.status === 401 response.status === 401
? "Invalid email or password" ? token && token !== "active-session"
: response.status === 400 ? "Your session has expired. Please log in again."
? payload.message || payload.error || "Please check your input." : "Invalid email or password"
: "Something went wrong. Please try again.", : payload.message || payload.error || "Something went wrong. Please try again.",
); );
error.status = response.status; error.status = response.status;
throw error; throw error;
@@ -47,14 +56,18 @@ async function request(path, { method = "GET", token, body, formData } = {}) {
} }
export const apiClient = { 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 }), register: (data) => request("/auth/register", { method: "POST", body: data }),
authMe: (token) => request("/auth/me", { token }), authMe: (token) => request("/auth/me", { token }),
authVerify: (token) => request("/auth/verify", { 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 }), 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 }), posts: (token) => request("/posts", { token }),
getPost: (token, id) => request(`/posts/${encodeURIComponent(id)}`, { 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();
@@ -79,17 +92,93 @@ export const apiClient = {
if (removeImage !== undefined) { if (removeImage !== undefined) {
formData.append("remove_image", String(removeImage)); 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) => 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", method: "POST",
token, 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 headers = {};
const effectiveToken = token && token !== "active-session" ? token : getAuthSession().token || null; 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") { if (effectiveToken && effectiveToken !== "active-session") {
headers.Authorization = `Bearer ${effectiveToken}`; headers.Authorization = `Bearer ${effectiveToken}`;
@@ -104,18 +193,24 @@ export const apiClient = {
if (target.startsWith("http://") || target.startsWith("https://")) { if (target.startsWith("http://") || target.startsWith("https://")) {
if (target.includes("/posts/image/")) { 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)); const cleanName = encodeURIComponent(decodeURIComponent(imagePart));
url = `${API_BASE_URL}/posts/image/${cleanName}`; url = `${API_BASE_URL}/posts/image/${cleanName}`;
} else { } else {
url = target; url = target;
} }
} else if (target.includes("/posts/image/")) { } 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)); const cleanName = encodeURIComponent(decodeURIComponent(imagePart));
url = `${API_BASE_URL}/posts/image/${cleanName}`; url = `${API_BASE_URL}/posts/image/${cleanName}`;
} else { } else {
const cleanName = encodeURIComponent(decodeURIComponent(target.split("/").pop())); const cleanName = encodeURIComponent(
decodeURIComponent(target.split("/").pop()),
);
url = `${API_BASE_URL}/posts/image/${cleanName}`; url = `${API_BASE_URL}/posts/image/${cleanName}`;
} }
} else { } else {
@@ -127,6 +222,9 @@ export const apiClient = {
credentials: "include", credentials: "include",
}); });
if (!response.ok) { if (!response.ok) {
if (response.status === 401 && unauthorizedHandler) {
unauthorizedHandler();
}
throw new Error("Unable to load image."); throw new Error("Unable to load image.");
} }
return response.blob(); return response.blob();
+123
View File
@@ -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>
);
}
+30
View File
@@ -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
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { AuthorAvatar } from "./AuthorAvatar";
import { Lightbox } from "./Lightbox";
function getImageFilename(post) { function getImageFilename(post) {
if (!post) return null; if (!post) return null;
@@ -57,18 +59,6 @@ function getAuthorUsername(post) {
return post.author_username || post.author?.username || null; 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) { function formatDate(timestamp) {
if (!timestamp) return null; if (!timestamp) return null;
try { try {
@@ -89,6 +79,7 @@ function formatDate(timestamp) {
function AuthenticatedImage({ filename, alt, loadImage }) { function AuthenticatedImage({ filename, alt, loadImage }) {
const [source, setSource] = useState(""); const [source, setSource] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
@@ -147,21 +138,27 @@ function AuthenticatedImage({ filename, alt, loadImage }) {
return <p className="muted">{error}</p>; return <p className="muted">{error}</p>;
} }
return source ? ( return (
<img <>
className="post-image" {source ? (
src={source} <img
alt={alt} className="post-image post-image-clickable"
loading="lazy" src={source}
decoding="async" alt={alt}
onError={() => { loading="lazy"
setError("Image unavailable."); decoding="async"
}} onClick={() => setLightboxOpen(true)}
/> onError={() => {
) : null; 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 [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("");
@@ -206,7 +203,7 @@ export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete }) {
<article className="panel post-card"> <article className="panel post-card">
<div className="post-top post-author"> <div className="post-top post-author">
<Link to={profilePath} className="author-link" title={`View ${authorName}'s profile`}> <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"> <div className="author-meta">
<span className="author-name">{authorName}</span> <span className="author-name">{authorName}</span>
{authorUsername && !authorName.startsWith("@") ? ( {authorUsername && !authorName.startsWith("@") ? (
@@ -274,10 +271,12 @@ export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete }) {
</form> </form>
) : ( ) : (
<> <>
<div className="post-copy"> <Link to={`/post/${post.id}`} className="post-copy-link">
{post.title ? <h3>{post.title}</h3> : null} <div className="post-copy">
<p>{post.text ?? post.content}</p> {post.title ? <h3>{post.title}</h3> : null}
</div> <p>{post.text ?? post.content}</p>
</div>
</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}
</> </>
)} )}
+8 -3
View File
@@ -28,7 +28,12 @@ export function PostComposer({ onCreate }) {
return ( return (
<section className="panel composer-card"> <section className="panel composer-card">
<div className="composer-heading"> <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> <div>
<h3>Share with your community</h3> <h3>Share with your community</h3>
<p className="muted">Add a title, a thought, or an image.</p> <p className="muted">Add a title, a thought, or an image.</p>
@@ -62,8 +67,8 @@ export function PostComposer({ onCreate }) {
</label> </label>
<button type="submit">Publish post</button> <button type="submit">Publish post</button>
</form> </form>
{error ? <p className="error-text">{error}</p> : null} {error ? <p className="error-text" role="alert">{error}</p> : null}
{message ? <p className="success-text">{message}</p> : null} {message ? <p className="success-text" aria-live="polite">{message}</p> : null}
</section> </section>
); );
} }
+3 -1
View File
@@ -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 }) { export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete, getProfile, fetchImage }) {
const [visibleCount, setVisibleCount] = useState(POSTS_PER_BATCH); const [visibleCount, setVisibleCount] = useState(POSTS_PER_BATCH);
const loadMoreRef = useRef(null); const loadMoreRef = useRef(null);
@@ -44,6 +44,8 @@ export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete })
loadImage={loadImage} loadImage={loadImage}
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
getProfile={getProfile}
fetchImage={fetchImage}
/> />
))} ))}
{visibleCount < posts.length ? ( {visibleCount < posts.length ? (
+116
View File
@@ -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
View File
@@ -1,20 +1,7 @@
import { Link, NavLink } from "react-router-dom"; import { Link, NavLink } from "react-router-dom";
import { AuthorAvatar } from "./AuthorAvatar";
function getInitials(name) { export function Shell({ user, onLogout, children, fetchImage }) {
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 }) {
const displayName = user?.name || (user?.username ? `@${user.username}` : "Your Account"); const displayName = user?.name || (user?.username ? `@${user.username}` : "Your Account");
const displaySub = user?.username && user?.name ? `@${user.username}` : ""; 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> <p className="tagline">Archive your social world.</p>
<nav> <nav>
<NavLink to="/feed" className="nav-link"> <NavLink to="/feed" className="nav-link" end>
<span className="nav-icon"></span> Feed <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>
<NavLink to="/create" className="nav-link create-link"> <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>
<NavLink to="/profile" className="nav-link"> <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> </NavLink>
</nav> </nav>
<div className="account-card"> <div className="account-card">
<Link to="/profile" className="account-info-row" title="Go to your profile"> <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"> <div className="account-details">
<span className="account-name">{displayName}</span> <span className="account-name">{displayName}</span>
<span className="account-sub">{displaySub}</span> <span className="account-sub">{displaySub}</span>
+36
View File
@@ -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>
);
}
+25
View File
@@ -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 };
}
+45
View File
@@ -7,9 +7,16 @@
--panel: #fffcf6; --panel: #fffcf6;
--accent: #c45a3d; --accent: #c45a3d;
--accent-dark: #9a3e27; --accent-dark: #9a3e27;
--accent-light: rgba(196, 90, 61, 0.1);
--accent-glow: rgba(196, 90, 61, 0.25);
--line: #d8d1c4; --line: #d8d1c4;
--ok: #2d8a58; --ok: #2d8a58;
--ok-light: rgba(45, 138, 88, 0.1);
--error: #b24343; --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; --display: "Fraunces", serif;
--body: "Manrope", sans-serif; --body: "Manrope", sans-serif;
@@ -33,3 +40,41 @@ body {
#root { #root {
min-height: 100vh; 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;
}
}
+2 -1
View File
@@ -1,11 +1,12 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { PostComposer } from "../components/PostComposer"; import { PostComposer } from "../components/PostComposer";
export function CreatePage({ createPost }) { export function CreatePage({ createPost, showToast }) {
const navigate = useNavigate(); const navigate = useNavigate();
async function handleCreate(data) { async function handleCreate(data) {
await createPost(data); await createPost(data);
showToast("Post created!", "success");
navigate("/feed"); navigate("/feed");
} }
+36 -5
View File
@@ -2,7 +2,15 @@ import { useCallback, useEffect, useState } from "react";
import { PostList } from "../components/PostList"; import { PostList } from "../components/PostList";
function getPosts(data) { 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) { function getAuthorId(post) {
@@ -16,7 +24,28 @@ function postsFromOthers(posts, currentUserId) {
return posts.filter((post) => String(getAuthorId(post)) !== String(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 [posts, setPosts] = useState([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -39,11 +68,13 @@ export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUs
async function handleEdit(id, data) { async function handleEdit(id, data) {
await editPost(id, data); await editPost(id, data);
showToast("Post updated.", "success");
await refresh(); await refresh();
} }
async function handleDelete(id) { async function handleDelete(id) {
await deletePost(id); await deletePost(id);
showToast("Post deleted.", "success");
await refresh(); await refresh();
} }
@@ -56,16 +87,16 @@ export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUs
</div> </div>
<span className="feed-status">Explore community posts</span> <span className="feed-status">Explore community posts</span>
</header> </header>
{error ? <p className="error-text">{error}</p> : null} {error ? <p className="error-text" role="alert">{error}</p> : null}
{loading ? ( {loading ? (
<p className="post-loading">Loading community feed...</p> <FeedSkeleton />
) : posts.length === 0 ? ( ) : posts.length === 0 ? (
<div className="panel empty-feed-panel"> <div className="panel empty-feed-panel">
<h3>No posts yet</h3> <h3>No posts yet</h3>
<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} /> <PostList posts={posts} currentUserId={currentUserId} loadImage={loadImage} onEdit={handleEdit} onDelete={handleDelete} getProfile={getProfile} fetchImage={fetchImage} />
)} )}
</section> </section>
); );
+194
View File
@@ -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>
);
}
+34
View File
@@ -1,5 +1,15 @@
import { Link } from "react-router-dom"; 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() { export function LandingPage() {
return ( return (
<div className="landing"> <div className="landing">
@@ -19,6 +29,30 @@ export function LandingPage() {
</Link> </Link>
</div> </div>
</section> </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> &mdash; Archive your social world. Een plek voor makers, netwerken en verhalen.
</p>
</aside>
</div> </div>
); );
} }
+223
View File
@@ -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
View File
@@ -1,9 +1,22 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { PostList } from "../components/PostList"; import { PostList } from "../components/PostList";
import { ProfileEditor } from "../components/ProfileEditor";
function getPosts(data) { 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) { function getInitials(name) {
@@ -26,12 +39,22 @@ export function ProfilePage({
getProfile, getProfile,
loadMyPosts, loadMyPosts,
loadAllPosts, loadAllPosts,
createRelationship, friends,
friendRequests,
sendFriendRequest,
acceptFriendRequest,
declineFriendRequest,
cancelFriendRequest,
removeFriend,
editPost, editPost,
deletePost, deletePost,
loadImage, loadImage,
currentUserId, currentUserId,
onLogout, onLogout,
showToast,
updateProfile,
fetchImage,
onProfileUpdate,
}) { }) {
const { userId } = useParams(); const { userId } = useParams();
const isOwnProfile = !userId || String(userId) === String(currentUserId); const isOwnProfile = !userId || String(userId) === String(currentUserId);
@@ -40,14 +63,18 @@ export function ProfilePage({
const [posts, setPosts] = useState([]); const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [following, setFollowing] = useState(false); const [editing, setEditing] = useState(false);
const [followMessage, setFollowMessage] = useState(""); 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 [chatNotice, setChatNotice] = useState("");
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
setFollowMessage(""); setFriendMessage("");
setChatNotice(""); setChatNotice("");
try { try {
@@ -74,6 +101,22 @@ export function ProfilePage({
setProfileData({ name: "User Profile", username: null }); 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 allPostsData = await loadAllPosts();
const allPosts = getPosts(allPostsData); const allPosts = getPosts(allPostsData);
const userPosts = allPosts.filter((post) => { const userPosts = allPosts.filter((post) => {
@@ -87,26 +130,145 @@ export function ProfilePage({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [isOwnProfile, userId, myProfile, getProfile, loadMyPosts, loadAllPosts]); }, [isOwnProfile, userId, myProfile, getProfile, loadMyPosts, loadAllPosts, friends, friendRequests]);
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, [loadData]); }, [loadData]);
async function handleFollow() { useEffect(() => {
if (!userId) return; let active = true;
try { const link = profileData?.profile_link || profileData?.profileLink || profileData?.avatar || null;
if (createRelationship) {
await createRelationship(userId); 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"); }, [profileData, fetchImage]);
setFollowMessage(`You are now following ${nameOrHandle}!`);
} catch { async function handleSaveProfile(data) {
// Unbinded preview fallback await updateProfile(data);
setFollowing(true); const updated = await myProfile();
const nameOrHandle = profileData?.name || (profileData?.username ? `@${profileData.username}` : "this user"); setProfileData(updated);
setFollowMessage(`Follow relationship recorded for ${nameOrHandle}`); 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) { async function handleEdit(id, data) {
await editPost(id, data); await editPost(id, data);
showToast("Post updated.", "success");
await loadData(); await loadData();
} }
async function handleDelete(id) { async function handleDelete(id) {
await deletePost(id); await deletePost(id);
showToast("Post deleted.", "success");
await loadData(); await loadData();
} }
@@ -136,7 +300,13 @@ export function ProfilePage({
<article className="panel profile-card"> <article className="panel profile-card">
<div className="profile-header-content"> <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"> <div className="profile-details">
<h3 className="profile-name">{displayName}</h3> <h3 className="profile-name">{displayName}</h3>
{displayUsername ? <p className="profile-username">@{displayUsername}</p> : null} {displayUsername ? <p className="profile-username">@{displayUsername}</p> : null}
@@ -148,8 +318,12 @@ export function ProfilePage({
<button <button
type="button" type="button"
className="btn-profile-secondary" 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 Edit Profile
</button> </button>
{onLogout ? ( {onLogout ? (
@@ -160,31 +334,87 @@ export function ProfilePage({
</> </>
) : ( ) : (
<> <>
<button {friendStatus === "none" ? (
type="button" <button
className={following ? "btn-profile-following" : "btn-profile-primary"} type="button"
onClick={handleFollow} className="btn-profile-primary"
> disabled={friendBusy}
{following ? "✓ Following" : " Follow"} onClick={handleSendFriendRequest}
</button> >
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 <button
type="button" type="button"
className="btn-profile-secondary" className="btn-profile-secondary"
onClick={handleChat} onClick={handleChat}
title="Unbinded button for future chat implementation" 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> </button>
</> </>
)} )}
</div> </div>
</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} {chatNotice ? <p className="info-text profile-notice">{chatNotice}</p> : null}
</article> </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"> <div className="profile-posts-section">
<h3 className="section-title">{isOwnProfile ? "Your posts" : `Posts by ${displayName}`}</h3> <h3 className="section-title">{isOwnProfile ? "Your posts" : `Posts by ${displayName}`}</h3>
@@ -193,7 +423,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} /> <PostList posts={posts} currentUserId={currentUserId} loadImage={loadImage} onEdit={handleEdit} onDelete={handleDelete} getProfile={getProfile} fetchImage={fetchImage} />
)} )}
</div> </div>
</section> </section>
+2 -2
View File
@@ -150,8 +150,8 @@ export function getRegistrationFieldErrors(form) {
} }
function validateLoginPassword(password) { function validateLoginPassword(password) {
if (password.length < 1 || password.length > 128) { if (password.length < 12 || password.length > 128) {
return "Password must be between 1 and 128 characters."; return "Password must be at least 12 characters.";
} }
return null; return null;
} }