Skip to content

Commit

Permalink
feat: Implement artist image loading with error handling and PWA caching
Browse files Browse the repository at this point in the history
- Add react-error-boundary for robust image loading
- Create ArtistImage component with suspense and error handling
- Implement image preloading in PWA service worker
- Add runtime caching for Appwrite storage assets
- Refactor image loading logic to use Appwrite storage
  • Loading branch information
luclu7 committed Mar 6, 2025
1 parent f2ad048 commit a4c94f7
Show file tree
Hide file tree
Showing 12 changed files with 207 additions and 33 deletions.
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"react": "^18.3.1",
"react-custom-roulette": "^1.4.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-i18next": "^15.4.0",
"react-qr-code": "^2.0.15",
Expand Down
34 changes: 33 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,50 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { RouterProvider } from "@tanstack/react-router";
import { Models } from "appwrite";
import { StrictMode, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useRegisterSW } from "virtual:pwa-register/react";

import { ToastAction } from "@/components/toasts/Toast.tsx";
import { QRDrawerContextProvider } from "@/contexts/QRDrawerContextProvider.tsx";
import { eventEndDate } from "@/lib/consts.ts";
import { getAssetsFilesList, storage } from "@/lib/appwrite.ts";
import { assetsBucketId, eventEndDate } from "@/lib/consts.ts";
import { useToast } from "@/lib/hooks/useToast.ts";
import { queryClient } from "@/lib/queryClient.ts";
import { router } from "@/router.tsx";

const LIMIT_DATE = new Date(eventEndDate);

// Fonction pour précharger une image
const preloadImage = async (file: Models.File) => {
try {
const fileUrl = storage.getFileDownload(assetsBucketId, file.$id);
console.log("Preloading image", fileUrl);
const response = await fetch(fileUrl);
const blob = await response.blob();
await caches.open("rally-assets").then((cache) => {
return cache.put(fileUrl, new Response(blob));
});
} catch (error) {
console.error(`Erreur lors du préchargement de l'image ${file}:`, error);
}
};

export function App() {
const { t } = useTranslation();
const { toast } = useToast();

const preloadAllImages = async () => {
getAssetsFilesList()
.then((e) => e.files)
.then((files) => {
files.forEach((file: Models.File) => {
preloadImage(file);
});
});
};

const { updateServiceWorker } = useRegisterSW({
onOfflineReady() {
toast({
Expand Down Expand Up @@ -52,6 +80,10 @@ export function App() {
},
60 * 10 * 1000,
);
// preload all images
preloadAllImages().then(() => {
console.log("Correctly preloaded all images ?");
});
}
},
});
Expand Down
31 changes: 31 additions & 0 deletions web/src/components/artists/ArtistImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useSuspenseQuery } from "@tanstack/react-query";

import { QUERY_KEYS } from "@/lib/QueryKeys";
import { getArtistImage } from "@/lib/images";

export const ArtistImage = ({
userId,
name,
}: {
userId: string;
name: string;
}) => {
const { data: imageSrc } = useSuspenseQuery({
queryKey: [QUERY_KEYS.ARTIST_IMAGE, userId],
queryFn: () => getArtistImage(userId),
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // bon ok ça c'est du copilot
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
});

if (!imageSrc) return null;

return (
<img
src={imageSrc}
alt={name}
className={"w-32 rounded-full border-8 border-secondary"}
/>
);
};
30 changes: 24 additions & 6 deletions web/src/components/artists/ArtistPresentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ import centroid from "@turf/centroid";
import { polygon } from "@turf/helpers";
import { ArrowUpRightFromSquare, MapPinned } from "lucide-react";
import type { FC, ReactNode } from "react";
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";

