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:
OpenClaw Sub-agent
2026-08-01 22:00:37 +10:00
parent 7f10a90f10
commit ed4c8853a6
+275 -12
View File
@@ -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;