Sunday, 4 May 2025

Full-time Pay, Part-time Job

Singapore has spoken. Yesterday on the 3rd of May, we spoke at the ballot box and made our respective decisions in our various constituencies. Some were won, some were lost, and Singapore moves on.

But in the neighbourhood of Mountbatten in the eastern part of the island, the spoils of victory go to Ms Gho Sze Kee of the People's Action Party. Her opponent? A certain independent candidate by the name of Jeremy Tan, a tech-savvy entrepreneur. And he's the subject of this blogpost today.

Tan (no relation here; us Tans are all over Southeast Asia) came to my attention on the 1st of May as he held a rally to present his case to Mountbatten residents. In the process, he won quite a few hearts, I suspect, including mine. Though I might be biased; I, too, am a software developer. There's footage of his presentation here, and honestly, I can't do it justice with a mere description; so do check it out below on YouTube.



Chill!

What I liked, first and foremost, was the chill, laid-back vibe. He was basically jiving with the crowd, getting them to repeat the night's catch-phrase - Full-time pay, part-time job, alluding to Gho Sze Kee's intention to only be a part-time MP - at pertinent parts of his speech. This was political showmanship without the fireworks. I know we love those from time to time, but this election served up a disproportionate dose of testosterone.





Thus, Jeremy Tan's cheeky quips and laid-back zingers were a refreshing change. It felt more like a stand-up comedy routine than a political rally as he roasted both his opponent Gho Sze Kee and the outgoing incumbent Lim Biow Chuan.

Relatable

One point Jeremy Tan made was in response to Lim Biow Chuan's assertion that "There's something about Jeremy Tan's language that I don't quite agree with.".

"You know what Mr Lim, I think most Singaporeans sound like myself than Miss Gho or yourself, you know. I think perhaps it's the fact that we speak different languages that you have been tone-deaf to your residents."

And this is a very valid point. You see, no one (OK, hardly any one) disputes the fact that the incumbent boasts some of the best and brightest minds of Singapore. The cream of the crop. The kind who would be able to make more than the monthly SGD 16,000 that is on offer for the average MP, quite easily. The kind, like Ms Gho Sze Kee, who saw the role of MP as only a "part-time job", or side gig.

And that's part of the problem. These people have higher-paying jobs that they don't have a chance of losing every 5 years or so. Therefore some of them sometimes give off the impression that they resent having to place themselves at the mercy of us unwashed schmucks every 5 years, and plead their case as to why they should be re-elected. After all they've done for the country! How dare these peasants presume to judge them?!

That ivory tower.

Therein lies the disconnect.

If you told the average man in the street, who earns maybe SGD 4,000 a month or less, and told him you'd pay him four times that but every 5 years he would have to go through a national review to keep his job, he would consider that an unbelievably good deal. That kind of pay and you don't need to worry about losing your job for at least 5 years?! Sign me up, bro.

Sometimes Singaporeans don't need the best. They need people who can adequately do the job and they can relate to. It's all very well to have the best and brightest doing the job until they piss you off every time they open their mouths. Remember, suitability for a role is not merely a matter of technical, but also social competence.

And finally... the tech stack

This was the part that sold me. I am a software developer, after all, even if not a particularly outstanding one.

Jeremy Tan commented on the state of the ERP and CRM Software used in the Town Council. As a software applications engineer with the relevant experience, he pledged to rebuild the entire tech stack.  It was his vision to create some kind of "digital town" for Mountbatten.

A "tech stack". Heh heh.

Now, was this all some tech gibberish designed to bamboozle the average non-techie? Maybe. It does sound like a tall order, especially when he started talking about A.I. But it also does sound like an exciting project.

This also resonated because I have noticed similar things about other Government-linked systems I have used recently and in the past. While some of the deficiencies were tech-based, some of it was due to tech being layered on top of outdated manual processes. Granted, there have been marked improvements as of late, but it's also a little concerning that these improvements have only come recently.

I could go on, but this is supposed to be about my guy Jeremy Tan, so...

In conclusion?

Jeremy Tan's efforts brought me back to a period in time where Big Tech founders conducted bold experiments in their family garages. That kind of enterprise is perhaps what we sorely need as a nation.

He may not last in the public eye beyond this election. But he'll be remembered fondly. By me, at least. And if he ever wants to run again, I'll gladly distribute his flyers.

Give this man a full-time job!
T___T

Tuesday, 29 April 2025

Sidestepping the year-end bonus trap

By the second year in my current job, I had gone for years without a year-end bonus due to a combination of factors such as constant job-hopping and working for cheap financially-prudent employers. But I made do. Not only that, I thrived.

Thus it was with some puzzlement when I received an email from HR informing me that I was due for a year-end bonus. An entire month's worth, no strings attached.

Extra money out of nowhere.

Wow. Imagine. My. Shock. Having spent so many years learning to live without a year-end windfall, now I faced an entirely different problem: what in the blue hell was I supposed to do with that chunk of change that had just appeared out of nowhere and didn't fit in my budget?

As a married man, the solution was easy. I just handed it over to my wife.

When the Missus received that fat stack of cash, the first question she asked, with a hint of suspicion, was why I was suddenly giving her money. I put on my most macho-bullshit act and declared, "Woman, I told you when you married me, whatever money I make from here onwards, you will make." Famous last words. Because every subsequent year, right at the tail end, she would inquire about my year-end bonus and now I was expected to give it to her every time.

Moral of the story: Be very careful what you tell your woman. She's going to hold you to it. Believe that!

The real reason

When I told my friends what I'd been doing with my year-end bonus for the past few years, what I got was the mind-numbing chorus of "wow, that's true love" and "what a great husband".

Have people always been this simple, or is this just happening now?

