feat(postcard): profile country, country_from, per-user card number uniqueness, public page overhaul - Profile: add country field for default origin country - Postcard: add country_from field, auto-fill from profile.country on create - card_number uniqueness: change from global to per-user (profile_id + card_number) - Public pages: remove card_number display, only show cards with image_front - Public pages: show country as from→to format - Index page: no time display, add register button, fix lightbox with prev/next - Infinite scroll: add /api/public/cards endpoints and IntersectionObserver - Internal pages: update postcards list/detail for country_from display - i18n: add profiles.country_ph, pc_form.country_from, pc_detail.route
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
"index.title": "Mailova",
|
"index.title": "Mailova",
|
||||||
"index.subtitle": "Postcards from around the world",
|
"index.subtitle": "Postcards from around the world",
|
||||||
"index.go_login": "Login",
|
"index.go_login": "Login",
|
||||||
|
"index.go_register": "Register",
|
||||||
"index.empty": "No public postcards yet",
|
"index.empty": "No public postcards yet",
|
||||||
"nav.profiles": "Profiles",
|
"nav.profiles": "Profiles",
|
||||||
"nav.settings": "Settings",
|
"nav.settings": "Settings",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"profiles.total": "Total",
|
"profiles.total": "Total",
|
||||||
"profiles.countries": "Countries",
|
"profiles.countries": "Countries",
|
||||||
"profiles.notes_ph": "Notes (private)",
|
"profiles.notes_ph": "Notes (private)",
|
||||||
|
"profiles.country_ph": "Country",
|
||||||
"profiles.save": "Save",
|
"profiles.save": "Save",
|
||||||
"profiles.created_at": "Created",
|
"profiles.created_at": "Created",
|
||||||
"profiles.view": "View",
|
"profiles.view": "View",
|
||||||
@@ -96,6 +98,7 @@
|
|||||||
"pc_detail.status": "Status",
|
"pc_detail.status": "Status",
|
||||||
"pc_detail.recipient": "Recipient",
|
"pc_detail.recipient": "Recipient",
|
||||||
"pc_detail.country": "Country",
|
"pc_detail.country": "Country",
|
||||||
|
"pc_detail.route": "Route",
|
||||||
"pc_detail.send_time": "Sent date",
|
"pc_detail.send_time": "Sent date",
|
||||||
"pc_detail.arrival_time": "Arrival date",
|
"pc_detail.arrival_time": "Arrival date",
|
||||||
"pc_detail.sender": "Sender",
|
"pc_detail.sender": "Sender",
|
||||||
@@ -112,6 +115,7 @@
|
|||||||
"pc_form.status": "Status",
|
"pc_form.status": "Status",
|
||||||
"pc_form.recipient": "Recipient",
|
"pc_form.recipient": "Recipient",
|
||||||
"pc_form.country": "Country",
|
"pc_form.country": "Country",
|
||||||
|
"pc_form.country_from": "From",
|
||||||
"pc_form.send_time": "Sent date",
|
"pc_form.send_time": "Sent date",
|
||||||
"pc_form.arrival_time": "Arrival date",
|
"pc_form.arrival_time": "Arrival date",
|
||||||
"pc_form.sender": "Sender",
|
"pc_form.sender": "Sender",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"index.title": "星笺",
|
"index.title": "星笺",
|
||||||
"index.subtitle": "来自世界各地的明信片",
|
"index.subtitle": "来自世界各地的明信片",
|
||||||
"index.go_login": "登录",
|
"index.go_login": "登录",
|
||||||
|
"index.go_register": "注册",
|
||||||
"index.empty": "暂无公开明信片",
|
"index.empty": "暂无公开明信片",
|
||||||
"nav.profiles": "Profile",
|
"nav.profiles": "Profile",
|
||||||
"nav.settings": "设置",
|
"nav.settings": "设置",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"profiles.total": "总计",
|
"profiles.total": "总计",
|
||||||
"profiles.countries": "国家",
|
"profiles.countries": "国家",
|
||||||
"profiles.notes_ph": "备注(仅自己可见)",
|
"profiles.notes_ph": "备注(仅自己可见)",
|
||||||
|
"profiles.country_ph": "国家",
|
||||||
"profiles.save": "保存",
|
"profiles.save": "保存",
|
||||||
"profiles.created_at": "创建于",
|
"profiles.created_at": "创建于",
|
||||||
"profiles.view": "查看",
|
"profiles.view": "查看",
|
||||||
@@ -96,6 +98,7 @@
|
|||||||
"pc_detail.status": "状态",
|
"pc_detail.status": "状态",
|
||||||
"pc_detail.recipient": "收件人",
|
"pc_detail.recipient": "收件人",
|
||||||
"pc_detail.country": "国家/地区",
|
"pc_detail.country": "国家/地区",
|
||||||
|
"pc_detail.route": "路线",
|
||||||
"pc_detail.send_time": "寄出时间",
|
"pc_detail.send_time": "寄出时间",
|
||||||
"pc_detail.arrival_time": "到达时间",
|
"pc_detail.arrival_time": "到达时间",
|
||||||
"pc_detail.sender": "发件人",
|
"pc_detail.sender": "发件人",
|
||||||
@@ -112,6 +115,7 @@
|
|||||||
"pc_form.status": "状态",
|
"pc_form.status": "状态",
|
||||||
"pc_form.recipient": "收件人",
|
"pc_form.recipient": "收件人",
|
||||||
"pc_form.country": "国家/地区",
|
"pc_form.country": "国家/地区",
|
||||||
|
"pc_form.country_from": "寄出地",
|
||||||
"pc_form.send_time": "寄出时间",
|
"pc_form.send_time": "寄出时间",
|
||||||
"pc_form.arrival_time": "到达时间",
|
"pc_form.arrival_time": "到达时间",
|
||||||
"pc_form.sender": "发件人",
|
"pc_form.sender": "发件人",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
|
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import relationship, validates
|
from sqlalchemy.orm import relationship, validates
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -61,6 +61,7 @@ class Profile(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
nickname = Column(String(64), nullable=False)
|
nickname = Column(String(64), nullable=False)
|
||||||
|
country = Column(String(2), nullable=True)
|
||||||
showcase_enabled = Column(default=False, nullable=False)
|
showcase_enabled = Column(default=False, nullable=False)
|
||||||
showcase_visible = Column(String(16), nullable=False, default="both")
|
showcase_visible = Column(String(16), nullable=False, default="both")
|
||||||
notes = Column(Text, nullable=False, default="")
|
notes = Column(Text, nullable=False, default="")
|
||||||
@@ -72,14 +73,18 @@ class Profile(Base):
|
|||||||
|
|
||||||
class Postcard(Base):
|
class Postcard(Base):
|
||||||
__tablename__ = "postcards"
|
__tablename__ = "postcards"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("profile_id", "card_number", name="uq_postcards_profile_card_number"),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False)
|
profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False)
|
||||||
card_number = Column(String(64), nullable=False, unique=True)
|
card_number = Column(String(64), nullable=False)
|
||||||
status = Column(String(16), nullable=False)
|
status = Column(String(16), nullable=False)
|
||||||
# sent & delivered
|
# sent & delivered
|
||||||
recipient_name = Column(String(64))
|
recipient_name = Column(String(64))
|
||||||
country = Column(String(64))
|
country = Column(String(64))
|
||||||
|
country_from = Column(String(2))
|
||||||
send_time = Column(DateTime)
|
send_time = Column(DateTime)
|
||||||
arrival_time = Column(DateTime)
|
arrival_time = Column(DateTime)
|
||||||
# received
|
# received
|
||||||
@@ -100,6 +105,10 @@ class Postcard(Base):
|
|||||||
def _upper_country(self, key, value):
|
def _upper_country(self, key, value):
|
||||||
return value.strip().upper() if value else None
|
return value.strip().upper() if value else None
|
||||||
|
|
||||||
|
@validates("country_from")
|
||||||
|
def _upper_country_from(self, key, value):
|
||||||
|
return value.strip().upper() if value else None
|
||||||
|
|
||||||
|
|
||||||
class ApiKey(Base):
|
class ApiKey(Base):
|
||||||
__tablename__ = "api_keys"
|
__tablename__ = "api_keys"
|
||||||
|
|||||||
@@ -81,13 +81,16 @@ def get_api_user_optional(
|
|||||||
|
|
||||||
class ProfileCreate(BaseModel):
|
class ProfileCreate(BaseModel):
|
||||||
nickname: str
|
nickname: str
|
||||||
|
country: str | None = None
|
||||||
|
|
||||||
class ProfileUpdate(BaseModel):
|
class ProfileUpdate(BaseModel):
|
||||||
nickname: str | None = None
|
nickname: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
|
||||||
class ProfileOut(BaseModel):
|
class ProfileOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
nickname: str
|
nickname: str
|
||||||
|
country: str | None
|
||||||
showcase_enabled: bool
|
showcase_enabled: bool
|
||||||
showcase_visible: str
|
showcase_visible: str
|
||||||
notes: str
|
notes: str
|
||||||
@@ -101,6 +104,7 @@ class PostcardCreate(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
recipient_name: str | None = None
|
recipient_name: str | None = None
|
||||||
country: str | None = None
|
country: str | None = None
|
||||||
|
country_from: str | None = None
|
||||||
send_time: str | None = None
|
send_time: str | None = None
|
||||||
arrival_time: str | None = None
|
arrival_time: str | None = None
|
||||||
sender_name: str | None = None
|
sender_name: str | None = None
|
||||||
@@ -112,6 +116,7 @@ class PostcardUpdate(BaseModel):
|
|||||||
status: str | None = None
|
status: str | None = None
|
||||||
recipient_name: str | None = None
|
recipient_name: str | None = None
|
||||||
country: str | None = None
|
country: str | None = None
|
||||||
|
country_from: str | None = None
|
||||||
send_time: str | None = None
|
send_time: str | None = None
|
||||||
arrival_time: str | None = None
|
arrival_time: str | None = None
|
||||||
sender_name: str | None = None
|
sender_name: str | None = None
|
||||||
@@ -125,6 +130,7 @@ class PostcardOut(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
recipient_name: str | None
|
recipient_name: str | None
|
||||||
country: str | None
|
country: str | None
|
||||||
|
country_from: str | None
|
||||||
send_time: datetime | None
|
send_time: datetime | None
|
||||||
arrival_time: datetime | None
|
arrival_time: datetime | None
|
||||||
sender_name: str | None
|
sender_name: str | None
|
||||||
@@ -205,7 +211,8 @@ def create_profile(
|
|||||||
db: DbSession = Depends(get_db),
|
db: DbSession = Depends(get_db),
|
||||||
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
|
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
|
||||||
):
|
):
|
||||||
p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id)
|
c = body.country.strip().upper() if body.country else None
|
||||||
|
p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id, country=c)
|
||||||
db.add(p)
|
db.add(p)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(p)
|
db.refresh(p)
|
||||||
@@ -231,6 +238,8 @@ def update_profile(
|
|||||||
p = _profile_or_404(ak.user, profile_id, db)
|
p = _profile_or_404(ak.user, profile_id, db)
|
||||||
if body.nickname is not None:
|
if body.nickname is not None:
|
||||||
p.nickname = body.nickname.strip()
|
p.nickname = body.nickname.strip()
|
||||||
|
if body.country is not None:
|
||||||
|
p.country = body.country.strip().upper() if body.country else None
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(p)
|
db.refresh(p)
|
||||||
return p
|
return p
|
||||||
@@ -275,7 +284,10 @@ def create_postcard(
|
|||||||
):
|
):
|
||||||
_profile_or_404(ak.user, profile_id, db)
|
_profile_or_404(ak.user, profile_id, db)
|
||||||
cn = body.card_number.strip()
|
cn = body.card_number.strip()
|
||||||
if db.query(Postcard).filter(Postcard.card_number == cn).first():
|
dup = db.query(Postcard).join(Profile).filter(
|
||||||
|
Postcard.card_number == cn, Profile.user_id == ak.user.id
|
||||||
|
).first()
|
||||||
|
if dup:
|
||||||
raise HTTPException(status_code=409, detail="Card number already exists")
|
raise HTTPException(status_code=409, detail="Card number already exists")
|
||||||
pc = Postcard(
|
pc = Postcard(
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
@@ -283,6 +295,7 @@ def create_postcard(
|
|||||||
status=body.status,
|
status=body.status,
|
||||||
recipient_name=body.recipient_name,
|
recipient_name=body.recipient_name,
|
||||||
country=body.country.strip().upper() if body.country else None,
|
country=body.country.strip().upper() if body.country else None,
|
||||||
|
country_from=body.country_from.strip().upper() if body.country_from else None,
|
||||||
send_time=_parse_date(body.send_time),
|
send_time=_parse_date(body.send_time),
|
||||||
arrival_time=_parse_date(body.arrival_time),
|
arrival_time=_parse_date(body.arrival_time),
|
||||||
sender_name=body.sender_name,
|
sender_name=body.sender_name,
|
||||||
@@ -320,12 +333,17 @@ def update_postcard(
|
|||||||
setattr(pc, field, val.strip() if isinstance(val, str) else val)
|
setattr(pc, field, val.strip() if isinstance(val, str) else val)
|
||||||
if body.card_number is not None:
|
if body.card_number is not None:
|
||||||
cn = body.card_number.strip()
|
cn = body.card_number.strip()
|
||||||
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
|
dup = db.query(Postcard).join(Profile).filter(
|
||||||
|
Postcard.card_number == cn, Postcard.id != postcard_id,
|
||||||
|
Profile.user_id == ak.user.id
|
||||||
|
).first()
|
||||||
if dup:
|
if dup:
|
||||||
raise HTTPException(status_code=409, detail="Card number already exists")
|
raise HTTPException(status_code=409, detail="Card number already exists")
|
||||||
pc.card_number = cn
|
pc.card_number = cn
|
||||||
if body.country is not None:
|
if body.country is not None:
|
||||||
pc.country = body.country.strip().upper() if body.country else None
|
pc.country = body.country.strip().upper() if body.country else None
|
||||||
|
if body.country_from is not None:
|
||||||
|
pc.country_from = body.country_from.strip().upper() if body.country_from else None
|
||||||
for field in ("send_time", "arrival_time", "receive_time"):
|
for field in ("send_time", "arrival_time", "receive_time"):
|
||||||
val = getattr(body, field)
|
val = getattr(body, field)
|
||||||
if val is not None:
|
if val is not None:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from random import shuffle
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -126,7 +126,8 @@ def index_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
).all()
|
).all()
|
||||||
for p in profiles:
|
for p in profiles:
|
||||||
cards = db.query(Postcard).filter(
|
cards = db.query(Postcard).filter(
|
||||||
Postcard.profile_id == p.id, Postcard.showcase_hidden == False
|
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
|
||||||
|
Postcard.image_front.isnot(None), Postcard.image_front != ""
|
||||||
).all()
|
).all()
|
||||||
for c in cards:
|
for c in cards:
|
||||||
visible = True
|
visible = True
|
||||||
@@ -147,7 +148,92 @@ def index_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
lang = "zh"
|
lang = "zh"
|
||||||
|
|
||||||
return templates.TemplateResponse("index.html", {
|
return templates.TemplateResponse("index.html", {
|
||||||
"request": request, "postcards": all_postcards, "lang": lang,
|
"request": request, "postcards": all_postcards[:20], "lang": lang,
|
||||||
|
"has_more": len(all_postcards) > 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/public/cards")
|
||||||
|
def api_public_cards(
|
||||||
|
request: Request,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
all_cards: list[Postcard] = []
|
||||||
|
users = db.query(User).filter(User.showcase_mode.isnot(None)).all()
|
||||||
|
for u in users:
|
||||||
|
profiles = db.query(Profile).filter(
|
||||||
|
Profile.user_id == u.id, Profile.showcase_enabled == True
|
||||||
|
).all()
|
||||||
|
for p in profiles:
|
||||||
|
cards = db.query(Postcard).filter(
|
||||||
|
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
|
||||||
|
Postcard.image_front.isnot(None), Postcard.image_front != ""
|
||||||
|
).all()
|
||||||
|
for c in cards:
|
||||||
|
vis = True
|
||||||
|
if c.status in ("sent", "delivered") and p.showcase_visible not in ("sent", "both"):
|
||||||
|
vis = False
|
||||||
|
if c.status == "received" and p.showcase_visible not in ("received", "both"):
|
||||||
|
vis = False
|
||||||
|
if vis:
|
||||||
|
all_cards.append(c)
|
||||||
|
shuffle(all_cards)
|
||||||
|
total = len(all_cards)
|
||||||
|
start = (page - 1) * limit
|
||||||
|
batch = all_cards[start:start + limit]
|
||||||
|
return JSONResponse({
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"has_more": start + limit < total,
|
||||||
|
"cards": [{
|
||||||
|
"image_front": c.image_front,
|
||||||
|
"country_from": c.country_from,
|
||||||
|
"country": c.country,
|
||||||
|
"status": c.status,
|
||||||
|
} for c in batch],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/public/cards/{username}")
|
||||||
|
def api_public_cards_user(
|
||||||
|
username: str,
|
||||||
|
request: Request,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
tab: str = Query("sent"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"error": "not found"}, status_code=404)
|
||||||
|
profiles = db.query(Profile).filter(
|
||||||
|
Profile.user_id == user.id, Profile.showcase_enabled == True
|
||||||
|
).all()
|
||||||
|
all_cards: list[Postcard] = []
|
||||||
|
for p in profiles:
|
||||||
|
cards = db.query(Postcard).filter(
|
||||||
|
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
|
||||||
|
Postcard.image_front.isnot(None), Postcard.image_front != ""
|
||||||
|
).all()
|
||||||
|
if tab == "sent" and p.showcase_visible in ("sent", "both"):
|
||||||
|
all_cards.extend([c for c in cards if c.status in ("sent", "delivered")])
|
||||||
|
elif tab == "received" and p.showcase_visible in ("received", "both"):
|
||||||
|
all_cards.extend([c for c in cards if c.status == "received"])
|
||||||
|
all_cards.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
|
||||||
|
total = len(all_cards)
|
||||||
|
start = (page - 1) * limit
|
||||||
|
batch = all_cards[start:start + limit]
|
||||||
|
return JSONResponse({
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"has_more": start + limit < total,
|
||||||
|
"cards": [{
|
||||||
|
"image_front": c.image_front,
|
||||||
|
"country_from": c.country_from,
|
||||||
|
"country": c.country,
|
||||||
|
} for c in batch],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -565,8 +651,9 @@ def profile_list(request: Request, user: User = Depends(get_current_user_web), d
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/profiles")
|
@router.post("/profiles")
|
||||||
def profile_create(request: Request, nickname: str = Form(...), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
def profile_create(request: Request, nickname: str = Form(...), country: str = Form(""), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||||
db.add(Profile(user_id=user.id, nickname=nickname))
|
c = country.strip().upper() if country else None
|
||||||
|
db.add(Profile(user_id=user.id, nickname=nickname, country=c))
|
||||||
db.commit()
|
db.commit()
|
||||||
return _redirect("/profiles")
|
return _redirect("/profiles")
|
||||||
|
|
||||||
@@ -581,10 +668,11 @@ def profile_delete(profile_id: int, user: User = Depends(get_current_user_web),
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/profiles/{profile_id}/rename")
|
@router.post("/profiles/{profile_id}/rename")
|
||||||
def profile_rename(profile_id: int, nickname: str = Form(...), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
def profile_rename(profile_id: int, nickname: str = Form(...), country: str = Form(""), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||||
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
|
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
|
||||||
if p:
|
if p:
|
||||||
p.nickname = nickname
|
p.nickname = nickname
|
||||||
|
p.country = country.strip().upper() if country else None
|
||||||
db.commit()
|
db.commit()
|
||||||
return _redirect("/profiles")
|
return _redirect("/profiles")
|
||||||
|
|
||||||
@@ -649,6 +737,7 @@ def postcard_create(
|
|||||||
status: str = Form(...),
|
status: str = Form(...),
|
||||||
recipient_name: str = Form(""),
|
recipient_name: str = Form(""),
|
||||||
country: str = Form(""),
|
country: str = Form(""),
|
||||||
|
country_from: str = Form(""),
|
||||||
send_time: str = Form(""),
|
send_time: str = Form(""),
|
||||||
arrival_time: str = Form(""),
|
arrival_time: str = Form(""),
|
||||||
sender_name: str = Form(""),
|
sender_name: str = Form(""),
|
||||||
@@ -662,7 +751,10 @@ def postcard_create(
|
|||||||
return _redirect("/profiles")
|
return _redirect("/profiles")
|
||||||
|
|
||||||
cn = card_number.strip()
|
cn = card_number.strip()
|
||||||
if db.query(Postcard).filter(Postcard.card_number == cn).first():
|
dup = db.query(Postcard).join(Profile).filter(
|
||||||
|
Postcard.card_number == cn, Profile.user_id == user.id
|
||||||
|
).first()
|
||||||
|
if dup:
|
||||||
return templates.TemplateResponse("postcard_form.html", {
|
return templates.TemplateResponse("postcard_form.html", {
|
||||||
"request": request, "user": user, "profile": profile, "postcard": None,
|
"request": request, "user": user, "profile": profile, "postcard": None,
|
||||||
"error": _t("pc_list.duplicate", user.language),
|
"error": _t("pc_list.duplicate", user.language),
|
||||||
@@ -686,6 +778,7 @@ def postcard_create(
|
|||||||
status=status,
|
status=status,
|
||||||
recipient_name=recipient_name or None,
|
recipient_name=recipient_name or None,
|
||||||
country=(country.strip().upper() if country else None),
|
country=(country.strip().upper() if country else None),
|
||||||
|
country_from=(country_from.strip().upper() if country_from else profile.country),
|
||||||
send_time=_parse_dt(send_time),
|
send_time=_parse_dt(send_time),
|
||||||
arrival_time=_parse_dt(arrival_time),
|
arrival_time=_parse_dt(arrival_time),
|
||||||
sender_name=sender_name or None,
|
sender_name=sender_name or None,
|
||||||
@@ -731,6 +824,7 @@ def postcard_update(
|
|||||||
status: str = Form(...),
|
status: str = Form(...),
|
||||||
recipient_name: str = Form(""),
|
recipient_name: str = Form(""),
|
||||||
country: str = Form(""),
|
country: str = Form(""),
|
||||||
|
country_from: str = Form(""),
|
||||||
send_time: str = Form(""),
|
send_time: str = Form(""),
|
||||||
arrival_time: str = Form(""),
|
arrival_time: str = Form(""),
|
||||||
sender_name: str = Form(""),
|
sender_name: str = Form(""),
|
||||||
@@ -749,7 +843,10 @@ def postcard_update(
|
|||||||
return _redirect("/profiles")
|
return _redirect("/profiles")
|
||||||
|
|
||||||
cn = card_number.strip()
|
cn = card_number.strip()
|
||||||
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
|
dup = db.query(Postcard).join(Profile).filter(
|
||||||
|
Postcard.card_number == cn, Postcard.id != postcard_id,
|
||||||
|
Profile.user_id == user.id
|
||||||
|
).first()
|
||||||
if dup:
|
if dup:
|
||||||
return templates.TemplateResponse("postcard_form.html", {
|
return templates.TemplateResponse("postcard_form.html", {
|
||||||
"request": request, "user": user, "profile": pc.profile, "postcard": pc,
|
"request": request, "user": user, "profile": pc.profile, "postcard": pc,
|
||||||
@@ -772,6 +869,7 @@ def postcard_update(
|
|||||||
pc.status = status
|
pc.status = status
|
||||||
pc.recipient_name = recipient_name or None
|
pc.recipient_name = recipient_name or None
|
||||||
pc.country = country or None
|
pc.country = country or None
|
||||||
|
pc.country_from = country_from.strip().upper() if country_from else pc.country_from
|
||||||
pc.send_time = _parse_dt(send_time)
|
pc.send_time = _parse_dt(send_time)
|
||||||
pc.arrival_time = None if status == "sent" else _parse_dt(arrival_time)
|
pc.arrival_time = None if status == "sent" else _parse_dt(arrival_time)
|
||||||
pc.sender_name = sender_name or None
|
pc.sender_name = sender_name or None
|
||||||
@@ -925,7 +1023,10 @@ def public_showcase(request: Request, username: str, db: Session = Depends(get_d
|
|||||||
profiles = db.query(Profile).filter(Profile.user_id == user.id, Profile.showcase_enabled == True).all()
|
profiles = db.query(Profile).filter(Profile.user_id == user.id, Profile.showcase_enabled == True).all()
|
||||||
sent_all, received_all, profile_groups = [], [], []
|
sent_all, received_all, profile_groups = [], [], []
|
||||||
for p in profiles:
|
for p in profiles:
|
||||||
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
|
cards = db.query(Postcard).filter(
|
||||||
|
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
|
||||||
|
Postcard.image_front.isnot(None), Postcard.image_front != ""
|
||||||
|
).all()
|
||||||
if p.showcase_visible in ("sent", "both"):
|
if p.showcase_visible in ("sent", "both"):
|
||||||
sent_all.extend([c for c in cards if c.status in ("sent", "delivered")])
|
sent_all.extend([c for c in cards if c.status in ("sent", "delivered")])
|
||||||
if p.showcase_visible in ("received", "both"):
|
if p.showcase_visible in ("received", "both"):
|
||||||
|
|||||||
@@ -7,11 +7,18 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
class ProfileCreate(BaseModel):
|
class ProfileCreate(BaseModel):
|
||||||
nickname: str
|
nickname: str
|
||||||
|
country: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdate(BaseModel):
|
||||||
|
nickname: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProfileOut(BaseModel):
|
class ProfileOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
nickname: str
|
nickname: str
|
||||||
|
country: str | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
@@ -24,6 +31,7 @@ class PostcardCreate(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
recipient_name: str | None = None
|
recipient_name: str | None = None
|
||||||
country: str | None = None
|
country: str | None = None
|
||||||
|
country_from: str | None = None
|
||||||
send_time: datetime | None = None
|
send_time: datetime | None = None
|
||||||
arrival_time: datetime | None = None
|
arrival_time: datetime | None = None
|
||||||
sender_name: str | None = None
|
sender_name: str | None = None
|
||||||
@@ -35,6 +43,7 @@ class PostcardUpdate(BaseModel):
|
|||||||
status: str | None = None
|
status: str | None = None
|
||||||
recipient_name: str | None = None
|
recipient_name: str | None = None
|
||||||
country: str | None = None
|
country: str | None = None
|
||||||
|
country_from: str | None = None
|
||||||
send_time: datetime | None = None
|
send_time: datetime | None = None
|
||||||
arrival_time: datetime | None = None
|
arrival_time: datetime | None = None
|
||||||
sender_name: str | None = None
|
sender_name: str | None = None
|
||||||
@@ -48,6 +57,7 @@ class PostcardOut(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
recipient_name: str | None
|
recipient_name: str | None
|
||||||
country: str | None
|
country: str | None
|
||||||
|
country_from: str | None
|
||||||
send_time: datetime | None
|
send_time: datetime | None
|
||||||
arrival_time: datetime | None
|
arrival_time: datetime | None
|
||||||
sender_name: str | None
|
sender_name: str | None
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="/login" class="btn btn-primary" style="margin-left:.5rem">{{ 'index.go_login'|t(lang) }}</a>
|
<a href="/login" class="btn btn-sm" style="margin-left:.5rem">{{ 'index.go_login'|t(lang) }}</a>
|
||||||
|
<a href="/register" class="btn btn-primary btn-sm">{{ 'index.go_register'|t(lang) }}</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -22,25 +23,30 @@
|
|||||||
<p style="color:var(--text-muted);margin-top:.25rem">{{ 'index.subtitle'|t(lang) }}</p>
|
<p style="color:var(--text-muted);margin-top:.25rem">{{ 'index.subtitle'|t(lang) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if postcards %}
|
{% set visible = [] %}
|
||||||
<div class="showcase-grid">
|
{% for pc in postcards %}
|
||||||
{% for pc in postcards %}
|
{% if pc.image_front %}
|
||||||
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
|
{% if visible.append(pc) %}{% endif %}
|
||||||
{% if pc.image_front %}
|
{% 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">
|
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
||||||
{% else %}
|
|
||||||
<div class="showcase-card-img placeholder-img">📷</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="showcase-card-body">
|
<div class="showcase-card-body">
|
||||||
<strong>{{ pc.card_number }}</strong>
|
|
||||||
<div class="showcase-card-meta">
|
<div class="showcase-card-meta">
|
||||||
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
{% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}→{{ pc.country|flag }}</span>
|
||||||
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
|
{% elif pc.country %}<span>{{ pc.country|flag }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<div id="scroll-sentinel" style="height:1px"></div>
|
||||||
|
<div id="scroll-loading" style="text-align:center;padding:1rem;color:var(--text-muted);display:none">⏳</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>{{ 'index.empty'|t(lang) }}</p>
|
<p>{{ 'index.empty'|t(lang) }}</p>
|
||||||
@@ -49,26 +55,116 @@
|
|||||||
|
|
||||||
<!-- Lightbox -->
|
<!-- Lightbox -->
|
||||||
<div id="lightbox" class="lightbox" onclick="closeLightbox()" style="display:none">
|
<div id="lightbox" class="lightbox" onclick="closeLightbox()" style="display:none">
|
||||||
<div class="lightbox-inner" onclick="event.stopPropagation()">
|
<div class="lightbox-content" onclick="event.stopPropagation()">
|
||||||
<button class="lightbox-close" onclick="closeLightbox()">×</button>
|
<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="">
|
<img id="lightbox-img" src="" alt="">
|
||||||
<div id="lightbox-number" class="lightbox-number"></div>
|
<div id="lightbox-caption" class="lightbox-caption"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
/* ---- Infinite Scroll ---- */
|
||||||
|
var _page = 1;
|
||||||
|
var _hasMore = {{ 'true' if has_more else 'false' }};
|
||||||
|
var _loading = false;
|
||||||
|
|
||||||
|
function _appendCard(c) {
|
||||||
|
var grid = document.getElementById('showcase-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'showcase-card';
|
||||||
|
div.setAttribute('data-front', c.image_front);
|
||||||
|
div.onclick = function() { openLightbox(div); };
|
||||||
|
var countryHtml = '';
|
||||||
|
if (c.country_from && c.country) countryHtml = '<span>' + _flag(c.country_from) + '→' + _flag(c.country) + '</span>';
|
||||||
|
else if (c.country) countryHtml = '<span>' + _flag(c.country) + '</span>';
|
||||||
|
div.innerHTML = '<img src="' + c.image_front + '" alt="" class="showcase-card-img" loading="lazy">' +
|
||||||
|
'<div class="showcase-card-body"><div class="showcase-card-meta">' + countryHtml + '</div></div>';
|
||||||
|
grid.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _flag(code) {
|
||||||
|
if (!code || code.length !== 2) return code || '';
|
||||||
|
return String.fromCodePoint(0x1F1E6 + code.charCodeAt(0) - 65) + String.fromCodePoint(0x1F1E6 + code.charCodeAt(1) - 65);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadMore() {
|
||||||
|
if (_loading || !_hasMore) return;
|
||||||
|
_loading = true;
|
||||||
|
document.getElementById('scroll-loading').style.display = '';
|
||||||
|
fetch('/api/public/cards?page=' + (_page + 1) + '&limit=20')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
_loading = false;
|
||||||
|
document.getElementById('scroll-loading').style.display = 'none';
|
||||||
|
_page = data.page;
|
||||||
|
_hasMore = data.has_more;
|
||||||
|
data.cards.forEach(_appendCard);
|
||||||
|
if (!_hasMore) {
|
||||||
|
var s = document.getElementById('scroll-sentinel');
|
||||||
|
if (s) s.remove();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { _loading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
var _observer = new IntersectionObserver(function(entries) {
|
||||||
|
if (entries[0].isIntersecting) _loadMore();
|
||||||
|
}, {rootMargin: '400px'});
|
||||||
|
var _sentinel = document.getElementById('scroll-sentinel');
|
||||||
|
if (_sentinel) _observer.observe(_sentinel);
|
||||||
|
|
||||||
|
/* ---- Lightbox ---- */
|
||||||
|
var lightboxItems = [];
|
||||||
|
var lightboxIdx = 0;
|
||||||
|
|
||||||
|
function buildLightboxItems() {
|
||||||
|
lightboxItems = [];
|
||||||
|
document.querySelectorAll('#showcase-grid .showcase-card[data-front]').forEach(function(card) {
|
||||||
|
lightboxItems.push({src: card.getAttribute('data-front')});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function openLightbox(el) {
|
function openLightbox(el) {
|
||||||
|
buildLightboxItems();
|
||||||
var src = el.getAttribute('data-front');
|
var src = el.getAttribute('data-front');
|
||||||
if (!src) return;
|
if (!src) return;
|
||||||
document.getElementById('lightbox-img').src = src;
|
for (var i = 0; i < lightboxItems.length; i++) {
|
||||||
document.getElementById('lightbox-number').textContent = el.getAttribute('data-number');
|
if (lightboxItems[i].src === src) { lightboxIdx = i; break; }
|
||||||
|
}
|
||||||
|
showLightboxItem();
|
||||||
document.getElementById('lightbox').style.display = 'flex';
|
document.getElementById('lightbox').style.display = 'flex';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
}
|
}
|
||||||
function closeLightbox() {
|
|
||||||
|
function showLightboxItem() {
|
||||||
|
if (!lightboxItems.length) return;
|
||||||
|
var item = lightboxItems[lightboxIdx];
|
||||||
|
document.getElementById('lightbox-img').src = item.src;
|
||||||
|
document.getElementById('lightbox-caption').textContent = (lightboxIdx+1) + '/' + lightboxItems.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox(e) {
|
||||||
|
if (e) e.stopPropagation();
|
||||||
document.getElementById('lightbox').style.display = 'none';
|
document.getElementById('lightbox').style.display = 'none';
|
||||||
|
document.body.style.overflow = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navLightbox(dir, e) {
|
||||||
|
if (e) e.stopPropagation();
|
||||||
|
if (!lightboxItems.length) return;
|
||||||
|
lightboxIdx = (lightboxIdx + dir + lightboxItems.length) % lightboxItems.length;
|
||||||
|
showLightboxItem();
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
|
var lb = document.getElementById('lightbox');
|
||||||
|
if (!lb || lb.style.display === 'none') return;
|
||||||
if (e.key === 'Escape') closeLightbox();
|
if (e.key === 'Escape') closeLightbox();
|
||||||
|
else if (e.key === 'ArrowLeft') navLightbox(-1);
|
||||||
|
else if (e.key === 'ArrowRight') navLightbox(1);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -52,11 +52,12 @@
|
|||||||
<dt>{{ 'pc_detail.status'|t(user.language) }}</dt><dd>{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</dd>
|
<dt>{{ 'pc_detail.status'|t(user.language) }}</dt><dd>{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</dd>
|
||||||
{% if postcard.status in ('sent','delivered') %}
|
{% if postcard.status in ('sent','delivered') %}
|
||||||
<dt>{{ 'pc_detail.recipient'|t(user.language) }}</dt><dd>{{ postcard.recipient_name or '-' }}</dd>
|
<dt>{{ 'pc_detail.recipient'|t(user.language) }}</dt><dd>{{ postcard.recipient_name or '-' }}</dd>
|
||||||
<dt>{{ 'pc_detail.country'|t(user.language) }}</dt><dd>{{ postcard.country|flag }} {{ postcard.country or '-' }}</dd>
|
<dt>{{ 'pc_detail.route'|t(user.language) }}</dt><dd>{% if postcard.country_from %}{{ postcard.country_from|flag }} {{ postcard.country_from }} → {% endif %}{{ postcard.country|flag }} {{ postcard.country or '-' }}</dd>
|
||||||
<dt>{{ 'pc_detail.send_time'|t(user.language) }}</dt><dd>{{ postcard.send_time.strftime('%Y-%m-%d') if postcard.send_time else '-' }}</dd>
|
<dt>{{ 'pc_detail.send_time'|t(user.language) }}</dt><dd>{{ postcard.send_time.strftime('%Y-%m-%d') if postcard.send_time else '-' }}</dd>
|
||||||
<dt>{{ 'pc_detail.arrival_time'|t(user.language) }}</dt><dd>{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard.arrival_time else '-' }}</dd>
|
<dt>{{ 'pc_detail.arrival_time'|t(user.language) }}</dt><dd>{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard.arrival_time else '-' }}</dd>
|
||||||
{% else %}
|
{% else %}
|
||||||
<dt>{{ 'pc_detail.sender'|t(user.language) }}</dt><dd>{{ postcard.sender_name or '-' }}</dd>
|
<dt>{{ 'pc_detail.sender'|t(user.language) }}</dt><dd>{{ postcard.sender_name or '-' }}</dd>
|
||||||
|
<dt>{{ 'pc_detail.route'|t(user.language) }}</dt><dd>{% if postcard.country_from %}{{ postcard.country_from|flag }} {{ postcard.country_from }} → {% endif %}{{ postcard.country|flag }} {{ postcard.country or '-' }}</dd>
|
||||||
<dt>{{ 'pc_detail.receive_time'|t(user.language) }}</dt><dd>{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard.receive_time else '-' }}</dd>
|
<dt>{{ 'pc_detail.receive_time'|t(user.language) }}</dt><dd>{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard.receive_time else '-' }}</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<dt>{{ 'pc_detail.profile'|t(user.language) }}</dt><dd>{{ postcard.profile.nickname }}</dd>
|
<dt>{{ 'pc_detail.profile'|t(user.language) }}</dt><dd>{{ postcard.profile.nickname }}</dd>
|
||||||
|
|||||||
@@ -18,8 +18,6 @@
|
|||||||
<div id="fields-sent-delivered">
|
<div id="fields-sent-delivered">
|
||||||
<label>{{ 'pc_form.recipient'|t(user.language) }} *</label>
|
<label>{{ 'pc_form.recipient'|t(user.language) }} *</label>
|
||||||
<input type="text" name="recipient_name" id="input-recipient" value="{{ postcard.recipient_name if postcard else '' }}">
|
<input type="text" name="recipient_name" id="input-recipient" value="{{ postcard.recipient_name if postcard else '' }}">
|
||||||
<label>{{ 'pc_form.country'|t(user.language) }} *</label>
|
|
||||||
<input type="text" name="country" id="input-country" value="{{ postcard.country if postcard else '' }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
|
|
||||||
<label>{{ 'pc_form.send_time'|t(user.language) }} *</label>
|
<label>{{ 'pc_form.send_time'|t(user.language) }} *</label>
|
||||||
<input type="date" name="send_time" id="input-send-time" value="{{ postcard.send_time.strftime('%Y-%m-%d') if postcard and postcard.send_time else '' }}">
|
<input type="date" name="send_time" id="input-send-time" value="{{ postcard.send_time.strftime('%Y-%m-%d') if postcard and postcard.send_time else '' }}">
|
||||||
<label id="label-arrival">{{ 'pc_form.arrival_time'|t(user.language) }}</label>
|
<label id="label-arrival">{{ 'pc_form.arrival_time'|t(user.language) }}</label>
|
||||||
@@ -33,6 +31,12 @@
|
|||||||
<input type="date" name="receive_time" id="input-receive" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard and postcard.receive_time else '' }}">
|
<input type="date" name="receive_time" id="input-receive" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard and postcard.receive_time else '' }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label>{{ 'pc_form.country_from'|t(user.language) }}</label>
|
||||||
|
<input type="text" name="country_from" id="input-country-from" value="{{ postcard.country_from if postcard and postcard.country_from else (profile.country if profile else '') }}" maxlength="2" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
|
||||||
|
|
||||||
|
<label>{{ 'pc_form.country'|t(user.language) }} *</label>
|
||||||
|
<input type="text" name="country" id="input-country" value="{{ postcard.country if postcard else '' }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
|
||||||
|
|
||||||
<label>{{ 'pc_form.notes'|t(user.language) }}</label>
|
<label>{{ 'pc_form.notes'|t(user.language) }}</label>
|
||||||
<textarea name="notes" rows="3" placeholder="{{ 'pc_form.notes_ph'|t(user.language) }}">{{ postcard.notes if postcard else '' }}</textarea>
|
<textarea name="notes" rows="3" placeholder="{{ 'pc_form.notes_ph'|t(user.language) }}">{{ postcard.notes if postcard else '' }}</textarea>
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<div class="postcard-row-info">
|
<div class="postcard-row-info">
|
||||||
<strong>{{ pc.card_number }}</strong>
|
<strong>{{ pc.card_number }}</strong>
|
||||||
<div class="postcard-row-detail">
|
<div class="postcard-row-detail">
|
||||||
<span>{{ pc.country|flag }} {{ pc.country or '-' }}</span>
|
<span>{% if pc.country_from %}{{ pc.country_from|flag }}→{% endif %}{{ pc.country|flag }} {{ pc.country or '-' }}</span>
|
||||||
<span>→ {{ pc.recipient_name or '-' }}</span>
|
<span>→ {{ pc.recipient_name or '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达'}.get(pc.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered'}.get(pc.status) }}</span>
|
<span class="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达'}.get(pc.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered'}.get(pc.status) }}</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -16,7 +16,20 @@
|
|||||||
<h1>📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1>
|
<h1>📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% set has_content = (sent_all|length + received_all|length) > 0 %}
|
{% set sent_visible = [] %}
|
||||||
|
{% set received_visible = [] %}
|
||||||
|
{% for pc in sent_all %}
|
||||||
|
{% if pc.image_front %}
|
||||||
|
{% if sent_visible.append(pc) %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for pc in received_all %}
|
||||||
|
{% if pc.image_front %}
|
||||||
|
{% if received_visible.append(pc) %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% set has_content = (sent_visible|length + received_visible|length) > 0 %}
|
||||||
{% if not has_content %}
|
{% if not has_content %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>{{ 'showcase.empty'|t(lang) }}</p>
|
<p>{{ 'showcase.empty'|t(lang) }}</p>
|
||||||
@@ -24,25 +37,21 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
<div class="tabs" id="tabs">
|
<div class="tabs" id="tabs">
|
||||||
<button class="tab active" onclick="switchTab('sent')">{{ 'showcase.tab_sent'|t(lang) }} ({{ sent_all|length }})</button>
|
<button class="tab active" onclick="switchTab('sent')">{{ 'showcase.tab_sent'|t(lang) }} ({{ sent_visible|length }})</button>
|
||||||
<button class="tab" onclick="switchTab('received')">{{ 'showcase.tab_received'|t(lang) }} ({{ received_all|length }})</button>
|
<button class="tab" onclick="switchTab('received')">{{ 'showcase.tab_received'|t(lang) }} ({{ received_visible|length }})</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-sent" class="tab-panel">
|
<div id="tab-sent" class="tab-panel">
|
||||||
{% if mode == 'flat' %}
|
{% if mode == 'flat' %}
|
||||||
<div class="showcase-grid">
|
<div class="showcase-grid" id="grid-sent">
|
||||||
{% for pc in sent_all %}
|
{% for pc in sent_visible %}
|
||||||
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
|
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
|
||||||
{% if pc.image_front %}
|
|
||||||
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
||||||
{% else %}
|
|
||||||
<div class="showcase-card-img placeholder-img">📷</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="showcase-card-body">
|
<div class="showcase-card-body">
|
||||||
<strong>{{ pc.card_number }}</strong>
|
|
||||||
<div class="showcase-card-meta">
|
<div class="showcase-card-meta">
|
||||||
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
{% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}→{{ pc.country|flag }}</span>
|
||||||
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
|
{% elif pc.country %}<span>{{ pc.country|flag }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,21 +59,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for g in profile_groups %}
|
{% for g in profile_groups %}
|
||||||
{% if g.sent %}
|
{% set g_sent = [] %}
|
||||||
|
{% for pc in g.sent %}
|
||||||
|
{% if pc.image_front %}
|
||||||
|
{% if g_sent.append(pc) %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if g_sent %}
|
||||||
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
|
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
|
||||||
<div class="showcase-grid">
|
<div class="showcase-grid">
|
||||||
{% for pc in g.sent %}
|
{% for pc in g_sent %}
|
||||||
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
|
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
|
||||||
{% if pc.image_front %}
|
|
||||||
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
||||||
{% else %}
|
|
||||||
<div class="showcase-card-img placeholder-img">📷</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="showcase-card-body">
|
<div class="showcase-card-body">
|
||||||
<strong>{{ pc.card_number }}</strong>
|
|
||||||
<div class="showcase-card-meta">
|
<div class="showcase-card-meta">
|
||||||
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
{% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}→{{ pc.country|flag }}</span>
|
||||||
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
|
{% elif pc.country %}<span>{{ pc.country|flag }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,23 +84,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div id="scroll-sentinel-sent" style="height:1px"></div>
|
||||||
|
<div id="scroll-loading-sent" style="text-align:center;padding:1rem;color:var(--text-muted);display:none">⏳</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-received" class="tab-panel" style="display:none">
|
<div id="tab-received" class="tab-panel" style="display:none">
|
||||||
{% if mode == 'flat' %}
|
{% if mode == 'flat' %}
|
||||||
<div class="showcase-grid">
|
<div class="showcase-grid" id="grid-received">
|
||||||
{% for pc in received_all %}
|
{% for pc in received_visible %}
|
||||||
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
|
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
|
||||||
{% if pc.image_front %}
|
|
||||||
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
||||||
{% else %}
|
|
||||||
<div class="showcase-card-img placeholder-img">📷</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="showcase-card-body">
|
<div class="showcase-card-body">
|
||||||
<strong>{{ pc.card_number }}</strong>
|
|
||||||
<div class="showcase-card-meta">
|
<div class="showcase-card-meta">
|
||||||
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
{% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}→{{ pc.country|flag }}</span>
|
||||||
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %}
|
{% elif pc.country %}<span>{{ pc.country|flag }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,21 +106,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for g in profile_groups %}
|
{% for g in profile_groups %}
|
||||||
{% if g.received %}
|
{% set g_received = [] %}
|
||||||
|
{% for pc in g.received %}
|
||||||
|
{% if pc.image_front %}
|
||||||
|
{% if g_received.append(pc) %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if g_received %}
|
||||||
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
|
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
|
||||||
<div class="showcase-grid">
|
<div class="showcase-grid">
|
||||||
{% for pc in g.received %}
|
{% for pc in g_received %}
|
||||||
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
|
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
|
||||||
{% if pc.image_front %}
|
|
||||||
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
||||||
{% else %}
|
|
||||||
<div class="showcase-card-img placeholder-img">📷</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="showcase-card-body">
|
<div class="showcase-card-body">
|
||||||
<strong>{{ pc.card_number }}</strong>
|
|
||||||
<div class="showcase-card-meta">
|
<div class="showcase-card-meta">
|
||||||
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
{% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}→{{ pc.country|flag }}</span>
|
||||||
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %}
|
{% elif pc.country %}<span>{{ pc.country|flag }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,20 +131,88 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div id="scroll-sentinel-received" style="height:1px"></div>
|
||||||
|
<div id="scroll-loading-received" style="text-align:center;padding:1rem;color:var(--text-muted);display:none">⏳</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div id="lightbox" class="lightbox" onclick="closeLightbox(event)">
|
<div id="lightbox" class="lightbox" onclick="closeLightbox(event)">
|
||||||
<button class="lightbox-close" onclick="closeLightbox(event)">×</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()">
|
<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="">
|
<img id="lightbox-img" src="" alt="">
|
||||||
<div id="lightbox-caption" class="lightbox-caption"></div>
|
<div id="lightbox-caption" class="lightbox-caption"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
var _username = '{{ profile_user.username }}';
|
||||||
|
var _sentPage = 1, _sentMore = {{ 'true' if sent_visible|length > 19 else 'false' }};
|
||||||
|
var _recvPage = 1, _recvMore = {{ 'true' if received_visible|length > 19 else 'false' }};
|
||||||
|
var _sentLoading = false, _recvLoading = false;
|
||||||
|
|
||||||
|
function _flag(code) {
|
||||||
|
if (!code || code.length !== 2) return code || '';
|
||||||
|
return String.fromCodePoint(0x1F1E6 + code.charCodeAt(0) - 65) + String.fromCodePoint(0x1F1E6 + code.charCodeAt(1) - 65);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _appendCard(c, gridId) {
|
||||||
|
var grid = document.getElementById(gridId);
|
||||||
|
if (!grid) return;
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'showcase-card';
|
||||||
|
div.setAttribute('data-front', c.image_front);
|
||||||
|
div.onclick = function() { openLightbox(div); };
|
||||||
|
var countryHtml = '';
|
||||||
|
if (c.country_from && c.country) countryHtml = '<span>' + _flag(c.country_from) + '→' + _flag(c.country) + '</span>';
|
||||||
|
else if (c.country) countryHtml = '<span>' + _flag(c.country) + '</span>';
|
||||||
|
div.innerHTML = '<img src="' + c.image_front + '" alt="" class="showcase-card-img" loading="lazy">' +
|
||||||
|
'<div class="showcase-card-body"><div class="showcase-card-meta">' + countryHtml + '</div></div>';
|
||||||
|
grid.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadMoreSent() {
|
||||||
|
if (_sentLoading || !_sentMore) return;
|
||||||
|
_sentLoading = true;
|
||||||
|
document.getElementById('scroll-loading-sent').style.display = '';
|
||||||
|
fetch('/api/public/cards/' + _username + '?page=' + (_sentPage + 1) + '&limit=20&tab=sent')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
_sentLoading = false;
|
||||||
|
document.getElementById('scroll-loading-sent').style.display = 'none';
|
||||||
|
_sentPage = data.page;
|
||||||
|
_sentMore = data.has_more;
|
||||||
|
data.cards.forEach(function(c) { _appendCard(c, 'grid-sent'); });
|
||||||
|
if (!_sentMore) { var s = document.getElementById('scroll-sentinel-sent'); if (s) s.remove(); }
|
||||||
|
})
|
||||||
|
.catch(function() { _sentLoading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadMoreReceived() {
|
||||||
|
if (_recvLoading || !_recvMore) return;
|
||||||
|
_recvLoading = true;
|
||||||
|
document.getElementById('scroll-loading-received').style.display = '';
|
||||||
|
fetch('/api/public/cards/' + _username + '?page=' + (_recvPage + 1) + '&limit=20&tab=received')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
_recvLoading = false;
|
||||||
|
document.getElementById('scroll-loading-received').style.display = 'none';
|
||||||
|
_recvPage = data.page;
|
||||||
|
_recvMore = data.has_more;
|
||||||
|
data.cards.forEach(function(c) { _appendCard(c, 'grid-received'); });
|
||||||
|
if (!_recvMore) { var s = document.getElementById('scroll-sentinel-received'); if (s) s.remove(); }
|
||||||
|
})
|
||||||
|
.catch(function() { _recvLoading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
var _obsSent = new IntersectionObserver(function(e) { if (e[0].isIntersecting) _loadMoreSent(); }, {rootMargin: '400px'});
|
||||||
|
var _obsRecv = new IntersectionObserver(function(e) { if (e[0].isIntersecting) _loadMoreReceived(); }, {rootMargin: '400px'});
|
||||||
|
var _sSent = document.getElementById('scroll-sentinel-sent');
|
||||||
|
var _sRecv = document.getElementById('scroll-sentinel-received');
|
||||||
|
if (_sSent) _obsSent.observe(_sSent);
|
||||||
|
if (_sRecv) _obsRecv.observe(_sRecv);
|
||||||
|
|
||||||
function switchTab(name) {
|
function switchTab(name) {
|
||||||
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
|
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
|
||||||
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
|
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
|
||||||
@@ -149,8 +228,7 @@ function buildLightboxItems() {
|
|||||||
var visible = document.querySelectorAll('.tab-panel:not([style*="display:none"]) .showcase-card[data-front]');
|
var visible = document.querySelectorAll('.tab-panel:not([style*="display:none"]) .showcase-card[data-front]');
|
||||||
visible.forEach(function(card) {
|
visible.forEach(function(card) {
|
||||||
var front = card.getAttribute('data-front');
|
var front = card.getAttribute('data-front');
|
||||||
var number = card.getAttribute('data-number');
|
if (front) lightboxItems.push({src: front});
|
||||||
if (front) lightboxItems.push({src: front, number: number});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +236,8 @@ function openLightbox(el) {
|
|||||||
buildLightboxItems();
|
buildLightboxItems();
|
||||||
var front = el.getAttribute('data-front');
|
var front = el.getAttribute('data-front');
|
||||||
if (!front) return;
|
if (!front) return;
|
||||||
var number = el.getAttribute('data-number');
|
|
||||||
for (var i = 0; i < lightboxItems.length; i++) {
|
for (var i = 0; i < lightboxItems.length; i++) {
|
||||||
if (lightboxItems[i].number === number) { lightboxIdx = i; break; }
|
if (lightboxItems[i].src === front) { lightboxIdx = i; break; }
|
||||||
}
|
}
|
||||||
showLightboxItem();
|
showLightboxItem();
|
||||||
document.getElementById('lightbox').classList.add('active');
|
document.getElementById('lightbox').classList.add('active');
|
||||||
@@ -171,7 +248,7 @@ function showLightboxItem() {
|
|||||||
if (!lightboxItems.length) return;
|
if (!lightboxItems.length) return;
|
||||||
var item = lightboxItems[lightboxIdx];
|
var item = lightboxItems[lightboxIdx];
|
||||||
document.getElementById('lightbox-img').src = item.src;
|
document.getElementById('lightbox-img').src = item.src;
|
||||||
document.getElementById('lightbox-caption').textContent = item.number + ' (' + (lightboxIdx+1) + '/' + lightboxItems.length + ')';
|
document.getElementById('lightbox-caption').textContent = (lightboxIdx+1) + '/' + lightboxItems.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeLightbox(e) {
|
function closeLightbox(e) {
|
||||||
@@ -194,13 +271,10 @@ document.addEventListener('keydown', function(e) {
|
|||||||
else if (e.key === 'ArrowLeft') navLightbox(-1);
|
else if (e.key === 'ArrowLeft') navLightbox(-1);
|
||||||
else if (e.key === 'ArrowRight') navLightbox(1);
|
else if (e.key === 'ArrowRight') navLightbox(1);
|
||||||
});
|
});
|
||||||
// Close language dropdown when clicking outside
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
var sw = document.getElementById('langSwitcher');
|
var sw = document.getElementById('langSwitcher');
|
||||||
var dd = document.getElementById('langDropdown');
|
var dd = document.getElementById('langDropdown');
|
||||||
if (sw && dd && !sw.contains(e.target)) {
|
if (sw && dd && !sw.contains(e.target)) dd.classList.remove('show');
|
||||||
dd.classList.remove('show');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user