... |
... |
@@ -1,12 +1,11 @@ |
1 |
1 |
{{velocity}} |
2 |
|
-## Gather video attachments on the current page |
3 |
|
-#set($videoExtensions = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v']) |
4 |
|
-#set($videos = []) |
|
2 |
+#set($videoExts = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v']) |
|
3 |
+#set($videos = []) |
5 |
5 |
#foreach($att in $doc.getAttachmentList()) |
6 |
|
- #set($name = $att.getFilename()) |
7 |
|
- #set($lname = $name.toLowerCase()) |
8 |
|
- #foreach($ext in $videoExtensions) |
9 |
|
- #if($lname.endsWith("." + $ext)) |
|
5 |
+ #set($n = $att.getFilename()) |
|
6 |
+ #set($ln = $n.toLowerCase()) |
|
7 |
+ #foreach($e in $videoExts) |
|
8 |
+ #if($ln.endsWith("." + $e)) |
10 |
10 |
#set($discard = $videos.add($att)) |
11 |
11 |
#break |
12 |
12 |
#end |
... |
... |
@@ -13,7 +13,7 @@ |
13 |
13 |
#end |
14 |
14 |
#end |
15 |
15 |
|
16 |
|
-{{cache id="vid-list-$doc.fullName" timeToLive="21600"}}## 6h cache of rendered HTML |
|
15 |
+{{cache id="vid-list-$doc.fullName" timeToLive="21600"}} |
17 |
17 |
{{html wiki="false" clean="false"}} |
18 |
18 |
<div id="xwiki-video-manager" style="margin:20px 0;"> |
19 |
19 |
<h2>📹 Videos on: ${escapetool.xml($doc.fullName)}</h2> |
... |
... |
@@ -24,10 +24,12 @@ |
24 |
24 |
<p>Attach video files to this page to see them here.</p> |
25 |
25 |
</div> |
26 |
26 |
#else |
27 |
|
- ## ---- Settings |
28 |
28 |
<script> |
29 |
|
- window.VID_CHUNK_SIZE = 48; // how many lightweight cards per chunk |
30 |
|
- window.VID_LAZY_MARGIN = '600px';// start loading a bit before entering view |
|
27 |
+ window.VID_CHUNK_SIZE = 48; |
|
28 |
+ window.VID_LAZY_MARGIN = '600px'; |
|
29 |
+ window.XWIKI_WIKI = ${jsontool.serialize($xcontext.database)}; // current wiki id (e.g., "xwiki") |
|
30 |
+ window.SOURCE_SPACE = ${jsontool.serialize($doc.space)}; // e.g., "Main" or "Main.Sub" |
|
31 |
+ window.SOURCE_PAGE = ${jsontool.serialize($doc.name)}; // e.g., "WebHome" |
31 |
31 |
</script> |
32 |
32 |
|
33 |
33 |
<div id="video-chunks"> |
... |
... |
@@ -39,7 +39,7 @@ |
39 |
39 |
#set($lname = $filename.toLowerCase()) |
40 |
40 |
#set($url = $doc.getAttachmentURL($filename)) |
41 |
41 |
|
42 |
|
- ## decide MIME type (lightweight, used later when creating <video>) |
|
43 |
+ ## MIME guess |
43 |
43 |
#set($videoType = "video/mp4") |
44 |
44 |
#if($lname.endsWith(".webm")) |
45 |
45 |
#set($videoType = "video/webm") |
... |
... |
@@ -57,7 +57,6 @@ |
57 |
57 |
#set($videoType = "video/mp4") |
58 |
58 |
#end |
59 |
59 |
|
60 |
|
- ## open chunk wrapper when starting a new chunk |
61 |
61 |
#if($i == 1 || ($i - 1) % 48 == 0) |
62 |
62 |
#set($chunkIndex = $chunkIndex + 1) |
63 |
63 |
<div class="vid-chunk" data-chunk="$chunkIndex" style="display: #if($chunkIndex == 1) block #else none #end;"> |
... |
... |
@@ -64,7 +64,6 @@ |
64 |
64 |
<div class="video-display-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px;"> |
65 |
65 |
#end |
66 |
66 |
|
67 |
|
- ## lightweight card (NO <video> yet) |
68 |
68 |
<div class="video-container" style="border:1px solid #ddd;border-radius:8px;padding:12px;background:#fff;"> |
69 |
69 |
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"> |
70 |
70 |
<h4 style="margin:0;flex:1;min-width:0;">${escapetool.xml($filename)}</h4> |
... |
... |
@@ -73,22 +73,34 @@ |
73 |
73 |
#end |
74 |
74 |
</div> |
75 |
75 |
|
76 |
|
- <!-- Placeholder; the real <video> is created on demand --> |
|
75 |
+ <!-- Placeholder (auto-poster generated near viewport) --> |
77 |
77 |
<div class="video-frame" |
78 |
78 |
data-src="${url}" |
79 |
79 |
data-type="${videoType}" |
80 |
80 |
data-name="${escapetool.xml($filename)}" |
81 |
|
- style="width:100%;aspect-ratio:16/9;background:#f3f3f3;border-radius:4px;display:flex;align-items:center;justify-content:center;cursor:pointer;"> |
82 |
|
- <button class="btn btn-sm btn-primary" type="button">Load & Play</button> |
|
80 |
+ style="position:relative;width:100%;aspect-ratio:16/9;background:#111;border-radius:4px;overflow:hidden;display:flex;align-items:center;justify-content:center;cursor:pointer;"> |
|
81 |
+ <canvas class="vid-canvas" width="320" height="180" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;"></canvas> |
|
82 |
+ <button class="btn btn-sm btn-primary" type="button" style="position:relative;z-index:1;">Load & Play</button> |
83 |
83 |
</div> |
84 |
84 |
|
85 |
|
- <div class="video-controls" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;"> |
|
85 |
+ <div class="video-controls" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;align-items:center;"> |
86 |
86 |
<a href="${url}" download="${escapetool.xml($filename)}" class="btn btn-sm btn-success">📥 Download</a> |
87 |
87 |
<span class="vid-duration" style="font-size:12px;color:#666;">Duration: —</span> |
88 |
88 |
</div> |
|
89 |
+ |
|
90 |
+ <!-- Move-to-page mini picker --> |
|
91 |
+ <div class="move-box" style="margin-top:10px;"> |
|
92 |
+ <label style="font-size:12px;color:#555;">Move to page:</label> |
|
93 |
+ <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;"> |
|
94 |
+ <input type="text" class="move-input" placeholder="Type page name (e.g., Main.MyPage)" |
|
95 |
+ data-filename="${escapetool.xml($filename)}" |
|
96 |
+ style="flex:1;min-width:220px;padding:4px;border:1px solid #ccc;border-radius:4px;"> |
|
97 |
+ <div class="move-results" style="position:relative;min-width:220px;max-width:420px;"></div> |
|
98 |
+ </div> |
|
99 |
+ <small style="color:#888;">Search is wiki-wide; click a result to move this file.</small> |
|
100 |
+ </div> |
89 |
89 |
</div> |
90 |
90 |
|
91 |
|
- ## close chunk wrapper when reaching size or end |
92 |
92 |
#if(($i % 48 == 0) || $foreach.last) |
93 |
93 |
</div> |
94 |
94 |
#if(!$foreach.last) |
... |
... |
@@ -105,54 +105,73 @@ |
105 |
105 |
|
106 |
106 |
<script> |
107 |
107 |
(function(){ |
108 |
|
- // Utility: create a <video preload="none"> only when needed |
109 |
|
- function mountVideo(frame){ |
110 |
|
- if(frame.getAttribute('data-mounted') === '1') return; |
|
119 |
+ // ---- Helper: build REST path for nested spaces |
|
120 |
+ function spacesPath(dotPath){ |
|
121 |
+ if(!dotPath) return ''; |
|
122 |
+ return dotPath.split('.').map(function(s){ return 'spaces/' + encodeURIComponent(s); }).join('/'); |
|
123 |
+ } |
|
124 |
+ |
|
125 |
+ // ---- On-demand poster: draw first frame into the placeholder canvas |
|
126 |
+ async function makePoster(frame){ |
|
127 |
+ if(frame.getAttribute('data-poster-ready')==='1') return; |
111 |
111 |
const src = frame.getAttribute('data-src'); |
112 |
112 |
const type = frame.getAttribute('data-type') || 'video/mp4'; |
113 |
|
- const name = frame.getAttribute('data-name') || 'video'; |
|
130 |
+ try{ |
|
131 |
+ const v = document.createElement('video'); |
|
132 |
+ v.preload = 'metadata'; |
|
133 |
+ v.muted = true; v.playsInline = true; |
|
134 |
+ v.src = src; |
114 |
114 |
|
115 |
|
- const v = document.createElement('video'); |
116 |
|
- v.setAttribute('controls',''); |
117 |
|
- v.setAttribute('preload','none'); // don't fetch until user interacts or we call load() |
118 |
|
- v.style.width = '100%'; |
119 |
|
- v.style.maxWidth = '100%'; |
120 |
|
- v.style.borderRadius = '4px'; |
|
136 |
+ await new Promise((res, rej)=>{ |
|
137 |
+ let done=false; |
|
138 |
+ function finish(){ if(done) return; done=true; res(); } |
|
139 |
+ v.addEventListener('loadeddata', finish, {once:true}); |
|
140 |
+ v.addEventListener('loadedmetadata', ()=>{ |
|
141 |
+ try { v.currentTime = 0.04; } catch(e){} |
|
142 |
+ }); |
|
143 |
+ v.addEventListener('error', ()=>rej(new Error('metadata error'))); |
|
144 |
+ // Safety timeout |
|
145 |
+ setTimeout(finish, 2500); |
|
146 |
+ }); |
121 |
121 |
|
122 |
|
- const s = document.createElement('source'); |
123 |
|
- s.src = src; |
124 |
|
- s.type = type; |
125 |
|
- v.appendChild(s); |
|
148 |
+ const canvas = frame.querySelector('.vid-canvas'); |
|
149 |
+ if(canvas){ |
|
150 |
+ const w = 320, h = Math.round(320 * (v.videoHeight||9) / (v.videoWidth||16)); |
|
151 |
+ canvas.width = 320; canvas.height = h>0?h:180; |
|
152 |
+ const ctx = canvas.getContext('2d'); |
|
153 |
+ if(ctx){ |
|
154 |
+ ctx.drawImage(v, 0, 0, canvas.width, canvas.height); |
|
155 |
+ } |
|
156 |
+ } |
|
157 |
+ frame.setAttribute('data-poster-ready','1'); |
|
158 |
+ }catch(e){ |
|
159 |
+ // Ignore; fallback stays as dark box |
|
160 |
+ } |
|
161 |
+ } |
126 |
126 |
|
127 |
|
- // when metadata arrives (after load()), fill duration text |
|
163 |
+ // ---- Mount real <video> on click / near viewport (preload="none") |
|
164 |
+ function mountVideo(frame){ |
|
165 |
+ if(frame.getAttribute('data-mounted')==='1') return; |
|
166 |
+ const src = frame.getAttribute('data-src'); |
|
167 |
+ const type = frame.getAttribute('data-type') || 'video/mp4'; |
|
168 |
+ const v = document.createElement('video'); |
|
169 |
+ v.setAttribute('controls',''); v.setAttribute('preload','none'); |
|
170 |
+ v.style.width='100%'; v.style.maxWidth='100%'; v.style.borderRadius='4px'; |
|
171 |
+ const s = document.createElement('source'); s.src=src; s.type=type; v.appendChild(s); |
128 |
128 |
v.addEventListener('loadedmetadata', function(){ |
129 |
|
- const d = Math.round(v.duration||0); |
130 |
|
- const mm = Math.floor(d/60), ss = String(d%60).padStart(2,'0'); |
131 |
|
- const dur = frame.parentElement.querySelector('.vid-duration'); |
132 |
|
- if(dur) dur.textContent = 'Duration: ' + mm + ':' + ss; |
|
173 |
+ const d = Math.round(v.duration||0), mm = Math.floor(d/60), ss = String(d%60).padStart(2,'0'); |
|
174 |
+ const dur = frame.parentElement.querySelector('.vid-duration'); if(dur) dur.textContent = 'Duration: '+mm+':'+ss; |
133 |
133 |
}); |
134 |
|
- |
135 |
135 |
frame.replaceChildren(v); |
136 |
136 |
frame.setAttribute('data-mounted','1'); |
137 |
|
- // We don't call v.load() here; it will start when the user clicks play. |
138 |
138 |
} |
139 |
139 |
|
140 |
|
- // Click-to-load for each placeholder |
141 |
|
- document.querySelectorAll('.video-frame').forEach(function(f){ |
142 |
|
- f.addEventListener('click', function(){ |
143 |
|
- mountVideo(f); |
144 |
|
- const v = f.querySelector('video'); |
145 |
|
- if(v) v.play().catch(()=>{}); |
146 |
|
- }, {once:false}); |
147 |
|
- }); |
148 |
|
- |
149 |
|
- // IntersectionObserver to auto-mount when approaching viewport |
|
180 |
+ // Observe frames for posters + optional pre-mount |
150 |
150 |
if('IntersectionObserver' in window){ |
151 |
151 |
const io = new IntersectionObserver((entries)=>{ |
152 |
152 |
entries.forEach(e=>{ |
153 |
153 |
if(e.isIntersecting){ |
154 |
|
- mountVideo(e.target); |
155 |
|
- // don't auto-play on scroll; user can play if desired |
|
185 |
+ makePoster(e.target); // generate preview only |
156 |
156 |
io.unobserve(e.target); |
157 |
157 |
} |
158 |
158 |
}); |
... |
... |
@@ -160,16 +160,178 @@ |
160 |
160 |
document.querySelectorAll('.video-frame').forEach(el=>io.observe(el)); |
161 |
161 |
} |
162 |
162 |
|
163 |
|
- // Chunk loader (reveals next batch only when user asks) |
|
193 |
+ // Click to load & play |
164 |
164 |
document.addEventListener('click', function(ev){ |
165 |
|
- const b = ev.target.closest('.load-more'); |
166 |
|
- if(!b) return; |
|
195 |
+ const frame = ev.target.closest('.video-frame'); |
|
196 |
+ if(frame){ |
|
197 |
+ mountVideo(frame); |
|
198 |
+ const v = frame.querySelector('video'); if(v) v.play().catch(()=>{}); |
|
199 |
+ } |
|
200 |
+ }); |
|
201 |
+ |
|
202 |
+ // Chunk reveal |
|
203 |
+ document.addEventListener('click', function(ev){ |
|
204 |
+ const b = ev.target.closest('.load-more'); if(!b) return; |
167 |
167 |
const next = b.getAttribute('data-next'); |
168 |
|
- const nxt = document.querySelector('.vid-chunk[data-chunk="'+ next +'"]'); |
|
206 |
+ const nxt = document.querySelector('.vid-chunk[data-chunk="'+ next +'"]'); |
169 |
169 |
if(nxt){ nxt.style.display = 'block'; b.parentElement.style.display = 'none'; } |
170 |
170 |
}); |
171 |
171 |
|
172 |
|
- // Basic button styles (same as before) |
|
210 |
+ // ---- Move to page: search & move |
|
211 |
+ const wiki = window.XWIKI_WIKI; |
|
212 |
+ async function searchPages(q){ |
|
213 |
+ const url = '/rest/wikis/' + encodeURIComponent(wiki) + |
|
214 |
+ '/search?q=' + encodeURIComponent(q) + |
|
215 |
+ '&scope=title,name&number=8&media=json'; |
|
216 |
+ // Title/name search is backed by Solr in recent XWiki; last token supports wildcard. |
|
217 |
+ const r = await fetch(url, {credentials:'same-origin'}); |
|
218 |
+ if(!r.ok) return []; |
|
219 |
+ const json = await r.json(); |
|
220 |
+ const items = (json.searchResults && json.searchResults.searchResult) || []; |
|
221 |
+ // Return list of {fullName, title} |
|
222 |
+ return items.map(it => ({ |
|
223 |
+ fullName: (it.pageFullName || it.fullName || '').replace(/^.*:/,''), |
|
224 |
+ title: it.title || it.pageTitle || it.highlight || it.fullName |
|
225 |
+ })).filter(it=>it.fullName); |
|
226 |
+ } |
|
227 |
+ |
|
228 |
+ function renderResults(box, results, onPick){ |
|
229 |
+ function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<'); } |
|
230 |
+ var wrap = document.createElement('div'); |
|
231 |
+ wrap.className = 'move-suggest'; |
|
232 |
+ wrap.style.position='absolute'; |
|
233 |
+ wrap.style.zIndex='1000'; |
|
234 |
+ wrap.style.top='0'; wrap.style.left='0'; wrap.style.right='0'; |
|
235 |
+ wrap.style.background='#fff'; |
|
236 |
+ wrap.style.border='1px solid #ddd'; |
|
237 |
+ wrap.style.borderRadius='4px'; |
|
238 |
+ wrap.style.boxShadow='0 6px 20px rgba(0,0,0,.08)'; |
|
239 |
+ wrap.style.maxHeight='240px'; |
|
240 |
+ wrap.style.overflow='auto'; |
|
241 |
+ |
|
242 |
+ var html = ''; |
|
243 |
+ if (results && results.length){ |
|
244 |
+ for (var i=0;i<results.length;i++){ |
|
245 |
+ var r = results[i]; |
|
246 |
+ var title = r.title ? esc(r.title) : '(untitled)'; |
|
247 |
+ var full = esc(r.fullName || ''); |
|
248 |
+ html += '<div class="move-item" data-full="' + full + '" ' + |
|
249 |
+ 'style="padding:6px 10px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + |
|
250 |
+ '<strong>' + title + '</strong>' + |
|
251 |
+ '<span style="color:#777;margin-left:6px;">' + full + '</span>' + |
|
252 |
+ '</div>'; |
|
253 |
+ } |
|
254 |
+ } else { |
|
255 |
+ html = '<div style="padding:8px 10px;color:#777;">No matches</div>'; |
|
256 |
+ } |
|
257 |
+ wrap.innerHTML = html; |
|
258 |
+ |
|
259 |
+ box.textContent = ''; |
|
260 |
+ box.appendChild(wrap); |
|
261 |
+ box.onpointerdown = function(e){ |
|
262 |
+ var it = e.target.closest('.move-item'); if(!it) return; |
|
263 |
+ var full = it.getAttribute('data-full'); |
|
264 |
+ onPick(full); |
|
265 |
+ box.textContent = ''; |
|
266 |
+ }; |
|
267 |
+ } |
|
268 |
+ |
|
269 |
+ function parseFullName(full){ |
|
270 |
+ // "Main.MyPage" or "Main.Sub.MyPage" |
|
271 |
+ const parts = full.split('.'); |
|
272 |
+ const page = parts.pop(); |
|
273 |
+ const spacePath = parts.join('.'); |
|
274 |
+ return {spacePath, page}; |
|
275 |
+ } |
|
276 |
+ |
|
277 |
+ async function moveAttachment(opts){ |
|
278 |
+ var srcSpace = opts.srcSpace; |
|
279 |
+ var srcPage = opts.srcPage; |
|
280 |
+ var filename = opts.filename; |
|
281 |
+ var dstFull = opts.dstFull; |
|
282 |
+ |
|
283 |
+ const pf = parseFullName(dstFull); |
|
284 |
+ const dstSpace = pf.spacePath; |
|
285 |
+ const dstPage = pf.page; |
|
286 |
+ const srcSpacesPath = spacesPath(srcSpace); |
|
287 |
+ const dstSpacesPath = spacesPath(dstSpace); |
|
288 |
+ |
|
289 |
+ // 1) GET the file as blob from the current attachment URL we already have in the card |
|
290 |
+ // 2) PUT to target page's attachments |
|
291 |
+ // 3) DELETE original |
|
292 |
+ var cardInputSel = '.video-container input.move-input[data-filename="' + CSS.escape(filename) + '"]'; |
|
293 |
+ var inputEl = document.querySelector(cardInputSel); |
|
294 |
+ var card = inputEl ? inputEl.closest('.video-container') : null; |
|
295 |
+ var frame = card ? card.querySelector('.video-frame') : null; |
|
296 |
+ const srcURL = frame ? frame.getAttribute('data-src') : null; |
|
297 |
+ |
|
298 |
+ if(!srcURL) throw new Error('Missing source URL'); |
|
299 |
+ |
|
300 |
+ const downloading = await fetch(srcURL, {credentials:'same-origin'}); |
|
301 |
+ if(!downloading.ok) throw new Error('Download failed: '+downloading.status); |
|
302 |
+ const blob = await downloading.blob(); |
|
303 |
+ |
|
304 |
+ const putURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + dstSpacesPath + |
|
305 |
+ '/pages/' + encodeURIComponent(dstPage) + |
|
306 |
+ '/attachments/' + encodeURIComponent(filename) + '?media=json'; |
|
307 |
+ const uploading = await fetch(putURL, { |
|
308 |
+ method: 'PUT', |
|
309 |
+ body: blob, |
|
310 |
+ headers: {'Content-Type':'application/octet-stream'}, |
|
311 |
+ credentials:'same-origin' |
|
312 |
+ }); |
|
313 |
+ if(!(uploading.status===201 || uploading.status===202)){ |
|
314 |
+ const txt = await uploading.text().catch(()=>String(uploading.status)); |
|
315 |
+ throw new Error('Upload failed: '+txt); |
|
316 |
+ } |
|
317 |
+ |
|
318 |
+ const delURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + srcSpacesPath + |
|
319 |
+ '/pages/' + encodeURIComponent(srcPage) + |
|
320 |
+ '/attachments/' + encodeURIComponent(filename); |
|
321 |
+ const deleting = await fetch(delURL, {method:'DELETE', credentials:'same-origin'}); |
|
322 |
+ if(!(deleting.status===204)){ |
|
323 |
+ // Not fatal: the file exists at destination; warn but keep going. |
|
324 |
+ console.warn('Delete original failed', deleting.status); |
|
325 |
+ } |
|
326 |
+ return true; |
|
327 |
+ } |
|
328 |
+ |
|
329 |
+ // Wire up per-card search boxes |
|
330 |
+ let timer=null; |
|
331 |
+ document.querySelectorAll('.move-box .move-input').forEach(inp=>{ |
|
332 |
+ const resultsBox = inp.parentElement.querySelector('.move-results'); |
|
333 |
+ inp.addEventListener('input', ()=>{ |
|
334 |
+ clearTimeout(timer); |
|
335 |
+ const q = inp.value.trim(); |
|
336 |
+ if(!q){ resultsBox.textContent=''; return; } |
|
337 |
+ timer = setTimeout(async ()=>{ |
|
338 |
+ const res = await searchPages(q); |
|
339 |
+ renderResults(resultsBox, res, async (full)=>{ |
|
340 |
+ inp.value = full; |
|
341 |
+ // Kick the move |
|
342 |
+ const filename = inp.getAttribute('data-filename'); |
|
343 |
+ const notice = document.createElement('div'); |
|
344 |
+ notice.style.fontSize='12px'; notice.style.color='#666'; notice.textContent='Moving…'; |
|
345 |
+ inp.parentElement.appendChild(notice); |
|
346 |
+ try{ |
|
347 |
+ await moveAttachment({ |
|
348 |
+ srcSpace: window.SOURCE_SPACE, |
|
349 |
+ srcPage: window.SOURCE_PAGE, |
|
350 |
+ filename: filename, |
|
351 |
+ dstFull: full |
|
352 |
+ }); |
|
353 |
+ notice.textContent = 'Moved ✔ — reloading…'; |
|
354 |
+ setTimeout(()=>location.reload(), 600); |
|
355 |
+ }catch(e){ |
|
356 |
+ notice.style.color = '#b00020'; |
|
357 |
+ notice.textContent = 'Move failed: ' + (e && e.message ? e.message : e); |
|
358 |
+ } |
|
359 |
+ }); |
|
360 |
+ }, 220); |
|
361 |
+ }); |
|
362 |
+ // Close suggestions when clicking out |
|
363 |
+ document.addEventListener('click', (e)=>{ if(!inp.parentElement.contains(e.target)) resultsBox.textContent=''; }); |
|
364 |
+ }); |
173 |
173 |
})(); |
174 |
174 |
</script> |
175 |
175 |
|
... |
... |
@@ -182,8 +182,9 @@ |
182 |
182 |
.btn-success{background:#28a745;color:#fff;border-color:#28a745;} |
183 |
183 |
.btn-sm{font-size:12px;padding:2px 6px;} |
184 |
184 |
@media (max-width:768px){.video-display-grid{grid-template-columns:1fr}} |
|
377 |
+.move-suggest::-webkit-scrollbar{width:10px;height:10px} |
|
378 |
+.move-suggest::-webkit-scrollbar-thumb{background:#ccc;border-radius:6px} |
185 |
185 |
</style> |
186 |
186 |
{{/html}} |
187 |
187 |
{{/cache}} |
188 |
188 |
{{/velocity}} |
189 |
|
- |