PageSourceSearch

https://thehiddenjams.org/assets/controllers/create_playlist_modal_controller-834dcb54.js

js thehiddenjams.org collected 2026-10-04 05:29:12 UTC 11,044 bytes, 305 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import { sendAnalyticsEvent } from "services/analytics_service"
3import { resolveArtistPlaylistTab } from "services/artist_playlist_tab"
4
5// On open: if we have stored "playlist created" data, show success screen; otherwise show create flow.
6export default class extends Controller {
7  static values = { suffix: String, artistSlug: String, rankingListId: Number, intro15ListId: Number, editRankingListUrl: String, method: String, tab: String }
8  static targets = ["trigger", "triggerContent", "triggerLoader", "youtubeRow", "youtubeButton"]
9
10  connect() {
11    const params = new URLSearchParams(window.location.search)
12    if (params.get("open_create_playlist_modal") !== "1") return
13    const url = new URL(window.location.href)
14    url.searchParams.delete("open_create_playlist_modal")
15    const newSearch = url.searchParams.toString()
16    window.history.replaceState({}, "", url.pathname + (newSearch ? "?" + newSearch : ""))
17    requestAnimationFrame(() => {
18      this.showScreen("options")
19      const modal = this.application.getControllerForElementAndIdentifier(this.element, "modal")
20      if (modal) modal.open()
21    })
22  }
23
24  async openWithState(event) {
25    if (event) event.preventDefault()
26
27    this.showLoaderOnTrigger()
28
29    // Check for fresh playlist availability status before opening
30    await this.refreshPlaylistAvailability()
31
32    sendAnalyticsEvent("playlist_create_modal_opened", {
33      artist_slug: this.artistSlugValue,
34      method: this.hasMethodValue ? this.methodValue : "api",
35      has_youtube_videos: !!this.hasYoutubeVideos
36    })
37
38    const suffix = this.suffixValue || "global"
39    const stored = this.getStored(suffix)
40    if (!stored) {
41      this.openModalAndShowScreen("options")
42      this.hideLoaderOnTrigger()
43      return
44    }
45    let data
46    try {
47      data = JSON.parse(stored)
48    } catch {
49      this.clearStored(suffix)
50      this.openModalAndShowScreen("options")
51      this.hideLoaderOnTrigger()
52      return
53    }
54    if (!data.youtube_playlist_id) {
55      this.clearStored(suffix)
56      this.openModalAndShowScreen("options")
57      this.hideLoaderOnTrigger()
58      return
59    }
60    // Refetch current playlist status from backend (syncs from YouTube) so privacy/title are up to date
61    const fresh = await this.fetchPlaylistStatus(data.youtube_playlist_id)
62    if (fresh === null) {
63      this.clearStored(suffix)
64      this.openModalAndShowScreen("options")
65      this.hideLoaderOnTrigger()
66      return
67    }
68    if (fresh) {
69      data = { ...data, ...fresh }
70    }
71    this.openModalAndShowScreen("success")
72    this.fillSuccessScreen(data)
73    this.hideLoaderOnTrigger()
74  }
75
76  async refreshPlaylistAvailability() {
77    const artistSlug = this.artistSlugValue || this.getArtistSlugFromUrl()
78    if (!artistSlug) return
79
80    try {
81      // Fetch both playlist availability and YouTube auth status in parallel
82      const [availabilityResponse, authResponse] = await Promise.all([
83        fetch(`/artists/${artistSlug}/playlist-availability${this.availabilityQuery()}`, {
84          headers: { "Accept": "application/json" },
85          credentials: "same-origin"
86        }),
87        fetch("/youtube/status", {
88          headers: { "Accept": "application/json" },
89          credentials: "same-origin"
90        })
91      ])
92
93      if (availabilityResponse.ok && authResponse.ok) {
94        const availabilityData = await availabilityResponse.json()
95        const authData = await authResponse.json()
96        this.hasYoutubeVideos = availabilityData.has_youtube_videos
97        // Ad-hoc share URLs need no Google connection, so treat the row as always ready.
98        const isAdhoc = this.hasMethodValue && this.methodValue === "adhoc"
99        this.updateButtonsState(availabilityData.has_youtube_videos, isAdhoc ? true : authData.connected)
100      }
101    } catch (error) {
102      console.error("Failed to refresh playlist availability:", error)
103    }
104  }
105
106  getArtistSlugFromUrl() {
107    const match = window.location.pathname.match(/\/artists\/([^\/]+)/)
108    return match ? match[1] : null
109  }
110
111  availabilityQuery() {
112    if (this.hasIntro15ListIdValue) return `?intro15_list_id=${this.intro15ListIdValue}`
113    const tab = this.resolveTab()
114    if (tab) return `?tab=${encodeURIComponent(tab)}`
115    if (this.hasRankingListIdValue) return `?ranking_list_id=${this.rankingListIdValue}`
116    return ""
117  }
118
119  resolveTab() {
120    return resolveArtistPlaylistTab(this.hasTabValue ? this.tabValue : null)
121  }
122
123  updateButtonsState(hasYoutubeVideos, isYoutubeConnected) {
124    // Update YouTube row styling
125    this.youtubeRowTargets.forEach(row => {
126      if (hasYoutubeVideos) {
127        row.classList.remove("opacity-50", "pl-row--platform-disabled")
128        row.classList.add("pl-row--platform-active")
129      } else {
130        row.classList.add("opacity-50", "pl-row--platform-disabled")
131        row.classList.remove("pl-row--platform-active")
132      }
133    })
134
135    // Update buttons in each container
136    this.youtubeButtonTargets.forEach(buttonContainer => {
137      const createButton = buttonContainer.querySelector("[data-create-playlist-button]")
138      const connectLink = buttonContainer.querySelector("[data-create-playlist-connect]")
139      const disabledButton = buttonContainer.querySelector("[data-create-playlist-disabled]")
140
141      if (hasYoutubeVideos) {
142        // YouTube videos available - show appropriate button based on auth status
143        if (disabledButton) disabledButton.style.display = "none"
144
145        if (isYoutubeConnected) {
146          // User is authenticated - show create button, hide connect link
147          if (createButton) createButton.style.display = ""
148          if (connectLink) connectLink.style.display = "none"
149        } else {
150          // User is not authenticated - show connect link, hide create button
151          if (createButton) createButton.style.display = "none"
152          if (connectLink) connectLink.style.display = ""
153        }
154      } else {
155        // No YouTube videos - hide action buttons, show disabled button
156        if (createButton) createButton.style.display = "none"
157        if (connectLink) connectLink.style.display = "none"
158        if (disabledButton) disabledButton.style.display = ""
159      }
160    })
161  }
162
163  openModalAndShowScreen(screenName) {
164    this.showScreen(screenName)
165    requestAnimationFrame(() => {
166      const modal = this.application.getControllerForElementAndIdentifier(this.element, "modal")
167      if (modal) modal.open()
168    })
169  }
170
171  async fetchPlaylistStatus(playlistId) {
172    try {
173      const res = await fetch(`/youtube-playlists/${playlistId}/status`, {
174        headers: { "Accept": "application/json" },
175        credentials: "same-origin"
176      })
177      if (res.ok) {
178        return await res.json()
179      }
180      if (res.status === 404) {
181        return null
182      }
183    } catch (_) {}
184    return undefined
185  }
186
187  clearAndShowOptions(suffix) {
188    this.clearStored(suffix)
189    this.showScreen("options")
190  }
191
192  startNewPlaylist(event) {
193    if (event) event.preventDefault()
194    const url = this.editRankingListUrlValue
195    if (url) window.location.href = url
196  }
197
198  showLoaderOnTrigger() {
199    if (!this.hasTriggerTarget) return
200    this.triggerTarget.setAttribute("aria-busy", "true")
201    this.triggerTarget.style.pointerEvents = "none"
202    if (this.hasTriggerContentTarget) this.triggerContentTarget.style.display = "none"
203    if (this.hasTriggerLoaderTarget) this.triggerLoaderTarget.style.display = "inline-flex"
204  }
205
206  hideLoaderOnTrigger() {
207    if (!this.hasTriggerTarget) return
208    this.triggerTarget.removeAttribute("aria-busy")
209    this.triggerTarget.style.pointerEvents = ""
210    if (this.hasTriggerContentTarget) this.triggerContentTarget.style.display = ""
211    if (this.hasTriggerLoaderTarget) this.triggerLoaderTarget.style.display = "none"
212  }
213
214  showCreatingScreen() {
215    this.showScreen("creating")
216  }
217
218  showOptionsScreen() {
219    this.showScreen("options")
220  }
221
222  showNoChannelScreen(googleEmail) {
223    this.showScreen("no-channel")
224  }
225
226  showInsufficientPermissionsScreen() {
227    this.showScreen("insufficient-permissions")
228  }
229
230  showSuccessScreen(data) {
231    this.fillSuccessScreen(data)
232    this.showScreen("success")
233  }
234
235  openModalWithSuccess(data) {
236    this.fillSuccessScreen(data)
237    this.showScreen("success")
238    const modal = this.application.getControllerForElementAndIdentifier(this.element, "modal")
239    if (modal) modal.open()
240  }
241
242  showScreen(name) {
243    const modalContent = this.element.querySelector(".modal-content")
244    if (!modalContent) return
245    modalContent.querySelectorAll("[data-create-playlist-screen]").forEach((el) => {
246      el.style.display = el.dataset.createPlaylistScreen === name ? "block" : "none"
247    })
248  }
249
250  fillSuccessScreen(data) {
251    const modalContent = this.element.querySelector(".modal-content")
252    const successScreen = modalContent?.querySelector('[data-create-playlist-screen="success"]')
253    if (!successScreen) return
254
255    const titleEl = successScreen.querySelector(".pl-title")
256    const visibilityEl = successScreen.querySelector(".pl-visibility")
257    const openBtn = successScreen.querySelector("a.pl-btn-open")
258    const thumbnailImg = successScreen.querySelector(".pl-thumb img")
259
260    if (titleEl && data.playlist_title) titleEl.textContent = data.playlist_title + " · The Hidden Jam
260s"
261    if (visibilityEl && data.visibility) {
262      const label = data.visibility.charAt(0).toUpperCase() + data.visibility.slice(1) + " Playlist"
263      visibilityEl.textContent = label
264    }
265    if (openBtn && data.playlist_url) {
266      openBtn.href = data.playlist_url
267      openBtn.style.display = ""
268      openBtn.textContent = data.provider_icon === "youtube_music" ? "Open in Youtube Music" : "Open in Youtube"
269      openBtn.onclick = () => sendAnalyticsEvent("youtube_playlist_link_clicked", {
270        artist_slug: this.artistSlugValue,
271        provider: data.provider_icon || "youtube",
272        method: this.hasMethodValue ? this.methodValue : "api"
273      })
274    }
275    if (thumbnailImg && data.thumbnail_url) {
276      thumbnailImg.src = data.thumbnail_url
277      thumbnailImg.onerror = function onError() {
278        thumbnailImg.onerror = null
279        if (data.thumbnail_fallback_url) thumbnailImg.src = data.thumbnail_fallback_url
280      }
281    }
282    const provider = data.provider_icon || "youtube"
283    successScreen.querySelectorAll("[data-provider]").forEach((el) => {
284      el.classList.toggle("hidden", el.dataset.provider !== provider)
285    })
286
287    // Reminder to Save applies only to the ad-hoc YouTube Music share (temporary list).
288    const saveNote = successScreen.querySelector("[data-yt-music-save-note]")
289    if (saveNote) saveNote.classList.toggle("hidden", !(data.adhoc && provider === "youtube_music"))
290  }
291
292  getStored(suffix) {
293    try {
294      return sessionStorage.getItem(`createPlaylistSuccess_${suffix}`)
295    } catch {
296      return null
297    }
298  }
299
300  clearStored(suffix) {
301    try {
302      sessionStorage.removeItem(`createPlaylistSuccess_${suffix}`)
303    } catch {}
304  }
305}

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.