diff --git a/client/src/components/MvpTracker/MvpTracker.tsx b/client/src/components/MvpTracker/MvpTracker.tsx index e29bbe4..dda1ab4 100644 --- a/client/src/components/MvpTracker/MvpTracker.tsx +++ b/client/src/components/MvpTracker/MvpTracker.tsx @@ -1,6 +1,7 @@ import { useTheme } from "../../contexts/ThemeContext"; import { useMvps } from "../../hooks/useMvps"; -import { useState } from "react"; +import { useState, useEffect } from "react"; +import useImages from "../../hooks/useImages"; const MvpTracker = () => { const { @@ -219,6 +220,29 @@ const MvpCard = ({ const [selectedLocation, setSelectedLocation] = useState(0); const location = mvp.locations[selectedLocation]; const hasMultipleLocations = mvp.locations.length > 1; + const { getServerImage } = useImages(); + const [imageSrc, setImageSrc] = useState(null); + const externalImgUrl = `https://ratemyserver.net/mobs/${mvp.id}.gif`; + + useEffect(() => { + let mounted = true; + // Try server first, fall back to external URL + (async () => { + try { + const url = await getServerImage(`${mvp.id}.gif`); + if (!mounted) return; + if (url) setImageSrc(url); + else setImageSrc(externalImgUrl); + } catch (e) { + if (!mounted) return; + setImageSrc(externalImgUrl); + } + })(); + + return () => { + mounted = false; + }; + }, [mvp.id]); const getStatusIndicatorGradient = (status) => { if (status === "alive") { @@ -270,7 +294,7 @@ const MvpCard = ({ )} {mvp.name} (e.target.style.display = "none")} + onError={(e) => { + const target = e.target as HTMLImageElement; + target.style.display = "none"; + }} /> {currentTheme !== "cute" && ( diff --git a/client/src/hooks/useImages.ts b/client/src/hooks/useImages.ts new file mode 100644 index 0000000..bda7ef5 --- /dev/null +++ b/client/src/hooks/useImages.ts @@ -0,0 +1,19 @@ +export const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; + +export async function getServerImage(key: string): Promise { + try { + const res = await fetch(`${API_BASE}/images/${encodeURIComponent(key)}`); + if (res.ok) { + const data = await res.json(); + return data.url; + } + return null; + } catch (e) { + console.error('getServerImage error', e); + return null; + } +} + +export default function useImages() { + return { getServerImage }; +} diff --git a/server/app/routes/images.py b/server/app/routes/images.py new file mode 100644 index 0000000..7f31e2b --- /dev/null +++ b/server/app/routes/images.py @@ -0,0 +1,58 @@ +from firebase_admin import storage +from firebase_functions import https_fn +import json +import urllib.request + + +def get_image(req: https_fn.Request, headers: dict, image_name: str) -> https_fn.Response: + """Check if an image exists in storage and return its public URL.""" + try: + bucket = storage.bucket() + blob = bucket.blob(f"mobs/{image_name}") + if blob.exists(): + # Ensure it's public (may already be) + try: + blob.make_public() + except Exception: + pass + return https_fn.Response(json.dumps({"url": blob.public_url}), headers=headers) + else: + return https_fn.Response(json.dumps({"error": "Not found"}), status=404, headers=headers) + except Exception as e: + return https_fn.Response(json.dumps({"error": str(e)}), status=400, headers=headers) + + +def save_image(req: https_fn.Request, headers: dict) -> https_fn.Response: + """Fetch an external image and save it into Firebase Storage under mobs/. + + Expects JSON body: { "externalUrl": "https://...", "key": "123.gif" } + Returns { url: publicUrl } on success. + """ + try: + data = req.get_json() + external = data.get("externalUrl") + key = data.get("key") + if not external or not key: + return https_fn.Response(json.dumps({"error": "externalUrl and key required"}), status=400, headers=headers) + + # Fetch external image + try: + resp = urllib.request.urlopen(external, timeout=10) + content_type = resp.headers.get_content_type() + img_bytes = resp.read() + except Exception as e: + return https_fn.Response(json.dumps({"error": f"Failed to fetch external image: {e}"}), status=400, headers=headers) + + # Upload to storage + bucket = storage.bucket() + blob = bucket.blob(f"mobs/{key}") + blob.upload_from_string(img_bytes, content_type=content_type or "application/octet-stream") + try: + blob.make_public() + except Exception: + pass + + return https_fn.Response(json.dumps({"url": blob.public_url}), headers=headers) + + except Exception as e: + return https_fn.Response(json.dumps({"error": str(e)}), status=400, headers=headers) diff --git a/server/main.py b/server/main.py index bf74730..3695d3b 100644 --- a/server/main.py +++ b/server/main.py @@ -7,7 +7,7 @@ # Add the current directory to sys.path to allow importing 'app' module sys.path.append(os.path.dirname(os.path.abspath(__file__))) -from app.routes import mvp, user +from app.routes import mvp, user, images # Initialize Firebase Admin # Use GOOGLE_APPLICATION_CREDENTIALS env var or default credentials @@ -88,6 +88,17 @@ def api(req: https_fn.Request) -> https_fn.Response: if req.method == 'POST': return user.upload_avatar(req, headers, user_id) + # --- IMAGES --- + # GET /images/ -> check storage and return public url + if path.startswith('/images'): + # POST /images -> save external image into storage + if path == '/images' and req.method == 'POST': + return images.save_image(req, headers) + # GET /images/ + if path.startswith('/images/') and req.method == 'GET': + image_name = path.split('/')[-1] + return images.get_image(req, headers, image_name) + # /users/ if path.startswith('/users/'): user_id = path.split('/')[-1]