This is about freedom, not love. I was once trapped in a job I hated due to having developed a dependency on their year-end bonus. And whenever I tried to leave, I always found myself waiting to get the bonus before making plans to leave. Plenty of people find themselves in the same predicament now. It's that Singaporean mentality of not wanting to lose out.

Yes, I know that this isn't necessarily some cynical move by the company to hold their employees ransom. A year-end bonus is a pretty common practice and nobody was holding a gun to my head in order to force me to stay at the company. It was purely my reluctance to forfeit that lump sum of cash by leaving at year end, that kept me there. However, even taking full responsibility for it, does not make the dependency on the year-end bonus any less of a trap. It merely turns this into a self-imposed trap.

No! It's a trap!

Now having tailored my cash flow around not having a year-end bonus after so many years of going without, I have effectively weaned myself off this dependency. Why would it be a good idea to voluntarily redevelop the same crippling dependency now? Why would I give up the freedom to leave whenever I damn well feel like it, even if there are no plans to leave at the moment?

Not only do I give my wife my yearly bonus, I've also developed the habit of giving Mom my salary increases. Why? Same reasoning. It's a bit of extra money that I don't care about, it keeps 'em happy, and that's a great ROI. Most importantly, it keeps me lean. And hungry. It was that same hunger that had me going back to school. Learning new tech tools. Picking up skillsets from related disciplines such as DevOps, Data Analytics and Mobile Development. It was that very hunger that had me build this blog and keep honing my craft.

It's the principle of the thing. Financially, I'm in a good place and don't need the money. I have worked long and hard, over the course of the last decade, to have the luxury of being able to easily ignore the lure of immediate money, in favor of things of greater value. And approaching the age of 50, autonomy in my career is arguably more important than ever. Definitely more important than an extra few thousand bucks.

Conclusion

Losing my job isn't high on the list of things to worry about. Come to think of it, it might not even be on the list. No matter what you tell yourself so you can get out of bed in the morning, a job is ultimately just a job. Your ability to land a job is far more important than the job itself.

Gotta maintain that edge.

It's more my edge I'm afraid of losing. Not being some special tech talent that can walk into any company and demand the sky, I only got this far due to enthusiasm and motivation. Not complacency. Due to careful financial management, I already have way more money than I realistically need.

I am in charge of my own career, and will give that up when hell freezes over.

Be$t wi$he$,
T___T

Wednesday, 23 April 2025

The case of the ill-considered feature quotation

When you're in-house tech personnel dealing with external tech vendors, sometimes experience and common sense can be useful.

Also, the willingness to prioritize professional duty over being liked. I mention this because that's not me - I actively try not to be the guy who ruins everyone's workday by nitpicking on small details in the name of being "thorough". Work does suck for a lot of people, and there's just no point in making it suck more than it already does, without good reason.

That's far enough.

Except, sometimes, there is a good reason. Sometimes, there are multiple good reasons to dig your heels in and say "that's far enough, buddy".

This is such a story I'm going to tell today.

What happened

My company at the time had contracted a vendor to store our data, which would be sent to them by means of an API endpoint they provided us. Admittedly, they weren't the solution provider I would have gone with, but in the interest of saving time (and also because I didn't have a better alternative), I played nice. After ascertaining that the solution worked - i.e, our system would send data though that API endpoint call and the data would be saved in their system, it was time to talk about security.

Just a key.

Our proposed solution was to have an extra property in the JSON object that we were sending them, with a password that they would provide. Like an API key. Any calls made using that key would be validated against their records, so that they could at least be confident that the origin of the data would be correct.

The solution was simple enough, and their Sales Representative told us it could be done. But then he made the mistake of telling us that we would be quoted an extra charge for it. And that was when I drew a line in the sand, and told them, no, this was absolutely not going to happen.

In retrospect, the fact that I was the one who had to broach the subject of security was a red flag. If I hadn't, would they just have carried on? Alarming if one considered that we weren't their only clients.

Why I put my foot down

The extra feature we requested was for security. Security should always be considered a basic, rather than extra, feature. Especially when the service providers in question are holding on to client data. If these vendors had our data in their storage, why should we have to pay extra for a basic security feature?

Also, in the event of a data breach, could these vendors really afford not to be able to show subsequent audit that they, at the very least, had previously done their due diligence?

Security should be a
basic feature.

From my experience with small vendors like these, they usually aren't using separate database servers for different clients. More often than not, it's some sort of shared virtual hosting. Which meant that any data breach on our part could affect their other clients, and vice versa.

All in all, the vendors had significantly more to lose than we did, from a security breach. From that viewpoint, it was patently ridiculous for them to want to charge us for security. That would be like me demanding payment from the locksmith to install a lock on my own door.

There is the possibility that their Sales Representative was not really thinking clearly, and that he was asking for extra payment out of sheer habit. Because that was the way he had been trained. Honestly, I don't think this made it better. Definitely didn't make my company's data feel more secure in their hands.

Conclusion

Going with the flow is easy. Standing firm on principle is harder. In fact, I would argue that standing firm when the situation is as egregious as this, is easy. The hard part is really identifying such situations in the first place. I don't think there's exactly a playbook for things like that.

Thanks for reading, that's far enough!
T___T

Saturday, 19 April 2025

Web Tutorial: The Easter Poem Generator (Part 2/2)

We actually already created functions for validating and submitting earlier, so let's fill them up now.

We start with the handleChecking() function. We first get name, value and checked from the target object of the e object. target will be the checkbox that is checked. So we have finalValue. If the checkbox is checked, then finalValue has the value value. If not, its value is an empty string.

src/app/page.js
const handleChecking = (e) => {
  const { name, value, checked } = e.target;
  var finalValue = (checked ? value : "");

};


