Make images visible in the app

This commit is contained in:
Alex Erdei 2025-04-29 23:43:54 +01:00
parent a8898d2ad9
commit d92d7939dd
4 changed files with 167 additions and 172 deletions

View File

@ -1,24 +1,22 @@
import { configureStore } from '@reduxjs/toolkit' import { configureStore } from "@reduxjs/toolkit";
import userReducer from '../features/user/userSlice' import userReducer from "../features/user/userSlice";
import currentUserReducer from '../features/currentUser/currentUserSlice' import currentUserReducer from "../features/currentUser/currentUserSlice";
import usersReducer from '../features/users/usersSlice' import usersReducer from "../features/users/usersSlice";
import postsReducer from '../features/posts/postsSlice' import postsReducer from "../features/posts/postsSlice";
import incomingMessagesReducer from '../features/incomingMessages/incomingMessagesSlice' import incomingMessagesReducer from "../features/incomingMessages/incomingMessagesSlice";
import outgoingMessagesReducer from '../features/outgoingMessages/outgoingMessagesSlice' import outgoingMessagesReducer from "../features/outgoingMessages/outgoingMessagesSlice";
import imagesReducer from '../features/images/imagesSlice' import linkReducer from "../features/link/linkSlice";
import linkReducer from '../features/link/linkSlice' import accountPageReducer from "../features/accountPage/accountPageSlice";
import accountPageReducer from '../features/accountPage/accountPageSlice'
export default configureStore({ export default configureStore({
reducer: { reducer: {
user: userReducer, user: userReducer,
currentUser: currentUserReducer, currentUser: currentUserReducer,
users: usersReducer, users: usersReducer,
posts: postsReducer, posts: postsReducer,
incomingMessages: incomingMessagesReducer, incomingMessages: incomingMessagesReducer,
outgoingMessages: outgoingMessagesReducer, outgoingMessages: outgoingMessagesReducer,
images: imagesReducer, link: linkReducer,
link: linkReducer, accountPage: accountPageReducer,
accountPage: accountPageReducer, },
},
}); });

View File

