fix: lướt xem ảnh ở màn hình index của người dùng public
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
# To AI Agent: Implement Mobile Horizontal Image Panning Component
|
||||||
|
|
||||||
|
## 1. Context & Objective
|
||||||
|
We are developing a travel application. We need to implement a mobile-first image viewer component.
|
||||||
|
**CRITICAL REQUIREMENT:** When a user swipes/drags horizontally on mobile, the UI must NOT switch to the next image. Instead, it must smoothly scroll/pan horizontally to reveal the hidden, unexposed parts of the *same* wide/panoramic image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Technical Stack & Scope
|
||||||
|
- **Target Platform:** Mobile Web / Responsive (Touch-friendly).
|
||||||
|
- **Preferred Method:** CSS-First approach utilizing Viewport Overflow (for optimal GPU performance and native inertia scrolling).
|
||||||
|
- **Avoid:** Do NOT use global slider libraries (like standard Swiper/Slick) if they force image switching behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UI/UX Specifications
|
||||||
|
|
||||||
|
### A. DOM Structure
|
||||||
|
- A wrapper/container acting as the "window view" (`.image-pan-container`).
|
||||||
|
- The target wide/panoramic image (`.image-pan-element`).
|
||||||
|
|
||||||
|
### B. CSS Rules & Constraints
|
||||||
|
1. **Container (`.image-pan-container`):**
|
||||||
|
- Must have a fixed width (e.g., `100vw` or `100%` of parent).
|
||||||
|
- Must set `overflow-x: auto` and `overflow-y: hidden` to enable horizontal touch scrolling only.
|
||||||
|
- Must enable smooth scrolling (`scroll-behavior: smooth`) and native touch momentum (`-webkit-overflow-scrolling: touch`).
|
||||||
|
- **Crucial:** Hide the native scrollbar across all major browsers (Webkit, Firefox, IE/Edge) to make it look like a native mobile app feature.
|
||||||
|
|
||||||
|
2. **Image Element (`.image-pan-element`):**
|
||||||
|
- Must fit the container's height perfectly (`height: 100%`).
|
||||||
|
- Width must be calculated automatically based on aspect ratio (`width: auto`).
|
||||||
|
- Must override any global framework styles: enforce `max-width: none !important`.
|
||||||
|
- Do NOT use `object-fit: cover` as it will crop the scrolling data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Reference Code Blueprint
|
||||||
|
|
||||||
|
Use the following snippet as a baseline for your implementation:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="image-pan-container">
|
||||||
|
<img src="YOUR_PANORAMIC_IMAGE_URL" class="image-pan-element" alt="Panoramic View" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
/* Styling Architecture */
|
||||||
|
.image-pan-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 400px; /* Adjust height based on project guidelines */
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbars entirely */
|
||||||
|
.image-pan-container::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.image-pan-container {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-pan-element {
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
|
max-width: none !important;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
5. Acceptance Criteria
|
||||||
|
[ ] The wide photo fills the component height and overflows horizontally without distortion.
|
||||||
|
|
||||||
|
[ ] Users can smoothly swipe left/right with their fingers to view all details of the photo.
|
||||||
|
|
||||||
|
[ ] No desktop/mobile scrollbars are visible during the interaction.
|
||||||
|
|
||||||
|
[ ] Ensure max-width override is active so Tailwind or other CSS frameworks don't crush the image width to 100%.
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+22
-12
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||||
<title>Travel Planner</title>
|
<title>Travel Planner</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BX4qY0bE.js"></script>
|
<script type="module" crossorigin src="/assets/index-Chfn55jq.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-pT9caalc.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D_50XTpY.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -34,37 +34,8 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
|||||||
const [fade2, setFade2] = useState(false);
|
const [fade2, setFade2] = useState(false);
|
||||||
const [activeSlot, setActiveSlot] = useState<1 | 2>(1);
|
const [activeSlot, setActiveSlot] = useState<1 | 2>(1);
|
||||||
|
|
||||||
const [touchOffsetX, setTouchOffsetX] = useState(0);
|
|
||||||
const touchStartXRef = useRef(0);
|
|
||||||
const isSwipingRef = useRef(false);
|
|
||||||
const panScale = 1.25;
|
|
||||||
const maxPanX = typeof window !== 'undefined' ? -(window.innerWidth * (panScale - 1)) : -250;
|
|
||||||
|
|
||||||
const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
|
const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
|
||||||
|
|
||||||
const handleTouchStart = (e: React.TouchEvent) => {
|
|
||||||
touchStartXRef.current = e.touches[0].clientX;
|
|
||||||
isSwipingRef.current = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchMove = (e: React.TouchEvent) => {
|
|
||||||
if (!isSwipingRef.current) return;
|
|
||||||
const currentX = e.touches[0].clientX;
|
|
||||||
const diffX = currentX - touchStartXRef.current;
|
|
||||||
|
|
||||||
// Allow panning within bounds (for panorama viewing)
|
|
||||||
const clampedX = Math.max(maxPanX, Math.min(0, diffX));
|
|
||||||
setTouchOffsetX(clampedX);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
|
||||||
if (!isSwipingRef.current) return;
|
|
||||||
isSwipingRef.current = false;
|
|
||||||
|
|
||||||
// Reset panning position when touch ends
|
|
||||||
setTouchOffsetX(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextUrl = publicPhotos.length > 0 ? publicPhotos[currentBgIndex]?.imageUrl : '/background.avif';
|
const nextUrl = publicPhotos.length > 0 ? publicPhotos[currentBgIndex]?.imageUrl : '/background.avif';
|
||||||
if (!nextUrl) return;
|
if (!nextUrl) return;
|
||||||
@@ -255,22 +226,10 @@ notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-dvh w-full overflow-hidden font-sans bg-gray-900 relative">
|
<div className="h-dvh w-full overflow-hidden font-sans bg-gray-900 relative">
|
||||||
{/* Background Image - Hiển thị trên mọi thiết bị với hiệu ứng Crossfade & Panning & Swiping */}
|
{/* Background Image with Horizontal Panning */}
|
||||||
<div
|
<div className="absolute inset-0 z-0">
|
||||||
className="absolute inset-0 z-0 bg-gray-950 overflow-hidden"
|
{/* Active image for panning */}
|
||||||
onTouchStart={handleTouchStart}
|
<div className={`absolute inset-0 image-pan-container ${activeSlot === 1 && fade1 ? 'block' : 'hidden'}`}>
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-y-0 left-0 right-0 cursor-grab active:cursor-grabbing"
|
|
||||||
style={{
|
|
||||||
transform: `translateX(${touchOffsetX}px)`,
|
|
||||||
transition: touchOffsetX === 0 ? 'transform 0.3s ease-out' : 'none',
|
|
||||||
touchAction: 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Slot 1 */}
|
|
||||||
{bg1 && (
|
{bg1 && (
|
||||||
<img
|
<img
|
||||||
key={bg1}
|
key={bg1}
|
||||||
@@ -278,18 +237,14 @@ notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải
|
|||||||
draggable="false"
|
draggable="false"
|
||||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-[1200ms] ease-in-out ${
|
className={`h-full image-pan-element ${
|
||||||
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{
|
|
||||||
opacity: fade1 ? 0.8 : 0,
|
|
||||||
transform: `scale(${panScale}) translateX(-${(panScale - 1) * 50}%)`,
|
|
||||||
}}
|
|
||||||
alt="Travel Background 1"
|
alt="Travel Background 1"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{/* Slot 2 */}
|
<div className={`absolute inset-0 image-pan-container ${activeSlot === 2 && fade2 ? 'block' : 'hidden'}`}>
|
||||||
{bg2 && (
|
{bg2 && (
|
||||||
<img
|
<img
|
||||||
key={bg2}
|
key={bg2}
|
||||||
@@ -297,35 +252,78 @@ notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải
|
|||||||
draggable="false"
|
draggable="false"
|
||||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-[1200ms] ease-in-out ${
|
className={`h-full image-pan-element ${
|
||||||
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{
|
|
||||||
opacity: fade2 ? 0.8 : 0,
|
|
||||||
transform: `scale(${panScale}) translateX(-${(panScale - 1) * 50}%)`,
|
|
||||||
}}
|
|
||||||
alt="Travel Background 2"
|
alt="Travel Background 2"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyframes cho hiệu ứng panning từ trái sang phải */}
|
{/* Style for horizontal panning */}
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes pan-left-to-right {
|
.image-pan-container {
|
||||||
0% {
|
width: 100%;
|
||||||
transform: scale(1.08) translate(0, 0) translateZ(0);
|
height: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
100% {
|
.image-pan-container::-webkit-scrollbar {
|
||||||
transform: scale(1.16) translate(-1%, 0.5%) translateZ(0);
|
display: none;
|
||||||
}
|
}
|
||||||
|
.image-pan-container {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
.animate-panning {
|
.image-pan-element {
|
||||||
animation: pan-left-to-right 13500ms linear forwards;
|
width: auto;
|
||||||
will-change: transform;
|
max-width: none !important;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
|
{/* Swipe Indicator Arrows - for image switching */}
|
||||||
|
{publicPhotos.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentBgIndex((prev) => (prev - 1 + publicPhotos.length) % publicPhotos.length)}
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 p-2 bg-black/30 hover:bg-black/50 backdrop-blur-md rounded-full text-white transition-all md:hidden"
|
||||||
|
aria-label="Previous photo"
|
||||||
|
>
|
||||||
|
<span className="text-xl">‹</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentBgIndex((prev) => (prev + 1) % publicPhotos.length)}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-2 bg-black/30 hover:bg-black/50 backdrop-blur-md rounded-full text-white transition-all md:hidden"
|
||||||
|
aria-label="Next photo"
|
||||||
|
>
|
||||||
|
<span className="text-xl">›</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photo Progress Dots */}
|
||||||
|
{publicPhotos.length > 1 && (
|
||||||
|
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 z-10 flex gap-1.5">
|
||||||
|
{publicPhotos.slice(0, 20).map((_, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => setCurrentBgIndex(idx)}
|
||||||
|
className={`w-2 h-2 rounded-full transition-all ${
|
||||||
|
currentBgIndex === idx
|
||||||
|
? 'bg-emerald-500 w-6'
|
||||||
|
: 'bg-white/40 hover:bg-white/60'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Top Bar - Thanh điều hướng trên cùng */}
|
{/* Top Bar - Thanh điều hướng trên cùng */}
|
||||||
<div className="absolute top-0 left-0 right-0 z-20 p-3 sm:p-4 pt-[calc(0.75rem+env(safe-area-inset-top,0px))] sm:pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center bg-gradient-to-b from-slate-950/40 to-transparent">
|
<div className="absolute top-0 left-0 right-0 z-20 p-3 sm:p-4 pt-[calc(0.75rem+env(safe-area-inset-top,0px))] sm:pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center bg-gradient-to-b from-slate-950/40 to-transparent">
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2 text-white drop-shadow-lg">
|
<div className="flex items-center gap-1.5 sm:gap-2 text-white drop-shadow-lg">
|
||||||
|
|||||||
Reference in New Issue
Block a user