Compare commits

..
12 Commits
Author SHA1 Message Date
earthjasonlin f27c263dd4 fix(storage): reduce presigned URL expiry from 7 days to 1 day 2026-07-18 08:25:13 +08:00
earthjasonlin d30fd59ade fix(storage): use presigned URLs for private S3 buckets (7-day expiry) 2026-07-18 08:24:40 +08:00
earthjasonlin 2dc68ff408 feat(cache): add 30-day browser cache for uploaded images (middleware + S3 CacheControl) 2026-07-18 08:23:13 +08:00
earthjasonlin 54d778971d fix(storage): allow S3 without public_url, auto-construct path-style URL from endpoint 2026-07-18 08:17:35 +08:00
earthjasonlin 4787fdbf22 refactor(config): consolidate into single config.json, storage backend selectable 2026-07-18 08:05:39 +08:00
earthjasonlin 3a931fe752 feat(storage): add S3 image storage support with file-based config and migration script 2026-07-18 08:03:18 +08:00
earthjasonlin ddeec36a01 fix(dashboard): exclude pending postcards from sent stat count 2026-07-15 19:40:42 +08:00
earthjasonlin 6636af9990 fix(dashboard): hide time for pending postcards in recent activity 2026-07-15 19:38:52 +08:00
earthjasonlin ad85b85e1e fix(dashboard+list): pending status sort first, stats count, badge/recipient/time display 2026-07-15 19:36:31 +08:00
earthjasonlin abec829117 fix(i18n): repair broken pc_form.country_from key in postcard form template 2026-07-15 19:29:27 +08:00
earthjasonlin 0a8f8c4904 fix(i18n): rename country labels to '寄出国/到达国' to avoid ambiguity 2026-07-15 19:27:26 +08:00
earthjasonlin 2fe3ea5249 fix(postcard): reorder country fields to from→to on both create forms 2026-07-15 19:24:02 +08:00
17 changed files with 290 additions and 65 deletions

No files matched your search

