1import { Controller } from "@hotwired/stimulus" 2 3// A simplified share flow for TikTok that mirrors Douyin manual-share logic 4// 1. Copies post content to clipboard 5// 2. Opens TikTok upload page (desktop) or TikTok app scheme (mobile) 6// 3. Marks the post as published via PATCH /dashboard/posts/:id/mark_as_published 7export default class extends Controller { 8 static targets = ["button", "icon", "text"] 9 static values = { postId: Number, publishingText: String, i18n: Object, publishSuccessText: String, publishNowText: String, hashtags: String, title: String, content: String, shareUrl: String } 10 11 // ä¸»å ¥å£ï¼ç¹å»"ç«å³åå¸" 12 shareToTiktok(event) { 13 event.preventDefault() 14 const button = this.buttonTarget 15 const icon = this.iconTarget 16 const text = this.textTarget 17 18 this.setLoadingState(button, text, icon) 19 20 try { 21 // Read title from data attributes (video platform â clipboard copies title only) 22 const content = (this.titleValue || '').trim() 23 24 if (!content) { 25 throw new Error(this.getI18nMessage('content_not_found')) 26 } 27 28 // æ¾ç¤º TikTok åäº«æ¨¡ææ¡ 29 this.showTiktokModal(content, button, text, icon) 30 31 } catch (e) { 32 console.error(e) 33 this.resetButtonState(button, text, icon, false) 34 this.showError(e.message || this.getI18nMessage('share_failed')) 35 } 36 } 37 38 // çææ¨èæ ç¾ 39 generateTags() { 40 const tags = [] 41 if (this.hashtagsValue && this.hashtagsValue.trim()) { 42 const existingTags = this.hashtagsValue.split(' ').filter(tag => tag.trim()) 43 tags.push(...existingTags) 44 } 45 return [...new Set(tags)].slice(0, 15) 46 } 47 48 // å建并æ¾ç¤ºæ¨¡ææ¡ï¼åèæé³é£æ ¼ï¼ 49 showTiktokModal(content, button, text, icon) { 50 const i18n = this.i18n() 51 const recommendedTags = this.generateTags() 52 const modal = document.createElement('div') 53 modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50' 54 modal.innerHTML = ` 55 <div class="bg-white rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"> 56 <div class="flex items-center justify-between mb-4"> 57 <h3 class="text-lg font-semibold text-ink-900">${i18n.title}</h3> 58 <button id="closeModalBtn" class="text-ink-400 hover:text-ink-500"> 59 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg> 60 </button> 61 </div> 62 63 <div class="mb-4"> 64 <label class="block text-sm font-medium text-ink-700 mb-2">${i18n.content_label}ï¼</label> 65 <div class="bg-surface-50 rounded-xl p-4 max-h-48 overflow-y-auto"> 66 <pre class="whitespace-pre-wrap text-sm text-ink-900" id="tiktokPreviewContent"></pre> 67 </div> 68 </div> 69 70 ${recommendedTags.length > 0 ? ` 71 <div class="mb-4"> 72 <label class="block text-sm font-medium text-ink-700 mb-2">${i18n.recommended_tags}ï¼</label> 73 <div class="bg-surface-50 rounded-xl p-4"> 74 <div class="flex flex-wrap gap-2"> 75 ${recommendedTags.map(tag => ` 76 <span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-black bg-opacity-10 text-ink-900"> 77 ${tag} 78 </span> 79 `).join('')} 80 </div> 81 </div> 82 </div> 83 ` : ''} 84 85 <div class="mb-6"> 86 <button id="tiktokShareBtn" class="bg-black hover:bg-ink-900 text-white px-6 py-3 rounded-xl w-full font-medium">${i18n.share_button}</button> 87 </div> 88 <div class="bg-surface-50 border border-surface-200 rounded-xl p-4"> 89 <h4 class="font-medium text-ink-900 mb-2">ð ${i18n.steps}ï¼</h4> 90 <ol class="text-sm text-ink-700 space-y-1"> 91 <li>1. ${i18n.step1}</li> 92 <li>2. ${i18n.step2}</li> 93 <li>3. ${i18n.step3}</li> 94 <li>4. ${i18n.step4}</li> 95 <li>5. ${i18n.step5}</li> 96 </ol> 97 </div> 98 </div>` 99 document.body.appendChild(modal) 100 101 // Set preview content safely via textContent 102 modal.querySelector('#tiktokPreviewContent').textContent = content 103 104 // å ³éæé®å¤ç 105 const closeBtn = modal.querySelector('#closeModalBtn') 106 const closeModal = () => { 107 modal.remove() 108 this.resetButtonState(button, text, icon, false) 109 } 110 closeBtn.addEventListener('click', closeModal) 111 112 const shareBtn = modal.querySelector('#tiktokShareBtn') 113 shareBtn.addEventListener('click', () => { 114 shareBtn.disabled = true;
114 shareBtn.classList.add('opacity-75', 'cursor-wait') 115 116 // Open TikTok SYNCHRONOUSLY to avoid popup blocker 117 this.openTiktok() 118 119 // æå»ºå¤å¶å å®¹ï¼æ é¢ + æ¨èæ ç¾ 120 let contentToCopy = content 121 if (recommendedTags.length > 0) { 122 contentToCopy = content + '\n\n' + recommendedTags.join(' ') 123 } 124 125 // Copy to clipboard (async is fine, window already opened) 126 if (navigator.clipboard && window.isSecureContext) { 127 navigator.clipboard.writeText(contentToCopy).then(() => { 128 this.markAsPublished(i18n.share_success) 129 modal.remove() 130 }).catch(error => { 131 console.warn('Failed to copy to clipboard:', error) 132 this.markAsPublished(i18n.share_success) 133 modal.remove() 134 }) 135 } else { 136 this.markAsPublished(i18n.share_success) 137 modal.remove() 138 } 139 }) 140 141 modal.addEventListener('click', e => { 142 if (e.target === modal) { 143 modal.remove() 144 this.resetButtonState(button, text, icon, false) 145 } 146 }) 147 } 148 149 150 151 setLoadingState(button, text, icon) { 152 button.disabled = true 153 text.textContent = this.publishingTextValue 154 icon.innerHTML = ` 155 <svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> 156 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> 157 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> 158 </svg> 159 ` 160 } 161 162 resetButtonState(button, text, icon, success = true) { 163 button.disabled = false 164 text.textContent = success ? this.publishSuccessTextValue : this.publishNowTextValue 165 icon.innerHTML = success ? 'â' : ` 166 <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 167 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path> 168 </svg> 169 ` 170 } 171 172 openTiktok() { 173 const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) 174 if (isMobile) { 175 window.open('tiktok://', '_self') // TikTok scheme; may fail silently 176 setTimeout(() => { window.open('https://www.tiktok.com/upload', '_blank') }, 1500) 177 } else { 178 window.open('https://www.tiktok.com/upload', '_blank') 179 } 180 } 181 182 async markAsPublished(message) { 183 try { 184 const resp = await fetch(`/dashboard/posts/${this.postIdValue}/mark_as_published`, { 185 method: 'PATCH', 186 headers: { 187 'Content-Type': 'application/json', 188 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content 189 } 190 }) 191 if (resp.ok) { 192 console.log('marked published'); 193 this.updatePostStatusDisplay(); 194 this.resetButtonState(this.buttonTarget, this.textTarget, this.iconTarget, true); 195 } 196 } catch (e) { 197 console.error(e) 198 } 199 } 200 201 // æ´æ°é¡µé¢å¸åç¶ææ¾ç¤ºï¼ä¸æé³é»è¾ä¸è´ 202 updatePostStatusDisplay() { 203 try { 204 const postContainer = this.element.closest('[id^="post_"]') || this.element.closest('.bg-white') 205 if (!postContainer) return 206 207 const i18nTexts = this.i18n() 208 209 const statusElements = postContainer.querySelectorAll('span') 210 statusElements.forEach(el => { 211 const txt = el.textContent.trim() 212 if (el.hasAttribute('data-manual-share-status') || txt.includes('å¾ æå¨å享') || txt.includes('pending_manual_share')) { 213 el.className = 'text-success-600 font-medium' 214 el.textContent = i18nTexts.published 215 } 216 }) 217 218 const button = this.buttonTarget 219 const icon = this.iconTarget 220 const text = this.textTarget 221 if (button && icon && text) { 222 button.disabled = true 223 button.classList.add('bg-ink-900', 'opacity-75') 224 icon.innerHTML = `<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>` 225 text.textContent = i18nTexts.published 226 } 227 228 // å¨åå¸é¡µé¢å叿ååèªå¨ç§»é¤è稿 229 const path = window.location.pathname 230 if (path.includes('/publish') && !path.includes('/publish/sent')) { 231 setTimeout(() => { 232 postContainer.style.transition = 'opacity 0.5s ease-out, transform 0.5s ease-out' 233 postContainer.style.opacity = '0'; postContainer.style.transform = 'translateY(-20px)' 234 setTimeout(() => { postContainer.parentNode?.removeChild(postContainer) }, 500) 235 }, 2000) 236 } 237 } catch (e) { console.error('Failed to update status:', e) } 238 } 239 240// å½é åææ¬ï¼æ¥èªæå¡ç«¯ i18n valueï¼ 241i18n() { 242 return this.i18nValue || {} 243} 244 245 // æ¾ç¤ºæåæ¶æ¯ 246 showSuccess(message) { 247 const toast = this.createToast(message, 'success') 248 document.body.appendChild(toast) 249 250 setTimeout(() => { 251 if (document.body.contains(toast)) { 252 document.body.removeChild(toast) 253 } 254 }, 3000) 255 } 256 257 // æ¾ç¤ºéè¯¯æ¶æ¯ 258 showError(message) { 259 const toast = this.createToast(message, 'error') 260 document.body.appendChild(toast) 261 262 setTimeout(() => { 263 if (document.body.contains(toast)) { 264 document.body.removeChild(toast) 265 } 266 }, 5000) 267 } 268 269 // å建æç¤ºæ¡ 270 createToast(message, type) { 271 const toast = document.createElement('div') 272 toast.className = 'fixed top-4 right-4 max-w-sm w-full z-50 transform transition-all duration-300 ease-in-out' 273 const bgColor = type === 'success' ? 'bg-black' : 'bg-danger-500' 274 const inner = document.createElement('div') 275 inner.className = `${bgColor} text-white px-6 py-4 rounded-xl shadow-lifted` 276 const flex = document.createElement('div') 277 flex.className = 'flex items-center' 278 const span = document.createElement('span') 279 span.className = 'flex-1' 280 span.textContent = message 281 const closeBtn = document.createElement('button') 282 closeBtn.className = 'ml-4 text-white hover:text-surface-200' 283 closeBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
283<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>' 284 closeBtn.addEventListener('click', () => toast.remove()) 285 flex.appendChild(span) 286 flex.appendChild(closeBtn) 287 inner.appendChild(flex) 288 toast.appendChild(inner) 289 return toast 290 } 291 292// å½é åæ¶æ¯ï¼æ¥èªæå¡ç«¯ i18n valueï¼ç¼ºçåéè±æï¼ 293getI18nMessage(key) { 294 const fallback = { 295 network_error: 'Network error. Please try again', 296 share_failed: 'Share failed. Please try again', 297 mark_failed: 'Failed to update status. Please try again', 298 content_not_found: 'Unable to get content', 299 marked_as_published: 'Post marked as published' 300 } 301 return (this.i18nValue && this.i18nValue[key]) || fallback[key] || key 302} 303}
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.