PlaylistsPage.tsx 48 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175
  1. import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
  2. import { useOutletContext } from 'react-router-dom'
  3. import { toast } from 'sonner'
  4. import { Trash2 } from 'lucide-react'
  5. import { apiClient } from '@/lib/apiClient'
  6. import {
  7. initPreviewCacheDB,
  8. getPreviewsFromCache,
  9. savePreviewToCache,
  10. } from '@/lib/previewCache'
  11. import { fuzzyMatch } from '@/lib/utils'
  12. import { useOnBackendConnected } from '@/hooks/useBackendConnection'
  13. import type { PatternMetadata, PreviewData, SortOption, PreExecution, RunMode } from '@/lib/types'
  14. import { preExecutionOptions } from '@/lib/types'
  15. import { Button } from '@/components/ui/button'
  16. import { Input } from '@/components/ui/input'
  17. import { Label } from '@/components/ui/label'
  18. import { Separator } from '@/components/ui/separator'
  19. import {
  20. Select,
  21. SelectContent,
  22. SelectItem,
  23. SelectTrigger,
  24. SelectValue,
  25. } from '@/components/ui/select'
  26. import {
  27. Dialog,
  28. DialogContent,
  29. DialogHeader,
  30. DialogTitle,
  31. DialogFooter,
  32. } from '@/components/ui/dialog'
  33. export function PlaylistsPage() {
  34. const { isPlayOnlyActive } = useOutletContext<{ isPlayOnlyActive?: boolean }>() || {}
  35. // Playlists state
  36. const [playlists, setPlaylists] = useState<string[]>([])
  37. const [selectedPlaylist, setSelectedPlaylist] = useState<string | null>(() => {
  38. return localStorage.getItem('playlist-selected')
  39. })
  40. const [playlistPatterns, setPlaylistPatterns] = useState<string[]>([])
  41. const [isLoadingPlaylists, setIsLoadingPlaylists] = useState(true)
  42. // All patterns for the picker modal
  43. const [allPatterns, setAllPatterns] = useState<PatternMetadata[]>([])
  44. const [allPatternHistories, setAllPatternHistories] = useState<Record<string, {
  45. play_count: number
  46. last_played: string | null
  47. }>>({})
  48. const [previews, setPreviews] = useState<Record<string, PreviewData>>({})
  49. // Pattern picker modal state
  50. const [isPickerOpen, setIsPickerOpen] = useState(false)
  51. const [selectedPatternPaths, setSelectedPatternPaths] = useState<Set<string>>(new Set())
  52. const [searchQuery, setSearchQuery] = useState('')
  53. const [selectedCategory, setSelectedCategory] = useState<string>('all')
  54. const [sortBy, setSortBy] = useState<SortOption>('name')
  55. const [sortAsc, setSortAsc] = useState(true)
  56. // Favorites state (loaded from "Favorites" playlist)
  57. const [favorites, setFavorites] = useState<Set<string>>(new Set())
  58. // Create/Rename playlist modal
  59. const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
  60. const [isRenameModalOpen, setIsRenameModalOpen] = useState(false)
  61. const [newPlaylistName, setNewPlaylistName] = useState('')
  62. const [playlistToRename, setPlaylistToRename] = useState<string | null>(null)
  63. // Mobile view state - show content panel when a playlist is selected
  64. const [mobileShowContent, setMobileShowContent] = useState(false)
  65. // Swipe gesture to go back on mobile
  66. const swipeTouchStartRef = useRef<{ x: number; y: number } | null>(null)
  67. const handleSwipeTouchStart = (e: React.TouchEvent) => {
  68. swipeTouchStartRef.current = {
  69. x: e.touches[0].clientX,
  70. y: e.touches[0].clientY,
  71. }
  72. }
  73. const handleSwipeTouchEnd = (e: React.TouchEvent) => {
  74. if (!swipeTouchStartRef.current || !mobileShowContent) return
  75. const deltaX = e.changedTouches[0].clientX - swipeTouchStartRef.current.x
  76. const deltaY = e.changedTouches[0].clientY - swipeTouchStartRef.current.y
  77. // Swipe right to go back (positive X, more horizontal than vertical)
  78. if (deltaX > 80 && deltaX > Math.abs(deltaY)) {
  79. setMobileShowContent(false)
  80. }
  81. swipeTouchStartRef.current = null
  82. }
  83. // Playback settings - initialized from localStorage
  84. const [runMode, setRunMode] = useState<RunMode>(() => {
  85. const cached = localStorage.getItem('playlist-runMode')
  86. return (cached === 'single' || cached === 'indefinite') ? cached : 'single'
  87. })
  88. const [shuffle, setShuffle] = useState(() => {
  89. return localStorage.getItem('playlist-shuffle') === 'true'
  90. })
  91. const [pauseTime, setPauseTime] = useState(() => {
  92. const cached = localStorage.getItem('playlist-pauseTime')
  93. return cached ? Number(cached) : 5
  94. })
  95. const [pauseUnit, setPauseUnit] = useState<'sec' | 'min' | 'hr'>(() => {
  96. const cached = localStorage.getItem('playlist-pauseUnit')
  97. return (cached === 'sec' || cached === 'min' || cached === 'hr') ? cached : 'min'
  98. })
  99. const [clearPattern, setClearPattern] = useState<PreExecution>(() => {
  100. const cached = localStorage.getItem('preExecution')
  101. return (cached as PreExecution) || 'adaptive'
  102. })
  103. // Persist playback settings to localStorage
  104. useEffect(() => {
  105. localStorage.setItem('playlist-runMode', runMode)
  106. }, [runMode])
  107. useEffect(() => {
  108. localStorage.setItem('playlist-shuffle', String(shuffle))
  109. }, [shuffle])
  110. useEffect(() => {
  111. localStorage.setItem('playlist-pauseTime', String(pauseTime))
  112. }, [pauseTime])
  113. useEffect(() => {
  114. localStorage.setItem('playlist-pauseUnit', pauseUnit)
  115. }, [pauseUnit])
  116. useEffect(() => {
  117. localStorage.setItem('preExecution', clearPattern)
  118. }, [clearPattern])
  119. // Persist selected playlist to localStorage
  120. useEffect(() => {
  121. if (selectedPlaylist) {
  122. localStorage.setItem('playlist-selected', selectedPlaylist)
  123. } else {
  124. localStorage.removeItem('playlist-selected')
  125. }
  126. }, [selectedPlaylist])
  127. // Validate cached playlist exists and load its patterns after playlists load
  128. const initialLoadDoneRef = useRef(false)
  129. useEffect(() => {
  130. if (isLoadingPlaylists) return
  131. if (selectedPlaylist) {
  132. if (playlists.includes(selectedPlaylist)) {
  133. // Load patterns for cached playlist on initial load only
  134. if (!initialLoadDoneRef.current) {
  135. initialLoadDoneRef.current = true
  136. fetchPlaylistPatterns(selectedPlaylist)
  137. }
  138. } else {
  139. // Cached playlist no longer exists
  140. setSelectedPlaylist(null)
  141. }
  142. }
  143. }, [isLoadingPlaylists, playlists, selectedPlaylist])
  144. // Close modals when playback starts
  145. useEffect(() => {
  146. const handlePlaybackStarted = () => {
  147. setIsPickerOpen(false)
  148. setIsCreateModalOpen(false)
  149. setIsRenameModalOpen(false)
  150. }
  151. window.addEventListener('playback-started', handlePlaybackStarted)
  152. return () => window.removeEventListener('playback-started', handlePlaybackStarted)
  153. }, [])
  154. const [isRunning, setIsRunning] = useState(false)
  155. // Convert pause time to seconds based on unit
  156. const getPauseTimeInSeconds = () => {
  157. switch (pauseUnit) {
  158. case 'hr':
  159. return pauseTime * 3600
  160. case 'min':
  161. return pauseTime * 60
  162. default:
  163. return pauseTime
  164. }
  165. }
  166. // Preview loading
  167. const pendingPreviewsRef = useRef<Set<string>>(new Set())
  168. const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  169. const abortControllerRef = useRef<AbortController | null>(null)
  170. // Initialize and fetch data
  171. useEffect(() => {
  172. initPreviewCacheDB().catch(() => {})
  173. fetchPlaylists()
  174. fetchAllPatterns()
  175. loadFavorites()
  176. // Cleanup on unmount: abort in-flight requests and clear pending queue
  177. return () => {
  178. if (batchTimeoutRef.current) {
  179. clearTimeout(batchTimeoutRef.current)
  180. }
  181. if (abortControllerRef.current) {
  182. abortControllerRef.current.abort()
  183. }
  184. pendingPreviewsRef.current.clear()
  185. }
  186. }, [])
  187. // Refetch when backend reconnects
  188. useOnBackendConnected(() => {
  189. fetchPlaylists()
  190. fetchAllPatterns()
  191. loadFavorites()
  192. })
  193. const fetchPlaylists = async () => {
  194. setIsLoadingPlaylists(true)
  195. try {
  196. const data = await apiClient.get<string[]>('/list_all_playlists')
  197. // Backend returns array directly, not { playlists: [...] }
  198. setPlaylists(Array.isArray(data) ? data : [])
  199. } catch (error) {
  200. console.error('Error fetching playlists:', error)
  201. toast.error('Failed to load playlists')
  202. } finally {
  203. setIsLoadingPlaylists(false)
  204. }
  205. }
  206. const fetchPlaylistPatterns = async (name: string) => {
  207. try {
  208. const data = await apiClient.get<{ files: string[] }>(`/get_playlist?name=${encodeURIComponent(name)}`)
  209. setPlaylistPatterns(data.files || [])
  210. // Previews are now lazy-loaded via IntersectionObserver in LazyPatternPreview
  211. } catch (error) {
  212. console.error('Error fetching playlist:', error)
  213. toast.error('Failed to load playlist')
  214. setPlaylistPatterns([])
  215. }
  216. }
  217. const fetchAllPatterns = async () => {
  218. try {
  219. const [data, historyData] = await Promise.all([
  220. apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata'),
  221. apiClient.get<Record<string, { play_count: number; last_played: string | null }>>('/api/pattern_history_all')
  222. ])
  223. setAllPatterns(data)
  224. setAllPatternHistories(historyData)
  225. } catch (error) {
  226. console.error('Error fetching patterns:', error)
  227. }
  228. }
  229. // Load favorites from "Favorites" playlist
  230. const loadFavorites = async () => {
  231. try {
  232. const playlist = await apiClient.get<{ files?: string[] }>('/get_playlist?name=Favorites')
  233. setFavorites(new Set(playlist.files || []))
  234. } catch {
  235. // Favorites playlist doesn't exist yet - that's OK
  236. }
  237. }
  238. // Preview loading functions (similar to BrowsePage)
  239. const loadPreviewsForPaths = async (paths: string[]) => {
  240. const cachedPreviews = await getPreviewsFromCache(paths)
  241. if (cachedPreviews.size > 0) {
  242. const cachedData: Record<string, PreviewData> = {}
  243. cachedPreviews.forEach((previewData, path) => {
  244. cachedData[path] = previewData
  245. })
  246. setPreviews(prev => ({ ...prev, ...cachedData }))
  247. }
  248. const uncached = paths.filter(p => !cachedPreviews.has(p))
  249. if (uncached.length > 0) {
  250. fetchPreviewsBatch(uncached)
  251. }
  252. }
  253. const fetchPreviewsBatch = async (paths: string[]) => {
  254. const BATCH_SIZE = 10 // Process 10 patterns at a time to avoid overwhelming the backend
  255. // Create new AbortController for this batch of requests
  256. abortControllerRef.current = new AbortController()
  257. const signal = abortControllerRef.current.signal
  258. // Process in batches
  259. for (let i = 0; i < paths.length; i += BATCH_SIZE) {
  260. // Check if aborted before each batch
  261. if (signal.aborted) break
  262. const batch = paths.slice(i, i + BATCH_SIZE)
  263. try {
  264. const data = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batch }, signal)
  265. const newPreviews: Record<string, PreviewData> = {}
  266. for (const [path, previewData] of Object.entries(data)) {
  267. newPreviews[path] = previewData as PreviewData
  268. // Only cache valid previews (with image_data and no error)
  269. if (previewData && !(previewData as PreviewData).error) {
  270. savePreviewToCache(path, previewData as PreviewData)
  271. }
  272. }
  273. setPreviews(prev => ({ ...prev, ...newPreviews }))
  274. } catch (error) {
  275. // Stop processing if aborted, otherwise continue with next batch
  276. if (error instanceof Error && error.name === 'AbortError') break
  277. console.error('Error fetching previews batch:', error)
  278. }
  279. // Small delay between batches to reduce backend load
  280. if (i + BATCH_SIZE < paths.length) {
  281. await new Promise((resolve) => setTimeout(resolve, 100))
  282. }
  283. }
  284. }
  285. const requestPreview = useCallback((path: string) => {
  286. if (previews[path] || pendingPreviewsRef.current.has(path)) return
  287. pendingPreviewsRef.current.add(path)
  288. if (batchTimeoutRef.current) {
  289. clearTimeout(batchTimeoutRef.current)
  290. }
  291. batchTimeoutRef.current = setTimeout(() => {
  292. const pathsToFetch = Array.from(pendingPreviewsRef.current)
  293. pendingPreviewsRef.current.clear()
  294. if (pathsToFetch.length > 0) {
  295. loadPreviewsForPaths(pathsToFetch)
  296. }
  297. }, 100)
  298. }, [previews])
  299. // Playlist CRUD operations
  300. const handleSelectPlaylist = (name: string) => {
  301. setSelectedPlaylist(name)
  302. fetchPlaylistPatterns(name)
  303. setMobileShowContent(true) // Show content panel on mobile
  304. }
  305. // Go back to playlist list on mobile
  306. const handleMobileBack = () => {
  307. setMobileShowContent(false)
  308. }
  309. const handleCreatePlaylist = async () => {
  310. if (!newPlaylistName.trim()) {
  311. toast.error('Please enter a playlist name')
  312. return
  313. }
  314. const name = newPlaylistName.trim()
  315. try {
  316. await apiClient.post('/create_playlist', { playlist_name: name, files: [] })
  317. toast.success('Playlist created')
  318. setIsCreateModalOpen(false)
  319. setNewPlaylistName('')
  320. await fetchPlaylists()
  321. handleSelectPlaylist(name)
  322. } catch (error) {
  323. console.error('Create playlist error:', error)
  324. toast.error(error instanceof Error ? error.message : 'Failed to create playlist')
  325. }
  326. }
  327. const handleRenamePlaylist = async () => {
  328. if (!playlistToRename || !newPlaylistName.trim()) return
  329. try {
  330. await apiClient.post('/rename_playlist', { old_name: playlistToRename, new_name: newPlaylistName.trim() })
  331. toast.success('Playlist renamed')
  332. setIsRenameModalOpen(false)
  333. setNewPlaylistName('')
  334. setPlaylistToRename(null)
  335. fetchPlaylists()
  336. if (selectedPlaylist === playlistToRename) {
  337. setSelectedPlaylist(newPlaylistName.trim())
  338. }
  339. } catch (error) {
  340. toast.error('Failed to rename playlist')
  341. }
  342. }
  343. const handleDeletePlaylist = async (name: string) => {
  344. if (!confirm(`Delete playlist "${name}"?`)) return
  345. try {
  346. await apiClient.delete('/delete_playlist', { playlist_name: name })
  347. toast.success('Playlist deleted')
  348. fetchPlaylists()
  349. if (selectedPlaylist === name) {
  350. setSelectedPlaylist(null)
  351. setPlaylistPatterns([])
  352. }
  353. } catch (error) {
  354. toast.error('Failed to delete playlist')
  355. }
  356. }
  357. const handleRemovePattern = async (patternPath: string) => {
  358. if (!selectedPlaylist) return
  359. const newPatterns = playlistPatterns.filter(p => p !== patternPath)
  360. try {
  361. await apiClient.post('/modify_playlist', { playlist_name: selectedPlaylist, files: newPatterns })
  362. setPlaylistPatterns(newPatterns)
  363. toast.success('Pattern removed')
  364. } catch (error) {
  365. toast.error('Failed to remove pattern')
  366. }
  367. }
  368. // Pattern picker modal
  369. const openPatternPicker = () => {
  370. setSelectedPatternPaths(new Set(playlistPatterns))
  371. setSearchQuery('')
  372. setIsPickerOpen(true)
  373. // Previews are lazy-loaded via IntersectionObserver in LazyPatternPreview
  374. }
  375. const handleSavePatterns = async () => {
  376. if (!selectedPlaylist) return
  377. const newPatterns = Array.from(selectedPatternPaths)
  378. try {
  379. await apiClient.post('/modify_playlist', { playlist_name: selectedPlaylist, files: newPatterns })
  380. setPlaylistPatterns(newPatterns)
  381. setIsPickerOpen(false)
  382. toast.success('Playlist updated')
  383. // Previews are lazy-loaded via IntersectionObserver
  384. } catch (error) {
  385. toast.error('Failed to update playlist')
  386. }
  387. }
  388. const togglePatternSelection = (path: string) => {
  389. setSelectedPatternPaths(prev => {
  390. const next = new Set(prev)
  391. if (next.has(path)) {
  392. next.delete(path)
  393. } else {
  394. next.add(path)
  395. }
  396. return next
  397. })
  398. }
  399. // Run playlist
  400. const handleRunPlaylist = async () => {
  401. if (!selectedPlaylist || playlistPatterns.length === 0) return
  402. setIsRunning(true)
  403. try {
  404. await apiClient.post('/run_playlist', {
  405. playlist_name: selectedPlaylist,
  406. run_mode: runMode === 'indefinite' ? 'indefinite' : 'single',
  407. pause_time: getPauseTimeInSeconds(),
  408. clear_pattern: clearPattern,
  409. shuffle: shuffle,
  410. })
  411. toast.success(`Started playlist: ${selectedPlaylist}`)
  412. // Trigger Now Playing bar to open
  413. window.dispatchEvent(new CustomEvent('playback-started'))
  414. } catch (error) {
  415. toast.error(error instanceof Error ? error.message : 'Failed to run playlist')
  416. } finally {
  417. setIsRunning(false)
  418. }
  419. }
  420. // Filter and sort patterns for picker
  421. const categories = useMemo(() => {
  422. const cats = new Set(allPatterns.map(p => p.category))
  423. return ['all', ...Array.from(cats).sort()]
  424. }, [allPatterns])
  425. const filteredPatterns = useMemo(() => {
  426. let filtered = allPatterns
  427. if (searchQuery) {
  428. filtered = filtered.filter(p => fuzzyMatch(p.name, searchQuery))
  429. }
  430. if (selectedCategory !== 'all') {
  431. filtered = filtered.filter(p => p.category === selectedCategory)
  432. }
  433. filtered = [...filtered].sort((a, b) => {
  434. let cmp = 0
  435. switch (sortBy) {
  436. case 'name':
  437. cmp = a.name.localeCompare(b.name)
  438. break
  439. case 'date':
  440. cmp = a.date_modified - b.date_modified
  441. break
  442. case 'size':
  443. cmp = a.coordinates_count - b.coordinates_count
  444. break
  445. case 'favorites': {
  446. const aFav = favorites.has(a.path) ? 1 : 0
  447. const bFav = favorites.has(b.path) ? 1 : 0
  448. cmp = bFav - aFav // Favorites first
  449. if (cmp === 0) {
  450. cmp = a.name.localeCompare(b.name) // Then by name
  451. }
  452. break
  453. }
  454. case 'plays': {
  455. const aKey = a.path.split('/').pop() || ''
  456. const bKey = b.path.split('/').pop() || ''
  457. const aPlays = allPatternHistories[aKey]?.play_count ?? 0
  458. const bPlays = allPatternHistories[bKey]?.play_count ?? 0
  459. cmp = aPlays - bPlays
  460. if (cmp === 0) {
  461. cmp = a.name.localeCompare(b.name)
  462. }
  463. break
  464. }
  465. case 'last_played': {
  466. const aKey = a.path.split('/').pop() || ''
  467. const bKey = b.path.split('/').pop() || ''
  468. const aTime = allPatternHistories[aKey]?.last_played || ''
  469. const bTime = allPatternHistories[bKey]?.last_played || ''
  470. cmp = aTime.localeCompare(bTime)
  471. if (cmp === 0) {
  472. cmp = a.name.localeCompare(b.name)
  473. }
  474. break
  475. }
  476. }
  477. return sortAsc ? cmp : -cmp
  478. })
  479. return filtered
  480. }, [allPatterns, searchQuery, selectedCategory, sortBy, sortAsc, favorites, allPatternHistories])
  481. // Get pattern name from path
  482. const getPatternName = (path: string) => {
  483. const pattern = allPatterns.find(p => p.path === path)
  484. return pattern?.name || path.split('/').pop()?.replace('.thr', '') || path
  485. }
  486. // Get preview URL (backend already returns full data URL)
  487. const getPreviewUrl = (path: string) => {
  488. const preview = previews[path]
  489. return preview?.image_data || null
  490. }
  491. return (
  492. <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4 overflow-hidden" style={{ height: 'calc(100dvh - 14rem - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))' }}>
  493. {/* Page Header */}
  494. <div className="space-y-0.5 sm:space-y-1 shrink-0 pl-1">
  495. <h1 className="text-xl font-semibold tracking-tight">Playlists</h1>
  496. <p className="text-xs text-muted-foreground">
  497. Create and manage pattern playlists
  498. </p>
  499. </div>
  500. <Separator className="shrink-0" />
  501. {/* Main Content Area */}
  502. <div className="flex flex-col lg:flex-row gap-4 flex-1 min-h-0 relative overflow-hidden">
  503. {/* Playlists Sidebar - Full screen on mobile, sidebar on desktop */}
  504. <aside className={`w-full lg:w-64 shrink-0 bg-card border rounded-lg flex flex-col h-full overflow-hidden transition-transform duration-300 ease-in-out ${
  505. mobileShowContent ? '-translate-x-full lg:translate-x-0 absolute lg:relative inset-0 lg:inset-auto' : 'translate-x-0'
  506. }`}>
  507. <div className="flex items-center justify-between px-3 py-2.5 border-b shrink-0">
  508. <div>
  509. <h2 className="text-lg font-semibold">My Playlists</h2>
  510. <p className="text-sm text-muted-foreground">{playlists.length} playlist{playlists.length !== 1 ? 's' : ''}</p>
  511. </div>
  512. {!isPlayOnlyActive && (
  513. <Button
  514. variant="ghost"
  515. size="icon"
  516. className="h-8 w-8"
  517. onClick={() => {
  518. setNewPlaylistName('')
  519. setIsCreateModalOpen(true)
  520. }}
  521. >
  522. <span className="material-icons-outlined text-xl">add</span>
  523. </Button>
  524. )}
  525. </div>
  526. <nav className="flex-1 overflow-y-auto p-2 space-y-1 min-h-0">
  527. {isLoadingPlaylists ? (
  528. <div className="flex items-center justify-center py-8 text-muted-foreground">
  529. <span className="text-sm">Loading...</span>
  530. </div>
  531. ) : playlists.length === 0 ? (
  532. <div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
  533. <span className="material-icons-outlined text-3xl">playlist_add</span>
  534. <span className="text-sm">No playlists yet</span>
  535. </div>
  536. ) : (
  537. playlists.map(name => (
  538. <div
  539. key={name}
  540. className={`group flex items-center justify-between rounded-lg px-3 py-2 cursor-pointer transition-colors ${
  541. selectedPlaylist === name
  542. ? 'bg-accent text-accent-foreground'
  543. : 'hover:bg-muted text-muted-foreground'
  544. }`}
  545. onClick={() => handleSelectPlaylist(name)}
  546. >
  547. <div className="flex items-center gap-2 min-w-0">
  548. <span className="material-icons-outlined text-lg">playlist_play</span>
  549. <span className="truncate text-sm font-medium">{name}</span>
  550. </div>
  551. {!isPlayOnlyActive && (
  552. <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
  553. <Button
  554. variant="ghost"
  555. size="icon-sm"
  556. className="h-7 w-7"
  557. onClick={(e) => {
  558. e.stopPropagation()
  559. setPlaylistToRename(name)
  560. setNewPlaylistName(name)
  561. setIsRenameModalOpen(true)
  562. }}
  563. >
  564. <span className="material-icons-outlined text-base">edit</span>
  565. </Button>
  566. <Button
  567. variant="ghost"
  568. size="icon-sm"
  569. className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/20"
  570. onClick={(e) => {
  571. e.stopPropagation()
  572. handleDeletePlaylist(name)
  573. }}
  574. >
  575. <Trash2 className="h-4 w-4" />
  576. </Button>
  577. </div>
  578. )}
  579. </div>
  580. ))
  581. )}
  582. </nav>
  583. </aside>
  584. {/* Main Content - Slides in from right on mobile, swipe right to go back */}
  585. <main
  586. className={`flex-1 bg-card border rounded-lg flex flex-col overflow-hidden min-h-0 relative transition-transform duration-300 ease-in-out ${
  587. mobileShowContent ? 'translate-x-0' : 'translate-x-full lg:translate-x-0 absolute lg:relative inset-0 lg:inset-auto'
  588. }`}
  589. onTouchStart={handleSwipeTouchStart}
  590. onTouchEnd={handleSwipeTouchEnd}
  591. >
  592. {/* Header */}
  593. <header className="flex items-center justify-between px-3 py-2.5 border-b shrink-0">
  594. <div className="flex items-center gap-2 min-w-0">
  595. {/* Back button - mobile only */}
  596. <Button
  597. variant="ghost"
  598. size="icon"
  599. className="h-8 w-8 lg:hidden shrink-0"
  600. onClick={handleMobileBack}
  601. >
  602. <span className="material-icons-outlined">arrow_back</span>
  603. </Button>
  604. <div className="min-w-0">
  605. <h2 className="text-lg font-semibold truncate">
  606. {selectedPlaylist || 'Select a Playlist'}
  607. </h2>
  608. {selectedPlaylist && playlistPatterns.length > 0 && (
  609. <p className="text-sm text-muted-foreground">
  610. {playlistPatterns.length} pattern{playlistPatterns.length !== 1 ? 's' : ''}
  611. </p>
  612. )}
  613. </div>
  614. </div>
  615. {!isPlayOnlyActive && (
  616. <Button
  617. onClick={openPatternPicker}
  618. disabled={!selectedPlaylist}
  619. size="sm"
  620. className="gap-2"
  621. >
  622. <span className="material-icons-outlined text-base">add</span>
  623. <span className="hidden sm:inline">Add Patterns</span>
  624. </Button>
  625. )}
  626. </header>
  627. {/* Patterns List */}
  628. <div className={`flex-1 overflow-y-auto p-4 min-h-0 ${selectedPlaylist ? 'pb-28 sm:pb-24' : ''}`}>
  629. {!selectedPlaylist ? (
  630. <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
  631. <div className="p-4 rounded-full bg-muted">
  632. <span className="material-icons-outlined text-5xl">touch_app</span>
  633. </div>
  634. <div className="text-center">
  635. <p className="font-medium">No playlist selected</p>
  636. <p className="text-sm">Select a playlist from the sidebar to view its patterns</p>
  637. </div>
  638. </div>
  639. ) : playlistPatterns.length === 0 ? (
  640. <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
  641. <div className="p-4 rounded-full bg-muted">
  642. <span className="material-icons-outlined text-5xl">library_music</span>
  643. </div>
  644. <div className="text-center">
  645. <p className="font-medium">Empty playlist</p>
  646. <p className="text-sm">Add patterns to get started</p>
  647. </div>
  648. {!isPlayOnlyActive && (
  649. <Button variant="secondary" className="mt-2 gap-2" onClick={openPatternPicker}>
  650. <span className="material-icons-outlined text-base">add</span>
  651. Add Patterns
  652. </Button>
  653. )}
  654. </div>
  655. ) : (
  656. <div className="grid grid-cols-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
  657. {playlistPatterns.map((path, index) => (
  658. <div
  659. key={`${path}-${index}`}
  660. className="flex flex-col items-center gap-1.5 sm:gap-2 group"
  661. >
  662. <div className="relative w-full aspect-square">
  663. <div className="w-full h-full rounded-full overflow-hidden border bg-muted hover:ring-2 hover:ring-primary hover:ring-offset-2 hover:ring-offset-background transition-all cursor-pointer">
  664. <LazyPatternPreview
  665. path={path}
  666. previewUrl={getPreviewUrl(path)}
  667. requestPreview={requestPreview}
  668. alt={getPatternName(path)}
  669. />
  670. </div>
  671. {!isPlayOnlyActive && (
  672. <button
  673. className="absolute -top-0.5 -right-0.5 sm:-top-1 sm:-right-1 w-5 h-5 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shadow-sm z-10"
  674. onClick={() => handleRemovePattern(path)}
  675. title="Remove from playlist"
  676. >
  677. <span className="material-icons" style={{ fontSize: '12px' }}>close</span>
  678. </button>
  679. )}
  680. </div>
  681. <p className="text-[10px] sm:text-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
  682. </div>
  683. ))}
  684. </div>
  685. )}
  686. </div>
  687. {/* Floating Playback Controls */}
  688. {selectedPlaylist && (
  689. <div className="absolute bottom-0 left-0 right-0 pointer-events-none z-20">
  690. {/* Blur backdrop */}
  691. <div className="h-20 bg-gradient-to-t" />
  692. {/* Controls container */}
  693. <div className="absolute bottom-4 left-0 right-0 flex items-center justify-center gap-3 px-4 pointer-events-auto">
  694. {/* Control pill */}
  695. <div className="flex items-center h-12 sm:h-14 bg-card rounded-full shadow-xl border px-1.5 sm:px-2">
  696. {/* Shuffle & Loop */}
  697. <div className="flex items-center px-1 sm:px-2 border-r border-border gap-0.5 sm:gap-1">
  698. <button
  699. onClick={() => setShuffle(!shuffle)}
  700. className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition ${
  701. shuffle
  702. ? 'text-primary bg-primary/10'
  703. : 'text-muted-foreground hover:bg-muted'
  704. }`}
  705. title="Shuffle"
  706. >
  707. <span className="material-icons-outlined text-lg sm:text-xl">shuffle</span>
  708. </button>
  709. <button
  710. onClick={() => setRunMode(runMode === 'indefinite' ? 'single' : 'indefinite')}
  711. className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition ${
  712. runMode === 'indefinite'
  713. ? 'text-primary bg-primary/10'
  714. : 'text-muted-foreground hover:bg-muted'
  715. }`}
  716. title={runMode === 'indefinite' ? 'Loop mode' : 'Play once mode'}
  717. >
  718. <span className="material-icons-outlined text-lg sm:text-xl">repeat</span>
  719. </button>
  720. </div>
  721. {/* Pause Time */}
  722. <div className="flex items-center px-2 sm:px-3 gap-2 sm:gap-3 border-r border-border">
  723. <span className="text-[10px] sm:text-xs font-semibold text-muted-foreground tracking-wider hidden sm:block">Pause</span>
  724. <div className="flex items-center gap-1">
  725. <Button
  726. variant="secondary"
  727. size="icon"
  728. className="w-7 h-7 sm:w-8 sm:h-8"
  729. onClick={() => {
  730. const step = pauseUnit === 'hr' ? 0.5 : 1
  731. setPauseTime(Math.max(0, pauseTime - step))
  732. }}
  733. >
  734. <span className="material-icons-outlined text-sm">remove</span>
  735. </Button>
  736. <button
  737. onClick={() => {
  738. const units: ('sec' | 'min' | 'hr')[] = ['sec', 'min', 'hr']
  739. const currentIndex = units.indexOf(pauseUnit)
  740. setPauseUnit(units[(currentIndex + 1) % units.length])
  741. }}
  742. className="relative flex items-center justify-center min-w-14 sm:min-w-16 px-1 text-xs sm:text-sm font-bold hover:text-primary transition"
  743. title="Click to change unit"
  744. >
  745. {pauseTime}{pauseUnit === 'sec' ? 's' : pauseUnit === 'min' ? 'm' : 'h'}
  746. <span className="material-icons-outlined text-xs opacity-50 scale-75 ml-0.5">swap_vert</span>
  747. </button>
  748. <Button
  749. variant="secondary"
  750. size="icon"
  751. className="w-7 h-7 sm:w-8 sm:h-8"
  752. onClick={() => {
  753. const step = pauseUnit === 'hr' ? 0.5 : 1
  754. setPauseTime(pauseTime + step)
  755. }}
  756. >
  757. <span className="material-icons-outlined text-sm">add</span>
  758. </Button>
  759. </div>
  760. </div>
  761. {/* Clear Pattern Dropdown */}
  762. <div className="flex items-center px-1 sm:px-2">
  763. <Select value={clearPattern} onValueChange={(v) => setClearPattern(v as PreExecution)}>
  764. <SelectTrigger className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full border-0 p-0 shadow-none focus:ring-0 justify-center [&>svg]:hidden transition ${
  765. clearPattern !== 'none' ? '!bg-primary/10' : '!bg-transparent hover:!bg-muted'
  766. }`}>
  767. <span className={`material-icons-outlined text-lg sm:text-xl ${
  768. clearPattern !== 'none' ? 'text-primary' : 'text-muted-foreground'
  769. }`}>cleaning_services</span>
  770. </SelectTrigger>
  771. <SelectContent>
  772. {preExecutionOptions.map(opt => (
  773. <SelectItem key={opt.value} value={opt.value}>
  774. <div>
  775. <div>{opt.label}</div>
  776. <div className="text-xs text-muted-foreground font-normal">{opt.description}</div>
  777. </div>
  778. </SelectItem>
  779. ))}
  780. </SelectContent>
  781. </Select>
  782. </div>
  783. </div>
  784. {/* Play Button */}
  785. <button
  786. onClick={handleRunPlaylist}
  787. disabled={isRunning || playlistPatterns.length === 0}
  788. className="w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground text-primary-foreground shadow-lg shadow-primary/30 hover:shadow-primary/50 hover:scale-105 disabled:shadow-none disabled:hover:scale-100 transition-all duration-200 flex items-center justify-center"
  789. title="Run Playlist"
  790. >
  791. {isRunning ? (
  792. <span className="material-icons-outlined text-xl sm:text-2xl animate-spin">sync</span>
  793. ) : (
  794. <span className="material-icons text-xl sm:text-2xl ml-0.5">play_arrow</span>
  795. )}
  796. </button>
  797. </div>
  798. </div>
  799. )}
  800. </main>
  801. </div>
  802. {/* Create Playlist Modal */}
  803. <Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
  804. <DialogContent className="sm:max-w-md">
  805. <DialogHeader>
  806. <DialogTitle className="flex items-center gap-2">
  807. <span className="material-icons-outlined text-primary">playlist_add</span>
  808. Create New Playlist
  809. </DialogTitle>
  810. </DialogHeader>
  811. <div className="space-y-4 py-4">
  812. <div className="space-y-2">
  813. <Label htmlFor="playlistName">Playlist Name</Label>
  814. <Input
  815. id="playlistName"
  816. value={newPlaylistName}
  817. onChange={(e) => setNewPlaylistName(e.target.value)}
  818. placeholder="e.g., Favorites, Morning Patterns..."
  819. onKeyDown={(e) => e.key === 'Enter' && handleCreatePlaylist()}
  820. autoFocus
  821. />
  822. </div>
  823. </div>
  824. <DialogFooter className="gap-2 sm:gap-0">
  825. <Button variant="secondary" onClick={() => setIsCreateModalOpen(false)}>
  826. Cancel
  827. </Button>
  828. <Button onClick={handleCreatePlaylist} className="gap-2">
  829. <span className="material-icons-outlined text-base">add</span>
  830. Create Playlist
  831. </Button>
  832. </DialogFooter>
  833. </DialogContent>
  834. </Dialog>
  835. {/* Rename Playlist Modal */}
  836. <Dialog open={isRenameModalOpen} onOpenChange={setIsRenameModalOpen}>
  837. <DialogContent className="sm:max-w-md">
  838. <DialogHeader>
  839. <DialogTitle className="flex items-center gap-2">
  840. <span className="material-icons-outlined text-primary">edit</span>
  841. Rename Playlist
  842. </DialogTitle>
  843. </DialogHeader>
  844. <div className="space-y-4 py-4">
  845. <div className="space-y-2">
  846. <Label htmlFor="renamePlaylist">New Name</Label>
  847. <Input
  848. id="renamePlaylist"
  849. value={newPlaylistName}
  850. onChange={(e) => setNewPlaylistName(e.target.value)}
  851. placeholder="Enter new name"
  852. onKeyDown={(e) => e.key === 'Enter' && handleRenamePlaylist()}
  853. autoFocus
  854. />
  855. </div>
  856. </div>
  857. <DialogFooter className="gap-2 sm:gap-0">
  858. <Button variant="secondary" onClick={() => setIsRenameModalOpen(false)}>
  859. Cancel
  860. </Button>
  861. <Button onClick={handleRenamePlaylist} className="gap-2">
  862. <span className="material-icons-outlined text-base">save</span>
  863. Save Name
  864. </Button>
  865. </DialogFooter>
  866. </DialogContent>
  867. </Dialog>
  868. {/* Pattern Picker Modal */}
  869. <Dialog open={isPickerOpen} onOpenChange={setIsPickerOpen}>
  870. <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
  871. <DialogHeader>
  872. <DialogTitle className="flex items-center gap-2">
  873. <span className="material-icons-outlined text-primary">playlist_add</span>
  874. Add Patterns to {selectedPlaylist}
  875. </DialogTitle>
  876. </DialogHeader>
  877. {/* Search and Filters */}
  878. <div className="space-y-3 py-2">
  879. <div className="relative">
  880. <span className="material-icons-outlined absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-lg">
  881. search
  882. </span>
  883. <Input
  884. value={searchQuery}
  885. onChange={(e) => setSearchQuery(e.target.value)}
  886. placeholder="Search patterns..."
  887. className="pl-10 pr-10 h-10"
  888. />
  889. {searchQuery && (
  890. <button
  891. className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
  892. onClick={() => setSearchQuery('')}
  893. >
  894. <span className="material-icons-outlined text-lg">close</span>
  895. </button>
  896. )}
  897. </div>
  898. <div className="flex flex-wrap gap-2 items-center">
  899. {/* Folder dropdown - icon only on mobile, with text on sm+ */}
  900. <Select value={selectedCategory} onValueChange={setSelectedCategory}>
  901. <SelectTrigger className="h-9 w-9 sm:w-auto rounded-full bg-card border-border shadow-sm text-sm px-0 sm:px-3 justify-center sm:justify-between [&>svg]:hidden sm:[&>svg]:block [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
  902. <span className="material-icons-outlined text-lg shrink-0">folder</span>
  903. <SelectValue />
  904. </SelectTrigger>
  905. <SelectContent>
  906. {categories.map(cat => (
  907. <SelectItem key={cat} value={cat}>
  908. {cat === 'all' ? 'All Folders' : cat}
  909. </SelectItem>
  910. ))}
  911. </SelectContent>
  912. </Select>
  913. {/* Sort dropdown - icon only on mobile, with text on sm+ */}
  914. <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
  915. <SelectTrigger className="h-9 w-9 sm:w-auto rounded-full bg-card border-border shadow-sm text-sm px-0 sm:px-3 justify-center sm:justify-between [&>svg]:hidden sm:[&>svg]:block [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
  916. <span className="material-icons-outlined text-lg shrink-0">sort</span>
  917. <SelectValue />
  918. </SelectTrigger>
  919. <SelectContent>
  920. <SelectItem value="favorites">Favorites</SelectItem>
  921. <SelectItem value="name">Name</SelectItem>
  922. <SelectItem value="date">Modified</SelectItem>
  923. <SelectItem value="size">Size</SelectItem>
  924. <SelectItem value="plays">Most Played</SelectItem>
  925. <SelectItem value="last_played">Last Played</SelectItem>
  926. </SelectContent>
  927. </Select>
  928. {/* Sort direction - pill shaped */}
  929. <Button
  930. variant="outline"
  931. size="icon"
  932. className="h-9 w-9 rounded-full bg-card shadow-sm"
  933. onClick={() => setSortAsc(!sortAsc)}
  934. title={sortAsc ? 'Ascending' : 'Descending'}
  935. >
  936. <span className="material-icons-outlined text-lg">
  937. {sortAsc ? 'arrow_upward' : 'arrow_downward'}
  938. </span>
  939. </Button>
  940. <div className="flex-1" />
  941. {/* Select All / Deselect All toggle */}
  942. <Button
  943. variant="outline"
  944. size="sm"
  945. className="h-9 rounded-full bg-card shadow-sm text-sm gap-1.5"
  946. onClick={() => {
  947. const allFilteredPaths = filteredPatterns.map(p => p.path)
  948. const allSelected = allFilteredPaths.every(p => selectedPatternPaths.has(p))
  949. setSelectedPatternPaths(prev => {
  950. const next = new Set(prev)
  951. if (allSelected) {
  952. allFilteredPaths.forEach(p => next.delete(p))
  953. } else {
  954. allFilteredPaths.forEach(p => next.add(p))
  955. }
  956. return next
  957. })
  958. }}
  959. >
  960. <span className="material-icons-outlined text-base">
  961. {filteredPatterns.length > 0 && filteredPatterns.every(p => selectedPatternPaths.has(p.path)) ? 'deselect' : 'select_all'}
  962. </span>
  963. <span className="hidden sm:inline">
  964. {filteredPatterns.length > 0 && filteredPatterns.every(p => selectedPatternPaths.has(p.path)) ? 'Deselect All' : 'Select All'}
  965. </span>
  966. </Button>
  967. {/* Selection count - compact on mobile */}
  968. <div className="flex items-center gap-1 sm:gap-2 text-sm bg-card rounded-full px-2 sm:px-3 py-2 shadow-sm border">
  969. <span className="material-icons-outlined text-base text-primary">check_circle</span>
  970. <span className="font-medium">{selectedPatternPaths.size}</span>
  971. <span className="hidden sm:inline text-muted-foreground">selected</span>
  972. </div>
  973. </div>
  974. </div>
  975. {/* Patterns Grid */}
  976. <div className="flex-1 overflow-y-auto border rounded-lg p-4 min-h-[300px] bg-muted/20">
  977. {filteredPatterns.length === 0 ? (
  978. <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
  979. <div className="p-4 rounded-full bg-muted">
  980. <span className="material-icons-outlined text-5xl">search_off</span>
  981. </div>
  982. <span className="text-sm">No patterns found</span>
  983. </div>
  984. ) : (
  985. <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4">
  986. {filteredPatterns.map(pattern => {
  987. const isSelected = selectedPatternPaths.has(pattern.path)
  988. return (
  989. <div
  990. key={pattern.path}
  991. className="flex flex-col items-center gap-2 cursor-pointer"
  992. onClick={() => togglePatternSelection(pattern.path)}
  993. >
  994. <div
  995. className={`relative w-full aspect-square rounded-full overflow-hidden border-2 bg-muted transition-all ${
  996. isSelected
  997. ? 'border-primary ring-2 ring-primary/20'
  998. : 'border-transparent hover:border-muted-foreground/30'
  999. }`}
  1000. >
  1001. <LazyPatternPreview
  1002. path={pattern.path}
  1003. previewUrl={getPreviewUrl(pattern.path)}
  1004. requestPreview={requestPreview}
  1005. alt={pattern.name}
  1006. />
  1007. {isSelected && (
  1008. <div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-primary flex items-center justify-center shadow-md">
  1009. <span className="material-icons text-primary-foreground" style={{ fontSize: '14px' }}>
  1010. check
  1011. </span>
  1012. </div>
  1013. )}
  1014. </div>
  1015. <p className={`text-xs truncate font-medium w-full text-center ${isSelected ? 'text-primary' : ''}`}>
  1016. {pattern.name}
  1017. </p>
  1018. </div>
  1019. )
  1020. })}
  1021. </div>
  1022. )}
  1023. </div>
  1024. <DialogFooter className="gap-2 sm:gap-0">
  1025. <Button variant="secondary" onClick={() => setIsPickerOpen(false)}>
  1026. Cancel
  1027. </Button>
  1028. <Button onClick={handleSavePatterns} className="gap-2">
  1029. <span className="material-icons-outlined text-base">save</span>
  1030. Save Selection
  1031. </Button>
  1032. </DialogFooter>
  1033. </DialogContent>
  1034. </Dialog>
  1035. </div>
  1036. )
  1037. }
  1038. // Lazy-loading pattern preview component
  1039. interface LazyPatternPreviewProps {
  1040. path: string
  1041. previewUrl: string | null
  1042. requestPreview: (path: string) => void
  1043. alt: string
  1044. className?: string
  1045. }
  1046. function LazyPatternPreview({ path, previewUrl, requestPreview, alt, className = '' }: LazyPatternPreviewProps) {
  1047. const containerRef = useRef<HTMLDivElement>(null)
  1048. const hasRequestedRef = useRef(false)
  1049. useEffect(() => {
  1050. if (!containerRef.current || previewUrl || hasRequestedRef.current) return
  1051. const observer = new IntersectionObserver(
  1052. (entries) => {
  1053. entries.forEach((entry) => {
  1054. if (entry.isIntersecting && !hasRequestedRef.current) {
  1055. hasRequestedRef.current = true
  1056. requestPreview(path)
  1057. observer.disconnect()
  1058. }
  1059. })
  1060. },
  1061. { rootMargin: '100px' }
  1062. )
  1063. observer.observe(containerRef.current)
  1064. return () => observer.disconnect()
  1065. }, [path, previewUrl, requestPreview])
  1066. return (
  1067. <div ref={containerRef} className={`w-full h-full flex items-center justify-center ${className}`}>
  1068. {previewUrl ? (
  1069. <img
  1070. src={previewUrl}
  1071. alt={alt}
  1072. loading="lazy"
  1073. className="w-full h-full object-cover pattern-preview"
  1074. />
  1075. ) : (
  1076. <span className="material-icons-outlined text-muted-foreground text-sm sm:text-base">
  1077. image
  1078. </span>
  1079. )}
  1080. </div>
  1081. )
  1082. }