Build a Touch-Friendly Document Search

Build a local dashboard that searches PDF content and previews documents.

Introduction

30 Second Summary

You have a folder full of PDFs sitting on your computer. Finding anything inside them means opening each one and scrolling through pages, hoping you spot the right paragraph.

In this project, you will build a local document search dashboard that searches across the actual text content of your PDFs and lets you preview any matching document instantly.

What You'll Build

You type a topic into a large search bar, watch matching documents filter down in real time, then tap any result to load the full PDF in a side-by-side preview panel.

By the end of this project, you'll have:

  • A live search bar powered by fuzzy full-text search that filters documents by their actual content as you type.
  • A split-panel layout where tapping a result loads the full PDF inline in an embedded preview panel, ready to scroll and read without leaving the dashboard.
  • A Node.js indexing script that extracts text from any PDF collection and builds a searchable JSON index you can regenerate anytime.
  • Secret Mission: Add an idle-state attract screen and auto-reset timer so your dashboard can run unattended on a kiosk.

Are there any prerequisites?

None. This guide walks you through installing everything you need from scratch, including Node.js and all project dependencies.

Before We Start

Finding the right document in a growing collection of PDFs is painful. You scroll through file names, open a few, skim pages, and close them again.

In this project, you will build a touch-friendly search dashboard that extracts the actual text from your PDFs and lets you find documents by content in real time. Before any building happens, you need a project folder, the right dependencies, and some sample PDFs to search through.

In this step, get ready to:
  • Confirm Node.js is ready on your machine.
  • Create a project folder and install dependencies.
  • Set up the folder structure and add sample PDF files.
Confirm Node.js is ready

Node.js powers both your web server and the script that extracts text from PDFs. A quick version check confirms everything is in place.

  • Open your terminal by pressing Ctrl+Alt+T.
  • Check your Node.js version by running this command:
node --version

You should see v18 or higher printed in the terminal.

Don't see v18 or higher?

If you see a version lower than 18, update Node.js using your package manager or download the latest LTS from the official Node.js website.

If you see command not found, Node.js is not installed. Visit the official Node.js downloads page and install the LTS version for Linux.

Help me install Node.js on Linux.

Create the project folder and initialize it

Every Node.js project starts with a folder and a package.json file that tracks your project's name, scripts, and dependencies.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir document-dashboard
cd document-dashboard
  • Initialize the project with npm by running:
npm init -y

You should see a confirmation that package.json was created.

What does npm init -y do?

This command creates a package.json file with default values. The -y flag accepts all defaults automatically so you skip the interactive prompts.

The default package.json needs a few edits before it is ready. You need to enable ES module syntax and add helpful scripts.

  • Open VS Code by pressing the Super key, typing VS Code, and pressing Enter.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the document-dashboard folder on your Desktop, then click Open.
  • In the Explorer sidebar, click package.json to open it.
  • Replace the entire contents of the file with this code:
{
  "name": "document-dashboard",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "index": "node index-builder.js"
  }
}

What does this code do?

  • "type": "module" tells Node.js to use modern ES modules (import/export syntax) instead of the older require() style.
  • The start script lets you launch the web server with npm start.
  • The index script runs the PDF text extraction tool you will build later.
  • Save package.json by pressing Ctrl+S.
Install dependencies and create the folder structure

Your project needs two packages: a web server to serve the dashboard, and a library to read text out of PDF files.

  • Open the integrated terminal in VS Code by pressing Ctrl+`.
  • Install both dependencies by running:
npm install express pdf.js-extract

You should see output listing the installed packages with no errors. A node_modules folder and package-lock.json file appear in the Explorer sidebar.

What are these packages?

  • Express is a lightweight web server framework for Node.js. It will serve your HTML dashboard and PDF files to the browser.
  • pdf.js-extract is a Node.js library that reads the text content from PDF files. It powers the indexing script that makes your PDFs searchable.

Seeing permission errors or EACCES?

Make sure you are inside the document-dashboard folder. Run pwd to confirm your current directory ends with document-dashboard.

Help me fix npm permission errors on Linux.

  • Create the two folders your project needs by running:
mkdir pdfs public
  • Confirm everything is in place by running:
ls

You should see node_modules, package.json, package-lock.json, pdfs, and public listed in the output.

What are these folders for?

  • pdfs/ holds the PDF documents you want to search through.
  • public/ holds the HTML, CSS, and JavaScript files that make up your dashboard UI.
Add sample PDF files

Your search dashboard needs real PDFs to index and search through. You need 3 to 5 PDF files with actual text content (not just images).

  • Find 3 to 5 PDF files on your computer, or download free sample PDFs from the internet.
  • Copy the PDF files into the pdfs folder inside document-dashboard.
  • Confirm the files are there by running:
ls pdfs

You should see your PDF filenames listed.

Good sample PDFs to use

Any text-based PDFs work well. Try using old homework assignments, research papers, user manuals, or free ebooks. Avoid scanned-image PDFs since those contain pictures of text rather than actual selectable text.

Not sure where to find sample PDFs?

Search for "sample PDF files free download" in your browser. Sites like africau.edu and w3.org host example PDFs specifically for testing.

Help me find free sample PDFs for testing.

✔️ Awesome, I've got everything!

Great. Double check you saved package.json and that your pdfs folder has at least 3 PDF files in it.

ⓧ I'd like to double check the full code

Here is what your package.json should look like after installing dependencies:

{
  "name": "document-dashboard",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "index": "node index-builder.js"
  },
  "dependencies": {
    "express": "^4.21.0",
    "pdf.js-extract": "^1.0.1"
  }
}

Your folder structure should look like this:

document-dashboard/
├── node_modules/
├── package.json
├── package-lock.json
├── pdfs/          (3-5 PDF files inside)
└── public/        (empty for now)

Your project skeleton is ready. Next up, you will build the Express server and create the dashboard layout that turns this folder of PDFs into a real search app.

Set Up the Project Structure

Your project folder has its dependencies installed and its directories ready. Now you need a way to actually serve your dashboard files to a browser.

Express is a lightweight Node.js web server. In this step, you'll create a server file that serves static files from your public/ folder and makes your PDFs accessible at a dedicated URL path.

In this step, get ready to:
  • Create the Express server file that serves your project.
  • Start the server and confirm it responds in the browser.
Create the Express server file

ES modules use import syntax instead of require(). Since your package.json sets "type": "module", all your JavaScript files will use this modern syntax.

  • In VS Code, create a new file called server.js in the root of your document-dashboard folder.
  • Paste the following code into server.js:
// Import Express and path utilities for ES modules
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

// Recreate __dirname since ES modules don't provide it
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// Create the Express app and set the port
const app = express();
const PORT = 3000;

// Serve static files from public/ and PDFs from pdfs/
app.use(express.static(path.join(__dirname, 'public')));
app.use('/pdfs', express.static(path.join(__dirname, 'pdfs')));

// Start the server and log the URL
app.listen(PORT, () => {
  console.log(`Document Dashboard running at http://localhost:${PORT}`);
});

