Showing posts with label api. Show all posts
Showing posts with label api. Show all posts

Tuesday, 29 September 2026

Web Tutorial: The List View (Part 4/4)

So far we have been working solely with March 2025 data. It's a limited subset of the entire dataset, which is why we could perform paging, sorting and all that, in the front-end. It was a deliberate design decision. This pattern will not scale if you have an unlimited dataset.

With that in mind, we will provide an interface for the user to get the rest of the data. In the topmost div, listFilters, add this HTML. It's a drop-down list.
<div id="listFilters">
  <label>
    Period:
    <select id="ddlPeriod">
      <option value="3">March 2025</option>
      <option value="4">April 2025</option>
    </select>          
  </label>


  <hr />
</div>  


So far, we only have March and April 2025 records, so this is easy - just have the drop-down list run getRecords() with 2025 as the argument for year, and the selected value as the argument for month.
<div id="listFilters">
  <label>
    Period:
    <select id="ddlPeriod" onchange="lv.getRecords(2025, this.value);">
      <option value="3">March 2025</option>
      <option value="4">April 2025</option>
    </select>          
  </label>

  <hr />
</div>  


The CSS for labels is just to bold the text.
  .asc:after
  {
    content: "\25B2";
  }

  .desc:after
  {
    content: "\25BC";
  }

  label
  {
    font-weight: bold;
  }

</style>


Don't need to do much else.


There, these are April borrowing records!


Now for the Search function

Add a space and another label tag, and insert the txtSearch textbox.
<div id="listFilters">
  <label>
    Period:
    <select id="ddlPeriod" onchange="lv.getRecords(2025, this.value);">
      <option value="3">March 2025</option>
      <option value="4">April 2025</option>
    </select>          
  </label>

   &nbsp;

  <label>
    Search:
    <input id="txtSearch" placeholder="Search term, e.g, orange" />
  </label>


  <hr />
</div>  


Have it run setSearch() with its current value, whenever someone types stuff in the box.
<div id="listFilters">
  <label>
    Period:
    <select id="ddlPeriod" onchange="lv.getRecords(2025, this.value);">
      <option value="3">March 2025</option>
      <option value="4">April 2025</option>
    </select>          
  </label>

   

  <label>
    Search:
    <input id="txtSearch" placeholder="Search term, e.g, orange" value="" oninput="lv.setSearch(this.value);" />
  </label>

  <hr />
</div>  


This is the setSearch() method. It has a parameter, searchTerm. We start with a return statement.
setPageSize: function(pageSize)
{
  this.pageSize = pageSize;
  this.pageNo = 1;
  this.filterRecords();
  this.renderPageInfo();

  return;
},
setSearch: function(searchTerm)
{
  return;
},

