Skip to content
This repository was archived by the owner on Sep 21, 2025. It is now read-only.
Open
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
10 changes: 2 additions & 8 deletions app/components/navbar.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -115,14 +116,7 @@ class Navbar extends Component {
</div>

<div class='col-12 col-lg-6'>
<form>
<input
class='w-100'
type='text'
placeholder='Search'
aria-label='Search'
/>
</form>
<NavbarSearch />
</div>

<div class='col-3 visible-lg text-end mt-3 mt-sm-0'>
Expand Down
73 changes: 73 additions & 0 deletions app/components/search/navbar-search.gjs
Original file line number Diff line number Diff line change
@@ -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);
}

<template>
<form>
<div class='search-container'>
<input
class='w-100'
type='text'
placeholder='Search'
aria-label='Search'
{{on 'focus' this.open}}
{{on 'blur' this.handleBlur}}
{{on 'keydown' this.onKeyDown}}
{{on 'input' this.updateQuery}}
/>
{{#if (and this.isOpen this.results.length)}}
<div class='search-results'>
{{#each this.results as |printing|}}
<div>
<LinkTo @route='card' @model={{printing.id}}>
{{printing.title}}
</LinkTo>
</div>
{{/each}}
</div>
{{/if}}
</div>
</form>
</template>
}
13 changes: 13 additions & 0 deletions app/routes/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
156 changes: 156 additions & 0 deletions app/services/all-printings.js
Original file line number Diff line number Diff line change
@@ -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);
}
}


15 changes: 15 additions & 0 deletions app/storages/all-printings.js
Original file line number Diff line number Diff line change
@@ -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;
37 changes: 37 additions & 0 deletions app/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading
Loading