Whatever the value of finalValue, it will be used in setFormData(). We use the spread operator to destructure formData, then add in a name-value pair using name and finalValue. If name already appears in formData, it will thus be overwritten by the value of finalValue.

src/app/page.js
const handleChecking = (e) => {
  const { name, value, checked } = e.target;
  var finalValue = (checked ? value : "");

  setFormData({
    ...formData,
    [name]: finalValue
  });

};


What we want to do is ensure that at least one checkbox is checked. So we'll test this. Declare tempFormData as a clone of formData by using stringify() and parse() methods. Modify tempFormData's property, pointed to by name, changing its value to finalValue.

src/app/page.js
const handleChecking = (e) => {
  const { name, value, checked } = e.target;
  var finalValue = (checked ? value : "");

  var tempFormData = JSON.stringify(formData);
  tempFormData = JSON.parse(tempFormData);
  tempFormData[name] = finalValue;


  setFormData({
    ...formData,
    [name]: finalValue
  });
};


Now we use an If block to check if all the string values are empty strings. If that's true, we reset the value of finalValue to value. This ensures that there is never  a situation where all the properties are empty strings.

src/app/page.js
const handleChecking = (e) => {
  const { name, value, checked } = e.target;
  var finalValue = (checked ? value : "");

  var tempFormData = JSON.stringify(formData);
  tempFormData = JSON.parse(tempFormData);
  tempFormData[name] = finalValue;

  if (tempFormData.symbol_bunny + tempFormData.symbol_chicks + tempFormData.symbol_cross + tempFormData.symbol_eggs + tempFormData.symbol_lillies === "") {
    finalValue = value;
  }


  setFormData({
    ...formData,
    [name]: finalValue
  });
};


Try it... you can select multiple options but you will find that you'll never be able to deselect all of the options.

That's it for validation! Now we want to handle submitting.

We first ensure that the form is not automatically submitted, by using the preventDefault() method. We want to implement our own submit logic. After that, we use setGeneration() to set a loading message.

src/app/page.js
const handleSubmit = async (e) => {
  e.preventDefault();
  setGeneration( { poem: "<h2>Pease wait...</h2>" } );

};


We'll add a div, styled using the CSS class poemContainer, with the generation object's poem property as content.

src/app/page.js
return (
  <div className={styles.generationContainer} >
    <form onSubmit={handleSubmit}>
      <h1>Generate an Easter Poem!</h1>
      <b>Include at least one of these elements</b>
      {
        Object.entries(formData).map(([key, value]) => (
        <label className={styles.label} key={key}>
          <input type="checkbox" name={key} value={key.replace("symbol_", "")} onChange={handleChecking} checked={ (value !== "") } />
           {key.replace("symbol_", "")}
           <Link href={ ("/" +key.replace("_", "/")) } target="_blank">►</Link>
          <br />
        </label>
      ))}    
      <button type="submit" className={styles.button}>Go!</button>
      <br style={{ clear:"both" }} />
    </form>
    <div className={styles.poemContainer} >{ generation.poem } </div>
  </div>
);


Here's poemContainer. Just a bit of alignment and layout. Nothing crazy.

src/app/page.module.css
.generationContainer form {
  width: 450px;
  padding: 10px;
  margin: 0 auto 0 auto;
  border-radius: 10px;
  border: 1px solid rgb(255, 100, 100);
}

.poemContainer {
  width: 450px;
  padding: 10px;
  margin: 0 auto 0 auto;
  text-align: center;
}


.label {
  display: inline-block;
  width: 45%;
  float: left;
}


Now try Clicking the SUBMIT button. Looks like the HTML is being treated like text content!


We will rectify this using dangerouslySetInnerHTML. This renders raw HTML in a React component.

src/app/page.js
<div className={styles.poemContainer} dangerouslySetInnerHTML={{ __html: generation.poem }} />


This time, the loading message is properly formatted.



Now that this is done, we're going to make a call to the backend using fetch(). We'll pair it with await because its very nature is async. The endpoint is poemgen and we will be creating it after this. The result of the call to the backend will be assigned to the object response.

src/app/page.js
const handleSubmit = async (e) => {
  e.preventDefault();
  setGeneration( { poem: "<h2>Pease wait...</h2>" } );

  const response = await fetch("/api/poemgen", {

  });

};


In fetch(), the second argument is an object. It contains the method of the call (in this case, it's a POST), the headers object (which in this context basically tells fetch() that we're ending data in JSON format, and the body property. The last one contains formData as a JSON-formatted string.

src/app/page.js
const handleSubmit = async (e) => {
  e.preventDefault();
  setGeneration( { poem: "<h2>Pease wait...</h2>" } );

  const response = await fetch("/api/poemgen", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify(formData)

  });
};


We then declare result and use it to store the value of response as a JSON object. Since the method we're using, json(), is asynchronous, we have to use await.

src/app/page.js
const handleSubmit = async (e) => {
  e.preventDefault();
  setGeneration( { poem: "<h2>Pease wait...</h2>" } );

  const response = await fetch("/api/poemgen", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify(formData)
  });
  const result = await response.json();

};


We then check if status is 200. If so, we run setGeneration() and set the value of the poem property to the message property of result. If not, we handle the error by logging it in the console.

src/app/page.js
const handleSubmit = async (e) => {
  e.preventDefault();
  setGeneration( { poem: "<h2>Pease wait...</h2>" } );

  const response = await fetch("/api/poemgen", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify(formData)
  });
  const result = await response.json();

  if (response.status === 200) {
    setGeneration( { poem: result.message } );
  } else {
    console.log(result);
  }

};


