Improved UI and compliance with api endpoint restrictions

This commit is contained in:
Sven laptop
2026-07-24 23:51:26 +02:00
parent c891f53197
commit beb8bb287e
17 changed files with 1464 additions and 335 deletions
+11 -9
View File
@@ -13,15 +13,16 @@ React social platform shell met duidelijke scheiding tussen frontend en backend.
- Login (`/login`) - Login (`/login`)
- Register (`/register`) - Register (`/register`)
- Feed (`/feed`) - Feed (`/feed`)
- Discover (`/discover`) - Create (`/create`)
- Profile (`/profile`) - Profile (`/profile`)
Werkende acties: Werkende acties:
- Inloggen en registreren - Inloggen en registreren
- Feed laden - Alle posts laden
- Vriendverzoek versturen - Eigen posts laden
- Upload triggeren - Posts aanmaken, bewerken en verwijderen
- Beveiligde postafbeeldingen laden
- Uitloggen - Uitloggen
## Externe API-koppeling ## Externe API-koppeling
@@ -34,11 +35,12 @@ De volgende endpoints worden vanuit de frontend aangeroepen:
- `POST /auth/login` - `POST /auth/login`
- `POST /auth/register` - `POST /auth/register`
- `GET /auth/me` - `GET /posts`
- `GET /feed` - `GET /posts/me`
- `GET /discover` - `GET /posts/image/:filename`
- `POST /friends/request` - `POST /posts/create`
- `POST /uploads` - `PUT /posts/edit?id=<id>`
- `DELETE /posts/delete?id=<id>`
Zie `frontend/.env.example`. Zie `frontend/.env.example`.
+543 -41
View File
@@ -112,6 +112,30 @@ input {
background: #fff; background: #fff;
} }
input.input-invalid {
border-color: var(--error);
box-shadow: 0 0 0 2px rgba(178, 67, 67, 0.12);
}
input.input-valid {
border-color: var(--ok);
box-shadow: 0 0 0 2px rgba(45, 138, 88, 0.12);
}
.field-error,
.field-valid {
font-size: 0.78rem;
font-weight: 500;
}
.field-error {
color: var(--error);
}
.field-valid {
color: var(--ok);
}
button { button {
border: none; border: none;
border-radius: 0.7rem; border-radius: 0.7rem;
@@ -130,16 +154,19 @@ button:disabled {
.shell { .shell {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
grid-template-columns: 280px 1fr; grid-template-columns: 248px minmax(0, 1fr);
} }
.sidebar { .sidebar {
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
background: rgba(255, 252, 246, 0.8); background: rgba(255, 252, 246, 0.8);
padding: 1.25rem; padding: 1.25rem;
display: grid; display: flex;
align-content: start; flex-direction: column;
gap: 1rem; gap: 1rem;
position: sticky;
top: 0;
height: 100vh;
} }
.brand { .brand {
@@ -156,33 +183,163 @@ button:disabled {
.nav-link { .nav-link {
display: block; display: block;
margin-bottom: 0.4rem; margin-bottom: 0.25rem;
color: var(--ink); color: var(--ink);
text-decoration: none; text-decoration: none;
padding: 0.48rem 0.6rem; padding: 0.75rem 0.8rem;
border-radius: 0.55rem; border-radius: 0.75rem;
font-weight: 600;
transition: background 160ms ease, transform 160ms ease;
} }
.nav-link.active { .nav-link.active {
background: rgba(196, 90, 61, 0.12); background: rgba(196, 90, 61, 0.12);
} }
.account-card { .nav-link:hover {
margin-top: 1rem; background: rgba(196, 90, 61, 0.08);
padding: 0.85rem; transform: translateX(2px);
border-radius: 0.8rem;
background: #fff;
border: 1px solid var(--line);
} }
.account-card span { .nav-icon {
display: block; display: inline-grid;
width: 1.4rem;
place-items: center;
margin-right: 0.35rem;
font-size: 1.15rem;
}
.create-link {
color: var(--accent-dark);
}
/* Sidebar Account Card - Improved placement & structure */
.account-card {
margin-top: auto;
padding: 0.85rem;
border-radius: 0.85rem;
background: #fff;
border: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 0.75rem;
box-shadow: 0 4px 12px rgba(31, 42, 43, 0.04);
}
.account-info-row {
display: flex;
align-items: center;
gap: 0.65rem;
text-decoration: none;
color: inherit;
border-radius: 0.5rem;
transition: opacity 150ms ease;
}
.account-info-row:hover {
opacity: 0.85;
}
.account-avatar {
flex: 0 0 2.3rem;
width: 2.3rem;
height: 2.3rem;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), #e4a15c);
color: #fff;
display: grid;
place-items: center;
font-weight: 800;
font-size: 0.78rem;
}
.account-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.account-name {
font-weight: 700;
font-size: 0.88rem;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.account-sub {
font-size: 0.75rem;
color: var(--muted); color: var(--muted);
margin-bottom: 0.6rem; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.account-actions {
display: flex;
justify-content: flex-end;
}
.logout-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.42rem 0.75rem;
border-radius: 0.5rem;
background: rgba(178, 67, 67, 0.08);
color: var(--error);
font-size: 0.78rem;
font-weight: 700;
border: none;
cursor: pointer;
transition: background 150ms ease, transform 150ms ease;
}
.logout-btn:hover {
background: rgba(178, 67, 67, 0.16);
transform: translateY(-1px);
} }
.content { .content {
padding: 1.25rem; padding: clamp(1rem, 3vw, 2.25rem);
min-width: 0;
}
.feed-page,
.create-page,
.profile-page {
width: min(680px, 100%);
margin: 0 auto;
}
.feed-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
margin: 0.25rem 0 1.25rem;
}
.feed-header h2,
.create-page h2 {
margin: 0;
font-family: var(--display);
font-size: clamp(2rem, 4vw, 2.8rem);
}
.eyebrow {
margin: 0 0 0.25rem;
color: var(--accent-dark);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.feed-status {
color: var(--muted);
font-size: 0.82rem;
} }
.page-head h2 { .page-head h2 {
@@ -193,11 +350,18 @@ button:disabled {
.grid, .grid,
.post-list { .post-list {
margin-top: 1rem; margin-top: 1.15rem;
display: grid; display: grid;
gap: 0.9rem; gap: 0.9rem;
} }
.post-loading {
padding: 1rem;
color: var(--muted);
text-align: center;
font-size: 0.85rem;
}
.grid { .grid {
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
} }
@@ -207,51 +371,370 @@ button:disabled {
border-radius: 0.9rem; border-radius: 0.9rem;
border: 1px solid var(--line); border: 1px solid var(--line);
padding: 1rem; padding: 1rem;
box-shadow: 0 8px 24px rgba(31, 42, 43, 0.05);
} }
.post-top, /* Post Card & Headers */
.profile-card { .post-card {
padding: 0;
overflow: hidden;
}
.post-card > .post-top,
.post-card > .post-copy,
.post-card > .inline-actions,
.post-card > .post-form,
.post-card > .error-text {
margin-left: 1rem;
margin-right: 1rem;
}
.post-card > .post-top {
margin-top: 1rem;
}
.post-author {
width: auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.author-link {
display: flex;
align-items: center;
gap: 0.65rem;
text-decoration: none;
color: inherit;
transition: opacity 150ms ease;
}
.author-link:hover {
opacity: 0.8;
}
.author-avatar {
flex: 0 0 2.4rem;
width: 2.4rem;
height: 2.4rem;
display: grid;
place-items: center;
border-radius: 50%;
color: #fff;
background: linear-gradient(135deg, var(--accent), #e4a15c);
font-size: 0.78rem;
font-weight: 800;
}
.author-meta {
display: flex;
flex-direction: column;
}
.author-name {
font-weight: 700;
font-size: 0.92rem;
color: var(--ink);
line-height: 1.25;
}
.author-handle {
font-size: 0.76rem;
color: var(--muted);
}
.post-top-right {
display: flex;
align-items: center;
gap: 0.65rem;
margin-left: auto;
}
.post-date {
font-size: 0.75rem;
color: var(--muted);
white-space: nowrap;
}
/* Tiny Icon Buttons with Padding Around */
.post-owner-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.icon-btn {
width: 32px;
height: 32px;
padding: 6px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
cursor: pointer;
transition: all 160ms ease;
}
.edit-icon-btn {
background: rgba(196, 90, 61, 0.1);
color: var(--accent-dark);
}
.edit-icon-btn:hover {
background: rgba(196, 90, 61, 0.22);
border-color: rgba(196, 90, 61, 0.3);
transform: scale(1.08);
}
.delete-icon-btn {
background: rgba(178, 67, 67, 0.1);
color: var(--error);
}
.delete-icon-btn:hover {
background: rgba(178, 67, 67, 0.22);
border-color: rgba(178, 67, 67, 0.3);
transform: scale(1.08);
}
.post-copy {
margin-top: 0.85rem;
}
.post-copy h3 {
margin: 0 0 0.3rem;
font-family: var(--display);
font-size: 1.3rem;
}
.post-copy p {
margin: 0;
line-height: 1.65;
white-space: pre-wrap;
}
.composer-card {
margin-bottom: 1.2rem;
}
.composer-heading {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.7rem; gap: 0.7rem;
margin-bottom: 1rem;
} }
.post-top img, .composer-heading h3 {
.user-card img { margin: 0;
width: 50px;
height: 50px;
object-fit: cover;
border-radius: 999px;
} }
.avatar-large { .composer-heading p {
width: 88px; margin: 0.2rem 0 0;
height: 88px; font-size: 0.82rem;
border-radius: 999px; }
object-fit: cover;
.post-image {
display: block;
width: 100%;
max-height: 420px;
object-fit: contain;
margin-top: 0.8rem;
border-radius: 0.7rem;
}
.post-form {
display: grid;
gap: 0.7rem;
}
.post-form label {
display: grid;
gap: 0.35rem;
font-weight: 600;
}
textarea {
font: inherit;
resize: vertical;
border: 1px solid var(--line);
border-radius: 0.7rem;
padding: 0.72rem 0.8rem;
}
.checkbox-label {
display: flex !important;
align-items: center;
gap: 0.5rem !important;
}
.checkbox-label input {
width: auto;
}
.inline-actions {
display: flex;
gap: 0.6rem;
margin-top: 0.7rem;
}
.danger-btn {
background: var(--error);
} }
.ghost-btn { .ghost-btn {
background: rgba(196, 90, 61, 0.12); background: rgba(196, 90, 61, 0.12);
color: var(--accent-dark); color: var(--accent-dark);
margin-top: 0.4rem;
} }
.user-card h3, /* Profile Page Layout & Account Panels */
.post-top p, .profile-card {
.account-card p { padding: 1.4rem;
margin: 0; margin-top: 1rem;
} }
.user-card p, .profile-header-content {
.post-top span {
color: var(--muted);
}
.upload-form {
display: flex; display: flex;
align-items: center;
gap: 1.2rem;
flex-wrap: wrap; flex-wrap: wrap;
}
.profile-avatar-large {
width: 4rem;
height: 4rem;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), #e4a15c);
color: #fff;
display: grid;
place-items: center;
font-size: 1.4rem;
font-weight: 800;
box-shadow: 0 4px 14px rgba(196, 90, 61, 0.25);
}
.profile-details {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1 1 200px;
}
.profile-name {
margin: 0;
font-family: var(--display);
font-size: 1.5rem;
color: var(--ink);
}
.profile-username {
margin: 0;
font-size: 0.9rem;
color: var(--accent-dark);
font-weight: 600;
}
.profile-id-badge {
display: inline-block;
margin-top: 0.2rem;
font-size: 0.72rem;
color: var(--muted);
background: rgba(0, 0, 0, 0.04);
padding: 0.15rem 0.45rem;
border-radius: 0.4rem;
width: fit-content;
}
.profile-actions-bar {
display: flex;
align-items: center;
gap: 0.6rem; gap: 0.6rem;
margin-left: auto;
}
.btn-profile-primary {
background: var(--ink);
color: #fff;
padding: 0.55rem 1.1rem;
border-radius: 0.65rem;
font-weight: 700;
font-size: 0.85rem;
border: none;
cursor: pointer;
transition: background 150ms ease, transform 150ms ease;
}
.btn-profile-primary:hover {
background: #2a3b3d;
transform: translateY(-1px);
}
.btn-profile-following {
background: rgba(45, 138, 88, 0.12);
color: var(--ok);
border: 1px solid var(--ok);
padding: 0.55rem 1.1rem;
border-radius: 0.65rem;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
}
.btn-profile-secondary {
background: rgba(0, 0, 0, 0.05);
color: var(--ink);
padding: 0.55rem 1.1rem;
border-radius: 0.65rem;
font-weight: 600;
font-size: 0.85rem;
border: 1px solid var(--line);
cursor: pointer;
transition: background 150ms ease;
}
.btn-profile-secondary:hover {
background: rgba(0, 0, 0, 0.09);
}
.btn-profile-danger {
background: rgba(178, 67, 67, 0.1);
color: var(--error);
padding: 0.55rem 1.1rem;
border-radius: 0.65rem;
font-weight: 700;
font-size: 0.85rem;
border: 1px solid rgba(178, 67, 67, 0.2);
cursor: pointer;
}
.profile-notice {
margin-top: 0.8rem;
font-size: 0.85rem;
}
.profile-posts-section {
margin-top: 1.5rem;
}
.section-title {
font-family: var(--display);
font-size: 1.3rem;
margin-bottom: 0.75rem;
}
.empty-feed-panel {
text-align: center;
padding: 2.5rem 1.5rem;
}
.empty-feed-panel h3 {
margin: 0 0 0.5rem;
font-family: var(--display);
font-size: 1.3rem;
}
.info-text {
color: var(--accent-dark);
} }
.success-text { .success-text {
@@ -281,5 +764,24 @@ button:disabled {
.sidebar { .sidebar {
border-right: none; border-right: none;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
position: static;
height: auto;
padding: 0.85rem 1rem;
gap: 0.65rem;
}
.tagline,
.account-card {
display: none;
}
.sidebar nav {
display: flex;
gap: 0.35rem;
}
.nav-link {
margin: 0;
padding: 0.55rem 0.65rem;
} }
} }
+114 -74
View File
@@ -2,7 +2,7 @@ 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 } from "./api/client";
import { Shell } from "./components/Shell"; import { Shell } from "./components/Shell";
import { DiscoverPage } from "./pages/DiscoverPage"; import { CreatePage } from "./pages/CreatePage";
import { FeedPage } from "./pages/FeedPage"; import { FeedPage } from "./pages/FeedPage";
import { LandingPage } from "./pages/LandingPage"; import { LandingPage } from "./pages/LandingPage";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
@@ -11,102 +11,95 @@ import { RegisterPage } from "./pages/RegisterPage";
import "./App.css"; import "./App.css";
const TOKEN_KEY = "fc_session_token"; const TOKEN_KEY = "fc_session_token";
const USER_KEY = "fc_session_user"; const USER_ID_KEY = "fc_session_user_id";
const EXPIRES_AT_KEY = "fc_session_expires_at";
function safeParseSessionUser(rawValue) { function hasExpired(expiresAt) {
if (!rawValue) { if (!expiresAt) {
return null; return false;
}
try {
return JSON.parse(rawValue);
} catch {
return null;
} }
const numericValue = Number(expiresAt);
const timestamp = Number.isNaN(numericValue)
? Date.parse(expiresAt)
: numericValue < 100000000000
? numericValue * 1000
: numericValue;
return Number.isFinite(timestamp) && timestamp <= Date.now();
} }
function App() { function App() {
const [session, setSession] = useState(() => { const [session, setSession] = useState(() => {
const storedToken = sessionStorage.getItem(TOKEN_KEY); const token = sessionStorage.getItem(TOKEN_KEY);
const storedUser = sessionStorage.getItem(USER_KEY); const userId = sessionStorage.getItem(USER_ID_KEY);
const expiresAt = sessionStorage.getItem(EXPIRES_AT_KEY);
const valid = token && userId && !hasExpired(expiresAt);
return { return {
token: storedToken, token: valid ? token : null,
user: safeParseSessionUser(storedUser), user: valid ? { id: userId } : null,
expiresAt: valid ? expiresAt : null,
}; };
}); });
const [isBootstrapping, setIsBootstrapping] = useState(Boolean(session.token));
const [userProfile, setUserProfile] = useState(null);
const isAuthenticated = Boolean(session.token); const isAuthenticated = Boolean(session.token);
const persistSession = useCallback((token, user) => { useEffect(() => {
let active = true;
if (session.token) {
apiClient
.myProfile(session.token)
.then((profile) => {
if (active) {
setUserProfile(profile);
}
})
.catch(() => {
if (active) {
setUserProfile(null);
}
});
} else {
setUserProfile(null);
}
return () => {
active = false;
};
}, [session.token]);
const persistSession = useCallback((id, token, expiresAt) => {
sessionStorage.setItem(TOKEN_KEY, token); sessionStorage.setItem(TOKEN_KEY, token);
sessionStorage.setItem(USER_KEY, JSON.stringify(user)); sessionStorage.setItem(USER_ID_KEY, id);
setSession({ token, user }); sessionStorage.removeItem(EXPIRES_AT_KEY);
if (expiresAt) {
sessionStorage.setItem(EXPIRES_AT_KEY, expiresAt);
}
setSession({ token, user: { id }, expiresAt });
}, []); }, []);
const logout = useCallback(() => { const logout = useCallback(() => {
sessionStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(USER_KEY); sessionStorage.removeItem(USER_ID_KEY);
setSession({ token: null, user: null }); sessionStorage.removeItem(EXPIRES_AT_KEY);
setSession({ token: null, user: null, expiresAt: null });
setUserProfile(null);
}, []); }, []);
useEffect(() => { useEffect(() => {
let mounted = true; if (session.token && hasExpired(session.expiresAt)) {
async function bootstrapSession() {
if (!session.token) {
setIsBootstrapping(false);
return;
}
try {
const result = await apiClient.me(session.token);
if (mounted && result.user) {
sessionStorage.setItem(USER_KEY, JSON.stringify(result.user));
setSession((prev) => ({ ...prev, user: result.user }));
}
} catch {
if (mounted) {
logout(); logout();
} }
} finally { }, [logout, session.expiresAt, session.token]);
if (mounted) {
setIsBootstrapping(false);
}
}
}
bootstrapSession();
return () => {
mounted = false;
};
}, [logout, session.token]);
const authActions = { const authActions = {
onLogin: async (credentials) => { onLogin: async (credentials) => {
const result = await apiClient.login(credentials); const result = await apiClient.login(credentials);
persistSession(result.token, result.user); persistSession(result.id, result.token, result.expires_at);
return result;
},
onRegister: async (data) => {
const result = await apiClient.register(data);
persistSession(result.token, result.user);
return result; return result;
}, },
onRegister: (data) => apiClient.register(data),
}; };
if (isBootstrapping) {
return (
<div className="auth-page">
<section className="auth-card">
<h1>Sessie controleren...</h1>
<p className="muted">Wacht even terwijl je account extern wordt gevalideerd.</p>
</section>
</div>
);
}
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<Routes> <Routes>
@@ -118,22 +111,69 @@ function App() {
); );
} }
const currentUser = {
id: session.user?.id,
name: userProfile?.name,
username: userProfile?.username,
};
return ( return (
<Shell user={session.user} onLogout={logout}> <Shell user={currentUser} onLogout={logout}>
<Routes> <Routes>
<Route path="/feed" element={<FeedPage loadFeed={() => apiClient.feed(session.token)} />} />
<Route <Route
path="/discover" path="/feed"
element={ element={
<DiscoverPage <FeedPage
loadUsers={() => apiClient.discover(session.token)} loadPosts={() => apiClient.posts(session.token)}
sendFriendRequest={(toUserId) => apiClient.sendFriendRequest(session.token, toUserId)} 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}
currentUsername={userProfile?.username}
/> />
} }
/> />
<Route
path="/create"
element={
<CreatePage createPost={(data) => apiClient.createPost(session.token, data)} />
}
/>
<Route <Route
path="/profile" path="/profile"
element={<ProfilePage user={session.user} onUpload={(file) => apiClient.upload(session.token, file)} />} element={
<ProfilePage
user={currentUser}
myProfile={() => apiClient.myProfile(session.token)}
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)}
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}
/>
}
/>
<Route
path="/profile/:userId"
element={
<ProfilePage
user={currentUser}
myProfile={() => apiClient.myProfile(session.token)}
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)}
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}
/>
}
/> />
<Route path="*" element={<Navigate to="/feed" replace />} /> <Route path="*" element={<Navigate to="/feed" replace />} />
</Routes> </Routes>
+42 -8
View File
@@ -48,14 +48,48 @@ 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 }),
me: (token) => request("/auth/me", { token }), myProfile: (token) => request("/profiles/me", { token }),
feed: (token) => request("/feed", { token }), getProfile: (token, id) => request(`/profiles/${encodeURIComponent(id)}`, { token }),
discover: (token) => request("/discover", { token }), posts: (token) => request("/posts", { token }),
sendFriendRequest: (token, toUserId) => myPosts: (token) => request("/posts/me", { token }),
request("/friends/request", { method: "POST", token, body: { toUserId } }), createPost: (token, { title, text, image }) => {
upload: (token, file) => {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("title", title);
return request("/uploads", { method: "POST", token, formData }); formData.append("text", text);
if (image) {
formData.append("image", image);
}
return request("/posts/create", { method: "POST", token, formData });
},
editPost: (token, id, { title, text, image, removeImage }) => {
const formData = new FormData();
if (title !== undefined) {
formData.append("title", title);
}
if (text !== undefined) {
formData.append("text", text);
}
if (image) {
formData.append("image", image);
}
if (removeImage !== undefined) {
formData.append("remove_image", String(removeImage));
}
return request(`/posts/edit?id=${encodeURIComponent(id)}`, { 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)}`, {
method: "POST",
token,
}),
postImage: async (token, filename) => {
const response = await fetch(`${API_BASE_URL}/posts/image/${encodeURIComponent(filename)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error("Unable to load image.");
}
return response.blob();
}, },
}; };
+12
View File
@@ -7,6 +7,7 @@ export function AuthForm({ title, fields, onSubmit, submitLabel, footer }) {
<label key={field.name}> <label key={field.name}>
<span>{field.label}</span> <span>{field.label}</span>
<input <input
className={field.error ? "input-invalid" : field.valid ? "input-valid" : ""}
type={field.type || "text"} type={field.type || "text"}
name={field.name} name={field.name}
value={field.value} value={field.value}
@@ -18,7 +19,18 @@ export function AuthForm({ title, fields, onSubmit, submitLabel, footer }) {
pattern={field.pattern} pattern={field.pattern}
title={field.title} title={field.title}
autoComplete={field.autoComplete} autoComplete={field.autoComplete}
aria-invalid={Boolean(field.error)}
aria-describedby={field.error || field.valid ? `${field.name}-status` : undefined}
/> />
{field.error ? (
<small id={`${field.name}-status`} className="field-error" role="alert">
{field.error}
</small>
) : field.valid ? (
<small id={`${field.name}-status`} className="field-valid">
Looks good.
</small>
) : null}
</label> </label>
))} ))}
<button type="submit">{submitLabel}</button> <button type="submit">{submitLabel}</button>
+208
View File
@@ -0,0 +1,208 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
function getImageFilename(post) {
return post.image || post.image_filename || post.filename || null;
}
function getAuthorName(post, authorId) {
return post.author?.name || post.author_name || (post.author_username ? `@${post.author_username}` : `User ${authorId || ""}`);
}
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 {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return String(timestamp);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
} catch {
return String(timestamp);
}
}
function AuthenticatedImage({ filename, alt, loadImage }) {
const [source, setSource] = useState("");
const [error, setError] = useState("");
useEffect(() => {
let active = true;
let objectUrl = "";
if (!filename) {
setSource("");
return () => {};
}
loadImage(filename)
.then((blob) => {
if (active) {
objectUrl = URL.createObjectURL(blob);
setSource(objectUrl);
}
})
.catch(() => {
if (active) {
setError("Image unavailable.");
}
});
return () => {
active = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [filename, loadImage]);
if (error) {
return <p className="muted">{error}</p>;
}
return source ? <img className="post-image" src={source} alt={alt} loading="lazy" decoding="async" /> : null;
}
export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete }) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({ title: post.title || "", text: post.text || "", image: null, removeImage: false });
const [error, setError] = useState("");
const authorId = post.author_id ?? post.authorId ?? post.author?.id;
const isOwner = authorId !== undefined && String(authorId) === String(currentUserId);
const imageFilename = getImageFilename(post);
const authorName = getAuthorName(post, authorId);
const authorUsername = getAuthorUsername(post);
const formattedDate = formatDate(post.created_at || post.createdAt);
async function handleEdit(event) {
event.preventDefault();
setError("");
if (form.image && form.removeImage) {
setError("An image cannot be uploaded and removed at the same time.");
return;
}
try {
await onEdit(post.id, form);
setEditing(false);
} catch (editError) {
setError(editError.message || "Unable to edit post.");
}
}
async function handleDelete() {
if (!window.confirm("Delete this post?")) {
return;
}
try {
await onDelete(post.id);
} catch (deleteError) {
setError(deleteError.message || "Unable to delete post.");
}
}
const profilePath = authorId ? `/profile/${authorId}` : "/profile";
return (
<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>
<div className="author-meta">
<span className="author-name">{authorName}</span>
{authorUsername && !authorName.startsWith("@") ? (
<span className="author-handle">@{authorUsername}</span>
) : null}
</div>
</Link>
<div className="post-top-right">
{formattedDate ? <span className="post-date">{formattedDate}</span> : null}
{isOwner && !editing ? (
<div className="post-owner-actions">
<button
type="button"
className="icon-btn edit-icon-btn"
title="Edit post"
aria-label="Edit post"
onClick={() => setEditing(true)}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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>
</button>
<button
type="button"
className="icon-btn delete-icon-btn"
title="Delete post"
aria-label="Delete post"
onClick={handleDelete}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
</div>
) : null}
</div>
</div>
{editing ? (
<form onSubmit={handleEdit} className="post-form">
<label>
<span>Title</span>
<input value={form.title} onChange={(event) => setForm((previous) => ({ ...previous, title: event.target.value }))} />
</label>
<label>
<span>Text</span>
<textarea value={form.text} onChange={(event) => setForm((previous) => ({ ...previous, text: event.target.value }))} rows="4" />
</label>
<label>
<span>Replace image (optional)</span>
<input type="file" accept="image/*" onChange={(event) => setForm((previous) => ({ ...previous, image: event.target.files?.[0] || null }))} />
</label>
<label className="checkbox-label">
<input type="checkbox" checked={form.removeImage} onChange={(event) => setForm((previous) => ({ ...previous, removeImage: event.target.checked }))} />
Remove current image
</label>
<div className="inline-actions">
<button type="submit">Save</button>
<button type="button" className="ghost-btn" onClick={() => setEditing(false)}>Cancel</button>
</div>
</form>
) : (
<>
<div className="post-copy">
{post.title ? <h3>{post.title}</h3> : null}
<p>{post.text ?? post.content}</p>
</div>
{imageFilename ? <AuthenticatedImage filename={imageFilename} alt={post.title || "Post image"} loadImage={loadImage} /> : null}
</>
)}
{error ? <p className="error-text">{error}</p> : null}
</article>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useState } from "react";
export function PostComposer({ onCreate }) {
const [form, setForm] = useState({ title: "", text: "", image: null });
const [error, setError] = useState("");
const [message, setMessage] = useState("");
async function handleSubmit(event) {
event.preventDefault();
setError("");
setMessage("");
if (!form.title.trim() || !form.text.trim()) {
setError("Title and text are required.");
return;
}
try {
await onCreate(form);
setForm({ title: "", text: "", image: null });
event.target.reset();
setMessage("Post created.");
} catch (submitError) {
setError(submitError.message || "Unable to create post.");
}
}
return (
<section className="panel composer-card">
<div className="composer-heading">
<div className="author-avatar"></div>
<div>
<h3>Share with your community</h3>
<p className="muted">Add a title, a thought, or an image.</p>
</div>
</div>
<form onSubmit={handleSubmit} className="post-form">
<label>
<span>Title</span>
<input
value={form.title}
onChange={(event) => setForm((previous) => ({ ...previous, title: event.target.value }))}
required
/>
</label>
<label>
<span>Text</span>
<textarea
value={form.text}
onChange={(event) => setForm((previous) => ({ ...previous, text: event.target.value }))}
required
rows="4"
/>
</label>
<label>
<span>Image (optional)</span>
<input
type="file"
accept="image/*"
onChange={(event) => setForm((previous) => ({ ...previous, image: event.target.files?.[0] || null }))}
/>
</label>
<button type="submit">Publish post</button>
</form>
{error ? <p className="error-text">{error}</p> : null}
{message ? <p className="success-text">{message}</p> : null}
</section>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from "react";
import { PostCard } from "./PostCard";
const POSTS_PER_BATCH = 6;
export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete }) {
const [visibleCount, setVisibleCount] = useState(POSTS_PER_BATCH);
const loadMoreRef = useRef(null);
useEffect(() => {
setVisibleCount(POSTS_PER_BATCH);
}, [posts]);
useEffect(() => {
const loadMoreElement = loadMoreRef.current;
if (!loadMoreElement || visibleCount >= posts.length) {
return undefined;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisibleCount((currentCount) => Math.min(currentCount + POSTS_PER_BATCH, posts.length));
}
},
{ rootMargin: "300px 0px" },
);
observer.observe(loadMoreElement);
return () => observer.disconnect();
}, [posts.length, visibleCount]);
if (!posts.length) {
return <p className="muted">No posts yet.</p>;
}
return (
<div className="post-list">
{posts.slice(0, visibleCount).map((post) => (
<PostCard
key={post.id}
post={post}
currentUserId={currentUserId}
loadImage={loadImage}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
{visibleCount < posts.length ? (
<div ref={loadMoreRef} className="post-loading" aria-live="polite">
Loading more posts
</div>
) : null}
</div>
);
}
+37 -8
View File
@@ -1,6 +1,23 @@
import { Link, NavLink } from "react-router-dom"; import { Link, NavLink } from "react-router-dom";
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 }) {
const displayName = user?.name || (user?.username ? `@${user.username}` : `User ${user?.id || ""}`);
const displaySub = user?.username && user?.name ? `@${user.username}` : `ID: ${user?.id}`;
return ( return (
<div className="shell"> <div className="shell">
<aside className="sidebar"> <aside className="sidebar">
@@ -11,23 +28,35 @@ export function Shell({ user, onLogout, children }) {
<nav> <nav>
<NavLink to="/feed" className="nav-link"> <NavLink to="/feed" className="nav-link">
Feed <span className="nav-icon"></span> Feed
</NavLink> </NavLink>
<NavLink to="/discover" className="nav-link"> <NavLink to="/create" className="nav-link create-link">
Discover <span className="nav-icon"></span> Create
</NavLink> </NavLink>
<NavLink to="/profile" className="nav-link"> <NavLink to="/profile" className="nav-link">
Profile <span className="nav-icon"></span> Profile
</NavLink> </NavLink>
</nav> </nav>
<div className="account-card"> <div className="account-card">
<p>{user?.name}</p> <Link to="/profile" className="account-info-row" title="Go to your profile">
<span>@{user?.username}</span> <div className="account-avatar">{getInitials(displayName)}</div>
<button type="button" onClick={onLogout}> <div className="account-details">
Uitloggen <span className="account-name">{displayName}</span>
<span className="account-sub">{displaySub}</span>
</div>
</Link>
<div className="account-actions">
<button type="button" className="logout-btn" onClick={onLogout} title="Log out of account">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
<span>Logout</span>
</button> </button>
</div> </div>
</div>
</aside> </aside>
<main className="content">{children}</main> <main className="content">{children}</main>
-53
View File
@@ -1,53 +0,0 @@
import { useState } from "react";
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
export function UploadBox({ onUpload }) {
const [file, setFile] = useState(null);
const [message, setMessage] = useState("");
async function handleSubmit(event) {
event.preventDefault();
if (!file) {
setMessage("Kies eerst een bestand.");
return;
}
if (file.size > MAX_FILE_SIZE_BYTES) {
setMessage("Bestand te groot. Maximum is 5 MB.");
return;
}
try {
const response = await onUpload(file);
setMessage(`Geupload: ${response.file.publicUrl}`);
} catch (error) {
setMessage(error.message || "Upload mislukt.");
}
}
return (
<section className="panel">
<h3>Upload Connector</h3>
<p className="muted">Open koppeling voor media uploads (S3/Cloudinary/etc.). Max 5 MB per bestand.</p>
<form onSubmit={handleSubmit} className="upload-form">
<input
type="file"
onChange={(event) => {
const nextFile = event.target.files?.[0] || null;
if (nextFile && nextFile.size > MAX_FILE_SIZE_BYTES) {
setFile(null);
setMessage("Bestand te groot. Maximum is 5 MB.");
return;
}
setMessage("");
setFile(nextFile);
}}
/>
<button type="submit">Upload bestand</button>
</form>
{message ? <p className="success-text">{message}</p> : null}
</section>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { useNavigate } from "react-router-dom";
import { PostComposer } from "../components/PostComposer";
export function CreatePage({ createPost }) {
const navigate = useNavigate();
async function handleCreate(data) {
await createPost(data);
navigate("/feed");
}
return (
<section className="create-page">
<header className="page-head">
<p className="eyebrow">Share something new</p>
<h2>Create post</h2>
</header>
<PostComposer onCreate={handleCreate} />
</section>
);
}
-58
View File
@@ -1,58 +0,0 @@
import { useEffect, useState } from "react";
export function DiscoverPage({ loadUsers, sendFriendRequest }) {
const [users, setUsers] = useState([]);
const [error, setError] = useState("");
useEffect(() => {
let mounted = true;
loadUsers()
.then((data) => {
if (mounted) {
setUsers(data.users || []);
}
})
.catch((err) => {
if (mounted) {
setError(err.message);
}
});
return () => {
mounted = false;
};
}, [loadUsers]);
async function handleAdd(userId) {
const result = await sendFriendRequest(userId);
if (result.status === "pending") {
setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, relationship: "pending" } : u)));
}
}
return (
<section>
<header className="page-head">
<h2>Discover People</h2>
</header>
{error ? <p className="error-text">{error}</p> : null}
<div className="grid">
{users.map((user) => (
<article className="panel user-card" key={user.id}>
<img src={user.avatarUrl} alt={user.name} />
<h3>{user.name}</h3>
<p>@{user.username}</p>
<button
type="button"
disabled={user.relationship !== "none"}
onClick={() => handleAdd(user.id)}
>
{user.relationship === "none" ? "Add friend" : user.relationship}
</button>
</article>
))}
</div>
</section>
);
}
+59 -40
View File
@@ -1,53 +1,72 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { PostList } from "../components/PostList";
export function FeedPage({ loadFeed }) { function getPosts(data) {
const [items, setItems] = useState([]); return Array.isArray(data) ? data : data.posts || data.items || [];
}
export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUserId, currentUsername }) {
const [posts, setPosts] = useState([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const refresh = useCallback(() => {
setError("");
setLoading(true);
return loadPosts()
.then((data) => {
const rawPosts = getPosts(data);
// Filter out logged-in user's own posts on feed
const otherUserPosts = rawPosts.filter((post) => {
const authorId = post.author_id ?? post.authorId ?? post.author?.id;
if (authorId !== undefined && authorId !== null) {
return String(authorId) !== String(currentUserId);
}
if (post.author_username && currentUsername) {
return post.author_username !== currentUsername;
}
return true;
});
setPosts(otherUserPosts);
})
.catch((loadError) => setError(loadError.message))
.finally(() => setLoading(false));
}, [loadPosts, currentUserId, currentUsername]);
useEffect(() => { useEffect(() => {
let mounted = true; refresh();
loadFeed() }, [refresh]);
.then((data) => {
if (mounted) {
setItems(data.items || []);
}
})
.catch((err) => {
if (mounted) {
setError(err.message);
}
});
return () => { async function handleEdit(id, data) {
mounted = false; await editPost(id, data);
}; await refresh();
}, [loadFeed]); }
async function handleDelete(id) {
await deletePost(id);
await refresh();
}
return ( return (
<section> <section className="feed-page">
<header className="page-head"> <header className="feed-header">
<h2>Community Feed</h2>
</header>
{error ? <p className="error-text">{error}</p> : null}
<div className="post-list">
{items.map((item) => (
<article key={item.id} className="panel post-card">
<div className="post-top">
<img src={item.author?.avatarUrl} alt={item.author?.name} />
<div> <div>
<p>{item.author?.name}</p> <p className="eyebrow">Your daily archive</p>
<span>@{item.author?.username}</span> <h2>Community Feed</h2>
</div> </div>
<span className="feed-status">Explore posts from others</span>
</header>
{error ? <p className="error-text">{error}</p> : null}
{loading ? (
<p className="post-loading">Loading community feed...</p>
) : posts.length === 0 ? (
<div className="panel empty-feed-panel">
<h3>No posts from other users yet</h3>
<p className="muted">Posts created by you will appear on your profile. Check back later for community updates!</p>
</div> </div>
<p>{item.content}</p> ) : (
<button type="button" className="ghost-btn"> <PostList posts={posts} currentUserId={currentUserId} loadImage={loadImage} onEdit={handleEdit} onDelete={handleDelete} />
Like ({item.likes}) )}
</button>
</article>
))}
</div>
</section> </section>
); );
} }
+15 -3
View File
@@ -1,12 +1,20 @@
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useState } from "react"; import { useState } from "react";
import { AuthForm } from "../components/AuthForm"; import { AuthForm } from "../components/AuthForm";
import { normalizeLoginData, validateLogin } from "../validation/auth"; import { getLoginFieldErrors, normalizeLoginData, validateLogin } from "../validation/auth";
export function LoginPage({ onLogin }) { export function LoginPage({ onLogin }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [form, setForm] = useState({ email: "", password: "" }); const [form, setForm] = useState({ email: "", password: "" });
const [touched, setTouched] = useState({});
const [error, setError] = useState(""); const [error, setError] = useState("");
const fieldErrors = getLoginFieldErrors(form);
function updateField(name, value) {
setForm((previous) => ({ ...previous, [name]: value }));
setTouched((previous) => ({ ...previous, [name]: true }));
setError("");
}
async function handleSubmit(event) { async function handleSubmit(event) {
event.preventDefault(); event.preventDefault();
@@ -38,7 +46,9 @@ export function LoginPage({ onLogin }) {
label: "E-mail", label: "E-mail",
type: "text", type: "text",
value: form.email, value: form.email,
onChange: (event) => setForm((prev) => ({ ...prev, email: event.target.value })), onChange: (event) => updateField("email", event.target.value),
error: touched.email ? fieldErrors.email : null,
valid: touched.email && !fieldErrors.email,
required: true, required: true,
minLength: 1, minLength: 1,
maxLength: 254, maxLength: 254,
@@ -49,7 +59,9 @@ export function LoginPage({ onLogin }) {
label: "Wachtwoord", label: "Wachtwoord",
type: "password", type: "password",
value: form.password, value: form.password,
onChange: (event) => setForm((prev) => ({ ...prev, password: event.target.value })), onChange: (event) => updateField("password", event.target.value),
error: touched.password ? fieldErrors.password : null,
valid: touched.password && !fieldErrors.password,
required: true, required: true,
minLength: 1, minLength: 1,
maxLength: 128, maxLength: 128,
+190 -10
View File
@@ -1,22 +1,202 @@
import { UploadBox } from "../components/UploadBox"; import { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { PostList } from "../components/PostList";
export function ProfilePage({ user, onUpload }) { function getPosts(data) {
return Array.isArray(data) ? data : data.posts || data.items || [];
}
function getInitials(name) {
if (!name) return "?";
const cleaned = String(name).replace(/^@/, "");
return ( return (
<section> cleaned
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase() || "?"
);
}
export function ProfilePage({
user,
myProfile,
getProfile,
loadMyPosts,
loadAllPosts,
createRelationship,
editPost,
deletePost,
loadImage,
currentUserId,
onLogout,
}) {
const { userId } = useParams();
const isOwnProfile = !userId || String(userId) === String(currentUserId);
const [profileData, setProfileData] = useState(isOwnProfile ? user : null);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [following, setFollowing] = useState(false);
const [followMessage, setFollowMessage] = useState("");
const [chatNotice, setChatNotice] = useState("");
const loadData = useCallback(async () => {
setLoading(true);
setError("");
setFollowMessage("");
setChatNotice("");
try {
if (isOwnProfile) {
if (myProfile) {
try {
const me = await myProfile();
setProfileData(me);
} catch {
// Keep default user prop
}
}
const myPostsData = await loadMyPosts();
setPosts(getPosts(myPostsData));
} else {
if (getProfile) {
try {
const targetProfile = await getProfile(userId);
setProfileData(targetProfile);
} catch {
setProfileData({ id: userId, name: `User ${userId}`, username: null });
}
} else {
setProfileData({ id: userId, name: `User ${userId}`, username: null });
}
const allPostsData = await loadAllPosts();
const allPosts = getPosts(allPostsData);
const userPosts = allPosts.filter((post) => {
const authorId = post.author_id ?? post.authorId ?? post.author?.id;
return String(authorId) === String(userId);
});
setPosts(userPosts);
}
} catch (err) {
setError(err.message || "Unable to load profile.");
} finally {
setLoading(false);
}
}, [isOwnProfile, userId, myProfile, getProfile, loadMyPosts, loadAllPosts]);
useEffect(() => {
loadData();
}, [loadData]);
async function handleFollow() {
if (!userId) return;
try {
if (createRelationship) {
await createRelationship(userId);
}
setFollowing(true);
const nameOrHandle = profileData?.name || (profileData?.username ? `@${profileData.username}` : `User ${userId}`);
setFollowMessage(`You are now following ${nameOrHandle}!`);
} catch {
// Unbinded preview fallback
setFollowing(true);
setFollowMessage(`Follow relationship recorded for User ${userId}`);
}
}
function handleChat() {
setChatNotice("Chat feature is coming soon! (Action unbinded)");
}
async function handleEdit(id, data) {
await editPost(id, data);
await loadData();
}
async function handleDelete(id) {
await deletePost(id);
await loadData();
}
const displayName = profileData?.name || (isOwnProfile ? (user?.name || "Your Profile") : `User ${userId}`);
const displayUsername = profileData?.username || (isOwnProfile ? user?.username : null);
const displayId = profileData?.id || userId || currentUserId;
return (
<section className="profile-page">
<header className="page-head"> <header className="page-head">
<h2>Profile Cabinet</h2> <p className="eyebrow">{isOwnProfile ? "Personal Cabinet" : "User Profile"}</p>
<h2>{isOwnProfile ? "Account Profile" : displayName}</h2>
</header> </header>
<article className="panel profile-card"> <article className="panel profile-card">
<img src={user.avatarUrl} alt={user.name} className="avatar-large" /> <div className="profile-header-content">
<div> <div className="profile-avatar-large">{getInitials(displayName)}</div>
<h3>{user.name}</h3> <div className="profile-details">
<p>@{user.username}</p> <h3 className="profile-name">{displayName}</h3>
<p className="muted">{user.bio}</p> {displayUsername ? <p className="profile-username">@{displayUsername}</p> : null}
<span className="profile-id-badge">ID: {displayId}</span>
</div> </div>
<div className="profile-actions-bar">
{isOwnProfile ? (
<>
<button
type="button"
className="btn-profile-secondary"
onClick={() => setChatNotice("Profile editing feature is ready.")}
>
Edit Profile
</button>
{onLogout ? (
<button type="button" className="btn-profile-danger" onClick={onLogout}>
Logout
</button>
) : null}
</>
) : (
<>
<button
type="button"
className={following ? "btn-profile-following" : "btn-profile-primary"}
onClick={handleFollow}
>
{following ? "✓ Following" : " Follow"}
</button>
<button
type="button"
className="btn-profile-secondary"
onClick={handleChat}
title="Unbinded button for future chat implementation"
>
💬 Chat
</button>
</>
)}
</div>
</div>
{followMessage ? <p className="success-text profile-notice">{followMessage}</p> : null}
{chatNotice ? <p className="info-text profile-notice">{chatNotice}</p> : null}
</article> </article>
<UploadBox onUpload={onUpload} /> {error ? <p className="error-text">{error}</p> : null}
<div className="profile-posts-section">
<h3 className="section-title">{isOwnProfile ? "Your posts" : `Posts by ${displayName}`}</h3>
{loading ? (
<p className="post-loading">Loading posts...</p>
) : 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} />
)}
</div>
</section> </section>
); );
} }
+22 -5
View File
@@ -2,6 +2,7 @@ import { Link, useNavigate } from "react-router-dom";
import { useState } from "react"; import { useState } from "react";
import { AuthForm } from "../components/AuthForm"; import { AuthForm } from "../components/AuthForm";
import { import {
getRegistrationFieldErrors,
isControlCharacterPresent, isControlCharacterPresent,
normalizeRegistrationData, normalizeRegistrationData,
validateRegistration, validateRegistration,
@@ -10,7 +11,15 @@ import {
export function RegisterPage({ onRegister }) { export function RegisterPage({ onRegister }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [form, setForm] = useState({ name: "", username: "", email: "", password: "" }); const [form, setForm] = useState({ name: "", username: "", email: "", password: "" });
const [touched, setTouched] = useState({});
const [error, setError] = useState(""); const [error, setError] = useState("");
const fieldErrors = getRegistrationFieldErrors(form);
function updateField(name, value) {
setForm((previous) => ({ ...previous, [name]: value }));
setTouched((previous) => ({ ...previous, [name]: true }));
setError("");
}
async function handleSubmit(event) { async function handleSubmit(event) {
event.preventDefault(); event.preventDefault();
@@ -29,7 +38,7 @@ export function RegisterPage({ onRegister }) {
try { try {
await onRegister(normalizeRegistrationData(form)); await onRegister(normalizeRegistrationData(form));
navigate("/feed"); navigate("/login");
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} }
@@ -46,7 +55,9 @@ export function RegisterPage({ onRegister }) {
name: "name", name: "name",
label: "Naam", label: "Naam",
value: form.name, value: form.name,
onChange: (event) => setForm((prev) => ({ ...prev, name: event.target.value })), onChange: (event) => updateField("name", event.target.value),
error: touched.name ? fieldErrors.name : null,
valid: touched.name && !fieldErrors.name,
required: true, required: true,
minLength: 1, minLength: 1,
maxLength: 100, maxLength: 100,
@@ -56,7 +67,9 @@ export function RegisterPage({ onRegister }) {
name: "username", name: "username",
label: "Gebruikersnaam", label: "Gebruikersnaam",
value: form.username, value: form.username,
onChange: (event) => setForm((prev) => ({ ...prev, username: event.target.value })), onChange: (event) => updateField("username", event.target.value),
error: touched.username ? fieldErrors.username : null,
valid: touched.username && !fieldErrors.username,
required: true, required: true,
minLength: 1, minLength: 1,
maxLength: 50, maxLength: 50,
@@ -68,7 +81,9 @@ export function RegisterPage({ onRegister }) {
label: "E-mail", label: "E-mail",
type: "text", type: "text",
value: form.email, value: form.email,
onChange: (event) => setForm((prev) => ({ ...prev, email: event.target.value })), onChange: (event) => updateField("email", event.target.value),
error: touched.email ? fieldErrors.email : null,
valid: touched.email && !fieldErrors.email,
required: true, required: true,
maxLength: 254, maxLength: 254,
autoComplete: "email", autoComplete: "email",
@@ -78,7 +93,9 @@ export function RegisterPage({ onRegister }) {
label: "Wachtwoord", label: "Wachtwoord",
type: "password", type: "password",
value: form.password, value: form.password,
onChange: (event) => setForm((prev) => ({ ...prev, password: event.target.value })), onChange: (event) => updateField("password", event.target.value),
error: touched.password ? fieldErrors.password : null,
valid: touched.password && !fieldErrors.password,
required: true, required: true,
minLength: 1, minLength: 1,
maxLength: 128, maxLength: 128,
+62 -23
View File
@@ -61,20 +61,36 @@ function isDisposableEmail(email) {
); );
} }
export function validateRegistration({ name, username, email, password }) { function hasControlCharacter(value) {
const trimmedName = name.trim(); return [...value].some((character) => {
const trimmedUsername = username.trim(); const code = character.charCodeAt(0);
const normalizedEmail = email.trim().toLowerCase(); return code <= 0x1f || code === 0x7f;
});
}
function validateName(name) {
const trimmedName = name.trim();
if (hasControlCharacter(name)) {
return "Input contains unsupported control characters.";
}
if (trimmedName.length < 1 || trimmedName.length > 100) { if (trimmedName.length < 1 || trimmedName.length > 100) {
return "Name must be between 1 and 100 characters."; return "Name must be between 1 and 100 characters.";
} }
return null;
}
if (trimmedUsername.length < 1 || trimmedUsername.length > 50 || /\s/.test(trimmedUsername)) { function validateUsername(username) {
const trimmedUsername = username.trim();
if (hasControlCharacter(username) || trimmedUsername.length < 1 || trimmedUsername.length > 50 || /\s/.test(trimmedUsername)) {
return "Username must be between 1 and 50 characters and cannot contain whitespace."; return "Username must be between 1 and 50 characters and cannot contain whitespace.";
} }
return null;
}
function validateEmail(email, { checkDisposable = false } = {}) {
const normalizedEmail = email.trim().toLowerCase();
if ( if (
hasControlCharacter(email) ||
normalizedEmail.length < 1 || normalizedEmail.length < 1 ||
normalizedEmail.length > 254 || normalizedEmail.length > 254 ||
/\s/.test(normalizedEmail) || /\s/.test(normalizedEmail) ||
@@ -82,8 +98,14 @@ export function validateRegistration({ name, username, email, password }) {
) { ) {
return "Enter a valid email address without whitespace."; return "Enter a valid email address without whitespace.";
} }
if (checkDisposable && isDisposableEmail(normalizedEmail)) {
return "Disposable or temporary email addresses are not allowed.";
}
return null;
}
if (password.length < 1 || password.length > 128) { function validatePassword(password, username, email) {
if (hasControlCharacter(password) || password.length < 1 || password.length > 128) {
return "Password must be between 1 and 128 characters."; return "Password must be between 1 and 128 characters.";
} }
@@ -96,9 +118,14 @@ export function validateRegistration({ name, username, email, password }) {
} }
const passwordForComparison = password.toLowerCase(); const passwordForComparison = password.toLowerCase();
const { localPart } = getEmailParts(normalizedEmail); const trimmedUsername = username.trim();
const normalizedEmail = email.trim().toLowerCase();
const localPart = EMAIL_PATTERN.test(normalizedEmail) ? getEmailParts(normalizedEmail).localPart : "";
if (passwordForComparison.includes(trimmedUsername.toLowerCase()) || passwordForComparison.includes(localPart.toLowerCase())) { if (
(trimmedUsername && passwordForComparison.includes(trimmedUsername.toLowerCase())) ||
(localPart && passwordForComparison.includes(localPart.toLowerCase()))
) {
return "Password cannot contain your username or email address."; return "Password cannot contain your username or email address.";
} }
@@ -110,27 +137,42 @@ export function validateRegistration({ name, username, email, password }) {
return "Password cannot contain repeated or sequential character patterns."; return "Password cannot contain repeated or sequential character patterns.";
} }
if (isDisposableEmail(normalizedEmail)) {
return "Disposable or temporary email addresses are not allowed.";
}
return null; return null;
} }
export function validateLogin({ email, password }) { export function getRegistrationFieldErrors(form) {
const normalizedEmail = email.trim().toLowerCase(); return {
name: validateName(form.name),
if (normalizedEmail.length < 1 || normalizedEmail.length > 254 || /\s/.test(normalizedEmail) || !EMAIL_PATTERN.test(normalizedEmail)) { username: validateUsername(form.username),
return "Enter a valid email address without whitespace."; email: validateEmail(form.email, { checkDisposable: true }),
} password: validatePassword(form.password, form.username, form.email),
};
}
function validateLoginPassword(password) {
if (password.length < 1 || password.length > 128) { if (password.length < 1 || password.length > 128) {
return "Password must be between 1 and 128 characters."; return "Password must be between 1 and 128 characters.";
} }
return null; return null;
} }
export function getLoginFieldErrors(form) {
return {
email: validateEmail(form.email),
password: validateLoginPassword(form.password),
};
}
export function validateRegistration(form) {
const errors = getRegistrationFieldErrors(form);
return errors.name || errors.username || errors.email || errors.password || null;
}
export function validateLogin({ email, password }) {
const errors = getLoginFieldErrors({ email, password });
return errors.email || errors.password || null;
}
export function normalizeRegistrationData({ name, username, email, password }) { export function normalizeRegistrationData({ name, username, email, password }) {
return { return {
name: name.trim(), name: name.trim(),
@@ -148,8 +190,5 @@ export function normalizeLoginData({ email, password }) {
} }
export function isControlCharacterPresent(value) { export function isControlCharacterPresent(value) {
return [...value].some((character) => { return hasControlCharacter(value);
const code = character.charCodeAt(0);
return code <= 0x1f || code === 0x7f;
});
} }