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
+24
-14
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" />
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<title>Travel Planner</title>
|
||||
<script type="module" crossorigin src="/assets/index-BX4qY0bE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-pT9caalc.css">
|
||||
<script type="module" crossorigin src="/assets/index-Chfn55jq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D_50XTpY.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -34,36 +34,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
const [fade2, setFade2] = useState(false);
|
||||
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 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);
|
||||
};
|
||||
const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
|
||||
|
||||
useEffect(() => {
|
||||
const nextUrl = publicPhotos.length > 0 ? publicPhotos[currentBgIndex]?.imageUrl : '/background.avif';
|
||||
@@ -253,79 +224,106 @@ 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">
|
||||
{/* Background Image - Hiển thị trên mọi thiết bị với hiệu ứng Crossfade & Panning & Swiping */}
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-gray-950 overflow-hidden"
|
||||
onTouchStart={handleTouchStart}
|
||||
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 && (
|
||||
<img
|
||||
key={bg1}
|
||||
src={bg1}
|
||||
draggable="false"
|
||||
onContextMenu={(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 ${
|
||||
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||
}`}
|
||||
style={{
|
||||
opacity: fade1 ? 0.8 : 0,
|
||||
transform: `scale(${panScale}) translateX(-${(panScale - 1) * 50}%)`,
|
||||
}}
|
||||
alt="Travel Background 1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Slot 2 */}
|
||||
{bg2 && (
|
||||
<img
|
||||
key={bg2}
|
||||
src={bg2}
|
||||
draggable="false"
|
||||
onContextMenu={(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 ${
|
||||
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||
}`}
|
||||
style={{
|
||||
opacity: fade2 ? 0.8 : 0,
|
||||
transform: `scale(${panScale}) translateX(-${(panScale - 1) * 50}%)`,
|
||||
}}
|
||||
alt="Travel Background 2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Background Image with Horizontal Panning */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
{/* Active image for panning */}
|
||||
<div className={`absolute inset-0 image-pan-container ${activeSlot === 1 && fade1 ? 'block' : 'hidden'}`}>
|
||||
{bg1 && (
|
||||
<img
|
||||
key={bg1}
|
||||
src={bg1}
|
||||
draggable="false"
|
||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
className={`h-full image-pan-element ${
|
||||
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||
}`}
|
||||
alt="Travel Background 1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`absolute inset-0 image-pan-container ${activeSlot === 2 && fade2 ? 'block' : 'hidden'}`}>
|
||||
{bg2 && (
|
||||
<img
|
||||
key={bg2}
|
||||
src={bg2}
|
||||
draggable="false"
|
||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
className={`h-full image-pan-element ${
|
||||
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||
}`}
|
||||
alt="Travel Background 2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyframes cho hiệu ứng panning từ trái sang phải */}
|
||||
{/* Style for horizontal panning */}
|
||||
<style>{`
|
||||
@keyframes pan-left-to-right {
|
||||
0% {
|
||||
transform: scale(1.08) translate(0, 0) translateZ(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.16) translate(-1%, 0.5%) translateZ(0);
|
||||
}
|
||||
.image-pan-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.animate-panning {
|
||||
animation: pan-left-to-right 13500ms linear forwards;
|
||||
will-change: transform;
|
||||
.image-pan-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.image-pan-container {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.image-pan-element {
|
||||
width: auto;
|
||||
max-width: none !important;
|
||||
height: 100%;
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
}
|
||||
`}</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 */}
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user