Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
view/hinana/exhibition.ejs
공개 가능
1 <!DOCTYPE html>
2 <html lang="ko">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="color-scheme" content="light dark">
6 <meta name="viewport" content="width=device-width, initial-scale=1.0">
7 <meta name="theme-color" content="#1a1610">
8 <meta property="og:image" content="/uploads/aoba.jpeg" />
9 <meta property="og:description" content="비나래 라운지 특별전 — Claude Design 시험용" />
10 <meta property="og:url" content="hinana.moe/hinana/exhibition" />
11 <meta property="og:title" content="특별전 — 비나래 라운지" />
12 <title>특별전 — 비나래 라운지</title>
13 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
14 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
15 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
16 <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;1,300;1,400&family=Noto+Serif+KR:wght@300;400&display=swap" rel="stylesheet">
17 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
18 <script src="/js/popup.js"></script>
19 <style>
20 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
21
22 :root {
23 --text-dark: #1e1a14; --text-mid: #5a5040; --text-light: #8a7e6e;
24 --label-bg: #f5f0e8; --gold: #c9a84c;
25 }
26
27 html, body {
28 width: 100%; height: 100%;
29 background: #1a1610;
30 font-family: 'Cormorant Garamond', 'Noto Serif KR', serif;
31 }
32
33 /* ── 헤더 ── */
34 .site-header {
35 position: fixed; top: 0; left: 0; right: 0; height: 52px;
36 background: rgba(20,16,10,0.92); backdrop-filter: blur(8px);
37 border-bottom: 1px solid rgba(201,168,76,0.2);
38 display: flex; align-items: center; justify-content: space-between;
39 padding: 0 28px; z-index: 500;
40 }
41 .site-header .brand {
42 display: flex; align-items: center; gap: 12px;
43 font-size: 0.82rem; letter-spacing: 0.18em; text-transform: uppercase; color: #c4b898;
44 }
45 .header-logo { height: 22px; width: auto; }
46 .site-header nav { display: flex; gap: 24px; align-items: center; }
47 .site-header nav a {
48 font-size: 0.78rem; letter-spacing: 0.14em; text-transform: uppercase;
49 color: #786e5e; text-decoration: none; transition: color 0.2s;
50 }
51 .site-header nav a:hover, .site-header nav a.active { color: #c9a84c; }
52 .header-badge {
53 font-size: 0.65rem; letter-spacing: 0.12em; text-transform: uppercase;
54 color: #c9a84c; border: 1px solid rgba(201,168,76,0.4); padding: 2px 8px;
55 }
56
57 /* ── 룸 ── */
58 #room {
59 position: fixed;
60 top: 52px; left: 0; right: 0; bottom: 0;
61 perspective: 900px; overflow: hidden;
62 }
63 #wall {
64 position: absolute; inset: 0;
65 background: radial-gradient(ellipse 60% 70% at 50% 38%, #f0ebe0 0%, #e4ddd1 40%, #d6cfc3 70%, #c8c0b2 100%);
66 }
67 #wall::before {
68 content: ''; position: absolute; inset: 0;
69 background-image:
70 repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(0,0,0,0.012) 3px, rgba(0,0,0,0.012) 4px),
71 repeating-linear-gradient(90deg, transparent, transparent 3px, rgba(0,0,0,0.008) 3px, rgba(0,0,0,0.008) 4px);
72 pointer-events: none;
73 }
74 #molding-top {
75 position: absolute; top: 0; left: 0; right: 0; height: 44px;
76 background: linear-gradient(180deg, #b8b0a2 0%, #cec6b8 40%, #d8d0c4 100%);
77 box-shadow: 0 4px 12px rgba(0,0,0,0.18);
78 }
79 #molding-top::after {
80 content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 4px;
81 background: linear-gradient(90deg, #a89880, #d4c8a8, #a89880);
82 }
83 #molding-base {
84 position: absolute; bottom: 0; left: 0; right: 0; height: 72px;
85 background: linear-gradient(180deg, #c4bdb0 0%, #b0a898 50%, #9e9080 100%);
86 box-shadow: inset 0 4px 8px rgba(0,0,0,0.12);
87 }
88 #molding-base::before {
89 content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
90 background: linear-gradient(90deg, #888070, #c0b498, #888070);
91 }
92 #floor {
93 position: absolute; bottom: -2px; left: 0; right: 0; height: 110px;
94 background: linear-gradient(180deg, #9a8e7c 0%, #7a6e5c 100%);
95 transform: perspective(400px) rotateX(12deg); transform-origin: top center;
96 }
97 #spotlight {
98 position: absolute; top: -40px; left: 50%; transform: translateX(-50%);
99 width: 420px; height: 700px;
100 background: radial-gradient(ellipse 45% 55% at 50% 8%, rgba(255,248,200,0.22) 0%, rgba(255,240,160,0.10) 30%, transparent 70%);
101 pointer-events: none; z-index: 10;
102 }
103
104 /* ── 시리즈 배지 (왼쪽) ── */
105 #series-badge {
106 position: absolute; left: 6%; top: 50%; transform: translateY(-48%);
107 width: 160px; z-index: 15; opacity: 0.88;
108 animation: fadeWall 2s 1s ease both;
109 }
110 .series-label {
111 font-size: 9px; letter-spacing: 0.25em; text-transform: uppercase;
112 color: var(--text-light); margin-bottom: 10px;
113 border-bottom: 1px solid #ccc4b4; padding-bottom: 6px;
114 }
115 .series-items { display: flex; flex-direction: column; gap: 10px; }
116 .series-item {
117 display: flex; align-items: center; gap: 10px;
118 cursor: pointer; opacity: 0.55; transition: opacity 0.25s;
119 text-decoration: none; border: none; background: none; padding: 0;
120 font-family: inherit; text-align: left;
121 }
122 .series-item:hover { opacity: 0.9; }
123 .series-item.current { opacity: 1; }
124 .series-thumb {
125 width: 40px; height: 40px; object-fit: cover;
126 border: 1px solid #c4b898; flex-shrink: 0;
127 }
128 .series-thumb-label { font-size: 11px; color: var(--text-mid); line-height: 1.4; }
129 .series-thumb-label em { font-style: italic; font-size: 10px; color: var(--text-light); display: block; }
130
131 /* ── 그림 프레임 ── */
132 #painting-wrapper {
133 position: absolute; top: 50%; left: 50%;
134 transform: translate(-50%, -52%);
135 z-index: 20;
136 transition: transform 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.4s ease;
137 animation: fadeIn 1.4s cubic-bezier(0.25,0.46,0.45,0.94) both;
138 }
139 #painting-wrapper:hover { transform: translate(-50%, -52%) scale(1.015); cursor: zoom-in; }
140 #painting-wrapper:hover #painting { filter: saturate(1.12) contrast(1.06); }
141
142 #frame {
143 position: relative; padding: 26px;
144 background: linear-gradient(135deg, #3d3020 0%, #1e1608 25%, #3a2e1e 50%, #1a1008 75%, #342a18 100%);
145 box-shadow: 0 0 0 2px #3a2e1e, inset 0 0 0 3px #4a3c28, 8px 16px 48px rgba(0,0,0,0.55), 2px 4px 12px rgba(0,0,0,0.35);
146 }
147 #frame::before {
148 content: ''; position: absolute; inset: 6px;
149 border: 2px solid transparent;
150 background: linear-gradient(135deg, #d4a840, #f0cc70, #c09030, #e8bc58, #b88820) border-box;
151 -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
152 -webkit-mask-composite: destination-out; mask-composite: exclude; pointer-events: none;
153 }
154 #frame::after {
155 content: ''; position: absolute; inset: 18px;
156 border: 1.5px solid rgba(180,140,60,0.4); pointer-events: none;
157 }
158 #painting {
159 display: block; width: 380px; height: 380px; object-fit: cover;
160 filter: saturate(1.08) contrast(1.04); transition: filter 0.5s ease;
161 }
162 #frame-glare {
163 position: absolute; inset: 26px;
164 background: linear-gradient(145deg, rgba(255,255,255,0.07) 0%, transparent 40%, rgba(255,255,255,0.02) 100%);
165 pointer-events: none; z-index: 5;
166 }
167
168 /* ── 작품 라벨 ── */
169 #label-wrapper {
170 position: absolute; bottom: -132px; left: 50%; transform: translateX(-50%);
171 width: 320px; z-index: 25;
172 animation: slideLabel 1.8s 0.4s cubic-bezier(0.25,0.46,0.45,0.94) both;
173 }
174 #label {
175 background: var(--label-bg); border: 1px solid #d4ccc0;
176 padding: 16px 22px 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.12);
177 }
178 .label-title { font-size: 18px; font-weight: 400; color: var(--text-dark); letter-spacing: 0.04em; line-height: 1.3; margin-bottom: 4px; }
179 .label-title em { font-style: italic; font-size: 12px; color: var(--text-mid); letter-spacing: 0.05em; display: block; margin-top: 2px; }
180 .label-divider { height: 1px; background: linear-gradient(90deg, transparent, #c4b898, transparent); margin: 9px 0; }
181 .label-meta { font-size: 11px; color: var(--text-mid); letter-spacing: 0.06em; line-height: 1.8; }
182 .label-meta span { display: block; }
183 .label-accession { font-size: 9.5px; color: var(--text-light); letter-spacing: 0.12em; margin-top: 7px; text-transform: uppercase; }
184
185 /* ── 벽면 텍스트 ── */
186 #wall-text {
187 position: absolute; right: 6%; top: 50%; transform: translateY(-48%);
188 width: 210px; z-index: 15; opacity: 0.92;
189 animation: fadeWall 2s 0.8s ease both;
190 }
191 .wall-text-head {
192 display: flex; align-items: center; justify-content: space-between; gap: 8px;
193 border-bottom: 1px solid #ccc4b4; padding-bottom: 7px; margin-bottom: 11px;
194 }
195 .wall-text-title { font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--text-light); }
196 .curator-mode-btn {
197 border: 1px solid rgba(169,138,68,0.42); background: rgba(245,240,232,0.75);
198 color: #6e634f; font-size: 8px; letter-spacing: 0.14em; text-transform: uppercase;
199 padding: 4px 7px; transition: all 0.2s; cursor: pointer; font-family: inherit;
200 }
201 .curator-mode-btn:hover { color: #4c4335; background: rgba(248,242,232,0.9); }
202 .curator-mode-btn.active { border-color: rgba(169,138,68,0.78); background: #efe3ca; color: #4f422c; }
203 .wall-text-body { font-size: 13px; line-height: 1.85; color: var(--text-mid); font-style: italic; }
204 .wall-text-body p + p { margin-top: 9px; }
205 .curator-panel { display: none; font-size: 12px; color: #5a5040; line-height: 1.7; }
206 .curator-theme { font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase; color: #8a7e6e; margin-bottom: 8px; }
207 .curator-focus-title { font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; color: #7e715d; margin-bottom: 6px; }
208 .curator-focus-list { margin: 0; padding-left: 16px; margin-bottom: 10px; }
209 .curator-focus-list li { margin-bottom: 4px; }
210 .curator-question { margin-top: 10px; padding-top: 8px; border-top: 1px dashed rgba(169,138,68,0.45); font-size: 12px; color: #5d5140; }
211 .curator-note-box { margin-top: 10px; display: grid; grid-template-columns: 1fr auto; gap: 6px; }
212 .curator-note-input { border: 1px solid rgba(169,138,68,0.35); background: rgba(255,255,255,0.6); color: #54493a; font-size: 12px; padding: 6px 8px; font-family: inherit; }
213 .curator-note-save { border: 1px solid rgba(169,138,68,0.5); background: #efe6d8; color: #5a5040; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; padding: 6px 8px; cursor: pointer; font-family: inherit; }
214 .curator-note-status { grid-column: 1 / -1; font-size: 10px; color: #8a7e6e; }
215 .ai-review-box { margin-top: 16px; padding: 14px 14px 12px; border: 1px solid rgba(201,168,76,0.28); background: rgba(245,240,232,0.5); }
216 .ai-review-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
217 .ai-review-title { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: #8a7e6e; }
218 .ai-review-btn { border: 1px solid rgba(169,138,68,0.48); background: #efe6d8; color: #5a5040; font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; padding: 5px 8px; transition: all 0.2s; cursor: pointer; font-family: inherit; }
219 .ai-review-btn:hover { background: #f6eddc; color: #3b3328; }
220 .ai-review-btn:disabled { opacity: 0.7; cursor: not-allowed; }
221 .ai-review-text { font-size: 12.5px; line-height: 1.72; color: #5a5040; }
222
223 /* ── 전시실 번호 / 오디오 ── */
224 #room-number { position: absolute; bottom: 80px; right: 36px; font-size: 9.5px; letter-spacing: 0.25em; text-transform: uppercase; color: #9a9080; z-index: 30; }
225 #audio-guide {
226 position: absolute; bottom: 80px; left: 36px;
227 display: flex; align-items: center; gap: 10px;
228 font-size: 9.5px; letter-spacing: 0.18em; text-transform: uppercase;
229 color: #9a9080; z-index: 30; cursor: pointer; user-select: none; transition: color 0.2s;
230 }
231 #audio-guide:hover { color: #c4b898; }
232 .audio-dot { width: 8px; height: 8px; border-radius: 50%; background: #c9a84c; animation: pulse 2.5s ease-in-out infinite; }
233
234 /* ── 상세 오버레이 ── */
235 #detail-overlay {
236 position: fixed; inset: 0;
237 background: rgba(15,12,8,0.95);
238 z-index: 600;
239 display: flex; align-items: center; justify-content: center; gap: 56px;
240 opacity: 0; pointer-events: none;
241 transition: opacity 0.5s ease; padding: 40px;
242 }
243 #detail-overlay.active { opacity: 1; pointer-events: all; }
244 #detail-frame {
245 padding: 18px; flex-shrink: 0;
246 background: linear-gradient(135deg, #3d3020 0%, #1e1608 25%, #3a2e1e 50%, #1a1008 75%, #342a18 100%);
247 box-shadow: 0 0 0 1px #c9a84c, 0 20px 80px rgba(0,0,0,0.8);
248 }
249 #detail-img { display: block; width: 480px; height: 480px; object-fit: cover; }
250 #detail-text { max-width: 360px; color: #e8e0d0; }
251 .detail-eyebrow { font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase; color: #c9a84c; margin-bottom: 18px; }
252 .detail-title-main { font-size: 34px; font-weight: 300; line-height: 1.2; margin-bottom: 5px; color: #f0e8d8; }
253 .detail-subtitle { font-style: italic; font-size: 15px; color: #a89878; margin-bottom: 24px; }
254 .detail-rule { height: 1px; background: linear-gradient(90deg, #c9a84c, transparent); margin-bottom: 22px; }
255 .detail-desc { font-size: 14.5px; line-height: 1.9; color: #c4b89e; font-style: italic; margin-bottom: 26px; }
256 .detail-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 22px; }
257 .detail-facts dt { text-transform: uppercase; letter-spacing: 0.15em; font-size: 9px; color: #c9a84c; margin-bottom: 3px; }
258 .detail-facts dd { color: #b4a890; font-size: 12.5px; }
259 #close-btn {
260 position: absolute; top: 28px; right: 36px;
261 font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
262 color: #786e5e; cursor: pointer;
263 border: 1px solid #3a3428; padding: 7px 14px;
264 background: transparent; transition: all 0.2s; font-family: inherit;
265 }
266 #close-btn:hover { color: #e8dcc8; border-color: #786e5e; }
267
268 /* ── 푸터 ── */
269 #gallery-footer {
270 position: absolute; bottom: 0; left: 0; right: 0;
271 z-index: 35;
272 background: linear-gradient(0deg, rgba(20,16,10,0.82) 0%, rgba(20,16,10,0.5) 70%, transparent 100%);
273 padding: 28px 36px 14px;
274 }
275 .gf-inner {
276 display: flex; align-items: center; justify-content: space-between;
277 flex-wrap: wrap; gap: 8px;
278 }
279 .gf-brand { display: flex; align-items: center; gap: 10px; }
280 .gf-logo { height: 20px; width: auto; opacity: 0.7; }
281 .gf-name { font-size: 9.5px; letter-spacing: 0.2em; text-transform: uppercase; color: #9a9080; }
282 .gf-links { display: flex; gap: 18px; }
283 .gf-links a {
284 font-size: 9.5px; letter-spacing: 0.15em; text-transform: uppercase;
285 color: #786e5e; text-decoration: none; transition: color 0.2s;
286 }
287 .gf-links a:hover { color: #c9a84c; }
288 .gf-copy { font-size: 9px; letter-spacing: 0.1em; color: #6a6050; }
289
290 /* ── 전환 페이드 ── */
291 #painting-wrapper.switching { opacity: 0; pointer-events: none; }
292
293 /* ── 모바일 ── */
294 @media (max-width: 768px) {
295 .site-header nav { display: none; }
296 .header-badge { display: none; }
297
298 html, body { height: auto; }
299 body { overflow-y: auto; }
300 html {
301 background: radial-gradient(ellipse 60% 70% at 50% 38%, #f0ebe0 0%, #e4ddd1 40%, #d6cfc3 70%, #c8c0b2 100%);
302 }
303 /* sticky: fixed와 달리 별도 컴포지터 레이어를 만들지 않아 스크롤 jank 없음 */
304 .site-header {
305 position: sticky; top: 0;
306 background: rgba(20,16,10,0.97);
307 backdrop-filter: none; -webkit-backdrop-filter: none;
308 }
309
310 #room {
311 position: relative; height: auto;
312 min-height: calc(100vh - 52px); overflow: visible;
313 display: flex; flex-direction: column; align-items: center;
314 padding: 20px 20px 32px;
315 perspective: none;
316 }
317 #wall { position: absolute; inset: 0; }
318 #molding-top { position: absolute; top: 0; z-index: 1; height: 36px; }
319 #spotlight { position: absolute; z-index: 2; }
320 #molding-base { display: none; }
321 #floor { display: none; }
322 #room-number { display: none; }
323 #audio-guide {
324 position: relative; bottom: auto; left: auto;
325 width: 100%; max-width: 340px;
326 margin-top: 20px;
327 justify-content: center;
328 animation: none; opacity: 1;
329 }
330
331 /* 시리즈 배지 → 가로 썸네일 스트립으로 */
332 #series-badge {
333 position: relative; top: auto; left: auto; transform: none;
334 width: 100%; max-width: 340px;
335 animation: none; opacity: 1;
336 margin-bottom: 20px;
337 }
338 .series-label { margin-bottom: 8px; }
339 .series-items { flex-direction: row; gap: 10px; justify-content: center; }
340 .series-thumb { width: 52px; height: 52px; }
341 .series-thumb-label { font-size: 10px; }
342
343 /* 그림 */
344 #painting-wrapper {
345 position: relative; top: auto; left: auto;
346 transform: none !important; animation: none; z-index: 20; margin: 0 auto;
347 }
348 #frame { padding: 14px; }
349 #painting { width: min(72vw, 300px); height: min(72vw, 300px); }
350
351 /* 라벨 */
352 #label-wrapper {
353 position: relative; bottom: auto; left: auto; transform: none;
354 width: min(72vw, 300px); margin: 14px auto 0; animation: none;
355 }
356
357 /* 벽면 텍스트 */
358 #wall-text {
359 position: relative; top: auto; right: auto; transform: none;
360 width: 100%; max-width: 320px; margin: 24px auto 0;
361 animation: none; opacity: 0.92;
362 }
363
364 /* 푸터 */
365 #gallery-footer {
366 position: relative; bottom: auto; left: auto; right: auto;
367 background: rgba(20,16,10,0.75);
368 padding: 20px 20px 16px;
369 margin-top: 28px; width: 100%;
370 }
371 .gf-inner { flex-direction: column; align-items: center; gap: 10px; text-align: center; }
372 .gf-links { justify-content: center; }
373
374 /* 상세 오버레이 */
375 #detail-overlay {
376 flex-direction: column; align-items: center;
377 overflow-y: auto; padding: 52px 20px 32px; gap: 22px;
378 }
379 #close-btn { top: 14px; right: 16px; padding: 6px 12px; }
380 #detail-frame { padding: 12px; }
381 #detail-img { width: min(85vw, 300px); height: min(85vw, 300px); }
382 #detail-text { max-width: 100%; }
383 .detail-title-main { font-size: 26px; }
384 .detail-facts { gap: 12px 16px; }
385 }
386
387 /* ── 애니메이션 ── */
388 @keyframes fadeIn {
389 from { opacity: 0; transform: translate(-50%, -48%); }
390 to { opacity: 1; transform: translate(-50%, -52%); }
391 }
392 @keyframes slideLabel {
393 from { opacity: 0; transform: translateX(-50%) translateY(10px); }
394 to { opacity: 1; transform: translateX(-50%) translateY(0); }
395 }
396 @keyframes fadeWall {
397 from { opacity: 0; transform: translateY(-48%) translateX(10px); }
398 to { opacity: 0.92; transform: translateY(-48%) translateX(0); }
399 }
400 @keyframes pulse {
401 0%, 100% { opacity: 0.5; transform: scale(1); }
402 50% { opacity: 1; transform: scale(1.3); }
403 }
404 </style>
405 </head>
406 <body>
407
408 <header class="site-header">
409 <div class="brand">
410 <a href="/hinana/lounge">
411 <img src="/image/lounge1.png" alt="비나래 라운지" class="header-logo">
412 </a>
413 <span style="opacity:0.3; font-weight:300;">|</span>
414 <span>특별전</span>
415 <span class="header-badge">Claude Design</span>
416 </div>
417 <nav>
418 <a href="/hinana/lounge">라운지</a>
419 <a href="/hinana/gallery">갤러리</a>
420 <a href="/hinana/exhibition" class="active">특별전</a>
421 </nav>
422 </header>
423
424 <div id="room">
425 <div id="wall"></div>
426 <div id="molding-top"></div>
427 <div id="spotlight"></div>
428
429 <!-- 시리즈 탐색 (왼쪽) -->
430 <div id="series-badge">
431 <div class="series-label">시리즈</div>
432 <div class="series-items" id="seriesItems"></div>
433 </div>
434
435 <!-- 작품 -->
436 <div id="painting-wrapper" onclick="openDetail()">
437 <div id="frame">
438 <img id="painting" src="" alt="">
439 <div id="frame-glare"></div>
440 </div>
441 <div id="label-wrapper">
442 <div id="label">
443 <div class="label-title" id="labelTitle"></div>
444 <div class="label-divider"></div>
445 <div class="label-meta" id="labelMeta"></div>
446 <div class="label-accession" id="labelAccession"></div>
447 </div>
448 </div>
449 </div>
450
451 <!-- 벽면 설명 -->
452 <div id="wall-text">
453 <div class="wall-text-head">
454 <div class="wall-text-title" id="wallTextTitle">작품 해설</div>
455 <button class="curator-mode-btn" id="curatorModeBtn" type="button">Curator Off</button>
456 </div>
457 <div class="wall-text-body" id="wallTextBody"></div>
458 <div class="curator-panel" id="curatorPanel">
459 <div class="curator-theme" id="curatorTheme"></div>
460 <div class="curator-focus-title">관람 포인트</div>
461 <ul class="curator-focus-list" id="curatorFocusList"></ul>
462 <div class="curator-question" id="curatorQuestion"></div>
463 <div class="curator-note-box">
464 <input id="curatorNoteInput" class="curator-note-input" maxlength="220" placeholder="한 줄 감상 메모를 남겨보세요">
465 <button id="curatorNoteSave" class="curator-note-save" type="button">저장</button>
466 <div id="curatorNoteStatus" class="curator-note-status">메모는 이 브라우저에 저장돼요.</div>
467 </div>
468 </div>
469 <div class="ai-review-box">
470 <div class="ai-review-head">
471 <div class="ai-review-title">히나나 AI 감상평</div>
472 <button class="ai-review-btn" id="reviewBtn" type="button">감상평 듣기</button>
473 </div>
474 <div class="ai-review-text" id="reviewText">버튼을 누르면 현재 작품 감상평을 들려드릴게요.</div>
475 </div>
476 </div>
477
478 <div id="molding-base"></div>
479 <div id="floor"></div>
480 <div id="room-number">Gallery IV · Room 12</div>
481 <div id="audio-guide" onclick="toggleAudio()">
482 <div class="audio-dot"></div>
483 <span id="audioNumber">오디오 가이드 · 작품 번호 47</span>
484 </div>
485
486 <!-- 푸터 -->
487 <div id="gallery-footer">
488 <div class="gf-inner">
489 <div class="gf-brand">
490 <img src="/image/lounge1.png" alt="비나래 라운지" class="gf-logo">
491 <span class="gf-name">비나래 라운지</span>
492 </div>
493 <div class="gf-links">
494 <a href="/hinana/lounge">라운지</a>
495 <a href="/hinana/gallery">갤러리</a>
496 <a href="/hinana/index">아카이브</a>
497 </div>
498 <div class="gf-copy">ⓒ 2024~2026. 비나래 | hinana.moe &nbsp;·&nbsp; X @NoctchillHinana</div>
499 </div>
500 </div>
501 </div>
502
503 <!-- 상세 오버레이 -->
504 <div id="detail-overlay" onclick="closeDetail(event)">
505 <div id="detail-frame">
506 <img id="detail-img" src="" alt="">
507 </div>
508 <div id="detail-text">
509 <div class="detail-eyebrow" id="detailEyebrow"></div>
510 <div class="detail-title-main" id="detailTitle"></div>
511 <div class="detail-subtitle" id="detailSubtitle"></div>
512 <div class="detail-rule"></div>
513 <div class="detail-desc" id="detailDesc"></div>
514 <dl class="detail-facts" id="detailFacts"></dl>
515 </div>
516 <button id="close-btn" onclick="closeDetail()">닫기 · Close</button>
517 </div>
518
519 <script>
520 const ARTWORKS = [
521 {
522 img: '/uploads/aoba.jpeg',
523 thumb: '/uploads/aoba.jpeg',
524 seriesName: '우츠미 아오바',
525 seriesNo: 'No. 1 · 2026',
526 labelTitle: '우츠미 아오바',
527 labelTitleEm: 'Utsumi Aoba — Railway Maintenance Division',
528 labelMeta: ['유화 기법의 수채화 / Watercolour in Oil Style', '캔버스에 혼합 매체 · Mixed media on canvas', '2026'],
529 labelAccession: 'Acc. No. BA-2026 · 전시실 IV',
530 wallText: '<p>철도 정비사의 제복을 입은 소녀의 모습을 인상주의적 붓터치로 담아낸 작품이다.</p><p>금빛 후광과 짙은 남색 유니폼의 대비 속에서, 무거운 공구 가방을 든 그녀의 표정은 조용한 자부심을 전한다.</p><p>빛과 그림자의 두꺼운 질감은 노동의 무게와 동시에 젊음의 가벼움을 공존시킨다.</p>',
531 audioNo: '오디오 가이드 · 작품 번호 47',
532 detailEyebrow: 'Blue Archive · 특별 기획전',
533 detailTitle: '우츠미 아오바',
534 detailSubtitle: '<em>Utsumi Aoba, Railway Engineer</em>',
535 detailDesc: '철도 정비부 소속의 아오바는 늘 묵직한 공구 가방과 함께 선로를 걷는다. 금빛 후광은 그녀의 일상적 헌신을 성스러운 것으로 만들고, 인상주의적 붓질은 그 순간의 빛과 온도를 화면 위에 영원히 봉인한다.',
536 curatorTheme: 'Curatorial Lens · 노동의 온기',
537 curatorFocus: [
538 '금빛 후광과 남색 제복의 대비가 인물의 중심성을 어떻게 만드는지 본다.',
539 '공구 가방의 무게감이 표정의 고요함과 어떤 긴장을 이루는지 살핀다.',
540 '붓터치가 금속의 냉기 대신 체온처럼 느껴지는 지점을 찾는다.'
541 ],
542 curatorQuestion: '질문: 이 장면에서 가장 먼저 눈에 들어오는 요소는 무엇이었나요?',
543 facts: [
544 { dt: '기법', dd: '혼합 매체 (수채·유화)' },
545 { dt: '제작 연도', dd: '2026' },
546 { dt: '크기', dd: '100 × 100 cm' },
547 { dt: '소장처', dd: '아비도스 미술관' },
548 { dt: '소재', dd: '캔버스, 혼합 물감' },
549 { dt: '등록 번호', dd: 'BA-2026-047' },
550 ]
551 },
552 {
553 img: '/uploads/aobadoll.jpeg',
554 thumb: '/uploads/aobadoll.jpeg',
555 seriesName: 'KTX를 수리하는 아오바',
556 seriesNo: 'No. 2 · 2026',
557 labelTitle: 'KTX를 수리하는 아오바 (인형)',
558 labelTitleEm: 'Aoba Doll Repairing the KTX · Railway Maintenance Series No. 2',
559 labelMeta: ['유화 기법 / Oil Painting Technique', '캔버스에 혼합 매체 · Mixed media on canvas', '2026'],
560 labelAccession: 'Acc. No. BA-2026-048 · 전시실 IV',
561 wallText: '<p>KTX-산천 차체를 배경으로, 정비 복장의 아오바 인형이 묵묵히 선로를 살핀다.</p><p>작은 체구와 차가운 철재의 대비가 오히려 그녀의 집중력을 더욱 도드라지게 만든다.</p><p>차갑고 건조한 공기 속에서도 손끝의 온기가 화면 전체를 감싸고 있다.</p>',
562 audioNo: '오디오 가이드 · 작품 번호 48',
563 detailEyebrow: 'Blue Archive · 철도 시리즈 No. 2',
564 detailTitle: 'KTX를 수리하는<br>아오바',
565 detailSubtitle: '<em>Aoba Doll Repairing the KTX</em>',
566 detailDesc: 'KTX-산천 차체를 배경으로, 인형 형태의 아오바가 정비 작업에 몰두하고 있다. 유화의 두꺼운 붓터치가 금속 표면의 냉기와 그녀의 온기를 동시에 포착하며, 시리즈 전체를 관통하는 노동과 헌신의 서사를 이어간다.',
567 curatorTheme: 'Curatorial Lens · 스케일의 역전',
568 curatorFocus: [
569 '인형 같은 작은 체구와 거대한 차체가 만드는 스케일 대비를 본다.',
570 '차가운 금속면을 표현한 붓 결이 감정적으로는 얼마나 따뜻하게 읽히는지 살핀다.',
571 '같은 철도 시리즈 1번 작품과 감정선이 어떻게 이어지는지 비교한다.'
572 ],
573 curatorQuestion: '질문: 차가움과 따뜻함 중 어떤 감정이 더 크게 남았나요?',
574 facts: [
575 { dt: '기법', dd: '혼합 매체 (수채·유화)' },
576 { dt: '제작 연도', dd: '2026' },
577 { dt: '크기', dd: '100 × 100 cm' },
578 { dt: '소장처', dd: '아비도스 미술관' },
579 { dt: '시리즈', dd: '철도 정비 시리즈' },
580 { dt: '등록 번호', dd: 'BA-2026-048' },
581 ]
582 },
583 {
584 img: '/uploads/ktxaoba.jpeg',
585 thumb: '/uploads/ktxaoba.jpeg',
586 seriesName: 'KTX 정비 현장의 아오바',
587 seriesNo: 'No. 3 · 2026',
588 labelTitle: 'KTX 정비 현장의 아오바',
589 labelTitleEm: 'Aoba at the KTX Maintenance Site · Railway Maintenance Series No. 3',
590 labelMeta: ['유화 기법 · 임파스토 / Oil, Impasto Technique', '캔버스에 혼합 매체 · Mixed media on canvas', '2026'],
591 labelAccession: 'Acc. No. BA-2026-049 · 전시실 IV',
592 wallText: '<p>역 플랫폼. KTX-산천의 차체가 화면을 가득 채운 가운데, 아오바는 무릎을 꿇고 전장(電裝) 패널 안으로 손을 뻗는다.</p><p>금빛 후광, 파란 정비복, 흰 장갑의 삼색 대비는 종교화의 문법을 차용한다. 공구함 옆에 놓인 작은 인형은 연작의 복선이다.</p><p>두꺼운 임파스토 붓질은 금속과 빛, 집중한 손끝의 긴장감을 동시에 포착한다.</p>',
593 audioNo: '오디오 가이드 · 작품 번호 49',
594 detailEyebrow: 'Blue Archive · 철도 정비 연작 No. 3',
595 detailTitle: 'KTX 정비 현장의<br>아오바',
596 detailSubtitle: '<em>Aoba at the KTX Maintenance Site</em>',
597 detailDesc: 'KTX-산천 차체의 압도적인 질량 앞에서, 아오바는 조용히 무릎을 꿇는다. 전장 패널 속 케이블들 사이에 뻗은 흰 장갑의 손끝은 섬세하고 단호하다. 금빛 후광은 역사(驛舍)의 형광등 아래서도 빛을 잃지 않는다. 작가는 영웅을 거대한 것에서 찾지 않는다 — 정밀한 손끝에서 찾는다.',
598 curatorTheme: 'Curatorial Lens · 정밀함의 경건함',
599 curatorFocus: [
600 '금빛 후광과 형광등 빛이 신성함과 일상을 어떻게 동시에 만드는지 본다.',
601 '임파스토 붓질이 금속 표면의 냉기와 아오바의 집중력을 어떻게 표현하는지 살핀다.',
602 '공구함 옆 작은 인형이 연작 전체에서 어떤 복선 역할을 하는지 생각한다.'
603 ],
604 curatorQuestion: '질문: 이 장면에서 경건함을 느끼게 하는 요소는 무엇인가요?',
605 facts: [
606 { dt: '기법', dd: '유화 (임파스토)' },
607 { dt: '제작 연도', dd: '2026' },
608 { dt: '크기', dd: '120 × 120 cm' },
609 { dt: '소장처', dd: '아비도스 미술관' },
610 { dt: '시리즈', dd: '철도 정비 연작' },
611 { dt: '등록 번호', dd: 'BA-2026-049' },
612 ]
613 }
614 ];
615
616 let currentIndex = 0;
617 let curatorMode = false;
618 let reviewLoading = false;
619 let audioGuideActive = false;
620 let audioSession = 0;
621 let reviewAutoRequestSeq = 0;
622 const reviewCache = new Map();
623 const CURATOR_NOTE_STORAGE_KEY = 'hinana_exhibition_curator_notes_v1';
624 let curatorNotes = {};
625 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
626
627 function getCuratorNoteKey() { return `artwork_${currentIndex}`; }
628 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
629
630 function setCuratorMode(enabled) {
631 curatorMode = enabled;
632 const btn = document.getElementById('curatorModeBtn');
633 const wallTitle = document.getElementById('wallTextTitle');
634 const wallBody = document.getElementById('wallTextBody');
635 const curatorPanel = document.getElementById('curatorPanel');
636 btn.classList.toggle('active', curatorMode);
637 btn.textContent = curatorMode ? 'Curator On' : 'Curator Off';
638 wallTitle.textContent = curatorMode ? '큐레이터 모드' : '작품 해설';
639 wallBody.style.display = curatorMode ? 'none' : 'block';
640 curatorPanel.style.display = curatorMode ? 'block' : 'none';
641 if (curatorMode) renderCuratorPanel(ARTWORKS[currentIndex]);
642 }
643
644 function renderCuratorPanel(art) {
645 document.getElementById('curatorTheme').textContent = art.curatorTheme || '';
646 document.getElementById('curatorFocusList').innerHTML = (art.curatorFocus || []).map(item => `<li>${item}</li>`).join('');
647 document.getElementById('curatorQuestion').textContent = art.curatorQuestion || '';
648 const noteKey = getCuratorNoteKey();
649 const input = document.getElementById('curatorNoteInput');
650 const status = document.getElementById('curatorNoteStatus');
651 input.value = curatorNotes[noteKey] || '';
652 status.textContent = curatorNotes[noteKey] ? '저장된 메모가 있어요. 수정 후 다시 저장할 수 있어요.' : '메모는 이 브라우저에 저장돼요.';
653 }
654
655 function saveCuratorNote() {
656 const input = document.getElementById('curatorNoteInput');
657 const status = document.getElementById('curatorNoteStatus');
658 const value = (input.value || '').trim().slice(0, 220);
659 const noteKey = getCuratorNoteKey();
660 if (!value) { delete curatorNotes[noteKey]; persistCuratorNotes(); status.textContent = '메모를 비웠어요.'; return; }
661 curatorNotes[noteKey] = value; persistCuratorNotes(); status.textContent = '메모를 저장했어요.';
662 }
663
664 function stripHtml(input) {
665 const div = document.createElement('div');
666 div.innerHTML = input || '';
667 return (div.textContent || div.innerText || '').trim();
668 }
669
670 function setReviewLoading(isLoading) {
671 reviewLoading = isLoading;
672 const btn = document.getElementById('reviewBtn');
673 if (!btn) return;
674 btn.disabled = isLoading;
675 btn.textContent = isLoading ? '생성 중...' : '감상평 듣기';
676 }
677
678 function renderReviewText(text) {
679 const el = document.getElementById('reviewText');
680 if (el) el.textContent = text;
681 }
682
683 function syncReviewPanel() {
684 const cached = reviewCache.get(currentIndex);
685 renderReviewText(cached || '버튼을 누르면 현재 작품 감상평을 들려드릴게요.');
686 }
687
688 function buildReviewPayload(art) {
689 return {
690 title: art.labelTitle,
691 subtitle: stripHtml(art.detailSubtitle),
692 description: art.detailDesc,
693 techniques: art.labelMeta
694 };
695 }
696
697 async function hydrateReviewFromServerCache() {
698 const localCached = reviewCache.get(currentIndex);
699 if (localCached) {
700 renderReviewText(localCached);
701 return;
702 }
703
704 const indexAtRequest = currentIndex;
705 const requestSeq = ++reviewAutoRequestSeq;
706 const art = ARTWORKS[indexAtRequest];
707 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
708
709 try {
710 const response = await fetch('/api/exhibition/review/cached', {
711 method: 'POST',
712 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
713 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
714 });
715 if (!response.ok) return;
716
717 const payload = await response.json();
718 const review = String(payload?.review || '').trim();
719 if (!payload?.cached || !review) return;
720 if (requestSeq !== reviewAutoRequestSeq || indexAtRequest !== currentIndex) return;
721
722 reviewCache.set(indexAtRequest, review);
723 renderReviewText(review);
724 } catch (err) {}
725 }
726
727 async function generateReview() {
728 if (reviewLoading) return;
729 const cached = reviewCache.get(currentIndex);
730 if (cached) { renderReviewText(cached); return; }
731 const art = ARTWORKS[currentIndex];
732 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
733 try {
734 setReviewLoading(true);
735 renderReviewText('히나나가 작품을 조용히 바라보고 있어요...');
736 const response = await fetch('/api/exhibition/review', {
737 method: 'POST',
738 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
739 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
740 });
741 const payload = await response.json();
742 if (!response.ok) throw new Error(payload?.error || '감상평을 불러오지 못했어요.');
743 const review = String(payload.review || '').trim();
744 if (!review) throw new Error('빈 감상평이 돌아왔어요.');
745 reviewCache.set(currentIndex, review);
746 renderReviewText(review);
747 } catch (err) {
748 renderReviewText('감상평을 준비하지 못했어요. 잠시 후 다시 시도해 주세요.');
749 } finally {
750 setReviewLoading(false);
751 }
752 }
753
754 function renderSeries() {
755 const container = document.getElementById('seriesItems');
756 container.innerHTML = '';
757 ARTWORKS.forEach((art, i) => {
758 const btn = document.createElement('button');
759 btn.className = 'series-item' + (i === currentIndex ? ' current' : '');
760 btn.innerHTML = `
761 <img class="series-thumb" src="${art.thumb}" alt="" onerror="this.src='/image/lounge1.png'">
762 <div class="series-thumb-label">${art.seriesName}<em>${art.seriesNo}</em></div>
763 `;
764 btn.addEventListener('click', () => { if (i !== currentIndex) switchTo(i); });
765 container.appendChild(btn);
766 });
767 }
768
769 function applyArtwork(art) {
770 document.getElementById('painting').src = art.img;
771 document.getElementById('painting').onerror = function() { this.src = '/image/lounge1.png'; };
772 document.getElementById('detail-img').src = art.img;
773
774 const lt = document.getElementById('labelTitle');
775 lt.textContent = art.labelTitle;
776 const em = document.createElement('em');
777 em.textContent = art.labelTitleEm;
778 lt.appendChild(em);
779
780 document.getElementById('labelMeta').innerHTML = art.labelMeta.map(s => `<span>${s}</span>`).join('');
781 document.getElementById('labelAccession').textContent = art.labelAccession;
782 document.getElementById('wallTextBody').innerHTML = art.wallText;
783 document.getElementById('audioNumber').textContent = art.audioNo;
784
785 document.getElementById('detailEyebrow').textContent = art.detailEyebrow;
786 document.getElementById('detailTitle').innerHTML = art.detailTitle;
787 document.getElementById('detailSubtitle').innerHTML = art.detailSubtitle;
788 document.getElementById('detailDesc').textContent = art.detailDesc;
789 document.getElementById('detailFacts').innerHTML = art.facts.map(f =>
790 `<div><dt>${f.dt}</dt><dd>${f.dd}</dd></div>`
791 ).join('');
792
793 if (curatorMode) renderCuratorPanel(art);
794 syncReviewPanel();
795 hydrateReviewFromServerCache();
796 }
797
798 function switchTo(index) {
799 stopAudioGuide();
800 const wrapper = document.getElementById('painting-wrapper');
801 wrapper.classList.add('switching');
802 setTimeout(() => {
803 currentIndex = index;
804 applyArtwork(ARTWORKS[index]);
805 renderSeries();
806 wrapper.classList.remove('switching');
807 }, 380);
808 }
809
810 function openDetail() { document.getElementById('detail-overlay').classList.add('active'); }
811 function closeDetail(e) {
812 if (!e || e.target === document.getElementById('detail-overlay') || e.currentTarget.id === 'close-btn') {
813 document.getElementById('detail-overlay').classList.remove('active');
814 }
815 }
816 document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDetail(); });
817
818 function stopAudioGuide() {
819 if (!audioGuideActive) return;
820 audioSession++;
821 window.speechSynthesis.cancel();
822 audioGuideActive = false;
823 const dot = document.querySelector('.audio-dot');
824 if (dot) { dot.style.background = ''; dot.style.animation = ''; }
825 }
826
827 function speakChunked(text, sessionId) {
828 // 문장 단위로 분리 후 150자 초과 시 추가 분할
829 const sentences = text.match(/[^.!?\n]+[.!?\n]*/g) || [text];
830 const chunks = [];
831 for (const s of sentences) {
832 let t = s.trim();
833 if (!t) continue;
834 while (t.length > 150) {
835 let cut = t.lastIndexOf(' ', 150);
836 if (cut < 30) cut = 150;
837 chunks.push(t.slice(0, cut).trim());
838 t = t.slice(cut).trim();
839 }
840 if (t) chunks.push(t);
841 }
842
843 let i = 0;
844 const dot = document.querySelector('.audio-dot');
845
846 function next() {
847 if (sessionId !== audioSession) return;
848 if (i >= chunks.length) {
849 audioGuideActive = false;
850 if (dot) { dot.style.background = ''; dot.style.animation = ''; }
851 return;
852 }
853 const u = new SpeechSynthesisUtterance(chunks[i++]);
854 u.lang = 'ko-KR';
855 u.rate = 0.88;
856 u.pitch = 1.0;
857 u.onend = () => { if (sessionId === audioSession) next(); };
858 u.onerror = () => {};
859 window.speechSynthesis.speak(u);
860 }
861
862 next();
863 }
864
865 function toggleAudio() {
866 if (!('speechSynthesis' in window)) {
867 alert('이 브라우저는 오디오 가이드를 지원하지 않아요.');
868 return;
869 }
870
871 if (audioGuideActive) { stopAudioGuide(); return; }
872
873 const art = ARTWORKS[currentIndex];
874 const guideText = `${art.labelTitle}. ${stripHtml(art.wallText)} ${art.detailDesc}`;
875 const sessionId = ++audioSession;
876 const dot = document.querySelector('.audio-dot');
877
878 audioGuideActive = true;
879 dot.style.background = 'rgb(100, 200, 150)';
880 dot.style.animation = 'none';
881
882 speakChunked(guideText, sessionId);
883 }
884
885 applyArtwork(ARTWORKS[0]);
886 renderSeries();
887 setCuratorMode(false);
888 document.getElementById('curatorModeBtn').addEventListener('click', () => setCuratorMode(!curatorMode));
889 document.getElementById('curatorNoteSave').addEventListener('click', saveCuratorNote);
890 document.getElementById('reviewBtn').addEventListener('click', generateReview);
891 </script>
892 </body>
893 </html>
894