toggleSort: function(col, id)
{


Now we set the search property. If searchTerm is longer than 2 characters, we convert to lowercase and set it. If not, we set search to an empty string.
setSearch: function(searchTerm)
{
  this.search = (searchTerm.length > 2 ? searchTerm.toLowerCase() : "");

  return;
},


Then we run filterRecords() and renderPageInfo().
setSearch: function(searchTerm)
{
  this.search = (searchTerm.length > 2 ? searchTerm.toLowerCase() : "");
  this.filterRecords();
  this.renderPageInfo();


  return;
},


In filterRecords(), before sorting, we filter according to the search term. We first check if action needs to be taken. If search is less than 2 characters, we won't bother.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  if (this.search.length > 2)
  {

  }


  if (this.sort.length > 0)
  {
    this.sort.forEach(


We then filter the filtered array using the filter() method.
if (this.search.length > 2)
{
  filtered = filtered.filter
  (
    (x) =>
    {

    }
  );

}


We check against the title, category, author and name properties, all converted to lowercase, against the search property, and return true if there is any match.
if (this.search.length > 2)
{
  filtered = filtered.filter
  (
    (x) =>
    {
      return (
        x.title.toLowerCase().indexOf(this.search) > -1 ||
        x.category.toLowerCase().indexOf(this.search) > -1 ||
        x.author.toLowerCase().indexOf(this.search) > -1 ||
        x.name.toLowerCase().indexOf(this.search) > -1

      );
    }
  );
}


Let's also remove the red outlines since we hve no more layout changes to make.
div {outline: 0px solid red;}


Let's try typing in "orange" like the nice placeholder text recommends. Start with "or". See how nothing happens.


Now add "a" for "ora". Now see the results change dramatically. They display because "ora" was in the title, or because the author was "Dora" "Deborah", "Flora" or "Coral".

Try the full term "orange". Now there's only one result!


Now switch the records to April 2025. Can you see the logic behind this?

We're done here! Thanks for participating!

Play around with your list view. Try out the features we made.

Orange you glad we did this?!
T___T

Tuesday, 22 September 2026

Web Tutorial: The List View (Part 1/4)

List views are a classic way to present data. I used to work with them in PHP, back in the 2010s. As my experience accumulated, I got a better sense of what worked, what didn't, what scaled, and what sounded like a good idea and just wasn't in practice.

With the help of jQuery, I'd like to walk you through some of the regular features of a list view that I worked over and over with.

To facilitate this Web Tutorial, I have also created a database of sample records in Oracle APEX and created an API endpoint to retrieve those records. It's a collection of records from a hypothetical library, with imaginary titles, authors and people. The categories, though, are real - they're from BISAC.

Here's what a sample of the data looks like in CSV. At least, this was what I imported into Oracle APEX. In this Web Tutorial, we wont be dealing with the API endpoint creation at all - we'll just call the endpoint and grab the data.
Title,Category,Author,Name,BorrowDate,ReturnBy,ReturnDate,Fine
Help my program just crashed,Computers,RJ Neville,Olive Yao,2025-03-01,2025-04-01,2025-03-22,0
The Saraville Murders,True Crime,Lillian Yang,Faith Wentz,2025-03-01,2025-05-01,2025-04-21,0
The Moon's Reflection in the Padi Fields,Fiction,Rudy Ismail,Charles Wong,2025-03-01,2025-04-01,2025-03-30,0
It's Now or Never!,Young Adult Fiction,Kara Leow,Lin Yiheng,2025-03-01,2025-04-01,2025-04-02,0.1
Dreaded Doberman: Man's Best Friend... or are they?,Pets,Selina Perez,Teo Teng Hwee,2025-03-01,202

...


Here's a sample API endpoint for these records. In this URL, "2026" and "3" are the year and month that the books were borrowed. Do note that we're not doing security for this - it's a purely display feature.
https://oracleapex.com/ords/teochewthunder/borrowing/2026/3


Here's some boilerplate HTML. We want to include jQuery, and the starting object, lv. In the styles, the outline of divs has been set to red. This will help us with layout until we're ready to turn it off.
<!DOCTYPE html>
<html>
  <head>
    <title>List View</title>

    <style>
      div {outline: 1px solid red;}
    </style>

    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>

    <script>
      const lv =
      {

      };
    </script>
  </head>

  <body>

  </body>
</html>


In the body tag, we have a div tag with the id listContent. Within it, here are four different divs with the ids listFilters, listHeader, listRows and listPaging. The first and last div tag have a hr tag within. You may not think much about it now, but that horizontal rule will be a Godsend later.
<body>
  <div id="listContent">
    <div id="listFilters">
      <hr />
    </div>  
        
    <div id="listHeader">

    </div>

    <div id="listRows">
      
    </div>

    <div id="listPaging">
      <hr />
    </div>
  </div>

</body>


listContent is a thousand pixels wide, with the height property set to auto. We center it using the margin property, and set a font. The other divs within will just inherit their parent's width.
<style>
  div {outline: 1px solid red;}

  #listContent
  {
    width: 1000px;
    height: auto;
    margin: 0 auto 0 auto;
    font-size: 12px;
    font-family: verdana;
  }

  #listHeader
  {
    width: 100%
  }

  #listRows
  {
    width: 100%
  }

  #listPaging
  {
    width: 100%;
  }

</style>


Now for every column we'll have, we add a div...
<div id="listHeader">
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>

</div>


...and insert a button with that column's name in it. Note that there is one more button at the beginning - that is just a column header for serialization of records. Note that each of these buttons, save the first one, has an unique id.
<div id="listHeader">
  <div><button>No</button></div>
  <div><button id="btnSortTitle">Title</button></div>
  <div><button id="btnSortAuthor">Author</button></div>
  <div><button id="btnSortCategory">Category</button></div>
  <div><button id="btnSortName">Name</button></div>
  <div><button id="btnSortBorrowdate">Borrowed</button></div>
  <div><button id="btnSortReturnBy">Return By</button></div>
  <div><button id="btnSortReturndate">Returned</button></div>
  <div><button id="btnSortFine">Fine</button></div>
</div>


Now you see a bunch of buttons. These are supposed to be clickable headers.


So let's style them accordingly. Each of these buttons is styled using the sortButton CSS class. Only the first button uses noButton.
<div id="listHeader">
  <div><button class="noButton">No</button></div>
  <div><button id="btnSortTitle" class="sortButton">Title</button></div>
  <div><button id="btnSortAuthor" class="sortButton">Author</button></div>
  <div><button id="btnSortCategory" class="sortButton">Category</button></div>
  <div><button id="btnSortName" class="sortButton">Name</button></div>
  <div><button id="btnSortBorrowdate" class="sortButton">Borrowed</button></div>
  <div><button id="btnSortReturnBy" class="sortButton">Return By</button></div>
  <div><button id="btnSortReturndate" class="sortButton">Returned</button></div>
  <div><button id="btnSortFine" class="sortButton">Fine</button></div>
</div>


Here's the styling. Visually, sortButton and noButton share the same visuals. No background or border - it just looks like black text wth a fixed height.
#listPaging
{
  width: 100%;
}

.sortButton, .noButton
{
  width: auto;
  padding: 0px;
  height: 1.5em;
  font-size: 1em;
  background: transparent;
  border: none;
  color: rgb(0, 0, 0);
}


Now they no longer look like buttons.


Time for a bit of layout magic. Add the CSS class listViewColumns to the listHeader div.
<div id="listHeader" class="listViewColumns">
  <div><button class="noButton">No</button></div>
  <div><button id="btnSortTitle" class="sortButton">Title</button></div>
  <div><button id="btnSortAuthor" class="sortButton">Author</button></div>
  <div><button id="btnSortCategory" class="sortButton">Category</button></div>
  <div><button id="btnSortName" class="sortButton">Name</button></div>
  <div><button id="btnSortBorrowdate" class="sortButton">Borrowed</button></div>
  <div><button id="btnSortReturnBy" class="sortButton">Return By</button></div>
  <div><button id="btnSortReturndate" class="sortButton">Returned</button></div>
  <div><button id="btnSortFine" class="sortButton">Fine</button></div>
</div>


In the CSS, we set the display property to grid, and then specify the widths of 8 columns, with a 5 pixel gap between columns.
#listRows
{
  width: 100%
}

.listViewColumns
{
  display: grid;
  grid-template-columns: 1fr 10fr 6fr 6fr 6fr 5fr 5fr 5fr 3fr;
  gap: 5px;
}


#listPaging
{
  width: 100%;
}


There, it's in one line now.


Time for JavaScript!

Now we're going to populate those rows. We'll first need to flesh out the lv object. We first have some properties.
- pageSize: How many records are shown on a page at any time. We'll set it at 50 as a default, but this won't affect anything right now.
- pageNo: The current page being shown. The default is 1. The value may change as pages are gone through.
- sort: An array which we'll use to carry out sorting later. Leave it empty for now.
- search: A string that will be used for searching values. Leave empty for now.
- records: An array of the current records being worked on. Leave it empty for now.
<script>
  const lv =
  {
    pageSize: 50,
    pageNo: 1,
    sort: [],
    search: "",
    records: []

  };
</script>


The next two are methods which will be relevant immediately. getRecords() will populate the records array. By default, it will fetch records in March 2025, so its two parameters, year and month, are set accordingly. filterRecords() will display the contents of records.
<script>
  const lv =
  {
    pageSize: 50,
    pageNo: 1,
    sort: [],
    search: "",
    records: [],
    getRecords: function(year = 2025, month = 3)
    {

    },
    filterRecords: function()
    {

    }

  };
</script>


In getRecords(), we have a jQuery AJAX call.
getRecords: function(year = 2025, month = 3)
{
  $.ajax(
    {

    }
  );  
      
},


In it, we specify the URL and that it will be a GET operation. The URL is basically a template string that incorporates year and month.
getRecords: function(year = 2025, month = 3)
{
  $.ajax(
    {
      url: `https://oracleapex.com/ords/teochewthunder/borrowing/${year}/${month}`,
      method: "GET",
      success: function(data)
      {

      },
      error: function (xhr, status, error)
      {
        console.log(xhr);
      }

    }
  );        
},


On success, we set records to the items array of data, and then run the filterRecords() method.
getRecords: function(year = 2025, month = 3)
{
  $.ajax(
    {
      url: `https://oracleapex.com/ords/teochewthunder/borrowing/${year}/${month}`,
      method: "GET",
      success: function(data)
      {
        lv.records = data.items;
        lv.filterRecords();

        return;

      },
      error: function (xhr, status, error)
      {
        console.log(xhr);
      }
    }
  );        
},


We begin the method with a return statement at the end. At the start, we clear the contents of the listRows div. Then we declare filtered. It's an independent copy of records, using the structuredClone() function.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  return;

},


Then we iterate through filtered using a forEach() loop. x is the element and i is the index. If you're wondering why the array's named "filtered", that's because later on we'll be putting it through filtering.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  filtered.forEach(
    (x, i) =>
    {
        
    }
  );


  return;
},


Here, we create a div element, row. It will be styled using the CSS class listViewColumns, which means it will be a single-line grid just like the header! Then we append row to listRows.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  filtered.forEach(
    (x, i) =>
    {
      const row = $("<div></div>");
      row.addClass("listViewColumns");

      $("#listRows").append(row);      
      
    }
  );

  return;
},


