Skip to content

Commit 27aa1e9

Browse files
authored
Merge pull request #5 from Alfex4936/feature/admin-page
refactor: 관리자 페이지 UI/UX 전면 개선
2 parents 09c83dc + 7a3f5b0 commit 27aa1e9

File tree

10 files changed

+1414
-430
lines changed

10 files changed

+1414
-430
lines changed

app/admin/admin-client.tsx

Lines changed: 222 additions & 428 deletions
Large diffs are not rendered by default.

app/admin/layout.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,12 @@ export default function RootLayout({
4444
children: React.ReactNode;
4545
}>) {
4646
return (
47-
<div className={`${pretendard.className} w-dvh h-dvh bg-gray-50 p-4 overflow-auto`}>
47+
<div className={`light ${pretendard.className} w-dvh h-dvh bg-gray-50 text-gray-900 p-4 overflow-auto`} style={{ color: '#111827', colorScheme: 'light' }} data-theme="light">
4848
<ThemeProvider
4949
attribute="class"
5050
defaultTheme="light"
51-
enableSystem
51+
enableSystem={false}
52+
forcedTheme="light"
5253
disableTransitionOnChange
5354
>
5455
<UserProvider>
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"use client";
2+
3+
import { Dialog, DialogContent } from "@/components/ui/dialog";
4+
import { ChevronLeft, ChevronRight, ImageIcon, X } from "lucide-react";
5+
import Image from "next/image";
6+
import { useState } from "react";
7+
8+
interface ImageGalleryProps {
9+
images: string[];
10+
className?: string;
11+
}
12+
13+
/**
14+
* Image gallery with thumbnail grid and lightbox viewer
15+
*/
16+
const ImageGallery = ({ images, className }: ImageGalleryProps) => {
17+
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
18+
19+
if (!images || images.length === 0) {
20+
return null;
21+
}
22+
23+
const handlePrevious = () => {
24+
if (selectedIndex === null) return;
25+
setSelectedIndex((selectedIndex - 1 + images.length) % images.length);
26+
};
27+
28+
const handleNext = () => {
29+
if (selectedIndex === null) return;
30+
setSelectedIndex((selectedIndex + 1) % images.length);
31+
};
32+
33+
return (
34+
<>
35+
{/* Thumbnail Grid */}
36+
<div className={className}>
37+
<div className="flex items-center gap-2 mb-2">
38+
<ImageIcon className="h-4 w-4 text-blue" />
39+
<span className="text-sm font-medium text-gray-700">
40+
첨부 사진 ({images.length})
41+
</span>
42+
</div>
43+
<div className="grid grid-cols-3 gap-2">
44+
{images.map((url, index) => (
45+
<button
46+
key={index}
47+
onClick={() => setSelectedIndex(index)}
48+
className="relative aspect-square rounded-lg overflow-hidden border-2 border-gray-200 hover:border-blue transition-colors cursor-pointer group"
49+
>
50+
<Image
51+
src={url}
52+
alt={`Report photo ${index + 1}`}
53+
fill
54+
className="object-cover group-hover:scale-110 transition-transform duration-200"
55+
unoptimized
56+
/>
57+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
58+
<div className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/90 rounded-full p-2">
59+
<ImageIcon className="h-4 w-4 text-gray-700" />
60+
</div>
61+
</div>
62+
</button>
63+
))}
64+
</div>
65+
</div>
66+
67+
{/* Lightbox Modal */}
68+
<Dialog
69+
open={selectedIndex !== null}
70+
onOpenChange={(open) => !open && setSelectedIndex(null)}
71+
>
72+
<DialogContent className="max-w-5xl w-full h-[90vh] p-0 bg-black/95 border-none">
73+
{selectedIndex !== null && (
74+
<div className="relative w-full h-full flex items-center justify-center">
75+
{/* Close Button */}
76+
<button
77+
onClick={() => setSelectedIndex(null)}
78+
className="absolute top-4 right-4 z-50 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
79+
>
80+
<X className="h-6 w-6 text-white" />
81+
</button>
82+
83+
{/* Image Counter */}
84+
<div className="absolute top-4 left-4 z-50 px-3 py-1 rounded-full bg-white/10 backdrop-blur-sm">
85+
<span className="text-sm text-white font-medium">
86+
{selectedIndex + 1} / {images.length}
87+
</span>
88+
</div>
89+
90+
{/* Previous Button */}
91+
{images.length > 1 && (
92+
<button
93+
onClick={handlePrevious}
94+
className="absolute left-4 z-50 p-3 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
95+
>
96+
<ChevronLeft className="h-6 w-6 text-white" />
97+
</button>
98+
)}
99+
100+
{/* Main Image */}
101+
<div className="relative w-full h-full flex items-center justify-center p-16">
102+
<Image
103+
src={images[selectedIndex]}
104+
alt={`Report photo ${selectedIndex + 1}`}
105+
fill
106+
className="object-contain"
107+
unoptimized
108+
/>
109+
</div>
110+
111+
{/* Next Button */}
112+
{images.length > 1 && (
113+
<button
114+
onClick={handleNext}
115+
className="absolute right-4 z-50 p-3 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
116+
>
117+
<ChevronRight className="h-6 w-6 text-white" />
118+
</button>
119+
)}
120+
121+
{/* Thumbnail Strip */}
122+
{images.length > 1 && (
123+
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-50 flex gap-2 p-2 rounded-lg bg-white/10 backdrop-blur-sm">
124+
{images.map((url, index) => (
125+
<button
126+
key={index}
127+
onClick={() => setSelectedIndex(index)}
128+
className={`relative w-16 h-16 rounded overflow-hidden border-2 transition-all ${
129+
index === selectedIndex
130+
? "border-white scale-110"
131+
: "border-white/30 hover:border-white/60"
132+
}`}
133+
>
134+
<Image
135+
src={url}
136+
alt={`Thumbnail ${index + 1}`}
137+
fill
138+
className="object-cover"
139+
unoptimized
140+
/>
141+
</button>
142+
))}
143+
</div>
144+
)}
145+
</div>
146+
)}
147+
</DialogContent>
148+
</Dialog>
149+
</>
150+
);
151+
};
152+
153+
export default ImageGallery;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"use client";
2+
3+
import {
4+
calculateDistance,
5+
formatDistance,
6+
} from "@lib/admin-utils";
7+
import { ArrowRight, MapPin, MoveRight } from "lucide-react";
8+
9+
interface LocationComparisonProps {
10+
oldLat: number;
11+
oldLng: number;
12+
newLat: number;
13+
newLng: number;
14+
className?: string;
15+
}
16+
17+
/**
18+
* Side-by-side location comparison with distance calculation
19+
*/
20+
const LocationComparison = ({
21+
oldLat,
22+
oldLng,
23+
newLat,
24+
newLng,
25+
className,
26+
}: LocationComparisonProps) => {
27+
const distance = calculateDistance(oldLat, oldLng, newLat, newLng);
28+
const formattedDistance = formatDistance(distance);
29+
const hasMoved = distance > 0.5; // Threshold: 0.5 meters
30+
31+
return (
32+
<div className={className}>
33+
<div className="flex items-center gap-2 mb-3">
34+
<MapPin className="h-4 w-4 text-blue" />
35+
<span className="text-sm font-medium text-gray-700">위치 변경</span>
36+
{hasMoved && (
37+
<span
38+
className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold"
39+
style={{ backgroundColor: '#fff7ed', color: '#ea580c', border: '1px solid #fdba74' }}
40+
>
41+
{formattedDistance} 이동
42+
</span>
43+
)}
44+
</div>
45+
46+
<div className="grid grid-cols-1 md:grid-cols-[1fr,auto,1fr] gap-4 items-center">
47+
{/* Old Location */}
48+
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
49+
<div className="text-xs font-medium text-gray-500 mb-1">현재 위치</div>
50+
<div className="font-mono text-sm text-gray-900">
51+
{oldLat.toFixed(6)}, {oldLng.toFixed(6)}
52+
</div>
53+
<a
54+
href={`https://map.kakao.com/link/map/${oldLat},${oldLng}`}
55+
target="_blank"
56+
rel="noopener noreferrer"
57+
className="text-xs text-blue hover:underline mt-1 inline-block"
58+
>
59+
지도에서 보기 →
60+
</a>
61+
</div>
62+
63+
{/* Arrow */}
64+
<div className="flex justify-center">
65+
{hasMoved ? (
66+
<MoveRight className="h-6 w-6 text-orange-500" />
67+
) : (
68+
<ArrowRight className="h-6 w-6 text-gray-300" />
69+
)}
70+
</div>
71+
72+
{/* New Location */}
73+
<div
74+
className={`rounded-lg p-4 border ${
75+
hasMoved
76+
? "bg-blue-50 border-blue-300"
77+
: "bg-gray-50 border-gray-200"
78+
}`}
79+
>
80+
<div
81+
className={`text-xs font-medium mb-1 ${
82+
hasMoved ? "text-blue-700" : "text-gray-500"
83+
}`}
84+
>
85+
{hasMoved ? "새 위치" : "위치 변경 없음"}
86+
</div>
87+
<div
88+
className={`font-mono text-sm ${
89+
hasMoved ? "text-blue-900" : "text-gray-900"
90+
}`}
91+
>
92+
{newLat.toFixed(6)}, {newLng.toFixed(6)}
93+
</div>
94+
<a
95+
href={`https://map.kakao.com/link/map/${newLat},${newLng}`}
96+
target="_blank"
97+
rel="noopener noreferrer"
98+
className="text-xs text-blue hover:underline mt-1 inline-block"
99+
>
100+
지도에서 보기 →
101+
</a>
102+
</div>
103+
</div>
104+
105+
{/* Distance Info */}
106+
{hasMoved && (
107+
<div className="mt-3 flex items-center gap-2 text-sm text-gray-600 bg-orange-50 border border-orange-200 rounded-lg px-3 py-2">
108+
<MoveRight className="h-4 w-4 text-orange-600" />
109+
<span>
110+
위치가 <strong className="text-orange-700">{formattedDistance}</strong>{" "}
111+
이동했습니다
112+
</span>
113+
</div>
114+
)}
115+
</div>
116+
);
117+
};
118+
119+
export default LocationComparison;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"use client";
2+
3+
import { coordToAddress } from "@lib/kakao-geocoder";
4+
import { MapPin } from "lucide-react";
5+
import { useEffect, useState } from "react";
6+
7+
interface LocationWithAddressProps {
8+
lat: number;
9+
lng: number;
10+
className?: string;
11+
}
12+
13+
/**
14+
* Display coordinates with human-readable address
15+
* Uses Kakao Geocoder with caching
16+
*/
17+
const LocationWithAddress = ({
18+
lat,
19+
lng,
20+
className = "",
21+
}: LocationWithAddressProps) => {
22+
const [address, setAddress] = useState<string | null>(null);
23+
const [loading, setLoading] = useState(true);
24+
25+
useEffect(() => {
26+
const fetchAddress = async () => {
27+
try {
28+
const result = await coordToAddress(lat, lng);
29+
setAddress(result.shortAddress);
30+
} catch (error) {
31+
console.error("Geocoding error:", error);
32+
setAddress(null);
33+
} finally {
34+
setLoading(false);
35+
}
36+
};
37+
38+
fetchAddress();
39+
}, [lat, lng]);
40+
41+
return (
42+
<div className={`flex items-center gap-2 text-xs text-gray-500 ${className}`}>
43+
<MapPin className="h-3 w-3 flex-shrink-0" />
44+
<span className="font-mono">
45+
{lat.toFixed(4)}, {lng.toFixed(4)}
46+
</span>
47+
{loading ? (
48+
<span className="text-gray-400 animate-pulse">...</span>
49+
) : address ? (
50+
<span className="text-gray-600">({address})</span>
51+
) : null}
52+
</div>
53+
);
54+
};
55+
56+
export default LocationWithAddress;

0 commit comments

Comments
 (0)