feat(ui): add landing page with random public postcards for unauthenticated visitors
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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": "邀请码管理",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
74
app/templates/index.html
Normal 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()">×</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 %}
|
||||||
Reference in New Issue
Block a user