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 }; +};