feat(postcard): profile country, country_from, per-user card number uniqueness, public page overhaul - Profile: add country field for default origin country - Postcard: add country_from field, auto-fill from profile.country on create - card_number uniqueness: change from global to per-user (profile_id + card_number) - Public pages: remove card_number display, only show cards with image_front - Public pages: show country as from→to format - Index page: no time display, add register button, fix lightbox with prev/next - Infinite scroll: add /api/public/cards endpoints and IntersectionObserver - Internal pages: update postcards list/detail for country_from display - i18n: add profiles.country_ph, pc_form.country_from, pc_detail.route

This commit is contained in:
2026-07-07 12:32:24 +08:00
parent bd664d23be
commit a071b3dde4
12 changed files with 414 additions and 90 deletions

View File

@@ -6,6 +6,7 @@
"index.title": "Mailova", "index.title": "Mailova",
"index.subtitle": "Postcards from around the world", "index.subtitle": "Postcards from around the world",
"index.go_login": "Login", "index.go_login": "Login",
"index.go_register": "Register",
"index.empty": "No public postcards yet", "index.empty": "No public postcards yet",
"nav.profiles": "Profiles", "nav.profiles": "Profiles",
"nav.settings": "Settings", "nav.settings": "Settings",
@@ -64,6 +65,7 @@
"profiles.total": "Total", "profiles.total": "Total",
"profiles.countries": "Countries", "profiles.countries": "Countries",
"profiles.notes_ph": "Notes (private)", "profiles.notes_ph": "Notes (private)",
"profiles.country_ph": "Country",
"profiles.save": "Save", "profiles.save": "Save",
"profiles.created_at": "Created", "profiles.created_at": "Created",
"profiles.view": "View", "profiles.view": "View",
@@ -96,6 +98,7 @@
"pc_detail.status": "Status", "pc_detail.status": "Status",
"pc_detail.recipient": "Recipient", "pc_detail.recipient": "Recipient",
"pc_detail.country": "Country", "pc_detail.country": "Country",
"pc_detail.route": "Route",
"pc_detail.send_time": "Sent date", "pc_detail.send_time": "Sent date",
"pc_detail.arrival_time": "Arrival date", "pc_detail.arrival_time": "Arrival date",
"pc_detail.sender": "Sender", "pc_detail.sender": "Sender",
@@ -112,6 +115,7 @@
"pc_form.status": "Status", "pc_form.status": "Status",
"pc_form.recipient": "Recipient", "pc_form.recipient": "Recipient",
"pc_form.country": "Country", "pc_form.country": "Country",
"pc_form.country_from": "From",
"pc_form.send_time": "Sent date", "pc_form.send_time": "Sent date",
"pc_form.arrival_time": "Arrival date", "pc_form.arrival_time": "Arrival date",
"pc_form.sender": "Sender", "pc_form.sender": "Sender",

View File

@@ -6,6 +6,7 @@
"index.title": "星笺", "index.title": "星笺",
"index.subtitle": "来自世界各地的明信片", "index.subtitle": "来自世界各地的明信片",
"index.go_login": "登录", "index.go_login": "登录",
"index.go_register": "注册",
"index.empty": "暂无公开明信片", "index.empty": "暂无公开明信片",
"nav.profiles": "Profile", "nav.profiles": "Profile",
"nav.settings": "设置", "nav.settings": "设置",
@@ -64,6 +65,7 @@
"profiles.total": "总计", "profiles.total": "总计",
"profiles.countries": "国家", "profiles.countries": "国家",
"profiles.notes_ph": "备注(仅自己可见)", "profiles.notes_ph": "备注(仅自己可见)",
"profiles.country_ph": "国家",
"profiles.save": "保存", "profiles.save": "保存",
"profiles.created_at": "创建于", "profiles.created_at": "创建于",
"profiles.view": "查看", "profiles.view": "查看",
@@ -96,6 +98,7 @@
"pc_detail.status": "状态", "pc_detail.status": "状态",
"pc_detail.recipient": "收件人", "pc_detail.recipient": "收件人",
"pc_detail.country": "国家/地区", "pc_detail.country": "国家/地区",
"pc_detail.route": "路线",
"pc_detail.send_time": "寄出时间", "pc_detail.send_time": "寄出时间",
"pc_detail.arrival_time": "到达时间", "pc_detail.arrival_time": "到达时间",
"pc_detail.sender": "发件人", "pc_detail.sender": "发件人",
@@ -112,6 +115,7 @@
"pc_form.status": "状态", "pc_form.status": "状态",
"pc_form.recipient": "收件人", "pc_form.recipient": "收件人",
"pc_form.country": "国家/地区", "pc_form.country": "国家/地区",
"pc_form.country_from": "寄出地",
"pc_form.send_time": "寄出时间", "pc_form.send_time": "寄出时间",
"pc_form.arrival_time": "到达时间", "pc_form.arrival_time": "到达时间",
"pc_form.sender": "发件人", "pc_form.sender": "发件人",

