neiki-gallery 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/neiki-gallery.css +675 -0
- package/dist/neiki-gallery.js +955 -0
- package/dist/neiki-gallery.min.css +1 -0
- package/dist/neiki-gallery.min.js +12 -0
- package/package.json +39 -0
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Neiki Gallery v1.0.0
|
|
3
|
+
* A vanilla JavaScript image gallery / lightbox library.
|
|
4
|
+
* No dependencies. No frameworks.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* Auto-init: <div data-neiki-gallery>...</div>
|
|
8
|
+
* Manual: new NeikiGallery('#my-gallery', { ... });
|
|
9
|
+
*
|
|
10
|
+
* License: MIT
|
|
11
|
+
*/
|
|
12
|
+
(function (root, factory) {
|
|
13
|
+
if (typeof define === 'function' && define.amd) {
|
|
14
|
+
define([], factory);
|
|
15
|
+
} else if (typeof module === 'object' && module.exports) {
|
|
16
|
+
module.exports = factory();
|
|
17
|
+
} else {
|
|
18
|
+
root.NeikiGallery = factory();
|
|
19
|
+
}
|
|
20
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
/* ========================================================================
|
|
24
|
+
Helpers
|
|
25
|
+
======================================================================== */
|
|
26
|
+
|
|
27
|
+
var uid = 0;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate a unique ID for each gallery instance.
|
|
31
|
+
*/
|
|
32
|
+
function nextId() {
|
|
33
|
+
return ++uid;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Merge defaults with user options (shallow).
|
|
38
|
+
*/
|
|
39
|
+
function mergeOptions(defaults, opts) {
|
|
40
|
+
var result = {};
|
|
41
|
+
for (var key in defaults) {
|
|
42
|
+
if (defaults.hasOwnProperty(key)) {
|
|
43
|
+
result[key] = defaults[key];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (opts) {
|
|
47
|
+
for (var key in opts) {
|
|
48
|
+
if (opts.hasOwnProperty(key)) {
|
|
49
|
+
result[key] = opts[key];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Query helper.
|
|
58
|
+
*/
|
|
59
|
+
function $(selector, ctx) {
|
|
60
|
+
return (ctx || document).querySelector(selector);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function $$(selector, ctx) {
|
|
64
|
+
return Array.prototype.slice.call((ctx || document).querySelectorAll(selector));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create an element with optional class, attributes, and innerHTML.
|
|
69
|
+
*/
|
|
70
|
+
function createElement(tag, className, attrs, html) {
|
|
71
|
+
var el = document.createElement(tag);
|
|
72
|
+
if (className) el.className = className;
|
|
73
|
+
if (attrs) {
|
|
74
|
+
for (var k in attrs) {
|
|
75
|
+
if (attrs.hasOwnProperty(k)) el.setAttribute(k, attrs[k]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (html) el.innerHTML = html;
|
|
79
|
+
return el;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ========================================================================
|
|
83
|
+
SVG Icons
|
|
84
|
+
======================================================================== */
|
|
85
|
+
|
|
86
|
+
var ICONS = {
|
|
87
|
+
close: '<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
|
88
|
+
prev: '<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>',
|
|
89
|
+
next: '<svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg>',
|
|
90
|
+
fullscreen: '<svg viewBox="0 0 24 24"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>',
|
|
91
|
+
exitFullscreen: '<svg viewBox="0 0 24 24"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/* ========================================================================
|
|
95
|
+
Default Options
|
|
96
|
+
======================================================================== */
|
|
97
|
+
|
|
98
|
+
var DEFAULTS = {
|
|
99
|
+
layout: 'masonry', // 'masonry' | 'grid'
|
|
100
|
+
loop: false, // infinite loop navigation
|
|
101
|
+
thumbnails: true, // show thumbnail strip in lightbox
|
|
102
|
+
zoom: true, // allow zoom on click in lightbox
|
|
103
|
+
fullscreen: true, // show fullscreen button
|
|
104
|
+
transition: 'fade', // 'fade' | 'slide'
|
|
105
|
+
theme: 'dark', // 'dark' | 'light'
|
|
106
|
+
hashNavigation: true, // deep linking via URL hash
|
|
107
|
+
counter: true, // show image counter
|
|
108
|
+
captions: true, // show captions
|
|
109
|
+
preload: 1, // how many adjacent images to preload
|
|
110
|
+
lazyLoad: true // lazy load grid thumbnails
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/* ========================================================================
|
|
114
|
+
NeikiGallery Constructor
|
|
115
|
+
======================================================================== */
|
|
116
|
+
|
|
117
|
+
function NeikiGallery(selectorOrElement, options) {
|
|
118
|
+
// Allow omitting `new`
|
|
119
|
+
if (!(this instanceof NeikiGallery)) {
|
|
120
|
+
return new NeikiGallery(selectorOrElement, options);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this._id = nextId();
|
|
124
|
+
this._events = {};
|
|
125
|
+
this._isOpen = false;
|
|
126
|
+
this._currentIndex = 0;
|
|
127
|
+
this._isZoomed = false;
|
|
128
|
+
this._isFullscreen = false;
|
|
129
|
+
this._destroyed = false;
|
|
130
|
+
this._boundHandlers = {};
|
|
131
|
+
|
|
132
|
+
// Resolve the container element
|
|
133
|
+
if (typeof selectorOrElement === 'string') {
|
|
134
|
+
this._container = $(selectorOrElement);
|
|
135
|
+
} else {
|
|
136
|
+
this._container = selectorOrElement;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!this._container) {
|
|
140
|
+
console.warn('NeikiGallery: Container not found for selector "' + selectorOrElement + '"');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Read data-* attributes from the container and merge with JS options
|
|
145
|
+
var dataOpts = this._readDataAttributes();
|
|
146
|
+
this._options = mergeOptions(DEFAULTS, mergeOptions(dataOpts, options));
|
|
147
|
+
|
|
148
|
+
// Parse items from DOM
|
|
149
|
+
this._items = this._parseItems();
|
|
150
|
+
|
|
151
|
+
if (this._items.length === 0) {
|
|
152
|
+
console.warn('NeikiGallery: No items found in container.');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Initialize
|
|
157
|
+
this._setupGrid();
|
|
158
|
+
this._setupLazyLoad();
|
|
159
|
+
this._buildLightbox();
|
|
160
|
+
this._bindEvents();
|
|
161
|
+
this._checkHash();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ========================================================================
|
|
165
|
+
Prototype — Private Methods
|
|
166
|
+
======================================================================== */
|
|
167
|
+
|
|
168
|
+
NeikiGallery.prototype._readDataAttributes = function () {
|
|
169
|
+
var c = this._container;
|
|
170
|
+
var opts = {};
|
|
171
|
+
if (c.hasAttribute('data-layout')) opts.layout = c.getAttribute('data-layout');
|
|
172
|
+
if (c.hasAttribute('data-theme')) opts.theme = c.getAttribute('data-theme');
|
|
173
|
+
if (c.hasAttribute('data-loop')) opts.loop = c.getAttribute('data-loop') !== 'false';
|
|
174
|
+
if (c.hasAttribute('data-thumbnails')) opts.thumbnails = c.getAttribute('data-thumbnails') !== 'false';
|
|
175
|
+
if (c.hasAttribute('data-zoom')) opts.zoom = c.getAttribute('data-zoom') !== 'false';
|
|
176
|
+
if (c.hasAttribute('data-fullscreen')) opts.fullscreen = c.getAttribute('data-fullscreen') !== 'false';
|
|
177
|
+
if (c.hasAttribute('data-transition')) opts.transition = c.getAttribute('data-transition');
|
|
178
|
+
if (c.hasAttribute('data-hash-navigation')) opts.hashNavigation = c.getAttribute('data-hash-navigation') !== 'false';
|
|
179
|
+
return opts;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Parse <a><img></a> children into an items array.
|
|
184
|
+
*/
|
|
185
|
+
NeikiGallery.prototype._parseItems = function () {
|
|
186
|
+
var anchors = $$(':scope > a', this._container);
|
|
187
|
+
var items = [];
|
|
188
|
+
for (var i = 0; i < anchors.length; i++) {
|
|
189
|
+
var a = anchors[i];
|
|
190
|
+
var img = $('img', a);
|
|
191
|
+
items.push({
|
|
192
|
+
src: a.getAttribute('href') || (img ? img.getAttribute('src') : ''),
|
|
193
|
+
thumb: img ? (img.getAttribute('data-src') || img.getAttribute('src')) : '',
|
|
194
|
+
caption: a.getAttribute('data-caption') || (img ? img.getAttribute('alt') : '') || '',
|
|
195
|
+
element: a,
|
|
196
|
+
img: img
|
|
197
|
+
});
|
|
198
|
+
// Prevent default link behaviour
|
|
199
|
+
a.addEventListener('click', function (e) { e.preventDefault(); });
|
|
200
|
+
}
|
|
201
|
+
return items;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Apply the grid layout class.
|
|
206
|
+
*/
|
|
207
|
+
NeikiGallery.prototype._setupGrid = function () {
|
|
208
|
+
this._container.classList.add('neiki-gallery');
|
|
209
|
+
if (this._options.layout === 'grid') {
|
|
210
|
+
this._container.classList.add('neiki-gallery--grid');
|
|
211
|
+
this._container.classList.remove('neiki-gallery--masonry');
|
|
212
|
+
} else {
|
|
213
|
+
this._container.classList.add('neiki-gallery--masonry');
|
|
214
|
+
this._container.classList.remove('neiki-gallery--grid');
|
|
215
|
+
}
|
|
216
|
+
// Theme
|
|
217
|
+
if (this._options.theme) {
|
|
218
|
+
this._container.setAttribute('data-theme', this._options.theme);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Lazy load grid thumbnails via IntersectionObserver.
|
|
224
|
+
*/
|
|
225
|
+
NeikiGallery.prototype._setupLazyLoad = function () {
|
|
226
|
+
var self = this;
|
|
227
|
+
if (!this._options.lazyLoad || !('IntersectionObserver' in window)) {
|
|
228
|
+
// Fallback: load all immediately
|
|
229
|
+
this._items.forEach(function (item) {
|
|
230
|
+
if (item.img) item.img.classList.add('neiki-loaded');
|
|
231
|
+
});
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this._observer = new IntersectionObserver(function (entries) {
|
|
236
|
+
entries.forEach(function (entry) {
|
|
237
|
+
if (entry.isIntersecting) {
|
|
238
|
+
var img = entry.target;
|
|
239
|
+
var lazySrc = img.getAttribute('data-src');
|
|
240
|
+
if (lazySrc) {
|
|
241
|
+
img.src = lazySrc;
|
|
242
|
+
img.removeAttribute('data-src');
|
|
243
|
+
}
|
|
244
|
+
img.addEventListener('load', function () {
|
|
245
|
+
img.classList.add('neiki-loaded');
|
|
246
|
+
});
|
|
247
|
+
// Already cached
|
|
248
|
+
if (img.complete && img.naturalWidth) {
|
|
249
|
+
img.classList.add('neiki-loaded');
|
|
250
|
+
}
|
|
251
|
+
self._observer.unobserve(img);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}, { rootMargin: '200px' });
|
|
255
|
+
|
|
256
|
+
this._items.forEach(function (item) {
|
|
257
|
+
if (item.img) {
|
|
258
|
+
// If img already has a real src and is loaded, mark it
|
|
259
|
+
if (item.img.complete && item.img.naturalWidth) {
|
|
260
|
+
item.img.classList.add('neiki-loaded');
|
|
261
|
+
} else {
|
|
262
|
+
self._observer.observe(item.img);
|
|
263
|
+
// Also listen for load in case it's not lazy
|
|
264
|
+
item.img.addEventListener('load', function () {
|
|
265
|
+
item.img.classList.add('neiki-loaded');
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/* ========================================================================
|
|
273
|
+
Lightbox — Build DOM
|
|
274
|
+
======================================================================== */
|
|
275
|
+
|
|
276
|
+
NeikiGallery.prototype._buildLightbox = function () {
|
|
277
|
+
var opts = this._options;
|
|
278
|
+
|
|
279
|
+
// Overlay
|
|
280
|
+
this._lightbox = createElement('div', 'neiki-lightbox neiki-lightbox--' + opts.transition, {
|
|
281
|
+
role: 'dialog',
|
|
282
|
+
'aria-modal': 'true',
|
|
283
|
+
'aria-label': 'Image lightbox',
|
|
284
|
+
tabindex: '-1'
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Apply theme
|
|
288
|
+
if (opts.theme) {
|
|
289
|
+
this._lightbox.setAttribute('data-theme', opts.theme);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Top bar (counter)
|
|
293
|
+
if (opts.counter) {
|
|
294
|
+
this._topbar = createElement('div', 'neiki-lightbox__topbar');
|
|
295
|
+
this._counter = createElement('span', 'neiki-lightbox__counter', { 'aria-live': 'polite' });
|
|
296
|
+
this._topbar.appendChild(this._counter);
|
|
297
|
+
this._lightbox.appendChild(this._topbar);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Close button
|
|
301
|
+
this._closeBtn = createElement('button', 'neiki-lightbox__btn neiki-lightbox__close', {
|
|
302
|
+
'aria-label': 'Close lightbox',
|
|
303
|
+
type: 'button'
|
|
304
|
+
}, ICONS.close);
|
|
305
|
+
this._lightbox.appendChild(this._closeBtn);
|
|
306
|
+
|
|
307
|
+
// Fullscreen button
|
|
308
|
+
if (opts.fullscreen) {
|
|
309
|
+
this._fsBtn = createElement('button', 'neiki-lightbox__btn neiki-lightbox__fullscreen', {
|
|
310
|
+
'aria-label': 'Toggle fullscreen',
|
|
311
|
+
type: 'button'
|
|
312
|
+
}, ICONS.fullscreen);
|
|
313
|
+
this._lightbox.appendChild(this._fsBtn);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Prev / Next
|
|
317
|
+
this._prevBtn = createElement('button', 'neiki-lightbox__btn neiki-lightbox__prev', {
|
|
318
|
+
'aria-label': 'Previous image',
|
|
319
|
+
type: 'button'
|
|
320
|
+
}, ICONS.prev);
|
|
321
|
+
this._nextBtn = createElement('button', 'neiki-lightbox__btn neiki-lightbox__next', {
|
|
322
|
+
'aria-label': 'Next image',
|
|
323
|
+
type: 'button'
|
|
324
|
+
}, ICONS.next);
|
|
325
|
+
|
|
326
|
+
// Stage (image area)
|
|
327
|
+
this._stage = createElement('div', 'neiki-lightbox__stage');
|
|
328
|
+
|
|
329
|
+
// Slide wrapper (for slide transition)
|
|
330
|
+
this._slideWrapper = createElement('div', 'neiki-lightbox__slide-wrapper');
|
|
331
|
+
|
|
332
|
+
// Spinner
|
|
333
|
+
this._spinner = createElement('div', 'neiki-lightbox__spinner neiki-hidden');
|
|
334
|
+
|
|
335
|
+
// Image
|
|
336
|
+
this._image = createElement('img', 'neiki-lightbox__image', {
|
|
337
|
+
alt: '',
|
|
338
|
+
draggable: 'false'
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
this._slideWrapper.appendChild(this._spinner);
|
|
342
|
+
this._slideWrapper.appendChild(this._image);
|
|
343
|
+
this._stage.appendChild(this._prevBtn);
|
|
344
|
+
this._stage.appendChild(this._slideWrapper);
|
|
345
|
+
this._stage.appendChild(this._nextBtn);
|
|
346
|
+
this._lightbox.appendChild(this._stage);
|
|
347
|
+
|
|
348
|
+
// Caption
|
|
349
|
+
if (opts.captions) {
|
|
350
|
+
this._caption = createElement('div', 'neiki-lightbox__caption', { 'aria-live': 'polite' });
|
|
351
|
+
this._lightbox.appendChild(this._caption);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Thumbnail strip
|
|
355
|
+
if (opts.thumbnails) {
|
|
356
|
+
this._thumbsContainer = createElement('div', 'neiki-lightbox__thumbs', {
|
|
357
|
+
role: 'listbox',
|
|
358
|
+
'aria-label': 'Image thumbnails'
|
|
359
|
+
});
|
|
360
|
+
this._thumbButtons = [];
|
|
361
|
+
for (var i = 0; i < this._items.length; i++) {
|
|
362
|
+
var btn = createElement('button', 'neiki-lightbox__thumb', {
|
|
363
|
+
type: 'button',
|
|
364
|
+
role: 'option',
|
|
365
|
+
'aria-label': 'View image ' + (i + 1)
|
|
366
|
+
});
|
|
367
|
+
var thumbImg = createElement('img', '', {
|
|
368
|
+
src: this._items[i].thumb,
|
|
369
|
+
alt: this._items[i].caption || 'Thumbnail ' + (i + 1),
|
|
370
|
+
draggable: 'false'
|
|
371
|
+
});
|
|
372
|
+
btn.appendChild(thumbImg);
|
|
373
|
+
this._thumbButtons.push(btn);
|
|
374
|
+
this._thumbsContainer.appendChild(btn);
|
|
375
|
+
}
|
|
376
|
+
this._lightbox.appendChild(this._thumbsContainer);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
document.body.appendChild(this._lightbox);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/* ========================================================================
|
|
383
|
+
Lightbox — Bind Events
|
|
384
|
+
======================================================================== */
|
|
385
|
+
|
|
386
|
+
NeikiGallery.prototype._bindEvents = function () {
|
|
387
|
+
var self = this;
|
|
388
|
+
|
|
389
|
+
// Grid item clicks
|
|
390
|
+
this._items.forEach(function (item, index) {
|
|
391
|
+
item.element.addEventListener('click', function (e) {
|
|
392
|
+
e.preventDefault();
|
|
393
|
+
self.open(index);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// Close button
|
|
398
|
+
this._closeBtn.addEventListener('click', function () { self.close(); });
|
|
399
|
+
|
|
400
|
+
// Nav buttons
|
|
401
|
+
this._prevBtn.addEventListener('click', function (e) { e.stopPropagation(); self.prev(); });
|
|
402
|
+
this._nextBtn.addEventListener('click', function (e) { e.stopPropagation(); self.next(); });
|
|
403
|
+
|
|
404
|
+
// Fullscreen
|
|
405
|
+
if (this._fsBtn) {
|
|
406
|
+
this._fsBtn.addEventListener('click', function (e) {
|
|
407
|
+
e.stopPropagation();
|
|
408
|
+
self._toggleFullscreen();
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Zoom on image click
|
|
413
|
+
if (this._options.zoom) {
|
|
414
|
+
this._image.addEventListener('click', function (e) {
|
|
415
|
+
e.stopPropagation();
|
|
416
|
+
self._toggleZoom();
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Thumbnail clicks
|
|
421
|
+
if (this._thumbButtons) {
|
|
422
|
+
this._thumbButtons.forEach(function (btn, idx) {
|
|
423
|
+
btn.addEventListener('click', function () { self._goTo(idx); });
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Click on stage background to close (but not on image / buttons)
|
|
428
|
+
this._stage.addEventListener('click', function (e) {
|
|
429
|
+
if (e.target === self._stage || e.target === self._slideWrapper) {
|
|
430
|
+
self.close();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Keyboard
|
|
435
|
+
this._boundHandlers.keydown = function (e) { self._onKeyDown(e); };
|
|
436
|
+
document.addEventListener('keydown', this._boundHandlers.keydown);
|
|
437
|
+
|
|
438
|
+
// Touch / Swipe
|
|
439
|
+
this._setupTouch();
|
|
440
|
+
|
|
441
|
+
// Hash change
|
|
442
|
+
if (this._options.hashNavigation) {
|
|
443
|
+
this._boundHandlers.hashchange = function () { self._onHashChange(); };
|
|
444
|
+
window.addEventListener('hashchange', this._boundHandlers.hashchange);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Fullscreen change
|
|
448
|
+
this._boundHandlers.fullscreenchange = function () { self._onFullscreenChange(); };
|
|
449
|
+
document.addEventListener('fullscreenchange', this._boundHandlers.fullscreenchange);
|
|
450
|
+
document.addEventListener('webkitfullscreenchange', this._boundHandlers.fullscreenchange);
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Keyboard handler.
|
|
455
|
+
*/
|
|
456
|
+
NeikiGallery.prototype._onKeyDown = function (e) {
|
|
457
|
+
if (!this._isOpen) return;
|
|
458
|
+
|
|
459
|
+
switch (e.key) {
|
|
460
|
+
case 'Escape':
|
|
461
|
+
e.preventDefault();
|
|
462
|
+
this.close();
|
|
463
|
+
break;
|
|
464
|
+
case 'ArrowLeft':
|
|
465
|
+
e.preventDefault();
|
|
466
|
+
this.prev();
|
|
467
|
+
break;
|
|
468
|
+
case 'ArrowRight':
|
|
469
|
+
e.preventDefault();
|
|
470
|
+
this.next();
|
|
471
|
+
break;
|
|
472
|
+
case 'Home':
|
|
473
|
+
e.preventDefault();
|
|
474
|
+
this._goTo(0);
|
|
475
|
+
break;
|
|
476
|
+
case 'End':
|
|
477
|
+
e.preventDefault();
|
|
478
|
+
this._goTo(this._items.length - 1);
|
|
479
|
+
break;
|
|
480
|
+
case 'f':
|
|
481
|
+
e.preventDefault();
|
|
482
|
+
this._toggleFullscreen();
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Touch swipe support (pointer events with fallback to touch events).
|
|
489
|
+
*/
|
|
490
|
+
NeikiGallery.prototype._setupTouch = function () {
|
|
491
|
+
var self = this;
|
|
492
|
+
var startX = 0;
|
|
493
|
+
var startY = 0;
|
|
494
|
+
var distX = 0;
|
|
495
|
+
var tracking = false;
|
|
496
|
+
var threshold = 50;
|
|
497
|
+
|
|
498
|
+
function onStart(e) {
|
|
499
|
+
if (!self._isOpen || self._isZoomed) return;
|
|
500
|
+
var point = e.touches ? e.touches[0] : e;
|
|
501
|
+
startX = point.clientX;
|
|
502
|
+
startY = point.clientY;
|
|
503
|
+
distX = 0;
|
|
504
|
+
tracking = true;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function onMove(e) {
|
|
508
|
+
if (!tracking) return;
|
|
509
|
+
var point = e.touches ? e.touches[0] : e;
|
|
510
|
+
distX = point.clientX - startX;
|
|
511
|
+
var distY = point.clientY - startY;
|
|
512
|
+
// If horizontal swipe is dominant, prevent scroll
|
|
513
|
+
if (Math.abs(distX) > Math.abs(distY) && Math.abs(distX) > 10) {
|
|
514
|
+
e.preventDefault();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function onEnd() {
|
|
519
|
+
if (!tracking) return;
|
|
520
|
+
tracking = false;
|
|
521
|
+
if (distX > threshold) {
|
|
522
|
+
self.prev();
|
|
523
|
+
} else if (distX < -threshold) {
|
|
524
|
+
self.next();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
this._stage.addEventListener('touchstart', onStart, { passive: true });
|
|
529
|
+
this._stage.addEventListener('touchmove', onMove, { passive: false });
|
|
530
|
+
this._stage.addEventListener('touchend', onEnd, { passive: true });
|
|
531
|
+
|
|
532
|
+
// Store refs for cleanup
|
|
533
|
+
this._boundHandlers.touchstart = onStart;
|
|
534
|
+
this._boundHandlers.touchmove = onMove;
|
|
535
|
+
this._boundHandlers.touchend = onEnd;
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
/* ========================================================================
|
|
539
|
+
Lightbox — Navigation Logic
|
|
540
|
+
======================================================================== */
|
|
541
|
+
|
|
542
|
+
NeikiGallery.prototype._goTo = function (index, skipHash) {
|
|
543
|
+
if (this._destroyed) return;
|
|
544
|
+
|
|
545
|
+
var len = this._items.length;
|
|
546
|
+
|
|
547
|
+
// Handle loop / bounds
|
|
548
|
+
if (this._options.loop) {
|
|
549
|
+
index = ((index % len) + len) % len;
|
|
550
|
+
} else {
|
|
551
|
+
if (index < 0) index = 0;
|
|
552
|
+
if (index >= len) index = len - 1;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (index === this._currentIndex && this._isOpen && this._image.src) {
|
|
556
|
+
// Already showing this image
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
var prevIndex = this._currentIndex;
|
|
561
|
+
this._currentIndex = index;
|
|
562
|
+
var item = this._items[index];
|
|
563
|
+
|
|
564
|
+
// Reset zoom
|
|
565
|
+
if (this._isZoomed) this._toggleZoom();
|
|
566
|
+
|
|
567
|
+
// Update nav button visibility when not looping
|
|
568
|
+
this._updateNavButtons();
|
|
569
|
+
|
|
570
|
+
// Show spinner
|
|
571
|
+
this._spinner.classList.remove('neiki-hidden');
|
|
572
|
+
|
|
573
|
+
// Load image
|
|
574
|
+
var self = this;
|
|
575
|
+
var img = this._image;
|
|
576
|
+
|
|
577
|
+
// Transition class for animation direction (slide)
|
|
578
|
+
if (this._options.transition === 'slide') {
|
|
579
|
+
var direction = index > prevIndex ? 1 : -1;
|
|
580
|
+
// If looping from last to first or vice versa, determine visually correct direction
|
|
581
|
+
if (this._options.loop) {
|
|
582
|
+
if (prevIndex === len - 1 && index === 0) direction = 1;
|
|
583
|
+
if (prevIndex === 0 && index === len - 1) direction = -1;
|
|
584
|
+
}
|
|
585
|
+
this._slideWrapper.style.transform = 'translateX(' + (-direction * 40) + 'px)';
|
|
586
|
+
img.style.opacity = '0';
|
|
587
|
+
requestAnimationFrame(function () {
|
|
588
|
+
requestAnimationFrame(function () {
|
|
589
|
+
self._slideWrapper.style.transform = 'translateX(0)';
|
|
590
|
+
img.style.opacity = '1';
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
} else {
|
|
594
|
+
// Fade
|
|
595
|
+
img.classList.add('neiki-entering');
|
|
596
|
+
img.classList.remove('neiki-active');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Set src
|
|
600
|
+
img.setAttribute('alt', item.caption || '');
|
|
601
|
+
|
|
602
|
+
// Preload into a temp image to catch load event correctly
|
|
603
|
+
var tempImg = new Image();
|
|
604
|
+
tempImg.onload = function () {
|
|
605
|
+
img.src = item.src;
|
|
606
|
+
self._spinner.classList.add('neiki-hidden');
|
|
607
|
+
if (self._options.transition === 'fade') {
|
|
608
|
+
// Small delay to trigger CSS transition
|
|
609
|
+
requestAnimationFrame(function () {
|
|
610
|
+
img.classList.remove('neiki-entering');
|
|
611
|
+
img.classList.add('neiki-active');
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
tempImg.onerror = function () {
|
|
616
|
+
img.src = item.src; // show broken image
|
|
617
|
+
self._spinner.classList.add('neiki-hidden');
|
|
618
|
+
if (self._options.transition === 'fade') {
|
|
619
|
+
requestAnimationFrame(function () {
|
|
620
|
+
img.classList.remove('neiki-entering');
|
|
621
|
+
img.classList.add('neiki-active');
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
tempImg.src = item.src;
|
|
626
|
+
|
|
627
|
+
// If already cached, fire immediately
|
|
628
|
+
if (tempImg.complete) {
|
|
629
|
+
img.src = item.src;
|
|
630
|
+
self._spinner.classList.add('neiki-hidden');
|
|
631
|
+
if (self._options.transition === 'fade') {
|
|
632
|
+
requestAnimationFrame(function () {
|
|
633
|
+
img.classList.remove('neiki-entering');
|
|
634
|
+
img.classList.add('neiki-active');
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Counter
|
|
640
|
+
if (this._counter) {
|
|
641
|
+
this._counter.textContent = (index + 1) + ' / ' + len;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Caption
|
|
645
|
+
if (this._caption) {
|
|
646
|
+
this._caption.textContent = item.caption || '';
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Active thumbnail
|
|
650
|
+
if (this._thumbButtons) {
|
|
651
|
+
this._thumbButtons.forEach(function (btn, idx) {
|
|
652
|
+
btn.classList.toggle('neiki-thumb--active', idx === index);
|
|
653
|
+
btn.setAttribute('aria-selected', idx === index ? 'true' : 'false');
|
|
654
|
+
});
|
|
655
|
+
// Scroll active thumbnail into view
|
|
656
|
+
var activeThumb = this._thumbButtons[index];
|
|
657
|
+
if (activeThumb && this._thumbsContainer) {
|
|
658
|
+
activeThumb.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Hash
|
|
663
|
+
if (this._options.hashNavigation && !skipHash) {
|
|
664
|
+
this._setHash(index);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Preload adjacent
|
|
668
|
+
this._preloadAdjacent(index);
|
|
669
|
+
|
|
670
|
+
// Fire event
|
|
671
|
+
this._emit('change', index);
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
NeikiGallery.prototype._updateNavButtons = function () {
|
|
675
|
+
if (this._options.loop) {
|
|
676
|
+
this._prevBtn.style.display = '';
|
|
677
|
+
this._nextBtn.style.display = '';
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
this._prevBtn.style.display = this._currentIndex <= 0 ? 'none' : '';
|
|
681
|
+
this._nextBtn.style.display = this._currentIndex >= this._items.length - 1 ? 'none' : '';
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Preload adjacent images.
|
|
686
|
+
*/
|
|
687
|
+
NeikiGallery.prototype._preloadAdjacent = function (index) {
|
|
688
|
+
var count = this._options.preload || 1;
|
|
689
|
+
var len = this._items.length;
|
|
690
|
+
for (var i = 1; i <= count; i++) {
|
|
691
|
+
var nextIdx = (index + i) % len;
|
|
692
|
+
var prevIdx = ((index - i) % len + len) % len;
|
|
693
|
+
if (this._items[nextIdx]) new Image().src = this._items[nextIdx].src;
|
|
694
|
+
if (this._items[prevIdx]) new Image().src = this._items[prevIdx].src;
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
/* ========================================================================
|
|
699
|
+
Zoom
|
|
700
|
+
======================================================================== */
|
|
701
|
+
|
|
702
|
+
NeikiGallery.prototype._toggleZoom = function () {
|
|
703
|
+
if (!this._options.zoom) return;
|
|
704
|
+
this._isZoomed = !this._isZoomed;
|
|
705
|
+
this._image.classList.toggle('neiki-zoomed', this._isZoomed);
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
/* ========================================================================
|
|
709
|
+
Fullscreen
|
|
710
|
+
======================================================================== */
|
|
711
|
+
|
|
712
|
+
NeikiGallery.prototype._toggleFullscreen = function () {
|
|
713
|
+
if (!this._options.fullscreen) return;
|
|
714
|
+
|
|
715
|
+
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
|
716
|
+
var el = this._lightbox;
|
|
717
|
+
if (el.requestFullscreen) {
|
|
718
|
+
el.requestFullscreen();
|
|
719
|
+
} else if (el.webkitRequestFullscreen) {
|
|
720
|
+
el.webkitRequestFullscreen();
|
|
721
|
+
}
|
|
722
|
+
} else {
|
|
723
|
+
if (document.exitFullscreen) {
|
|
724
|
+
document.exitFullscreen();
|
|
725
|
+
} else if (document.webkitExitFullscreen) {
|
|
726
|
+
document.webkitExitFullscreen();
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
NeikiGallery.prototype._onFullscreenChange = function () {
|
|
732
|
+
this._isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement);
|
|
733
|
+
if (this._fsBtn) {
|
|
734
|
+
this._fsBtn.innerHTML = this._isFullscreen ? ICONS.exitFullscreen : ICONS.fullscreen;
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
/* ========================================================================
|
|
739
|
+
Hash Navigation
|
|
740
|
+
======================================================================== */
|
|
741
|
+
|
|
742
|
+
NeikiGallery.prototype._setHash = function (index) {
|
|
743
|
+
if (typeof index === 'number' && this._isOpen) {
|
|
744
|
+
history.replaceState(null, '', '#neiki-' + this._id + '=' + (index + 1));
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
NeikiGallery.prototype._clearHash = function () {
|
|
749
|
+
if (window.location.hash.indexOf('#neiki-' + this._id + '=') === 0) {
|
|
750
|
+
history.replaceState(null, '', window.location.pathname + window.location.search);
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
NeikiGallery.prototype._checkHash = function () {
|
|
755
|
+
if (!this._options.hashNavigation) return;
|
|
756
|
+
var hash = window.location.hash;
|
|
757
|
+
var prefix = '#neiki-' + this._id + '=';
|
|
758
|
+
if (hash.indexOf(prefix) === 0) {
|
|
759
|
+
var idx = parseInt(hash.substring(prefix.length), 10);
|
|
760
|
+
if (!isNaN(idx) && idx >= 1 && idx <= this._items.length) {
|
|
761
|
+
this.open(idx - 1);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
NeikiGallery.prototype._onHashChange = function () {
|
|
767
|
+
this._checkHash();
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
/* ========================================================================
|
|
771
|
+
Event Emitter
|
|
772
|
+
======================================================================== */
|
|
773
|
+
|
|
774
|
+
NeikiGallery.prototype._emit = function (event, data) {
|
|
775
|
+
var listeners = this._events[event];
|
|
776
|
+
if (listeners) {
|
|
777
|
+
listeners.forEach(function (fn) { fn(data); });
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
/* ========================================================================
|
|
782
|
+
Public API
|
|
783
|
+
======================================================================== */
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Open the lightbox at a given index.
|
|
787
|
+
*/
|
|
788
|
+
NeikiGallery.prototype.open = function (index) {
|
|
789
|
+
if (this._destroyed) return;
|
|
790
|
+
if (typeof index !== 'number') index = 0;
|
|
791
|
+
if (index < 0) index = 0;
|
|
792
|
+
if (index >= this._items.length) index = this._items.length - 1;
|
|
793
|
+
|
|
794
|
+
this._isOpen = true;
|
|
795
|
+
|
|
796
|
+
// Prevent body scroll
|
|
797
|
+
document.body.style.overflow = 'hidden';
|
|
798
|
+
|
|
799
|
+
// Show overlay
|
|
800
|
+
this._lightbox.classList.add('neiki-lightbox--visible');
|
|
801
|
+
|
|
802
|
+
// Focus the lightbox for keyboard events
|
|
803
|
+
this._lightbox.focus();
|
|
804
|
+
|
|
805
|
+
// Force a fresh load (reset current to -1 so _goTo actually proceeds)
|
|
806
|
+
this._currentIndex = -1;
|
|
807
|
+
this._goTo(index);
|
|
808
|
+
|
|
809
|
+
this._emit('open', index);
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Close the lightbox.
|
|
814
|
+
*/
|
|
815
|
+
NeikiGallery.prototype.close = function () {
|
|
816
|
+
if (!this._isOpen) return;
|
|
817
|
+
this._isOpen = false;
|
|
818
|
+
|
|
819
|
+
// Exit fullscreen if active
|
|
820
|
+
if (this._isFullscreen) {
|
|
821
|
+
if (document.exitFullscreen) document.exitFullscreen();
|
|
822
|
+
else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Reset zoom
|
|
826
|
+
if (this._isZoomed) {
|
|
827
|
+
this._isZoomed = false;
|
|
828
|
+
this._image.classList.remove('neiki-zoomed');
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Restore body scroll
|
|
832
|
+
document.body.style.overflow = '';
|
|
833
|
+
|
|
834
|
+
// Hide overlay
|
|
835
|
+
this._lightbox.classList.remove('neiki-lightbox--visible');
|
|
836
|
+
|
|
837
|
+
// Clear hash
|
|
838
|
+
if (this._options.hashNavigation) {
|
|
839
|
+
this._clearHash();
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
this._emit('close');
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Go to the next image.
|
|
847
|
+
*/
|
|
848
|
+
NeikiGallery.prototype.next = function () {
|
|
849
|
+
this._goTo(this._currentIndex + 1);
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Go to the previous image.
|
|
854
|
+
*/
|
|
855
|
+
NeikiGallery.prototype.prev = function () {
|
|
856
|
+
this._goTo(this._currentIndex - 1);
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Register an event listener.
|
|
861
|
+
*/
|
|
862
|
+
NeikiGallery.prototype.on = function (event, callback) {
|
|
863
|
+
if (!this._events[event]) this._events[event] = [];
|
|
864
|
+
this._events[event].push(callback);
|
|
865
|
+
return this;
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Remove an event listener.
|
|
870
|
+
*/
|
|
871
|
+
NeikiGallery.prototype.off = function (event, callback) {
|
|
872
|
+
var listeners = this._events[event];
|
|
873
|
+
if (listeners) {
|
|
874
|
+
this._events[event] = listeners.filter(function (fn) { return fn !== callback; });
|
|
875
|
+
}
|
|
876
|
+
return this;
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Destroy the gallery instance — remove all DOM, listeners, observers.
|
|
881
|
+
*/
|
|
882
|
+
NeikiGallery.prototype.destroy = function () {
|
|
883
|
+
if (this._destroyed) return;
|
|
884
|
+
this._destroyed = true;
|
|
885
|
+
|
|
886
|
+
// Close if open
|
|
887
|
+
if (this._isOpen) this.close();
|
|
888
|
+
|
|
889
|
+
// Remove keyboard listener
|
|
890
|
+
if (this._boundHandlers.keydown) {
|
|
891
|
+
document.removeEventListener('keydown', this._boundHandlers.keydown);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Remove hashchange listener
|
|
895
|
+
if (this._boundHandlers.hashchange) {
|
|
896
|
+
window.removeEventListener('hashchange', this._boundHandlers.hashchange);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Remove fullscreen listener
|
|
900
|
+
if (this._boundHandlers.fullscreenchange) {
|
|
901
|
+
document.removeEventListener('fullscreenchange', this._boundHandlers.fullscreenchange);
|
|
902
|
+
document.removeEventListener('webkitfullscreenchange', this._boundHandlers.fullscreenchange);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Remove touch listeners
|
|
906
|
+
if (this._boundHandlers.touchstart) {
|
|
907
|
+
this._stage.removeEventListener('touchstart', this._boundHandlers.touchstart);
|
|
908
|
+
this._stage.removeEventListener('touchmove', this._boundHandlers.touchmove);
|
|
909
|
+
this._stage.removeEventListener('touchend', this._boundHandlers.touchend);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Disconnect intersection observer
|
|
913
|
+
if (this._observer) {
|
|
914
|
+
this._observer.disconnect();
|
|
915
|
+
this._observer = null;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Remove lightbox DOM
|
|
919
|
+
if (this._lightbox && this._lightbox.parentNode) {
|
|
920
|
+
this._lightbox.parentNode.removeChild(this._lightbox);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Remove grid classes
|
|
924
|
+
this._container.classList.remove('neiki-gallery', 'neiki-gallery--masonry', 'neiki-gallery--grid');
|
|
925
|
+
|
|
926
|
+
// Clear event listeners
|
|
927
|
+
this._events = {};
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
/* ========================================================================
|
|
931
|
+
Auto-Init
|
|
932
|
+
======================================================================== */
|
|
933
|
+
|
|
934
|
+
function autoInit() {
|
|
935
|
+
var galleries = $$('[data-neiki-gallery]');
|
|
936
|
+
galleries.forEach(function (el) {
|
|
937
|
+
// Don't double-init
|
|
938
|
+
if (el._neikiGallery) return;
|
|
939
|
+
el._neikiGallery = new NeikiGallery(el);
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Auto-init when DOM is ready
|
|
944
|
+
if (document.readyState === 'loading') {
|
|
945
|
+
document.addEventListener('DOMContentLoaded', autoInit);
|
|
946
|
+
} else {
|
|
947
|
+
// DOM already loaded (script at bottom or defer)
|
|
948
|
+
autoInit();
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Expose auto-init for dynamic content
|
|
952
|
+
NeikiGallery.autoInit = autoInit;
|
|
953
|
+
|
|
954
|
+
return NeikiGallery;
|
|
955
|
+
});
|