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:
@@ -12,7 +12,8 @@
|
||||
{% endfor %}
|
||||
</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>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
@@ -22,25 +23,30 @@
|
||||
<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 %}
|
||||
{% 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">
|
||||
{% 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 %}
|
||||
{% if pc.country_from and pc.country %}<span>{{ pc.country_from|flag }}→{{ pc.country|flag }}</span>
|
||||
{% elif pc.country %}<span>{{ pc.country|flag }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</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 %}
|
||||
<div class="empty-state">
|
||||
<p>{{ 'index.empty'|t(lang) }}</p>
|
||||
@@ -49,26 +55,116 @@
|
||||
|
||||
<!-- Lightbox -->
|
||||
<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-prev" onclick="navLightbox(-1)">‹</button>
|
||||
<button class="lightbox-next" onclick="navLightbox(1)">›</button>
|
||||
<img id="lightbox-img" src="" alt="">
|
||||
<div id="lightbox-number" class="lightbox-number"></div>
|
||||
<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) 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) {
|
||||
buildLightboxItems();
|
||||
var src = el.getAttribute('data-front');
|
||||
if (!src) return;
|
||||
document.getElementById('lightbox-img').src = src;
|
||||
document.getElementById('lightbox-number').textContent = el.getAttribute('data-number');
|
||||
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 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.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 %}
|
||||
|
||||
Reference in New Issue
Block a user