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.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",
@@ -64,6 +65,7 @@
"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",
@@ -96,6 +98,7 @@
"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,6 +115,7 @@
"pc_form.status": "Status",
"pc_form.recipient": "Recipient",
"pc_form.country": "Country",
"pc_form.country_from": "From",
"pc_form.send_time": "Sent date",
"pc_form.arrival_time": "Arrival date",
"pc_form.sender": "Sender",

View File

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

View File

@@ -1,6 +1,6 @@
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 app.database import Base
@@ -61,6 +61,7 @@ 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="")
@@ -72,14 +73,18 @@ 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, unique=True)
card_number = Column(String(64), nullable=False)
status = Column(String(16), nullable=False)
# sent & delivered
recipient_name = Column(String(64))
country = Column(String(64))
country_from = Column(String(2))
send_time = Column(DateTime)
arrival_time = Column(DateTime)
# received
@@ -100,6 +105,10 @@ class Postcard(Base):
def _upper_country(self, key, value):
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):
__tablename__ = "api_keys"

View File

@@ -81,13 +81,16 @@ 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
@@ -101,6 +104,7 @@ class PostcardCreate(BaseModel):
status: str
recipient_name: str | None = None
country: str | None = None
country_from: str | None = None
send_time: str | None = None
arrival_time: str | None = None
sender_name: str | None = None
@@ -112,6 +116,7 @@ class PostcardUpdate(BaseModel):
status: str | None = None
recipient_name: str | None = None
country: str | None = None
country_from: str | None = None
send_time: str | None = None
arrival_time: str | None = None
sender_name: str | None = None
@@ -125,6 +130,7 @@ class PostcardOut(BaseModel):
status: str
recipient_name: str | None
country: str | None
country_from: str | None
send_time: datetime | None
arrival_time: datetime | None
sender_name: str | None
@@ -205,7 +211,8 @@ def create_profile(
db: DbSession = Depends(get_db),
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.commit()
db.refresh(p)
@@ -231,6 +238,8 @@ 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
@@ -275,7 +284,10 @@ def create_postcard(
):
_profile_or_404(ak.user, profile_id, db)
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")
pc = Postcard(
profile_id=profile_id,
@@ -283,6 +295,7 @@ def create_postcard(
status=body.status,
recipient_name=body.recipient_name,
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),
arrival_time=_parse_date(body.arrival_time),
sender_name=body.sender_name,
@@ -320,12 +333,17 @@ 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).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:
raise HTTPException(status_code=409, detail="Card number already exists")
pc.card_number = cn
if body.country is not 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"):
val = getattr(body, field)
if val is not None:

View File

@@ -4,7 +4,7 @@ from random import shuffle
from uuid import uuid4
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 sqlalchemy.orm import Session
@@ -126,7 +126,8 @@ def index_page(request: Request, db: Session = Depends(get_db)):
).all()
for p in profiles:
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()
for c in cards:
visible = True
@@ -147,7 +148,92 @@ def index_page(request: Request, db: Session = Depends(get_db)):
lang = "zh"
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")
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))
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))
db.commit()
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")
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()
if p:
p.nickname = nickname
p.country = country.strip().upper() if country else None
db.commit()
return _redirect("/profiles")
@@ -649,6 +737,7 @@ def postcard_create(
status: str = Form(...),
recipient_name: str = Form(""),
country: str = Form(""),
country_from: str = Form(""),
send_time: str = Form(""),
arrival_time: str = Form(""),
sender_name: str = Form(""),
@@ -662,7 +751,10 @@ def postcard_create(
return _redirect("/profiles")
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", {
"request": request, "user": user, "profile": profile, "postcard": None,
"error": _t("pc_list.duplicate", user.language),
@@ -686,6 +778,7 @@ def postcard_create(
status=status,
recipient_name=recipient_name or 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),
arrival_time=_parse_dt(arrival_time),
sender_name=sender_name or None,
@@ -731,6 +824,7 @@ def postcard_update(
status: str = Form(...),
recipient_name: str = Form(""),
country: str = Form(""),
country_from: str = Form(""),
send_time: str = Form(""),
arrival_time: str = Form(""),
sender_name: str = Form(""),
@@ -749,7 +843,10 @@ def postcard_update(
return _redirect("/profiles")
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:
return templates.TemplateResponse("postcard_form.html", {
"request": request, "user": user, "profile": pc.profile, "postcard": pc,
@@ -772,6 +869,7 @@ def postcard_update(
pc.status = status
pc.recipient_name = recipient_name 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.arrival_time = None if status == "sent" else _parse_dt(arrival_time)
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()
sent_all, received_all, profile_groups = [], [], []
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"):
sent_all.extend([c for c in cards if c.status in ("sent", "delivered")])
if p.showcase_visible in ("received", "both"):

View File

@@ -7,11 +7,18 @@ from pydantic import BaseModel
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}
@@ -24,6 +31,7 @@ class PostcardCreate(BaseModel):
status: str
recipient_name: str | None = None
country: str | None = None
country_from: str | None = None
send_time: datetime | None = None
arrival_time: datetime | None = None
sender_name: str | None = None
@@ -35,6 +43,7 @@ class PostcardUpdate(BaseModel):
status: str | None = None
recipient_name: str | None = None
country: str | None = None
country_from: str | None = None
send_time: datetime | None = None
arrival_time: datetime | None = None
sender_name: str | None = None
@@ -48,6 +57,7 @@ class PostcardOut(BaseModel):
status: str
recipient_name: str | None
country: str | None
country_from: str | None
send_time: datetime | None
arrival_time: datetime | None
sender_name: str | None

View File

@@ -12,7 +12,8 @@
{% endfor %}
</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>
</nav>
{% endblock %}
@@ -22,25 +23,30 @@
<p style="color:var(--text-muted);margin-top:.25rem">{{ 'index.subtitle'|t(lang) }}</p>
</div>
{% if postcards %}
<div class="showcase-grid">
{% for pc in postcards %}
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
{% set visible = [] %}
{% for pc in postcards %}
{% 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>
{% 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 }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
{% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}{{ pc.country|flag }}</span>
{% elif pc.country %}<span>{{ pc.country|flag }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</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 %}
<div class="empty-state">
<p>{{ 'index.empty'|t(lang) }}</p>
@@ -49,26 +55,116 @@
<!-- Lightbox -->
<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-prev" onclick="navLightbox(-1)">&#8249;</button>
<button class="lightbox-next" onclick="navLightbox(1)">&#8250;</button>
<img id="lightbox-img" src="" alt="">
<div id="lightbox-number" class="lightbox-number"></div>
<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) 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) {
buildLightboxItems();
var src = el.getAttribute('data-front');
if (!src) return;
document.getElementById('lightbox-img').src = src;
document.getElementById('lightbox-number').textContent = el.getAttribute('data-number');
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 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.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 %}

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>
{% if postcard.status in ('sent','delivered') %}
<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.arrival_time'|t(user.language) }}</dt><dd>{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard.arrival_time else '-' }}</dd>
{% 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|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>
{% endif %}
<dt>{{ 'pc_detail.profile'|t(user.language) }}</dt><dd>{{ postcard.profile.nickname }}</dd>

View File

@@ -18,8 +18,6 @@
<div id="fields-sent-delivered">
<label>{{ 'pc_form.recipient'|t(user.language) }} *</label>
<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>
@@ -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 '' }}">
</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>
<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">
<strong>{{ pc.card_number }}</strong>
<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>
</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>

View File

@@ -7,6 +7,7 @@
<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>
@@ -18,10 +19,12 @@
<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>

View File

@@ -16,7 +16,20 @@
<h1>📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1>
</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 %}
<div class="empty-state">
<p>{{ 'showcase.empty'|t(lang) }}</p>
@@ -24,25 +37,21 @@
{% else %}
<div class="tabs" id="tabs">
<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>
<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>
</div>
<div id="tab-sent" class="tab-panel">
{% if mode == 'flat' %}
<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 %}
<div class="showcase-grid" id="grid-sent">
{% for pc in sent_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">
{% 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">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
{% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}{{ pc.country|flag }}</span>
{% elif pc.country %}<span>{{ pc.country|flag }}</span>
{% endif %}
</div>
</div>
</div>
@@ -50,21 +59,23 @@
</div>
{% else %}
{% 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>
<div class="showcase-grid">
{% 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 %}
{% for pc in g_sent %}
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<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">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
{% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}{{ pc.country|flag }}</span>
{% elif pc.country %}<span>{{ pc.country|flag }}</span>
{% endif %}
</div>
</div>
</div>
@@ -73,23 +84,21 @@
{% endif %}
{% endfor %}
{% 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 id="tab-received" class="tab-panel" style="display:none">
{% if mode == 'flat' %}
<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 %}
<div class="showcase-grid" id="grid-received">
{% for pc in received_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">
{% 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">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %}
{% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}{{ pc.country|flag }}</span>
{% elif pc.country %}<span>{{ pc.country|flag }}</span>
{% endif %}
</div>
</div>
</div>
@@ -97,21 +106,23 @@
</div>
{% else %}
{% 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>
<div class="showcase-grid">
{% 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 %}
{% for pc in g_received %}
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<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">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %}
{% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}{{ pc.country|flag }}</span>
{% elif pc.country %}<span>{{ pc.country|flag }}</span>
{% endif %}
</div>
</div>
</div>
@@ -120,20 +131,88 @@
{% endif %}
{% endfor %}
{% 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>
{% 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) 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) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
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]');
visible.forEach(function(card) {
var front = card.getAttribute('data-front');
var number = card.getAttribute('data-number');
if (front) lightboxItems.push({src: front, number: number});
if (front) lightboxItems.push({src: front});
});
}
@@ -158,9 +236,8 @@ 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].number === number) { lightboxIdx = i; break; }
if (lightboxItems[i].src === front) { lightboxIdx = i; break; }
}
showLightboxItem();
document.getElementById('lightbox').classList.add('active');
@@ -171,7 +248,7 @@ function showLightboxItem() {
if (!lightboxItems.length) return;
var item = lightboxItems[lightboxIdx];
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) {
@@ -194,13 +271,10 @@ 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 %}