1.7.1原版

This commit is contained in:
2026-07-18 15:20:48 +08:00
commit 8b7190ae69
53 changed files with 7962 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
import type {
HaloDocument,
SearchOption,
SearchResult,
} from '@halo-dev/api-client';
import { msg } from '@lit/localize';
import resetStyles from '@unocss/reset/tailwind.css?inline';
import { type DebouncedFunction, debounce } from 'es-toolkit';
import { uniqBy } from 'es-toolkit/compat';
import { css, html, LitElement, unsafeCSS } from 'lit';
import { property, state } from 'lit/decorators.js';
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { HISTORY_KEY, MAX_HISTORY_ITEMS } from './constants';
import baseStyles from './styles/base';
export class SearchForm extends LitElement {
@property({ type: String })
baseUrl = '';
@property({ type: Object })
options = {};
@state()
private keyword = '';
@state()
private searchResult?: SearchResult;
@state()
private loading: boolean = false;
@state()
private selectedIndex = 0;
@state()
private historyHits: HaloDocument[] = [];
inputRef: Ref<HTMLInputElement> = createRef<HTMLInputElement>();
constructor() {
super();
this.addEventListener('keydown', this.handleKeydown);
this.historyHits = JSON.parse(
localStorage.getItem(HISTORY_KEY) || '[]'
) as HaloDocument[];
setTimeout(() => {
this.inputRef.value?.focus();
}, 0);
}
override render() {
return html`
<div class="p-3 z-1 bg-base sticky top-0 border-b border-divider">
<form
class="flex items-center ring-2 h-12 rounded-base px-2.5 ring-primary bg-base"
@submit=${(e: Event) => e.preventDefault()}
>
<span
class="shrink flex-none size-6 text-primary ${
this.loading
? 'i-lucide-loader-circle animate-spin'
: 'i-lucide-search'
}"
></span>
<input
@input="${this.onInput}"
placeholder=${msg('Enter keywords to search')}
autocomplete="off"
spellcheck="false"
${ref(this.inputRef)}
class="flex-1 min-w-0 outline-none text-content h-full px-2.5 bg-transparent"
/>
${
this.keyword
? html`
<span
@click=${this.handleClearInput}
class="flex-none cursor-pointer shrink i-lucide-x size-5 text-muted hover:text-content"
></span>
`
: ''
}
</form>
</div>
${this.keyword ? this.renderItems() : this.renderHistoryItems()}
<div
class="border-t border-divider p-3 bg-base sticky bottom-0 space-x-5 flex justify-end"
>
${[
{
text: html`${msg('Select')}`,
kbdIcons: ['i-lucide-arrow-up', 'i-lucide-arrow-down'],
},
{
text: html`${msg('Confirm')}`,
kbdIcons: ['i-lucide-corner-down-left'],
},
{
text: html`${msg('Close')}`,
kbdIcons: ['i-mdi-keyboard-esc'],
},
].map(
(item) => html`
<div class="flex items-center space-x-1.5">
${item.kbdIcons.map(
(icon) => html`
<kbd
class="inline-flex justify-center items-center py-1 px-1.5 bg-base border border-kbd font-mono text-sm text-content rounded-base shadow-kbd"
>
<i class="${icon}"></i>
</kbd>
`
)}
<span class="text-xs text-muted">${item.text}</span>
</div>
`
)}
</div>
`;
}
handleClearInput() {
this.keyword = '';
this.searchResult = undefined;
if (this.inputRef.value) {
this.inputRef.value.value = '';
this.inputRef.value.focus();
}
}
renderItems() {
return html`<div class="p-3">
${!this.searchResult?.hits?.length ? this.renderEmpty() : ''}
<ul class="space-y-1.5" role="list">
${this.searchResult?.hits?.map((hit, index) =>
this.renderListItem(hit, index, 'i-lucide-file-text')
)}
</ul>
</div>`;
}
renderEmpty() {
return html`<div class="flex py-4 justify-center text-sm text-muted">
<span>${msg('No search results')}</span>
</div>`;
}
renderHistoryItems() {
return html`
<div class="p-3">
${
this.historyHits.length
? html`<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-primary">
${msg('Recent')}
</h3>
<span
class="text-xs cursor-pointer text-muted hover:text-content"
@click=${this.handleClearHistory}
>
${msg('Clear')}
</span>
</div>
<ul class="mt-3 space-y-1.5" role="list">
${this.historyHits?.map((hit, index) =>
this.renderListItem(hit, index, 'i-lucide-history')
)}
</ul>`
: this.renderEmpty()
}
</div>
`;
}
renderListItem(hit: HaloDocument, index: number, listIcon: string) {
return html`
<li
@click="${() => this.handleOpenLink(hit)}"
@mouseenter=${() => {
this.selectedIndex = index;
}}
class="shadow-sm flex items-center space-x-3 rounded-base cursor-pointer p-3 bg-hit [&_mark]:text-primary [&_mark]:font-semibold [&_mark]:bg-transparent ${
index === this.selectedIndex
? '!bg-primary [&_mark]:!text-white [&_mark]:underline'
: ''
}"
data-index=${index}
>
<span
class="flex-none shrink ${listIcon} ${
this.selectedIndex === index ? 'text-white' : 'text-muted'
}"
></span>
<div class="flex-1 space-y-1.5 min-w-0">
<h2
class="text-sm font-medium ${
this.selectedIndex === index ? 'text-white' : 'text-content'
}"
>
${unsafeHTML(hit.title)}
</h2>
${
hit.description
? html`
<p
class="text-xs leading-6 ${
this.selectedIndex === index
? 'text-white/90'
: 'text-muted'
}"
>
${unsafeHTML(hit.description)}
</p>
`
: ''
}
</div>
<span
class="i-lucide-corner-down-left flex-none shrink text-white invisible ${
this.selectedIndex === index ? '!visible' : ''
}"
></span>
</li>
`;
}
onInput(e: InputEvent) {
const input = e.target as HTMLInputElement;
const value = input.value;
this.keyword = value || '';
this.selectedIndex = 0;
if (value === '') {
this.searchResult = undefined;
return;
}
this.loading = true;
this.fetchHits(value);
}
handleClearHistory() {
localStorage.removeItem(HISTORY_KEY);
this.historyHits = [];
}
fetchHits: DebouncedFunction<(keyword: string) => Promise<void>> = debounce(
async (keyword: string) => {
const searchOptions: SearchOption = {
...this.options,
highlightPostTag: '</mark>',
highlightPreTag: '<mark>',
keyword,
limit: 20,
};
const response = await fetch(
`/apis/api.halo.run/v1alpha1/indices/-/search`,
{
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(searchOptions),
method: 'post',
}
);
const data = (await response.json()) as SearchResult;
this.searchResult = data;
this.loading = false;
},
300
);
handleOpenLink(hit: HaloDocument) {
const updatedHistory = uniqBy([hit, ...this.historyHits], 'id').slice(
0,
MAX_HISTORY_ITEMS
);
localStorage.setItem(HISTORY_KEY, JSON.stringify(updatedHistory));
this.historyHits = updatedHistory;
window.location.href = hit.permalink;
}
handleKeydown = (e: KeyboardEvent) => {
const { key, ctrlKey } = e;
const hits = this.keyword ? this.searchResult?.hits : this.historyHits;
if (!hits) {
return;
}
if (key === 'ArrowUp' || (key === 'k' && ctrlKey)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.handleScrollIntoSelected();
e.preventDefault();
}
if (key === 'ArrowDown' || (key === 'j' && ctrlKey)) {
this.selectedIndex = Math.min(hits.length || 0, this.selectedIndex + 1);
this.handleScrollIntoSelected();
e.preventDefault();
}
if (key === 'Enter') {
const hit = hits[this.selectedIndex];
if (hit) {
this.handleOpenLink(hit);
}
}
};
handleScrollIntoSelected() {
const selectedElement = this.shadowRoot?.querySelector(
`[data-index="${this.selectedIndex}"]`
);
if (selectedElement) {
selectedElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}
static override styles = [
unsafeCSS(resetStyles),
baseStyles,
css`
@unocss-placeholder;
`,
];
}
customElements.get('search-form') ||
customElements.define('search-form', SearchForm);
declare global {
interface HTMLElementTagNameMap {
'search-form': SearchForm;
}
}