What does this code do?

  • fileURLToPath and path.dirname recreate the __dirname variable that ES modules don't provide by default. This gives Express the absolute path to your project folder.
  • express.static(path.join(__dirname, 'public')) tells Express to serve any file inside public/ when a browser requests it. This is where your HTML, CSS, and JavaScript will live.
  • The second express.static line maps the URL path /pdfs to your pdfs/ folder. A browser requesting /pdfs/report.pdf gets the matching file from that directory.
  • Save server.js.

✔️ Awesome, I've got everything!

Great. Make sure you've saved server.js in the root of your document-dashboard folder (next to package.json).

ⓧ I'd like to double check the full code

Here is the complete server.js file:

import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const app = express();
const PORT = 3000;

app.use(express.static(path.join(__dirname, 'public')));
app.use('/pdfs', express.static(path.join(__dirname, 'pdfs')));

app.listen(PORT, () => {
  console.log(`Document Dashboard running at http://localhost:${PORT}`);
});
Start the server and verify it responds

Before you run the server, what do you think will happen when you visit the URL in your browser? The public/ folder is still empty.

  • Start the server by running this command in your terminal:
node server.js

You should see this output in your terminal:

Document Dashboard running at http://localhost:3000
  • Open your browser and navigate to http://localhost:3000.

You'll see Cannot GET / in the browser. That's expected. Express is running and looking for files to serve, but public/ has no HTML yet.

Don't see the server start message?

  • Check that server.js is saved in the root of document-dashboard (same level as package.json), not inside public/ or pdfs/.
  • If you see SyntaxError: Cannot use import statement, confirm your package.json has "type": "module". Without this, Node.js tries to use the older require() syntax.
  • If port 3000 is already in use, stop whatever process is using it, then retry.

Your server is live and ready to deliver files. Next up, you'll build the HTML dashboard and CSS styling that turns this empty page into a touch-friendly search interface.

Build the Dashboard Layout

Your Express server is running and ready to serve files, but the public/ folder is empty. Right now, visiting http://localhost:3000 shows a blank page because there is nothing for the server to deliver.

In this step you will create the visible, interactive shell of the dashboard. By the end you will have a large centered search bar, a results panel on the left, and a PDF preview panel on the right that loads a real PDF when you click a result.

In this step, get ready to:
  • Build the HTML skeleton with a search bar, results list, and embedded PDF preview.
  • Style the layout for touch screens with large tap targets and no hover states.
  • Add temporary hardcoded document data so clicking a result loads a PDF.
Create the HTML structure

The dashboard needs three main areas: a search input at the top, a scrollable results list on the left, and an iframe preview on the right. You will create a single HTML file that defines all of these regions.

  • In the VS Code Explorer sidebar, expand the public folder.
  • Hover over public and click the New File icon (the page icon with a plus sign).
  • Type index.html and press Enter.
  • Paste the following code into public/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
  <title>Document Search Dashboard</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header class="header">
    <h1>Document Search</h1>
  </header>

  <main class="dashboard">
    <section class="search-section">
      <input
        type="search"
        id="search-input"
        class="search-bar"
        placeholder="Search documents..."
        autocomplete="off"
      >
      <p id="result-count" class="result-count"></p>
    </section>

What does this code do?

  • The viewport meta tag with user-scalable=no prevents pinch-to-zoom, which keeps the layout stable on touch screens.
  • The search-section wraps a large search input and a paragraph for showing result counts.
  • Directly below the code you just pasted (still inside index.html), paste the content section:
    <section class="content-section">
      <div class="results-panel">
        <ul id="results-list" class="results-list"></ul>
      </div>
      <div class="preview-panel">
        <div id="preview-placeholder" class="preview-placeholder">
          <p>Select a document to preview</p>
        </div>
        <iframe id="pdf-preview" class="pdf-preview" title="PDF Preview"></iframe>
      </div>
    </section>
  </main>

  <script type="module" src="app.js"></script>