View File

@@ -1,6 +1,6 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import relationship, validates from sqlalchemy.orm import relationship, validates
from app.database import Base from app.database import Base
@@ -61,6 +61,7 @@ class Profile(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
nickname = Column(String(64), nullable=False) nickname = Column(String(64), nullable=False)
country = Column(String(2), nullable=True)
showcase_enabled = Column(default=False, nullable=False) showcase_enabled = Column(default=False, nullable=False)
showcase_visible = Column(String(16), nullable=False, default="both") showcase_visible = Column(String(16), nullable=False, default="both")
notes = Column(Text, nullable=False, default="") notes = Column(Text, nullable=False, default="")
@@ -72,14 +73,18 @@ class Profile(Base):
class Postcard(Base): class Postcard(Base):
__tablename__ = "postcards" __tablename__ = "postcards"
__table_args__ = (
UniqueConstraint("profile_id", "card_number", name="uq_postcards_profile_card_number"),
)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False) profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False)
card_number = Column(String(64), nullable=False, unique=True) card_number = Column(String(64), nullable=False)
status = Column(String(16), nullable=False) status = Column(String(16), nullable=False)
# sent & delivered # sent & delivered
recipient_name = Column(String(64)) recipient_name = Column(String(64))
country = Column(String(64)) country = Column(String(64))
country_from = Column(String(2))
send_time = Column(DateTime) send_time = Column(DateTime)
arrival_time = Column(DateTime) arrival_time = Column(DateTime)
# received # received
@@ -100,6 +105,10 @@ class Postcard(Base):
def _upper_country(self, key, value): def _upper_country(self, key, value):
return value.strip().upper() if value else None return value.strip().upper() if value else None
@validates("country_from")
def _upper_country_from(self, key, value):
return value.strip().upper() if value else None
class ApiKey(Base): class ApiKey(Base):
__tablename__ = "api_keys" __tablename__ = "api_keys"

View File

