Remove PDF thumbnails, fix 3MF clipping, fix PDF range-request loading
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m6s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m6s
This commit is contained in:
parent
c239fbb296
commit
3c47584bb2
5 changed files with 47 additions and 105 deletions
|
|
@ -21,7 +21,6 @@ RUN apk add --no-cache \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
exiftool \
|
exiftool \
|
||||||
sqlite \
|
sqlite \
|
||||||
ghostscript \
|
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
mailcap \
|
mailcap \
|
||||||
tzdata
|
tzdata
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ func (m *ThumbnailManager) ScanMissingThumbnails() {
|
||||||
mimeType = "video/"
|
mimeType = "video/"
|
||||||
} else if ext == ".mp3" || ext == ".flac" || ext == ".wav" || ext == ".m4a" || ext == ".aac" {
|
} else if ext == ".mp3" || ext == ".flac" || ext == ".wav" || ext == ".m4a" || ext == ".aac" {
|
||||||
mimeType = "audio/"
|
mimeType = "audio/"
|
||||||
} else if ext == ".docx" || ext == ".pptx" || ext == ".xlsx" || ext == ".pdf" {
|
} else if ext == ".docx" || ext == ".pptx" || ext == ".xlsx" {
|
||||||
mimeType = "application/"
|
mimeType = "application/"
|
||||||
} else {
|
} else {
|
||||||
continue
|
continue
|
||||||
|
|
@ -148,8 +148,6 @@ func (m *ThumbnailManager) worker(id int) {
|
||||||
err = generateVideoThumbnail(fullSourcePath, destPath)
|
err = generateVideoThumbnail(fullSourcePath, destPath)
|
||||||
} else if strings.HasPrefix(job.MimeType, "audio/") {
|
} else if strings.HasPrefix(job.MimeType, "audio/") {
|
||||||
err = generateAudioThumbnail(fullSourcePath, destPath)
|
err = generateAudioThumbnail(fullSourcePath, destPath)
|
||||||
} else if strings.HasSuffix(strings.ToLower(job.FilePath), ".pdf") {
|
|
||||||
err = generatePdfThumbnail(fullSourcePath, destPath)
|
|
||||||
} else if strings.HasSuffix(job.FilePath, ".docx") || strings.HasSuffix(job.FilePath, ".pptx") || strings.HasSuffix(job.FilePath, ".xlsx") {
|
} else if strings.HasSuffix(job.FilePath, ".docx") || strings.HasSuffix(job.FilePath, ".pptx") || strings.HasSuffix(job.FilePath, ".xlsx") {
|
||||||
err = generateOfficeThumbnail(job.FilePath, destPath, m.Config)
|
err = generateOfficeThumbnail(job.FilePath, destPath, m.Config)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -213,38 +211,6 @@ func generateAudioThumbnail(src, dest string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generatePdfThumbnail(src, dest string) error {
|
|
||||||
// Use Ghostscript to render the first page of the PDF as a JPEG
|
|
||||||
tmpFile := dest + ".tmp.jpg"
|
|
||||||
cmd := exec.Command("gs",
|
|
||||||
"-dNOPAUSE", "-dBATCH", "-dSAFER", "-dQUIET",
|
|
||||||
"-sDEVICE=jpeg",
|
|
||||||
"-dFirstPage=1", "-dLastPage=1",
|
|
||||||
"-r72", // 72 DPI is enough for a thumbnail
|
|
||||||
"-dJPEGQ=85",
|
|
||||||
fmt.Sprintf("-sOutputFile=%s", tmpFile),
|
|
||||||
src,
|
|
||||||
)
|
|
||||||
|
|
||||||
timer := time.AfterFunc(30*time.Second, func() {
|
|
||||||
if cmd.Process != nil {
|
|
||||||
cmd.Process.Kill()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
err := cmd.Run()
|
|
||||||
timer.Stop()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(tmpFile)
|
|
||||||
return fmt.Errorf("ghostscript failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resize the rendered page to standard thumbnail size
|
|
||||||
err = generateImageThumbnail(tmpFile, dest)
|
|
||||||
os.Remove(tmpFile)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateOfficeThumbnail(filePath, destPath string, cfg *config.Config) error {
|
func generateOfficeThumbnail(filePath, destPath string, cfg *config.Config) error {
|
||||||
// Generate a download token for the Conversion API to fetch the file from our backend
|
// Generate a download token for the Conversion API to fetch the file from our backend
|
||||||
|
|
|
||||||
|
|
@ -1159,7 +1159,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||||||
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) || file.mime_type?.startsWith('image/');
|
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) || file.mime_type?.startsWith('image/');
|
||||||
const isVideo = ['mp4', 'mkv', 'avi', 'mov', 'webm', 'ogg'].includes(ext) || file.mime_type?.startsWith('video/');
|
const isVideo = ['mp4', 'mkv', 'avi', 'mov', 'webm', 'ogg'].includes(ext) || file.mime_type?.startsWith('video/');
|
||||||
const isOffice = ['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls', 'pdf'].includes(ext) || file.mime_type?.includes('officedocument') || file.mime_type === 'application/pdf';
|
const isOffice = ['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls'].includes(ext) || file.mime_type?.includes('officedocument');
|
||||||
const isAudio = ['mp3', 'flac', 'wav', 'm4a', 'wma', 'aac'].includes(ext) || file.mime_type?.startsWith('audio/');
|
const isAudio = ['mp3', 'flac', 'wav', 'm4a', 'wma', 'aac'].includes(ext) || file.mime_type?.startsWith('audio/');
|
||||||
const isMedia = isImage || isVideo || isOffice || isAudio;
|
const isMedia = isImage || isVideo || isOffice || isAudio;
|
||||||
|
|
||||||
|
|
@ -1240,7 +1240,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
if (isMedia && file.checksum && viewMode === 'grid') {
|
if (isMedia && file.checksum && viewMode === 'grid') {
|
||||||
const docType = (file.name.endsWith('.docx') || file.name.endsWith('.pdf')) ? 'docs' : file.name.endsWith('.pptx') ? 'slides' : (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) ? 'sheets' : null;
|
const docType = file.name.endsWith('.docx') ? 'docs' : file.name.endsWith('.pptx') ? 'slides' : (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) ? 'sheets' : null;
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full flex items-center justify-center overflow-hidden rounded-md">
|
<div className="relative w-full h-full flex items-center justify-center overflow-hidden rounded-md">
|
||||||
<ThumbnailImage checksum={file.checksum} fallbackIcon={fallbackIcon} downloadToken={downloadToken || ''} docType={docType} />
|
<ThumbnailImage checksum={file.checksum} fallbackIcon={fallbackIcon} downloadToken={downloadToken || ''} docType={docType} />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Document, Page, pdfjs } from 'react-pdf';
|
import { Document, Page, pdfjs } from 'react-pdf';
|
||||||
import { ZoomIn, ZoomOut, Loader2 } from 'lucide-react';
|
import { ZoomIn, ZoomOut, Loader2 } from 'lucide-react';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
|
@ -17,9 +17,8 @@ function LazyPage({ pageNumber, scale, containerWidth }: { pageNumber: number; s
|
||||||
rootMargin: '150% 0px',
|
rootMargin: '150% 0px',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Estimate the page height based on typical A4 ratio (1:1.414)
|
const pageWidth = Math.min(containerWidth * 0.9, 900);
|
||||||
const estimatedWidth = Math.min(containerWidth * 0.9, 900) * scale;
|
const estimatedHeight = pageWidth * 1.414 * scale;
|
||||||
const estimatedHeight = estimatedWidth * 1.414;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -31,14 +30,14 @@ function LazyPage({ pageNumber, scale, containerWidth }: { pageNumber: number; s
|
||||||
<Page
|
<Page
|
||||||
pageNumber={pageNumber}
|
pageNumber={pageNumber}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
width={Math.min(containerWidth * 0.9, 900)}
|
width={pageWidth}
|
||||||
renderTextLayer={true}
|
renderTextLayer={true}
|
||||||
renderAnnotationLayer={true}
|
renderAnnotationLayer={true}
|
||||||
className="bg-white shadow-xl"
|
className="bg-white shadow-xl"
|
||||||
loading={
|
loading={
|
||||||
<div
|
<div
|
||||||
className="bg-white shadow-xl flex items-center justify-center"
|
className="bg-white shadow-xl flex items-center justify-center"
|
||||||
style={{ width: `${estimatedWidth}px`, height: `${estimatedHeight}px` }}
|
style={{ width: `${pageWidth * scale}px`, height: `${estimatedHeight}px` }}
|
||||||
>
|
>
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -47,7 +46,7 @@ function LazyPage({ pageNumber, scale, containerWidth }: { pageNumber: number; s
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="bg-gray-100 shadow-xl rounded"
|
className="bg-gray-100 shadow-xl rounded"
|
||||||
style={{ width: `${estimatedWidth}px`, height: `${estimatedHeight}px` }}
|
style={{ width: `${pageWidth * scale}px`, height: `${estimatedHeight}px` }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -57,13 +56,10 @@ function LazyPage({ pageNumber, scale, containerWidth }: { pageNumber: number; s
|
||||||
export default function PdfViewer({ url }: { url: string }) {
|
export default function PdfViewer({ url }: { url: string }) {
|
||||||
const [numPages, setNumPages] = useState<number>();
|
const [numPages, setNumPages] = useState<number>();
|
||||||
const [scale, setScale] = useState<number>(1.0);
|
const [scale, setScale] = useState<number>(1.0);
|
||||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
|
||||||
const [fetchingPdf, setFetchingPdf] = useState(true);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [containerWidth, setContainerWidth] = useState(800);
|
const [containerWidth, setContainerWidth] = useState(800);
|
||||||
|
|
||||||
// Measure container width for responsive page sizing
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
@ -74,57 +70,20 @@ export default function PdfViewer({ url }: { url: string }) {
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch the PDF as a blob to avoid Next.js proxy streaming issues with large files
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setFetchingPdf(true);
|
|
||||||
setFetchError(null);
|
|
||||||
|
|
||||||
fetch(url)
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
return res.blob();
|
|
||||||
})
|
|
||||||
.then((blob) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
|
||||||
setBlobUrl(objectUrl);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setFetchError(`Failed to load PDF: ${err.message}`);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setFetchingPdf(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
||||||
};
|
|
||||||
}, [url]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
|
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
|
||||||
setNumPages(numPages);
|
setNumPages(numPages);
|
||||||
|
setLoadError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fetchingPdf) {
|
if (loadError) {
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-accent)' }} />
|
|
||||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>Loading PDF...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fetchError) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--color-danger, #ef4444)' }}>{fetchError}</span>
|
<span className="text-sm font-medium" style={{ color: 'var(--color-danger, #ef4444)' }}>{loadError}</span>
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
|
download
|
||||||
className="px-4 py-2 mt-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
className="px-4 py-2 mt-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
||||||
style={{ backgroundColor: 'var(--color-accent)', color: 'white' }}
|
style={{ backgroundColor: 'var(--color-accent)', color: 'white' }}
|
||||||
>
|
>
|
||||||
|
|
@ -167,12 +126,20 @@ export default function PdfViewer({ url }: { url: string }) {
|
||||||
{/* PDF Content */}
|
{/* PDF Content */}
|
||||||
<div ref={containerRef} className="flex-1 overflow-auto bg-neutral-100 dark:bg-neutral-900 py-4 flex flex-col items-center">
|
<div ref={containerRef} className="flex-1 overflow-auto bg-neutral-100 dark:bg-neutral-900 py-4 flex flex-col items-center">
|
||||||
<Document
|
<Document
|
||||||
file={blobUrl!}
|
file={url}
|
||||||
onLoadSuccess={onDocumentLoadSuccess}
|
onLoadSuccess={onDocumentLoadSuccess}
|
||||||
onLoadError={(error) => console.error('Failed to load PDF:', error)}
|
onLoadError={(error) => {
|
||||||
|
console.error('Failed to load PDF:', error);
|
||||||
|
setLoadError('Failed to load PDF file.');
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
disableAutoFetch: true,
|
||||||
|
disableStream: true,
|
||||||
|
}}
|
||||||
loading={
|
loading={
|
||||||
<div className="flex items-center justify-center h-full mt-20">
|
<div className="flex flex-col items-center justify-center h-full mt-20 gap-3">
|
||||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-accent)' }} />
|
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-accent)' }} />
|
||||||
|
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>Loading PDF...</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
className="flex flex-col items-center gap-2 w-full"
|
className="flex flex-col items-center gap-2 w-full"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Canvas, useThree } from '@react-three/fiber';
|
import { Canvas, useThree } from '@react-three/fiber';
|
||||||
import { OrbitControls } from '@react-three/drei';
|
import { OrbitControls } from '@react-three/drei';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
@ -15,7 +15,6 @@ function Model({ url, onLoad, onError }: { url: string; onLoad: () => void; onEr
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
// Dynamically import the official Three.js 3MFLoader
|
|
||||||
import('three/examples/jsm/loaders/3MFLoader.js').then(({ ThreeMFLoader }) => {
|
import('three/examples/jsm/loaders/3MFLoader.js').then(({ ThreeMFLoader }) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const loader = new ThreeMFLoader();
|
const loader = new ThreeMFLoader();
|
||||||
|
|
@ -25,32 +24,38 @@ function Model({ url, onLoad, onError }: { url: string; onLoad: () => void; onEr
|
||||||
(group: THREE.Group) => {
|
(group: THREE.Group) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
// Ensure all meshes have materials and shadows
|
|
||||||
group.traverse((child) => {
|
group.traverse((child) => {
|
||||||
if ((child as THREE.Mesh).isMesh) {
|
if ((child as THREE.Mesh).isMesh) {
|
||||||
const mesh = child as THREE.Mesh;
|
const mesh = child as THREE.Mesh;
|
||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
mesh.receiveShadow = true;
|
mesh.receiveShadow = true;
|
||||||
// Ensure material exists
|
|
||||||
if (!mesh.material) {
|
if (!mesh.material) {
|
||||||
mesh.material = new THREE.MeshStandardMaterial({ color: 0x888888 });
|
mesh.material = new THREE.MeshStandardMaterial({ color: 0x888888 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Center and fit model to camera
|
// Compute bounding box and center the model
|
||||||
const box = new THREE.Box3().setFromObject(group);
|
const box = new THREE.Box3().setFromObject(group);
|
||||||
const center = box.getCenter(new THREE.Vector3());
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
const size = box.getSize(new THREE.Vector3());
|
const size = box.getSize(new THREE.Vector3());
|
||||||
group.position.sub(center);
|
group.position.sub(center);
|
||||||
|
|
||||||
// Adjust camera to fit
|
// Adjust camera clipping planes and position based on model size
|
||||||
const maxDim = Math.max(size.x, size.y, size.z);
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
const perspCam = camera as THREE.PerspectiveCamera;
|
||||||
|
|
||||||
if (maxDim > 0) {
|
if (maxDim > 0) {
|
||||||
const fov = (camera as THREE.PerspectiveCamera).fov * (Math.PI / 180);
|
// Set clipping planes generously based on model size
|
||||||
const dist = (maxDim / 2) / Math.tan(fov / 2) * 1.5;
|
perspCam.near = maxDim * 0.001;
|
||||||
camera.position.set(dist * 0.7, dist * 0.5, dist);
|
perspCam.far = maxDim * 100;
|
||||||
camera.lookAt(0, 0, 0);
|
|
||||||
|
// Position camera to see the whole model
|
||||||
|
const fov = perspCam.fov * (Math.PI / 180);
|
||||||
|
const dist = (maxDim / 2) / Math.tan(fov / 2) * 2.0;
|
||||||
|
perspCam.position.set(dist * 0.6, dist * 0.4, dist * 0.8);
|
||||||
|
perspCam.lookAt(0, 0, 0);
|
||||||
|
perspCam.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
|
|
||||||
setModel(group);
|
setModel(group);
|
||||||
|
|
@ -97,7 +102,12 @@ export default function ThreeMfPreview({ url }: ThreeMfPreviewProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`w-full h-full transition-opacity duration-300 ${loading ? 'opacity-0' : 'opacity-100'}`}>
|
<div className={`w-full h-full transition-opacity duration-300 ${loading ? 'opacity-0' : 'opacity-100'}`}>
|
||||||
<Canvas shadows dpr={[1, 2]} camera={{ position: [0, 0, 150], fov: 50 }}>
|
<Canvas
|
||||||
|
shadows
|
||||||
|
dpr={[1, 2]}
|
||||||
|
camera={{ position: [0, 0, 500], fov: 50, near: 0.01, far: 100000 }}
|
||||||
|
gl={{ antialias: true, logarithmicDepthBuffer: true }}
|
||||||
|
>
|
||||||
<ambientLight intensity={0.6} />
|
<ambientLight intensity={0.6} />
|
||||||
<directionalLight position={[10, 10, 10]} intensity={1} castShadow />
|
<directionalLight position={[10, 10, 10]} intensity={1} castShadow />
|
||||||
<directionalLight position={[-10, -5, -10]} intensity={0.3} />
|
<directionalLight position={[-10, -5, -10]} intensity={0.3} />
|
||||||
|
|
@ -113,7 +123,7 @@ export default function ThreeMfPreview({ url }: ThreeMfPreviewProps) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
<OrbitControls makeDefault />
|
<OrbitControls makeDefault enableDamping dampingFactor={0.1} />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Reference in a new issue