Compare commits

..

19 Commits

Author SHA1 Message Date
05168fb1ca fix(postcard): remove auto option from status dropdown, auto-select silently by date 2026-07-15 19:16:41 +08:00
5ec21f0e3f feat(postcard): allow manual status override in new-sent form (auto/pending/sent/delivered) 2026-07-15 19:10:12 +08:00
de29f5445e feat(postcard): split create into sent/received, add pending status and mark-sent flow 2026-07-15 19:04:54 +08:00
d79aaea4ae fix(web): correct country_to form parameter name mismatch in create and edit routes 2026-07-10 23:07:56 +08:00
3323523e8e fix(dashboard): put time in its own column on the right side 2026-07-07 14:00:07 +08:00
afee0b2d92 fix(dashboard): country on first line, person + time on second line 2026-07-07 13:58:56 +08:00
722895a0c8 fix(dashboard): wrap recent-meta in container so country and person display on separate lines 2026-07-07 13:58:06 +08:00
985010d63c fix(dashboard): show country and person on separate lines in recent activity 2026-07-07 13:56:07 +08:00
599a95ee12 fix(ui): fix field name corruption (country_to_from -> country_from) and ensure all flag displays show country code 2026-07-07 13:54:00 +08:00
fed060ea84 fix(ui): hide scroll loading indicator when no more content to load 2026-07-07 13:45:25 +08:00
ad6f2b2ff2 refactor(model): rename Postcard.country -> country_to for clear semantics - country_from: origin country (where postcard was sent from) - country_to: destination country (where postcard was sent to) - Profile.country remains as user's home country - Updated all routes, schemas, templates, and API endpoints 2026-07-07 13:43:39 +08:00
806c9ba8a0 fix(ui): show country code alongside flag emoji in from→to display 2026-07-07 13:34:06 +08:00
1292c6591b fix(profiles): allow existing profiles to edit country field in rename form 2026-07-07 13:28:24 +08:00
5de27f1937 feat(showcase): display send_time/receive_time on personal showcase cards 2026-07-07 13:24:58 +08:00
b5716ff340 fix(dashboard): sort recent activity by updated_at so delivered/received cards also appear 2026-07-07 13:23:39 +08:00
a071b3dde4 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 2026-07-07 12:32:24 +08:00
bd664d23be feat(ui): add landing page with random public postcards for unauthenticated visitors 2026-07-07 12:05:44 +08:00
ba56fc6e6b feat(admin): merge invites + copyright into system admin page with SystemConfig table 2026-07-07 11:35:57 +08:00
8a4500f723 chore: remove scripts and export_i18n.py from git, add to .gitignore 2026-07-07 11:35:00 +08:00
18 changed files with 762 additions and 233 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ uploads/*
*.db *.db
.env .env
server.log server.log
scripts/

View File

@@ -3,6 +3,11 @@
"name": "English" "name": "English"
}, },
"brand": "📮 Mailova", "brand": "📮 Mailova",
"index.title": "Mailova",
"index.subtitle": "Postcards from around the world",
"index.go_login": "Login",
"index.go_register": "Register",
"index.empty": "No public postcards yet",
"nav.profiles": "Profiles", "nav.profiles": "Profiles",
"nav.settings": "Settings", "nav.settings": "Settings",
"nav.invites": "Invites", "nav.invites": "Invites",
@@ -17,6 +22,11 @@
"admin.copyright_label": "Copyright Text", "admin.copyright_label": "Copyright Text",
"admin.copyright_hint": "Displayed at the bottom of all pages. Leave empty to hide.", "admin.copyright_hint": "Displayed at the bottom of all pages. Leave empty to hide.",
"admin.save_config": "Save", "admin.save_config": "Save",
"admin.invite_length_error": "Invite code must be 8 characters",
"admin.invite_exists_error": "Invite code already exists",
"admin.invite_created": "Invite code {code} created",
"admin.invite_updated": "Invite code {code} updated",
"admin.config_saved": "Settings saved",
"login.title": "Login", "login.title": "Login",
"login.username": "Username", "login.username": "Username",
"login.password": "Password", "login.password": "Password",
@@ -55,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",
@@ -62,17 +73,23 @@
"profiles.confirm_delete": "Delete this profile?", "profiles.confirm_delete": "Delete this profile?",
"pc_list.title": "'s postcards", "pc_list.title": "'s postcards",
"pc_list.new": "+ New postcard", "pc_list.new": "+ New postcard",
"pc_list.new_sent": "✉️ Sent",
"pc_list.new_received": "📬 Received",
"pc_list.duplicate": "Card number already exists", "pc_list.duplicate": "Card number already exists",
"pc_list.all_countries": "All countries", "pc_list.all_countries": "All countries",
"pc_list.filter": "Filter", "pc_list.filter": "Filter",
"pc_list.tab_sent": "Sent", "pc_list.tab_sent": "Sent",
"pc_list.tab_received": "Received", "pc_list.tab_received": "Received",
"pc_list.mark_sent": "Send",
"pc_list.confirm_sent": "Mark as sent?",
"pc_list.mark_delivered": "Delivered", "pc_list.mark_delivered": "Delivered",
"pc_list.confirm_delivered": "Mark as delivered?", "pc_list.confirm_delivered": "Mark as delivered?",
"pc_list.empty_sent": "No sent postcards.", "pc_list.empty_sent": "No sent postcards.",
"pc_list.empty_received": "No received postcards.", "pc_list.empty_received": "No received postcards.",
"pc_detail.title": "Postcard detail", "pc_detail.title": "Postcard detail",
"pc_detail.edit": "Edit", "pc_detail.edit": "Edit",
"pc_detail.mark_sent": "Send",
"pc_detail.confirm_sent": "Mark as sent?",
"pc_detail.mark_delivered": "Mark delivered", "pc_detail.mark_delivered": "Mark delivered",
"pc_detail.confirm_delivered": "Mark as delivered?", "pc_detail.confirm_delivered": "Mark as delivered?",
"pc_detail.delete": "Delete", "pc_detail.delete": "Delete",
@@ -87,6 +104,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",
@@ -94,15 +112,19 @@
"pc_detail.profile": "Profile", "pc_detail.profile": "Profile",
"pc_detail.notes": "Notes", "pc_detail.notes": "Notes",
"status.sent": "Sent", "status.sent": "Sent",
"status.pending": "Pending",
"status.delivered": "Delivered", "status.delivered": "Delivered",
"status.received": "Received", "status.received": "Received",
"pc_form.edit": "Edit postcard", "pc_form.edit": "Edit postcard",
"pc_form.new": "New postcard", "pc_form.new": "New postcard",
"pc_form.new_sent": "New sent",
"pc_form.new_received": "New received",
"pc_form.number": "Number", "pc_form.number": "Number",
"pc_form.number_hint": "Format: AB-1234567", "pc_form.number_hint": "Format: AB-1234567",
"pc_form.status": "Status", "pc_form.status": "Status",
"pc_form.recipient": "Recipient", "pc_form.recipient": "Recipient",
"pc_form.country": "Country", "pc_form.country": "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

@@ -3,6 +3,11 @@
"name": "中文" "name": "中文"
}, },
"brand": "📮 星笺", "brand": "📮 星笺",
"index.title": "星笺",
"index.subtitle": "来自世界各地的明信片",
"index.go_login": "登录",
"index.go_register": "注册",
"index.empty": "暂无公开明信片",
"nav.profiles": "Profile", "nav.profiles": "Profile",
"nav.settings": "设置", "nav.settings": "设置",
"nav.invites": "邀请码管理", "nav.invites": "邀请码管理",
@@ -17,6 +22,11 @@
"admin.copyright_label": "Copyright 文本", "admin.copyright_label": "Copyright 文本",
"admin.copyright_hint": "将显示在所有页面底部,留空则不显示。", "admin.copyright_hint": "将显示在所有页面底部,留空则不显示。",
"admin.save_config": "保存设置", "admin.save_config": "保存设置",
"admin.invite_length_error": "邀请码必须为8个字符",
"admin.invite_exists_error": "邀请码已存在",
"admin.invite_created": "邀请码 {code} 已创建",
"admin.invite_updated": "邀请码 {code} 已更新",
"admin.config_saved": "设置已保存",
"login.title": "登录", "login.title": "登录",
"login.username": "用户名", "login.username": "用户名",
"login.password": "密码", "login.password": "密码",
@@ -55,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": "查看",
@@ -62,17 +73,23 @@
"profiles.confirm_delete": "确定删除此 Profile", "profiles.confirm_delete": "确定删除此 Profile",
"pc_list.title": " 的明信片", "pc_list.title": " 的明信片",
"pc_list.new": "+ 新明信片", "pc_list.new": "+ 新明信片",
"pc_list.new_sent": "✉️ 寄出",
"pc_list.new_received": "📬 收到",
"pc_list.duplicate": "编号已存在", "pc_list.duplicate": "编号已存在",
"pc_list.all_countries": "全部国家", "pc_list.all_countries": "全部国家",
"pc_list.filter": "筛选", "pc_list.filter": "筛选",
"pc_list.tab_sent": "寄出", "pc_list.tab_sent": "寄出",
"pc_list.tab_received": "收到", "pc_list.tab_received": "收到",
"pc_list.mark_sent": "寄出",
"pc_list.confirm_sent": "确认寄出?",
"pc_list.mark_delivered": "已送达", "pc_list.mark_delivered": "已送达",
"pc_list.confirm_delivered": "确认已送达?", "pc_list.confirm_delivered": "确认已送达?",
"pc_list.empty_sent": "没有寄出的明信片。", "pc_list.empty_sent": "没有寄出的明信片。",
"pc_list.empty_received": "没有收到的明信片。", "pc_list.empty_received": "没有收到的明信片。",
"pc_detail.title": "明信片详情", "pc_detail.title": "明信片详情",
"pc_detail.edit": "编辑", "pc_detail.edit": "编辑",
"pc_detail.mark_sent": "寄出",
"pc_detail.confirm_sent": "确认寄出?",
"pc_detail.mark_delivered": "已送达", "pc_detail.mark_delivered": "已送达",
"pc_detail.confirm_delivered": "确认已送达?", "pc_detail.confirm_delivered": "确认已送达?",
"pc_detail.delete": "删除", "pc_detail.delete": "删除",
@@ -87,6 +104,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": "发件人",
@@ -94,15 +112,19 @@
"pc_detail.profile": "所属 Profile", "pc_detail.profile": "所属 Profile",
"pc_detail.notes": "备注", "pc_detail.notes": "备注",
"status.sent": "已寄出", "status.sent": "已寄出",
"status.pending": "待寄出",
"status.delivered": "已送达", "status.delivered": "已送达",
"status.received": "已收到", "status.received": "已收到",
"pc_form.edit": "编辑明信片", "pc_form.edit": "编辑明信片",
"pc_form.new": "新建明信片", "pc_form.new": "新建明信片",
"pc_form.new_sent": "新建寄出",
"pc_form.new_received": "新建收到",
"pc_form.number": "编号", "pc_form.number": "编号",
"pc_form.number_hint": "格式AB-1234567", "pc_form.number_hint": "格式AB-1234567",
"pc_form.status": "状态", "pc_form.status": "状态",
"pc_form.recipient": "收件人", "pc_form.recipient": "收件人",
"pc_form.country": "国家/地区", "pc_form.country": "国家/地区",
"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_from = Column(String(2))
country_to = Column(String(2))
send_time = Column(DateTime) send_time = Column(DateTime)
arrival_time = Column(DateTime) arrival_time = Column(DateTime)
# received # received
@@ -96,8 +101,12 @@ class Postcard(Base):
profile = relationship("Profile", back_populates="postcards") profile = relationship("Profile", back_populates="postcards")
@validates("country") @validates("country_from")
def _upper_country(self, key, value): def _upper_country_from(self, key, value):
return value.strip().upper() if value else None
@validates("country_to")
def _upper_country_to(self, key, value):
return value.strip().upper() if value else None return value.strip().upper() if value else None

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
@@ -100,7 +103,8 @@ class PostcardCreate(BaseModel):
card_number: str card_number: str
status: str status: str
recipient_name: str | None = None recipient_name: str | None = None
country: str | None = None country_from: str | None = None
country_to: 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
@@ -111,7 +115,8 @@ class PostcardUpdate(BaseModel):
card_number: str | None = None card_number: str | None = None
status: str | None = None status: str | None = None
recipient_name: str | None = None recipient_name: str | None = None
country: str | None = None country_from: str | None = None
country_to: 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
@@ -124,7 +129,8 @@ class PostcardOut(BaseModel):
card_number: str card_number: str
status: str status: str
recipient_name: str | None recipient_name: str | None
country: str | None country_from: str | None
country_to: 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,14 +284,18 @@ 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,
card_number=cn, card_number=cn,
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_from=body.country_from.strip().upper() if body.country_from else None,
country_to=body.country_to.strip().upper() if body.country_to else None,
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_to is not None:
pc.country = body.country.strip().upper() if body.country else None pc.country_to = body.country_to.strip().upper() if body.country_to else None
if body.country_from is not None:
pc.country_from = body.country_from.strip().upper() if body.country_from else None
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

@@ -1,9 +1,10 @@
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from pathlib import Path from pathlib import Path
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
@@ -17,12 +18,11 @@ from app.auth import (
) )
from app.config import SECRET_KEY, SESSION_COOKIE_NAME, UPLOAD_DIR from app.config import SECRET_KEY, SESSION_COOKIE_NAME, UPLOAD_DIR
from app.database import get_db from app.database import get_db
from datetime import datetime
from random import choice from random import choice
from string import ascii_letters, digits from string import ascii_letters, digits
from app.models import InviteCode, Postcard, Profile, User from app.models import InviteCode, Postcard, Profile, User
from app.i18n import t as _t from app.i18n import t as _t, get_languages as _get_languages
router = APIRouter(tags=["web"]) router = APIRouter(tags=["web"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
@@ -102,13 +102,141 @@ def _get_copyright() -> str:
return "" return ""
templates.env.globals["copyright_text"] = _get_copyright() templates.env.globals["get_copyright"] = _get_copyright
def _redirect(url: str) -> RedirectResponse: def _redirect(url: str) -> RedirectResponse:
return RedirectResponse(url, status_code=303) return RedirectResponse(url, status_code=303)
# ---------- Root / Index ----------
@router.get("/", response_class=HTMLResponse)
def index_page(request: Request, db: Session = Depends(get_db)):
user = redirect_if_not_logged_in(request, db)
if user:
return _redirect("/dashboard")
# Collect all public postcards from all users with showcase enabled
all_postcards: list[Postcard] = []
users = db.query(User).filter(User.showcase_mode.isnot(None)).all()
for u in users:
profiles = db.query(Profile).filter(
Profile.user_id == u.id, Profile.showcase_enabled == True
).all()
for p in profiles:
cards = db.query(Postcard).filter(
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
Postcard.image_front.isnot(None), Postcard.image_front != ""
).all()
for c in cards:
visible = True
if c.status in ("pending", "sent", "delivered") and p.showcase_visible not in ("sent", "both"):
visible = False
if c.status == "received" and p.showcase_visible not in ("received", "both"):
visible = False
if visible:
all_postcards.append(c)
shuffle(all_postcards)
lang = request.query_params.get("lang")
avail = _get_languages()
if not lang or lang not in avail:
lang = request.cookies.get("showcase_lang")
if not lang or lang not in avail:
lang = "zh"
return templates.TemplateResponse("index.html", {
"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 ("pending", "sent", "delivered") and p.showcase_visible not in ("sent", "both"):
vis = False
if c.status == "received" and p.showcase_visible not in ("received", "both"):
vis = False
if vis:
all_cards.append(c)
shuffle(all_cards)
total = len(all_cards)
start = (page - 1) * limit
batch = all_cards[start:start + limit]
return JSONResponse({
"total": total,
"page": page,
"has_more": start + limit < total,
"cards": [{
"image_front": c.image_front,
"country_from": c.country_from,
"country_to": c.country_to,
"status": c.status,
} for c in batch],
})
@router.get("/api/public/cards/{username}")
def api_public_cards_user(
username: str,
request: Request,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
tab: str = Query("sent"),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.username == username).first()
if not user:
return JSONResponse({"error": "not found"}, status_code=404)
profiles = db.query(Profile).filter(
Profile.user_id == user.id, Profile.showcase_enabled == True
).all()
all_cards: list[Postcard] = []
for p in profiles:
cards = db.query(Postcard).filter(
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
Postcard.image_front.isnot(None), Postcard.image_front != ""
).all()
if tab == "sent" and p.showcase_visible in ("sent", "both"):
all_cards.extend([c for c in cards if c.status in ("pending", "sent", "delivered")])
elif tab == "received" and p.showcase_visible in ("received", "both"):
all_cards.extend([c for c in cards if c.status == "received"])
all_cards.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
total = len(all_cards)
start = (page - 1) * limit
batch = all_cards[start:start + limit]
return JSONResponse({
"total": total,
"page": page,
"has_more": start + limit < total,
"cards": [{
"image_front": c.image_front,
"country_from": c.country_from,
"country_to": c.country_to,
} for c in batch],
})
# ---------- Auth ---------- # ---------- Auth ----------
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)
@@ -270,7 +398,7 @@ def admin_save_config(
cfg = SystemConfig(key="copyright_text", value=copyright_text.strip()) cfg = SystemConfig(key="copyright_text", value=copyright_text.strip())
db.add(cfg) db.add(cfg)
db.commit() db.commit()
return _admin_render(request, user, db, "config", success="Saved") return _admin_render(request, user, db, "config", success=_t("admin.config_saved", user.language))
@router.post("/admin/invites/add") @router.post("/admin/invites/add")
@@ -291,9 +419,9 @@ def admin_add_invite(
else: else:
code = code.strip() code = code.strip()
if len(code) != 8: if len(code) != 8:
return _admin_render(request, user, db, "invites", error="邀请码必须为8个字符") return _admin_render(request, user, db, "invites", error=_t("admin.invite_length_error", user.language))
if db.query(InviteCode).filter(InviteCode.code == code).first(): if db.query(InviteCode).filter(InviteCode.code == code).first():
return _admin_render(request, user, db, "invites", error="邀请码已存在") return _admin_render(request, user, db, "invites", error=_t("admin.invite_exists_error", user.language))
# Parse limits # Parse limits
m_uses = None m_uses = None
exp_at = None exp_at = None
@@ -307,7 +435,7 @@ def admin_add_invite(
) )
db.add(new_code) db.add(new_code)
db.commit() db.commit()
return _admin_render(request, user, db, "invites", success=f"邀请码 {code} 已创建") return _admin_render(request, user, db, "invites", success=_t("admin.invite_created", user.language).format(code=code))
@router.post("/admin/invites/{code_id}/edit") @router.post("/admin/invites/{code_id}/edit")
@@ -335,7 +463,7 @@ def admin_edit_invite(
code_obj.max_uses = None code_obj.max_uses = None
code_obj.expires_at = None code_obj.expires_at = None
db.commit() db.commit()
return _admin_render(request, user, db, "invites", success=f"邀请码 {code_obj.code} 已更新") return _admin_render(request, user, db, "invites", success=_t("admin.invite_updated", user.language).format(code=code_obj.code))
@router.post("/admin/invites/{code_id}/delete") @router.post("/admin/invites/{code_id}/delete")
@@ -465,10 +593,10 @@ def dashboard(request: Request, user: User = Depends(get_current_user_web), db:
sent = sum(1 for c in all_cards if c.status == "sent") sent = sum(1 for c in all_cards if c.status == "sent")
delivered = sum(1 for c in all_cards if c.status == "delivered") delivered = sum(1 for c in all_cards if c.status == "delivered")
received = sum(1 for c in all_cards if c.status == "received") received = sum(1 for c in all_cards if c.status == "received")
countries = len({c.country for c in all_cards if c.country}) countries = len({c.country_to for c in all_cards if c.country_to})
# recent postcards across all profiles (last 8) # recent postcards across all profiles (last 8), sorted by most recently updated
all_cards.sort(key=lambda c: c.created_at, reverse=True) all_cards.sort(key=lambda c: c.updated_at or c.created_at, reverse=True)
recent = all_cards[:8] recent = all_cards[:8]
# status distribution for progress bar # status distribution for progress bar
@@ -483,12 +611,12 @@ def dashboard(request: Request, user: User = Depends(get_current_user_web), db:
sent_country_counts: dict[str, int] = {} sent_country_counts: dict[str, int] = {}
received_country_counts: dict[str, int] = {} received_country_counts: dict[str, int] = {}
for c in all_cards: for c in all_cards:
if not c.country: if not c.country_to:
continue continue
if c.status in ("sent", "delivered"): if c.status in ("pending", "sent", "delivered"):
sent_country_counts[c.country] = sent_country_counts.get(c.country, 0) + 1 sent_country_counts[c.country_to] = sent_country_counts.get(c.country_to, 0) + 1
elif c.status == "received": elif c.status == "received":
received_country_counts[c.country] = received_country_counts.get(c.country, 0) + 1 received_country_counts[c.country_to] = received_country_counts.get(c.country_to, 0) + 1
sent_country_stats = sorted(sent_country_counts.items(), key=lambda x: -x[1]) sent_country_stats = sorted(sent_country_counts.items(), key=lambda x: -x[1])
received_country_stats = sorted(received_country_counts.items(), key=lambda x: -x[1]) received_country_stats = sorted(received_country_counts.items(), key=lambda x: -x[1])
@@ -517,14 +645,15 @@ def profile_list(request: Request, user: User = Depends(get_current_user_web), d
"sent": sum(1 for c in cards if c.status == "sent"), "sent": sum(1 for c in cards if c.status == "sent"),
"delivered": sum(1 for c in cards if c.status == "delivered"), "delivered": sum(1 for c in cards if c.status == "delivered"),
"received": sum(1 for c in cards if c.status == "received"), "received": sum(1 for c in cards if c.status == "received"),
"countries": len({c.country for c in cards if c.country}), "countries": len({c.country_to for c in cards if c.country_to}),
} }
return templates.TemplateResponse("profiles.html", {"request": request, "user": user, "profiles": profiles, "extras": extras}) return templates.TemplateResponse("profiles.html", {"request": request, "user": user, "profiles": profiles, "extras": extras})
@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")
@@ -539,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")
@@ -571,18 +701,18 @@ def postcard_list(
return _redirect("/profiles") return _redirect("/profiles")
q = db.query(Postcard).filter(Postcard.profile_id == profile_id) q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
if country: if country:
q = q.filter(Postcard.country == country) q = q.filter(Postcard.country_to == country)
postcards = q.all() postcards = q.all()
# collect existing countries for dropdown # collect existing countries for dropdown
all_countries = sorted({pc.country for pc in db.query(Postcard.country).filter(Postcard.profile_id == profile_id, Postcard.country.isnot(None)).all()}) all_countries = sorted({pc.country_to for pc in db.query(Postcard.country_to).filter(Postcard.profile_id == profile_id, Postcard.country_to.isnot(None)).all()})
# status sort order: sent=0, delivered=1, received=2 # status sort order: sent=0, delivered=1, received=2
_status_order = {"sent": 0, "delivered": 1, "received": 2} _status_order = {"sent": 0, "delivered": 1, "received": 2}
postcards.sort(key=lambda pc: (_status_order.get(pc.status, 9), -(pc.send_time or pc.arrival_time or pc.receive_time or pc.created_at).timestamp())) postcards.sort(key=lambda pc: (_status_order.get(pc.status, 9), -(pc.send_time or pc.arrival_time or pc.receive_time or pc.created_at).timestamp()))
# split into sent and received # split into sent and received
sent = [pc for pc in postcards if pc.status in ("sent", "delivered")] sent = [pc for pc in postcards if pc.status in ("pending", "sent", "delivered")]
received = [pc for pc in postcards if pc.status == "received"] received = [pc for pc in postcards if pc.status == "received"]
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -592,11 +722,12 @@ def postcard_list(
@router.get("/profiles/{profile_id}/postcards/new", response_class=HTMLResponse) @router.get("/profiles/{profile_id}/postcards/new", response_class=HTMLResponse)
def postcard_new(request: Request, profile_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): def postcard_new(request: Request, profile_id: int, create_type: str = Query("sent"), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
profile = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first() profile = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
if not profile: if not profile:
return _redirect("/profiles") return _redirect("/profiles")
return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": profile, "postcard": None}) today = datetime.now().strftime("%Y-%m-%d")
return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": profile, "postcard": None, "create_type": create_type, "today": today})
@router.post("/profiles/{profile_id}/postcards") @router.post("/profiles/{profile_id}/postcards")
@@ -606,7 +737,8 @@ def postcard_create(
card_number: str = Form(...), card_number: str = Form(...),
status: str = Form(...), status: str = Form(...),
recipient_name: str = Form(""), recipient_name: str = Form(""),
country: str = Form(""), country_to: 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(""),
@@ -620,7 +752,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),
@@ -643,7 +778,8 @@ def postcard_create(
card_number=card_number.strip(), card_number=card_number.strip(),
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_from=(country_from.strip().upper() if country_from else profile.country),
country_to=(country_to.strip().upper() if country_to else None),
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,
@@ -688,7 +824,8 @@ def postcard_update(
card_number: str = Form(...), card_number: str = Form(...),
status: str = Form(...), status: str = Form(...),
recipient_name: str = Form(""), recipient_name: str = Form(""),
country: str = Form(""), country_to: 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(""),
@@ -707,7 +844,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,
@@ -729,7 +869,8 @@ def postcard_update(
pc.card_number = card_number.strip() pc.card_number = card_number.strip()
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_from = country_from.strip().upper() if country_from else pc.country_from
pc.country_to = country_to or None
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
@@ -776,6 +917,28 @@ def mark_delivered(
return _redirect(f"/postcards/{postcard_id}") return _redirect(f"/postcards/{postcard_id}")
@router.post("/postcards/{postcard_id}/mark-sent")
def mark_sent(
postcard_id: int,
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
"""Transition pending → sent, auto-fill send_time if empty."""
pc = (
db.query(Postcard)
.join(Profile)
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
.first()
)
if not pc:
return _redirect("/profiles")
pc.status = "sent"
if not pc.send_time:
pc.send_time = datetime.now()
db.commit()
return _redirect(f"/postcards/{postcard_id}")
# ---------- Image Upload ---------- # ---------- Image Upload ----------
@router.post("/postcards/{postcard_id}/image/{side}") @router.post("/postcards/{postcard_id}/image/{side}")
@@ -883,13 +1046,16 @@ 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 ("pending", "sent", "delivered")])
if p.showcase_visible in ("received", "both"): if p.showcase_visible in ("received", "both"):
received_all.extend([c for c in cards if c.status == "received"]) received_all.extend([c for c in cards if c.status == "received"])
if p.showcase_visible in ("sent", "both"): if p.showcase_visible in ("sent", "both"):
p_sent = [c for c in cards if c.status in ("sent", "delivered")] p_sent = [c for c in cards if c.status in ("pending", "sent", "delivered")]
else: else:
p_sent = [] p_sent = []
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}
@@ -23,7 +30,8 @@ class PostcardCreate(BaseModel):
card_number: str card_number: str
status: str status: str
recipient_name: str | None = None recipient_name: str | None = None
country: str | None = None country_from: str | None = None
country_to: 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
@@ -34,7 +42,8 @@ class PostcardUpdate(BaseModel):
card_number: str | None = None card_number: str | None = None
status: str | None = None status: str | None = None
recipient_name: str | None = None recipient_name: str | None = None
country: str | None = None country_from: str | None = None
country_to: 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
@@ -47,7 +56,8 @@ class PostcardOut(BaseModel):
card_number: str card_number: str
status: str status: str
recipient_name: str | None recipient_name: str | None
country: str | None country_from: str | None
country_to: 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

@@ -88,7 +88,8 @@ input[type="text"] + label, input[type="password"] + label, input[type="datetime
.form-row .btn { white-space: nowrap; flex-shrink: 0; } .form-row .btn { white-space: nowrap; flex-shrink: 0; }
.filter-bar .form-row select { flex: 0 0 auto; width: auto; } .filter-bar .form-row select { flex: 0 0 auto; width: auto; }
.inline-form { margin-bottom: 1.25rem; } .inline-form { margin-bottom: 1.25rem; }
.rename-form { display: inline-flex; gap: .35rem; align-items: center; margin-right: .5rem; } .rename-form { display: inline-flex; gap: .35rem; align-items: center; margin-right: .5rem; flex-wrap: wrap; }
.rename-form input { flex: 1; min-width: 0; }
.nickname-display { display: inline; } .nickname-display { display: inline; }
.nickname-edit { display: inline-flex; gap: .35rem; align-items: center; } .nickname-edit { display: inline-flex; gap: .35rem; align-items: center; }
.nickname-edit input[type="text"] { width: 140px; flex: none; } .nickname-edit input[type="text"] { width: 140px; flex: none; }
@@ -155,6 +156,7 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
/* Badge */ /* Badge */
.badge { display: inline-block; padding: .1rem .4rem; border-radius: 999px; font-size: .65rem; font-weight: 600; color: #fff; background: var(--text-muted); white-space: nowrap; line-height: 1.4; align-self: center; } .badge { display: inline-block; padding: .1rem .4rem; border-radius: 999px; font-size: .65rem; font-weight: 600; color: #fff; background: var(--text-muted); white-space: nowrap; line-height: 1.4; align-self: center; }
.badge-sent { background: var(--sent); } .badge-sent { background: var(--sent); }
.badge-pending { background: var(--primary); }
.badge-delivered { background: var(--delivered); } .badge-delivered { background: var(--delivered); }
.badge-received { background: var(--received); } .badge-received { background: var(--received); }

View File

@@ -9,7 +9,7 @@
<body> <body>
{% block nav %} {% block nav %}
<nav class="navbar"> <nav class="navbar">
<a href="/dashboard" class="nav-brand">{{ 'brand'|t(user.language if user else 'zh') }}</a> <a href="/" class="nav-brand">{{ 'brand'|t(user.language if user else 'zh') }}</a>
{% if user is defined and user %} {% if user is defined and user %}
<button class="nav-hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')"></button> <button class="nav-hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')"></button>
<div class="nav-links"> <div class="nav-links">
@@ -39,9 +39,10 @@
<main class="container"> <main class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% if copyright_text %} {% set _c = get_copyright() %}
{% if _c %}
<footer style="text-align:center;padding:2rem 1rem;color:var(--text-muted);font-size:.8rem;border-top:1px solid var(--border);margin-top:2rem"> <footer style="text-align:center;padding:2rem 1rem;color:var(--text-muted);font-size:.8rem;border-top:1px solid var(--border);margin-top:2rem">
{{ copyright_text }} {{ _c|safe }}
</footer> </footer>
{% endif %} {% endif %}
</body> </body>

