diff --git a/README.md b/README.md index 7659d46..b6d210a 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,16 @@ React social platform shell met duidelijke scheiding tussen frontend en backend. - Login (`/login`) - Register (`/register`) - Feed (`/feed`) -- Discover (`/discover`) +- Create (`/create`) - Profile (`/profile`) Werkende acties: - Inloggen en registreren -- Feed laden -- Vriendverzoek versturen -- Upload triggeren +- Alle posts laden +- Eigen posts laden +- Posts aanmaken, bewerken en verwijderen +- Beveiligde postafbeeldingen laden - Uitloggen ## Externe API-koppeling @@ -34,11 +35,12 @@ De volgende endpoints worden vanuit de frontend aangeroepen: - `POST /auth/login` - `POST /auth/register` -- `GET /auth/me` -- `GET /feed` -- `GET /discover` -- `POST /friends/request` -- `POST /uploads` +- `GET /posts` +- `GET /posts/me` +- `GET /posts/image/:filename` +- `POST /posts/create` +- `PUT /posts/edit?id=` +- `DELETE /posts/delete?id=` Zie `frontend/.env.example`. diff --git a/src/App.css b/src/App.css index 9a83edc..12dc5aa 100644 --- a/src/App.css +++ b/src/App.css @@ -112,6 +112,30 @@ input { 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 { border: none; border-radius: 0.7rem; @@ -130,16 +154,19 @@ button:disabled { .shell { min-height: 100vh; display: grid; - grid-template-columns: 280px 1fr; + grid-template-columns: 248px minmax(0, 1fr); } .sidebar { border-right: 1px solid var(--line); background: rgba(255, 252, 246, 0.8); padding: 1.25rem; - display: grid; - align-content: start; + display: flex; + flex-direction: column; gap: 1rem; + position: sticky; + top: 0; + height: 100vh; } .brand { @@ -156,33 +183,163 @@ button:disabled { .nav-link { display: block; - margin-bottom: 0.4rem; + margin-bottom: 0.25rem; color: var(--ink); text-decoration: none; - padding: 0.48rem 0.6rem; - border-radius: 0.55rem; + padding: 0.75rem 0.8rem; + border-radius: 0.75rem; + font-weight: 600; + transition: background 160ms ease, transform 160ms ease; } .nav-link.active { background: rgba(196, 90, 61, 0.12); } -.account-card { - margin-top: 1rem; - padding: 0.85rem; - border-radius: 0.8rem; - background: #fff; - border: 1px solid var(--line); +.nav-link:hover { + background: rgba(196, 90, 61, 0.08); + transform: translateX(2px); } -.account-card span { - display: block; +.nav-icon { + 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); - 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 { - 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 { @@ -193,11 +350,18 @@ button:disabled { .grid, .post-list { - margin-top: 1rem; + margin-top: 1.15rem; display: grid; gap: 0.9rem; } +.post-loading { + padding: 1rem; + color: var(--muted); + text-align: center; + font-size: 0.85rem; +} + .grid { grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); } @@ -207,51 +371,370 @@ button:disabled { border-radius: 0.9rem; border: 1px solid var(--line); padding: 1rem; + box-shadow: 0 8px 24px rgba(31, 42, 43, 0.05); } -.post-top, -.profile-card { +/* Post Card & Headers */ +.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; align-items: center; gap: 0.7rem; + margin-bottom: 1rem; } -.post-top img, -.user-card img { - width: 50px; - height: 50px; - object-fit: cover; - border-radius: 999px; +.composer-heading h3 { + margin: 0; } -.avatar-large { - width: 88px; - height: 88px; - border-radius: 999px; - object-fit: cover; +.composer-heading p { + margin: 0.2rem 0 0; + font-size: 0.82rem; +} + +.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 { background: rgba(196, 90, 61, 0.12); color: var(--accent-dark); - margin-top: 0.4rem; } -.user-card h3, -.post-top p, -.account-card p { - margin: 0; +/* Profile Page Layout & Account Panels */ +.profile-card { + padding: 1.4rem; + margin-top: 1rem; } -.user-card p, -.post-top span { - color: var(--muted); -} - -.upload-form { +.profile-header-content { display: flex; + align-items: center; + gap: 1.2rem; 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; + 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 { @@ -281,5 +764,24 @@ button:disabled { .sidebar { border-right: none; 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; } } diff --git a/src/App.jsx b/src/App.jsx index 5bfefdb..16b5e2d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; import { apiClient } from "./api/client"; import { Shell } from "./components/Shell"; -import { DiscoverPage } from "./pages/DiscoverPage"; +import { CreatePage } from "./pages/CreatePage"; import { FeedPage } from "./pages/FeedPage"; import { LandingPage } from "./pages/LandingPage"; import { LoginPage } from "./pages/LoginPage"; @@ -11,102 +11,95 @@ import { RegisterPage } from "./pages/RegisterPage"; import "./App.css"; 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) { - if (!rawValue) { - return null; - } - - try { - return JSON.parse(rawValue); - } catch { - return null; +function hasExpired(expiresAt) { + if (!expiresAt) { + return false; } + 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() { const [session, setSession] = useState(() => { - const storedToken = sessionStorage.getItem(TOKEN_KEY); - const storedUser = sessionStorage.getItem(USER_KEY); + const token = sessionStorage.getItem(TOKEN_KEY); + const userId = sessionStorage.getItem(USER_ID_KEY); + const expiresAt = sessionStorage.getItem(EXPIRES_AT_KEY); + const valid = token && userId && !hasExpired(expiresAt); return { - token: storedToken, - user: safeParseSessionUser(storedUser), + token: valid ? token : null, + 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 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(USER_KEY, JSON.stringify(user)); - setSession({ token, user }); + sessionStorage.setItem(USER_ID_KEY, id); + sessionStorage.removeItem(EXPIRES_AT_KEY); + if (expiresAt) { + sessionStorage.setItem(EXPIRES_AT_KEY, expiresAt); + } + setSession({ token, user: { id }, expiresAt }); }, []); const logout = useCallback(() => { sessionStorage.removeItem(TOKEN_KEY); - sessionStorage.removeItem(USER_KEY); - setSession({ token: null, user: null }); + sessionStorage.removeItem(USER_ID_KEY); + sessionStorage.removeItem(EXPIRES_AT_KEY); + setSession({ token: null, user: null, expiresAt: null }); + setUserProfile(null); }, []); useEffect(() => { - let mounted = true; - - 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(); - } - } finally { - if (mounted) { - setIsBootstrapping(false); - } - } + if (session.token && hasExpired(session.expiresAt)) { + logout(); } - - bootstrapSession(); - - return () => { - mounted = false; - }; - }, [logout, session.token]); + }, [logout, session.expiresAt, session.token]); const authActions = { onLogin: async (credentials) => { const result = await apiClient.login(credentials); - persistSession(result.token, result.user); - return result; - }, - onRegister: async (data) => { - const result = await apiClient.register(data); - persistSession(result.token, result.user); + persistSession(result.id, result.token, result.expires_at); return result; }, + onRegister: (data) => apiClient.register(data), }; - if (isBootstrapping) { - return ( -
-
-

