Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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 · 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