Now, in the app directory, create the api subdirectory, and in it, the poemgen subdirectory. In that, create route.js. This is the NextJS App router method of creating backend API endpoints. In this file, we export an async function that responds to the POST method. There is a parameter, req, that represents the request.

src/app/api/poemgen/route.js
export async function POST(req) {

}


We declare formData and use await to obtain the JSON object req. Then we declare symbols as an empty string.

src/app/api/poemgen/route.js
export async function POST(req) {
  const formData = await req.json();

  var symbols = "";
}


This is followed by a series of If blocks that correspond to each property in formData. If the value is not an empty string, concatenate that value to symbols, with a comma and space. Remember, there will never be a case where all the strings are empty. Thus, there will always be at least one comma and space. We remove the one at the end using the slice() method.

src/app/api/poemgen/route.js
export async function POST(req) {
  const formData = await req.json();

  var symbols = "";
  if (formData.symbol_bunny !== "") symbols += (formData.symbol_bunny + ", ");
  if (formData.symbol_chicks !== "") symbols += (formData.symbol_chicks + ", ");
  if (formData.symbol_cross !== "") symbols += (formData.symbol_cross + ", ");
  if (formData.symbol_eggs !== "") symbols += (formData.symbol_eggs + ", ");
  if (formData.symbol_lillies !== "") symbols += (formData.symbol_lillies + ", ");
  symbols = symbols.slice(0, -2);

}


Next, we declare headers as an object, and populate it with standard properties such as Authorization and Content-Type. Since this is for ChatGPT, we'll need OpenAI-Oganization also. Authorization and OpenAI-Oganization will have values that can be accessed from your .env file.

src/app/api/poemgen/route.js
export async function POST(req) {
  const formData = await req.json();

  var symbols = "";
  if (formData.symbol_bunny !== "") symbols += (formData.symbol_bunny + ", ");
  if (formData.symbol_chicks !== "") symbols += (formData.symbol_chicks + ", ");
  if (formData.symbol_cross !== "") symbols += (formData.symbol_cross + ", ");
  if (formData.symbol_eggs !== "") symbols += (formData.symbol_eggs + ", ");
  if (formData.symbol_lillies !== "") symbols += (formData.symbol_lillies + ", ");
  symbols = symbols.slice(0, -2);

  var headers = {
     "Authorization": "Bearer " + process.env.NEXT_PUBLIC_API_KEY,
     "OpenAI-Oganization": process.env.NEXT_PUBLIC_ORG,
     "Content-Type": "application/json"    
  };  

}


The values will look like this in the .env file. They won't, of course, be "xxx".

.env
NEXT_PUBLIC_API_KEY=xxx
NEXT_PUBLIC_ORG=org-xxx


Now create an empty array, messages. Then create an object, obj. It should have the property role, which has a value of "user", and the property content. Its value should be a prompt we create from symbols. After that, push obj into messages.

src/app/api/poemgen/route.js
export async function POST(req) {
  const formData = await req.json();

  var symbols = "";
  if (formData.symbol_bunny !== "") symbols += (formData.symbol_bunny + ", ");
  if (formData.symbol_chicks !== "") symbols += (formData.symbol_chicks + ", ");
  if (formData.symbol_cross !== "") symbols += (formData.symbol_cross + ", ");
  if (formData.symbol_eggs !== "") symbols += (formData.symbol_eggs + ", ");
  if (formData.symbol_lillies !== "") symbols += (formData.symbol_lillies + ", ");
  symbols = symbols.slice(0, -2);

  var headers = {
    "Authorization": "Bearer " + process.env.NEXT_PUBLIC_API_KEY,
    "OpenAI-Oganization": process.env.NEXT_PUBLIC_ORG,
    "Content-Type": "application/json"    
  };  

  var messages = [];
  var obj = {
    "role": "user",
    "content" : "Generate an Easter-themed poem with the following element(s): " + symbols + "."
  };
  messages.push(obj);

}


Create body as an object. In there, we specify the AI model and set the number of tokens to be used, to 2000. The messages property value will be the array messages.

src/app/api/poemgen/route.js
export async function POST(req) {
  const formData = await req.json();

  var symbols = "";
  if (formData.symbol_bunny !== "") symbols += (formData.symbol_bunny + ", ");
  if (formData.symbol_chicks !== "") symbols += (formData.symbol_chicks + ", ");
  if (formData.symbol_cross !== "") symbols += (formData.symbol_cross + ", ");
  if (formData.symbol_eggs !== "") symbols += (formData.symbol_eggs + ", ");
  if (formData.symbol_lillies !== "") symbols += (formData.symbol_lillies + ", ");
  symbols = symbols.slice(0, -2);

  var headers = {
    "Authorization": "Bearer " + process.env.NEXT_PUBLIC_API_KEY,
    "OpenAI-Oganization": process.env.NEXT_PUBLIC_ORG,
    "Content-Type": "application/json"    
  };  

  var messages = [];
  var obj = {
    "role": "user",
    "content" : "Generate an Easter-themed poem with the following element(s): " + symbols + "."
  };
  messages.push(obj);

  var body = {
     "model": "gpt-3.5-turbo",
     "messages" : messages,
     "max_tokens" : 2000
  };

}


Here, we have a Try-catch block. In here, we use fetch() to grab data via a REST endpoint provided by OpenAI, sending header and body as the data. The response is assigned to the variable apiResponse.

