UwU 7 hónapja
szülő
commit
52cccb1a65

+ 5 - 0
src/main/ipc/folders.handlers.ts

@@ -31,4 +31,9 @@ export function registerFolderHandlers(): void {
   ipcMain.handle(IpcChannels.FOLDERS_IMPORT_WE_CONFIG, (_e, configPath: string) => {
     return lib.importWEConfig(configPath)
   })
+
+  ipcMain.handle(IpcChannels.FOLDERS_CLEANUP, () => {
+    const removed = lib.cleanupFolders()
+    return { removed }
+  })
 }

+ 10 - 0
src/main/ipc/shell.handlers.ts

@@ -26,4 +26,14 @@ export function registerShellHandlers(): void {
     const result = await shell.openPath(resolved)
     return { ok: !result, error: result || undefined }
   })
+
+  // Open a URL or protocol link (e.g. steam://, https://) with the OS handler
+  ipcMain.handle(IpcChannels.SHELL_OPEN_EXTERNAL, async (_e, url: string) => {
+    try {
+      await shell.openExternal(url)
+      return { ok: true }
+    } catch (err) {
+      return { ok: false, error: (err as Error).message }
+    }
+  })
 }

+ 28 - 0
src/main/services/library.service.ts

@@ -237,6 +237,10 @@ export function scanLibrary(): { imported: number; skipped: number } {
   }
 
   console.log(`[Library] Scan complete: ${imported} imported, ${skipped} skipped`)
+
+  // Auto-cleanup folder items that reference non-existent wallpapers
+  cleanupFolders()
+
   return { imported, skipped }
 }
 
@@ -289,6 +293,30 @@ export function removeItemsFromFolder(folderId: string, itemIds: string[]): Wall
   return folder
 }
 
