Compare commits
17 Commits
ba56fc6e6b
...
05168fb1ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 05168fb1ca | |||
| 5ec21f0e3f | |||
| de29f5445e | |||
| d79aaea4ae | |||
| 3323523e8e | |||
| afee0b2d92 | |||
| 722895a0c8 | |||
| 985010d63c | |||
| 599a95ee12 | |||
| fed060ea84 | |||
| ad6f2b2ff2 | |||
| 806c9ba8a0 | |||
| 1292c6591b | |||
| 5de27f1937 | |||
| b5716ff340 | |||
| a071b3dde4 | |||
| bd664d23be |
@@ -3,6 +3,11 @@
|
||||
"name": "English"
|
||||
},
|
||||
"brand": "📮 Mailova",
|
||||
"index.title": "Mailova",
|
||||
"index.subtitle": "Postcards from around the world",
|
||||
"index.go_login": "Login",
|
||||
"index.go_register": "Register",
|
||||
"index.empty": "No public postcards yet",
|
||||
"nav.profiles": "Profiles",
|
||||
"nav.settings": "Settings",
|
||||
"nav.invites": "Invites",
|
||||
@@ -60,6 +65,7 @@
|
||||
"profiles.total": "Total",
|
||||
"profiles.countries": "Countries",
|
||||
"profiles.notes_ph": "Notes (private)",
|
||||
"profiles.country_ph": "Country",
|
||||
"profiles.save": "Save",
|
||||
"profiles.created_at": "Created",
|
||||
"profiles.view": "View",
|
||||
@@ -67,17 +73,23 @@
|
||||
"profiles.confirm_delete": "Delete this profile?",
|
||||
"pc_list.title": "'s postcards",
|
||||
"pc_list.new": "+ New postcard",
|
||||
"pc_list.new_sent": "✉️ Sent",
|
||||
"pc_list.new_received": "📬 Received",
|
||||
"pc_list.duplicate": "Card number already exists",
|
||||
"pc_list.all_countries": "All countries",
|
||||
"pc_list.filter": "Filter",
|
||||
"pc_list.tab_sent": "Sent",
|
||||
"pc_list.tab_received": "Received",
|
||||
"pc_list.mark_sent": "Send",
|
||||
"pc_list.confirm_sent": "Mark as sent?",
|
||||
"pc_list.mark_delivered": "Delivered",
|
||||
"pc_list.confirm_delivered": "Mark as delivered?",
|
||||
"pc_list.empty_sent": "No sent postcards.",
|
||||
"pc_list.empty_received": "No received postcards.",
|
||||
"pc_detail.title": "Postcard detail",
|
||||
"pc_detail.edit": "Edit",
|
||||
"pc_detail.mark_sent": "Send",
|
||||
"pc_detail.confirm_sent": "Mark as sent?",
|
||||
"pc_detail.mark_delivered": "Mark delivered",
|
||||
"pc_detail.confirm_delivered": "Mark as delivered?",
|
||||
"pc_detail.delete": "Delete",
|
||||
@@ -92,6 +104,7 @@
|
||||
"pc_detail.status": "Status",
|
||||
"pc_detail.recipient": "Recipient",
|
||||
"pc_detail.country": "Country",
|
||||
"pc_detail.route": "Route",
|
||||
"pc_detail.send_time": "Sent date",
|
||||
"pc_detail.arrival_time": "Arrival date",
|
||||
"pc_detail.sender": "Sender",
|
||||
@@ -99,15 +112,19 @@
|
||||
"pc_detail.profile": "Profile",
|
||||
"pc_detail.notes": "Notes",
|
||||
"status.sent": "Sent",
|
||||
"status.pending": "Pending",
|
||||
"status.delivered": "Delivered",
|
||||
"status.received": "Received",
|
||||
"pc_form.edit": "Edit postcard",
|
||||
"pc_form.new": "New postcard",
|
||||
"pc_form.new_sent": "New sent",
|
||||
"pc_form.new_received": "New received",
|
||||
"pc_form.number": "Number",
|
||||
"pc_form.number_hint": "Format: AB-1234567",
|
||||
"pc_form.status": "Status",
|
||||
"pc_form.recipient": "Recipient",
|
||||
"pc_form.country": "Country",
|
||||
"pc_form.country_from": "From",
|
||||
"pc_form.send_time": "Sent date",
|
||||
"pc_form.arrival_time": "Arrival date",
|
||||
"pc_form.sender": "Sender",
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"name": "中文"
|
||||
},
|
||||
"brand": "📮 星笺",
|
||||
"index.title": "星笺",
|
||||
"index.subtitle": "来自世界各地的明信片",
|
||||
"index.go_login": "登录",
|
||||
"index.go_register": "注册",
|
||||
"index.empty": "暂无公开明信片",
|
||||
"nav.profiles": "Profile",
|
||||
"nav.settings": "设置",
|
||||
"nav.invites": "邀请码管理",
|
||||
@@ -60,6 +65,7 @@
|
||||
"profiles.total": "总计",
|
||||
"profiles.countries": "国家",
|
||||
"profiles.notes_ph": "备注(仅自己可见)",
|
||||
"profiles.country_ph": "国家",
|
||||
"profiles.save": "保存",
|
||||
"profiles.created_at": "创建于",
|
||||
"profiles.view": "查看",
|
||||
@@ -67,17 +73,23 @@
|
||||
"profiles.confirm_delete": "确定删除此 Profile?",
|
||||
"pc_list.title": " 的明信片",
|
||||
"pc_list.new": "+ 新明信片",
|
||||
"pc_list.new_sent": "✉️ 寄出",
|
||||
"pc_list.new_received": "📬 收到",
|
||||
"pc_list.duplicate": "编号已存在",
|
||||
"pc_list.all_countries": "全部国家",
|
||||
"pc_list.filter": "筛选",
|
||||
"pc_list.tab_sent": "寄出",
|
||||
"pc_list.tab_received": "收到",
|
||||
"pc_list.mark_sent": "寄出",
|
||||
"pc_list.confirm_sent": "确认寄出?",
|
||||
"pc_list.mark_delivered": "已送达",
|
||||
"pc_list.confirm_delivered": "确认已送达?",
|
||||
"pc_list.empty_sent": "没有寄出的明信片。",
|
||||
"pc_list.empty_received": "没有收到的明信片。",
|
||||
"pc_detail.title": "明信片详情",
|
||||
"pc_detail.edit": "编辑",
|
||||
"pc_detail.mark_sent": "寄出",
|
||||
"pc_detail.confirm_sent": "确认寄出?",
|
||||
"pc_detail.mark_delivered": "已送达",
|
||||
"pc_detail.confirm_delivered": "确认已送达?",
|
||||
"pc_detail.delete": "删除",
|
||||
@@ -92,6 +104,7 @@
|
||||
"pc_detail.status": "状态",
|
||||
"pc_detail.recipient": "收件人",
|
||||
"pc_detail.country": "国家/地区",
|
||||
"pc_detail.route": "路线",
|
||||
"pc_detail.send_time": "寄出时间",
|
||||
"pc_detail.arrival_time": "到达时间",
|
||||
"pc_detail.sender": "发件人",
|
||||
@@ -99,15 +112,19 @@
|
||||
"pc_detail.profile": "所属 Profile",
|
||||
"pc_detail.notes": "备注",
|
||||
"status.sent": "已寄出",
|
||||
"status.pending": "待寄出",
|
||||
"status.delivered": "已送达",
|
||||
"status.received": "已收到",
|
||||
"pc_form.edit": "编辑明信片",
|
||||
"pc_form.new": "新建明信片",
|
||||
"pc_form.new_sent": "新建寄出",
|
||||
"pc_form.new_received": "新建收到",
|
||||
"pc_form.number": "编号",
|
||||
"pc_form.number_hint": "格式:AB-1234567",
|
||||
"pc_form.status": "状态",
|
||||
"pc_form.recipient": "收件人",
|
||||
"pc_form.country": "国家/地区",
|
||||
"pc_form.country_from": "寄出地",
|
||||
"pc_form.send_time": "寄出时间",
|
||||
"pc_form.arrival_time": "到达时间",
|
||||
"pc_form.sender": "发件人",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship, validates
|
||||
|
||||
from app.database import Base
|
||||
@@ -61,6 +61,7 @@ class Profile(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
nickname = Column(String(64), nullable=False)
|
||||
country = Column(String(2), nullable=True)
|
||||
showcase_enabled = Column(default=False, nullable=False)
|
||||
showcase_visible = Column(String(16), nullable=False, default="both")
|
||||
notes = Column(Text, nullable=False, default="")
|
||||
@@ -72,14 +73,18 @@ class Profile(Base):
|
||||
|
||||
class Postcard(Base):
|
||||
__tablename__ = "postcards"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("profile_id", "card_number", name="uq_postcards_profile_card_number"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False)
|
||||
card_number = Column(String(64), nullable=False, unique=True)
|
||||
card_number = Column(String(64), nullable=False)
|
||||
status = Column(String(16), nullable=False)
|
||||
# sent & delivered
|
||||
recipient_name = Column(String(64))
|
||||
country = Column(String(64))
|
||||
country_from = Column(String(2))
|
||||
country_to = Column(String(2))
|
||||
send_time = Column(DateTime)
|
||||
arrival_time = Column(DateTime)
|
||||
# received
|
||||
@@ -96,8 +101,12 @@ class Postcard(Base):
|
||||
|
||||
profile = relationship("Profile", back_populates="postcards")
|
||||
|
||||
@validates("country")
|
||||
def _upper_country(self, key, value):
|
||||
@validates("country_from")
|
||||
def _upper_country_from(self, key, value):
|
||||
return value.strip().upper() if value else None
|
||||
|
||||
@validates("country_to")
|
||||
def _upper_country_to(self, key, value):
|
||||
return value.strip().upper() if value else None
|
||||
|
||||
|
||||
|
||||
@@ -81,13 +81,16 @@ def get_api_user_optional(
|
||||
|
||||
class ProfileCreate(BaseModel):
|
||||
nickname: str
|
||||
country: str | None = None
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
nickname: str | None = None
|
||||
country: str | None = None
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
country: str | None
|
||||
showcase_enabled: bool
|
||||
showcase_visible: str
|
||||
notes: str
|
||||
@@ -100,7 +103,8 @@ class PostcardCreate(BaseModel):
|
||||
card_number: str
|
||||
status: str
|
||||
recipient_name: str | None = None
|
||||
country: str | None = None
|
||||
country_from: str | None = None
|
||||
country_to: str | None = None
|
||||
send_time: str | None = None
|
||||
arrival_time: str | None = None
|
||||
sender_name: str | None = None
|
||||
@@ -111,7 +115,8 @@ class PostcardUpdate(BaseModel):
|
||||
card_number: str | None = None
|
||||
status: 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
|
||||
arrival_time: str | None = None
|
||||
sender_name: str | None = None
|
||||
@@ -124,7 +129,8 @@ class PostcardOut(BaseModel):
|
||||
card_number: str
|
||||
status: str
|
||||
recipient_name: str | None
|
||||
country: str | None
|
||||
country_from: str | None
|
||||
country_to: str | None
|
||||
send_time: datetime | None
|
||||
arrival_time: datetime | None
|
||||
sender_name: str | None
|
||||
@@ -205,7 +211,8 @@ def create_profile(
|
||||
db: DbSession = Depends(get_db),
|
||||
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
|
||||
):
|
||||
p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id)
|
||||
c = body.country.strip().upper() if body.country else None
|
||||
p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id, country=c)
|
||||
db.add(p)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
@@ -231,6 +238,8 @@ def update_profile(
|
||||
p = _profile_or_404(ak.user, profile_id, db)
|
||||
if body.nickname is not None:
|
||||
p.nickname = body.nickname.strip()
|
||||
if body.country is not None:
|
||||
p.country = body.country.strip().upper() if body.country else None
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return p
|
||||
@@ -275,14 +284,18 @@ def create_postcard(
|
||||
):
|
||||
_profile_or_404(ak.user, profile_id, db)
|
||||
cn = body.card_number.strip()
|
||||
if db.query(Postcard).filter(Postcard.card_number == cn).first():
|
||||
dup = db.query(Postcard).join(Profile).filter(
|
||||
Postcard.card_number == cn, Profile.user_id == ak.user.id
|
||||
).first()
|
||||
if dup:
|
||||
raise HTTPException(status_code=409, detail="Card number already exists")
|
||||
pc = Postcard(
|
||||
profile_id=profile_id,
|
||||
card_number=cn,
|
||||
status=body.status,
|
||||
recipient_name=body.recipient_name,
|
||||
country=body.country.strip().upper() if body.country else None,
|
||||
country_from=body.country_from.strip().upper() if body.country_from else None,
|
||||
country_to=body.country_to.strip().upper() if body.country_to else None,
|
||||
send_time=_parse_date(body.send_time),
|
||||
arrival_time=_parse_date(body.arrival_time),
|
||||
sender_name=body.sender_name,
|
||||
@@ -320,12 +333,17 @@ def update_postcard(
|
||||
setattr(pc, field, val.strip() if isinstance(val, str) else val)
|
||||
if body.card_number is not None:
|
||||
cn = body.card_number.strip()
|
||||
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
|
||||
dup = db.query(Postcard).join(Profile).filter(
|
||||
Postcard.card_number == cn, Postcard.id != postcard_id,
|
||||
Profile.user_id == ak.user.id
|
||||
).first()
|
||||
if dup:
|
||||
raise HTTPException(status_code=409, detail="Card number already exists")
|
||||
pc.card_number = cn
|
||||
if body.country is not None:
|
||||
pc.country = body.country.strip().upper() if body.country else None
|
||||
if body.country_to is not None:
|
||||
pc.country_to = body.country_to.strip().upper() if body.country_to else None
|
||||
if body.country_from is not None:
|
||||
pc.country_from = body.country_from.strip().upper() if body.country_from else None
|
||||
for field in ("send_time", "arrival_time", "receive_time"):
|
||||
val = getattr(body, field)
|
||||
if val is not None:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from random import shuffle
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
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.database import get_db
|
||||
from datetime import datetime
|
||||
from random import choice
|
||||
from string import ascii_letters, digits
|
||||
|
||||
from app.models import InviteCode, Postcard, Profile, User
|
||||
from app.i18n import t as _t
|
||||
from app.i18n import t as _t, get_languages as _get_languages
|
||||
|
||||
router = APIRouter(tags=["web"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
|
||||
@@ -109,6 +109,134 @@ def _redirect(url: str) -> RedirectResponse:
|
||||
return RedirectResponse(url, status_code=303)
|
||||
|
||||
|
||||
# ---------- Root / Index ----------
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def index_page(request: Request, db: Session = Depends(get_db)):
|
||||
user = redirect_if_not_logged_in(request, db)
|
||||
if user:
|
||||
return _redirect("/dashboard")
|
||||
|
||||
# Collect all public postcards from all users with showcase enabled
|
||||
all_postcards: list[Postcard] = []
|
||||
users = db.query(User).filter(User.showcase_mode.isnot(None)).all()
|
||||
for u in users:
|
||||
profiles = db.query(Profile).filter(
|
||||
Profile.user_id == u.id, Profile.showcase_enabled == True
|
||||
).all()
|
||||
for p in profiles:
|
||||
cards = db.query(Postcard).filter(
|
||||
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
|
||||
Postcard.image_front.isnot(None), Postcard.image_front != ""
|
||||
).all()
|
||||
for c in cards:
|
||||
visible = True
|
||||
if c.status in ("pending", "sent", "delivered") and p.showcase_visible not in ("sent", "both"):
|
||||
visible = False
|
||||
if c.status == "received" and p.showcase_visible not in ("received", "both"):
|
||||
visible = False
|
||||
if visible:
|
||||
all_postcards.append(c)
|
||||
|
||||
shuffle(all_postcards)
|
||||
|
||||
lang = request.query_params.get("lang")
|
||||
avail = _get_languages()
|
||||
if not lang or lang not in avail:
|
||||
lang = request.cookies.get("showcase_lang")
|
||||
if not lang or lang not in avail:
|
||||
lang = "zh"
|
||||
|
||||
return 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 ----------
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
@@ -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")
|
||||
delivered = sum(1 for c in all_cards if c.status == "delivered")
|
||||
received = sum(1 for c in all_cards if c.status == "received")
|
||||
countries = len({c.country 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)
|
||||
all_cards.sort(key=lambda c: c.created_at, reverse=True)
|
||||
# recent postcards across all profiles (last 8), sorted by most recently updated
|
||||
all_cards.sort(key=lambda c: c.updated_at or c.created_at, reverse=True)
|
||||
recent = all_cards[:8]
|
||||
|
||||
# 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] = {}
|
||||
received_country_counts: dict[str, int] = {}
|
||||
for c in all_cards:
|
||||
if not c.country:
|
||||
if not c.country_to:
|
||||
continue
|
||||
if c.status in ("sent", "delivered"):
|
||||
sent_country_counts[c.country] = sent_country_counts.get(c.country, 0) + 1
|
||||
if c.status in ("pending", "sent", "delivered"):
|
||||
sent_country_counts[c.country_to] = sent_country_counts.get(c.country_to, 0) + 1
|
||||
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])
|
||||
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"),
|
||||
"delivered": sum(1 for c in cards if c.status == "delivered"),
|
||||
"received": sum(1 for c in cards if c.status == "received"),
|
||||
"countries": len({c.country 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})
|
||||
|
||||
|
||||
@router.post("/profiles")
|
||||
def profile_create(request: Request, nickname: str = Form(...), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
db.add(Profile(user_id=user.id, nickname=nickname))
|
||||
def profile_create(request: Request, nickname: str = Form(...), country: str = Form(""), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
c = country.strip().upper() if country else None
|
||||
db.add(Profile(user_id=user.id, nickname=nickname, country=c))
|
||||
db.commit()
|
||||
return _redirect("/profiles")
|
||||
|
||||
@@ -539,10 +668,11 @@ def profile_delete(profile_id: int, user: User = Depends(get_current_user_web),
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_id}/rename")
|
||||
def profile_rename(profile_id: int, nickname: str = Form(...), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
def profile_rename(profile_id: int, nickname: str = Form(...), country: str = Form(""), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
|
||||
if p:
|
||||
p.nickname = nickname
|
||||
p.country = country.strip().upper() if country else None
|
||||
db.commit()
|
||||
return _redirect("/profiles")
|
||||
|
||||
@@ -571,18 +701,18 @@ def postcard_list(
|
||||
return _redirect("/profiles")
|
||||
q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
|
||||
if country:
|
||||
q = q.filter(Postcard.country == country)
|
||||
q = q.filter(Postcard.country_to == country)
|
||||
postcards = q.all()
|
||||
|
||||
# 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_order = {"sent": 0, "delivered": 1, "received": 2}
|
||||
postcards.sort(key=lambda pc: (_status_order.get(pc.status, 9), -(pc.send_time or pc.arrival_time or pc.receive_time or pc.created_at).timestamp()))
|
||||
|
||||
# split into sent and received
|
||||
sent = [pc for pc in postcards if pc.status in ("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"]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
@@ -592,11 +722,12 @@ def postcard_list(
|
||||
|
||||
|
||||
@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()
|
||||
if not profile:
|
||||
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")
|
||||
@@ -606,7 +737,8 @@ def postcard_create(
|
||||
card_number: str = Form(...),
|
||||
status: str = Form(...),
|
||||
recipient_name: str = Form(""),
|
||||
country: str = Form(""),
|
||||
country_to: str = Form(""),
|
||||
country_from: str = Form(""),
|
||||
send_time: str = Form(""),
|
||||
arrival_time: str = Form(""),
|
||||
sender_name: str = Form(""),
|
||||
@@ -620,7 +752,10 @@ def postcard_create(
|
||||
return _redirect("/profiles")
|
||||
|
||||
cn = card_number.strip()
|
||||
if db.query(Postcard).filter(Postcard.card_number == cn).first():
|
||||
dup = db.query(Postcard).join(Profile).filter(
|
||||
Postcard.card_number == cn, Profile.user_id == user.id
|
||||
).first()
|
||||
if dup:
|
||||
return templates.TemplateResponse("postcard_form.html", {
|
||||
"request": request, "user": user, "profile": profile, "postcard": None,
|
||||
"error": _t("pc_list.duplicate", user.language),
|
||||
@@ -643,7 +778,8 @@ def postcard_create(
|
||||
card_number=card_number.strip(),
|
||||
status=status,
|
||||
recipient_name=recipient_name or None,
|
||||
country=(country.strip().upper() if country else None),
|
||||
country_from=(country_from.strip().upper() if country_from else profile.country),
|
||||
country_to=(country_to.strip().upper() if country_to else None),
|
||||
send_time=_parse_dt(send_time),
|
||||
arrival_time=_parse_dt(arrival_time),
|
||||
sender_name=sender_name or None,
|
||||
@@ -688,7 +824,8 @@ def postcard_update(
|
||||
card_number: str = Form(...),
|
||||
status: str = Form(...),
|
||||
recipient_name: str = Form(""),
|
||||
country: str = Form(""),
|
||||
country_to: str = Form(""),
|
||||
country_from: str = Form(""),
|
||||
send_time: str = Form(""),
|
||||
arrival_time: str = Form(""),
|
||||
sender_name: str = Form(""),
|
||||
@@ -707,7 +844,10 @@ def postcard_update(
|
||||
return _redirect("/profiles")
|
||||
|
||||
cn = card_number.strip()
|
||||
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
|
||||
dup = db.query(Postcard).join(Profile).filter(
|
||||
Postcard.card_number == cn, Postcard.id != postcard_id,
|
||||
Profile.user_id == user.id
|
||||
).first()
|
||||
if dup:
|
||||
return templates.TemplateResponse("postcard_form.html", {
|
||||
"request": request, "user": user, "profile": pc.profile, "postcard": pc,
|
||||
@@ -729,7 +869,8 @@ def postcard_update(
|
||||
pc.card_number = card_number.strip()
|
||||
pc.status = status
|
||||
pc.recipient_name = recipient_name or None
|
||||
pc.country = country or None
|
||||
pc.country_from = country_from.strip().upper() if country_from else pc.country_from
|
||||
pc.country_to = country_to or None
|
||||
pc.send_time = _parse_dt(send_time)
|
||||
pc.arrival_time = None if status == "sent" else _parse_dt(arrival_time)
|
||||
pc.sender_name = sender_name or None
|
||||
@@ -776,6 +917,28 @@ def mark_delivered(
|
||||
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 ----------
|
||||
|
||||
@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()
|
||||
sent_all, received_all, profile_groups = [], [], []
|
||||
for p in profiles:
|
||||
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
|
||||
cards = db.query(Postcard).filter(
|
||||
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
|
||||
Postcard.image_front.isnot(None), Postcard.image_front != ""
|
||||
).all()
|
||||
if p.showcase_visible in ("sent", "both"):
|
||||
sent_all.extend([c for c in cards if c.status in ("sent", "delivered")])
|
||||
sent_all.extend([c for c in cards if c.status in ("pending", "sent", "delivered")])
|
||||
if p.showcase_visible in ("received", "both"):
|
||||
received_all.extend([c for c in cards if c.status == "received"])
|
||||
if p.showcase_visible in ("sent", "both"):
|
||||
p_sent = [c for c in cards if c.status in ("sent", "delivered")]
|
||||
p_sent = [c for c in cards if c.status in ("pending", "sent", "delivered")]
|
||||
else:
|
||||
p_sent = []
|
||||
if p.showcase_visible in ("received", "both"):
|
||||
|
||||
@@ -7,11 +7,18 @@ from pydantic import BaseModel
|
||||
|
||||
class ProfileCreate(BaseModel):
|
||||
nickname: str
|
||||
country: str | None = None
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
nickname: str | None = None
|
||||
country: str | None = None
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
country: str | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -23,7 +30,8 @@ class PostcardCreate(BaseModel):
|
||||
card_number: str
|
||||
status: str
|
||||
recipient_name: str | None = None
|
||||
country: str | None = None
|
||||
country_from: str | None = None
|
||||
country_to: str | None = None
|
||||
send_time: datetime | None = None
|
||||
arrival_time: datetime | None = None
|
||||
sender_name: str | None = None
|
||||
@@ -34,7 +42,8 @@ class PostcardUpdate(BaseModel):
|
||||
card_number: str | None = None
|
||||
status: 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
|
||||
arrival_time: datetime | None = None
|
||||
sender_name: str | None = None
|
||||
@@ -47,7 +56,8 @@ class PostcardOut(BaseModel):
|
||||
card_number: str
|
||||
status: str
|
||||
recipient_name: str | None
|
||||
country: str | None
|
||||
country_from: str | None
|
||||
country_to: str | None
|
||||
send_time: datetime | None
|
||||
arrival_time: datetime | None
|
||||
sender_name: str | None
|
||||
|
||||
@@ -88,7 +88,8 @@ input[type="text"] + label, input[type="password"] + label, input[type="datetime
|
||||
.form-row .btn { white-space: nowrap; flex-shrink: 0; }
|
||||
.filter-bar .form-row select { flex: 0 0 auto; width: auto; }
|
||||
.inline-form { margin-bottom: 1.25rem; }
|
||||
.rename-form { display: inline-flex; gap: .35rem; align-items: center; margin-right: .5rem; }
|
||||
.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-edit { display: inline-flex; gap: .35rem; align-items: center; }
|
||||
.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 { display: inline-block; padding: .1rem .4rem; border-radius: 999px; font-size: .65rem; font-weight: 600; color: #fff; background: var(--text-muted); white-space: nowrap; line-height: 1.4; align-self: center; }
|
||||
.badge-sent { background: var(--sent); }
|
||||
.badge-pending { background: var(--primary); }
|
||||
.badge-delivered { background: var(--delivered); }
|
||||
.badge-received { background: var(--received); }
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<body>
|
||||
{% block nav %}
|
||||
<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 %}
|
||||
<button class="nav-hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')">☰</button>
|
||||
<div class="nav-links">
|
||||
|
||||
@@ -117,13 +117,22 @@ new Chart(document.getElementById('chart-received'), {
|
||||
<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>
|
||||
</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') %}
|
||||
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
||||
<span>→ {{ pc.recipient_name or '-' }}</span>
|
||||
{% else %}
|
||||
<span>← {{ pc.sender_name or '-' }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="recent-meta" style="margin-left:0">
|
||||
<span class="recent-time">
|
||||
{% if pc.status == 'sent' and pc.send_time %}
|
||||
{% set delta = (now.date() - pc.send_time.date()).days %}
|
||||
@@ -137,6 +146,7 @@ new Chart(document.getElementById('chart-received'), {
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
172
app/templates/index.html
Normal file
172
app/templates/index.html
Normal 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()">×</button>
|
||||
<button class="lightbox-prev" onclick="navLightbox(-1)">‹</button>
|
||||
<button class="lightbox-next" onclick="navLightbox(1)">›</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 %}
|
||||
@@ -5,11 +5,15 @@
|
||||
<div class="detail-title-row">
|
||||
<a href="/profiles/{{ postcard.profile_id }}/postcards" class="btn btn-back">←</a>
|
||||
<h1>{{ postcard.card_number }}</h1>
|
||||
<span class="badge badge-{{ postcard.status }}">{{ {'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 class="detail-actions">
|
||||
<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">
|
||||
<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>
|
||||
@@ -49,14 +53,17 @@
|
||||
<div class="detail-meta">
|
||||
<dl>
|
||||
<dt>{{ 'pc_detail.card_number'|t(user.language) }}</dt><dd>{{ postcard.card_number }}</dd>
|
||||
<dt>{{ 'pc_detail.status'|t(user.language) }}</dt><dd>{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</dd>
|
||||
{% if postcard.status in ('sent','delivered') %}
|
||||
<dt>{{ 'pc_detail.status'|t(user.language) }}</dt><dd>{{ {'pending':'待寄出','sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'pending':'Pending','sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</dd>
|
||||
{% if postcard.status in ('pending','sent','delivered') %}
|
||||
<dt>{{ 'pc_detail.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>
|
||||
{% if postcard.status == 'delivered' %}
|
||||
<dt>{{ 'pc_detail.arrival_time'|t(user.language) }}</dt><dd>{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard.arrival_time else '-' }}</dd>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<dt>{{ 'pc_detail.sender'|t(user.language) }}</dt><dd>{{ postcard.sender_name or '-' }}</dd>
|
||||
<dt>{{ 'pc_detail.route'|t(user.language) }}</dt><dd>{% if postcard.country_from %}{{ postcard.country_from|flag }} {{ postcard.country_from }} → {% endif %}{{ postcard.country_to|flag }} {{ postcard.country_to or '-' }}</dd>
|
||||
<dt>{{ 'pc_detail.receive_time'|t(user.language) }}</dt><dd>{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard.receive_time else '-' }}</dd>
|
||||
{% endif %}
|
||||
<dt>{{ 'pc_detail.profile'|t(user.language) }}</dt><dd>{{ postcard.profile.nickname }}</dd>
|
||||
|
||||
@@ -1,40 +1,76 @@
|
||||
{% 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 %}
|
||||
<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>
|
||||
<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>
|
||||
<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="delivered" {% if postcard and postcard.status=='delivered' %}selected{% endif %}>{{ 'status.delivered'|t(user.language) }}</option>
|
||||
<option value="received" {% if postcard and postcard.status=='received' %}selected{% endif %}>{{ 'status.received'|t(user.language) }}</option>
|
||||
<option value="pending" {% if edit_status=='pending' %}selected{% endif %}>{{ 'status.pending'|t(user.language) }}</option>
|
||||
<option value="sent" {% if edit_status=='sent' %}selected{% endif %}>{{ 'status.sent'|t(user.language) }}</option>
|
||||
<option value="delivered" {% if edit_status=='delivered' %}selected{% endif %}>{{ 'status.delivered'|t(user.language) }}</option>
|
||||
</select>
|
||||
{% endif %}
|
||||
|
||||
<div id="fields-sent-delivered">
|
||||
<label>{{ 'pc_form.recipient'|t(user.language) }} *</label>
|
||||
<input type="text" name="recipient_name" id="input-recipient" value="{{ postcard.recipient_name if postcard else '' }}">
|
||||
<label>{{ 'pc_form.country'|t(user.language) }} *</label>
|
||||
<input type="text" name="country" id="input-country" value="{{ postcard.country if postcard else '' }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
|
||||
<input type="text" name="recipient_name" value="{{ postcard.recipient_name if is_edit else '' }}">
|
||||
<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>
|
||||
<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 id="fields-received" style="display:none">
|
||||
<label>{{ 'pc_form.sender'|t(user.language) }} *</label>
|
||||
<input type="text" name="sender_name" id="input-sender" value="{{ postcard.sender_name if postcard else '' }}">
|
||||
<label>{{ 'pc_form.receive_time'|t(user.language) }} *</label>
|
||||
<input type="date" name="receive_time" id="input-receive" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard and postcard.receive_time else '' }}">
|
||||
</div>
|
||||
<label>{{ 'pc_form.country_from'|t(user.language) }}</label>
|
||||
<input type="text" name="country_from" value="{{ postcard.country_from if is_edit and postcard.country_from else (profile.country if profile else '') }}" maxlength="2" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
|
||||
|
||||
<label>{{ 'pc_form.country'|t(user.language) }}</label>
|
||||
<input type="text" name="country_to" value="{{ postcard.country_to if is_edit and postcard.country_to else '' }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
|
||||
|
||||
{% endif %}
|
||||
|
||||
<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">
|
||||
<button type="submit" class="btn btn-primary">{{ 'pc_form.save'|t(user.language) }}</button>
|
||||
@@ -42,68 +78,70 @@
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
function toggleFields() {
|
||||
var s = document.getElementById('status-select').value;
|
||||
var sd = document.getElementById('fields-sent-delivered');
|
||||
var rc = document.getElementById('fields-received');
|
||||
var recipient = document.getElementById('input-recipient');
|
||||
var country = document.getElementById('input-country');
|
||||
{% if is_new and not is_received_form %}
|
||||
// ── 新建寄出:根据日期自动判定状态,可手动覆盖 ──
|
||||
(function() {
|
||||
var sendTime = document.getElementById('input-send-time');
|
||||
var select = document.getElementById('status-select');
|
||||
var arrival = document.getElementById('input-arrival');
|
||||
var labelArrival = document.getElementById('label-arrival');
|
||||
var sender = document.getElementById('input-sender');
|
||||
var receive = document.getElementById('input-receive');
|
||||
if (s === 'received') {
|
||||
sd.style.display = 'none';
|
||||
rc.style.display = '';
|
||||
recipient.required = false;
|
||||
country.required = false;
|
||||
sendTime.required = false;
|
||||
arrival.required = false;
|
||||
var today = new Date().toISOString().slice(0, 10);
|
||||
if (!sendTime.value) sendTime.value = today;
|
||||
|
||||
var userOverrode = false;
|
||||
|
||||
function autoFromSendTime() {
|
||||
var v = sendTime.value;
|
||||
if (!v) return 'sent';
|
||||
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;
|
||||
sender.required = true;
|
||||
receive.required = true;
|
||||
} else if (s === 'sent') {
|
||||
sd.style.display = '';
|
||||
rc.style.display = 'none';
|
||||
recipient.required = true;
|
||||
country.required = true;
|
||||
sendTime.required = true;
|
||||
arrival.required = false;
|
||||
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }} *';
|
||||
} else {
|
||||
arrival.disabled = true;
|
||||
arrival.value = '';
|
||||
labelArrival.textContent = '到达时间';
|
||||
sender.required = false;
|
||||
receive.required = false;
|
||||
} else {
|
||||
sd.style.display = '';
|
||||
rc.style.display = 'none';
|
||||
recipient.required = true;
|
||||
country.required = true;
|
||||
sendTime.required = true;
|
||||
arrival.disabled = false;
|
||||
arrival.required = true;
|
||||
labelArrival.textContent = '到达时间 *';
|
||||
sender.required = false;
|
||||
receive.required = false;
|
||||
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }}';
|
||||
}
|
||||
}
|
||||
toggleFields();
|
||||
{% if not postcard %}
|
||||
// Auto-fill date fields with today on new postcards
|
||||
{% endif %}
|
||||
{% if is_new and is_received_form %}
|
||||
// ── 新建收到:自动填日期 ──
|
||||
(function() {
|
||||
var today = new Date().toISOString().slice(0, 10);
|
||||
var sendTime = document.getElementById('input-send-time');
|
||||
var receiveTime = document.getElementById('input-receive');
|
||||
var statusSel = document.getElementById('status-select');
|
||||
// Fill on page load
|
||||
if (statusSel.value === 'sent' && !sendTime.value) sendTime.value = today;
|
||||
if (statusSel.value === 'received' && !receiveTime.value) receiveTime.value = today;
|
||||
// Fill on status change
|
||||
statusSel.addEventListener('change', function() {
|
||||
if (this.value === 'sent' && !sendTime.value) sendTime.value = today;
|
||||
if (this.value === 'received' && !receiveTime.value) receiveTime.value = today;
|
||||
});
|
||||
var rt = document.querySelector('input[name="receive_time"]');
|
||||
if (rt && !rt.value) rt.value = today;
|
||||
})();
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
{% block content %}
|
||||
<div class="section-header">
|
||||
<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 style="margin-bottom:1rem">
|
||||
{% if profile.notes %}
|
||||
@@ -41,10 +44,10 @@
|
||||
<div class="postcard-row-info">
|
||||
<strong>{{ pc.card_number }}</strong>
|
||||
<div class="postcard-row-detail">
|
||||
<span>{{ pc.country|flag }} {{ pc.country or '-' }}</span>
|
||||
<span>{% if pc.country_from %}{{ pc.country_from|flag }} {{ pc.country_from }} → {% endif %}{{ pc.country_to|flag }} {{ pc.country_to or '-' }}</span>
|
||||
<span>→ {{ pc.recipient_name or '-' }}</span>
|
||||
</div>
|
||||
<span class="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达'}.get(pc.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered'}.get(pc.status) }}</span>
|
||||
<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">
|
||||
{% 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 %}
|
||||
@@ -52,7 +55,11 @@
|
||||
</div>
|
||||
{% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %}
|
||||
<div class="postcard-row-action">
|
||||
{% if pc.status == '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();">
|
||||
<button type="submit" class="btn btn-sm btn-primary">{{ 'pc_list.mark_delivered'|t(user.language) }}</button>
|
||||
</form>
|
||||
@@ -81,7 +88,10 @@
|
||||
<div class="postcard-row-detail">
|
||||
<span>← {{ pc.sender_name or '-' }}</span>
|
||||
</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>
|
||||
{% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %}
|
||||
</a>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<div class="inline-form">
|
||||
<form method="post" action="/profiles" class="form-row">
|
||||
<input type="text" name="nickname" placeholder="{{ 'profiles.new_ph'|t(user.language) }}" required>
|
||||
<input type="text" name="country" placeholder="{{ 'profiles.country_ph'|t(user.language) }}" maxlength="2" style="width:60px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
|
||||
<button type="submit" class="btn btn-primary">{{ 'profiles.new_btn'|t(user.language) }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -18,10 +19,12 @@
|
||||
<div class="profile-card-header">
|
||||
<h3 class="profile-card-name">
|
||||
<span id="name-display-{{ p.id }}">{{ p.nickname }}</span>
|
||||
{% if p.country %}<span style="font-size:.8rem;color:var(--text-muted);font-weight:400">{{ p.country|flag }} {{ p.country }}</span>{% endif %}
|
||||
<button type="button" class="icon-btn" onclick="startRename({{ p.id }})" title="{{ 'profiles.rename'|t(user.language) }}">✏️</button>
|
||||
</h3>
|
||||
<form method="post" action="/profiles/{{ p.id }}/rename" class="rename-form" id="name-edit-{{ p.id }}" style="display:none">
|
||||
<input type="text" name="nickname" value="{{ p.nickname }}" required>
|
||||
<input type="text" name="country" value="{{ p.country or '' }}" maxlength="2" style="width:60px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()" placeholder="{{ 'profiles.country_ph'|t(user.language) }}">
|
||||
<button type="submit" class="btn btn-primary btn-sm">✓</button>
|
||||
<button type="button" class="btn btn-sm" onclick="cancelRename({{ p.id }})">✕</button>
|
||||
</form>
|
||||
|
||||
@@ -16,7 +16,20 @@
|
||||
<h1>📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1>
|
||||
</div>
|
||||
|
||||
{% set has_content = (sent_all|length + received_all|length) > 0 %}
|
||||
{% set sent_visible = [] %}
|
||||
{% set received_visible = [] %}
|
||||
{% for pc in sent_all %}
|
||||
{% if pc.image_front %}
|
||||
{% if sent_visible.append(pc) %}{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for pc in received_all %}
|
||||
{% if pc.image_front %}
|
||||
{% if received_visible.append(pc) %}{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% set has_content = (sent_visible|length + received_visible|length) > 0 %}
|
||||
{% if not has_content %}
|
||||
<div class="empty-state">
|
||||
<p>{{ 'showcase.empty'|t(lang) }}</p>
|
||||
@@ -24,24 +37,21 @@
|
||||
{% else %}
|
||||
|
||||
<div class="tabs" id="tabs">
|
||||
<button class="tab active" onclick="switchTab('sent')">{{ 'showcase.tab_sent'|t(lang) }} ({{ sent_all|length }})</button>
|
||||
<button class="tab" onclick="switchTab('received')">{{ 'showcase.tab_received'|t(lang) }} ({{ received_all|length }})</button>
|
||||
<button class="tab active" onclick="switchTab('sent')">{{ 'showcase.tab_sent'|t(lang) }} ({{ sent_visible|length }})</button>
|
||||
<button class="tab" onclick="switchTab('received')">{{ 'showcase.tab_received'|t(lang) }} ({{ received_visible|length }})</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-sent" class="tab-panel">
|
||||
{% if mode == 'flat' %}
|
||||
<div class="showcase-grid">
|
||||
{% for pc in sent_all %}
|
||||
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
|
||||
{% if pc.image_front %}
|
||||
<div class="showcase-grid" id="grid-sent">
|
||||
{% for pc in sent_visible %}
|
||||
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
|
||||
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
||||
{% else %}
|
||||
<div class="showcase-card-img placeholder-img">📷</div>
|
||||
{% endif %}
|
||||
<div class="showcase-card-body">
|
||||
<strong>{{ pc.card_number }}</strong>
|
||||
<div class="showcase-card-meta">
|
||||
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
||||
{% if pc.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 %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,20 +60,23 @@
|
||||
</div>
|
||||
{% else %}
|
||||
{% for g in profile_groups %}
|
||||
{% if g.sent %}
|
||||
{% set g_sent = [] %}
|
||||
{% for pc in g.sent %}
|
||||
{% if pc.image_front %}
|
||||
{% if g_sent.append(pc) %}{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if g_sent %}
|
||||
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
|
||||
<div class="showcase-grid">
|
||||
{% for pc in g.sent %}
|
||||
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
|
||||
{% if pc.image_front %}
|
||||
{% for pc in g_sent %}
|
||||
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
|
||||
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
||||
{% else %}
|
||||
<div class="showcase-card-img placeholder-img">📷</div>
|
||||
{% endif %}
|
||||
<div class="showcase-card-body">
|
||||
<strong>{{ pc.card_number }}</strong>
|
||||
<div class="showcase-card-meta">
|
||||
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
||||
{% if pc.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 %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,22 +86,23 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if sent_visible|length > 19 %}
|
||||
<div id="scroll-sentinel-sent" style="height:1px"></div>
|
||||
<div id="scroll-loading-sent" style="text-align:center;padding:1rem;color:var(--text-muted);display:none">⏳</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="tab-received" class="tab-panel" style="display:none">
|
||||
{% if mode == 'flat' %}
|
||||
<div class="showcase-grid">
|
||||
{% for pc in received_all %}
|
||||
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
|
||||
{% if pc.image_front %}
|
||||
<div class="showcase-grid" id="grid-received">
|
||||
{% for pc in received_visible %}
|
||||
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
|
||||
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
||||
{% else %}
|
||||
<div class="showcase-card-img placeholder-img">📷</div>
|
||||
{% endif %}
|
||||
<div class="showcase-card-body">
|
||||
<strong>{{ pc.card_number }}</strong>
|
||||
<div class="showcase-card-meta">
|
||||
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
||||
{% if pc.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 %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,20 +111,23 @@
|
||||
</div>
|
||||
{% else %}
|
||||
{% for g in profile_groups %}
|
||||
{% if g.received %}
|
||||
{% set g_received = [] %}
|
||||
{% for pc in g.received %}
|
||||
{% if pc.image_front %}
|
||||
{% if g_received.append(pc) %}{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if g_received %}
|
||||
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
|
||||
<div class="showcase-grid">
|
||||
{% for pc in g.received %}
|
||||
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
|
||||
{% if pc.image_front %}
|
||||
{% for pc in g_received %}
|
||||
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
|
||||
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
||||
{% else %}
|
||||
<div class="showcase-card-img placeholder-img">📷</div>
|
||||
{% endif %}
|
||||
<div class="showcase-card-body">
|
||||
<strong>{{ pc.card_number }}</strong>
|
||||
<div class="showcase-card-meta">
|
||||
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
||||
{% if pc.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 %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,20 +137,90 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if received_visible|length > 19 %}
|
||||
<div id="scroll-sentinel-received" style="height:1px"></div>
|
||||
<div id="scroll-loading-received" style="text-align:center;padding:1rem;color:var(--text-muted);display:none">⏳</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="lightbox" class="lightbox" onclick="closeLightbox(event)">
|
||||
<button class="lightbox-close" onclick="closeLightbox(event)">×</button>
|
||||
<button class="lightbox-prev" onclick="navLightbox(-1,event)">❮</button>
|
||||
<button class="lightbox-next" onclick="navLightbox(1,event)">❯</button>
|
||||
<div class="lightbox-content" onclick="event.stopPropagation()">
|
||||
<button class="lightbox-close" onclick="closeLightbox(event)">×</button>
|
||||
<button class="lightbox-prev" onclick="navLightbox(-1,event)">‹</button>
|
||||
<button class="lightbox-next" onclick="navLightbox(1,event)">›</button>
|
||||
<img id="lightbox-img" src="" alt="">
|
||||
<div id="lightbox-caption" class="lightbox-caption"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var _username = '{{ profile_user.username }}';
|
||||
var _sentPage = 1, _sentMore = {{ 'true' if sent_visible|length > 19 else 'false' }};
|
||||
var _recvPage = 1, _recvMore = {{ 'true' if received_visible|length > 19 else 'false' }};
|
||||
var _sentLoading = false, _recvLoading = false;
|
||||
|
||||
function _flag(code) {
|
||||
if (!code || code.length !== 2) return code || '';
|
||||
return String.fromCodePoint(0x1F1E6 + code.charCodeAt(0) - 65) + String.fromCodePoint(0x1F1E6 + code.charCodeAt(1) - 65);
|
||||
}
|
||||
|
||||
function _appendCard(c, gridId) {
|
||||
var grid = document.getElementById(gridId);
|
||||
if (!grid) return;
|
||||
var div = document.createElement('div');
|
||||
div.className = 'showcase-card';
|
||||
div.setAttribute('data-front', c.image_front);
|
||||
div.onclick = function() { openLightbox(div); };
|
||||
var countryHtml = '';
|
||||
if (c.country_from && c.country_to) countryHtml = '<span>' + _flag(c.country_from) + ' ' + c.country_from + ' → ' + _flag(c.country_to) + ' ' + c.country_to + '</span>';
|
||||
else if (c.country_to) countryHtml = '<span>' + _flag(c.country_to) + '</span>';
|
||||
div.innerHTML = '<img src="' + c.image_front + '" alt="" class="showcase-card-img" loading="lazy">' +
|
||||
'<div class="showcase-card-body"><div class="showcase-card-meta">' + countryHtml + '</div></div>';
|
||||
grid.appendChild(div);
|
||||
}
|
||||
|
||||
function _loadMoreSent() {
|
||||
if (_sentLoading || !_sentMore) return;
|
||||
_sentLoading = true;
|
||||
document.getElementById('scroll-loading-sent').style.display = '';
|
||||
fetch('/api/public/cards/' + _username + '?page=' + (_sentPage + 1) + '&limit=20&tab=sent')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
_sentLoading = false;
|
||||
document.getElementById('scroll-loading-sent').style.display = 'none';
|
||||
_sentPage = data.page;
|
||||
_sentMore = data.has_more;
|
||||
data.cards.forEach(function(c) { _appendCard(c, 'grid-sent'); });
|
||||
if (!_sentMore) { var s = document.getElementById('scroll-sentinel-sent'); if (s) s.remove(); }
|
||||
})
|
||||
.catch(function() { _sentLoading = false; });
|
||||
}
|
||||
|
||||
function _loadMoreReceived() {
|
||||
if (_recvLoading || !_recvMore) return;
|
||||
_recvLoading = true;
|
||||
document.getElementById('scroll-loading-received').style.display = '';
|
||||
fetch('/api/public/cards/' + _username + '?page=' + (_recvPage + 1) + '&limit=20&tab=received')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
_recvLoading = false;
|
||||
document.getElementById('scroll-loading-received').style.display = 'none';
|
||||
_recvPage = data.page;
|
||||
_recvMore = data.has_more;
|
||||
data.cards.forEach(function(c) { _appendCard(c, 'grid-received'); });
|
||||
if (!_recvMore) { var s = document.getElementById('scroll-sentinel-received'); if (s) s.remove(); }
|
||||
})
|
||||
.catch(function() { _recvLoading = false; });
|
||||
}
|
||||
|
||||
var _obsSent = new IntersectionObserver(function(e) { if (e[0].isIntersecting) _loadMoreSent(); }, {rootMargin: '400px'});
|
||||
var _obsRecv = new IntersectionObserver(function(e) { if (e[0].isIntersecting) _loadMoreReceived(); }, {rootMargin: '400px'});
|
||||
var _sSent = document.getElementById('scroll-sentinel-sent');
|
||||
var _sRecv = document.getElementById('scroll-sentinel-received');
|
||||
if (_sSent) _obsSent.observe(_sSent);
|
||||
if (_sRecv) _obsRecv.observe(_sRecv);
|
||||
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
|
||||
@@ -149,8 +236,7 @@ function buildLightboxItems() {
|
||||
var visible = document.querySelectorAll('.tab-panel:not([style*="display:none"]) .showcase-card[data-front]');
|
||||
visible.forEach(function(card) {
|
||||
var front = card.getAttribute('data-front');
|
||||
var number = card.getAttribute('data-number');
|
||||
if (front) lightboxItems.push({src: front, number: number});
|
||||
if (front) lightboxItems.push({src: front});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,9 +244,8 @@ function openLightbox(el) {
|
||||
buildLightboxItems();
|
||||
var front = el.getAttribute('data-front');
|
||||
if (!front) return;
|
||||
var number = el.getAttribute('data-number');
|
||||
for (var i = 0; i < lightboxItems.length; i++) {
|
||||
if (lightboxItems[i].number === number) { lightboxIdx = i; break; }
|
||||
if (lightboxItems[i].src === front) { lightboxIdx = i; break; }
|
||||
}
|
||||
showLightboxItem();
|
||||
document.getElementById('lightbox').classList.add('active');
|
||||
@@ -171,7 +256,7 @@ function showLightboxItem() {
|
||||
if (!lightboxItems.length) return;
|
||||
var item = lightboxItems[lightboxIdx];
|
||||
document.getElementById('lightbox-img').src = item.src;
|
||||
document.getElementById('lightbox-caption').textContent = item.number + ' (' + (lightboxIdx+1) + '/' + lightboxItems.length + ')';
|
||||
document.getElementById('lightbox-caption').textContent = (lightboxIdx+1) + '/' + lightboxItems.length;
|
||||
}
|
||||
|
||||
function closeLightbox(e) {
|
||||
@@ -194,13 +279,10 @@ document.addEventListener('keydown', function(e) {
|
||||
else if (e.key === 'ArrowLeft') navLightbox(-1);
|
||||
else if (e.key === 'ArrowRight') navLightbox(1);
|
||||
});
|
||||
// Close language dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
var sw = document.getElementById('langSwitcher');
|
||||
var dd = document.getElementById('langDropdown');
|
||||
if (sw && dd && !sw.contains(e.target)) {
|
||||
dd.classList.remove('show');
|
||||
}
|
||||
if (sw && dd && !sw.contains(e.target)) dd.classList.remove('show');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user