Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.PATCHED.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# CHANGELOG - PATCHED

**v0.9.9-patched.nightly.494.4**
- Downloads: clicking a cover now opens the game entry inside Atlas Library/ Catalog, and clicking the build label (e.g. Full Archive) opens the source thread in your browser. [#406](https://github.com/towerwatchman/Atlas/pull/406)

**v0.9.9-patched.nightly.494.3**
- Importer and Library folder scheme now support `{atlasId}`, so installs can be matched back to AtlasDB on re-import / rebuild.[#404](https://github.com/towerwatchman/Atlas/pull/404)

Expand Down
3 changes: 2 additions & 1 deletion PATCHES.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# CUMMULATIVE PATCHES

## Pending Patched Changes
*Changes that's already on the fork and waiting to be reviewed for merge into original Atlas*
*Changes that's already on the fork and waiting to be reviewed for merge into original Atlas*
- Downloads: clicking a cover now opens the game entry inside Atlas Library/ Catalog, and clicking the build label (e.g. Full Archive) opens the source thread in your browser. [#406](https://github.com/towerwatchman/Atlas/pull/406)
- Importer and Library folder scheme now support `{atlasId}`, so installs can be matched back to AtlasDB on re-import / rebuild.[#404](https://github.com/towerwatchman/Atlas/pull/404)
- Removed the stale restart popup and hint on the Show debug console toggle — it applies immediately to all open windows.[#399](https://github.com/towerwatchman/Atlas/pull/399)
- (Dev-Only) DevTools no longer auto-opens in dev mode unless explicitly enabled in config.
Expand Down
27 changes: 27 additions & 0 deletions electron/ipc/games.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const { runDatabaseAudit, getInvalidMappingCount } = require('../db/audit')
const { getCatalogIndexStatus, rebuildCatalogIndex } = require('../db/catalogIndex')
const { runClientAudit, repairClientAuditSection } = require('../db/clientAudit')
const { auditSeasonMerges, applySeasonMerge, applyAllSeasonMerges } = require('../db/seasonMerge')
const { parseCatalogRef } = require('../library/catalogRef')

// Guards against two full rebuilds interleaving their chunked transactions on
// the single shared sqlite connection.
Expand Down Expand Up @@ -448,6 +449,32 @@ function registerGamesHandlers(ctx) {
return { total: Number(result?.total || 0) }
})

// One Browse entry by catalog ref. Single-row fetch so a banner click
// doesn't page the catalog.
ipcMain.handle('get-catalog-entry', async (_, ref) => {
if (!BROWSE_MODE_ENABLED) return { success: false, error: 'Browse is not available' }
const raw = typeof ref === 'string' ? ref : ref?.ref
const parsed = parseCatalogRef(raw)
if (!parsed) return { success: false, error: 'Unknown catalog entry' }
try {
const result = await getCatalogGames(
getAssetBasePath(),
process.defaultApp,
{
hydrateKeys: [`${parsed.kind}:${parsed.id}`],
offset: 0,
limit: 1,
mediaStorageMode: getMediaStorageMode(),
},
)
const game = result?.games?.[0] || null
if (!game) return { success: false, error: 'Catalog entry not found' }
return { success: true, game: withMedia(game) }
} catch (err) {
return { success: false, error: err?.message || String(err) }
}
})

ipcMain.handle('wishlist-add', async (_, entry = {}) => {
return await addWishlistEntry(entry)
})
Expand Down
1 change: 1 addition & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
},
getCatalogGames: (args = {}) => ipcRenderer.invoke("get-catalog-games", args),
getCatalogCount: (args = {}) => ipcRenderer.invoke("get-catalog-count", args),
getCatalogEntry: (ref) => ipcRenderer.invoke("get-catalog-entry", ref),
addWishlistEntry: (entry) => ipcRenderer.invoke("wishlist-add", entry),
removeWishlistEntry: (identity) =>
ipcRenderer.invoke("wishlist-remove", identity),
Expand Down
4 changes: 2 additions & 2 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1982,7 +1982,7 @@ const App = () => {
<div
className="w-[60px] bg-accent flex items-center justify-center h-[70px] z-50 cursor-pointer -webkit-app-region-no-drag shadow-[0_8px_8px_-8px_rgba(0,0,0,0.5)]"
onClick={goHome}
title="Back to Library"
title="Home"
>
{logoVariant === 'colored' ? (
<img
Expand Down Expand Up @@ -2021,7 +2021,7 @@ const App = () => {
<div
className="text-shadow-fx text-glow-fx page-titles text-accent font-semibold cursor-pointer -webkit-app-region-no-drag"
onClick={goHome}
title="Back to Library"
title="Home"
>
{viewTitle}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/detail/page/ActionBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export default function ActionBar({
{showBack && (
<button
onClick={onBack}
title="Back to Library"
title="Back"
style={{
...ACTION_BTN,
background: 'var(--color-primary, #19191c)',
Expand Down
2 changes: 1 addition & 1 deletion src/components/detail/page/HeroBanner.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export default function HeroBanner({ game, heroOverride = null, bannerRef, banne
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(0,0,0,0.65)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(0,0,0,0.45)' }}
>
<i className="fas fa-arrow-left" style={{ marginRight: 6 }}></i>Back to Library
<i className="fas fa-arrow-left" style={{ marginRight: 6 }}></i>Back
</button>
</div>

Expand Down
58 changes: 30 additions & 28 deletions src/components/downloads/DownloadsPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import HostIcon from './HostIcon.jsx'
import { toMediaSrc } from '../../utils/mediaSrc.js'
import { describeBuild } from './linkSections.js'
import { threadUrlForGame } from './threadUrl.js'
import { keepsBothVersions, bannerTargetFor } from './cardFacts.js'
import { keepsBothVersions, downloadBannerTarget, downloadCatalogRef } from './cardFacts.js'

// ── Downloads page ───────────────────────────────────────────────────────────
//
Expand Down Expand Up @@ -528,16 +528,13 @@ export default function DownloadsPage({ gamesByRecordId = new Map(), onOpenGame,
? (item.totalBytes - item.receivedBytes) / rate
: null
const transferring = item.state === 'downloading'
// Where the banner goes. Installed titles open inside Atlas; everything else
// opens the thread it came from, which is the page a user wants while they
// are still deciding. A download with no library record -- Browse, wishlist --
// has neither, and the banner stays inert rather than becoming a dead link.
const gameThreadUrl = threadUrlForGame(game)
// The download's own page on the host, behind the host name. Guarded on the
// scheme: a row can carry a non-http url and opening one externally is not
// something to do on the strength of a substring.
// Banner stays in-app. Installed opens the library entry, otherwise the Browse entry when known.
const catalogRef = downloadCatalogRef(item, game)
const bannerTarget = downloadBannerTarget({ game, catalogRef })
const hostUrl = /^https?:\/\//i.test(String(item.url || '')) ? item.url : ''
const bannerTarget = bannerTargetFor({ game, threadUrl: gameThreadUrl, hostUrl })
const threadUrl = threadUrlForGame(game)
const buildDesc = describeBuild(item.buildLabel)
const buildChipClass = 'inline-block max-w-full truncate rounded border border-border bg-tertiary/50 px-1.5 py-0.5 text-[11px] text-text'
const working = WORKING_STATES.includes(item.state)
const errored = item.state === 'failed' || item.state === 'install_failed'
const tone = errored ? 'danger' : item.state === 'done' ? 'success' : 'accent'
Expand All @@ -549,23 +546,15 @@ export default function DownloadsPage({ gamesByRecordId = new Map(), onOpenGame,
>
<button
type="button"
onClick={() => {
onClick={async () => {
if (bannerTarget === 'game') onOpenGame?.(game)
else if (bannerTarget === 'thread') window.electronAPI.openExternalUrl?.(gameThreadUrl)
else if (bannerTarget === 'host') window.electronAPI.openExternalUrl?.(hostUrl)
else if (bannerTarget === 'catalog') {
const result = await window.electronAPI.getCatalogEntry?.(catalogRef)
if (result?.success) onOpenGame?.(result.game)
}
}}
disabled={!bannerTarget}
// Said out loud, because one control doing two different things with
// no visible difference is otherwise a coin flip for the user.
title={
bannerTarget === 'game'
? `Open ${item.title} in Atlas`
: bannerTarget === 'thread'
? `Open the ${item.title} thread in your browser`
: bannerTarget === 'host'
? `Open this download's page on ${item.host || 'the host'}`
: undefined
}
title={bannerTarget ? 'Open game entry in Atlas' : undefined}
className={bannerTarget ? 'cursor-pointer' : 'cursor-default'}
>
<Cover game={game} item={item} title={item.title} />
Expand All @@ -582,11 +571,24 @@ export default function DownloadsPage({ gamesByRecordId = new Map(), onOpenGame,
running them together is what made an old season, a compressed
build and the current one three identical-looking rows. Rendered
only when the row actually recorded one - see describeBuild. */}
{describeBuild(item.buildLabel) && (
{buildDesc && (
<div className="mt-1">
<span className="inline-block max-w-full truncate rounded border border-border bg-tertiary/50 px-1.5 py-0.5 text-[11px] text-text">
{describeBuild(item.buildLabel)}
</span>
{threadUrl || catalogRef ? (
<button
type="button"
onClick={async () => {
const url = threadUrl
|| threadUrlForGame((await window.electronAPI.getCatalogEntry?.(catalogRef))?.game)
if (url) window.electronAPI.openExternalUrl?.(url)
}}
title="Open source thread for this download"
className={`${buildChipClass} cursor-pointer hover:bg-highlight hover:underline focus:outline-none focus-visible:ring-1 focus-visible:ring-accent`}
>
{buildDesc}
</button>
) : (
<span className={buildChipClass}>{buildDesc}</span>
)}
</div>
)}

Expand Down
42 changes: 22 additions & 20 deletions src/components/downloads/cardFacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,28 @@ export function keepsBothVersions(item, game) {
}

/**
* Where the banner click goes: 'game' | 'thread' | 'host' | null.
* Where the download banner goes: 'game' | 'catalog' | null.
*
* Installed titles open inside Atlas; anything else opens the page a user would
* want while still deciding. The order matters more than it looks:
*
* - A library game with no thread url still opens its game page. Requiring a
* thread url made Steam imports and local titles silently dead, which was a
* regression against the original behaviour of opening the game page for any
* row that had a record at all.
* - A row with no record at all falls back to the host page. That was
* originally left inert on the understanding it meant Browse and wishlist
* downloads only; it actually covers every download of a game not already in
* the library, which is the common case, and an inert banner there reads as
* broken rather than deliberate.
* Banner stays in-app; the host chip already opens the download URL.
* Installed opens the library entry. Not installed opens Browse when the
* entry is known. Otherwise falls back to the library entry when there is
* one (local titles have no Browse entry). Else nowhere.
*/
export function downloadBannerTarget({ game = null, catalogRef = null } = {}) {
if (game && isInstalledGame(game)) return 'game'
if (catalogRef) return 'catalog'
if (game) return 'game'
return null
}

/**
* Which catalog entry this download belongs to.
* Uses the saved reference first, then the game's Atlas id if needed.
* Returns null if neither is available.
*/
export function bannerTargetFor({ game = null, threadUrl = '', hostUrl = '' } = {}) {
if (game) {
if (isInstalledGame(game)) return 'game'
return threadUrl ? 'thread' : 'game'
}
if (threadUrl) return 'thread'
return hostUrl ? 'host' : null
export function downloadCatalogRef(item, game) {
if (item?.catalogRef) return item.catalogRef
const atlas = game?.atlas_id ?? game?.atlasId
if (atlas) return `catalog:${atlas}`
return null
}
43 changes: 27 additions & 16 deletions tests/download-card-facts.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { keepsBothVersions, bannerTargetFor } from '../src/components/downloads/cardFacts.js'
import { keepsBothVersions, downloadBannerTarget, downloadCatalogRef } from '../src/components/downloads/cardFacts.js'

// ── Two bugs, one root cause ─────────────────────────────────────────────────
//
Expand Down Expand Up @@ -41,31 +41,42 @@ describe('keepsBothVersions', () => {
})
})

describe('bannerTargetFor', () => {
describe('downloadBannerTarget', () => {
const game = { hasInstalledVersion: false }
const installedGame = { hasInstalledVersion: true }

it('opens the game in Atlas once installed', () => {
expect(bannerTargetFor({ game: installedGame, threadUrl: 'https://t/', hostUrl: 'https://h/' }))
.toBe('game')
it('opens the library entry once installed', () => {
expect(downloadBannerTarget({ game: installedGame, catalogRef: 'catalog:1' })).toBe('game')
})

it('opens the thread while it is not installed', () => {
expect(bannerTargetFor({ game, threadUrl: 'https://t/', hostUrl: 'https://h/' })).toBe('thread')
it('opens Browse when the entry is known', () => {
expect(downloadBannerTarget({ game, catalogRef: 'catalog:1' })).toBe('catalog')
expect(downloadBannerTarget({ game: null, catalogRef: 'catalog:steam:480' })).toBe('catalog')
})

it('still opens the game page when a library game has no thread', () => {
// Regression guard. Before the click targets landed, ANY row with a game
// record opened the game page; requiring a thread url made Steam imports and
// local titles -- which have no forum link -- silently dead.
expect(bannerTargetFor({ game, threadUrl: '', hostUrl: '' })).toBe('game')
it('falls back to the library row when there is one', () => {
// Local titles have no Browse entry.
expect(downloadBannerTarget({ game, catalogRef: null })).toBe('game')
})

it('falls back to the host page when there is no library record', () => {
expect(bannerTargetFor({ game: null, threadUrl: '', hostUrl: 'https://h/' })).toBe('host')
it('is inert only when there is nowhere to go', () => {
expect(downloadBannerTarget({ game: null, catalogRef: null })).toBeNull()
})
})

describe('downloadCatalogRef', () => {
it('passes the stored ref through', () => {
expect(downloadCatalogRef({ catalogRef: 'catalog:steam:480' }, null))
.toBe('catalog:steam:480')
})

it('falls back to the atlas id', () => {
expect(downloadCatalogRef({}, { atlas_id: 30956 })).toBe('catalog:30956')
expect(downloadCatalogRef({}, { atlasId: 30956 })).toBe('catalog:30956')
})

it('is inert only when there is genuinely nowhere to go', () => {
expect(bannerTargetFor({ game: null, threadUrl: '', hostUrl: '' })).toBeNull()
it('returns null when there is nothing to open', () => {
expect(downloadCatalogRef({}, null)).toBeNull()
expect(downloadCatalogRef({}, {})).toBeNull()
})
})
Loading
Loading