+5 -1
View File
@@ -6,4 +6,8 @@ uploads/*
*.db *.db
.env .env
server.log server.log
scripts/ s3_config.json
config.json
scripts/fix_*.py
scripts/migrate_*.py
scripts/test_*.py
+1 -1
View File
@@ -1,5 +1,5 @@
@echo off @echo off
cd /d "%~dp0" cd /d "%~dp0"
call .venv\Scripts\activate.bat call .venv\Scripts\activate.bat
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload @rem python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
pause pause
+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": "Country", "pc_form.country_from": "From country",
"pc_form.country_from": "From", "pc_form.country_to": "To country",
"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": "国家/地区", "pc_form.country_from": "寄出国",
"pc_form.country_from": "寄出地", "pc_form.country_to": "到达国",
"pc_form.send_time": "寄出时间", "pc_form.send_time": "寄出时间",
"pc_form.arrival_time": "到达时间", "pc_form.arrival_time": "到达时间",
"pc_form.sender": "发件人", "pc_form.sender": "发件人",
+14 -1
View File
@@ -1,7 +1,8 @@
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, Request, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import UPLOAD_DIR from app.config import UPLOAD_DIR
from app.database import init_db from app.database import init_db
@@ -13,6 +14,18 @@ UPLOAD_DIR.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static") app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static")
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads") app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
# Browser cache: 30 days for uploaded images (they never change)
class CacheControlMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response: Response = await call_next(request)
if request.url.path.startswith("/uploads/"):
response.headers["Cache-Control"] = "public, max-age=2592000, immutable"
return response
app.add_middleware(CacheControlMiddleware)
app.include_router(web.router) app.include_router(web.router)
app.include_router(api.router) app.include_router(api.router)
+7 -6
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,
) )
from app.config import UPLOAD_DIR # config removed: image storage now via app.storage
router = APIRouter(prefix="/api/v1", tags=["api"]) router = APIRouter(prefix="/api/v1", tags=["api"])
@@ -380,15 +380,16 @@ 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"
save_name = f"pc_{pc.id}_{side}{ext}" key = f"pc_{pc.id}_{side}{ext}"
save_path = UPLOAD_DIR / save_name
content = await file.read() content = await file.read()
save_path.write_bytes(content) storage.put(key, content)
if side == "front": if side == "front":
pc.image_front = f"/uploads/{save_name}" pc.image_front = key
else: else:
pc.image_back = f"/uploads/{save_name}" pc.image_back = key
db.commit() db.commit()
db.refresh(pc) db.refresh(pc)
return pc return pc
+14 -9
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, UPLOAD_DIR from app.config import SECRET_KEY, SESSION_COOKIE_NAME
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,6 +86,10 @@ 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["available_languages"] = _get_languages() templates.env.globals["available_languages"] = _get_languages()
@@ -188,7 +192,7 @@ def api_public_cards(
"page": page, "page": page,
"has_more": start + limit < total, "has_more": start + limit < total,
"cards": [{ "cards": [{
"image_front": c.image_front, "image_front": _resolve_image_url(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,
@@ -230,7 +234,7 @@ def api_public_cards_user(
"page": page, "page": page,
"has_more": start + limit < total, "has_more": start + limit < total,
"cards": [{ "cards": [{
"image_front": c.image_front, "image_front": _resolve_image_url(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],
@@ -707,8 +711,8 @@ 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: sent=0, delivered=1, received=2 # status sort order: pending=0, sent=1, delivered=2, received=3
_status_order = {"sent": 0, "delivered": 1, "received": 2} _status_order = {"pending": 0, "sent": 1, "delivered": 2, "received": 3}
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
@@ -959,12 +963,13 @@ 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"
filename = f"{uuid4().hex}{ext}" key = f"{uuid4().hex}{ext}"
dest = UPLOAD_DIR / filename
content = await file.read() content = await file.read()
dest.write_bytes(content) storage.put(key, content)
setattr(pc, f"image_{side}", f"/uploads/{filename}") setattr(pc, f"image_{side}", key)
db.commit() db.commit()
return _redirect(f"/postcards/{postcard_id}") return _redirect(f"/postcards/{postcard_id}")
+15 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel, model_validator
# ---------- Profile ---------- # ---------- Profile ----------
@@ -23,6 +23,13 @@ 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 ----------
@@ -68,3 +75,10 @@ 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
+196
View File
@@ -0,0 +1,196 @@
"""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:
import 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
+3 -3
View File
@@ -108,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 }}" alt="" loading="lazy"> <img src="{{ pc.image_front|image_url }}" 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 }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(pc.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(pc.status) }}</span> <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>
</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>
@@ -125,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 ('sent','delivered') %} {% if pc.status in ('pending','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>
+2 -2
View File
@@ -33,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 }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy"> <img src="{{ pc.image_front|image_url }}" 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>
+2 -2
View File
@@ -28,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 }}" alt="" class="detail-img"> <img src="{{ postcard.image_front|image_url }}" 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 %}
@@ -40,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 }}" alt="" class="detail-img"> <img src="{{ postcard.image_back|image_url }}" 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 %}
+4 -4
View File
@@ -26,12 +26,12 @@
<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 %}
@@ -64,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'|t(user.language) }}</label> <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 '' }}" 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 %}
+2 -2
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 }}" alt="" loading="lazy"> <img src="{{ pc.image_front|image_url }}" alt="" loading="lazy">
{% else %} {% else %}
<div class="thumb placeholder">📷</div> <div class="thumb placeholder">📷</div>
{% endif %} {% endif %}
@@ -78,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 }}" alt="" loading="lazy"> <img src="{{ pc.image_front|image_url }}" alt="" loading="lazy">
{% else %} {% else %}
<div class="thumb placeholder">📷</div> <div class="thumb placeholder">📷</div>
{% endif %} {% endif %}
+8 -8
View File
@@ -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 }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy"> <img src="{{ pc.image_front|image_url }}" 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 }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy"> <img src="{{ pc.image_front|image_url }}" 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 }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy"> <img src="{{ pc.image_front|image_url }}" 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 }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy"> <img src="{{ pc.image_front|image_url }}" 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
@@ -0,0 +1,13 @@
{
"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
@@ -1,21 +0,0 @@
"""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!")