@0m0g1/griot 0.1.3 → 0.1.5
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/README.md +392 -127
- package/package.json +1 -1
- package/src/Griot.js +36 -35
- package/src/blocks/BlockRenderer.js +240 -93
- package/src/blocks/BlockSchema.js +32 -19
- package/src/editor/SlashMenu.js +197 -0
- package/src/viewer/GalleryRenderer.js +417 -0
- package/src/viewer/Lightbox.js +320 -0
- package/src/viewer/Viewer.js +47 -8
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// ─── SlashMenu.js ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Slash-command palette. Triggered when '/' is typed at the very start of
|
|
3
|
+
// an empty block editable. Shows a searchable list of block types grouped
|
|
4
|
+
// by category. Selecting an item calls onSelect(type).
|
|
5
|
+
//
|
|
6
|
+
// Usage (inside Editor):
|
|
7
|
+
// const menu = new SlashMenu(editorContainerEl, onSelect);
|
|
8
|
+
// menu.show(anchorEl); // position near anchorEl
|
|
9
|
+
// menu.hide();
|
|
10
|
+
// menu.destroy();
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
import { getAllTypes, getBlockDef, getTypesByCategory } from '../blocks/BlockSchema.js';
|
|
14
|
+
|
|
15
|
+
const CATEGORIES = [
|
|
16
|
+
{ key: 'text', label: 'Text' },
|
|
17
|
+
{ key: 'media', label: 'Media' },
|
|
18
|
+
{ key: 'embed', label: 'Embed' },
|
|
19
|
+
{ key: 'structure', label: 'Structure' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export class SlashMenu {
|
|
23
|
+
constructor(container, onSelect) {
|
|
24
|
+
this._container = container;
|
|
25
|
+
this._onSelect = onSelect;
|
|
26
|
+
this._el = null;
|
|
27
|
+
this._query = '';
|
|
28
|
+
this._idx = 0;
|
|
29
|
+
this._items = []; // filtered list of type strings
|
|
30
|
+
this._visible = false;
|
|
31
|
+
|
|
32
|
+
this._build();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Public ────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** Show menu anchored below anchorEl. */
|
|
38
|
+
show(anchorEl) {
|
|
39
|
+
this._query = '';
|
|
40
|
+
this._refresh();
|
|
41
|
+
this._visible = true;
|
|
42
|
+
this._el.style.display = 'block';
|
|
43
|
+
this._reposition(anchorEl);
|
|
44
|
+
this._el.querySelector('.griot-slash__search')?.focus();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Filter list to items matching query string (the text after '/'). */
|
|
48
|
+
filter(query) {
|
|
49
|
+
this._query = query;
|
|
50
|
+
this._refresh();
|
|
51
|
+
if (!this._items.length) { this.hide(); return; }
|
|
52
|
+
this._reposition(this._anchorEl);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
hide() {
|
|
56
|
+
this._visible = false;
|
|
57
|
+
if (this._el) this._el.style.display = 'none';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get visible() { return this._visible; }
|
|
61
|
+
|
|
62
|
+
/** Handle keydown while slash menu is open. Returns true if consumed. */
|
|
63
|
+
handleKey(e) {
|
|
64
|
+
if (!this._visible) return false;
|
|
65
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); this._move(1); return true; }
|
|
66
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); this._move(-1); return true; }
|
|
67
|
+
if (e.key === 'Enter') { e.preventDefault(); this._select(); return true; }
|
|
68
|
+
if (e.key === 'Escape') { e.preventDefault(); this.hide(); return true; }
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
destroy() {
|
|
73
|
+
this._el?.remove();
|
|
74
|
+
this._el = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Build ─────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
_build() {
|
|
80
|
+
const el = document.createElement('div');
|
|
81
|
+
el.className = 'griot-slash';
|
|
82
|
+
el.setAttribute('role', 'listbox');
|
|
83
|
+
el.setAttribute('aria-label', 'Block type');
|
|
84
|
+
|
|
85
|
+
const search = document.createElement('input');
|
|
86
|
+
search.type = 'text';
|
|
87
|
+
search.className = 'griot-slash__search';
|
|
88
|
+
search.placeholder = 'Search blocks…';
|
|
89
|
+
search.addEventListener('input', () => { this._query = search.value; this._refresh(); });
|
|
90
|
+
search.addEventListener('keydown', e => { this.handleKey(e); });
|
|
91
|
+
|
|
92
|
+
const list = document.createElement('div');
|
|
93
|
+
list.className = 'griot-slash__list';
|
|
94
|
+
|
|
95
|
+
el.append(search, list);
|
|
96
|
+
document.body.appendChild(el);
|
|
97
|
+
this._el = el;
|
|
98
|
+
this._list = list;
|
|
99
|
+
this._search = search;
|
|
100
|
+
|
|
101
|
+
// Hide on outside click
|
|
102
|
+
this._onDocClick = (e) => { if (this._visible && !el.contains(e.target)) this.hide(); };
|
|
103
|
+
document.addEventListener('mousedown', this._onDocClick);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_refresh() {
|
|
107
|
+
const q = this._query.toLowerCase().trim();
|
|
108
|
+
this._list.innerHTML = '';
|
|
109
|
+
this._items = [];
|
|
110
|
+
this._idx = 0;
|
|
111
|
+
|
|
112
|
+
const all = getAllTypes();
|
|
113
|
+
const filtered = q
|
|
114
|
+
? all.filter(t => {
|
|
115
|
+
const d = getBlockDef(t);
|
|
116
|
+
return d.slashLabel?.toLowerCase().includes(q) || t.includes(q) || d.label.toLowerCase().includes(q);
|
|
117
|
+
})
|
|
118
|
+
: all;
|
|
119
|
+
|
|
120
|
+
// Group by category when not searching
|
|
121
|
+
const groups = q
|
|
122
|
+
? [{ key: 'results', label: 'Results', types: filtered }]
|
|
123
|
+
: CATEGORIES.map(c => ({ ...c, types: filtered.filter(t => getBlockDef(t).category === c.key) })).filter(g => g.types.length);
|
|
124
|
+
|
|
125
|
+
for (const group of groups) {
|
|
126
|
+
const hdr = document.createElement('div');
|
|
127
|
+
hdr.className = 'griot-slash__group';
|
|
128
|
+
hdr.textContent = group.label;
|
|
129
|
+
this._list.appendChild(hdr);
|
|
130
|
+
|
|
131
|
+
for (const type of group.types) {
|
|
132
|
+
const def = getBlockDef(type);
|
|
133
|
+
const idx = this._items.length;
|
|
134
|
+
this._items.push(type);
|
|
135
|
+
|
|
136
|
+
const item = document.createElement('div');
|
|
137
|
+
item.className = 'griot-slash__item';
|
|
138
|
+
item.setAttribute('role', 'option');
|
|
139
|
+
item.dataset.idx = idx;
|
|
140
|
+
item.innerHTML = `<span class="griot-slash__item-icon">${def.icon}</span><span class="griot-slash__item-label">${def.slashLabel ?? def.label}</span>`;
|
|
141
|
+
|
|
142
|
+
item.addEventListener('mousedown', (e) => {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
this._idx = idx;
|
|
145
|
+
this._select();
|
|
146
|
+
});
|
|
147
|
+
item.addEventListener('mouseover', () => { this._idx = idx; this._highlight(); });
|
|
148
|
+
|
|
149
|
+
this._list.appendChild(item);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this._highlight();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_highlight() {
|
|
157
|
+
for (const el of this._list.querySelectorAll('.griot-slash__item')) {
|
|
158
|
+
el.classList.toggle('is-active', Number(el.dataset.idx) === this._idx);
|
|
159
|
+
}
|
|
160
|
+
// Scroll into view
|
|
161
|
+
const active = this._list.querySelector('.griot-slash__item.is-active');
|
|
162
|
+
active?.scrollIntoView({ block: 'nearest' });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_move(dir) {
|
|
166
|
+
this._idx = Math.max(0, Math.min(this._items.length - 1, this._idx + dir));
|
|
167
|
+
this._highlight();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_select() {
|
|
171
|
+
const type = this._items[this._idx];
|
|
172
|
+
if (type) { this.hide(); this._onSelect(type); }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
_reposition(anchorEl) {
|
|
176
|
+
this._anchorEl = anchorEl;
|
|
177
|
+
if (!anchorEl || !this._el) return;
|
|
178
|
+
|
|
179
|
+
requestAnimationFrame(() => {
|
|
180
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
181
|
+
const menuH = this._el.offsetHeight || 280;
|
|
182
|
+
let top = rect.bottom + window.scrollY + 4;
|
|
183
|
+
let left = rect.left + window.scrollX;
|
|
184
|
+
|
|
185
|
+
// Flip above if too close to bottom
|
|
186
|
+
if (rect.bottom + menuH + 8 > window.innerHeight) {
|
|
187
|
+
top = rect.top + window.scrollY - menuH - 4;
|
|
188
|
+
}
|
|
189
|
+
// Clamp left
|
|
190
|
+
left = Math.min(left, window.scrollX + window.innerWidth - 280 - 8);
|
|
191
|
+
left = Math.max(left, window.scrollX + 8);
|
|
192
|
+
|
|
193
|
+
this._el.style.top = `${top}px`;
|
|
194
|
+
this._el.style.left = `${left}px`;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// ─── GalleryRenderer.js ───────────────────────────────────────────────────────
|
|
2
|
+
// Renders a gallery of image items in one of four layouts.
|
|
3
|
+
// All layouts open the shared lightbox singleton on click.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// import { renderGallery } from './GalleryRenderer.js';
|
|
7
|
+
// const el = renderGallery(items, 'carousel');
|
|
8
|
+
// container.appendChild(el);
|
|
9
|
+
//
|
|
10
|
+
// items shape: { src?, url?, alt?, alt_text?, caption? }[]
|
|
11
|
+
// layouts: 'grid' | 'masonry' | 'carousel' | 'strip'
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
import { lightbox } from './Lightbox.js';
|
|
15
|
+
|
|
16
|
+
const VALID_LAYOUTS = new Set(['grid', 'masonry', 'carousel', 'strip']);
|
|
17
|
+
|
|
18
|
+
// ── Public ────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Render a gallery element.
|
|
22
|
+
* @param {object[]} items
|
|
23
|
+
* @param {'grid'|'masonry'|'carousel'|'strip'} layout
|
|
24
|
+
* @returns {HTMLElement}
|
|
25
|
+
*/
|
|
26
|
+
export function renderGallery(items = [], layout = 'grid') {
|
|
27
|
+
_injectStyles();
|
|
28
|
+
|
|
29
|
+
const l = VALID_LAYOUTS.has(layout) ? layout : 'grid';
|
|
30
|
+
const wrap = document.createElement('div');
|
|
31
|
+
wrap.className = `griot-gallery griot-gallery--${l}`;
|
|
32
|
+
wrap.dataset.layout = l;
|
|
33
|
+
|
|
34
|
+
if (!items.length) {
|
|
35
|
+
const empty = document.createElement('div');
|
|
36
|
+
empty.className = 'griot-gallery__empty';
|
|
37
|
+
empty.textContent = 'No images yet';
|
|
38
|
+
wrap.appendChild(empty);
|
|
39
|
+
return wrap;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
switch (l) {
|
|
43
|
+
case 'carousel': return _carousel(items, wrap);
|
|
44
|
+
case 'masonry': return _masonry(items, wrap);
|
|
45
|
+
case 'strip': return _strip(items, wrap);
|
|
46
|
+
default: return _grid(items, wrap);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Grid ──────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function _grid(items, wrap) {
|
|
53
|
+
items.forEach((item, i) => {
|
|
54
|
+
const el = _itemEl(item, i, items);
|
|
55
|
+
el.className = 'griot-gallery__item griot-gallery__item--grid';
|
|
56
|
+
wrap.appendChild(el);
|
|
57
|
+
});
|
|
58
|
+
return wrap;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Masonry ───────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function _masonry(items, wrap) {
|
|
64
|
+
items.forEach((item, i) => {
|
|
65
|
+
const el = _itemEl(item, i, items);
|
|
66
|
+
el.className = 'griot-gallery__item griot-gallery__item--masonry';
|
|
67
|
+
wrap.appendChild(el);
|
|
68
|
+
});
|
|
69
|
+
return wrap;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Strip ─────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function _strip(items, wrap) {
|
|
75
|
+
const inner = document.createElement('div');
|
|
76
|
+
inner.className = 'griot-gallery__strip-inner';
|
|
77
|
+
|
|
78
|
+
items.forEach((item, i) => {
|
|
79
|
+
const el = _itemEl(item, i, items);
|
|
80
|
+
el.className = 'griot-gallery__item griot-gallery__item--strip';
|
|
81
|
+
inner.appendChild(el);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
wrap.appendChild(inner);
|
|
85
|
+
return wrap;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Shared item element (grid / masonry / strip) ──────────────────────────────
|
|
89
|
+
|
|
90
|
+
function _itemEl(item, index, allItems) {
|
|
91
|
+
const el = document.createElement('div');
|
|
92
|
+
|
|
93
|
+
const img = document.createElement('img');
|
|
94
|
+
img.src = item.src ?? item.url ?? '';
|
|
95
|
+
img.alt = item.alt ?? item.alt_text ?? item.caption ?? '';
|
|
96
|
+
img.loading = index < 6 ? 'eager' : 'lazy';
|
|
97
|
+
img.decoding = 'async';
|
|
98
|
+
img.draggable = false;
|
|
99
|
+
|
|
100
|
+
img.addEventListener('click', () => lightbox.open(allItems, index));
|
|
101
|
+
|
|
102
|
+
el.appendChild(img);
|
|
103
|
+
|
|
104
|
+
if (item.caption) {
|
|
105
|
+
const cap = document.createElement('p');
|
|
106
|
+
cap.className = 'griot-gallery__caption';
|
|
107
|
+
cap.textContent = item.caption;
|
|
108
|
+
el.appendChild(cap);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return el;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Carousel ──────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function _carousel(items, wrap) {
|
|
117
|
+
let idx = 0;
|
|
118
|
+
|
|
119
|
+
// ── DOM structure ────────────────────────────────────────────────────────
|
|
120
|
+
// .griot-carousel__viewport (clips)
|
|
121
|
+
// .griot-carousel__track (slides)
|
|
122
|
+
// .griot-carousel__slide × N
|
|
123
|
+
// .griot-carousel__controls (prev · counter · next)
|
|
124
|
+
// .griot-carousel__dots (dot buttons, hidden if > 12 items)
|
|
125
|
+
|
|
126
|
+
const viewport = document.createElement('div');
|
|
127
|
+
viewport.className = 'griot-carousel__viewport';
|
|
128
|
+
|
|
129
|
+
const track = document.createElement('div');
|
|
130
|
+
track.className = 'griot-carousel__track';
|
|
131
|
+
|
|
132
|
+
items.forEach((item, i) => {
|
|
133
|
+
const slide = document.createElement('div');
|
|
134
|
+
slide.className = 'griot-carousel__slide';
|
|
135
|
+
|
|
136
|
+
const img = document.createElement('img');
|
|
137
|
+
img.src = item.src ?? item.url ?? '';
|
|
138
|
+
img.alt = item.alt ?? item.alt_text ?? item.caption ?? '';
|
|
139
|
+
img.loading = i === 0 ? 'eager' : 'lazy';
|
|
140
|
+
img.decoding = 'async';
|
|
141
|
+
img.draggable = false;
|
|
142
|
+
|
|
143
|
+
// Click on carousel image → open lightbox at CURRENT idx (not i, since user
|
|
144
|
+
// may have navigated away from the first image)
|
|
145
|
+
img.addEventListener('click', () => lightbox.open(items, idx));
|
|
146
|
+
|
|
147
|
+
slide.appendChild(img);
|
|
148
|
+
|
|
149
|
+
if (item.caption) {
|
|
150
|
+
const cap = document.createElement('p');
|
|
151
|
+
cap.className = 'griot-gallery__caption griot-carousel__caption';
|
|
152
|
+
cap.textContent = item.caption;
|
|
153
|
+
slide.appendChild(cap);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
track.appendChild(slide);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
viewport.appendChild(track);
|
|
160
|
+
|
|
161
|
+
// Controls bar
|
|
162
|
+
const controls = document.createElement('div');
|
|
163
|
+
controls.className = 'griot-carousel__controls';
|
|
164
|
+
|
|
165
|
+
const prevBtn = _carBtn('‹', 'griot-carousel__btn griot-carousel__btn--prev', 'Previous');
|
|
166
|
+
const nextBtn = _carBtn('›', 'griot-carousel__btn griot-carousel__btn--next', 'Next');
|
|
167
|
+
const counter = document.createElement('span');
|
|
168
|
+
counter.className = 'griot-carousel__counter';
|
|
169
|
+
|
|
170
|
+
controls.append(prevBtn, counter, nextBtn);
|
|
171
|
+
|
|
172
|
+
// Dot strip
|
|
173
|
+
const dots = document.createElement('div');
|
|
174
|
+
dots.className = 'griot-carousel__dots';
|
|
175
|
+
|
|
176
|
+
const dotEls = items.map((_, i) => {
|
|
177
|
+
const d = document.createElement('button');
|
|
178
|
+
d.type = 'button';
|
|
179
|
+
d.className = 'griot-carousel__dot';
|
|
180
|
+
d.setAttribute('aria-label', `Image ${i + 1}`);
|
|
181
|
+
d.addEventListener('click', () => goTo(i));
|
|
182
|
+
dots.appendChild(d);
|
|
183
|
+
return d;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── Navigation logic ──────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
function goTo(n, animate = true) {
|
|
189
|
+
idx = Math.max(0, Math.min(n, items.length - 1));
|
|
190
|
+
|
|
191
|
+
if (!animate) {
|
|
192
|
+
track.style.transition = 'none';
|
|
193
|
+
requestAnimationFrame(() => { track.style.transition = ''; });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
track.style.transform = `translateX(-${idx * 100}%)`;
|
|
197
|
+
counter.textContent = `${idx + 1} / ${items.length}`;
|
|
198
|
+
|
|
199
|
+
prevBtn.disabled = items.length <= 1;
|
|
200
|
+
nextBtn.disabled = items.length <= 1;
|
|
201
|
+
prevBtn.classList.toggle('is-edge', idx === 0);
|
|
202
|
+
nextBtn.classList.toggle('is-edge', idx === items.length - 1);
|
|
203
|
+
|
|
204
|
+
dotEls.forEach((d, i) => {
|
|
205
|
+
d.classList.toggle('is-active', i === idx);
|
|
206
|
+
d.setAttribute('aria-pressed', String(i === idx));
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
prevBtn.addEventListener('click', () => goTo(idx - 1));
|
|
211
|
+
nextBtn.addEventListener('click', () => goTo(idx + 1));
|
|
212
|
+
|
|
213
|
+
// Touch / swipe on the viewport
|
|
214
|
+
let touchX = 0, touchY = 0, isScrolling = null;
|
|
215
|
+
|
|
216
|
+
viewport.addEventListener('touchstart', e => {
|
|
217
|
+
touchX = e.touches[0].clientX;
|
|
218
|
+
touchY = e.touches[0].clientY;
|
|
219
|
+
isScrolling = null;
|
|
220
|
+
}, { passive: true });
|
|
221
|
+
|
|
222
|
+
viewport.addEventListener('touchmove', e => {
|
|
223
|
+
if (isScrolling === null) {
|
|
224
|
+
const dx = Math.abs(e.touches[0].clientX - touchX);
|
|
225
|
+
const dy = Math.abs(e.touches[0].clientY - touchY);
|
|
226
|
+
isScrolling = dy > dx;
|
|
227
|
+
}
|
|
228
|
+
}, { passive: true });
|
|
229
|
+
|
|
230
|
+
viewport.addEventListener('touchend', e => {
|
|
231
|
+
if (isScrolling) return;
|
|
232
|
+
const dx = e.changedTouches[0].clientX - touchX;
|
|
233
|
+
if (Math.abs(dx) > 40) goTo(dx < 0 ? idx + 1 : idx - 1);
|
|
234
|
+
}, { passive: true });
|
|
235
|
+
|
|
236
|
+
// Keyboard when carousel has focus
|
|
237
|
+
wrap.tabIndex = 0;
|
|
238
|
+
wrap.addEventListener('keydown', e => {
|
|
239
|
+
if (e.key === 'ArrowLeft') { e.preventDefault(); goTo(idx - 1); }
|
|
240
|
+
if (e.key === 'ArrowRight') { e.preventDefault(); goTo(idx + 1); }
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Accessibility
|
|
244
|
+
wrap.setAttribute('role', 'region');
|
|
245
|
+
wrap.setAttribute('aria-roledescription', 'carousel');
|
|
246
|
+
wrap.setAttribute('aria-label', `Gallery, ${items.length} images`);
|
|
247
|
+
viewport.setAttribute('aria-live', 'polite');
|
|
248
|
+
|
|
249
|
+
dots.hidden = items.length < 2 || items.length > 14;
|
|
250
|
+
|
|
251
|
+
wrap.append(viewport, controls, dots);
|
|
252
|
+
|
|
253
|
+
goTo(0, false); // initial position, no animation
|
|
254
|
+
return wrap;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function _carBtn(label, className, ariaLabel) {
|
|
258
|
+
const b = document.createElement('button');
|
|
259
|
+
b.type = 'button';
|
|
260
|
+
b.className = className;
|
|
261
|
+
b.setAttribute('aria-label', ariaLabel);
|
|
262
|
+
b.textContent = label;
|
|
263
|
+
return b;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Style injection ───────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
let _stylesInjected = false;
|
|
269
|
+
function _injectStyles() {
|
|
270
|
+
if (_stylesInjected || typeof document === 'undefined') return;
|
|
271
|
+
_stylesInjected = true;
|
|
272
|
+
const s = document.createElement('style');
|
|
273
|
+
s.id = 'griot-gallery-styles';
|
|
274
|
+
s.textContent = `
|
|
275
|
+
/* ── Shared gallery wrapper ─────────────────────────────────────────────── */
|
|
276
|
+
.griot-gallery {
|
|
277
|
+
width: 100%;
|
|
278
|
+
box-sizing: border-box;
|
|
279
|
+
}
|
|
280
|
+
.griot-gallery__empty {
|
|
281
|
+
font-size: 13px; color: #64748b;
|
|
282
|
+
padding: 24px; text-align: center;
|
|
283
|
+
border: 2px dashed rgba(255,255,255,0.10); border-radius: 10px;
|
|
284
|
+
}
|
|
285
|
+
.griot-gallery__caption {
|
|
286
|
+
font-size: 12px; color: #64748b;
|
|
287
|
+
margin: 5px 0 0; text-align: center; line-height: 1.4;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* ── Grid ───────────────────────────────────────────────────────────────── */
|
|
291
|
+
.griot-gallery--grid {
|
|
292
|
+
display: grid;
|
|
293
|
+
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
294
|
+
gap: 8px;
|
|
295
|
+
}
|
|
296
|
+
.griot-gallery__item--grid {
|
|
297
|
+
cursor: zoom-in;
|
|
298
|
+
border-radius: 8px; overflow: hidden;
|
|
299
|
+
background: rgba(255,255,255,0.04);
|
|
300
|
+
}
|
|
301
|
+
.griot-gallery__item--grid img {
|
|
302
|
+
width: 100%; display: block;
|
|
303
|
+
aspect-ratio: 4/3; object-fit: cover;
|
|
304
|
+
transition: transform 0.22s;
|
|
305
|
+
}
|
|
306
|
+
.griot-gallery__item--grid:hover img { transform: scale(1.04); }
|
|
307
|
+
|
|
308
|
+
/* ── Masonry ────────────────────────────────────────────────────────────── */
|
|
309
|
+
.griot-gallery--masonry {
|
|
310
|
+
columns: 2 200px; gap: 8px;
|
|
311
|
+
}
|
|
312
|
+
.griot-gallery__item--masonry {
|
|
313
|
+
break-inside: avoid; margin-bottom: 8px;
|
|
314
|
+
cursor: zoom-in; border-radius: 8px; overflow: hidden;
|
|
315
|
+
background: rgba(255,255,255,0.04);
|
|
316
|
+
}
|
|
317
|
+
.griot-gallery__item--masonry img {
|
|
318
|
+
width: 100%; display: block;
|
|
319
|
+
transition: transform 0.22s;
|
|
320
|
+
}
|
|
321
|
+
.griot-gallery__item--masonry:hover img { transform: scale(1.02); }
|
|
322
|
+
|
|
323
|
+
/* ── Strip ──────────────────────────────────────────────────────────────── */
|
|
324
|
+
.griot-gallery--strip { overflow: hidden; }
|
|
325
|
+
|
|
326
|
+
.griot-gallery__strip-inner {
|
|
327
|
+
display: flex; gap: 8px;
|
|
328
|
+
overflow-x: auto; padding-bottom: 6px;
|
|
329
|
+
scroll-snap-type: x mandatory;
|
|
330
|
+
-webkit-overflow-scrolling: touch;
|
|
331
|
+
}
|
|
332
|
+
.griot-gallery__strip-inner::-webkit-scrollbar { height: 4px; }
|
|
333
|
+
.griot-gallery__strip-inner::-webkit-scrollbar-track { background: transparent; }
|
|
334
|
+
.griot-gallery__strip-inner::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 2px; }
|
|
335
|
+
|
|
336
|
+
.griot-gallery__item--strip {
|
|
337
|
+
flex-shrink: 0; scroll-snap-align: start;
|
|
338
|
+
width: 180px; height: 120px;
|
|
339
|
+
border-radius: 8px; overflow: hidden; cursor: zoom-in;
|
|
340
|
+
background: rgba(255,255,255,0.04);
|
|
341
|
+
}
|
|
342
|
+
.griot-gallery__item--strip img {
|
|
343
|
+
width: 100%; height: 100%; object-fit: cover;
|
|
344
|
+
transition: transform 0.22s;
|
|
345
|
+
}
|
|
346
|
+
.griot-gallery__item--strip:hover img { transform: scale(1.04); }
|
|
347
|
+
|
|
348
|
+
/* ── Carousel ───────────────────────────────────────────────────────────── */
|
|
349
|
+
.griot-gallery--carousel { outline: none; }
|
|
350
|
+
|
|
351
|
+
.griot-carousel__viewport {
|
|
352
|
+
overflow: hidden; border-radius: 10px;
|
|
353
|
+
background: rgba(0,0,0,0.15);
|
|
354
|
+
}
|
|
355
|
+
.griot-carousel__track {
|
|
356
|
+
display: flex;
|
|
357
|
+
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
|
358
|
+
will-change: transform;
|
|
359
|
+
}
|
|
360
|
+
.griot-carousel__slide {
|
|
361
|
+
flex: 0 0 100%; min-width: 0;
|
|
362
|
+
display: flex; flex-direction: column; align-items: center;
|
|
363
|
+
}
|
|
364
|
+
.griot-carousel__slide img {
|
|
365
|
+
width: 100%; max-height: 420px;
|
|
366
|
+
object-fit: contain; display: block;
|
|
367
|
+
cursor: zoom-in; user-select: none;
|
|
368
|
+
}
|
|
369
|
+
.griot-carousel__caption {
|
|
370
|
+
padding: 8px 16px 10px;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/* Controls bar */
|
|
374
|
+
.griot-carousel__controls {
|
|
375
|
+
display: flex; align-items: center; justify-content: center;
|
|
376
|
+
gap: 16px; margin-top: 10px;
|
|
377
|
+
}
|
|
378
|
+
.griot-carousel__btn {
|
|
379
|
+
background: rgba(255,255,255,0.06);
|
|
380
|
+
border: 1px solid rgba(255,255,255,0.12);
|
|
381
|
+
border-radius: 6px; color: #94a3b8;
|
|
382
|
+
width: 34px; height: 34px; font-size: 18px; line-height: 1;
|
|
383
|
+
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
384
|
+
transition: background 0.15s, color 0.15s, opacity 0.15s;
|
|
385
|
+
}
|
|
386
|
+
.griot-carousel__btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
387
|
+
.griot-carousel__btn:not(:disabled):hover {
|
|
388
|
+
background: rgba(99,102,241,0.20); color: #a5b4fc;
|
|
389
|
+
}
|
|
390
|
+
.griot-carousel__btn.is-edge { opacity: 0.45; }
|
|
391
|
+
.griot-carousel__btn.is-edge:hover { opacity: 1; }
|
|
392
|
+
.griot-carousel__counter { font-size: 13px; color: #64748b; min-width: 48px; text-align: center; }
|
|
393
|
+
|
|
394
|
+
/* Dots */
|
|
395
|
+
.griot-carousel__dots {
|
|
396
|
+
display: flex; justify-content: center; gap: 6px; margin-top: 10px;
|
|
397
|
+
}
|
|
398
|
+
.griot-carousel__dot {
|
|
399
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
400
|
+
background: rgba(255,255,255,0.18); border: none; padding: 0;
|
|
401
|
+
cursor: pointer; transition: background 0.2s, transform 0.2s;
|
|
402
|
+
}
|
|
403
|
+
.griot-carousel__dot:hover { background: rgba(255,255,255,0.40); }
|
|
404
|
+
.griot-carousel__dot.is-active {
|
|
405
|
+
background: #6366f1; transform: scale(1.3);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
@media (max-width: 480px) {
|
|
409
|
+
.griot-gallery--grid {
|
|
410
|
+
grid-template-columns: repeat(2, 1fr);
|
|
411
|
+
}
|
|
412
|
+
.griot-gallery--masonry { columns: 2; }
|
|
413
|
+
.griot-gallery__item--strip { width: 140px; height: 96px; }
|
|
414
|
+
}
|
|
415
|
+
`;
|
|
416
|
+
document.head.appendChild(s);
|
|
417
|
+
}
|