View File

@@ -117,13 +117,22 @@ new Chart(document.getElementById('chart-received'), {
<strong>{{ pc.card_number }}</strong> <strong>{{ pc.card_number }}</strong>
<span class="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(pc.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(pc.status) }}</span> <span class="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(pc.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(pc.status) }}</span>
</div> </div>
<div class="recent-meta"> <div style="margin-left:auto;text-align:right;display:flex;gap:1rem;align-items:center">
<div>
<div class="recent-meta" style="margin-left:0">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
</div>
<div class="recent-meta" style="margin-left:0">
{% if pc.status in ('sent','delivered') %} {% if pc.status in ('sent','delivered') %}
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
<span>→ {{ pc.recipient_name or '-' }}</span> <span>→ {{ pc.recipient_name or '-' }}</span>
{% else %} {% else %}
<span>← {{ pc.sender_name or '-' }}</span> <span>← {{ pc.sender_name or '-' }}</span>
{% endif %} {% endif %}
</div>
</div>
<div class="recent-meta" style="margin-left:0">
<span class="recent-time"> <span class="recent-time">
{% if pc.status == 'sent' and pc.send_time %} {% if pc.status == 'sent' and pc.send_time %}
{% set delta = (now.date() - pc.send_time.date()).days %} {% set delta = (now.date() - pc.send_time.date()).days %}
@@ -137,6 +146,7 @@ new Chart(document.getElementById('chart-received'), {
{% endif %} {% endif %}
</span> </span>
</div> </div>
</div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>

172
app/templates/index.html Normal file
View File

@@ -0,0 +1,172 @@
{% extends "base.html" %}
{% block title %}{{ 'brand'|t(lang) }}{% endblock %}
{% block nav %}
<nav class="navbar">
<a href="/" class="nav-brand">{{ 'brand'|t(lang) }}</a>
<div class="nav-links">
<div class="lang-switcher" id="langSwitcher">
<button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button>
<div class="lang-dropdown" id="langDropdown">
{% for code, name in available_languages.items() %}
<a href="/?lang={{ code }}" class="lang-option {% if lang == code %}active{% endif %}">{{ name }}</a>
{% endfor %}
</div>
</div>
<a href="/login" class="btn btn-sm" style="margin-left:.5rem">{{ 'index.go_login'|t(lang) }}</a>
<a href="/register" class="btn btn-primary btn-sm">{{ 'index.go_register'|t(lang) }}</a>
</div>
</nav>
{% endblock %}
{% block content %}
<div class="section-header">
<h1>📮 {{ 'index.title'|t(lang) }}</h1>
<p style="color:var(--text-muted);margin-top:.25rem">{{ 'index.subtitle'|t(lang) }}</p>
</div>
{% set visible = [] %}
{% for pc in postcards %}
{% if pc.image_front %}
{% if visible.append(pc) %}{% endif %}
{% endif %}
{% endfor %}
{% if visible|length > 0 or has_more %}
<div class="showcase-grid" id="showcase-grid">
{% for pc in visible %}
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body">
<div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% if has_more %}
<div id="scroll-sentinel" style="height:1px"></div>
<div id="scroll-loading" style="text-align:center;padding:1rem;color:var(--text-muted);display:none"></div>
{% endif %}
{% else %}
<div class="empty-state">
<p>{{ 'index.empty'|t(lang) }}</p>
</div>
{% endif %}
<!-- Lightbox -->
<div id="lightbox" class="lightbox" onclick="closeLightbox()" style="display:none">
<div class="lightbox-content" onclick="event.stopPropagation()">
<button class="lightbox-close" onclick="closeLightbox()">&times;</button>
<button class="lightbox-prev" onclick="navLightbox(-1)">&#8249;</button>
<button class="lightbox-next" onclick="navLightbox(1)">&#8250;</button>
<img id="lightbox-img" src="" alt="">
<div id="lightbox-caption" class="lightbox-caption"></div>
</div>
</div>
<script>
/* ---- Infinite Scroll ---- */
var _page = 1;
var _hasMore = {{ 'true' if has_more else 'false' }};
var _loading = false;
function _appendCard(c) {
var grid = document.getElementById('showcase-grid');
if (!grid) return;
var div = document.createElement('div');
div.className = 'showcase-card';
div.setAttribute('data-front', c.image_front);
div.onclick = function() { openLightbox(div); };
var countryHtml = '';
if (c.country_from && c.country_to) countryHtml = '<span>' + _flag(c.country_from) + ' ' + c.country_from + ' → ' + _flag(c.country_to) + ' ' + c.country_to + '</span>';
else if (c.country_to) countryHtml = '<span>' + _flag(c.country_to) + ' ' + c.country_to + '</span>';
div.innerHTML = '<img src="' + c.image_front + '" alt="" class="showcase-card-img" loading="lazy">' +
'<div class="showcase-card-body"><div class="showcase-card-meta">' + countryHtml + '</div></div>';
grid.appendChild(div);
}
function _flag(code) {
if (!code || code.length !== 2) return code || '';
return String.fromCodePoint(0x1F1E6 + code.charCodeAt(0) - 65) + String.fromCodePoint(0x1F1E6 + code.charCodeAt(1) - 65);
}
function _loadMore() {
if (_loading || !_hasMore) return;
_loading = true;
document.getElementById('scroll-loading').style.display = '';
fetch('/api/public/cards?page=' + (_page + 1) + '&limit=20')
.then(function(r) { return r.json(); })
.then(function(data) {
_loading = false;
document.getElementById('scroll-loading').style.display = 'none';
_page = data.page;
_hasMore = data.has_more;
data.cards.forEach(_appendCard);
if (!_hasMore) {
var s = document.getElementById('scroll-sentinel');
if (s) s.remove();
}
})
.catch(function() { _loading = false; });
}
var _observer = new IntersectionObserver(function(entries) {
if (entries[0].isIntersecting) _loadMore();
}, {rootMargin: '400px'});
var _sentinel = document.getElementById('scroll-sentinel');
if (_sentinel) _observer.observe(_sentinel);
/* ---- Lightbox ---- */
var lightboxItems = [];
var lightboxIdx = 0;
function buildLightboxItems() {
lightboxItems = [];
document.querySelectorAll('#showcase-grid .showcase-card[data-front]').forEach(function(card) {
lightboxItems.push({src: card.getAttribute('data-front')});
});
}
function openLightbox(el) {
buildLightboxItems();
var src = el.getAttribute('data-front');
if (!src) return;
for (var i = 0; i < lightboxItems.length; i++) {
if (lightboxItems[i].src === src) { lightboxIdx = i; break; }
}
showLightboxItem();
document.getElementById('lightbox').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function showLightboxItem() {
if (!lightboxItems.length) return;
var item = lightboxItems[lightboxIdx];
document.getElementById('lightbox-img').src = item.src;
document.getElementById('lightbox-caption').textContent = (lightboxIdx+1) + '/' + lightboxItems.length;
}
function closeLightbox(e) {
if (e) e.stopPropagation();
document.getElementById('lightbox').style.display = 'none';
document.body.style.overflow = '';
}
function navLightbox(dir, e) {
if (e) e.stopPropagation();
if (!lightboxItems.length) return;
lightboxIdx = (lightboxIdx + dir + lightboxItems.length) % lightboxItems.length;
showLightboxItem();
}
document.addEventListener('keydown', function(e) {
var lb = document.getElementById('lightbox');
if (!lb || lb.style.display === 'none') return;
if (e.key === 'Escape') closeLightbox();
else if (e.key === 'ArrowLeft') navLightbox(-1);
else if (e.key === 'ArrowRight') navLightbox(1);
});
</script>
{% endblock %}

View File

@@ -5,11 +5,15 @@
<div class="detail-title-row"> <div class="detail-title-row">
<a href="/profiles/{{ postcard.profile_id }}/postcards" class="btn btn-back"></a> <a href="/profiles/{{ postcard.profile_id }}/postcards" class="btn btn-back"></a>
<h1>{{ postcard.card_number }}</h1> <h1>{{ postcard.card_number }}</h1>
<span class="badge badge-{{ postcard.status }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</span> <span class="badge badge-{{ postcard.status }}">{{ {'pending':'待寄出','sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'pending':'Pending','sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</span>
</div> </div>
<div class="detail-actions"> <div class="detail-actions">
<a href="/postcards/{{ postcard.id }}/edit" class="btn">{{ 'pc_detail.edit'|t(user.language) }}</a> <a href="/postcards/{{ postcard.id }}/edit" class="btn">{{ 'pc_detail.edit'|t(user.language) }}</a>
{% if postcard.status == 'sent' %} {% if postcard.status == 'pending' %}
<form method="post" action="/postcards/{{ postcard.id }}/mark-sent" class="inline">
<button type="submit" class="btn btn-primary" onclick="return confirm('{{ 'pc_detail.confirm_sent'|t(user.language) }}')">{{ 'pc_detail.mark_sent'|t(user.language) }}</button>
</form>
{% elif postcard.status == 'sent' %}
<form method="post" action="/postcards/{{ postcard.id }}/mark-delivered" class="inline"> <form method="post" action="/postcards/{{ postcard.id }}/mark-delivered" class="inline">
<button type="submit" class="btn btn-primary" onclick="return confirm('{{ 'pc_detail.confirm_delivered'|t(user.language) }}')">{{ 'pc_detail.mark_delivered'|t(user.language) }}</button> <button type="submit" class="btn btn-primary" onclick="return confirm('{{ 'pc_detail.confirm_delivered'|t(user.language) }}')">{{ 'pc_detail.mark_delivered'|t(user.language) }}</button>
</form> </form>
@@ -49,14 +53,17 @@
<div class="detail-meta"> <div class="detail-meta">
<dl> <dl>
<dt>{{ 'pc_detail.card_number'|t(user.language) }}</dt><dd>{{ postcard.card_number }}</dd> <dt>{{ 'pc_detail.card_number'|t(user.language) }}</dt><dd>{{ postcard.card_number }}</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> <dt>{{ 'pc_detail.status'|t(user.language) }}</dt><dd>{{ {'pending':'待寄出','sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'pending':'Pending','sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</dd>
{% if postcard.status in ('sent','delivered') %} {% if postcard.status in ('pending','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_to|flag }} {{ postcard.country_to 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>
{% if postcard.status == 'delivered' %}
<dt>{{ 'pc_detail.arrival_time'|t(user.language) }}</dt><dd>{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard.arrival_time else '-' }}</dd> <dt>{{ 'pc_detail.arrival_time'|t(user.language) }}</dt><dd>{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard.arrival_time else '-' }}</dd>
{% endif %}
{% else %} {% 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_to|flag }} {{ postcard.country_to or '-' }}</dd>
<dt>{{ 'pc_detail.receive_time'|t(user.language) }}</dt><dd>{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard.receive_time else '-' }}</dd> <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

@@ -1,40 +1,76 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ ('pc_form.edit'|t(user.language)) if postcard else ('pc_form.new'|t(user.language)) }} - {{ 'brand'|t(user.language) }}{% endblock %} {% block title %}{% if postcard %}{{ 'pc_form.edit'|t(user.language) }}{% else %}{% if create_type == 'received' %}{{ 'pc_form.new_received'|t(user.language) }}{% else %}{{ 'pc_form.new_sent'|t(user.language) }}{% endif %}{% endif %} - {{ 'brand'|t(user.language) }}{% endblock %}
{% block content %} {% block content %}
<div class="section-header"> <div class="section-header">
<h1>{{ ('pc_form.edit'|t(user.language)) if postcard else ('pc_form.new'|t(user.language)) }}</h1> <h1>{% if postcard %}{{ 'pc_form.edit'|t(user.language) }}{% else %}{% if create_type == 'received' %}{{ 'pc_form.new_received'|t(user.language) }}{% else %}{{ 'pc_form.new_sent'|t(user.language) }}{% endif %}{% endif %}</h1>
</div> </div>
<form method="post" action="{{ '/postcards/%d/edit' % postcard.id if postcard else '/profiles/%d/postcards' % profile.id }}" class="form-card">
<label>{{ 'pc_form.number'|t(user.language) }} *</label>
<input type="text" name="card_number" value="{{ postcard.card_number if postcard else '' }}" required>
{% if error %}<div class="form-card" style="border-left:3px solid var(--danger);margin-bottom:1rem;padding:.75rem 1rem;color:var(--danger)">{{ error }}</div>{% endif %}
{% set is_new = (postcard is none) %}
{% set is_edit = (postcard is not none) %}
{% set edit_status = postcard.status if is_edit else '' %}
{% set is_received_form = (create_type == 'received' and is_new) or (is_edit and edit_status == 'received') %}
<form method="post" action="{{ '/postcards/%d/edit' % postcard.id if is_edit else '/profiles/%d/postcards' % profile.id }}" class="form-card">
<label>{{ 'pc_form.number'|t(user.language) }} *</label>
<input type="text" name="card_number" value="{{ postcard.card_number if is_edit else '' }}" required>
{% if is_received_form %}
{# ── 收到表单:无状态选择 ── #}
<input type="hidden" name="status" value="received">
<label>{{ 'pc_form.sender'|t(user.language) }} *</label>
<input type="text" name="sender_name" value="{{ postcard.sender_name if is_edit else '' }}">
<label>{{ 'pc_form.receive_time'|t(user.language) }} *</label>
<input type="date" name="receive_time" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if is_edit and postcard.receive_time else today }}">
<label>{{ 'pc_form.country'|t(user.language) }}</label>
<input type="text" name="country_to" value="{{ postcard.country_to if is_edit and postcard.country_to else (profile.country if profile else '') }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<label>{{ 'pc_form.country_from'|t(user.language) }}</label>
<input type="text" name="country_from" value="{{ postcard.country_from if is_edit and postcard.country_from else '' }}" maxlength="2" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
{% else %}
{# ── 寄出表单 ── #}
{% if is_new %}
{# 新建寄出:状态默认由日期自动判定,可手动覆盖 #}
<label>{{ 'pc_form.status'|t(user.language) }}</label>
<select name="status" id="status-select">
<option value="pending">{{ 'status.pending'|t(user.language) }}</option>
<option value="sent">{{ 'status.sent'|t(user.language) }}</option>
<option value="delivered">{{ 'status.delivered'|t(user.language) }}</option>
</select>
{% else %}
{# 编辑时:显示状态选择 #}
<label>{{ 'pc_form.status'|t(user.language) }} *</label> <label>{{ 'pc_form.status'|t(user.language) }} *</label>
<select name="status" id="status-select" onchange="toggleFields()" required> <select name="status" id="status-select" onchange="toggleFields()" required>
<option value="sent" {% if postcard and postcard.status=='sent' %}selected{% endif %}>{{ 'status.sent'|t(user.language) }}</option> <option value="pending" {% if edit_status=='pending' %}selected{% endif %}>{{ 'status.pending'|t(user.language) }}</option>
<option value="delivered" {% if postcard and postcard.status=='delivered' %}selected{% endif %}>{{ 'status.delivered'|t(user.language) }}</option> <option value="sent" {% if edit_status=='sent' %}selected{% endif %}>{{ 'status.sent'|t(user.language) }}</option>
<option value="received" {% if postcard and postcard.status=='received' %}selected{% endif %}>{{ 'status.received'|t(user.language) }}</option> <option value="delivered" {% if edit_status=='delivered' %}selected{% endif %}>{{ 'status.delivered'|t(user.language) }}</option>
</select> </select>
{% endif %}
<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" value="{{ postcard.recipient_name if is_edit 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 is_edit and postcard.send_time else (today if is_new 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>
<input type="date" name="arrival_time" id="input-arrival" value="{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard and postcard.arrival_time else '' }}"> <input type="date" name="arrival_time" id="input-arrival" value="{{ postcard.arrival_time.strftime('%Y-%m-%d') if is_edit and postcard.arrival_time else '' }}" {% if is_new or (is_edit and edit_status=='sent') %}disabled{% endif %}>
</div> </div>
<div id="fields-received" style="display:none"> <label>{{ 'pc_form.country_from'|t(user.language) }}</label>
<label>{{ 'pc_form.sender'|t(user.language) }} *</label> <input type="text" name="country_from" value="{{ postcard.country_from if is_edit and postcard.country_from else (profile.country if profile else '') }}" maxlength="2" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<input type="text" name="sender_name" id="input-sender" value="{{ postcard.sender_name if postcard else '' }}">
<label>{{ 'pc_form.receive_time'|t(user.language) }} *</label> <label>{{ 'pc_form.country'|t(user.language) }}</label>
<input type="date" name="receive_time" id="input-receive" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard and postcard.receive_time else '' }}"> <input type="text" name="country_to" value="{{ postcard.country_to if is_edit and postcard.country_to else '' }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
</div>
{% endif %}
<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 is_edit else '' }}</textarea>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'pc_form.save'|t(user.language) }}</button> <button type="submit" class="btn btn-primary">{{ 'pc_form.save'|t(user.language) }}</button>
@@ -42,68 +78,70 @@
</div> </div>
</form> </form>
<script> <script>
function toggleFields() { {% if is_new and not is_received_form %}
var s = document.getElementById('status-select').value; // ── 新建寄出:根据日期自动判定状态,可手动覆盖 ──
var sd = document.getElementById('fields-sent-delivered'); (function() {
var rc = document.getElementById('fields-received');
var recipient = document.getElementById('input-recipient');
var country = document.getElementById('input-country');
var sendTime = document.getElementById('input-send-time'); var sendTime = document.getElementById('input-send-time');
var select = document.getElementById('status-select');
var arrival = document.getElementById('input-arrival'); var arrival = document.getElementById('input-arrival');
var labelArrival = document.getElementById('label-arrival'); var labelArrival = document.getElementById('label-arrival');
var sender = document.getElementById('input-sender'); var today = new Date().toISOString().slice(0, 10);
var receive = document.getElementById('input-receive'); if (!sendTime.value) sendTime.value = today;
if (s === 'received') {
sd.style.display = 'none'; var userOverrode = false;
rc.style.display = '';
recipient.required = false; function autoFromSendTime() {
country.required = false; var v = sendTime.value;
sendTime.required = false; if (!v) return 'sent';
arrival.required = false; return v > today ? 'pending' : 'sent';
}
function updateArrival(s) {
if (s === 'delivered') {
arrival.disabled = false;
arrival.value = arrival.value || today;
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }} *';
} else {
arrival.disabled = true;
arrival.value = '';
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }}';
}
}
function sync() {
var auto = autoFromSendTime();
if (!userOverrode) select.value = auto;
updateArrival(select.value);
}
select.addEventListener('change', function() { userOverrode = true; updateArrival(select.value); });
sendTime.addEventListener('change', function() { if (!userOverrode) sync(); });
sendTime.addEventListener('input', function() { if (!userOverrode) sync(); });
sync();
})();
{% elif is_edit and not is_received_form %}
// ── 编辑寄出:根据状态切换 arrival 可用性 ──
function toggleFields() {
var s = document.getElementById('status-select').value;
var arrival = document.getElementById('input-arrival');
var labelArrival = document.getElementById('label-arrival');
if (s === 'delivered') {
arrival.disabled = false; arrival.disabled = false;
sender.required = true; labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }} *';
receive.required = true; } else {
} else if (s === 'sent') {
sd.style.display = '';
rc.style.display = 'none';
recipient.required = true;
country.required = true;
sendTime.required = true;
arrival.required = false;
arrival.disabled = true; arrival.disabled = true;
arrival.value = ''; arrival.value = '';
labelArrival.textContent = '到达时间'; labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }}';
sender.required = false;
receive.required = false;
} else {
sd.style.display = '';
rc.style.display = 'none';
recipient.required = true;
country.required = true;
sendTime.required = true;
arrival.disabled = false;
arrival.required = true;
labelArrival.textContent = '到达时间 *';
sender.required = false;
receive.required = false;
} }
} }
toggleFields(); toggleFields();
{% if not postcard %} {% endif %}
// Auto-fill date fields with today on new postcards {% if is_new and is_received_form %}
// ── 新建收到:自动填日期 ──
(function() { (function() {
var today = new Date().toISOString().slice(0, 10); var today = new Date().toISOString().slice(0, 10);
var sendTime = document.getElementById('input-send-time'); var rt = document.querySelector('input[name="receive_time"]');
var receiveTime = document.getElementById('input-receive'); if (rt && !rt.value) rt.value = today;
var statusSel = document.getElementById('status-select');
// Fill on page load
if (statusSel.value === 'sent' && !sendTime.value) sendTime.value = today;
if (statusSel.value === 'received' && !receiveTime.value) receiveTime.value = today;
// Fill on status change
statusSel.addEventListener('change', function() {
if (this.value === 'sent' && !sendTime.value) sendTime.value = today;
if (this.value === 'received' && !receiveTime.value) receiveTime.value = today;
});
})(); })();
{% endif %} {% endif %}
</script> </script>