@ -1,7 +1,9 @@
/* ===================================================================== /* =====================================================================
Fakebook back-end file (AUTH + mock social features) Fakebook back-end file (AUTH + mock social features)
===================================================================== */ ===================================================================== */
import store from "../app/store"; import store from "../app/store";
@ -38,6 +40,39 @@ const LS_TOKEN = "fakebook.jwt";
const LS_USER_ID = "fakebook.user_id"; 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 -------------------------------- */ /* --------------------------- Utilities -------------------------------- */
const genId = () => Math.random().toString(36).slice(2, 11); 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)); const delay = (ms = 200) => new Promise((r) => setTimeout(r, ms));
async function $fetch(url, opts = {}) { async function $fetch(url, opts = {}) {
/* inject bearer automatically */
const res = await fetch(url, { const res = await fetch(url, {
headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, headers: {
"Content-Type": "application/json",
...authHeader(),
...(opts.headers || {}),
},
...opts, ...opts,
}); });
@ -57,9 +101,33 @@ async function $fetch(url, opts = {}) {
return data; 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) { 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 { return {
userID: u.user_id, userID: u.user_id,
@ -67,11 +135,13 @@ function mapRestUser(u) {
lastname: u.lastname, lastname: u.lastname,
/* already stored as folder/filename → leave untouched */
profilePictureURL: u.profilePictureURL, profilePictureURL: u.profilePictureURL,
backgroundPictureURL: u.backgroundPictureURL, backgroundPictureURL: u.backgroundPictureURL,
photos: JSON.parse(u.photos || "[]"), photos,
posts: JSON.parse(u.posts || "[]"), 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); const user_id = localStorage.getItem(LS_USER_ID);
if (!token || !user_id) { if (!token || !user_id) {
clearAuth(); /* make sure RAM copy is empty */
store.dispatch(signOut()); store.dispatch(signOut());
store.dispatch(loadingFinished()); store.dispatch(loadingFinished());
@ -107,16 +181,16 @@ export function subscribeAuth() {
return; return;
} }
/* restore session into RAM */
setAuth(token, user_id);
try { try {
/* The users endpoint does NOT understand ?user_id=. /* users endpoint lacks ?user_id, so fetch all */
Workaround: fetch all users (limit=-1) then find ours. */
const users = await $fetch(`${USERS_URL}?limit=-1`, { const users = await $fetch(`${USERS_URL}?limit=-1`);
headers: { Authorization: `Bearer ${token}` },
});
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"); if (!u) throw new Error("User not found");
@ -130,13 +204,9 @@ export function subscribeAuth() {
}) })
); );
} catch (err) { } catch (err) {
/* Token invalid / expired / user not found */
console.warn("[Auth] subscribeAuth failed:", err.message); console.warn("[Auth] subscribeAuth failed:", err.message);
localStorage.removeItem(LS_TOKEN); clearAuth();
localStorage.removeItem(LS_USER_ID);
store.dispatch(signOut()); store.dispatch(signOut());
} finally { } finally {
@ -167,7 +237,7 @@ export async function createUserAccount(user) {
}), }),
}); });
store.dispatch(errorOccured("")); // clear previous error store.dispatch(errorOccured(""));
} catch (err) { } catch (err) {
store.dispatch(errorOccured(err.message)); store.dispatch(errorOccured(err.message));
} finally { } finally {
@ -175,41 +245,32 @@ export async function createUserAccount(user) {
} }
} }
/* ---------------------- signInUser (FIXED) -------------------------- */ /* ---------------------- signInUser ---------------------------------- */
export async function signInUser(user) { export async function signInUser(user) {
store.dispatch(loadingStarted()); store.dispatch(loadingStarted());
try { try {
/* 1. Login with email + password (as API expects) */
const url = `${LOGIN_URL}?email=${encodeURIComponent( const url = `${LOGIN_URL}?email=${encodeURIComponent(
user.email user.email
)}&password=${encodeURIComponent(user.password)}`; )}&password=${encodeURIComponent(user.password)}`;
const { token, user_id } = await $fetch(url); 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`);
const users = await $fetch(`${USERS_URL}?limit=-1`, {
headers: { Authorization: `Bearer ${token}` },
});
const profile = users.find((u) => u.user_id === user_id); const profile = users.find((u) => u.user_id === user_id);
if (!profile) throw new Error("User not found"); if (!profile) throw new Error("User not found");
if (!profile.isEmailVerified) { if (!profile.isEmailVerified)
throw new Error("Please verify your email before to continue"); throw new Error("Please verify your email before to continue");
}
/* 4. Dispatch sign-in */
store.dispatch( store.dispatch(
signIn({ signIn({
@ -225,9 +286,7 @@ export async function signInUser(user) {
} catch (err) { } catch (err) {
store.dispatch(errorOccured(err.message)); store.dispatch(errorOccured(err.message));
localStorage.removeItem(LS_TOKEN); clearAuth();
localStorage.removeItem(LS_USER_ID);
} finally { } finally {
store.dispatch(loadingFinished()); store.dispatch(loadingFinished());
} }
@ -236,13 +295,9 @@ export async function signInUser(user) {
/* ---------------------- signUserOut ---------------------------------- */ /* ---------------------- signUserOut ---------------------------------- */
export async function signUserOut() { export async function signUserOut() {
/* mark offline in DB */ await patchOnline(false); /* mark offline */
await patchOnline(false);
/* clear session */ clearAuth();
localStorage.removeItem(LS_TOKEN);
localStorage.removeItem(LS_USER_ID);
store.dispatch(signOut()); store.dispatch(signOut());
} }

View File

@ -1,102 +1,68 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { getImageURL } from "../backend/backend";
import { useSelector, useDispatch } from "react-redux";
import placeholderImage from "../images/placeholder-image.jpg"; import placeholderImage from "../images/placeholder-image.jpg";
import fakebookAvatar from "../images/fakebook-avatar.jpeg"; import fakebookAvatar from "../images/fakebook-avatar.jpeg";
import backgroundServer from "../images/background-server.jpg"; import backgroundServer from "../images/background-server.jpg";
import { imageAdded, imageUrlFound } from "../features/images/imagesSlice";
import { useEffect } from "react";
const StorageImage = (props) => { const API_BASE = "https://alexerdei-team.us.ainiro.io/magic/modules/fakebook";
const { storagePath, alt, ...rest } = props;
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 function toWebp(name) {
//in the storage takes relatively long time and uses Firebase. We render the same if (name.toLowerCase().endsWith(".webp")) return name;
//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();
const [url, setUrl] = useState(placeholderImage); const dot = name.lastIndexOf(".");
function changeStoragePath(storagePath) { return dot === -1 ? `${name}.webp` : `${name.slice(0, dot)}.webp`;
const words = storagePath.split("."); }
words[words.length - 2] += "_400x400";
return words.join("."); 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(() => { useEffect(() => {
let shouldUpdate = true; let cancelled = false;
const cleanup = () => (shouldUpdate = false);
//We filter out placeholder pictures fetchImageURL(storagePath)
if (storagePath === PLACEHOLDER_AVATAR_STORAGE_PATH) { .then((url) => !cancelled && setSrc(url))
setUrl(fakebookAvatar);
return cleanup;
}
if (storagePath === PLACEHOLDER_BACKGROUND_STORAGE_PATH) {
setUrl(backgroundServer);
return cleanup;
}
//We look for the url in images slice first .catch(() => !cancelled && setSrc(placeholderImage));
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]);
return <img src={url} alt={alt} {...rest} />; return () => {
cancelled = true;
};
}, [storagePath]);
return <img src={src} alt='' {...rest} />;
}; };
export default StorageImage; export default StorageImage;

View File

@ -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;