Compare commits

..
0 Commits
23 changed files with 318 additions and 1161 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/
+1 -18
View File
@@ -3,11 +3,6 @@
"name": "English"
},
"brand": "📮 Mailova",
"index.title": "Mailova",
"index.subtitle": "Postcards from around the world",
"index.go_login": "Login",
"index.go_register": "Register",
"index.empty": "No public postcards yet",
"nav.profiles": "Profiles",
"nav.settings": "Settings",
"nav.invites": "Invites",
@@ -65,7 +60,6 @@
"profiles.total": "Total",
"profiles.countries": "Countries",
"profiles.notes_ph": "Notes (private)",
"profiles.country_ph": "Country",
"profiles.save": "Save",
"profiles.created_at": "Created",
"profiles.view": "View",
@@ -73,23 +67,17 @@
"profiles.confirm_delete": "Delete this profile?",
"pc_list.title": "'s postcards",
"pc_list.new": "+ New postcard",
"pc_list.new_sent": "✉️ Sent",
"pc_list.new_received": "📬 Received",
"pc_list.duplicate": "Card number already exists",
"pc_list.all_countries": "All countries",
"pc_list.filter": "Filter",
"pc_list.tab_sent": "Sent",
"pc_list.tab_received": "Received",
"pc_list.mark_sent": "Send",
"pc_list.confirm_sent": "Mark as sent?",
"pc_list.mark_delivered": "Delivered",
"pc_list.confirm_delivered": "Mark as delivered?",
"pc_list.empty_sent": "No sent postcards.",
"pc_list.empty_received": "No received postcards.",
"pc_detail.title": "Postcard detail",
"pc_detail.edit": "Edit",
"pc_detail.mark_sent": "Send",
"pc_detail.confirm_sent": "Mark as sent?",
"pc_detail.mark_delivered": "Mark delivered",
"pc_detail.confirm_delivered": "Mark as delivered?",
"pc_detail.delete": "Delete",
@@ -104,7 +92,6 @@
"pc_detail.status": "Status",
"pc_detail.recipient": "Recipient",
"pc_detail.country": "Country",
"pc_detail.route": "Route",
"pc_detail.send_time": "Sent date",
"pc_detail.arrival_time": "Arrival date",
"pc_detail.sender": "Sender",
@@ -112,19 +99,15 @@
"pc_detail.profile": "Profile",
"pc_detail.notes": "Notes",
"status.sent": "Sent",
"status.pending": "Pending",
"status.delivered": "Delivered",
"status.received": "Received",
"pc_form.edit": "Edit postcard",
"pc_form.new": "New postcard",
"pc_form.new_sent": "New sent",
"pc_form.new_received": "New received",
"pc_form.number": "Number",
"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.send_time": "Sent date",
"pc_form.arrival_time": "Arrival date",
"pc_form.sender": "Sender",
+1 -18
View File
@@ -3,11 +3,6 @@
"name": "中文"
},
"brand": "📮 星笺",
"index.title": "星笺",
"index.subtitle": "来自世界各地的明信片",
"index.go_login": "登录",
"index.go_register": "注册",
"index.empty": "暂无公开明信片",
"nav.profiles": "Profile",
"nav.settings": "设置",
"nav.invites": "邀请码管理",
@@ -65,7 +60,6 @@
"profiles.total": "总计",
"profiles.countries": "国家",
"profiles.notes_ph": "备注(仅自己可见)",
"profiles.country_ph": "国家",
"profiles.save": "保存",
"profiles.created_at": "创建于",
"profiles.view": "查看",
@@ -73,23 +67,17 @@
"profiles.confirm_delete": "确定删除此 Profile?",
"pc_list.title": " 的明信片",
"pc_list.new": "+ 新明信片",
"pc_list.new_sent": "✉️ 寄出",
"pc_list.new_received": "📬 收到",
"pc_list.duplicate": "编号已存在",
"pc_list.all_countries": "全部国家",
"pc_list.filter": "筛选",
"pc_list.tab_sent": "寄出",
"pc_list.tab_received": "收到",
"pc_list.mark_sent": "寄出",
"pc_list.confirm_sent": "确认寄出?",
"pc_list.mark_delivered": "已送达",
"pc_list.confirm_delivered": "确认已送达?",
"pc_list.empty_sent": "没有寄出的明信片。",
"pc_list.empty_received": "没有收到的明信片。",
"pc_detail.title": "明信片详情",
"pc_detail.edit": "编辑",
"pc_detail.mark_sent": "寄出",
"pc_detail.confirm_sent": "确认寄出?",
"pc_detail.mark_delivered": "已送达",
"pc_detail.confirm_delivered": "确认已送达?",
"pc_detail.delete": "删除",
@@ -104,7 +92,6 @@
"pc_detail.status": "状态",
"pc_detail.recipient": "收件人",
"pc_detail.country": "国家/地区",
"pc_detail.route": "路线",
"pc_detail.send_time": "寄出时间",
"pc_detail.arrival_time": "到达时间",
"pc_detail.sender": "发件人",
@@ -112,19 +99,15 @@
"pc_detail.profile": "所属 Profile",
"pc_detail.notes": "备注",
"status.sent": "已寄出",
"status.pending": "待寄出",
"status.delivered": "已送达",
"status.received": "已收到",
"pc_form.edit": "编辑明信片",
"pc_form.new": "新建明信片",
"pc_form.new_sent": "新建寄出",
"pc_form.new_received": "新建收到",
"pc_form.number": "编号",
"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.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)
+5 -14
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship, validates
from app.database import Base
@@ -61,7 +61,6 @@ class Profile(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
nickname = Column(String(64), nullable=False)
country = Column(String(2), nullable=True)
showcase_enabled = Column(default=False, nullable=False)
showcase_visible = Column(String(16), nullable=False, default="both")
notes = Column(Text, nullable=False, default="")
@@ -73,18 +72,14 @@ class Profile(Base):
class Postcard(Base):
__tablename__ = "postcards"
__table_args__ = (
UniqueConstraint("profile_id", "card_number", name="uq_postcards_profile_card_number"),
)
id = Column(Integer, primary_key=True, index=True)
profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False)
card_number = Column(String(64), nullable=False)
card_number = Column(String(64), nullable=False, unique=True)
status = Column(String(16), nullable=False)
# sent & delivered
recipient_name = Column(String(64))
country_from = Column(String(2))
country_to = Column(String(2))
country = Column(String(64))
send_time = Column(DateTime)
arrival_time = Column(DateTime)
# received
@@ -101,12 +96,8 @@ class Postcard(Base):
profile = relationship("Profile", back_populates="postcards")
@validates("country_from")
def _upper_country_from(self, key, value):
return value.strip().upper() if value else None
@validates("country_to")
def _upper_country_to(self, key, value):
@validates("country")
def _upper_country(self, key, value):
return value.strip().upper() if value else None
+15 -34
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"])
@@ -81,16 +81,13 @@ def get_api_user_optional(
class ProfileCreate(BaseModel):
nickname: str
country: str | None = None
class ProfileUpdate(BaseModel):
nickname: str | None = None
country: str | None = None
class ProfileOut(BaseModel):
id: int
nickname: str
country: str | None
showcase_enabled: bool
showcase_visible: str
notes: str
@@ -103,8 +100,7 @@ class PostcardCreate(BaseModel):
card_number: str
status: str
recipient_name: str | None = None
country_from: str | None = None
country_to: str | None = None
country: str | None = None
send_time: str | None = None
arrival_time: str | None = None
sender_name: str | None = None
@@ -115,8 +111,7 @@ class PostcardUpdate(BaseModel):
card_number: str | None = None
status: str | None = None
recipient_name: str | None = None
country_from: str | None = None
country_to: str | None = None
country: str | None = None
send_time: str | None = None
arrival_time: str | None = None
sender_name: str | None = None
@@ -129,8 +124,7 @@ class PostcardOut(BaseModel):
card_number: str
status: str
recipient_name: str | None
country_from: str | None
country_to: str | None
country: str | None
send_time: datetime | None
arrival_time: datetime | None
sender_name: str | None
@@ -211,8 +205,7 @@ def create_profile(
db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
):
c = body.country.strip().upper() if body.country else None
p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id, country=c)
p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id)
db.add(p)
db.commit()
db.refresh(p)
@@ -238,8 +231,6 @@ def update_profile(
p = _profile_or_404(ak.user, profile_id, db)
if body.nickname is not None:
p.nickname = body.nickname.strip()
if body.country is not None:
p.country = body.country.strip().upper() if body.country else None
db.commit()
db.refresh(p)
return p
@@ -284,18 +275,14 @@ def create_postcard(
):
_profile_or_404(ak.user, profile_id, db)
cn = body.card_number.strip()
dup = db.query(Postcard).join(Profile).filter(
Postcard.card_number == cn, Profile.user_id == ak.user.id
).first()
if dup:
if db.query(Postcard).filter(Postcard.card_number == cn).first():
raise HTTPException(status_code=409, detail="Card number already exists")
pc = Postcard(
profile_id=profile_id,
card_number=cn,
status=body.status,
recipient_name=body.recipient_name,
country_from=body.country_from.strip().upper() if body.country_from else None,
country_to=body.country_to.strip().upper() if body.country_to else None,
country=body.country.strip().upper() if body.country else None,
send_time=_parse_date(body.send_time),
arrival_time=_parse_date(body.arrival_time),
sender_name=body.sender_name,
@@ -333,17 +320,12 @@ def update_postcard(
setattr(pc, field, val.strip() if isinstance(val, str) else val)
if body.card_number is not None:
cn = body.card_number.strip()
dup = db.query(Postcard).join(Profile).filter(
Postcard.card_number == cn, Postcard.id != postcard_id,
Profile.user_id == ak.user.id
).first()
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
if dup:
raise HTTPException(status_code=409, detail="Card number already exists")
pc.card_number = cn
if body.country_to is not None:
pc.country_to = body.country_to.strip().upper() if body.country_to else None
if body.country_from is not None:
pc.country_from = body.country_from.strip().upper() if body.country_from else None
if body.country is not None:
pc.country = body.country.strip().upper() if body.country else None
for field in ("send_time", "arrival_time", "receive_time"):
val = getattr(body, field)
if val is not None:
@@ -380,16 +362,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
+95 -261
View File
@@ -1,10 +1,9 @@
from datetime import datetime, timezone, timedelta
from pathlib import Path
from random import shuffle
from uuid import uuid4
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
@@ -16,13 +15,14 @@ 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 datetime import datetime
from random import choice
from string import ascii_letters, digits
from app.models import InviteCode, Postcard, Profile, User
from app.i18n import t as _t, get_languages as _get_languages
from app.i18n import t as _t
router = APIRouter(tags=["web"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
@@ -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:
@@ -125,133 +109,6 @@ def _redirect(url: str) -> RedirectResponse:
return RedirectResponse(url, status_code=303)
# ---------- Root / Index ----------
@router.get("/", response_class=HTMLResponse)
def index_page(request: Request, db: Session = Depends(get_db)):
user = redirect_if_not_logged_in(request, db)
if user:
return _redirect("/dashboard")
# Collect all public postcards from all users with showcase enabled
all_postcards: list[Postcard] = []
users = db.query(User).filter(User.showcase_mode.isnot(None)).all()
for u in users:
profiles = db.query(Profile).filter(
Profile.user_id == u.id, Profile.showcase_enabled == True
).all()
for p in profiles:
cards = db.query(Postcard).filter(
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
Postcard.image_front.isnot(None), Postcard.image_front != ""
).all()
for c in cards:
visible = True
if c.status in ("pending", "sent", "delivered") and p.showcase_visible not in ("sent", "both"):
visible = False
if c.status == "received" and p.showcase_visible not in ("received", "both"):
visible = False
if visible:
all_postcards.append(c)
shuffle(all_postcards)
lang = request.query_params.get("lang")
avail = _get_languages()
if not lang or lang not in avail:
lang = request.cookies.get("showcase_lang")
if not lang or lang not in avail:
lang = "zh"
return _render(request, "index.html", {"postcards": all_postcards[:20], "lang": lang,
"has_more": len(all_postcards) > 20,
})
@router.get("/api/public/cards")
def api_public_cards(
request: Request,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
):
all_cards: list[Postcard] = []
users = db.query(User).filter(User.showcase_mode.isnot(None)).all()
for u in users:
profiles = db.query(Profile).filter(
Profile.user_id == u.id, Profile.showcase_enabled == True
).all()
for p in profiles:
cards = db.query(Postcard).filter(
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
Postcard.image_front.isnot(None), Postcard.image_front != ""
).all()
for c in cards:
vis = True
if c.status in ("pending", "sent", "delivered") and p.showcase_visible not in ("sent", "both"):
vis = False
if c.status == "received" and p.showcase_visible not in ("received", "both"):
vis = False
if vis:
all_cards.append(c)
shuffle(all_cards)
total = len(all_cards)
start = (page - 1) * limit
batch = all_cards[start:start + limit]
return JSONResponse({
"total": total,
"page": page,
"has_more": start + limit < total,
"cards": [{
"image_front": _resolve_image_url(c.image_front),
"country_from": c.country_from,
"country_to": c.country_to,
"status": c.status,
} for c in batch],
})
@router.get("/api/public/cards/{username}")
def api_public_cards_user(
username: str,
request: Request,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
tab: str = Query("sent"),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.username == username).first()
if not user:
return JSONResponse({"error": "not found"}, status_code=404)
profiles = db.query(Profile).filter(
Profile.user_id == user.id, Profile.showcase_enabled == True
).all()
all_cards: list[Postcard] = []
for p in profiles:
cards = db.query(Postcard).filter(
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
Postcard.image_front.isnot(None), Postcard.image_front != ""
).all()
if tab == "sent" and p.showcase_visible in ("sent", "both"):
all_cards.extend([c for c in cards if c.status in ("pending", "sent", "delivered")])
elif tab == "received" and p.showcase_visible in ("received", "both"):
all_cards.extend([c for c in cards if c.status == "received"])
all_cards.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
total = len(all_cards)
start = (page - 1) * limit
batch = all_cards[start:start + limit]
return JSONResponse({
"total": total,
"page": page,
"has_more": start + limit < total,
"cards": [{
"image_front": _resolve_image_url(c.image_front),
"country_from": c.country_from,
"country_to": c.country_to,
} for c in batch],
})
# ---------- Auth ----------
@router.get("/login", response_class=HTMLResponse)
@@ -259,7 +116,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 +131,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 +151,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 +166,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 +227,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 +369,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 +384,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 +404,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,48 +462,45 @@ 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")
countries = len({c.country_to for c in all_cards if c.country_to})
countries = len({c.country for c in all_cards if c.country})
# recent postcards across all profiles (last 8), sorted by most recently updated
all_cards.sort(key=lambda c: c.updated_at or c.created_at, reverse=True)
# recent postcards across all profiles (last 8)
all_cards.sort(key=lambda c: c.created_at, reverse=True)
recent = all_cards[:8]
# 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 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
if not c.country:
continue
if c.status in ("sent", "delivered"):
sent_country_counts[c.country] = sent_country_counts.get(c.country, 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] = received_country_counts.get(c.country, 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,19 +514,17 @@ 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}),
"countries": len({c.country for c in cards if c.country}),
}
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")
def profile_create(request: Request, nickname: str = Form(...), country: str = Form(""), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
c = country.strip().upper() if country else None
db.add(Profile(user_id=user.id, nickname=nickname, country=c))
def profile_create(request: Request, nickname: str = Form(...), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
db.add(Profile(user_id=user.id, nickname=nickname))
db.commit()
return _redirect("/profiles")
@@ -675,11 +539,10 @@ def profile_delete(profile_id: int, user: User = Depends(get_current_user_web),
@router.post("/profiles/{profile_id}/rename")
def profile_rename(profile_id: int, nickname: str = Form(...), country: str = Form(""), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
def profile_rename(profile_id: int, nickname: str = Form(...), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
if p:
p.nickname = nickname
p.country = country.strip().upper() if country else None
db.commit()
return _redirect("/profiles")
@@ -708,31 +571,32 @@ def postcard_list(
return _redirect("/profiles")
q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
if country:
q = q.filter(Postcard.country_to == country)
q = q.filter(Postcard.country == country)
postcards = q.all()
# 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 for pc in db.query(Postcard.country).filter(Postcard.profile_id == profile_id, Postcard.country.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")]
sent = [pc for pc in postcards if pc.status in ("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},
)
@router.get("/profiles/{profile_id}/postcards/new", response_class=HTMLResponse)
def postcard_new(request: Request, profile_id: int, create_type: str = Query("sent"), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
def postcard_new(request: Request, profile_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
profile = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
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})
@router.post("/profiles/{profile_id}/postcards")
@@ -742,8 +606,7 @@ def postcard_create(
card_number: str = Form(...),
status: str = Form(...),
recipient_name: str = Form(""),
country_to: str = Form(""),
country_from: str = Form(""),
country: str = Form(""),
send_time: str = Form(""),
arrival_time: str = Form(""),
sender_name: str = Form(""),
@@ -757,11 +620,9 @@ def postcard_create(
return _redirect("/profiles")
cn = card_number.strip()
dup = db.query(Postcard).join(Profile).filter(
Postcard.card_number == cn, Profile.user_id == user.id
).first()
if dup:
return _render(request, "postcard_form.html", {"user": user, "profile": profile, "postcard": None,
if db.query(Postcard).filter(Postcard.card_number == cn).first():
return templates.TemplateResponse("postcard_form.html", {
"request": request, "user": user, "profile": profile, "postcard": None,
"error": _t("pc_list.duplicate", user.language),
})
@@ -782,8 +643,7 @@ def postcard_create(
card_number=card_number.strip(),
status=status,
recipient_name=recipient_name or None,
country_from=(country_from.strip().upper() if country_from else profile.country),
country_to=(country_to.strip().upper() if country_to else None),
country=(country.strip().upper() if country else None),
send_time=_parse_dt(send_time),
arrival_time=_parse_dt(arrival_time),
sender_name=sender_name or None,
@@ -805,7 +665,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 +678,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")
@@ -828,8 +688,7 @@ def postcard_update(
card_number: str = Form(...),
status: str = Form(...),
recipient_name: str = Form(""),
country_to: str = Form(""),
country_from: str = Form(""),
country: str = Form(""),
send_time: str = Form(""),
arrival_time: str = Form(""),
sender_name: str = Form(""),
@@ -848,12 +707,10 @@ def postcard_update(
return _redirect("/profiles")
cn = card_number.strip()
dup = db.query(Postcard).join(Profile).filter(
Postcard.card_number == cn, Postcard.id != postcard_id,
Profile.user_id == user.id
).first()
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_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),
})
@@ -872,8 +729,7 @@ def postcard_update(
pc.card_number = card_number.strip()
pc.status = status
pc.recipient_name = recipient_name or None
pc.country_from = country_from.strip().upper() if country_from else pc.country_from
pc.country_to = country_to or None
pc.country = country or None
pc.send_time = _parse_dt(send_time)
pc.arrival_time = None if status == "sent" else _parse_dt(arrival_time)
pc.sender_name = sender_name or None
@@ -900,7 +756,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,34 +768,12 @@ 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})
@router.post("/postcards/{postcard_id}/mark-sent")
async def mark_sent(
postcard_id: int,
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
"""Transition pending → sent, auto-fill send_time if empty."""
pc = (
db.query(Postcard)
.join(Profile)
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
.first()
)
if not pc:
return JSONResponse({"ok": False}, status_code=404)
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 +796,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 +832,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,
})
@@ -1049,16 +883,13 @@ def public_showcase(request: Request, username: str, db: Session = Depends(get_d
profiles = db.query(Profile).filter(Profile.user_id == user.id, Profile.showcase_enabled == True).all()
sent_all, received_all, profile_groups = [], [], []
for p in profiles:
cards = db.query(Postcard).filter(
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
Postcard.image_front.isnot(None), Postcard.image_front != ""
).all()
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
if p.showcase_visible in ("sent", "both"):
sent_all.extend([c for c in cards if c.status in ("pending", "sent", "delivered")])
sent_all.extend([c for c in cards if c.status in ("sent", "delivered")])
if p.showcase_visible in ("received", "both"):
received_all.extend([c for c in cards if c.status == "received"])
if p.showcase_visible in ("sent", "both"):
p_sent = [c for c in cards if c.status in ("pending", "sent", "delivered")]
p_sent = [c for c in cards if c.status in ("sent", "delivered")]
else:
p_sent = []
if p.showcase_visible in ("received", "both"):
@@ -1071,7 +902,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 +933,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 +982,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,
})
+4 -28
View File
@@ -1,35 +1,21 @@
from datetime import datetime
from pydantic import BaseModel, model_validator
from pydantic import BaseModel
# ---------- Profile ----------
class ProfileCreate(BaseModel):
nickname: str
country: str | None = None
class ProfileUpdate(BaseModel):
nickname: str | None = None
country: str | None = None
class ProfileOut(BaseModel):
id: int
nickname: str
country: str | None
created_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
# ---------- Postcard ----------
@@ -37,8 +23,7 @@ class PostcardCreate(BaseModel):
card_number: str
status: str
recipient_name: str | None = None
country_from: str | None = None
country_to: str | None = None
country: str | None = None
send_time: datetime | None = None
arrival_time: datetime | None = None
sender_name: str | None = None
@@ -49,8 +34,7 @@ class PostcardUpdate(BaseModel):
card_number: str | None = None
status: str | None = None
recipient_name: str | None = None
country_from: str | None = None
country_to: str | None = None
country: str | None = None
send_time: datetime | None = None
arrival_time: datetime | None = None
sender_name: str | None = None
@@ -63,8 +47,7 @@ class PostcardOut(BaseModel):
card_number: str
status: str
recipient_name: str | None
country_from: str | None
country_to: str | None
country: str | None
send_time: datetime | None
arrival_time: datetime | None
sender_name: str | None
@@ -75,10 +58,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

+10 -38
View File
@@ -88,8 +88,7 @@ input[type="text"] + label, input[type="password"] + label, input[type="datetime
.form-row .btn { white-space: nowrap; flex-shrink: 0; }
.filter-bar .form-row select { flex: 0 0 auto; width: auto; }
.inline-form { margin-bottom: 1.25rem; }
.rename-form { display: inline-flex; gap: .35rem; align-items: center; margin-right: .5rem; flex-wrap: wrap; }
.rename-form input { flex: 1; min-width: 0; }
.rename-form { display: inline-flex; gap: .35rem; align-items: center; margin-right: .5rem; }
.nickname-display { display: inline; }
.nickname-edit { display: inline-flex; gap: .35rem; align-items: center; }
.nickname-edit input[type="text"] { width: 140px; flex: none; }
@@ -156,7 +155,6 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
/* Badge */
.badge { display: inline-block; padding: .1rem .4rem; border-radius: 999px; font-size: .65rem; font-weight: 600; color: #fff; background: var(--text-muted); white-space: nowrap; line-height: 1.4; align-self: center; }
.badge-sent { background: var(--sent); }
.badge-pending { background: var(--primary); }
.badge-delivered { background: var(--delivered); }
.badge-received { background: var(--received); }
@@ -186,20 +184,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 +227,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 +300,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
+2 -3
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">
@@ -10,7 +9,7 @@
<body>
{% block nav %}
<nav class="navbar">
<a href="/" class="nav-brand">{{ 'brand'|t(user.language if user else 'zh') }}</a>
<a href="/dashboard" class="nav-brand">{{ 'brand'|t(user.language if user else 'zh') }}</a>
{% if user is defined and user %}
<button class="nav-hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')">☰</button>
<div class="nav-links">
@@ -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>
+14 -36
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,48 +108,35 @@ 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>
<div class="recent-meta" style="margin-left:0">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
</div>
<div class="recent-meta" style="margin-left:0">
{% if pc.status in ('pending','sent','delivered') %}
<div class="recent-meta">
{% if pc.status in ('sent','delivered') %}
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
<span>→ {{ pc.recipient_name or '-' }}</span>
{% else %}
<span>← {{ pc.sender_name or '-' }}</span>
{% endif %}
</div>
</div>
<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>
</div>
</div>
</a>
{% endfor %}
</div>
-173
View File
@@ -1,173 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ 'brand'|t(lang) }}{% endblock %}
{% 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() %}
<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="/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>
{% set visible = [] %}
{% for pc in postcards %}
{% if pc.image_front %}
{% if visible.append(pc) %}{% endif %}
{% endif %}
{% endfor %}
{% 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-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>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% if has_more %}
<div id="scroll-sentinel" style="height:1px"></div>
<div id="scroll-loading" style="text-align:center;padding:1rem;color:var(--text-muted);display:none">⏳</div>
{% endif %}
{% else %}
<div class="empty-state">
<p>{{ 'index.empty'|t(lang) }}</p>
</div>
{% endif %}
<!-- Lightbox -->
<div id="lightbox" class="lightbox" onclick="closeLightbox()" style="display:none">
<div class="lightbox-content" onclick="event.stopPropagation()">
<button class="lightbox-close" onclick="closeLightbox()">&times;</button>
<button class="lightbox-prev" onclick="navLightbox(-1)">&#8249;</button>
<button class="lightbox-next" onclick="navLightbox(1)">&#8250;</button>
<img id="lightbox-img" src="" alt="">
<div id="lightbox-caption" class="lightbox-caption"></div>
</div>
</div>
<script>
/* ---- Infinite Scroll ---- */
var _page = 1;
var _hasMore = {{ 'true' if has_more else 'false' }};
var _loading = false;
function _appendCard(c) {
var grid = document.getElementById('showcase-grid');
if (!grid) return;
var div = document.createElement('div');
div.className = 'showcase-card';
div.setAttribute('data-front', c.image_front);
div.onclick = function() { openLightbox(div); };
var countryHtml = '';
if (c.country_from && c.country_to) countryHtml = '<span>' + _flag(c.country_from) + ' ' + c.country_from + ' → ' + _flag(c.country_to) + ' ' + c.country_to + '</span>';
else if (c.country_to) countryHtml = '<span>' + _flag(c.country_to) + ' ' + c.country_to + '</span>';
div.innerHTML = '<img src="' + c.image_front + '" alt="" class="showcase-card-img" loading="lazy">' +
'<div class="showcase-card-body"><div class="showcase-card-meta">' + countryHtml + '</div></div>';
grid.appendChild(div);
}
function _flag(code) {
if (!code || code.length !== 2) return code || '';
return String.fromCodePoint(0x1F1E6 + code.charCodeAt(0) - 65) + String.fromCodePoint(0x1F1E6 + code.charCodeAt(1) - 65);
}
function _loadMore() {
if (_loading || !_hasMore) return;
_loading = true;
document.getElementById('scroll-loading').style.display = '';
fetch('/api/public/cards?page=' + (_page + 1) + '&limit=20')
.then(function(r) { return r.json(); })
.then(function(data) {
_loading = false;
document.getElementById('scroll-loading').style.display = 'none';
_page = data.page;
_hasMore = data.has_more;
data.cards.forEach(_appendCard);
if (!_hasMore) {
var s = document.getElementById('scroll-sentinel');
if (s) s.remove();
}
})
.catch(function() { _loading = false; });
}
var _observer = new IntersectionObserver(function(entries) {
if (entries[0].isIntersecting) _loadMore();
}, {rootMargin: '400px'});
var _sentinel = document.getElementById('scroll-sentinel');
if (_sentinel) _observer.observe(_sentinel);
/* ---- Lightbox ---- */
var lightboxItems = [];
var lightboxIdx = 0;
function buildLightboxItems() {
lightboxItems = [];
document.querySelectorAll('#showcase-grid .showcase-card[data-front]').forEach(function(card) {
lightboxItems.push({src: card.getAttribute('data-front')});
});
}
function openLightbox(el) {
buildLightboxItems();
var src = el.getAttribute('data-front');
if (!src) return;
for (var i = 0; i < lightboxItems.length; i++) {
if (lightboxItems[i].src === src) { lightboxIdx = i; break; }
}
showLightboxItem();
document.getElementById('lightbox').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function showLightboxItem() {
if (!lightboxItems.length) return;
var item = lightboxItems[lightboxIdx];
document.getElementById('lightbox-img').src = item.src;
document.getElementById('lightbox-caption').textContent = (lightboxIdx+1) + '/' + lightboxItems.length;
}
function closeLightbox(e) {
if (e) e.stopPropagation();
document.getElementById('lightbox').style.display = 'none';
document.body.style.overflow = '';
}
function navLightbox(dir, e) {
if (e) e.stopPropagation();
if (!lightboxItems.length) return;
lightboxIdx = (lightboxIdx + dir + lightboxItems.length) % lightboxItems.length;
showLightboxItem();
}
document.addEventListener('keydown', function(e) {
var lb = document.getElementById('lightbox');
if (!lb || lb.style.display === 'none') return;
if (e.key === 'Escape') closeLightbox();
else if (e.key === 'ArrowLeft') navLightbox(-1);
else if (e.key === 'ArrowRight') navLightbox(1);
});
</script>
{% endblock %}
+10 -13
View File
@@ -5,14 +5,14 @@
<div class="detail-title-row">
<a href="/profiles/{{ postcard.profile_id }}/postcards" class="btn btn-back">←</a>
<h1>{{ postcard.card_number }}</h1>
<span class="badge badge-{{ postcard.status }}">{{ {'pending':'待寄出','sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'pending':'Pending','sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</span>
<span class="badge badge-{{ postcard.status }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</span>
</div>
<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>
{% 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>
{% if postcard.status == 'sent' %}
<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 +24,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 +36,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 %}
@@ -49,17 +49,14 @@
<div class="detail-meta">
<dl>
<dt>{{ 'pc_detail.card_number'|t(user.language) }}</dt><dd>{{ postcard.card_number }}</dd>
<dt>{{ 'pc_detail.status'|t(user.language) }}</dt><dd>{{ {'pending':'待寄出','sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'pending':'Pending','sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</dd>
{% if postcard.status in ('pending','sent','delivered') %}
<dt>{{ 'pc_detail.status'|t(user.language) }}</dt><dd>{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</dd>
{% if postcard.status in ('sent','delivered') %}
<dt>{{ 'pc_detail.recipient'|t(user.language) }}</dt><dd>{{ postcard.recipient_name or '-' }}</dd>
<dt>{{ 'pc_detail.route'|t(user.language) }}</dt><dd>{% if postcard.country_from %}{{ postcard.country_from|flag }} {{ postcard.country_from }} → {% endif %}{{ postcard.country_to|flag }} {{ postcard.country_to or '-' }}</dd>
<dt>{{ 'pc_detail.country'|t(user.language) }}</dt><dd>{{ postcard.country|flag }} {{ postcard.country or '-' }}</dd>
<dt>{{ 'pc_detail.send_time'|t(user.language) }}</dt><dd>{{ postcard.send_time.strftime('%Y-%m-%d') if postcard.send_time else '-' }}</dd>
{% if postcard.status == 'delivered' %}
<dt>{{ 'pc_detail.arrival_time'|t(user.language) }}</dt><dd>{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard.arrival_time else '-' }}</dd>
{% endif %}
{% else %}
<dt>{{ 'pc_detail.sender'|t(user.language) }}</dt><dd>{{ postcard.sender_name or '-' }}</dd>
<dt>{{ 'pc_detail.route'|t(user.language) }}</dt><dd>{% if postcard.country_from %}{{ postcard.country_from|flag }} {{ postcard.country_from }} → {% endif %}{{ postcard.country_to|flag }} {{ postcard.country_to or '-' }}</dd>
<dt>{{ 'pc_detail.receive_time'|t(user.language) }}</dt><dd>{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard.receive_time else '-' }}</dd>
{% endif %}
<dt>{{ 'pc_detail.profile'|t(user.language) }}</dt><dd>{{ postcard.profile.nickname }}</dd>
+69 -121
View File
@@ -1,79 +1,40 @@
{% extends "base.html" %}
{% block title %}{% if postcard %}{{ 'pc_form.edit'|t(user.language) }}{% else %}{% if create_type == 'received' %}{{ 'pc_form.new_received'|t(user.language) }}{% else %}{{ 'pc_form.new_sent'|t(user.language) }}{% endif %}{% endif %} - {{ 'brand'|t(user.language) }}{% endblock %}
{% block title %}{{ ('pc_form.edit'|t(user.language)) if postcard else ('pc_form.new'|t(user.language)) }} - {{ 'brand'|t(user.language) }}{% endblock %}
{% block content %}
<div class="section-header">
<h1>{% if postcard %}{{ 'pc_form.edit'|t(user.language) }}{% else %}{% if create_type == 'received' %}{{ 'pc_form.new_received'|t(user.language) }}{% else %}{{ 'pc_form.new_sent'|t(user.language) }}{% endif %}{% endif %}</h1>
<h1>{{ ('pc_form.edit'|t(user.language)) if postcard else ('pc_form.new'|t(user.language)) }}</h1>
</div>
{% if error %}<div class="form-card" style="border-left:3px solid var(--danger);margin-bottom:1rem;padding:.75rem 1rem;color:var(--danger)">{{ error }}</div>{% endif %}
{% set is_new = (postcard is none) %}
{% set is_edit = (postcard is not none) %}
{% set edit_status = postcard.status if is_edit else '' %}
{% set is_received_form = (create_type == 'received' and is_new) or (is_edit and edit_status == 'received') %}
<form method="post" action="{{ '/postcards/%d/edit' % postcard.id if is_edit else '/profiles/%d/postcards' % profile.id }}" class="form-card">
<form method="post" action="{{ '/postcards/%d/edit' % postcard.id if postcard else '/profiles/%d/postcards' % profile.id }}" class="form-card">
<label>{{ 'pc_form.number'|t(user.language) }} *</label>
<input type="text" name="card_number" value="{{ postcard.card_number if is_edit else '' }}" required>
<input type="text" name="card_number" value="{{ postcard.card_number if postcard else '' }}" required>
{% if is_received_form %}
{# ── 收到表单:无状态选择 ── #}
<input type="hidden" name="status" value="received">
<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_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 %}
{# 新建寄出:状态默认由日期自动判定,可手动覆盖 #}
<label>{{ 'pc_form.status'|t(user.language) }}</label>
<select name="status" id="status-select">
<option value="pending">{{ 'status.pending'|t(user.language) }}</option>
<option value="sent">{{ 'status.sent'|t(user.language) }}</option>
<option value="delivered">{{ 'status.delivered'|t(user.language) }}</option>
</select>
{% else %}
{# 编辑时:显示状态选择 #}
<label>{{ 'pc_form.status'|t(user.language) }} *</label>
<select name="status" id="status-select" onchange="toggleFields()" required>
<option value="pending" {% if edit_status=='pending' %}selected{% endif %}>{{ 'status.pending'|t(user.language) }}</option>
<option value="sent" {% if edit_status=='sent' %}selected{% endif %}>{{ 'status.sent'|t(user.language) }}</option>
<option value="delivered" {% if edit_status=='delivered' %}selected{% endif %}>{{ 'status.delivered'|t(user.language) }}</option>
<option value="sent" {% if postcard and postcard.status=='sent' %}selected{% endif %}>{{ 'status.sent'|t(user.language) }}</option>
<option value="delivered" {% if postcard and postcard.status=='delivered' %}selected{% endif %}>{{ 'status.delivered'|t(user.language) }}</option>
<option value="received" {% if postcard and postcard.status=='received' %}selected{% endif %}>{{ 'status.received'|t(user.language) }}</option>
</select>
{% endif %}
<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>
<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="text" name="recipient_name" id="input-recipient" value="{{ postcard.recipient_name if postcard else '' }}">
<label>{{ 'pc_form.country'|t(user.language) }} *</label>
<input type="text" name="country" id="input-country" value="{{ postcard.country if postcard else '' }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<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 postcard and postcard.send_time 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 %}>
<input type="date" name="arrival_time" id="input-arrival" value="{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard and postcard.arrival_time else '' }}">
</div>
<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>
<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 %}
<div id="fields-received" style="display:none">
<label>{{ 'pc_form.sender'|t(user.language) }} *</label>
<input type="text" name="sender_name" id="input-sender" value="{{ postcard.sender_name if postcard else '' }}">
<label>{{ 'pc_form.receive_time'|t(user.language) }} *</label>
<input type="date" name="receive_time" id="input-receive" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard and postcard.receive_time else '' }}">
</div>
<label>{{ 'pc_form.notes'|t(user.language) }}</label>
<textarea name="notes" rows="3" placeholder="{{ 'pc_form.notes_ph'|t(user.language) }}">{{ postcard.notes if is_edit else '' }}</textarea>
<textarea name="notes" rows="3" placeholder="{{ 'pc_form.notes_ph'|t(user.language) }}">{{ postcard.notes if postcard else '' }}</textarea>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'pc_form.save'|t(user.language) }}</button>
@@ -81,81 +42,68 @@
</div>
</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);
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) }} *';
}
}
function updateArrival(s) {
if (s === 'delivered') {
arrival.disabled = false;
arrival.value = arrival.value || today;
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }} *';
} else {
arrival.disabled = true;
arrival.value = '';
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }}';
}
}
function sync() {
updateArrival(select.value);
updateSendTime(select.value);
}
select.addEventListener('change', sync);
sync();
})();
{% elif is_edit and not is_received_form %}
// ── 编辑寄出:根据状态切换 send_time / arrival 可用性 ──
function toggleFields() {
var s = document.getElementById('status-select').value;
var sd = document.getElementById('fields-sent-delivered');
var rc = document.getElementById('fields-received');
var recipient = document.getElementById('input-recipient');
var country = document.getElementById('input-country');
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') {
var sender = document.getElementById('input-sender');
var receive = document.getElementById('input-receive');
if (s === 'received') {
sd.style.display = 'none';
rc.style.display = '';
recipient.required = false;
country.required = false;
sendTime.required = false;
arrival.required = false;
arrival.disabled = false;
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }} *';
} else {
sender.required = true;
receive.required = true;
} else if (s === 'sent') {
sd.style.display = '';
rc.style.display = 'none';
recipient.required = true;
country.required = true;
sendTime.required = true;
arrival.required = false;
arrival.disabled = true;
arrival.value = '';
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }}';
labelArrival.textContent = '到达时间';
sender.required = false;
receive.required = false;
} else {
sd.style.display = '';
rc.style.display = 'none';
recipient.required = true;
country.required = true;
sendTime.required = true;
arrival.disabled = false;
arrival.required = true;
labelArrival.textContent = '到达时间 *';
sender.required = false;
receive.required = false;
}
}
toggleFields();
{% endif %}
{% if is_new and is_received_form %}
// ── 新建收到:自动填日期 ──
{% if not postcard %}
// Auto-fill date fields with today on new postcards
(function() {
var today = new Date().toISOString().slice(0, 10);
var rt = document.querySelector('input[name="receive_time"]');
if (rt && !rt.value) rt.value = today;
var sendTime = document.getElementById('input-send-time');
var receiveTime = document.getElementById('input-receive');
var statusSel = document.getElementById('status-select');
// Fill on page load
if (statusSel.value === 'sent' && !sendTime.value) sendTime.value = today;
if (statusSel.value === 'received' && !receiveTime.value) receiveTime.value = today;
// Fill on status change
statusSel.addEventListener('change', function() {
if (this.value === 'sent' && !sendTime.value) sendTime.value = today;
if (this.value === 'received' && !receiveTime.value) receiveTime.value = today;
});
})();
{% endif %}
</script>
+10 -16
View File
@@ -3,10 +3,7 @@
{% block content %}
<div class="section-header">
<h1>{{ profile.nickname }} {{ 'pc_list.title'|t(user.language) }}</h1>
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
<a href="/profiles/{{ profile.id }}/postcards/new?create_type=sent" class="btn btn-primary">{{ 'pc_list.new_sent'|t(user.language) }}</a>
<a href="/profiles/{{ profile.id }}/postcards/new?create_type=received" class="btn">{{ 'pc_list.new_received'|t(user.language) }}</a>
</div>
<a href="/profiles/{{ profile.id }}/postcards/new" class="btn btn-primary">{{ 'pc_list.new'|t(user.language) }}</a>
</div>
<div style="margin-bottom:1rem">
{% if profile.notes %}
@@ -36,7 +33,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 %}
@@ -44,10 +41,10 @@
<div class="postcard-row-info">
<strong>{{ pc.card_number }}</strong>
<div class="postcard-row-detail">
<span>{% if pc.country_from %}{{ pc.country_from|flag }} {{ pc.country_from }} → {% endif %}{{ pc.country_to|flag }} {{ pc.country_to or '-' }}</span>
<span>{{ pc.country|flag }} {{ pc.country or '-' }}</span>
<span>→ {{ pc.recipient_name or '-' }}</span>
</div>
<span class="badge badge-{{ pc.status }}">{{ {'pending':'待寄出','sent':'已寄出','delivered':'已送达'}.get(pc.status) if user.language=='zh' else {'pending':'Pending','sent':'Sent','delivered':'Delivered'}.get(pc.status) }}</span>
<span class="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达'}.get(pc.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered'}.get(pc.status) }}</span>
<span class="postcard-row-date">
{% if pc.send_time %}{{ pc.send_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}寄出{% endif %}{% endif %}
{% if pc.arrival_time %}<br>{{ pc.arrival_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}送达{% endif %}{% endif %}
@@ -55,10 +52,10 @@
</div>
{% 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>
{% 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>
{% if pc.status == 'sent' %}
<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 +71,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 %}
@@ -84,10 +81,7 @@
<div class="postcard-row-detail">
<span>← {{ pc.sender_name or '-' }}</span>
</div>
<span class="badge badge-received">{{ 'status.received'|t(user.language) }}</span>
<span class="postcard-row-date">
{% if pc.receive_time %}{{ pc.receive_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}收到{% endif %}{% endif %}
</span>
{% if pc.receive_time %}<span class="postcard-row-date">{{ pc.receive_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}收到{% endif %}</span>{% endif %}
</div>
{% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %}
</a>
-7
View File
@@ -7,7 +7,6 @@
<div class="inline-form">
<form method="post" action="/profiles" class="form-row">
<input type="text" name="nickname" placeholder="{{ 'profiles.new_ph'|t(user.language) }}" required>
<input type="text" name="country" placeholder="{{ 'profiles.country_ph'|t(user.language) }}" maxlength="2" style="width:60px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<button type="submit" class="btn btn-primary">{{ 'profiles.new_btn'|t(user.language) }}</button>
</form>
</div>
@@ -19,12 +18,10 @@
<div class="profile-card-header">
<h3 class="profile-card-name">
<span id="name-display-{{ p.id }}">{{ p.nickname }}</span>
{% if p.country %}<span style="font-size:.8rem;color:var(--text-muted);font-weight:400">{{ p.country|flag }} {{ p.country }}</span>{% endif %}
<button type="button" class="icon-btn" onclick="startRename({{ p.id }})" title="{{ 'profiles.rename'|t(user.language) }}">✏️</button>
</h3>
<form method="post" action="/profiles/{{ p.id }}/rename" class="rename-form" id="name-edit-{{ p.id }}" style="display:none">
<input type="text" name="nickname" value="{{ p.nickname }}" required>
<input type="text" name="country" value="{{ p.country or '' }}" maxlength="2" style="width:60px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()" placeholder="{{ 'profiles.country_ph'|t(user.language) }}">
<button type="submit" class="btn btn-primary btn-sm">✓</button>
<button type="button" class="btn btn-sm" onclick="cancelRename({{ p.id }})">✕</button>
</form>
@@ -34,10 +31,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>
+58 -140
View File
@@ -6,30 +6,17 @@
<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 = [] %}
{% set received_visible = [] %}
{% for pc in sent_all %}
{% if pc.image_front %}
{% if sent_visible.append(pc) %}{% endif %}
{% endif %}
{% endfor %}
{% for pc in received_all %}
{% if pc.image_front %}
{% if received_visible.append(pc) %}{% endif %}
{% endif %}
{% endfor %}
{% set has_content = (sent_visible|length + received_visible|length) > 0 %}
{% set has_content = (sent_all|length + received_all|length) > 0 %}
{% if not has_content %}
<div class="empty-state">
<p>{{ 'showcase.empty'|t(lang) }}</p>
@@ -37,21 +24,24 @@
{% else %}
<div class="tabs" id="tabs">
<button class="tab active" onclick="switchTab('sent')">{{ 'showcase.tab_sent'|t(lang) }} ({{ sent_visible|length }})</button>
<button class="tab" onclick="switchTab('received')">{{ 'showcase.tab_received'|t(lang) }} ({{ received_visible|length }})</button>
<button class="tab active" onclick="switchTab('sent')">{{ 'showcase.tab_sent'|t(lang) }} ({{ sent_all|length }})</button>
<button class="tab" onclick="switchTab('received')">{{ 'showcase.tab_received'|t(lang) }} ({{ received_all|length }})</button>
</div>
<div id="tab-sent" class="tab-panel">
{% 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-grid">
{% for pc in sent_all %}
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% endif %}
<div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<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>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div>
</div>
@@ -60,23 +50,20 @@
</div>
{% else %}
{% for g in profile_groups %}
{% set g_sent = [] %}
{% for pc in g.sent %}
{% if pc.image_front %}
{% if g_sent.append(pc) %}{% endif %}
{% endif %}
{% endfor %}
{% if g_sent %}
{% if g.sent %}
<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">
{% for pc in g.sent %}
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% endif %}
<div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<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>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div>
</div>
@@ -86,23 +73,22 @@
{% endif %}
{% endfor %}
{% endif %}
{% if sent_visible|length > 19 %}
<div id="scroll-sentinel-sent" style="height:1px"></div>
<div id="scroll-loading-sent" style="text-align:center;padding:1rem;color:var(--text-muted);display:none">⏳</div>
{% endif %}
</div>
<div id="tab-received" class="tab-panel" style="display:none">
{% 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-grid">
{% for pc in received_all %}
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% endif %}
<div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<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>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div>
</div>
@@ -111,23 +97,20 @@
</div>
{% else %}
{% for g in profile_groups %}
{% set g_received = [] %}
{% for pc in g.received %}
{% if pc.image_front %}
{% if g_received.append(pc) %}{% endif %}
{% endif %}
{% endfor %}
{% if g_received %}
{% if g.received %}
<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">
{% for pc in g.received %}
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% endif %}
<div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<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>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div>
</div>
@@ -137,90 +120,20 @@
{% endif %}
{% endfor %}
{% endif %}
{% if received_visible|length > 19 %}
<div id="scroll-sentinel-received" style="height:1px"></div>
<div id="scroll-loading-received" style="text-align:center;padding:1rem;color:var(--text-muted);display:none">⏳</div>
{% endif %}
</div>
{% endif %}
<div id="lightbox" class="lightbox" onclick="closeLightbox(event)">
<button class="lightbox-close" onclick="closeLightbox(event)">&times;</button>
<button class="lightbox-prev" onclick="navLightbox(-1,event)">&#10094;</button>
<button class="lightbox-next" onclick="navLightbox(1,event)">&#10095;</button>
<div class="lightbox-content" onclick="event.stopPropagation()">
<button class="lightbox-close" onclick="closeLightbox(event)">&times;</button>
<button class="lightbox-prev" onclick="navLightbox(-1,event)">&#8249;</button>
<button class="lightbox-next" onclick="navLightbox(1,event)">&#8250;</button>
<img id="lightbox-img" src="" alt="">
<div id="lightbox-caption" class="lightbox-caption"></div>
</div>
</div>
<script>
var _username = '{{ profile_user.username }}';
var _sentPage = 1, _sentMore = {{ 'true' if sent_visible|length > 19 else 'false' }};
var _recvPage = 1, _recvMore = {{ 'true' if received_visible|length > 19 else 'false' }};
var _sentLoading = false, _recvLoading = false;
function _flag(code) {
if (!code || code.length !== 2) return code || '';
return String.fromCodePoint(0x1F1E6 + code.charCodeAt(0) - 65) + String.fromCodePoint(0x1F1E6 + code.charCodeAt(1) - 65);
}
function _appendCard(c, gridId) {
var grid = document.getElementById(gridId);
if (!grid) return;
var div = document.createElement('div');
div.className = 'showcase-card';
div.setAttribute('data-front', c.image_front);
div.onclick = function() { openLightbox(div); };
var countryHtml = '';
if (c.country_from && c.country_to) countryHtml = '<span>' + _flag(c.country_from) + ' ' + c.country_from + ' → ' + _flag(c.country_to) + ' ' + c.country_to + '</span>';
else if (c.country_to) countryHtml = '<span>' + _flag(c.country_to) + '</span>';
div.innerHTML = '<img src="' + c.image_front + '" alt="" class="showcase-card-img" loading="lazy">' +
'<div class="showcase-card-body"><div class="showcase-card-meta">' + countryHtml + '</div></div>';
grid.appendChild(div);
}
function _loadMoreSent() {
if (_sentLoading || !_sentMore) return;
_sentLoading = true;
document.getElementById('scroll-loading-sent').style.display = '';
fetch('/api/public/cards/' + _username + '?page=' + (_sentPage + 1) + '&limit=20&tab=sent')
.then(function(r) { return r.json(); })
.then(function(data) {
_sentLoading = false;
document.getElementById('scroll-loading-sent').style.display = 'none';
_sentPage = data.page;
_sentMore = data.has_more;
data.cards.forEach(function(c) { _appendCard(c, 'grid-sent'); });
if (!_sentMore) { var s = document.getElementById('scroll-sentinel-sent'); if (s) s.remove(); }
})
.catch(function() { _sentLoading = false; });
}
function _loadMoreReceived() {
if (_recvLoading || !_recvMore) return;
_recvLoading = true;
document.getElementById('scroll-loading-received').style.display = '';
fetch('/api/public/cards/' + _username + '?page=' + (_recvPage + 1) + '&limit=20&tab=received')
.then(function(r) { return r.json(); })
.then(function(data) {
_recvLoading = false;
document.getElementById('scroll-loading-received').style.display = 'none';
_recvPage = data.page;
_recvMore = data.has_more;
data.cards.forEach(function(c) { _appendCard(c, 'grid-received'); });
if (!_recvMore) { var s = document.getElementById('scroll-sentinel-received'); if (s) s.remove(); }
})
.catch(function() { _recvLoading = false; });
}
var _obsSent = new IntersectionObserver(function(e) { if (e[0].isIntersecting) _loadMoreSent(); }, {rootMargin: '400px'});
var _obsRecv = new IntersectionObserver(function(e) { if (e[0].isIntersecting) _loadMoreReceived(); }, {rootMargin: '400px'});
var _sSent = document.getElementById('scroll-sentinel-sent');
var _sRecv = document.getElementById('scroll-sentinel-received');
if (_sSent) _obsSent.observe(_sSent);
if (_sRecv) _obsRecv.observe(_sRecv);
function switchTab(name) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
@@ -236,7 +149,8 @@ function buildLightboxItems() {
var visible = document.querySelectorAll('.tab-panel:not([style*="display:none"]) .showcase-card[data-front]');
visible.forEach(function(card) {
var front = card.getAttribute('data-front');
if (front) lightboxItems.push({src: front});
var number = card.getAttribute('data-number');
if (front) lightboxItems.push({src: front, number: number});
});
}
@@ -244,8 +158,9 @@ function openLightbox(el) {
buildLightboxItems();
var front = el.getAttribute('data-front');
if (!front) return;
var number = el.getAttribute('data-number');
for (var i = 0; i < lightboxItems.length; i++) {
if (lightboxItems[i].src === front) { lightboxIdx = i; break; }
if (lightboxItems[i].number === number) { lightboxIdx = i; break; }
}
showLightboxItem();
document.getElementById('lightbox').classList.add('active');
@@ -256,7 +171,7 @@ function showLightboxItem() {
if (!lightboxItems.length) return;
var item = lightboxItems[lightboxIdx];
document.getElementById('lightbox-img').src = item.src;
document.getElementById('lightbox-caption').textContent = (lightboxIdx+1) + '/' + lightboxItems.length;
document.getElementById('lightbox-caption').textContent = item.number + ' (' + (lightboxIdx+1) + '/' + lightboxItems.length + ')';
}
function closeLightbox(e) {
@@ -279,10 +194,13 @@ document.addEventListener('keydown', function(e) {
else if (e.key === 'ArrowLeft') navLightbox(-1);
else if (e.key === 'ArrowRight') navLightbox(1);
});
// Close language dropdown when clicking outside
document.addEventListener('click', function(e) {
var sw = document.getElementById('langSwitcher');
var dd = document.getElementById('langDropdown');
if (sw && dd && !sw.contains(e.target)) dd.classList.remove('show');
if (sw && dd && !sw.contains(e.target)) {
dd.classList.remove('show');
}
});
</script>
{% endblock %}
-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.