But before that, we declare col. Repeatedly, we define col as a div element, use the text() method to insert that element's data, and append it to row. We begin with the serial number, which is just 1 added to i so we don't have the 0 value at the start. The 0 would make sense to programmers. To everyone else, not so much.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  filtered.forEach(
    (x, i) =>
    {
      const row = $("<div></div>");
      row.addClass("listViewColumns");

      let col;
      col = $("<div></div>");
      col.text(i + 1);
      row.append(col);
      col = $("<div></div>");
      col.text(x.title);
      row.append(col);
      col = $("<div></div>");
      col.text(x.author);
      row.append(col);
      col = $("<div></div>");
      col.text(x.category);
      row.append(col);
      col = $("<div></div>");
      col.text(x.name);
      row.append(col);
      col = $("<div></div>");
      col.text(x.borrowdate);
      row.append(col);
      col = $("<div></div>");
      col.text(x.returnby);
      row.append(col);
      col = $("<div></div>");
      col.text(x.returndate);
      row.append(col);
      col = $("<div></div>");
      col.text(x.fine);
      row.append(col);

      $("#listRows").append(row);            
    }
  );

  return;
},


Note that for all numerical and date data, we'll also style col using the figure CSS class.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  filtered.forEach(
    (x, i) =>
    {
      const row = $("<div></div>");
      row.addClass("listViewColumns");

      let col;
      col = $("<div></div>");
      col.text(i + 1);
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(x.title);
      row.append(col);
      col = $("<div></div>");
      col.text(x.author);
      row.append(col);
      col = $("<div></div>");
      col.text(x.category);
      row.append(col);
      col = $("<div></div>");
      col.text(x.name);
      row.append(col);
      col = $("<div></div>");
      col.text(x.borrowdate);
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(x.returnby);
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(x.returndate);
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(x.fine);
      col.addClass("figure");
      row.append(col);
      $("#listRows").append(row);            
    }
  );

  return;
},