Sessie controleren...

-

Wacht even terwijl je account extern wordt gevalideerd.

-
-
- ); - } - if (!isAuthenticated) { return ( @@ -118,22 +111,69 @@ function App() { ); } + const currentUser = { + id: session.user?.id, + name: userProfile?.name, + username: userProfile?.username, + }; + return ( - + - apiClient.feed(session.token)} />} /> apiClient.discover(session.token)} - sendFriendRequest={(toUserId) => apiClient.sendFriendRequest(session.token, toUserId)} + apiClient.posts(session.token)} + 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} /> } /> + apiClient.createPost(session.token, data)} /> + } + /> apiClient.upload(session.token, file)} />} + element={ + 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} + /> + } + /> + 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} + /> + } /> } /> diff --git a/src/api/client.js b/src/api/client.js index d7fa154..7517b4f 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -48,14 +48,48 @@ async function request(path, { method = "GET", token, body, formData } = {}) { export const apiClient = { login: (credentials) => request("/auth/login", { method: "POST", body: credentials }), register: (data) => request("/auth/register", { method: "POST", body: data }), - me: (token) => request("/auth/me", { token }), - feed: (token) => request("/feed", { token }), - discover: (token) => request("/discover", { token }), - sendFriendRequest: (token, toUserId) => - request("/friends/request", { method: "POST", token, body: { toUserId } }), - upload: (token, file) => { + myProfile: (token) => request("/profiles/me", { token }), + getProfile: (token, id) => request(`/profiles/${encodeURIComponent(id)}`, { token }), + posts: (token) => request("/posts", { token }), + myPosts: (token) => request("/posts/me", { token }), + createPost: (token, { title, text, image }) => { const formData = new FormData(); - formData.append("file", file); - return request("/uploads", { method: "POST", token, formData }); + formData.append("title", title); + 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(); }, }; diff --git a/src/components/AuthForm.jsx b/src/components/AuthForm.jsx index 70244a5..f56720f 100644 --- a/src/components/AuthForm.jsx +++ b/src/components/AuthForm.jsx @@ -7,6 +7,7 @@ export function AuthForm({ title, fields, onSubmit, submitLabel, footer }) { ))} diff --git a/src/components/PostCard.jsx b/src/components/PostCard.jsx new file mode 100644 index 0000000..c341da7 --- /dev/null +++ b/src/components/PostCard.jsx @@ -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

{error}

; + } + return source ? {alt} : 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 ( +
+
+ +
{getInitials(authorName)}
+
+ {authorName} + {authorUsername && !authorName.startsWith("@") ? ( + @{authorUsername} + ) : null} +
+ +
+ {formattedDate ? {formattedDate} : null} + {isOwner && !editing ? ( +
+ + +
+ ) : null} +
+
+ + {editing ? ( +
+ +