How to connect a Node.js web app to an SQLite database

Updated: 04/30/2020 by Computer Hope
Node.js and SQLite

SQLite, pronounced ess-kue-ELL-ite, is a lightweight, serverless database system that stores data in a single file. It provides the basic functions of a full-featured SQL RDBMS.

This example implements a web application in Node.js using the sqlite3 module to read and write data in an SQLite database file. It uses the layout and design of the Node.js and Express holy grail example, adding an administrator Control Panel where content can be created, read, updated, or deleted (CRUD operations).

Note

This example demonstrates database integration with Node.js, but it does not protect access to the administrator section with a password. To be used in production, the application would require authentication middleware, such as Passport, to provide session management.

Design

The home page of the website displays four content sections in the main body: Announcements, Items for sale, Upcoming events, and Message of the day.

Layout diagram

Goals

By adding connectivity to a database, this application seeks to accomplish two related goals.

Goal 1: When the user requests the home page route / from the website, the app should read the current data for these sections from a database. The current data is incorporated into the view template and displayed to the user.

Goal 2: The application should provide a browser interface where database entries can be viewed, created, updated, or deleted. The "Control Panel" interface is accessible at route /admin, and is intended for administrator use only.

To accomplish these goals, the structure of the data must be defined.

Database schema

A database schema defines how the information is structured.

This database must store four tables of data, one for each section. These tables are similar to spreadsheets, organized in rows and columns.

In a table, a row comprises one set of related data: one announcement, one item for sale, etc.

The columns of the tables are structured as follows.

Table name Column name Data type Description
items id INTEGER A positive whole number uniquely identifying this row in the table: 1, 2, etc.
name TEXT A string naming the item for sale, e.g., Eggs.
unit TEXT A string describing the units of how it's priced, e.g., lb. or dozen.
price INTEGER The price per unit, expressed as a whole number of cents, e.g., 500 for five dollars per unit.
qty INTEGER A whole number of units in stock (quantity).
desc TEXT A description, e.g., From our organic free-range chickens.
announce id INTEGER Unique row id.
title TEXT A title string, such as Open for business.
date TEXT A date string, such as January 15, or Next Wednesday at noon.
body TEXT The body of the announcement.
events id INTEGER Unique row id.
title TEXT Title of the event.
date TEXT Date of the event.
body TEXT Description of the event.
motd id INTEGER Unique row id.
title TEXT Title of the message.
body TEXT Body of the message.

The next step is to design how this data is used in the application.

MVC logic

In an MVC (model-view-controller) design, the application logic is divided into three categories.

  • The data model is the central component of the app. It defines programmatically how data is structured, accessed, and managed. The data model transacts directly with the database.
  • The view is the outermost app component. It defines how data is viewed by the user in the browser, including the UI layout, and interactive components, like buttons, text fields, and menus. The view logic boils down to the HTML, CSS, and client-side JavaScript sent to the user's browser.
  • The controller bridges the gap between the model and the view. It defines application behavior, such as what happens when the user requests an application route. Business logic (how data is used and transformed internally by the application) is part of the controller.

In this application, the data model logic is contained in a single file (/models/data-model.js). Only the functions listed in this file have direct access to the database object.

The view logic is contained in the directories /views/, /public/stylesheets/, and /public/javascripts/.

The controller logic is contained in /app.js and /routes/*.js. These scripts handle HTTP requests, errors, and transactions with the data model.

The entry point of the application is the controller. The entry point of the user experience is the view.

The data model is accessed by the controller, to satisfy requests made from the view.

Basic setup

The set up for this application is the same as the Node.js and Express example.

  1. Make sure Node.js LTS is installed, and npm is up-to-date.

Windows

npm i npm -g

Linux, macOS

sudo npm i npm -g
  1. Use npm to globally install express-generator.

Windows

npm i express-generator -g

Linux, macOS

sudo npm i express-generator -g
  1. Generate a new Express app, and change to the directory.
express myapp --view="pug"
cd myapp
  1. Install the Express app dependencies (npm reads them from the package.json file generated for the Express app).
npm i
  1. If security patches are available, apply them.
npm audit fix
  1. Install nodemon as a development dependency.
npm i nodemon --save-dev
  1. With a text editor, add a development startup script to the app's package.json file.

package.json (Windows)

  "scripts": {
    "start": "node ./bin/www",
    "devstart": "SET DEBUG=myapp:* & nodemon ./bin/www"
  },

package.json (Linux, macOS)

  "scripts": {
    "start": "node ./bin/www",
    "devstart": "DEBUG=myapp:* nodemon ./bin/www"
  },

Don't forget to add a comma after the "start" definition, as shown above.

Install SQLite

Install the SQLite 3 database software.

Windows

Download the SQLite software and tools, sqlite-tools-win32-x86-version.zip, from the SQLite official downloads site under Precompiled Binaries for Windows.

The archive contains three files.

sqldiff.exe
sqlite3.exe
sqlite3_analyzer.exe

Create a directory, such as "C:\Program Files\SQLite," and extract the three files to the directory.

Add the full path of that directory to your system's %PATH% environment variable. For more information, see how to set environment variables in Windows.

Verify that you can run the sqlite3 command in any directory. Open a new command prompt window, and run the following command.

sqlite3 --version
3.31.1 2020-01-27 19:55:54 3bfa9cc97da5..

macOS

The macOS binaries can be downloaded from the SQLite website.

Alternatively, the sqlite3 package can be installed with MacPorts.

sudo port install sqlite3

Or, the sqlite package can be installed with Homebrew.

brew install sqlite

Linux

On Linux, SQLite can be installed using the system's package manager.

For example, on Debian and Ubuntu, the sqlite3 package can be installed with apt.

sudo apt install sqlite3

Precompiled binaries and source are also available at the SQLite website.

Install the sqlite3 Node module

The sqlite3 Node.js module enables communication between a Node app and an SQLite database.

In the Express app directory, use npm to install the module and save it as a dependency.

npm i sqlite3 --save

If security updates are available, install them.

npm audit fix

Initialize the database

The database init script contains several SQL commands that initialize the database. When executed, the script creates each of the four tables, and populates them with preset values.

Overview of init script

The script is a text file with extension .sql. The SQLite commands in the init script perform the following tasks.

  1. First, CREATE a named TABLE, and set parameters for each named column. Parameters include data type, DEFAULT value to be set when a row is created, and any special attributes.
  1. Then, INSERT INTO the table one or more rows, populated with preset initial VALUES.
Note

In all four tables, the "id" column has the special attribute PRIMARY KEY. Entries in the primary key column must be unique. The primary key value is set automatically by SQLite when the row is created. By default, the first key assigned is 1, then 2, etc.

Create the init script

In the app directory, create a new directory called database, and change to it.

mkdir database
cd database

In the database directory, make a directory init.

mkdir init

Create a new text file in the init directory, db.sql, containing the following SQLite commands.

/database/init/db.sql

/* 
 *  Create & init site content
 */

