From 9176e32e6fcabcfb39c5f972c36dba3d587b449d Mon Sep 17 00:00:00 2001 From: dvirlabs Date: Fri, 8 May 2026 18:07:46 +0300 Subject: [PATCH] Fix image not load correcttlly --- IMAGE_UPLOAD_FIX.md | 386 ++++++++++++++++++ apply-migration.bat | 29 ++ apply-migration.sh | 29 ++ backend/Dockerfile | 1 - .../app/__pycache__/config.cpython-314.pyc | Bin 1919 -> 1994 bytes backend/app/__pycache__/main.cpython-314.pyc | Bin 4935 -> 5523 bytes backend/app/__pycache__/utils.cpython-314.pyc | Bin 2071 -> 2239 bytes backend/app/config.py | 1 + .../models/__pycache__/user.cpython-314.pyc | Bin 2008 -> 2008 bytes .../routers/__pycache__/auth.cpython-314.pyc | Bin 11391 -> 11391 bytes .../__pycache__/categories.cpython-314.pyc | Bin 5032 -> 6440 bytes .../__pycache__/products.cpython-314.pyc | Bin 12582 -> 12735 bytes .../routers/__pycache__/users.cpython-314.pyc | Bin 3235 -> 3235 bytes backend/app/routers/categories.py | 26 +- backend/app/routers/products.py | 5 +- .../schemas/__pycache__/user.cpython-314.pyc | Bin 4274 -> 4274 bytes .../services/__pycache__/auth.cpython-314.pyc | Bin 5836 -> 6570 bytes backend/app/utils.py | 13 +- frontend/Dockerfile | 1 + frontend/src/components/CategoryCard.jsx | 12 +- frontend/src/components/ProductCard.jsx | 14 +- frontend/src/pages/Admin.jsx | 22 +- 22 files changed, 509 insertions(+), 30 deletions(-) create mode 100644 IMAGE_UPLOAD_FIX.md create mode 100644 apply-migration.bat create mode 100644 apply-migration.sh diff --git a/IMAGE_UPLOAD_FIX.md b/IMAGE_UPLOAD_FIX.md new file mode 100644 index 0000000..722d84d --- /dev/null +++ b/IMAGE_UPLOAD_FIX.md @@ -0,0 +1,386 @@ +# Image Upload & Loading Fix - Brand Master + +## Problem Summary + +Images were not loading correctly in the Brand Master application due to: + +1. **URL Resolution Issue**: Images stored with relative paths (`/uploads/products/...`) were resolving to the frontend domain instead of the backend +2. **Missing Category Upload Endpoint**: No dedicated endpoint for category image uploads +3. **Ingress Routing**: No route from frontend domain to backend for serving static uploads +4. **No Fallback Handling**: Broken images displayed when URLs were invalid + +## Solutions Implemented + +### 1. Backend Changes + +#### a. Updated Settings Configuration +**File**: `backend/app/config.py` +- Added `backend_url` setting to store the backend URL +- This is set via `BACKEND_URL` environment variable in Kubernetes + +```python +backend_url: str = "http://localhost:8000" +``` + +#### b. Enhanced Upload Utility +**File**: `backend/app/utils.py` +- Modified `save_upload_file()` to accept `backend_url` parameter +- Now returns full URLs (e.g., `https://api-brand-master.dvirlabs.com/uploads/products/...`) instead of relative paths +- Maintains backward compatibility with relative paths if `backend_url` not provided + +```python +def save_upload_file(upload_file, folder: str = "products", backend_url: str = None) -> str: + # ... saves file ... + relative_path = f"/uploads/{folder}/{unique_filename}" + if backend_url: + backend_url = backend_url.rstrip('/') + return f"{backend_url}{relative_path}" + return relative_path +``` + +#### c. Updated Product Upload Endpoints +**File**: `backend/app/routers/products.py` +- Updated `/api/products/upload-image` to use `backend_url` from settings +- Updated `/api/products/upload-images` to use `backend_url` from settings +- Images now return full URLs immediately + +#### d. Added Category Upload Endpoint +**File**: `backend/app/routers/categories.py` +- Added new endpoint: `POST /api/categories/upload-image` +- Validates image file types (jpeg, png, jpg, webp, gif) +- Returns full URL with backend domain +- Saves to `uploads/categories/` folder + +### 2. Frontend Changes + +#### a. Updated Admin Panel Image Upload +**File**: `frontend/src/pages/Admin.jsx` + +**Product Images**: +- Removed URL building logic (backend returns full URLs) +- Simplified `handleImageUpload()` function +- Backend now handles URL generation + +**Category Images**: +- Changed from `/products/upload-image` to `/categories/upload-image` +- Removed URL building logic +- Directly uses backend-returned full URLs + +#### b. Added Fallback Image Handling +**File**: `frontend/src/components/ProductCard.jsx` +- Added error state tracking with `useState` +- Handles image load failures with `onError` handler +- Falls back to placeholder image if product image fails to load +- Fallback: `https://via.placeholder.com/400x400?text={product-name}` + +**File**: `frontend/src/components/CategoryCard.jsx` +- Added error state tracking with `useState` +- Handles image load failures with `onError` handler +- Falls back to placeholder image if category image fails to load +- Fallback: `https://via.placeholder.com/300x300?text={category-name}` + +### 3. Kubernetes/Helm Changes + +#### a. Updated Frontend Ingress +**File**: `charts/brand-master-chart/templates/frontend-ingress.yaml` +- Added `/uploads` path routing to backend service +- This provides backward compatibility for any existing relative URLs +- Routes `/uploads/*` requests from frontend domain to backend service +- Ensures images work even if old relative URLs exist in database + +**Path Priority**: +1. `/uploads` → Backend Service (for images) +2. `/` → Frontend Service (for everything else) + +#### b. Persistence Configuration +**Already Configured** in `manifests/brand-master/values.yaml`: +- PersistentVolumeClaim enabled for backend +- Storage: 15Gi on NFS storage class +- Mount path: `/app/uploads` +- Ensures images persist across pod restarts + +## Deployment Instructions + +### 1. Backend Deployment + +```bash +cd ~/OneDrive/Desktop/gitea/brand-master + +# Build new backend image +docker build -t harbor.dvirlabs.com/my-apps/brand-master-backend:latest -f backend/Dockerfile backend/ + +# Push to Harbor +docker push harbor.dvirlabs.com/my-apps/brand-master-backend:latest +``` + +### 2. Frontend Deployment + +```bash +cd ~/OneDrive/Desktop/gitea/brand-master + +# Build new frontend image +docker build -t harbor.dvirlabs.com/my-apps/brand-master-frontend:latest -f frontend/Dockerfile frontend/ + +# Push to Harbor +docker push harbor.dvirlabs.com/my-apps/brand-master-frontend:latest +``` + +### 3. Update Helm Deployment + +```bash +cd ~/OneDrive/Desktop/gitea/my-apps + +# Update image tags in values.yaml if using specific tags +# Or use :latest for auto-pull + +# Upgrade Helm release +helm upgrade brand-master charts/brand-master-chart \ + -f manifests/brand-master/values.yaml \ + -n my-apps +``` + +### 4. Verify Deployment + +```bash +# Check pods are running +kubectl get pods -n my-apps -l app.kubernetes.io/name=brand-master + +# Check backend logs +kubectl logs -n my-apps -l app.kubernetes.io/component=backend --tail=50 + +# Check frontend logs +kubectl logs -n my-apps -l app.kubernetes.io/component=frontend --tail=50 + +# Verify PVC is mounted +kubectl describe pod -n my-apps -l app.kubernetes.io/component=backend | grep uploads +``` + +## Testing Instructions + +### 1. Test Category Image Upload + +1. Login as admin: https://brand-master.dvirlabs.com/admin +2. Go to "Categories" tab +3. Create or edit a category +4. Upload an image +5. **Expected**: Image preview shows immediately +6. Save category +7. **Expected**: Image displays on home page category cards +8. Refresh page +9. **Expected**: Image still loads correctly + +### 2. Test Product Image Upload + +1. Login as admin +2. Go to "Products" tab +3. Create or edit a product +4. Upload one or more images +5. **Expected**: Image previews show immediately in upload section +6. Save product +7. **Expected**: Product displays correctly in products list +8. View product detail page +9. **Expected**: All images load and gallery works +10. Refresh page +11. **Expected**: All images still load correctly + +### 3. Test Image Persistence + +1. Upload some product/category images +2. Restart backend pod: + ```bash + kubectl rollout restart deployment -n my-apps brand-master-backend + ``` +3. Wait for pod to be ready +4. **Expected**: All previously uploaded images still load correctly + +### 4. Test Fallback Handling + +1. Manually break an image URL in the database (or edit in admin with invalid URL) +2. View product/category with broken image +3. **Expected**: Placeholder image displays instead of broken image icon +4. No console errors about failed image loads + +### 5. Verify URLs in Database + +```bash +# Connect to database pod +kubectl exec -it -n my-apps brand-master-db-0 -- psql -U brand_master_user -d brand_master_db + +# Check product image URLs +SELECT id, name, images FROM product LIMIT 5; + +# Check category image URLs +SELECT id, name, image FROM category; +``` + +**Expected URL Format**: +- New images: `https://api-brand-master.dvirlabs.com/uploads/products/...` +- Old images (if any): `/uploads/products/...` (will still work via ingress routing) + +### 6. Test Direct Image Access + +1. Upload an image and note the URL returned +2. Copy the full URL +3. Open in new browser tab +4. **Expected**: Image loads directly +5. Test both domains: + - `https://api-brand-master.dvirlabs.com/uploads/...` (backend) + - `https://brand-master.dvirlabs.com/uploads/...` (frontend → routed to backend) + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Ingress (Traefik) │ +└─────────────────────────────────────────────────────────────┘ + │ │ + │ api-brand-master.dvirlabs.com │ brand-master.dvirlabs.com + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Backend Svc │ │ Frontend Svc │ +│ (Port 8000) │ │ (Port 80) │ +└─────────────────┘ └─────────────────┘ + │ │ + │ ├── /uploads → Backend Svc + │ └── /* → Frontend Pod + ▼ +┌─────────────────┐ +│ Backend Pod │ +│ - FastAPI │ +│ - Static Files │ +│ at /uploads │ +└─────────────────┘ + │ + ▼ +┌─────────────────┐ +│ PVC (15Gi) │ +│ /app/uploads │ +└─────────────────┘ +``` + +## Image Flow + +### Upload Flow: +1. Admin uploads image via frontend form +2. Frontend sends file to `/api/products/upload-image` or `/api/categories/upload-image` +3. Backend saves file to `uploads/{folder}/{uuid}{ext}` on PVC +4. Backend returns full URL: `https://api-brand-master.dvirlabs.com/uploads/{folder}/{filename}` +5. Frontend stores full URL in database + +### Display Flow (New Images): +1. Frontend renders `` +2. Browser requests image from backend domain +3. Ingress routes to Backend Service +4. FastAPI serves file via StaticFiles mount +5. Image displays + +### Display Flow (Old Relative URLs - Backup): +1. Frontend renders `` +2. Browser requests from frontend domain +3. Ingress routes `/uploads/*` to Backend Service +4. FastAPI serves file +5. Image displays + +## Troubleshooting + +### Issue: Images still not loading + +**Check 1**: Verify backend URL is set correctly +```bash +kubectl get pod -n my-apps -l app.kubernetes.io/component=backend -o jsonpath='{.items[0].spec.containers[0].env[?(@.name=="BACKEND_URL")].value}' +``` +Should return: `https://api-brand-master.dvirlabs.com` + +**Check 2**: Verify StaticFiles is mounted +```bash +kubectl exec -n my-apps -l app.kubernetes.io/component=backend -- ls -la /app/uploads +``` + +**Check 3**: Verify ingress routes +```bash +kubectl get ingress -n my-apps brand-master-frontend -o yaml +``` +Should show `/uploads` path routing to backend service. + +**Check 4**: Test direct backend access +```bash +curl -I https://api-brand-master.dvirlabs.com/uploads/products/some-file.jpg +``` + +### Issue: Images disappear after pod restart + +**Check**: PVC is properly mounted +```bash +kubectl get pvc -n my-apps +kubectl describe pvc -n my-apps brand-master-uploads-pvc +``` + +Verify: +- STATUS: Bound +- STORAGECLASS: nfs-client +- CAPACITY: 15Gi + +### Issue: Upload fails with 500 error + +**Check backend logs**: +```bash +kubectl logs -n my-apps -l app.kubernetes.io/component=backend --tail=100 +``` + +Common causes: +- Permissions issue on /app/uploads +- PVC not mounted +- Disk space full + +## Summary of Files Changed + +### Backend (Code Repo) +- ✅ `backend/app/config.py` - Added backend_url setting +- ✅ `backend/app/utils.py` - Updated save_upload_file to return full URLs +- ✅ `backend/app/routers/products.py` - Updated to use backend_url +- ✅ `backend/app/routers/categories.py` - Added upload endpoint + +### Frontend (Code Repo) +- ✅ `frontend/src/pages/Admin.jsx` - Removed URL building, use backend URLs +- ✅ `frontend/src/components/ProductCard.jsx` - Added fallback handling +- ✅ `frontend/src/components/CategoryCard.jsx` - Added fallback handling + +### Kubernetes (GitOps Repo) +- ✅ `charts/brand-master-chart/templates/frontend-ingress.yaml` - Added /uploads routing + +## Next Steps + +1. Deploy backend changes +2. Deploy frontend changes +3. Update Helm deployment with new ingress config +4. Test all image upload scenarios +5. Verify images persist across restarts +6. Monitor logs for any errors + +## Rollback Plan + +If issues occur after deployment: + +```bash +# Rollback Helm release +helm rollback brand-master -n my-apps + +# Or rollback individual components +kubectl rollout undo deployment/brand-master-backend -n my-apps +kubectl rollout undo deployment/brand-master-frontend -n my-apps +``` + +## Environment Variables Reference + +Required in backend deployment: + +```yaml +env: + BACKEND_URL: "https://api-brand-master.dvirlabs.com" # Must be set! + FRONTEND_URL: "https://brand-master.dvirlabs.com" + PYTHONUNBUFFERED: "1" + # ... other env vars ... +``` + +Already configured in `manifests/brand-master/values.yaml`. diff --git a/apply-migration.bat b/apply-migration.bat new file mode 100644 index 0000000..9a59df6 --- /dev/null +++ b/apply-migration.bat @@ -0,0 +1,29 @@ +@echo off +REM Apply database migration 005 - Add password reset fields + +echo Getting database pod name... +for /f "delims=" %%i in ('kubectl get pod -n my-apps -l app.kubernetes.io/component=db -o jsonpath^="{.items[0].metadata.name}"') do set DB_POD=%%i + +if "%DB_POD%"=="" ( + echo ❌ Database pod not found + exit /b 1 +) + +echo 📦 Database pod: %DB_POD% +echo 📝 Applying migration 005_add_password_reset_fields.sql... +echo. + +REM Copy migration file to pod +kubectl cp backend/migrations/005_add_password_reset_fields.sql my-apps/%DB_POD%:/tmp/migration.sql + +REM Execute migration +kubectl exec -n my-apps %DB_POD% -- psql -U brand_master_user -d brand_master_db -f /tmp/migration.sql + +echo. +echo ✅ Migration applied successfully! +echo. +echo 🔄 Restarting backend pod to pick up changes... +kubectl delete pod -n my-apps -l app.kubernetes.io/component=backend + +echo. +echo ✅ Done! Backend will restart with updated schema. diff --git a/apply-migration.sh b/apply-migration.sh new file mode 100644 index 0000000..6ec289c --- /dev/null +++ b/apply-migration.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Apply database migration 005 - Add password reset fields + +# Get the database pod name +DB_POD=$(kubectl get pod -n my-apps -l app.kubernetes.io/component=db -o jsonpath='{.items[0].metadata.name}') + +if [ -z "$DB_POD" ]; then + echo "❌ Database pod not found" + exit 1 +fi + +echo "📦 Database pod: $DB_POD" +echo "📝 Applying migration 005_add_password_reset_fields.sql..." +echo "" + +# Copy migration file to pod +kubectl cp backend/migrations/005_add_password_reset_fields.sql my-apps/$DB_POD:/tmp/migration.sql + +# Execute migration +kubectl exec -n my-apps $DB_POD -- psql -U brand_master_user -d brand_master_db -f /tmp/migration.sql + +echo "" +echo "✅ Migration applied successfully!" +echo "" +echo "🔄 Restarting backend pod to pick up changes..." +kubectl delete pod -n my-apps -l app.kubernetes.io/component=backend + +echo "" +echo "✅ Done! Backend will restart with updated schema." diff --git a/backend/Dockerfile b/backend/Dockerfile index 4f349b1..26a4ee4 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -28,4 +28,3 @@ ENV PYTHONUNBUFFERED=1 # Run the application CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] - diff --git a/backend/app/__pycache__/config.cpython-314.pyc b/backend/app/__pycache__/config.cpython-314.pyc index f6d7598ef062fb23a2ee22952e4d5f9749cca3cb..58a498018a9c5d252ee73dda7de4a862f12a7c17 100644 GIT binary patch delta 543 zcmXv}O>5L(5T188iTjn0?&eLhwUw53rBS-OwI1qL)FOyb@DQOOMM5@hX>mVF_7$Oc zDC$A+N62vHV@aoYT&gF*H0%K t_(5AN`10&?NI0HpYh}9p*MoyM&nY%1Zft%g_zFECcFR^}c zH8rlds@>vh1{A=KnqbF%hC~y9n^=Mh+Hn)=ikq}mH?;(A+Sa!fIaWZ5_dpR%SGLX*ljPKTHmaWh&YhsEzr4pJnCzbJrcBJ12Nk|(W8}?Ix z=%>N3JXZ8G0(8i>dTi7;1mqx@@mP=FCg4W^mftR*19a14ZGNYK{C_A2=nC3~U%ELs z!S!)|*g`4es60BF5IG?-3g_6u8*XIBP!6`8BXh(52f_`vt4xL6k8;_l_ z0$#l4=?rJz&OxT;(W!Yd9YY_m?L>Sv9S|bmdZ-?gaUm}oMmbL48;ow$%Z9@|$nZ&W zfT&?3;A*G=;&~p|LiAzIw;~|JbN~@t57e0%pj527b*3STnqzp7bR2Uw_~>Kn+nDW2 zlg`eu>2?%k&xz@dI_e?|_r$d-sXU3QbBGp^d@|l;eC+sY%*IJ_yV3TuWTC!M?gJYcM}?L zCZ&pq8Girti@(-C6veb;7`HU2h&jo~uE;X8WS z#*306E=q;Mf+XMU?d_diqATUa_yx4+{L|6hhTe4DUKS*!m@j4Z=V?sm#BEvC%XzJ2 z(mKqS$|h4IG#RS`{g%n7C0SEclf9vq45@$tifS90YE+ArLLpm{imFLUiefUdRxIYr zCI_`zAto&sG(+un!jk}?cDKJBS_5dhEdH$B9*kKALN%ONilT_d+zZiRtheE1tl}A5 z0S8p_fa*M;`hRn_z8_w>bcnx)pWLq{4jnE}^p|M=_tU!rGg|{Q+tJjWrpJEJ+P3cx z@A}Vf`OoeLqPv0QRv@`2CeSHNmqmOkX3>LE9&@KhJM z8KrUXW*2Q)dPqx8oR}uae}##-^;%RYtKxY2%2NO2n&^3zczpyH0mrOu6mKS_XtJ?H zCoJ(TfHT`qtvvZ?G jO{o;rixA=n>n~+=qG0n^Oc&AeI76&$Hor?yRx$qpgR@A! delta 799 zcmY*XO-vI(7@g_<(4VFJv^2P|{n@Iu#l)}_5v^d9)*xz{hLlT_vh1#Q!?xSZmOzLH z?w-ut^lsu!gC4y>Jb7q{A#5Pgt6nt5n=#I`LF#Pgn>TOXdtY`kc;dsw;6$+B56IA^ z-=-qG3Cg_Zj|VIKC*N}b#z7e-04{o?xY8HEQeSAW>`8#iFm=#T6mgLeua(%&$Ib9v zy2+NAqSVE`wajK=cOL^p5qnk?gE3G^oN7uu>2-Ouhv@sSSKK=?=2e)pkOxwa)UmMf2+Hi&;&Oa z379Yrh>CU*V&IfZ3wX}2;I{uO^DrI8?*m(<0adS?jq0Nf%c?fiy5R=9L{oK~O&jSh zqw2cLYj(YEwp^}>%tp)g<+n7W*)r{h>qWL@t*P3&%W0PF7-=5A7DTTL4Ts(`{wA!+ zSpv?H=|su3?tl|kIbqMAuv1@rBTu6{x4(Nvu-!kMJPMB=VGxIqT_~K4_Aq)0cn9qPcZk*yo2WJEM6tg!U&CATb z#F5i|nIp`8ilI0&oX_p949vot(cKp=PL7t5(%&hPIBD2)~L~ha#}ByE#;YFpEDA wFYz=8E#b*fDA~z4vwL%OTi>vZIb;zHeUDBOKMuX*#$f2j_1#eT31q4IFVmHjW{!faJkIpIMnflck6i z$hpOqmYwf5>U2*;X8vUqvS^ru}1j=h&m$-rmjRp zgGGyYfy$CW*1!NekOAbgeEu}~6|)92a~ShvQI=*#mdP_&w3S(S7>t+-Si?rke*!1s>p3=%`D51$*9Rur8RjWt2%RNkk8~Ztj3JIlh^{}Zm|^= zmlS0dXmZ}-E-lE(PfUqV%gjkF5(gSsBr!RW%~|3WZ&7MaVo7FMYJ5RrNk%azP)bcr zO=0qCHd!tsAV&a*i*HT7#5Sv5Twwv*jEV(?E95U|SY8mZYVf_!F7!Z5@w%ACMKO&P z!k5K#JGgHM$xe`+Q9C350>9n`4!s-P5*Jt`z6&#O$zSJ``&G;XH0}_$ma`h`VKr`N zGfqvWTO0*N`6;EzCB=T4>_tLA`62}np#mgsv4b3ylbKWm415lTB2kcp(d5(YQjBJk NpRud(!r~DeV*sk8ey#uj delta 405 zcmdllI9-5Gn~#@^0SJCC`;uwTvXM`ld19|UZxJg{=oVXAeojhi(d2o|+E!9Pfd+=} z3<8WsA3?+#9}|7K{SzL;9p7Mt|TM=**r@ zQry}N0nN?M%+7xEv$MLk|JqdSE3s&Z!0#vbADF+0{wS92{8S|5DtV3QLV;)k6Eu;D zn#3ecX0oO*C5t+-;M4rf&v~g3(1I+;dAShM!Ys^rr4Z4gEULv=jIVu#xRziEEy+Io+V4+`2vvigeg?&U1>EXPfM~(tC{IP-J!0Zv1-%a&s zF<49<*t;XsW8R*kxQ`UWhXq$RE$H!nq9^)EEm0E=Z*5?i;y~wrI!+dok8#xlu6iGG zH3$%&-2vip0P&HYI7A_3-x0FMhGF|jZ`00Ke+&@5J!KyfvmF(7pV`^vaeC5=|BKmh zcY3;MQBOla0}ll>@`!-;di#nBR4Rk3qj=zdIUNM2Lk~F})rUt(F8c{?$SHPF9PM#J zmzuRksq(y8GaMPIeN}u*%?VDzD!pzjw77O*5fmT`SO#Ney=vJH#2*L&`(5cxWf+{r zZJ+$Z$p~Sf0Hkt#4d8e zTL$d~v%)>(L`O2~j0QmqP6y$`osMv)6p!X4WxuO{r(ge42?)M@D2>>^^$q);L^fmJ z_3cS>6p>vh+H?}My>`?;0~lWQ&!0gI^bp?y#8FC>6|*%8JPxKQ$JqVJ+p9ofGM_-42dtIMR5T8jvk7Wo>!_ zyk+e7gH-@K7Fv3lgT?rq_;%J>%?j+{nWNJPRiRZRNx$Z17uN@oS>E z$QzH1%JEffb#t9C#GXURau#>P~)+vDzro$8$f_1El3av6p%A*;~|_g)<| zS4veQZ$FUFbud0h#*60GPNY7N{NI*C&z1`0D)|TL5ndKP<@8P{wJG89FX8y73NjQ= zE^p0rU3`{Ff`9U%-c{*LtA7LLM zx}?jq*cuQ0=0tChMaYz_D@Qw9$d-f*nfH6vb42&e2fWoRe>}aF`_=tNw}3PyJuoNf zK|M4fjuMs3W>Xn5StvM19dKL^;GX-oT z)$J*8W3|dHx8+B&X*#d^eCx9a_H_STi0a&ybi4MdGKKEwXXfm@=cO;8Z z7<%VXhcn8Fx@qWCxD(3>BD95Oi7h#TVPWgQwNke(R^QfpBSHL3gb%*Ws8UeJF(_mS2;eCcLvt{a%UzbT=? zzbzc9-#aCq?iH>p`%aDSC)dXWTgU2*zK&1N1Rh0>LhWo$DvjJgo&RvIv>EdD_8 zQRAdgYN5}PmG{!@K7n{!Xa^q?C&{f0`nm~?mM^u~5>28!i1HRQ!6mkUdj>SgqFLqb z;P_~x1(Vc~;3ek&1n^hbk(&+Z3w+}oso7!_hd;%NQk-wRzVvD2vd<&sPh{Y$0KQc- zPEZqM(B;Is1QgOM2zSW~e3j+R delta 1735 zcma)5L2nyH6rS0&ch_Ef*LG+eCk<{A>UPr*(>A3oL`7A5SW5`n1;hoDuIweXw2sZ} zSc(tGxuPBl%oT|PmmawF7jOU}R8d7e04qh|%B?~OA;E$7#<86SQM%H8GxO%XZ@%~D z&AoH?PFZKHY?{D#>GeNa-=;pbW(La&Av@%K;?Wv$DW|T&6<6hI8Er}};U;-f&efXc z>RgxeM9pwhJSFGJTG}1KFFuC-d$wYcTl+;($3CvAEy?-qEWOzL!jc!rl+rC$0X zoJFMl+B&raH4QycZ{YqnT%q1P#-+o>&&d-?VHU_hy!eQ~DKS998TpOF~mW5?`u!s3xk3 z?IhkvR(zGXdls81R{bz^C@aD|!V9Ue5}K{efNwN>9&C14D~MPbj2RIom*|}MBzfz# zX>>9V9QqrrretuGSZ!@_ro%3f&cGl^7bPjfqa_qXRRgB`Z)pbA=FyrKzW)5x1)QRk z*ceeqDl99N6GK^vs5+#FN0swk(I=Va~d1`D8a zqw@9HD-c!^&GaS|??L+ZyHXYA8r!2ZH+tPB#7LP8d(}y>MHrZc@Jvcii#z6JQ2LGe z10B48VJ8tP2q^q*hsN1pyAk%9!A5T@3b>PH!{Vb0zG1eEa0VfXfW0ka89vK6+2TPv ziggI}IcMx;+{Qk23?{E`9SvA%UFN`6M9Eq?D9SLwNl~tY+bMC&`kvxVT^-ltPzlsw z37#qYb@81I_aq(~rTh7uO3gzR5as;p;HjC$S~Ca(s6VCzr5`uqK^|a@5&)HPC)&e+ zNj^2$eii+EfF1HULvn??+aGU#ZalIVeze7d{EaJfu=R#BAL}3Z5%;?-hRw~8Sk^nX zq7OqqY}^jo{nZX@%X^PC-tV@yHkl!Qo}MeFz&TF)-R^3;0}>*Jub7#_EY6y;tFySa z#7o8Ez(!wE9HSUXo=001l2j AL;wH) diff --git a/backend/app/routers/__pycache__/products.cpython-314.pyc b/backend/app/routers/__pycache__/products.cpython-314.pyc index 09e390cd039df738fc32f35740579b1b067a415b..a852b3760f16af9a6cb8d9ac564e9a90ce804e72 100644 GIT binary patch delta 2856 zcma)8du&r>6u)0@Z`Z9`*}LnuKKAVHo~)ZI9mr!39wSb?Oaxgs`ncOFr7hoWVIs*C zqej6*_((LGs02mv-~Oe<$G}AYz(f)v$xTf7NB=Y`F_;*m=X_mP7!0_}&OP^h=XYM; zch1)fp|8W%4r@t~0MEPs{2T3YoVM1G!PA}}7N!KHc!y_uTdK_^1W1FRlqlA2qO9>5 zP+1UCazNhobe6x9Dy4yvfW5OeFH_2L7^VE~zbRp=GGJ&B0_FWAtKVl)Dr|yMY4Z!Q z(irJqvXbgp#aY$8&e(^<(OV5hY__{?bgp8fCSX^}*jt9`{yw7E0a&{N*ag5qKA#*1 znP!2W-a0_luR!$xDwxyFzm=RjPnQEw&K0O$K&g4{9H-QNO+PPfs;*iCkmEzxpSTw-3|+y2?!>Y%f_Us zK^mT;Pk?+I`>kAqEJ7928+jH*OIc1wBiSSjj~4#~`>&#j_%ltFC&|eDq7>Emhbut1cIqJ_9Se*`HMu0{gUX(t?5URA?`|TJ5Y|wTMAxwDmv`{kE6LBtHPg zc<_*#-Re@bvI&ST%|xk|qBX!n@i3TdSo?6XFcj3(X)TtXrP%m-K{%0)r&O9NBR>?Z z%w8jfVEF*+wx5A87wvN%9wsKKTXRKBCnG_?@}$waY@}YctF~(O6w+*=)(-|hubtWa z6e0}N4lgPpFJ)^OLBX=k>?Y$n-{kCxwV`LW*-k%v*qix_m}y zIXZg4K|R4n>(N%ae#W9;AiI%FuwU!_HTfOJWVP%Lw^ASTIULe+AbN@Ib9e!K%5h*F z2BI5hqA`{7(ims~T6&oOH(9e&1{32>J85Tg&i9HY&4A<2nX1ZqrND=N;Yo^$d5@?R zDn&hpCc!OC7BiQtfvjO$T<>@$K!NwjMT2gdNyH*5)h(f5_@Ek(Os8o~7lF_yyX87Y z%q;G9Hc=Nsl0fvr(fP2NOhprM-r=w>FRvRw%$ zHl}LXI*GIxojp>qK4M22+o4$J8tvq7cB#=xl1y(rb9c#%isTbnvZ-SY6@)^;DJbR% zDBENi`>d(QUk1+jvq#L`2*vP=U@|Wk?vPu(Qjc+|*yYWmQ0%?UGWms_Xg+9igBcoU zx0*d9%IaD=%-~Lt3#rD&Tk7^NSC^(yk55hUf)SPPlSUVC zf!F^ia`QkmJP|nX@PXs^MN56r%OEPMe@+uzKCusfV?Q& z7DO`e2a>Zs{-XZOdCxqV{WD zp38PxZoU`cEL~bX398?)qw4jSKn5s>~rOaoMZsgy5B4sxKH1$G^iZ9T!@ckY8s`E8|-^uGTJG%zR zLQyc8><%a5GtpVzYPyLg(l9)A5s>`OBrQK^b?Q~!3cZQ&2DvX5`Pw48E?<;#E8oO1E-=T A5&!@I delta 2797 zcma)7eQZ-z6o0p`uXG>nx)0WN{n$shwQt=A+h7bCn5`QqQGqukU{*@kzCpuV*WK5O z63s@`7=s$&5;1BtDls9^KTQ8NFi12?OfV8n-ar0fV&XrD7-LKf@tpg%RSE;T?DX{9 z^E>yP-#z!-i>Xhiol&QwQh?7i`6u(O|FqLXdQXSHTZofZL8(?8y+m7xjR9nVH6P%hLq7V$5bjaue00VhZZrZX@;a{ zPN=#eYHC(?QpB(+=b&vX;+omJ&KB+~_O`HDX{KO|(B>8|eiV3(WI{K=myW&NMlmwQjJep!BDMWwxcE%%E;79uN0z7$ zJtk1>K*3Ae2AXW6yU?)e%lD+sXxj-ww}I${PfH8m%CPBblBSN*C&0dg+3Iyj{zQFZ zJ5RoHxwu3oZRT!yxU?skyCGy7hL9->+v{#9?rWfA{9fsb^eON%ljQk{Jiq1Ss*b3u z1k?SnqOi(F3K?GVO~o)nUK2+fQZhZ8$pX9eXh&jc9fk0Dqs7p~bnO758 z_4rDYP`tDWw!?eIisY~b*W;|(U%`5t>i6?h5#lxxRGo%>0b}}!&R7P z=bE-ah2J;LY~F(rEYyo&33bWjK7|JCOkRxHCAiMJgbuN+FCeM#TW4?ikS*YTbT!!$ z5SU+W=?5}@lEwWd^D|XA7N~+^lHK-i@st_@Yu3$%=!8Ue2Le(COp9zjFaU|14;&uG zL=5XpMpJqAP~SR?^q`Y}os9%#AD%&K`Jn<;5GvsRB|)(MSfDyNR7W z-P}s5*zx9fnkK=A-=_=2u+7YAX_Xpcfok+98)`X0Z0w7cpx3CFcrK;p@|n3TzwN;F zF~l8Uw_2iPhy`1lDvyKV6#VNSu(8&|;`N4Y?DN*09agd=)s1?DB{vxZ-BUG2#HEmT zbVOVlkwCvp#4#)Lw&l$$3XQ3$x>>wPt8tTuV&D^YwXN%J1{@H8Ghi@RC`gj5FLcJL zNWjA{p=b@XN`(*<6#G6=shD!GKzko?uqWHyAl3F};OUk23E=5!yG(v$f3+X8@xe38 zW;(*;D0{ObYR|z2@;W-tmO6Y3I;P8~Ax<&bPM=2u9y`s0FdT)PHkVAR=>!n0(-(1p z^H@ObSrED%1Wq&jm=mqh1SMt*T0WE0)I?@Bc?2jr37#J# zH=XCAQL^L;jzug>y^qAA^q*qW%fI2uWisnT@eBgdYGmn4%-<(G#|M$gXW={N+3TZMZVwrQHorgi>o znO9<^+)A90%d4+}>qYjjd@TlNDA1QNzMvDVb>^gP(`!PTR1|8I(fke`L!KMCyZzJcP^zMF*4=l=jS CRv6O& diff --git a/backend/app/routers/__pycache__/users.cpython-314.pyc b/backend/app/routers/__pycache__/users.cpython-314.pyc index 72ea1dd3c30be5104b99087672965f02cfe592f3..b0fd3b19c1fda8f2d220618de291fbe15b9fddec 100644 GIT binary patch delta 20 acmZ21xmc20n~#@^0SKn${N2bsg9iXGm<2Nc delta 20 acmZ21xmc20n~#@^0SLCq{@KVqg9iXFnFRv? diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index c9e92d8..7e09560 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -1,10 +1,12 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from sqlalchemy.orm import Session from typing import List from app.database.database import get_db from app.models import Category, User from app.schemas.category import CategoryCreate, CategoryResponse, CategoryUpdate from app.services.auth import get_current_admin_user +from app.utils import save_upload_file +from app.config import settings router = APIRouter(prefix="/api/categories", tags=["categories"]) @@ -69,3 +71,25 @@ def delete_category( db.delete(category) db.commit() return {"message": "Category deleted successfully"} + + +@router.post("/upload-image") +async def upload_category_image( + file: UploadFile = File(...), + admin: User = Depends(get_current_admin_user), +): + """Upload a category image and return the URL""" + # Validate file type + allowed_types = ["image/jpeg", "image/png", "image/jpg", "image/webp", "image/gif"] + if file.content_type not in allowed_types: + raise HTTPException( + status_code=400, + detail=f"File type {file.content_type} not allowed. Allowed types: {', '.join(allowed_types)}" + ) + + try: + # Save file and get full URL + file_path = save_upload_file(file, folder="categories", backend_url=settings.backend_url) + return {"url": file_path, "message": "Image uploaded successfully"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error uploading file: {str(e)}") diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index b5ef5a4..f314829 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -20,6 +20,7 @@ from app.services.product import ( ) from app.services.auth import get_current_admin_user from app.utils import save_upload_file, generate_slug +from app.config import settings router = APIRouter(prefix="/api/products", tags=["products"]) @@ -152,7 +153,7 @@ async def upload_product_image( try: # Save file and get path - file_path = save_upload_file(file, folder="products") + file_path = save_upload_file(file, folder="products", backend_url=settings.backend_url) # Return full URL return {"url": file_path, "message": "Image uploaded successfully"} except Exception as e: @@ -175,7 +176,7 @@ async def upload_multiple_images( continue try: - file_path = save_upload_file(file, folder="products") + file_path = save_upload_file(file, folder="products", backend_url=settings.backend_url) uploaded_urls.append(file_path) except Exception as e: errors.append(f"File {file.filename}: {str(e)}") diff --git a/backend/app/schemas/__pycache__/user.cpython-314.pyc b/backend/app/schemas/__pycache__/user.cpython-314.pyc index b79b1fcea2a638922dbc81710e9a320b02e584f1..450ef46debf4d4fb94aac6d055c86b45ade529f2 100644 GIT binary patch delta 20 acmdm_xJi*)n~#@^0SKn${N2dCTmS$x6a{nu delta 20 acmdm_xJi*)n~#@^0SL-u|7_%5E&u>ARRsh9 diff --git a/backend/app/services/__pycache__/auth.cpython-314.pyc b/backend/app/services/__pycache__/auth.cpython-314.pyc index 0bfafeb9484447c372edda9ad6825baf3e1fa78e..1a25e92bbab5d98058060e120692ca119e3a808e 100644 GIT binary patch delta 1488 zcmaJ>Urbw77(eHp-uCw2?F9-*$7orhc%gvXn95{eXOK;?<4-fDPD$5ZN+)TXbITYe zONmbsqii|GnfOA?9(3`=^g)v?Sx9{JVb~?qW-rQ%i7#Ck!7z<}=e8gk<4NxC`_A`$ z=ey^e@AsYWyKf(tyJV*g!ML>fM^cf$mZ!*&9m|gpz^#|cT-@~=;=4A(xW$8G0>|pG zfF$s3{Q|Ir4zb@&-rN-KtH$_|4_!1>W04uf#}H~p8DRoljx1($s^uI z6ZottLn36%Uhy5o2pV%9oLBUTy*xP-!T%e8_O>@K9EKldgdxF?W~*8fC(#S!FiK*T zyxWn9>iR;O#(lA9DwWRqE@{5GbSj}yUv@T{@;!IVcWE)J>ET^*K0DL>ylN@gGr5KM zbS$09YFD!*^A(LIXBJDg^U=9^ZJ5$DrOmKkS_e?F#AdbFJDG)&Sxd#zaZNSRChqib zC)SK0onK!d;PX*0x4J3`r}s8SJHe{JrzZiHP+73$MERl1v*Bu8ceSqe6kMn8OQ(N( zXs>^aBvCHf`|gr&LwC#%?1P(*rea;w&A}UkMWtm!XJ;0l)$bmeRM!jY*3LA_e z86hH!S^3g`4q(iG@Mxbg2v#}Sb=f2I32dOoll$9Kr3fCveohdO2MIzs&%@LYs7kaA zM9DN8)n`lA;j1w%lTD^ml}ekrHM2mU<{_4ZmeQkhz>c_Pl%3EZ*s02a6C{j7G{9}v$YQ42I=wJgMLNO+#}U!EFV^8`5y!#Y zpMS;iD#k(fiSr~L&96IEOuocn_NUzFkMK0&Op@wZqhE{$gJzoqJLOIqWc{u#@l8-i zS<-b6Pcl_GfhY6R$^&fXcTC4we{Fm36jyn>(F*`2AsMHyg93Xpf{cVC+zk&MqHnMd zYum**sFV3`YCRYy*bjAg@HG3#{S2OA_uL)gWpF0gukKU$JX7m`^zwE#s!REeQGPPY gw?%mtdYQF0c<>cA&~VlUuk&^@bkELHY^CAUUz@5wvH$=8 delta 796 zcmYjP&1(}u6wmBtH)&Rrv@xHhEjG<2F>Mt@#6V(8DON0@#im6C)0k{*uua0-*pHLJ zvmiJKy?Dz(iwDad6}*Tb2)0xZ7lr-_N|jy&eQ&L`59aqi=gsfU%*NUGL++IOxXXqz z{Psunp|a_o6Elom{BA>Nk9{NcDr%`W16FX4ic13Ff!I-txb_ASTD&c$?mc6n`%Jlrr7aB!fU#UYGRE&D5%7@UheNh6R zl%8#+XT7$gWVh^DIEnERL!d=+NQCc7OJgT6qSG-g+dht=5_`2R(k$w8(enPcpzS0y z$B|hc$*G$V#Te2aKxNt&iuyv5K>{6}&OKn?d0T59QZG;uq%do7IKsbsTj^e(*im9z z_Ly~ej!z2#{=m7xLdN-yUcqU~TCG$sYy7&>FArf080Pnt2`P(a$oQZPG8Q*}9ec_q zO}VU5CAg{T0zn$V6p;#ylItMXHCtS~tBnKH!R(k>Yy30ES4&!>S*_QS5{&R&cfV*d zjXONOQVzGpjZ>a0j3xN8H_g(qX>mvBq&R}$!8cUTb=uPXHtk+E1VTw+rBqVsS zD<#dLndX8oVYE5i(eDOAglM*}%oMOMQRN>38Z z@;m;RG>_(}@z@_>EYDvAUa}i}F?fdE;!lEcse;Koe;3TKD0hTD_t7u1o&x9wfo21u d1@ir`2$C9w&MT* diff --git a/backend/app/utils.py b/backend/app/utils.py index c8f0f41..8499283 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -13,8 +13,8 @@ def generate_slug(text: str) -> str: slug = re.sub(r'^-+|-+$', '', slug) return slug -def save_upload_file(upload_file, folder: str = "products") -> str: - """Save uploaded file and return the file path""" +def save_upload_file(upload_file, folder: str = "products", backend_url: str = None) -> str: + """Save uploaded file and return the file URL""" # Create uploads directory if it doesn't exist upload_dir = Path("uploads") / folder upload_dir.mkdir(parents=True, exist_ok=True) @@ -28,5 +28,10 @@ def save_upload_file(upload_file, folder: str = "products") -> str: with open(file_path, "wb") as buffer: buffer.write(upload_file.file.read()) - # Return relative path for URL - return f"/uploads/{folder}/{unique_filename}" + # Return full URL if backend_url provided, otherwise relative path + relative_path = f"/uploads/{folder}/{unique_filename}" + if backend_url: + # Remove trailing slash from backend_url if present + backend_url = backend_url.rstrip('/') + return f"{backend_url}{relative_path}" + return relative_path diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 0d3b02f..c7fab33 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -67,3 +67,4 @@ EXPOSE 80 # Start nginx CMD ["nginx", "-g", "daemon off;"] + diff --git a/frontend/src/components/CategoryCard.jsx b/frontend/src/components/CategoryCard.jsx index 06c8ba8..838d939 100644 --- a/frontend/src/components/CategoryCard.jsx +++ b/frontend/src/components/CategoryCard.jsx @@ -1,12 +1,18 @@ -import React from 'react' +import React, { useState } from 'react' import { Link } from 'react-router-dom' export default function CategoryCard({ category }) { - const categoryImage = category.image || `https://via.placeholder.com/300x300?text=${category.name}` + const [imageError, setImageError] = useState(false) + const fallbackImage = `https://via.placeholder.com/300x300?text=${encodeURIComponent(category.name)}` + const categoryImage = (category.image && !imageError) ? category.image : fallbackImage return ( - {category.name} + {category.name} setImageError(true)} + />