src/app/api/poemgen/route.js
export async function POST(req) {
  const formData = await req.json();

  var symbols = "";
  if (formData.symbol_bunny !== "") symbols += (formData.symbol_bunny + ", ");
  if (formData.symbol_chicks !== "") symbols += (formData.symbol_chicks + ", ");
  if (formData.symbol_cross !== "") symbols += (formData.symbol_cross + ", ");
  if (formData.symbol_eggs !== "") symbols += (formData.symbol_eggs + ", ");
  if (formData.symbol_lillies !== "") symbols += (formData.symbol_lillies + ", ");
  symbols = symbols.slice(0, -2);

  var headers = {
    "Authorization": "Bearer " + process.env.NEXT_PUBLIC_API_KEY,
    "OpenAI-Oganization": process.env.NEXT_PUBLIC_ORG,
    "Content-Type": "application/json"    
  };  

  var messages = [];
  var obj = {
    "role": "user",
    "content" : "Generate an Easter-themed poem with the following element(s): " + symbols + "."
  };
  messages.push(obj);

  var body = {
     "model": "gpt-3.5-turbo",
     "messages" : messages,
    "max_tokens" : 2000
  };

  try {
    const apiResponse = await fetch("https://api.openai.com/v1/chat/completions", {
         method: "POST",
         headers: headers,
         body: JSON.stringify(body)
     });
  } catch (error) {

  }

}


We then check the ok property of apiResponse. If this is false, we return a Response object with an appropriate status. We return the same thing if there is an exception thrown.

src/app/api/poemgen/route.js
export async function POST(req) {
  const formData = await req.json();

  var symbols = "";
  if (formData.symbol_bunny !== "") symbols += (formData.symbol_bunny + ", ");
  if (formData.symbol_chicks !== "") symbols += (formData.symbol_chicks + ", ");
  if (formData.symbol_cross !== "") symbols += (formData.symbol_cross + ", ");
  if (formData.symbol_eggs !== "") symbols += (formData.symbol_eggs + ", ");
  if (formData.symbol_lillies !== "") symbols += (formData.symbol_lillies + ", ");
  symbols = symbols.slice(0, -2);

  var headers = {
    "Authorization": "Bearer " + process.env.NEXT_PUBLIC_API_KEY,
    "OpenAI-Oganization": process.env.NEXT_PUBLIC_ORG,
    "Content-Type": "application/json"    
  };  

  var messages = [];
  var obj = {
    "role": "user",
     "content" : "Generate an Easter-themed poem with the following element(s): " + symbols + "."
  };
  messages.push(obj);

  var body = {
     "model": "gpt-3.5-turbo",
     "messages" : messages,
     "max_tokens" : 2000
  };

  try {
    const apiResponse = await fetch("https://api.openai.com/v1/chat/completions", {
         method: "POST",
         headers: headers,
         body: JSON.stringify(body)
     });

    if (!apiResponse.ok) {
      return new Response(JSON.stringify({ message: "Error in poem generation" }), {
        status: 500,
        headers: { "Content-Type": "application/json" },
       });
     }

  } catch (error) {
     return new Response(JSON.stringify({ message: error.message }), {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });

  }
}


Here, we obtain data by converting apiResponse to JSON using the asynchronous method json(). And we'll define html_content as the generated content but with HTML breaks in place of line breaks.

src/app/api/poemgen/route.js
export async function POST(req) {
  const formData = await req.json();

  var symbols = "";
  if (formData.symbol_bunny !== "") symbols += (formData.symbol_bunny + ", ");
  if (formData.symbol_chicks !== "") symbols += (formData.symbol_chicks + ", ");
  if (formData.symbol_cross !== "") symbols += (formData.symbol_cross + ", ");
  if (formData.symbol_eggs !== "") symbols += (formData.symbol_eggs + ", ");
  if (formData.symbol_lillies !== "") symbols += (formData.symbol_lillies + ", ");
  symbols = symbols.slice(0, -2);

  var headers = {
    "Authorization": "Bearer " + process.env.NEXT_PUBLIC_API_KEY,
     "OpenAI-Oganization": process.env.NEXT_PUBLIC_ORG,
     "Content-Type": "application/json"    
  };  

  var messages = [];
  var obj = {
     "role": "user",
     "content" : "Generate an Easter-themed poem with the following element(s): " + symbols + "."
  };
  messages.push(obj);

  var body = {
    "model": "gpt-3.5-turbo",
     "messages" : messages,
     "max_tokens" : 2000
  };

  try {
      const apiResponse = await fetch("https://api.openai.com/v1/chat/completions", {
           method: "POST",
           headers: headers,
           body: JSON.stringify(body)
       });

       if (!apiResponse.ok) {
        return new Response(JSON.stringify({ message: "Error in poem generation" }), {
          status: 500,
          headers: { "Content-Type": "application/json" },
      });
    }

    const data = await apiResponse.json();
    var html_content =  data.choices[0].message.content.replaceAll("\n", "<br />");

  } catch (error) {
    return new Response(JSON.stringify({ message: error.message }), {
        status: 500,
        headers: { "Content-Type": "application/json" },
    });
  }
}


And once that is done, we will return a new Response with the status 200 and html_content as the message.