figure is just setting the text to align right.
#listPaging
{
  width: 100%;
}

.figure
{
  text-align: right;
}


.sortButton, .noButton
{
  width: auto;
  padding: 0px;
  height: 1.5em;
  font-size: 1em;
  background: transparent;
  border: none;
  color: rgb(0, 0, 0);
}


After the lv object, set getRecords() to run as soon as the document loads.
          col.text(x.fine);
          col.addClass("figure");
          row.append(col);
          $("#listRows").append(row);            
        }
      );

      return;
    }
  };

  $(document).ready(
    function()
    {
      lv.getRecords();
    }
  );

</script>


You see here, we have the records! But the dates look weird...


No matter. Let's run all the date values through the prettifyDate() method.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  filtered.forEach(
    (x, i) =>
    {
      const row = $("<div></div>");
      row.addClass("listViewColumns");

      let col;
      col = $("<div></div>");
      col.text(i + 1);
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(x.title);
      row.append(col);
      col = $("<div></div>");
      col.text(x.author);
      row.append(col);
      col = $("<div></div>");
      col.text(x.category);
      row.append(col);
      col = $("<div></div>");
      col.text(x.name);
      row.append(col);
      col = $("<div></div>");
      col.text(this.prettifyDate(x.borrowdate));
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(this.prettifyDate(x.returnby));
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(this.prettifyDate(x.returndate));
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(x.fine);
      col.addClass("figure");
      row.append(col);
      $("#listRows").append(row);            
    }
  );

  return;
},


Create this method. It basically just cleans up the value by using the split() method to divide up the string by the "T" character, and then returning only the first element of the resultant array.
          col.text(x.fine);
          col.addClass("figure");
          row.append(col);
          $("#listRows").append(row);            
        }
      );

      return;
    },
    prettifyDate: function(dt)
    {
      var arr = dt.split("T");
      return arr[0];
    }

  };
</script>


Now it's looking much better.


It's a small thing, but let's get this out of the way. Create CSS class shade. Its background color is a very translucent black.
.listViewColumns
{
  display: grid;
  grid-template-columns: 1fr 10fr 6fr 6fr 6fr 5fr 5fr 5fr 3fr;
  gap: 5px;
}

.shade
{
  background-color: rgba(0, 0, 0, 0.1);
}


#listPaging
{
  width: 100%;
}


Then in filterRecords(), when styling row using the listViewColumns CSS class, we check to see if index i is even, using the modulus operator. If so, also style row using shade.
const row = $("<div></div>");
row.addClass("listViewColumns");
if (i % 2 == 0) row.addClass("shade");

let col;
col = $("<div></div>");
col.text(i + 1);
col.addClass("figure");
row.append(col);



More presentable and readable now.

That's about it for now. We'll do paging through records next.

Next

Implementing paging.

Friday, 17 July 2026

Web Tutorial: NodeJS Chinese Name Transliterator

