diff --git a/app/components/navbar.gjs b/app/components/navbar.gjs index e5e4208..4182f53 100644 --- a/app/components/navbar.gjs +++ b/app/components/navbar.gjs @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { LinkTo } from '@ember/routing'; +import NavbarSearch from 'netrunnerdb/components/search/navbar-search'; import { on } from '@ember/modifier'; import FaIcon from '@fortawesome/ember-fontawesome/components/fa-icon'; import { faBars } from '@fortawesome/free-solid-svg-icons'; @@ -115,14 +116,7 @@ class Navbar extends Component {
-
- -
+
diff --git a/app/components/search/navbar-search.gjs b/app/components/search/navbar-search.gjs new file mode 100644 index 0000000..f5e569f --- /dev/null +++ b/app/components/search/navbar-search.gjs @@ -0,0 +1,73 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import { LinkTo } from '@ember/routing'; +import { on } from '@ember/modifier'; +import { and } from 'netrunnerdb/utils/template-operators'; + +export default class NavbarSearchComponent extends Component { + @service('all-printings') allPrintings; + + @tracked isOpen = false; + @tracked query = ''; + @tracked results = []; + + @action open() { + this.isOpen = true; + } + + @action onKeyDown(e) { + if (e.key === 'Escape') { + this.isOpen = false; + } + } + + @action handleBlur(e) { + const next = e.relatedTarget; + if (next && document.querySelector('.search-results')?.contains(next)) { + e.preventDefault?.(); + return; + } + this.isOpen = false; + } + + @action updateQuery(e) { + this.isOpen = true; + this.query = e.target.value; + let q = this.query?.trim(); + if (!q) { + this.results = []; + return; + } + this.results = this.allPrintings.search(q, 5); + } + + +} diff --git a/app/routes/application.js b/app/routes/application.js index 2d38635..b75de0e 100644 --- a/app/routes/application.js +++ b/app/routes/application.js @@ -4,9 +4,22 @@ import { service } from '@ember/service'; export default class ApplicationRoute extends Route { @service session; @service intl; + @service('all-printings') allPrintings; + + queryParams = { + refreshCache: { refreshModel: false } + }; async beforeModel() { await this.session.setup(); this.intl.setLocale(['pt-PT', 'en-US']); } + + activate() { + // Check if cache refresh is requested via query parameter + const params = this.paramsFor('application'); + const forceRefresh = params.refreshCache === 'true'; + + this.allPrintings.loadPrintings(forceRefresh); + } } diff --git a/app/services/all-printings.js b/app/services/all-printings.js new file mode 100644 index 0000000..5c3f0e0 --- /dev/null +++ b/app/services/all-printings.js @@ -0,0 +1,156 @@ +import Service from '@ember/service'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { storageFor } from 'ember-local-storage'; +import LZString from 'lz-string'; + +export default class AllPrintingsService extends Service { + @service store; + + @tracked allPrintings = []; + #loadPromise = null; + @storageFor('all-printings') allPrintingsCache; + + // Compression helpers + compressData(data) { + try { + const jsonString = JSON.stringify(data); + return LZString.compress(jsonString); + } catch (e) { + console.error('Error compressing data:', e); + return null; + } + } + + decompressData(compressedData) { + try { + if (!compressedData) return null; + const decompressed = LZString.decompress(compressedData); + return decompressed ? JSON.parse(decompressed) : null; + } catch (e) { + console.error('Error decompressing data:', e); + return null; + } + } + + // Enhanced cache methods with compression + getCachedItems() { + const compressed = this.allPrintingsCache?.get?.('items'); + return this.decompressData(compressed) || []; + } + + setCachedItems(items) { + const compressed = this.compressData(items); + if (compressed) { + this.allPrintingsCache?.set?.('items', compressed); + } + } + + async getRemoteUpdatedAt() { + try { + let cards = await this.store.query('card', { + sort: '-updated_at', + page: { size: 1 }, + fields: { cards: 'updated_at' }, + }); + let first = cards?.[0]; + return first?.updatedAt ?? null; + } catch (e) { + console.error("error getting remote updated at", e); + return null; + } + } + + async needsRefresh() { + try { + let updated = await this.getRemoteUpdatedAt(); + if (!updated) return true; + let cachedRemote = this.allPrintingsCache?.get?.('remoteUpdatedAt'); + return !cachedRemote || new Date(updated) > new Date(cachedRemote); + } catch (e) { + console.error("error checking if needs refresh", e); + return true; + } + } + + async loadPrintings(forceRefresh = false) { + // Reset load promise if forcing refresh + if (forceRefresh) { + this.#loadPromise = null; + } + + if (this.#loadPromise) return this.#loadPromise; + + this.#loadPromise = (async () => { + try { + // 1) Load from cache first if available (unless forcing refresh) + let cached = this.getCachedItems(); + if (cached.length > 0 && !forceRefresh) { + this.allPrintings = cached; + } + + // 2) Check freshness in background + let remoteUpdatedAt = await this.getRemoteUpdatedAt(); + let cachedRemote = this.allPrintingsCache?.get?.('remoteUpdatedAt'); + let stale = forceRefresh || !remoteUpdatedAt || !cachedRemote || new Date(remoteUpdatedAt) > new Date(cachedRemote); + + // 3) If data is fresh and not forcing refresh, return early + if (!stale && cached.length > 0) { + return; + } + + // 4) Fetch and persist fresh data if stale or no cache + let printings = await this.store.query('printing', { page: { size: 5000 } }); + let compact = printings.map((p) => { + // Serialize all attributes and properties from the printing object + let serialized = p.serialize(); + return { + id: p.id, + ...serialized.data.attributes + }; + }); + + this.allPrintings = compact; + this.setCachedItems(compact); + this.allPrintingsCache?.set?.('updatedAt', new Date().toISOString()); + + if (remoteUpdatedAt) { + this.allPrintingsCache?.set?.('remoteUpdatedAt', remoteUpdatedAt); + } + } catch (e) { + console.error("error loading printings", e); + } + })(); + + return this.#loadPromise; + } + + normalize(str) { + return str.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, ''); + } + + filterLocal(query) { + let q = this.normalize(query || ''); + if (!q) return []; + + const matches = this.allPrintings.filter((p) => { + let title = this.normalize(p.title); + return title.includes(q); + }); + + // matches that start with the query are prioritized + return matches.sort((a, b) => { + let aStartsWith = this.normalize(a.title).startsWith(q); + let bStartsWith = this.normalize(b.title).startsWith(q); + if (aStartsWith && !bStartsWith) return -1; + if (!aStartsWith && bStartsWith) return 1; + return 0; + }); + } + + search(query, limit) { + return this.filterLocal(query).slice(0, limit); + } +} + + diff --git a/app/storages/all-printings.js b/app/storages/all-printings.js new file mode 100644 index 0000000..04b3fa9 --- /dev/null +++ b/app/storages/all-printings.js @@ -0,0 +1,15 @@ +import StorageObject from 'ember-local-storage/local/object'; + +const AllPrintingsStorage = class extends StorageObject {}; + +AllPrintingsStorage.reopenClass({ + initialState() { + return { + items: [], + remoteUpdatedAt: null, + updatedAt: null, + }; + }, +}); + +export default AllPrintingsStorage; diff --git a/app/styles/app.css b/app/styles/app.css index f2a9e0f..faab975 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -671,6 +671,18 @@ table.game-card-stats tr:nth-child(even) { background-color: var(--color--header-bg); } +.search-results a { + display: block; + padding: 6px 10px; + color: var(--color--default); + text-decoration: none; +} + +.search-results a:hover { + background-color: var(--color--secondary-bg); + text-decoration: none; +} + #bottom-nav { top: 0; position: sticky; @@ -1057,3 +1069,28 @@ table.results tr:nth-child(even) { .user-content p:not(:first-of-type) { margin-top: 1em; } + +/* SEARCH */ + +.search-results { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background-color: var(--color--box-bg); + border: 1px solid var(--color--box-border); + border-radius: 6px; + box-shadow: 0 6px 16px rgb(0 0 0 / 25%); + max-height: 320px; + overflow-y: auto; + padding: 4px 0; + z-index: 1000; +} + +.search-container { + position: relative; +} + +.search-results > * { + padding: 6px 10px; +} diff --git a/package.json b/package.json index a68efb1..5a3838e 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "ember-inflector": "^6.0.0", "ember-intl": "^7.3.0", "ember-load-initializers": "^3.0.1", + "ember-local-storage": "^2.0.7", "ember-math-helpers": "^5.0.0", "ember-modifier": "^4.2.2", "ember-page-title": "9.0.2", @@ -89,6 +90,7 @@ "eslint-plugin-n": "^17.21.3", "eslint-plugin-qunit": "^8.2.5", "globals": "^16.2.0", + "lz-string": "^1.5.0", "markdown-it": "^14.1.0", "prettier": "^3.6.2", "prettier-plugin-ember-template-tag": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 067e040..b794643 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,6 +145,9 @@ importers: ember-load-initializers: specifier: ^3.0.1 version: 3.0.1(ember-source@6.6.0(@glimmer/component@2.0.0)(rsvp@4.8.5)) + ember-local-storage: + specifier: ^2.0.7 + version: 2.0.7(@babel/core@7.28.0) ember-math-helpers: specifier: ^5.0.0 version: 5.0.0(@babel/core@7.28.0)(ember-source@6.6.0(@glimmer/component@2.0.0)(rsvp@4.8.5)) @@ -199,6 +202,9 @@ importers: globals: specifier: ^16.2.0 version: 16.2.0 + lz-string: + specifier: ^1.5.0 + version: 1.5.0 markdown-it: specifier: ^14.1.0 version: 14.1.0 @@ -1620,56 +1626,67 @@ packages: resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.46.2': resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.46.2': resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.46.2': resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.46.2': resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.46.2': resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.46.2': resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.46.2': resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.46.2': resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.46.2': resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.46.2': resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.46.2': resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==} @@ -2271,6 +2288,9 @@ packages: blank-object@1.0.2: resolution: {integrity: sha512-kXQ19Xhoghiyw66CUiGypnuRpWlbHAzY/+NyvqTEdTfhfQGH1/dbEMYiXju7fYKIFePpzp/y9dsu5Cu/PkmawQ==} + blob-polyfill@7.0.20220408: + resolution: {integrity: sha512-oD8Ydw+5lNoqq+en24iuPt1QixdPpe/nUF8azTHnviCZYu9zUC+TwdzIp5orpblJosNlgNbVmmAb//c6d6ImUQ==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -3298,6 +3318,10 @@ packages: peerDependencies: ember-source: '>=4.0' + ember-copy@2.0.1: + resolution: {integrity: sha512-N/XFvZszrzyyX4IcNoeK4mJvIItNuONumhPLqi64T8NDjJkxBj4Pq61rvMkJx/9eZ8alzE4I8vYKOLxT0FvRuQ==} + engines: {node: 10.* || >= 12} + ember-data@5.6.0: resolution: {integrity: sha512-nFFHhAD06C6DM2iD/UQFe+XKPEh27ALudZVZ6CipCAn598i8gfnm5k09ZZiW/YYpU0+4DAqtf91LwZN2e+O3vw==} peerDependencies: @@ -3310,6 +3334,10 @@ packages: qunit: optional: true + ember-destroyable-polyfill@2.0.3: + resolution: {integrity: sha512-TovtNqCumzyAiW0/OisSkkVK93xnVF4NRU6+FN0ubpfwEOpRrmM2RqDwXI6YAChCgSHON1cz0DfQStpA1Gjuuw==} + engines: {node: 10.* || >= 12} + ember-element-helper@0.8.8: resolution: {integrity: sha512-3slTltQV5ke53t3YVP2GYoswsQ6y+lhuVzKmt09tbEx91DapG8I/xa8W5OA0StvcQlavL3/vHrz/vCQEFs8bBA==} engines: {node: 14.* || 16.* || >= 18} @@ -3370,6 +3398,10 @@ packages: peerDependencies: ember-source: '>= 5' + ember-local-storage@2.0.7: + resolution: {integrity: sha512-EPvxH/27mIzrX/EEgng+FG6HD0ri/God9OH/9mhmgPSXrgMNq/614Z3NMnooM4QKIEBAvr0p+p1UL2DgrTTMNg==} + engines: {node: 12.* || 14.* || >= 16} + ember-math-helpers@5.0.0: resolution: {integrity: sha512-UKChQuu1Ki57NGMFF0V1mbRJ5LtkZ+EMIdCl5w+3nrwCOzn8GpePQDgtQqgdE3tFrm3TsHfLgHtfa38uNSSG6w==} engines: {node: '>= 18'} @@ -4900,6 +4932,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} @@ -9523,6 +9559,8 @@ snapshots: blank-object@1.0.2: {} + blob-polyfill@7.0.20220408: {} + bluebird@3.7.2: {} body-parser@1.20.3: @@ -10997,6 +11035,12 @@ snapshots: transitivePeerDependencies: - supports-color + ember-copy@2.0.1: + dependencies: + ember-cli-babel: 7.26.11 + transitivePeerDependencies: + - supports-color + ember-data@5.6.0(@ember/test-helpers@5.2.2(@babel/core@7.28.0))(@ember/test-waiters@4.1.0)(ember-inflector@6.0.0(@babel/core@7.28.0))(qunit@2.24.1): dependencies: '@ember-data/adapter': 5.6.0 @@ -11028,6 +11072,15 @@ snapshots: - ember-provide-consume-context - supports-color + ember-destroyable-polyfill@2.0.3(@babel/core@7.28.0): + dependencies: + ember-cli-babel: 7.26.11 + ember-cli-version-checker: 5.1.2 + ember-compatibility-helpers: 1.2.7(@babel/core@7.28.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + ember-element-helper@0.8.8: dependencies: '@embroider/addon-shim': 1.10.0 @@ -11119,6 +11172,21 @@ snapshots: dependencies: ember-source: 6.6.0(@glimmer/component@2.0.0)(rsvp@4.8.5) + ember-local-storage@2.0.7(@babel/core@7.28.0): + dependencies: + blob-polyfill: 7.0.20220408 + broccoli-funnel: 3.0.8 + broccoli-merge-trees: 4.2.0 + broccoli-stew: 3.0.0 + ember-cli-babel: 7.26.11 + ember-cli-string-utils: 1.1.0 + ember-cli-version-checker: 5.1.2 + ember-copy: 2.0.1 + ember-destroyable-polyfill: 2.0.3(@babel/core@7.28.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + ember-math-helpers@5.0.0(@babel/core@7.28.0)(ember-source@6.6.0(@glimmer/component@2.0.0)(rsvp@4.8.5)): dependencies: '@embroider/addon-shim': 1.10.0 @@ -13183,6 +13251,8 @@ snapshots: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8