</body>
</html>

What does this section do?

  • The results-panel holds an empty <ul> that JavaScript will populate with document entries.
  • The preview-panel contains both a placeholder message and a hidden <iframe> that will display the selected PDF.
  • The <script type="module"> tag loads app.js as an ES module, enabling import syntax in the browser.
  • Save public/index.html.
  • Open your browser and refresh http://localhost:3000.
  • You should see the text "Document Search" at the top and a search input field, all unstyled.

Seeing a blank page still?

  • Confirm the file is saved inside the public folder, not in the project root. The Express server only serves files from public/.
  • Check that the filename is exactly index.html (lowercase, no spaces).
  • Make sure the server is still running in your terminal. If it stopped, restart it with node server.js.

Need more help? Why isn't my index.html showing up at localhost:3000?

✔️ Awesome, I've got everything!

Great. Make sure you have saved public/index.html before moving on to the stylesheet.

ⓧ I'd like to double check the full code

Your public/index.html should contain the two code blocks above pasted together in order. Scroll up and confirm:

  • The first block (DOCTYPE through </section> of search-section) is at the top.
  • The second block (content-section through </html>) follows immediately after.
Add touch-optimized styling

Touch interfaces have no hover cursor, so feedback must come from tap states and large target areas. The stylesheet you are about to create uses a minimum 64px height for tap targets and :active states instead of :hover.

  • In the public folder, create a new file called style.css (hover over public in the Explorer sidebar and click the New File icon).
  • Paste the global reset and design tokens into public/style.css:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

:root {
  --primary: #1a73e8;
  --primary-light: rgba(26, 115, 232, 0.12);
  --bg: #f5f5f5;
  --card-bg: #ffffff;
  --text: #202124;
  --text-light: #5f6368;
  --border: #dadce0;
  --radius: 12px;
  --shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

html, body {
  height: 100%;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: var(--bg);
  color: var(--text);
  overflow: hidden;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}

What does this code do?

  • The :root block defines CSS variables so you can change colors and spacing in one place.
  • Setting touch-action: manipulation disables double-tap-to-zoom, making taps feel instant.
  • The -webkit-tap-highlight-color: transparent rule removes the blue flash that appears when tapping on mobile browsers.
  • Save style.css and refresh your browser.
  • The page background should now be a light grey with system fonts instead of the browser defaults.

Background still white?

  • Make sure style.css is inside the public folder, alongside index.html.
  • Open DevTools (F12) and check the Console tab for a 404 on style.css. A 404 means the filename or path does not match.

Why is my CSS not loading?

  • Below the existing code in style.css, paste the header and page layout rules:
.header {
  padding: 16px 24px;
  background: var(--card-bg);
  border-bottom: 1px solid var(--border);
  text-align: center;
}

.header h1 {
  font-size: 1.5rem;
  font-weight: 600;
}

.dashboard {
  display: flex;
  flex-direction: column;
  height: calc(100vh - 65px);
}

.search-section {
  padding: 24px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}

What does this code do?

  • The .header sits at the top with a white background and a subtle bottom border.
  • The .dashboard uses flexbox to fill the remaining viewport height below the header.
  • The .search-section centers the search bar horizontally with padding around it.
  • Save and refresh. You should see the "Document Search" heading in a white bar across the top.
  • Continue in style.css by pasting the search bar and panel container styles:
.search-bar {
  width: 100%;
  max-width: 600px;
  padding: 16px 24px;
  font-size: 1.25rem;
  border: 2px solid var(--border);
  border-radius: 30px;
  outline: none;
  transition: border-color 0.2s;
  -webkit-appearance: none;
}

.search-bar:focus {
  border-color: var(--primary);
  box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.2);
}

.result-count {
  font-size: 0.9rem;
  color: var(--text-light);
  min-height: 1.2em;
}

.content-section {
  flex: 1;
  display: flex;
  gap: 16px;
  padding: 0 24px 24px;
  min-height: 0;
}

What does this code do?

  • The .search-bar has 16px vertical padding, making it large enough to tap comfortably (over 48px tall).
  • The :focus state uses a blue border and glow ring so the learner can see which field is active without a hover cursor.
  • The .content-section splits the remaining space into a side-by-side flexbox layout for results and preview.
  • Save and refresh. The search bar should now appear as a large rounded pill shape centered below the heading.
  • Next, paste the results panel and result item styles:
.results-panel {
  flex: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  background: var(--card-bg);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}

.results-list {
  list-style: none;
  padding: 8px;
}

