From d92d7939ddc39978c510d6be71be7045017ab502 Mon Sep 17 00:00:00 2001 From: Alex Erdei Date: Tue, 29 Apr 2025 23:43:54 +0100 Subject: [PATCH] Make images visible in the app --- src/app/store.js | 40 ++++----- src/backend/backend.js | 137 +++++++++++++++++++--------- src/components/StorageImage.jsx | 138 +++++++++++------------------ src/features/images/imagesSlice.js | 24 ----- 4 files changed, 167 insertions(+), 172 deletions(-) delete mode 100644 src/features/images/imagesSlice.js diff --git a/src/app/store.js b/src/app/store.js index d57bf82..348bddd 100644 --- a/src/app/store.js +++ b/src/app/store.js @@ -1,24 +1,22 @@ -import { configureStore } from '@reduxjs/toolkit' -import userReducer from '../features/user/userSlice' -import currentUserReducer from '../features/currentUser/currentUserSlice' -import usersReducer from '../features/users/usersSlice' -import postsReducer from '../features/posts/postsSlice' -import incomingMessagesReducer from '../features/incomingMessages/incomingMessagesSlice' -import outgoingMessagesReducer from '../features/outgoingMessages/outgoingMessagesSlice' -import imagesReducer from '../features/images/imagesSlice' -import linkReducer from '../features/link/linkSlice' -import accountPageReducer from '../features/accountPage/accountPageSlice' +import { configureStore } from "@reduxjs/toolkit"; +import userReducer from "../features/user/userSlice"; +import currentUserReducer from "../features/currentUser/currentUserSlice"; +import usersReducer from "../features/users/usersSlice"; +import postsReducer from "../features/posts/postsSlice"; +import incomingMessagesReducer from "../features/incomingMessages/incomingMessagesSlice"; +import outgoingMessagesReducer from "../features/outgoingMessages/outgoingMessagesSlice"; +import linkReducer from "../features/link/linkSlice"; +import accountPageReducer from "../features/accountPage/accountPageSlice"; export default configureStore({ - reducer: { - user: userReducer, - currentUser: currentUserReducer, - users: usersReducer, - posts: postsReducer, - incomingMessages: incomingMessagesReducer, - outgoingMessages: outgoingMessagesReducer, - images: imagesReducer, - link: linkReducer, - accountPage: accountPageReducer, - }, + reducer: { + user: userReducer, + currentUser: currentUserReducer, + users: usersReducer, + posts: postsReducer, + incomingMessages: incomingMessagesReducer, + outgoingMessages: outgoingMessagesReducer, + link: linkReducer, + accountPage: accountPageReducer, + }, }); diff --git a/src/backend/backend.js b/src/backend/backend.js index c0a469f..38db8b9 100644 --- a/src/backend/backend.js +++ b/src/backend/backend.js @@ -1,7 +1,9 @@ /* ===================================================================== + Fakebook — back-end file (AUTH + mock social features) + ===================================================================== */ import store from "../app/store"; @@ -38,6 +40,39 @@ const LS_TOKEN = "fakebook.jwt"; const LS_USER_ID = "fakebook.user_id"; +/* --------------------------- SESSION (NEW) ----------------------------- */ + +/* In-memory copy – null while logged-out */ + +let authToken = null; + +let authUser = null; + +/* Header helper – import this anywhere you need the bearer token */ + +const authHeader = () => + authToken ? { Authorization: `Bearer ${authToken}` } : {}; + +/* Initialise / clear session */ + +function setAuth(token, user_id) { + authToken = token; + + authUser = user_id; + + localStorage.setItem(LS_TOKEN, token); + + localStorage.setItem(LS_USER_ID, user_id); +} + +function clearAuth() { + authToken = authUser = null; + + localStorage.removeItem(LS_TOKEN); + + localStorage.removeItem(LS_USER_ID); +} + /* --------------------------- Utilities -------------------------------- */ const genId = () => Math.random().toString(36).slice(2, 11); @@ -45,8 +80,17 @@ const genId = () => Math.random().toString(36).slice(2, 11); const delay = (ms = 200) => new Promise((r) => setTimeout(r, ms)); async function $fetch(url, opts = {}) { + /* inject bearer automatically */ + const res = await fetch(url, { - headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, + headers: { + "Content-Type": "application/json", + + ...authHeader(), + + ...(opts.headers || {}), + }, + ...opts, }); @@ -57,9 +101,33 @@ async function $fetch(url, opts = {}) { return data; } -/* ---------------------- REST → UI mapper (moved up) ------------------ */ +/* ---------------------- REST → UI mapper ------------------------------- */ + +function addPath(u, fileName) { + if (typeof fileName !== "string" || !fileName.length) return fileName; + + if (fileName.includes("/")) return fileName; /* already has folder */ + + return `${u.user_id}/${fileName}`; /* prepend owner id */ +} function mapRestUser(u) { + const photosRaw = JSON.parse(u.photos || "[]"); + + const photos = photosRaw.map((item) => { + if (typeof item === "string") { + /* legacy: array of strings */ + + return { filename: addPath(u, item) }; + } + + if (item && typeof item.filename === "string") { + return { ...item, filename: addPath(u, item.filename) }; + } + + return item; + }); + return { userID: u.user_id, @@ -67,11 +135,13 @@ function mapRestUser(u) { lastname: u.lastname, + /* already stored as folder/filename → leave untouched */ + profilePictureURL: u.profilePictureURL, backgroundPictureURL: u.backgroundPictureURL, - photos: JSON.parse(u.photos || "[]"), + photos, posts: JSON.parse(u.posts || "[]"), @@ -85,7 +155,9 @@ function mapRestUser(u) { /* ===================================================================== * - SECTION A — REAL AUTH WORKFLOW (FIXED) * + + SECTION A — REAL AUTH WORKFLOW + ===================================================================== */ @@ -100,6 +172,8 @@ export function subscribeAuth() { const user_id = localStorage.getItem(LS_USER_ID); if (!token || !user_id) { + clearAuth(); /* make sure RAM copy is empty */ + store.dispatch(signOut()); store.dispatch(loadingFinished()); @@ -107,16 +181,16 @@ export function subscribeAuth() { return; } + /* restore session into RAM */ + + setAuth(token, user_id); + try { - /* The users endpoint does NOT understand ?user_id=. - - Workaround: fetch all users (limit=-1) then find ours. */ + /* users endpoint lacks ?user_id, so fetch all */ - const users = await $fetch(`${USERS_URL}?limit=-1`, { - headers: { Authorization: `Bearer ${token}` }, - }); + const users = await $fetch(`${USERS_URL}?limit=-1`); - const u = users.find((u) => u.user_id === user_id); + const u = users.find((x) => x.user_id === user_id); if (!u) throw new Error("User not found"); @@ -130,13 +204,9 @@ export function subscribeAuth() { }) ); } catch (err) { - /* Token invalid / expired / user not found */ - console.warn("[Auth] subscribeAuth failed:", err.message); - localStorage.removeItem(LS_TOKEN); - - localStorage.removeItem(LS_USER_ID); + clearAuth(); store.dispatch(signOut()); } finally { @@ -167,7 +237,7 @@ export async function createUserAccount(user) { }), }); - store.dispatch(errorOccured("")); // clear previous error + store.dispatch(errorOccured("")); } catch (err) { store.dispatch(errorOccured(err.message)); } finally { @@ -175,41 +245,32 @@ export async function createUserAccount(user) { } } -/* ---------------------- signInUser (FIXED) -------------------------- */ +/* ---------------------- signInUser ---------------------------------- */ export async function signInUser(user) { store.dispatch(loadingStarted()); try { - /* 1. Login with email + password (as API expects) */ - const url = `${LOGIN_URL}?email=${encodeURIComponent( user.email )}&password=${encodeURIComponent(user.password)}`; const { token, user_id } = await $fetch(url); - /* 2. Persist session */ + /* persist + put into RAM */ - localStorage.setItem(LS_TOKEN, token); + setAuth(token, user_id); - localStorage.setItem(LS_USER_ID, user_id); + /* get full profile */ - /* 3. Fetch FULL user list, find our profile */ - - const users = await $fetch(`${USERS_URL}?limit=-1`, { - headers: { Authorization: `Bearer ${token}` }, - }); + const users = await $fetch(`${USERS_URL}?limit=-1`); const profile = users.find((u) => u.user_id === user_id); if (!profile) throw new Error("User not found"); - if (!profile.isEmailVerified) { + if (!profile.isEmailVerified) throw new Error("Please verify your email before to continue"); - } - - /* 4. Dispatch sign-in */ store.dispatch( signIn({ @@ -225,9 +286,7 @@ export async function signInUser(user) { } catch (err) { store.dispatch(errorOccured(err.message)); - localStorage.removeItem(LS_TOKEN); - - localStorage.removeItem(LS_USER_ID); + clearAuth(); } finally { store.dispatch(loadingFinished()); } @@ -236,13 +295,9 @@ export async function signInUser(user) { /* ---------------------- signUserOut ---------------------------------- */ export async function signUserOut() { - /* mark offline in DB */ - await patchOnline(false); + await patchOnline(false); /* mark offline */ - /* clear session */ - localStorage.removeItem(LS_TOKEN); - - localStorage.removeItem(LS_USER_ID); + clearAuth(); store.dispatch(signOut()); } diff --git a/src/components/StorageImage.jsx b/src/components/StorageImage.jsx index 0e4d270..19c2664 100644 --- a/src/components/StorageImage.jsx +++ b/src/components/StorageImage.jsx @@ -1,102 +1,68 @@ -import React, { useState } from "react"; -import { getImageURL } from "../backend/backend"; -import { useSelector, useDispatch } from "react-redux"; +import React, { useEffect, useState } from "react"; + import placeholderImage from "../images/placeholder-image.jpg"; + import fakebookAvatar from "../images/fakebook-avatar.jpeg"; + import backgroundServer from "../images/background-server.jpg"; -import { imageAdded, imageUrlFound } from "../features/images/imagesSlice"; -import { useEffect } from "react"; -const StorageImage = (props) => { - const { storagePath, alt, ...rest } = props; +const API_BASE = "https://alexerdei-team.us.ainiro.io/magic/modules/fakebook"; - const PLACEHOLDER_AVATAR_STORAGE_PATH = "fakebook-avatar.jpeg"; - const PLACEHOLDER_BACKGROUND_STORAGE_PATH = "background-server.jpg"; +/* ------------------------------------------------------------------ */ - //We use the images slice as a buffer. Fetching the actual url of the image - //in the storage takes relatively long time and uses Firebase. We render the same - //image in the app several times. Our goal to fetch the url only once for each image - //to save resources. - const images = useSelector((state) => state.images); - const dispatch = useDispatch(); +function toWebp(name) { + if (name.toLowerCase().endsWith(".webp")) return name; - const [url, setUrl] = useState(placeholderImage); + const dot = name.lastIndexOf("."); - function changeStoragePath(storagePath) { - const words = storagePath.split("."); - words[words.length - 2] += "_400x400"; - return words.join("."); - } + return dot === -1 ? `${name}.webp` : `${name.slice(0, dot)}.webp`; +} + +async function fetchImageURL(storagePath) { + if (!storagePath) return placeholderImage; + + if (storagePath === "fakebook-avatar.jpeg") return fakebookAvatar; + + if (storagePath === "background-server.jpg") return backgroundServer; + + const [folder, ...rest] = storagePath.split("/"); + + const rawFilename = rest.join("/"); + + const filenameWebp = toWebp(rawFilename); // ← only change + + const url = + `${API_BASE}/image?folder=fakebook/${encodeURIComponent(folder)}` + + `&filename=${encodeURIComponent(filenameWebp)}`; + + const res = await fetch(url); + + if (!res.ok) throw new Error("Image request failed"); + + const blob = await res.blob(); + + return URL.createObjectURL(blob); +} + +/* ================================================================== */ + +const StorageImage = ({ storagePath, ...rest }) => { + const [src, setSrc] = useState(placeholderImage); useEffect(() => { - let shouldUpdate = true; - const cleanup = () => (shouldUpdate = false); + let cancelled = false; - //We filter out placeholder pictures - if (storagePath === PLACEHOLDER_AVATAR_STORAGE_PATH) { - setUrl(fakebookAvatar); - return cleanup; - } - if (storagePath === PLACEHOLDER_BACKGROUND_STORAGE_PATH) { - setUrl(backgroundServer); - return cleanup; - } + fetchImageURL(storagePath) + .then((url) => !cancelled && setSrc(url)) - //We look for the url in images slice first - let imageIndex = images - .map((image) => image.storagePath) - .indexOf(storagePath); - if (imageIndex === -1) { - imageIndex = images - .map((image) => image.storagePath) - .indexOf(changeStoragePath(storagePath)); - //If we are unable to find it anyway we add the image to the slice - if (imageIndex === -1) { - dispatch( - imageAdded({ - storagePath, - url, - }) - ); - } - //We add the url for the image to the images slice when we have actually got it - //We also update the local state to show the right image - getImageURL(storagePath) - .then((url) => { - setUrl(url); - dispatch( - imageUrlFound({ - storagePath, - url, - }) - ); - }) - .catch((_error) => { - getImageURL(changeStoragePath(storagePath)).then((url) => { - setUrl(url); - dispatch( - imageUrlFound({ - storagePath, - url, - }) - ); - }); - }); - } else { - //If we are able to find the url in the images slice we just use it instead of fetching - const newUrl = images[imageIndex].url; - //We only update the state if it contains different value and we should update because - //the component has not been unmounted by the time the promise resolves - if (newUrl !== url && shouldUpdate) setUrl(newUrl); - } - //We return a cleanup function which runs when the component unmounts. We set - //the shouldUpdate to false, so after this the state cannot be updated. If - //we don't do this React gives us error messages about state update on our - //unmounted component - return cleanup; - }, [images, storagePath, url, dispatch]); + .catch(() => !cancelled && setSrc(placeholderImage)); - return {alt}; + return () => { + cancelled = true; + }; + }, [storagePath]); + + return ; }; export default StorageImage; diff --git a/src/features/images/imagesSlice.js b/src/features/images/imagesSlice.js deleted file mode 100644 index a505ae5..0000000 --- a/src/features/images/imagesSlice.js +++ /dev/null @@ -1,24 +0,0 @@ -import { createSlice } from "@reduxjs/toolkit"; - -export const imagesSlice = createSlice({ - name: "images", - initialState: [], - reducers: { - imageAdded: (state, action) => { - const index = state - .map((image) => image.storagePath) - .indexOf(action.payload.storagePath); - if (index === -1) state.push(action.payload); - }, - imageUrlFound: (state, action) => { - const index = state - .map((image) => image.storagePath) - .indexOf(action.payload.storagePath); - if (index > -1) state[index].url = action.payload.url; - }, - }, -}); - -export const { imageAdded, imageUrlFound } = imagesSlice.actions; - -export default imagesSlice.reducer;