The Romanization of Chinese names have always been a subject of fascination for me. I've had Vietnamese colleagues and Chinese colleagues from Hong Kong and Taiwan. I've watched Korean dramas that were dubbed in Mandarin, and wondered what the original Korean names were.

Today, I want to present this tool I wrote in NodeJS - it accepts a single Chinese name, in Chinese characters, and provides transliterations of the name in various languages.

Setup

After installing my project using the node command, I installed Express and Handlebars.
npm install --save express
npm install --save express-handlebars


This is what exists in app.js, as a baseline. We load Express and Handlebars, and ensure that the view engine is Handlebars. We also ensure that main is the default layout, and we'll create that soon. We also declare assets as the folder where static content is served, and set the default page to serve the view for home. And then set the 404 and 500 pages. Lastly, we start the app up using the listen() method.

app.js
var express = require("express");

var app = express();

var handlebars = require("express-handlebars").create({defaultLayout:"main"});
app.engine("handlebars", handlebars.engine);

app.set("view engine", "handlebars");
app.set("port", process.env.PORT || 3000);

app.use(express.static("assets"));

app.get("/", (req, res)=> {
  res.render("home");
});

app.use((req, res, next)=> {
  res.status(404);
  res.render("404");
});

app.use((err, req, res, next)=> {
  res.status(500);
  res.render("500", { errorMessage: err.code });
});

app.listen(app.get("port"), ()=> {

});


This is the main layout file. Note the links to CSS and JavaScript. In the body, we have a div with id pnlContainer, which will contain the content of whatever page the app serves.

views/layouts/main.handlebars
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Chinese Name Transliterator</title>

    <link rel="stylesheet" type="text/css" href="css/styles.css">
    <script type="text/javascript" src="js/functions.js"></script>
  </head>
  <body>
    <div id="pnlContainer">
      {{{ body }}}    
    </div>
  </body>
</html>


Let's just create a very basic CSS file that only specifies font. We can add more later.

asset/css/styles.css
body
{
    font-size: 16px;
    font-family: georgia;
}


In the JavaScript, we'll just have one single function, to check if a string is made up of only Chinese characters. To do that, we use a Regular Expression of a range of Chinese characters, then use the test() method with name as the argument, returning the result. That's also incidentally the only JavaScript validation we'll need in here.

asset/js/functions.js
function isValidChineseName(name) {
    const re = /^[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF]+$/;
    return re.test(name);
}


Here's the view for 404s.

views/404.handlebars
<h1>404</h1>

<p>Not found!</p>


And for error 500s.

views/500.handlebars
<h1>500</h1>

<p>There was an error.</p>
<p><b>{{ errorMessage }}</b></p>


And here's the default page. You'll notice there is no form tag; we won't need one because I don't actually intend to do any form submission here. What we do have is a nice header, a div (id pnlSelection), a button (id btnTransliterate) and a span tag (id pnlError) for showing errors. And a script tag for more shenanigans later.

views/home.handlebars
<h1>Chinese Name Transliterator</h1>

<div id="pnlSelection">

</div>

<button id="btnTransliterate">TRANSLITERATE</button>
<span id="pnlError"></span>

<script>

</script>


In pnlSelection, we have two more divs. One contains a label, and the other contains a textbox. The id of the textbox is txtFullChineseName and it will have at most 4 characters, unless you feel like transliterating full Manchurian names or something.

views/home.handlebars
<h1>Chinese Name Transliterator</h1>

<div id="pnlSelection">
  <div><label for="txtFullChineseName">Full Chinese Name</label></div>
  <div><input id="txtFullChineseName" maxlength="4" /></div>

</div>

<button id="btnTransliterate">TRANSLITERATE</button>
<span id="pnlError"></span>

<script>

</script>


This probably looks a mess. Time for more CSS.


pnlContainer fits the full height of the window (thus 100vh), has a maximum width of 400 pixels, which will fit nicely on mobile, and is centered in the middle of the window via the margin property. Text is centered by default.

asset/css/styles.css
body
{
    font-size: 16px;
    font-family: georgia;
}

#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}


pnlSelection is slightly more interesting. The display property is grid, which instantly turns it into a grid container. grid-template-columns has a value of "1fr 1fr" which basically means two by two, and I've specified a 10-pixel gap. The text-align property has been set to justify to offset the text centering of its parent.

asset/css/styles.css
body
{
    font-size: 16px;
    font-family: georgia;
}

#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}

#pnlSelection
{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    text-align: justify;
}


Now for pnlError and the button. pnlError has red text. The button is pill-shaped and has large text. Nothing to see here, move along.

asset/css/styles.css
body
{
    font-size: 16px;
    font-family: georgia;
}

#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}

#pnlSelection
{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    text-align: justify;
}

#pnlError
{
    font-size: 0.8em;
    font-weight: bold;
    color: #FF0000;
}

button
{
    width: 100%;
    height: 2em;
    margin-top: 2em;
    border-radius: 20px;
}