.result-item {
  padding: 16px 20px;
  margin-bottom: 8px;
  border-radius: 8px;
  cursor: pointer;
  transition: background-color 0.15s;
  min-height: 64px;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.result-item:active {
  background-color: var(--primary-light);
}

What does this code do?

  • Each .result-item has a min-height: 64px so it exceeds the 48px minimum touch target size.
  • The :active pseudo-class (not :hover) provides instant visual feedback the moment the user taps.
  • Save and refresh. You should now see a white rounded card on the left side of the page where results will appear.
  • Paste the item text styles and the selected state:
.result-item.selected {
  background-color: var(--primary-light);
  border-left: 4px solid var(--primary);
}

.result-item-title {
  font-size: 1.1rem;
  font-weight: 500;
  margin-bottom: 4px;
}

.result-item-snippet {
  font-size: 0.85rem;
  color: var(--text-light);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.result-item-meta {
  font-size: 0.75rem;
  color: var(--text-light);
  margin-top: 4px;
}

What does this code do?

  • The .selected class adds a blue left border and a light background so the user can see which result is active.
  • The .result-item-snippet uses -webkit-line-clamp: 2 to truncate long text previews at two lines.
  • Save and refresh. No visible change yet because the results list is empty, but the styles are ready for when JavaScript adds items.
  • Paste the preview panel styles:
.preview-panel {
  flex: 1.5;
  background: var(--card-bg);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  overflow: hidden;
  position: relative;
}

.preview-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: var(--text-light);
  font-size: 1.1rem;
}

.pdf-preview {
  width: 100%;
  height: 100%;
  border: none;
  display: none;
}

.pdf-preview.active {
  display: block;
}

What does this code do?

  • The .preview-panel takes 1.5x the width of the results panel, giving the PDF more reading room.
  • The iframe starts hidden (display: none) and only appears when JavaScript adds the .active class.
  • Save and refresh. A second white card should appear to the right, showing "Select a document to preview" centered inside it.
  • Finally, paste the highlight utility and responsive breakpoint:
.highlight {
  background-color: #fdd835;
  border-radius: 2px;
  padding: 0 2px;
}

@media (max-width: 768px) {
  .content-section {
    flex-direction: column;
  }

  .results-panel {
    max-height: 40vh;
  }

  .preview-panel {
    flex: 1;
    min-height: 50vh;
  }
}

What does this code do?

  • The .highlight class will be used later to mark search matches with a yellow background.
  • The media query stacks the panels vertically on screens narrower than 768px, so the layout works on portrait tablets.
  • Save style.css and refresh.
  • You should see the full split-panel layout: a search bar at the top center, a white results card on the left, and a larger preview card on the right showing the placeholder message.

Panels not appearing side by side?

  • If the panels stack vertically on a desktop-width window, check that your .content-section rule has display: flex without flex-direction: column. The column direction only applies inside the @media block.
  • Make sure all six CSS blocks are pasted in order with no overlap or missing sections.

Why are my flex panels not side by side?

✔️ Awesome, I've got everything!

Great. Your stylesheet is complete. Double check that style.css is saved.

ⓧ I'd like to double check the full code

Your public/style.css should contain all six code blocks above pasted in sequence. Scroll up and confirm they appear in this order:

  • Global reset, :root variables, html, body rules.
  • Header, dashboard, and search-section layout.
  • Search bar, focus state, result-count, and content-section.
  • Results panel, results-list, result-item, and :active state.
  • Selected state, title, snippet, and meta text.
  • Preview panel, placeholder, pdf-preview, highlight, and media query.
Add hardcoded document data

The HTML and CSS give you the shell, but the results list is empty. Before you build the real search, you will wire up temporary hardcoded data so you can confirm the click-to-preview flow works end to end.

  • In the public folder, create a new file called app.js.
  • Paste the module import and the document selection function:
// Hardcoded data version — will be replaced in Step 3
import MiniSearch from 'https://cdn.jsdelivr.net/npm/minisearch@7.2.0/+esm';

let documents = [];

function selectDocument(element, filename) {
  document.querySelectorAll('.result-item').forEach(item => {
    item.classList.remove('selected');
  });
  element.classList.add('selected');

  const iframe = document.getElementById('pdf-preview');
  const placeholder = document.getElementById('preview-placeholder');

  iframe.src = `/pdfs/${filename}`;
  iframe.classList.add('active');
  placeholder.style.display = 'none';
}

What does this code do?

  • The import loads MiniSearch directly from a CDN as an ES module. No bundler needed.
  • The selectDocument function removes the selected class from all items, adds it to the clicked one, then sets the iframe source to the matching PDF file.

Why import MiniSearch now if we are not searching yet?

Loading MiniSearch at this stage confirms the CDN import works before you add search logic later. If the import fails, you will see a console error immediately rather than debugging two problems at once.

  • Directly below, paste the startup code that renders hardcoded documents:
document.addEventListener('DOMContentLoaded', () => {
  // Hardcoded placeholder data
  documents = [
    { id: 1, title: 'Sample Document 1', filename: 'sample1.pdf', pages: 1 },
    { id: 2, title: 'Sample Document 2', filename: 'sample2.pdf', pages: 1 }
  ];

  const list = document.getElementById('results-list');
  documents.forEach(doc => {
    const li = document.createElement('li');
    li.className = 'result-item';
    li.setAttribute('data-filename', doc.filename);
    li.innerHTML = `<div class="result-item-title">${doc.title}</div>`;
    li.addEventListener('click', () => selectDocument(li, doc.filename));
    list.appendChild(li);
  });
});

What does this code do?

  • The DOMContentLoaded listener waits until the HTML is fully parsed before running.
  • For each document in the hardcoded array, it creates a list item with the result-item class and attaches a click handler that calls selectDocument.
  • Replace sample1.pdf and sample2.pdf with the actual filenames of two PDFs in your pdfs/ folder so the preview works.
  • Save public/app.js.

Before you refresh, what do you think will happen when you click one of the document names in the left panel?

  • Refresh http://localhost:3000 in your browser.
  • You should see two document names listed in the left panel.
  • Click either name. The right panel should load the PDF, and the clicked item should get a blue left border.

Results not showing or PDF not loading?

  • Open the browser Console (F12 then Console tab). If you see a CORS or module error, confirm the <script> tag in index.html has type="module".
  • If the PDF shows a "file not found" error in the iframe, check that the filename values in the hardcoded array match the actual file names inside your pdfs/ folder (case-sensitive).
  • If you see no list items at all, confirm app.js is inside the public folder (not the project root).

Why aren't my hardcoded results showing or the PDF loading?

✔️ Awesome, I've got everything!

Great. Confirm app.js is saved and the PDF preview loads when you click a result.

ⓧ I'd like to double check the full code

Your public/app.js should contain the two code blocks above pasted in order. Scroll up and confirm:

  • The first block starts with the import line and ends with the closing } of selectDocument.
  • The second block is the entire DOMContentLoaded listener with the hardcoded array and forEach loop.

