Add skeleton loading UI for better perceived performance
Replace "로딩 중..." text with animated skeleton placeholders in RestaurantList, RestaurantDetail, and ReviewSection components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -132,7 +132,7 @@ export default function Home() {
|
|||||||
const { user, login, logout, isLoading: authLoading } = useAuth();
|
const { user, login, logout, isLoading: authLoading } = useAuth();
|
||||||
const [restaurants, setRestaurants] = useState<Restaurant[]>([]);
|
const [restaurants, setRestaurants] = useState<Restaurant[]>([]);
|
||||||
const [selected, setSelected] = useState<Restaurant | null>(null);
|
const [selected, setSelected] = useState<Restaurant | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showDetail, setShowDetail] = useState(false);
|
const [showDetail, setShowDetail] = useState(false);
|
||||||
const [channels, setChannels] = useState<Channel[]>([]);
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
const [channelFilter, setChannelFilter] = useState("");
|
const [channelFilter, setChannelFilter] = useState("");
|
||||||
@@ -199,10 +199,12 @@ export default function Home() {
|
|||||||
|
|
||||||
// Load restaurants on mount and when channel filter changes
|
// Load restaurants on mount and when channel filter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
api
|
api
|
||||||
.getRestaurants({ limit: 500, channel: channelFilter || undefined })
|
.getRestaurants({ limit: 500, channel: channelFilter || undefined })
|
||||||
.then(setRestaurants)
|
.then(setRestaurants)
|
||||||
.catch(console.error);
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, [channelFilter]);
|
}, [channelFilter]);
|
||||||
|
|
||||||
// Auto-select region from user's geolocation (once)
|
// Auto-select region from user's geolocation (once)
|
||||||
@@ -388,6 +390,7 @@ export default function Home() {
|
|||||||
restaurants={filteredRestaurants}
|
restaurants={filteredRestaurants}
|
||||||
selectedId={selected?.id}
|
selectedId={selected?.id}
|
||||||
onSelect={handleSelectRestaurant}
|
onSelect={handleSelectRestaurant}
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -410,6 +413,7 @@ export default function Home() {
|
|||||||
restaurants={filteredRestaurants}
|
restaurants={filteredRestaurants}
|
||||||
selectedId={selected?.id}
|
selectedId={selected?.id}
|
||||||
onSelect={handleSelectRestaurant}
|
onSelect={handleSelectRestaurant}
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { api, getToken } from "@/lib/api";
|
import { api, getToken } from "@/lib/api";
|
||||||
import type { Restaurant, VideoLink } from "@/lib/api";
|
import type { Restaurant, VideoLink } from "@/lib/api";
|
||||||
import ReviewSection from "@/components/ReviewSection";
|
import ReviewSection from "@/components/ReviewSection";
|
||||||
|
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
|
||||||
|
|
||||||
interface RestaurantDetailProps {
|
interface RestaurantDetailProps {
|
||||||
restaurant: Restaurant;
|
restaurant: Restaurant;
|
||||||
@@ -137,7 +138,21 @@ export default function RestaurantDetail({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-sm mb-2">관련 영상</h3>
|
<h3 className="font-semibold text-sm mb-2">관련 영상</h3>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-gray-500">로딩 중...</p>
|
<div className="space-y-3 animate-pulse">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="border rounded-lg p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-4 w-16 bg-gray-200 rounded-sm" />
|
||||||
|
<div className="h-3 w-20 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-full bg-gray-200 rounded" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div className="h-5 w-14 bg-gray-200 rounded" />
|
||||||
|
<div className="h-5 w-16 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : videos.length === 0 ? (
|
) : videos.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500">관련 영상이 없습니다</p>
|
<p className="text-sm text-gray-500">관련 영상이 없습니다</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,18 +2,25 @@
|
|||||||
|
|
||||||
import type { Restaurant } from "@/lib/api";
|
import type { Restaurant } from "@/lib/api";
|
||||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
|
import { RestaurantListSkeleton } from "@/components/Skeleton";
|
||||||
|
|
||||||
interface RestaurantListProps {
|
interface RestaurantListProps {
|
||||||
restaurants: Restaurant[];
|
restaurants: Restaurant[];
|
||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
onSelect: (r: Restaurant) => void;
|
onSelect: (r: Restaurant) => void;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RestaurantList({
|
export default function RestaurantList({
|
||||||
restaurants,
|
restaurants,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
loading,
|
||||||
}: RestaurantListProps) {
|
}: RestaurantListProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <RestaurantListSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!restaurants.length) {
|
if (!restaurants.length) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-center text-gray-500 text-sm">
|
<div className="p-4 text-center text-gray-500 text-sm">
|
||||||
|
|||||||
@@ -200,7 +200,18 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
|||||||
<h3 className="font-semibold text-sm mb-2">리뷰</h3>
|
<h3 className="font-semibold text-sm mb-2">리뷰</h3>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-gray-500">로딩 중...</p>
|
<div className="space-y-3 animate-pulse">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-4 w-24 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 w-8 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<div className="h-3 w-20 bg-gray-200 rounded" />
|
||||||
|
<div className="h-3 w-full bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{reviewCount > 0 && avgRating !== null && (
|
{reviewCount > 0 && avgRating !== null && (
|
||||||
|
|||||||
80
frontend/src/components/Skeleton.tsx
Normal file
80
frontend/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/** Pulsing skeleton block */
|
||||||
|
function Block({ className = "" }: { className?: string }) {
|
||||||
|
return <div className={`animate-pulse bg-gray-200 rounded ${className}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skeleton for a single restaurant list item */
|
||||||
|
export function RestaurantCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 space-y-2">
|
||||||
|
<Block className="h-4 w-3/5" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Block className="h-3 w-16" />
|
||||||
|
<Block className="h-3 w-20" />
|
||||||
|
<Block className="h-3 w-14" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Block className="h-4 w-14 rounded-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skeleton for the restaurant list (multiple cards) */
|
||||||
|
export function RestaurantListSkeleton({ count = 8 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{Array.from({ length: count }, (_, i) => (
|
||||||
|
<RestaurantCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skeleton for restaurant detail view */
|
||||||
|
export function RestaurantDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4 animate-pulse">
|
||||||
|
{/* Title + close */}
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<Block className="h-6 w-40" />
|
||||||
|
<Block className="h-6 w-6 rounded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Block className="h-4 w-24" />
|
||||||
|
<Block className="h-4 w-8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info lines */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Block className="h-4 w-48" />
|
||||||
|
<Block className="h-4 w-64" />
|
||||||
|
<Block className="h-4 w-36" />
|
||||||
|
<Block className="h-4 w-28" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Videos section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Block className="h-4 w-20" />
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="border rounded-lg p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Block className="h-4 w-16 rounded-sm" />
|
||||||
|
<Block className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
<Block className="h-4 w-full" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Block className="h-5 w-14 rounded" />
|
||||||
|
<Block className="h-5 w-16 rounded" />
|
||||||
|
</div>
|
||||||
|
<Block className="h-3 w-3/4" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user