import { ButtonLink } from "@/components/controls/ButtonLink.tsx";
import QRCodeLink from "@/components/controls/QRCodeLink.tsx";
import { DrawerDescription, DrawerTitle } from "@/components/layout/Drawer.tsx";
import { Header } from "@/components/layout/Header.tsx";
import { ImageErrorFallback } from "@/components/routes/rallyists/ArtistsList.tsx";
import { QUERY_KEYS } from "@/lib/QueryKeys.ts";
import { useStandist } from "@/lib/hooks/useStandist.ts";
import { imagePrefix, images } from "@/lib/images.ts";
import { queryClient } from "@/lib/queryClient.ts";

import { ArtistImage } from "./ArtistImage";

export const ArtistPresentation: FC<{ artistId: string }> = ({ artistId }) => {
const artist = useStandist(artistId);
Expand All @@ -30,11 +36,23 @@ export const ArtistPresentation: FC<{ artistId: string }> = ({ artistId }) => {
<Header>{artist.name}</Header>

<div className={"flex flex-col items-center"}>
<img
src={images[`${imagePrefix}${artist.image}`]}
alt={artist.name}
className="w-48 rounded-full border-8 border-secondary"
/>
{/* au cas où les images plantes pour x raison */}
<ErrorBoundary
FallbackComponent={ImageErrorFallback}
onReset={() => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.ARTIST_IMAGE, artist.userId],
});
}}
>
<Suspense
fallback={
<div className="h-32 w-32 animate-pulse rounded-full border-8 border-secondary bg-gray-200" />
}
>
<ArtistImage userId={artist.userId} name={artist.name} />
</Suspense>
</ErrorBoundary>
</div>

{artist.geometry && (
Expand Down
51 changes: 40 additions & 11 deletions web/src/components/routes/rallyists/ArtistsList.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,40 @@
import clsx from "clsx";
import { TicketCheck } from "lucide-react";
import { useState } from "react";
import { Suspense, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useTranslation } from "react-i18next";

import { ArtistDrawer } from "@/components/artists/ArtistDrawer.tsx";
import { ArtistImage } from "@/components/artists/ArtistImage";
import { Header } from "@/components/layout/Header.tsx";
import { QUERY_KEYS } from "@/lib/QueryKeys.ts";
import { stampsToCollect } from "@/lib/consts.ts";
import { useStandists } from "@/lib/hooks/useStandists.ts";
import { imagePrefix, images } from "@/lib/images.ts";
import { StampWithId } from "@/lib/models/Stamp.ts";
import { queryClient } from "@/lib/queryClient";

type ArtistsListProps = {
stamps: StampWithId[];
};

export const ImageErrorFallback = ({
resetErrorBoundary,
}: {
error: Error;
resetErrorBoundary: () => void;
}) => {
return (
<div
onClick={resetErrorBoundary}
className="flex h-32 w-32 cursor-pointer items-center justify-center rounded-full border-8 border-secondary bg-gray-100"
>
<div className="text-center text-sm text-gray-500">
Cliquez pour réessayer
</div>
</div>
);
};

const ArtistsList = ({ stamps }: ArtistsListProps) => {
const { t } = useTranslation();
const { data: standistsList } = useStandists();
Expand Down Expand Up @@ -54,15 +75,23 @@ const ArtistsList = ({ stamps }: ArtistsListProps) => {
setActiveStandistId(doc.userId);
}}
>
{images[
`${imagePrefix}${doc.image}` as keyof typeof images
] && (
<img
src={images[`${imagePrefix}${doc.image}`]}
alt={doc.name}
className={"w-32 rounded-full border-8 border-secondary"}
/>
)}
{/* au cas où les images plantes pour x raison */}
<ErrorBoundary
FallbackComponent={ImageErrorFallback}
onReset={() => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.ARTIST_IMAGE, doc.userId],
});
}}
>
<Suspense
fallback={
<div className="h-32 w-32 animate-pulse rounded-full border-8 border-secondary bg-gray-200" />
}
>
<ArtistImage userId={doc.userId} name={doc.name} />
</Suspense>
</ErrorBoundary>