-- ITEMS --

CREATE TABLE IF NOT EXISTS items (
  id INTEGER PRIMARY KEY,
  name TEXT DEFAULT "",
  unit TEXT DEFAULT "",
  price INTEGER DEFAULT 0,
  qty INTEGER DEFAULT 0,
  desc TEXT DEFAULT ""
);

INSERT INTO items
  (name, unit, price, qty, desc) 
VALUES 
  ("Biscotti", "bag of 10", 500, 5, "Great dunked in coffee."),
  ("Milk", "quart", 500, 3, "Good source of calcium."),
  ("Eggs", "dozen", 400, 7, "Great for breakfast and baking."),
  ("Whole chicken", "lb", 750, 2, "Perfect for roasting."),
  ("Honey", "pint", 1350, 4, "Sweetens your tea.");

-- ANNOUNCEMENTS --

CREATE TABLE IF NOT EXISTS announce (
  id INTEGER PRIMARY KEY,
 title TEXT DEFAULT "",
  date TEXT DEFAULT "",
  body TEXT DEFAULT ""
);

INSERT INTO announce 
  (title, date, body)
VALUES (
  "Open for business", "Jan. 15", "Renovations of our new storefront are complete, and we're open for business."
);

-- EVENTS --

CREATE TABLE IF NOT EXISTS events (
  id INTEGER PRIMARY KEY,
 title TEXT DEFAULT "",
  date TEXT DEFAULT "",
  body TEXT DEFAULT ""
);

INSERT INTO events (
 title, date, body
) VALUES (
  "Cider Fest", 
  "October 20, 2pm-6pm", 
  "Celebrate the season with fresh-pressed cider from our orchards."
), (
  "Bread baking workshop", 
  "December 13, 9am-noon", 
  "Learn how to create and cultivate a sourdough starter."
);

-- MOTD --

CREATE TABLE IF NOT EXISTS motd (
  id INTEGER PRIMARY KEY,
 title TEXT DEFAULT "",
  body TEXT DEFAULT ""
);

INSERT INTO motd (
 title, body
) VALUES (
  "Message of the day", "Eat better food. Support your local farm stand."
);

Run the init script

The SQLite script cannot run directly from the system command line. It must run from the SQLite command-line client.

Note

These commands are not intended to be executed on an existing database file. If you have an existing database, and you want to reset it to its initial state, delete (or move) the existing database file before running the script again.

In the database directory, run sqlite3 to start the interactive client, opening the file db.sqlite as the (new) database.

sqlite3 db.sqlite

In the SQLite client, run the init script using the .read command.

.read init/db.sql

The script is executed immediately. If the commands are successful, data is written to file db.sqlite in the database directory, and no message is displayed.

To exit the SQLite client, run the command .quit.

.quit
Tip

To learn more about SQLite and the sqlite3 module, continue reading the next two sections. To proceed directly to the code of the app, see: Implementation: code and structure.

Overview of SQLite

SQLite is similar to other SQL databases, with its own strengths and variations of syntax. The following is an overview of some essential commands and example queries.

Tip

SQLite commands beginning with a period (e.g., .read) perform administrative functions. These commands must run interactively in the client, and cannot be scripted.

SQL query commands (e.g., SELECT * FROM table;) must end in a semicolon when run in the client or a script. Line breaks are permitted as white space in a SQL query command.

Command names are not case-sensitive, but by convention, they are written in all-capital letters (e.g., SELECT rather than select) to help distinguish them from parameters and values.

Listing tables

In the database directory you created (myapp/database), start the SQLite client, opening the db.sqlite database for transactions.

sqlite3 db.sqlite

At the sqlite> prompt, run the command .tables to list the tables created by the init script.

.tables
announce  events    items     motd

Reading data from a table

The query SELECT column FROM table fetches all values in that column of that table.

SELECT unit FROM items;
bag of 10
quart
dozen
lb
pint

The clause WHERE constraint can be used in the SELECT command to filter the results.

SELECT desc FROM items WHERE name = 'Honey';
Sweetens your tea.

The SELECT command accepts the wildcard * for the column name, meaning "fetch the entire row." Values in the same row are delimited by a vertical bar.

SELECT * FROM items;
1|Biscotti|bag of 10|500|5|Great dunked in coffee.
2|Milk|quart|500|3|Good source of calcium.
3|Eggs|dozen|400|7|Great for breakfast and baking.
4|Whole chicken|lb|750|2|Perfect for roasting.
5|Honey|pint|1350|4|Sweetens your tea.

Modifying values

Values can be modified using UPDATE table SET column WHERE constraint.

SELECT qty FROM items WHERE name = 'Eggs';
7
UPDATE items SET qty = 6 WHERE name = 'Eggs';
SELECT qty FROM items WHERE name = 'Eggs';
6

Multiple columns can be specified in any order, delimited by a comma. If a WHERE clause is omitted in an UPDATE query, all rows are affected.

UPDATE items SET qty = 1, price = 888;  /* update two columns in all rows */
SELECT * FROM items;
1|Biscotti|bag of 10|888|1|Great dunked in coffee.
2|Milk|quart|888|1|Good source of calcium.
3|Eggs|dozen|888|1|Great for breakfast and baking.
4|Whole chicken|lb|888|1|Perfect for roasting.
5|Honey|pint|888|1|Sweetens your tea.

Quoting

By convention, double-quoted text in a SQL query denotes an identifier, such as "items" or "qty". Single-quoted text denotes a string literal, such as 'bag of 10' or 'Good source of calcium.'

In general, using a single or double quotes is accepted, as long as the quotes match. For example, if it's more convenient to double-quote a string because it contains single quotes as part of its text, that's okay.

String literals must always be quoted. Quoting identifiers and numbers is optional.

UPDATE items SET desc = 'foo', qty = 2 WHERE name = 'Milk';     /* valid */
UPDATE items SET "desc" = "foo", qty = '2' WHERE name = "Milk"; /* valid */
UPDATE 'items' SET desc = 'foo', qty = 2 WHERE name = "Milk";   /* valid */

Creating a table

A new table can be created using CREATE TABLE table (column type attributes..., ...).

CREATE TABLE foobar (id INTEGER PRIMARY KEY, 
  foo TEXT NOT NULL DEFAULT 'bar');

If you try to create a table that already exists, an error is reported, and no change is made.

Error: table foobar already exists

The CREATE TABLE IF NOT EXISTS command behaves similar to CREATE TABLE, except if the table already exists, the command fails silently (no error is reported).

CREATE TABLE IF NOT EXISTS foobar (id INTEGER PRIMARY KEY, bar BLOB);

The column attribute DEFAULT literal sets a default value for the column. When a new row is created, specifying DEFAULT VALUES inserts this value into the new row. If no default value is specified, SQLite uses the NULL value instead.

CREATE TABLE IF NOT EXISTS foobar (
  id INTEGER PRIMARY KEY,
  bar TEXT DEFAULT 'not foo',
  baz TEXT DEFAULT 'not bar'
);

