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
</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 · 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