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