diff --git a/README.md b/README.md index 2b9c02d..ed9054d 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,11 @@ This is a monorepo containing multiple standalone projects. Each project lives i ```plaintext code-samples/ +├── typesense-angular-search-bar/ # Angular + Typesense search implementation ├── typesense-astro-search/ # Astro + Typesense search implementation ├── typesense-gin-full-text-search/ # Go (Gin) + Typesense backend implementation ├── typesense-next-search-bar/ # Next.js + Typesense search implementation +├── typesense-nuxt-search-bar/ # Nuxt.js + Typesense search implementation ├── typesense-qwik-js-search/ # Qwik + Typesense search implementation ├── typesense-react-native-search-bar/ # React Native + Typesense search implementation ├── typesense-solid-js-search/ # SolidJS + Typesense search implementation @@ -22,9 +24,11 @@ code-samples/ | Project | Framework | Description | | ---------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------- | +| [typesense-angular-search-bar](./typesense-angular-search-bar) | Angular | A modern search bar with instant search capabilities | | [typesense-astro-search](./typesense-astro-search) | Astro | A modern search bar with instant search capabilities | | [typesense-gin-full-text-search](./typesense-gin-full-text-search) | Go (Gin) | Backend API with full-text search using Typesense | | [typesense-next-search-bar](./typesense-next-search-bar) | Next.js | A modern search bar with instant search capabilities | +| [typesense-nuxt-search-bar](./typesense-nuxt-search-bar) | Nuxt.js | A modern search bar with instant search capabilities | | [typesense-qwik-js-search](./typesense-qwik-js-search) | Qwik | Resumable search bar with real-time search and modern UI | | [typesense-react-native-search-bar](./typesense-react-native-search-bar) | React Native | A mobile search bar with instant search capabilities | | [typesense-solid-js-search](./typesense-solid-js-search) | SolidJS | A modern search bar with instant search capabilities | diff --git a/typesense-angular-search-bar/.editorconfig b/typesense-angular-search-bar/.editorconfig new file mode 100644 index 0000000..f166060 --- /dev/null +++ b/typesense-angular-search-bar/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/typesense-angular-search-bar/.gitignore b/typesense-angular-search-bar/.gitignore new file mode 100644 index 0000000..baedb75 --- /dev/null +++ b/typesense-angular-search-bar/.gitignore @@ -0,0 +1,45 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Environment config (contains API keys) +/src/environments/environment.ts + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db diff --git a/typesense-angular-search-bar/README.md b/typesense-angular-search-bar/README.md new file mode 100644 index 0000000..f91813a --- /dev/null +++ b/typesense-angular-search-bar/README.md @@ -0,0 +1,95 @@ +# Angular Search Bar with Typesense + +A modern search bar application built with Angular and Typesense, featuring instant search capabilities. + +## Tech Stack + +- Angular 18 (LTS) +- Typesense +- typesense-instantsearch-adapter & instantsearch.js + +## Prerequisites + +- Node.js 18+ and npm 9+. +- Docker (for running Typesense locally). Alternatively, you can use a Typesense Cloud cluster. +- Basic knowledge of Angular. + +## Quick Start + +### 1. Clone the repository + +```bash +git clone https://github.com/typesense/code-samples.git +cd typesense-angular-search-bar +``` + +### 2. Install dependencies + +```bash +npm install +``` + +### 3. Set up environment variables + +Copy the example environment file and fill in your Typesense connection details: + +```bash +cp src/environments/environment.example.ts src/environments/environment.ts +``` + +Then edit `src/environments/environment.ts`: + +```typescript +export const environment = { + typesense: { + apiKey: 'your-api-key', + host: 'localhost', + port: 8108, + protocol: 'http', + index: 'books', + }, +}; +``` + +### 4. Project Structure + +```text +src/app/ +├── components/ +│ ├── heading/ # App title and branding +│ ├── search-bar/ # Search input +│ ├── book-list/ # Results grid +│ └── book-card/ # Individual book display +├── lib/ +│ └── instantsearch-adapter.ts # Typesense adapter config +├── services/ +│ └── search.service.ts # InstantSearch singleton +├── types/ +│ └── book.ts # Book type definition +├── app.component.* # Root component +└── app.config.ts # Angular app configuration +``` + +### 5. Start the development server + +```bash +npm start +``` + +Open [http://localhost:4200](http://localhost:4200) in your browser. + +### 6. Deployment + +Update `src/environments/environment.ts` to point to your Typesense Cloud cluster: + +```typescript +export const environment = { + typesense: { + apiKey: 'your-search-only-api-key', + host: 'xxx.typesense.net', + port: 443, + protocol: 'https', + index: 'books', + }, +}; +``` diff --git a/typesense-angular-search-bar/angular.json b/typesense-angular-search-bar/angular.json new file mode 100644 index 0000000..4499e9c --- /dev/null +++ b/typesense-angular-search-bar/angular.json @@ -0,0 +1,121 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "typesense-angular-search-bar": { + "projectType": "application", + "schematics": { + "@schematics/angular:class": { + "skipTests": true + }, + "@schematics/angular:component": { + "skipTests": true + }, + "@schematics/angular:directive": { + "skipTests": true + }, + "@schematics/angular:guard": { + "skipTests": true + }, + "@schematics/angular:interceptor": { + "skipTests": true + }, + "@schematics/angular:pipe": { + "skipTests": true + }, + "@schematics/angular:resolver": { + "skipTests": true + }, + "@schematics/angular:service": { + "skipTests": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/typesense-angular-search-bar", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": ["src/styles.css"], + "scripts": [], + "allowedCommonJsDependencies": [ + "algoliasearch-helper", + "@algolia/events", + "typesense", + "qs" + ] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kB", + "maximumError": "4kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "typesense-angular-search-bar:build:production" + }, + "development": { + "buildTarget": "typesense-angular-search-bar:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": ["zone.js", "zone.js/testing"], + "tsConfig": "tsconfig.spec.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": ["src/styles.css"], + "scripts": [] + } + } + } + } + }, + "cli": { + "analytics": false + } +} diff --git a/typesense-angular-search-bar/package.json b/typesense-angular-search-bar/package.json new file mode 100644 index 0000000..f53f88b --- /dev/null +++ b/typesense-angular-search-bar/package.json @@ -0,0 +1,42 @@ +{ + "name": "typesense-angular-search-bar", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "dependencies": { + "@angular/animations": "^18.2.0", + "@angular/common": "^18.2.0", + "@angular/compiler": "^18.2.0", + "@angular/core": "^18.2.0", + "@angular/forms": "^18.2.0", + "@angular/platform-browser": "^18.2.0", + "@angular/platform-browser-dynamic": "^18.2.0", + "@angular/router": "^18.2.0", + "algoliasearch": "^5.50.1", + "instantsearch.js": "^4.93.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "typesense": "^3.0.5", + "typesense-instantsearch-adapter": "^3.0.2", + "zone.js": "~0.14.10" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^18.2.21", + "@angular/cli": "^18.2.21", + "@angular/compiler-cli": "^18.2.0", + "@types/jasmine": "~5.1.0", + "jasmine-core": "~5.2.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.5.2" + } +} diff --git a/typesense-angular-search-bar/public/favicon.ico b/typesense-angular-search-bar/public/favicon.ico new file mode 100644 index 0000000..57614f9 Binary files /dev/null and b/typesense-angular-search-bar/public/favicon.ico differ diff --git a/typesense-angular-search-bar/public/favicon.png b/typesense-angular-search-bar/public/favicon.png new file mode 100644 index 0000000..f42ec19 Binary files /dev/null and b/typesense-angular-search-bar/public/favicon.png differ diff --git a/typesense-angular-search-bar/src/app/app.component.css b/typesense-angular-search-bar/src/app/app.component.css new file mode 100644 index 0000000..2b4eb28 --- /dev/null +++ b/typesense-angular-search-bar/src/app/app.component.css @@ -0,0 +1,11 @@ +.app-container { + min-height: 100vh; + background-color: #f9fafb; + padding: 2rem 1rem; +} + +ais-instantsearch { + display: block; + max-width: 80rem; + margin: 0 auto; +} diff --git a/typesense-angular-search-bar/src/app/app.component.html b/typesense-angular-search-bar/src/app/app.component.html new file mode 100644 index 0000000..bfadbc8 --- /dev/null +++ b/typesense-angular-search-bar/src/app/app.component.html @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/typesense-angular-search-bar/src/app/app.component.ts b/typesense-angular-search-bar/src/app/app.component.ts new file mode 100644 index 0000000..3403d89 --- /dev/null +++ b/typesense-angular-search-bar/src/app/app.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { HeadingComponent } from './components/heading/heading.component'; +import { SearchBarComponent } from './components/search-bar/search-bar.component'; +import { BookListComponent } from './components/book-list/book-list.component'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [HeadingComponent, SearchBarComponent, BookListComponent], + templateUrl: './app.component.html', + styleUrl: './app.component.css', +}) +export class AppComponent {} diff --git a/typesense-angular-search-bar/src/app/app.config.ts b/typesense-angular-search-bar/src/app/app.config.ts new file mode 100644 index 0000000..034603c --- /dev/null +++ b/typesense-angular-search-bar/src/app/app.config.ts @@ -0,0 +1,5 @@ +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; + +export const appConfig: ApplicationConfig = { + providers: [provideZoneChangeDetection({ eventCoalescing: true })], +}; diff --git a/typesense-angular-search-bar/src/app/components/book-card/book-card.component.css b/typesense-angular-search-bar/src/app/components/book-card/book-card.component.css new file mode 100644 index 0000000..6fe4601 --- /dev/null +++ b/typesense-angular-search-bar/src/app/components/book-card/book-card.component.css @@ -0,0 +1,90 @@ +.book-card { + display: flex; + gap: 1.5rem; + padding: 1.5rem; + background-color: white; + border-radius: 0.5rem; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transition: box-shadow 200ms ease-in-out; +} + +.book-card:hover { + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.book-image-container { + flex-shrink: 0; + width: 8rem; + height: 12rem; + background-color: #f3f4f6; + border-radius: 0.375rem; + overflow: hidden; +} + +.book-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.no-image { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: #9ca3af; +} + +.book-info { + flex: 1; + display: flex; + flex-direction: column; +} + +.book-title { + font-size: 1.25rem; + font-weight: 600; + color: #111827; + margin: 0 0 0.5rem 0; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.book-author { + color: #4b5563; + margin: 0 0 0.25rem 0; + font-size: 0.875rem; +} + +.book-year { + color: #6b7280; + font-size: 0.75rem; + margin: 0 0 0.5rem 0; +} + +.rating-container { + margin-top: auto; + padding-top: 0.5rem; + display: flex; + align-items: center; +} + +.star-rating { + color: #f59e0b; + font-size: 1.125rem; + line-height: 1; +} + +.rating-text { + margin-left: 0.5rem; + font-size: 0.75rem; + color: #4b5563; +} diff --git a/typesense-angular-search-bar/src/app/components/book-card/book-card.component.html b/typesense-angular-search-bar/src/app/components/book-card/book-card.component.html new file mode 100644 index 0000000..b26906e --- /dev/null +++ b/typesense-angular-search-bar/src/app/components/book-card/book-card.component.html @@ -0,0 +1,30 @@ +
+
+ @if (book.image_url) { + + } @else { +
No Image
+ } +
+ +
+

