diff --git a/src/app/app.module.ts b/src/app/app.module.ts index f718a21c..7053dfe9 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -15,16 +15,16 @@ import {HttpClientModule} from '@angular/common/http' export const AppModule = ({providers, shellRouter}: {providers:any, shellRouter: Router}) => { @NgModule({ declarations: [ - AppComponent, - AutoAssetSrcDirective + AppComponent ], - exports: [AutoAssetSrcDirective], imports: [ + AutoAssetSrcDirective, BrowserModule, CommonModule, HttpClientModule, TranslateModule.forRoot({}) ], + exports: [AutoAssetSrcDirective], providers: [...providers, {provide: SHELL_ROUTER, useValue: shellRouter}], bootstrap: [] }) @@ -52,4 +52,3 @@ export const AppModule = ({providers, shellRouter}: {providers:any, shellRouter: } return AppModule } - diff --git a/src/app/custom1-module/customComponentMappings.ts b/src/app/custom1-module/customComponentMappings.ts index 8bba4a33..579c4782 100644 --- a/src/app/custom1-module/customComponentMappings.ts +++ b/src/app/custom1-module/customComponentMappings.ts @@ -5,6 +5,8 @@ import { PayFinesComponent } from '../pay-fines/pay-fines.component'; import { TrafficResultLimitComponent } from '../traffic-result-limit/traffic-result-limit.component'; import { PickupFilterComponent } from '../pickup-filter/pickup-filter.component'; import { NdeViewItReorderComponent } from '../nde-view-it-reorder/nde-view-it-reorder.component'; +import { NdeRequestReorderComponent } from '../nde-request-reorder/nde-request-reorder.component'; +import { FilterAssistPanelComponent } from '../filter-assist-panel/filter-assist-panel.component'; // Define the map export const selectorComponentMap = new Map([ @@ -15,4 +17,6 @@ export const selectorComponentMap = new Map([ ['nde-search-no-results-before', TrafficResultLimitComponent], ['nde-formly-general-wrapper-bottom', PickupFilterComponent], ['nde-full-display-service-container-before', NdeViewItReorderComponent], + ['nde-search-result-item-container-after', NdeRequestReorderComponent], + ['nde-filters-group-before', FilterAssistPanelComponent], ]); diff --git a/src/app/filter-assist-panel/config/external-sources.config.ts b/src/app/filter-assist-panel/config/external-sources.config.ts new file mode 100644 index 00000000..6ec2b03d --- /dev/null +++ b/src/app/filter-assist-panel/config/external-sources.config.ts @@ -0,0 +1,92 @@ +import { SearchTarget } from '../models/search-target.model'; + +/** + * Helper function to extract and format search queries for external sources + * Converts Primo query format to simple search terms + * @param queries Array of Primo-formatted query strings + * @returns URL-encoded search string + */ +function buildSearchQuery(queries: string[]): string { + try { + let fullSearchQuery = ''; + + // Process each query element + queries.forEach((queryElement) => { + // Primo query format: "field,operator,searchTerm,boolean" + // Example: "any,contains,test search,AND" + + const firstCommaIndex = queryElement.indexOf(','); + const secondCommaIndex = queryElement.indexOf(',', firstCommaIndex + 1); + let searchStr = queryElement.slice(secondCommaIndex + 1); + + // Remove trailing boolean operators (,AND or ,OR) + if (searchStr.endsWith(',AND') || searchStr.endsWith(',OR')) { + const finalComma = searchStr.lastIndexOf(','); + searchStr = searchStr.slice(0, finalComma); + } + + // Append to full query with space separator + fullSearchQuery = fullSearchQuery.concat(searchStr + ' '); + }); + + // Trim and encode for URL + return encodeURIComponent(fullSearchQuery.trim()); + } catch (e) { + console.error('Error building search query:', e); + return ''; + } +} + +/** + * Configuration array of external search sources + * Each source can be clicked from the filter panel to search with current query + */ +export const EXTERNAL_SEARCH_SOURCES: SearchTarget[] = [ + { + name: 'WorldCat', + nameHe: 'WorldCat', + url: 'https://kansasstateuniversity.on.worldcat.org/search?queryString=kw:', + img: 'assets/images/external-sources/worldcat-logo.png', + img_2: 'assets/images/external-sources/worldcat-logo.png', + alt: 'Worldcat', + mapping: (queries: string[], filters: string[]): string => { + return buildSearchQuery(queries); + } + }, + { + name: 'Google Scholar', + nameHe: 'Google Scholar', + url: 'https://scholar.google.com/scholar?q=', + img: 'assets/images/external-sources/logo-googlescholar.png', + img_2: 'assets/images/external-sources/logo-googlescholar.png', + alt: 'Google Scholar', + mapping: (queries: string[], filters: string[]): string => { + return buildSearchQuery(queries); + } + }, + { + name: 'EBSCO', + nameHe: 'EBSCO', + url: 'https://research.ebsco.com/c/b2utja/search/results?q=', + img: 'assets/images/external-sources/ebsco-logo-color-screen.png', + img_2: '', + alt: 'EBSCO', + mapping: (queries: string[], filters: string[]): string => { + return buildSearchQuery(queries); + } + } + // Crossref is commented out in the original - can be added later if needed + /* + { + name: 'Crossref', + nameHe: 'Crossref', + url: 'https://search.crossref.org/?from_ui=yes&q=', + img: 'assets/images/external-sources/crossref_logo_16_16.png', + img_2: 'assets/images/external-sources/crossref_logo_16_16.png', + alt: 'Crossref', + mapping: (queries: string[], filters: string[]): string => { + return buildSearchQuery(queries); + } + } + */ +]; \ No newline at end of file diff --git a/src/app/filter-assist-panel/filter-assist-panel.component.html b/src/app/filter-assist-panel/filter-assist-panel.component.html new file mode 100644 index 00000000..c060946a --- /dev/null +++ b/src/app/filter-assist-panel/filter-assist-panel.component.html @@ -0,0 +1,49 @@ + \ No newline at end of file diff --git a/src/app/filter-assist-panel/filter-assist-panel.component.scss b/src/app/filter-assist-panel/filter-assist-panel.component.scss new file mode 100644 index 00000000..1e55f685 --- /dev/null +++ b/src/app/filter-assist-panel/filter-assist-panel.component.scss @@ -0,0 +1,129 @@ +.external-search-panel { + background: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 12px 16px; + margin-bottom: 16px; + font-family: 'Roboto', 'Helvetica Neue', Arial, sans-serif; + color: #424242; + + // Title styling + &__title { + margin: 0 0 12px; + font-size: 0.875rem; + font-weight: 500; + color: #616161; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + // List container + &__list { + list-style: none; + margin: 0; + padding: 0; + } + + // Individual list item + &__item { + margin: 0; + padding: 0; + + &:not(:last-child) { + margin-bottom: 2px; + } + } + + // Link styling + &__link { + display: flex; + align-items: center; + padding: 4px 12px; + text-decoration: none; + border-radius: 4px; + transition: + background-color 0.2s ease, + color 0.2s ease; + font-size: 0.875rem; + gap: 8px; + } + + // Icon (logo) styling + &__icon { + flex-shrink: 0; + width: 16px; + height: 16px; + object-fit: contain; + vertical-align: middle; + } + + // Source name text + &__name { + flex-grow: 1; + font-weight: 400; + line-height: 1.4; + } + + // External link icon (arrow) + &__external-icon { + flex-shrink: 0; + opacity: 0.6; + transition: opacity 0.2s ease; + + .external-search-panel__link:hover & { + opacity: 1; + } + } + + // RTL support + &[dir='rtl'] { + .external-search-panel__link { + // Reverse flex direction for RTL + direction: rtl; + } + + .external-search-panel__external-icon { + // Flip the external icon for RTL + transform: scaleX(-1); + } + } + + // Responsive design for mobile + @media (max-width: 768px) { + padding: 10px 12px; + + &__title { + font-size: 0.8125rem; + margin-bottom: 10px; + } + + &__link { + padding: 6px 10px; + font-size: 0.8125rem; + } + } + + // Dark mode support (if NDE implements it) +/* @media (prefers-color-scheme: dark) { + background: #2c2c2c; + border-color: #444; + color: #e0e0e0; + + &__title { + color: #b0b0b0; + } + + &__link { + color: #64b5f6; + + &:hover { + background-color: #383838; + color: #90caf9; + } + + &:active { + background-color: #1e3a5f; + } + } + } */ +} diff --git a/src/app/filter-assist-panel/filter-assist-panel.component.ts b/src/app/filter-assist-panel/filter-assist-panel.component.ts new file mode 100644 index 00000000..294ed1c9 --- /dev/null +++ b/src/app/filter-assist-panel/filter-assist-panel.component.ts @@ -0,0 +1,128 @@ +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SearchQueryService } from './services/search-query.service'; +import { FilterAssistPanelRegistryService } from './services/filter-assist-panel-registry.service'; +import { SearchTarget, SearchQuery } from './models/search-target.model'; +import { EXTERNAL_SEARCH_SOURCES } from './config/external-sources.config'; +import { AutoAssetSrcDirective } from '../services/auto-asset-src.directive'; + + +/** + * Filter Assist Panel Component + * Displays external search links in the NDE filter side navigation + * Allows users to search the current query in external sources (ULI, WorldCat, Google Scholar) + * + * Note: NDE may insert this component multiple times (once before each filter group). + * We use a registry service to ensure only one instance renders at a time, + * properly handling component destruction/recreation during Angular lifecycle events. + */ +@Component({ + selector: 'tau-filter-assist-panel', + standalone: true, + imports: [CommonModule, AutoAssetSrcDirective], + templateUrl: './filter-assist-panel.component.html', + styleUrls: ['./filter-assist-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FilterAssistPanelComponent implements OnInit, OnDestroy { + /** + * Controls whether this specific instance should render + * Determined by registry service during initialization + */ + public shouldRender = false; + + /** List of external search sources to display */ + externalSources: SearchTarget[] = EXTERNAL_SEARCH_SOURCES; + + /** Current UI language */ + currentLanguage: 'en' | 'he' = 'en'; + + /** Parsed search data from URL */ + searchData: SearchQuery = { + queries: [], + filters: [], + searchTerm: '' + }; + + /** Text direction based on language */ + get textDirection(): 'ltr' | 'rtl' { + return this.currentLanguage === 'he' ? 'rtl' : 'ltr'; + } + + /** Panel title based on language */ + get panelTitle(): string { + return this.currentLanguage === 'he' + ? 'לחפש במנועי חיפוש נוספים' + : 'External Search'; + } + + constructor( + private searchQueryService: SearchQueryService, + private registry: FilterAssistPanelRegistryService + ) {} + + ngOnInit(): void { + // Register with the singleton service + // Only one instance across all component creations will successfully register + this.shouldRender = this.registry.register(this); + + if (this.shouldRender) { + // Detect current language from URL + this.currentLanguage = this.searchQueryService.getCurrentLanguage(); + + // Extract search data from URL + this.searchData = this.searchQueryService.getSearchData(); + + console.log('FilterAssistPanel initialized (active instance):', { + language: this.currentLanguage, + searchData: this.searchData + }); + } + } + + ngOnDestroy(): void { + // Unregister when component is destroyed + // This allows a new instance to register during the next creation cycle + this.registry.unregister(this); + } + + /** + * Build complete URL for external search source + * Combines base URL with mapped query parameters + * @param source External search target + * @returns Complete URL with encoded search query + */ + buildExternalUrl(source: SearchTarget): string { + try { + // Use the source's mapping function to format the query + const mappedQuery = source.mapping( + this.searchData.queries, + this.searchData.filters + ); + + // Combine base URL with mapped query + return `${source.url}${mappedQuery}`; + } catch (e) { + console.error(`Error building URL for ${source.name}:`, e); + // Fallback: use simple search term + return `${source.url}${encodeURIComponent(this.searchData.searchTerm)}`; + } + } + + /** + * Get source name in current language + * @param source External search target + * @returns Localized source name + */ + getSourceName(source: SearchTarget): string { + return this.currentLanguage === 'he' ? source.nameHe : source.name; + } + + /** + * Check if we have a valid search query to display links + * @returns True if search data is available + */ + hasSearchQuery(): boolean { + return this.searchData.queries.length > 0 || this.searchData.searchTerm.length > 0; + } +} \ No newline at end of file diff --git a/src/app/filter-assist-panel/models/search-target.model.ts b/src/app/filter-assist-panel/models/search-target.model.ts new file mode 100644 index 00000000..b6b745e6 --- /dev/null +++ b/src/app/filter-assist-panel/models/search-target.model.ts @@ -0,0 +1,44 @@ +/** + * Interface for external search source configuration + */ +export interface SearchTarget { + /** English name of the search source */ + name: string; + + /** Hebrew name of the search source */ + nameHe: string; + + /** Base URL for the search source */ + url: string; + + /** Path to the icon image (primary) */ + img: string; + + /** Path to the icon image (alternative) */ + img_2: string; + + /** Alt text for the icon */ + alt: string; + + /** + * Mapping function to convert Primo query format to external source format + * @param queries Array of query strings from Primo URL + * @param filters Array of filter strings from Primo URL + * @returns Formatted query string for the external source + */ + mapping: (queries: string[], filters: string[]) => string; +} + +/** + * Interface for parsed search query data + */ +export interface SearchQuery { + /** Array of query strings from URL (e.g., query parameter) */ + queries: string[]; + + /** Array of filter strings from URL (e.g., pfilter parameter) */ + filters: string[]; + + /** Simple search term extracted from query */ + searchTerm: string; +} \ No newline at end of file diff --git a/src/app/filter-assist-panel/services/filter-assist-panel-registry.service.ts b/src/app/filter-assist-panel/services/filter-assist-panel-registry.service.ts new file mode 100644 index 00000000..fa1a7007 --- /dev/null +++ b/src/app/filter-assist-panel/services/filter-assist-panel-registry.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; +import { FilterAssistPanelComponent } from '../filter-assist-panel.component'; + +/** + * Registry service to ensure only one FilterAssistPanel instance renders + * at a time, even when components are destroyed and recreated during + * Angular's lifecycle (e.g., when filters change). + */ +@Injectable({ + providedIn: 'root' +}) +export class FilterAssistPanelRegistryService { + /** + * Reference to the currently active (rendering) component instance + */ + private activeInstance: FilterAssistPanelComponent | null = null; + + /** + * Register a component instance as the active renderer + * @param component The component instance attempting to register + * @returns true if successfully registered (should render), false otherwise + */ + register(component: FilterAssistPanelComponent): boolean { + if (!this.activeInstance) { + this.activeInstance = component; + console.log('FilterAssistPanel registered as active instance'); + return true; + } + console.log('FilterAssistPanel registration denied (instance already active)'); + return false; + } + + /** + * Unregister a component instance when it's destroyed + * Allows a new instance to register during next recreation cycle + * @param component The component instance being destroyed + */ + unregister(component: FilterAssistPanelComponent): void { + if (this.activeInstance === component) { + this.activeInstance = null; + console.log('FilterAssistPanel unregistered (instance destroyed)'); + } + } +} \ No newline at end of file diff --git a/src/app/filter-assist-panel/services/search-query.service.ts b/src/app/filter-assist-panel/services/search-query.service.ts new file mode 100644 index 00000000..89c21c55 --- /dev/null +++ b/src/app/filter-assist-panel/services/search-query.service.ts @@ -0,0 +1,126 @@ +import { Injectable } from '@angular/core'; +import { SearchQuery } from '../models/search-target.model'; + +/** + * Service to extract and parse search parameters from the URL + * Handles Primo NDE query format and converts to usable search data + * Uses native browser location API to work in NDE micro-frontend context + */ +@Injectable({ + providedIn: 'root' +}) +export class SearchQueryService { + /** + * Parse URL query parameters from window.location + * @returns Object with query parameter key-value pairs + */ + private getUrlParams(): URLSearchParams { + return new URLSearchParams(window.location.search); + } + + /** + * Get all search query data from current URL + * @returns SearchQuery object with queries, filters, and search term + */ + getSearchData(): SearchQuery { + const urlParams = this.getUrlParams(); + + const queries = this.getSearchQueries(urlParams); + const filters = this.getSearchFilters(urlParams); + const searchTerm = this.getSearchTerm(queries); + + return { + queries, + filters, + searchTerm + }; + } + + /** + * Extract query array from URL parameters + * Handles both single query and array of queries + * @param urlParams URLSearchParams from window.location + * @returns Array of query strings + */ + private getSearchQueries(urlParams: URLSearchParams): string[] { + // URLSearchParams.getAll() returns array of all values for a key + const queries = urlParams.getAll('query'); + + if (queries.length === 0) { + // Try single query parameter + const singleQuery = urlParams.get('query'); + return singleQuery ? [singleQuery] : []; + } + + return queries; + } + + /** + * Extract filter array from URL parameters + * Handles both single filter and array of filters + * @param urlParams URLSearchParams from window.location + * @returns Array of filter strings + */ + private getSearchFilters(urlParams: URLSearchParams): string[] { + // URLSearchParams.getAll() returns array of all values for a key + const filters = urlParams.getAll('pfilter'); + + if (filters.length === 0) { + // Try single filter parameter + const singleFilter = urlParams.get('pfilter'); + return singleFilter ? [singleFilter] : []; + } + + return filters; + } + + /** + * Extract simple search term from query array + * Parses Primo query format to get the actual search text + * Example: "any,contains,test search,AND" -> "test search" + * @param queries Array of query strings + * @returns Extracted search term or empty string + */ + private getSearchTerm(queries: string[]): string { + if (queries.length === 0) { + return ''; + } + + try { + // Take the first query and extract the search term + // Primo format: "field,operator,searchTerm,boolean" + const firstQuery = queries[0]; + const parts = firstQuery.split(','); + + if (parts.length >= 3) { + // Get the search term (third element) + let searchTerm = parts.slice(2).join(','); // In case term contains commas + + // Remove trailing boolean operator if present + if (searchTerm.endsWith(',AND') || searchTerm.endsWith(',OR')) { + const lastComma = searchTerm.lastIndexOf(','); + searchTerm = searchTerm.slice(0, lastComma); + } + + return searchTerm; + } + + return firstQuery; // Fallback to full query if parsing fails + } catch (e) { + console.warn('Error parsing search term:', e); + return ''; + } + } + + /** + * Get current UI language from URL or default to English + * @returns Language code ('en' or 'he') + */ + getCurrentLanguage(): 'en' | 'he' { + const urlParams = this.getUrlParams(); + const lang = urlParams.get('lang'); + + // Check if Hebrew is requested + return lang === 'he' || lang === 'he_IL' ? 'he' : 'en'; + } +} \ No newline at end of file diff --git a/src/app/nde-request-reorder/nde-request-reorder.component.ts b/src/app/nde-request-reorder/nde-request-reorder.component.ts new file mode 100644 index 00000000..0419fde6 --- /dev/null +++ b/src/app/nde-request-reorder/nde-request-reorder.component.ts @@ -0,0 +1,79 @@ +import { Component, AfterViewInit, OnDestroy } from '@angular/core'; + +@Component({ + selector: 'nde-request-reorder', + template: '', + standalone: true +}) +export class NdeRequestReorderComponent implements AfterViewInit, OnDestroy { + private observer: MutationObserver | null = null; + private pollTimer: ReturnType | null = null; + private scheduled = false; + + ngAfterViewInit(): void { + // The full-display sections (Requests, View Online, ...) render asynchronously, + // and this component is hosted in a "before" remote slot that mounts before the + // View Online section exists. Poll until both sections are present and the + // reorder succeeds, then keep an observer to re-apply after Angular re-renders. + this.scheduleReorder(); + this.pollTimer = setInterval(() => { + if (this.reorderRequestSection()) { + this.stopPolling(); + this.attachObserver(); + } + }, 100); + } + + ngOnDestroy(): void { + this.stopPolling(); + this.observer?.disconnect(); + this.observer = null; + } + + private stopPolling(): void { + if (this.pollTimer !== null) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + private attachObserver(): void { + const requestSection = document.querySelector('[id="nde.request.title"]'); + const parent = requestSection?.parentElement; + if (!parent || this.observer) return; + + this.observer = new MutationObserver(() => this.scheduleReorder()); + this.observer.observe(parent, { childList: true }); + } + + private scheduleReorder(): void { + if (this.scheduled) return; + this.scheduled = true; + setTimeout(() => { + this.scheduled = false; + this.reorderRequestSection(); + }, 50); + } + + /** + * Move the "Requests" section (nde.request.title) to immediately after the + * "View Online" section (nui.getit.service_viewit). + * @returns true if both sections exist and the desired order is in place + * (after performing the move if needed), false otherwise. + */ + private reorderRequestSection(): boolean { + const requestSection = document.querySelector('[id="nde.request.title"]'); + const viewItSection = document.querySelector('[id="nui.getit.service_viewit"]'); + if (!requestSection || !viewItSection) return false; + + const parent = requestSection.parentElement; + if (!parent || viewItSection.parentElement !== parent) return false; + + // Already in the desired order: no DOM mutation (keeps the observer quiet). + if (viewItSection.nextElementSibling === requestSection) return true; + + parent.removeChild(requestSection); + parent.insertBefore(requestSection, viewItSection.nextSibling); + return true; + } +} diff --git a/src/app/services/auto-asset-src.directive.ts b/src/app/services/auto-asset-src.directive.ts index b3115581..c49c9650 100644 --- a/src/app/services/auto-asset-src.directive.ts +++ b/src/app/services/auto-asset-src.directive.ts @@ -9,7 +9,8 @@ import { import { AssetBaseService } from './asset-base.service'; @Directive({ - selector: '[autoAssetSrc]' // + selector: '[autoAssetSrc]', // + standalone: true }) export class AutoAssetSrcDirective implements OnInit { @Input() src: string = ''; diff --git a/src/assets/images/external-sources/ebsco-logo-color-screen.png b/src/assets/images/external-sources/ebsco-logo-color-screen.png new file mode 100644 index 00000000..e6d73e89 Binary files /dev/null and b/src/assets/images/external-sources/ebsco-logo-color-screen.png differ diff --git a/src/assets/images/external-sources/logo-googlescholar.png b/src/assets/images/external-sources/logo-googlescholar.png new file mode 100644 index 00000000..aad3484f Binary files /dev/null and b/src/assets/images/external-sources/logo-googlescholar.png differ diff --git a/src/assets/images/external-sources/worldcat-logo.png b/src/assets/images/external-sources/worldcat-logo.png new file mode 100644 index 00000000..3cbb35de Binary files /dev/null and b/src/assets/images/external-sources/worldcat-logo.png differ