mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Update UI
This commit is contained in:
+54
-27
@@ -325,6 +325,17 @@ def get_random_image_hierarchical(base_path: Path) -> Optional[Path]:
|
||||
|
||||
Only selects from subfolders, excludes root directory.
|
||||
Assumes flat structure within subfolders (no nested subdirectories).
|
||||
|
||||
Robustness notes:
|
||||
- Candidates are validated during the scan (not after picking), so a folder
|
||||
whose first few files are invalid (tiny/corrupt/oversized) doesn't go
|
||||
permanently dark — directory iteration order is stable, so validating
|
||||
only the picked candidate used to fail the same way on every attempt.
|
||||
- If a full pass finds nothing because every valid candidate is in the
|
||||
recently-served queue, a second pass ignores the queue. Recency
|
||||
avoidance is best-effort and must never cause a hard 404: the queue only
|
||||
rotates on success, so without this fallback a run of failures froze the
|
||||
queue and the API degraded until process restart.
|
||||
"""
|
||||
try:
|
||||
# Get only subdirectories (exclude root directory)
|
||||
@@ -337,45 +348,61 @@ def get_random_image_hierarchical(base_path: Path) -> Optional[Path]:
|
||||
logger.warning("No subfolders found in images directory")
|
||||
return None
|
||||
|
||||
# Try up to 20 random directories
|
||||
for attempt in range(min(20, len(directories))):
|
||||
random_dir = random.choice(directories)
|
||||
for ignore_recent in (False, True):
|
||||
# Try up to 20 random directories per pass
|
||||
for attempt in range(min(20, len(directories))):
|
||||
random_dir = random.choice(directories)
|
||||
|
||||
# Quick check if directory has images (flat structure only)
|
||||
image_files = []
|
||||
file_count = 0
|
||||
# Quick check if directory has images (flat structure only)
|
||||
image_files = []
|
||||
file_count = 0
|
||||
|
||||
for item in random_dir.iterdir():
|
||||
# Skip subdirectories - only process files
|
||||
if not item.is_file():
|
||||
continue
|
||||
for item in random_dir.iterdir():
|
||||
# Skip subdirectories - only process files
|
||||
if not item.is_file():
|
||||
continue
|
||||
|
||||
file_count += 1
|
||||
if file_count > MAX_DIRECTORY_SCAN:
|
||||
logger.warning(f"Directory {random_dir.name} has too many files, skipping")
|
||||
break
|
||||
|
||||
if item.suffix.lower() in SUPPORTED_FORMATS:
|
||||
# Skip recently served images
|
||||
with recent_images_lock:
|
||||
if item.name not in recent_images_queue:
|
||||
image_files.append(item)
|
||||
|
||||
# Early termination: if we found some images, we can select randomly
|
||||
if len(image_files) >= 5:
|
||||
file_count += 1
|
||||
if file_count > MAX_DIRECTORY_SCAN:
|
||||
logger.warning(f"Directory {random_dir.name} has too many files, skipping")
|
||||
break
|
||||
|
||||
if image_files:
|
||||
selected_file = random.choice(image_files)
|
||||
if is_valid_image_fast(selected_file):
|
||||
if item.suffix.lower() in SUPPORTED_FORMATS:
|
||||
# Skip recently served images (unless we're on the
|
||||
# fallback pass that ignores the recency queue)
|
||||
if not ignore_recent:
|
||||
with recent_images_lock:
|
||||
if item.name in recent_images_queue:
|
||||
continue
|
||||
|
||||
# Keep only candidates that pass validation, so an
|
||||
# unlucky pick can't waste the whole attempt
|
||||
if is_valid_image_fast(item):
|
||||
image_files.append(item)
|
||||
|
||||
# Early termination: if we found some images, we can select randomly
|
||||
if len(image_files) >= 5:
|
||||
break
|
||||
|
||||
if image_files:
|
||||
selected_file = random.choice(image_files)
|
||||
# Add to recent images queue
|
||||
with recent_images_lock:
|
||||
recent_images_queue.append(selected_file.name)
|
||||
|
||||
logger.debug(f"Hierarchical selection: folder='{random_dir.name}', "
|
||||
f"file='{selected_file.name}', candidates={len(image_files)}")
|
||||
f"file='{selected_file.name}', candidates={len(image_files)}, "
|
||||
f"ignore_recent={ignore_recent}")
|
||||
return selected_file
|
||||
|
||||
if ignore_recent:
|
||||
break
|
||||
with recent_images_lock:
|
||||
had_recent = len(recent_images_queue) > 0
|
||||
if not had_recent:
|
||||
break
|
||||
logger.info("First pass found no non-recent images; retrying without the recency filter")
|
||||
|
||||
logger.warning("No valid images found in any subfolder after 20 attempts")
|
||||
return None
|
||||
|
||||
|
||||
@@ -56,19 +56,22 @@
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.app-tile, .post-card, .fan-card, .fan-card img, .fan-card-frame, .fan-lightbox { transition: none !important; animation: none !important; }
|
||||
.app-chip, .post-row, .fan-card, .fan-card img, .fan-card-frame, .fan-lightbox { transition: none !important; animation: none !important; }
|
||||
}
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
/* ---------- Hero (compact: search + stats share one row on desktop) ---------- */
|
||||
.apple-hero {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: clamp(2.75rem, 8vh, 5.5rem) 1rem clamp(2.5rem, 6vh, 4rem);
|
||||
padding: clamp(1.5rem, 4vh, 2.5rem) 1rem clamp(1.25rem, 3vh, 1.75rem);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.apple-hero { display: flex; align-items: center; gap: 2.25rem; text-align: left; }
|
||||
}
|
||||
.hero-glow {
|
||||
position: absolute;
|
||||
inset: -12% -6% auto;
|
||||
height: 460px;
|
||||
height: 340px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
@@ -100,7 +103,10 @@
|
||||
}
|
||||
|
||||
/* ---------- Hero search (frosted glass pill) ---------- */
|
||||
.hero-search { position: relative; max-width: 40rem; margin: 2.6rem auto 0; text-align: left; }
|
||||
.hero-search { position: relative; width: 100%; max-width: 44rem; margin: 0 auto; text-align: left; }
|
||||
@media (min-width: 768px) {
|
||||
.hero-search { flex: 1 1 auto; margin: 0; }
|
||||
}
|
||||
/* Must stack above .hero-stats (which shares the same z-index: 1 from the
|
||||
">*:not(.hero-glow)" rule above) so the search results dropdown never
|
||||
renders underneath the stats row. */
|
||||
@@ -110,7 +116,7 @@
|
||||
width: 1.25rem; height: 1.25rem; color: var(--apple-gray); pointer-events: none;
|
||||
}
|
||||
.hero-search input {
|
||||
width: 100%; height: 3.8rem; padding: 0 1.6rem 0 3.5rem;
|
||||
width: 100%; height: 3.4rem; padding: 0 1.6rem 0 3.5rem;
|
||||
font-size: 1.08rem; color: var(--apple-text);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
@@ -154,14 +160,17 @@
|
||||
.search-empty { padding: 1.1rem; color: var(--apple-gray); text-align: center; font-size: 0.95rem; }
|
||||
|
||||
/* ---------- Hero stats ---------- */
|
||||
.hero-stats { display: flex; align-items: center; justify-content: center; gap: 1.8rem; margin-top: 2.5rem; }
|
||||
.hero-stat { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; }
|
||||
.hero-stat-number { font-size: 1.65rem; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||
.hero-stat-label { font-size: 0.76rem; color: var(--apple-gray); text-transform: uppercase; letter-spacing: 0.09em; font-weight: 500; }
|
||||
.hero-stat-divider { width: 1px; height: 2.3rem; background: rgba(0, 0, 0, 0.10); }
|
||||
.hero-stats { display: flex; align-items: center; justify-content: center; gap: 1.4rem; margin-top: 1.1rem; }
|
||||
@media (min-width: 768px) {
|
||||
.hero-stats { margin-top: 0; flex: none; padding-right: 0.35rem; }
|
||||
}
|
||||
.hero-stat { display: flex; flex-direction: column; align-items: center; gap: 0.1rem; }
|
||||
.hero-stat-number { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||
.hero-stat-label { font-size: 0.7rem; color: var(--apple-gray); text-transform: uppercase; letter-spacing: 0.09em; font-weight: 500; }
|
||||
.hero-stat-divider { width: 1px; height: 2rem; background: rgba(0, 0, 0, 0.10); }
|
||||
|
||||
/* ---------- Sections, cards, buttons ---------- */
|
||||
.home-section { margin-top: clamp(2.5rem, 6vh, 4.5rem); }
|
||||
.home-section { margin-top: clamp(1.5rem, 3.5vh, 2.5rem); }
|
||||
.apple-card {
|
||||
background: #fff;
|
||||
border-radius: var(--apple-radius-lg);
|
||||
@@ -276,62 +285,96 @@
|
||||
.apple-btn-secondary:hover { background: #ececef; }
|
||||
.apple-btn-secondary[disabled] { opacity: 0.55; cursor: default; pointer-events: none; }
|
||||
|
||||
/* ---------- Posts ---------- */
|
||||
.posts-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1.1rem; list-style: none; margin: 0; padding: 0; }
|
||||
.post-card {
|
||||
background: #fff; border-radius: 24px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
box-shadow: var(--apple-shadow-card);
|
||||
transition: transform 0.45s var(--apple-ease), box-shadow 0.45s var(--apple-ease);
|
||||
/* ---------- First-screen dashboard (posts + images side by side) ---------- */
|
||||
.dash-grid {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
.post-card:hover { transform: translateY(-5px); box-shadow: var(--apple-shadow-lift); }
|
||||
.post-card-link { display: flex; flex-direction: column; gap: 0.55rem; height: 100%; padding: 1.5rem 1.5rem 1.4rem; color: inherit; text-decoration: none; }
|
||||
.post-card-date { font-size: 0.78rem; color: var(--apple-gray); font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.post-card-title {
|
||||
font-size: 1.13rem; font-weight: 700; letter-spacing: -0.012em; line-height: 1.32;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
@media (min-width: 1024px) {
|
||||
.dash-grid { grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); }
|
||||
}
|
||||
.post-card-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: auto; padding-top: 0.4rem; }
|
||||
.tag-pill { font-size: 0.75rem; font-weight: 500; color: var(--apple-gray); background: rgba(0, 0, 0, 0.05); padding: 0.22rem 0.65rem; border-radius: 980px; }
|
||||
.dash-rail { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
||||
|
||||
/* ---------- Latest Posts (dense list) ---------- */
|
||||
.posts-panel { overflow: hidden; }
|
||||
.posts-panel-header {
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: 1rem;
|
||||
padding: 1.35rem 1.5rem 0.8rem;
|
||||
}
|
||||
.post-list { list-style: none; margin: 0; padding: 0.15rem 0.5rem 0.6rem; }
|
||||
.post-row {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center; gap: 0.9rem;
|
||||
padding: 0.72rem 1rem; border-radius: 16px;
|
||||
color: inherit; text-decoration: none;
|
||||
transition: background 0.25s ease;
|
||||
}
|
||||
.post-row:hover { background: rgba(0, 0, 0, 0.035); }
|
||||
.post-row-date {
|
||||
font-size: 0.78rem; font-weight: 600; color: var(--apple-gray);
|
||||
text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap;
|
||||
}
|
||||
.post-row-main { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
|
||||
.post-row-title {
|
||||
font-size: 0.98rem; font-weight: 600; letter-spacing: -0.01em;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.post-row-summary {
|
||||
font-size: 0.82rem; color: var(--apple-gray);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.post-row-tags { display: flex; gap: 0.35rem; }
|
||||
.post-row-chev { width: 0.9rem; height: 0.9rem; color: #c7c7cc; flex: none; transition: transform 0.3s var(--apple-ease); }
|
||||
.post-row:hover .post-row-chev { transform: translateX(2px); }
|
||||
.tag-pill { font-size: 0.75rem; font-weight: 500; color: var(--apple-gray); background: rgba(0, 0, 0, 0.05); padding: 0.22rem 0.65rem; border-radius: 980px; white-space: nowrap; }
|
||||
|
||||
.empty-state { padding: 2.75rem 1.5rem; text-align: center; color: var(--apple-gray); display: flex; flex-direction: column; align-items: center; gap: 1rem; }
|
||||
.empty-state svg { width: 2.6rem; height: 2.6rem; color: #c7c7cc; }
|
||||
|
||||
/* ---------- Bento tiles (mini apps) ---------- */
|
||||
.tile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 1.1rem; }
|
||||
.app-tile {
|
||||
/* ---------- Mini app chips (compact gradients under the photo fan) ---------- */
|
||||
.app-chips { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
|
||||
.app-chip {
|
||||
position: relative; isolation: isolate; overflow: hidden;
|
||||
display: flex; flex-direction: column; justify-content: flex-end;
|
||||
min-height: 200px; padding: 1.7rem; border-radius: var(--apple-radius-lg);
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
|
||||
min-height: 68px; padding: 0.9rem 1.15rem; border-radius: var(--apple-radius-md);
|
||||
color: #fff; text-decoration: none; box-shadow: var(--apple-shadow-card);
|
||||
transition: transform 0.45s var(--apple-ease), box-shadow 0.45s var(--apple-ease);
|
||||
}
|
||||
.app-tile:hover { transform: translateY(-5px); box-shadow: var(--apple-shadow-lift); color: #fff; }
|
||||
.app-tile::before {
|
||||
.app-chip:hover { transform: translateY(-3px); box-shadow: var(--apple-shadow-lift); color: #fff; }
|
||||
.app-chip::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: -1;
|
||||
background: radial-gradient(120% 120% at 85% 0%, rgba(255, 255, 255, 0.30) 0%, rgba(255, 255, 255, 0) 46%);
|
||||
}
|
||||
.tile-arrow {
|
||||
position: absolute; top: 1.15rem; right: 1.15rem; width: 34px; height: 34px;
|
||||
border-radius: 50%; background: rgba(255, 255, 255, 0.22);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.3s ease, transform 0.35s var(--apple-ease);
|
||||
.app-chip > svg { width: 1rem; height: 1rem; flex: none; opacity: 0.9; transition: transform 0.35s var(--apple-ease); }
|
||||
.app-chip:hover > svg { transform: translate(2px, -2px); }
|
||||
.app-chip-text { display: flex; flex-direction: column; gap: 0.05rem; min-width: 0; }
|
||||
.app-chip-name { font-size: 1.02rem; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.app-chip-desc {
|
||||
font-size: 0.8rem; color: rgba(255, 255, 255, 0.85);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.tile-arrow svg { width: 1rem; height: 1rem; }
|
||||
.app-tile:hover .tile-arrow { background: rgba(255, 255, 255, 0.36); transform: translate(2px, -2px); }
|
||||
.app-tile-name { font-size: 1.45rem; font-weight: 700; letter-spacing: -0.015em; }
|
||||
.app-tile-desc { font-size: 0.95rem; color: rgba(255, 255, 255, 0.86); margin-top: 0.15rem; }
|
||||
.gradient-1 { background: linear-gradient(135deg, #0a84ff 0%, #5e5ce6 100%); }
|
||||
.gradient-2 { background: linear-gradient(135deg, #ff9f0a 0%, #ff375f 100%); }
|
||||
.gradient-3 { background: linear-gradient(135deg, #30d158 0%, #0a84ff 100%); }
|
||||
.gradient-4 { background: linear-gradient(135deg, #bf5af2 0%, #ff375f 100%); }
|
||||
|
||||
/* ---------- Photo fan (creative image deck) ---------- */
|
||||
.photo-fan-section { margin-top: clamp(2rem, 5vh, 3.5rem); }
|
||||
.fan-actions { display: flex; align-items: center; gap: 1.1rem; }
|
||||
.photo-fan {
|
||||
display: flex; justify-content: center; align-items: flex-end;
|
||||
padding: clamp(1.75rem, 4.5vw, 2.75rem) 0.5rem 1.25rem;
|
||||
padding: clamp(1.25rem, 3vw, 1.75rem) 0.5rem 0.75rem;
|
||||
}
|
||||
/* Fan lives in the dashboard's side rail on desktop: shrink the cards so
|
||||
all five fit the narrower column. When the grid stacks (< 1024px) the
|
||||
fan is full-width again and falls back to the original roomy sizes. */
|
||||
.rail-header { flex-wrap: wrap; margin-bottom: 0; padding: 0; }
|
||||
.dash-rail .fan-card { --w: clamp(120px, 11vw, 170px); }
|
||||
@media (max-width: 1023px) {
|
||||
.dash-rail .fan-card { --w: clamp(96px, 15.5vw, 172px); }
|
||||
.rail-header { padding: 0 0.35rem; margin-bottom: 0.5rem; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.dash-rail .fan-card { --w: clamp(84px, 21vw, 120px); }
|
||||
}
|
||||
.fan-card {
|
||||
--w: clamp(96px, 15.5vw, 172px);
|
||||
@@ -438,6 +481,11 @@
|
||||
.library-footer { padding: 1rem 1.1rem 1.25rem; }
|
||||
.apple-table thead th, .apple-table tbody td { padding-left: 0.8rem; padding-right: 0.8rem; }
|
||||
.hero-stats { gap: 1.25rem; }
|
||||
.posts-panel-header { padding: 1.15rem 1.1rem 0.6rem; }
|
||||
.post-row { gap: 0.65rem; padding: 0.68rem 0.75rem; }
|
||||
.post-row-tags { display: none; }
|
||||
.app-chip-desc { display: none; }
|
||||
.app-chip { min-height: 52px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -531,26 +579,77 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ Photo Fan (Random Images) ============ -->
|
||||
<section class="photo-fan-section" aria-labelledby="images-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="images-heading" class="section-title">{% trans "Random Images" %}</h2>
|
||||
<div class="fan-actions">
|
||||
<button type="button" id="shuffle-all-images" class="apple-btn apple-btn-secondary">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
{% trans "Shuffle all" %}
|
||||
</button>
|
||||
<a href="{% url 'mini-apps-image-gallery' %}" class="apple-link">
|
||||
{% trans "Open gallery" %}
|
||||
<!-- ============ First-screen dashboard: Latest Posts + Random Images ============ -->
|
||||
<section class="dash-grid home-section">
|
||||
<!-- Latest Posts (dense list) -->
|
||||
<div class="apple-card posts-panel reveal" aria-labelledby="posts-heading">
|
||||
<div class="posts-panel-header">
|
||||
<h2 id="posts-heading" class="section-title">{% trans "Latest Posts" %}</h2>
|
||||
<a href="{% url 'post-list' %}" class="apple-link">
|
||||
{% trans "View all posts" %}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{% if latest_posts %}
|
||||
<ul class="post-list" role="list">
|
||||
{% for post in latest_posts %}
|
||||
<li>
|
||||
<a href="{{ post.get_absolute_url }}" class="post-row">
|
||||
<span class="post-row-date">{{ post.created_at|date:"M d" }}</span>
|
||||
<span class="post-row-main">
|
||||
<span class="post-row-title">{{ post.title }}</span>
|
||||
{% if post.summary %}
|
||||
<span class="post-row-summary">{{ post.summary }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if post.tags.all %}
|
||||
<span class="post-row-tags">
|
||||
{% for tag in post.tags.all|slice:":2" %}
|
||||
<span class="tag-pill">{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<svg class="post-row-chev" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5a2 2 0 00-2-2h-2"/>
|
||||
</svg>
|
||||
<p>{% trans "No posts available yet" %}</p>
|
||||
<a href="{% url 'post-create' %}" class="apple-btn apple-btn-primary">{% trans "Create your first post" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="photo-fan">
|
||||
|
||||
<!-- Side rail: photo fan + mini app chips -->
|
||||
<div class="dash-rail">
|
||||
<section aria-labelledby="images-heading">
|
||||
<div class="section-header rail-header">
|
||||
<h2 id="images-heading" class="section-title">{% trans "Random Images" %}</h2>
|
||||
<div class="fan-actions">
|
||||
<button type="button" id="shuffle-all-images" class="apple-btn apple-btn-secondary">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
{% trans "Shuffle all" %}
|
||||
</button>
|
||||
<a href="{% url 'mini-apps-image-gallery' %}" class="apple-link">
|
||||
{% trans "Open gallery" %}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="photo-fan">
|
||||
<button type="button" class="fan-card" style="--r: -13deg; --fy: 18px; --z: 1; --d: 0; --tape: rgba(0, 113, 227, 0.22); --tape-r: -4deg;" onclick="openFanLightbox(this.querySelector('img'))" aria-label="{% trans 'View Full Size' %}">
|
||||
<span class="fan-card-frame">
|
||||
<img src="/api/images/random/600/800/?fit=crop&v={{ timestamp }}&box=fan1" data-box="fan1" alt="{% trans 'Random Image' %}" loading="lazy" onerror="handleRandomImageError(this)">
|
||||
@@ -601,8 +700,36 @@
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Mini app chips -->
|
||||
{% if mini_apps %}
|
||||
<div class="app-chips">
|
||||
{% for app in mini_apps %}
|
||||
{% if app.url != '#' %}
|
||||
<a href="{% url app.url %}" class="app-chip gradient-{% cycle '1' '2' '3' '4' %}" aria-label="{{ app.name }} — {{ app.description }}">
|
||||
<span class="app-chip-text">
|
||||
<span class="app-chip-name">{{ app.name }}</span>
|
||||
<span class="app-chip-desc">{{ app.description }}</span>
|
||||
</span>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 17L17 7M17 7H8M17 7v9"/>
|
||||
</svg>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="app-chip gradient-{% cycle '1' '2' '3' '4' %}">
|
||||
<span class="app-chip-text">
|
||||
<span class="app-chip-name">{{ app.name }}</span>
|
||||
<span class="app-chip-desc">{{ app.description }}</span>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div><!-- /.dash-rail -->
|
||||
</section><!-- /.dash-grid -->
|
||||
|
||||
<!-- ============ Fan Lightbox (full-size preview) ============ -->
|
||||
<div id="fan-lightbox" class="fan-lightbox" hidden role="dialog" aria-modal="true" aria-label="{% trans 'Random Image' %}">
|
||||
@@ -724,74 +851,7 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- ============ Latest Posts ============ -->
|
||||
<section class="home-section reveal" aria-labelledby="posts-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="posts-heading" class="section-title">{% trans "Latest Posts" %}</h2>
|
||||
<a href="{% url 'post-list' %}" class="apple-link">
|
||||
{% trans "View all posts" %}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{% if latest_posts %}
|
||||
<ul class="posts-grid" role="list">
|
||||
{% for post in latest_posts %}
|
||||
<li class="post-card reveal" style="--d: {{ forloop.counter0 }};">
|
||||
<a href="{{ post.get_absolute_url }}" class="post-card-link">
|
||||
<span class="post-card-date">{{ post.created_at|date:"M d, Y" }}</span>
|
||||
<span class="post-card-title">{{ post.title }}</span>
|
||||
{% if post.tags.all %}
|
||||
<span class="post-card-tags">
|
||||
{% for tag in post.tags.all|slice:":2" %}
|
||||
<span class="tag-pill">{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="apple-card empty-state">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5a2 2 0 00-2-2h-2"/>
|
||||
</svg>
|
||||
<p>{% trans "No posts available yet" %}</p>
|
||||
<a href="{% url 'post-create' %}" class="apple-btn apple-btn-primary">{% trans "Create your first post" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- ============ Pinned Mini Apps ============ -->
|
||||
<section class="home-section reveal" aria-labelledby="apps-heading" style="margin-bottom: 3.5rem;">
|
||||
<div class="section-header">
|
||||
<h2 id="apps-heading" class="section-title">{% trans "Pinned Mini Apps" %}</h2>
|
||||
</div>
|
||||
<div class="tile-grid">
|
||||
{% for app in mini_apps %}
|
||||
{% if app.url != '#' %}
|
||||
<a href="{% url app.url %}" class="app-tile gradient-{% cycle '1' '2' '3' '4' %} reveal" style="--d: {{ forloop.counter0 }};" aria-label="{{ app.name }} — {{ app.description }}">
|
||||
<span class="tile-arrow" aria-hidden="true">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 17L17 7M17 7H8M17 7v9"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-tile-name">{{ app.name }}</span>
|
||||
<span class="app-tile-desc">{{ app.description }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="app-tile gradient-{% cycle '1' '2' '3' '4' %} reveal" style="--d: {{ forloop.counter0 }};">
|
||||
<span class="app-tile-name">{{ app.name }}</span>
|
||||
<span class="app-tile-desc">{{ app.description }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<p style="color: var(--apple-gray);">{% trans "No mini apps available" %}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<div style="height: 2.5rem;" aria-hidden="true"></div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+3
-2
@@ -88,8 +88,9 @@ class LinkListView(ListView):
|
||||
'#1abc9c', '#d35400', '#34495e', '#16a085', '#27ae60'
|
||||
]
|
||||
|
||||
# Add latest 3 posts to the context (most recent first)
|
||||
context['latest_posts'] = Post.objects.all().order_by('-created_at')[:3]
|
||||
# Add latest 5 posts to the context (most recent first), shown as a
|
||||
# dense list in the first-screen dashboard on the home page.
|
||||
context['latest_posts'] = Post.objects.prefetch_related('tags').order_by('-created_at')[:5]
|
||||
|
||||
# Add pinned mini apps with random colors
|
||||
mini_apps = [
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@
|
||||
<script src="{% static 'js/common.js' %}"></script>
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<nav id="main-nav" class="bg-custom-blue text-white p-4 fixed w-full top-0 z-10 nav-visible">
|
||||
<nav id="main-nav" class="bg-custom-blue text-white p-4 fixed w-full top-0 z-50 nav-visible">
|
||||
<div class="container mx-auto flex justify-between items-center">
|
||||
<a href="{% url 'link_list' %}" class="text-xl font-bold">{% trans "GoLinks" %}</a>
|
||||
<div class="flex items-center space-x-4">
|
||||
|
||||
Reference in New Issue
Block a user