PageSourceSearch

https://autowhisper.xyz/assets/controllers/tiktok_share_controller-f4157e87.js

js autowhisper.xyz collected 2026-10-02 01:13:04 UTC 11,696 bytes, 303 lines download raw bytes

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.