Time for a preview! So far so good.


We're not just going to have a Chinese name transliterated - we're also going to select the languages it is transliterated in. For that, let's created options.js in the data folder of the assets folder. We basically export an array of objects. Each object has two properties - name and label. These are the five romanization systems to be transliterated in - Wade-Giles, Hokkien, Jyutping, Hangul and Vietnamese.

assets/data/options.js
const options = [
{
    value: "wade-giles",
    label: "Wade-Giles",
},
{
    value: "hokkien",
    label: "Hokkien",
},
{
    value: "jyutping",
    label: "Cantonese",
},
{
    value: "hangul",
    label: "Korean",
},
{
    value: "vietnamese",
    label: "Vietnamese",
}
];

module.exports = options;


We also have to make sure it's available in the back-end. Declare options as what options.js exports, then pass it into home as options.

app.js
const options = require("./assets/data/options.js");
var express = require("express");

var app = express();

var handlebars = require("express-handlebars").create({defaultLayout:"main"});
app.engine("handlebars", handlebars.engine);

app.set("view engine", "handlebars");
app.set("port", process.env.PORT || 3000);

app.use(express.static("assets"));

app.get("/", (req, res)=> {
  res.render("home", { options: options });
});

app.use((req, res, next)=> {
  res.status(404);
  res.render("404");
});

app.use((err, req, res, next)=> {
  res.status(500);
  res.render("500", { errorMessage: err.code });
});

app.listen(app.get("port"), ()=> {

});


Now in the view, we want to iterate through options, which has been passed down to this view from app.js. For each option, we'll have a div and a span. The first div will contain a checkbox and label, with values reflecting those of the option. The span tag will have an id that is "result_" concatenated with the name property of the current element of options.

views/home.handlebars
<div id="pnlSelection">
  <div><label for="txtFullChineseName">Full Chinese Name</label></div>
  <div><input id="txtFullChineseName" maxlength="4" /></div>

  {{#each options}}
  <div>
     <label>
       <input type="checkbox" name="languages" value="{{value}}">
       {{label}}
     </label>
  </div>
  <div>
     <span class="txtResult" id="result_{{value}}"></span>
  </div>
  {{/each}}

</div>


That's how it looks like! We haven't styled the span tags yet, and they have no content at the moment, so they're invisible.


Now, we want to handle clicking! Here's some beginning code...

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {

});
</script>


And then we want to start with validation. First, we clear the contents of pnlError. If the string given is not a valid Chinese name (remember the function we created earlier?), we set the contents of pnlError to a message, then exit early.
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

});
</script>


If no options are selected, we also set the error message and exit early. selectedOptions can be reused later.

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

  const selectedOptions = document.querySelectorAll("input[name='languages']:checked");

  if (selectedOptions.length === 0) {
    document.getElementById("pnlError").textContent = "Please select at least one transliteration option.";
    return;
  }

});
</script>


See what happens when the name contains anything that isn't Chinese.


Or when no option are checked. I'll be using this name as an example for the rest of this tutorial.


Then we declare properties, which really is a comma-separated string of all values selected. For this, we iterate through selectedOptions using the map() method, grabbing the value property values and appending them using the join() method.

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

  const selectedOptions = document.querySelectorAll("input[name='languages']:checked");

  if (selectedOptions.length === 0) {
    document.getElementById("pnlError").textContent = "Please select at least one transliteration option.";
    return;
  }

  let properties = [...selectedOptions]
  .map(x => `"${x.value}"`)
  .join(", ");

});
</script>


We'll create a prompt, prompt. For this, we want a valid JSON object that will contain named properties that the user has selected. So if the user checked on the checkboxes labelled "Cantonese" and "Korean", the resultant value inserted into prompt would be "jyutping,hangul".

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

  const selectedOptions = document.querySelectorAll("input[name='languages']:checked");

  if (selectedOptions.length === 0) {
    document.getElementById("pnlError").textContent = "Please select at least one transliteration option.";
return;
  }

  let properties = [...selectedOptions]
  .map(x => `"${x.value}"`)
  .join(", ");

  let prompt = `Return only a valid JSON object. The object should contain the following properties only: ${properties}. The value of each property should be the equivalent romanized version of the Chinese name "${name}" (without intonations) only. In the event of ambiguity, pick the first reasonable result.`;
});
</script>