Creating rows

A new row can be created with INSERT INTO table (column, ...) VALUES (value, ...).

INSERT INTO items (name, price, unit, qty, desc) 
  VALUES ('Garlic', 150, 'head', 24, 'Vampire repellant.');

Multiple rows can be created by specifying multiple value groups.

INSERT INTO items (name, price) VALUES 
  ('Parsley', 100), 
  ('Spinach', 350);

Deleting rows

Rows can be deleted with DELETE FROM table WHERE constraint.

DELETE FROM items WHERE name='Garlic';

If the WHERE clause is omitted, all rows are deleted.

DELETE FROM foobar;

Listing columns of a table

To list metadata of a table's columns, including identifier name, data type, default value and attributes, use the command PRAGMA table_info(table).

PRAGMA table_info(foobar);
0|id|INTEGER|0||1
1|foo|TEXT|1|'bar'|0

Deleting a table

To delete a table, use the DROP TABLE command.

DROP TABLE foobar;

Exiting the SQLite client

To exit the SQLite client, use the command .quit.

.quit
Tip

For a detailed description of SQLite commands and queries, see the official SQLite SQL reference.

Overview of sqlite3 Node module

The sqlite3 module provides functions for a Node app to communicate with an SQLite database.

The Database object

In this app, the sqlite3 module is required by /database/db.js, specifying that verbose errors are shown.

var sqlite3 = require('sqlite3').verbose();

Then a new Database object is created using the database initialized above, /database/db.sqlite.

var db = new sqlite3.Database('./database/db.sqlite');

This object is exported (available to other scripts that require this one).

module.exports = db;

Using the Database object

The only script that requires the database object is the data model, /models/data-model.js.

var db = require('../database/db');

Methods of this object can run with db.function().

Example use in a function

For example, the Database.run() function executes an SQL command on the database, and returns no rows.

Tip

In general, the difference between an SQL query and SQL command is that a query returns database rows, but a command does not require a database response.

The syntax of the run() function is:

Database.run(querystring, [parameters...], [callback])

In this app, the run() command is used the createRow() and deleteRow() data model functions, which act as wrapper functions for a single db.run() call.

function createRow (table, cb) {
  let sql = `INSERT INTO ${table} DEFAULT VALUES`;
  db.run(sql, cb);
};

The createRow() function takes two parameters: the name of a table in the database, and a callback function.

The query string sql is constructed as a JavaScript template string, which uses backticks (`...`) instead of single or double quotes. Inside the template string, instances of ${...} are evaluated, and their value inserted at that point in the string.

The callback function cb is passed to db.run as the last parameter. When the query in querystring is complete, db executes the callback cb.

If another script requires data-model.js as dataModel, it can create a row in the database with dataModel.createRow("tablename", callback). The callback function can be defined inline, as a parameter in the calling function.

For example, the callback function in this call to createRow() is defined inline as an anonymous function.

dataModel.createRow("items", function() { 
  console.log("All done.")
});

Callbacks

Like most Node modules, sqlite3 is designed for asynchronous operation, using callback functions to prevent database I/O from blocking the main program flow.

By default, the sqlite3 Node module executes database transactions in parallel. When a function like db.run() is called, it returns immediately, before any transactions have occurred, and program flow continues. The controller can continue doing other things, assured that as soon as possible, the database action occurs.

Any tasks that depend on the database action's completion are structured as a "callback" function. The Database object "calls back" to this function when the database transactions are complete.

For example, when a user requests the app home page, the router (in index.js) requests content from the database, providing a callback function that renders the view.

Tip

For detailed information about the sqlite3 API, see the official sqlite3 Wiki.

Implementation: code and structure

The application file structure and code is listed below.

  • Files and directories listed in black are part of the default Express app, and have not been modified.
  • Files listed in blue are part of the default Express app. Their contents should be replaced with the code below.
  • Files listed in green must be created. The "models" directory must also be created.
  • Files listed in red are part of the default Express app, but not used in this project, and can safely be deleted.

Application file structure

myapp/
├─ app.js                App core logic.
├─ bin/                  (Contains the app's executable scripts)
│  └─ www                A wrapper that runs app.js.
├─ database/             (Contains database file, init script, and object JS)
│  ├─ db.js              Opens the database as an sqlite3 database object.
│  ├─ db.sqlite          The database.
│  └─ init/              (Contains database init scripts)
│     └─ db.sql          Database init script.
├─ models/               (Contains model logic)
│  └─ data-model.js      The data model, including CRUD functions.
├─ node_modules/         (Contains dependencies installed by npm)
├─ package-lock.json     JSON manifest of installed dependencies.
├─ package.json          JSON of saved dependencies, startup scripts, etc.
├─ public/               (Files downloaded by the user's web browser)
│  ├─ images/            (Contains client-accessible image files)
│  ├─ javascripts/       (Contains client-accessible JavaScript files)
│  │  ├─ datestring.js   Converts UNIX times to date strings
│  │  └─ menu.js         Implements menu toggle
│  └─ stylesheets/       (Contains client-accessible CSS)
│     └─ style.css       CSS stylesheet
├─ routes/               (Contains logic for individual site routes)
│  ├─ about.js           Logic for route /about
│  ├─ admin.js           Logic for route /admin
│  ├─ advice.js          Logic for route /advice
│  ├─ contact.js         Logic for route /contact
│  ├─ index.js           Logic for route /
│  ├─ recipes.js         Logic for route /recipes
│  ├─ tips.js            Logic for route /tips
│  └─ users.js           Not used, can be deleted
└─ views/                (Contains view logic)
   ├─ about.pug          View for route /about
   ├─ admin-announce.pug View for route /admin/announce
   ├─ admin-events.pug   View for route /admin/events
   ├─ admin-items.pug    View for route /admin/items
   ├─ admin-layout.pug   Parent template for admin* views
   ├─ admin-motd.pug     View for route /admin/motd
   ├─ admin.pug          View for route /admin
   ├─ advice.pug         View for route /advice
   ├─ contact.pug        View for route /contact
   ├─ error.pug          View for error pages, e.g., HTTP 404
   ├─ index.pug          View for route /
   ├─ layout.pug         View template for all non-admin pages
   ├─ recipes.pug        View for route /recipes
   └─ tips.pug           View for route /tips

blue = modified, green = new file, red = not used

/app.js

/*              app.js -- application core logic and config               */

var createError = require('http-errors');
var express = require('express');
var path = require('path');
var logger = require('morgan');

// the app object
var app = express();

// router objects
var indexRouter = require('./routes/index');
var aboutRouter = require('./routes/about');
var contactRouter = require('./routes/contact');
var tipsRouter = require('./routes/tips');
var recipesRouter = require('./routes/recipes');
var adviceRouter = require('./routes/advice');
var adminRouter = require('./routes/admin');

// view engine config 
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');

// app config
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, 'public')));

