Compare commits

..
0 Commits
22 changed files with 186 additions and 468 deletions

No files matched your search

+1 -5
View File
@@ -6,8 +6,4 @@ uploads/*
*.db *.db
.env .env
server.log server.log
s3_config.json scripts/
config.json
scripts/fix_*.py
scripts/migrate_*.py
scripts/test_*.py
+2 -2
View File
@@ -123,8 +123,8 @@
"pc_form.number_hint": "Format: AB-1234567", "pc_form.number_hint": "Format: AB-1234567",
"pc_form.status": "Status", "pc_form.status": "Status",
"pc_form.recipient": "Recipient", "pc_form.recipient": "Recipient",
"pc_form.country_from": "From country", "pc_form.country": "Country",
"pc_form.country_to": "To country", "pc_form.country_from": "From",
"pc_form.send_time": "Sent date", "pc_form.send_time": "Sent date",
"pc_form.arrival_time": "Arrival date", "pc_form.arrival_time": "Arrival date",
"pc_form.sender": "Sender", "pc_form.sender": "Sender",
+2 -2
View File
@@ -123,8 +123,8 @@
"pc_form.number_hint": "格式:AB-1234567", "pc_form.number_hint": "格式:AB-1234567",
"pc_form.status": "状态", "pc_form.status": "状态",
"pc_form.recipient": "收件人", "pc_form.recipient": "收件人",
"pc_form.country_from": "寄出国", "pc_form.country": "国家/地区",
"pc_form.country_to": "到达国", "pc_form.country_from": "寄出地",
"pc_form.send_time": "寄出时间", "pc_form.send_time": "寄出时间",
"pc_form.arrival_time": "到达时间", "pc_form.arrival_time": "到达时间",
"pc_form.sender": "发件人", "pc_form.sender": "发件人",
+1 -19
View File
@@ -1,7 +1,6 @@
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.config import UPLOAD_DIR from app.config import UPLOAD_DIR
@@ -11,24 +10,7 @@ from app.routers import web, api
app = FastAPI(title="Mailova 星笺") app = FastAPI(title="Mailova 星笺")
UPLOAD_DIR.mkdir(exist_ok=True) UPLOAD_DIR.mkdir(exist_ok=True)
STATIC_DIR = Path(__file__).resolve().parent / "static" app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static")
# Cache-Control for uploaded images (30 days, immutable — filenames contain UUID)
@app.middleware("http")
async def cache_control_middleware(request, call_next):
response = await call_next(request)
if request.url.path.startswith("/uploads/"):
response.headers["Cache-Control"] = "public, max-age=2592000, immutable"
return response
@app.get("/favicon.ico")
async def favicon():
return FileResponse(STATIC_DIR / "favicon.ico", media_type="image/x-icon")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads") app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
app.include_router(web.router) app.include_router(web.router)
+6 -7
View File
@@ -13,7 +13,7 @@ from app.models import (
PERM_POSTCARDS_READ, PERM_POSTCARDS_WRITE, PERM_POSTCARDS_READ, PERM_POSTCARDS_WRITE,
PERM_IMAGES_UPLOAD, PERM_IMAGES_UPLOAD,
) )
# config removed: image storage now via app.storage from app.config import UPLOAD_DIR
router = APIRouter(prefix="/api/v1", tags=["api"]) router = APIRouter(prefix="/api/v1", tags=["api"])
@@ -380,16 +380,15 @@ async def upload_image(
ak: ApiKey = Depends(require_scopes(PERM_IMAGES_UPLOAD)), ak: ApiKey = Depends(require_scopes(PERM_IMAGES_UPLOAD)),
): ):
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db) pc = _postcard_or_404(ak.user, profile_id, postcard_id, db)
from app.storage import get_storage
storage = get_storage()
ext = Path(file.filename).suffix if file.filename else ".jpg" ext = Path(file.filename).suffix if file.filename else ".jpg"
key = f"pc_{pc.id}_{side}{ext}" save_name = f"pc_{pc.id}_{side}{ext}"
save_path = UPLOAD_DIR / save_name
content = await file.read() content = await file.read()
storage.put(key, content) save_path.write_bytes(content)
if side == "front": if side == "front":
pc.image_front = key pc.image_front = f"/uploads/{save_name}"
else: else:
pc.image_back = key pc.image_back = f"/uploads/{save_name}"
db.commit() db.commit()
db.refresh(pc) db.refresh(pc)
return pc return pc
+76 -76
View File
@@ -16,7 +16,7 @@ from app.auth import (
redirect_if_not_logged_in, redirect_if_not_logged_in,
verify_password, verify_password,
) )
from app.config import SECRET_KEY, SESSION_COOKIE_NAME from app.config import SECRET_KEY, SESSION_COOKIE_NAME, UPLOAD_DIR
from app.database import get_db from app.database import get_db
from random import choice from random import choice
from string import ascii_letters, digits from string import ascii_letters, digits
@@ -86,24 +86,8 @@ def _translate_filter(key: str, lang: str = "zh") -> str:
templates.env.filters["t"] = _translate_filter templates.env.filters["t"] = _translate_filter
# image URL resolver: bare key → full URL (local /uploads/... or S3 public URL)
from app.storage import resolve_url as _resolve_image_url
templates.env.filters["image_url"] = lambda v: _resolve_image_url(v) if v else ""
from app.i18n import get_languages as _get_languages from app.i18n import get_languages as _get_languages
templates.env.globals["get_languages"] = _get_languages templates.env.globals["available_languages"] = _get_languages()
def _render(request: Request, name: str, ctx: dict) -> HTMLResponse:
"""Render a Jinja2 template and return HTMLResponse.
Bypasses Starlette TemplateResponse to avoid a Jinja2 cache bug where
the context dict is passed as ``globals`` and used as an unhashable
cache key (``TypeError: unhashable type: 'dict'``).
"""
ctx["request"] = request
html = templates.get_template(name).render(ctx)
return HTMLResponse(html)
def _get_copyright() -> str: def _get_copyright() -> str:
@@ -163,7 +147,8 @@ def index_page(request: Request, db: Session = Depends(get_db)):
if not lang or lang not in avail: if not lang or lang not in avail:
lang = "zh" lang = "zh"
return _render(request, "index.html", {"postcards": all_postcards[:20], "lang": lang, return templates.TemplateResponse("index.html", {
"request": request, "postcards": all_postcards[:20], "lang": lang,
"has_more": len(all_postcards) > 20, "has_more": len(all_postcards) > 20,
}) })
@@ -203,7 +188,7 @@ def api_public_cards(
"page": page, "page": page,
"has_more": start + limit < total, "has_more": start + limit < total,
"cards": [{ "cards": [{
"image_front": _resolve_image_url(c.image_front), "image_front": c.image_front,
"country_from": c.country_from, "country_from": c.country_from,
"country_to": c.country_to, "country_to": c.country_to,
"status": c.status, "status": c.status,
@@ -245,7 +230,7 @@ def api_public_cards_user(
"page": page, "page": page,
"has_more": start + limit < total, "has_more": start + limit < total,
"cards": [{ "cards": [{
"image_front": _resolve_image_url(c.image_front), "image_front": c.image_front,
"country_from": c.country_from, "country_from": c.country_from,
"country_to": c.country_to, "country_to": c.country_to,
} for c in batch], } for c in batch],
@@ -259,7 +244,7 @@ def login_page(request: Request, db: Session = Depends(get_db)):
user = redirect_if_not_logged_in(request, db) user = redirect_if_not_logged_in(request, db)
if user: if user:
return _redirect("/dashboard") return _redirect("/dashboard")
return _render(request, "login.html", {"request": request}) return templates.TemplateResponse("login.html", {"request": request})
@router.post("/login") @router.post("/login")
@@ -274,7 +259,8 @@ def login_submit(
cleanup_expired_sessions(db) cleanup_expired_sessions(db)
user = db.query(User).filter(User.username == username).first() user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(password, user.password_hash): if not user or not verify_password(password, user.password_hash):
return _render(request, "login.html", {"error": "用户名或密码错误"}, status_code=401 return templates.TemplateResponse(
"login.html", {"request": request, "error": "用户名或密码错误"}, status_code=401
) )
is_remember = remember_me == "on" is_remember = remember_me == "on"
token = create_session(user.id, db, remember_me=is_remember) token = create_session(user.id, db, remember_me=is_remember)
@@ -293,7 +279,7 @@ def register_page(request: Request, db: Session = Depends(get_db)):
user = redirect_if_not_logged_in(request, db) user = redirect_if_not_logged_in(request, db)
if user: if user:
return _redirect("/dashboard") return _redirect("/dashboard")
return _render(request, "register.html", {"request": request}) return templates.TemplateResponse("register.html", {"request": request})
@router.post("/register") @router.post("/register")
@@ -308,24 +294,30 @@ def register_submit(
# Validate invite code # Validate invite code
code_obj = db.query(InviteCode).filter(InviteCode.code == invite_code).first() code_obj = db.query(InviteCode).filter(InviteCode.code == invite_code).first()
if not code_obj: if not code_obj:
return _render(request, "register.html", {"error": "邀请码无效"}, status_code=400 return templates.TemplateResponse(
"register.html", {"request": request, "error": "邀请码无效"}, status_code=400
) )
if code_obj.expires_at and code_obj.expires_at < datetime.utcnow(): if code_obj.expires_at and code_obj.expires_at < datetime.utcnow():
return _render(request, "register.html", {"error": "邀请码已过期"}, status_code=400 return templates.TemplateResponse(
"register.html", {"request": request, "error": "邀请码已过期"}, status_code=400
) )
if code_obj.max_uses is not None and code_obj.use_count >= code_obj.max_uses: if code_obj.max_uses is not None and code_obj.use_count >= code_obj.max_uses:
return _render(request, "register.html", {"error": "邀请码使用次数已用尽"}, status_code=400 return templates.TemplateResponse(
"register.html", {"request": request, "error": "邀请码使用次数已用尽"}, status_code=400
) )
# Validate passwords # Validate passwords
if password != confirm_password: if password != confirm_password:
return _render(request, "register.html", {"error": "两次输入的密码不一致"}, status_code=400 return templates.TemplateResponse(
"register.html", {"request": request, "error": "两次输入的密码不一致"}, status_code=400
) )
if len(password) < 6: if len(password) < 6:
return _render(request, "register.html", {"error": "密码至少6个字符"}, status_code=400 return templates.TemplateResponse(
"register.html", {"request": request, "error": "密码至少6个字符"}, status_code=400
) )
# Check username # Check username
if db.query(User).filter(User.username == username).first(): if db.query(User).filter(User.username == username).first():
return _render(request, "register.html", {"error": "用户名已存在"}, status_code=400 return templates.TemplateResponse(
"register.html", {"request": request, "error": "用户名已存在"}, status_code=400
) )
# Create user # Create user
new_user = User(username=username, password_hash=hash_password(password), invited_by_code_id=code_obj.id) new_user = User(username=username, password_hash=hash_password(password), invited_by_code_id=code_obj.id)
@@ -363,9 +355,7 @@ def _admin_context(user, db, active_tab="invites", **extra):
def _admin_render(request, user, db, active_tab="invites", **extra): def _admin_render(request, user, db, active_tab="invites", **extra):
ctx = _admin_context(user, db, active_tab, **extra) return templates.TemplateResponse("admin.html", _admin_context(user, db, active_tab, request=request, **extra))
ctx["request"] = request
return _render(request, "admin.html", ctx)
@router.get("/admin", response_class=HTMLResponse) @router.get("/admin", response_class=HTMLResponse)
@@ -507,7 +497,8 @@ def settings_page(
for p in profiles: for p in profiles:
profile_cards[p.id] = db.query(Postcard).filter(Postcard.profile_id == p.id).all() profile_cards[p.id] = db.query(Postcard).filter(Postcard.profile_id == p.id).all()
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all() api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
return _render(request, "settings.html", {"user": user, "profiles": profiles, return templates.TemplateResponse("settings.html", {
"request": request, "user": user, "profiles": profiles,
"profile_cards": profile_cards, "active_tab": active_tab, "profile_cards": profile_cards, "active_tab": active_tab,
"api_keys": api_keys, "api_keys": api_keys,
}) })
@@ -521,11 +512,13 @@ def change_username(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
if db.query(User).filter(User.username == new_username, User.id != user.id).first(): if db.query(User).filter(User.username == new_username, User.id != user.id).first():
return _render(request, "settings.html", {"user": user, "error": "用户名已存在"}, status_code=400 return templates.TemplateResponse(
"settings.html", {"request": request, "user": user, "error": "用户名已存在"}, status_code=400
) )
user.username = new_username user.username = new_username
db.commit() db.commit()
return _render(request, "settings.html", {"user": user, "success": "用户名修改成功"} return templates.TemplateResponse(
"settings.html", {"request": request, "user": user, "success": "用户名修改成功"}
) )
@@ -539,17 +532,21 @@ def change_password(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
if not verify_password(old_password, user.password_hash): if not verify_password(old_password, user.password_hash):
return _render(request, "settings.html", {"user": user, "error": "当前密码错误"}, status_code=400 return templates.TemplateResponse(
"settings.html", {"request": request, "user": user, "error": "当前密码错误"}, status_code=400
) )
if new_password != confirm_password: if new_password != confirm_password:
return _render(request, "settings.html", {"user": user, "error": "两次输入的新密码不一致"}, status_code=400 return templates.TemplateResponse(
"settings.html", {"request": request, "user": user, "error": "两次输入的新密码不一致"}, status_code=400
) )
if len(new_password) < 6: if len(new_password) < 6:
return _render(request, "settings.html", {"user": user, "error": "新密码长度至少6位"}, status_code=400 return templates.TemplateResponse(
"settings.html", {"request": request, "user": user, "error": "新密码长度至少6位"}, status_code=400
) )
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
db.commit() db.commit()
return _render(request, "settings.html", {"user": user, "success": "密码修改成功"} return templates.TemplateResponse(
"settings.html", {"request": request, "user": user, "success": "密码修改成功"}
) )
@@ -593,7 +590,6 @@ def dashboard(request: Request, user: User = Depends(get_current_user_web), db:
all_cards.extend(cards) all_cards.extend(cards)
total = len(all_cards) total = len(all_cards)
pending = sum(1 for c in all_cards if c.status == "pending")
sent = sum(1 for c in all_cards if c.status == "sent") sent = sum(1 for c in all_cards if c.status == "sent")
delivered = sum(1 for c in all_cards if c.status == "delivered") delivered = sum(1 for c in all_cards if c.status == "delivered")
received = sum(1 for c in all_cards if c.status == "received") received = sum(1 for c in all_cards if c.status == "received")
@@ -605,36 +601,34 @@ def dashboard(request: Request, user: User = Depends(get_current_user_web), db:
# status distribution for progress bar # status distribution for progress bar
if total > 0: if total > 0:
pending_pct = round(pending / total * 100)
sent_pct = round(sent / total * 100) sent_pct = round(sent / total * 100)
delivered_pct = round(delivered / total * 100) delivered_pct = round(delivered / total * 100)
received_pct = round(received / total * 100) received_pct = round(received / total * 100)
else: else:
pending_pct = sent_pct = delivered_pct = received_pct = 0 sent_pct = delivered_pct = received_pct = 0
# country stats split by sent/received # country stats split by sent/received
sent_country_counts: dict[str, int] = {} sent_country_counts: dict[str, int] = {}
received_country_counts: dict[str, int] = {} received_country_counts: dict[str, int] = {}
for c in all_cards: for c in all_cards:
if c.status in ("pending", "sent", "delivered"):
if not c.country_to: if not c.country_to:
continue continue
if c.status in ("pending", "sent", "delivered"):
sent_country_counts[c.country_to] = sent_country_counts.get(c.country_to, 0) + 1 sent_country_counts[c.country_to] = sent_country_counts.get(c.country_to, 0) + 1
elif c.status == "received": elif c.status == "received":
if not c.country_from: received_country_counts[c.country_to] = received_country_counts.get(c.country_to, 0) + 1
continue
received_country_counts[c.country_from] = received_country_counts.get(c.country_from, 0) + 1
sent_country_stats = sorted(sent_country_counts.items(), key=lambda x: -x[1]) sent_country_stats = sorted(sent_country_counts.items(), key=lambda x: -x[1])
received_country_stats = sorted(received_country_counts.items(), key=lambda x: -x[1]) received_country_stats = sorted(received_country_counts.items(), key=lambda x: -x[1])
from datetime import datetime, timezone as _tz from datetime import datetime, timezone as _tz
return _render(request, "dashboard.html", {"user": user, "profiles": profiles, return templates.TemplateResponse("dashboard.html", {
"total": total, "pending": pending, "sent": sent, "delivered": delivered, "received": received, "request": request, "user": user, "profiles": profiles,
"total": total, "sent": sent, "delivered": delivered, "received": received,
"countries": countries, "recent": recent, "countries": countries, "recent": recent,
"pending_pct": pending_pct, "sent_pct": sent_pct, "delivered_pct": delivered_pct, "received_pct": received_pct, "sent_pct": sent_pct, "delivered_pct": delivered_pct, "received_pct": received_pct,
"sent_country_stats": sent_country_stats, "received_country_stats": received_country_stats, "sent_country_stats": sent_country_stats, "received_country_stats": received_country_stats,
"now": datetime.now(_tz.utc) + timedelta(hours=8), "now": datetime.now(_tz.utc),
}) })
@@ -648,13 +642,12 @@ def profile_list(request: Request, user: User = Depends(get_current_user_web), d
cards = db.query(Postcard).filter(Postcard.profile_id == p.id).all() cards = db.query(Postcard).filter(Postcard.profile_id == p.id).all()
extras[p.id] = { extras[p.id] = {
"total": len(cards), "total": len(cards),
"pending": sum(1 for c in cards if c.status == "pending"),
"sent": sum(1 for c in cards if c.status == "sent"), "sent": sum(1 for c in cards if c.status == "sent"),
"delivered": sum(1 for c in cards if c.status == "delivered"), "delivered": sum(1 for c in cards if c.status == "delivered"),
"received": sum(1 for c in cards if c.status == "received"), "received": sum(1 for c in cards if c.status == "received"),
"countries": len({c.country_to for c in cards if c.country_to}), "countries": len({c.country_to for c in cards if c.country_to}),
} }
return _render(request, "profiles.html", {"user": user, "profiles": profiles, "extras": extras}) return templates.TemplateResponse("profiles.html", {"request": request, "user": user, "profiles": profiles, "extras": extras})
@router.post("/profiles") @router.post("/profiles")
@@ -714,15 +707,17 @@ def postcard_list(
# collect existing countries for dropdown # collect existing countries for dropdown
all_countries = sorted({pc.country_to for pc in db.query(Postcard.country_to).filter(Postcard.profile_id == profile_id, Postcard.country_to.isnot(None)).all()}) all_countries = sorted({pc.country_to for pc in db.query(Postcard.country_to).filter(Postcard.profile_id == profile_id, Postcard.country_to.isnot(None)).all()})
# status sort order: pending=0, sent=1, delivered=2, received=3 # status sort order: sent=0, delivered=1, received=2
_status_order = {"pending": 0, "sent": 1, "delivered": 2, "received": 3} _status_order = {"sent": 0, "delivered": 1, "received": 2}
postcards.sort(key=lambda pc: (_status_order.get(pc.status, 9), -(pc.send_time or pc.arrival_time or pc.receive_time or pc.created_at).timestamp())) postcards.sort(key=lambda pc: (_status_order.get(pc.status, 9), -(pc.send_time or pc.arrival_time or pc.receive_time or pc.created_at).timestamp()))
# split into sent and received # split into sent and received
sent = [pc for pc in postcards if pc.status in ("pending", "sent", "delivered")] sent = [pc for pc in postcards if pc.status in ("pending", "sent", "delivered")]
received = [pc for pc in postcards if pc.status == "received"] received = [pc for pc in postcards if pc.status == "received"]
return _render(request, "postcards.html", {"user": user, "profile": profile, "sent": sent, "received": received, "countries": all_countries, "country": country}, return templates.TemplateResponse(
"postcards.html",
{"request": request, "user": user, "profile": profile, "sent": sent, "received": received, "countries": all_countries, "country": country},
) )
@@ -732,7 +727,7 @@ def postcard_new(request: Request, profile_id: int, create_type: str = Query("se
if not profile: if not profile:
return _redirect("/profiles") return _redirect("/profiles")
today = datetime.now().strftime("%Y-%m-%d") today = datetime.now().strftime("%Y-%m-%d")
return _render(request, "postcard_form.html", {"user": user, "profile": profile, "postcard": None, "create_type": create_type, "today": today}) return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": profile, "postcard": None, "create_type": create_type, "today": today})
@router.post("/profiles/{profile_id}/postcards") @router.post("/profiles/{profile_id}/postcards")
@@ -761,7 +756,8 @@ def postcard_create(
Postcard.card_number == cn, Profile.user_id == user.id Postcard.card_number == cn, Profile.user_id == user.id
).first() ).first()
if dup: if dup:
return _render(request, "postcard_form.html", {"user": user, "profile": profile, "postcard": None, return templates.TemplateResponse("postcard_form.html", {
"request": request, "user": user, "profile": profile, "postcard": None,
"error": _t("pc_list.duplicate", user.language), "error": _t("pc_list.duplicate", user.language),
}) })
@@ -805,7 +801,7 @@ def postcard_detail(request: Request, postcard_id: int, user: User = Depends(get
) )
if not pc: if not pc:
return _redirect("/profiles") return _redirect("/profiles")
return _render(request, "postcard_detail.html", {"user": user, "postcard": pc}) return templates.TemplateResponse("postcard_detail.html", {"request": request, "user": user, "postcard": pc})
@router.get("/postcards/{postcard_id}/edit", response_class=HTMLResponse) @router.get("/postcards/{postcard_id}/edit", response_class=HTMLResponse)
@@ -818,7 +814,7 @@ def postcard_edit(request: Request, postcard_id: int, user: User = Depends(get_c
) )
if not pc: if not pc:
return _redirect("/profiles") return _redirect("/profiles")
return _render(request, "postcard_form.html", {"user": user, "profile": pc.profile, "postcard": pc}) return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": pc.profile, "postcard": pc})
@router.post("/postcards/{postcard_id}/edit") @router.post("/postcards/{postcard_id}/edit")
@@ -853,7 +849,8 @@ def postcard_update(
Profile.user_id == user.id Profile.user_id == user.id
).first() ).first()
if dup: if dup:
return _render(request, "postcard_form.html", {"user": user, "profile": pc.profile, "postcard": pc, return templates.TemplateResponse("postcard_form.html", {
"request": request, "user": user, "profile": pc.profile, "postcard": pc,
"error": _t("pc_list.duplicate", user.language), "error": _t("pc_list.duplicate", user.language),
}) })
@@ -900,7 +897,7 @@ def postcard_delete(postcard_id: int, user: User = Depends(get_current_user_web)
@router.post("/postcards/{postcard_id}/mark-delivered") @router.post("/postcards/{postcard_id}/mark-delivered")
async def mark_delivered( def mark_delivered(
postcard_id: int, postcard_id: int,
user: User = Depends(get_current_user_web), user: User = Depends(get_current_user_web),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -912,16 +909,16 @@ async def mark_delivered(
.first() .first()
) )
if not pc: if not pc:
return JSONResponse({"ok": False}, status_code=404) return _redirect("/profiles")
pc.status = "delivered" pc.status = "delivered"
if not pc.arrival_time: if not pc.arrival_time:
pc.arrival_time = datetime.now() pc.arrival_time = datetime.now()
db.commit() db.commit()
return JSONResponse({"ok": True}) return _redirect(f"/postcards/{postcard_id}")
@router.post("/postcards/{postcard_id}/mark-sent") @router.post("/postcards/{postcard_id}/mark-sent")
async def mark_sent( def mark_sent(
postcard_id: int, postcard_id: int,
user: User = Depends(get_current_user_web), user: User = Depends(get_current_user_web),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -934,12 +931,12 @@ async def mark_sent(
.first() .first()
) )
if not pc: if not pc:
return JSONResponse({"ok": False}, status_code=404) return _redirect("/profiles")
pc.status = "sent" pc.status = "sent"
if not pc.send_time: if not pc.send_time:
pc.send_time = datetime.now() pc.send_time = datetime.now()
db.commit() db.commit()
return JSONResponse({"ok": True}) return _redirect(f"/postcards/{postcard_id}")
# ---------- Image Upload ---------- # ---------- Image Upload ----------
@@ -962,13 +959,12 @@ async def upload_image(
) )
if not pc: if not pc:
return _redirect("/profiles") return _redirect("/profiles")
from app.storage import get_storage
storage = get_storage()
ext = Path(file.filename or "upload.jpg").suffix or ".jpg" ext = Path(file.filename or "upload.jpg").suffix or ".jpg"
key = f"{uuid4().hex}{ext}" filename = f"{uuid4().hex}{ext}"
dest = UPLOAD_DIR / filename
content = await file.read() content = await file.read()
storage.put(key, content) dest.write_bytes(content)
setattr(pc, f"image_{side}", key) setattr(pc, f"image_{side}", f"/uploads/{filename}")
db.commit() db.commit()
return _redirect(f"/postcards/{postcard_id}") return _redirect(f"/postcards/{postcard_id}")
@@ -999,7 +995,8 @@ def profile_showcase_page(
if not p: if not p:
return _redirect("/settings?tab=showcase") return _redirect("/settings?tab=showcase")
cards = db.query(Postcard).filter(Postcard.profile_id == profile_id).all() cards = db.query(Postcard).filter(Postcard.profile_id == profile_id).all()
return _render(request, "profile_showcase.html", {"user": user, "profile": p, "cards": cards, return templates.TemplateResponse("profile_showcase.html", {
"request": request, "user": user, "profile": p, "cards": cards,
}) })
@@ -1071,7 +1068,8 @@ def public_showcase(request: Request, username: str, db: Session = Depends(get_d
profile_groups.append({"profile": p, "sent": p_sent, "received": p_received}) profile_groups.append({"profile": p, "sent": p_sent, "received": p_received})
sent_all.sort(key=lambda c: c.send_time or c.created_at, reverse=True) sent_all.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
received_all.sort(key=lambda c: c.receive_time or c.created_at, reverse=True) received_all.sort(key=lambda c: c.receive_time or c.created_at, reverse=True)
resp = _render(request, "showcase.html", {"profile_user": user, "mode": user.showcase_mode, resp = templates.TemplateResponse("showcase.html", {
"request": request, "profile_user": user, "mode": user.showcase_mode,
"profile_groups": profile_groups, "sent_all": sent_all, "received_all": received_all, "profile_groups": profile_groups, "sent_all": sent_all, "received_all": received_all,
"lang": visitor_lang, "available_languages": _avail, "lang": visitor_lang, "available_languages": _avail,
}) })
@@ -1101,7 +1099,8 @@ def apikey_create(
db.commit() db.commit()
db.refresh(ak) db.refresh(ak)
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all() api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
return _render(request, "settings.html", {"user": user, "active_tab": "apikeys", return templates.TemplateResponse("settings.html", {
"request": request, "user": user, "active_tab": "apikeys",
"api_keys": api_keys, "new_key": ak.key, "success": "API Key created", "api_keys": api_keys, "new_key": ak.key, "success": "API Key created",
}) })
@@ -1149,5 +1148,6 @@ def api_docs_page(
user: User = Depends(get_current_user_web), user: User = Depends(get_current_user_web),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
return _render(request, "api_docs.html", {"user": user, return templates.TemplateResponse("api_docs.html", {
"request": request, "user": user,
}) })
+1 -15
View File
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, model_validator from pydantic import BaseModel
# ---------- Profile ---------- # ---------- Profile ----------
@@ -23,13 +23,6 @@ class ProfileOut(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@model_validator(mode="after")
def _resolve_image_urls(self) -> "PostcardOut":
from app.storage import resolve_url
self.image_front = resolve_url(self.image_front) or None
self.image_back = resolve_url(self.image_back) or None
return self
# ---------- Postcard ---------- # ---------- Postcard ----------
@@ -75,10 +68,3 @@ class PostcardOut(BaseModel):
updated_at: datetime updated_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@model_validator(mode="after")
def _resolve_image_urls(self) -> "PostcardOut":
from app.storage import resolve_url
self.image_front = resolve_url(self.image_front) or None
self.image_back = resolve_url(self.image_back) or None
return self
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+9 -35
View File
@@ -186,20 +186,17 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
.dash-stat-body { display: flex; flex-direction: column; } .dash-stat-body { display: flex; flex-direction: column; }
.dash-stat-value { font-size: 1.15rem; font-weight: 700; line-height: 1.2; } .dash-stat-value { font-size: 1.15rem; font-weight: 700; line-height: 1.2; }
.dash-stat-label { font-size: .68rem; color: var(--text-muted); } .dash-stat-label { font-size: .68rem; color: var(--text-muted); }
.dash-pending { color: var(--primary); }
.dash-sent { color: var(--sent); } .dash-sent { color: var(--sent); }
.dash-delivered { color: var(--delivered); } .dash-delivered { color: var(--delivered); }
.dash-received { color: var(--received); } .dash-received { color: var(--received); }
.dash-progress { margin-bottom: 2rem; } .dash-progress { margin-bottom: 2rem; }
.progress-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--border); } .progress-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--border); }
.progress-seg { height: 100%; } .progress-seg { height: 100%; }
.progress-pending { background: var(--primary); }
.progress-sent { background: var(--sent); } .progress-sent { background: var(--sent); }
.progress-delivered { background: var(--delivered); } .progress-delivered { background: var(--delivered); }
.progress-received { background: var(--received); } .progress-received { background: var(--received); }
.progress-legend { display: flex; gap: 1.25rem; margin-top: .5rem; font-size: .8rem; color: var(--text-muted); } .progress-legend { display: flex; gap: 1.25rem; margin-top: .5rem; font-size: .8rem; color: var(--text-muted); }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: .25rem; vertical-align: middle; } .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: .25rem; vertical-align: middle; }
.dot-pending { background: var(--primary); }
.dot-sent { background: var(--sent); } .dot-sent { background: var(--sent); }
.dot-delivered { background: var(--delivered); } .dot-delivered { background: var(--delivered); }
.dot-received { background: var(--received); } .dot-received { background: var(--received); }
@@ -232,7 +229,6 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
.stat-value { font-size: 1.1rem; font-weight: 700; } .stat-value { font-size: 1.1rem; font-weight: 700; }
.stat-label { font-size: .75rem; color: var(--text-muted); } .stat-label { font-size: .75rem; color: var(--text-muted); }
.badge-sent-text { color: var(--sent); } .badge-sent-text { color: var(--sent); }
.badge-pending-text { color: var(--primary); }
.badge-delivered-text { color: var(--delivered); } .badge-delivered-text { color: var(--delivered); }
.badge-received-text { color: var(--received); } .badge-received-text { color: var(--received); }
.profile-card-footer { display: flex; align-items: center; justify-content: space-between; } .profile-card-footer { display: flex; align-items: center; justify-content: space-between; }
@@ -306,48 +302,26 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
.detail-grid { grid-template-columns: 1fr; } .detail-grid { grid-template-columns: 1fr; }
.card-grid { grid-template-columns: 1fr; } .card-grid { grid-template-columns: 1fr; }
.postcard-row-info { flex-wrap: wrap; gap: .35rem; } .postcard-row-info { flex-wrap: wrap; gap: .35rem; }
.navbar { padding: .5rem 1rem; }
/* Navbar */ .nav-links { gap: .75rem; }
.navbar { padding: .5rem .75rem; } .nav-links a { font-size: .8rem; }
.nav-brand { font-size: .9rem; } .container { margin: 1rem auto; padding: 0 1rem; }
.nav-links { gap: .5rem; }
.nav-links a { font-size: .78rem; }
.nav-links .btn-sm { padding: .3rem .6rem; font-size: .75rem; }
.container { margin: 1rem auto; padding: 0 .75rem; }
/* Dashboard */
.dash-stats { display: grid; grid-template-columns: 1fr 1fr; } .dash-stats { display: grid; grid-template-columns: 1fr 1fr; }
.dash-stat-card { min-width: 0; } .dash-stat-card { min-width: 0; }
.dash-sections { grid-template-columns: 1fr; } .dash-sections { grid-template-columns: 1fr; }
.recent-meta > span:nth-child(-n+2) { display: none; } .recent-meta > span:nth-child(-n+2) { display: none; }
.recent-meta .recent-time { display: inline; } .recent-meta .recent-time { display: inline; }
.recent-item { gap: .5rem; padding: .5rem; } .recent-item { gap: .5rem; padding: .5rem; }
.section-header { flex-direction: column; gap: .75rem; }
/* Section header */
.section-header { flex-direction: column; gap: .5rem; text-align: center; }
.section-header .btn { width: auto; } .section-header .btn { width: auto; }
/* Detail */
.detail-title-row h1 { font-size: 1.2rem; } .detail-title-row h1 { font-size: 1.2rem; }
.btn-back { padding: .25rem .5rem; font-size: .85rem; } .btn-back { padding: .25rem .5rem; font-size: .85rem; }
/* Profiles */
.profile-grid { grid-template-columns: 1fr; } .profile-grid { grid-template-columns: 1fr; }
.profile-card-stats { gap: .75rem; flex-wrap: wrap; } .profile-card-stats { gap: .75rem; flex-wrap: wrap; }
.showcase-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: .5rem; }
/* Showcase / Landing grid — 2 columns on mobile */ .showcase-card-meta { flex-wrap: wrap; }
.showcase-grid { grid-template-columns: repeat(2, 1fr); gap: .5rem; } .detail-title-row h1 { font-size: 1.2rem; }
.showcase-card-body { padding: .5rem; } .btn-back { padding: .25rem .5rem; font-size: .85rem; }
.showcase-card-meta { font-size: .72rem; flex-wrap: wrap; gap: .25rem; }
/* Lightbox — tighter on mobile */
.lightbox-content { max-width: 95vw; }
.lightbox-content img { max-width: 95vw; max-height: 75vh; }
.lightbox-prev, .lightbox-next { font-size: 1.4rem; padding: .4rem .6rem; }
.lightbox-prev { left: .5rem; }
.lightbox-next { right: .5rem; }
.lightbox-close { top: .5rem; right: .75rem; font-size: 1.6rem; }
} }
/* Admin invite form */ /* Admin invite form */
-202
View File
@@ -1,202 +0,0 @@
"""Image storage abstraction: local filesystem or S3-compatible object storage.
All system configuration lives in ``config.json`` in the project root::
{
"storage": {
"backend": "local",
"s3": {
"endpoint": "https://s3.amazonaws.com",
"bucket": "my-mailova-bucket",
"region": "auto",
"access_key": "AKIA...",
"secret_key": "...",
"public_url": "https://cdn.example.com/mailova"
}
}
}
``storage.backend``: ``"local"`` (default) or ``"s3"``.
URL scheme
~~~~~~~~~~
Database stores **bare filenames** (``xxx.jpeg``) regardless of backend.
``resolve_url(key)`` converts a bare key to a full URL:
- S3 → ``<public_url>/xxx.jpeg``
- local → ``/uploads/xxx.jpeg``
Legacy records that still contain ``/uploads/xxx.jpeg`` are auto-stripped
by ``resolve_url``.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Protocol
from app.config import UPLOAD_DIR
CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.json"
class StorageBackend(Protocol):
def put(self, key: str, data: bytes, content_type: str = "image/jpeg") -> str:
"""Store data and return the bare key."""
...
def delete(self, key: str) -> None: ...
def exists(self, key: str) -> bool: ...
def resolve_url(self, key: str) -> str:
"""Convert a bare key (or legacy /uploads/xxx path) to a full URL."""
...
# ── Local Storage ──────────────────────────────────────────────────
class LocalStorage:
def __init__(self) -> None:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
def put(self, key: str, data: bytes, content_type: str = "") -> str:
(UPLOAD_DIR / key).write_bytes(data)
return key
def delete(self, key: str) -> None:
p = UPLOAD_DIR / key
if p.exists():
p.unlink()
def exists(self, key: str) -> bool:
return (UPLOAD_DIR / key).exists()
def resolve_url(self, key: str) -> str:
return f"/uploads/{_strip_prefix(key)}"
# ── S3 Storage ─────────────────────────────────────────────────────
class S3Storage:
def __init__(
self,
endpoint_url: str,
bucket: str,
access_key: str,
secret_key: str,
region: str = "auto",
public_url: str = "",
) -> None:
try:
import boto3
except ImportError:
raise ImportError(
"boto3 is required for S3 storage. "
"Install it with: pip install boto3"
)
self.bucket = bucket
self.public_url = public_url.rstrip("/")
self._client = boto3.client(
"s3",
endpoint_url=endpoint_url or None,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region or "auto",
)
def put(self, key: str, data: bytes, content_type: str = "image/jpeg") -> str:
self._client.put_object(
Bucket=self.bucket, Key=key, Body=data, ContentType=content_type,
CacheControl="public, max-age=2592000, immutable",
)
return key
def delete(self, key: str) -> None:
self._client.delete_object(Bucket=self.bucket, Key=key)
def exists(self, key: str) -> bool:
try:
self._client.head_object(Bucket=self.bucket, Key=key)
return True
except Exception:
return False
def resolve_url(self, key: str) -> str:
k = _strip_prefix(key)
if self.public_url:
return f"{self.public_url}/{k}"
# private bucket: generate presigned URL (7 days expiry)
return self._client.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket, "Key": k},
ExpiresIn=86400,
)
# ── Helpers ────────────────────────────────────────────────────────
def _strip_prefix(key: str) -> str:
"""Strip ``/uploads/`` prefix from legacy DB values."""
if key.startswith("/uploads/"):
return key[len("/uploads/"):]
return key
# ── Config loading ─────────────────────────────────────────────────
def load_config() -> dict:
"""Load config.json. Returns empty dict if missing."""
if not CONFIG_PATH.exists():
return {}
try:
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
# ── Singleton accessor ─────────────────────────────────────────────
_backend: StorageBackend | None = None
def get_storage() -> StorageBackend:
"""Get the active storage backend based on config.json."""
global _backend
if _backend is not None:
return _backend
cfg = load_config()
backend = cfg.get("storage", {}).get("backend", "local")
if backend == "s3":
s3 = cfg.get("storage", {}).get("s3", {})
if not s3.get("bucket"):
# fallback to local if S3 is misconfigured
_backend = LocalStorage()
else:
_backend = S3Storage(
endpoint_url=s3.get("endpoint", ""),
bucket=s3["bucket"],
access_key=s3.get("access_key", ""),
secret_key=s3.get("secret_key", ""),
region=s3.get("region", "auto"),
public_url=s3.get("public_url", ""),
)
else:
_backend = LocalStorage()
return _backend
def resolve_url(key: str | None) -> str:
"""Convenience: resolve a possibly-None image key to a URL (or empty)."""
if not key:
return ""
return get_storage().resolve_url(key)
def reset_storage() -> None:
"""Force re-detection on next get_storage() call."""
global _backend
_backend = None
+1 -2
View File
@@ -2,7 +2,6 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}星笺{% endblock %}</title> <title>{% block title %}星笺{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
@@ -24,7 +23,7 @@
<div class="lang-switcher" id="langSwitcher"> <div class="lang-switcher" id="langSwitcher">
<button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button> <button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button>
<div class="lang-dropdown" id="langDropdown"> <div class="lang-dropdown" id="langDropdown">
{% for code, name in get_languages().items() %} {% for code, name in available_languages.items() %}
<form method="post" action="/settings/language"> <form method="post" action="/settings/language">
<input type="hidden" name="language" value="{{ code }}"> <input type="hidden" name="language" value="{{ code }}">
<button type="submit" class="lang-option {% if user.language == code %}active{% endif %}">{{ name }}</button> <button type="submit" class="lang-option {% if user.language == code %}active{% endif %}">{{ name }}</button>
+12 -24
View File
@@ -14,13 +14,6 @@
<span class="dash-stat-label">{{ 'dash.total_cards'|t(user.language) }}</span> <span class="dash-stat-label">{{ 'dash.total_cards'|t(user.language) }}</span>
</div> </div>
</div> </div>
<div class="dash-stat-card">
<div class="dash-stat-icon">📋</div>
<div class="dash-stat-body">
<span class="dash-stat-value dash-pending">{{ pending }}</span>
<span class="dash-stat-label">{{ 'status.pending'|t(user.language) }}</span>
</div>
</div>
<div class="dash-stat-card"> <div class="dash-stat-card">
<div class="dash-stat-icon">📤</div> <div class="dash-stat-icon">📤</div>
<div class="dash-stat-body"> <div class="dash-stat-body">
@@ -54,13 +47,11 @@
{% if total > 0 %} {% if total > 0 %}
<div class="dash-progress"> <div class="dash-progress">
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-seg progress-pending" style="width:{{ pending_pct }}%"></div>
<div class="progress-seg progress-sent" style="width:{{ sent_pct }}%"></div> <div class="progress-seg progress-sent" style="width:{{ sent_pct }}%"></div>
<div class="progress-seg progress-delivered" style="width:{{ delivered_pct }}%"></div> <div class="progress-seg progress-delivered" style="width:{{ delivered_pct }}%"></div>
<div class="progress-seg progress-received" style="width:{{ received_pct }}%"></div> <div class="progress-seg progress-received" style="width:{{ received_pct }}%"></div>
</div> </div>
<div class="progress-legend"> <div class="progress-legend">
<span><i class="dot dot-pending"></i>{{ 'status.pending'|t(user.language) }} {{ pending_pct }}%</span>
<span><i class="dot dot-sent"></i>{{ 'dash.sent'|t(user.language) }} {{ sent_pct }}%</span> <span><i class="dot dot-sent"></i>{{ 'dash.sent'|t(user.language) }} {{ sent_pct }}%</span>
<span><i class="dot dot-delivered"></i>{{ 'dash.delivered'|t(user.language) }} {{ delivered_pct }}%</span> <span><i class="dot dot-delivered"></i>{{ 'dash.delivered'|t(user.language) }} {{ delivered_pct }}%</span>
<span><i class="dot dot-received"></i>{{ 'dash.received'|t(user.language) }} {{ received_pct }}%</span> <span><i class="dot dot-received"></i>{{ 'dash.received'|t(user.language) }} {{ received_pct }}%</span>
@@ -87,22 +78,22 @@
<script> <script>
{% if sent_country_stats %} {% if sent_country_stats %}
new Chart(document.getElementById('chart-sent'), { new Chart(document.getElementById('chart-sent'), {
type: 'pie', type: 'bar',
data: { data: {
labels: [{% for code, _ in sent_country_stats %}'{{ code|flag }} {{ code }}',{% endfor %}], labels: [{% for code, _ in sent_country_stats %}'{{ code|flag }} {{ code }}',{% endfor %}],
datasets: [{ data: [{% for _, count in sent_country_stats %}{{ count }},{% endfor %}], backgroundColor: ['#3b82f6','#f59e0b','#10b981','#8b5cf6','#ef4444','#06b6d4','#f97316','#84cc16','#ec4899','#6366f1'] }] datasets: [{ data: [{% for _, count in sent_country_stats %}{{ count }},{% endfor %}], backgroundColor: '#3b82f6', borderRadius: 4, maxBarThickness: 40 }]
}, },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { padding: 12, usePointStyle: true } } } } options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } }, x: { grid: { display: false } } } }
}); });
{% endif %} {% endif %}
{% if received_country_stats %} {% if received_country_stats %}
new Chart(document.getElementById('chart-received'), { new Chart(document.getElementById('chart-received'), {
type: 'pie', type: 'bar',
data: { data: {
labels: [{% for code, _ in received_country_stats %}'{{ code|flag }} {{ code }}',{% endfor %}], labels: [{% for code, _ in received_country_stats %}'{{ code|flag }} {{ code }}',{% endfor %}],
datasets: [{ data: [{% for _, count in received_country_stats %}{{ count }},{% endfor %}], backgroundColor: ['#8b5cf6','#3b82f6','#f59e0b','#10b981','#ef4444','#06b6d4','#f97316','#84cc16','#ec4899','#6366f1'] }] datasets: [{ data: [{% for _, count in received_country_stats %}{{ count }},{% endfor %}], backgroundColor: '#8b5cf6', borderRadius: 4, maxBarThickness: 40 }]
}, },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { padding: 12, usePointStyle: true } } } } options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } }, x: { grid: { display: false } } } }
}); });
{% endif %} {% endif %}
</script> </script>
@@ -117,14 +108,14 @@ new Chart(document.getElementById('chart-received'), {
<a href="/postcards/{{ pc.id }}" class="recent-item"> <a href="/postcards/{{ pc.id }}" class="recent-item">
<div class="recent-img"> <div class="recent-img">
{% if pc.image_front %} {% if pc.image_front %}
<img src="{{ pc.image_front|image_url }}" alt="" loading="lazy"> <img src="{{ pc.image_front }}" alt="" loading="lazy">
{% else %} {% else %}
<span>📷</span> <span>📷</span>
{% endif %} {% endif %}
</div> </div>
<div class="recent-info"> <div class="recent-info">
<strong>{{ pc.card_number }}</strong> <strong>{{ pc.card_number }}</strong>
<span class="badge badge-{{ pc.status }}">{{ {'pending':'待寄出','sent':'已寄出','delivered':'已送达','received':'已收到'}.get(pc.status) if user.language=='zh' else {'pending':'Pending','sent':'Sent','delivered':'Delivered','received':'Received'}.get(pc.status) }}</span> <span class="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(pc.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(pc.status) }}</span>
</div> </div>
<div style="margin-left:auto;text-align:right;display:flex;gap:1rem;align-items:center"> <div style="margin-left:auto;text-align:right;display:flex;gap:1rem;align-items:center">
<div> <div>
@@ -134,7 +125,7 @@ new Chart(document.getElementById('chart-received'), {
{% endif %} {% endif %}
</div> </div>
<div class="recent-meta" style="margin-left:0"> <div class="recent-meta" style="margin-left:0">
{% if pc.status in ('pending','sent','delivered') %} {% if pc.status in ('sent','delivered') %}
<span>→ {{ pc.recipient_name or '-' }}</span> <span>→ {{ pc.recipient_name or '-' }}</span>
{% else %} {% else %}
<span>← {{ pc.sender_name or '-' }}</span> <span>← {{ pc.sender_name or '-' }}</span>
@@ -144,16 +135,13 @@ new Chart(document.getElementById('chart-received'), {
<div class="recent-meta" style="margin-left:0"> <div class="recent-meta" style="margin-left:0">
<span class="recent-time"> <span class="recent-time">
{% if pc.status == 'sent' and pc.send_time %} {% if pc.status == 'sent' and pc.send_time %}
{% set local_time = pc.send_time|to_local %} {% set delta = (now.date() - pc.send_time.date()).days %}
{% set delta = (now.date() - local_time.date()).days %}
{% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %} {% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %}
{% elif pc.status == 'delivered' and pc.arrival_time %} {% elif pc.status == 'delivered' and pc.arrival_time %}
{% set local_time = pc.arrival_time|to_local %} {% set delta = (now.date() - pc.arrival_time.date()).days %}
{% set delta = (now.date() - local_time.date()).days %}
{% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %} {% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %}
{% elif pc.status == 'received' and pc.receive_time %} {% elif pc.status == 'received' and pc.receive_time %}
{% set local_time = pc.receive_time|to_local %} {% set delta = (now.date() - pc.receive_time.date()).days %}
{% set delta = (now.date() - local_time.date()).days %}
{% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %} {% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %}
{% endif %} {% endif %}
</span> </span>
+7 -8
View File
@@ -3,25 +3,24 @@
{% block nav %} {% block nav %}
<nav class="navbar"> <nav class="navbar">
<a href="/" class="nav-brand">{{ 'brand'|t(lang) }}</a> <a href="/" class="nav-brand">{{ 'brand'|t(lang) }}</a>
<button class="nav-hamburger" onclick="this.nextElementSibling.classList.toggle('open')">☰</button>
<div class="nav-links"> <div class="nav-links">
<div class="lang-switcher" id="langSwitcher"> <div class="lang-switcher" id="langSwitcher">
<button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button> <button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button>
<div class="lang-dropdown" id="langDropdown"> <div class="lang-dropdown" id="langDropdown">
{% for code, name in get_languages().items() %} {% for code, name in available_languages.items() %}
<a href="/?lang={{ code }}" class="lang-option {% if lang == code %}active{% endif %}">{{ name }}</a> <a href="/?lang={{ code }}" class="lang-option {% if lang == code %}active{% endif %}">{{ name }}</a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<a href="/login" class="btn btn-sm">{{ 'index.go_login'|t(lang) }}</a> <a href="/login" class="btn btn-sm" style="margin-left:.5rem">{{ 'index.go_login'|t(lang) }}</a>
<a href="/register" class="btn btn-primary btn-sm">{{ 'index.go_register'|t(lang) }}</a> <a href="/register" class="btn btn-primary btn-sm">{{ 'index.go_register'|t(lang) }}</a>
</div> </div>
</nav> </nav>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="section-header" style="text-align:center"> <div class="section-header">
<h1 style="font-size:1.3rem">📮 {{ 'index.title'|t(lang) }}</h1> <h1>📮 {{ 'index.title'|t(lang) }}</h1>
<p style="color:var(--text-muted);font-size:.85rem;margin-top:.15rem">{{ 'index.subtitle'|t(lang) }}</p> <p style="color:var(--text-muted);margin-top:.25rem">{{ 'index.subtitle'|t(lang) }}</p>
</div> </div>
{% set visible = [] %} {% set visible = [] %}
@@ -34,8 +33,8 @@
{% if visible|length > 0 or has_more %} {% if visible|length > 0 or has_more %}
<div class="showcase-grid" id="showcase-grid"> <div class="showcase-grid" id="showcase-grid">
{% for pc in visible %} {% for pc in visible %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy"> <img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body"> <div class="showcase-card-body">
<div class="showcase-card-meta"> <div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span> {% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
+8 -4
View File
@@ -10,9 +10,13 @@
<div class="detail-actions"> <div class="detail-actions">
<a href="/postcards/{{ postcard.id }}/edit" class="btn">{{ 'pc_detail.edit'|t(user.language) }}</a> <a href="/postcards/{{ postcard.id }}/edit" class="btn">{{ 'pc_detail.edit'|t(user.language) }}</a>
{% if postcard.status == 'pending' %} {% if postcard.status == 'pending' %}
<button type="button" class="btn btn-primary" onclick="if(confirm('{{ 'pc_detail.confirm_sent'|t(user.language) }}'))fetch('/postcards/{{ postcard.id }}/mark-sent',{method:'POST'}).then(function(){location.reload();})">{{ 'pc_detail.mark_sent'|t(user.language) }}</button> <form method="post" action="/postcards/{{ postcard.id }}/mark-sent" class="inline">
<button type="submit" class="btn btn-primary" onclick="return confirm('{{ 'pc_detail.confirm_sent'|t(user.language) }}')">{{ 'pc_detail.mark_sent'|t(user.language) }}</button>
</form>
{% elif postcard.status == 'sent' %} {% elif postcard.status == 'sent' %}
<button type="button" class="btn btn-primary" onclick="if(confirm('{{ 'pc_detail.confirm_delivered'|t(user.language) }}'))fetch('/postcards/{{ postcard.id }}/mark-delivered',{method:'POST'}).then(function(){location.reload();})">{{ 'pc_detail.mark_delivered'|t(user.language) }}</button> <form method="post" action="/postcards/{{ postcard.id }}/mark-delivered" class="inline">
<button type="submit" class="btn btn-primary" onclick="return confirm('{{ 'pc_detail.confirm_delivered'|t(user.language) }}')">{{ 'pc_detail.mark_delivered'|t(user.language) }}</button>
</form>
{% endif %} {% endif %}
<form method="post" action="/postcards/{{ postcard.id }}/delete" class="inline"> <form method="post" action="/postcards/{{ postcard.id }}/delete" class="inline">
<button type="submit" class="btn btn-danger" onclick="return confirm('{{ 'pc_detail.confirm_delete'|t(user.language) }}')">{{ 'pc_detail.delete'|t(user.language) }}</button> <button type="submit" class="btn btn-danger" onclick="return confirm('{{ 'pc_detail.confirm_delete'|t(user.language) }}')">{{ 'pc_detail.delete'|t(user.language) }}</button>
@@ -24,7 +28,7 @@
<div class="image-block"> <div class="image-block">
<h3>{{ 'pc_detail.front'|t(user.language) }}</h3> <h3>{{ 'pc_detail.front'|t(user.language) }}</h3>
{% if postcard.image_front %} {% if postcard.image_front %}
<img src="{{ postcard.image_front|image_url }}" alt="" class="detail-img"> <img src="{{ postcard.image_front }}" alt="" class="detail-img">
{% else %} {% else %}
<div class="detail-img placeholder">{{ 'pc_detail.no_front'|t(user.language) }}</div> <div class="detail-img placeholder">{{ 'pc_detail.no_front'|t(user.language) }}</div>
{% endif %} {% endif %}
@@ -36,7 +40,7 @@
<div class="image-block"> <div class="image-block">
<h3>{{ 'pc_detail.back'|t(user.language) }}</h3> <h3>{{ 'pc_detail.back'|t(user.language) }}</h3>
{% if postcard.image_back %} {% if postcard.image_back %}
<img src="{{ postcard.image_back|image_url }}" alt="" class="detail-img"> <img src="{{ postcard.image_back }}" alt="" class="detail-img">
{% else %} {% else %}
<div class="detail-img placeholder">{{ 'pc_detail.no_back'|t(user.language) }}</div> <div class="detail-img placeholder">{{ 'pc_detail.no_back'|t(user.language) }}</div>
{% endif %} {% endif %}
+19 -33
View File
@@ -23,18 +23,15 @@
<label>{{ 'pc_form.sender'|t(user.language) }} *</label> <label>{{ 'pc_form.sender'|t(user.language) }} *</label>
<input type="text" name="sender_name" value="{{ postcard.sender_name if is_edit else '' }}"> <input type="text" name="sender_name" value="{{ postcard.sender_name if is_edit else '' }}">
<label>{{ 'pc_form.send_time'|t(user.language) }}</label>
<input type="date" name="send_time" value="{{ postcard.send_time.strftime('%Y-%m-%d') if is_edit and postcard.send_time else '' }}">
<label>{{ 'pc_form.receive_time'|t(user.language) }} *</label> <label>{{ 'pc_form.receive_time'|t(user.language) }} *</label>
<input type="date" name="receive_time" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if is_edit and postcard.receive_time else today }}"> <input type="date" name="receive_time" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if is_edit and postcard.receive_time else today }}">
<label>{{ 'pc_form.country'|t(user.language) }}</label>
<input type="text" name="country_to" value="{{ postcard.country_to if is_edit and postcard.country_to else (profile.country if profile else '') }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<label>{{ 'pc_form.country_from'|t(user.language) }}</label> <label>{{ 'pc_form.country_from'|t(user.language) }}</label>
<input type="text" name="country_from" value="{{ postcard.country_from if is_edit and postcard.country_from else '' }}" maxlength="2" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()"> <input type="text" name="country_from" value="{{ postcard.country_from if is_edit and postcard.country_from else '' }}" maxlength="2" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<label>{{ 'pc_form.country_to'|t(user.language) }}</label>
<input type="text" name="country_to" value="{{ postcard.country_to if is_edit and postcard.country_to else (profile.country if profile else '') }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
{% else %} {% else %}
{# ── 寄出表单 ── #} {# ── 寄出表单 ── #}
{% if is_new %} {% if is_new %}
@@ -58,7 +55,7 @@
<div id="fields-sent-delivered"> <div id="fields-sent-delivered">
<label>{{ 'pc_form.recipient'|t(user.language) }} *</label> <label>{{ 'pc_form.recipient'|t(user.language) }} *</label>
<input type="text" name="recipient_name" value="{{ postcard.recipient_name if is_edit else '' }}"> <input type="text" name="recipient_name" value="{{ postcard.recipient_name if is_edit else '' }}">
<label id="label-send-time">{{ 'pc_form.send_time'|t(user.language) }} *</label> <label>{{ 'pc_form.send_time'|t(user.language) }} *</label>
<input type="date" name="send_time" id="input-send-time" value="{{ postcard.send_time.strftime('%Y-%m-%d') if is_edit and postcard.send_time else (today if is_new else '') }}"> <input type="date" name="send_time" id="input-send-time" value="{{ postcard.send_time.strftime('%Y-%m-%d') if is_edit and postcard.send_time else (today if is_new else '') }}">
<label id="label-arrival">{{ 'pc_form.arrival_time'|t(user.language) }}</label> <label id="label-arrival">{{ 'pc_form.arrival_time'|t(user.language) }}</label>
<input type="date" name="arrival_time" id="input-arrival" value="{{ postcard.arrival_time.strftime('%Y-%m-%d') if is_edit and postcard.arrival_time else '' }}" {% if is_new or (is_edit and edit_status=='sent') %}disabled{% endif %}> <input type="date" name="arrival_time" id="input-arrival" value="{{ postcard.arrival_time.strftime('%Y-%m-%d') if is_edit and postcard.arrival_time else '' }}" {% if is_new or (is_edit and edit_status=='sent') %}disabled{% endif %}>
@@ -67,7 +64,7 @@
<label>{{ 'pc_form.country_from'|t(user.language) }}</label> <label>{{ 'pc_form.country_from'|t(user.language) }}</label>
<input type="text" name="country_from" value="{{ postcard.country_from if is_edit and postcard.country_from else (profile.country if profile else '') }}" maxlength="2" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()"> <input type="text" name="country_from" value="{{ postcard.country_from if is_edit and postcard.country_from else (profile.country if profile else '') }}" maxlength="2" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<label>{{ 'pc_form.country_to'|t(user.language) }}</label> <label>{{ 'pc_form.country'|t(user.language) }}</label>
<input type="text" name="country_to" value="{{ postcard.country_to if is_edit and postcard.country_to else '' }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()"> <input type="text" name="country_to" value="{{ postcard.country_to if is_edit and postcard.country_to else '' }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
{% endif %} {% endif %}
@@ -82,25 +79,21 @@
</form> </form>
<script> <script>
{% if is_new and not is_received_form %} {% if is_new and not is_received_form %}
// ── 新建寄出:待寄出时禁用寄出时间,寄出时自动填入 ── // ── 新建寄出:根据日期自动判定状态,可手动覆盖 ──
(function() { (function() {
var sendTime = document.getElementById('input-send-time'); var sendTime = document.getElementById('input-send-time');
var select = document.getElementById('status-select'); var select = document.getElementById('status-select');
var arrival = document.getElementById('input-arrival'); var arrival = document.getElementById('input-arrival');
var labelArrival = document.getElementById('label-arrival'); var labelArrival = document.getElementById('label-arrival');
var labelSend = document.getElementById('label-send-time');
var today = new Date().toISOString().slice(0, 10); var today = new Date().toISOString().slice(0, 10);
function updateSendTime(s) {
if (s === 'pending') {
sendTime.disabled = true;
sendTime.value = '';
if (labelSend) labelSend.textContent = '{{ "pc_form.send_time"|t(user.language) }}';
} else {
sendTime.disabled = false;
if (!sendTime.value) sendTime.value = today; if (!sendTime.value) sendTime.value = today;
if (labelSend) labelSend.textContent = '{{ "pc_form.send_time"|t(user.language) }} *';
} var userOverrode = false;
function autoFromSendTime() {
var v = sendTime.value;
if (!v) return 'sent';
return v > today ? 'pending' : 'sent';
} }
function updateArrival(s) { function updateArrival(s) {
@@ -116,29 +109,22 @@
} }
function sync() { function sync() {
var auto = autoFromSendTime();
if (!userOverrode) select.value = auto;
updateArrival(select.value); updateArrival(select.value);
updateSendTime(select.value);
} }
select.addEventListener('change', sync); select.addEventListener('change', function() { userOverrode = true; updateArrival(select.value); });
sendTime.addEventListener('change', function() { if (!userOverrode) sync(); });
sendTime.addEventListener('input', function() { if (!userOverrode) sync(); });
sync(); sync();
})(); })();
{% elif is_edit and not is_received_form %} {% elif is_edit and not is_received_form %}
// ── 编辑寄出:根据状态切换 send_time / arrival 可用性 ── // ── 编辑寄出:根据状态切换 arrival 可用性 ──
function toggleFields() { function toggleFields() {
var s = document.getElementById('status-select').value; var s = document.getElementById('status-select').value;
var sendTime = document.getElementById('input-send-time');
var labelSend = document.getElementById('label-send-time');
var arrival = document.getElementById('input-arrival'); var arrival = document.getElementById('input-arrival');
var labelArrival = document.getElementById('label-arrival'); var labelArrival = document.getElementById('label-arrival');
var today = new Date().toISOString().slice(0, 10);
if (s === 'pending') {
sendTime.disabled = true;
if (labelSend) labelSend.textContent = '{{ "pc_form.send_time"|t(user.language) }}';
} else {
sendTime.disabled = false;
if (labelSend) labelSend.textContent = '{{ "pc_form.send_time"|t(user.language) }} *';
}
if (s === 'delivered') { if (s === 'delivered') {
arrival.disabled = false; arrival.disabled = false;
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }} *'; labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }} *';
+8 -4
View File
@@ -36,7 +36,7 @@
<a href="/postcards/{{ pc.id }}" class="postcard-row"> <a href="/postcards/{{ pc.id }}" class="postcard-row">
<div class="postcard-row-img"> <div class="postcard-row-img">
{% if pc.image_front %} {% if pc.image_front %}
<img src="{{ pc.image_front|image_url }}" alt="" loading="lazy"> <img src="{{ pc.image_front }}" alt="" loading="lazy">
{% else %} {% else %}
<div class="thumb placeholder">📷</div> <div class="thumb placeholder">📷</div>
{% endif %} {% endif %}
@@ -56,9 +56,13 @@
{% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %} {% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %}
<div class="postcard-row-action"> <div class="postcard-row-action">
{% if pc.status == 'pending' %} {% if pc.status == 'pending' %}
<button type="button" class="btn btn-sm btn-primary" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_sent'|t(user.language) }}'))fetch('/postcards/{{ pc.id }}/mark-sent',{method:'POST'}).then(function(){location.reload();})">{{ 'pc_list.mark_sent'|t(user.language) }}</button> <form method="post" action="/postcards/{{ pc.id }}/mark-sent" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_sent'|t(user.language) }}'))this.submit();">
<button type="submit" class="btn btn-sm btn-primary">{{ 'pc_list.mark_sent'|t(user.language) }}</button>
</form>
{% elif pc.status == 'sent' %} {% elif pc.status == 'sent' %}
<button type="button" class="btn btn-sm btn-primary" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_delivered'|t(user.language) }}'))fetch('/postcards/{{ pc.id }}/mark-delivered',{method:'POST'}).then(function(){location.reload();})">{{ 'pc_list.mark_delivered'|t(user.language) }}</button> <form method="post" action="/postcards/{{ pc.id }}/mark-delivered" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_delivered'|t(user.language) }}'))this.submit();">
<button type="submit" class="btn btn-sm btn-primary">{{ 'pc_list.mark_delivered'|t(user.language) }}</button>
</form>
{% endif %} {% endif %}
</div> </div>
</a> </a>
@@ -74,7 +78,7 @@
<a href="/postcards/{{ pc.id }}" class="postcard-row"> <a href="/postcards/{{ pc.id }}" class="postcard-row">
<div class="postcard-row-img"> <div class="postcard-row-img">
{% if pc.image_front %} {% if pc.image_front %}
<img src="{{ pc.image_front|image_url }}" alt="" loading="lazy"> <img src="{{ pc.image_front }}" alt="" loading="lazy">
{% else %} {% else %}
<div class="thumb placeholder">📷</div> <div class="thumb placeholder">📷</div>
{% endif %} {% endif %}
-4
View File
@@ -34,10 +34,6 @@
<span class="stat-value">{{ e.total }}</span> <span class="stat-value">{{ e.total }}</span>
<span class="stat-label">{{ 'profiles.total'|t(user.language) }}</span> <span class="stat-label">{{ 'profiles.total'|t(user.language) }}</span>
</div> </div>
<div class="stat">
<span class="stat-value badge-pending-text">{{ e.pending }}</span>
<span class="stat-label">{{ 'status.pending'|t(user.language) }}</span>
</div>
<div class="stat"> <div class="stat">
<span class="stat-value badge-sent-text">{{ e.sent }}</span> <span class="stat-value badge-sent-text">{{ e.sent }}</span>
<span class="stat-label">{{ 'dash.sent'|t(user.language) }}</span> <span class="stat-label">{{ 'dash.sent'|t(user.language) }}</span>
+1 -1
View File
@@ -51,7 +51,7 @@
<h3 style="margin-bottom:.75rem">{{ 'settings.language'|t(user.language) }}</h3> <h3 style="margin-bottom:.75rem">{{ 'settings.language'|t(user.language) }}</h3>
<form method="post" action="/settings/language" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap"> <form method="post" action="/settings/language" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
<select name="language" style="width:auto"> <select name="language" style="width:auto">
{% for code, name in get_languages().items() %} {% for code, name in available_languages.items() %}
<option value="{{ code }}" {% if user.language == code %}selected{% endif %}>{{ name }}</option> <option value="{{ code }}" {% if user.language == code %}selected{% endif %}>{{ name }}</option>
{% endfor %} {% endfor %}
</select> </select>
+11 -11
View File
@@ -6,14 +6,14 @@
<div class="lang-switcher" id="langSwitcher"> <div class="lang-switcher" id="langSwitcher">
<button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button> <button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button>
<div class="lang-dropdown" id="langDropdown"> <div class="lang-dropdown" id="langDropdown">
{% for code, name in get_languages().items() %} {% for code, name in available_languages.items() %}
<a href="?lang={{ code }}" class="lang-option {% if lang == code %}active{% endif %}">{{ name }}</a> <a href="?lang={{ code }}" class="lang-option {% if lang == code %}active{% endif %}">{{ name }}</a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div> </div>
<div class="section-header" style="text-align:center"> <div class="section-header">
<h1 style="font-size:1.3rem">📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1> <h1>📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1>
</div> </div>
{% set sent_visible = [] %} {% set sent_visible = [] %}
@@ -45,8 +45,8 @@
{% if mode == 'flat' %} {% if mode == 'flat' %}
<div class="showcase-grid" id="grid-sent"> <div class="showcase-grid" id="grid-sent">
{% for pc in sent_visible %} {% for pc in sent_visible %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy"> <img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body"> <div class="showcase-card-body">
<div class="showcase-card-meta"> <div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span> {% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
@@ -70,8 +70,8 @@
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3> <h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
<div class="showcase-grid"> <div class="showcase-grid">
{% for pc in g_sent %} {% for pc in g_sent %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy"> <img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body"> <div class="showcase-card-body">
<div class="showcase-card-meta"> <div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span> {% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
@@ -96,8 +96,8 @@
{% if mode == 'flat' %} {% if mode == 'flat' %}
<div class="showcase-grid" id="grid-received"> <div class="showcase-grid" id="grid-received">
{% for pc in received_visible %} {% for pc in received_visible %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy"> <img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body"> <div class="showcase-card-body">
<div class="showcase-card-meta"> <div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span> {% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
@@ -121,8 +121,8 @@
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3> <h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
<div class="showcase-grid"> <div class="showcase-grid">
{% for pc in g_received %} {% for pc in g_received %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy"> <img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body"> <div class="showcase-card-body">
<div class="showcase-card-meta"> <div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span> {% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
-13
View File
@@ -1,13 +0,0 @@
{
"storage": {
"backend": "local",
"s3": {
"endpoint": "https://s3.amazonaws.com",
"bucket": "my-mailova-bucket",
"region": "auto",
"access_key": "AKIA...",
"secret_key": "...",
"public_url": "https://cdn.example.com/mailova"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
"""One-time export: convert i18n.py translations to JSON files."""
import json
from pathlib import Path
from app.i18n import _translations
locales_dir = Path(__file__).resolve().parent / "app" / "locales"
locales_dir.mkdir(exist_ok=True)
langs: dict[str, dict[str, str]] = {}
for key, values in _translations.items():
for lang, text in values.items():
langs.setdefault(lang, {})[key] = text
for lang, data in sorted(langs.items()):
out = locales_dir / f"{lang}.json"
with open(out, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"Exported {len(data)} keys -> {out.name}")
print("Done!")
-1
View File
@@ -6,4 +6,3 @@ jinja2
bcrypt bcrypt
python-jose[cryptography] python-jose[cryptography]
aiofiles aiofiles
boto3
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.