feat(ui): add landing page with random public postcards for unauthenticated visitors

This commit is contained in:
2026-07-07 12:05:44 +08:00
parent ba56fc6e6b
commit bd664d23be
5 changed files with 127 additions and 3 deletions

View File

@@ -3,6 +3,10 @@
"name": "English" "name": "English"
}, },
"brand": "📮 Mailova", "brand": "📮 Mailova",
"index.title": "Mailova",
"index.subtitle": "Postcards from around the world",
"index.go_login": "Login",
"index.empty": "No public postcards yet",
"nav.profiles": "Profiles", "nav.profiles": "Profiles",
"nav.settings": "Settings", "nav.settings": "Settings",
"nav.invites": "Invites", "nav.invites": "Invites",

View File

@@ -3,6 +3,10 @@
"name": "中文" "name": "中文"
}, },
"brand": "📮 星笺", "brand": "📮 星笺",
"index.title": "星笺",
"index.subtitle": "来自世界各地的明信片",
"index.go_login": "登录",
"index.empty": "暂无公开明信片",
"nav.profiles": "Profile", "nav.profiles": "Profile",
"nav.settings": "设置", "nav.settings": "设置",
"nav.invites": "邀请码管理", "nav.invites": "邀请码管理",

View File

@@ -1,5 +1,6 @@
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from pathlib import Path from pathlib import Path
from random import shuffle
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
@@ -17,12 +18,11 @@ from app.auth import (
) )
from app.config import SECRET_KEY, SESSION_COOKIE_NAME, UPLOAD_DIR from app.config import SECRET_KEY, SESSION_COOKIE_NAME, UPLOAD_DIR
from app.database import get_db from app.database import get_db
from datetime import datetime
from random import choice from random import choice
from string import ascii_letters, digits from string import ascii_letters, digits
from app.models import InviteCode, Postcard, Profile, User from app.models import InviteCode, Postcard, Profile, User
from app.i18n import t as _t from app.i18n import t as _t, get_languages as _get_languages
router = APIRouter(tags=["web"]) router = APIRouter(tags=["web"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
@@ -109,6 +109,48 @@ def _redirect(url: str) -> RedirectResponse:
return RedirectResponse(url, status_code=303) return RedirectResponse(url, status_code=303)
# ---------- Root / Index ----------
@router.get("/", response_class=HTMLResponse)
def index_page(request: Request, db: Session = Depends(get_db)):
user = redirect_if_not_logged_in(request, db)
if user:
return _redirect("/dashboard")
# Collect all public postcards from all users with showcase enabled
all_postcards: list[Postcard] = []
users = db.query(User).filter(User.showcase_mode.isnot(None)).all()
for u in users:
profiles = db.query(Profile).filter(
Profile.user_id == u.id, Profile.showcase_enabled == True
).all()
for p in profiles:
cards = db.query(Postcard).filter(
Postcard.profile_id == p.id, Postcard.showcase_hidden == False
).all()
for c in cards:
visible = True
if c.status in ("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, "lang": lang,
})
# ---------- Auth ---------- # ---------- Auth ----------
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)

View File

@@ -9,7 +9,7 @@
<body> <body>
{% block nav %} {% block nav %}
<nav class="navbar"> <nav class="navbar">
<a href="/dashboard" class="nav-brand">{{ 'brand'|t(user.language if user else 'zh') }}</a> <a href="/" class="nav-brand">{{ 'brand'|t(user.language if user else 'zh') }}</a>
{% if user is defined and user %} {% if user is defined and user %}
<button class="nav-hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')"></button> <button class="nav-hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')"></button>
<div class="nav-links"> <div class="nav-links">

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

@@ -0,0 +1,74 @@
{% 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-primary" style="margin-left:.5rem">{{ 'index.go_login'|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>
{% if postcards %}
<div class="showcase-grid">
{% for pc in postcards %}
<div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% endif %}
<div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% 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-inner" onclick="event.stopPropagation()">
<button class="lightbox-close" onclick="closeLightbox()">&times;</button>
<img id="lightbox-img" src="" alt="">
<div id="lightbox-number" class="lightbox-number"></div>
</div>
</div>
<script>
function openLightbox(el) {
var src = el.getAttribute('data-front');
if (!src) return;
document.getElementById('lightbox-img').src = src;
document.getElementById('lightbox-number').textContent = el.getAttribute('data-number');
document.getElementById('lightbox').style.display = 'flex';
}
function closeLightbox() {
document.getElementById('lightbox').style.display = 'none';
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeLightbox();
});
</script>
{% endblock %}