{category.name}

{category.description}

diff --git a/frontend/src/components/ProductCard.jsx b/frontend/src/components/ProductCard.jsx index 6aba573..a9e0ded 100644 --- a/frontend/src/components/ProductCard.jsx +++ b/frontend/src/components/ProductCard.jsx @@ -1,8 +1,9 @@ -import React from 'react' +import React, { useState } from 'react' import { Link } from 'react-router-dom' import '../styles/global.css' export default function ProductCard({ product }) { + const [imageError, setImageError] = useState(false) const price = product.discount_price || product.price const discount = product.discount_price && product.is_on_sale @@ -11,11 +12,20 @@ export default function ProductCard({ product }) { ) : 0 + const fallbackImage = `https://via.placeholder.com/400x400?text=${encodeURIComponent(product.name)}` + const imageUrl = (product.images && product.images.length > 0 && !imageError) + ? product.images[0] + : fallbackImage + return (
- {product.name} + {product.name} setImageError(true)} + /> {product.is_on_sale && discount > 0 && (
{discount}% OFF
)} diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 1bfbdf0..cc844ef 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -167,10 +167,6 @@ export default function Admin() { const newImages = [...uploadedImages] try { - // Get backend URL from environment - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api' - const backendUrl = apiUrl.replace('/api', '') // Remove /api suffix to get base URL - for (let i = 0; i < files.length; i++) { const file = files[i] const formDataUpload = new FormData() @@ -182,11 +178,8 @@ export default function Admin() { }, }) - // Add the full URL - const imageUrl = response.data.url.startsWith('http') - ? response.data.url - : `${backendUrl}${response.data.url}` - newImages.push(imageUrl) + // Backend now returns full URLs + newImages.push(response.data.url) } setUploadedImages(newImages) @@ -341,16 +334,11 @@ export default function Admin() { formData.append('file', file) try { - const response = await api.post('/products/upload-image', formData, { + const response = await api.post('/categories/upload-image', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) - // Prepend backend URL for image display - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api' - const backendUrl = apiUrl.replace('/api', '') // Remove /api suffix to get base URL - const imageUrl = response.data.url.startsWith('http') - ? response.data.url - : `${backendUrl}${response.data.url}` - setCategoryFormData({ ...categoryFormData, image: imageUrl }) + // Backend now returns full URLs + setCategoryFormData({ ...categoryFormData, image: response.data.url }) setToast({ type: 'success', message: 'Image uploaded successfully!' }) } catch (error) { console.error('Error uploading image:', error)