fix: sửa UI photoview hiển thị ở public
This commit is contained in:
-110
@@ -1,110 +0,0 @@
|
|||||||
# To AI Agent: Restore Smart Android App Download Banner Below Top-Bar Header
|
|
||||||
|
|
||||||
## 1. Context & Feature Objective
|
|
||||||
Previously, the codebase had a promotional banner encouraging mobile users to download the native Android `.apk` file. However, during the recent overhaul of the Top-Bar header and Member Dropdown Menu configurations, this banner component was accidentally unmounted or hidden.
|
|
||||||
|
|
||||||
**Objective:** Restore and refactor this promotional frame (`AppDownloadBanner.tsx`). It must **ONLY** appear when a user accesses the web application via a **Mobile Android Browser** (Chrome, Samsung Internet, Opera Mobile, etc.). It must be positioned dynamically as a small, clean horizontal frame pinned directly underneath the main Top-Bar header layout, without conflicting with the new absolute Profile Dropdown Menu.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Visual & Behavioral Layout Specifications
|
|
||||||
- **Placement Context:** Directly underneath the Top-Bar layer, shifting the core page content (Map Canvas or Landing Hero) down proportionally (`relative` or `sticky` stack). It must not overlay or block map navigation tools.
|
|
||||||
- **Conditional Trigger Logic:** The banner must evaluate `navigator.userAgent`. If the user agent includes `'android'` **AND** the user is running inside a standard web browser (not inside the compiled wrapper app itself), display the banner.
|
|
||||||
- **Dismissible Interaction:** Include a small close button (`X`). Clicking it should temporarily store a flag in `sessionStorage` or `localStorage` to prevent the banner from bugging the user repeatedly during their session.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Technical Implementation Blueprint
|
|
||||||
|
|
||||||
### Step 1: Create the Responsive Banner Component (`AppDownloadBanner.tsx`)
|
|
||||||
Create or re-engineer the banner layout within `frontend/src/components/layout/AppDownloadBanner.tsx`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { X, Download } from 'lucide-react';
|
|
||||||
|
|
||||||
export const AppDownloadBanner: React.FC = () => {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const APK_DOWNLOAD_URL = `${import.meta.env.VITE_BACKEND_URL || window.location.origin}/downloads/yotrip-latest.apk`;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const userAgent = navigator.userAgent.toLowerCase();
|
|
||||||
const isAndroidBrowser = userAgent.includes('android') && !window.location.origin.includes('capacitor://') && !window.location.origin.includes('localhost:80');
|
|
||||||
const isBannerDismissed = localStorage.getItem('yotrip_apk_banner_dismissed') === 'true';
|
|
||||||
|
|
||||||
// ✅ Target condition match: User is on Android mobile browser and hasn't closed it yet
|
|
||||||
if (isAndroidBrowser && !isBannerDismissed) {
|
|
||||||
setIsVisible(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDismiss = () => {
|
|
||||||
localStorage.setItem('yotrip_apk_banner_dismissed', 'true');
|
|
||||||
setIsVisible(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isVisible) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full bg-gradient-to-r from-blue-900 to-indigo-950 border-b border-blue-800 px-4 py-2 flex items-center justify-between text-white text-[11px] font-medium z-40 relative animate-fade-in shrink-0">
|
|
||||||
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
|
||||||
<div className="p-1 bg-blue-500/20 rounded-lg text-blue-400 shrink-0">
|
|
||||||
<Download className="w-3.5 h-3.5" />
|
|
||||||
</div>
|
|
||||||
<p className="truncate text-slate-200">
|
|
||||||
Trải nghiệm mượt mà hơn với ứng dụng <span className="text-white font-bold">YoTrip cho Android</span>!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 ml-2 shrink-0">
|
|
||||||
{/* Direct Download Trigger Target Link */}
|
|
||||||
<a
|
|
||||||
href={APK_DOWNLOAD_URL}
|
|
||||||
download="yotrip.apk"
|
|
||||||
className="bg-blue-600 hover:bg-blue-500 px-2.5 py-1 rounded font-bold text-white transition-colors shadow-sm active:scale-95"
|
|
||||||
>
|
|
||||||
Tải APK
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Close Button Trigger */}
|
|
||||||
<button
|
|
||||||
onClick={handleDismiss}
|
|
||||||
className="p-1 hover:bg-slate-800 rounded text-slate-400 hover:text-slate-200 transition-colors"
|
|
||||||
title="Đóng thông báo"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
### Step 2: Integrate into Layout Hierarchies (LandingPage.tsx & ExplorerMap.tsx)
|
|
||||||
Mount the verified download banner right below your primary header or top-bar row item array context:
|
|
||||||
|
|
||||||
{/* ❌ BEFORE STRUCTURAL INTEGRATION */}
|
|
||||||
<div className="w-screen h-screen flex flex-col">
|
|
||||||
<TopBarHeader />
|
|
||||||
<MapCanvasWorkspace />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ✅ AFTER STRUCTURAL INTEGRATION: Stacked relative context */}
|
|
||||||
<div className="w-screen h-screen flex flex-col overflow-hidden">
|
|
||||||
{/* 1. Main Application Header */}
|
|
||||||
<TopBarHeader />
|
|
||||||
|
|
||||||
{/* 2. THE RESTORED ANDROID APP PROMOTIONAL BANNER */}
|
|
||||||
<AppDownloadBanner />
|
|
||||||
|
|
||||||
{/* 3. Primary Workspace Area - Shifts down cleanly when banner initializes */}
|
|
||||||
<div className="flex-1 relative min-h-0 w-full">
|
|
||||||
<MapCanvasWorkspace />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## 4. Verification & Quality Acceptance Criteria for AI Agent
|
|
||||||
[ ] Targeted UserAgent Isolation: Emulate a desktop display width (iPhone or Desktop layouts). Verify the banner remains completely hidden. Toggle the network responsive preview device model to Android (e.g., Pixel 7) and refresh. Confirm the download bar pops up seamlessly.
|
|
||||||
|
|
||||||
[ ] Absolute Dropdown Overlay Preservation: Expand the Member Avatar menu row dropdown list while the banner is visible. Confirm that the dropdown box displays layered ON TOP of the banner context, without layout shifting or text clipping.
|
|
||||||
|
|
||||||
[ ] State Dismissal Memory: Click the X button on the banner, then refresh the browser session. Confirm the banner remains hidden and respects the localStorage condition toggle.
|
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
# To AI Agent: Restructure Mobile Photo Viewer Layout with Fixed Viewport, Overlay Metadata, and Scrollable Comments
|
||||||
|
|
||||||
|
## 1. Context & Architectural UI Refactor
|
||||||
|
We are redesigning the full-screen mobile photo inspector interface based on the visual layout annotated in `image.png`. The current structure is fragmented, causing layout instability when switching between images.
|
||||||
|
|
||||||
|
### Key Refactor Requirements:
|
||||||
|
1. **Fixed Image Viewport (Blue Zone):** Secure the photo viewport inside a strict, unyielding aspect-locked square container. Regardless of whether the active image is Landscape or Portrait, the container dimension must NOT snap, resize, or cause screen layout jumps.
|
||||||
|
2. **Geospatial Overlay (Red Arrow Destination):** Completely remove the static location metadata box from the bottom white panel. Relocate and render this location string as a clean, translucent text overlay pinned directly to the **bottom-left corner inside the image viewport**.
|
||||||
|
3. **Title Transformation (Green Arrow Destination):** Remove the text header segment "Lịch sử ảnh tại vị trí này (...)". In its place, dynamically render the active **Title of the Photo** (Tiêu đề của ảnh), serving as the primary text separator.
|
||||||
|
4. **Isolated Scrollable Comment Feed:** The entire lower comment zone must be configured to scroll dynamically. When users swipe up to read multiple comments, the layout thread must slide seamlessly underneath the fixed sticky image frame container (`z-20`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Structural Layer Elevation (`z-index`) Matrix
|
||||||
|
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ Tier 4: System Action Row & Closes (z-50) │ ➔ Native buttons (X, Close)
|
||||||
|
├────────────────────────────────────────────────────────┤
|
||||||
|
│ Tier 3: Fixed Photo Frame Viewport (z-20 / sticky) │ ➔ Aspect-Square Image Box
|
||||||
|
│ └─► Sub-Layer: Location & Time Overlay (z-30) │ ➔ Pinned Bottom-Left on Image
|
||||||
|
├────────────────────────────────────────────────────────┤
|
||||||
|
│ Tier 2: Scrollable Comments Panel (z-10) │ ➔ Slides BEHIND Tier 3 when swiped
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Code Refactoring Blueprint
|
||||||
|
|
||||||
|
### Step 1: Overhaul Layout Architecture (`MobilePhotoViewerModal.tsx`)
|
||||||
|
Update or create the mobile component layout to enforce the absolute layer boundaries and fixed dimensions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, MapPin, Calendar, Heart, Send } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PhotoDetail {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
locationName: string;
|
||||||
|
capturedAt: string;
|
||||||
|
likesCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobilePhotoViewerModal: React.FC<{ photo: PhotoDetail; onClose: () => void }> = ({ photo, onClose }) => {
|
||||||
|
const [commentInput, setCommentInput] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-slate-950 z-[999999] flex flex-col overflow-hidden select-none text-white antialiased">
|
||||||
|
|
||||||
|
{/* TIER 4: FIXED SYSTEM ACTIONS CONTROL ROW (z-50) */}
|
||||||
|
<div className="absolute top-4 right-4 z-50">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2.5 bg-black/60 hover:bg-black/80 rounded-full border border-slate-800 backdrop-blur-md active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TIER 3: FIXED BLUE ZONE - ASPECT LOCKED PHOTO FRAME (z-20 / sticky) */}
|
||||||
|
<div className="w-full aspect-square bg-slate-950 flex items-center justify-center sticky top-0 z-20 border-b border-slate-900/60 shadow-2xl shrink-0">
|
||||||
|
<img
|
||||||
|
src={photo.url}
|
||||||
|
alt={photo.title}
|
||||||
|
className="w-full h-full object-contain select-none pointer-events-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* TIER 3.1: RELOCATED DYNAMIC LOCATION & TIMESTAMP OVERLAY (z-30) */}
|
||||||
|
<div className="absolute bottom-4 left-4 right-16 z-30 flex flex-col gap-1 p-2.5 bg-black/50 backdrop-blur-sm rounded-xl border border-white/10 text-[10px] text-slate-200 max-w-[75vw] pointer-events-none">
|
||||||
|
<div className="flex items-center gap-1.5 font-semibold text-white">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||||
|
<span className="truncate">{photo.locationName || "Vị trí không xác định"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-300 pl-5">
|
||||||
|
<Calendar className="w-3 h-3 text-slate-400 shrink-0" />
|
||||||
|
<span>Ngày chụp: {new Date(photo.capturedAt).toLocaleDateString('vi-VN')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Like Badge Counter */}
|
||||||
|
<div className="absolute bottom-4 right-4 z-30 bg-black/50 backdrop-blur-sm border border-white/10 rounded-xl px-2.5 py-1.5 flex items-center gap-1 text-[11px] font-bold text-rose-500">
|
||||||
|
<Heart className="w-3.5 h-3.5 fill-rose-500" />
|
||||||
|
<span>{photo.likesCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TIER 2: SCROLLABLE CORE CONTENTS & COMMENTS PANEL (z-10) */}
|
||||||
|
{/* This entire workspace container slides underneath the photo layout on swipe-up */}
|
||||||
|
<div className="flex-1 overflow-y-auto bg-slate-900/30 relative z-10 flex flex-col min-h-0">
|
||||||
|
|
||||||
|
{/* REPLACED HEADER ZONE: Title replaces the old History text strip */}
|
||||||
|
<div className="px-5 py-4 border-b border-slate-900/80 bg-slate-900/90 backdrop-blur-md sticky top-0 z-10 space-y-1">
|
||||||
|
<h2 className="text-sm font-bold text-slate-100 tracking-wide">
|
||||||
|
{photo.title || "Chưa có tiêu đề"}
|
||||||
|
</h2>
|
||||||
|
{photo.description && (
|
||||||
|
<p className="text-[11px] text-slate-400 leading-relaxed font-medium">
|
||||||
|
{photo.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DYNAMIC COMMENTS FEED BLOCK */}
|
||||||
|
<div className="p-4 space-y-3.5 flex-1 overflow-y-visible">
|
||||||
|
{/* Mock iteration loop representing user chat bubbles */}
|
||||||
|
{[...Array(5)].map((_, index) => (
|
||||||
|
<div key={index} className="flex gap-3 items-start text-xs text-slate-300 animate-fade-in">
|
||||||
|
<div className="w-7 h-7 rounded-full bg-slate-800 font-bold text-[9px] flex items-center justify-center shrink-0 border border-slate-700">U</div>
|
||||||
|
<div className="flex-1 bg-slate-900/50 border border-slate-800/60 rounded-xl p-3 space-y-1 shadow-sm">
|
||||||
|
<div className="flex justify-between items-center text-[10px] font-semibold">
|
||||||
|
<span className="text-slate-200">Thành viên YoTrip</span>
|
||||||
|
<span className="text-slate-500 font-normal">Vừa xong</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 leading-relaxed text-[11px]">Góc chụp đẹp quá, bối cảnh nhìn rất thoáng và đầy đủ ánh sáng tự nhiên!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* STICKY BOTTOM INPUT SEND TRAY BAR */}
|
||||||
|
<div className="p-3 bg-slate-950 border-t border-slate-900 flex items-center gap-2 sticky bottom-0 z-20">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={commentInput}
|
||||||
|
onChange={(e) => setCommentInput(e.target.value)}
|
||||||
|
placeholder="Viết bình luận công khai..."
|
||||||
|
className="flex-1 bg-slate-900 border border-slate-800 text-white rounded-xl p-3 text-xs outline-none focus:border-blue-500 transition-colors placeholder-slate-500"
|
||||||
|
/>
|
||||||
|
<button className="p-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-colors active:scale-95 shadow-lg">
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
## 4. Quality Verification & Acceptance Criteria for AI Agent
|
||||||
|
[ ] Dimension Stability Test: Switch back and forth between an ultra-wide panoramic photo and a 4:3 vertical shot. The parent blue container (aspect-square) must remain exactly static on the viewport layout, blocking size shifts.
|
||||||
|
|
||||||
|
[ ] Overlay Check Validation: Verify the location marker coordinates box is completely wiped from the lower white profile zone and draws successfully on top of the image canvas at the bottom-left edge.
|
||||||
|
|
||||||
|
[ ] Text Swap Alignment: Ensure the string text "Lịch sử ảnh tại vị trí này" is replaced completely by the active image title asset hook.
|
||||||
|
|
||||||
|
[ ] Scroll Pass Inspection: Swipe up to read the text inside the comments panel. Verify the message rows pass behind the bottom border line of the image canvas container (z-20), while the close button at the top remains fully accessible.
|
||||||
Vendored
+2
-2
@@ -21,7 +21,7 @@
|
|||||||
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||||
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
||||||
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
|
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||||
<script type="module" crossorigin src="/assets/index-CSiax1SI.js"></script>
|
<script type="module" crossorigin src="/assets/index-BtRV7JVM.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CMxvf4Kt.js">
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CMxvf4Kt.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-others-CNhtyHGs.js">
|
<link rel="modulepreload" crossorigin href="/assets/vendor-others-CNhtyHGs.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-BDwQQzB8.js">
|
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-BDwQQzB8.js">
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-pdf-C3XQY6t9.js">
|
<link rel="modulepreload" crossorigin href="/assets/vendor-pdf-C3XQY6t9.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/vendor-maps-CcXJxNtP.css">
|
<link rel="stylesheet" crossorigin href="/assets/vendor-maps-CcXJxNtP.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/vendor-react-CuSj0z1j.css">
|
<link rel="stylesheet" crossorigin href="/assets/vendor-react-CuSj0z1j.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CIipVipb.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CLipHKhu.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -427,358 +427,308 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Left Side: Photo Detail */}
|
{/* ============================================================
|
||||||
<div className="relative w-full md:w-3/5 md:h-full bg-slate-950 flex flex-col overflow-hidden group shrink-0">
|
TIER 3: FIXED PHOTO FRAME VIEWPORT (z-20 / sticky on mobile)
|
||||||
|
Aspect-square container. Does NOT resize on image swap.
|
||||||
{/* Photo wrapper for mobile view (handles top overlay name and bottom-right like) */}
|
Desktop: left 3/5 column, absolute-fill with overlay metadata.
|
||||||
<div className="relative w-full flex items-center justify-center md:absolute md:inset-0 md:flex md:items-center md:justify-center bg-slate-950">
|
============================================================ */}
|
||||||
{/* Mobile Only: Uploader details overlay */}
|
<div className="w-full aspect-square bg-slate-950 flex items-center justify-center sticky top-0 z-20 shrink-0 shadow-2xl border-b border-slate-900/60 md:relative md:aspect-auto md:w-3/5 md:h-full md:overflow-hidden md:border-b-0 group">
|
||||||
<div className="absolute top-[calc(0.75rem+env(safe-area-inset-top,0px))] left-4 z-40 md:hidden flex items-center gap-2 bg-slate-950/70 backdrop-blur-md px-2.5 py-1.5 rounded-full border border-slate-700/50">
|
|
||||||
<div className="w-5 h-5 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
{/* Image fill */}
|
||||||
<User className="w-3 h-3 text-emerald-400" />
|
<a
|
||||||
</div>
|
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
|
||||||
<span className="text-xs text-slate-200 max-w-[120px] truncate">
|
className="absolute inset-0 flex items-center justify-center cursor-zoom-in"
|
||||||
{photo.uploader?.name || 'Ẩn danh'}
|
onClick={(e) => {
|
||||||
</span>
|
e.preventDefault();
|
||||||
|
setIsFullscreen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={photo.imageUrl}
|
||||||
|
alt="Public Map Upload"
|
||||||
|
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
|
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
|
className="w-full h-full object-contain select-none pointer-events-none"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
{(!isAuthorized || !isLoggedIn) && (
|
||||||
|
<div className="absolute inset-0 bg-transparent select-none z-10" />
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* TIER 3.1: LOCATION & TIMESTAMP OVERLAY — bottom-left inside image (z-30) */}
|
||||||
|
<div className="absolute bottom-3 left-3 z-30 flex flex-col gap-0.5 max-w-[70vw] md:max-w-[45%] pointer-events-none drop-shadow-[0_1px_3px_rgba(0,0,0,0.9)]">
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] font-bold text-blue-300">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-red-500 shrink-0" />
|
||||||
|
<span className="truncate leading-tight">{resolvedAddress}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-[9px] text-blue-200/80 pl-5">
|
||||||
|
<Calendar className="w-2.5 h-2.5 text-blue-300/70 shrink-0" />
|
||||||
|
<span>{new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
})}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Like Button Overlay */}
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleToggleLike();
|
|
||||||
}}
|
|
||||||
className="absolute bottom-4 right-4 z-40 flex items-center gap-1.5 bg-slate-950/70 hover:bg-slate-900/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md md:absolute md:top-[calc(1rem+env(safe-area-inset-top,0px))] md:left-4 md:bottom-auto md:right-auto"
|
|
||||||
title={isLiked ? "Bỏ thích" : "Thích"}
|
|
||||||
>
|
|
||||||
<Heart className={`w-4 h-4 transition-colors ${
|
|
||||||
isLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-slate-350 hover:text-rose-450'
|
|
||||||
}`} />
|
|
||||||
<span>{likeCount}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
|
|
||||||
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center relative"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsFullscreen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={photo.imageUrl}
|
|
||||||
alt="Public Map Upload"
|
|
||||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
|
||||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
|
||||||
className={`w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none select-none ${
|
|
||||||
!isLoggedIn ? 'pointer-events-none' : ''
|
|
||||||
}`}
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
{/* Shield overlay to prevent Save Image As on right-click / long press for non-owners */}
|
|
||||||
{(!isAuthorized || !isLoggedIn) && (
|
|
||||||
<div className="absolute inset-0 bg-transparent select-none z-10" />
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info & Timeline overlay inside photo panel */}
|
{/* Like button — bottom-right on mobile, top-left on desktop */}
|
||||||
<div className="relative z-20 p-6 bg-slate-900 border-b border-slate-800/60 md:absolute md:bottom-0 md:left-0 md:right-0 md:bg-gradient-to-t md:from-black/60 md:via-black/20 md:to-transparent md:border-b-0 flex flex-col gap-4">
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
{/* Timeline scroll */}
|
e.stopPropagation();
|
||||||
|
handleToggleLike();
|
||||||
|
}}
|
||||||
|
className="absolute bottom-3 right-3 z-30 flex items-center gap-1 bg-black/55 backdrop-blur-sm border border-white/10 rounded-xl px-2.5 py-1.5 text-[11px] font-bold transition-all active:scale-95 md:top-[calc(1rem+env(safe-area-inset-top,0px))] md:left-4 md:bottom-auto md:right-auto"
|
||||||
|
title={isLiked ? "Bỏ thích" : "Thích"}
|
||||||
|
>
|
||||||
|
<Heart className={`w-3.5 h-3.5 transition-colors ${
|
||||||
|
isLiked ? 'text-rose-500 fill-rose-500' : 'text-white'
|
||||||
|
}`} />
|
||||||
|
<span className="text-white">{likeCount}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Uploader pill — top-left on mobile */}
|
||||||
|
<div className="absolute top-[calc(0.75rem+env(safe-area-inset-top,0px))] left-3 z-30 md:hidden flex items-center gap-1.5 bg-black/55 backdrop-blur-sm px-2.5 py-1 rounded-full border border-white/10">
|
||||||
|
<div className="w-4 h-4 rounded-full bg-emerald-500/30 flex items-center justify-center">
|
||||||
|
<User className="w-2.5 h-2.5 text-emerald-300" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-200 max-w-[110px] truncate font-semibold">
|
||||||
|
{photo.uploader?.name || 'Ẩn danh'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: overlay metadata gradient at bottom of photo column */}
|
||||||
|
<div className="hidden md:flex absolute bottom-0 left-0 right-0 z-20 px-6 pb-5 pt-12 bg-gradient-to-t from-black/70 via-black/20 to-transparent flex-col gap-1 pointer-events-none">
|
||||||
|
<h4 className="text-sm font-black text-white tracking-tight leading-snug drop-shadow">
|
||||||
|
{photo.metadata?.title || ''}
|
||||||
|
</h4>
|
||||||
|
{photo.metadata?.description && (
|
||||||
|
<p className="text-xs text-slate-300 leading-snug line-clamp-2 drop-shadow">{photo.metadata.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-slate-300 mt-1">
|
||||||
|
<MapPin className="w-3 h-3 text-rose-400 shrink-0" />
|
||||||
|
<span className="truncate">{resolvedAddress}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-slate-400">
|
||||||
|
<Calendar className="w-2.5 h-2.5 shrink-0" />
|
||||||
|
<span>{new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||||
|
})}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop thumbnail timeline carousel at the bottom */}
|
||||||
|
{photoGroup && photoGroup.length > 1 && (
|
||||||
|
<div className="hidden md:flex absolute bottom-0 left-0 right-0 z-20 px-4 pb-3 gap-2 overflow-x-auto no-scrollbar justify-end items-end pointer-events-auto">
|
||||||
|
{photoGroup.map((p) => {
|
||||||
|
const isActive = p.id === photo.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onSelectPhoto?.(p); }}
|
||||||
|
className={`relative w-10 h-10 rounded-xl overflow-hidden transition-all active:scale-95 shrink-0 ${
|
||||||
|
isActive ? 'border-2 border-emerald-400 scale-105 shadow-lg' : 'border border-slate-600/50 opacity-70 hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img src={p.imageUrl} alt="thumb" className="w-full h-full object-cover pointer-events-none" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ============================================================
|
||||||
|
TIER 2: SCROLLABLE COMMENTS PANEL (z-10)
|
||||||
|
Slides BEHIND the sticky image frame when swiping up on mobile.
|
||||||
|
Desktop: right 2/5 column with its own overflow-y-auto.
|
||||||
|
============================================================ */}
|
||||||
|
<div className="flex-1 overflow-y-auto relative z-10 flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800 md:w-2/5 md:flex-1 md:min-h-0">
|
||||||
|
|
||||||
|
{/* ---- TITLE HEADER: replaces old "Lịch sử ảnh tại vị trí này" strip ---- */}
|
||||||
|
<div className="px-5 pt-4 pb-0 bg-slate-900/95 backdrop-blur-md border-b border-slate-800/60 sticky top-0 z-10 flex flex-col gap-2">
|
||||||
|
|
||||||
|
{/* Row 1: Title + Edit button */}
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-sm font-black text-slate-100 tracking-wide leading-snug">
|
||||||
|
{photo.metadata?.title || 'Chưa có tiêu đề'}
|
||||||
|
</h2>
|
||||||
|
{photo.metadata?.description && (
|
||||||
|
<p className="text-[11px] text-slate-400 leading-relaxed mt-0.5 line-clamp-2">
|
||||||
|
{photo.metadata.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isAuthorized && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
|
||||||
|
className="p-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700/50 rounded-xl text-slate-300 hover:text-white transition-all shrink-0"
|
||||||
|
title="Chỉnh sửa thông tin"
|
||||||
|
>
|
||||||
|
<Edit className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Thumbnail timeline strip — below title, mobile only */}
|
||||||
{photoGroup && photoGroup.length > 1 && (
|
{photoGroup && photoGroup.length > 1 && (
|
||||||
<div className="flex flex-col gap-2 border-b border-slate-800/80 pb-3 relative z-20">
|
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-3 md:hidden">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
|
{photoGroup.map((p) => {
|
||||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
|
const isActive = p.id === photo.id;
|
||||||
Lịch sử ảnh tại vị trí này ({photoGroup.length})
|
const dateObj = new Date(p.capturedAt);
|
||||||
</span>
|
const day = String(dateObj.getDate()).padStart(2, '0');
|
||||||
<div className="flex gap-3 overflow-x-auto no-scrollbar py-1 relative z-20">
|
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||||
{photoGroup.map((p) => {
|
return (
|
||||||
const isActive = p.id === photo.id;
|
<button
|
||||||
return (
|
key={p.id}
|
||||||
<button
|
onClick={(e) => { e.stopPropagation(); onSelectPhoto?.(p); }}
|
||||||
key={p.id}
|
className={`relative w-12 h-12 rounded-2xl overflow-hidden transition-all active:scale-95 shrink-0 ${
|
||||||
onClick={(e) => {
|
isActive
|
||||||
e.stopPropagation();
|
? 'border-[3px] border-emerald-500 shadow-lg shadow-emerald-500/25'
|
||||||
onSelectPhoto?.(p);
|
: 'border-2 border-slate-600 hover:border-slate-400'
|
||||||
}}
|
}`}
|
||||||
className={`relative w-12 h-12 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
|
>
|
||||||
isActive ? 'border-emerald-500 scale-110 shadow-lg' : 'border-slate-700 hover:border-slate-500'
|
<img
|
||||||
}`}
|
src={p.imageUrl}
|
||||||
>
|
alt="thumb"
|
||||||
<img
|
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
src={p.imageUrl}
|
className="w-full h-full object-cover pointer-events-none"
|
||||||
alt="Timeline thumbnail"
|
/>
|
||||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
<div className="absolute bottom-0 inset-x-0 bg-slate-950/70 text-[7px] text-center font-black text-slate-200 py-px tracking-wide">
|
||||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
{day}-{month}
|
||||||
className={`w-full h-full object-cover ${
|
</div>
|
||||||
!isLoggedIn ? 'pointer-events-none' : ''
|
</button>
|
||||||
}`}
|
);
|
||||||
/>
|
})}
|
||||||
<div className="absolute bottom-0 inset-x-0 bg-slate-950/70 text-[8px] text-center font-bold text-slate-300 py-0.5">
|
|
||||||
{new Date(p.capturedAt).toLocaleDateString('vi-VN', { month: '2-digit', day: '2-digit' })}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isEditing ? (
|
{/* Edit form (when editing) */}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="px-5 py-4 border-b border-slate-800/60 bg-slate-900/95">
|
||||||
<div className="flex flex-col gap-3 bg-slate-900/95 border border-slate-800 p-4 rounded-2xl animate-in slide-in-from-bottom-2">
|
<div className="flex flex-col gap-3 bg-slate-900/95 border border-slate-800 p-4 rounded-2xl animate-in slide-in-from-bottom-2">
|
||||||
<h4 className="text-xs font-black uppercase tracking-wider text-emerald-400">Chỉnh sửa thông tin ảnh</h4>
|
<h4 className="text-xs font-black uppercase tracking-wider text-emerald-400">Chỉnh sửa thông tin ảnh</h4>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Tiêu đề</label>
|
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Tiêu đề</label>
|
||||||
<input
|
<input type="text" value={editTitle} onChange={(e) => setEditTitle(e.target.value)}
|
||||||
type="text"
|
|
||||||
value={editTitle}
|
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
|
||||||
placeholder="Nhập tiêu đề cho ảnh..."
|
placeholder="Nhập tiêu đề cho ảnh..."
|
||||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500" />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Mô tả</label>
|
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Mô tả</label>
|
||||||
<textarea
|
<textarea value={editDescription} onChange={(e) => setEditDescription(e.target.value)}
|
||||||
value={editDescription}
|
placeholder="Mô tả bức ảnh này..." rows={2}
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none" />
|
||||||
placeholder="Mô tả bức ảnh này..."
|
|
||||||
rows={2}
|
|
||||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Vĩ độ</label>
|
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Vĩ độ</label>
|
||||||
<input
|
<input type="number" step="any" value={editLat}
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
value={editLat}
|
|
||||||
onChange={(e) => setEditLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
onChange={(e) => setEditLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
||||||
placeholder="Vĩ độ..."
|
placeholder="Vĩ độ..."
|
||||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500" />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Kinh độ</label>
|
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Kinh độ</label>
|
||||||
<input
|
<input type="number" step="any" value={editLng}
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
value={editLng}
|
|
||||||
onChange={(e) => setEditLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
onChange={(e) => setEditLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
||||||
placeholder="Kinh độ..."
|
placeholder="Kinh độ..."
|
||||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500" />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); setIsMapOpen(true); }}
|
||||||
<div className="flex justify-start">
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white rounded-xl text-[10px] font-bold transition-all">
|
||||||
<button
|
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
||||||
type="button"
|
Chọn trên bản đồ
|
||||||
onClick={(e) => {
|
</button>
|
||||||
e.stopPropagation();
|
|
||||||
setIsMapOpen(true);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white rounded-xl text-[10px] font-bold transition-all"
|
|
||||||
>
|
|
||||||
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
|
||||||
Chọn trên bản đồ
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 mt-1">
|
||||||
<div className="flex justify-end gap-2 mt-2">
|
<button onClick={(e) => { e.stopPropagation(); setIsEditing(false); }} disabled={isSavingEdit}
|
||||||
<button
|
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-bold transition-all">
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsEditing(false);
|
|
||||||
}}
|
|
||||||
disabled={isSavingEdit}
|
|
||||||
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-bold transition-all"
|
|
||||||
>
|
|
||||||
Hủy
|
Hủy
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={(e) => { e.stopPropagation(); handleSaveEdit(); }} disabled={isSavingEdit}
|
||||||
onClick={(e) => {
|
className="flex items-center gap-1 px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all">
|
||||||
e.stopPropagation();
|
{isSavingEdit ? (<><Loader2 className="w-3 h-3 animate-spin" />Đang lưu...</>) : 'Lưu lại'}
|
||||||
handleSaveEdit();
|
|
||||||
}}
|
|
||||||
disabled={isSavingEdit}
|
|
||||||
className="flex items-center gap-1 px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
|
|
||||||
>
|
|
||||||
{isSavingEdit ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
|
||||||
Đang lưu...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Lưu lại'
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Title & Description display */}
|
|
||||||
<div className="flex justify-between items-start gap-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{photo.metadata?.title ? (
|
|
||||||
<h4 className="text-sm font-black text-white tracking-tight leading-snug break-words">
|
|
||||||
{photo.metadata.title}
|
|
||||||
</h4>
|
|
||||||
) : (
|
|
||||||
<span className="text-[10px] text-slate-500 italic block mb-1">Chưa có tiêu đề</span>
|
|
||||||
)}
|
|
||||||
{photo.metadata?.description ? (
|
|
||||||
<p className="text-xs text-slate-300 leading-relaxed mt-1 max-h-20 overflow-y-auto no-scrollbar break-words">
|
|
||||||
{photo.metadata.description}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<span className="text-[10px] text-slate-500 italic block mt-1">Chưa có mô tả</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isAuthorized && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsEditing(true);
|
|
||||||
}}
|
|
||||||
className="p-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700/50 rounded-xl text-slate-300 hover:text-white transition-all shrink-0"
|
|
||||||
title="Chỉnh sửa thông tin"
|
|
||||||
>
|
|
||||||
<Edit className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Photo Metadata */}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-slate-800/80 pt-3 text-xs text-slate-350">
|
|
||||||
<div className="space-y-1 text-left">
|
|
||||||
<div className="flex items-center gap-1.5 text-slate-400">
|
|
||||||
<Calendar className="w-3.5 h-3.5" />
|
|
||||||
Ngày chụp: {new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 text-slate-400" title={photo.metadata?.lat && photo.metadata?.lng ? `${photo.metadata.lat.toFixed(6)}, ${photo.metadata.lng.toFixed(6)}` : ''}>
|
|
||||||
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
|
||||||
Địa điểm: {resolvedAddress}
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:flex items-center gap-1.5 text-xs text-emerald-400">
|
|
||||||
<User className="w-3.5 h-3.5" />
|
|
||||||
Người đăng: {photo.uploader?.name || 'Ẩn danh'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isAuthorized && photo.originalUrl && (
|
|
||||||
<a
|
|
||||||
href={photo.originalUrl}
|
|
||||||
download
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1.5 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1 px-3 rounded-full transition-all active:scale-95 text-[10px]"
|
|
||||||
>
|
|
||||||
<Download className="w-3.5 h-3.5 text-emerald-500" />
|
|
||||||
Tải ảnh gốc
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<style>{`
|
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.no-scrollbar {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Side: Comments */}
|
|
||||||
<div className="w-full md:w-2/5 md:flex-1 md:min-h-0 flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800">
|
|
||||||
|
|
||||||
{/* Comments Header */}
|
|
||||||
<div className="hidden md:block p-6 border-b border-slate-800">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-black tracking-tight text-white flex items-center gap-2">
|
|
||||||
<MessageSquare className="w-5 h-5 text-emerald-500" />
|
|
||||||
{t('commentSectionTitle')}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-slate-400 mt-1">Ảnh chia sẻ công khai trên bản đồ</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Download original (authorized) */}
|
||||||
|
{isAuthorized && photo.originalUrl && (
|
||||||
|
<div className="px-5 py-2 border-b border-slate-800/40 flex justify-end">
|
||||||
|
<a href={photo.originalUrl} download target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1 px-3 rounded-full transition-all active:scale-95 text-[10px]">
|
||||||
|
<Download className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
Tải ảnh gốc
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- COMMENTS HEADER (desktop) ---- */}
|
||||||
|
<div className="hidden md:block px-6 py-4 border-b border-slate-800">
|
||||||
|
<h3 className="text-base font-black tracking-tight text-white flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-4 h-4 text-emerald-500" />
|
||||||
|
{t('commentSectionTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Ảnh chia sẻ công khai trên bản đồ</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comments list scroll area */}
|
{/* ---- COMMENT FEED ---- */}
|
||||||
<div className="md:flex-1 md:overflow-y-auto p-6 space-y-4 bg-slate-900/50">
|
<div className="p-4 space-y-4 flex-1">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-2">
|
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-2">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-emerald-500" />
|
<Loader2 className="w-8 h-8 animate-spin text-emerald-500" />
|
||||||
<span className="text-xs font-semibold">{t('loading')}</span>
|
<span className="text-xs font-semibold">{t('loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : comments.length === 0 ? (
|
) : comments.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500 gap-3">
|
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-3">
|
||||||
<div className="p-4 bg-slate-800/40 rounded-full text-slate-600">
|
<div className="p-4 bg-slate-800/40 rounded-full text-slate-600">
|
||||||
<MessageSquare className="w-8 h-8" />
|
<MessageSquare className="w-7 h-7" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold italic">Chưa có bình luận nào. Hãy bắt đầu cuộc trò chuyện!</span>
|
<span className="text-sm font-semibold italic">Chưa có bình luận nào. Hãy bắt đầu cuộc trò chuyện!</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
comments.map((c) => {
|
comments.map((c) => (
|
||||||
return (
|
<div key={c.id} className="flex gap-3 items-start animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||||
<div key={c.id} className="flex gap-3 items-start animate-in fade-in slide-in-from-bottom-2 duration-200">
|
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-700/60">
|
||||||
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-700/60">
|
<User className="w-4 h-4 text-slate-400" />
|
||||||
<User className="w-4.5 h-4.5 text-slate-400" />
|
</div>
|
||||||
</div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
|
||||||
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
|
<div className="flex justify-between items-center mb-1">
|
||||||
<div className="flex justify-between items-center mb-1">
|
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
|
||||||
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-[9px] font-medium text-slate-500">
|
||||||
<span className="text-[9px] font-medium text-slate-500">
|
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
</span>
|
||||||
</span>
|
{(currentUser?.isAdmin || currentUser?.id === c.userId ||
|
||||||
{(currentUser?.isAdmin ||
|
currentUser?.id === photo.uploaderId || currentUser?.id === photo.uploader?.id) && (
|
||||||
currentUser?.id === c.userId ||
|
<button
|
||||||
currentUser?.id === photo.uploaderId ||
|
onClick={(e) => { e.stopPropagation(); handleDeleteComment(c.id); }}
|
||||||
currentUser?.id === photo.uploader?.id) && (
|
className="text-slate-500 hover:text-rose-500 transition-colors p-0.5"
|
||||||
<button
|
title={t('delete') || "Xóa"}
|
||||||
onClick={(e) => {
|
>
|
||||||
e.stopPropagation();
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
handleDeleteComment(c.id);
|
</button>
|
||||||
}}
|
)}
|
||||||
className="text-slate-500 hover:text-rose-500 transition-colors p-0.5"
|
|
||||||
title={t('delete') || "Xóa"}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={commentsEndRef} />
|
<div ref={commentsEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comment Input Area */}
|
{/* ---- STICKY COMMENT INPUT TRAY BAR (z-20 stays above comments) ---- */}
|
||||||
<div className="sticky bottom-0 md:static p-4 bg-slate-950 md:bg-slate-950/40 border-t border-slate-800/80 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] md:pb-4 z-40">
|
<div className="sticky bottom-0 z-20 p-4 bg-slate-950 border-t border-slate-800/80 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] md:pb-4">
|
||||||
<div className="relative flex items-center gap-2">
|
<div className="relative flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -790,22 +740,19 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
disabled={isSending}
|
disabled={isSending}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); handleSend(); }}
|
||||||
e.stopPropagation();
|
|
||||||
handleSend();
|
|
||||||
}}
|
|
||||||
disabled={!newComment.trim() || isSending}
|
disabled={!newComment.trim() || isSending}
|
||||||
className="p-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-40 text-white rounded-2xl transition-all active:scale-95 shadow-lg shadow-emerald-950/30 flex items-center justify-center"
|
className="p-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-40 text-white rounded-2xl transition-all active:scale-95 shadow-lg flex items-center justify-center"
|
||||||
>
|
>
|
||||||
{isSending ? (
|
{isSending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||||
<Loader2 className="w-4.5 h-4.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send className="w-4.5 h-4.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user