LibraryView.tsx 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249
  1. import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
  2. import { useQuery, useQueryClient } from '@tanstack/react-query'
  3. import {
  4. Search,
  5. Loader2,
  6. RefreshCw,
  7. SlidersHorizontal,
  8. X,
  9. ChevronDown,
  10. ChevronRight,
  11. FolderPlus,
  12. Folder,
  13. FolderOpen,
  14. Upload,
  15. Pencil,
  16. Trash2,
  17. ExternalLink,
  18. FolderInput,
  19. Eye,
  20. ThumbsUp,
  21. ThumbsDown,
  22. Eraser
  23. } from 'lucide-react'
  24. import WallpaperCard from './WallpaperCard'
  25. import type { LibraryFilters, WallpaperFolder, LweStatus } from '@shared/types'
  26. import clsx from 'clsx'
  27. import { WE_TYPES, WE_AGE_RATINGS } from '../../constants/weFilters'
  28. const STORAGE_KEY = 'we-library-filters'
  29. interface LibraryFilterState {
  30. types: string[]
  31. ageRatings: string[]
  32. genres: string[]
  33. }
  34. const DEFAULT_STATE: LibraryFilterState = { types: [], ageRatings: [], genres: [] }
  35. const TYPE_MAP: Record<string, string> = {
  36. Scene: 'scene',
  37. Video: 'video',
  38. Web: 'web',
  39. Application: 'application'
  40. }
  41. const RATING_MAP: Record<string, string> = {
  42. Everyone: 'everyone',
  43. Questionable: 'questionable',
  44. Mature: 'mature'
  45. }
  46. function loadFilters(): LibraryFilterState {
  47. try {
  48. const raw = localStorage.getItem(STORAGE_KEY)
  49. return raw ? { ...DEFAULT_STATE, ...JSON.parse(raw) } : DEFAULT_STATE
  50. } catch {
  51. return DEFAULT_STATE
  52. }
  53. }
  54. function toggle(arr: string[], value: string): string[] {
  55. return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]
  56. }
  57. function Chip({
  58. label,
  59. active,
  60. onClick
  61. }: {
  62. label: string
  63. active: boolean
  64. onClick: () => void
  65. }) {
  66. return (
  67. <button
  68. onClick={onClick}
  69. className={clsx(
  70. 'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
  71. active
  72. ? 'bg-indigo-600 text-white'
  73. : 'text-gray-400 hover:bg-white/5 hover:text-gray-200'
  74. )}
  75. >
  76. {label}
  77. </button>
  78. )
  79. }
  80. function TagDropdown({
  81. available,
  82. selected,
  83. onChange
  84. }: {
  85. available: string[]
  86. selected: string[]
  87. onChange: (tags: string[]) => void
  88. }) {
  89. const [open, setOpen] = useState(false)
  90. const [search, setSearch] = useState('')
  91. const ref = useRef<HTMLDivElement>(null)
  92. useEffect(() => {
  93. function onOutside(e: MouseEvent) {
  94. if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
  95. }
  96. document.addEventListener('mousedown', onOutside)
  97. return () => document.removeEventListener('mousedown', onOutside)
  98. }, [])
  99. const filtered = available.filter((t) =>
  100. t.toLowerCase().includes(search.toLowerCase())
  101. )
  102. function toggleTag(tag: string) {
  103. onChange(
  104. selected.includes(tag) ? selected.filter((t) => t !== tag) : [...selected, tag]
  105. )
  106. }
  107. return (
  108. <div className="relative" ref={ref}>
  109. <button
  110. onClick={() => setOpen((v) => !v)}
  111. className={clsx(
  112. 'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs transition-colors',
  113. selected.length > 0
  114. ? 'bg-indigo-600 text-white'
  115. : 'bg-white/5 text-gray-300 hover:bg-white/10'
  116. )}
  117. >
  118. Tags
  119. {selected.length > 0 && (
  120. <span className="rounded-full bg-white/20 px-1.5">{selected.length}</span>
  121. )}
  122. <ChevronDown size={12} />
  123. </button>
  124. {open && (
  125. <div className="absolute right-0 top-full z-50 mt-1 w-52 rounded-lg border border-white/10 bg-[#1a1a1a] shadow-xl">
  126. <div className="p-2">
  127. <input
  128. type="text"
  129. placeholder="Search tags…"
  130. value={search}
  131. onChange={(e) => setSearch(e.target.value)}
  132. className="w-full rounded bg-white/5 px-2 py-1 text-xs text-gray-200 outline-none"
  133. />
  134. </div>
  135. <div className="max-h-52 overflow-y-auto">
  136. {filtered.length === 0 && (
  137. <p className="px-3 py-2 text-xs text-gray-500">No tags found</p>
  138. )}
  139. {filtered.map((tag) => (
  140. <label
  141. key={tag}
  142. className="flex cursor-pointer items-center gap-2 px-3 py-1 hover:bg-white/5"
  143. >
  144. <input
  145. type="checkbox"
  146. checked={selected.includes(tag)}
  147. onChange={() => toggleTag(tag)}
  148. className="accent-indigo-500"
  149. />
  150. <span className="text-xs text-gray-300">{tag}</span>
  151. </label>
  152. ))}
  153. </div>
  154. </div>
  155. )}
  156. </div>
  157. )
  158. }
  159. // ── Folder context menu ──
  160. function FolderMenu({
  161. x,
  162. y,
  163. onClose,
  164. onRename,
  165. onDelete
  166. }: {
  167. folder: WallpaperFolder
  168. x: number
  169. y: number
  170. onClose: () => void
  171. onRename: () => void
  172. onDelete: () => void
  173. }) {
  174. const ref = useRef<HTMLDivElement>(null)
  175. useEffect(() => {
  176. function onMouseDown(e: MouseEvent) {
  177. if (ref.current && !ref.current.contains(e.target as Node)) {
  178. onClose()
  179. }
  180. }
  181. document.addEventListener('mousedown', onMouseDown)
  182. return () => document.removeEventListener('mousedown', onMouseDown)
  183. }, [onClose])
  184. return (
  185. <div
  186. ref={ref}
  187. className="fixed z-50 min-w-[140px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
  188. style={{ left: x, top: y }}
  189. >
  190. <button
  191. onClick={onRename}
  192. className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
  193. >
  194. <Pencil size={12} /> Rename
  195. </button>
  196. <button
  197. onClick={onDelete}
  198. className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"
  199. >
  200. <Trash2 size={12} /> Delete
  201. </button>
  202. </div>
  203. )
  204. }
  205. interface Marquee {
  206. startX: number
  207. startY: number
  208. endX: number
  209. endY: number
  210. }
  211. export default function LibraryView() {
  212. const queryClient = useQueryClient()
  213. const [search, setSearch] = useState('')
  214. const [sortBy, setSortBy] = useState<LibraryFilters['sortBy']>('updatedAt')
  215. const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
  216. const [filterState, setFilterState] = useState<LibraryFilterState>(loadFilters)
  217. const [scanning, setScanning] = useState(false)
  218. const [scanResult, setScanResult] = useState<{
  219. imported: number
  220. skipped: number
  221. removed: number
  222. } | null>(null)
  223. // Pagination
  224. const [page, setPage] = useState(1)
  225. const [pageSize, setPageSize] = useState(50)
  226. // Folder state
  227. const [activeFolder, setActiveFolder] = useState<string | null>(null) // null = "All"
  228. const [folderMenu, setFolderMenu] = useState<{
  229. folder: WallpaperFolder
  230. x: number
  231. y: number
  232. } | null>(null)
  233. const [renamingId, setRenamingId] = useState<string | null>(null)
  234. const [renameValue, setRenameValue] = useState('')
  235. // Selection state
  236. const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
  237. const [lastClickedId, setLastClickedId] = useState<string | null>(null)
  238. const [marquee, setMarquee] = useState<Marquee | null>(null)
  239. const marqueeActive = useRef(false)
  240. const gridRef = useRef<HTMLDivElement>(null)
  241. // Wallpaper context menu state
  242. const [ctxMenu, setCtxMenu] = useState<{
  243. x: number
  244. y: number
  245. ids: string[]
  246. showFolderSub: boolean
  247. } | null>(null)
  248. const ctxMenuRef = useRef<HTMLDivElement>(null)
  249. // Close wallpaper context menu on outside click (mousedown so right-click on another card works)
  250. useEffect(() => {
  251. if (!ctxMenu) return
  252. function onMouseDown(e: MouseEvent) {
  253. if (ctxMenuRef.current && !ctxMenuRef.current.contains(e.target as Node)) {
  254. setCtxMenu(null)
  255. }
  256. }
  257. document.addEventListener('mousedown', onMouseDown)
  258. return () => document.removeEventListener('mousedown', onMouseDown)
  259. }, [ctxMenu])
  260. useEffect(() => {
  261. localStorage.setItem(STORAGE_KEY, JSON.stringify(filterState))
  262. }, [filterState])
  263. function updateFilter<K extends keyof LibraryFilterState>(key: K, tag: string) {
  264. setFilterState((prev) => ({ ...prev, [key]: toggle(prev[key], tag) }))
  265. }
  266. const activeCount =
  267. filterState.types.length + filterState.ageRatings.length + filterState.genres.length
  268. const filters: LibraryFilters = {
  269. sortBy,
  270. sortDir,
  271. searchText: search || undefined,
  272. type:
  273. filterState.types.length === 1
  274. ? (TYPE_MAP[filterState.types[0]] as LibraryFilters['type'])
  275. : undefined,
  276. contentRating:
  277. filterState.ageRatings.length === 1
  278. ? (RATING_MAP[filterState.ageRatings[0]] as LibraryFilters['contentRating'])
  279. : undefined
  280. }
  281. const { data: allWallpapers = [], isLoading } = useQuery({
  282. queryKey: ['library', filters],
  283. queryFn: () => window.electronAPI.library.getAll(filters)
  284. })
  285. const { data: folders = [], refetch: refetchFolders } = useQuery({
  286. queryKey: ['folders'],
  287. queryFn: () => window.electronAPI.folders.getAll()
  288. })
  289. // Client-side OR filtering for multiple types / ratings / genres
  290. const filtered = useMemo(() => {
  291. return allWallpapers.filter((w) => {
  292. if (filterState.types.length > 1) {
  293. const types = filterState.types.map((t) => TYPE_MAP[t]).filter(Boolean)
  294. if (!types.includes(w.type)) return false
  295. }
  296. if (filterState.ageRatings.length > 1) {
  297. const ratings = filterState.ageRatings
  298. .map((r) => RATING_MAP[r])
  299. .filter(Boolean)
  300. if (!ratings.includes(w.contentRating ?? 'everyone')) return false
  301. }
  302. if (filterState.genres.length > 0) {
  303. if (!filterState.genres.some((g) => w.tags.includes(g))) return false
  304. }
  305. return true
  306. })
  307. }, [allWallpapers, filterState])
  308. // Set of all existing library wallpaper IDs (for cross-referencing)
  309. const existingIds = useMemo(
  310. () => new Set(allWallpapers.map((w) => w.id)),
  311. [allWallpapers]
  312. )
  313. // Set of all IDs that belong to at least one folder AND exist in the library
  314. const allFolderItemIds = useMemo(() => {
  315. const set = new Set<string>()
  316. for (const f of folders) {
  317. for (const id of f.items) {
  318. if (existingIds.has(id)) set.add(id)
  319. }
  320. }
  321. return set
  322. }, [folders, existingIds])
  323. // Real item count per folder (only items that exist in the library)
  324. const folderCounts = useMemo(() => {
  325. const map = new Map<string, number>()
  326. for (const f of folders) {
  327. map.set(f.id, f.items.filter((id) => existingIds.has(id)).length)
  328. }
  329. return map
  330. }, [folders, existingIds])
  331. // Folder filtering:
  332. // Default (null) = everything NOT inside any folder (unsorted)
  333. // Specific folder = only that folder's items (wallpapers can be in multiple folders)
  334. const allWallpapersForView = useMemo(() => {
  335. if (!activeFolder) {
  336. return filtered.filter((w) => !allFolderItemIds.has(w.id))
  337. }
  338. const folder = folders.find((f) => f.id === activeFolder)
  339. if (!folder) return filtered
  340. const set = new Set(folder.items)
  341. return filtered.filter((w) => set.has(w.id))
  342. }, [filtered, activeFolder, folders, allFolderItemIds])
  343. // Pagination
  344. const totalItems = allWallpapersForView.length
  345. const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
  346. const safePage = Math.min(page, totalPages)
  347. const wallpapers = useMemo(
  348. () => allWallpapersForView.slice((safePage - 1) * pageSize, safePage * pageSize),
  349. [allWallpapersForView, safePage, pageSize]
  350. )
  351. // Reset page when filters/folder/search/sort change
  352. useEffect(() => { setPage(1) }, [filterState, activeFolder, search, sortBy, sortDir, pageSize])
  353. // Count for "All" sidebar button: unsorted items only
  354. const unsortedCount = useMemo(
  355. () => filtered.filter((w) => !allFolderItemIds.has(w.id)).length,
  356. [filtered, allFolderItemIds]
  357. )
  358. const { data: availableTags = [] } = useQuery({
  359. queryKey: ['library-tags'],
  360. queryFn: () => window.electronAPI.library.distinctTags()
  361. })
  362. const { data: lweStatus } = useQuery({
  363. queryKey: ['lwe-status'],
  364. queryFn: () => window.electronAPI.lwe.status()
  365. })
  366. const { data: votedIds } = useQuery({
  367. queryKey: ['steam-voted-ids'],
  368. queryFn: () => window.electronAPI.steam.getVotedIds(),
  369. staleTime: Infinity
  370. })
  371. const votedSet = useMemo(() => new Set(votedIds ?? []), [votedIds])
  372. async function handleScan() {
  373. setScanning(true)
  374. setScanResult(null)
  375. const result = await window.electronAPI.library.scan()
  376. setScanResult(result)
  377. setScanning(false)
  378. queryClient.invalidateQueries({ queryKey: ['library'] })
  379. queryClient.invalidateQueries({ queryKey: ['library-tags'] })
  380. }
  381. // Create folder — inline input instead of prompt()
  382. const [creatingFolder, setCreatingFolder] = useState(false)
  383. const [newFolderName, setNewFolderName] = useState('')
  384. async function handleCreateFolderSubmit() {
  385. if (!newFolderName.trim()) {
  386. setCreatingFolder(false)
  387. return
  388. }
  389. await window.electronAPI.folders.create(newFolderName.trim())
  390. setNewFolderName('')
  391. setCreatingFolder(false)
  392. refetchFolders()
  393. }
  394. async function handleImportWEConfig() {
  395. try {
  396. const filePath = await window.electronAPI.config.pickFile()
  397. if (!filePath) return
  398. const result = await window.electronAPI.config.importWE(filePath)
  399. refetchFolders()
  400. queryClient.invalidateQueries({ queryKey: ['library'] })
  401. setImportStatus(
  402. `Imported ${result.folders} folders and ${result.playlists} playlists.`
  403. )
  404. } catch (err) {
  405. setImportStatus(`Import failed: ${(err as Error).message}`)
  406. }
  407. }
  408. const [importStatus, setImportStatus] = useState<string | null>(null)
  409. async function handleCleanupFolders() {
  410. const { removed } = await window.electronAPI.folders.cleanup()
  411. refetchFolders()
  412. setImportStatus(
  413. removed > 0
  414. ? `Cleaned up ${removed} non-existent wallpaper(s) from folders.`
  415. : 'All folder items are valid — nothing to clean up.'
  416. )
  417. }
  418. async function handleRename(id: string) {
  419. if (!renameValue.trim()) return
  420. await window.electronAPI.folders.rename(id, renameValue.trim())
  421. setRenamingId(null)
  422. refetchFolders()
  423. }
  424. async function handleDeleteFolder(id: string) {
  425. await window.electronAPI.folders.delete(id)
  426. if (activeFolder === id) setActiveFolder(null)
  427. setFolderMenu(null)
  428. refetchFolders()
  429. }
  430. // Drop handler: add wallpaper(s) to folder via drag
  431. const handleDrop = useCallback(
  432. async (folderId: string, wallpaperId: string) => {
  433. // If the dragged item is in the selection, add all selected items
  434. const ids = selectedIds.has(wallpaperId)
  435. ? Array.from(selectedIds)
  436. : [wallpaperId]
  437. await window.electronAPI.folders.addItems(folderId, ids)
  438. refetchFolders()
  439. },
  440. [refetchFolders, selectedIds]
  441. )
  442. // Card click handler: normal = select single, ctrl = toggle, shift = range
  443. const handleCardSelect = useCallback(
  444. (wallpaperId: string, e: React.MouseEvent) => {
  445. if (e.ctrlKey || e.metaKey) {
  446. // Ctrl+click: toggle individual
  447. setSelectedIds((prev) => {
  448. const next = new Set(prev)
  449. if (next.has(wallpaperId)) next.delete(wallpaperId)
  450. else next.add(wallpaperId)
  451. return next
  452. })
  453. setLastClickedId(wallpaperId)
  454. } else if (e.shiftKey && lastClickedId) {
  455. // Shift+click: range select
  456. const ids = wallpapers.map((w) => w.id)
  457. const from = ids.indexOf(lastClickedId)
  458. const to = ids.indexOf(wallpaperId)
  459. if (from !== -1 && to !== -1) {
  460. const start = Math.min(from, to)
  461. const end = Math.max(from, to)
  462. const rangeIds = ids.slice(start, end + 1)
  463. setSelectedIds((prev) => {
  464. const next = new Set(prev)
  465. for (const id of rangeIds) next.add(id)
  466. return next
  467. })
  468. }
  469. } else {
  470. // Normal click: select single
  471. setSelectedIds(new Set([wallpaperId]))
  472. setLastClickedId(wallpaperId)
  473. }
  474. },
  475. [lastClickedId, wallpapers]
  476. )
  477. // Marquee selection handlers
  478. const handleMarqueeStart = useCallback(
  479. (e: React.MouseEvent) => {
  480. // Only start marquee when clicking on the grid background, not on a card
  481. if ((e.target as HTMLElement).closest('[data-wallpaper-id]')) return
  482. if (e.button !== 0) return
  483. const container = gridRef.current
  484. if (!container) return
  485. const rect = container.getBoundingClientRect()
  486. const x = e.clientX - rect.left + container.scrollLeft
  487. const y = e.clientY - rect.top + container.scrollTop
  488. marqueeActive.current = true
  489. setMarquee({ startX: x, startY: y, endX: x, endY: y })
  490. // Clear selection unless ctrl is held
  491. if (!e.ctrlKey && !e.metaKey) {
  492. setSelectedIds(new Set())
  493. }
  494. },
  495. []
  496. )
  497. const handleMarqueeMove = useCallback(
  498. (e: React.MouseEvent) => {
  499. if (!marqueeActive.current || !marquee) return
  500. const container = gridRef.current
  501. if (!container) return
  502. const rect = container.getBoundingClientRect()
  503. const x = e.clientX - rect.left + container.scrollLeft
  504. const y = e.clientY - rect.top + container.scrollTop
  505. setMarquee((prev) => prev ? { ...prev, endX: x, endY: y } : null)
  506. },
  507. [marquee]
  508. )
  509. const handleMarqueeEnd = useCallback(() => {
  510. if (!marqueeActive.current || !marquee) return
  511. marqueeActive.current = false
  512. const container = gridRef.current
  513. if (!container) return
  514. // Calculate marquee rectangle in container-relative coordinates
  515. const mx1 = Math.min(marquee.startX, marquee.endX)
  516. const my1 = Math.min(marquee.startY, marquee.endY)
  517. const mx2 = Math.max(marquee.startX, marquee.endX)
  518. const my2 = Math.max(marquee.startY, marquee.endY)
  519. // Skip if marquee is too small (just a click)
  520. if (Math.abs(mx2 - mx1) < 5 && Math.abs(my2 - my1) < 5) {
  521. setMarquee(null)
  522. return
  523. }
  524. const containerRect = container.getBoundingClientRect()
  525. const cards = container.querySelectorAll('[data-wallpaper-id]')
  526. const hits = new Set<string>()
  527. cards.forEach((card) => {
  528. const cardRect = card.getBoundingClientRect()
  529. // Convert card rect to container-relative coordinates
  530. const cx1 = cardRect.left - containerRect.left + container.scrollLeft
  531. const cy1 = cardRect.top - containerRect.top + container.scrollTop
  532. const cx2 = cx1 + cardRect.width
  533. const cy2 = cy1 + cardRect.height
  534. // Check intersection
  535. if (cx1 < mx2 && cx2 > mx1 && cy1 < my2 && cy2 > my1) {
  536. const id = card.getAttribute('data-wallpaper-id')
  537. if (id) hits.add(id)
  538. }
  539. })
  540. setSelectedIds((prev) => {
  541. // If ctrl was held during start, merge with existing
  542. const next = new Set(prev)
  543. hits.forEach((id) => next.add(id))
  544. return next
  545. })
  546. setMarquee(null)
  547. }, [marquee])
  548. // Clear selection when folder changes
  549. useEffect(() => {
  550. setSelectedIds(new Set())
  551. setLastClickedId(null)
  552. }, [activeFolder])
  553. // Keyboard: Escape clears selection, Ctrl+A selects all
  554. useEffect(() => {
  555. function onKeyDown(e: KeyboardEvent) {
  556. if (e.key === 'Escape') {
  557. setSelectedIds(new Set())
  558. }
  559. if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
  560. // Only if library grid is focused (not a text input)
  561. const active = document.activeElement
  562. if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
  563. e.preventDefault()
  564. setSelectedIds(new Set(wallpapers.map((w) => w.id)))
  565. }
  566. }
  567. window.addEventListener('keydown', onKeyDown)
  568. return () => window.removeEventListener('keydown', onKeyDown)
  569. }, [wallpapers])
  570. // Compute marquee rect for rendering (in container-relative px)
  571. const marqueeRect = marquee
  572. ? {
  573. left: Math.min(marquee.startX, marquee.endX),
  574. top: Math.min(marquee.startY, marquee.endY),
  575. width: Math.abs(marquee.endX - marquee.startX),
  576. height: Math.abs(marquee.endY - marquee.startY)
  577. }
  578. : null
  579. // ── Wallpaper context menu handlers ──
  580. const openWallpaperCtxMenu = useCallback(
  581. (e: React.MouseEvent, wallpaperId: string) => {
  582. e.preventDefault()
  583. const ids = selectedIds.has(wallpaperId) ? Array.from(selectedIds) : [wallpaperId]
  584. // Also select the right-clicked item if not already selected
  585. if (!selectedIds.has(wallpaperId)) {
  586. setSelectedIds(new Set(ids))
  587. }
  588. setCtxMenu({ x: e.clientX, y: e.clientY, ids, showFolderSub: false })
  589. },
  590. [selectedIds]
  591. )
  592. function closeCtxMenu() {
  593. setCtxMenu(null)
  594. }
  595. // Get WallpaperMeta objects for context menu items
  596. const ctxWallpapers = useMemo(() => {
  597. if (!ctxMenu) return []
  598. const idSet = new Set(ctxMenu.ids)
  599. return wallpapers.filter((w) => idSet.has(w.id))
  600. }, [ctxMenu, wallpapers])
  601. async function ctxUnsubscribe() {
  602. if (!ctxMenu) return
  603. for (const id of ctxMenu.ids) {
  604. try { await window.electronAPI.steam.unsubscribe(id) } catch {}
  605. }
  606. closeCtxMenu()
  607. queryClient.invalidateQueries({ queryKey: ['library'] })
  608. refetchFolders()
  609. }
  610. async function ctxVote(up: boolean) {
  611. if (!ctxMenu) return
  612. for (const id of ctxMenu.ids) {
  613. try { await window.electronAPI.steam.vote(id, up) } catch {}
  614. }
  615. queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })
  616. closeCtxMenu()
  617. }
  618. function ctxOpenInSteam() {
  619. if (!ctxMenu) return
  620. for (const id of ctxMenu.ids) {
  621. window.electronAPI.shell.openExternal(`steam://url/CommunityFilePage/${id}`)
  622. }
  623. closeCtxMenu()
  624. }
  625. async function ctxOpenLocally() {
  626. if (!ctxMenu) return
  627. for (const w of ctxWallpapers) {
  628. if (w.localPath) {
  629. await window.electronAPI.shell.openPath(w.localPath)
  630. }
  631. }
  632. closeCtxMenu()
  633. }
  634. async function ctxPreviewVideo() {
  635. if (!ctxMenu) return
  636. const w = ctxWallpapers[0]
  637. if (w?.localPath && w.file) {
  638. const videoPath = w.localPath + '/' + w.file
  639. await window.electronAPI.shell.openWithDefault(videoPath)
  640. }
  641. closeCtxMenu()
  642. }
  643. async function ctxMoveToFolder(folderId: string) {
  644. if (!ctxMenu) return
  645. await window.electronAPI.folders.addItems(folderId, ctxMenu.ids)
  646. refetchFolders()
  647. closeCtxMenu()
  648. }
  649. async function ctxMoveToDefault() {
  650. if (!ctxMenu) return
  651. // Remove from every folder that contains these items
  652. for (const folder of folders) {
  653. const overlap = ctxMenu.ids.filter((id) => folder.items.includes(id))
  654. if (overlap.length > 0) {
  655. await window.electronAPI.folders.removeItems(folder.id, overlap)
  656. }
  657. }
  658. refetchFolders()
  659. closeCtxMenu()
  660. }
  661. async function ctxRemoveFromFolder() {
  662. if (!ctxMenu || !activeFolder) return
  663. await window.electronAPI.folders.removeItems(activeFolder, ctxMenu.ids)
  664. refetchFolders()
  665. closeCtxMenu()
  666. }
  667. // Check if any of the context menu items are video type
  668. const ctxHasVideo = ctxWallpapers.some((w) => w.type === 'video' && w.file)
  669. return (
  670. <div className="flex h-full flex-col">
  671. {/* Header bar */}
  672. <div className="flex items-center gap-3 border-b border-white/5 px-4 py-3">
  673. <div className="relative max-w-md flex-1">
  674. <Search
  675. size={16}
  676. className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
  677. />
  678. <input
  679. type="text"
  680. placeholder="Search library…"
  681. value={search}
  682. onChange={(e) => setSearch(e.target.value)}
  683. className="w-full rounded-lg bg-white/5 py-2 pl-9 pr-3 text-sm text-gray-200 placeholder-gray-500 outline-none focus:ring-1 focus:ring-indigo-500"
  684. />
  685. </div>
  686. <select
  687. value={sortBy ?? 'updatedAt'}
  688. onChange={(e) => setSortBy(e.target.value as LibraryFilters['sortBy'])}
  689. className="rounded-lg bg-[#1a1a1a] px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 [&>option]:bg-[#1a1a1a] [&>option]:text-gray-200"
  690. >
  691. <option value="title">Name</option>
  692. <option value="updatedAt">Date Updated</option>
  693. <option value="createdAt">Date Added</option>
  694. <option value="fileSize">File Size</option>
  695. <option value="lastApplied">Last Applied</option>
  696. <option value="appliedCount">Most Applied</option>
  697. </select>
  698. <button
  699. onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
  700. title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
  701. className="rounded-lg bg-white/5 px-2 py-2 text-sm text-gray-300 hover:bg-white/10"
  702. >
  703. {sortDir === 'asc' ? '↑' : '↓'}
  704. </button>
  705. <select
  706. value={pageSize}
  707. onChange={(e) => setPageSize(Number(e.target.value))}
  708. className="rounded-lg bg-[#1a1a1a] px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 [&>option]:bg-[#1a1a1a] [&>option]:text-gray-200"
  709. >
  710. <option value={50}>50 / page</option>
  711. <option value={100}>100 / page</option>
  712. <option value={200}>200 / page</option>
  713. </select>
  714. <button
  715. onClick={handleScan}
  716. disabled={scanning}
  717. title="Scan workshop folder"
  718. className="flex items-center gap-2 rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-300 hover:bg-white/10 disabled:opacity-50"
  719. >
  720. {scanning ? (
  721. <Loader2 size={14} className="animate-spin" />
  722. ) : (
  723. <RefreshCw size={14} />
  724. )}
  725. Scan
  726. </button>
  727. </div>
  728. {/* Filter row */}
  729. <div className="flex flex-wrap items-center gap-2 border-b border-white/5 px-4 py-2">
  730. <SlidersHorizontal size={12} className="text-gray-600" />
  731. <div className="flex gap-0.5">
  732. {WE_TYPES.filter((t) =>
  733. ['Scene', 'Video', 'Web', 'Application'].includes(t.tag)
  734. ).map((item) => (
  735. <Chip
  736. key={item.tag}
  737. label={item.label}
  738. active={filterState.types.includes(item.tag)}
  739. onClick={() => updateFilter('types', item.tag)}
  740. />
  741. ))}
  742. </div>
  743. <div className="h-3 w-px bg-white/10" />
  744. <div className="flex gap-0.5">
  745. {WE_AGE_RATINGS.map((item) => (
  746. <Chip
  747. key={item.tag}
  748. label={item.label}
  749. active={filterState.ageRatings.includes(item.tag)}
  750. onClick={() => updateFilter('ageRatings', item.tag)}
  751. />
  752. ))}
  753. </div>
  754. <div className="h-3 w-px bg-white/10" />
  755. <TagDropdown
  756. available={availableTags}
  757. selected={filterState.genres}
  758. onChange={(tags) => setFilterState((prev) => ({ ...prev, genres: tags }))}
  759. />
  760. {activeCount > 0 && (
  761. <button
  762. onClick={() => setFilterState(DEFAULT_STATE)}
  763. className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"
  764. >
  765. <X size={12} /> Clear
  766. </button>
  767. )}
  768. <span className="ml-auto text-xs text-gray-600">
  769. {selectedIds.size > 0
  770. ? `${selectedIds.size} selected / ${wallpapers.length} wallpapers`
  771. : `${wallpapers.length} wallpapers`}
  772. </span>
  773. </div>
  774. {scanResult && (
  775. <div className="border-b border-white/5 px-4 py-2 text-xs text-gray-400">
  776. Scan complete — {scanResult.imported} imported{scanResult.removed > 0 ? `, ${scanResult.removed} removed` : ''}
  777. </div>
  778. )}
  779. {/* Main content: folder sidebar + grid */}
  780. <div className="flex flex-1 overflow-hidden">
  781. {/* Folder sidebar */}
  782. <div className="flex w-48 flex-col border-r border-white/5 overflow-y-auto">
  783. <div className="flex items-center justify-between px-3 pt-3 pb-1">
  784. <span className="text-[10px] font-semibold uppercase tracking-wide text-gray-600">
  785. Folders
  786. </span>
  787. <div className="flex gap-1">
  788. <button
  789. onClick={handleImportWEConfig}
  790. title="Import from WE config.json"
  791. className="rounded p-0.5 text-gray-600 hover:text-gray-300"
  792. >
  793. <Upload size={12} />
  794. </button>
  795. <button
  796. onClick={handleCleanupFolders}
  797. title="Clean up folders (remove non-existent wallpapers)"
  798. className="rounded p-0.5 text-gray-600 hover:text-gray-300"
  799. >
  800. <Eraser size={12} />
  801. </button>
  802. <button
  803. onClick={() => { setCreatingFolder(true); setNewFolderName('') }}
  804. title="Create folder"
  805. className="rounded p-0.5 text-gray-600 hover:text-gray-300"
  806. >
  807. <FolderPlus size={12} />
  808. </button>
  809. </div>
  810. </div>
  811. {importStatus && (
  812. <div className="px-3 py-1.5 text-[10px] text-gray-400 border-b border-white/5">
  813. {importStatus}
  814. </div>
  815. )}
  816. {/* Default */}
  817. <button
  818. onClick={() => setActiveFolder(null)}
  819. className={clsx(
  820. 'flex items-center gap-2 px-3 py-1.5 text-xs',
  821. activeFolder === null
  822. ? 'bg-white/5 text-white'
  823. : 'text-gray-400 hover:bg-white/5 hover:text-gray-200'
  824. )}
  825. >
  826. <Folder size={14} />
  827. <span className="flex-1 text-left truncate">Default</span>
  828. <span className="text-gray-600">{unsortedCount}</span>
  829. </button>
  830. {/* Inline new folder input */}
  831. {creatingFolder && (
  832. <div className="flex items-center gap-1 px-3 py-1.5">
  833. <FolderPlus size={14} className="shrink-0 text-gray-500" />
  834. <input
  835. autoFocus
  836. value={newFolderName}
  837. onChange={(e) => setNewFolderName(e.target.value)}
  838. onBlur={handleCreateFolderSubmit}
  839. onKeyDown={(e) => {
  840. if (e.key === 'Enter') handleCreateFolderSubmit()
  841. if (e.key === 'Escape') setCreatingFolder(false)
  842. }}
  843. placeholder="Folder name…"
  844. className="flex-1 bg-transparent text-xs text-gray-200 outline-none placeholder-gray-600"
  845. />
  846. </div>
  847. )}
  848. {/* Folder list */}
  849. {folders.map((folder) => (
  850. <button
  851. key={folder.id}
  852. onClick={() => setActiveFolder(folder.id)}
  853. onContextMenu={(e) => {
  854. e.preventDefault()
  855. setFolderMenu({ folder, x: e.clientX, y: e.clientY })
  856. }}
  857. onDragOver={(e) => e.preventDefault()}
  858. onDrop={(e) => {
  859. const wid = e.dataTransfer.getData('text/wallpaper-id')
  860. if (wid) handleDrop(folder.id, wid)
  861. }}
  862. className={clsx(
  863. 'flex items-center gap-2 px-3 py-1.5 text-xs',
  864. activeFolder === folder.id
  865. ? 'bg-white/5 text-white'
  866. : 'text-gray-400 hover:bg-white/5 hover:text-gray-200'
  867. )}
  868. >
  869. {activeFolder === folder.id ? (
  870. <FolderOpen size={14} />
  871. ) : (
  872. <Folder size={14} />
  873. )}
  874. {renamingId === folder.id ? (
  875. <input
  876. autoFocus
  877. value={renameValue}
  878. onChange={(e) => setRenameValue(e.target.value)}
  879. onBlur={() => handleRename(folder.id)}
  880. onKeyDown={(e) => {
  881. if (e.key === 'Enter') handleRename(folder.id)
  882. if (e.key === 'Escape') setRenamingId(null)
  883. }}
  884. onClick={(e) => e.stopPropagation()}
  885. className="flex-1 bg-transparent text-xs text-gray-200 outline-none"
  886. />
  887. ) : (
  888. <span className="flex-1 truncate text-left">{folder.title}</span>
  889. )}
  890. <span className="text-gray-600">{folderCounts.get(folder.id) ?? 0}</span>
  891. </button>
  892. ))}
  893. </div>
  894. {/* Card grid */}
  895. <div
  896. ref={gridRef}
  897. className="relative flex-1 overflow-y-auto p-4 select-none"
  898. onMouseDown={handleMarqueeStart}
  899. onMouseMove={handleMarqueeMove}
  900. onMouseUp={handleMarqueeEnd}
  901. onMouseLeave={handleMarqueeEnd}
  902. >
  903. {isLoading && (
  904. <div className="flex h-40 items-center justify-center text-gray-500">
  905. <Loader2 size={24} className="animate-spin" />
  906. </div>
  907. )}
  908. {!isLoading && wallpapers.length === 0 && (
  909. <div className="flex h-40 flex-col items-center justify-center gap-2 text-gray-500">
  910. <p>
  911. {activeFolder
  912. ? 'This folder is empty — drag wallpapers here'
  913. : activeCount > 0
  914. ? 'No wallpapers match your filters'
  915. : 'Your library is empty'}
  916. </p>
  917. {activeCount === 0 && !activeFolder && (
  918. <p className="text-xs">
  919. Click Scan to import wallpapers from your workshop folder
  920. </p>
  921. )}
  922. </div>
  923. )}
  924. {/* Folder cards in "All" view */}
  925. {!activeFolder && folders.length > 0 && (
  926. <div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-4 mb-4">
  927. {folders.map((folder) => (
  928. <div
  929. key={folder.id}
  930. onClick={() => setActiveFolder(folder.id)}
  931. onContextMenu={(e) => {
  932. e.preventDefault()
  933. setFolderMenu({ folder, x: e.clientX, y: e.clientY })
  934. }}
  935. onDragOver={(e) => {
  936. e.preventDefault()
  937. e.currentTarget.classList.add('ring-2', 'ring-indigo-500')
  938. }}
  939. onDragLeave={(e) => {
  940. e.currentTarget.classList.remove('ring-2', 'ring-indigo-500')
  941. }}
  942. onDrop={(e) => {
  943. e.currentTarget.classList.remove('ring-2', 'ring-indigo-500')
  944. const wid = e.dataTransfer.getData('text/wallpaper-id')
  945. if (wid) handleDrop(folder.id, wid)
  946. }}
  947. className="group flex cursor-pointer items-center gap-3 rounded-lg bg-[#1a1a1a] p-4 transition-all hover:ring-1 hover:ring-indigo-500/50"
  948. >
  949. <Folder size={28} className="shrink-0 text-indigo-400" />
  950. <div className="min-w-0 flex-1">
  951. <p className="truncate text-sm font-medium text-gray-200">
  952. {folder.title}
  953. </p>
  954. <p className="text-xs text-gray-500">
  955. {folderCounts.get(folder.id) ?? 0} items
  956. </p>
  957. </div>
  958. </div>
  959. ))}
  960. </div>
  961. )}
  962. <div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-4">
  963. {wallpapers.map((wallpaper) => (
  964. <WallpaperCard
  965. key={wallpaper.id}
  966. wallpaper={wallpaper}
  967. selected={selectedIds.has(wallpaper.id)}
  968. lweInstalled={lweStatus?.installed ?? false}
  969. isLiked={votedSet.has(wallpaper.id)}
  970. onApplied={() =>
  971. queryClient.invalidateQueries({ queryKey: ['library'] })
  972. }
  973. onLiked={() => {
  974. queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
  975. old ? [...old, wallpaper.id] : [wallpaper.id]
  976. )
  977. queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })
  978. }}
  979. onUnsubscribed={() => {
  980. queryClient.invalidateQueries({ queryKey: ['library'] })
  981. refetchFolders()
  982. }}
  983. onSelect={(e) => handleCardSelect(wallpaper.id, e)}
  984. onContextMenu={(e) => openWallpaperCtxMenu(e, wallpaper.id)}
  985. />
  986. ))}
  987. </div>
  988. {/* Pagination */}
  989. {totalPages > 1 && (
  990. <div className="mt-6 flex items-center justify-center gap-2">
  991. <button
  992. onClick={() => setPage((p) => Math.max(1, p - 1))}
  993. disabled={safePage <= 1}
  994. className="rounded-lg bg-white/5 px-3 py-1.5 text-sm text-gray-300 hover:bg-white/10 disabled:opacity-30"
  995. >
  996. Prev
  997. </button>
  998. <span className="text-xs text-gray-500">
  999. Page {safePage} of {totalPages} ({totalItems} items)
  1000. </span>
  1001. <button
  1002. onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
  1003. disabled={safePage >= totalPages}
  1004. className="rounded-lg bg-white/5 px-3 py-1.5 text-sm text-gray-300 hover:bg-white/10 disabled:opacity-30"
  1005. >
  1006. Next
  1007. </button>
  1008. </div>
  1009. )}
  1010. {/* Marquee selection rectangle */}
  1011. {marqueeRect && marqueeRect.width > 3 && marqueeRect.height > 3 && (
  1012. <div
  1013. className="pointer-events-none absolute z-20 border border-indigo-500 bg-indigo-500/15"
  1014. style={{
  1015. left: marqueeRect.left,
  1016. top: marqueeRect.top,
  1017. width: marqueeRect.width,
  1018. height: marqueeRect.height
  1019. }}
  1020. />
  1021. )}
  1022. </div>
  1023. </div>
  1024. {/* Folder context menu */}
  1025. {folderMenu && (
  1026. <FolderMenu
  1027. folder={folderMenu.folder}
  1028. x={folderMenu.x}
  1029. y={folderMenu.y}
  1030. onClose={() => setFolderMenu(null)}
  1031. onRename={() => {
  1032. setRenamingId(folderMenu.folder.id)
  1033. setRenameValue(folderMenu.folder.title)
  1034. setFolderMenu(null)
  1035. }}
  1036. onDelete={() => handleDeleteFolder(folderMenu.folder.id)}
  1037. />
  1038. )}
  1039. {/* Wallpaper context menu */}
  1040. {ctxMenu && (
  1041. <div
  1042. ref={ctxMenuRef}
  1043. className="fixed z-50 min-w-[200px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
  1044. style={{ left: ctxMenu.x, top: ctxMenu.y }}
  1045. >
  1046. {ctxMenu.ids.length > 1 && (
  1047. <div className="px-3 py-1 text-xs text-gray-600 border-b border-white/5 mb-1">
  1048. {ctxMenu.ids.length} items selected
  1049. </div>
  1050. )}
  1051. <button
  1052. onClick={ctxUnsubscribe}
  1053. className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"
  1054. >
  1055. Unsubscribe
  1056. </button>
  1057. <div className="my-1 border-t border-white/5" />
  1058. <button
  1059. onClick={() => ctxVote(true)}
  1060. className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
  1061. >
  1062. <ThumbsUp size={12} /> Like
  1063. </button>
  1064. <button
  1065. onClick={() => ctxVote(false)}
  1066. className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
  1067. >
  1068. <ThumbsDown size={12} /> Dislike
  1069. </button>
  1070. <div className="my-1 border-t border-white/5" />
  1071. {/* File operations */}
  1072. <button
  1073. onClick={ctxOpenInSteam}
  1074. className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
  1075. >
  1076. <ExternalLink size={12} /> Open in Steam Workshop
  1077. </button>
  1078. <button
  1079. onClick={ctxOpenLocally}
  1080. className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
  1081. >
  1082. <FolderOpen size={12} /> Open wallpaper locally
  1083. </button>
  1084. {/* Preview video - only for video type wallpapers */}
  1085. {ctxHasVideo && (
  1086. <button
  1087. onClick={ctxPreviewVideo}
  1088. className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
  1089. >
  1090. <Eye size={12} /> Preview in media player
  1091. </button>
  1092. )}
  1093. <div className="my-1 border-t border-white/5" />
  1094. {/* Move to folder - with submenu */}
  1095. <div className="relative">
  1096. <button
  1097. onClick={() => setCtxMenu((prev) => prev ? { ...prev, showFolderSub: !prev.showFolderSub } : null)}
  1098. className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
  1099. >
  1100. <FolderInput size={12} /> Move to folder
  1101. <ChevronRight size={12} className="ml-auto" />
  1102. </button>
  1103. {ctxMenu.showFolderSub && (
  1104. <div className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl">
  1105. <button
  1106. onClick={ctxMoveToDefault}
  1107. className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
  1108. >
  1109. <Folder size={12} /> Default
  1110. </button>
  1111. {folders.length > 0 && <div className="my-1 border-t border-white/5" />}
  1112. {folders.map((f) => (
  1113. <button
  1114. key={f.id}
  1115. onClick={() => ctxMoveToFolder(f.id)}
  1116. className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
  1117. >
  1118. <Folder size={12} /> {f.title}
  1119. </button>
  1120. ))}
  1121. </div>
  1122. )}
  1123. </div>
  1124. {/* Remove from folder - only when inside a folder */}
  1125. {activeFolder && (
  1126. <button
  1127. onClick={ctxRemoveFromFolder}
  1128. className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"
  1129. >
  1130. <Trash2 size={12} /> Remove from folder
  1131. </button>
  1132. )}
  1133. </div>
  1134. )}
  1135. </div>
  1136. )
  1137. }