View File

@@ -3,7 +3,10 @@
{% block content %} {% block content %}
<div class="section-header"> <div class="section-header">
<h1>{{ profile.nickname }} {{ 'pc_list.title'|t(user.language) }}</h1> <h1>{{ profile.nickname }} {{ 'pc_list.title'|t(user.language) }}</h1>
<a href="/profiles/{{ profile.id }}/postcards/new" class="btn btn-primary">{{ 'pc_list.new'|t(user.language) }}</a> <div style="display:flex;gap:.5rem;flex-wrap:wrap">
<a href="/profiles/{{ profile.id }}/postcards/new?create_type=sent" class="btn btn-primary">{{ 'pc_list.new_sent'|t(user.language) }}</a>
<a href="/profiles/{{ profile.id }}/postcards/new?create_type=received" class="btn">{{ 'pc_list.new_received'|t(user.language) }}</a>
</div>
</div> </div>
<div style="margin-bottom:1rem"> <div style="margin-bottom:1rem">
{% if profile.notes %} {% if profile.notes %}
@@ -41,10 +44,10 @@
<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 }} {{ pc.country_from }} → {% endif %}{{ pc.country_to|flag }} {{ pc.country_to 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 }}">{{ {'pending':'待寄出','sent':'已寄出','delivered':'已送达'}.get(pc.status) if user.language=='zh' else {'pending':'Pending','sent':'Sent','delivered':'Delivered'}.get(pc.status) }}</span>
<span class="postcard-row-date"> <span class="postcard-row-date">
{% if pc.send_time %}{{ pc.send_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}寄出{% endif %}{% endif %} {% if pc.send_time %}{{ pc.send_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}寄出{% endif %}{% endif %}
{% if pc.arrival_time %}<br>{{ pc.arrival_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}送达{% endif %}{% endif %} {% if pc.arrival_time %}<br>{{ pc.arrival_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}送达{% endif %}{% endif %}
@@ -52,7 +55,11 @@
</div> </div>
{% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %} {% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %}
<div class="postcard-row-action"> <div class="postcard-row-action">
{% if pc.status == 'sent' %} {% if pc.status == 'pending' %}
<form method="post" action="/postcards/{{ pc.id }}/mark-sent" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_sent'|t(user.language) }}'))this.submit();">
<button type="submit" class="btn btn-sm btn-primary">{{ 'pc_list.mark_sent'|t(user.language) }}</button>
</form>
{% elif pc.status == 'sent' %}
<form method="post" action="/postcards/{{ pc.id }}/mark-delivered" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_delivered'|t(user.language) }}'))this.submit();"> <form method="post" action="/postcards/{{ pc.id }}/mark-delivered" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_delivered'|t(user.language) }}'))this.submit();">
<button type="submit" class="btn btn-sm btn-primary">{{ 'pc_list.mark_delivered'|t(user.language) }}</button> <button type="submit" class="btn btn-sm btn-primary">{{ 'pc_list.mark_delivered'|t(user.language) }}</button>
</form> </form>
@@ -81,7 +88,10 @@
<div class="postcard-row-detail"> <div class="postcard-row-detail">
<span>← {{ pc.sender_name or '-' }}</span> <span>← {{ pc.sender_name or '-' }}</span>
</div> </div>
{% if pc.receive_time %}<span class="postcard-row-date">{{ pc.receive_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}收到{% endif %}</span>{% endif %} <span class="badge badge-received">{{ 'status.received'|t(user.language) }}</span>
<span class="postcard-row-date">
{% if pc.receive_time %}{{ pc.receive_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}收到{% endif %}{% endif %}
</span>
</div> </div>
{% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %} {% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %}
</a> </a>

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,24 +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_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %} {% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div> </div>
</div> </div>
@@ -50,20 +60,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_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %} {% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div> </div>
</div> </div>
@@ -73,22 +86,23 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if sent_visible|length > 19 %}
<div id="scroll-sentinel-sent" style="height:1px"></div>
<div id="scroll-loading-sent" style="text-align:center;padding:1rem;color:var(--text-muted);display:none"></div>
{% endif %}
</div> </div>
<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_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %} {% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div> </div>
</div> </div>
@@ -97,20 +111,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_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %} {% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div> </div>
</div> </div>
@@ -120,20 +137,90 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if received_visible|length > 19 %}
<div id="scroll-sentinel-received" style="height:1px"></div>
<div id="scroll-loading-received" style="text-align:center;padding:1rem;color:var(--text-muted);display:none"></div>
{% endif %}
</div> </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_to) countryHtml = '<span>' + _flag(c.country_from) + ' ' + c.country_from + ' → ' + _flag(c.country_to) + ' ' + c.country_to + '</span>';
else if (c.country_to) countryHtml = '<span>' + _flag(c.country_to) + '</span>';
div.innerHTML = '<img src="' + c.image_front + '" alt="" class="showcase-card-img" loading="lazy">' +
'<div class="showcase-card-body"><div class="showcase-card-meta">' + countryHtml + '</div></div>';
grid.appendChild(div);
}
function _loadMoreSent() {
if (_sentLoading || !_sentMore) return;
_sentLoading = true;
document.getElementById('scroll-loading-sent').style.display = '';
fetch('/api/public/cards/' + _username + '?page=' + (_sentPage + 1) + '&limit=20&tab=sent')
.then(function(r) { return r.json(); })
.then(function(data) {
_sentLoading = false;
document.getElementById('scroll-loading-sent').style.display = 'none';
_sentPage = data.page;
_sentMore = data.has_more;
data.cards.forEach(function(c) { _appendCard(c, 'grid-sent'); });
if (!_sentMore) { var s = document.getElementById('scroll-sentinel-sent'); if (s) s.remove(); }
})
.catch(function() { _sentLoading = false; });
}
function _loadMoreReceived() {
if (_recvLoading || !_recvMore) return;
_recvLoading = true;
document.getElementById('scroll-loading-received').style.display = '';
fetch('/api/public/cards/' + _username + '?page=' + (_recvPage + 1) + '&limit=20&tab=received')
.then(function(r) { return r.json(); })
.then(function(data) {
_recvLoading = false;
document.getElementById('scroll-loading-received').style.display = 'none';
_recvPage = data.page;
_recvMore = data.has_more;
data.cards.forEach(function(c) { _appendCard(c, 'grid-received'); });
if (!_recvMore) { var s = document.getElementById('scroll-sentinel-received'); if (s) s.remove(); }
})
.catch(function() { _recvLoading = false; });
}
var _obsSent = new IntersectionObserver(function(e) { if (e[0].isIntersecting) _loadMoreSent(); }, {rootMargin: '400px'});
var _obsRecv = new IntersectionObserver(function(e) { if (e[0].isIntersecting) _loadMoreReceived(); }, {rootMargin: '400px'});
var _sSent = document.getElementById('scroll-sentinel-sent');
var _sRecv = document.getElementById('scroll-sentinel-received');
if (_sSent) _obsSent.observe(_sSent);
if (_sRecv) _obsRecv.observe(_sRecv);
function switchTab(name) { 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 +236,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 +244,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 +256,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 +279,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 %}

