-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
+
+
+
+
+ {!isLoading && sortedItems.length === 0 ? (
+
+
📭
+
+ 暂无内容
+
+
+ 请先在设置页配置 API Key,或稍后重试
-
-
+ ) : (
+
+ )}
);
}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
new file mode 100644
index 0000000..3c53f17
--- /dev/null
+++ b/src/app/settings/page.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import { useState } from "react";
+import { ArrowLeft, Eye, EyeOff, Check } from "lucide-react";
+import Link from "next/link";
+import { useSettingsStore } from "@/stores/settings";
+import { toast } from "sonner";
+
+const REFRESH_OPTIONS: { value: 5 | 10 | 15 | 30 | 60; label: string }[] = [
+ { value: 5, label: "5 分钟" },
+ { value: 10, label: "10 分钟" },
+ { value: 15, label: "15 分钟" },
+ { value: 30, label: "30 分钟" },
+ { value: 60, label: "60 分钟" },
+];
+
+export default function SettingsPage() {
+ const { apiKey, setApiKey, refreshInterval, setRefreshInterval } =
+ useSettingsStore();
+
+ const [inputKey, setInputKey] = useState(apiKey);
+ const [showKey, setShowKey] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ const handleSaveApiKey = async () => {
+ const trimmed = inputKey.trim();
+ if (!trimmed) {
+ toast.error("请输入 API Key");
+ return;
+ }
+
+ setSaving(true);
+ try {
+ const res = await fetch("/api/settings", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ apiKey: trimmed }),
+ });
+
+ if (!res.ok) {
+ throw new Error("保存失败");
+ }
+
+ setApiKey(trimmed);
+ toast.success("API Key 已保存");
+ } catch {
+ toast.error("保存失败,请重试");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ {/* API Key Section */}
+
+
+ TikHub API Key
+
+
+ 请前往{" "}
+
+ tikhub.io
+ {" "}
+ 获取 API Key
+
+
+
+ setInputKey(e.target.value)}
+ placeholder="输入你的 API Key"
+ className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 pr-10 bg-white text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ />
+
+
+
+
+
+
+ {/* Refresh Interval Section */}
+
+
+ 自动刷新间隔
+
+
+ 设置热门内容的自动刷新频率
+
+
+ {REFRESH_OPTIONS.map((opt) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/card/CardSkeleton.tsx b/src/components/card/CardSkeleton.tsx
new file mode 100644
index 0000000..d27be22
--- /dev/null
+++ b/src/components/card/CardSkeleton.tsx
@@ -0,0 +1,27 @@
+export function CardSkeleton() {
+ return (
+
+ {/* Cover placeholder */}
+
+
+
+ {/* Platform tag */}
+
+ {/* Title */}
+
+
+ {/* Author */}
+
+ {/* Stats */}
+
+
+
+ );
+}
diff --git a/src/components/card/ContentCard.tsx b/src/components/card/ContentCard.tsx
new file mode 100644
index 0000000..7eac049
--- /dev/null
+++ b/src/components/card/ContentCard.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+import { Play, Heart, MessageCircle } from "lucide-react";
+import { getPlatformConfig } from "@/lib/platforms";
+import { formatCount } from "@/lib/format";
+import { FavoriteButton } from "@/components/common/FavoriteButton";
+import type { ContentItem } from "@/types/content";
+import { useState } from "react";
+
+interface ContentCardProps {
+ item: ContentItem;
+}
+
+export function ContentCard({ item }: ContentCardProps) {
+ const platform = getPlatformConfig(item.platform);
+ const [imgError, setImgError] = useState(false);
+
+ const playCount = formatCount(item.play_count);
+ const likeCount = formatCount(item.like_count);
+ const commentCount = formatCount(item.comment_count);
+
+ return (
+
+ {/* Cover image */}
+
+ {item.cover_url && !imgError ? (
+
setImgError(true)}
+ />
+ ) : (
+
+ {platform?.icon || "📄"}
+
+ )}
+
+
+ {/* Content */}
+
+ {/* Platform tag */}
+ {platform && (
+
+ {platform.icon} {platform.name}
+
+ )}
+
+ {/* Title */}
+
+ {item.title}
+
+
+ {/* Author */}
+
+ {item.author_avatar ? (
+
{
+ (e.target as HTMLImageElement).style.display = "none";
+ }}
+ />
+ ) : (
+
+ 👤
+
+ )}
+
+ {item.author_name}
+
+
+
+ {/* Stats + Favorite */}
+
+
+ {playCount && (
+
+
+ {playCount}
+
+ )}
+ {likeCount && (
+
+
+ {likeCount}
+
+ )}
+ {commentCount && (
+
+
+ {commentCount}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/card/ContentGrid.tsx b/src/components/card/ContentGrid.tsx
new file mode 100644
index 0000000..4d00f51
--- /dev/null
+++ b/src/components/card/ContentGrid.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { ContentCard } from "./ContentCard";
+import { CardSkeleton } from "./CardSkeleton";
+import type { ContentItem } from "@/types/content";
+
+interface ContentGridProps {
+ items: ContentItem[];
+ loading?: boolean;
+ skeletonCount?: number;
+}
+
+export function ContentGrid({
+ items,
+ loading,
+ skeletonCount = 12,
+}: ContentGridProps) {
+ if (loading) {
+ return (
+
+ {Array.from({ length: skeletonCount }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/common/EmptyState.tsx b/src/components/common/EmptyState.tsx
new file mode 100644
index 0000000..712fea6
--- /dev/null
+++ b/src/components/common/EmptyState.tsx
@@ -0,0 +1,35 @@
+import Link from "next/link";
+
+interface EmptyStateProps {
+ icon?: string;
+ title: string;
+ description?: string;
+ actionLabel?: string;
+ actionHref?: string;
+}
+
+export function EmptyState({
+ icon = "📭",
+ title,
+ description,
+ actionLabel,
+ actionHref,
+}: EmptyStateProps) {
+ return (
+
+
{icon}
+
{title}
+ {description && (
+
{description}
+ )}
+ {actionLabel && actionHref && (
+
+ {actionLabel}
+
+ )}
+
+ );
+}
diff --git a/src/components/common/ErrorState.tsx b/src/components/common/ErrorState.tsx
new file mode 100644
index 0000000..1559b28
--- /dev/null
+++ b/src/components/common/ErrorState.tsx
@@ -0,0 +1,41 @@
+import { RefreshCw } from "lucide-react";
+import Link from "next/link";
+
+interface ErrorStateProps {
+ message?: string;
+ onRetry?: () => void;
+ showHomeLink?: boolean;
+}
+
+export function ErrorState({
+ message = "加载失败,请稍后重试",
+ onRetry,
+ showHomeLink = true,
+}: ErrorStateProps) {
+ return (
+
+
😵
+
出错了
+
{message}
+
+ {onRetry && (
+
+ )}
+ {showHomeLink && (
+
+ 返回首页
+
+ )}
+
+
+ );
+}
diff --git a/src/components/common/FavoriteButton.tsx b/src/components/common/FavoriteButton.tsx
new file mode 100644
index 0000000..be44485
--- /dev/null
+++ b/src/components/common/FavoriteButton.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { Heart } from "lucide-react";
+import { useFavoritesStore } from "@/stores/favorites";
+import type { ContentItem } from "@/types/content";
+import { cn } from "@/lib/utils";
+import { useState } from "react";
+
+interface FavoriteButtonProps {
+ item: ContentItem;
+ size?: "sm" | "md";
+}
+
+export function FavoriteButton({ item, size = "sm" }: FavoriteButtonProps) {
+ const { isFavorited, addFavorite, removeFavorite } = useFavoritesStore();
+ const favorited = isFavorited(item.id, item.platform);
+ const [animating, setAnimating] = useState(false);
+
+ const handleClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ setAnimating(true);
+ setTimeout(() => setAnimating(false), 300);
+
+ if (favorited) {
+ removeFavorite(item.id, item.platform);
+ } else {
+ addFavorite(item);
+ }
+ };
+
+ const iconSize = size === "sm" ? "w-4 h-4" : "w-5 h-5";
+
+ return (
+
+ );
+}
diff --git a/src/components/detail/DetailPanel.tsx b/src/components/detail/DetailPanel.tsx
new file mode 100644
index 0000000..2082b06
--- /dev/null
+++ b/src/components/detail/DetailPanel.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import Image from "next/image";
+import { ArrowLeft, Play, Heart, MessageCircle, Share2, ExternalLink } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { getPlatformConfig } from "@/lib/platforms";
+import { formatCount, formatTime } from "@/lib/format";
+import { FavoriteButton } from "@/components/common/FavoriteButton";
+import type { ContentItem } from "@/types/content";
+import { useState } from "react";
+
+interface DetailPanelProps {
+ item: ContentItem;
+}
+
+const STAT_ITEMS = [
+ { key: "play_count" as const, label: "播放", icon: Play },
+ { key: "like_count" as const, label: "点赞", icon: Heart },
+ { key: "comment_count" as const, label: "评论", icon: MessageCircle },
+ { key: "share_count" as const, label: "分享", icon: Share2 },
+];
+
+export function DetailPanel({ item }: DetailPanelProps) {
+ const router = useRouter();
+ const platform = getPlatformConfig(item.platform);
+ const [imgError, setImgError] = useState(false);
+
+ return (
+
+ {/* Back button */}
+
+
+ {/* Cover image */}
+
+ {item.cover_url && !imgError ? (
+
setImgError(true)}
+ />
+ ) : (
+
+ {platform?.icon || "📄"}
+
+ )}
+
+
+ {/* Content */}
+
+ {/* Platform tag */}
+ {platform && (
+
+ {platform.icon} {platform.name}
+
+ )}
+
+ {/* Title */}
+
+ {item.title}
+
+
+ {/* Author */}
+
+ {item.author_avatar ? (
+
{
+ (e.target as HTMLImageElement).style.display = "none";
+ }}
+ />
+ ) : (
+
+ 👤
+
+ )}
+
+
+ {item.author_name}
+
+ {item.publish_time && (
+
+ {formatTime(item.publish_time)}
+
+ )}
+
+
+
+ {/* Stats panel */}
+
+ {STAT_ITEMS.map(({ key, label, icon: Icon }) => {
+ const value = item[key];
+ return (
+
+
+
+ {formatCount(value) || "0"}
+
+ {label}
+
+ );
+ })}
+
+
+ {/* Tags */}
+ {item.tags && item.tags.length > 0 && (
+
+ {item.tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/detail/DetailSkeleton.tsx b/src/components/detail/DetailSkeleton.tsx
new file mode 100644
index 0000000..84fd1d3
--- /dev/null
+++ b/src/components/detail/DetailSkeleton.tsx
@@ -0,0 +1,34 @@
+export function DetailSkeleton() {
+ return (
+
+ {/* Cover */}
+
+
+
+ {/* Title */}
+
+
+
+ {/* Author */}
+
+
+ {/* Stats */}
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+ {/* Tags */}
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/layout/SortToolbar.tsx b/src/components/layout/SortToolbar.tsx
new file mode 100644
index 0000000..6446a9a
--- /dev/null
+++ b/src/components/layout/SortToolbar.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { RefreshCw } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export type SortField = "play_count" | "like_count" | "comment_count" | "publish_time";
+export type SortOrder = "asc" | "desc";
+
+const SORT_OPTIONS: { value: SortField; label: string }[] = [
+ { value: "play_count", label: "播放量" },
+ { value: "like_count", label: "点赞数" },
+ { value: "comment_count", label: "评论数" },
+ { value: "publish_time", label: "发布时间" },
+];
+
+interface SortToolbarProps {
+ sortBy: SortField;
+ sortOrder: SortOrder;
+ onSortByChange: (field: SortField) => void;
+ onSortOrderChange: () => void;
+ onRefresh: () => void;
+ isRefreshing: boolean;
+ lastRefreshTime: string | null;
+}
+
+export function SortToolbar({
+ sortBy,
+ sortOrder,
+ onSortByChange,
+ onSortOrderChange,
+ onRefresh,
+ isRefreshing,
+ lastRefreshTime,
+}: SortToolbarProps) {
+ return (
+
+
+ 排序:
+
+
+
+
+
+
+ {lastRefreshTime && (
+
+ 上次: {lastRefreshTime}
+
+ )}
+
+
+ );
+}
diff --git a/src/hooks/useContentQuery.ts b/src/hooks/useContentQuery.ts
new file mode 100644
index 0000000..628f37c
--- /dev/null
+++ b/src/hooks/useContentQuery.ts
@@ -0,0 +1,47 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useSettingsStore } from "@/stores/settings";
+import type { ContentItem } from "@/types/content";
+import { MVP_PLATFORMS } from "@/lib/platforms";
+
+async function fetchPlatformContent(platform: string): Promise
{
+ const res = await fetch(`/api/tikhub/${platform}?count=20`);
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || `请求失败: ${res.status}`);
+ }
+ const data = await res.json();
+ return data.data || [];
+}
+
+async function fetchAllPlatforms(): Promise {
+ const results = await Promise.allSettled(
+ MVP_PLATFORMS.filter((p) => p.enabled).map((p) => fetchPlatformContent(p.id))
+ );
+
+ return results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
+}
+
+export function useContentQuery(platform: string) {
+ const refreshInterval = useSettingsStore((s) => s.refreshInterval);
+
+ return useQuery({
+ queryKey: ["content", platform],
+ queryFn: () =>
+ platform === "all"
+ ? fetchAllPlatforms()
+ : fetchPlatformContent(platform),
+ refetchInterval: refreshInterval * 60 * 1000,
+ refetchIntervalInBackground: false,
+ });
+}
+
+export function useRefreshContent() {
+ const queryClient = useQueryClient();
+
+ return {
+ refresh: (platform: string) =>
+ queryClient.invalidateQueries({ queryKey: ["content", platform] }),
+ };
+}
diff --git a/src/hooks/useDetailQuery.ts b/src/hooks/useDetailQuery.ts
new file mode 100644
index 0000000..116e020
--- /dev/null
+++ b/src/hooks/useDetailQuery.ts
@@ -0,0 +1,27 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import type { ContentItem } from "@/types/content";
+
+async function fetchDetail(
+ platform: string,
+ id: string
+): Promise {
+ const res = await fetch(
+ `/api/tikhub/${platform}/detail?id=${encodeURIComponent(id)}`
+ );
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || `请求失败: ${res.status}`);
+ }
+ const data = await res.json();
+ return data.data;
+}
+
+export function useDetailQuery(platform: string, id: string) {
+ return useQuery({
+ queryKey: ["detail", platform, id],
+ queryFn: () => fetchDetail(platform, id),
+ enabled: !!platform && !!id,
+ });
+}
diff --git a/src/lib/format.ts b/src/lib/format.ts
new file mode 100644
index 0000000..77a277c
--- /dev/null
+++ b/src/lib/format.ts
@@ -0,0 +1,29 @@
+export function formatCount(count: number | undefined): string | null {
+ if (count === undefined || count === null) return null;
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
+ return String(count);
+}
+
+export function formatTime(isoString: string): string {
+ try {
+ const date = new Date(isoString);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffMin = Math.floor(diffMs / 60_000);
+ const diffHour = Math.floor(diffMs / 3_600_000);
+ const diffDay = Math.floor(diffMs / 86_400_000);
+
+ if (diffMin < 1) return "刚刚";
+ if (diffMin < 60) return `${diffMin}分钟前`;
+ if (diffHour < 24) return `${diffHour}小时前`;
+ if (diffDay < 30) return `${diffDay}天前`;
+
+ return date.toLocaleDateString("zh-CN", {
+ month: "short",
+ day: "numeric",
+ });
+ } catch {
+ return "";
+ }
+}