diff --git a/.gitignore b/.gitignore index 1cac559..a80cb1a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ dist-ssr *.njsproj *.sln *.sw? -.env \ No newline at end of file + +# Other +.env +.opencode/* \ No newline at end of file diff --git a/src/App.css b/src/App.css index 8f42b67..4dd6b49 100644 --- a/src/App.css +++ b/src/App.css @@ -21,7 +21,7 @@ border: 1px solid var(--line); border-radius: 1.25rem; padding: clamp(1.5rem, 4vw, 4rem); - box-shadow: 0 22px 50px rgba(31, 42, 43, 0.12); + box-shadow: var(--shadow-lg); animation: rise 600ms ease-out; } @@ -57,6 +57,16 @@ border-radius: 999px; font-weight: 700; border: 1px solid var(--ink); + transition: transform 200ms ease, box-shadow 200ms ease; +} + +.cta:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(31, 42, 43, 0.15); +} + +.cta:active { + transform: translateY(0); } .cta.primary { @@ -69,6 +79,77 @@ background: rgba(255, 255, 255, 0.6); } +.landing-features { + width: min(920px, 100%); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-top: 1.5rem; + animation: rise 600ms ease-out 200ms both; +} + +.feature-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 1rem; + padding: 1.5rem; + text-align: center; + box-shadow: var(--shadow-sm); + transition: transform 200ms ease, box-shadow 200ms ease; +} + +.feature-card:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-md); +} + +.feature-icon { + width: 3rem; + height: 3rem; + margin: 0 auto 0.75rem; + border-radius: 0.75rem; + background: var(--accent-light); + color: var(--accent-dark); + display: grid; + place-items: center; +} + +.feature-card h3 { + margin: 0 0 0.35rem; + font-family: var(--display); + font-size: 1.05rem; +} + +.feature-card p { + margin: 0; + font-size: 0.85rem; + color: var(--muted); + line-height: 1.55; +} + +.landing-social { + width: min(920px, 100%); + margin-top: 2rem; + padding: 1.5rem; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 1rem; + text-align: center; + animation: rise 600ms ease-out 400ms both; + box-shadow: var(--shadow-sm); +} + +.landing-social p { + margin: 0; + font-size: 0.9rem; + color: var(--muted); +} + +.landing-social strong { + color: var(--ink); + font-weight: 700; +} + .auth-page { min-height: 100vh; display: grid; @@ -383,6 +464,7 @@ button:disabled { .post-card > .post-top, .post-card > .post-copy, +.post-card > .post-copy-link, .post-card > .inline-actions, .post-card > .post-form, .post-card > .error-text, @@ -762,6 +844,179 @@ textarea { } } +@keyframes shimmer { + 0% { background-position: -200px 0; } + 100% { background-position: calc(200px + 100%) 0; } +} + +@keyframes toast-in { + from { transform: translateY(-12px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +@keyframes toast-out { + from { transform: translateY(0); opacity: 1; } + to { transform: translateY(-12px); opacity: 0; } +} + +.skeleton { + background: linear-gradient(90deg, var(--line) 0%, #e8e2d4 40%, var(--line) 80%); + background-size: 200px 100%; + animation: shimmer 1.4s ease-in-out infinite; + border-radius: 0.5rem; +} + +.skeleton-card { + padding: 1rem; +} + +.skeleton-author { + display: flex; + align-items: center; + gap: 0.65rem; + margin-bottom: 0.85rem; +} + +.skeleton-avatar { + width: 2.4rem; + height: 2.4rem; + border-radius: 50%; + flex-shrink: 0; +} + +.skeleton-lines { + flex: 1; + display: grid; + gap: 0.4rem; +} + +.skeleton-line { + height: 0.75rem; + width: 100%; +} + +.skeleton-line.short { + width: 60%; +} + +.skeleton-line.title { + height: 1rem; + width: 45%; + margin-bottom: 0.5rem; +} + +.toast-container { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1000; + display: grid; + gap: 0.5rem; + pointer-events: none; +} + +.toast { + pointer-events: auto; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.75rem 1rem; + border-radius: 0.75rem; + background: var(--ink); + color: #fff; + font-size: 0.88rem; + font-weight: 600; + box-shadow: var(--shadow-md); + animation: toast-in 300ms ease-out; + min-width: 200px; + max-width: 360px; +} + +.toast.leaving { + animation: toast-out 250ms ease-in forwards; +} + +.toast-success { background: var(--ok); } +.toast-error { background: var(--error); } + +.toast-icon { + flex-shrink: 0; + display: grid; + place-items: center; +} + +.post-card { + transition: transform 200ms ease, box-shadow 200ms ease; +} + +.post-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.post-card:active { + transform: translateY(0); +} + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 500; + background: rgba(31, 42, 43, 0.5); + display: grid; + place-items: center; + padding: 1.5rem; + animation: rise 200ms ease-out; +} + +.modal-dialog { + width: min(480px, 100%); + max-height: 90vh; + overflow-y: auto; + padding: 1.5rem; + box-shadow: var(--shadow-lg); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.modal-header h3 { + margin: 0; + font-family: var(--display); + font-size: 1.3rem; +} + +.modal-close { + width: 2rem; + height: 2rem; + padding: 0.3rem; + border-radius: 50%; + display: grid; + place-items: center; + background: transparent; + color: var(--muted); + border: none; + cursor: pointer; + transition: background 150ms ease, color 150ms ease; +} + +.modal-close:hover { + background: rgba(0, 0, 0, 0.06); + color: var(--ink); +} + +.profile-avatar-image { + width: 4rem; + height: 4rem; + border-radius: 50%; + object-fit: cover; + box-shadow: 0 4px 14px rgba(196, 90, 61, 0.25); +} + @media (max-width: 900px) { .shell { grid-template-columns: 1fr; @@ -817,3 +1072,243 @@ textarea { } } +/* Lightbox */ +.lightbox-overlay { + position: fixed; + inset: 0; + z-index: 999; + background: rgba(0, 0, 0, 0.85); + display: grid; + place-items: center; + padding: 1.5rem; + cursor: zoom-out; + animation: rise 200ms ease-out; +} + +.lightbox-image { + max-width: 100%; + max-height: 90vh; + object-fit: contain; + border-radius: 0.5rem; + cursor: default; + box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); +} + +.lightbox-close { + position: absolute; + top: 1rem; + right: 1rem; + width: 2.4rem; + height: 2.4rem; + padding: 0.3rem; + border-radius: 50%; + display: grid; + place-items: center; + background: rgba(255, 255, 255, 0.15); + color: #fff; + border: none; + cursor: pointer; + transition: background 150ms ease; +} + +.lightbox-close:hover { + background: rgba(255, 255, 255, 0.3); +} + +.post-image-clickable { + cursor: zoom-in; +} + +/* Post-copy link reset */ +.post-copy-link { + text-decoration: none; + color: inherit; + display: block; + margin-top: 0.85rem; +} + +.post-copy-link:hover h3 { + color: var(--accent-dark); +} + +.post-copy-link > .post-copy { + margin-top: 0; +} + +/* Post Detail Page */ +.post-page { + max-width: 680px; + margin: 0 auto; + padding: 0 1rem 2rem; +} + +.post-page-back { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.88rem; + font-weight: 600; + color: var(--muted); + text-decoration: none; + margin-bottom: 1rem; + transition: color 150ms ease; +} + +.post-page-back:hover { + color: var(--ink); +} + +.post-detail-card { + padding: 1.5rem; +} + +.post-detail-card .post-image { + max-height: 600px; +} + +.post-engagement { + margin-top: 1.2rem; + padding-top: 1rem; + border-top: 1px solid var(--line); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.like-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.9rem; + border-radius: 2rem; + border: 1px solid var(--line); + background: transparent; + color: var(--muted); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: all 160ms ease; +} + +.like-btn:hover { + color: #e25555; + border-color: #e25555; + background: rgba(226, 85, 85, 0.06); +} + +.like-btn-active { + color: #e25555; + border-color: #e25555; + background: rgba(226, 85, 85, 0.1); +} + +.like-count { + font-variant-numeric: tabular-nums; +} + +/* Friends Page */ +.friends-page { + max-width: 680px; + margin: 0 auto; + padding: 0 1rem 2rem; +} + +.friends-section { + margin-bottom: 1.75rem; +} + +.friends-section .section-title { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.count-badge { + display: inline-grid; + place-items: center; + min-width: 1.4rem; + height: 1.4rem; + padding: 0 0.35rem; + border-radius: 1rem; + background: var(--accent); + color: #fff; + font-size: 0.72rem; + font-weight: 800; +} + +.friend-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.6rem; +} + +.friend-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.7rem 0.9rem; + transition: transform 150ms ease, box-shadow 150ms ease; +} + +.friend-row:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.friend-info { + display: flex; + align-items: center; + gap: 0.7rem; + min-width: 0; + text-decoration: none; + color: inherit; +} + +.friend-avatar { + flex-shrink: 0; +} + +.friend-details { + display: flex; + flex-direction: column; + min-width: 0; +} + +.friend-name { + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.friend-info:hover .friend-name { + color: var(--accent-dark); +} + +.friend-handle { + font-size: 0.8rem; + color: var(--muted); +} + +.friend-actions { + display: flex; + gap: 0.45rem; + flex-shrink: 0; +} + +.friend-action { + white-space: nowrap; +} + +.friend-action:disabled { + opacity: 0.6; + cursor: default; +} + +.skeleton-friend { + padding: 0.9rem; +} + diff --git a/src/App.jsx b/src/App.jsx index f8db6be..64891cd 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,11 +1,15 @@ import { useCallback, useEffect, useState } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; -import { apiClient } from "./api/client"; +import { apiClient, setUnauthorizedHandler } from "./api/client"; import { Shell } from "./components/Shell"; +import { ToastContainer } from "./components/Toast"; +import { useToast } from "./components/useToast"; import { CreatePage } from "./pages/CreatePage"; import { FeedPage } from "./pages/FeedPage"; +import { FriendsPage } from "./pages/FriendsPage"; import { LandingPage } from "./pages/LandingPage"; import { LoginPage } from "./pages/LoginPage"; +import { PostPage } from "./pages/PostPage"; import { ProfilePage } from "./pages/ProfilePage"; import { RegisterPage } from "./pages/RegisterPage"; import { clearAuthSession, getAuthSession, saveAuthSession } from "./utils/cookie"; @@ -32,6 +36,7 @@ function App() { expiresAt: null, }); const [userProfile, setUserProfile] = useState(null); + const { toasts, showToast, removeToast } = useToast(); const isAuthenticated = Boolean(session.token); @@ -108,6 +113,17 @@ function App() { setUserProfile(null); }, []); + // Immediately log out when the backend rejects an authenticated request with 401 + useEffect(() => { + if (initializing) { + return; + } + setUnauthorizedHandler(() => { + logout(); + }); + return () => setUnauthorizedHandler(null); + }, [initializing, logout]); + // Fetch user profile whenever logged-in token changes (e.g. after fresh login) useEffect(() => { let active = true; @@ -160,12 +176,15 @@ function App() { if (!isAuthenticated) { return ( - - } /> - } /> - } /> - } /> - + <> + + } /> + } /> + } /> + } /> + + + ); } @@ -173,10 +192,23 @@ function App() { id: session.user?.id, name: userProfile?.name, username: userProfile?.username, + profile_link: userProfile?.profile_link || userProfile?.profileLink || null, + }; + + const loadProfileImage = (url) => apiClient.fetchImage(session.token, url); + + const friendProps = { + friends: () => apiClient.friends(session.token), + friendRequests: () => apiClient.friendRequests(session.token), + sendFriendRequest: (them) => apiClient.sendFriendRequest(session.token, them), + acceptFriendRequest: (them) => apiClient.acceptFriendRequest(session.token, them), + declineFriendRequest: (them) => apiClient.declineFriendRequest(session.token, them), + cancelFriendRequest: (them) => apiClient.cancelFriendRequest(session.token, them), + removeFriend: (id) => apiClient.removeFriend(session.token, id), }; return ( - + apiClient.deletePost(session.token, id)} loadImage={(filename) => apiClient.postImage(session.token, filename)} currentUserId={session.user.id} + showToast={showToast} + getProfile={(id) => apiClient.getProfile(session.token, id)} + fetchImage={loadProfileImage} + /> + } + /> + apiClient.getPost(session.token, id)} + loadImage={(filename) => apiClient.postImage(session.token, filename)} /> } /> apiClient.createPost(session.token, data)} /> + apiClient.createPost(session.token, data)} showToast={showToast} /> } /> apiClient.getProfile(session.token, id)} loadMyPosts={() => apiClient.myPosts(session.token)} loadAllPosts={() => apiClient.posts(session.token)} - createRelationship={(them) => apiClient.createRelationship(session.token, session.user.id, them)} + {...friendProps} editPost={(id, data) => apiClient.editPost(session.token, id, data)} deletePost={(id) => apiClient.deletePost(session.token, id)} loadImage={(filename) => apiClient.postImage(session.token, filename)} currentUserId={session.user.id} onLogout={logout} + showToast={showToast} + updateProfile={(data) => apiClient.updateProfile(session.token, data)} + fetchImage={(url) => apiClient.fetchImage(session.token, url)} + onProfileUpdate={setUserProfile} /> } /> @@ -223,17 +271,34 @@ function App() { getProfile={(id) => apiClient.getProfile(session.token, id)} loadMyPosts={() => apiClient.myPosts(session.token)} loadAllPosts={() => apiClient.posts(session.token)} - createRelationship={(them) => apiClient.createRelationship(session.token, session.user.id, them)} + {...friendProps} editPost={(id, data) => apiClient.editPost(session.token, id, data)} deletePost={(id) => apiClient.deletePost(session.token, id)} loadImage={(filename) => apiClient.postImage(session.token, filename)} currentUserId={session.user.id} onLogout={logout} + showToast={showToast} + fetchImage={(url) => apiClient.fetchImage(session.token, url)} + /> + } + /> + 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} /> } /> } /> + ); } diff --git a/src/api/client.js b/src/api/client.js index 8af846d..ba440de 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -2,6 +2,12 @@ import { getAuthSession } from "../utils/cookie"; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; +let unauthorizedHandler = null; + +export function setUnauthorizedHandler(handler) { + unauthorizedHandler = handler; +} + async function request(path, { method = "GET", token, body, formData } = {}) { if (!API_BASE_URL) { throw new Error("VITE_API_BASE_URL ontbreekt."); @@ -32,12 +38,15 @@ async function request(path, { method = "GET", token, body, formData } = {}) { const payload = await response.json().catch(() => ({})); if (!response.ok) { + if (response.status === 401 && token && token !== "active-session" && unauthorizedHandler) { + unauthorizedHandler(); + } const error = new Error( response.status === 401 - ? "Invalid email or password" - : response.status === 400 - ? payload.message || payload.error || "Please check your input." - : "Something went wrong. Please try again.", + ? token && token !== "active-session" + ? "Your session has expired. Please log in again." + : "Invalid email or password" + : payload.message || payload.error || "Something went wrong. Please try again.", ); error.status = response.status; throw error; @@ -47,14 +56,18 @@ async function request(path, { method = "GET", token, body, formData } = {}) { } export const apiClient = { - login: (credentials) => request("/auth/login", { method: "POST", body: credentials }), + login: (credentials) => + request("/auth/login", { method: "POST", body: credentials }), register: (data) => request("/auth/register", { method: "POST", body: data }), authMe: (token) => request("/auth/me", { token }), authVerify: (token) => request("/auth/verify", { token }), - logoutApi: () => request("/auth/logout", { method: "POST" }).catch(() => ({})), + logoutApi: () => + request("/auth/logout", { method: "POST" }).catch(() => ({})), myProfile: (token) => request("/profiles/me", { token }), - getProfile: (token, id) => request(`/profiles/${encodeURIComponent(id)}`, { token }), + getProfile: (token, id) => + request(`/profiles/${encodeURIComponent(id)}`, { token }), posts: (token) => request("/posts", { token }), + getPost: (token, id) => request(`/posts/${encodeURIComponent(id)}`, { token }), myPosts: (token) => request("/posts/me", { token }), createPost: (token, { title, text, image }) => { const formData = new FormData(); @@ -79,17 +92,93 @@ export const apiClient = { if (removeImage !== undefined) { formData.append("remove_image", String(removeImage)); } - return request(`/posts/edit?id=${encodeURIComponent(id)}`, { method: "PUT", token, formData }); + return request(`/posts/edit?id=${encodeURIComponent(id)}`, { + method: "PUT", + token, + formData, + }); + }, + deletePost: (token, id) => + request(`/posts/delete?id=${encodeURIComponent(id)}`, { + method: "DELETE", + token, + }), + updateProfile: (token, { name, username, image, removeImage }) => { + const formData = new FormData(); + if (name !== undefined) { + formData.append("name", name); + } + if (username !== undefined) { + formData.append("username", username); + } + if (image) { + formData.append("image", image); + } + if (removeImage !== undefined) { + formData.append("remove_image", String(removeImage)); + } + return request("/profiles/me", { method: "PUT", token, formData }); }, - deletePost: (token, id) => request(`/posts/delete?id=${encodeURIComponent(id)}`, { method: "DELETE", token }), createRelationship: (token, me, them) => - request(`/create_relationship?me=${encodeURIComponent(me)}&them=${encodeURIComponent(them)}`, { + request( + `/create_relationship?me=${encodeURIComponent(me)}&them=${encodeURIComponent(them)}`, + { + method: "POST", + token, + }, + ), + friends: (token) => request("/friends", { token }), + friendRequests: (token) => request("/friends/requests", { token }), + sendFriendRequest: (token, to) => + request(`/friends/request?to=${encodeURIComponent(to)}`, { method: "POST", token, }), - postImage: async (token, filename) => { + acceptFriendRequest: (token, from) => + request(`/friends/accept?from=${encodeURIComponent(from)}`, { + method: "POST", + token, + }), + declineFriendRequest: (token, from) => + request(`/friends/decline?from=${encodeURIComponent(from)}`, { + method: "POST", + token, + }), + cancelFriendRequest: (token, to) => + request(`/friends/cancel?to=${encodeURIComponent(to)}`, { + method: "POST", + token, + }), + removeFriend: (token, id) => + request(`/friends/remove?id=${encodeURIComponent(id)}`, { + method: "DELETE", + token, + }), + fetchImage: async (token, url) => { const headers = {}; const effectiveToken = token && token !== "active-session" ? token : getAuthSession().token || null; + if (effectiveToken && effectiveToken !== "active-session") { + headers.Authorization = `Bearer ${effectiveToken}`; + } + let fullUrl = url; + if (url && !url.startsWith("http://") && !url.startsWith("https://")) { + fullUrl = `${API_BASE_URL}${url.startsWith("/") ? "" : "/"}${url}`; + } + const response = await fetch(fullUrl, { headers, credentials: "include" }); + if (!response.ok) { + if (response.status === 401 && unauthorizedHandler) { + unauthorizedHandler(); + } + throw new Error("Unable to load image."); + } + return response.blob(); + }, + postImage: async (token, filename) => { + const headers = {}; + const effectiveToken = + token && token !== "active-session" + ? token + : getAuthSession().token || null; if (effectiveToken && effectiveToken !== "active-session") { headers.Authorization = `Bearer ${effectiveToken}`; @@ -104,18 +193,24 @@ export const apiClient = { if (target.startsWith("http://") || target.startsWith("https://")) { if (target.includes("/posts/image/")) { - const imagePart = target.substring(target.indexOf("/posts/image/") + "/posts/image/".length); + const imagePart = target.substring( + target.indexOf("/posts/image/") + "/posts/image/".length, + ); const cleanName = encodeURIComponent(decodeURIComponent(imagePart)); url = `${API_BASE_URL}/posts/image/${cleanName}`; } else { url = target; } } else if (target.includes("/posts/image/")) { - const imagePart = target.substring(target.indexOf("/posts/image/") + "/posts/image/".length); + const imagePart = target.substring( + target.indexOf("/posts/image/") + "/posts/image/".length, + ); const cleanName = encodeURIComponent(decodeURIComponent(imagePart)); url = `${API_BASE_URL}/posts/image/${cleanName}`; } else { - const cleanName = encodeURIComponent(decodeURIComponent(target.split("/").pop())); + const cleanName = encodeURIComponent( + decodeURIComponent(target.split("/").pop()), + ); url = `${API_BASE_URL}/posts/image/${cleanName}`; } } else { @@ -127,6 +222,9 @@ export const apiClient = { credentials: "include", }); if (!response.ok) { + if (response.status === 401 && unauthorizedHandler) { + unauthorizedHandler(); + } throw new Error("Unable to load image."); } return response.blob(); diff --git a/src/components/AuthorAvatar.jsx b/src/components/AuthorAvatar.jsx new file mode 100644 index 0000000..e0464ad --- /dev/null +++ b/src/components/AuthorAvatar.jsx @@ -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 ( + {name + ); + } + + return ( +
+ {getInitials(name)} +
+ ); +} diff --git a/src/components/Lightbox.jsx b/src/components/Lightbox.jsx new file mode 100644 index 0000000..8ba3c9d --- /dev/null +++ b/src/components/Lightbox.jsx @@ -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 ( +
+ + {alt event.stopPropagation()} /> +
+ ); +} diff --git a/src/components/PostCard.jsx b/src/components/PostCard.jsx index d9f9bee..6aaafd3 100644 --- a/src/components/PostCard.jsx +++ b/src/components/PostCard.jsx @@ -1,5 +1,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; +import { AuthorAvatar } from "./AuthorAvatar"; +import { Lightbox } from "./Lightbox"; function getImageFilename(post) { if (!post) return null; @@ -57,18 +59,6 @@ function getAuthorUsername(post) { return post.author_username || post.author?.username || null; } -function getInitials(name) { - if (!name) return "?"; - const cleaned = name.replace(/^@/, ""); - return cleaned - .split(" ") - .filter(Boolean) - .slice(0, 2) - .map((part) => part[0]) - .join("") - .toUpperCase() || "?"; -} - function formatDate(timestamp) { if (!timestamp) return null; try { @@ -89,6 +79,7 @@ function formatDate(timestamp) { function AuthenticatedImage({ filename, alt, loadImage }) { const [source, setSource] = useState(""); const [error, setError] = useState(""); + const [lightboxOpen, setLightboxOpen] = useState(false); useEffect(() => { let active = true; @@ -147,21 +138,27 @@ function AuthenticatedImage({ filename, alt, loadImage }) { return

{error}

; } - return source ? ( - {alt} { - setError("Image unavailable."); - }} - /> - ) : null; + return ( + <> + {source ? ( + {alt} setLightboxOpen(true)} + onError={() => { + setError("Image unavailable."); + }} + /> + ) : null} + setLightboxOpen(false)} /> + + ); } -export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete }) { +export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete, getProfile, fetchImage }) { const [editing, setEditing] = useState(false); const [form, setForm] = useState({ title: post.title || "", text: post.text || "", image: null, removeImage: false }); const [error, setError] = useState(""); @@ -206,7 +203,7 @@ export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete }) {
-
{getInitials(authorName)}
+
{authorName} {authorUsername && !authorName.startsWith("@") ? ( @@ -274,10 +271,12 @@ export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete }) { ) : ( <> -
- {post.title ?

{post.title}

: null} -

{post.text ?? post.content}

-
+ +
+ {post.title ?

{post.title}

: null} +

{post.text ?? post.content}

+
+ {imageFilename ? : null} )} diff --git a/src/components/PostComposer.jsx b/src/components/PostComposer.jsx index 1a42777..b91f3b7 100644 --- a/src/components/PostComposer.jsx +++ b/src/components/PostComposer.jsx @@ -28,7 +28,12 @@ export function PostComposer({ onCreate }) { return (
-
+
+ + + + +

Share with your community

Add a title, a thought, or an image.

@@ -62,8 +67,8 @@ export function PostComposer({ onCreate }) { - {error ?

{error}

: null} - {message ?

{message}

: null} + {error ?

{error}

: null} + {message ?

{message}

: null}
); } diff --git a/src/components/PostList.jsx b/src/components/PostList.jsx index 7581fcf..7959dae 100644 --- a/src/components/PostList.jsx +++ b/src/components/PostList.jsx @@ -3,7 +3,7 @@ import { PostCard } from "./PostCard"; const POSTS_PER_BATCH = 6; -export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete }) { +export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete, getProfile, fetchImage }) { const [visibleCount, setVisibleCount] = useState(POSTS_PER_BATCH); const loadMoreRef = useRef(null); @@ -44,6 +44,8 @@ export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete }) loadImage={loadImage} onEdit={onEdit} onDelete={onDelete} + getProfile={getProfile} + fetchImage={fetchImage} /> ))} {visibleCount < posts.length ? ( diff --git a/src/components/ProfileEditor.jsx b/src/components/ProfileEditor.jsx new file mode 100644 index 0000000..90975c4 --- /dev/null +++ b/src/components/ProfileEditor.jsx @@ -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 ( +
+
event.stopPropagation()}> +
+

Edit Profile

+ +
+
+ + + + +
+ + +
+
+ {error ?

{error}

: null} +
+
+ ); +} diff --git a/src/components/Shell.jsx b/src/components/Shell.jsx index 90a2de4..8aaab6c 100644 --- a/src/components/Shell.jsx +++ b/src/components/Shell.jsx @@ -1,20 +1,7 @@ import { Link, NavLink } from "react-router-dom"; +import { AuthorAvatar } from "./AuthorAvatar"; -function getInitials(name) { - if (!name) return "?"; - const cleaned = String(name).replace(/^@/, ""); - return ( - cleaned - .split(" ") - .filter(Boolean) - .slice(0, 2) - .map((part) => part[0]) - .join("") - .toUpperCase() || "?" - ); -} - -export function Shell({ user, onLogout, children }) { +export function Shell({ user, onLogout, children, fetchImage }) { const displayName = user?.name || (user?.username ? `@${user.username}` : "Your Account"); const displaySub = user?.username && user?.name ? `@${user.username}` : ""; @@ -27,20 +14,50 @@ export function Shell({ user, onLogout, children }) {

Archive your social world.

-
{getInitials(displayName)}
+
{displayName} {displaySub} diff --git a/src/components/Toast.jsx b/src/components/Toast.jsx new file mode 100644 index 0000000..6af9b44 --- /dev/null +++ b/src/components/Toast.jsx @@ -0,0 +1,36 @@ +export function ToastContainer({ toasts }) { + if (!toasts.length) return null; + + return ( +
+ {toasts.map((toast) => ( +
+ + {toast.type === "success" ? ( + + + + ) : toast.type === "error" ? ( + + + + + + ) : ( + + + + + + )} + + {toast.message} +
+ ))} +
+ ); +} diff --git a/src/components/useToast.js b/src/components/useToast.js new file mode 100644 index 0000000..769a29c --- /dev/null +++ b/src/components/useToast.js @@ -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 }; +} diff --git a/src/index.css b/src/index.css index 0667000..1d93e25 100644 --- a/src/index.css +++ b/src/index.css @@ -7,9 +7,16 @@ --panel: #fffcf6; --accent: #c45a3d; --accent-dark: #9a3e27; + --accent-light: rgba(196, 90, 61, 0.1); + --accent-glow: rgba(196, 90, 61, 0.25); --line: #d8d1c4; --ok: #2d8a58; + --ok-light: rgba(45, 138, 88, 0.1); --error: #b24343; + --error-light: rgba(178, 67, 67, 0.1); + --shadow-sm: 0 4px 12px rgba(31, 42, 43, 0.04); + --shadow-md: 0 8px 24px rgba(31, 42, 43, 0.05); + --shadow-lg: 0 22px 50px rgba(31, 42, 43, 0.12); --display: "Fraunces", serif; --body: "Manrope", sans-serif; @@ -33,3 +40,41 @@ body { #root { min-height: 100vh; } + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} + +::selection { + background: var(--accent); + color: #fff; +} + +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--line); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--muted); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/pages/CreatePage.jsx b/src/pages/CreatePage.jsx index 69a3865..ec1c0f1 100644 --- a/src/pages/CreatePage.jsx +++ b/src/pages/CreatePage.jsx @@ -1,11 +1,12 @@ import { useNavigate } from "react-router-dom"; import { PostComposer } from "../components/PostComposer"; -export function CreatePage({ createPost }) { +export function CreatePage({ createPost, showToast }) { const navigate = useNavigate(); async function handleCreate(data) { await createPost(data); + showToast("Post created!", "success"); navigate("/feed"); } diff --git a/src/pages/FeedPage.jsx b/src/pages/FeedPage.jsx index d9cd561..d44655e 100644 --- a/src/pages/FeedPage.jsx +++ b/src/pages/FeedPage.jsx @@ -2,7 +2,15 @@ import { useCallback, useEffect, useState } from "react"; import { PostList } from "../components/PostList"; function getPosts(data) { - return Array.isArray(data) ? data : data.posts || data.items || []; + const posts = Array.isArray(data) ? data : data.posts || data.items || []; + return posts.sort((a, b) => { + const aDate = a.created_at || a.createdAt; + const bDate = b.created_at || b.createdAt; + if (!aDate && !bDate) return 0; + if (!aDate) return 1; + if (!bDate) return -1; + return new Date(bDate) - new Date(aDate); + }); } function getAuthorId(post) { @@ -16,7 +24,28 @@ function postsFromOthers(posts, currentUserId) { return posts.filter((post) => String(getAuthorId(post)) !== String(currentUserId)); } -export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUserId }) { +function FeedSkeleton() { + return ( +