View File

@@ -1,20 +0,0 @@
"""Migrate: add permissions column to api_keys table."""
import sqlite3
from pathlib import Path
DB_PATH = Path(__file__).parent.parent / "postcard.db"
conn = sqlite3.connect(str(DB_PATH))
cur = conn.cursor()
cur.execute("PRAGMA table_info(api_keys)")
cols = [r[1] for r in cur.fetchall()]
if "permissions" not in cols:
cur.execute('ALTER TABLE api_keys ADD COLUMN permissions TEXT NOT NULL DEFAULT "[]"')
conn.commit()
print("OK: column 'permissions' added to api_keys")
else:
print("SKIP: column 'permissions' already exists")
conn.close()

View File

@@ -1,24 +0,0 @@
"""Migrate: create system_config table."""
import sqlite3
from pathlib import Path
DB_PATH = Path(__file__).parent.parent / "postcard.db"
conn = sqlite3.connect(str(DB_PATH))
cur = conn.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='system_config'")
if not cur.fetchone():
cur.execute(
"CREATE TABLE system_config ("
"key TEXT PRIMARY KEY, "
"value TEXT NOT NULL DEFAULT '', "
"updated_at TIMESTAMP"
")"
)
conn.commit()
print("OK: system_config table created")
else:
print("SKIP: system_config table exists")
conn.close()