diff --git a/.gitignore b/.gitignore index 8c7863e7..389473bc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ pom.xml.versionsBackup release.properties .flattened-pom.xml +# web-bundler uses it for IDE support +node_modules/ + # Eclipse .project .classpath diff --git a/README.adoc b/README.adoc index 77caea81..cb64ef87 100644 --- a/README.adoc +++ b/README.adoc @@ -260,3 +260,5 @@ you will need to set up a few things manually: == License This project is licensed under the Apache License Version 2.0. + +The web assets in `src/main/resources/web` are licensed under the Creative Commons Attribution 3.0 International License. diff --git a/pom.xml b/pom.xml index e0b02816..e8b7c888 100644 --- a/pom.xml +++ b/pom.xml @@ -1,6 +1,5 @@ - - + + 4.0.0 io.quarkus.search search-quarkus-io @@ -28,7 +27,7 @@ UTF-8 quarkus-bom io.quarkus - 3.9.0 + 3.9.1 999-SNAPSHOT true 3.2.5 @@ -38,6 +37,7 @@ 1.9.0 2.12 + 1.4.0 @@ -80,6 +80,31 @@ jgettext 0.15.1 + + io.quarkiverse.helm + quarkus-helm + 1.2.3 + + + io.quarkiverse.playwright + quarkus-playwright + 0.0.1 + + + org.mvnpm + lit + 3.1.2 + + + org.mvnpm + lodash + 4.17.21 + + + org.mvnpm.at.types + lodash + 4.17.0 + @@ -105,7 +130,12 @@ io.quarkus - quarkus-resteasy-reactive + quarkus-rest + + + io.quarkiverse.web-bundler + quarkus-web-bundler + ${version.quarkus-web-bundler} io.quarkus @@ -117,7 +147,7 @@ io.quarkus - quarkus-resteasy-reactive-jackson + quarkus-rest-jackson io.quarkus @@ -163,6 +193,10 @@ io.quarkus quarkus-hibernate-validator + + org.awaitility + awaitility + io.quarkus quarkus-cache @@ -170,8 +204,26 @@ io.quarkiverse.helm quarkus-helm - 1.2.3 + + + + org.mvnpm + lit + provided + + + org.mvnpm + lodash + provided + + + org.mvnpm.at.types + lodash + provided + + + io.quarkus quarkus-junit5 @@ -190,10 +242,13 @@ org.assertj assertj-core + test - org.awaitility - awaitility + io.quarkiverse.playwright + quarkus-playwright + 0.0.1 + test @@ -339,4 +394,67 @@ + + + + locker + + + !unlocked + + + + + + org.mvnpm.at.lit-labs + ssr-dom-shim + 1.2.0 + runtime + + + org.mvnpm.at.lit + reactive-element + 2.0.4 + runtime + + + org.mvnpm.at.types + lodash + 4.17.0 + provided + + + org.mvnpm.at.types + trusted-types + 2.0.7 + runtime + + + org.mvnpm + lit-element + 4.0.4 + runtime + + + org.mvnpm + lit-html + 3.1.2 + runtime + + + org.mvnpm + lit + 3.1.2 + provided + + + org.mvnpm + lodash + 4.17.21 + provided + + + + + diff --git a/src/main/java/io/quarkus/search/app/SearchService.java b/src/main/java/io/quarkus/search/app/SearchService.java index c26fc8eb..8c367df3 100644 --- a/src/main/java/io/quarkus/search/app/SearchService.java +++ b/src/main/java/io/quarkus/search/app/SearchService.java @@ -3,6 +3,7 @@ import java.util.List; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.validation.constraints.Max; @@ -19,6 +20,8 @@ import io.quarkus.search.app.entity.Language; import io.quarkus.search.app.entity.QuarkusVersionAndLanguageRoutingBinder; +import io.quarkus.runtime.LaunchMode; + import org.hibernate.Length; import org.hibernate.search.engine.search.common.BooleanOperator; import org.hibernate.search.engine.search.common.ValueConvert; @@ -28,6 +31,8 @@ import org.eclipse.microprofile.openapi.annotations.Operation; import org.jboss.resteasy.reactive.RestQuery; +import io.vertx.ext.web.Router; + @ApplicationScoped @Path("/") public class SearchService { @@ -39,6 +44,16 @@ public class SearchService { @Inject SearchSession session; + public void init(@Observes Router router) { + if (LaunchMode.current().isDevOrTest()) { + return; + } + // DISABLE the index.html route in production + router.getWithRegex("/(index\\.html)?").order(Integer.MIN_VALUE).handler(rc -> { + rc.response().setStatusCode(404).end(); + }); + } + @GET @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "Search for Guides") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 01998b20..784605fb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -50,7 +50,7 @@ quarkus.http.header."X-Content-Type-Options".value=nosniff quarkus.http.header."X-Frame-Options".value=deny quarkus.http.header."Strict-Transport-Security".value=max-age=31536000; includeSubDomains -quarkus.resteasy-reactive.path=/api +quarkus.rest.path=/api ######################## # Hibernate @@ -114,6 +114,8 @@ quarkus.test.integration-test-profile=integrationtest %dev,test,integrationtest.indexing.scheduled.cron=off # Allow localhost in particular %dev.quarkus.http.cors.origins=/.*/ +%dev.quarkus.http.header."Access-Control-Allow-Private-Network".value=true + ######################## # Logging @@ -233,3 +235,11 @@ quarkus.helm.values."@.opensearch.resources.limits.cpu".value=2000m quarkus.helm.values."@.opensearch.resources.requests.cpu".value=500m quarkus.helm.values."@.opensearch.resources.limits.memory".value=2Gi quarkus.helm.values."@.opensearch.resources.requests.memory".value=1.9Gi + +######################## +# Web Bundler config +######################## +quarkus.web-bundler.loaders.data-url=svg +quarkus.web-bundler.loaders.file= +quarkus.web-bundler.bundle-redirect=true +quarkus.web-bundler.dependencies.node-modules=node_modules diff --git a/src/main/resources/web/app/assets/icons/docsicon-concepts.svg b/src/main/resources/web/app/assets/icons/docsicon-concepts.svg new file mode 100644 index 00000000..4916aa33 --- /dev/null +++ b/src/main/resources/web/app/assets/icons/docsicon-concepts.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/web/app/assets/icons/docsicon-guides.svg b/src/main/resources/web/app/assets/icons/docsicon-guides.svg new file mode 100644 index 00000000..d7ca4e0d --- /dev/null +++ b/src/main/resources/web/app/assets/icons/docsicon-guides.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/web/app/assets/icons/docsicon-pdf.svg b/src/main/resources/web/app/assets/icons/docsicon-pdf.svg new file mode 100644 index 00000000..bba3719a --- /dev/null +++ b/src/main/resources/web/app/assets/icons/docsicon-pdf.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + diff --git a/src/main/resources/web/app/assets/icons/docsicon-reference.svg b/src/main/resources/web/app/assets/icons/docsicon-reference.svg new file mode 100644 index 00000000..ecf8b094 --- /dev/null +++ b/src/main/resources/web/app/assets/icons/docsicon-reference.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/web/app/assets/icons/docsicon-tutorials.svg b/src/main/resources/web/app/assets/icons/docsicon-tutorials.svg new file mode 100644 index 00000000..4a012259 --- /dev/null +++ b/src/main/resources/web/app/assets/icons/docsicon-tutorials.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/web/app/assets/icons/index.ts b/src/main/resources/web/app/assets/icons/index.ts new file mode 100644 index 00000000..9bdee69a --- /dev/null +++ b/src/main/resources/web/app/assets/icons/index.ts @@ -0,0 +1,27 @@ +import tutorials from './docsicon-tutorials.svg'; +import guides from './docsicon-guides.svg'; +import howto from './docsicon-guides.svg'; +import pdf from './docsicon-pdf.svg'; +import concepts from './docsicon-concepts.svg'; +import reference from './docsicon-reference.svg'; +import quarkus from './quarkus_icon_default.svg'; +import quarkiverse from './quarkiverse_icon_default.svg'; +import loading from './loading.svg'; + +const icons = { + docs: { + tutorials, + guides, + howto, + pdf, + concepts, + reference + }, + origins: { + quarkus, + quarkiverse + }, + loading +} + +export default icons; \ No newline at end of file diff --git a/src/main/resources/web/app/assets/icons/loading.svg b/src/main/resources/web/app/assets/icons/loading.svg new file mode 100644 index 00000000..fd7db769 --- /dev/null +++ b/src/main/resources/web/app/assets/icons/loading.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/web/app/assets/icons/quarkiverse_icon_default.svg b/src/main/resources/web/app/assets/icons/quarkiverse_icon_default.svg new file mode 100644 index 00000000..80ed798c --- /dev/null +++ b/src/main/resources/web/app/assets/icons/quarkiverse_icon_default.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/web/app/assets/icons/quarkus_icon_default.svg b/src/main/resources/web/app/assets/icons/quarkus_icon_default.svg new file mode 100644 index 00000000..ca2bc890 --- /dev/null +++ b/src/main/resources/web/app/assets/icons/quarkus_icon_default.svg @@ -0,0 +1 @@ +quarkus_icon_rgb_1024px_default \ No newline at end of file diff --git a/src/main/resources/web/app/local-search.ts b/src/main/resources/web/app/local-search.ts new file mode 100644 index 00000000..360f5116 --- /dev/null +++ b/src/main/resources/web/app/local-search.ts @@ -0,0 +1,74 @@ +import {Guide} from "./types"; + +export class LocalSearch { + + static guides: Guide[] = null; + + static queryDocumentGuides(selector = "qs-target qs-guide"): Guide[] { + const elements = document.querySelectorAll(selector); + const guides: Guide[] | undefined = elements ? [] : null; + + for (let i = 0; i < elements.length; i++) { + const element: Element = elements[i]; + guides.push({ + title: element.getAttribute("title"), + categories: element.getAttribute("categories"), + type: element.getAttribute("type"), + url: element.getAttribute("url"), + summary: element.getAttribute("summary"), + keywords: element.getAttribute("keywords"), + content: element.getAttribute("content"), + origin: element.getAttribute("origin") + }); + } + return guides; + } + + static enableLocalSearch(selector?: string) { + LocalSearch.guides = LocalSearch.queryDocumentGuides(selector); + if (LocalSearch.guides != null) { + console.log("LocalSearch is ready with " + LocalSearch.guides.length + " guides found."); + } + } + + static search(params: any) { + const guides = LocalSearch.guides; + if (guides == null) { + return null; + } + const terms: string[] = []; + if (params["q"]) { + terms.push(...params["q"].split(' ').map((token: string) => token.trim())); + } + const categories = []; + if (params["categories"]) { + if (Array.isArray(params["categories"])) { + categories.push(...params["categories"]); + } else { + categories.push(params["categories"]); + } + } + + return guides + .filter(g => { + let match = true + if (match && categories.length > 0) { + match = LocalSearch.containsAllCaseInsensitive(g.categories, categories) + } + if (match && terms.length > 0) { + match = LocalSearch.containsAllCaseInsensitive(`${g.keywords}${g.summary}${g.title}${g.categories}`, terms) + } + return match + }) + } + + private static containsAllCaseInsensitive(elem: string, terms: string[]) { + const text = (elem ? elem : '').toLowerCase(); + for (let i in terms) { + if (text.indexOf(terms[i].toLowerCase()) < 0) { + return false + } + } + return true + } +} \ No newline at end of file diff --git a/src/main/resources/web/app/qs-form.ts b/src/main/resources/web/app/qs-form.ts new file mode 100644 index 00000000..a040d1f6 --- /dev/null +++ b/src/main/resources/web/app/qs-form.ts @@ -0,0 +1,225 @@ +import {css, html, LitElement} from 'lit-element'; +import {customElement, property} from 'lit/decorators.js'; +import debounce from 'lodash/debounce'; +import {LocalSearch} from "./local-search"; + +export const QS_START_EVENT = 'qs-start'; +export const QS_END_EVENT = 'qs-end'; +export const QS_RESULT_EVENT = 'qs-result'; +export const QS_NEXT_PAGE_EVENT = 'qs-next-page'; + +export interface QsResult { + hits: QsHit[]; + hasMoreHits: boolean; +} + +export interface QsHit { + title: string; + summary: string; + url: string; + keywords: string | undefined; + content: string | undefined; + type: string | undefined; +} + +/** + * This component is the form that triggers the search + */ +@customElement('qs-form') +export class QsForm extends LitElement { + + static styles = css` + .quarkus-search { + display: block !important; + } + + .d-none { + display: none; + } + `; + + @property({type: String}) server: string = ""; + @property({type: String, attribute: 'min-chars'}) minChars: number = 3; + @property({type: String, attribute: 'initial-timeout'}) initialTimeout: number = 1500; + @property({type: String, attribute: 'more-timeout'}) moreTimeout: number = 2500; + @property({type: String}) language: String = "en"; + @property({type: String, attribute: 'quarkus-version'}) quarkusversion?: string; + @property({type: String, attribute: 'local-search'}) localSearch: boolean = false; + + private _page: number = 0; + private _currentHitCount: number = 0; + private _abortController?: AbortController = null; + + render() { + return html` +
+ +
+ `; + } + + connectedCallback() { + super.connectedCallback(); + LocalSearch.enableLocalSearch(); + const formElements = this._getFormElements(); + this.addEventListener(QS_NEXT_PAGE_EVENT, this._handleNextPage); + formElements.forEach((el) => { + const eventName = this._isInput(el) ? 'input' : 'change'; + el.addEventListener(eventName, this._handleInputChange); + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener(QS_NEXT_PAGE_EVENT, this._handleNextPage); + const formElements = this._getFormElements(); + formElements.forEach(el => { + const eventName = this._isInput(el) ? 'input' : 'change'; + el.removeEventListener(eventName, this._handleInputChange); + }); + } + + private _getFormElements(): NodeListOf { + return this.querySelectorAll('input, select'); + } + + private _readQueryInputs() { + const formElements = this._getFormElements(); + const formData = { + language: this.language, + contentSnippets: 2, + contentSnippetsLength: 120, + }; + + if (this.quarkusversion) { + formData['version'] = this.quarkusversion; + } + + formElements.forEach((el: HTMLFormElement) => { + if (el.value && el.value.length > 0 && el.name.length > 0) { + formData[el.name] = el.value; + } + }); + + return formData; + } + + private _search = () => { + if (this._abortController) { + // If a search is already in progress, abort it + this._abortController.abort(); + } + const controller = new AbortController(); + this._abortController = controller; + this.dispatchEvent(new CustomEvent(QS_START_EVENT)); + const data = this._readQueryInputs(); + if (this.localSearch) { + this._localSearch(); + if (this._abortController == controller) { + this._abortController = null + } + return; + } + + this._jsonFetch(controller, 'GET', data, this._page > 0 ? this.initialTimeout : this.moreTimeout) + .then((r: any) => { + let event = QS_RESULT_EVENT; + if (this._page > 0) { + this._currentHitCount += r.hits.length; + } else { + this._currentHitCount = r.hits.length; + } + const total = r.total?.lowerBound; + const hasMoreHits = r.hits.length > 0 && total > this._currentHitCount; + this.dispatchEvent(new CustomEvent(event, {detail: {...r, page: this._page, hasMoreHits}})); + this.dispatchEvent(new CustomEvent(QS_END_EVENT)); + }).catch(e => { + console.error('Could not run search: ' + e); + if (this._abortController != controller) { + // A concurrent search erased ours; most likely input changed while waiting for results. + // Ignore this search and let the concurrent one reset the data as it sees fit. + return; + } + this._clearSearch(); + // Fall back to Javascript in-page search + this._localSearch(); + }).finally(() => { + if (this._abortController == controller) { + this._abortController = null + } + }); + } + + + + private _searchDebounced = debounce(this._search, 300); + + private _handleInputChange = (e: Event) => { + const target = e.target as HTMLFormElement; + if (this._isInput(target)) { + if (target.value.length === 0) { + this._clearSearch(); + return; + } + if (target.value.length < this.minChars) { + return; + } + } + this._searchDebounced(); + } + + private _handleNextPage = (e: CustomEvent) => { + this._page++; + this._search(); + } + + private _isInput(el: HTMLFormElement) { + return el.tagName.toLowerCase() === 'input' + } + + private async _jsonFetch(controller: AbortController, method: string, params: object, timeout: number) { + const queryParams: Record = { + ...params, + 'page': this._page.toString() + }; + const timeoutId = setTimeout(() => controller.abort(), timeout) + const response = await fetch(this.server + '/api/guides/search?' + (new URLSearchParams(queryParams)).toString(), { + method: method, + mode: 'cors', + headers: { + 'Access-Control-Allow-Origin': '*' + }, + signal: controller.signal, + body: null + }) + clearTimeout(timeoutId) + if (response.ok) { + return await response.json() + } else { + throw 'Response status is ' + response.status + '; response: ' + await response.text() + } + } + + private _clearSearch() { + this._page = 0; + this._currentHitCount = 0; + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + } + this.dispatchEvent(new CustomEvent(QS_RESULT_EVENT, {detail: {}})); + } + + private _localSearch(): any { + let hits = LocalSearch.search(this._readQueryInputs()); + if(!!hits) { + const result = { + hits, + total: hits.length + } + this.dispatchEvent(new CustomEvent(QS_RESULT_EVENT, {detail: {...result, page: 0, hasMoreHits: false}})); + } + this.dispatchEvent(new CustomEvent(QS_END_EVENT)); + } + +} \ No newline at end of file diff --git a/src/main/resources/web/app/qs-guide.ts b/src/main/resources/web/app/qs-guide.ts new file mode 100644 index 00000000..5f02ae8b --- /dev/null +++ b/src/main/resources/web/app/qs-guide.ts @@ -0,0 +1,166 @@ +import { LitElement, html, css, unsafeCSS } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { customElement, property } from 'lit/decorators.js'; +import icons from './assets/icons'; + +/** + * This component is a single guide hit in the search results + */ +@customElement('qs-guide') +export class QsGuide extends LitElement { + + static styles = css` + :host { + --link-color: #1259A5; + --link-hover-color: #c00; + --content-highlight-color: #777; + } + + @media screen and (max-width: 1300px) { + + .qs-hit { + grid-column: span 6; + } + } + + .highlighted { + font-weight: bold; + } + + .qs-guide { + background-size: 70px 70px; + background-repeat: no-repeat; + background-image: url('${unsafeCSS(icons.docs.guides)}'); + + &.type-tutorial { + background-image: url('${unsafeCSS(icons.docs.tutorials)}'); + } + + &.type-guide { + background-image: url('${unsafeCSS(icons.docs.guides)}'); + } + + &.type-howto { + background-image: url('${unsafeCSS(icons.docs.howto)}'); + } + + &.type-reference { + background-image: url('${unsafeCSS(icons.docs.reference)}'); + } + + &.type-pdf { + background-image: url('${unsafeCSS(icons.docs.pdf)}'); + } + + &.type-concepts { + background-image: url('${unsafeCSS(icons.docs.concepts)}'); + } + } + + .qs-guide a { + line-height: 1.5rem; + font-weight: 400; + cursor: pointer; + text-decoration: underline; + color: var(--link-color); + } + + .qs-guide a:hover { + color: var(--link-hover-color); + } + + .qs-guide h4 { + margin: 1rem 0 0 90px; + } + + .qs-guide div { + margin: 1rem 0 0 90px; + font-size: 1rem; + line-height: 1.5rem; + font-weight: 400; + } + + .qs-guide .content-highlights { + font-size: 0.7rem; + line-height: 1rem; + color: var(--content-highlight-color); + } + + .qs-guide .origin { + background-size: 20px 20px; + background-repeat: no-repeat; + background-position: center; + margin-left: 5px; + width: 20px; + height: 20px; + display: inline-block; + vertical-align: middle; + } + + .qs-guide .origin.quarkus { + background-image: url('${unsafeCSS(icons.origins.quarkus)}'); + } + + .qs-guide .origin.quarkiverse-hub { + background-image: url('${unsafeCSS(icons.origins.quarkiverse)}'); + } + + .summary { + min-height: 40px; + } + `; + + @property({type: Object}) data: any; + @property({type: String}) type: string = "default"; + @property({type: String}) url: string; + @property({type: String}) title: string; + @property({type: String}) summary: string; + @property({type: String}) keywords: string; + @property({type: String}) content: string | [string] + @property({type: String}) origin: string = "quarkus"; + + + connectedCallback() { + if (this.data) { + for (const key in this.data) { + if (this.data.hasOwnProperty(key)) { + this[key] = this.data[key]; + } + } + } + super.connectedCallback(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + } + + render() { + return html` +
+

+ ${this._renderHTML(this.title)} + ${(this.origin && this.origin.toLowerCase() !== 'quarkus') ? html`` : ''} +

+
+

${this._renderHTML(this.summary)}

+
+
${this._renderHTML(this.keywords)}
+
+ ${this._renderHTML(this.content)} +
+ +
+ `; + } + + private _renderHTML(content?: string | [string]) { + if(!content) { + return content; + } + if(Array.isArray(content)) { + return content.map((c) => html`

${this._renderHTML(c)}

`); + } + return unsafeHTML(content); + } +} \ No newline at end of file diff --git a/src/main/resources/web/app/qs-target.ts b/src/main/resources/web/app/qs-target.ts new file mode 100644 index 00000000..1d9a7d38 --- /dev/null +++ b/src/main/resources/web/app/qs-target.ts @@ -0,0 +1,187 @@ +import {LitElement, html, css, unsafeCSS} from 'lit'; +import {customElement, property, state, queryAll} from 'lit/decorators.js'; +import './qs-guide' +import {QS_END_EVENT, QS_NEXT_PAGE_EVENT, QS_RESULT_EVENT, QS_START_EVENT, QsResult} from "./qs-form"; +import debounce from 'lodash/debounce'; +import icons from "./assets/icons"; + + +/** + * This component is the target of the search results + */ +@customElement('qs-target') +export class QsTarget extends LitElement { + + static styles = css` + + .loading { + background-image: url('${unsafeCSS(icons.loading)}'); + background-repeat: no-repeat; + background-position: top; + background-size: 45px; + padding-top: 55px; + text-align: center; + padding-bottom: 10px; + } + + .qs-hits { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-gap: 1em; + clear: both; + } + + .no-hits { + padding: 10px; + margin: 10px; + font-size: 1.2rem; + line-height: 1.5; + font-weight: 400; + font-style: italic; + text-align: center; + background: var(--empty-background-color, #F0CA4D); + } + + + qs-guide { + grid-column: span 4; + margin: 1rem 0rem 1rem 0rem; + + @media screen and (max-width: 1300px) { + grid-column: span 6; + } + + @media screen and (max-width: 768px) { + grid-column: span 12; + margin: 1rem 0rem 1rem 0rem; + } + + @media screen and (max-width: 480px) { + grid-column: span 12; + } + } + + `; + + @property({type: String}) private type: string = "guide"; + @state() private _result: QsResult | undefined; + @state() private _loading = false; + @queryAll('.qs-hit') private _hits: NodeListOf; + + private _form: HTMLElement; + + connectedCallback() { + super.connectedCallback(); + this._form = document.querySelector("qs-form"); + this._form.addEventListener(QS_RESULT_EVENT, this._handleResult); + this._form.addEventListener(QS_START_EVENT, this._loadingStart); + this._form.addEventListener(QS_END_EVENT, this._loadingEnd); + document.addEventListener('scroll', this._handleScrollDebounced) + } + + disconnectedCallback() { + this._form.removeEventListener(QS_RESULT_EVENT, this._handleResult); + this._form.removeEventListener(QS_START_EVENT, this._loadingStart); + this._form.removeEventListener(QS_END_EVENT, this._loadingEnd); + document.removeEventListener('scroll', this._handleScrollDebounced); + super.disconnectedCallback(); + } + + render() { + if (this._result?.hits) { + if (this._result.hits.length === 0) { + return html` +
+

Sorry, no ${this.type} matched your search. Please try again.

+
+ `; + } + const result = this._result.hits.map(i => this._renderHit(i)); + return html` +
+ ${result} +
+ ${this._loading ? this._renderLoading() : ''} + `; + } + if (this._loading) { + return html` +
${this._renderLoading()}
`; + } + return html` +
+ +
+ `; + } + + + private _renderLoading() { + return html` +
Searching...
+ `; + } + + private _renderHit(i) { + switch (this.type) { + case 'guide': + return html` + ` + } + return '' + } + + + private _handleScroll = (e) => { + if (this._loading) { + return; + } + if (!this._result) { + // No search. + return; + } + if (!this._result.hasMoreHits) { + // No more hits to fetch. + console.log("no more hits"); + return + } + const lastHit = this._hits.length == 0 ? null : this._hits[this._hits.length - 1] + if (!lastHit) { + // No result card is being displayed at the moment. + return + } + const scrollElement = document.documentElement // Scroll bar is on the element + const bottomOfViewport = scrollElement.scrollTop + scrollElement.clientHeight + const topOfLastResultCard = lastHit.offsetTop + if (bottomOfViewport >= topOfLastResultCard) { + // We have scrolled to the bottom of the last result card. + this._loading = true; + this._form.dispatchEvent(new CustomEvent(QS_NEXT_PAGE_EVENT)); + } + } + private _handleScrollDebounced = debounce(this._handleScroll, 100); + + private _handleResult = (e: CustomEvent) => { + console.debug("Received results in qs-target: ", e.detail); + if (!this._result || !e.detail.hits || e.detail.page === 0) { + if(e.detail.hits) { + document.body.classList.add("qs-has-results"); + } else { + document.body.classList.remove("qs-has-results"); + } + this._result = e.detail; + return; + } + this._result.hits = this._result.hits.concat(e.detail.hits); + console.debug(`${this._result.hits.length} results in qs-target: `); + this._result.hasMoreHits = e.detail.hasMoreHits; + } + + private _loadingStart = () => { + this._loading = true; + } + + private _loadingEnd = () => { + this._loading = false; + } +} \ No newline at end of file diff --git a/src/main/resources/web/app/style.scss b/src/main/resources/web/app/style.scss new file mode 100644 index 00000000..a9371fa7 --- /dev/null +++ b/src/main/resources/web/app/style.scss @@ -0,0 +1,109 @@ +:root { + --background: white; + --text-color: black; + --input-border-color: #5996E5; + --top-nav-bg: black; +} + +qs-form::slotted(*) { + display: none; +} + +body { + font-family: 'Open Sans', Arial, sans-serif; + background-color: var(--background); /* Quarkus background color */ + color: var(--text-color); /* Quarkus text color */ + margin: 0; +} + +nav.search { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-around; + background-color: var(--top-nav-bg); + gap: 20px; + padding: 0 10px; + @media screen and (max-width: 768px) { + gap: 10px; + } + +} + +.search-query input{ + padding: 10px; + border-radius: 5px; + outline: none; + border: 1px solid var(--input-border-color); + width: 100%; +} + +.logo { + background-image: url('/static/quarkus-logo-h-w.svg'); + background-position: left; + background-repeat: no-repeat; + height: 50px; + width: 200px; +} + +qs-form { + flex-grow: 1; +} + +body:not(.qs-has-results) { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + + + .logo { + background-image: url('/static/quarkus-logo-v.svg'); + background-size: 200px; + background-position: center; + height: 200px; + width: 200px; + } + + nav.search { + flex-direction: column; + background-color: white; + width: 500px; + gap: 0; + + .search-query input { + width: 500px; + margin-bottom: 100px; + + } + + @media screen and (max-width: 768px) { + padding: 0 20px; + + .search-query input { + width: 300px; + } + } + } + + +} + +.title { + color: #007bff; /* Quarkus primary color */ +} + +.filters { + padding: 15px; + border-radius: 5px; +} + +qs-target { + display: block; + padding: 30px; + + @media screen and (max-width: 768px) { + padding: 10px; + } +} + diff --git a/src/main/resources/web/app/types.d.ts b/src/main/resources/web/app/types.d.ts new file mode 100644 index 00000000..e1102e92 --- /dev/null +++ b/src/main/resources/web/app/types.d.ts @@ -0,0 +1,10 @@ +export interface Guide { + title: string; + type: string; + url: string; + summary: string; + keywords: string; + content?: [string] | string; + categories?: string; + origin?: string; +} \ No newline at end of file diff --git a/src/main/resources/web/index.d.ts b/src/main/resources/web/index.d.ts new file mode 100644 index 00000000..c26f543d --- /dev/null +++ b/src/main/resources/web/index.d.ts @@ -0,0 +1 @@ +declare module '*.svg'; \ No newline at end of file diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html new file mode 100644 index 00000000..187806bf --- /dev/null +++ b/src/main/resources/web/index.html @@ -0,0 +1,30 @@ + + + + Quarkus Search + + + + + + + + {#bundle/} + + +
+ + + +
+ + \ No newline at end of file diff --git a/src/main/resources/web/static/quarkus-logo-h-w.svg b/src/main/resources/web/static/quarkus-logo-h-w.svg new file mode 100644 index 00000000..1734560d --- /dev/null +++ b/src/main/resources/web/static/quarkus-logo-h-w.svg @@ -0,0 +1 @@ +quarkus_logo_horizontal_rgb_1280px_reverse \ No newline at end of file diff --git a/src/main/resources/web/static/quarkus-logo-v.svg b/src/main/resources/web/static/quarkus-logo-v.svg new file mode 100644 index 00000000..9b05612b --- /dev/null +++ b/src/main/resources/web/static/quarkus-logo-v.svg @@ -0,0 +1 @@ +quarkus_logo_vertical_rgb_1280px_default \ No newline at end of file diff --git a/src/main/resources/web/tsconfig.json b/src/main/resources/web/tsconfig.json new file mode 100644 index 00000000..95bb7e24 --- /dev/null +++ b/src/main/resources/web/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "target": "es2016", + "lib": ["es2021", "DOM"], + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": false, + "noImplicitAny": false + } +} \ No newline at end of file diff --git a/src/test/java/io/quarkus/search/app/WebComponentsTest.java b/src/test/java/io/quarkus/search/app/WebComponentsTest.java new file mode 100644 index 00000000..a97a8c96 --- /dev/null +++ b/src/test/java/io/quarkus/search/app/WebComponentsTest.java @@ -0,0 +1,64 @@ +package io.quarkus.search.app; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URL; +import java.util.List; + +import io.quarkus.search.app.testsupport.QuarkusIOSample; +import io.quarkus.search.app.testsupport.SetupUtil; + +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.ElementHandle; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Response; + +import io.quarkiverse.playwright.InjectPlaywright; +import io.quarkiverse.playwright.WithPlaywright; + +@QuarkusTest +@WithPlaywright(verbose = true) +@QuarkusIOSample.Setup +public class WebComponentsTest { + + private static final String LABEL_SEARCH_QUERY = "[aria-label='Search Query']"; + private static final String LABEL_SEARCH_HITs = "[aria-label='Search Hits']"; + private static final String LABEL_GUIDE_HIT = "[aria-label='Guide Hit']"; + + @InjectPlaywright + BrowserContext context; + + @TestHTTPResource("/") + URL indexPage; + + @BeforeAll + static void setup() { + SetupUtil.waitForIndexing(WebComponentsTest.class); + } + + @Test + public void testSearch() { + final Page page = context.newPage(); + Response response = page.navigate(indexPage.toString()); + Assertions.assertEquals("OK", response.statusText()); + + page.waitForLoadState(); + + String title = page.title(); + Assertions.assertEquals("Quarkus Search", title); + + ElementHandle searchInput = page.waitForSelector(LABEL_SEARCH_QUERY); + searchInput.type("rest"); + page.waitForSelector(LABEL_SEARCH_HITs); + List hits = page.querySelectorAll(LABEL_GUIDE_HIT); + assertThat(hits).isNotEmpty(); + } + +}