// configure app to use these routes
app.use('/', indexRouter);
app.use('/about', aboutRouter);
app.use('/contact', contactRouter);
app.use('/tips', tipsRouter);
app.use('/recipes', recipesRouter);
app.use('/advice', adviceRouter);
app.use('/admin', adminRouter);

// catch 404, forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});

// respond to favicon requests with 204 no content 
app.get('/favicon.ico', (req, res) => res.sendStatus(204));

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};
  // render the error page
  res.status(err.status || 500);
  res.render('error');
});


// expose this app to scripts that require it, i.e. myapp/bin/www
module.exports = app;

/database/db.js

/*                db.js -- Database object configuration                  */
/*           verbose = long stack traces, for development only            */

var sqlite3 = require('sqlite3').verbose();
var db = new sqlite3.Database('./database/db.sqlite');

module.exports = db;

/models/data-model.js

/*        data-model.js: define how data is structured and managed        */
/*        This is the only file that requires the Database object         */

var db = require('../database/db');

/*  var schema is used for convenience to get column names in updateRow() */

var schema = {
  "items": [
    "id", "name", "unit", "price", "qty", "desc" ],
  "announce": [
    "id", "title", "date", "body" ],
  "events": [
    "id", "title", "date", "body" ],
  "motd": [
    "id", "title", "body" ]
};

/* CRUD functions: readTable, createRow, updateRow, deleteRow             */

function readTable (table, cb) {
  let sql = `SELECT * FROM ${table}`;
  let data = {};
  db.all(sql, function(err, rows) {        /* Return all results of query */
    if (err) throw(err);            /* If there's an error, terminate app */
    rows.forEach(function(row) {       /* For each row matching the query */
      data[row.id] = {};                  /* init row id as top-level key */
      Object.keys(row).forEach(function(k) {    /* For each column of row */
        data[row.id][k] = unescape(row[k]);     /* add the key-value pair */
      });
    });
    cb(data);    /* data = { id: { "colname" : value }, ... }, id2: ... } */
  });
};

function createRow (table, cb) {
  let sql = `INSERT INTO ${table} DEFAULT VALUES`;
  db.run(sql, cb);
};

function updateRow (table, rb, cb) {
  var pairs = "";           /* for constructing 'identifier = value, ...' */
  for (field of schema[table].slice(1)) {   /* for every column except id */
      if (pairs) pairs += ", ";    /* insert comma unless string is empty */
      pairs += `${field} = '${escape(rb[field])}'`;   /* column = 'value' */
  }
  let sql = `UPDATE ${table} SET ${pairs} WHERE id = ?`;  /* ? = rb['id'] */
  db.run(sql, rb['id'], cb);
};

function deleteRow (table, id, cb) {
  let sql = `DELETE FROM ${table} WHERE id = ${id};`;
  db.run(sql, cb);
};

module.exports = {   
  schema,
  readTable,
  createRow,
  updateRow,
  deleteRow
}

/public/javascripts/datestring.js

/*  datestring.js -- client-side script to convert UNIX times to strings  */
/*      Used in the "today" button in /admin/announce, /admin/events      */

function stringifyDate( unixDate ) {
  var monthLUT = {
    0: "January",
    1: "February",
    2: "March",
    3: "April",
    4: "May",
    5: "June",
    6: "July",
    7: "August",
    8: "September",
    9: "October",
    10: "November",
    11: "December"
  };
  var weekdayLUT = {
    0: "Sunday",
    1: "Monday",
    2: "Tuesday",
    3: "Wednesday",
    4: "Thursday",
    5: "Friday",
    6: "Saturday",
  };

  var dateObj = new Date(unixDate * 1000);
  var weekdayStr = `${weekdayLUT[dateObj.getDay()]}`;
  var monthStr = `${monthLUT[dateObj.getMonth()]}`;
  var monthday = dateObj.getDate();
  var monthdayStr = `${monthday}`;
  var yearStr = `${dateObj.getFullYear()}`;

  var dateString = "";
  dateString += `${weekdayStr}, `;
  dateString += `${monthStr} `;
  dateString += `${monthdayStr}`;
  /*                         Uncomment next line for "12th", "23rd", etc. */
  // dateString += `${getOrd(monthday)}, ${yearStr}`;

  return dateString;
}

function getOrd(num) {
  var ord = "";
  switch (true) {
    case (num == 1 || num == 21 || num == 31):
      ord = "st";
      break;
    case (num == 2 || num == 22):
      ord = "nd";
      break;
    case (num == 3 || num == 23):
      ord = "rd";
      break;
    default:
      ord = "th";
      break;
  }
  return ord;
}

function todayString() {
  let now = new Date();
  return stringifyDate(
    Math.round(now.getTime() / 1000)
  );
}
/*    menu.js -- client-side script to toggle menu when button is clicked */

function menuToggle(state) {
  var ele = document.getElementById('menu');
  switch(state) {
    case 'show':
      ele.style.opacity=1;
      ele.style.color='rgb(96, 96, 96)';
      ele.style.visibility='visible';
      ele.style.transition='visibility 0s, opacity 0.3s';
      break;
    case 'hide':
      ele.style.opacity=0;
      ele.style.color='black';
      ele.style.visibility='hidden';
      ele.style.transition='visibility 0.3s, opacity 0.3s'; 
      break;
  }
}

/public/stylesheets/style.css

/* style.css -- sitewide element styles and responsive viewport behavior */

* {
  margin: 0;     /* by default, all elements (selector *) have no margin */
}

html {
  width: 100%;                    /* 100% width of parent (root) element */
  height: 100vh;                              /* 100% height of viewport */
  background: rgb(0, 0, 0, 0.1);                            /* 10% black */
  font-size: 1.0em;                                /* our root font size */
  font-family: Arial, Helvetica, sans-serif;             /* default font */
}

body {
  min-height: 100%;
}

section {
  padding: 0.5rem;
}

section.admin {
  padding: 0 0 0 0.5rem;
  flex-grow: 0;
}

h1 {                                           /* Website name in header */
  font-size: 2.0rem;
  font-weight: normal;
  padding: 0.5rem;
}

h2 {                                                   /* About, Contact */
  font-size: 1.25rem;
}

h3 {                                                 /* Section headings */
  font-size: 1.2rem;
  padding: 0.5rem;
}

h3.admin {
  padding-left: 1.0rem;
}

h4 {                                               /* Section item title */
  font-weight: normal;
  padding: 0.5rem;
}

p {                                                 /* Section item body */
  padding: 0.5rem;
}

a:link, a:visited {            /* anchor links, and visited anchor links */
  color: black;
  text-decoration: none;                            /* disable underline */
}

a:hover {                                 /* when anchor link is hovered */
  color: rgb(25, 25, 25);
}

a:active {                                /* when anchor link is clicked */
  color: rgb(96, 96, 96);
}

input, textarea {
  font-size: 1.0rem;
}

