mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
feat: Files page layout switcher — grid (Finder-style) & carousel (cover-flow) views
- Three layouts: list (unchanged) / grid / carousel, persisted in localStorage - Grid: image thumbnails (lazy), video first-frame + play badge, doc icon fallback - Carousel: scroll-snap horizontal track, desktop arrows, native swipe on mobile - Click any card opens the existing preview modal (with prev/next navigation) - Mobile-first: 2-column grid on phones, hidden carousel arrows on touch - Pure frontend in list.html; no model/API changes
This commit is contained in:
@@ -140,6 +140,131 @@
|
||||
|
||||
.file-actions { flex: none; display: flex; align-items: center; gap: 0.15rem; }
|
||||
|
||||
/* ---------- Layout switcher (list / grid / carousel) ---------- */
|
||||
.layout-switcher {
|
||||
display: inline-flex; align-items: center; gap: 2px;
|
||||
background: rgba(0, 0, 0, 0.055); border-radius: 980px; padding: 3px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.layout-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 40px; height: 34px; border-radius: 980px; border: none; cursor: pointer;
|
||||
background: transparent; color: var(--apple-gray);
|
||||
transition: background 0.25s ease, color 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
.layout-btn:hover { color: var(--apple-text); }
|
||||
.layout-btn.active {
|
||||
background: #fff; color: var(--apple-blue);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.layout-btn svg { width: 1.15rem; height: 1.15rem; display: block; }
|
||||
@media (max-width: 640px) {
|
||||
.layout-btn { width: 38px; height: 32px; }
|
||||
}
|
||||
|
||||
/* ---------- Grid view (macOS Finder style) ---------- */
|
||||
.file-grid {
|
||||
display: grid; gap: 0.9rem; padding: 0.6rem 1.8rem 1.6rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr));
|
||||
}
|
||||
.grid-card {
|
||||
display: flex; flex-direction: column; align-items: stretch; gap: 0.45rem;
|
||||
padding: 0.65rem 0.65rem 0.8rem; border-radius: var(--apple-radius-md);
|
||||
background: #fff; border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
box-shadow: var(--apple-shadow-card); cursor: pointer; text-align: left;
|
||||
transition: transform 0.3s var(--apple-ease), box-shadow 0.3s var(--apple-ease);
|
||||
}
|
||||
.grid-card:hover { transform: translateY(-3px); box-shadow: var(--apple-shadow-lift); }
|
||||
.grid-card:active { transform: scale(0.97); }
|
||||
.grid-thumb {
|
||||
position: relative; width: 100%; aspect-ratio: 1 / 1;
|
||||
border-radius: 16px; overflow: hidden; background: #f0f0f2;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.grid-thumb img, .grid-thumb video {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
}
|
||||
.grid-thumb .thumb-badge {
|
||||
position: absolute; bottom: 0.4rem; right: 0.4rem;
|
||||
width: 1.6rem; height: 1.6rem; border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
|
||||
}
|
||||
.grid-thumb .thumb-badge svg { width: 0.85rem; height: 0.85rem; }
|
||||
.grid-file-icon { width: 3.4rem; height: 3.4rem; color: #c7c7cc; }
|
||||
.grid-file-icon svg { width: 100%; height: 100%; }
|
||||
.grid-name {
|
||||
font-size: 0.82rem; font-weight: 600; color: var(--apple-text);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.grid-meta { font-size: 0.72rem; color: var(--apple-gray); }
|
||||
.grid-badge {
|
||||
align-self: flex-start; display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
padding: 0.18rem 0.6rem; border-radius: 980px;
|
||||
font-size: 0.68rem; font-weight: 600;
|
||||
}
|
||||
.grid-badge.pub { background: rgba(48, 209, 88, 0.14); color: var(--apple-green); }
|
||||
.grid-badge.priv { background: rgba(0, 0, 0, 0.05); color: var(--apple-gray); }
|
||||
@media (max-width: 640px) {
|
||||
.file-grid { gap: 0.65rem; padding: 0.5rem 1.1rem 1.2rem; grid-template-columns: repeat(auto-fill, minmax(6.4rem, 1fr)); }
|
||||
.grid-card { padding: 0.5rem 0.5rem 0.65rem; }
|
||||
.grid-thumb { border-radius: 13px; }
|
||||
}
|
||||
|
||||
/* ---------- Carousel view (iOS cover-flow style) ---------- */
|
||||
.file-carousel { position: relative; }
|
||||
.carousel-track {
|
||||
display: flex; gap: 1.1rem; overflow-x: auto;
|
||||
padding: 0.9rem 1.8rem 1.4rem; margin-bottom: -1.4rem;
|
||||
scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.carousel-track::-webkit-scrollbar { display: none; }
|
||||
.carousel-card {
|
||||
flex: none; width: 11.5rem; scroll-snap-align: center;
|
||||
display: flex; flex-direction: column; gap: 0.5rem;
|
||||
background: #fff; border-radius: var(--apple-radius-lg);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05); box-shadow: var(--apple-shadow-card);
|
||||
padding: 0.8rem; cursor: pointer; text-align: left;
|
||||
transition: transform 0.3s var(--apple-ease), box-shadow 0.3s var(--apple-ease);
|
||||
}
|
||||
.carousel-card:hover { transform: translateY(-4px) scale(1.02); box-shadow: var(--apple-shadow-lift); }
|
||||
.carousel-cover {
|
||||
position: relative; width: 100%; aspect-ratio: 1 / 1; border-radius: 18px;
|
||||
overflow: hidden; background: #f0f0f2;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.carousel-cover img, .carousel-cover video {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
}
|
||||
.carousel-file-icon { width: 4rem; height: 4rem; color: #c7c7cc; }
|
||||
.carousel-file-icon svg { width: 100%; height: 100%; }
|
||||
.carousel-name {
|
||||
font-size: 0.85rem; font-weight: 600; color: var(--apple-text);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.carousel-meta { font-size: 0.72rem; color: var(--apple-gray); }
|
||||
.carousel-nav {
|
||||
position: absolute; top: 42%; transform: translateY(-50%); z-index: 5;
|
||||
width: 2.4rem; height: 2.4rem; border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.92); color: var(--apple-text);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06); box-shadow: var(--apple-shadow-card);
|
||||
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
||||
-webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.carousel-nav:hover { background: #fff; transform: translateY(-50%) scale(1.06); }
|
||||
.carousel-nav svg { width: 1.1rem; height: 1.1rem; }
|
||||
.carousel-nav.prev { left: 0.9rem; }
|
||||
.carousel-nav.next { right: 0.9rem; }
|
||||
@media (max-width: 640px) {
|
||||
.carousel-track { gap: 0.8rem; padding: 0.7rem 1.1rem 1.2rem; }
|
||||
.carousel-card { width: 9.5rem; padding: 0.6rem; }
|
||||
.carousel-cover { border-radius: 14px; }
|
||||
.carousel-nav { display: none; } /* native swipe on touch */
|
||||
}
|
||||
|
||||
/* ---------- Empty state ---------- */
|
||||
.empty-state {
|
||||
padding: 2.75rem 1.5rem; text-align: center; color: var(--apple-gray);
|
||||
@@ -327,6 +452,33 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="library-actions">
|
||||
<!-- Layout switcher: list / grid / carousel (persisted in localStorage) -->
|
||||
<div class="layout-switcher" role="group" aria-label="{% trans 'View layout' %}">
|
||||
<button type="button" @click="setLayout('list')"
|
||||
class="layout-btn" :class="layout === 'list' ? 'active' : ''"
|
||||
:title="'{% trans "List view" %}'" aria-label="{% trans "List view" %}"
|
||||
:aria-pressed="layout === 'list'">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" @click="setLayout('grid')"
|
||||
class="layout-btn" :class="layout === 'grid' ? 'active' : ''"
|
||||
:title="'{% trans "Grid view" %}'" aria-label="{% trans "Grid view" %}"
|
||||
:aria-pressed="layout === 'grid'">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" @click="setLayout('carousel')"
|
||||
class="layout-btn" :class="layout === 'carousel' ? 'active' : ''"
|
||||
:title="'{% trans "Carousel view" %}'" aria-label="{% trans "Carousel view" %}"
|
||||
:aria-pressed="layout === 'carousel'">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h3a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2V9zm11 0a2 2 0 012-2h3a2 2 0 012 2v6a2 2 0 01-2 2h-3a2 2 0 01-2-2V9z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" @click="openPicker()" class="apple-btn apple-btn-primary">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14m-7-7h14"/>
|
||||
@@ -353,8 +505,8 @@
|
||||
}{% if not forloop.last %},{% endif %}{% endfor %}]
|
||||
</script>
|
||||
|
||||
<!-- List body. Single render per file — responsive layout adapts to viewport. -->
|
||||
<ul x-ref="listEl" role="list" class="file-list" @remove-file.window="removeFile($event.detail.id)">
|
||||
<!-- List body. Three layouts share one Alpine scope: list / grid / carousel. -->
|
||||
<ul x-ref="listEl" x-show="layout === 'list'" role="list" class="file-list" @remove-file.window="removeFile($event.detail.id)">
|
||||
<template x-for="f in visibleFiles" :key="f.id">
|
||||
<li class="file-row" x-data="fileRow(f)" :data-pk="f.id">
|
||||
|
||||
@@ -454,17 +606,97 @@
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<li x-show="visibleFiles.length === 0" 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="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="es-title">{% trans "No files yet" %}</p>
|
||||
<p class="es-sub">{% trans "Drag files anywhere on this page, or click Upload Files above." %}</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- ── Grid view (macOS Finder style) ─────────────────────────── -->
|
||||
<div x-show="layout === 'grid'" x-cloak class="file-grid" @remove-file.window="removeFile($event.detail.id)">
|
||||
<template x-for="f in visibleFiles" :key="'g' + f.id">
|
||||
<div class="grid-card"
|
||||
@click="openPreview({name:f.name, mimeType:f.mime_type, url:f.download_url})"
|
||||
:title="f.name" role="button" tabindex="0"
|
||||
@keydown.enter.prevent="openPreview({name:f.name, mimeType:f.mime_type, url:f.download_url})">
|
||||
|
||||
<!-- Thumbnail: image / video first frame / type icon -->
|
||||
<div class="grid-thumb">
|
||||
<img x-show="f.is_image" :src="f.download_url" :alt="f.name" loading="lazy" decoding="async">
|
||||
<video x-show="isVideo(f)" :src="f.download_url" muted playsinline preload="metadata"
|
||||
aria-hidden="true"></video>
|
||||
<span class="grid-file-icon" x-show="!f.is_image && !isVideo(f)" aria-hidden="true">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="thumb-badge" x-show="isVideo(f)" aria-hidden="true">
|
||||
<svg fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5.14v13.72c0 .82.9 1.32 1.6.9l10.6-6.86a1.06 1.06 0 000-1.8L9.6 4.24a1.06 1.06 0 00-1.6.9z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Name + meta -->
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;min-width:0">
|
||||
<span class="grid-name" x-text="f.name"></span>
|
||||
<span class="grid-meta" x-text="f.size"></span>
|
||||
</div>
|
||||
|
||||
<!-- Public/private badge -->
|
||||
<span class="grid-badge" :class="f.is_public ? 'pub' : 'priv'">
|
||||
<span x-show="f.is_public" aria-hidden="true">●</span>
|
||||
<span x-show="!f.is_public" aria-hidden="true">○</span>
|
||||
<span x-text="f.is_public ? '{% trans "Public" %}' : '{% trans "Private" %}'"></span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── Carousel view (iOS cover-flow style) ───────────────────── -->
|
||||
<div x-show="layout === 'carousel'" x-cloak class="file-carousel" @remove-file.window="removeFile($event.detail.id)">
|
||||
<button type="button" class="carousel-nav prev" @click="scrollCarousel(-1)"
|
||||
title="{% trans 'Previous' %}" aria-label="{% trans 'Previous' %}">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="carousel-track" x-ref="carouselTrack">
|
||||
<template x-for="f in visibleFiles" :key="'c' + f.id">
|
||||
<div class="carousel-card"
|
||||
@click="openPreview({name:f.name, mimeType:f.mime_type, url:f.download_url})"
|
||||
:title="f.name" role="button" tabindex="0"
|
||||
@keydown.enter.prevent="openPreview({name:f.name, mimeType:f.mime_type, url:f.download_url})">
|
||||
<div class="carousel-cover">
|
||||
<img x-show="f.is_image" :src="f.download_url" :alt="f.name" loading="lazy" decoding="async">
|
||||
<video x-show="isVideo(f)" :src="f.download_url" muted playsinline preload="metadata"
|
||||
aria-hidden="true"></video>
|
||||
<span class="carousel-file-icon" x-show="!f.is_image && !isVideo(f)" aria-hidden="true">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<span class="carousel-name" x-text="f.name"></span>
|
||||
<span class="carousel-meta" x-text="f.size"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" class="carousel-nav next" @click="scrollCarousel(1)"
|
||||
title="{% trans 'Next' %}" aria-label="{% trans 'Next' %}">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Shared empty state (all layouts) -->
|
||||
<div x-show="visibleFiles.length === 0" class="empty-state" style="border-top:1px solid var(--apple-separator)">
|
||||
<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="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="es-title">{% trans "No files yet" %}</p>
|
||||
<p class="es-sub">{% trans "Drag files anywhere on this page, or click Upload Files above." %}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="height: 2.5rem;" aria-hidden="true"></div>
|
||||
@@ -690,6 +922,7 @@ function fileManager() {
|
||||
visibleFiles: [],
|
||||
uploads: [],
|
||||
hudOpen: false,
|
||||
layout: 'list',
|
||||
toastMsg: '',
|
||||
toastVisible: false,
|
||||
_toastTimer: null,
|
||||
@@ -699,6 +932,12 @@ function fileManager() {
|
||||
currentIndex: -1,
|
||||
|
||||
init() {
|
||||
// Restore persisted layout preference (list | grid | carousel)
|
||||
try {
|
||||
const saved = localStorage.getItem('links-files-layout');
|
||||
if (saved === 'grid' || saved === 'carousel' || saved === 'list') this.layout = saved;
|
||||
} catch (e) { /* localStorage unavailable — keep default */ }
|
||||
|
||||
// Seed initial file list from server-rendered JSON
|
||||
try {
|
||||
this.visibleFiles = JSON.parse(document.getElementById('initial-file-data').textContent);
|
||||
@@ -756,6 +995,30 @@ function fileManager() {
|
||||
document.getElementById('globalFileInput').click();
|
||||
},
|
||||
|
||||
// ── Layout switching (list / grid / carousel) ─────────────────
|
||||
setLayout(layout) {
|
||||
this.layout = layout;
|
||||
try { localStorage.setItem('links-files-layout', layout); } catch (e) { /* ignore */ }
|
||||
// Re-snap the carousel to the start when switching into it
|
||||
if (layout === 'carousel') {
|
||||
this.$nextTick(() => {
|
||||
const track = this.$refs.carouselTrack;
|
||||
if (track && track.scrollTo) track.scrollTo({ left: 0, behavior: 'auto' });
|
||||
});
|
||||
}
|
||||
},
|
||||
isVideo(f) {
|
||||
const mime = (f.mime_type || '').toLowerCase();
|
||||
return mime.startsWith('video/') || /\.(mp4|webm|mov|m4v)$/i.test(f.name || '');
|
||||
},
|
||||
scrollCarousel(dir) {
|
||||
const track = this.$refs.carouselTrack;
|
||||
if (!track) return;
|
||||
const card = track.querySelector('.carousel-card');
|
||||
const step = card ? card.offsetWidth + 16 : track.clientWidth * 0.8;
|
||||
track.scrollBy({ left: dir * step, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// ── Uploads ─────────────────────────────────────────────────
|
||||
uploadFiles(fileList) {
|
||||
this.hudOpen = true;
|
||||
|
||||
Reference in New Issue
Block a user