Skip to content

Commit ffaad5b

Browse files
committed
PWA
1 parent abc45cd commit ffaad5b

15 files changed

Lines changed: 692 additions & 210 deletions

api/main.py

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
- GET /healthz
55
- POST /generate-form-simple : build PDF from profile + (optional) layout/theme
66
- /api/profiles/* : save/load JSON profiles (via profiles router)
7+
- GET / : PWA home (serves templates/index.html)
8+
- GET /manifest.json : PWA manifest (root scope)
9+
- GET /service-worker.js : PWA service worker (root scope)
710
"""
811

912
from __future__ import annotations
@@ -14,29 +17,54 @@
1417
from pathlib import Path
1518
from typing import Any, Dict, Optional
1619

17-
from fastapi import FastAPI, HTTPException, Response
20+
from fastapi import FastAPI, HTTPException, Response, Request
1821
from fastapi.middleware.cors import CORSMiddleware
22+
from fastapi.responses import HTMLResponse, FileResponse
23+
from fastapi.staticfiles import StaticFiles
24+
from fastapi.templating import Jinja2Templates
1925
from pydantic import BaseModel, Field, ValidationError, field_validator
2026

2127
# 1) Register fonts (side-effect)
2228
from api.pdf_utils import fonts # noqa: F401
2329
from api.pdf_utils.builder import build_resume_pdf
2430
from api.pdf_utils.mapper import profile_to_overrides
2531
from api.routes import profiles as profiles_routes # /api/profiles/*
26-
from api.pdf_utils.schema import ensure_profile_schema # ✅ أضفنا هذا الاستيراد
32+
from api.pdf_utils.schema import ensure_profile_schema
33+
34+
import asyncio
35+
import httpx
36+
from starlette.responses import StreamingResponse
37+
2738

2839
log = logging.getLogger("resume.api")
2940
logging.basicConfig(level=logging.INFO)
3041

31-
APP_ROOT = Path(__file__).resolve().parent.parent
42+
# Project paths
43+
APP_ROOT = Path(__file__).resolve().parent.parent # build/
44+
API_DIR = Path(__file__).resolve().parent # build/api
3245
THEMES_DIR = APP_ROOT / "themes"
3346
LAYOUTS_DIR = APP_ROOT / "layouts"
3447

48+
# ---------------------------------------------------------------------
49+
# FastAPI app
50+
# ---------------------------------------------------------------------
3551
app = FastAPI(title="Resume API")
3652

37-
# ─────────────────────────────────────────────────────────────
53+
# ---------------------------------------------------------------------
54+
# PWA: root endpoints (so SW has scope '/')
55+
# ---------------------------------------------------------------------
56+
@app.get("/manifest.json")
57+
def manifest() -> FileResponse:
58+
return FileResponse(API_DIR / "static" / "manifest.json", media_type="application/json")
59+
60+
@app.get("/service-worker.js")
61+
def service_worker() -> FileResponse:
62+
# Important: correct JS media type
63+
return FileResponse(API_DIR / "static" / "service-worker.js", media_type="application/javascript")
64+
65+
# ---------------------------------------------------------------------
3866
# CORS (tighten in production)
39-
# ─────────────────────────────────────────────────────────────
67+
# ---------------------------------------------------------------------
4068
ALLOWED_ORIGINS = [
4169
"http://localhost:8501", # Streamlit (local)
4270
"http://127.0.0.1:8501",
@@ -49,17 +77,39 @@
4977
allow_headers=["Content-Type", "Authorization"],
5078
)
5179

52-
# Routes for profiles CRUD
80+
# ---------------------------------------------------------------------
81+
# Routers
82+
# ---------------------------------------------------------------------
5383
app.include_router(profiles_routes.router, prefix="/api")
5484

85+
# ---------------------------------------------------------------------
86+
# Static & Templates
87+
# ---------------------------------------------------------------------
88+
# Serve api/static at /static (icons, extra assets...)
89+
app.mount("/static", StaticFiles(directory=str(API_DIR / "static")), name="static")
90+
91+
# Templates
92+
templates = Jinja2Templates(directory=str(API_DIR / "templates"))
93+
94+
@app.get("/", response_class=HTMLResponse)
95+
def index(request: Request) -> HTMLResponse:
96+
"""PWA home page (renders templates/index.html)."""
97+
return templates.TemplateResponse("index.html", {"request": request})
98+
99+
100+
101+
55102

103+
104+
# ---------------------------------------------------------------------
105+
# Helpers
106+
# ---------------------------------------------------------------------
56107
def normalize_theme_name(tn: Optional[str]) -> str:
57108
"""Normalize an incoming theme name (drop '.theme.json' if present)."""
58109
if not tn:
59110
return "default"
60111
return tn[:-11] if tn.endswith(".theme.json") else tn
61112

62-
63113
def coerce_summary(profile: Dict[str, Any]) -> None:
64114
"""If summary is a stringified list, convert it to a single joined string."""
65115
val = profile.get("summary")
@@ -68,14 +118,12 @@ def coerce_summary(profile: Dict[str, Any]) -> None:
68118
if s.startswith("[") and s.endswith("]"):
69119
try:
70120
import ast
71-
72121
lst = ast.literal_eval(s)
73122
if isinstance(lst, list):
74123
profile["summary"] = " ".join(str(x) for x in lst if x)
75124
except Exception:
76125
pass
77126

78-
79127
def _decode_headshots(node: Any) -> None:
80128
"""Recursively convert avatar_circle.data.photo_b64 -> photo_bytes."""
81129
if isinstance(node, dict):
@@ -93,7 +141,6 @@ def _decode_headshots(node: Any) -> None:
93141
for it in node:
94142
_decode_headshots(it)
95143

96-
97144
def _deep_merge_fill_missing(dst: dict, src: dict) -> dict:
98145
"""
99146
Merge without overwriting existing keys in dst (fill-only-missing).
@@ -107,7 +154,6 @@ def _deep_merge_fill_missing(dst: dict, src: dict) -> dict:
107154
dst[k] = v
108155
return dst
109156

110-
111157
def _safe_read_layout_by_name(layout_name: str) -> Dict[str, Any]:
112158
"""Read a JSON layout by name safely (prevent path traversal)."""
113159
candidate = (LAYOUTS_DIR / layout_name).resolve()
@@ -120,7 +166,9 @@ def _safe_read_layout_by_name(layout_name: str) -> Dict[str, Any]:
120166
except Exception as exc:
121167
raise HTTPException(status_code=400, detail=f"Failed to read layout: {exc}")
122168

123-
169+
# ---------------------------------------------------------------------
170+
# Payload model
171+
# ---------------------------------------------------------------------
124172
class GeneratePayload(BaseModel):
125173
theme_name: Optional[str] = Field(default=None, description="Theme name or 'x.theme.json'")
126174
theme: Optional[str] = Field(default=None, description="Legacy alias for theme_name")
@@ -139,7 +187,9 @@ def _trim_lang(cls, v: str) -> str:
139187
def effective_theme_name(self) -> str:
140188
return normalize_theme_name(self.theme_name or self.theme)
141189

142-
190+
# ---------------------------------------------------------------------
191+
# Lifecycle & health
192+
# ---------------------------------------------------------------------
143193
@app.on_event("startup")
144194
def _startup() -> None:
145195
try:
@@ -148,12 +198,13 @@ def _startup() -> None:
148198
except Exception as exc:
149199
log.warning("Font registration failed: %s", exc)
150200

151-
152201
@app.get("/healthz")
153202
def healthz() -> Dict[str, bool]:
154203
return {"ok": True}
155204

156-
205+
# ---------------------------------------------------------------------
206+
# PDF generation endpoint
207+
# ---------------------------------------------------------------------
157208
@app.post("/generate-form-simple")
158209
def generate_form_simple(payload: Dict[str, Any]) -> Response:
159210
"""Generate a resume PDF from the provided payload."""
@@ -170,7 +221,7 @@ def generate_form_simple(payload: Dict[str, Any]) -> Response:
170221
"profile": args.profile or {},
171222
}
172223

173-
# Normalize profile data before PDF build
224+
# Normalize profile data before PDF build
174225
data["profile"] = ensure_profile_schema(data["profile"])
175226

176227
# Resolve layout_inline (prefer inline, else by name)

api/static/icons/icon-192.png

641 Bytes
Loading

api/static/icons/icon-512.png

2.2 KB
Loading

api/static/manifest.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "Tamer Resume Builder API",
3+
"short_name": "ResumeAPI",
4+
"start_url": "/",
5+
"display": "standalone",
6+
"background_color": "#ffffff",
7+
"theme_color": "#0d47a1",
8+
"icons": [
9+
{
10+
"src": "/static/icons/icon-192.png",
11+
"sizes": "192x192",
12+
"type": "image/png"
13+
},
14+
{
15+
"src": "/static/icons/icon-512.png",
16+
"sizes": "512x512",
17+
"type": "image/png"
18+
}
19+
]
20+
}

api/static/service-worker.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
2+
const CACHE_NAME = "resume-pwa-v1";
3+
const URLS_TO_CACHE = ["/", "/static/manifest.json"];
4+
5+
self.addEventListener("install", (e) => {
6+
e.waitUntil(
7+
caches.open(CACHE_NAME).then((c) => c.addAll(URLS_TO_CACHE))
8+
);
9+
self.skipWaiting();
10+
});
11+
12+
self.addEventListener("activate", (e) => {
13+
e.waitUntil(
14+
caches.keys().then((keys) =>
15+
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
16+
)
17+
);
18+
self.clients.claim();
19+
});
20+
21+
self.addEventListener("fetch", (e) => {
22+
e.respondWith(
23+
caches.match(e.request).then((res) => {
24+
return res || fetch(e.request).then((resp) => {
25+
const copy = resp.clone();
26+
caches.open(CACHE_NAME).then((c) => c.put(e.request, copy));
27+
return resp;
28+
}).catch(() => res);
29+
})
30+
);
31+
});

api/templates/index.html

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Tamer Resume Builder</title>
7+
8+
<!-- روابط الـ PWA -->
9+
<link rel="manifest" href="/manifest.json" />
10+
<meta name="theme-color" content="#0d47a1" />
11+
<link rel="icon" href="/static/icons/icon-192.png" />
12+
13+
<!-- تسجيل الـ Service Worker -->
14+
<script>
15+
if ("serviceWorker" in navigator) {
16+
window.addEventListener("load", () => {
17+
navigator.serviceWorker
18+
.register("/service-worker.js")
19+
.then(() => console.log("Service Worker registered"))
20+
.catch(console.error);
21+
});
22+
}
23+
</script>
24+
25+
<!-- تصميم بسيط -->
26+
<style>
27+
body {
28+
font-family: "Segoe UI", Roboto, sans-serif;
29+
background: #f9fafc;
30+
color: #222;
31+
display: flex;
32+
flex-direction: column;
33+
align-items: center;
34+
justify-content: center;
35+
height: 100vh;
36+
margin: 0;
37+
}
38+
h1 {
39+
color: #0d47a1;
40+
font-size: 2rem;
41+
margin-bottom: 1rem;
42+
}
43+
p {
44+
color: #555;
45+
margin-bottom: 2rem;
46+
}
47+
a.button {
48+
display: inline-block;
49+
padding: 0.9rem 1.6rem;
50+
background: #0d47a1;
51+
color: #fff;
52+
font-weight: 500;
53+
border-radius: 0.6rem;
54+
text-decoration: none;
55+
transition: background 0.25s ease;
56+
}
57+
a.button:hover {
58+
background: #1565c0;
59+
}
60+
footer {
61+
position: absolute;
62+
bottom: 1rem;
63+
font-size: 0.85rem;
64+
color: #777;
65+
}
66+
</style>
67+
</head>
68+
69+
<body>
70+
<h1>🚀 Tamer Resume Builder</h1>
71+
<p>Welcome to your portable Resume Builder API (PWA).</p>
72+
73+
<!-- الزر الذي يفتح واجهة Streamlit -->
74+
<a class="button" href="http://127.0.0.1:8501/" target="_blank">
75+
Open Resume Builder UI
76+
</a>
77+
78+
79+
</body>
80+
<footer>© 2025 TamerOnLine — All rights reserved</footer>
81+
</html>

0 commit comments

Comments
 (0)