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:
joungmin
2026-03-09 11:08:03 +09:00
parent 6c47d3c57d
commit 4d09be2419
5 changed files with 121 additions and 4 deletions

View File

@@ -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}
/> />
); );

View File

@@ -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>
) : ( ) : (

View File

@@ -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">

View File

@@ -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 && (

View 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>
);
}