textarea {
  width: 90%;
  height: 8rem;
  padding-right: 2rem;
}

button,
input[type=button], 
input[type=submit], 
input[type=reset] {
  display: flex;
  font-size: 1rem;
  background-color: rgb(0, 0, 0, 0.15);
  padding: 0 1rem 0 1rem;
  border-radius: 1rem;
  border: 0.08rem solid rgb(48, 40, 32);
  align-items: center;
  justify-content: space-between;
  cursor: pointer;            /* indicates it can be clicked like a link */
  user-select: none;            /* user cannot select the button as text */
}
button.delete {
  background-color: rgb(210, 200, 200);
}
button.update {
  background-color: rgb(200, 210, 200);
}
button.add {
  background-color: rgb(210, 210, 220);
}

.buttons {
  display: flex;
  justify-content: flex-start;
  align-items: center; 
  margin-right: 2rem;
}

.icon {
  font-size: 1.5rem;
}
.delete .icon {
  color: darkred;
}
.update .icon {
  color: darkgreen;
}
.add .icon {
  color: darkblue;
}

input[type=submit]:hover {
  background-color: rgb(200, 225, 250, 1);
}

/* component styles */

#container {
  display: grid;
  height: 100vh;
  grid-template-columns:
    [left] 10rem auto 10rem [right];
  grid-template-rows:
    [top] 5rem auto 5rem [bottom];     /* header height fits its content */
  grid-template-areas:
    "head head head"
    "panleft mainbody panright"
    "foot foot foot";
}

#header {
  grid-area: head;                    /* corresponds to name in template */
  background: rgb(0, 0, 0, 0.2);                            /* 20% black */
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  align-items: baseline;  /* site name and nav item text aligns baseline */
  padding: 0.5rem;
}

#panel {                                       /* for element id="panel" */
  display: flex;                     /* this element is a flexbox parent */
  flex-direction: column;          /* its child elements flex vertically */
  padding: 0.5rem;
  background: rgb(0, 0, 0, 0.1);                            /* 10% black */
}
#panel.left {                 /* for element id="panel" and class="left" */
  grid-area: panleft;                  /* this element fills a grid area */
}
#panel.right {
  grid-area: panright;
}

#footer {
  grid-area: foot;
  display: flex;                     /* this element is a flexbox parent */
  flex-direction: column;          /* its child elements flex vertically */
  justify-content: center;           /* horizontal center footer content */
  align-items: center;                 /* vertical center footer content */
  padding: 0.5rem;
  background: rgb(0, 0, 0, 0.2);
}

#mainbody {                                 /* for element id="mainbody" */
  display: flex;                     /* this element is a flexbox parent */
  flex-direction: column;          /* its child elements flex vertically */
  grid-area: mainbody;
  justify-self: left;            
  width: 99%;                   /* slight breathing room at right margin */
  min-width: 22.5rem;            /* mainbody width can't go < 22.5rem */
}

#partners, #sections {     /* for element id="partners" or id="sections" */
  display: flex;                     /* this element is a flexbox parent */
  flex-direction: row;           /* its child elements flex horizontally */
  flex-wrap: wrap;           /* its child elements can wrap to next line */
  align-content: flex-start;       /* child elements start in upper left */
}

#partners.wide {           /* for element id="partners" and class="wide" */
  display: none;              /* by default, do not display this element */
}

#menu {
  position: absolute;      /* menu position unaffected by other elements */
  right: 0;                       /* zero pixels from the right boundary */
  background: rgb(239, 239, 239);
  border: 0.15rem solid rgb(0, 0, 0, 0.4);
  visibility: hidden;        /* visibility property supports transitions */
  opacity: 0;      /* opacity + visibility transition = menu fade effect */
  z-index: 1;              /* ensure menu appears over all other content */
  min-width: 13rem;               /* the menu should never get to narrow */
}

#menuitems {               /* menu is implemented as a flexbox container */
  display: flex;
  flex-direction: column;
  padding: 0.5rem;
}

#menuitems h3 {
  border-top: 0.15rem solid rgb(0, 0, 0, 0.1);  /* light horizontal rule */
}

#menuitems .sectrule {
  border-color: rgb(0, 0, 0, 0.25);            /* darker horizontal rule */
}

#menuitems .menuhead {
  border-top: none;
}

#menuitems h3:hover {
  background-color: rgb(0, 0, 0, 0.1);     /* gray of rollover menuitems */
}

.menubutton {
  text-align: right;
  cursor: pointer;            /* indicates it can be clicked like a link */
  user-select: none;            /* user cannot select the button as text */
}

#menuitems .alignright {
  text-align: right;            /* right-aligned menu item text (unused) */
}

#header .menubutton {
  display: none;        /* in default view (landscape), hide menu button */
  border: 0.15rem solid rgb(0, 0, 0, 0);   /* (invisible) alignment shim */
}

#header .placeholder {    /* this invisible button is rendered when menu */
  color: rgb(0, 0, 0, 0); /* button is hidden, so header height matches. */
  user-select: none;       /* user can't select text of invisible button */
  border: 0.15rem solid rgb(0, 0, 0, 0);   /* (invisible) alignment shim */ 
}

.sectionlink, .partnerlink {
  border-radius: 0.25rem;     /* give this element a slight rounded edge */
  font-weight: normal;
  font-size: 1.1rem;
  width: 100%;
  margin-bottom: 1.0rem;
  background: rgb(0, 0, 0, 0.1);
}

.sectionlink:hover, .partnerlink:hover {
  background-color: rgb(0, 0, 0, 0.065);   /* brighten bg on mouse hover */
}

.partnerlink {
  height: 8rem;        /* partner elements are additionally fixed height */
  width: 8rem;
  padding: 0.0rem;
  margin-bottom: 1.0rem;
}

.partnerlink.wide {
  margin: 0.5rem 1rem 0.5rem 0;      /* margins for spacing if they wrap */
}

.eventitem, .announceitem, .motditem {
  margin-bottom: 0.5rem;                /* slight margin for readability */
}

.title {                                    /* e.g., "Open for business" */
  font-style: italic;
  font-weight: normal;
  font-size: 1.1rem;
}

.date, .ingredient {         
  font-style: italic;
  font-size: 0.9rem;
  padding: 0 0 0.01rem 0.5rem;
  color: rgb(0, 0, 0, 0.5);
}

.navitem {                                             /* About, Contact */
  font-weight: normal;
  padding: 0.5rem;
  padding-right: 1.0rem;
}

.space, .headspace, .panspace, .footspace, .bodyspace {
  flex-grow: 1;      /* these elements expand on flex axis to fill space */
}

.indent {
  padding-left: 0.5rem;
}

/* table styles ("items for sale") */

table {
  border-collapse: collapse;               /* pixel-adjacent table cells */
  width: 100%;
  margin-bottom: 1rem;
}

th {
  text-align: left;
}

tr {
  margin: 4rem 0 0 0;
  border-bottom: 0.15rem solid rgb(0, 0, 0, 0.15);    /* horizontal rule */
}