Your dashboard shell is alive. Next up, you will extract real text from your PDFs and build a search index so the search bar actually finds content.

Extract Text and Build the Search Index

Your dashboard looks great, but right now it shows hardcoded placeholder names that have nothing to do with your actual PDFs.

For search to work against real document content, you need a script that cracks open each PDF, pulls out its text, and saves everything into a structured file your front end can load.

In this step, get ready to:
  • Write an indexing script that extracts text from every PDF in your collection.
  • Run the script and confirm it produces a searchable JSON index file.
Write the index builder setup and file scanner

The pdf.js-extract library reads PDF files and returns their text content page by page. Your script will use it to process every PDF in the pdfs/ folder and save the results to a JSON file that the browser can fetch.

  • Create a new file called index-builder.js in the root of your document-dashboard folder (next to server.js, not inside public/).
  • Add the imports and path configuration by pasting this code:
import { PDFExtract } from 'pdf.js-extract';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const PDF_DIR = path.join(__dirname, 'pdfs');
const OUTPUT_FILE = path.join(__dirname, 'public', 'search-index.json');

async function buildIndex() {
  const pdfExtract = new PDFExtract();
  const files = fs.readdirSync(PDF_DIR).filter(f => f.toLowerCase().endsWith('.pdf'));

  console.log(`Found ${files.length} PDF files to index...\n`);

  const documents = [];

What does this code do?

  • PDFExtract is the class from pdf.js-extract that handles reading PDF files and returning their text.
  • The fileURLToPath pattern gives you a __dirname equivalent in ES modules, so you can build reliable file paths.
  • PDF_DIR points to your PDFs folder. OUTPUT_FILE points to where the JSON index will be saved inside public/ so the browser can fetch it.
  • fs.readdirSync(PDF_DIR) reads the folder, and the .filter() keeps only files ending in .pdf.
Add the extraction loop and file output

The function shell is ready. Now you need the loop that processes each PDF, extracts its text, and writes the final index to disk.

  • Add the following code directly below the const documents = []; line in index-builder.js:
  for (let i = 0; i < files.length; i++) {
    const file = files[i];
    const filePath = path.join(PDF_DIR, file);

    try {
      const data = await pdfExtract.extract(filePath, { normalizeWhitespace: true });
      const text = data.pages
        .map(page => page.content.map(item => item.str).join(' '))
        .join('\n\n');

      documents.push({
        id: i + 1,
        title: file.replace('.pdf', '').replace(/[-_]/g, ' '),
        filename: file,
        text: text.substring(0, 10000),
        pages: data.pages.length
      });

      console.log(`  Indexed: ${file} (${data.pages.length} pages)`);
    } catch (err) {
      console.error(`  Error indexing ${file}: ${err.message}`);
    }
  }

  fs.writeFileSync(OUTPUT_FILE, JSON.stringify(documents, null, 2));
  console.log(`\nIndex saved to search-index.json (${documents.length} documents)`);
}

buildIndex();

What does this code do?

  • pdfExtract.extract() reads a PDF and returns structured page data. The normalizeWhitespace option cleans up messy spacing in the extracted text.
  • The .map() chain collects every text fragment from every page into one continuous string.
  • text.substring(0, 10000) caps each document at 10,000 characters. This keeps the index file small enough for the browser to load quickly.
  • The title is derived from the filename by stripping .pdf and replacing dashes or underscores with spaces.
  • fs.writeFileSync() saves the completed documents array as formatted JSON. The buildIndex() call at the bottom kicks everything off when you run the script.
  • Save index-builder.js.

✔️ Awesome, I've got everything!

Great. Make sure you have saved index-builder.js before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm these two chunks appear in order in your index-builder.js:

  • The imports, path config, and function opening (ending with const documents = [];).
  • The for loop, fs.writeFileSync() call, closing brace, and buildIndex(); at the very end.
Run the indexer and inspect the output

The script is ready. Before you run it, think about what you expect to see. You have PDF files in your pdfs/ folder. What should the script print for each one?

  • Run the index builder by entering this command in your terminal:
node index-builder.js

You should see output listing each PDF file with its page count, like this:

Found 3 PDF files to index...

  Indexed: my-document.pdf (4 pages)
  Indexed: another-file.pdf (2 pages)
  Indexed: report.pdf (7 pages)

Index saved to search-index.json (3 documents)

The exact filenames and page counts will match whatever PDFs you placed in your pdfs/ folder.

  • Confirm the index file was created by running:
ls public/search-index.json

You should see public/search-index.json listed in the output.

  • Inspect the first few lines of the generated JSON to confirm it contains extracted text by running:
head -20 public/search-index.json

