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.