1 2Vue.component('SupportList', { 3 template: ` 4 <div class="support-container"> 5 <Loading v-if="loading" :show="loading" /> 6 <PopConfirm v-if="confirm.show" :show="confirm.show" :title="confirm.title" :prompt="confirm.prompt" :cancel="confirm.cancel" :okay="confirm.okay" @close="confirm.show=false" @decline="confirm.action.negative" @confirm="confirm.action.positive" :negative="confirm.red" /> 7 <PopToast :toasts="toasts" @remove="removeToast($event)" /> 8 9 <PopSupport v-if="showGuide" :guide="showGuide" :editMode="editMode" @edit="clickEdit($event)" @close="showGuide=false" /> 10 11 <!-- Modify Guide Modal --> 12 <ModifyGuide 13 v-if="showEditGuide" 14 :guide="showEditGuide" 15 :levelTypes="levelTypes" 16 @close="showEditGuide=false" 17 @save="clickSave($event)" 18 @delete="clickDelete($event)" 19 /> 20 21 <!-- Search Bar --> 22 <div style="position:relative;"> 23 <button v-if="isAdmin && editMode" class="btn-green" style="position:absolute;right:10px;" @click="clickNew">{{ $t('points.btn.add').toUpperCase() }}</button> 24 <div class="vid-search"> 25 <div class="tbl"> 26 <div class="tbl-row"> 27 <div class="tbl-cell"> 28 <input type="text" v-model="searchTerm" :placeholder="$t('support.search.placeholder')" autofocus /> 29 </div> 30 </div> 31 </div> 32 </div> 33 </div> 34 35 <div class="none" v-if="filteredGuides.length === 0 && !loading"> 36 {{ $t('common.empty.no_guides') }} 37 </div> 38 39 <!-- Videos Grid --> 40 <div class="vid-grid"> 41 <div 42 v-for="(guide, index) in filteredGuides" 43 :key="guide.id" 44 class="vid" 45 :style="guide.png_url ? {backgroundImage: 'url('+guide.png_url+')'} : {}" 46 @click="clickGuide(guide)" 47 > 48 <div class="vid-label">{{ guide.title }}</div> 49 </div> 50 51 <div v-if="isAdmin && editMode" class="vid vid-create" @click="clickNew"> 52 <div class="vid-plus">+</div> 53 <div class="vid-label">{{ $t('support.faq.create_new').toUpperCase() }}</div> 54 </div> 55 </div> 56 </div> 57 `, 58 mixins: [Utils, Core, I18n], 59 props: { 60 editMode: { 61 type: Boolean, 62 default: false 63 } 64 }, 65 data() { 66 return { 67 loading: false, 68 guides: [], 69 searchTerm: '', 70 showGuide: false, 71 showEditGuide: false, 72 levelTypes: {} 73 } 74 }, 75 computed: { 76 filteredGuides() { 77 if (!this.searchTerm) { 78 return this.guides; 79 } 80 const term = this.searchTerm.toLowerCase().trim(); 81 return this.guides.filter(guide => 82 guide.title.toLowerCase().includes(term) || 83 (guide.content && guide.content.toLowerCase().includes(term)) 84 ); 85 } 86 }, 87 mounted() { 88 this.refresh(); 89 // Check URL hash for direct video link 90 const hash = window.location.hash.replace('#', ''); 91 if (hash) { 92 this.$nextTick(() => { 93 const guide = this.guides.find(g => 94 this.hashify(g.title) === hash 95 ); 96 if (guide) { 97 this.clickGuide(guide); 98 } 99 }); 100 } 101 }, 102 methods: { 103 refresh() { 104 this.requestGetGuides(); 105 }, 106 hashify(title) { 107 return title.replace(/[^a-zA-Z0-9\-]+/g, '').replace(/ /g, '-').toLowerCase(); 108 }, 109 clickGuide(guide) { 110 this.showGuide = guide; 111 // Update URL hash 112 window.location.hash = this.hashify(guide.title); 113 }, 114 clickNew() { 115 this.showEditGuide = {}; 116 }, 117 clickEdit(guide) { 118 this.showGuide = false;
119 this.showEditGuide = guide; 120 }, 121 clickSave(guide) { 122 if (guide.id) { 123 this.requestEditGuide(guide); 124 } else { 125 this.requestAddGuide(guide); 126 } 127 }, 128 clickDelete(guide) { 129 this.showConfirm(this.$t('confirm.delete_guide.title'), this.$t('confirm.delete_item.prompt', {item: guide.title}), this.$t('common.btn.delete'), this.$t('common.btn.cancel'), true, 130 () => { 131 this.showEditGuide = false; 132 this.requestDeleteGuide(guide.id); 133 }, 134 () => {}); 135 }, 136 requestGetGuides() { 137 this.loading = true; 138 139 this.postAPI('get-videos', { 140 name_short: this.org ? this.org.vanity : '', 141 org_id: 0 // Always fetch global support guides 142 }, 143 result => { 144 this.loading = false; 145 if (!result.success) { 146 this.addToast('get-guides-' + Math.floor(Math.random() * 1000000), result.reason, result.success, 15000); 147 console.error('Failed to get guides:', result.reason); 148 return; 149 } 150 this.guides = result.data; 151 // Retrieve level types for the form 152 if (this.constant && this.constant.levelTypes) { 153 this.levelTypes = this.constant.levelTypes[-1] || {}; 154 } 155 // Update page count 156 if ($('.page-amount').length) { 157 $('.page-amount').text(this.guides.length); 158 } 159 }); 160 }, 161 requestAddGuide(guideForm) { 162 this.loading = true; 163 164 // Create FormData for file upload 165 const formData = new FormData(); 166 formData.append('access_id', this.access.id); 167 formData.append('access_token', this.access.token); 168 formData.append('name_short', this.org ? this.org.vanity : ''); 169 formData.append('access', guideForm.access); 170 formData.append('title', guideForm.title); 171 formData.append('content', guideForm.content || ''); 172 formData.append('code', guideForm.code || ''); 173 if (guideForm.png_file) { 174 formData.append('png_img', guideForm.png_file); 175 } 176 if (guideForm.gif_file) { 177 formData.append('gif_img', guideForm.gif_file); 178 } 179 180 console.log('Params add-video',Object.fromEntries(formData.entries())) 181 fetch(this.access.api_url + 'add-video', { 182 method: 'POST', 183 body: formData 184 }) 185 .then(response => response.json()) 186 .then(result => { 187 console.log('add-video',result) 188 this.loading = false; 189 this.addToast('add-guide-' + Math.floor(Math.random() * 1000000), result.reason, result.success, 15000); 190 191 if (!result.success) { 192 console.error('Failed to add guide:', result.reason); 193 return; 194 } 195 196 this.showEditGuide = false; 197 this.refresh(); 198 }) 199 .catch(error => { 200 this.loading = false; 201 console.error('Add guide error:', error); 202 this.addToast('add-guide-error', 'Failed to add guide'+result.reason, false, 15000); 203 }); 204 }, 205 requestEditGuide(guideForm) { 206 this.loading = true; 207 208 // Create FormData for file upload 209 const formData = new FormData(); 210 formData.append('access_id', this.access.id); 211 formData.append('access_token', this.access.token); 212 formData.append('name_short', this.org ? this.org.vanity : ''); 213 formData.append('video_id', guideForm.id); 214 formData.append('access', guideForm.access); 215 formData.append('title', guideForm.title); 216 formData.append('content', guideForm.content || ''); 217 formData.append('code', guideForm.code || ''); 218 if (guideForm.png_file) { 219 formData.append('png_img', guideForm.png_file); 220 } 221 if (guideForm.gif_file) { 222 formData.append('gif_img', guideForm.gif_file); 223 } 224 225 console.log('Params edit-video',Object.fromEntries(formData.entries())) 226 fetch(this.access.api_url + 'edit-video', { 227 method: 'POST', 228 body: formData 229 }) 230 .then(response => response.json()) 231 .then(result => { 232 console.log('edit-video',result) 233 this.loading = false;
234 this.addToast('edit-guide-' + Math.floor(Math.random() * 1000000), result.reason, result.success, 15000); 235 236 if (!result.success) { 237 this.addToast('edit-guide-error', 'Failed to edit guide: '+result.reason, false, 15000); 238 return; 239 } 240 241 this.showEditGuide = false; 242 this.refresh(); 243 }) 244 .catch(error => { 245 this.loading = false; 246 this.addToast('edit-guide-error', 'Failed to edit guide: '+result.reason, false, 15000); 247 }); 248 }, 249 requestDeleteGuide(guide_id) { 250 this.loading = true; 251 252 this.postAPI('delete-video', { 253 name_short: this.org ? this.org.vanity : '', 254 video_id: guide_id 255 }, 256 result => { 257 this.loading = false; 258 this.addToast('delete-guide-'+Math.floor(Math.random() * 1000000), result.reason, result.success, 15000); 259 260 if (!result.success) { 261 this.addToast('delete-guide-error-'+Math.floor(Math.random() * 1000000), 'Failed to delete guide: '+result.reason, false, 15000); 262 return; 263 } 264 265 this.refresh(); 266 }); 267 } 268 } 269}); 270Vue.component('PopSupport', { 271 template: ` 272 <div> 273 <!-- Video Player Modal --> 274 <div v-if="showPlayer" class="chooser-fade" style="z-index:80;" @mousedown="closePlayer"> 275 <iframe 276 ref="videoPlayer" 277 class="player-frame" 278 type="text/html" 279 :width="playerWidth" 280 :height="playerHeight" 281 :src="'https://www.youtube.com/embed/'+guide.code+'?enablejsapi=1&autoplay=1&controls=1&modestbranding=1&rel=0'" 282 frameborder="0" 283 allow="autoplay; encrypted-media" 284 allowfullscreen 285 @mousedown.stop> 286 </iframe> 287 </div> 288 289 <!-- Video Detail Modal --> 290 <div class="chooser-fade" @mousedown="$emit('close')"> 291 <div class="box chooser chooser-auto" @mousedown.stop> 292 <div class="title"> 293 <div class="close" @click="$emit('close')">×</div> 294 <span v-if="isAdmin && editMode" @click="$emit('edit',guide)" class="edit hint--top-left hint--no-animate hint--rounded" data-hint="Edit"> 295 <img :src="config.CDN_HOST+'/images/icon-edit.png'" /> 296 </span> 297 <span class="vid-title">{{ guide ? guide.title : '' }}</span> 298 </div> 299 300 <img v-if="guide && guide.gif_url" class="vid-img" :src="guide.gif_url" @click="playVideo(guide.code)" :style="{cursor:guide.code ? 'pointer' : 'auto'}" /> 301 <img v-else-if="guide && guide.png_url" class="vid-img" :src="guide.png_url" @click="playVideo(guide.code)" :style="{cursor:guide.code ? 'pointer' : 'auto'}" /> 302 303 <div v-if="guide && guide.code" class="vid-how"> 304 {{ $t('support.faq.click_to_watch') }} 305 </div> 306 307 <div class="vid-text" v-html="formatContent(guide)"></div> 308 </div> 309 </div> 310 </div> 311 `, 312 mixins: [Utils, Core, I18n], 313 props: ['guide','editMode'], 314 data() { 315 return { 316 showPlayer: false, 317 playerWidth: 0, 318 palyerHeight: 0, 319 } 320 }, 321 methods: { 322 playVideo() { 323 if (this.guide && this.guide.code) { 324 this.resizeVideo(); 325 this.showPlayer = true; 326 } 327 }, 328 resizeVideo() { 329 this.playerWidth = window.innerWidth * 0.7; 330 if(this.playerWidth > 1280) 331 this.playerWidth = 1280; 332 if(this.playerWidth < 500) 333 this.playerWidth = window.innerWidth; 334 this.playerHeight = this.playerWidth * .5625// aspect ratio 335 }, 336 closePlayer() { 337 this.showPlayer = false; 338 }, 339 formatContent(guide) { 340 if (!guide || !guide.content) return ''; 341 342 let content = guide.content; 343 // Replace studio short name placeholder 344 if (this.org) { 345 content = content.replace(/\{\{studio_short\}\}/g, this.org.vanity); 346 } 347 // Replace files CDN placeholder 348 content = content.replace(/\{\{files\}\}/g, this.config.CDN_HOST); 349 // Add click handler to links 350 content = content.replace(/<a /g, '<a onclick="event.stopPropagation();" '); 351
352 // Convert newlines to step divs 353 const steps = content.split(/\r\n|\r|\n/); 354 return steps.map(step => '<div class="vid-step">' + step + '</div>').join(''); 355 } 356 } 357}) 358Vue.component('ModifyGuide', { 359 template: ` 360 <div class="chooser-fade" @click="$emit('close')"> 361 <PopConfirm v-if="confirm.show" :show="confirm.show" :title="confirm.title" :prompt="confirm.prompt" :cancel="confirm.cancel" :okay="confirm.okay" @close="confirm.show=false" @decline="confirm.action.negative" @confirm="confirm.action.positive" :negative="confirm.red" /> 362 363 <div class="box chooser" @click.stop style="max-width: 520px;"> 364 <div class="title"> 365 <span>{{ guide ? $t('support.guide.edit_title').toUpperCase() : $t('support.guide.add_title').toUpperCase() }}</span> 366 <span class="close" @click="$emit('close')">×</span> 367 </div> 368 369 <div class="label">{{ $t('support.guide.label_access') }}</div> 370 <select v-model="guideForm.access" required> 371 <option value="" disabled>{{ $t('support.guide.placeholder_access') }}</option> 372 <option v-for="(label, key) in levelTypes" :key="key" :value="key">{{ label }}</option> 373 </select> 374 375 <div class="label">{{ $t('support.guide.label_title') }}</div> 376 <input type="text" v-model="guideForm.title" :placeholder="$t('support.guide.placeholder_title')" required /> 377 378 <div class="label">{{ $t('support.guide.label_content') }}</div> 379 <textarea v-model="guideForm.content" :placeholder="$t('support.guide.placeholder_content')" rows="5"></textarea> 380 381 <div class="label">{{ $t('support.guide.label_cover') }}</div> 382 <input type="file" @change="handlePngFile" accept="image/png" /> 383 <img v-if="guideForm.png_url && !guideForm.png_file" :src="guideForm.png_url" style="max-width:100px;margin-top:5px;" /> 384 385 <div class="label">{{ $t('support.guide.label_gif') }}</div> 386 <input type="file" @change="handleGifFile" accept="image/gif" /> 387 <img v-if="guideForm.gif_url && !guideForm.gif_file" :src="guideForm.gif_url" style="max-width:100px;margin-top:5px;" /> 388 389 <div class="label">{{ $t('support.guide.label_youtube') }}</div> 390 <input type="text" v-model="guideForm.code" :placeholder="$t('support.guide.placeholder_youtube')" /> 391 392 <button class="btn-long btn-green" @click="clickSave" :disabled="!isValid">{{ guide.id ? $t('support.guide.btn_update').toUpperCase() : $t('support.guide.btn_create').toUpperCase() }}</button> 393 <button v-if="guide" class="btn-long btn-red" @click="clickDelete">{{ $t('common.btn.delete').toUpperCase() }}</button> 394 </div> 395 </div>`, 396 mixins: [Utils, Core, I18n], 397 props: ['guide', 'levelTypes'], 398 data() { 399 return { 400 guideForm: { 401 id: this.guide ? this.guide.id : null, 402 access: this.guide ? this.guide.access : '', 403 title: this.guide ? this.guide.title : '', 404 content: this.guide ? this.guide.content : '', 405 code: this.guide ? this.guide.code : '', 406 png_url: this.guide ? this.guide.png_url : '', 407 gif_url: this.guide ? this.guide.gif_url : '', 408 png_file: null, 409 gif_file: null 410 } 411 }; 412 }, 413 computed: { 414 isValid() { 415 return this.guideForm.access && this.guideForm.title; 416 } 417 }, 418 methods: { 419 handlePngFile(event) { 420 const file = event.target.files[0]; 421 if (file) { 422 this.guideForm.png_file = file; 423 } 424 }, 425 handleGifFile(event) { 426 const file = event.target.files[0]; 427 if (file) { 428 this.guideForm.gif_file = file; 429 } 430 }, 431 clickSave() { 432 if (this.isValid) { 433 this.$emit('save', this.guideForm); 434 } 435 }, 436 clickDelete() { 437 this.$emit('delete', this.guideForm); 438 } 439 } 440}); 441 442/** 443 * Standalone Support Guide Popup Component
444 * Can be embedded on any page to show a specific support guide by ID. 445 * 446 * Usage: 447 * 1. Add to page: <div id="support-guide-popup"><support-guide-popup></support-guide-popup></div> 448 * 2. Initialize: window.vmSupportGuide = new Vue({ el: '#support-guide-popup' }); 449 * 3. Trigger: showSupportGuide(guideId) or vmSupportGuide.$emit('show-support', guideId); 450 */ 451Vue.component('SupportGuidePopup', { 452 template: ` 453 <div> 454 <PopSupport v-if="show" :guide="guide" @close="close" /> 455 </div> 456 `, 457 mixins: [Utils, Core, I18n], 458 data() { 459 return { 460 show: false, 461 loading: false, 462 guide: null, 463 showPlayer: false, 464 error: null 465 } 466 }, 467 mounted() { 468 // Listen for global event to open guide 469 this.$root.$on('show-support', (guideId) => this.open(guideId)); 470 }, 471 beforeDestroy() { 472 this.$root.$off('show-support'); 473 }, 474 methods: { 475 open(guideId) { 476 this.show = true; 477 this.loading = true; 478 this.error = null; 479 this.guide = null; 480 this.fetchGuide(guideId); 481 }, 482 close() { 483 this.show = false; 484 this.guide = null; 485 this.showPlayer = false; 486 this.error = null; 487 }, 488 fetchGuide(guideId) { 489 this.postAPI('get-video', { org_id: 0, video_id: guideId }, result => { 490 this.loading = false; 491 if (!result.success) { 492 this.error = result.reason || 'Failed to load guide'; 493 return; 494 } 495 this.guide = result.data; 496 if (!this.guide) { 497 this.error = 'Guide not found'; 498 } 499 }); 500 } 501 } 502});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.