tr.norule {
  border-bottom: none;
}

td, th {
  padding: 0.5rem;
  vertical-align: top;
}

td.price {
  white-space: nowrap;        /* white space in price does not wrap line */
}

td.qty, th.qty {
  text-align: center;
}

th {
  font-size: 1.1rem;
}

td.label, th.label {
  font-size: 1.1rem;
  width: 3rem;
  text-align: right;
  
}

span.perunit {
  opacity: 0.5;
}

/* responsive styles for portrait mode: hide panels, partners in body    */

@media screen and (max-width: 45rem) {      /* if viewport width < 45rem */
  #panel.left {
    grid-column-end: left;         /* panel grid area shrinks to nothing */
  }
  #panel.right {
    grid-column-start: right;      /* panel grid area shrinks to nothing */
  }
  #partners.tall {
    display: none;  /* hide partners in panel (overwrites display: flex) */
  }
  #partners.wide {
    display: flex;   /* show partners in body (overwrites display: none) */
  }
  #panel,                                 /* these disappear from layout */
  #header .placeholder,
  .navitem {
    display: none;
  }
  #mainbody {
    grid-column-start: left;         /* mainbody now starts at left edge */
    grid-column-end: right;           /* mainbody now ends at right edge */
  }
  #header .menubutton {                /* display the header menu button */
    display: inline;                         /* overwrites display: none */
  }
}

/* In landscape mode (approx. viewport width 45-55rem), hide right panel */

@media screen and (max-width: 55rem) {
  #partners.tall {
    display: none;  /* hide partners in panel (overwrites display: flex) */
  }
  #partners.wide {
    display: flex;   /* show partners in body (overwrites display: none) */
  }
  #panel.right {
    grid-column-start: right;
    display: none;
  }
  #mainbody {
    grid-column-end: right;
  }
}

/routes/about.js

/*                        about.js -- route /about                        */

var express = require('express');
var router = express.Router();

router.get('/', function(req, res, next) {
  res.render('about', { pagetitle: 'About Us' });
});

module.exports = router;

/routes/admin.js

/*                       admin.js -- routes /admin/*                      */

var express = require('express');
var router = express.Router();
var dataModel = require('../models/data-model.js');

/* ROUTE: /admin                                                          */

router.get('/', function(req, res, next) {
  res.render('admin', { pagetitle: 'Control Panel' });
});

/* ROUTE: /admin/announce                                                 */

router.get('/announce', function(req, res, next) {
  dataModel.readTable("announce", function(data) {
    res.render('admin-announce', { 
      pagetitle: 'Edit Announcements', 
      announcements: data
    });
  });
});

/* ROUTE: /admin/update/announce                                          */

router.post('/update/announce', function(req, res, next) {
  cb = function () {          /* same callback used for update and delete */
    res.redirect('/admin/announce');
  };
  if (req.body.action == "delete") {                /* if action = delete */
    dataModel.deleteRow("announce", req.body.id, cb);
  } else {                                        /* else action = update */
    dataModel.updateRow("announce", req.body, cb);
  }
});

/* ROUTE: /admin/create/announce                                          */

router.post('/create/announce', function(req, res, next) {
  dataModel.createRow("announce", function() {
    res.redirect('/admin/announce#footer');
  });
});

/* ROUTE: /admin/items                                                    */

router.get('/items', function(req, res, next) {
  dataModel.readTable("items", function(data) {
    Object.keys(data).forEach(function(id) {
      data[id].price = (parseFloat(data[id].price) / 100).toFixed(2);
    });
    res.render('admin-items', { pagetitle: 'Edit Items', items: data });
  });
});

/* ROUTE: /admin/update/item                                              */

router.post('/update/item', function(req, res, next) {
  cb = function () {
    res.redirect('/admin/items');
  };
  if (req.body.action == "delete") {
    dataModel.deleteRow("items", req.body.id, cb);
  } else {
    /* req.body.price is e.g., 5.00 */
    /* Convert to e.g., 500 for database storage */
    /* if user input is more precise than a penny e.g., 5.005 */
    /* then use Math.floor to discard that data */
    req.body.price = Math.floor(parseFloat(req.body.price) * 100);
    dataModel.updateRow("items", req.body, cb);
  }
});

/* ROUTE: /admin/create/item                                              */

router.post('/create/item', function(req, res, next) {
  dataModel.createRow("items", function() {
    res.redirect('/admin/items#footer');
  });
});

/* ROUTE: /admin/events                                                   */

router.get('/events', function(req, res, next) {
  dataModel.readTable("events", function(data) {
    res.render('admin-events', { 
      pagetitle: 'Edit Events', events: data });
  });
});

/* ROUTE: /admin/update/event                                             */

router.post('/update/event', function(req, res, next) {
  let cb = function () {
    res.redirect('/admin/events');
  };
  if (req.body.action == "delete") {
    dataModel.deleteRow("events", req.body.id, cb);
  } else {
    dataModel.updateRow("events", req.body, cb);
  }
});

/* ROUTE: /admin/create/event                                             */

router.post('/create/event', function(req, res, next) {
  dataModel.createRow("events", function () {
    res.redirect('/admin/events#footer');
  });
});

/* ROUTE: /admin/motd                                                     */

router.get('/motd', function(req, res, next) {
  dataModel.readTable("motd", function(data) {
    res.render('admin-motd', {
      pagetitle: 'Edit MOTD',
      motd: data
    });
  });
});

/* ROUTE: /admin/update/motd                                              */

router.post('/update/motd', function(req, res, next) {
  cb = function () {
    res.redirect('/admin/motd');
  };
  if (req.body.action == "delete") {
    dataModel.deleteRow("motd", req.body.id, cb);
  } else {
    dataModel.updateRow("motd", req.body, cb);
  }
});

/*  ROUTE: /admin/create/motd                                             */

router.post('/create/motd', function(req, res, next) {
  dataModel.createRow("motd", function() {
    res.redirect('/admin/motd');
  });
});

module.exports = router;

/routes/advice.js

var express = require('express');
var router = express.Router();

router.get('/', function(req, res, next) {
  res.render('advice', { pagetitle: 'Homesteading Advice' });
});

module.exports = router;

/routes/contact.js

var express = require('express');
var router = express.Router();

router.get('/', function(req, res, next) {
  res.render('contact', { pagetitle: 'Contact Us' });
});

module.exports = router;

/routes/index.js

/*           index.js -- logic for route /  (site home page)              */

var express = require('express');
var router = express.Router();
var dataModel = require('../models/data-model.js');

/* The next four functions fetch data, and pass it on as part of req obj  */

function getAnnouncements(req, res, next) {
  req.announce = {};
  dataModel.readTable("announce", function(data) {
    req.announce = data;
    next();
  });
}