Then we use a Try-catch block to send the prompt to the transliterate route using the asynchronous fetch() function. We'll display an error if there's one.

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

  const selectedOptions = document.querySelectorAll("input[name='languages']:checked");

  if (selectedOptions.length === 0) {
    document.getElementById("pnlError").textContent = "Please select at least one transliteration option.";
return;
  }

  let properties = [...selectedOptions]
  .map(x => `"${x.value}"`)
  .join(", ");

  let prompt = `Return only a valid JSON object. The object should contain the following properties only: ${properties}. The value of each property should be the equivalent English romanized version of the Chinese name "${name}" (without intonations) only. In the event of ambiguity, pick the first reasonable result.`;

  try {
    const response = await fetch("/transliterate", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        prompt: prompt
      })
    });
  } catch (err) {
    document.getElementById("pnlError").textContent = "An unexpected error occurred.";
    console.error(err);
  }

});
</script>


Now let's handle the transliterate route. First, we set up the api.js file. org and key are derived from the OpenAI API project you should have set up for this project. Obviously my org and key values aren't "xx", but you'll have to get your own.

api.js
module.exports = {
  org: "xx",
  key: "xx"
}


Now, in app.js, define api as the exported value from api.js. Ensure that app uses the json() method from express. That's what we'll use to parse. And then define the transliterate route. It's POST, and async.

app.js
const api = require("./api.js");
const options = require("./assets/data/options.js");
var express = require("express");

var app = express();

var handlebars = require("express-handlebars").create({defaultLayout:"main"});
app.engine("handlebars", handlebars.engine);

app.set("view engine", "handlebars");
app.set("port", process.env.PORT || 3000);

app.use(express.static("assets"));

app.get("/", (req, res)=> {
  res.render("home", { options: options });
});

app.use(express.json());

app.post("/transliterate", async (req, res) => {

});


app.use((req, res, next)=> {
  res.status(404);
  res.render("404");
});

app.use((err, req, res, next)=> {
  res.status(500);
  res.render("500", { errorMessage: err.code });
});

app.listen(app.get("port"), ()=> {

});


We have a Try-catch block here.
app.post("/transliterate", async (req, res) => {
  try {

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }

});


We obtain prompt by getting it from the body object of the request, req. If prompt does not exist, we exit early with an error.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