You should see a JSON array where each object has an id, title, filename, a text field full of extracted words from the PDF, and a pages count.

Seeing 'Found 0 PDF files to index'?

  • Make sure your pdfs/ folder is in the project root (same level as server.js) and contains at least one .pdf file.
  • Check that the files actually end in .pdf (not .PDF.pdf or similar duplicates from downloads).
  • Run ls pdfs/ to double check what files are in the folder.

Why isn't my index-builder.js finding any PDF files?

Seeing 'Error indexing' for a file?

  • Some PDFs are image-only scans with no embedded text. The script will still index them, but the text field will be empty.
  • If a file is corrupted or password-protected, the script logs the error and continues with the next file.

Help me understand the error message from my PDF indexer.

Your PDFs are now cracked open and their content is sitting in a JSON file ready to be searched. Next up, you will wire the search bar to this index so typing a query instantly filters your documents by their actual content.

Wire Up Live Search

Your dashboard can display hardcoded document names, but the search bar is just decoration. Typing in it does nothing.

In this step, you will connect MiniSearch to your real search index so that typing a query instantly filters the results panel. By the end, the search bar will be the fastest way to find anything in your PDF collection.

In this step, get ready to:
  • Replace the hardcoded data with live data from your search index.
  • Wire up real-time search filtering with fuzzy matching and highlighted snippets.
  • Add a browse-all default so every document appears when the search bar is empty.
Replace the hardcoded data and initialize MiniSearch

The current app.js file uses a hardcoded array of fake documents. You will replace the entire file with code that fetches your real search index and initializes MiniSearch.

  • Switch to public/app.js in VS Code.
  • Select all the existing code and delete it.
  • Add the following code that imports MiniSearch from the CDN, fetches your index, and configures the search engine:
import MiniSearch from 'https://cdn.jsdelivr.net/npm/minisearch@7.2.0/+esm';

let miniSearch;
let documents = [];

async function init() {
  try {
    const response = await fetch('/search-index.json');
    documents = await response.json();

    miniSearch = new MiniSearch({
      fields: ['title', 'text'],
      storeFields: ['title', 'filename', 'text', 'pages'],
      searchOptions: {
        boost: { title: 3 },
        fuzzy: 0.2,
        prefix: true
      }
    });

    miniSearch.addAll(documents);
    showAllDocuments();

    document.getElementById('search-input').addEventListener('input', handleSearch);
  } catch (error) {
    console.error('Failed to load search index:', error);
    document.getElementById('result-count').textContent =
      'Error: Run "node index-builder.js" first to generate the search index.';
  }
}

What does this code do?

The init() function runs on page load and does three things. First, it fetches the search-index.json file your indexer built in the previous step.

Second, it creates a MiniSearch instance configured with fuzzy: 0.2 (catches typos) and prefix: true (matches partial words as you type). Title matches are boosted 3x over body text matches.

Third, it wires the search input to a handleSearch function we will write next.

  • Below the init() function, add the selectDocument helper and the DOMContentLoaded listener that kicks everything off:
function selectDocument(element, filename) {
  document.querySelectorAll('.result-item').forEach(item => {
    item.classList.remove('selected');
  });
  element.classList.add('selected');

  const iframe = document.getElementById('pdf-preview');
  const placeholder = document.getElementById('preview-placeholder');

  iframe.src = `/pdfs/${filename}`;
  iframe.classList.add('active');
  placeholder.style.display = 'none';
}

document.addEventListener('DOMContentLoaded', init);

What does this code do?

The selectDocument() function handles what happens when the learner taps a result. It highlights the tapped item and loads the matching PDF into the preview iframe.

The last line tells the browser to call init() once the page has finished loading.

  • Save public/app.js and refresh your browser.
  • Open DevTools by pressing Ctrl+Shift+I and switch to the Network tab.
  • Refresh the page. You should see two successful requests: the MiniSearch module from cdn.jsdelivr.net and search-index.json from your server.

Why check the Network tab?

The page will show errors in the Console because handleSearch and showAllDocuments do not exist yet. The Network tab confirms that the two critical resources loaded successfully. The Console errors disappear once you add the remaining functions.

Not seeing search-index.json in the Network tab?

  • Make sure your Express server is still running. If you stopped it, run node server.js in your terminal.
  • Confirm public/search-index.json exists. If not, run node index-builder.js to regenerate it.
  • Check that the browser is pointed at http://localhost:3000.

Help me debug why search-index.json isn't loading in the Network tab.

Wire up the search input with highlighting and snippets

With MiniSearch loaded and ready, you need the functions that respond to keystrokes, display results, and highlight matching terms.

  • In public/app.js, add the handleSearch() function between the init() closing brace and selectDocument():
function handleSearch(event) {
  const query = event.target.value.trim();

  if (query.length === 0) {
    showAllDocuments();
    return;
  }

  const results = miniSearch.search(query);
  displayResults(results, query);
}

What does this code do?

Every keystroke in the search bar fires this function. If the input is empty, it shows all documents. Otherwise it passes the query to MiniSearch and sends the scored results to a displayResults() function you will write next.

  • Below handleSearch(), add the displayResults() function that renders each search result as a list item:
