Compare commits

..
0 Commits
22 changed files with 187 additions and 469 deletions

No files matched your search

+1 -5
View File
@@ -6,8 +6,4 @@ uploads/*
*.db
.env
server.log
s3_config.json
config.json
scripts/fix_*.py
scripts/migrate_*.py
scripts/test_*.py
scripts/
+2 -2
View File
@@ -123,8 +123,8 @@
"pc_form.number_hint": "Format: AB-1234567",
"pc_form.status": "Status",
"pc_form.recipient": "Recipient",
"pc_form.country_from": "From country",
"pc_form.country_to": "To country",
"pc_form.country": "Country",
"pc_form.country_from": "From",
"pc_form.send_time": "Sent date",
"pc_form.arrival_time": "Arrival date",
"pc_form.sender": "Sender",
+2 -2
View File
@@ -123,8 +123,8 @@
"pc_form.number_hint": "格式:AB-1234567",
"pc_form.status": "状态",
"pc_form.recipient": "收件人",
"pc_form.country_from": "寄出国",
"pc_form.country_to": "到达国",
"pc_form.country": "国家/地区",
"pc_form.country_from": "寄出地",
"pc_form.send_time": "寄出时间",
"pc_form.arrival_time": "到达时间",
"pc_form.sender": "发件人",
+1 -19
View File
@@ -1,7 +1,6 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.config import UPLOAD_DIR
@@ -11,24 +10,7 @@ from app.routers import web, api
app = FastAPI(title="Mailova 星笺")
UPLOAD_DIR.mkdir(exist_ok=True)
STATIC_DIR = Path(__file__).resolve().parent / "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("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static")
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
app.include_router(web.router)
+6 -7
View File
@@ -13,7 +13,7 @@ from app.models import (
PERM_POSTCARDS_READ, PERM_POSTCARDS_WRITE,
PERM_IMAGES_UPLOAD,
)
# config removed: image storage now via app.storage
from app.config import UPLOAD_DIR
router = APIRouter(prefix="/api/v1", tags=["api"])
@@ -380,16 +380,15 @@ async def upload_image(
ak: ApiKey = Depends(require_scopes(PERM_IMAGES_UPLOAD)),
):
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"
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()
storage.put(key, content)
save_path.write_bytes(content)
if side == "front":
pc.image_front = key
pc.image_front = f"/uploads/{save_name}"
else:
pc.image_back = key
pc.image_back = f"/uploads/{save_name}"
db.commit()
db.refresh(pc)
return pc
+77 -77
View File
@@ -16,7 +16,7 @@ from app.auth import (
redirect_if_not_logged_in,
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 random import choice
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
# 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
templates.env.globals["get_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)
templates.env.globals["available_languages"] = _get_languages()
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:
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,
})
@@ -203,7 +188,7 @@ def api_public_cards(
"page": page,
"has_more": start + limit < total,
"cards": [{
"image_front": _resolve_image_url(c.image_front),
"image_front": c.image_front,
"country_from": c.country_from,
"country_to": c.country_to,
"status": c.status,
@@ -245,7 +230,7 @@ def api_public_cards_user(
"page": page,
"has_more": start + limit < total,
"cards": [{
"image_front": _resolve_image_url(c.image_front),
"image_front": c.image_front,
"country_from": c.country_from,
"country_to": c.country_to,
} 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)
if user:
return _redirect("/dashboard")
return _render(request, "login.html", {"request": request})
return templates.TemplateResponse("login.html", {"request": request})
@router.post("/login")
@@ -274,7 +259,8 @@ def login_submit(
cleanup_expired_sessions(db)
user = db.query(User).filter(User.username == username).first()
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"
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)
if user:
return _redirect("/dashboard")
return _render(request, "register.html", {"request": request})
return templates.TemplateResponse("register.html", {"request": request})
@router.post("/register")
@@ -308,24 +294,30 @@ def register_submit(
# Validate invite code
code_obj = db.query(InviteCode).filter(InviteCode.code == invite_code).first()
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():
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:
return _render(request, "register.html", {"error": "邀请码使用次数已用尽"}, status_code=400
return templates.TemplateResponse(
"register.html", {"request": request, "error": "邀请码使用次数已用尽"}, status_code=400
)
# Validate passwords
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:
return _render(request, "register.html", {"error": "密码至少6个字符"}, status_code=400
return templates.TemplateResponse(
"register.html", {"request": request, "error": "密码至少6个字符"}, status_code=400
)
# Check username
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
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):
ctx = _admin_context(user, db, active_tab, **extra)
ctx["request"] = request
return _render(request, "admin.html", ctx)
return templates.TemplateResponse("admin.html", _admin_context(user, db, active_tab, request=request, **extra))
@router.get("/admin", response_class=HTMLResponse)
@@ -507,7 +497,8 @@ def settings_page(
for p in profiles:
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()
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,
"api_keys": api_keys,
})
@@ -521,11 +512,13 @@ def change_username(
db: Session = Depends(get_db),
):
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
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),
):
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:
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:
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)
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)
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")
delivered = sum(1 for c in all_cards if c.status == "delivered")
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
if total > 0:
pending_pct = round(pending / total * 100)
sent_pct = round(sent / total * 100)
delivered_pct = round(delivered / total * 100)
received_pct = round(received / total * 100)
else:
pending_pct = sent_pct = delivered_pct = received_pct = 0
sent_pct = delivered_pct = received_pct = 0
# country stats split by sent/received
sent_country_counts: dict[str, int] = {}
received_country_counts: dict[str, int] = {}
for c in all_cards:
if not c.country_to:
continue
if c.status in ("pending", "sent", "delivered"):
if not c.country_to:
continue
sent_country_counts[c.country_to] = sent_country_counts.get(c.country_to, 0) + 1
elif c.status == "received":
if not c.country_from:
continue
received_country_counts[c.country_from] = received_country_counts.get(c.country_from, 0) + 1
received_country_counts[c.country_to] = received_country_counts.get(c.country_to, 0) + 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])
from datetime import datetime, timezone as _tz
return _render(request, "dashboard.html", {"user": user, "profiles": profiles,
"total": total, "pending": pending, "sent": sent, "delivered": delivered, "received": received,
return templates.TemplateResponse("dashboard.html", {
"request": request, "user": user, "profiles": profiles,
"total": total, "sent": sent, "delivered": delivered, "received": received,
"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,
"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()
extras[p.id] = {
"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"),
"delivered": sum(1 for c in cards if c.status == "delivered"),
"received": sum(1 for c in cards if c.status == "received"),
"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")
@@ -714,15 +707,17 @@ def postcard_list(
# 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()})
# status sort order: pending=0, sent=1, delivered=2, received=3
_status_order = {"pending": 0, "sent": 1, "delivered": 2, "received": 3}
# status sort order: sent=0, delivered=1, received=2
_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()))
# split into sent and received
sent = [pc for pc in postcards if pc.status in ("pending", "sent", "delivered")]
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:
return _redirect("/profiles")
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")
@@ -761,7 +756,8 @@ def postcard_create(
Postcard.card_number == cn, Profile.user_id == user.id
).first()
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),
})
@@ -805,7 +801,7 @@ def postcard_detail(request: Request, postcard_id: int, user: User = Depends(get
)
if not pc:
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)
@@ -818,7 +814,7 @@ def postcard_edit(request: Request, postcard_id: int, user: User = Depends(get_c
)
if not pc:
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")
@@ -853,7 +849,8 @@ def postcard_update(
Profile.user_id == user.id
).first()
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),
})
@@ -900,7 +897,7 @@ def postcard_delete(postcard_id: int, user: User = Depends(get_current_user_web)
@router.post("/postcards/{postcard_id}/mark-delivered")
async def mark_delivered(
def mark_delivered(
postcard_id: int,
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
@@ -912,16 +909,16 @@ async def mark_delivered(
.first()
)
if not pc:
return JSONResponse({"ok": False}, status_code=404)
return _redirect("/profiles")
pc.status = "delivered"
if not pc.arrival_time:
pc.arrival_time = datetime.now()
db.commit()
return JSONResponse({"ok": True})
return _redirect(f"/postcards/{postcard_id}")
@router.post("/postcards/{postcard_id}/mark-sent")
async def mark_sent(
def mark_sent(
postcard_id: int,
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
@@ -934,12 +931,12 @@ async def mark_sent(
.first()
)
if not pc:
return JSONResponse({"ok": False}, status_code=404)
return _redirect("/profiles")
pc.status = "sent"
if not pc.send_time:
pc.send_time = datetime.now()
db.commit()
return JSONResponse({"ok": True})
return _redirect(f"/postcards/{postcard_id}")
# ---------- Image Upload ----------
@@ -962,13 +959,12 @@ async def upload_image(
)
if not pc:
return _redirect("/profiles")
from app.storage import get_storage
storage = get_storage()
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()
storage.put(key, content)
setattr(pc, f"image_{side}", key)
dest.write_bytes(content)
setattr(pc, f"image_{side}", f"/uploads/{filename}")
db.commit()
return _redirect(f"/postcards/{postcard_id}")
@@ -999,7 +995,8 @@ def profile_showcase_page(
if not p:
return _redirect("/settings?tab=showcase")
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})
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)
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,
"lang": visitor_lang, "available_languages": _avail,
})
@@ -1101,7 +1099,8 @@ def apikey_create(
db.commit()
db.refresh(ak)
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",
})
@@ -1149,5 +1148,6 @@ def api_docs_page(
user: User = Depends(get_current_user_web),
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 pydantic import BaseModel, model_validator
from pydantic import BaseModel
# ---------- Profile ----------
@@ -23,13 +23,6 @@ class ProfileOut(BaseModel):
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 ----------
@@ -75,10 +68,3 @@ class PostcardOut(BaseModel):
updated_at: datetime
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-value { font-size: 1.15rem; font-weight: 700; line-height: 1.2; }
.dash-stat-label { font-size: .68rem; color: var(--text-muted); }
.dash-pending { color: var(--primary); }
.dash-sent { color: var(--sent); }
.dash-delivered { color: var(--delivered); }
.dash-received { color: var(--received); }
.dash-progress { margin-bottom: 2rem; }
.progress-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--border); }
.progress-seg { height: 100%; }
.progress-pending { background: var(--primary); }
.progress-sent { background: var(--sent); }
.progress-delivered { background: var(--delivered); }
.progress-received { background: var(--received); }
.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-pending { background: var(--primary); }
.dot-sent { background: var(--sent); }
.dot-delivered { background: var(--delivered); }
.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-label { font-size: .75rem; color: var(--text-muted); }
.badge-sent-text { color: var(--sent); }
.badge-pending-text { color: var(--primary); }
.badge-delivered-text { color: var(--delivered); }
.badge-received-text { color: var(--received); }
.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; }
.card-grid { grid-template-columns: 1fr; }
.postcard-row-info { flex-wrap: wrap; gap: .35rem; }
/* Navbar */
.navbar { padding: .5rem .75rem; }
.nav-brand { font-size: .9rem; }
.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 */
.navbar { padding: .5rem 1rem; }
.nav-links { gap: .75rem; }
.nav-links a { font-size: .8rem; }
.container { margin: 1rem auto; padding: 0 1rem; }
.dash-stats { display: grid; grid-template-columns: 1fr 1fr; }
.dash-stat-card { min-width: 0; }
.dash-sections { grid-template-columns: 1fr; }
.recent-meta > span:nth-child(-n+2) { display: none; }
.recent-meta .recent-time { display: inline; }
.recent-item { gap: .5rem; padding: .5rem; }
/* Section header */
.section-header { flex-direction: column; gap: .5rem; text-align: center; }
.section-header { flex-direction: column; gap: .75rem; }
.section-header .btn { width: auto; }
/* Detail */
.detail-title-row h1 { font-size: 1.2rem; }
.btn-back { padding: .25rem .5rem; font-size: .85rem; }
/* Profiles */
.profile-grid { grid-template-columns: 1fr; }
.profile-card-stats { gap: .75rem; flex-wrap: wrap; }
/* Showcase / Landing grid — 2 columns on mobile */
.showcase-grid { grid-template-columns: repeat(2, 1fr); gap: .5rem; }
.showcase-card-body { padding: .5rem; }
.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; }
.showcase-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: .5rem; }
.showcase-card-meta { flex-wrap: wrap; }
.detail-title-row h1 { font-size: 1.2rem; }
.btn-back { padding: .25rem .5rem; font-size: .85rem; }
}
/* 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">
<head>
<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">
<title>{% block title %}星笺{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
@@ -24,7 +23,7 @@
<div class="lang-switcher" id="langSwitcher">
<button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button>
<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">
<input type="hidden" name="language" value="{{ code }}">
<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>
</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-icon">📤</div>
<div class="dash-stat-body">
@@ -54,13 +47,11 @@
{% if total > 0 %}
<div class="dash-progress">
<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-delivered" style="width:{{ delivered_pct }}%"></div>
<div class="progress-seg progress-received" style="width:{{ received_pct }}%"></div>
</div>
<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-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>
@@ -87,22 +78,22 @@
<script>
{% if sent_country_stats %}
new Chart(document.getElementById('chart-sent'), {
type: 'pie',
type: 'bar',
data: {
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 %}
{% if received_country_stats %}
new Chart(document.getElementById('chart-received'), {
type: 'pie',
type: 'bar',
data: {
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 %}
</script>
@@ -117,14 +108,14 @@ new Chart(document.getElementById('chart-received'), {
<a href="/postcards/{{ pc.id }}" class="recent-item">
<div class="recent-img">
{% if pc.image_front %}
<img src="{{ pc.image_front|image_url }}" alt="" loading="lazy">
<img src="{{ pc.image_front }}" alt="" loading="lazy">
{% else %}
<span>📷</span>
{% endif %}
</div>
<div class="recent-info">
<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 style="margin-left:auto;text-align:right;display:flex;gap:1rem;align-items:center">
<div>
@@ -134,7 +125,7 @@ new Chart(document.getElementById('chart-received'), {
{% endif %}
</div>
<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>
{% else %}
<span>← {{ pc.sender_name or '-' }}</span>
@@ -144,16 +135,13 @@ new Chart(document.getElementById('chart-received'), {
<div class="recent-meta" style="margin-left:0">
<span class="recent-time">
{% if pc.status == 'sent' and pc.send_time %}
{% set local_time = pc.send_time|to_local %}
{% set delta = (now.date() - local_time.date()).days %}
{% set delta = (now.date() - pc.send_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 %}
{% elif pc.status == 'delivered' and pc.arrival_time %}
{% set local_time = pc.arrival_time|to_local %}
{% set delta = (now.date() - local_time.date()).days %}
{% set delta = (now.date() - pc.arrival_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 %}
{% elif pc.status == 'received' and pc.receive_time %}
{% set local_time = pc.receive_time|to_local %}
{% set delta = (now.date() - local_time.date()).days %}
{% set delta = (now.date() - pc.receive_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 %}
{% endif %}
</span>
+7 -8
View File
@@ -3,25 +3,24 @@
{% block nav %}
<nav class="navbar">
<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="lang-switcher" id="langSwitcher">
<button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button>
<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>
{% endfor %}
</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>
</div>
</nav>
{% endblock %}
{% block content %}
<div class="section-header" style="text-align:center">
<h1 style="font-size:1.3rem">📮 {{ 'index.title'|t(lang) }}</h1>
<p style="color:var(--text-muted);font-size:.85rem;margin-top:.15rem">{{ 'index.subtitle'|t(lang) }}</p>
<div class="section-header">
<h1>📮 {{ 'index.title'|t(lang) }}</h1>
<p style="color:var(--text-muted);margin-top:.25rem">{{ 'index.subtitle'|t(lang) }}</p>
</div>
{% set visible = [] %}
@@ -34,8 +33,8 @@
{% if visible|length > 0 or has_more %}
<div class="showcase-grid" id="showcase-grid">
{% for pc in visible %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body">
<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>
+8 -4
View File
@@ -10,9 +10,13 @@
<div class="detail-actions">
<a href="/postcards/{{ postcard.id }}/edit" class="btn">{{ 'pc_detail.edit'|t(user.language) }}</a>
{% 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' %}
<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 %}
<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>
@@ -24,7 +28,7 @@
<div class="image-block">
<h3>{{ 'pc_detail.front'|t(user.language) }}</h3>
{% 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 %}
<div class="detail-img placeholder">{{ 'pc_detail.no_front'|t(user.language) }}</div>
{% endif %}
@@ -36,7 +40,7 @@
<div class="image-block">
<h3>{{ 'pc_detail.back'|t(user.language) }}</h3>
{% 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 %}
<div class="detail-img placeholder">{{ 'pc_detail.no_back'|t(user.language) }}</div>
{% endif %}
+19 -33
View File
@@ -23,18 +23,15 @@
<label>{{ 'pc_form.sender'|t(user.language) }} *</label>
<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>
<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>
<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 %}
{# ── 寄出表单 ── #}
{% if is_new %}
@@ -58,7 +55,7 @@
<div id="fields-sent-delivered">
<label>{{ 'pc_form.recipient'|t(user.language) }} *</label>
<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 '') }}">
<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 %}>
@@ -67,7 +64,7 @@
<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()">
<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()">
{% endif %}
@@ -82,25 +79,21 @@
</form>
<script>
{% if is_new and not is_received_form %}
// ── 新建寄出:待寄出时禁用寄出时间,寄出时自动填入 ──
// ── 新建寄出:根据日期自动判定状态,可手动覆盖 ──
(function() {
var sendTime = document.getElementById('input-send-time');
var select = document.getElementById('status-select');
var arrival = document.getElementById('input-arrival');
var labelArrival = document.getElementById('label-arrival');
var labelSend = document.getElementById('label-send-time');
var today = new Date().toISOString().slice(0, 10);
if (!sendTime.value) sendTime.value = today;
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 (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) {
@@ -116,29 +109,22 @@
}
function sync() {
var auto = autoFromSendTime();
if (!userOverrode) select.value = auto;
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();
})();
{% elif is_edit and not is_received_form %}
// ── 编辑寄出:根据状态切换 send_time / arrival 可用性 ──
// ── 编辑寄出:根据状态切换 arrival 可用性 ──
function toggleFields() {
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 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') {
arrival.disabled = false;
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">
<div class="postcard-row-img">
{% if pc.image_front %}
<img src="{{ pc.image_front|image_url }}" alt="" loading="lazy">
<img src="{{ pc.image_front }}" alt="" loading="lazy">
{% else %}
<div class="thumb placeholder">📷</div>
{% endif %}
@@ -56,9 +56,13 @@
{% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %}
<div class="postcard-row-action">
{% 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' %}
<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 %}
</div>
</a>
@@ -74,7 +78,7 @@
<a href="/postcards/{{ pc.id }}" class="postcard-row">
<div class="postcard-row-img">
{% if pc.image_front %}
<img src="{{ pc.image_front|image_url }}" alt="" loading="lazy">
<img src="{{ pc.image_front }}" alt="" loading="lazy">
{% else %}
<div class="thumb placeholder">📷</div>
{% endif %}
-4
View File
@@ -34,10 +34,6 @@
<span class="stat-value">{{ e.total }}</span>
<span class="stat-label">{{ 'profiles.total'|t(user.language) }}</span>
</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">
<span class="stat-value badge-sent-text">{{ e.sent }}</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>
<form method="post" action="/settings/language" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
<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>
{% endfor %}
</select>
+11 -11
View File
@@ -6,14 +6,14 @@
<div class="lang-switcher" id="langSwitcher">
<button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button>
<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>
{% endfor %}
</div>
</div>
</div>
<div class="section-header" style="text-align:center">
<h1 style="font-size:1.3rem">📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1>
<div class="section-header">
<h1>📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1>
</div>
{% set sent_visible = [] %}
@@ -45,8 +45,8 @@
{% if mode == 'flat' %}
<div class="showcase-grid" id="grid-sent">
{% for pc in sent_visible %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body">
<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>
@@ -70,8 +70,8 @@
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
<div class="showcase-grid">
{% for pc in g_sent %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body">
<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>
@@ -96,8 +96,8 @@
{% if mode == 'flat' %}
<div class="showcase-grid" id="grid-received">
{% for pc in received_visible %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body">
<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>
@@ -121,8 +121,8 @@
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
<div class="showcase-grid">
{% for pc in g_received %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body">
<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>
-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
python-jose[cryptography]
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.