src/app/api/poemgen/route.js
export async function POST(req) {
  const formData = await req.json();

  var symbols = "";
  if (formData.symbol_bunny !== "") symbols += (formData.symbol_bunny + ", ");
  if (formData.symbol_chicks !== "") symbols += (formData.symbol_chicks + ", ");
  if (formData.symbol_cross !== "") symbols += (formData.symbol_cross + ", ");
  if (formData.symbol_eggs !== "") symbols += (formData.symbol_eggs + ", ");
  if (formData.symbol_lillies !== "") symbols += (formData.symbol_lillies + ", ");
  symbols = symbols.slice(0, -2);

  var headers = {
     "Authorization": "Bearer " + process.env.NEXT_PUBLIC_API_KEY,
     "OpenAI-Oganization": process.env.NEXT_PUBLIC_ORG,
     "Content-Type": "application/json"    
  };  

  var messages = [];
  var obj = {
     "role": "user",
     "content" : "Generate an Easter-themed poem with the following element(s): " + symbols + "."
  };
  messages.push(obj);

  var body = {
     "model": "gpt-3.5-turbo",
    "messages" : messages,
    "max_tokens" : 2000
  };

  try {
    const apiResponse = await fetch("https://api.openai.com/v1/chat/completions", {
        method: "POST",
        headers: headers,
        body: JSON.stringify(body)
    });

    if (!apiResponse.ok) {
       return new Response(JSON.stringify({ message: "Error in poem generation" }), {
          status: 500,
          headers: { "Content-Type": "application/json" },
      });
    }

    const data = await apiResponse.json();
    var html_content =  data.choices[0].message.content.replaceAll("\n", "<br />");

    return new Response(JSON.stringify({ message: html_content }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });

  } catch (error) {
     return new Response(JSON.stringify({ message: error.message }), {
       status: 500,
       headers: { "Content-Type": "application/json" },
     });
  }
}


Now try this! First, try it with one item checked.


Then try it with multiple items checked, and see the difference!


Enjoy your Easter!

This has been my virgin NextJS tutorial. We covered a fair amount of territory, and hopefully had fun in the process!

May your Easter eggs all be found,
His Teochewness will see you around!
T___T

Wednesday, 16 April 2025

Web Tutorial: The Easter Poem Generator (Part 1/2)

As of early 2025, the create-react-app method of ReactJS development was officially deprecated. As such, an alternative way forward was to use the ReactJS framework, NextJS. For today's Easter-themed web tutorial, I will be walking through some basic features of NextJS.

Do note that not a lot of things will be new here; the bulk of it is still doable in a standard ReactJS setup, or even NodeJS.

To begin, I will assume that NextJS has been installed on your machine. We'll create the app with this command. "easter-poem-generator" is the name of the app, and consequently, the folder that it will be created in.
npx create-next-app@latest easter-poem-generator


The script will ask some questions. These are the options I have chosen.
Would you like to use TypeScript?  No
Would you like to use ESLint?  No
Would you like to use Tailwind CSS?  No
Would you like your code inside a `src/` directory?  Yes
Would you like to use App Router? (recommended)  Yes
Would you like to use Turbopack for `next dev`?  No
Would you like to customize the import alias (`@/*` by default)?  No

Once done, navigate to the src folder. We'll make some changes to the layout.

src/app/layout.js
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata = {
  title: "Easter Poem Generator",
  description: "Generated by create next app",
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={`${geistSans.variable} ${geistMono.variable}`}>
        {children}
      </body>
    </html>
  );
}


Next, in the public directory, add these images.

public/symbol_bunny.jpg

public/symbol_chicks.jpg

public/symbol_cross.jpg

public/symbol_eggs.jpg

public/symbol_lillies.jpg

Time to create the form. We start with "use client" to state that this file is only for front-end JavaScript. Since JavaScript is being used for both front and back ends of the application, it's important to specify because this determines what features of JavaScript you'll get to use in the file.

src/app/page.js
"use client"


Then, because we have links in this page, we'll import the Link component. We will also import styles that have already been compiled. Finally, we will import useState because we will be using state variables.

src/app/page.js
"use client"

import Link from "next/link";
import styles from "./page.module.css";

import { useState } from "react";


Next, we create a main function for this page. We will create the return statement in advance.

src/app/page.js
"use client"

import Link from "next/link";
import styles from "./page.module.css";

import { useState } from "react";

export default function Home() {
  return (

  );
}


Now, if you know ReactJS at all, this should be familiar ground. This is why we imported useState earlier, so we can set state variables. The first thing we want is form data. Thus, formData will be an object containing these properties. As  default, only symbol_bunny is not an empty string.

src/app/page.js
export default function Home() {
  const [formData, setFormData] = useState({
    symbol_bunny: "bunny",
    symbol_chicks: "",
    symbol_cross: "",
    symbol_eggs: "",
    symbol_lillies: ""
  });


  return (

  );
}


And then we have generation, which is an object containing the property poem.

src/app/page.js
export default function Home() {
  const [formData, setFormData] = useState({
    symbol_bunny: "bunny",
    symbol_chicks: "",
    symbol_cross: "",
    symbol_eggs: "",
    symbol_lillies: ""
  });

  const [generation, setGeneration] = useState({
    poem: ""
  });


  return (

  );
}


Next, we have two functions. handleChecking() is something standar that happens instantaneously, to ensure that the input is correct. handleSubmit() is an asynchronous function because we are going to have to call API endpoints and wait for responses in this function.

src/app/page.js
const [formData, setFormData] = useState({
  symbol_bunny: "bunny",
  symbol_chicks: "",
  symbol_cross: "",
  symbol_eggs: "",
  symbol_lillies: ""
});

const [generation, setGeneration] = useState({
  poem: ""
});

const handleChecking = (e) => {

};

const handleSubmit = async (e) => {

};


return (

);


In the return statement there will be JSX. In here, we have a div styled using the CSS class generationContainer, and a form. The form will run handleSubmit() when data is submitted.

src/app/page.js
return (
  <div className={styles.generationContainer} >
    <form onSubmit={handleSubmit}>
      <h1>Generate an Easter Poem!</h1>
    </form>
  </div>

);


Clear out this file. We will add in the generationContainer CSS class. It's basically a centered div with a fixed width. Then we'll style the form. It has rounded corners and a solid light red border.

src/app/page.module.css
.generationContainer {
  width: 500px;
  margin: 0 auto 0 auto;
}

.generationContainer form {
  width: 450px;
  padding: 10px;
  margin: 0 auto 0 auto;
  border-radius: 10px;
  border: 1px solid rgb(255, 100, 100);
}


