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).
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.
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
Goals
Database schema
MVC logic
Basic setup
Install SQLite
Install the sqlite3 Node module
Initialize the database
Overview of init script
Create the init script
Run the init script
Overview of SQLite
Listing tables in database
Reading data from a table
Modifying values
Quoting
Creating a table
Creating rows
Deleting rows
Listing columns of a table
Deleting a table
Exiting the SQLite client
Overview of sqlite3 Node module
Creating a new Database object
Using the Database object
Callbacks
Implementation: code and structure
Running the app
Goals
Database schema
MVC logic
Overview of init script
Create the init script
Run the init script
Listing tables in database
Reading data from a table
Modifying values
Quoting
Creating a table
Creating rows
Deleting rows
Listing columns of a table
Deleting a table
Exiting the SQLite client
Creating a new Database object
Using the Database object
Callbacks
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.
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.
- 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
- Use npm to globally install express-generator.
npm i express-generator -g
sudo npm i express-generator -g
- Generate a new Express app, and change to the directory.
express myapp –view=“pug”
cd myapp
- Install the Express app dependencies (npm reads them from the package.json file generated for the Express app).
npm i
- If security patches are available, apply them.
npm audit fix
- Install nodemon as a development dependency.
npm i nodemon –save-dev
- 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.
- SQLite for Windows
- SQLite for macOS
- SQLite for Linux
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.
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.
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.
Then, INSERT INTO the table one or more rows, populated with preset initial VALUES.
Create the init script
In the app directory, create a new directory called database, and change to it.
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.
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.
In the database directory, run sqlite3 to start the interactive client, opening the file db.sqlite as the (new) database.
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.
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
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.
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.
Listing tables
In the database directory you created (myapp/database), start the SQLite client, opening the db.sqlite database for transactions.
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.
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’;
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 */
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 either 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.
Overview of sqlite3 Node module
The sqlite3 module provides functions for a Node app to communicate with an SQLite database.
For a detailed description of SQLite commands and queries, see the official SQLite SQL reference.
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.
The syntax of the run() function is:
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.
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.
Implementation: code and structure
The application file structure and code is listed below.
For detailed information about the sqlite3 API, see the official sqlite3 Wiki.
- 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) ); }
/public/javascripts/menu.js
/* 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 & 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 + 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 ✓ 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 + 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 ✓ 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 + 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 ✓ 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’)”) ☰
p
a(href="/admin/announce")
h3.sectrule ✎ Announce
a(href="/admin/items")
h3 ✎ Items
a(href="/admin/events")
h3 ✎ Events
a(href="/admin/motd")
h3 ✎ Message
a(href="/")
h3.sectrule → Go to site
#container(onmouseup=“menuToggle(‘hide’)”)
#header
a(href="/admin")
h1(style=“padding-left: 0”) Control Panel
.headspace
h1.menubutton(onclick=“menuToggle(‘show’)”) ☰
h1.placeholder ☰
a(href="/")
h2.navitem → Go to site
#panel.left
section#sections
a.sectionlink(href="/admin/announce")
p ✎ Announce
a.sectionlink(href="/admin/items")
p ✎ Items
a.sectionlink(href="/admin/events")
p ✎ Events
a.sectionlink(href="/admin/motd")
p ✎ Message
#mainbody
block mainbody
#panel.right
#footer
p Copyright © 2020 Alice & 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 + 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 ✓ 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 & 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’)”) ☰ 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’)”) ☰ h1.placeholder ☰ 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 © 2020 Alice & 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°. 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.
If you don’t know the computer’s IP address, see: How to find my IP address.
To view the Control Panel, go to localhost:3000/admin, or ipaddress:3000/admin. Choose a section to go to its edit page.
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.
The new information is immediately visible if you reload the home page.
To add a new item, click or tap the New button.
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 some data, and click or tap Update. The new announcement is visible on the 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.)
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.
Related information
- How to start in HTML and web design.
- How to create a holy grail website layout using CSS Grid.
- How to create a responsive website using CSS Grid and Flexbox.
- How to create a website using Node.js and Express.
- HTML and web design help and support.