Skip to content

Commit 442fc5d

Browse files
authored
Merge pull request #249 from GDGoCINHA/develop
merge dev
2 parents a553d73 + 6507c6e commit 442fc5d

File tree

5 files changed

+153
-104
lines changed

5 files changed

+153
-104
lines changed

src/app/event/homecoming/admin/guestbook/page.jsx

Lines changed: 8 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,18 @@
11
'use client';
22

3+
import { useState, useRef, useCallback } from "react";
34
import { useAuthenticatedApi } from "@/hooks/useAuthenticatedApi";
4-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5-
6-
const REFRESH_INTERVAL = 10000;
7-
8-
const hashString = (value) => {
9-
let hash = 0;
10-
for (let i = 0; i < value.length; i += 1) {
11-
hash = value.charCodeAt(i) + ((hash << 5) - hash);
12-
}
13-
return hash;
14-
};
15-
16-
const mapToRange = (value, min, max) => {
17-
const normalized = Math.abs(value % 1000) / 1000;
18-
return min + normalized * (max - min);
19-
};
5+
import { useGuestbookEntries } from "@/hooks/homecoming/useGuestbookEntries";
6+
import GuestbookWordCloud from "@/components/event/homecoming/GuestbookWordCloud";
207

218
export default function GuestbookAdminPage() {
9+
const { entries, isLoading, error, lastSyncedAt, refresh } = useGuestbookEntries();
2210
const { apiClient } = useAuthenticatedApi();
23-
const [entries, setEntries] = useState([]);
2411
const [formValues, setFormValues] = useState({ wristbandSerial: "", name: "" });
25-
const [isLoading, setIsLoading] = useState(true);
2612
const [isSubmitting, setIsSubmitting] = useState(false);
27-
const [error, setError] = useState("");
2813
const [statusMessage, setStatusMessage] = useState("");
29-
const [lastSyncedAt, setLastSyncedAt] = useState(null);
3014
const statusTimerRef = useRef(null);
3115

32-
const fetchEntries = useCallback(async () => {
33-
try {
34-
setError("");
35-
const res = await apiClient.get("/guestbook/entries");
36-
setEntries(res?.data?.data ?? []);
37-
setLastSyncedAt(new Date());
38-
} catch (err) {
39-
console.error("방명록 목록 조회 실패", err);
40-
setError("방명록 목록을 불러오지 못했습니다.");
41-
} finally {
42-
setIsLoading(false);
43-
}
44-
}, [apiClient]);
45-
46-
useEffect(() => {
47-
fetchEntries();
48-
const interval = setInterval(fetchEntries, REFRESH_INTERVAL);
49-
50-
return () => {
51-
clearInterval(interval);
52-
};
53-
}, [fetchEntries]);
54-
5516
const handleChange = (event) => {
5617
const { name, value } = event.target;
5718
setFormValues((prev) => ({ ...prev, [name]: value }));
@@ -79,7 +40,7 @@ export default function GuestbookAdminPage() {
7940
setFormValues({ wristbandSerial: "", name: "" });
8041
setStatusMessage("입장 등록이 완료되었습니다.");
8142
resetStatusTimer();
82-
await fetchEntries();
43+
await refresh();
8344
} catch (err) {
8445
console.error("방명록 등록 실패", err);
8546
const message = err?.response?.data?.message || "입장 등록에 실패했습니다.";
@@ -90,45 +51,6 @@ export default function GuestbookAdminPage() {
9051
}
9152
};
9253

93-
useEffect(() => {
94-
return () => {
95-
if (statusTimerRef.current) {
96-
clearTimeout(statusTimerRef.current);
97-
}
98-
};
99-
}, []);
100-
101-
const words = useMemo(() => {
102-
if (!entries.length) {
103-
return [];
104-
}
105-
106-
const recentThreshold = Math.max(entries.length - 5, 0);
107-
108-
return entries.map((entry, idx) => {
109-
const key = entry.id ?? `${entry.wristbandSerial ?? "unknown"}-${idx}`;
110-
const baseHash = hashString(`${key}-${entry.name}`);
111-
const top = mapToRange(baseHash, 8, 92);
112-
const left = mapToRange(baseHash * 3, 12, 88);
113-
const fontSize = mapToRange(baseHash * 5, 0.9, 2.6);
114-
const rotate = mapToRange(baseHash * 7, -12, 12);
115-
const opacity = mapToRange(baseHash * 11, 0.35, 0.85);
116-
117-
return {
118-
key,
119-
label: entry.name,
120-
isRecent: idx >= recentThreshold,
121-
style: {
122-
top: `${top}%`,
123-
left: `${left}%`,
124-
fontSize: `${fontSize}rem`,
125-
opacity,
126-
transform: `translate(-50%, -50%) rotate(${rotate}deg)`,
127-
},
128-
};
129-
});
130-
}, [entries]);
131-
13254
const isSubmitDisabled = isSubmitting || !formValues.wristbandSerial.trim() || !formValues.name.trim();
13355

13456
return (
@@ -143,25 +65,7 @@ export default function GuestbookAdminPage() {
14365
"radial-gradient(circle at 50% 70%, rgba(52,168,83,0.18), transparent 45%)",
14466
}}
14567
/>
146-
<div className="absolute inset-0 pointer-events-none select-none">
147-
{words.length ? (
148-
words.map((word) => (
149-
<span
150-
key={word.key}
151-
style={word.style}
152-
className={`absolute font-semibold tracking-wide drop-shadow-[0_2px_12px_rgba(15,23,42,0.08)] transition-all duration-700 ease-in-out ${word.isRecent ? "text-cblue" : "text-slate-500"}`}
153-
>
154-
{word.label}
155-
</span>
156-
))
157-
) : (
158-
!isLoading && (
159-
<div className="w-full h-full flex items-center justify-center text-slate-400 text-lg">
160-
아직 등록된 입장 정보가 없습니다.
161-
</div>
162-
)
163-
)}
164-
</div>
68+
<GuestbookWordCloud entries={entries} isLoading={isLoading} recentCount={5} />
16569
</section>
16670

16771
<section className="relative z-10 w-full px-4 pb-12">
@@ -172,10 +76,11 @@ export default function GuestbookAdminPage() {
17276
<div className="flex flex-col gap-3 text-slate-700">
17377
<h2 className="text-2xl font-semibold text-slate-900">입장 등록</h2>
17478
<p className="text-sm">현재 {entries.length}명의 게스트가 입장했습니다.</p>
79+
{error && <p className="text-sm text-cred">{error}</p>}
17580
</div>
17681
<div className="flex flex-col gap-4 md:flex-row">
17782
<label className="flex-1 text-sm text-slate-600">
178-
<span className="block mb-2 font-medium text-slate-800">손목밴드 번호</span>
83+
<span className="block mb-2 font-medium text-slate-800">손목띠지 번호</span>
17984
<input
18085
name="wristbandSerial"
18186
value={formValues.wristbandSerial}
@@ -212,7 +117,6 @@ export default function GuestbookAdminPage() {
212117
마지막 동기화: {lastSyncedAt.toLocaleTimeString("ko-KR", { hour12: false })}
213118
</p>
214119
)}
215-
{error && <p className="text-cred mt-1">{error}</p>}
216120
</div>
217121
</form>
218122
</section>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const metadata = {
2+
title: "Homecoming Guestbook Cloud",
3+
description: "현재 입장한 게스트 이름을 실시간으로 보여줍니다.",
4+
};
5+
6+
export default function HomecomingGuestbookLayout({ children }) {
7+
return children;
8+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client';
2+
3+
import { useGuestbookEntries } from "@/hooks/homecoming/useGuestbookEntries";
4+
import GuestbookWordCloud from "@/components/event/homecoming/GuestbookWordCloud";
5+
6+
export default function GuestbookWordCloudPage() {
7+
const { entries, isLoading, error, lastSyncedAt } = useGuestbookEntries();
8+
9+
return (
10+
<div className="min-h-screen bg-gradient-to-br from-[#E0F2FE] via-[#FDF2FF] to-[#FDE7F3] text-slate-900 relative overflow-hidden">
11+
<div className="absolute inset-0 pointer-events-none">
12+
<div className="absolute inset-0 opacity-60"
13+
style={{
14+
background: "radial-gradient(circle at 30% 25%, rgba(96,165,250,0.35), transparent 45%), " +
15+
"radial-gradient(circle at 70% 20%, rgba(249,168,212,0.3), transparent 40%), " +
16+
"radial-gradient(circle at 50% 80%, rgba(134,239,172,0.3), transparent 45%)",
17+
}}
18+
/>
19+
</div>
20+
21+
<div className="absolute top-6 left-6 bg-white/80 backdrop-blur rounded-2xl px-6 py-4 shadow-lg text-slate-700">
22+
<p className="text-base font-semibold">현재 입장 {entries.length}</p>
23+
{lastSyncedAt && (
24+
<p className="text-xs text-slate-500 mt-1">
25+
업데이트 {lastSyncedAt.toLocaleTimeString('ko-KR', { hour12: false })}
26+
</p>
27+
)}
28+
{error && <p className="text-xs text-cred mt-1">{error}</p>}
29+
</div>
30+
31+
<GuestbookWordCloud entries={entries} isLoading={isLoading} recentCount={5} />
32+
</div>
33+
);
34+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use client';
2+
3+
const hashString = (value) => {
4+
let hash = 0;
5+
for (let i = 0; i < value.length; i += 1) {
6+
hash = value.charCodeAt(i) + ((hash << 5) - hash);
7+
}
8+
return hash;
9+
};
10+
11+
const mapToRange = (value, min, max) => {
12+
const normalized = Math.abs(value % 1000) / 1000;
13+
return min + normalized * (max - min);
14+
};
15+
16+
const defaultColors = {
17+
highlight: "text-cblue",
18+
muted: "text-slate-600",
19+
shadow: "drop-shadow-[0_1.5px_10px_rgba(15,23,42,0.12)]",
20+
};
21+
22+
export default function GuestbookWordCloud({ entries, isLoading, recentCount = 5, className = "", style = {}, colorScheme = defaultColors }) {
23+
const words = (entries ?? []).map((entry, idx) => {
24+
const key = entry.id ?? `${entry.wristbandSerial ?? "unknown"}-${idx}`;
25+
const baseHash = hashString(`${key}-${entry.name}`);
26+
const top = mapToRange(baseHash, 12, 88);
27+
const left = mapToRange(baseHash * 3, 10, 90);
28+
const fontSize = mapToRange(baseHash * 5, 1.2, 3.2);
29+
const rotate = mapToRange(baseHash * 7, -10, 10);
30+
const opacity = mapToRange(baseHash * 11, 0.45, 0.95);
31+
32+
return {
33+
key,
34+
label: entry.name,
35+
isRecent: idx >= Math.max(entries.length - recentCount, 0),
36+
style: {
37+
top: `${top}%`,
38+
left: `${left}%`,
39+
fontSize: `${fontSize}rem`,
40+
opacity,
41+
transform: `translate(-50%, -50%) rotate(${rotate}deg)`,
42+
},
43+
};
44+
});
45+
46+
return (
47+
<div className={`absolute inset-0 pointer-events-none select-none ${className}`} style={style}>
48+
{words.length ? (
49+
words.map((word) => (
50+
<span
51+
key={word.key}
52+
style={word.style}
53+
className={`absolute font-semibold tracking-wide ${colorScheme.shadow} transition-all duration-700 ease-in-out ${word.isRecent ? colorScheme.highlight : colorScheme.muted}`}
54+
>
55+
{word.label}
56+
</span>
57+
))
58+
) : (
59+
!isLoading && (
60+
<div className="w-full h-full flex items-center justify-center text-slate-500 text-xl">
61+
아직 등록된 입장 정보가 없습니다.
62+
</div>
63+
)
64+
)}
65+
</div>
66+
);
67+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use client';
2+
3+
import { useAuthenticatedApi } from "@/hooks/useAuthenticatedApi";
4+
import { useCallback, useEffect, useState } from "react";
5+
6+
const REFRESH_INTERVAL = 10000;
7+
8+
export const useGuestbookEntries = () => {
9+
const { apiClient } = useAuthenticatedApi();
10+
const [entries, setEntries] = useState([]);
11+
const [isLoading, setIsLoading] = useState(true);
12+
const [error, setError] = useState("");
13+
const [lastSyncedAt, setLastSyncedAt] = useState(null);
14+
15+
const fetchEntries = useCallback(async () => {
16+
try {
17+
setError("");
18+
const res = await apiClient.get("/guestbook/entries");
19+
setEntries(res?.data?.data ?? []);
20+
setLastSyncedAt(new Date());
21+
} catch (err) {
22+
console.error("방명록 목록 조회 실패", err);
23+
setError("방명록 목록을 불러오지 못했습니다.");
24+
} finally {
25+
setIsLoading(false);
26+
}
27+
}, [apiClient]);
28+
29+
useEffect(() => {
30+
fetchEntries();
31+
const interval = setInterval(fetchEntries, REFRESH_INTERVAL);
32+
return () => clearInterval(interval);
33+
}, [fetchEntries]);
34+
35+
return { entries, isLoading, error, lastSyncedAt, refresh: fetchEntries };
36+
};

0 commit comments

Comments
 (0)