mirror of
https://github.com/aleksey-hoffman/sigma-file-manager.git
synced 2026-10-19 01:31:05 -05:00
906 lines
28 KiB
HTML
906 lines
28 KiB
HTML
<!-- SPDX-License-Identifier: GPL-3.0-or-later
|
|
License: GNU GPLv3 or later. See the license file in the project root for more information.
|
|
Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
|
-->
|
|
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Sigma File Manager — Media Hub</title>
|
|
<link rel="icon" href="/app-icon.png" type="image/png">
|
|
<style>
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
:root {
|
|
--bg: #16181d;
|
|
--surface: #1e2026;
|
|
--surface-hover: #262930;
|
|
--surface-active: #2c2f38;
|
|
--border: #2e3138;
|
|
--text: #e0e0e4;
|
|
--text-muted: #8b8d94;
|
|
--accent: #7c8aff;
|
|
--accent-hover: #9aa4ff;
|
|
--radius: 8px;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.app {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 24px 16px;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding-bottom: 20px;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.header-brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.header-icon {
|
|
width: 40px;
|
|
height: 40px;
|
|
flex-shrink: 0;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.header-logo {
|
|
font-size: 1.125rem;
|
|
font-weight: 700;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
.header-badge {
|
|
padding: 3px 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 100px;
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
body.viewer-open {
|
|
overflow: hidden;
|
|
}
|
|
|
|
.viewer-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1000;
|
|
display: none;
|
|
flex-direction: column;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
backdrop-filter: blur(20px);
|
|
-webkit-backdrop-filter: blur(20px);
|
|
}
|
|
|
|
.viewer-overlay--open {
|
|
display: flex;
|
|
}
|
|
|
|
.viewer-overlay__toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 16px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.viewer-overlay__nav {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
}
|
|
|
|
.viewer-overlay__btn {
|
|
all: unset;
|
|
display: inline-flex;
|
|
width: 36px;
|
|
height: 36px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 6px;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
cursor: pointer;
|
|
font-size: 1.125rem;
|
|
transition: background 100ms, color 100ms;
|
|
user-select: none;
|
|
}
|
|
|
|
.viewer-overlay__btn:hover {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
color: #fff;
|
|
}
|
|
|
|
.viewer-overlay__counter {
|
|
color: rgba(255, 255, 255, 0.5);
|
|
font-size: 0.75rem;
|
|
padding: 0 6px;
|
|
user-select: none;
|
|
}
|
|
|
|
.viewer-overlay__content {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
padding: 16px;
|
|
touch-action: none;
|
|
}
|
|
|
|
.viewer-overlay__content img {
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
object-fit: contain;
|
|
transform-origin: center center;
|
|
will-change: transform;
|
|
}
|
|
|
|
.viewer-overlay__content video {
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
}
|
|
|
|
.viewer-overlay__content audio {
|
|
width: 80%;
|
|
max-width: 500px;
|
|
}
|
|
|
|
.viewer-overlay__audio-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 32px 16px;
|
|
width: 100%;
|
|
}
|
|
|
|
.viewer-overlay__audio-icon {
|
|
font-size: 2.5rem;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.viewer-overlay__audio-name {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.viewer-overlay__filename {
|
|
padding: 8px 16px;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
color: rgba(255, 255, 255, 0.5);
|
|
font-size: 0.8125rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
text-align: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.breadcrumb {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
margin-bottom: 16px;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.breadcrumb-segment {
|
|
cursor: pointer;
|
|
color: var(--accent);
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
background: none;
|
|
border: none;
|
|
font: inherit;
|
|
}
|
|
|
|
.breadcrumb-segment:hover {
|
|
color: var(--accent-hover);
|
|
background: var(--surface);
|
|
}
|
|
|
|
.breadcrumb-separator {
|
|
color: var(--text-muted);
|
|
user-select: none;
|
|
}
|
|
|
|
.breadcrumb-current {
|
|
color: var(--text);
|
|
cursor: default;
|
|
}
|
|
|
|
.breadcrumb-current:hover {
|
|
background: none;
|
|
color: var(--text);
|
|
}
|
|
|
|
.media-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.media-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
background: var(--surface);
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
transition: border-color 120ms, background 120ms;
|
|
}
|
|
|
|
.media-card:hover {
|
|
border-color: var(--accent);
|
|
background: var(--surface-hover);
|
|
}
|
|
|
|
.media-card--active {
|
|
border-color: var(--accent);
|
|
background: var(--surface-active);
|
|
}
|
|
|
|
.media-card__preview {
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0, 0, 0, 0.15);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.media-card__preview img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.media-card__preview-icon {
|
|
font-size: 2rem;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.media-card__preview-badge {
|
|
position: absolute;
|
|
top: 6px;
|
|
right: 6px;
|
|
padding: 1px 6px;
|
|
border-radius: 4px;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
font-size: 0.625rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
color: var(--text);
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.media-card__info {
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
.media-card__name {
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.media-card__meta {
|
|
font-size: 0.6875rem;
|
|
color: var(--text-muted);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.media-card--dir .media-card__preview {
|
|
background: rgba(124, 138, 255, 0.06);
|
|
}
|
|
|
|
.media-card--dir .media-card__preview-icon {
|
|
color: var(--accent);
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 48px 0;
|
|
color: var(--text-muted);
|
|
font-size: 0.9375rem;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 48px 0;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.app {
|
|
padding: 16px 12px;
|
|
}
|
|
|
|
.media-grid {
|
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="app" id="app">
|
|
<div class="header">
|
|
<div class="header-brand">
|
|
<img class="header-icon" src="/app-icon.png" width="40" height="40" alt="" decoding="async">
|
|
<div class="header-logo">Sigma File Manager</div>
|
|
</div>
|
|
<div class="header-badge">Media Hub</div>
|
|
</div>
|
|
|
|
<div class="breadcrumb" id="breadcrumb"></div>
|
|
|
|
<div id="content">
|
|
<div class="loading">Loading...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="viewer-overlay" id="viewerOverlay">
|
|
<div class="viewer-overlay__toolbar">
|
|
<div class="viewer-overlay__nav">
|
|
<button type="button" class="viewer-overlay__btn" id="prevBtn" title="Previous">◀</button>
|
|
<span class="viewer-overlay__counter" id="viewerCounter"></span>
|
|
<button type="button" class="viewer-overlay__btn" id="nextBtn" title="Next">▶</button>
|
|
</div>
|
|
<button type="button" class="viewer-overlay__btn" id="closeBtn" title="Close">✕</button>
|
|
</div>
|
|
<div class="viewer-overlay__content" id="viewerContent"></div>
|
|
<div class="viewer-overlay__filename" id="viewerFilename"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "avif"];
|
|
const VIDEO_EXTENSIONS = ["mp4", "webm", "ogv", "mov"];
|
|
const AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus"];
|
|
|
|
let currentPath = "";
|
|
let allEntries = [];
|
|
let mediaFileList = [];
|
|
let currentMediaIndex = -1;
|
|
let shareRootLabel = "Shared";
|
|
|
|
const contentEl = document.getElementById("content");
|
|
const breadcrumbEl = document.getElementById("breadcrumb");
|
|
const viewerOverlay = document.getElementById("viewerOverlay");
|
|
const viewerContent = document.getElementById("viewerContent");
|
|
const viewerFilename = document.getElementById("viewerFilename");
|
|
const viewerCounter = document.getElementById("viewerCounter");
|
|
const prevBtn = document.getElementById("prevBtn");
|
|
const nextBtn = document.getElementById("nextBtn");
|
|
const closeBtn = document.getElementById("closeBtn");
|
|
const apiOrigin = window.location.origin;
|
|
|
|
function getExtension (name) {
|
|
const dotIndex = name.lastIndexOf(".");
|
|
if (dotIndex < 0) return "";
|
|
return name.slice(dotIndex + 1).toLowerCase();
|
|
}
|
|
|
|
function getMediaType (name) {
|
|
const extension = getExtension(name);
|
|
if (IMAGE_EXTENSIONS.includes(extension)) return "image";
|
|
if (VIDEO_EXTENSIONS.includes(extension)) return "video";
|
|
if (AUDIO_EXTENSIONS.includes(extension)) return "audio";
|
|
return null;
|
|
}
|
|
|
|
function formatBytes (bytes) {
|
|
if (bytes === 0) return "0 B";
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
const unitIndex = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
return (bytes / Math.pow(1024, unitIndex)).toFixed(unitIndex > 0 ? 1 : 0) + " " + units[unitIndex];
|
|
}
|
|
|
|
function escapeHtml (text) {
|
|
const div = document.createElement("div");
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function buildFileUrl (entryPath) {
|
|
return apiOrigin + "/files/" + encodeURIComponent(entryPath);
|
|
}
|
|
|
|
function entryListPathSegment (entry) {
|
|
if (entry.fileId != null && entry.fileId !== "") {
|
|
return String(entry.fileId);
|
|
}
|
|
return entry.name;
|
|
}
|
|
|
|
function buildEntryRelativePath (entry) {
|
|
const segment = entryListPathSegment(entry);
|
|
return currentPath ? currentPath + "/" + segment : segment;
|
|
}
|
|
|
|
async function fetchListing (path) {
|
|
currentPath = path || "";
|
|
currentMediaIndex = -1;
|
|
closeViewer();
|
|
const queryPath = path ? "?path=" + encodeURIComponent(path) : "";
|
|
try {
|
|
const response = await fetch(apiOrigin + "/api/list" + queryPath);
|
|
if (!response.ok) throw new Error("HTTP " + response.status);
|
|
const data = await response.json();
|
|
allEntries = data.entries;
|
|
if (typeof data.shareName === "string" && data.shareName.length > 0) {
|
|
shareRootLabel = data.shareName;
|
|
}
|
|
mediaFileList = allEntries
|
|
.filter(entry => !entry.is_dir && getMediaType(entry.name))
|
|
.map(entry => {
|
|
const filePath = buildEntryRelativePath(entry);
|
|
return { path: filePath, name: entry.name, type: getMediaType(entry.name) };
|
|
});
|
|
renderBreadcrumb(data.currentPath, data.canGoUp);
|
|
renderGrid(allEntries);
|
|
} catch (error) {
|
|
contentEl.innerHTML = '<div class="empty-state">Failed to load directory listing</div>';
|
|
}
|
|
}
|
|
|
|
function renderBreadcrumb (pathStr, canGoUp) {
|
|
breadcrumbEl.innerHTML = "";
|
|
const rootBtn = document.createElement("button");
|
|
rootBtn.className = "breadcrumb-segment" + (!pathStr ? " breadcrumb-current" : "");
|
|
rootBtn.textContent = shareRootLabel;
|
|
rootBtn.onclick = () => fetchListing("");
|
|
breadcrumbEl.appendChild(rootBtn);
|
|
|
|
if (pathStr) {
|
|
const segments = pathStr.split("/").filter(Boolean);
|
|
segments.forEach((segment, index) => {
|
|
const separator = document.createElement("span");
|
|
separator.className = "breadcrumb-separator";
|
|
separator.textContent = "/";
|
|
breadcrumbEl.appendChild(separator);
|
|
|
|
const isLast = index === segments.length - 1;
|
|
const btn = document.createElement("button");
|
|
btn.className = "breadcrumb-segment" + (isLast ? " breadcrumb-current" : "");
|
|
btn.textContent = segment;
|
|
if (!isLast) {
|
|
const targetPath = segments.slice(0, index + 1).join("/");
|
|
btn.onclick = () => fetchListing(targetPath);
|
|
}
|
|
breadcrumbEl.appendChild(btn);
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderGrid (entries) {
|
|
const dirs = entries.filter(entry => entry.is_dir);
|
|
const mediaFiles = entries.filter(entry => !entry.is_dir && getMediaType(entry.name));
|
|
|
|
if (!dirs.length && !mediaFiles.length) {
|
|
contentEl.innerHTML = '<div class="empty-state">No media files in this directory</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '<div class="media-grid">';
|
|
|
|
for (const dir of dirs) {
|
|
const dirPath = currentPath ? currentPath + "/" + dir.name : dir.name;
|
|
html += '<div class="media-card media-card--dir" data-dir="' + escapeHtml(dirPath) + '">'
|
|
+ '<div class="media-card__preview"><div class="media-card__preview-icon">📁</div></div>'
|
|
+ '<div class="media-card__info">'
|
|
+ '<div class="media-card__name">' + escapeHtml(dir.name) + '</div>'
|
|
+ '<div class="media-card__meta">Folder</div>'
|
|
+ '</div></div>';
|
|
}
|
|
|
|
for (const file of mediaFiles) {
|
|
const filePath = buildEntryRelativePath(file);
|
|
const mediaType = getMediaType(file.name);
|
|
const fileUrl = buildFileUrl(filePath);
|
|
const extension = getExtension(file.name).toUpperCase();
|
|
|
|
let previewHtml;
|
|
if (mediaType === "image") {
|
|
previewHtml = '<img src="' + fileUrl + '" alt="" loading="lazy" decoding="async">';
|
|
} else if (mediaType === "video") {
|
|
previewHtml = '<div class="media-card__preview-icon">🎬</div>';
|
|
} else {
|
|
previewHtml = '<div class="media-card__preview-icon">🎵</div>';
|
|
}
|
|
|
|
html += '<div class="media-card" data-file="' + escapeHtml(filePath) + '" data-type="' + mediaType + '" data-name="' + escapeHtml(file.name) + '">'
|
|
+ '<div class="media-card__preview">' + previewHtml
|
|
+ '<div class="media-card__preview-badge">' + extension + '</div>'
|
|
+ '</div>'
|
|
+ '<div class="media-card__info">'
|
|
+ '<div class="media-card__name">' + escapeHtml(file.name) + '</div>'
|
|
+ '<div class="media-card__meta">' + formatBytes(file.size) + '</div>'
|
|
+ '</div></div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
contentEl.innerHTML = html;
|
|
|
|
contentEl.querySelectorAll(".media-card[data-dir]").forEach(card => {
|
|
card.addEventListener("click", () => {
|
|
fetchListing(card.dataset.dir);
|
|
});
|
|
});
|
|
|
|
contentEl.querySelectorAll(".media-card[data-file]").forEach(card => {
|
|
card.addEventListener("click", () => {
|
|
const filePath = card.dataset.file;
|
|
const mediaIndex = mediaFileList.findIndex(media => media.path === filePath);
|
|
if (mediaIndex >= 0) {
|
|
navigateToMedia(mediaIndex);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
let imageScale = 1;
|
|
let imageTranslateX = 0;
|
|
let imageTranslateY = 0;
|
|
let lastPinchDist = 0;
|
|
let lastPinchCenterX = 0;
|
|
let lastPinchCenterY = 0;
|
|
let isPinching = false;
|
|
let isPanning = false;
|
|
let panLastX = 0;
|
|
let panLastY = 0;
|
|
|
|
function applyImageTransform () {
|
|
const img = viewerContent.querySelector("img");
|
|
if (img) {
|
|
img.style.transform = "translate(" + imageTranslateX + "px, " + imageTranslateY + "px) scale(" + imageScale + ")";
|
|
}
|
|
}
|
|
|
|
function clampTranslation () {
|
|
const img = viewerContent.querySelector("img");
|
|
if (!img) return;
|
|
const rect = viewerContent.getBoundingClientRect();
|
|
const scaledW = img.naturalWidth > 0
|
|
? Math.min(img.clientWidth, rect.width) * imageScale
|
|
: rect.width * imageScale;
|
|
const scaledH = img.naturalHeight > 0
|
|
? Math.min(img.clientHeight, rect.height) * imageScale
|
|
: rect.height * imageScale;
|
|
const maxPanX = Math.max(0, (scaledW - rect.width) / 2);
|
|
const maxPanY = Math.max(0, (scaledH - rect.height) / 2);
|
|
imageTranslateX = Math.max(-maxPanX, Math.min(maxPanX, imageTranslateX));
|
|
imageTranslateY = Math.max(-maxPanY, Math.min(maxPanY, imageTranslateY));
|
|
}
|
|
|
|
function resetImageZoom () {
|
|
imageScale = 1;
|
|
imageTranslateX = 0;
|
|
imageTranslateY = 0;
|
|
const img = viewerContent.querySelector("img");
|
|
if (img) img.style.transform = "";
|
|
}
|
|
|
|
let isAnimating = false;
|
|
|
|
function loadMediaContent (index) {
|
|
resetImageZoom();
|
|
currentMediaIndex = index;
|
|
const media = mediaFileList[index];
|
|
const fileUrl = buildFileUrl(media.path);
|
|
|
|
if (media.type === "image") {
|
|
viewerContent.innerHTML = '<img src="' + fileUrl + '" alt="' + escapeHtml(media.name) + '">';
|
|
} else if (media.type === "video") {
|
|
viewerContent.innerHTML = '<video src="' + fileUrl + '" controls playsinline></video>';
|
|
} else if (media.type === "audio") {
|
|
viewerContent.innerHTML = '<div class="viewer-overlay__audio-wrap">'
|
|
+ '<div class="viewer-overlay__audio-icon">🎵</div>'
|
|
+ '<div class="viewer-overlay__audio-name">' + escapeHtml(media.name) + '</div>'
|
|
+ '<audio src="' + fileUrl + '" controls></audio>'
|
|
+ '</div>';
|
|
}
|
|
|
|
viewerFilename.textContent = media.name;
|
|
viewerCounter.textContent = (index + 1) + " / " + mediaFileList.length;
|
|
highlightActiveCard(media.path);
|
|
}
|
|
|
|
function navigateToMedia (index) {
|
|
if (index < 0 || index >= mediaFileList.length) return;
|
|
viewerOverlay.classList.add("viewer-overlay--open");
|
|
document.body.classList.add("viewer-open");
|
|
loadMediaContent(index);
|
|
}
|
|
|
|
function navigateWithAnimation (targetIndex, direction) {
|
|
if (isAnimating) return;
|
|
if (targetIndex < 0 || targetIndex >= mediaFileList.length) return;
|
|
isAnimating = true;
|
|
|
|
var containerWidth = viewerContent.offsetWidth;
|
|
var slideOutX = direction === "next" ? -containerWidth : containerWidth;
|
|
var slideInX = direction === "next" ? containerWidth : -containerWidth;
|
|
|
|
viewerContent.style.transition = "transform 200ms ease-out";
|
|
viewerContent.style.transform = "translateX(" + slideOutX + "px)";
|
|
|
|
function afterSlideOut () {
|
|
viewerContent.removeEventListener("transitionend", afterSlideOut);
|
|
loadMediaContent(targetIndex);
|
|
viewerContent.style.transition = "none";
|
|
viewerContent.style.transform = "translateX(" + slideInX + "px)";
|
|
void viewerContent.offsetHeight;
|
|
viewerContent.style.transition = "transform 200ms ease-out";
|
|
viewerContent.style.transform = "translateX(0)";
|
|
function afterSlideIn () {
|
|
viewerContent.removeEventListener("transitionend", afterSlideIn);
|
|
viewerContent.style.transition = "";
|
|
viewerContent.style.transform = "";
|
|
isAnimating = false;
|
|
}
|
|
viewerContent.addEventListener("transitionend", afterSlideIn);
|
|
}
|
|
viewerContent.addEventListener("transitionend", afterSlideOut);
|
|
}
|
|
|
|
function commitSwipeAnimation (targetIndex, direction, currentDragX) {
|
|
if (isAnimating) return;
|
|
isAnimating = true;
|
|
|
|
var containerWidth = viewerContent.offsetWidth;
|
|
var slideOutX = direction === "next" ? -containerWidth : containerWidth;
|
|
var slideInX = direction === "next" ? containerWidth : -containerWidth;
|
|
var remaining = containerWidth - Math.abs(currentDragX);
|
|
var outDuration = Math.max(80, Math.round(200 * (remaining / containerWidth)));
|
|
|
|
viewerContent.style.transition = "transform " + outDuration + "ms ease-out";
|
|
viewerContent.style.transform = "translateX(" + slideOutX + "px)";
|
|
|
|
function afterSlideOut () {
|
|
viewerContent.removeEventListener("transitionend", afterSlideOut);
|
|
loadMediaContent(targetIndex);
|
|
viewerContent.style.transition = "none";
|
|
viewerContent.style.transform = "translateX(" + slideInX + "px)";
|
|
void viewerContent.offsetHeight;
|
|
viewerContent.style.transition = "transform 200ms ease-out";
|
|
viewerContent.style.transform = "translateX(0)";
|
|
function afterSlideIn () {
|
|
viewerContent.removeEventListener("transitionend", afterSlideIn);
|
|
viewerContent.style.transition = "";
|
|
viewerContent.style.transform = "";
|
|
isAnimating = false;
|
|
}
|
|
viewerContent.addEventListener("transitionend", afterSlideIn);
|
|
}
|
|
viewerContent.addEventListener("transitionend", afterSlideOut);
|
|
}
|
|
|
|
function cancelSwipeDrag () {
|
|
viewerContent.style.transition = "transform 180ms ease-out";
|
|
viewerContent.style.transform = "translateX(0)";
|
|
function afterSnap () {
|
|
viewerContent.removeEventListener("transitionend", afterSnap);
|
|
viewerContent.style.transition = "";
|
|
viewerContent.style.transform = "";
|
|
}
|
|
viewerContent.addEventListener("transitionend", afterSnap);
|
|
}
|
|
|
|
function highlightActiveCard (filePath) {
|
|
contentEl.querySelectorAll(".media-card--active").forEach(card => card.classList.remove("media-card--active"));
|
|
const activeCard = contentEl.querySelector('.media-card[data-file="' + CSS.escape(filePath) + '"]');
|
|
if (activeCard) {
|
|
activeCard.classList.add("media-card--active");
|
|
activeCard.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
}
|
|
}
|
|
|
|
function navigatePrev () {
|
|
if (currentMediaIndex > 0) {
|
|
navigateWithAnimation(currentMediaIndex - 1, "prev");
|
|
}
|
|
}
|
|
|
|
function navigateNext () {
|
|
if (currentMediaIndex < mediaFileList.length - 1) {
|
|
navigateWithAnimation(currentMediaIndex + 1, "next");
|
|
}
|
|
}
|
|
|
|
function closeViewer () {
|
|
resetImageZoom();
|
|
viewerOverlay.classList.remove("viewer-overlay--open");
|
|
document.body.classList.remove("viewer-open");
|
|
viewerContent.innerHTML = "";
|
|
currentMediaIndex = -1;
|
|
contentEl.querySelectorAll(".media-card--active").forEach(card => card.classList.remove("media-card--active"));
|
|
}
|
|
|
|
prevBtn.addEventListener("click", navigatePrev);
|
|
nextBtn.addEventListener("click", navigateNext);
|
|
closeBtn.addEventListener("click", closeViewer);
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (currentMediaIndex < 0) return;
|
|
if (event.key === "ArrowLeft") { event.preventDefault(); navigatePrev(); }
|
|
else if (event.key === "ArrowRight") { event.preventDefault(); navigateNext(); }
|
|
else if (event.key === "Escape") { closeViewer(); }
|
|
});
|
|
|
|
let swipeStartX = 0;
|
|
let swipeStartY = 0;
|
|
let swipeDragX = 0;
|
|
let swipeDragging = false;
|
|
let swipeDirectionLocked = false;
|
|
let swipeIsHorizontal = false;
|
|
const SWIPE_THRESHOLD = 50;
|
|
const DIRECTION_LOCK_THRESHOLD = 8;
|
|
|
|
viewerContent.addEventListener("touchstart", (event) => {
|
|
if (isAnimating) return;
|
|
if (event.touches.length === 2) {
|
|
isPinching = true;
|
|
isPanning = false;
|
|
swipeDragging = false;
|
|
lastPinchDist = Math.hypot(
|
|
event.touches[0].clientX - event.touches[1].clientX,
|
|
event.touches[0].clientY - event.touches[1].clientY
|
|
);
|
|
lastPinchCenterX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
|
|
lastPinchCenterY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
|
|
} else if (event.touches.length === 1) {
|
|
swipeStartX = event.touches[0].clientX;
|
|
swipeStartY = event.touches[0].clientY;
|
|
swipeDragX = 0;
|
|
swipeDirectionLocked = false;
|
|
swipeIsHorizontal = false;
|
|
if (imageScale > 1.05) {
|
|
isPanning = true;
|
|
panLastX = event.touches[0].clientX;
|
|
panLastY = event.touches[0].clientY;
|
|
}
|
|
}
|
|
}, { passive: true });
|
|
|
|
viewerContent.addEventListener("touchmove", (event) => {
|
|
if (isAnimating) return;
|
|
if (event.touches.length === 2) {
|
|
isPinching = true;
|
|
const dist = Math.hypot(
|
|
event.touches[0].clientX - event.touches[1].clientX,
|
|
event.touches[0].clientY - event.touches[1].clientY
|
|
);
|
|
const centerX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
|
|
const centerY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
|
|
const prevScale = imageScale;
|
|
imageScale = Math.max(1, Math.min(5, imageScale * (dist / lastPinchDist)));
|
|
|
|
const rect = viewerContent.getBoundingClientRect();
|
|
const originX = centerX - rect.left - rect.width / 2;
|
|
const originY = centerY - rect.top - rect.height / 2;
|
|
const scaleFactor = imageScale / prevScale;
|
|
imageTranslateX = originX - scaleFactor * (originX - imageTranslateX);
|
|
imageTranslateY = originY - scaleFactor * (originY - imageTranslateY);
|
|
|
|
imageTranslateX += centerX - lastPinchCenterX;
|
|
imageTranslateY += centerY - lastPinchCenterY;
|
|
|
|
lastPinchDist = dist;
|
|
lastPinchCenterX = centerX;
|
|
lastPinchCenterY = centerY;
|
|
|
|
clampTranslation();
|
|
applyImageTransform();
|
|
event.preventDefault();
|
|
} else if (event.touches.length === 1 && isPanning && !isPinching) {
|
|
const touchX = event.touches[0].clientX;
|
|
const touchY = event.touches[0].clientY;
|
|
imageTranslateX += touchX - panLastX;
|
|
imageTranslateY += touchY - panLastY;
|
|
panLastX = touchX;
|
|
panLastY = touchY;
|
|
clampTranslation();
|
|
applyImageTransform();
|
|
event.preventDefault();
|
|
} else if (event.touches.length === 1 && !isPanning && !isPinching && imageScale <= 1.05) {
|
|
const deltaX = event.touches[0].clientX - swipeStartX;
|
|
const deltaY = event.touches[0].clientY - swipeStartY;
|
|
if (!swipeDirectionLocked) {
|
|
if (Math.abs(deltaX) > DIRECTION_LOCK_THRESHOLD || Math.abs(deltaY) > DIRECTION_LOCK_THRESHOLD) {
|
|
swipeDirectionLocked = true;
|
|
swipeIsHorizontal = Math.abs(deltaX) >= Math.abs(deltaY);
|
|
}
|
|
}
|
|
if (swipeDirectionLocked && swipeIsHorizontal) {
|
|
swipeDragging = true;
|
|
swipeDragX = deltaX;
|
|
viewerContent.style.transition = "none";
|
|
viewerContent.style.transform = "translateX(" + deltaX + "px)";
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
}, { passive: false });
|
|
|
|
viewerContent.addEventListener("touchend", (event) => {
|
|
if (event.touches.length < 2 && isPinching) {
|
|
isPinching = false;
|
|
if (imageScale < 1.05) {
|
|
resetImageZoom();
|
|
}
|
|
return;
|
|
}
|
|
if (event.touches.length === 0) {
|
|
if (isPanning) {
|
|
isPanning = false;
|
|
return;
|
|
}
|
|
if (swipeDragging) {
|
|
swipeDragging = false;
|
|
var dragX = swipeDragX;
|
|
swipeDragX = 0;
|
|
if (Math.abs(dragX) >= SWIPE_THRESHOLD) {
|
|
if (dragX < 0 && currentMediaIndex < mediaFileList.length - 1) {
|
|
commitSwipeAnimation(currentMediaIndex + 1, "next", dragX);
|
|
} else if (dragX > 0 && currentMediaIndex > 0) {
|
|
commitSwipeAnimation(currentMediaIndex - 1, "prev", dragX);
|
|
} else {
|
|
cancelSwipeDrag();
|
|
}
|
|
} else {
|
|
cancelSwipeDrag();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}, { passive: true });
|
|
|
|
fetchListing("");
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|