diff --git a/src/app/event/homecoming/admin/guestbook/page.jsx b/src/app/event/homecoming/admin/guestbook/page.jsx
index 192b714..bdcccc3 100644
--- a/src/app/event/homecoming/admin/guestbook/page.jsx
+++ b/src/app/event/homecoming/admin/guestbook/page.jsx
@@ -1,57 +1,18 @@
'use client';
+import { useState, useRef, useCallback } from "react";
import { useAuthenticatedApi } from "@/hooks/useAuthenticatedApi";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-
-const REFRESH_INTERVAL = 10000;
-
-const hashString = (value) => {
- let hash = 0;
- for (let i = 0; i < value.length; i += 1) {
- hash = value.charCodeAt(i) + ((hash << 5) - hash);
- }
- return hash;
-};
-
-const mapToRange = (value, min, max) => {
- const normalized = Math.abs(value % 1000) / 1000;
- return min + normalized * (max - min);
-};
+import { useGuestbookEntries } from "@/hooks/homecoming/useGuestbookEntries";
+import GuestbookWordCloud from "@/components/event/homecoming/GuestbookWordCloud";
export default function GuestbookAdminPage() {
+ const { entries, isLoading, error, lastSyncedAt, refresh } = useGuestbookEntries();
const { apiClient } = useAuthenticatedApi();
- const [entries, setEntries] = useState([]);
const [formValues, setFormValues] = useState({ wristbandSerial: "", name: "" });
- const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
- const [error, setError] = useState("");
const [statusMessage, setStatusMessage] = useState("");
- const [lastSyncedAt, setLastSyncedAt] = useState(null);
const statusTimerRef = useRef(null);
- const fetchEntries = useCallback(async () => {
- try {
- setError("");
- const res = await apiClient.get("/guestbook/entries");
- setEntries(res?.data?.data ?? []);
- setLastSyncedAt(new Date());
- } catch (err) {
- console.error("방명록 목록 조회 실패", err);
- setError("방명록 목록을 불러오지 못했습니다.");
- } finally {
- setIsLoading(false);
- }
- }, [apiClient]);
-
- useEffect(() => {
- fetchEntries();
- const interval = setInterval(fetchEntries, REFRESH_INTERVAL);
-
- return () => {
- clearInterval(interval);
- };
- }, [fetchEntries]);
-
const handleChange = (event) => {
const { name, value } = event.target;
setFormValues((prev) => ({ ...prev, [name]: value }));
@@ -79,7 +40,7 @@ export default function GuestbookAdminPage() {
setFormValues({ wristbandSerial: "", name: "" });
setStatusMessage("입장 등록이 완료되었습니다.");
resetStatusTimer();
- await fetchEntries();
+ await refresh();
} catch (err) {
console.error("방명록 등록 실패", err);
const message = err?.response?.data?.message || "입장 등록에 실패했습니다.";
@@ -90,45 +51,6 @@ export default function GuestbookAdminPage() {
}
};
- useEffect(() => {
- return () => {
- if (statusTimerRef.current) {
- clearTimeout(statusTimerRef.current);
- }
- };
- }, []);
-
- const words = useMemo(() => {
- if (!entries.length) {
- return [];
- }
-
- const recentThreshold = Math.max(entries.length - 5, 0);
-
- return entries.map((entry, idx) => {
- const key = entry.id ?? `${entry.wristbandSerial ?? "unknown"}-${idx}`;
- const baseHash = hashString(`${key}-${entry.name}`);
- const top = mapToRange(baseHash, 8, 92);
- const left = mapToRange(baseHash * 3, 12, 88);
- const fontSize = mapToRange(baseHash * 5, 0.9, 2.6);
- const rotate = mapToRange(baseHash * 7, -12, 12);
- const opacity = mapToRange(baseHash * 11, 0.35, 0.85);
-
- return {
- key,
- label: entry.name,
- isRecent: idx >= recentThreshold,
- style: {
- top: `${top}%`,
- left: `${left}%`,
- fontSize: `${fontSize}rem`,
- opacity,
- transform: `translate(-50%, -50%) rotate(${rotate}deg)`,
- },
- };
- });
- }, [entries]);
-
const isSubmitDisabled = isSubmitting || !formValues.wristbandSerial.trim() || !formValues.name.trim();
return (
@@ -143,25 +65,7 @@ export default function GuestbookAdminPage() {
"radial-gradient(circle at 50% 70%, rgba(52,168,83,0.18), transparent 45%)",
}}
/>
-
- {words.length ? (
- words.map((word) => (
-
- {word.label}
-
- ))
- ) : (
- !isLoading && (
-
- 아직 등록된 입장 정보가 없습니다.
-
- )
- )}
-
+
@@ -172,10 +76,11 @@ export default function GuestbookAdminPage() {
입장 등록
현재 {entries.length}명의 게스트가 입장했습니다.
+ {error &&
{error}
}
diff --git a/src/app/event/homecoming/guestbook/layout.jsx b/src/app/event/homecoming/guestbook/layout.jsx
new file mode 100644
index 0000000..57df161
--- /dev/null
+++ b/src/app/event/homecoming/guestbook/layout.jsx
@@ -0,0 +1,8 @@
+export const metadata = {
+ title: "Homecoming Guestbook Cloud",
+ description: "현재 입장한 게스트 이름을 실시간으로 보여줍니다.",
+};
+
+export default function HomecomingGuestbookLayout({ children }) {
+ return children;
+}
diff --git a/src/app/event/homecoming/guestbook/page.jsx b/src/app/event/homecoming/guestbook/page.jsx
new file mode 100644
index 0000000..e44a85a
--- /dev/null
+++ b/src/app/event/homecoming/guestbook/page.jsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { useGuestbookEntries } from "@/hooks/homecoming/useGuestbookEntries";
+import GuestbookWordCloud from "@/components/event/homecoming/GuestbookWordCloud";
+
+export default function GuestbookWordCloudPage() {
+ const { entries, isLoading, error, lastSyncedAt } = useGuestbookEntries();
+
+ return (
+
+
+
+
+
현재 입장 {entries.length}명
+ {lastSyncedAt && (
+
+ 업데이트 {lastSyncedAt.toLocaleTimeString('ko-KR', { hour12: false })}
+
+ )}
+ {error &&
{error}
}
+
+
+
+
+ );
+}
diff --git a/src/components/event/homecoming/GuestbookWordCloud.jsx b/src/components/event/homecoming/GuestbookWordCloud.jsx
new file mode 100644
index 0000000..4827b0d
--- /dev/null
+++ b/src/components/event/homecoming/GuestbookWordCloud.jsx
@@ -0,0 +1,67 @@
+'use client';
+
+const hashString = (value) => {
+ let hash = 0;
+ for (let i = 0; i < value.length; i += 1) {
+ hash = value.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ return hash;
+};
+
+const mapToRange = (value, min, max) => {
+ const normalized = Math.abs(value % 1000) / 1000;
+ return min + normalized * (max - min);
+};
+
+const defaultColors = {
+ highlight: "text-cblue",
+ muted: "text-slate-600",
+ shadow: "drop-shadow-[0_1.5px_10px_rgba(15,23,42,0.12)]",
+};
+
+export default function GuestbookWordCloud({ entries, isLoading, recentCount = 5, className = "", style = {}, colorScheme = defaultColors }) {
+ const words = (entries ?? []).map((entry, idx) => {
+ const key = entry.id ?? `${entry.wristbandSerial ?? "unknown"}-${idx}`;
+ const baseHash = hashString(`${key}-${entry.name}`);
+ const top = mapToRange(baseHash, 12, 88);
+ const left = mapToRange(baseHash * 3, 10, 90);
+ const fontSize = mapToRange(baseHash * 5, 1.2, 3.2);
+ const rotate = mapToRange(baseHash * 7, -10, 10);
+ const opacity = mapToRange(baseHash * 11, 0.45, 0.95);
+
+ return {
+ key,
+ label: entry.name,
+ isRecent: idx >= Math.max(entries.length - recentCount, 0),
+ style: {
+ top: `${top}%`,
+ left: `${left}%`,
+ fontSize: `${fontSize}rem`,
+ opacity,
+ transform: `translate(-50%, -50%) rotate(${rotate}deg)`,
+ },
+ };
+ });
+
+ return (
+
+ {words.length ? (
+ words.map((word) => (
+
+ {word.label}
+
+ ))
+ ) : (
+ !isLoading && (
+
+ 아직 등록된 입장 정보가 없습니다.
+
+ )
+ )}
+
+ );
+}
diff --git a/src/hooks/homecoming/useGuestbookEntries.js b/src/hooks/homecoming/useGuestbookEntries.js
new file mode 100644
index 0000000..3e4ab1f
--- /dev/null
+++ b/src/hooks/homecoming/useGuestbookEntries.js
@@ -0,0 +1,36 @@
+'use client';
+
+import { useAuthenticatedApi } from "@/hooks/useAuthenticatedApi";
+import { useCallback, useEffect, useState } from "react";
+
+const REFRESH_INTERVAL = 10000;
+
+export const useGuestbookEntries = () => {
+ const { apiClient } = useAuthenticatedApi();
+ const [entries, setEntries] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [lastSyncedAt, setLastSyncedAt] = useState(null);
+
+ const fetchEntries = useCallback(async () => {
+ try {
+ setError("");
+ const res = await apiClient.get("/guestbook/entries");
+ setEntries(res?.data?.data ?? []);
+ setLastSyncedAt(new Date());
+ } catch (err) {
+ console.error("방명록 목록 조회 실패", err);
+ setError("방명록 목록을 불러오지 못했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [apiClient]);
+
+ useEffect(() => {
+ fetchEntries();
+ const interval = setInterval(fetchEntries, REFRESH_INTERVAL);
+ return () => clearInterval(interval);
+ }, [fetchEntries]);
+
+ return { entries, isLoading, error, lastSyncedAt, refresh: fetchEntries };
+};