function getItems(req, res, next) {
  req.items = {};
  dataModel.readTable("items", function(data) {
    Object.keys(data).forEach(function (id) {
      let price = data[id].price;
      if (price % 100) {           /* if price is not whole dollar amount */
        price = (parseInt(price) / 100).toFixed(2);      /* include cents */
      } else {
        price = (parseInt(price) / 100);        /* otherwise omit decimal */
      }
      data[id].price = price;
    });
    req.items = data;
    next();
  });
}

function getEvents(req, res, next) {
  dataModel.readTable("events", function (data) {
    req.events = data;
    next();
  });
}

function getMotd(req, res, next) {
  dataModel.readTable("motd", function (data) {
    req.motd = data;
    next();
  });
}

/* Fetch data, render homepage                                            */

function renderPage(req, res) {
  res.render('index', {
    pagetitle: "Our Farm Stand",
    announcements: req.announce,
    items: req.items,
    events: req.events,
    motd: req.motd
  });
}

router.get('/',
  getAnnouncements, 
  getItems, 
  getEvents, 
  getMotd,
  renderPage
);

module.exports = router;

/routes/recipes.js

var express = require('express');
var router = express.Router();

router.get('/', function(req, res, next) {
  res.render('recipes', { pagetitle: 'Recipes' });
});

module.exports = router;

/routes/tips.js

var express = require('express');
var router = express.Router();

router.get('/', function(req, res, next) {
  res.render('tips', { pagetitle: 'Tips For Living Well' });
});

module.exports = router;

/views/about.pug

extends layout

block mainbody
  p Alice &amp; Bob have been operating their farm stand since 1992.

/views/admin-announce.pug

extends admin-layout

block mainbody
  .buttons
    h1 Announcements
    p
    form(method='POST' action='/admin/create/announce')
      button.add(type="submit")
        .icon &plus;
        p New
  each announce in announcements
    hr
    form(method='POST' action='/admin/update/announce')
      table
        tr
          th.label #{announce['id']}
          th #{announce['title']}
        each value, field in announce
          if field != 'id'
            - var inputId = "_" + announce['id'] + "_" + field;
            - var useToday = "document.getElementById('_" + announce['id']
            - useToday += "_" + field + "').value=todayString()";
            tr
              td.label: label(for=field) #{field}:
              if field == 'body'
                td: textarea(name=field id=inputId)
                  =announce[field]
              else
                td.buttons
                  if field == 'date'
                    input(type="text", size=18, name=field, value=announce[field] id=inputId)
                    p
                    button(type="button" onclick=useToday)
                      div today
                  else
                    input(type="text", size=28, name=field, value=announce[field] id=inputId)
        tr.norule
          td: input(type="hidden" name="id" value=announce['id'])
          td.buttons
            button.update(type="submit" name="update")
              .icon &check;
              p Update
            .space
            button.delete(name="action" value="delete" onclick="return confirm('Are you sure?')")
              .icon X
              p Delete
    p

/views/admin-events.pug

extends admin-layout

block mainbody
  .buttons
    h1 Events
    p
    form(method='POST' action='/admin/create/event')
      button.add(type="submit")
        .icon &plus;
        p New
  each event in events
    hr
    form(method='post' action='/admin/update/event')
      table
        tr
          th.label #{event['id']}
          th #{event['title']}
        each value, field in event
          if field != 'id'
            - var inputId = "_" + event['id'] + "_" + field;
            - var useToday = "document.getElementById('_" + event['id']
            - useToday += "_" + field + "').value=todayString()";
            tr
              td.label: label(for=field) #{field}:
              if field == 'body'
                td: textarea(name=field id=inputId)
                  =event[field]
              else
                td.buttons
                  if field == 'date'
                    input(type="text", size=18, name=field, value=event[field] id=inputId)
                    p
                    button(type="button" onclick=useToday)
                      div today
                  else
                    input(type="text", size=28, name=field, value=event[field] id=inputId)
        tr.norule 
          td: input(type="hidden" name="id" value=event['id'])
          td.buttons
            button.update(type="submit" name="update")
              .icon &check;
              p Update
            .space
            button.delete(name="action" value="delete" onclick="return confirm('Are you sure?')")
              .icon X
              p Delete
    p

/views/admin-items.pug

extends admin-layout

block mainbody
  .buttons
    h1 Items
    p
    form(method='POST' action='/admin/create/item')
      button.add(type="submit")
        .icon &plus;
        p New
  each item in items
    hr
    form(method='POST' action='/admin/update/item')
      table
        tr
          th.label #{item['id']}
          th #{item['name']}
        each value, field in item
          if field != 'id'
            tr
              td.label: label(for=field) #{field}:
              if field == 'desc'
                td: textarea(name=field)
                  =item[field]
              else
                td: input(type="text", size=25, name=field, value=item[field])
        tr.norule
          td: input(type="hidden" name="id" value=item['id'])
          td.buttons
            button.update(type="submit" name="update")
              .icon &check;
              p Update
            .space
            button.delete(name="action" value="delete" onclick="return confirm('Are you sure?')")
              .icon X
              p Delete
      p

/views/admin-layout.pug

doctype html
html
  head
 title= pagetitle
    meta(charset="utf-8")
    meta(name="viewport" content="width=device-width, initial-scale=1")
    script(src="/javascripts/menu.js")
    script(src="/javascripts/datestring.js")
    link(rel="stylesheet", href="/stylesheets/style.css")
  body
    #menu
      section#menuitems
        .menubutton
          h1.menubutton(onclick="menuToggle('hide')") &#9776;
        p
        a(href="/admin/announce")
          h3.sectrule &#9998; Announce
        a(href="/admin/items")
          h3 &#9998; Items
        a(href="/admin/events")
          h3 &#9998; Events
        a(href="/admin/motd")
          h3 &#9998; Message
        a(href="/")
          h3.sectrule &rarr; Go to site  
    #container(onmouseup="menuToggle('hide')")
      #header
        a(href="/admin")
          h1(style="padding-left: 0") Control Panel
        .headspace
        h1.menubutton(onclick="menuToggle('show')") &#9776;
        h1.placeholder &#9776;
        a(href="/")
          h2.navitem &rarr; Go to site
      #panel.left
        section#sections
          a.sectionlink(href="/admin/announce")
            p &#9998; Announce
          a.sectionlink(href="/admin/items")
            p &#9998; Items
          a.sectionlink(href="/admin/events")
            p &#9998; Events
          a.sectionlink(href="/admin/motd")
            p  &#9998; Message
      #mainbody
        block mainbody
      #panel.right
      #footer
        p Copyright &copy; 2020 Alice &amp; Bob's Farm Stand

/views/admin-motd.pug

extends admin-layout