<div className="relative w-40 rounded-xl bg-secondary py-1 text-center">
<div>{doc.name}</div>
Expand Down
1 change: 1 addition & 0 deletions web/src/lib/QueryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export const QUERY_KEYS = {
WHEEL_ENTRIES: "WHEEL_ENTRIES",
SHOW_INTRO: "SHOW_INTRO",
STAFF_GEN_QRCODE: "STAFF_GEN_QRCODE",
ARTIST_IMAGE: "ARTIST_IMAGE",
};
18 changes: 16 additions & 2 deletions web/src/lib/appwrite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import {
Databases,
Functions,
ID,
Storage,
} from "appwrite";

import { appwriteEndpoint, appwriteProjectId } from "@/lib/consts.ts";
import {
appwriteEndpoint,
appwriteProjectId,
assetsBucketId,
} from "@/lib/consts.ts";

export const client = new Client();

Expand All @@ -18,7 +23,7 @@ export { ID, Query } from "appwrite";
export const account = new Account(client);
export const databases = new Databases(client);
export const functions = new Functions(client);

export const storage = new Storage(client);
export const getUserData = async () => {
try {
return account.get();
Expand Down Expand Up @@ -142,3 +147,12 @@ export const loginUserIdSecret = async (userId: string, secret: string) => {
throw new Error("Cannot login with magic link", { cause: appwriteError });
}
};

export const getAssetsFilesList = async () => {
try {
return storage.listFiles(assetsBucketId);
} catch (error) {
const appwriteError = error as AppwriteException;
throw new Error("Cannot get files list", { cause: appwriteError });
}
};
7 changes: 0 additions & 7 deletions web/src/lib/images.test.ts

This file was deleted.

27 changes: 22 additions & 5 deletions web/src/lib/images.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
export const images = import.meta.glob("/src/assets/avatars/*.jpg", {
eager: true,
import: "default",
}) as Record<string, string>;
import * as Sentry from "@sentry/react";

export const imagePrefix = "/src/assets/avatars/";
import { storage } from "./appwrite";
import { assetsBucketId } from "./consts";
import { getStandists } from "./hooks/useStandists";
import { Standist } from "./models/Standist";

export const getArtistImage = async (standistId: string) => {
try {
const standists = await getStandists();
const standist = standists.find(
(standist: Standist) => standist.userId === standistId,
);
if (!standist?.image || standist?.image == "fallback")
throw new Error("No image found for standist " + standistId);
return storage.getFileDownload(assetsBucketId, standist?.image);
} catch (error) {
console.error("Failed to load artist image:", error);
// ça devrait jamais arriver hein... n'est-ce pas ?
Sentry.captureException(error); // au cas où
return "https://www.shutterstock.com/image-vector/default-avatar-profile-icon-social-600nw-1906669723.jpg";
}
};
21 changes: 21 additions & 0 deletions web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,27 @@ export default defineConfig({
workbox: {
// https://vite-pwa-org.netlify.app/guide/static-assets#globpatterns
globPatterns: ["**/*.{woff2,js,css,html,jpg,svg,png}"],
runtimeCaching: [
{
urlPattern:
/^https:\/\/appwrite\.luc\.ovh\/v1\/storage\/buckets.+$/,
handler: "StaleWhileRevalidate",
options: {
cacheName: "rally-assets",
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24, // 24 heures?
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
},
devOptions: {
enabled: true,
type: "module",
},
}),
mkcert({ savePath: "./certs" }),
Expand Down
6 changes: 5 additions & 1 deletion web/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ vi.mock("appwrite", () => {
const Functions = vi.fn();
Functions.prototype.createExecution = vi.fn();

return { Client, Account, Functions, Databases };
const Storage = vi.fn();
Storage.prototype.getFileDownload = vi.fn();
Storage.prototype.listFiles = vi.fn();

return { Client, Account, Functions, Databases, Storage };
});

Object.defineProperty(window, "matchMedia", {
Expand Down

0 comments on commit a4c94f7

Please sign in to comment.