Not much to look at right now, but this will change soon.


We then have a line of instructions, and we will display a series of checkboxes based on the formData object. For this, we use the map() method on formData. Since formData is an object, we need to make it iterable by using the entries() method of Object, on it. entries() will turn formData into an array of objects containing properties and values.

src/app/page.js
return (
  <div className={styles.generationContainer} >
    <form onSubmit={handleSubmit}>
      <h1>Generate an Easter Poem!</h1>
      <b>Include at least one of these elements</b>
      {
        Object.entries(formData).map(([key, value]) => (

      ))}    

    </form>
  </div>
);


In this, we have a label tag styled using CSS class label. Because the ReactJS engine will complain if we don't, use key to populate the value of the key attribute.

src/app/page.js
return (
  <div className={styles.generationContainer} >
    <form onSubmit={handleSubmit}>
      <h1>Generate an Easter Poem!</h1>
      <b>Include at least one of these elements</b>
      {
        Object.entries(formData).map(([key, value]) => (
        <label className={styles.label} key={key}>

        </label>

      ))}    
    </form>
  </div>
);


We will then have a checkbox within that label tag. The name attribute will be key. handleChecking() will be run for the onchange() attribute. The checked value will depend if value is an empty string.

src/app/page.js
return (
  <div className={styles.generationContainer} >
    <form onSubmit={handleSubmit}>
      <h1>Generate an Easter Poem!</h1>
      <b>Include at least one of these elements</b>
      {
        Object.entries(formData).map(([key, value]) => (
        <label className={styles.label} key={key}>
          <input type="checkbox" name={key} value={key.replace("symbol_", "")} onChange={handleChecking} checked={ (value !== "") } />
          <br />
        </label>
      ))}    
    </form>
  </div>
);


Then add the text label key, again less the string "symbol_".

src/app/page.js
return (
  <div className={styles.generationContainer} >
    <form onSubmit={handleSubmit}>
      <h1>Generate an Easter Poem!</h1>
      <b>Include at least one of these elements</b>
      {
        Object.entries(formData).map(([key, value]) => (
        <label className={styles.label} key={key}>
          <input type="checkbox" name={key} value={key.replace("symbol_", "")} onChange={handleChecking} checked={ (formData[key] === key.replace("symbol_", "")) } />
           {key.replace("symbol_", "")}
          <br />
        </label>
      ))}    
    </form>
  </div>
);


Back to styles, label has a width that will take up less than half of its container, and be floated left.

src/app/page.module.css
.generationContainer form {
  width: 450px;
  padding: 10px;
  margin: 0 auto 0 auto;
  border-radius: 10px;
  border: 1px solid rgb(255, 100, 100);
}

.label {
  display: inline-block;
  width: 45%;
  float: left;
}


This is nothing that a CSS break won't fix.


Add this CSS break after adding a SUBMIT button.

src/app/page.js
return (
  <div className={styles.generationContainer} >
    <form onSubmit={handleSubmit}>
      <h1>Generate an Easter Poem!</h1>
      <b>Include at least one of these elements</b>
      {
        Object.entries(formData).map(([key, value]) => (
        <label className={styles.label} key={key}>
          <input type="checkbox" name={key} value={key.replace("symbol_", "")} onChange={handleChecking} checked={ (formData[key] === key.replace("symbol_", "")) } />
           {key.replace("symbol_", "")}
          <br />
        </label>
      ))}    
      <button type="submit" className={styles.button}>Go!</button>
      <br style={{ clear:"both" }} />

    </form>
  </div>
);


Just some styling for the button.

src/app/page.module.css
.generationContainer form {
  width: 450px;
  padding: 10px;
  margin: 0 auto 0 auto;
  border-radius: 10px;
  border: 1px solid rgb(255, 100, 100);
}

.label {
  display: inline-block;
  width: 45%;
  float: left;
}

.button {
  display: inline-block;
  width: 5em;
  height: 2em;
  float: right;
}


All falling into place nicely.


OK, we'll now add a Link component. Each link's route will be symbol followed by the identifier such as "bunny" or "eggs", thus I cleverly just took key and replaced the underscore with a forward slash.

src/app/page.js
return (
  <div className={styles.generationContainer} >
    <form onSubmit={handleSubmit}>
      <h1>Generate an Easter Poem!</h1>
      <b>Include at least one of these elements</b>
      {
        Object.entries(formData).map(([key, value]) => (
        <label className={styles.label} key={key}>
          <input type="checkbox" name={key} value={key.replace("symbol_", "")} onChange={handleChecking} checked={ (formData[key] === key.replace("symbol_", "")) } />
           {key.replace("symbol_", "")}
           <Link href={ ("/" +key.replace("_", "/")) } target="_blank">&#9658;</Link>
          <br />
        </label>
      ))}    
      <button type="submit" className={styles.button}>Go!</button>
      <br style={{ clear:"both" }} />
    </form>
  </div>
);


In order for the link to work, the route needs to work. So create the symbol directory in app. And in that directory, create the [symbolName] directory, square brackets and all. This tells NextJS that symbolName is a parameter value rather than an actual fixed value. And in that directory, create page.js. We'll want to import Image because we'll be working with images. As in the previous page we were working on, we want to import the CSS.

src/app/symbol/[symbolName]/page.js
import Image from "next/image";
import styles from "../../page.module.css";


We'll want to export this function. As always, we add a return statement inside the function.

src/app/symbol/[symbolName]/page.js
import Image from "next/image";
import styles from "../../page.module.css";

export default function SymbolPage({ params }) {
  return (

  );
}


