0 Votes

Changes for page Uncategorized Videos

Last modified by Ryan C on 2025/09/10 07:29

From version 500.1
edited by Ryan C
on 2025/09/10 05:01
Change comment: There is no comment for this version
To version 501.1
edited by Ryan C
on 2025/09/10 05:56
Change comment: There is no comment for this version

Summary

Details

Page properties
Content
... ... @@ -1,8 +1,9 @@
1 1  {{velocity}}
2 +#pagePicker_import ## load the native Page Picker resources once
2 2  
3 -#set($videoExts = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v'])
4 -#set($videos = [])
5 -
4 +## 1) Collect video attachments from the current page
5 +#set($videoExts = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v'])
6 +#set($videos = [])
6 6  #foreach($att in $doc.getAttachmentList())
7 7   #set($n = $att.getFilename())
8 8   #set($ln = $n.toLowerCase())
... ... @@ -14,10 +14,9 @@
14 14   #end
15 15  #end
16 16  
18 +## 2) Cache the rendered HTML (auto-bust on each save via version)
17 17  {{cache id="vid-list-$doc.fullName-$doc.version" timeToLive="21600"}}
18 -
19 19  {{html wiki="false" clean="false"}}
20 -
21 21  <div id="xwiki-video-manager" style="margin:20px 0;">
22 22   <h2>📹 Videos on: ${escapetool.xml($doc.fullName)}</h2>
23 23  
... ... @@ -30,22 +30,21 @@
30 30   <script>
31 31   window.VID_CHUNK_SIZE = 48;
32 32   window.VID_LAZY_MARGIN = '600px';
33 - window.XWIKI_WIKI = ${jsontool.serialize($xcontext.database)}; // current wiki id (e.g., "xwiki")
34 - window.SOURCE_SPACE = ${jsontool.serialize($doc.space)}; // e.g., "Main" or "Main.Sub"
35 - window.SOURCE_PAGE = ${jsontool.serialize($doc.name)}; // e.g., "WebHome"
33 + window.XWIKI_WIKI = ${jsontool.serialize($xcontext.database)}; // e.g., "xwiki"
34 + window.SOURCE_SPACE = ${jsontool.serialize($doc.space)}; // e.g., "Main" or "Main.Sub"
35 + window.SOURCE_PAGE = ${jsontool.serialize($doc.name)}; // e.g., "WebHome"
36 36   </script>
37 37  
38 38   <div id="video-chunks">
39 39   #set($i = 0)
40 40   #set($chunkIndex = 0)
41 -
42 42   #foreach($att in $videos)
43 43   #set($i = $i + 1)
44 44   #set($filename = $att.getFilename())
45 - #set($lname = $filename.toLowerCase())
46 - #set($url = $doc.getAttachmentURL($filename))
44 + #set($lname = $filename.toLowerCase())
45 + #set($url = $doc.getAttachmentURL($filename))
47 47  
48 - ## MIME type detection
47 + ## MIME guess
49 49   #set($videoType = "video/mp4")
50 50   #if($lname.endsWith(".webm"))
51 51   #set($videoType = "video/webm")
... ... @@ -72,13 +72,13 @@
72 72   <div class="video-container" style="border:1px solid #ddd;border-radius:8px;padding:12px;background:#fff;">
73 73   <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
74 74   <h4 style="margin:0;flex:1;min-width:0;">${escapetool.xml($filename)}</h4>
75 - #if($xcontext.action == 'edit')
76 - <input type="checkbox" class="video-selector" data-video="${escapetool.xml($filename)}" title="Select for bulk actions">
77 - #end
78 78   </div>
79 79  
80 - <!-- Video placeholder with auto-generated poster -->
81 - <div class="video-frame" data-src="${url}" data-type="${videoType}" data-name="${escapetool.xml($filename)}"
76 + <!-- Placeholder (poster drawn on-demand) -->
77 + <div class="video-frame"
78 + data-src="${url}"
79 + data-type="${videoType}"
80 + data-name="${escapetool.xml($filename)}"
82 82   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;">
83 83   <canvas class="vid-canvas" width="320" height="180" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;"></canvas>
84 84   <button class="btn btn-sm btn-primary" type="button" style="position:relative;z-index:1;">Load &amp; Play</button>
... ... @@ -89,27 +89,28 @@
89 89   <span class="vid-duration" style="font-size:12px;color:#666;">Duration: —</span>
90 90   </div>
91 91  
92 - <!-- Move-to-page functionality -->
91 + <!-- Move-to-page (native Page Picker) -->
93 93   <div class="move-box" style="margin-top:10px;">
94 94   <label style="font-size:12px;color:#555;">Move to page:</label>
95 95   <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
96 - <input type="text" class="move-input" placeholder="Type page name (e.g., Main.MyPage)"
97 - data-filename="${escapetool.xml($filename)}" style="flex:1;min-width:220px;padding:4px;border:1px solid #ccc;border-radius:4px;">
98 - <div class="move-results" style="position:relative;min-width:220px;max-width:420px;"></div>
95 + <input type="text"
96 + class="move-input suggest-pages"
97 + placeholder="Find a page…"
98 + data-search-scope="wiki:${escapetool.xml($xcontext.database)}"
99 + data-filename="${escapetool.xml($filename)}"
100 + style="flex:1;min-width:220px;padding:4px;border:1px solid #ccc;border-radius:4px;">
99 99   </div>
100 - <small style="color:#888;">Search is wiki-wide; click a result to move this file.</small>
102 + <small style="color:#888;">Pick a page (e.g., <code>Main.SomePage</code> or a titled page). Selecting it moves this file.</small>
101 101   </div>
102 102   </div>
103 103  
104 104   #if(($i % 48 == 0) || $foreach.last)
105 105   </div>
106 -
107 - #if(!$foreach.last)
108 - <div style="text-align:center;margin:12px 0 28px;">
109 - <button class="btn btn-secondary load-more" data-next="$mathtool.add($chunkIndex,1)">Load more</button>
110 - </div>
111 - #end
112 -
108 + #if(!$foreach.last)
109 + <div style="text-align:center;margin:12px 0 28px;">
110 + <button class="btn btn-secondary load-more" data-next="$mathtool.add($chunkIndex,1)">Load more</button>
111 + </div>
112 + #end
113 113   </div>
114 114   #end
115 115   #end
... ... @@ -119,365 +119,172 @@
119 119  
120 120  <script>
121 121  (function(){
122 - // ---- Helper: build REST path for nested spaces
122 + // ---- Helpers
123 123   function spacesPath(dotPath){
124 124   if(!dotPath) return '';
125 - return dotPath.split('.').map(function(s){
126 - return 'spaces/' + encodeURIComponent(s);
127 - }).join('/');
125 + return dotPath.split('.').map(function(s){ return 'spaces/' + encodeURIComponent(s); }).join('/');
128 128   }
127 + function parseFullName(full){
128 + // Accept "xwiki:Main.Sub.Page" or "Main.Sub.Page"
129 + full = String(full || '').replace(/^[^:]+:/,'');
130 + var parts = full.split('.');
131 + var page = parts.pop();
132 + return {spacePath: parts.join('.'), page: page};
133 + }
129 129  
130 - // ---- On-demand poster: draw first frame into the placeholder canvas
135 + // ---- Poster generation (cheap first-frame)
131 131   async function makePoster(frame){
132 132   if(frame.getAttribute('data-poster-ready')==='1') return;
133 -
134 - const src = frame.getAttribute('data-src');
135 - const type = frame.getAttribute('data-type') || 'video/mp4';
136 -
138 + var src = frame.getAttribute('data-src');
137 137   try{
138 - const v = document.createElement('video');
140 + var v = document.createElement('video');
139 139   v.preload = 'metadata';
140 - v.muted = true;
141 - v.playsInline = true;
142 + v.muted = true; v.playsInline = true;
142 142   v.src = src;
143 143  
144 - await new Promise((res, rej)=>{
145 - let done=false;
146 - function finish(){
147 - if(done) return;
148 - done=true;
149 - res();
150 - }
145 + await new Promise(function(res, rej){
146 + var done=false;
147 + function finish(){ if(done) return; done=true; res(); }
151 151   v.addEventListener('loadeddata', finish, {once:true});
152 - v.addEventListener('loadedmetadata', ()=>{
153 - try {
154 - v.currentTime = 0.04;
155 - } catch(e){}
156 - });
157 - v.addEventListener('error', ()=>rej(new Error('metadata error')));
158 - // Safety timeout
149 + v.addEventListener('loadedmetadata', function(){ try{ v.currentTime = 0.04; }catch(e){} });
150 + v.addEventListener('error', function(){ rej(new Error('metadata error')); });
159 159   setTimeout(finish, 2500);
160 160   });
161 161  
162 - const canvas = frame.querySelector('.vid-canvas');
154 + var canvas = frame.querySelector('.vid-canvas');
163 163   if(canvas){
164 - const w = 320, h = Math.round(320 * (v.videoHeight||9) / (v.videoWidth||16));
165 - canvas.width = 320;
166 - canvas.height = h>0?h:180;
167 - const ctx = canvas.getContext('2d');
168 - if(ctx){
169 - ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
170 - }
156 + var w = 320, h = Math.round(320 * (v.videoHeight||9) / (v.videoWidth||16));
157 + canvas.width = 320; canvas.height = h>0?h:180;
158 + var ctx = canvas.getContext('2d');
159 + if(ctx){ ctx.drawImage(v, 0, 0, canvas.width, canvas.height); }
171 171   }
172 172   frame.setAttribute('data-poster-ready','1');
173 - }catch(e){
174 - // Ignore; fallback stays as dark box
175 - }
162 + }catch(e){ /* keep dark box */ }
176 176   }
177 177  
178 - // ---- Mount real <video> on click / near viewport (preload="none")
165 + // ---- Mount real <video> on click (preload="none")
179 179   function mountVideo(frame){
180 180   if(frame.getAttribute('data-mounted')==='1') return;
181 -
182 - const src = frame.getAttribute('data-src');
183 - const type = frame.getAttribute('data-type') || 'video/mp4';
184 - const v = document.createElement('video');
185 - v.setAttribute('controls','');
186 - v.setAttribute('preload','none');
187 - v.style.width='100%';
188 - v.style.maxWidth='100%';
189 - v.style.borderRadius='4px';
190 -
191 - const s = document.createElement('source');
192 - s.src=src;
193 - s.type=type;
194 - v.appendChild(s);
195 -
168 + var src = frame.getAttribute('data-src');
169 + var type = frame.getAttribute('data-type') || 'video/mp4';
170 + var v = document.createElement('video');
171 + v.setAttribute('controls',''); v.setAttribute('preload','none');
172 + v.style.width='100%'; v.style.maxWidth='100%'; v.style.borderRadius='4px';
173 + var s = document.createElement('source'); s.src=src; s.type=type; v.appendChild(s);
196 196   v.addEventListener('loadedmetadata', function(){
197 - const d = Math.round(v.duration||0),
198 - mm = Math.floor(d/60),
199 - ss = String(d%60).padStart(2,'0');
200 - const dur = frame.parentElement.querySelector('.vid-duration');
201 - if(dur) dur.textContent = 'Duration: '+mm+':'+ss;
175 + var d = Math.round(v.duration||0), mm = Math.floor(d/60), ss = String(d%60).padStart(2,'0');
176 + var dur = frame.parentElement.querySelector('.vid-duration'); if(dur) dur.textContent = 'Duration: '+mm+':'+ss;
202 202   });
203 -
204 204   frame.replaceChildren(v);
205 205   frame.setAttribute('data-mounted','1');
206 206   }
207 207  
208 - // Observe frames for posters + optional pre-mount
182 + // ---- Lazy poster
209 209   if('IntersectionObserver' in window){
210 - const io = new IntersectionObserver((entries)=>{
211 - entries.forEach(e=>{
212 - if(e.isIntersecting){
213 - makePoster(e.target); // generate preview only
214 - io.unobserve(e.target);
215 - }
184 + var io = new IntersectionObserver(function(entries){
185 + entries.forEach(function(e){
186 + if(e.isIntersecting){ makePoster(e.target); io.unobserve(e.target); }
216 216   });
217 217   }, { rootMargin: (window.VID_LAZY_MARGIN||'600px') });
218 -
219 - document.querySelectorAll('.video-frame').forEach(el=>io.observe(el));
189 + document.querySelectorAll('.video-frame').forEach(function(el){ io.observe(el); });
220 220   }
221 221  
222 - // Click to load & play
192 + // ---- Click to load & play
223 223   document.addEventListener('click', function(ev){
224 - const frame = ev.target.closest('.video-frame');
194 + var frame = ev.target.closest('.video-frame');
225 225   if(frame){
226 226   mountVideo(frame);
227 - const v = frame.querySelector('video');
228 - if(v) v.play().catch(()=>{});
197 + var v = frame.querySelector('video'); if(v) v.play().catch(function(){});
229 229   }
230 230   });
231 231  
232 - // Chunk reveal
201 + // ---- Chunk reveal
233 233   document.addEventListener('click', function(ev){
234 - const b = ev.target.closest('.load-more');
235 - if(!b) return;
236 - const next = b.getAttribute('data-next');
237 - const nxt = document.querySelector('.vid-chunk[data-chunk="'+ next +'"]');
238 - if(nxt){
239 - nxt.style.display = 'block';
240 - b.parentElement.style.display = 'none';
241 - }
203 + var b = ev.target.closest('.load-more'); if(!b) return;
204 + var next = b.getAttribute('data-next');
205 + var nxt = document.querySelector('.vid-chunk[data-chunk="'+ next +'"]');
206 + if(nxt){ nxt.style.display = 'block'; b.parentElement.style.display = 'none'; }
242 242   });
243 243  
244 - // ---- Move to page: search & move
245 - const wiki = window.XWIKI_WIKI;
209 + // ---- Move: when a page is picked in the native Page Picker (fires change)
210 + var wiki = window.XWIKI_WIKI;
211 + function moveAttachment(opts){
212 + return (async function(){
213 + var srcSpace = opts.srcSpace, srcPage = opts.srcPage, filename = opts.filename, dstFull = opts.dstFull;
214 + var pf = (function(full){ full = String(full||'').replace(/^[^:]+:/,''); var p=full.split('.'); var page=p.pop(); return {spacePath:p.join('.'), page:page}; })(dstFull);
215 + var srcSpacesPath = spacesPath(srcSpace), dstSpacesPath = spacesPath(pf.spacePath);
246 246  
247 - async function searchPages(q){
248 - const url = '/rest/wikis/' + encodeURIComponent(wiki) + '/search?q=' + encodeURIComponent(q) + '&scope=title,name&number=8&media=json';
249 - // Title/name search is backed by Solr in recent XWiki; last token supports wildcard.
250 - const r = await fetch(url, {credentials:'same-origin'});
251 - if(!r.ok) return [];
252 - const json = await r.json();
253 - const items = (json.searchResults && json.searchResults.searchResult) || [];
254 - // Return list of {fullName, title}
255 - return items.map(it => ({
256 - fullName: (it.pageFullName || it.fullName || '').replace(/^.*:/,''),
257 - title: it.title || it.pageTitle || it.highlight || it.fullName
258 - })).filter(it=>it.fullName);
259 - }
217 + // Download -> Upload -> Delete original
218 + var cardInputSel = '.video-container input.move-input[data-filename="' + CSS.escape(filename) + '"]';
219 + var inputEl = document.querySelector(cardInputSel);
220 + var card = inputEl ? inputEl.closest('.video-container') : null;
221 + var frame = card ? card.querySelector('.video-frame') : null;
222 + var srcURL = frame ? frame.getAttribute('data-src') : null;
223 + if(!srcURL) throw new Error('Missing source URL');
260 260  
261 - function renderResults(box, results, onPick){
262 - function esc(s){
263 - return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;');
264 - }
225 + var downloading = await fetch(srcURL, {credentials:'same-origin'});
226 + if(!downloading.ok) throw new Error('Download failed: ' + downloading.status);
227 + var blob = await downloading.blob();
265 265  
266 - var wrap = document.createElement('div');
267 - wrap.className = 'move-suggest';
268 - wrap.style.position='absolute';
269 - wrap.style.zIndex='1000';
270 - wrap.style.top='0';
271 - wrap.style.left='0';
272 - wrap.style.right='0';
273 - wrap.style.background='#fff';
274 - wrap.style.border='1px solid #ddd';
275 - wrap.style.borderRadius='4px';
276 - wrap.style.boxShadow='0 6px 20px rgba(0,0,0,.08)';
277 - wrap.style.maxHeight='240px';
278 - wrap.style.overflow='auto';
279 -
280 - var html = '';
281 - if (results && results.length){
282 - for (var i=0;i<results.length;i++){
283 - var r = results[i];
284 - var title = r.title ? esc(r.title) : '(untitled)';
285 - var full = esc(r.fullName || '');
286 - html += '<div class="move-item" data-full="' + full + '" ' +
287 - 'style="padding:6px 10px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' +
288 - '<strong>' + title + '</strong>' +
289 - '<span style="color:#777;margin-left:6px;">' + full + '</span>' +
290 - '</div>';
229 + var putURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + dstSpacesPath +
230 + '/pages/' + encodeURIComponent(pf.page) +
231 + '/attachments/' + encodeURIComponent(filename) + '?media=json';
232 + var uploading = await fetch(putURL, {
233 + method: 'PUT',
234 + body: blob,
235 + headers: {'Content-Type':'application/octet-stream'},
236 + credentials:'same-origin'
237 + });
238 + if(!(uploading.status===201 || uploading.status===202)){
239 + var txt = await uploading.text().catch(function(){ return String(uploading.status); });
240 + throw new Error('Upload failed: ' + txt);
291 291   }
292 - } else {
293 - html = '<div style="padding:8px 10px;color:#777;">No matches</div>';
294 - }
295 - wrap.innerHTML = html;
296 - box.textContent = '';
297 - box.appendChild(wrap);
298 298  
299 - box.onpointerdown = function(e){
300 - var it = e.target.closest('.move-item');
301 - if(!it) return;
302 - var full = it.getAttribute('data-full');
303 - onPick(full);
304 - box.textContent = '';
305 - };
243 + var delURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + srcSpacesPath +
244 + '/pages/' + encodeURIComponent(srcPage) +
245 + '/attachments/' + encodeURIComponent(filename);
246 + var deleting = await fetch(delURL, {method:'DELETE', credentials:'same-origin'});
247 + if(!(deleting.status===204)){ console.warn('Delete original failed', deleting.status); }
248 + return true;
249 + })();
306 306   }
307 307  
308 - function parseFullName(full){
309 - // "Main.MyPage" or "Main.Sub.MyPage"
310 - const parts = full.split('.');
311 - const page = parts.pop();
312 - const spacePath = parts.join('.');
313 - return {spacePath, page};
314 - }
252 + document.addEventListener('change', function(e){
253 + var inp = e.target.closest('.move-input.suggest-pages'); if(!inp) return;
254 + var selected = (inp.value || '').trim(); if(!selected) return;
315 315  
316 - async function moveAttachment(opts){
317 - var srcSpace = opts.srcSpace;
318 - var srcPage = opts.srcPage;
319 - var filename = opts.filename;
320 - var dstFull = opts.dstFull;
256 + var filename = inp.getAttribute('data-filename');
257 + var notice = document.createElement('div');
258 + notice.style.fontSize='12px'; notice.style.color='#666'; notice.textContent='Moving…';
259 + inp.parentElement.appendChild(notice);
321 321  
322 - const pf = parseFullName(dstFull);
323 - const dstSpace = pf.spacePath;
324 - const dstPage = pf.page;
325 -
326 - const srcSpacesPath = spacesPath(srcSpace);
327 - const dstSpacesPath = spacesPath(dstSpace);
328 -
329 - // 1) GET the file as blob from the current attachment URL we already have in the card
330 - // 2) PUT to target page's attachments
331 - // 3) DELETE original
332 -
333 - var cardInputSel = '.video-container input.move-input[data-filename="' + CSS.escape(filename) + '"]';
334 - var inputEl = document.querySelector(cardInputSel);
335 - var card = inputEl ? inputEl.closest('.video-container') : null;
336 - var frame = card ? card.querySelector('.video-frame') : null;
337 - const srcURL = frame ? frame.getAttribute('data-src') : null;
338 - if(!srcURL) throw new Error('Missing source URL');
339 -
340 - const downloading = await fetch(srcURL, {credentials:'same-origin'});
341 - if(!downloading.ok) throw new Error('Download failed: '+downloading.status);
342 - const blob = await downloading.blob();
343 -
344 - const putURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + dstSpacesPath + '/pages/' + encodeURIComponent(dstPage) + '/attachments/' + encodeURIComponent(filename) + '?media=json';
345 - const uploading = await fetch(putURL, {
346 - method: 'PUT',
347 - body: blob,
348 - headers: {'Content-Type':'application/octet-stream'},
349 - credentials:'same-origin'
261 + moveAttachment({
262 + srcSpace: window.SOURCE_SPACE,
263 + srcPage: window.SOURCE_PAGE,
264 + filename: filename,
265 + dstFull: selected
266 + }).then(function(){
267 + notice.textContent = 'Moved ✔ — reloading…';
268 + setTimeout(function(){ location.reload(); }, 600);
269 + }).catch(function(err){
270 + notice.style.color = '#b00020';
271 + notice.textContent = 'Move failed: ' + (err && err.message ? err.message : err);
350 350   });
351 -
352 - if(!(uploading.status===201 || uploading.status===202)){
353 - const txt = await uploading.text().catch(()=>String(uploading.status));
354 - throw new Error('Upload failed: '+txt);
355 - }
356 -
357 - const delURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + srcSpacesPath + '/pages/' + encodeURIComponent(srcPage) + '/attachments/' + encodeURIComponent(filename);
358 - const deleting = await fetch(delURL, {method:'DELETE', credentials:'same-origin'});
359 -
360 - if(!(deleting.status===204)){
361 - // Not fatal: the file exists at destination; warn but keep going.
362 - console.warn('Delete original failed', deleting.status);
363 - }
364 -
365 - return true;
366 - }
367 -
368 - // Wire up per-card search boxes
369 - let timer=null;
370 - document.querySelectorAll('.move-box .move-input').forEach(inp=>{
371 - const resultsBox = inp.parentElement.querySelector('.move-results');
372 -
373 - inp.addEventListener('input', ()=>{
374 - clearTimeout(timer);
375 - const q = inp.value.trim();
376 - if(!q){
377 - resultsBox.textContent='';
378 - return;
379 - }
380 - timer = setTimeout(async ()=>{
381 - const res = await searchPages(q);
382 - renderResults(resultsBox, res, async (full)=>{
383 - inp.value = full; // Fill the input
384 -
385 - // Kick the move
386 - const filename = inp.getAttribute('data-filename');
387 - const notice = document.createElement('div');
388 - notice.style.fontSize='12px';
389 - notice.style.color='#666';
390 - notice.textContent='Moving…';
391 - inp.parentElement.appendChild(notice);
392 -
393 - try{
394 - await moveAttachment({
395 - srcSpace: window.SOURCE_SPACE,
396 - srcPage: window.SOURCE_PAGE,
397 - filename: filename,
398 - dstFull: full
399 - });
400 - notice.textContent = 'Moved ✔ — reloading…';
401 - setTimeout(()=>location.reload(), 600);
402 - }catch(e){
403 - notice.style.color = '#b00020';
404 - notice.textContent = 'Move failed: ' + (e && e.message ? e.message : e);
405 - }
406 - });
407 - }, 220);
408 - });
409 -
410 - // Close suggestions when clicking out
411 - document.addEventListener('click', (e)=>{
412 - if(!inp.parentElement.contains(e.target)) resultsBox.textContent='';
413 - });
414 414   });
415 -
416 416  })();
417 417  </script>
418 418  
419 419  <style>
420 - .video-container:hover{
421 - box-shadow:0 4px 8px rgba(0,0,0,0.08);
422 - transition:box-shadow .25s;
423 - }
424 -
425 - .btn{
426 - padding:4px 8px;
427 - border:1px solid #ddd;
428 - background:#f8f9fa;
429 - border-radius:4px;
430 - cursor:pointer;
431 - text-decoration:none;
432 - display:inline-block;
433 - }
434 -
435 - .btn:hover{
436 - background:#e9ecef;
437 - }
438 -
439 - .btn-primary{
440 - background:#007bff;
441 - color:#fff;
442 - border-color:#007bff;
443 - }
444 -
445 - .btn-secondary{
446 - background:#6c757d;
447 - color:#fff;
448 - border-color:#6c757d;
449 - }
450 -
451 - .btn-success{
452 - background:#28a745;
453 - color:#fff;
454 - border-color:#28a745;
455 - }
456 -
457 - .btn-sm{
458 - font-size:12px;
459 - padding:2px 6px;
460 - }
461 -
462 - @media (max-width:768px){
463 - .video-display-grid{
464 - grid-template-columns:1fr;
465 - }
466 - }
467 -
468 - .move-suggest::-webkit-scrollbar{
469 - width:10px;
470 - height:10px;
471 - }
472 -
473 - .move-suggest::-webkit-scrollbar-thumb{
474 - background:#ccc;
475 - border-radius:6px;
476 - }
278 +.video-container:hover{box-shadow:0 4px 8px rgba(0,0,0,0.08);transition:box-shadow .25s;}
279 +.btn{padding:4px 8px;border:1px solid #ddd;background:#f8f9fa;border-radius:4px;cursor:pointer;text-decoration:none;display:inline-block;}
280 +.btn:hover{background:#e9ecef;}
281 +.btn-primary{background:#007bff;color:#fff;border-color:#007bff;}
282 +.btn-secondary{background:#6c757d;color:#fff;border-color:#6c757d;}
283 +.btn-success{background:#28a745;color:#fff;border-color:#28a745;}
284 +.btn-sm{font-size:12px;padding:2px 6px;}
285 +@media (max-width:768px){.video-display-grid{grid-template-columns:1fr}}
477 477  </style>
478 -
479 479  {{/html}}
480 -
481 481  {{/cache}}
482 -
483 483  {{/velocity}}
290 +