+/**
+ * Remove folder item IDs that don't exist in the wallpapers store.
+ * Returns the number of stale IDs removed.
+ */
+export function cleanupFolders(): number {
+  const wallpapers = store.get('wallpapers')
+  const validIds = new Set(Object.keys(wallpapers))
+  const folders = store.get('folders')
+  let removed = 0
+
+  for (const folder of folders) {
+    const before = folder.items.length
+    folder.items = folder.items.filter((id) => validIds.has(id))
+    removed += before - folder.items.length
+  }
+
+  if (removed > 0) {
+    store.set('folders', folders)
+    console.log(`[Library] Folder cleanup: removed ${removed} stale item(s)`)
+  }
+
+  return removed
+}
+
 export function importWEConfig(configPath: string): { folders: number; playlists: number } {
   const raw = fs.readFileSync(configPath, 'utf8')
   const data = JSON.parse(raw)

+ 6 - 2
src/preload/index.ts

@@ -87,7 +87,9 @@ const api = {
     removeItems: (folderId: string, itemIds: string[]): Promise<WallpaperFolder | null> =>
       ipcRenderer.invoke(IpcChannels.FOLDERS_REMOVE_ITEMS, folderId, itemIds),
     importWEConfig: (configPath: string): Promise<{ folders: number; playlists: number }> =>
-      ipcRenderer.invoke(IpcChannels.FOLDERS_IMPORT_WE_CONFIG, configPath)
+      ipcRenderer.invoke(IpcChannels.FOLDERS_IMPORT_WE_CONFIG, configPath),
+    cleanup: (): Promise<{ removed: number }> =>
+      ipcRenderer.invoke(IpcChannels.FOLDERS_CLEANUP)
   },
 
   wallpaper: {
@@ -105,7 +107,9 @@ const api = {
     openWithDefault: (filePath: string): Promise<{ ok: boolean; error?: string }> =>
       ipcRenderer.invoke(IpcChannels.SHELL_OPEN_WITH_DEFAULT, filePath),
     openPath: (targetPath: string): Promise<{ ok: boolean; error?: string }> =>
-      ipcRenderer.invoke(IpcChannels.SHELL_OPEN_PATH, targetPath)
+      ipcRenderer.invoke(IpcChannels.SHELL_OPEN_PATH, targetPath),
+    openExternal: (url: string): Promise<{ ok: boolean; error?: string }> =>
+      ipcRenderer.invoke(IpcChannels.SHELL_OPEN_EXTERNAL, url)
   },
 
   on: {

+ 133 - 72
src/renderer/src/components/library/LibraryView.tsx

@@ -18,7 +18,8 @@ import {
   FolderInput,
   Eye,
   ThumbsUp,
-  ThumbsDown
+  ThumbsDown,
+  Eraser
 } from 'lucide-react'
 import WallpaperCard from './WallpaperCard'
 import type { LibraryFilters, WallpaperFolder } from '@shared/types'
@@ -183,27 +184,37 @@ function FolderMenu({
   onRename: () => void
   onDelete: () => void
 }) {
+  const ref = useRef<HTMLDivElement>(null)
+
+  useEffect(() => {
+    function onMouseDown(e: MouseEvent) {
+      if (ref.current && !ref.current.contains(e.target as Node)) {
+        onClose()
+      }
+    }
+    document.addEventListener('mousedown', onMouseDown)
+    return () => document.removeEventListener('mousedown', onMouseDown)
+  }, [onClose])
+
   return (
-    <>
-      <div className="fixed inset-0 z-40" onClick={onClose} />
-      <div
-        className="fixed z-50 min-w-[140px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
-        style={{ left: x, top: y }}
+    <div
+      ref={ref}
+      className="fixed z-50 min-w-[140px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
+      style={{ left: x, top: y }}
+    >
+      <button
+        onClick={onRename}
+        className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
       >
-        <button
-          onClick={onRename}
-          className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-        >
-          <Pencil size={12} /> Rename
-        </button>
-        <button
-          onClick={onDelete}
-          className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"
-        >
-          <Trash2 size={12} /> Delete
-        </button>
-      </div>
-    </>
+        <Pencil size={12} /> Rename
+      </button>
+      <button
+        onClick={onDelete}
+        className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"
+      >
+        <Trash2 size={12} /> Delete
+      </button>
+    </div>
   )
 }
 
@@ -254,6 +265,19 @@ export default function LibraryView() {
     ids: string[]
     showFolderSub: boolean
   } | null>(null)
+  const ctxMenuRef = useRef<HTMLDivElement>(null)
+
+  // Close wallpaper context menu on outside click (mousedown so right-click on another card works)
+  useEffect(() => {
+    if (!ctxMenu) return
+    function onMouseDown(e: MouseEvent) {
+      if (ctxMenuRef.current && !ctxMenuRef.current.contains(e.target as Node)) {
+        setCtxMenu(null)
+      }
+    }
+    document.addEventListener('mousedown', onMouseDown)
+    return () => document.removeEventListener('mousedown', onMouseDown)
+  }, [ctxMenu])
 
   useEffect(() => {
     localStorage.setItem(STORAGE_KEY, JSON.stringify(filterState))
@@ -310,18 +334,35 @@ export default function LibraryView() {
     })
   }, [allWallpapers, filterState])
 
-  // Set of all IDs that belong to at least one folder
+  // Set of all existing library wallpaper IDs (for cross-referencing)
+  const existingIds = useMemo(
+    () => new Set(allWallpapers.map((w) => w.id)),
+    [allWallpapers]
+  )
+
+  // Set of all IDs that belong to at least one folder AND exist in the library
   const allFolderItemIds = useMemo(() => {
     const set = new Set<string>()
     for (const f of folders) {
-      for (const id of f.items) set.add(id)
+      for (const id of f.items) {
+        if (existingIds.has(id)) set.add(id)
+      }
     }
     return set
-  }, [folders])
+  }, [folders, existingIds])
+
+  // Real item count per folder (only items that exist in the library)
+  const folderCounts = useMemo(() => {
+    const map = new Map<string, number>()
+    for (const f of folders) {
+      map.set(f.id, f.items.filter((id) => existingIds.has(id)).length)
+    }
+    return map
+  }, [folders, existingIds])
 
   // Folder filtering:
