mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-22 02:24:44 +08:00
feat: douban page
This commit is contained in:
@@ -88,6 +88,10 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (tag === 'top250') {
|
||||
return handleTop250(pageStart);
|
||||
}
|
||||
|
||||
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
|
||||
|
||||
try {
|
||||
@@ -114,3 +118,71 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTop250(pageStart: number) {
|
||||
const target = `https://movie.douban.com/top250?start=${pageStart}&filter=`;
|
||||
|
||||
// 直接使用 fetch 获取 HTML 页面
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const fetchOptions = {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
Referer: 'https://movie.douban.com/',
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
},
|
||||
};
|
||||
|
||||
return fetch(target, fetchOptions)
|
||||
.then(async (fetchResponse) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!fetchResponse.ok) {
|
||||
throw new Error(`HTTP error! Status: ${fetchResponse.status}`);
|
||||
}
|
||||
|
||||
// 获取 HTML 内容
|
||||
const html = await fetchResponse.text();
|
||||
|
||||
// 使用正则表达式提取电影信息
|
||||
const moviePattern =
|
||||
/<div class="item">[\s\S]*?<img.*?alt="([^"]*)"[\s\S]*?src="([^"]*)"[\s\S]*?<\/div>/g;
|
||||
const movies: DoubanItem[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = moviePattern.exec(html)) !== null) {
|
||||
const title = match[1];
|
||||
const cover = match[2];
|
||||
|
||||
// 处理图片 URL,确保使用 HTTPS
|
||||
const processedCover = cover.replace(/^http:/, 'https:');
|
||||
|
||||
movies.push({
|
||||
title: title,
|
||||
poster: processedCover,
|
||||
});
|
||||
}
|
||||
|
||||
const apiResponse: DoubanResponse = {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: movies,
|
||||
};
|
||||
|
||||
return NextResponse.json(apiResponse);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '获取豆瓣 Top250 数据失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Image from 'next/image';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
|
||||
import { VideoDetail } from '../api/detail/route';
|
||||
|
||||
@@ -43,23 +43,27 @@ export default function DetailPage() {
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen'>
|
||||
<Sidebar activePath='/detail' />
|
||||
<main className='flex-1 p-8 flex flex-col items-center'>
|
||||
<PageLayout activePath='/detail'>
|
||||
<div className='px-10 py-8 overflow-visible'>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center min-h-screen'>
|
||||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||||
<div className='animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500'></div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex items-center justify-center min-h-screen'>
|
||||
<div className='text-red-500'>{error}</div>
|
||||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||||
<div className='text-red-500 text-center'>
|
||||
<div className='text-lg font-semibold mb-2'>加载失败</div>
|
||||
<div className='text-sm'>{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !detail ? (
|
||||
<div className='flex items-center justify-center min-h-screen'>
|
||||
<div className='text-gray-500'>未找到视频详情</div>
|
||||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||||
<div className='text-gray-500 text-center'>
|
||||
<div className='text-lg font-semibold mb-2'>未找到视频详情</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='w-full max-w-[95%]'>
|
||||
<div className='max-w-[95%] mx-auto'>
|
||||
{/* 主信息区:左图右文 */}
|
||||
<div className='flex flex-col md:flex-row gap-8 mb-8 bg-transparent rounded-xl p-6'>
|
||||
{/* 封面 */}
|
||||
@@ -152,7 +156,7 @@ export default function DetailPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
250
src/app/douban/page.tsx
Normal file
250
src/app/douban/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import DemoCard from '@/components/DemoCard';
|
||||
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
|
||||
// 定义豆瓣数据项类型
|
||||
interface DoubanItem {
|
||||
title: string;
|
||||
poster: string;
|
||||
}
|
||||
|
||||
// 定义豆瓣响应类型
|
||||
interface DoubanResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
list: DoubanItem[];
|
||||
}
|
||||
|
||||
export default function DoubanPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [doubanData, setDoubanData] = useState<DoubanItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const type = searchParams.get('type');
|
||||
const tag = searchParams.get('tag');
|
||||
|
||||
// 生成骨架屏数据
|
||||
const skeletonData = Array.from({ length: 20 }, (_, index) => index);
|
||||
|
||||
useEffect(() => {
|
||||
if (!type || !tag) {
|
||||
setError('缺少必要参数: type 或 tag');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置页面状态
|
||||
setDoubanData([]);
|
||||
setCurrentPage(0);
|
||||
setHasMore(true);
|
||||
setError(null);
|
||||
setIsLoadingMore(false);
|
||||
|
||||
// 立即加载第一页数据
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/douban?type=${type}&tag=${tag}&pageSize=25&pageStart=0`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取豆瓣数据失败');
|
||||
}
|
||||
|
||||
const data: DoubanResponse = await response.json();
|
||||
|
||||
if (data.code === 200) {
|
||||
setDoubanData(data.list);
|
||||
setHasMore(data.list.length === 25);
|
||||
} else {
|
||||
throw new Error(data.message || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取豆瓣数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
}, [type, tag]);
|
||||
|
||||
// 单独处理 currentPage 变化(加载更多)
|
||||
useEffect(() => {
|
||||
if (currentPage > 0 && type && tag) {
|
||||
const fetchMoreData = async () => {
|
||||
try {
|
||||
setIsLoadingMore(true);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/douban?type=${type}&tag=${tag}&pageSize=25&pageStart=${
|
||||
currentPage * 25
|
||||
}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取豆瓣数据失败');
|
||||
}
|
||||
|
||||
const data: DoubanResponse = await response.json();
|
||||
|
||||
if (data.code === 200) {
|
||||
setDoubanData((prev) => [...prev, ...data.list]);
|
||||
setHasMore(data.list.length === 25);
|
||||
} else {
|
||||
throw new Error(data.message || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取豆瓣数据失败');
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMoreData();
|
||||
}
|
||||
}, [currentPage, type, tag]);
|
||||
|
||||
// 设置滚动监听
|
||||
useEffect(() => {
|
||||
// 如果没有更多数据或正在加载,则不设置监听
|
||||
if (!hasMore || isLoadingMore || loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保 loadingRef 存在
|
||||
if (!loadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !isLoadingMore) {
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(loadingRef.current);
|
||||
observerRef.current = observer;
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [hasMore, isLoadingMore, loading]);
|
||||
|
||||
const getPageTitle = () => {
|
||||
// 优先使用 URL 中的 title 参数
|
||||
const titleParam = searchParams.get('title');
|
||||
if (titleParam) {
|
||||
return titleParam;
|
||||
}
|
||||
|
||||
// 如果 title 参数不存在,根据 type 和 tag 拼接
|
||||
if (!type || !tag) return '豆瓣内容';
|
||||
|
||||
const typeText = type === 'movie' ? '电影' : '电视剧';
|
||||
const tagText = tag === 'top250' ? 'Top250' : tag;
|
||||
|
||||
return `${typeText} - ${tagText}`;
|
||||
};
|
||||
|
||||
const getActivePath = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (type) params.set('type', type);
|
||||
if (tag) params.set('tag', tag);
|
||||
const titleParam = searchParams.get('title');
|
||||
if (titleParam) params.set('title', titleParam);
|
||||
|
||||
const queryString = params.toString();
|
||||
const activePath = `/douban${queryString ? `?${queryString}` : ''}`;
|
||||
return activePath;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout activePath={getActivePath()}>
|
||||
<div className='px-10 py-8 overflow-visible'>
|
||||
{/* 页面标题 */}
|
||||
<div className='mb-8'>
|
||||
<h1 className='text-3xl font-bold text-gray-800 mb-2'>
|
||||
{getPageTitle()}
|
||||
</h1>
|
||||
<p className='text-gray-600'>来自豆瓣的精选内容</p>
|
||||
</div>
|
||||
|
||||
{/* 内容展示区域 */}
|
||||
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
|
||||
{error ? (
|
||||
<div className='flex justify-center items-center h-40'>
|
||||
<div className='text-red-500 text-center'>
|
||||
<div className='text-lg font-semibold mb-2'>加载失败</div>
|
||||
<div className='text-sm'>{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 内容网格 */}
|
||||
<div className='grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-x-8 gap-y-20 px-4'>
|
||||
{loading
|
||||
? // 显示骨架屏
|
||||
skeletonData.map((index) => (
|
||||
<DoubanCardSkeleton key={index} />
|
||||
))
|
||||
: // 显示实际数据
|
||||
doubanData.map((item, index) => (
|
||||
<div key={`${item.title}-${index}`} className='w-44'>
|
||||
<DemoCard title={item.title} poster={item.poster} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 加载更多指示器 */}
|
||||
{hasMore && !loading && (
|
||||
<div
|
||||
ref={loadingRef}
|
||||
className='flex justify-center mt-12 py-8'
|
||||
>
|
||||
{isLoadingMore && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-green-500'></div>
|
||||
<span className='text-gray-600'>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 没有更多数据提示 */}
|
||||
{!hasMore && doubanData.length > 0 && (
|
||||
<div className='text-center text-gray-500 py-8'>
|
||||
已加载全部内容
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && doubanData.length === 0 && !error && (
|
||||
<div className='text-center text-gray-500 py-8'>
|
||||
暂无相关内容
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
21
src/components/DoubanCardSkeleton.tsx
Normal file
21
src/components/DoubanCardSkeleton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
const DoubanCardSkeleton = () => {
|
||||
return (
|
||||
<div className='w-44'>
|
||||
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
|
||||
{/* 海报骨架 - 2:3 比例 */}
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||
<div className='absolute inset-0 bg-gray-300'></div>
|
||||
</div>
|
||||
|
||||
{/* 信息层骨架 */}
|
||||
<div className='absolute top-[calc(100%+0.5rem)] left-0 right-0'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='h-4 w-32 bg-gray-200 rounded animate-pulse mb-2'></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoubanCardSkeleton;
|
||||
@@ -1,20 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SkeletonProps = React.ComponentPropsWithoutRef<'div'>;
|
||||
|
||||
export default function Skeleton({ className, ...rest }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-shimmer bg-[#f6f7f8]', className)}
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(to right, #f6f7f8 0%, #edeef1 20%, #f6f7f8 40%, #f6f7f8 100%)',
|
||||
backgroundSize: '700px 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Film, Folder, Home, Menu, Search, Star, Tv } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -26,7 +26,7 @@ const Logo = () => (
|
||||
className='flex items-center justify-center h-16 select-none hover:opacity-80 transition-opacity duration-200'
|
||||
>
|
||||
<span className='text-2xl font-bold text-green-600 tracking-tight'>
|
||||
LibreTV
|
||||
MoonTV
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
@@ -39,6 +39,7 @@ interface SidebarProps {
|
||||
const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const saved = localStorage.getItem('sidebarCollapsed');
|
||||
@@ -47,8 +48,19 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
const [active, setActive] = useState(activePath);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(pathname);
|
||||
}, [pathname]);
|
||||
// 优先使用传入的 activePath
|
||||
if (activePath) {
|
||||
setActive(activePath);
|
||||
} else {
|
||||
// 否则使用当前路径
|
||||
const getCurrentFullPath = () => {
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `${pathname}?${queryString}` : pathname;
|
||||
};
|
||||
const fullPath = getCurrentFullPath();
|
||||
setActive(fullPath);
|
||||
}
|
||||
}, [activePath, pathname, searchParams]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
const newState = !isCollapsed;
|
||||
@@ -66,12 +78,25 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ icon: Tv, label: '电视剧', href: '/tv-shows' },
|
||||
{ icon: Film, label: '电影', href: '/movies' },
|
||||
{ icon: Star, label: '豆瓣 Top250', href: '/top250' },
|
||||
{ icon: Folder, label: '合集', href: '/collections' },
|
||||
{ icon: Star, label: '热门电影', href: '/douban/hot-movies' },
|
||||
{ icon: Star, label: '热门电视剧', href: '/douban/hot-tv' },
|
||||
{
|
||||
icon: Film,
|
||||
label: '热门电影',
|
||||
href: '/douban?type=movie&tag=热门&title=热门电影',
|
||||
},
|
||||
{
|
||||
icon: Tv,
|
||||
label: '热门剧集',
|
||||
href: '/douban?type=tv&tag=热门&title=热门剧集',
|
||||
},
|
||||
{
|
||||
icon: Star,
|
||||
label: '豆瓣 Top250',
|
||||
href: '/douban?type=movie&tag=top250&title=豆瓣 Top250',
|
||||
},
|
||||
{ icon: Folder, label: '美剧', href: '/douban?type=tv&tag=美剧' },
|
||||
{ icon: Folder, label: '韩剧', href: '/douban?type=tv&tag=韩剧' },
|
||||
{ icon: Folder, label: '日剧', href: '/douban?type=tv&tag=日剧' },
|
||||
{ icon: Folder, label: '日漫', href: '/douban?type=tv&tag=日本动画' },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -158,28 +183,44 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
{/* 菜单项 */}
|
||||
<div className='flex-1 overflow-y-auto px-2 pt-4'>
|
||||
<div className='space-y-1'>
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setActive(item.href)}
|
||||
data-active={active === item.href}
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] ${
|
||||
isCollapsed
|
||||
? 'w-full max-w-none mx-0'
|
||||
: 'max-w-[220px] mx-auto'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<item.icon className='h-4 w-4 text-gray-500 group-hover:text-green-600 group-data-[active=true]:text-green-700' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
{menuItems.map((item) => {
|
||||
// 检查当前路径是否匹配这个菜单项
|
||||
const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
|
||||
const tagMatch = item.href.match(/tag=([^&]+)/)?.[1];
|
||||
|
||||
// 解码URL以进行正确的比较
|
||||
const decodedActive = decodeURIComponent(active);
|
||||
const decodedItemHref = decodeURIComponent(item.href);
|
||||
|
||||
const isActive =
|
||||
decodedActive === decodedItemHref ||
|
||||
(decodedActive.startsWith('/douban') &&
|
||||
decodedActive.includes(`type=${typeMatch}`) &&
|
||||
decodedActive.includes(`tag=${tagMatch}`));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setActive(item.href)}
|
||||
data-active={isActive}
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] ${
|
||||
isCollapsed
|
||||
? 'w-full max-w-none mx-0'
|
||||
: 'max-w-[220px] mx-auto'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<item.icon className='h-4 w-4 text-gray-500 group-hover:text-green-600 group-data-[active=true]:text-green-700' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user