block mainbody
  .buttons
    h1 Message of the day
    p
    form(method='POST' action='/admin/create/motd')
      button.add(type="submit")
        .icon &plus;
        p New
  each message in motd
    hr
    form(method='POST' action='/admin/update/motd')
      table
        tr
          th.label #{message['id']}
          th #{message['title']}
        each value, field in message
          if field != 'id'
            tr
              td.label: label(for=field) #{field}:
              if field == 'body'
                td: textarea(name=field)
                  =message[field]
              else
                td: input(type="text", size=30, name=field, value=message[field])
        tr.norule
          td: input(type="hidden" name="id" value=message['id'])
          td.buttons
            button.update(type="submit" name="update")
              .icon &check;
              p Update
            .space
            button.delete(name="action" value="delete" onclick="return confirm('Are you sure?')")
              .icon X
              p Delete
    p

/views/admin.pug

extends admin-layout

block mainbody
  h3 Welcome to the Control Panel
  p Choose a section to edit.

/views/contact.pug

extends layout

block mainbody
  h3 Alice &amp; Bob
  p 1344 Chattanooga Way
  p Homestead, VT 05401
  p (802) 555-5555

/views/index.pug

extends layout

block mainbody
  h3 Announcements
  .indent
    .announceitem
      each announce in announcements
        unless announce.title == ""
          h4.title #{announce.title}
          p.date #{announce.date}
          p #{announce.body}
  h3 Items for sale
  .indent
    table
      tr
        th Item
        th Description
        th Price
        th.qty Qty
      each item in items
        unless item.name == ""
          tr
            td #{item.name}
            td #{item.desc}
            td.price $#{item.price}
              span.perunit  / #{item.unit}
            td.qty #{item.qty}
  h3 Upcoming events
  each event in events
    unless event.title == ""
      .eventitem.indent
        h4.title #{event.title}
        p.date #{event.date}
        p #{event.body}
  each message in motd
    unless message.title == ""
      h3 #{message.title}
      .motditem.indent
        p #{message.body}
  h3#partners.wide Our friends
  .indent
    section#partners.wide
      a.partnerlink.wide(href="")
        p Green Valley Greens
      a.partnerlink.wide(href="/")
        p Burt's Maple Syrup
      a.partnerlink.wide(href="")
        p Turkey Hill Farm
      a.partnerlink.wide(href="")
        p Only Organic Seeds
  .bodyspace

/views/layout.pug

doctype html
html
  head
  title=pagetitle
    meta(charset="utf-8")
    meta(name="viewport" content="width=device-width, initial-scale=1")
    script(src="/javascripts/menu.js")
    link(rel="stylesheet", href="/stylesheets/style.css")
  body
    #menu
      section#menuitems
        .menubutton
          h1.menubutton(onclick="menuToggle('hide')") &#9776;
        a(href="/")
          h3.menuhead Our Farm Stand
        a(href="/tips")
          h3.sectrule Tips for living well
        a(href="/recipes")
          h3 Recipes
        a(href="/advice")
          h3 Homesteading advice
        a(href="/about")
          h3.sectrule About Us
        a(href="/contact")
          h3 Contact Us
    #container(onmouseup="menuToggle('hide')")
      #header
        a(href="/")
          h1.logo Our Farm Stand
        .headspace
        h1.menubutton(onclick="menuToggle('show')") &#9776;
        h1.placeholder &#9776;
        h2.navitem
          a(href="/about")
            .clickable-area About Us
        h2.navitem
          a(href="/contact")
            .clickable-area Contact Us
      #panel.left
        section#sections
          a.sectionlink(href="/tips")
            p Tips for living well
          a.sectionlink(href="/recipes")
            p Recipes
          a.sectionlink(href="/advice")
            p Homesteading advice
      #mainbody
        section
          block mainbody
      #panel.right
        h3 Our friends
        section#partners.tall
          a.partnerlink(href="/")
            p Green Valley Greens
          a.partnerlink(href="/")
            p Turkey Hill Farm
          a.partnerlink(href="/")
            p Burt's Maple Syrup
          a.partnerlink(href="/")
            p Only Organic Seeds
      #footer
        p Copyright &copy; 2020 Alice &amp; Bob's Farm Stand

/views/recipes.pug

extends layout

block mainbody
  h3 Alice's Recipes
  .indent
    p
      b No-knead next-day dutch oven bread
    .indent
      p.ingredient 1/4 tsp active dry yeast
      p.ingredient 3 cups all-purpose flour
      p.ingredient 1 1/2 tsp salt
      p.ingredient Cornmeal or wheat bran for dusting
    p In a large bowl, dissolve yeast in water.
    p Add the flour and salt, stirring until blended.
    p Cover bowl. Let rest at least 8 hours, preferably 12 to 18, at warm room temperature, about 70 degrees.
    p When the surface of the dough is dotted with bubbles, it's ready to be folded. Lightly flour a work surface. Sprinkle flour on the dough and fold it over on itself once or twice. Cover loosely and let it rest about 15 minutes.
    p Using just enough flour to keep the dough from sticking, gently shape it into a ball. Generously coat a clean dish towel with flour, wheat bran, or cornmeal. Put the seam side of the dough on the towel. Cover with another towel and let rise for 1 to 2 hours.
    p Heat oven to 475&deg;. Cover and bake for 30 minutes.

/views/tips.pug

extends layout

block mainbody
  h3 Alice's Tips
  p Always rise before the sun.
  p Never use fake maple syrup.
  p If the bear is black, be loud, attack.
  p If the bear is brown, play dead, lie down.

Running the app

In the app directory, start the app in development mode.

npm run devstart

The app is served on the local network on port 3000. To view the site on a local network, open a web browser to ipaddress:3000, where ipaddress is the IP address of the computer running the Node app. If you're browsing on the computer running the app, you can use the address localhost:3000.

Tip

If you don't know the computer's IP address, see: How to find my IP address.

App home page

To view the Control Panel, go to localhost:3000/admin, or ipaddress:3000/admin. Choose a section to go to its edit page.

App Control Panel

To change information, edit the text in any field, then click or tap the Update button directly under it. If you change multiple fields, they are all updated.

Update database

The new information is immediately visible if you reload the home page.

Home page updated

To add a new item, click or tap the New button.

Insert row

The new item has no data, but the entry is "live." It would show on the home page if the title was not blank (unless announce.title in index.pug).

Add data

Add some data, and click or tap Update. The new announcement is visible on the home page.

New data on home page

To delete an item, click the Delete button, then click OK to confirm. (Deleted items are gone forever. This app does not save deleted items, or implement an undo function.)

Confirm delete

Additional notes

  • This app does not perform input filtering or validation. It performs simple sanitization by escaping all input, and unescaping the data before passing it to the view engine.
  • If you initialize the database file while the app is running, nodemon will not automatically detect new file handle. In nodemon, type rs and press Enter to force-restart the app. Or, press Ctrl+C to kill nodemon (and the app), then run npm run devstart again. Either of these will force the app to detect the updated descriptor of the database file.
  • This app uses three responsive views. In desktop view, both panels are shown. In landscape view, the left panel is shown, but the right panel is hidden. In portrait view, both panels are hidden, and the menu button is active.
  • When the menu is open in portrait view, clicking or tapping outside the menu closes it.