-  // "All" = everything NOT inside any folder (unsorted)
-  // Specific folder = only that folder's items
+  // Default (null) = everything NOT inside any folder (unsorted)
+  // Specific folder = only that folder's items (wallpapers can be in multiple folders)
   const allWallpapersForView = useMemo(() => {
     if (!activeFolder) {
       return filtered.filter((w) => !allFolderItemIds.has(w.id))
@@ -397,6 +438,16 @@ export default function LibraryView() {
 
   const [importStatus, setImportStatus] = useState<string | null>(null)
 
+  async function handleCleanupFolders() {
+    const { removed } = await window.electronAPI.folders.cleanup()
+    refetchFolders()
+    setImportStatus(
+      removed > 0
+        ? `Cleaned up ${removed} non-existent wallpaper(s) from folders.`
+        : 'All folder items are valid — nothing to clean up.'
+    )
+  }
+
   async function handleRename(id: string) {
     if (!renameValue.trim()) return
     await window.electronAPI.folders.rename(id, renameValue.trim())
@@ -608,20 +659,15 @@ export default function LibraryView() {
     return wallpapers.filter((w) => idSet.has(w.id))
   }, [ctxMenu, wallpapers])
 
-  async function ctxSubscribe() {
-    if (!ctxMenu) return
-    for (const id of ctxMenu.ids) {
-      try { await window.electronAPI.steam.subscribe(id) } catch {}
-    }
-    closeCtxMenu()
-  }
-
   async function ctxUnsubscribe() {
     if (!ctxMenu) return
     for (const id of ctxMenu.ids) {
       try { await window.electronAPI.steam.unsubscribe(id) } catch {}
+      try { await window.electronAPI.library.delete(id) } catch {}
     }
     closeCtxMenu()
+    queryClient.invalidateQueries({ queryKey: ['library'] })
+    refetchFolders()
   }
 
   async function ctxVote(up: boolean) {
@@ -635,17 +681,17 @@ export default function LibraryView() {
   function ctxOpenInSteam() {
     if (!ctxMenu) return
     for (const id of ctxMenu.ids) {
-      window.open(`https://steamcommunity.com/sharedfiles/filedetails/?id=${id}`)
+      window.electronAPI.shell.openExternal(`steam://url/CommunityFilePage/${id}`)
     }
     closeCtxMenu()
   }
 
   async function ctxOpenLocally() {
     if (!ctxMenu) return
-    // Open the first item's local folder in the file manager
-    const w = ctxWallpapers[0]
-    if (w?.localPath) {
-      await window.electronAPI.shell.openInFileManager(w.localPath)
+    for (const w of ctxWallpapers) {
+      if (w.localPath) {
+        await window.electronAPI.shell.openPath(w.localPath)
+      }
     }
     closeCtxMenu()
   }
@@ -667,6 +713,19 @@ export default function LibraryView() {
     closeCtxMenu()
   }
 
+  async function ctxMoveToDefault() {
+    if (!ctxMenu) return
+    // Remove from every folder that contains these items
+    for (const folder of folders) {
+      const overlap = ctxMenu.ids.filter((id) => folder.items.includes(id))
+      if (overlap.length > 0) {
+        await window.electronAPI.folders.removeItems(folder.id, overlap)
+      }
+    }
+    refetchFolders()
+    closeCtxMenu()
+  }
+
   async function ctxRemoveFromFolder() {
     if (!ctxMenu || !activeFolder) return
     await window.electronAPI.folders.removeItems(activeFolder, ctxMenu.ids)
@@ -813,6 +872,13 @@ export default function LibraryView() {
               >
                 <Upload size={12} />
               </button>
+              <button
+                onClick={handleCleanupFolders}
+                title="Clean up folders (remove non-existent wallpapers)"
+                className="rounded p-0.5 text-gray-600 hover:text-gray-300"
+              >
+                <Eraser size={12} />
+              </button>
               <button
                 onClick={() => { setCreatingFolder(true); setNewFolderName('') }}
                 title="Create folder"
@@ -829,7 +895,7 @@ export default function LibraryView() {
             </div>
           )}
 
-          {/* All */}
+          {/* Default */}
           <button
             onClick={() => setActiveFolder(null)}
             className={clsx(
@@ -840,7 +906,7 @@ export default function LibraryView() {
             )}
           >
             <Folder size={14} />
-            <span className="flex-1 text-left truncate">All</span>
+            <span className="flex-1 text-left truncate">Default</span>
             <span className="text-gray-600">{unsortedCount}</span>
           </button>
 
@@ -905,7 +971,7 @@ export default function LibraryView() {
               ) : (
                 <span className="flex-1 truncate text-left">{folder.title}</span>
               )}
-              <span className="text-gray-600">{folder.items.length}</span>
+              <span className="text-gray-600">{folderCounts.get(folder.id) ?? 0}</span>
             </button>
           ))}
         </div>
@@ -971,7 +1037,7 @@ export default function LibraryView() {
                       {folder.title}
                     </p>
                     <p className="text-xs text-gray-500">
-                      {folder.items.length} items
+                      {folderCounts.get(folder.id) ?? 0} items
                     </p>
                   </div>
                 </div>
@@ -1050,33 +1116,25 @@ export default function LibraryView() {
 
       {/* Wallpaper context menu */}
       {ctxMenu && (
-        <>
-          <div className="fixed inset-0 z-40" onClick={closeCtxMenu} />
-          <div
-            className="fixed z-50 min-w-[200px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
-            style={{ left: ctxMenu.x, top: ctxMenu.y }}
-          >
-            {ctxMenu.ids.length > 1 && (
-              <div className="px-3 py-1 text-xs text-gray-600 border-b border-white/5 mb-1">
-                {ctxMenu.ids.length} items selected
-              </div>
-            )}
+        <div
+          ref={ctxMenuRef}
+          className="fixed z-50 min-w-[200px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
+          style={{ left: ctxMenu.x, top: ctxMenu.y }}
+        >
+          {ctxMenu.ids.length > 1 && (
+            <div className="px-3 py-1 text-xs text-gray-600 border-b border-white/5 mb-1">
+              {ctxMenu.ids.length} items selected
+            </div>
+          )}
 
-            {/* Steam actions */}
-            <button
-              onClick={ctxSubscribe}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              Subscribe
-            </button>
-            <button
-              onClick={ctxUnsubscribe}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              Unsubscribe
-            </button>
+          <button
+            onClick={ctxUnsubscribe}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"
+          >
+            Unsubscribe
+          </button>
 
-            <div className="my-1 border-t border-white/5" />
+          <div className="my-1 border-t border-white/5" />
 
             <button
               onClick={() => ctxVote(true)}
@@ -1130,9 +1188,13 @@ export default function LibraryView() {
               </button>
               {ctxMenu.showFolderSub && (
                 <div className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl">
-                  {folders.length === 0 && (
-                    <p className="px-3 py-1.5 text-xs text-gray-500">No folders yet</p>
-                  )}
+                  <button
+                    onClick={ctxMoveToDefault}
+                    className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+                  >
+                    <Folder size={12} /> Default
+                  </button>
+                  {folders.length > 0 && <div className="my-1 border-t border-white/5" />}
                   {folders.map((f) => (
                     <button
                       key={f.id}
@@ -1155,8 +1217,7 @@ export default function LibraryView() {
                 <Trash2 size={12} /> Remove from folder
               </button>
             )}
-          </div>
-        </>
+        </div>
       )}
     </div>
   )

+ 55 - 46
src/renderer/src/components/workshop/WorkshopBrowser.tsx

@@ -143,6 +143,18 @@ export default function WorkshopBrowser() {
   const [page, setPage] = useState(1)
   const [pageSize, setPageSize] = useState(50)
 
+  // Close context menu on outside click (mousedown so right-click on another card works)
+  useEffect(() => {
+    if (!ctxMenu) return
+    function onMouseDown(e: MouseEvent) {
+      if (ctxRef.current && !ctxRef.current.contains(e.target as Node)) {
+        setCtxMenu(null)
+      }
+    }
+    document.addEventListener('mousedown', onMouseDown)
+    return () => document.removeEventListener('mousedown', onMouseDown)
+  }, [ctxMenu])
+
   useEffect(() => {
     saveFilters(filters)
   }, [filters])
@@ -248,7 +260,7 @@ export default function WorkshopBrowser() {
   function ctxOpenInSteam() {
     if (!ctxMenu) return
     for (const id of ctxMenu.ids) {
-      window.open(`https://steamcommunity.com/sharedfiles/filedetails/?id=${id}`)
+      window.electronAPI.shell.openExternal(`steam://url/CommunityFilePage/${id}`)
     }
     closeCtxMenu()
   }
@@ -605,52 +617,49 @@ export default function WorkshopBrowser() {
 
       {/* Context menu */}
       {ctxMenu && (
-        <>
-          <div className="fixed inset-0 z-40" onClick={closeCtxMenu} />
-          <div
-            ref={ctxRef}
-            className="fixed z-50 min-w-[180px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
-            style={{ left: ctxMenu.x, top: ctxMenu.y }}
+        <div
+          ref={ctxRef}
+          className="fixed z-50 min-w-[180px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
+          style={{ left: ctxMenu.x, top: ctxMenu.y }}
+        >
+          {ctxMenu.ids.length > 1 && (
+            <div className="px-3 py-1 text-xs text-gray-600 border-b border-white/5 mb-1">
+              {ctxMenu.ids.length} items selected
+            </div>
+          )}
+          <button
+            onClick={ctxSubscribe}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
           >
-            {ctxMenu.ids.length > 1 && (
-              <div className="px-3 py-1 text-xs text-gray-600 border-b border-white/5 mb-1">
-                {ctxMenu.ids.length} items selected
-              </div>
-            )}
-            <button
-              onClick={ctxSubscribe}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              Subscribe
-            </button>
-            <button
-              onClick={ctxUnsubscribe}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              Unsubscribe
-            </button>
-            <div className="my-1 border-t border-white/5" />
-            <button
-              onClick={() => ctxVote(true)}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              👍 Like
-            </button>
-            <button
-              onClick={() => ctxVote(false)}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              👎 Dislike
-            </button>
-            <div className="my-1 border-t border-white/5" />
-            <button
-              onClick={ctxOpenInSteam}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              Open in Steam Workshop
-            </button>
-          </div>
-        </>
+            Subscribe
+          </button>
+          <button
+            onClick={ctxUnsubscribe}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            Unsubscribe
+          </button>
+          <div className="my-1 border-t border-white/5" />
+          <button
+            onClick={() => ctxVote(true)}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            Like
+          </button>
+          <button
+            onClick={() => ctxVote(false)}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            Dislike
+          </button>
+          <div className="my-1 border-t border-white/5" />
+          <button
+            onClick={ctxOpenInSteam}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            Open in Steam Workshop
+          </button>
+        </div>
       )}
     </div>
   )

+ 2 - 0
src/shared/ipc-channels.ts

@@ -33,6 +33,7 @@ export const IpcChannels = {
   FOLDERS_ADD_ITEMS: 'folders:add-items',
   FOLDERS_REMOVE_ITEMS: 'folders:remove-items',
   FOLDERS_IMPORT_WE_CONFIG: 'folders:import-we-config',
+  FOLDERS_CLEANUP: 'folders:cleanup',
 
   // Wallpaper application
   WALLPAPER_APPLY: 'wallpaper:apply',
@@ -51,6 +52,7 @@ export const IpcChannels = {
   SHELL_OPEN_PATH: 'shell:open-path',
   SHELL_OPEN_IN_FILE_MANAGER: 'shell:open-in-file-manager',
   SHELL_OPEN_WITH_DEFAULT: 'shell:open-with-default',
+  SHELL_OPEN_EXTERNAL: 'shell:open-external',
 
   // Events pushed from main to renderer
   EVENT_DOWNLOAD_PROGRESS: 'event:download-progress',