params will contain symbolName, which will typically be "bunny", "lillies", etc. Thankfully, we've already saved those in the public directory, so they're accessible with just a slash. They can thus be used as the value of the src attribute for the Image component. Here, I have included the width and height of the image. The Image component will be placed inside a div styled using the symbolImage CSS class.

src/app/symbol/[symbolName]/page.js
export default function SymbolPage({ params }) {
  return (
    <div className={ styles.symbolImage } >
        <Image src={ "/symbol_" + params.symbolName + ".jpg" } alt={ params.symbolName } width={ 300 } height={ 300 } />
    </div>

  );
}


I am using symbolImage mostly for positioning. I have also set it so that any img tag inside symbolImage will have rounded corners.

src/app/page.module.css
.symbolImage {
  width: 100%;
  min-height: auto;
  margin: 10px auto 10px auto;
  text-align: center;
}

.symbolImage img{
  border-radius: 10px;
}


.generationContainer {
  width: 500px;
  margin: 0 auto 0 auto;
}


You can see the arrows. Now click on maybe the one beside "bunny".


You see a picture, in a new tab, with rounded corners!


We now add an object, texts. It has properties that mirror the possible values of symbolName.

src/app/symbol/[symbolName]/page.js
import Image from "next/image";
import styles from "../../page.module.css";

export default function SymbolPage({ params }) {
  const texts = {
   "bunny": ,
   "chicks": ,
   "cross": ,
   "eggs": ,
   "lillies":
  };


  return (
    <div className={ styles.symbolImage } >
        <Image src={ "/symbol_" + params.symbolName + ".jpg" } alt={ params.symbolName } width={ 300 } height={ 300 } />
    </div>
  );
}


Here, I'm going to add text strings as values for these properties. These were generated using Copilot.

src/app/symbol/[symbolName]/page.js
import Image from "next/image";
import styles from "../../page.module.css";

export default function SymbolPage({ params }) {
  const texts = {
   "bunny": "The bunny, or Easter rabbit, is a symbol of fertility and new life, which aligns with the themes of rebirth and renewal celebrated during Easter. Originating from ancient pagan traditions, the rabbit was associated with the goddess Ēostre, who represented spring and fertility. As Christianity spread, the symbolism of the rabbit was incorporated into Easter celebrations. The Easter bunny is often depicted delivering eggs, which are also symbols of new life and rebirth, to children, adding a playful and joyful element to the holiday.",
   "chicks": "Chicks are another symbol of new life and rebirth, aligning with the overall themes of Easter. Just as chicks hatch from eggs, they represent the idea of life emerging from the seemingly lifeless, echoing the resurrection of Jesus. They also symbolize innocence and the start of new beginnings, adding to the joyful and hopeful atmosphere of Easter celebrations. The imagery of chicks, along with eggs and bunnies, helps to convey the sense of renewal and the promise of new life that Easter embodies.",
   "cross": "The crucifix is a powerful symbol in Christianity, especially during Easter. It represents the crucifixion of Jesus Christ, an event central to Christian belief. It signifies the ultimate sacrifice Jesus made by dying on the cross to atone for humanity's sins. It also symbolizes the immense suffering Jesus endured during the crucifixion. The crucifix serves as a reminder of the redemption and salvation offered through Jesus' sacrifice. Easter celebrates Jesus' resurrection, signifying victory over death and the promise of eternal life for believers. This powerful imagery serves as a reminder of Jesus' love and the hope that comes with his resurrection, making the crucifix an integral part of Easter celebrations.",
   "eggs": "Eggs are a rich and multifaceted symbol in Easter traditions. They embody the themes of new life, rebirth, and resurrection, which are central to the celebration of Easter. They represent the emergence of new life from within, mirroring the resurrection of Jesus from the tomb. Just as a chick hatches from an egg, the concept of rebirth is symbolized, signifying the idea of starting anew. Historically, eggs have been seen as symbols of fertility and renewal, aligning with the arrival of spring. The egg, appearing lifeless on the outside but containing life within, represents transformation and the miracle of life. Eggs are often decorated and used in Easter egg hunts, making them a playful and engaging way to celebrate the holiday while reflecting on its deeper meanings.",
   "lillies": "Lilies, particularly white lilies, hold significant symbolism in Easter celebrations. They represent purity, virtue, and the resurrection of Jesus Christ. The trumpet shape of the lily flower is said to symbolize the trumpet call of God, heralding the resurrection. Lilies are often used in Easter decorations and church services to convey a sense of hope, renewal, and new beginnings. Their association with new life and purity makes them a fitting symbol for the themes of Easter."
  };

  return (
    <div className={ styles.symbolImage } >
        <Image src={ "/symbol_" + params.symbolName + ".jpg" } alt={ params.symbolName } width={ 300 } height={ 300 } />
    </div>
  );
}


Then we add a paragraph styled using CSS class symbolText. The HTML in here will be the current property of texts being pointed to by symbolName.

src/app/symbol/[symbolName]/page.js
return (
  <div className={ styles.symbolImage } >
      <Image src={ "/symbol_" + params.symbolName + ".jpg" } alt={ params.symbolName } width={ 300 } height={ 300 } />
      <p className={styles.symbolText} >{ texts[params.symbolName] }</p>
  </div>
);


The symbolText CSS class just imposes some padding.

src/app/page.module.css
.symbolImage {
  width: 100%;
  min-height: auto;
  margin: 10px auto 10px auto;
  text-align: center;
}

.symbolImage img{
  border-radius: 10px;
}

.symbolText {
  padding: 10px;
}


.generationContainer {
  width: 500px;
  margin: 0 auto 0 auto;
}


You see the paragraph appears!


We're done for the time being. So far, we've covered a bit of NextJS routing and built-in components.

Next

Form validation, submitting and API usage.