So here, we create response. It is what's returned from running fetch() with OpenAI's API responses endpoint, The operation is a POST and the headers passed in are Authorization, which uses the key value from api; and Content-Type which is set to accept JSON.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }
  
    const response = await fetch(
      "https://api.openai.com/v1/responses",
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${api.key}`,
          "Content-Type": "application/json"
        }
      }
    );

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


Then in the body, we send a JSON-encoded string of an object containing the model and input. The model is "gpt-5" and the value for input is set to the string known as prompt. In effect, we're asking the API endpoint to provided romanizations for the provided Chinese name, in the various selected languages.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }
  
    const response = await fetch(
      "https://api.openai.com/v1/responses",
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${api.key}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          model: "gpt-5",
          input: prompt
        })

      }
    );
  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


Since we used await for the previous line, the next part will have response ready. If the ok property of response is false or does not exist, exit with an error.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }
  
    const response = await fetch(
      "https://api.openai.com/v1/responses",
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${api.key}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          model: "gpt-5",
          input: prompt
        })
      }
    );
  
    if (!response.ok) {
      const errorText = await response.text();
  
      return res.status(response.status).json({
        success: false,
        error: errorText
      });  
    }

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


At this point, if there has been no early exit, that means the call was successful. We define data by converting it to JSON using the async method json(). Then we return the data. It looks convoluted right now because that's the way OpenAI wraps it - the text property of the first object of the content array, which in turn is the second element of the output array of data.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }
  
    const response = await fetch(
      "https://api.openai.com/v1/responses",
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${api.key}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          model: "gpt-5",
          input: prompt
        })
      }
    );
  
    if (!response.ok) {
      const errorText = await response.text();
  
      return res.status(response.status).json({
        success: false,
        error: errorText
      });
    }
  
    const data = await response.json();

    res.json({
      success: true,
      result: data.output[1].content[0].text
    });

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


Back to the view! We define data as the JSON shape of response. If success was not defined, then we set the error message in pnlError and exit early.

home.handlebars
try {
  const response = await fetch("/transliterate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      prompt: prompt
    })
  });

  const data = await response.json();

  if (!data.success) {
    document.getElementById("pnlError").textContent = data.error;
    return;
  }

} catch (err) {
  document.getElementById("pnlError").textContent = "An unexpected error occurred.";
  console.error(err);
}


Define transliterations. It'll be the object data's result object. Then we have txtResult, declared as a collection of all elements in the DOM using the CSS class txtResult.

home.handlebars
try {
  const response = await fetch("/transliterate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      prompt: prompt
    })
  });

  const data = await response.json();

  if (!data.success) {
    document.getElementById("pnlError").textContent = data.error;
    return;
  }

  const transliterations = JSON.parse(data.result);

  const txtResult = document.getElementsByClassName("txtResult");

} catch (err) {
  document.getElementById("pnlError").textContent = "An unexpected error occurred.";
  console.error(err);
}


We convert it to an array, then run a forEach() to iterate through it. x is the current element. We get the id and strip "result_" from it to get the language that it's supposed to be in. Then from there, we populate x with its relevant transliteration, if it exists.

home.handlebars
try {
  const response = await fetch("/transliterate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      prompt: prompt
    })
  });

  const data = await response.json();

  if (!data.success) {
    document.getElementById("pnlError").textContent = data.error;
    return;
  }

  const transliterations = JSON.parse(data.result);

  const txtResult = document.getElementsByClassName("txtResult");

  Array.from(txtResult).forEach((x) => {
    let propName = x.id.replace("result_", "");
    x.textContent = (transliterations[propName] ? transliterations[propName] : "");
  });

} catch (err) {
  document.getElementById("pnlError").textContent = "An unexpected error occurred.";
  console.error(err);
}


Try this! Enter a name. Then select a couple of languages. Let's try Wade-Giles, Cantonese and Korean.


It works! It takes a bit of time, but it works. In Wade-Giles, the name is "Tu Yu-lei", and so on.



Here's an improvement...

Add this to the layout file. It's another div, id pnlOverlay. In it, you have a message and an hourglass icon.

views/layouts/main.handlebars
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Chinese Name Transliterator</title>

    <link rel="stylesheet" type="text/css" href="css/styles.css">
    <script type="text/javascript" src="js/functions.js"></script>
  </head>
  <body>
    <div id="pnlOverlay">
      <p><h1>Please wait.</h1>This may take<br />a minute...</p>
      <p class="hourglass">⧗</p>
    </div>

      
    <div id="pnlContainer">
      {{{ body }}}    
    </div>
  </body>
</html>


Style it this way. The position property is fixed, and we anchor it to the top left corner of the screen, with full height and width, a translucent black background and white text. Classic overlay. Of course, we have to set display to none, to hide it. The hourglass CSS class just makes sure the font for this is huge.

assets/css/styles.css
#pnlOverlay
{
    position: fixed;
    display: none;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.9);
    color: rgb(255, 255, 255);
    text-align: center;
}


#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}

#pnlSelection
{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    text-align: justify;
}

#pnlError
{
    font-size: 0.8em;
    font-weight: bold;
    color: #FF0000;
}

.txtResult
{
    font-weight: bold;
    color: rgb(100, 100,100);
}

.hourglass
{
    font-size: 5em;
}


button
{
    width: 100%;
    height: 2em;
    margin-top: 2em;
    border-radius: 20px;
}


For the heck of it, throw in an animation!

assets/css/styles.css
#pnlOverlay
{
    position: fixed;
    display: none;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.9);
    color: rgb(255, 255, 255);
    text-align: center;
    animation: pulse 1s infinite;
}

#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}

#pnlSelection
{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    text-align: justify;
}

#pnlError
{
    font-size: 0.8em;
    font-weight: bold;
    color: #FF0000;
}

.txtResult
{
    font-weight: bold;
    color: rgb(100, 100,100);
}

.hourglass
{
    font-size: 5em;
}

@keyframes pulse
{
    0%
    {
        color: rgb(255, 255, 255);
    }
    50%
    {
        color: rgb(100, 100, 100);
    }
}


button
{
    width: 100%;
    height: 2em;
    margin-top: 2em;
    border-radius: 20px;
}


Now, add this in the view. When the button is clicked, pnlOverlay should pop up just before the Try-catch block.

views/home.handlebars
let properties = [...selectedOptions]
.map(x => `"${x.value}"`)
.join(", ");

let prompt = `Return only a valid JSON object. The object should contain the following properties only: ${properties}. The value of each property should be the equivalent romanized version of the Chinese name "${name}" (without intonations) only. In the event of ambiguity, pick the first reasonable result.`;

document.getElementById("pnlOverlay").style.display = "block";
  
try {
  const response = await fetch("/transliterate", {


And once there's a resolution, it should disappear!

views/home.handlebars
try {
  const response = await fetch("/transliterate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      prompt: prompt
    })
  });

  document.getElementById("pnlOverlay").style.display = "none";

  const data = await response.json();

  if (!data.success) {
    document.getElementById("pnlError").textContent = data.error;
    return;
  }

  const transliterations = JSON.parse(data.result);

  const txtResult = document.getElementsByClassName("txtResult");

  Array.from(txtResult).forEach((x) => {
    let propName = x.id.replace("result_", "");
    x.innerHTML = (transliterations[propName] ? transliterations[propName] : "");
  });
} catch (err) {
  document.getElementById("pnlError").textContent = "An unexpected error occurred.";
  console.error(err);
}


Here, let's try this.


There, it politely tells you to wait!


And then it shows you the results.


We're done here!

Just another fun NodeJS project. Combining my love for writing code, with exploring new human languages.

Talk Too U-lei-ter!
T___T