feat: tải ảnh lên bằng tài khoản public và cho phép bình luận
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { LogIn, Compass, Map as MapIcon, Camera } from 'lucide-react';
|
||||
import { LoginModal } from '../components/LoginModal';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
|
||||
interface LandingPageProps {
|
||||
onContinue?: () => void;
|
||||
@@ -11,63 +12,209 @@ interface LandingPageProps {
|
||||
}
|
||||
|
||||
// Component Animation cho người lữ hành
|
||||
const TravelerAnimation = () => (
|
||||
<div className="relative w-64 h-64">
|
||||
{/* 1. Animation người lữ hành (SVG Silhouette) */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ animation: 'travelerFadeIn 4s cubic-bezier(0.4, 0, 0.6, 1) infinite' }}
|
||||
>
|
||||
{/* Thân người */}
|
||||
<path d="M 50,30 C 40,30 35,40 35,50 L 35,90 L 65,90 L 65,50 C 65,40 60,30 50,30 Z" fill="rgba(255,255,255,0.1)" />
|
||||
{/* Máy ảnh */}
|
||||
<rect x="42" y="45" width="16" height="10" rx="2" fill="rgba(255,255,255,0.5)" />
|
||||
</svg>
|
||||
|
||||
{/* 2. Animation đèn flash (giữ nguyên) */}
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 w-48 h-48 md:w-64 md:h-64 bg-white rounded-full"
|
||||
const TravelerAnimation = ({ onClick }: { onClick?: () => void }) => {
|
||||
return <button
|
||||
onClick={onClick}
|
||||
className="relative w-48 h-48 flex items-center justify-center transition-transform active:scale-95 focus:outline-none"
|
||||
style={{
|
||||
transform: 'translate(-50%, -50%)',
|
||||
animation: 'flash 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
animation: 'gentle-shake 5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 3. Icon máy ảnh tĩnh để luôn hiển thị */}
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-50">
|
||||
<Camera className="w-20 h-20 md:w-24 md:h-24 text-white/60 drop-shadow-xl" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
{/* 4. Thêm keyframes cho cả hai animation */}
|
||||
<style>{`
|
||||
@keyframes travelerFadeIn {
|
||||
0%, 40%, 100% { opacity: 0; transform: scale(0.8); }
|
||||
50%, 90% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes flash {
|
||||
0%, 50%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.5); }
|
||||
55% { opacity: 0.6; transform: translate(-50%, -50%) scale(1.2); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
aria-label="Khám phá bản đồ"
|
||||
>
|
||||
{/* Giảm kích thước icon xuống 75% (w-24 -> w-18, md:w-28 -> md:w-21) */}
|
||||
<Camera className="w-18 h-18 md:w-21 md:h-21 text-white/80 drop-shadow-2xl" strokeWidth={1.5} />
|
||||
{/* Keyframes cho animation rung lắc */}
|
||||
<style>{`
|
||||
@keyframes gentle-shake {
|
||||
0%, 100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(2deg) scale(1.05);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</button>;
|
||||
};
|
||||
|
||||
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup, onGoToMap, onLoginSuccess, isInitialSetup }) => {
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const notify = useNotification();
|
||||
|
||||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||||
const [currentBgIndex, setCurrentBgIndex] = useState(0);
|
||||
const [bg1, setBg1] = useState('/background.avif');
|
||||
const [bg2, setBg2] = useState('');
|
||||
const [fade1, setFade1] = useState(true);
|
||||
const [fade2, setFade2] = useState(false);
|
||||
const [activeSlot, setActiveSlot] = useState<1 | 2>(1);
|
||||
|
||||
useEffect(() => {
|
||||
const nextUrl = publicPhotos.length > 0 ? publicPhotos[currentBgIndex]?.imageUrl : '/background.avif';
|
||||
if (!nextUrl) return;
|
||||
|
||||
const currentUrl = activeSlot === 1 ? bg1 : bg2;
|
||||
if (nextUrl === currentUrl) return;
|
||||
|
||||
if (activeSlot === 1) {
|
||||
setBg2(nextUrl);
|
||||
setFade2(true);
|
||||
setFade1(false);
|
||||
setActiveSlot(2);
|
||||
} else {
|
||||
setBg1(nextUrl);
|
||||
setFade1(true);
|
||||
setFade2(false);
|
||||
setActiveSlot(1);
|
||||
}
|
||||
}, [currentBgIndex, publicPhotos]);
|
||||
|
||||
const fetchPublicPhotos = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/public-photos');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPublicPhotos(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Lỗi khi tải ảnh công khai:', e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPublicPhotos();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (publicPhotos.length <= 1) return;
|
||||
const interval = setInterval(() => {
|
||||
setCurrentBgIndex((prev) => (prev + 1) % publicPhotos.length);
|
||||
}, 12000);
|
||||
return () => clearInterval(interval);
|
||||
}, [publicPhotos]);
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
||||
|
||||
try {
|
||||
// Lấy tọa độ hiện tại của người dùng để làm tọa độ dự phòng
|
||||
const location = await new Promise<GeolocationPosition | null>((resolve) => {
|
||||
if (!navigator.geolocation) {
|
||||
resolve(null);
|
||||
} else {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => resolve(pos),
|
||||
() => resolve(null),
|
||||
{ timeout: 5000, enableHighAccuracy: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Tạo tài khoản khách và lấy token
|
||||
let guestToken = localStorage.getItem('guest_token');
|
||||
let guestUser = JSON.parse(localStorage.getItem('guest_user') || 'null');
|
||||
|
||||
if (!guestToken) {
|
||||
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
|
||||
if (!guestRes.ok) throw new Error('Không thể tạo phiên khách.');
|
||||
const guestData = await guestRes.json();
|
||||
guestToken = guestData.access_token;
|
||||
guestUser = guestData.user;
|
||||
localStorage.setItem('guest_token', guestToken!);
|
||||
localStorage.setItem('guest_user', JSON.stringify(guestUser));
|
||||
}
|
||||
|
||||
// 2. Tải ảnh lên
|
||||
const formData = new FormData();
|
||||
formData.append('images', file);
|
||||
if (location) {
|
||||
formData.append('latitude', location.coords.latitude.toString());
|
||||
formData.append('longitude', location.coords.longitude.toString());
|
||||
}
|
||||
|
||||
const uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${guestToken!}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
const errorData = await uploadRes.json();
|
||||
throw new Error(errorData.message || 'Tải ảnh thất bại.');
|
||||
}
|
||||
|
||||
notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải lên.', type: 'success' });
|
||||
|
||||
// Cập nhật lại danh sách ảnh lập tức
|
||||
await fetchPublicPhotos();
|
||||
setCurrentBgIndex(0);
|
||||
|
||||
// Đăng nhập luôn bằng tài khoản khách này để người dùng xem được ảnh của mình trên bản đồ
|
||||
localStorage.setItem('token', guestToken!);
|
||||
localStorage.setItem('user', JSON.stringify(guestUser));
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(guestUser);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
notify({ title: 'Lỗi', message: error.message, type: 'error' });
|
||||
} finally {
|
||||
// Reset input để có thể chọn lại cùng 1 file
|
||||
if (event.target) event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Sử dụng trực tiếp ảnh nền AVIF từ thư mục `public`
|
||||
const backgroundImage = '/background.avif'; // Giả sử bạn đặt tên file là background.avif
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full overflow-hidden font-sans bg-gray-900 relative">
|
||||
{/* Background Image - Hiển thị trên mọi thiết bị */}
|
||||
<img
|
||||
src={backgroundImage}
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-60"
|
||||
alt="Travel Background"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-black/20" />
|
||||
{/* Background Image - Hiển thị trên mọi thiết bị với hiệu ứng Crossfade & Panning */}
|
||||
<div className="absolute inset-0 z-0 bg-gray-950 overflow-hidden">
|
||||
{/* Slot 1 */}
|
||||
{bg1 && (
|
||||
<img
|
||||
key={bg1}
|
||||
src={bg1}
|
||||
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
|
||||
style={{
|
||||
opacity: fade1 ? 0.8 : 0,
|
||||
}}
|
||||
alt="Travel Background 1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Slot 2 */}
|
||||
{bg2 && (
|
||||
<img
|
||||
key={bg2}
|
||||
src={bg2}
|
||||
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
|
||||
style={{
|
||||
opacity: fade2 ? 0.8 : 0,
|
||||
}}
|
||||
alt="Travel Background 2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyframes cho hiệu ứng panning từ trái sang phải */}
|
||||
<style>{`
|
||||
@keyframes pan-left-to-right {
|
||||
0% {
|
||||
transform: translateX(0) translateZ(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-16.667%) translateZ(0);
|
||||
}
|
||||
}
|
||||
.animate-panning {
|
||||
animation: pan-left-to-right 13500ms linear forwards;
|
||||
will-change: transform;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Top Bar - Thanh điều hướng trên cùng */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 p-4 flex justify-between items-center">
|
||||
@@ -85,10 +232,53 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
||||
</div>
|
||||
|
||||
{/* Body: Animation người lữ hành */}
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none">
|
||||
<TravelerAnimation />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10" >
|
||||
{/* Kích hoạt click vào file input để chọn hoặc chụp ảnh */}
|
||||
<TravelerAnimation onClick={() => fileInputRef.current?.click()} />
|
||||
</div>
|
||||
|
||||
{/* Input chọn file ẩn để chụp/chọn ảnh */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Community Gallery Previews */}
|
||||
{publicPhotos.length > 0 && (
|
||||
<div className="absolute bottom-28 left-0 right-0 z-20 px-4 flex flex-col items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-white/70 drop-shadow-md">
|
||||
Khoảnh khắc từ cộng đồng ({publicPhotos.length})
|
||||
</span>
|
||||
<div className="flex gap-3 overflow-x-auto max-w-full no-scrollbar pb-2 px-4 justify-center">
|
||||
{publicPhotos.slice(0, 8).map((photo, index) => (
|
||||
<button
|
||||
key={photo.id}
|
||||
onClick={() => setCurrentBgIndex(index)}
|
||||
className={`relative w-14 h-14 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
|
||||
currentBgIndex === index ? 'border-emerald-500 scale-110 shadow-lg' : 'border-white/20 hover:border-white/50'
|
||||
}`}
|
||||
>
|
||||
<img src={photo.imageUrl} alt="Community thumbnail" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Bar - Nút hành động chính */}
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-20 w-full px-4">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user