{{ book.title }}

+

By: {{ authorList }}

+ + @if (book.publication_year) { +

Published: {{ book.publication_year }}

+ } + +
+
{{ stars }}
+ + {{ formattedRating }} ({{ formattedRatingsCount }} ratings) + +
+
+
diff --git a/typesense-angular-search-bar/src/app/components/book-card/book-card.component.ts b/typesense-angular-search-bar/src/app/components/book-card/book-card.component.ts new file mode 100644 index 0000000..6f237f8 --- /dev/null +++ b/typesense-angular-search-bar/src/app/components/book-card/book-card.component.ts @@ -0,0 +1,32 @@ +import { Component, Input } from '@angular/core'; +import { Book } from '../../types/book'; + +@Component({ + selector: 'app-book-card', + standalone: true, + templateUrl: './book-card.component.html', + styleUrl: './book-card.component.css', +}) +export class BookCardComponent { + @Input({ required: true }) book!: Book; + + get stars(): string { + return '\u2605'.repeat(Math.round(this.book.average_rating || 0)); + } + + get formattedRating(): string { + return (this.book.average_rating || 0).toFixed(1); + } + + get formattedRatingsCount(): string { + return (this.book.ratings_count || 0).toLocaleString(); + } + + get authorList(): string { + return this.book.authors?.join(', ') ?? ''; + } + + onImageError(event: Event): void { + (event.target as HTMLImageElement).style.display = 'none'; + } +} diff --git a/typesense-angular-search-bar/src/app/components/book-list/book-list.component.css b/typesense-angular-search-bar/src/app/components/book-list/book-list.component.css new file mode 100644 index 0000000..583ecc6 --- /dev/null +++ b/typesense-angular-search-bar/src/app/components/book-list/book-list.component.css @@ -0,0 +1,25 @@ +.book-list { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + padding: 1.5rem 1.5rem; + margin: 1.5rem 1.5rem; +} + +@media (min-width: 768px) { + .book-list { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 1024px) { + .book-list { + grid-template-columns: repeat(3, 1fr); + } +} + +.empty-state { + text-align: center; + padding: 3rem 0; + color: #6b7280; +} diff --git a/typesense-angular-search-bar/src/app/components/book-list/book-list.component.html b/typesense-angular-search-bar/src/app/components/book-list/book-list.component.html new file mode 100644 index 0000000..ca4be88 --- /dev/null +++ b/typesense-angular-search-bar/src/app/components/book-list/book-list.component.html @@ -0,0 +1,9 @@ +@if (hasSearched && hits.length === 0) { +
No books found. Try a different search term.
+} @else { +
+ @for (hit of hits; track hit.id) { + + } +
+} diff --git a/typesense-angular-search-bar/src/app/components/book-list/book-list.component.ts b/typesense-angular-search-bar/src/app/components/book-list/book-list.component.ts new file mode 100644 index 0000000..6cd9e28 --- /dev/null +++ b/typesense-angular-search-bar/src/app/components/book-list/book-list.component.ts @@ -0,0 +1,40 @@ +import { Component, NgZone, OnInit, OnDestroy } from '@angular/core'; +import connectHits from 'instantsearch.js/es/connectors/hits/connectHits'; +import { SearchService } from '../../services/search.service'; +import { BookCardComponent } from '../book-card/book-card.component'; +import { Book } from '../../types/book'; + +@Component({ + selector: 'app-book-list', + standalone: true, + imports: [BookCardComponent], + templateUrl: './book-list.component.html', + styleUrl: './book-list.component.css', +}) +export class BookListComponent implements OnInit, OnDestroy { + hits: Book[] = []; + hasSearched = false; + private widget: ReturnType> | null = null; + + constructor( + private searchService: SearchService, + private ngZone: NgZone, + ) {} + + ngOnInit(): void { + const hitsConnector = connectHits((renderOptions) => { + this.ngZone.run(() => { + this.hits = renderOptions.hits as unknown as Book[]; + this.hasSearched = true; + }); + }); + this.widget = hitsConnector({}); + this.searchService.searchInstance.addWidgets([this.widget]); + } + + ngOnDestroy(): void { + if (this.widget) { + this.searchService.searchInstance.removeWidgets([this.widget]); + } + } +} diff --git a/typesense-angular-search-bar/src/app/components/heading/heading.component.css b/typesense-angular-search-bar/src/app/components/heading/heading.component.css new file mode 100644 index 0000000..13ca750 --- /dev/null +++ b/typesense-angular-search-bar/src/app/components/heading/heading.component.css @@ -0,0 +1,95 @@ +.heading { + width: fit-content; + text-align: right; + position: relative; + margin: 6rem auto 2rem; + isolation: isolate; +} + +.heading::after { + content: ""; + background: conic-gradient( + from 180deg at 50% 50%, + #ed0e73 0deg, + hsla(210, 100%, 52%, 0.2) 55deg, + #54d6ff33 140deg, + #dd003059 160deg, + transparent 360deg + ); + width: 320px; + height: 180px; + z-index: -1; + left: 70%; + position: absolute; + filter: blur(45px); + top: -50%; + animation: move 40s ease-out infinite; +} + +.heading h1 { + font-size: clamp(1.5rem, 4vw, 2.5rem); + font-weight: 700; + color: #1a1a1a; + margin: 0 0 0.5rem 0; +} + +.subtitle { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.25rem; + font-size: clamp(0.875rem, 2vw, 1rem); +} + +.typesense { + color: #ed0e73; + font-size: clamp(0.875rem, 2vw, 1rem); + text-decoration: none; + font-family: monospace; +} + +.typesense:hover { + text-decoration: underline; +} + +.angular-logo { + width: clamp(20px, 3vw, 28px); + position: relative; + margin-left: 0.25rem; + vertical-align: middle; +} + +.github-link { + position: fixed; + top: 2vmax; + right: 2vmax; + color: #333; + transition: color 0.2s ease; +} + +.github-link:hover { + color: #000; +} + +.github-link svg { + width: 28px; + height: 28px; +} + +@keyframes move { + 0% { + transform: translate(0%) scale(1); + } + 45% { + transform: translate(-40%, 20%) scale(1.2); + } + 75% { + transform: translate(5%, 40%) scale(1.3); + } + 90% { + transform: translate(-5%, 5%) scale(0.9); + } + 100% { + transform: translate(0%) scale(1); + } +} diff --git a/typesense-angular-search-bar/src/app/components/heading/heading.component.html b/typesense-angular-search-bar/src/app/components/heading/heading.component.html new file mode 100644 index 0000000..1cf4cb2 --- /dev/null +++ b/typesense-angular-search-bar/src/app/components/heading/heading.component.html @@ -0,0 +1,57 @@ +
+

Angular Search Bar

+
+ powered by + + typesense| + + & + + +
+
+ + + + + + + diff --git a/typesense-angular-search-bar/src/app/components/heading/heading.component.ts b/typesense-angular-search-bar/src/app/components/heading/heading.component.ts new file mode 100644 index 0000000..1f61024 --- /dev/null +++ b/typesense-angular-search-bar/src/app/components/heading/heading.component.ts @@ -0,0 +1,10 @@ +import { Component, ViewEncapsulation } from '@angular/core'; + +@Component({ + selector: 'app-heading', + standalone: true, + templateUrl: './heading.component.html', + styleUrl: './heading.component.css', + encapsulation: ViewEncapsulation.None, +}) +export class HeadingComponent {} diff --git a/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.css b/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.css new file mode 100644 index 0000000..1b78733 --- /dev/null +++ b/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.css @@ -0,0 +1,66 @@ +.search-container { + max-width: 48rem; + margin: 0 auto 2rem; +} + +.search-form { + position: relative; +} + +.search-input { + width: 100%; + padding: 1rem 3rem; + border-radius: 0.5rem; + border: 2px solid #e5e7eb; + font-size: 1rem; + box-sizing: border-box; +} + +.search-input::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; +} + +.search-input:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); +} + +.search-button, +.reset-button { + position: absolute; + top: 50%; + transform: translateY(-50%); + color: #9ca3af; + background: none; + border: none; + cursor: pointer; + padding: 0.25rem; + display: flex; + align-items: center; + justify-content: center; +} + +.search-button { + left: 0.75rem; +} + +.reset-button { + right: 0.75rem; +} + +.reset-button:hover { + color: #4b5563; +} + +.search-icon, +.close-icon { + width: 1.25rem; + height: 1.25rem; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; +} diff --git a/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.html b/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.html new file mode 100644 index 0000000..d14ce18 --- /dev/null +++ b/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.html @@ -0,0 +1,47 @@ +
+
+ + + + + @if (query) { + + } +
+
diff --git a/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.ts b/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.ts new file mode 100644 index 0000000..48ed987 --- /dev/null +++ b/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.ts @@ -0,0 +1,46 @@ +import { Component, NgZone, OnInit, OnDestroy } from '@angular/core'; +import connectSearchBox from 'instantsearch.js/es/connectors/search-box/connectSearchBox'; +import { SearchService } from '../../services/search.service'; + +@Component({ + selector: 'app-search-bar', + standalone: true, + templateUrl: './search-bar.component.html', + styleUrl: './search-bar.component.css', +}) +export class SearchBarComponent implements OnInit, OnDestroy { + query = ''; + private refineFn: (value: string) => void = () => {}; + private widget: ReturnType> | null = null; + + constructor( + private searchService: SearchService, + private ngZone: NgZone, + ) {} + + ngOnInit(): void { + const searchBoxConnector = connectSearchBox((renderOptions) => { + this.ngZone.run(() => { + this.query = renderOptions.query; + this.refineFn = renderOptions.refine; + }); + }); + this.widget = searchBoxConnector({}); + this.searchService.searchInstance.addWidgets([this.widget]); + } + + onSearch(event: Event): void { + const value = (event.target as HTMLInputElement).value; + this.refineFn(value); + } + + onReset(): void { + this.refineFn(''); + } + + ngOnDestroy(): void { + if (this.widget) { + this.searchService.searchInstance.removeWidgets([this.widget]); + } + } +} diff --git a/typesense-angular-search-bar/src/app/lib/instantsearch-adapter.ts b/typesense-angular-search-bar/src/app/lib/instantsearch-adapter.ts new file mode 100644 index 0000000..de687fa --- /dev/null +++ b/typesense-angular-search-bar/src/app/lib/instantsearch-adapter.ts @@ -0,0 +1,21 @@ +import TypesenseInstantsearchAdapter from 'typesense-instantsearch-adapter'; +import { environment } from '../../environments/environment'; + +export const typesenseInstantSearchAdapter = new TypesenseInstantsearchAdapter({ + server: { + apiKey: environment.typesense.apiKey, + nodes: [ + { + host: environment.typesense.host, + port: environment.typesense.port, + protocol: environment.typesense.protocol, + }, + ], + }, + additionalSearchParameters: { + query_by: 'title,authors', + query_by_weights: '4,2', + num_typos: 1, + sort_by: 'ratings_count:desc', + }, +}); diff --git a/typesense-angular-search-bar/src/app/services/search.service.ts b/typesense-angular-search-bar/src/app/services/search.service.ts new file mode 100644 index 0000000..13b8704 --- /dev/null +++ b/typesense-angular-search-bar/src/app/services/search.service.ts @@ -0,0 +1,20 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import instantsearch from 'instantsearch.js'; +import { typesenseInstantSearchAdapter } from '../lib/instantsearch-adapter'; +import { environment } from '../../environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class SearchService implements OnDestroy { + readonly searchInstance = instantsearch({ + indexName: environment.typesense.index, + searchClient: typesenseInstantSearchAdapter.searchClient, + }); + + constructor() { + this.searchInstance.start(); + } + + ngOnDestroy(): void { + this.searchInstance.dispose(); + } +} diff --git a/typesense-angular-search-bar/src/app/types/book.ts b/typesense-angular-search-bar/src/app/types/book.ts new file mode 100644 index 0000000..4e53931 --- /dev/null +++ b/typesense-angular-search-bar/src/app/types/book.ts @@ -0,0 +1,9 @@ +export interface Book { + id: string; + title: string; + authors: string[]; + publication_year: number; + average_rating: number; + image_url: string; + ratings_count: number; +} diff --git a/typesense-angular-search-bar/src/environments/environment.example.ts b/typesense-angular-search-bar/src/environments/environment.example.ts new file mode 100644 index 0000000..2f6b1d2 --- /dev/null +++ b/typesense-angular-search-bar/src/environments/environment.example.ts @@ -0,0 +1,9 @@ +export const environment = { + typesense: { + apiKey: 'your-api-key-here', + host: 'localhost', + port: 8108, + protocol: 'http', + index: 'books', + }, +}; diff --git a/typesense-angular-search-bar/src/index.html b/typesense-angular-search-bar/src/index.html new file mode 100644 index 0000000..4b49290 --- /dev/null +++ b/typesense-angular-search-bar/src/index.html @@ -0,0 +1,14 @@ + + + + + Angular Search Bar + + + + + + + + + diff --git a/typesense-angular-search-bar/src/main.ts b/typesense-angular-search-bar/src/main.ts new file mode 100644 index 0000000..8882c45 --- /dev/null +++ b/typesense-angular-search-bar/src/main.ts @@ -0,0 +1,7 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => + console.error(err), +); diff --git a/typesense-angular-search-bar/src/styles.css b/typesense-angular-search-bar/src/styles.css new file mode 100644 index 0000000..fea287a --- /dev/null +++ b/typesense-angular-search-bar/src/styles.css @@ -0,0 +1,14 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/typesense-angular-search-bar/tsconfig.app.json b/typesense-angular-search-bar/tsconfig.app.json new file mode 100644 index 0000000..3775b37 --- /dev/null +++ b/typesense-angular-search-bar/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/typesense-angular-search-bar/tsconfig.json b/typesense-angular-search-bar/tsconfig.json new file mode 100644 index 0000000..a8bb65b --- /dev/null +++ b/typesense-angular-search-bar/tsconfig.json @@ -0,0 +1,33 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "lib": [ + "ES2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/typesense-angular-search-bar/tsconfig.spec.json b/typesense-angular-search-bar/tsconfig.spec.json new file mode 100644 index 0000000..5fb748d --- /dev/null +++ b/typesense-angular-search-bar/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/typesense-nuxt-search-bar/.env.example b/typesense-nuxt-search-bar/.env.example new file mode 100644 index 0000000..8a12cc6 --- /dev/null +++ b/typesense-nuxt-search-bar/.env.example @@ -0,0 +1,5 @@ +NUXT_PUBLIC_TYPESENSE_API_KEY=xyz +NUXT_PUBLIC_TYPESENSE_HOST=localhost +NUXT_PUBLIC_TYPESENSE_PORT=8108 +NUXT_PUBLIC_TYPESENSE_PROTOCOL=http +NUXT_PUBLIC_TYPESENSE_INDEX=books diff --git a/typesense-nuxt-search-bar/.gitignore b/typesense-nuxt-search-bar/.gitignore new file mode 100644 index 0000000..4a7f73a --- /dev/null +++ b/typesense-nuxt-search-bar/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/typesense-nuxt-search-bar/README.md b/typesense-nuxt-search-bar/README.md new file mode 100644 index 0000000..28572f6 --- /dev/null +++ b/typesense-nuxt-search-bar/README.md @@ -0,0 +1,115 @@ +# Nuxt.js Search Bar with Typesense + +A modern search bar application built with Nuxt.js and Typesense, featuring instant search capabilities. + +## Tech Stack + +- Nuxt.js +- Vue 3 +- Typesense +- typesense-instantsearch-adapter & vue-instantsearch +- Tailwind CSS + +## Prerequisites + +- Node.js 18+ and npm 9+. +- Docker (for running Typesense locally). Alternatively, you can use a Typesense Cloud cluster. +- Basic knowledge of Vue and Nuxt.js. + +## Quick Start + +### 1. Clone the repository + +```bash +git clone https://github.com/typesense/code-samples.git +cd code-samples/typesense-nuxt-search-bar +``` + +### 2. Install dependencies + +```bash +npm install +``` + +### 3. Set up Typesense and import data + +#### Start Typesense Server (Local Development) + +```bash +docker run -d -p 8108:8108 \ + -v/tmp/typesense-data:/data typesense/typesense:27.1 \ + --data-dir /data --api-key=xyz --enable-cors +``` + +#### Create Collection and Import Data + +The application expects a `books` collection with the following schema: + +```json +{ + "name": "books", + "fields": [ + {"name": "title", "type": "string"}, + {"name": "authors", "type": "string[]"}, + {"name": "publication_year", "type": "int32"}, + {"name": "average_rating", "type": "float"}, + {"name": "ratings_count", "type": "int32"}, + {"name": "image_url", "type": "string", "optional": true} + ] +} +``` + +### 4. Set up environment variables + +Create a `.env` file in the project root (copy from `.env.example`): + +```bash +cp .env.example .env +``` + +Update the values in `.env`: + +```env +NUXT_PUBLIC_TYPESENSE_API_KEY=xyz +NUXT_PUBLIC_TYPESENSE_HOST=localhost +NUXT_PUBLIC_TYPESENSE_PORT=8108 +NUXT_PUBLIC_TYPESENSE_PROTOCOL=http +NUXT_PUBLIC_TYPESENSE_INDEX=books +``` + +### 5. Project Structure + +```text +├── app +│ └── app.vue +├── components +│ ├── BookCard.vue +│ ├── BookList.vue +│ ├── Heading.vue +│ └── SearchBar.vue +├── types +│ └── Book.ts +├── utils +│ └── instantSearchAdapter.ts +└── nuxt.config.ts +``` + +### 5. Start the development server + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) in your browser. + +### 6. Deployment + +Set env variables to point the app to the Typesense Cluster: + +```env +NUXT_PUBLIC_TYPESENSE_API_KEY=xxx +NUXT_PUBLIC_TYPESENSE_HOST=xxx.typesense.net +NUXT_PUBLIC_TYPESENSE_PORT=443 +NUXT_PUBLIC_TYPESENSE_PROTOCOL=https +NUXT_PUBLIC_TYPESENSE_INDEX=books +``` diff --git a/typesense-nuxt-search-bar/app/app.vue b/typesense-nuxt-search-bar/app/app.vue new file mode 100644 index 0000000..bfd3f0f --- /dev/null +++ b/typesense-nuxt-search-bar/app/app.vue @@ -0,0 +1,72 @@ + + + + + + + diff --git a/typesense-nuxt-search-bar/components/BookCard.vue b/typesense-nuxt-search-bar/components/BookCard.vue new file mode 100644 index 0000000..6be40d7 --- /dev/null +++ b/typesense-nuxt-search-bar/components/BookCard.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/typesense-nuxt-search-bar/components/BookList.vue b/typesense-nuxt-search-bar/components/BookList.vue new file mode 100644 index 0000000..fbb17c3 --- /dev/null +++ b/typesense-nuxt-search-bar/components/BookList.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/typesense-nuxt-search-bar/components/Heading.vue b/typesense-nuxt-search-bar/components/Heading.vue new file mode 100644 index 0000000..622403c --- /dev/null +++ b/typesense-nuxt-search-bar/components/Heading.vue @@ -0,0 +1,165 @@ + + + diff --git a/typesense-nuxt-search-bar/components/SearchBar.vue b/typesense-nuxt-search-bar/components/SearchBar.vue new file mode 100644 index 0000000..2ca65cf --- /dev/null +++ b/typesense-nuxt-search-bar/components/SearchBar.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/typesense-nuxt-search-bar/nuxt.config.ts b/typesense-nuxt-search-bar/nuxt.config.ts new file mode 100644 index 0000000..b2c7cae --- /dev/null +++ b/typesense-nuxt-search-bar/nuxt.config.ts @@ -0,0 +1,16 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + compatibilityDate: "2024-07-15", + devtools: { enabled: true }, + runtimeConfig: { + public: { + typesense: { + apiKey: process.env.NUXT_PUBLIC_TYPESENSE_API_KEY || "xyz", + host: process.env.NUXT_PUBLIC_TYPESENSE_HOST || "localhost", + port: parseInt(process.env.NUXT_PUBLIC_TYPESENSE_PORT || "8108", 10), + protocol: process.env.NUXT_PUBLIC_TYPESENSE_PROTOCOL || "http", + index: process.env.NUXT_PUBLIC_TYPESENSE_INDEX || "books", + }, + }, + }, +}); diff --git a/typesense-nuxt-search-bar/package.json b/typesense-nuxt-search-bar/package.json new file mode 100644 index 0000000..0ff1461 --- /dev/null +++ b/typesense-nuxt-search-bar/package.json @@ -0,0 +1,23 @@ +{ + "name": "typesense-nuxt-search-bar", + "type": "module", + "private": true, + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "postinstall": "nuxt prepare" + }, + "dependencies": { + "nuxt": "^4.4.2", + "vue": "^3.5.30", + "vue-router": "^5.0.3", + "vue-instantsearch": "^4.20.4", + "typesense": "^2.1.0", + "typesense-instantsearch-adapter": "^2.9.0" + }, + "devDependencies": { + "@types/node": "^20" + } +} diff --git a/typesense-nuxt-search-bar/public/favicon.ico b/typesense-nuxt-search-bar/public/favicon.ico new file mode 100644 index 0000000..18993ad Binary files /dev/null and b/typesense-nuxt-search-bar/public/favicon.ico differ diff --git a/typesense-nuxt-search-bar/public/favicon.png b/typesense-nuxt-search-bar/public/favicon.png new file mode 100644 index 0000000..f42ec19 Binary files /dev/null and b/typesense-nuxt-search-bar/public/favicon.png differ diff --git a/typesense-nuxt-search-bar/public/robots.txt b/typesense-nuxt-search-bar/public/robots.txt new file mode 100644 index 0000000..0ad279c --- /dev/null +++ b/typesense-nuxt-search-bar/public/robots.txt @@ -0,0 +1,2 @@ +User-Agent: * +Disallow: diff --git a/typesense-nuxt-search-bar/tsconfig.json b/typesense-nuxt-search-bar/tsconfig.json new file mode 100644 index 0000000..307b213 --- /dev/null +++ b/typesense-nuxt-search-bar/tsconfig.json @@ -0,0 +1,18 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "files": [], + "references": [ + { + "path": "./.nuxt/tsconfig.app.json" + }, + { + "path": "./.nuxt/tsconfig.server.json" + }, + { + "path": "./.nuxt/tsconfig.shared.json" + }, + { + "path": "./.nuxt/tsconfig.node.json" + } + ] +} diff --git a/typesense-nuxt-search-bar/types/Book.ts b/typesense-nuxt-search-bar/types/Book.ts new file mode 100644 index 0000000..a60803f --- /dev/null +++ b/typesense-nuxt-search-bar/types/Book.ts @@ -0,0 +1,9 @@ +export type Book = { + id: string; + title: string; + authors: string[]; + publication_year: number; + average_rating: number; + image_url: string; + ratings_count: number; +}; diff --git a/typesense-nuxt-search-bar/utils/instantSearchAdapter.ts b/typesense-nuxt-search-bar/utils/instantSearchAdapter.ts new file mode 100644 index 0000000..c7de503 --- /dev/null +++ b/typesense-nuxt-search-bar/utils/instantSearchAdapter.ts @@ -0,0 +1,27 @@ +import TypesenseInstantsearchAdapter from "typesense-instantsearch-adapter"; + +export const createTypesenseAdapter = (config: { + apiKey: string; + host: string; + port: number; + protocol: string; +}) => { + return new TypesenseInstantsearchAdapter({ + server: { + apiKey: config.apiKey, + nodes: [ + { + host: config.host, + port: config.port, + protocol: config.protocol, + }, + ], + }, + additionalSearchParameters: { + query_by: "title,authors", + query_by_weights: "4,2", + num_typos: 1, + sort_by: "ratings_count:desc", + }, + }); +};