function displayResults(results, query) {
  const list = document.getElementById('results-list');
  const count = document.getElementById('result-count');

  count.textContent = query
    ? `${results.length} result${results.length !== 1 ? 's' : ''} for "${query}"`
    : `${results.length} document${results.length !== 1 ? 's' : ''}`;

  list.innerHTML = '';

  results.forEach(result => {
    const doc = documents.find(d => d.id === result.id) || result;
    const li = document.createElement('li');
    li.className = 'result-item';
    li.setAttribute('data-filename', doc.filename);

    const snippet = getSnippet(doc.text, query);

    li.innerHTML = `
      <div class="result-item-title">${highlightText(doc.title, query)}</div>
      <div class="result-item-snippet">${highlightText(snippet, query)}</div>
      <div class="result-item-meta">${doc.pages} page${doc.pages !== 1 ? 's' : ''}</div>
    `;

    li.addEventListener('click', () => selectDocument(li, doc.filename));
    list.appendChild(li);
  });
}

What does this code do?

This function clears the current results list, then loops through each result to create a new list item. Each item shows a highlighted title, a text snippet showing where the match was found, and a page count.

It relies on two helper functions (getSnippet and highlightText) that you will add next.

  • Below displayResults(), add the two helper functions that extract context snippets and highlight matching terms:
function getSnippet(text, query) {
  if (!query || !text) return text ? text.substring(0, 120) + '...' : '';

  const lowerText = text.toLowerCase();
  const lowerQuery = query.toLowerCase();
  const index = lowerText.indexOf(lowerQuery);

  if (index === -1) return text.substring(0, 120) + '...';

  const start = Math.max(0, index - 40);
  const end = Math.min(text.length, index + query.length + 80);
  let snippet = text.substring(start, end);

  if (start > 0) snippet = '...' + snippet;
  if (end < text.length) snippet = snippet + '...';

  return snippet;
}

function highlightText(text, query) {
  if (!query || !text) return text || '';

  const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const regex = new RegExp(`(${escaped})`, 'gi');
  return text.replace(regex, '<span class="highlight">$1</span>');
}

What does this code do?

  • getSnippet() finds the query term inside the document text and extracts a window of surrounding characters. This gives the user context about where the match appears.
  • highlightText() wraps every occurrence of the query term in a <span class="highlight"> tag. Your CSS already styles this with a yellow background.
Add the browse-all default state

When the search bar is empty, the dashboard should show every document in your collection like a visual file cabinet. The showAllDocuments() function handles this default state.

  • Between handleSearch() and displayResults(), add the showAllDocuments() function:
function showAllDocuments() {
  const allDocs = documents.map(doc => ({
    id: doc.id,
    title: doc.title,
    filename: doc.filename,
    text: doc.text,
    pages: doc.pages,
    score: 0
  }));

  displayResults(allDocs, '');
}

What does this code do?

This function maps every document into the same shape that MiniSearch results use, then passes them to displayResults() with an empty query. The result is a complete document listing with no highlighting and no filtering.

It runs on page load (called inside init()) and again whenever the learner clears the search bar.

  • Save public/app.js.

Before you refresh the browser, what do you expect to see in the results panel? Think about what init() does on page load.

  • Refresh http://localhost:3000 in your browser.

You should see all your PDF documents listed in the left panel with their real titles and page counts. The result count above the search bar shows the total number of documents.

  • Type a word you know exists in one of your PDFs into the search bar.

The results list updates instantly, showing only matching documents. Each result displays a text snippet with your search term highlighted in yellow.

  • Clear the search bar completely.

All documents reappear. The browse-all default state is working.

Results not appearing or search not filtering?

  • Open DevTools Console and look for errors. A ReferenceError means a function name is misspelled or missing.
  • Confirm the function order in your file: init, handleSearch, showAllDocuments, displayResults, getSnippet, highlightText, selectDocument, then the DOMContentLoaded listener.
  • If the page loads but search does nothing, check that the input event listener line inside init() references handleSearch (not handleInput or another name).

Help me debug why my MiniSearch-powered search isn't filtering results when I type.

✔️ Awesome, I've got everything!

Great. Double check you have saved public/app.js and confirmed that search filtering works.

ⓧ I'd like to double check the full code

Scroll up and confirm that your public/app.js contains each of these sections in order:

  • The import statement and two variables (miniSearch and documents).
  • The init() function with fetch, MiniSearch config, and the event listener.
  • The handleSearch() function.
  • The showAllDocuments() function.
  • The displayResults() function.
  • The getSnippet() and highlightText() helpers.
  • The selectDocument() function.
  • The document.addEventListener('DOMContentLoaded', init) line at the very bottom.

Search is alive. Every keystroke filters your entire PDF library in real time. Next up, you will connect the preview panel so tapping a result loads the full PDF.

Connect the PDF Preview

Your search bar filters results in real time. But the right-side preview panel still says "Select a document to preview" no matter what you click.

In this step, you will confirm that clicking a search result loads the actual PDF into the preview panel. The wiring is already in your code from the previous step. Now it is time to understand how it works and verify the full experience.

In this step, get ready to:
  • Understand how click handlers connect results to the PDF preview.
  • Confirm the visual selection state highlights the active result.
  • Test the full search-to-preview flow end to end.
Review how click handlers connect to the preview

Every time displayResults() builds a result item, it attaches a click listener. That listener calls selectDocument() with the clicked element and its PDF filename.

  • In VS Code, open public/app.js and find the selectDocument function near the bottom of the file.
function selectDocument(element, filename) {
  document.querySelectorAll('.result-item').forEach(item => {
    item.classList.remove('selected');
  });
  element.classList.add('selected');

  const iframe = document.getElementById('pdf-preview');
  const placeholder = document.getElementById('preview-placeholder');

  iframe.src = `/pdfs/${filename}`;
  iframe.classList.add('active');
  placeholder.style.display = 'none';
}

