본문으로 건너뛰기
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 <link rel="stylesheet" href="/css/site-ui.css?v=3">
406 <link rel="stylesheet" href="/css/site-app.css?v=1">
407 </head>
408 <body data-ui-page="exhibition" class="ui-shell <%= typeof theme !== 'undefined' && theme === 'dark' ? 'dark-mode' : '' %>">
409
410 <%- include('partials/site-header', { uiPage: 'exhibition' }) %>
411 <div id="ui-content" tabindex="-1" class="ui-content-anchor"></div>
412
413 <div id="room">
414 <div id="wall"></div>
415 <div id="molding-top"></div>
416 <div id="spotlight"></div>
417
418 <!-- 시리즈 탐색 (왼쪽) -->
419 <div id="series-badge">
420 <div class="series-label">시리즈</div>
421 <div class="series-items" id="seriesItems"></div>
422 </div>
423
424 <!-- 작품 -->
425 <div id="painting-wrapper" onclick="openDetail()">
426 <div id="frame">
427 <img id="painting" src="" alt="">
428 <div id="frame-glare"></div>
429 </div>
430 <div id="label-wrapper">
431 <div id="label">
432 <div class="label-title" id="labelTitle"></div>
433 <div class="label-divider"></div>
434 <div class="label-meta" id="labelMeta"></div>
435 <div class="label-accession" id="labelAccession"></div>
436 </div>
437 </div>
438 </div>
439
440 <!-- 벽면 설명 -->
441 <div id="wall-text">
442 <div class="wall-text-head">
443 <div class="wall-text-title" id="wallTextTitle">작품 해설</div>
444 <button class="curator-mode-btn" id="curatorModeBtn" type="button">Curator Off</button>
445 </div>
446 <div class="wall-text-body" id="wallTextBody"></div>
447 <div class="curator-panel" id="curatorPanel">
448 <div class="curator-theme" id="curatorTheme"></div>
449 <div class="curator-focus-title">관람 포인트</div>
450 <ul class="curator-focus-list" id="curatorFocusList"></ul>
451 <div class="curator-question" id="curatorQuestion"></div>
452 <div class="curator-note-box">
453 <input id="curatorNoteInput" class="curator-note-input" maxlength="220" placeholder="한 줄 감상 메모를 남겨보세요">
454 <button id="curatorNoteSave" class="curator-note-save" type="button">저장</button>
455 <div id="curatorNoteStatus" class="curator-note-status">메모는 이 브라우저에 저장돼요.</div>
456 </div>
457 </div>
458 <div class="ai-review-box">
459 <div class="ai-review-head">
460 <div class="ai-review-title">히나나 AI 감상평</div>
461 <button class="ai-review-btn" id="reviewBtn" type="button">감상평 듣기</button>
462 </div>
463 <div class="ai-review-text" id="reviewText">버튼을 누르면 현재 작품 감상평을 들려드릴게요.</div>
464 </div>
465 </div>
466
467 <div id="molding-base"></div>
468 <div id="floor"></div>
469 <div id="room-number">Gallery IV · Room 12</div>
470 <div id="audio-guide" onclick="toggleAudio()">
471 <div class="audio-dot"></div>
472 <span id="audioNumber">오디오 가이드 · 작품 번호 47</span>
473 </div>
474
475 <!-- 푸터 -->
476 <div id="gallery-footer">
477 <div class="gf-inner">
478 <div class="gf-brand">
479 <img src="/image/lounge1.png" alt="비나래 라운지" class="gf-logo">
480 <span class="gf-name">비나래 라운지</span>
481 </div>
482 <div class="gf-links">
483 <a href="/hinana/lounge">라운지</a>
484 <a href="/hinana/gallery">갤러리</a>
485 <a href="/hinana/index">아카이브</a>
486 </div>
487 <div class="gf-copy">ⓒ 2024~2026. 비나래 | hinana.moe &nbsp;·&nbsp; X @NoctchillHinana</div>
488 </div>
489 </div>
490 </div>
491
492 <!-- 상세 오버레이 -->
493 <div id="detail-overlay" onclick="closeDetail(event)">
494 <div id="detail-frame">
495 <img id="detail-img" src="" alt="">
496 </div>
497 <div id="detail-text">
498 <div class="detail-eyebrow" id="detailEyebrow"></div>
499 <div class="detail-title-main" id="detailTitle"></div>
500 <div class="detail-subtitle" id="detailSubtitle"></div>
501 <div class="detail-rule"></div>
502 <div class="detail-desc" id="detailDesc"></div>
503 <dl class="detail-facts" id="detailFacts"></dl>
504 </div>
505 <button id="close-btn" onclick="closeDetail()">닫기 · Close</button>
506 </div>
507
508 <script>
509 const ARTWORKS = [
510 {
511 img: '/uploads/aoba.jpeg',
512 thumb: '/uploads/aoba.jpeg',
513 seriesName: '우츠미 아오바',
514 seriesNo: 'No. 1 · 2026',
515 labelTitle: '우츠미 아오바',
516 labelTitleEm: 'Utsumi Aoba — Railway Maintenance Division',
517 labelMeta: ['유화 기법의 수채화 / Watercolour in Oil Style', '캔버스에 혼합 매체 · Mixed media on canvas', '2026'],
518 labelAccession: 'Acc. No. BA-2026 · 전시실 IV',
519 wallText: '<p>철도 정비사의 제복을 입은 소녀의 모습을 인상주의적 붓터치로 담아낸 작품이다.</p><p>금빛 후광과 짙은 남색 유니폼의 대비 속에서, 무거운 공구 가방을 든 그녀의 표정은 조용한 자부심을 전한다.</p><p>빛과 그림자의 두꺼운 질감은 노동의 무게와 동시에 젊음의 가벼움을 공존시킨다.</p>',
520 audioNo: '오디오 가이드 · 작품 번호 47',
521 detailEyebrow: 'Blue Archive · 특별 기획전',
522 detailTitle: '우츠미 아오바',
523 detailSubtitle: '<em>Utsumi Aoba, Railway Engineer</em>',
524 detailDesc: '철도 정비부 소속의 아오바는 늘 묵직한 공구 가방과 함께 선로를 걷는다. 금빛 후광은 그녀의 일상적 헌신을 성스러운 것으로 만들고, 인상주의적 붓질은 그 순간의 빛과 온도를 화면 위에 영원히 봉인한다.',
525 curatorTheme: 'Curatorial Lens · 노동의 온기',
526 curatorFocus: [
527 '금빛 후광과 남색 제복의 대비가 인물의 중심성을 어떻게 만드는지 본다.',
528 '공구 가방의 무게감이 표정의 고요함과 어떤 긴장을 이루는지 살핀다.',
529 '붓터치가 금속의 냉기 대신 체온처럼 느껴지는 지점을 찾는다.'
530 ],
531 curatorQuestion: '질문: 이 장면에서 가장 먼저 눈에 들어오는 요소는 무엇이었나요?',
532 facts: [
533 { dt: '기법', dd: '혼합 매체 (수채·유화)' },
534 { dt: '제작 연도', dd: '2026' },
535 { dt: '크기', dd: '100 × 100 cm' },
536 { dt: '소장처', dd: '아비도스 미술관' },
537 { dt: '소재', dd: '캔버스, 혼합 물감' },
538 { dt: '등록 번호', dd: 'BA-2026-047' },
539 ]
540 },
541 {
542 img: '/uploads/aobadoll.jpeg',
543 thumb: '/uploads/aobadoll.jpeg',
544 seriesName: 'KTX를 수리하는 아오바',
545 seriesNo: 'No. 2 · 2026',
546 labelTitle: 'KTX를 수리하는 아오바 (인형)',
547 labelTitleEm: 'Aoba Doll Repairing the KTX · Railway Maintenance Series No. 2',
548 labelMeta: ['유화 기법 / Oil Painting Technique', '캔버스에 혼합 매체 · Mixed media on canvas', '2026'],
549 labelAccession: 'Acc. No. BA-2026-048 · 전시실 IV',
550 wallText: '<p>KTX-산천 차체를 배경으로, 정비 복장의 아오바 인형이 묵묵히 선로를 살핀다.</p><p>작은 체구와 차가운 철재의 대비가 오히려 그녀의 집중력을 더욱 도드라지게 만든다.</p><p>차갑고 건조한 공기 속에서도 손끝의 온기가 화면 전체를 감싸고 있다.</p>',
551 audioNo: '오디오 가이드 · 작품 번호 48',
552 detailEyebrow: 'Blue Archive · 철도 시리즈 No. 2',
553 detailTitle: 'KTX를 수리하는<br>아오바',
554 detailSubtitle: '<em>Aoba Doll Repairing the KTX</em>',
555 detailDesc: 'KTX-산천 차체를 배경으로, 인형 형태의 아오바가 정비 작업에 몰두하고 있다. 유화의 두꺼운 붓터치가 금속 표면의 냉기와 그녀의 온기를 동시에 포착하며, 시리즈 전체를 관통하는 노동과 헌신의 서사를 이어간다.',
556 curatorTheme: 'Curatorial Lens · 스케일의 역전',
557 curatorFocus: [
558 '인형 같은 작은 체구와 거대한 차체가 만드는 스케일 대비를 본다.',
559 '차가운 금속면을 표현한 붓 결이 감정적으로는 얼마나 따뜻하게 읽히는지 살핀다.',
560 '같은 철도 시리즈 1번 작품과 감정선이 어떻게 이어지는지 비교한다.'
561 ],
562 curatorQuestion: '질문: 차가움과 따뜻함 중 어떤 감정이 더 크게 남았나요?',
563 facts: [
564 { dt: '기법', dd: '혼합 매체 (수채·유화)' },
565 { dt: '제작 연도', dd: '2026' },
566 { dt: '크기', dd: '100 × 100 cm' },
567 { dt: '소장처', dd: '아비도스 미술관' },
568 { dt: '시리즈', dd: '철도 정비 시리즈' },
569 { dt: '등록 번호', dd: 'BA-2026-048' },
570 ]
571 },
572 {
573 img: '/uploads/ktxaoba.jpeg',
574 thumb: '/uploads/ktxaoba.jpeg',
575 seriesName: 'KTX 정비 현장의 아오바',
576 seriesNo: 'No. 3 · 2026',
577 labelTitle: 'KTX 정비 현장의 아오바',
578 labelTitleEm: 'Aoba at the KTX Maintenance Site · Railway Maintenance Series No. 3',
579 labelMeta: ['유화 기법 · 임파스토 / Oil, Impasto Technique', '캔버스에 혼합 매체 · Mixed media on canvas', '2026'],
580 labelAccession: 'Acc. No. BA-2026-049 · 전시실 IV',
581 wallText: '<p>역 플랫폼. KTX-산천의 차체가 화면을 가득 채운 가운데, 아오바는 무릎을 꿇고 전장(電裝) 패널 안으로 손을 뻗는다.</p><p>금빛 후광, 파란 정비복, 흰 장갑의 삼색 대비는 종교화의 문법을 차용한다. 공구함 옆에 놓인 작은 인형은 연작의 복선이다.</p><p>두꺼운 임파스토 붓질은 금속과 빛, 집중한 손끝의 긴장감을 동시에 포착한다.</p>',
582 audioNo: '오디오 가이드 · 작품 번호 49',
583 detailEyebrow: 'Blue Archive · 철도 정비 연작 No. 3',
584 detailTitle: 'KTX 정비 현장의<br>아오바',
585 detailSubtitle: '<em>Aoba at the KTX Maintenance Site</em>',
586 detailDesc: 'KTX-산천 차체의 압도적인 질량 앞에서, 아오바는 조용히 무릎을 꿇는다. 전장 패널 속 케이블들 사이에 뻗은 흰 장갑의 손끝은 섬세하고 단호하다. 금빛 후광은 역사(驛舍)의 형광등 아래서도 빛을 잃지 않는다. 작가는 영웅을 거대한 것에서 찾지 않는다 — 정밀한 손끝에서 찾는다.',
587 curatorTheme: 'Curatorial Lens · 정밀함의 경건함',
588 curatorFocus: [
589 '금빛 후광과 형광등 빛이 신성함과 일상을 어떻게 동시에 만드는지 본다.',
590 '임파스토 붓질이 금속 표면의 냉기와 아오바의 집중력을 어떻게 표현하는지 살핀다.',
591 '공구함 옆 작은 인형이 연작 전체에서 어떤 복선 역할을 하는지 생각한다.'
592 ],
593 curatorQuestion: '질문: 이 장면에서 경건함을 느끼게 하는 요소는 무엇인가요?',
594 facts: [
595 { dt: '기법', dd: '유화 (임파스토)' },
596 { dt: '제작 연도', dd: '2026' },
597 { dt: '크기', dd: '120 × 120 cm' },
598 { dt: '소장처', dd: '아비도스 미술관' },
599 { dt: '시리즈', dd: '철도 정비 연작' },
600 { dt: '등록 번호', dd: 'BA-2026-049' },
601 ]
602 }
603 ];
604
605 let currentIndex = 0;
606 let curatorMode = false;
607 let reviewLoading = false;
608 let audioGuideActive = false;
609 let audioSession = 0;
610 let reviewAutoRequestSeq = 0;
611 const reviewCache = new Map();
612 const CURATOR_NOTE_STORAGE_KEY = 'hinana_exhibition_curator_notes_v1';
613 let curatorNotes = {};
614 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
615
616 function getCuratorNoteKey() { return `artwork_${currentIndex}`; }
617 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
618
619 function setCuratorMode(enabled) {
620 curatorMode = enabled;
621 const btn = document.getElementById('curatorModeBtn');
622 const wallTitle = document.getElementById('wallTextTitle');
623 const wallBody = document.getElementById('wallTextBody');
624 const curatorPanel = document.getElementById('curatorPanel');
625 btn.classList.toggle('active', curatorMode);
626 btn.textContent = curatorMode ? 'Curator On' : 'Curator Off';
627 wallTitle.textContent = curatorMode ? '큐레이터 모드' : '작품 해설';
628 wallBody.style.display = curatorMode ? 'none' : 'block';
629 curatorPanel.style.display = curatorMode ? 'block' : 'none';
630 if (curatorMode) renderCuratorPanel(ARTWORKS[currentIndex]);
631 }
632
633 function renderCuratorPanel(art) {
634 document.getElementById('curatorTheme').textContent = art.curatorTheme || '';
635 document.getElementById('curatorFocusList').innerHTML = (art.curatorFocus || []).map(item => `<li>${item}</li>`).join('');
636 document.getElementById('curatorQuestion').textContent = art.curatorQuestion || '';
637 const noteKey = getCuratorNoteKey();
638 const input = document.getElementById('curatorNoteInput');
639 const status = document.getElementById('curatorNoteStatus');
640 input.value = curatorNotes[noteKey] || '';
641 status.textContent = curatorNotes[noteKey] ? '저장된 메모가 있어요. 수정 후 다시 저장할 수 있어요.' : '메모는 이 브라우저에 저장돼요.';
642 }
643
644 function saveCuratorNote() {
645 const input = document.getElementById('curatorNoteInput');
646 const status = document.getElementById('curatorNoteStatus');
647 const value = (input.value || '').trim().slice(0, 220);
648 const noteKey = getCuratorNoteKey();
649 if (!value) { delete curatorNotes[noteKey]; persistCuratorNotes(); status.textContent = '메모를 비웠어요.'; return; }
650 curatorNotes[noteKey] = value; persistCuratorNotes(); status.textContent = '메모를 저장했어요.';
651 }
652
653 function stripHtml(input) {
654 const div = document.createElement('div');
655 div.innerHTML = input || '';
656 return (div.textContent || div.innerText || '').trim();
657 }
658
659 function setReviewLoading(isLoading) {
660 reviewLoading = isLoading;
661 const btn = document.getElementById('reviewBtn');
662 if (!btn) return;
663 btn.disabled = isLoading;
664 btn.textContent = isLoading ? '생성 중...' : '감상평 듣기';
665 }
666
667 function renderReviewText(text) {
668 const el = document.getElementById('reviewText');
669 if (el) el.textContent = text;
670 }
671
672 function syncReviewPanel() {
673 const cached = reviewCache.get(currentIndex);
674 renderReviewText(cached || '버튼을 누르면 현재 작품 감상평을 들려드릴게요.');
675 }
676
677 function buildReviewPayload(art) {
678 return {
679 title: art.labelTitle,
680 subtitle: stripHtml(art.detailSubtitle),
681 description: art.detailDesc,
682 techniques: art.labelMeta
683 };
684 }
685
686 async function hydrateReviewFromServerCache() {
687 const localCached = reviewCache.get(currentIndex);
688 if (localCached) {
689 renderReviewText(localCached);
690 return;
691 }
692
693 const indexAtRequest = currentIndex;
694 const requestSeq = ++reviewAutoRequestSeq;
695 const art = ARTWORKS[indexAtRequest];
696 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
697
698 try {
699 const response = await fetch('/api/exhibition/review/cached', {
700 method: 'POST',
701 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
702 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
703 });
704 if (!response.ok) return;
705
706 const payload = await response.json();
707 const review = String(payload?.review || '').trim();
708 if (!payload?.cached || !review) return;
709 if (requestSeq !== reviewAutoRequestSeq || indexAtRequest !== currentIndex) return;
710
711 reviewCache.set(indexAtRequest, review);
712 renderReviewText(review);
713 } catch (err) {}
714 }
715
716 async function generateReview() {
717 if (reviewLoading) return;
718 const cached = reviewCache.get(currentIndex);
719 if (cached) { renderReviewText(cached); return; }
720 const art = ARTWORKS[currentIndex];
721 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
722 try {
723 setReviewLoading(true);
724 renderReviewText('히나나가 작품을 조용히 바라보고 있어요...');
725 const response = await fetch('/api/exhibition/review', {
726 method: 'POST',
727 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
728 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
729 });
730 const payload = await response.json();
731 if (!response.ok) throw new Error(payload?.error || '감상평을 불러오지 못했어요.');
732 const review = String(payload.review || '').trim();
733 if (!review) throw new Error('빈 감상평이 돌아왔어요.');
734 reviewCache.set(currentIndex, review);
735 renderReviewText(review);
736 } catch (err) {
737 renderReviewText('감상평을 준비하지 못했어요. 잠시 후 다시 시도해 주세요.');
738 } finally {
739 setReviewLoading(false);
740 }
741 }
742
743 function renderSeries() {
744 const container = document.getElementById('seriesItems');
745 container.innerHTML = '';
746 ARTWORKS.forEach((art, i) => {
747 const btn = document.createElement('button');
748 btn.className = 'series-item' + (i === currentIndex ? ' current' : '');
749 btn.innerHTML = `
750 <img class="series-thumb" src="${art.thumb}" alt="" onerror="this.src='/image/lounge1.png'">
751 <div class="series-thumb-label">${art.seriesName}<em>${art.seriesNo}</em></div>
752 `;
753 btn.addEventListener('click', () => { if (i !== currentIndex) switchTo(i); });
754 container.appendChild(btn);
755 });
756 }
757
758 function applyArtwork(art) {
759 document.getElementById('painting').src = art.img;
760 document.getElementById('painting').onerror = function() { this.src = '/image/lounge1.png'; };
761 document.getElementById('detail-img').src = art.img;
762
763 const lt = document.getElementById('labelTitle');
764 lt.textContent = art.labelTitle;
765 const em = document.createElement('em');
766 em.textContent = art.labelTitleEm;
767 lt.appendChild(em);
768
769 document.getElementById('labelMeta').innerHTML = art.labelMeta.map(s => `<span>${s}</span>`).join('');
770 document.getElementById('labelAccession').textContent = art.labelAccession;
771 document.getElementById('wallTextBody').innerHTML = art.wallText;
772 document.getElementById('audioNumber').textContent = art.audioNo;
773
774 document.getElementById('detailEyebrow').textContent = art.detailEyebrow;
775 document.getElementById('detailTitle').innerHTML = art.detailTitle;
776 document.getElementById('detailSubtitle').innerHTML = art.detailSubtitle;
777 document.getElementById('detailDesc').textContent = art.detailDesc;
778 document.getElementById('detailFacts').innerHTML = art.facts.map(f =>
779 `<div><dt>${f.dt}</dt><dd>${f.dd}</dd></div>`
780 ).join('');
781
782 if (curatorMode) renderCuratorPanel(art);
783 syncReviewPanel();
784 hydrateReviewFromServerCache();
785 }
786
787 function switchTo(index) {
788 stopAudioGuide();
789 const wrapper = document.getElementById('painting-wrapper');
790 wrapper.classList.add('switching');
791 setTimeout(() => {
792 currentIndex = index;
793 applyArtwork(ARTWORKS[index]);
794 renderSeries();
795 wrapper.classList.remove('switching');
796 }, 380);
797 }
798
799 function openDetail() { document.getElementById('detail-overlay').classList.add('active'); }
800 function closeDetail(e) {
801 if (!e || e.target === document.getElementById('detail-overlay') || e.currentTarget.id === 'close-btn') {
802 document.getElementById('detail-overlay').classList.remove('active');
803 }
804 }
805 document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDetail(); });
806
807 function stopAudioGuide() {
808 if (!audioGuideActive) return;
809 audioSession++;
810 window.speechSynthesis.cancel();
811 audioGuideActive = false;
812 const dot = document.querySelector('.audio-dot');
813 if (dot) { dot.style.background = ''; dot.style.animation = ''; }
814 }
815
816 function speakChunked(text, sessionId) {
817 // 문장 단위로 분리 후 150자 초과 시 추가 분할
818 const sentences = text.match(/[^.!?\n]+[.!?\n]*/g) || [text];
819 const chunks = [];
820 for (const s of sentences) {
821 let t = s.trim();
822 if (!t) continue;
823 while (t.length > 150) {
824 let cut = t.lastIndexOf(' ', 150);
825 if (cut < 30) cut = 150;
826 chunks.push(t.slice(0, cut).trim());
827 t = t.slice(cut).trim();
828 }
829 if (t) chunks.push(t);
830 }
831
832 let i = 0;
833 const dot = document.querySelector('.audio-dot');
834
835 function next() {
836 if (sessionId !== audioSession) return;
837 if (i >= chunks.length) {
838 audioGuideActive = false;
839 if (dot) { dot.style.background = ''; dot.style.animation = ''; }
840 return;
841 }
842 const u = new SpeechSynthesisUtterance(chunks[i++]);
843 u.lang = 'ko-KR';
844 u.rate = 0.88;
845 u.pitch = 1.0;
846 u.onend = () => { if (sessionId === audioSession) next(); };
847 u.onerror = () => {};
848 window.speechSynthesis.speak(u);
849 }
850
851 next();
852 }
853
854 function toggleAudio() {
855 if (!('speechSynthesis' in window)) {
856 alert('이 브라우저는 오디오 가이드를 지원하지 않아요.');
857 return;
858 }
859
860 if (audioGuideActive) { stopAudioGuide(); return; }
861
862 const art = ARTWORKS[currentIndex];
863 const guideText = `${art.labelTitle}. ${stripHtml(art.wallText)} ${art.detailDesc}`;
864 const sessionId = ++audioSession;
865 const dot = document.querySelector('.audio-dot');
866
867 audioGuideActive = true;
868 dot.style.background = 'rgb(100, 200, 150)';
869 dot.style.animation = 'none';
870
871 speakChunked(guideText, sessionId);
872 }
873
874 applyArtwork(ARTWORKS[0]);
875 renderSeries();
876 setCuratorMode(false);
877 document.getElementById('curatorModeBtn').addEventListener('click', () => setCuratorMode(!curatorMode));
878 document.getElementById('curatorNoteSave').addEventListener('click', saveCuratorNote);
879 document.getElementById('reviewBtn').addEventListener('click', generateReview);
880 </script>
881 </body>
882 </html>
883