@@ -81,13 +81,16 @@ def get_api_user_optional(
class ProfileCreate(BaseModel): class ProfileCreate(BaseModel):
nickname: str nickname: str
country: str | None = None
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
nickname: str | None = None nickname: str | None = None
country: str | None = None
class ProfileOut(BaseModel): class ProfileOut(BaseModel):
id: int id: int
nickname: str nickname: str
country: str | None
showcase_enabled: bool showcase_enabled: bool
showcase_visible: str showcase_visible: str
notes: str notes: str
@@ -101,6 +104,7 @@ class PostcardCreate(BaseModel):
status: str status: str
recipient_name: str | None = None recipient_name: str | None = None
country: str | None = None country: str | None = None
country_from: str | None = None
send_time: str | None = None send_time: str | None = None
arrival_time: str | None = None arrival_time: str | None = None
sender_name: str | None = None sender_name: str | None = None
@@ -112,6 +116,7 @@ class PostcardUpdate(BaseModel):
status: str | None = None status: str | None = None
recipient_name: str | None = None recipient_name: str | None = None
country: str | None = None country: str | None = None
country_from: str | None = None
send_time: str | None = None send_time: str | None = None
arrival_time: str | None = None arrival_time: str | None = None
sender_name: str | None = None sender_name: str | None = None
@@ -125,6 +130,7 @@ class PostcardOut(BaseModel):
status: str status: str
recipient_name: str | None recipient_name: str | None
country: str | None country: str | None
country_from: str | None
send_time: datetime | None send_time: datetime | None
arrival_time: datetime | None arrival_time: datetime | None
sender_name: str | None sender_name: str | None
@@ -205,7 +211,8 @@ def create_profile(
db: DbSession = Depends(get_db), db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)), ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
): ):
p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id) c = body.country.strip().upper() if body.country else None
p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id, country=c)
db.add(p) db.add(p)
db.commit() db.commit()
db.refresh(p) db.refresh(p)
@@ -231,6 +238,8 @@ def update_profile(
p = _profile_or_404(ak.user, profile_id, db) p = _profile_or_404(ak.user, profile_id, db)
if body.nickname is not None: if body.nickname is not None:
p.nickname = body.nickname.strip() 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.commit()
db.refresh(p) db.refresh(p)
return p return p
@@ -275,7 +284,10 @@ def create_postcard(
): ):
_profile_or_404(ak.user, profile_id, db) _profile_or_404(ak.user, profile_id, db)
cn = body.card_number.strip() cn = body.card_number.strip()
if db.query(Postcard).filter(Postcard.card_number == cn).first(): dup = db.query(Postcard).join(Profile).filter(
Postcard.card_number == cn, Profile.user_id == ak.user.id
).first()
if dup:
raise HTTPException(status_code=409, detail="Card number already exists") raise HTTPException(status_code=409, detail="Card number already exists")
pc = Postcard( pc = Postcard(
profile_id=profile_id, profile_id=profile_id,
@@ -283,6 +295,7 @@ def create_postcard(
status=body.status, status=body.status,
recipient_name=body.recipient_name, recipient_name=body.recipient_name,
country=body.country.strip().upper() if body.country else None, country=body.country.strip().upper() if body.country else None,
country_from=body.country_from.strip().upper() if body.country_from else None,
send_time=_parse_date(body.send_time), send_time=_parse_date(body.send_time),
arrival_time=_parse_date(body.arrival_time), arrival_time=_parse_date(body.arrival_time),
sender_name=body.sender_name, sender_name=body.sender_name,
@@ -320,12 +333,17 @@ def update_postcard(
setattr(pc, field, val.strip() if isinstance(val, str) else val) setattr(pc, field, val.strip() if isinstance(val, str) else val)
if body.card_number is not None: if body.card_number is not None:
cn = body.card_number.strip() cn = body.card_number.strip()
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first() dup = db.query(Postcard).join(Profile).filter(
Postcard.card_number == cn, Postcard.id != postcard_id,
Profile.user_id == ak.user.id
).first()
if dup: if dup:
raise HTTPException(status_code=409, detail="Card number already exists") raise HTTPException(status_code=409, detail="Card number already exists")
pc.card_number = cn pc.card_number = cn
if body.country is not None: if body.country is not None:
pc.country = body.country.strip().upper() if body.country else None pc.country = body.country.strip().upper() if body.country else None
if body.country_from is not None:
pc.country_from = body.country_from.strip().upper() if body.country_from else None
for field in ("send_time", "arrival_time", "receive_time"): for field in ("send_time", "arrival_time", "receive_time"):
val = getattr(body, field) val = getattr(body, field)
if val is not None: if val is not None:

View File

@@ -4,7 +4,7 @@ from random import shuffle
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -126,7 +126,8 @@ def index_page(request: Request, db: Session = Depends(get_db)):
).all() ).all()
for p in profiles: for p in profiles:
cards = db.query(Postcard).filter( cards = db.query(Postcard).filter(
Postcard.profile_id == p.id, Postcard.showcase_hidden == False Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
Postcard.image_front.isnot(None), Postcard.image_front != ""
).all() ).all()
for c in cards: for c in cards:
visible = True visible = True
@@ -147,7 +148,92 @@ def index_page(request: Request, db: Session = Depends(get_db)):
lang = "zh" lang = "zh"
return templates.TemplateResponse("index.html", { return templates.TemplateResponse("index.html", {
"request": request, "postcards": all_postcards, "lang": lang, "request": request, "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 ("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": c.image_front,
"country_from": c.country_from,
"country": c.country,
"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 ("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": c.image_front,
"country_from": c.country_from,
"country": c.country,
} for c in batch],
}) })
@@ -565,8 +651,9 @@ def profile_list(request: Request, user: User = Depends(get_current_user_web), d
@router.post("/profiles") @router.post("/profiles")
def profile_create(request: Request, nickname: str = Form(...), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): def profile_create(request: Request, nickname: str = Form(...), country: str = Form(""), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
db.add(Profile(user_id=user.id, nickname=nickname)) c = country.strip().upper() if country else None
db.add(Profile(user_id=user.id, nickname=nickname, country=c))
db.commit() db.commit()
return _redirect("/profiles") return _redirect("/profiles")
@@ -581,10 +668,11 @@ def profile_delete(profile_id: int, user: User = Depends(get_current_user_web),
@router.post("/profiles/{profile_id}/rename") @router.post("/profiles/{profile_id}/rename")
def profile_rename(profile_id: int, nickname: str = Form(...), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): def profile_rename(profile_id: int, nickname: str = Form(...), country: 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() p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
if p: if p:
p.nickname = nickname p.nickname = nickname
p.country = country.strip().upper() if country else None
db.commit() db.commit()
return _redirect("/profiles") return _redirect("/profiles")
@@ -649,6 +737,7 @@ def postcard_create(
status: str = Form(...), status: str = Form(...),
recipient_name: str = Form(""), recipient_name: str = Form(""),
country: str = Form(""), country: str = Form(""),
country_from: str = Form(""),
send_time: str = Form(""), send_time: str = Form(""),
arrival_time: str = Form(""), arrival_time: str = Form(""),
sender_name: str = Form(""), sender_name: str = Form(""),
@@ -662,7 +751,10 @@ def postcard_create(
return _redirect("/profiles") return _redirect("/profiles")
cn = card_number.strip() cn = card_number.strip()
if db.query(Postcard).filter(Postcard.card_number == cn).first(): dup = db.query(Postcard).join(Profile).filter(
Postcard.card_number == cn, Profile.user_id == user.id
).first()
if dup:
return templates.TemplateResponse("postcard_form.html", { return templates.TemplateResponse("postcard_form.html", {
"request": request, "user": user, "profile": profile, "postcard": None, "request": request, "user": user, "profile": profile, "postcard": None,
"error": _t("pc_list.duplicate", user.language), "error": _t("pc_list.duplicate", user.language),
@@ -686,6 +778,7 @@ def postcard_create(
status=status, status=status,
recipient_name=recipient_name or None, recipient_name=recipient_name or None,
country=(country.strip().upper() if country else None), country=(country.strip().upper() if country else None),
country_from=(country_from.strip().upper() if country_from else profile.country),
send_time=_parse_dt(send_time), send_time=_parse_dt(send_time),
arrival_time=_parse_dt(arrival_time), arrival_time=_parse_dt(arrival_time),
sender_name=sender_name or None, sender_name=sender_name or None,
@@ -731,6 +824,7 @@ def postcard_update(
status: str = Form(...), status: str = Form(...),
recipient_name: str = Form(""), recipient_name: str = Form(""),
country: str = Form(""), country: str = Form(""),
country_from: str = Form(""),
send_time: str = Form(""), send_time: str = Form(""),
arrival_time: str = Form(""), arrival_time: str = Form(""),
sender_name: str = Form(""), sender_name: str = Form(""),
@@ -749,7 +843,10 @@ def postcard_update(
return _redirect("/profiles") return _redirect("/profiles")
cn = card_number.strip() cn = card_number.strip()
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first() dup = db.query(Postcard).join(Profile).filter(
Postcard.card_number == cn, Postcard.id != postcard_id,
Profile.user_id == user.id
).first()
if dup: if dup:
return templates.TemplateResponse("postcard_form.html", { return templates.TemplateResponse("postcard_form.html", {
"request": request, "user": user, "profile": pc.profile, "postcard": pc, "request": request, "user": user, "profile": pc.profile, "postcard": pc,
@@ -772,6 +869,7 @@ def postcard_update(
pc.status = status pc.status = status
pc.recipient_name = recipient_name or None pc.recipient_name = recipient_name or None
pc.country = country or None pc.country = country or None
pc.country_from = country_from.strip().upper() if country_from else pc.country_from
pc.send_time = _parse_dt(send_time) pc.send_time = _parse_dt(send_time)
pc.arrival_time = None if status == "sent" else _parse_dt(arrival_time) pc.arrival_time = None if status == "sent" else _parse_dt(arrival_time)
pc.sender_name = sender_name or None pc.sender_name = sender_name or None
@@ -925,7 +1023,10 @@ 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() profiles = db.query(Profile).filter(Profile.user_id == user.id, Profile.showcase_enabled == True).all()
sent_all, received_all, profile_groups = [], [], [] sent_all, received_all, profile_groups = [], [], []
for p in profiles: for p in profiles:
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all() cards = db.query(Postcard).filter(
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
Postcard.image_front.isnot(None), Postcard.image_front != ""
).all()
if p.showcase_visible in ("sent", "both"): if p.showcase_visible in ("sent", "both"):
sent_all.extend([c for c in cards if c.status in ("sent", "delivered")]) sent_all.extend([c for c in cards if c.status in ("sent", "delivered")])
if p.showcase_visible in ("received", "both"): if p.showcase_visible in ("received", "both"):

View File

@@ -7,11 +7,18 @@ from pydantic import BaseModel
class ProfileCreate(BaseModel): class ProfileCreate(BaseModel):
nickname: str nickname: str
country: str | None = None
class ProfileUpdate(BaseModel):
nickname: str | None = None
country: str | None = None
class ProfileOut(BaseModel): class ProfileOut(BaseModel):
id: int id: int
nickname: str nickname: str
country: str | None
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -24,6 +31,7 @@ class PostcardCreate(BaseModel):
status: str status: str
recipient_name: str | None = None recipient_name: str | None = None
country: str | None = None country: str | None = None
country_from: str | None = None
send_time: datetime | None = None send_time: datetime | None = None
arrival_time: datetime | None = None arrival_time: datetime | None = None
sender_name: str | None = None sender_name: str | None = None
@@ -35,6 +43,7 @@ class PostcardUpdate(BaseModel):
status: str | None = None status: str | None = None
recipient_name: str | None = None recipient_name: str | None = None
country: str | None = None country: str | None = None
country_from: str | None = None
send_time: datetime | None = None send_time: datetime | None = None
arrival_time: datetime | None = None arrival_time: datetime | None = None
sender_name: str | None = None sender_name: str | None = None
@@ -48,6 +57,7 @@ class PostcardOut(BaseModel):
status: str status: str
recipient_name: str | None recipient_name: str | None
country: str | None country: str | None
country_from: str | None
send_time: datetime | None send_time: datetime | None
arrival_time: datetime | None arrival_time: datetime | None
sender_name: str | None sender_name: str | None

View File

@@ -12,7 +12,8 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<a href="/login" class="btn btn-primary" style="margin-left:.5rem">{{ 'index.go_login'|t(lang) }}</a> <a href="/login" class="btn btn-sm" style="margin-left:.5rem">{{ 'index.go_login'|t(lang) }}</a>
<a href="/register" class="btn btn-primary btn-sm">{{ 'index.go_register'|t(lang) }}</a>
</div> </div>
</nav> </nav>
{% endblock %} {% endblock %}
@@ -22,25 +23,30 @@
<p style="color:var(--text-muted);margin-top:.25rem">{{ 'index.subtitle'|t(lang) }}</p> <p style="color:var(--text-muted);margin-top:.25rem">{{ 'index.subtitle'|t(lang) }}</p>
</div> </div>
{% if postcards %} {% set visible = [] %}
<div class="showcase-grid">
{% for pc in postcards %} {% for pc in postcards %}
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
{% if pc.image_front %} {% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy"> {% if visible.append(pc) %}{% endif %}
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% 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 }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body"> <div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta"> <div class="showcase-card-meta">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span> {% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}{{ pc.country|flag }}</span>
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %} {% elif pc.country %}<span>{{ pc.country|flag }}</span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<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>
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
<p>{{ 'index.empty'|t(lang) }}</p> <p>{{ 'index.empty'|t(lang) }}</p>
@@ -49,26 +55,116 @@
<!-- Lightbox --> <!-- Lightbox -->
<div id="lightbox" class="lightbox" onclick="closeLightbox()" style="display:none"> <div id="lightbox" class="lightbox" onclick="closeLightbox()" style="display:none">
<div class="lightbox-inner" onclick="event.stopPropagation()"> <div class="lightbox-content" onclick="event.stopPropagation()">
<button class="lightbox-close" onclick="closeLightbox()">&times;</button> <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=""> <img id="lightbox-img" src="" alt="">
<div id="lightbox-number" class="lightbox-number"></div> <div id="lightbox-caption" class="lightbox-caption"></div>
</div> </div>
</div> </div>
<script> <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) countryHtml = '<span>' + _flag(c.country_from) + '→' + _flag(c.country) + '</span>';
else if (c.country) countryHtml = '<span>' + _flag(c.country) + '</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) { function openLightbox(el) {
buildLightboxItems();
var src = el.getAttribute('data-front'); var src = el.getAttribute('data-front');
if (!src) return; if (!src) return;
document.getElementById('lightbox-img').src = src; for (var i = 0; i < lightboxItems.length; i++) {
document.getElementById('lightbox-number').textContent = el.getAttribute('data-number'); if (lightboxItems[i].src === src) { lightboxIdx = i; break; }
}
showLightboxItem();
document.getElementById('lightbox').style.display = 'flex'; document.getElementById('lightbox').style.display = 'flex';
document.body.style.overflow = 'hidden';
} }
function closeLightbox() {
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.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) { document.addEventListener('keydown', function(e) {
var lb = document.getElementById('lightbox');
if (!lb || lb.style.display === 'none') return;
if (e.key === 'Escape') closeLightbox(); if (e.key === 'Escape') closeLightbox();
else if (e.key === 'ArrowLeft') navLightbox(-1);
else if (e.key === 'ArrowRight') navLightbox(1);
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -52,11 +52,12 @@
<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> <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') %} {% if postcard.status in ('sent','delivered') %}
<dt>{{ 'pc_detail.recipient'|t(user.language) }}</dt><dd>{{ postcard.recipient_name or '-' }}</dd> <dt>{{ 'pc_detail.recipient'|t(user.language) }}</dt><dd>{{ postcard.recipient_name or '-' }}</dd>
<dt>{{ 'pc_detail.country'|t(user.language) }}</dt><dd>{{ postcard.country|flag }} {{ postcard.country 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|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> <dt>{{ 'pc_detail.send_time'|t(user.language) }}</dt><dd>{{ postcard.send_time.strftime('%Y-%m-%d') if postcard.send_time else '-' }}</dd>
<dt>{{ 'pc_detail.arrival_time'|t(user.language) }}</dt><dd>{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard.arrival_time else '-' }}</dd> <dt>{{ 'pc_detail.arrival_time'|t(user.language) }}</dt><dd>{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard.arrival_time else '-' }}</dd>
{% else %} {% else %}
<dt>{{ 'pc_detail.sender'|t(user.language) }}</dt><dd>{{ postcard.sender_name or '-' }}</dd> <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|flag }} {{ postcard.country 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> <dt>{{ 'pc_detail.receive_time'|t(user.language) }}</dt><dd>{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard.receive_time else '-' }}</dd>
{% endif %} {% endif %}
<dt>{{ 'pc_detail.profile'|t(user.language) }}</dt><dd>{{ postcard.profile.nickname }}</dd> <dt>{{ 'pc_detail.profile'|t(user.language) }}</dt><dd>{{ postcard.profile.nickname }}</dd>

View File

@@ -18,8 +18,6 @@
<div id="fields-sent-delivered"> <div id="fields-sent-delivered">
<label>{{ 'pc_form.recipient'|t(user.language) }} *</label> <label>{{ 'pc_form.recipient'|t(user.language) }} *</label>
<input type="text" name="recipient_name" id="input-recipient" value="{{ postcard.recipient_name if postcard 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> <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 '' }}"> <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> <label id="label-arrival">{{ 'pc_form.arrival_time'|t(user.language) }}</label>
@@ -33,6 +31,12 @@
<input type="date" name="receive_time" id="input-receive" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard and postcard.receive_time else '' }}"> <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> </div>
<label>{{ 'pc_form.country_from'|t(user.language) }}</label>
<input type="text" name="country_from" id="input-country-from" value="{{ postcard.country_from if postcard and postcard.country_from else (profile.country if profile else '') }}" maxlength="2" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<label>{{ 'pc_form.country'|t(user.language) }} *</label>
<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.notes'|t(user.language) }}</label> <label>{{ 'pc_form.notes'|t(user.language) }}</label>
<textarea name="notes" rows="3" placeholder="{{ 'pc_form.notes_ph'|t(user.language) }}">{{ postcard.notes if postcard else '' }}</textarea> <textarea name="notes" rows="3" placeholder="{{ 'pc_form.notes_ph'|t(user.language) }}">{{ postcard.notes if postcard else '' }}</textarea>

View File

@@ -41,7 +41,7 @@
<div class="postcard-row-info"> <div class="postcard-row-info">
<strong>{{ pc.card_number }}</strong> <strong>{{ pc.card_number }}</strong>
<div class="postcard-row-detail"> <div class="postcard-row-detail">
<span>{{ pc.country|flag }} {{ pc.country or '-' }}</span> <span>{% if pc.country_from %}{{ pc.country_from|flag }}→{% endif %}{{ pc.country|flag }} {{ pc.country or '-' }}</span>
<span>→ {{ pc.recipient_name or '-' }}</span> <span>→ {{ pc.recipient_name or '-' }}</span>
</div> </div>
<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="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达'}.get(pc.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered'}.get(pc.status) }}</span>

View File

@@ -7,6 +7,7 @@
<div class="inline-form"> <div class="inline-form">
<form method="post" action="/profiles" class="form-row"> <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="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> <button type="submit" class="btn btn-primary">{{ 'profiles.new_btn'|t(user.language) }}</button>
</form> </form>
</div> </div>
@@ -18,10 +19,12 @@
<div class="profile-card-header"> <div class="profile-card-header">
<h3 class="profile-card-name"> <h3 class="profile-card-name">
<span id="name-display-{{ p.id }}">{{ p.nickname }}</span> <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> <button type="button" class="icon-btn" onclick="startRename({{ p.id }})" title="{{ 'profiles.rename'|t(user.language) }}">✏️</button>
</h3> </h3>
<form method="post" action="/profiles/{{ p.id }}/rename" class="rename-form" id="name-edit-{{ p.id }}" style="display:none"> <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="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="submit" class="btn btn-primary btn-sm"></button>
<button type="button" class="btn btn-sm" onclick="cancelRename({{ p.id }})"></button> <button type="button" class="btn btn-sm" onclick="cancelRename({{ p.id }})"></button>
</form> </form>

View File

@@ -16,7 +16,20 @@
<h1>📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1> <h1>📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1>
</div> </div>
{% set has_content = (sent_all|length + received_all|length) > 0 %} {% 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 %}
{% if not has_content %} {% if not has_content %}
<div class="empty-state"> <div class="empty-state">
<p>{{ 'showcase.empty'|t(lang) }}</p> <p>{{ 'showcase.empty'|t(lang) }}</p>
@@ -24,25 +37,21 @@
{% else %} {% else %}
<div class="tabs" id="tabs"> <div class="tabs" id="tabs">
<button class="tab active" onclick="switchTab('sent')">{{ 'showcase.tab_sent'|t(lang) }} ({{ sent_all|length }})</button> <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_all|length }})</button> <button class="tab" onclick="switchTab('received')">{{ 'showcase.tab_received'|t(lang) }} ({{ received_visible|length }})</button>
</div> </div>
<div id="tab-sent" class="tab-panel"> <div id="tab-sent" class="tab-panel">
{% if mode == 'flat' %} {% if mode == 'flat' %}
<div class="showcase-grid"> <div class="showcase-grid" id="grid-sent">
{% for pc in sent_all %} {% for pc in sent_visible %}
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy"> <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"> <div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta"> <div class="showcase-card-meta">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span> {% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}{{ pc.country|flag }}</span>
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %} {% elif pc.country %}<span>{{ pc.country|flag }}</span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -50,21 +59,23 @@
</div> </div>
{% else %} {% else %}
{% for g in profile_groups %} {% for g in profile_groups %}
{% if g.sent %} {% set g_sent = [] %}
{% for pc in g.sent %}
{% if pc.image_front %}
{% if g_sent.append(pc) %}{% endif %}
{% endif %}
{% endfor %}
{% if g_sent %}
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3> <h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
<div class="showcase-grid"> <div class="showcase-grid">
{% for pc in g.sent %} {% for pc in g_sent %}
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy"> <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"> <div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta"> <div class="showcase-card-meta">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span> {% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}{{ pc.country|flag }}</span>
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %} {% elif pc.country %}<span>{{ pc.country|flag }}</span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -73,23 +84,21 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<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>
</div> </div>
<div id="tab-received" class="tab-panel" style="display:none"> <div id="tab-received" class="tab-panel" style="display:none">
{% if mode == 'flat' %} {% if mode == 'flat' %}
<div class="showcase-grid"> <div class="showcase-grid" id="grid-received">
{% for pc in received_all %} {% for pc in received_visible %}
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy"> <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"> <div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta"> <div class="showcase-card-meta">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span> {% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}{{ pc.country|flag }}</span>
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %} {% elif pc.country %}<span>{{ pc.country|flag }}</span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -97,21 +106,23 @@
</div> </div>
{% else %} {% else %}
{% for g in profile_groups %} {% for g in profile_groups %}
{% if g.received %} {% set g_received = [] %}
{% for pc in g.received %}
{% if pc.image_front %}
{% if g_received.append(pc) %}{% endif %}
{% endif %}
{% endfor %}
{% if g_received %}
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3> <h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
<div class="showcase-grid"> <div class="showcase-grid">
{% for pc in g.received %} {% for pc in g_received %}
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy"> <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"> <div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta"> <div class="showcase-card-meta">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span> {% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}{{ pc.country|flag }}</span>
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %} {% elif pc.country %}<span>{{ pc.country|flag }}</span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -120,20 +131,88 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<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>
</div> </div>
{% endif %} {% endif %}
<div id="lightbox" class="lightbox" onclick="closeLightbox(event)"> <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()"> <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=""> <img id="lightbox-img" src="" alt="">
<div id="lightbox-caption" class="lightbox-caption"></div> <div id="lightbox-caption" class="lightbox-caption"></div>
</div> </div>
</div> </div>
<script> <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) countryHtml = '<span>' + _flag(c.country_from) + '→' + _flag(c.country) + '</span>';
else if (c.country) countryHtml = '<span>' + _flag(c.country) + '</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) { function switchTab(name) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); }); document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; }); document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
@@ -149,8 +228,7 @@ function buildLightboxItems() {
var visible = document.querySelectorAll('.tab-panel:not([style*="display:none"]) .showcase-card[data-front]'); var visible = document.querySelectorAll('.tab-panel:not([style*="display:none"]) .showcase-card[data-front]');
visible.forEach(function(card) { visible.forEach(function(card) {
var front = card.getAttribute('data-front'); var front = card.getAttribute('data-front');
var number = card.getAttribute('data-number'); if (front) lightboxItems.push({src: front});
if (front) lightboxItems.push({src: front, number: number});
}); });
} }
@@ -158,9 +236,8 @@ function openLightbox(el) {
buildLightboxItems(); buildLightboxItems();
var front = el.getAttribute('data-front'); var front = el.getAttribute('data-front');
if (!front) return; if (!front) return;
var number = el.getAttribute('data-number');
for (var i = 0; i < lightboxItems.length; i++) { for (var i = 0; i < lightboxItems.length; i++) {
if (lightboxItems[i].number === number) { lightboxIdx = i; break; } if (lightboxItems[i].src === front) { lightboxIdx = i; break; }
} }
showLightboxItem(); showLightboxItem();
document.getElementById('lightbox').classList.add('active'); document.getElementById('lightbox').classList.add('active');
@@ -171,7 +248,7 @@ function showLightboxItem() {
if (!lightboxItems.length) return; if (!lightboxItems.length) return;
var item = lightboxItems[lightboxIdx]; var item = lightboxItems[lightboxIdx];
document.getElementById('lightbox-img').src = item.src; document.getElementById('lightbox-img').src = item.src;
document.getElementById('lightbox-caption').textContent = item.number + ' (' + (lightboxIdx+1) + '/' + lightboxItems.length + ')'; document.getElementById('lightbox-caption').textContent = (lightboxIdx+1) + '/' + lightboxItems.length;
} }
function closeLightbox(e) { function closeLightbox(e) {
@@ -194,13 +271,10 @@ document.addEventListener('keydown', function(e) {
else if (e.key === 'ArrowLeft') navLightbox(-1); else if (e.key === 'ArrowLeft') navLightbox(-1);
else if (e.key === 'ArrowRight') navLightbox(1); else if (e.key === 'ArrowRight') navLightbox(1);
}); });
// Close language dropdown when clicking outside
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var sw = document.getElementById('langSwitcher'); var sw = document.getElementById('langSwitcher');
var dd = document.getElementById('langDropdown'); var dd = document.getElementById('langDropdown');
if (sw && dd && !sw.contains(e.target)) { if (sw && dd && !sw.contains(e.target)) dd.classList.remove('show');
dd.classList.remove('show');
}
}); });
</script> </script>
{% endblock %} {% endblock %}