What does this function do?

When the learner taps a result, three things happen in sequence:

  • querySelectorAll('.result-item') removes the selected class from every result, then adds it to the tapped one. This gives exactly one result the highlighted border at any time.
  • iframe.src = `/pdfs/${filename}` tells the browser to load the PDF file from your Express server's /pdfs route directly into the iframe.
  • iframe.classList.add('active') switches the iframe from display: none to display: block, and placeholder.style.display = 'none' hides the "Select a document to preview" message.
Verify the selection state and placeholder behavior

The visual feedback for the active result comes from the .result-item.selected CSS rule already in your stylesheet.

  • In VS Code, open public/style.css and find the .result-item.selected rule.
.result-item.selected {
  background-color: var(--primary-light);
  border-left: 4px solid var(--primary);
}

Why a left border instead of a full outline?

A left border is a common pattern in touch interfaces. It gives a clear visual signal ("this is the active item") without making the entire card look like a button. Combined with the light blue background from --primary-light, the tapped result stands out immediately.

Before you test, think about what you expect to happen when you click a result in the left panel. Do you think the PDF will appear instantly, or will there be a loading delay?

  • Return to your browser at http://localhost:3000 and refresh the page.
  • Click any document in the results list on the left.

The "Select a document to preview" placeholder disappears. The full PDF loads in the right panel, rendered by your browser's built-in PDF viewer. The clicked result now has a blue left border and light background.

  • Click a different result in the list.

The preview switches to the new PDF instantly. The blue border moves to the newly selected result, and the previous one loses its highlight.

PDF not loading in the preview?

  • Make sure your Express server is still running. Check the terminal for the "Document Dashboard running at http://localhost:3000" message.
  • Confirm your PDF files are in the pdfs/ folder at the project root. The server route /pdfs serves files from that directory.
  • Open your browser's DevTools console and check for 404 errors. A 404 means the filename in your search-index.json does not match an actual file in pdfs/.

Why isn't my PDF loading when I click a search result?

Test the full search-to-preview flow

The individual pieces work. Now confirm the entire experience flows together without breaking.

  • Type a word you know exists in one of your PDFs into the search bar.
  • Confirm the results list filters to show only matching documents with highlighted snippets.
  • Click one of the filtered results.
  • Confirm the PDF loads in the right panel and the result gets the blue selection border.
  • Clear the search bar so all documents reappear.
  • Click a different document from the full list.
  • Confirm the preview switches to the new PDF and the selection border moves.

You now have a fully working search dashboard. Type a query, see results filter instantly, tap any result, and read the PDF right there in the same page.

Why an iframe instead of a custom PDF renderer?

Modern browsers (Chromium, Firefox) render PDFs natively inside iframes with built-in scroll, zoom, and page navigation. This gives you a full PDF reading experience with zero extra code.

Building a custom renderer with canvas would require managing page controls, zoom levels, and text selection yourself. The iframe approach delivers the same result for a fraction of the complexity.

Your document search dashboard is complete. You can search by content, see matching snippets, and preview any PDF inline. Next up, tackle the Secret Mission and add an idle-state attract screen for kiosk deployment.

Secret mission

Add Kiosk Idle Screen and Auto-Reset

Your dashboard works perfectly for hands-on use. But what if it were mounted on a wall in a library or office lobby? After someone walks away, the screen would be stuck showing their last search. In this secret mission, you will add an inactivity timer and an attract screen that resets the dashboard automatically after 30 seconds of idle time.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine. There are no cloud resources, no subscriptions, and no ongoing costs.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • A local Node.js project folder (document-dashboard) with Express server, search index, and application files.
  • PDF files in the pdfs/ folder (your original files remain unchanged).
  • npm packages installed locally inside the project's node_modules/ folder.

Keep everything running

No action needed. Choose this if you are still actively building or using your document search dashboard.

  • Leave node server.js running in your terminal for continued use.
  • Access your dashboard anytime at http://localhost:3000.
  • Add more PDFs to the pdfs/ folder and re-run node index-builder.js to update your search index.

Pause - I'll come back to this later

Stop the running server to free up your terminal. You can restart anytime.

  • In the terminal where the server is running, press Ctrl+C to stop it.
  • When you want to use the dashboard again, navigate to your project folder and run node server.js.

Delete - I don't want to use this again

Remove all project files and start fresh. Your original PDF files inside the project folder will also be deleted, so copy them elsewhere first if you need them.

  • In the terminal where the server is running, press Ctrl+C to stop it.
  • Delete the entire project folder by running:
cd ~/Desktop
rm -rf document-dashboard
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see document-dashboard in the output.

Nice Work!

Nice Work!

Nice work! You just built a fully functional, touch-friendly document search dashboard that extracts real text from PDFs and lets you find content instantly.

You've learned how to:

  • Built a touch-optimized document search dashboard with a centered search bar, split-panel results layout, and inline PDF preview.
  • Created a Node.js indexing script that extracts text from PDFs using pdf.js-extract and builds a searchable JSON index.
  • Implemented real-time fuzzy search powered by MiniSearch that filters documents by content as you type, with highlighted snippets showing where matches appear.
  • Secret Mission: Added an idle-state attract screen with a pulsing animation and auto-reset timer for future kiosk deployment.

Ready to quiz yourself?