Compare commits

..

10 Commits

Author SHA1 Message Date
Ayobami fe95626d9f feat: complete tasks 1 to 15 2025-07-25 22:16:08 +01:00
emmymayo a5dbf762b6 added simple flow builder 2025-02-12 21:41:56 +01:00
emmymayo 1ccca077fa add another integration 2025-01-25 13:02:28 +01:00
manaknight 5524cabab4 Update README.md 2023-12-05 02:10:41 +00:00
manaknight d3586f8cab add json 2023-11-27 22:12:10 +00:00
manaknight 3f73ca3b44 Upload files to "/" 2023-11-27 22:12:03 +00:00
Possible 40fb916437 Update Project purpose 2023-11-16 17:46:07 +01:00
ryanwong fbbadd6fbf update chat requirement 2022-09-19 03:28:52 -04:00
ryanwong 845d0eea7b update 2022-09-16 10:00:22 -04:00
ryanwong f2c4f790ca update doc 2022-09-14 10:09:00 -04:00
17 changed files with 4121 additions and 700 deletions
+3
View File
@@ -0,0 +1,3 @@
PORT=8000
WEATHER_API_KEY=de18eeaec53e4caca18170027252507
DB_PASSWORD=ayobamidavid
+74 -8
View File
@@ -1,33 +1,99 @@
# This project is a toy project for training and quality assurance purposes
# node_task
1. Clone repo into your github
2. Create a page on route / that match the figma file https://www.figma.com/file/wDRDYmVG1qZI3qff32631b/Untitled?node-id=0%3A1
3. Call the weather api https://www.weatherapi.com/pricing.aspx to show the temperature in celsius and draw a sun if sunny. If its raining show clouds (choose an image from google). If its snowing, choose a snowflake image from google. This widget should update every 5 minute calling teh weather api.
4. Given the current UTC time, create 4 time widget where you convert the UTC time to local time of london, EST, Nigeria and Pakistan time.
5. Create a widget that call autocomplete api /airports?search= that dropdown the airports that match the search terms partially. Minimum number of character to trigger autocomplete is 3.
3. Call the weather api https://www.weatherapi.com/pricing.aspx to show the temperature in celsius and draw a sun if sunny. If its raining show clouds (choose an image from google). If its snowing, choose a snowflake image from google. This widget should update every 5 minute calling teh weather api. Call weather api from backend route. Do error handling.
4. Given the current UTC time, create 4 time widget where you convert the UTC time to local time of london, EST, Nigeria and Pakistan time. Every second, the time should be updated like regular clock. Create backend route to return the time.
5. Create a widget that call autocomplete api /airports?search= that dropdown the airports that match the search terms partially. Minimum number of character to trigger autocomplete is 3. Create backend route to handle this.
6. Show map widget for airport chosen using latitude and longitude from autocomplete chosen airport. Use this map api https://openlayers.org/doc/quickstart.html
7. Create a widget that calculate the distance from artic circle to airport https://stackoverflow.com/questions/27928/calculate-distance-between-two-latitude-longitude-points-haversine-formula
8. Use bootstrap 4 for ui
8. Use tailwindcss for ui
9. Create db table called analytic (id, create_at, widget_name, browser_type, ). Everytime user clicks on a widget, call api /analytic and send the widget name to log it in the db.
10. Number of Click widget call an api every minute that count the # of rows in analytic
10. Number of Click widget call an api every minute that count the # of rows in analytic.
11. Create widget export xml that will export the analytic database as xml file.
11. Create widget export xml that will export the analytic database as xml file. Create backend route to handle this.
12. Query https://www.reddit.com/r/programming.json and create a reddit widget where we show the top 4 even post as cards in the widget (title, link, who posted it)
(b) add route to import the analytic xml file.
13. Count # of coin widget. Once user type in a money amount, and click calculate, we show how many bills to add up to the money amount. The bills allowed are: $20 bill, $10 bill, $5 bill, $1 bill, $25 cent, $10 cent, $5 cent, $1 cent.
12. Query https://www.reddit.com/r/programming.json and create a reddit widget where we show the top 4 even post as cards in the widget (title, link, who posted it). Create backend route to handle this.
13. Count # of coin widget. Once user type in a money amount, and click calculate, we show how many bills to add up to the money amount. The bills allowed are: $20 bill, $10 bill, $5 bill, $1 bill, $25 cent, $10 cent, $5 cent, $1 cent. Create backend route to handle this.
14. Rate limit analytic api to only be able to be called 10 times a minute.
15. If the rate limit is exceeded, redirect to a page that requires user to pay for the service ($5) with stripe. Free to use stripe checkout or payment element.
15. Create upload widget where we upload image to server. Save image to db table. Always show the latest image uploaded above upload button.
16. Read this documentation https://www.npmjs.com/package/speakeasy and implement a modal popup that blocks the dashboard. Unless 2FA is verified, cannot see dashboard.
17. Implement a single long polling chat. Here's a document explaining it
https://www.enjoyalgorithms.com/blog/long-polling-in-system-design
https://javascript.info/long-polling
https://www.technouz.com/4879/long-polling-explained-with-an-example/
Redis:
https://redis.io/docs/connect/clients/nodejs/
https://www.npmjs.com/package/redis
In demo what should happen:
If I open 2 browsers to /chat, both users should connect to chat room. Use redis to store state of chatroom.
Have basic html ui showing the chat log using UL and single input box with send button to send messages.
When I type a message in send message, call /send POST api to send message to backend. The chat room is updated with new message.
All client browsers have an api called /poll that check if chat room on redis is updated. If updated, it will return 200 and frontend
need to call GET /chat/all to pull all the messages in chat room which you will update the UL. Have a button called save where it will save the current chat room chat messages to database table chat with fields (id, create_at, chat_messages).
Stripe key:
pk_test_51IWQUwH8oljXErmdg6L4MhsuB6tDdmumlHFfyNaopty2U27pmRcqMX1c868zn838lGQtU1eYV6bKRSQtMFWf36VT00aNsvnTOE
sk_test_51IWQUwH8oljXErmds28KftkL6o6jYIcPgYbBdfEmCPSuAlIh0fgoS4NADcCmsIZbdQ3p5nbAeCOcGkSmo38U9BIe00BdOenrqo
18. ### Simple Flow Builder
#### Core Features
##### Flow Creation:
* Users can create a flow with a name and description.
* Each flow consists of one or more tasks (each task should have a default input on creation).
* Tasks are executed sequentially (non-branching).
##### Task Actions:
* Each task performs a specific action with a single input.
* Supported action types:
+ Send Test Mail: Sends an email to the provided address.
- Input: Email address (e.g., `test@example.com`).
+ HTTP GET Request: Performs a GET request to the provided URL.
- Input: URL (e.g., `https://api.example.com/data`).
+ MySQL Select: Executes a SELECT query on a specified table and ID.
- Input: Table name and ID (e.g., `users|123` [select from users where id = 123]).
+ Drive Upload: Writes text to a file and uploads it to Google Drive.
- Input: Text content (e.g., `Hello, World!`).
- Hint: Create a google drive service account, enable drive api, generate credentials and utilize google sdk or api to upload.
##### Triggers:
* Webhook Trigger:
+ A GET request with the action input in the query parameter called `payload`.
+ Example: `localhost:3001?payload=test@example.com` (triggers the "Send Test Mail" action).
* Click Trigger:
+ On clicking a button, show a popup to receive the action input.
+ Example: Click a button, enter `test@example.com`, and trigger the "Send Test Mail" action.
##### Execution:
* When a flow is triggered, execute all tasks in sequence.
* Log the results of each task execution in a database table (`flow_logs`).
+1
View File
File diff suppressed because one or more lines are too long
+16 -19
View File
@@ -4,16 +4,16 @@
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('node-task-2a:server');
var http = require('http');
var app = require("../app");
var debug = require("debug")("node-task-2a:server");
var http = require("http");
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
var port = normalizePort(process.env.PORT || "3000");
app.set("port", port);
/**
* Create HTTP server.
@@ -26,8 +26,8 @@ var server = http.createServer(app);
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
server.on("error", onError);
server.on("listening", onListening);
/**
* Normalize a port into a number, string, or false.
@@ -54,22 +54,20 @@ function normalizePort(val) {
*/
function onError(error) {
if (error.syscall !== 'listen') {
if (error.syscall !== "listen") {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
var bind = typeof port === "string" ? "Pipe " + port : "Port " + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
case "EACCES":
console.error(bind + " requires elevated privileges");
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
case "EADDRINUSE":
console.error(bind + " is already in use");
process.exit(1);
break;
default:
@@ -83,8 +81,7 @@ function onError(error) {
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
debug('Listening on ' + bind);
var bind = typeof addr === "string" ? "pipe " + addr : "port " + addr.port;
debug("Listening on " + bind);
console.log("Server is running on port " + bind);
}
+30
View File
@@ -0,0 +1,30 @@
module.exports = (sequelize, DataTypes) => {
const analytic = sequelize.define(
"analytic",
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
create_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
},
widget_name: {
type: DataTypes.STRING,
allowNull: false,
},
browser_type: {
type: DataTypes.STRING,
allowNull: false,
},
},
{
timestamps: false,
freezeTableName: true,
tableName: "analytic",
}
);
return analytic;
};
+83
View File
@@ -0,0 +1,83 @@
"use strict";
/*Powered By: Manaknightdigital Inc. https://manaknightdigital.com/ Year: 2020*/
/**
* Sequelize File
* @copyright 2020 Manaknightdigital Inc.
* @link https://manaknightdigital.com
* @license Proprietary Software licensing
* @author Ryan Wong
*
*/
const fs = require("fs");
const path = require("path");
let Sequelize = require("sequelize");
const basename = path.basename(__filename);
const { DataTypes } = require("sequelize");
const config = {
DB_DATABASE: "mysql",
DB_USERNAME: "root",
DB_PASSWORD: process.env.DB_PASSWORD || "root",
DB_ADAPTER: "mysql",
DB_NAME: "day_17",
DB_HOSTNAME: "localhost",
DB_PORT: 3306,
};
let db = {};
let sequelize = new Sequelize(
config.DB_NAME,
config.DB_USERNAME,
config.DB_PASSWORD,
{
dialect: config.DB_ADAPTER,
username: config.DB_USERNAME,
password: config.DB_PASSWORD,
database: config.DB_NAME,
host: config.DB_HOSTNAME,
port: config.DB_PORT,
logging: console.log,
timezone: "-04:00",
pool: {
maxConnections: 1,
minConnections: 0,
maxIdleTime: 100,
},
define: {
timestamps: false,
underscoredAll: true,
underscored: true,
},
}
);
sequelize
.sync()
.then(() => {
console.log("Database & tables created!");
})
.catch((err) => {
console.log(err);
});
fs.readdirSync(__dirname)
.filter((file) => {
return (
file.indexOf(".") !== 0 && file !== basename && file.slice(-3) === ".js"
);
})
.forEach((file) => {
var model = require(path.join(__dirname, file))(sequelize, DataTypes);
db[model.name] = model;
});
Object.keys(db).forEach((modelName) => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
db.sequelize = sequelize;
db.Sequelize = Sequelize;
module.exports = db;
+26
View File
@@ -0,0 +1,26 @@
module.exports = (sequelize, DataTypes) => {
const location = sequelize.define(
"location",
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: DataTypes.STRING,
created_at: DataTypes.DATEONLY,
updated_at: DataTypes.DATE,
},
{
timestamps: true,
freezeTableName: true,
tableName: "location",
},
{
underscoredAll: false,
underscored: false,
}
);
return location;
};
+2551 -655
View File
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -3,9 +3,14 @@
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./bin/www"
"start": "node ./bin/www",
"build-css": "npx @tailwindcss/cli -i ./public/stylesheets/style.css -o ./public/stylesheets/output.css",
"dev": "npm run build-css & node --watch --env-file=.env ./bin/www",
"commit": "git add . && git commit -m \"Update Project purpose\" && git push"
},
"dependencies": {
"@tailwindcss/cli": "^4.1.11",
"@tailwindcss/postcss": "^4.1.11",
"cookie-parser": "~1.4.4",
"debug": "~2.6.9",
"express": "~4.16.1",
@@ -13,7 +18,15 @@
"mariadb": "^3.0.1",
"morgan": "~1.9.1",
"mysql2": "^2.3.3",
"node-fetch": "^2.7.0",
"ol": "^7.1.0",
"pug": "^3.0.2",
"sequelize": "^6.21.6"
"sequelize": "^6.21.6",
"xmlbuilder2": "^3.1.1"
},
"devDependencies": {
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+424
View File
@@ -0,0 +1,424 @@
// main.js
// Function to update weather widget
async function updateWeather() {
try {
const res = await fetch("/api/weather");
if (!res.ok) throw new Error("Failed to fetch weather");
const data = await res.json();
const weatherDiv = document.getElementById("weather-widget");
if (!weatherDiv) return;
// Set temperature
weatherDiv.querySelector(".weather-temp").textContent = `${data.temp_c} °C`;
// Set icon
const icon = weatherDiv.querySelector(".weather-icon");
if (data.condition === "sunny") {
icon.src = "https://cdn-icons-png.flaticon.com/512/869/869869.png";
icon.alt = "Sunny";
} else if (data.condition === "rain") {
icon.src = "https://cdn-icons-png.flaticon.com/512/414/414974.png";
icon.alt = "Rainy";
} else if (data.condition === "snow") {
icon.src = "https://cdn-icons-png.flaticon.com/512/642/642102.png";
icon.alt = "Snowy";
}
} catch (err) {
// Show error in widget
const weatherDiv = document.getElementById("weather-widget");
if (weatherDiv) {
weatherDiv.querySelector(".weather-temp").textContent = "N/A";
weatherDiv.querySelector(".weather-icon").src = "";
weatherDiv.querySelector(".weather-icon").alt = "Error";
}
}
}
// Initial load
updateWeather();
// Update every 5 minutes
setInterval(updateWeather, 5 * 60 * 1000);
// --- Time Widget Logic ---
// Time zone offsets in hours relative to UTC
const timeZones = {
london: 0, // UTC+0 (BST not handled for simplicity)
est: -4, // UTC-4 (EDT, adjust for DST if needed)
nigeria: 1, // UTC+1
pakistan: 5, // UTC+5
};
function pad(n) {
return n < 10 ? "0" + n : n;
}
async function updateClocks() {
try {
const res = await fetch("/api/utc");
if (!res.ok) throw new Error("Failed to fetch UTC time");
const { utc } = await res.json();
const utcDate = new Date(utc);
// Update each clock
for (const [zone, offset] of Object.entries(timeZones)) {
const local = new Date(utcDate.getTime() + offset * 60 * 60 * 1000);
const h = pad(local.getUTCHours());
const m = pad(local.getUTCMinutes());
const s = pad(local.getUTCSeconds());
const el = document.getElementById(`${zone}-clock`);
if (el) el.textContent = `${h}:${m}:${s}`;
}
} catch (err) {
// Show error in all clocks
for (const zone of Object.keys(timeZones)) {
const el = document.getElementById(`${zone}-clock`);
if (el) el.textContent = "N/A";
}
}
}
// Initial load and update every second
updateClocks();
setInterval(updateClocks, 1000);
// --- Airport Autocomplete ---
// const airportInput = document.querySelector('input[placeholder*="airport"]');
// let dropdownDiv;
// if (airportInput) {
// console.log("hi");
// dropdownDiv = document.createElement("div");
// dropdownDiv.className =
// "absolute bg-white border border-gray-300 rounded shadow z-10 w-full max-h-48 overflow-y-auto top-[calc(100%+10px)]";
// dropdownDiv.style.display = "none";
// airportInput.parentNode.appendChild(dropdownDiv);
// airportInput.addEventListener("input", async function () {
// const val = airportInput.value.trim();
// if (val.length < 3) {
// dropdownDiv.style.display = "none";
// return;
// }
// try {
// const res = await fetch(`/airports?search=${encodeURIComponent(val)}`);
// if (!res.ok) throw new Error("Failed to fetch airports");
// const airports = await res.json();
// if (!Array.isArray(airports) || airports.length === 0) {
// dropdownDiv.innerHTML =
// '<div class="p-2 text-gray-500">No results</div>';
// dropdownDiv.style.display = "block";
// return;
// }
// dropdownDiv.innerHTML = airports
// .map(
// (a, i) =>
// `<div class="p-2 hover:bg-sky-100 cursor-pointer" data-index="${i}">${a.name}</div>`
// )
// .join("");
// dropdownDiv.style.display = "block";
// Array.from(dropdownDiv.children).forEach((child, i) => {
// child.addEventListener("click", () => {
// airportInput.value = child.textContent;
// dropdownDiv.style.display = "none";
// selectedAirport = airports[i];
// console.log("hey");
// airportInput.textContent = `hold`;
// if (selectedAirport) {
// console.log(selectedAirport);
// airportInput.value = `${selectedAirport.name} (${selectedAirport.code})`;
// showMap(
// Number(selectedAirport.latitude_deg),
// Number(selectedAirport.longitude_deg)
// );
// }
// });
// });
// } catch (err) {
// dropdownDiv.innerHTML =
// '<div class="p-2 text-red-500">Error loading airports</div>';
// dropdownDiv.style.display = "block";
// }
// });
// // Hide dropdown on blur
// airportInput.addEventListener("blur", () =>
// setTimeout(() => (dropdownDiv.style.display = "none"), 200)
// );
// }
// --- Airport Autocomplete ---
const airportInput = document.querySelector('input[placeholder*="airport"]');
let dropdownDiv;
let selectedAirport; // Declare globally to store selection
if (airportInput) {
dropdownDiv = document.createElement("div");
dropdownDiv.className =
"absolute bg-white border border-gray-300 rounded shadow z-10 w-full max-h-48 overflow-y-auto top-[calc(100%+10px)]";
dropdownDiv.style.display = "none";
dropdownDiv.setAttribute("role", "listbox"); // ARIA role
airportInput.parentNode.appendChild(dropdownDiv);
// Event Delegation for dropdown clicks (fixes listener duplication)
dropdownDiv.addEventListener("click", (e) => {
const item = e.target.closest("[data-index]");
if (!item) return;
const index = item.dataset.index;
selectedAirport = currentAirports[index]; // Use latest fetched data
airportInput.value = `${selectedAirport.name} (${selectedAirport.code})`; // Set once
// showMap(
// Number(selectedAirport.latitude_deg),
// Number(selectedAirport.longitude_deg)
// );
onAirportSelected(selectedAirport);
dropdownDiv.style.display = "none";
});
// Debounce input (300ms delay)
let debounceTimer;
let currentAirports = []; // Track latest results
airportInput.addEventListener("input", async function () {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const val = airportInput.value.trim();
if (val.length < 3) {
dropdownDiv.style.display = "none";
return;
}
try {
const res = await fetch(`/airports?search=${encodeURIComponent(val)}`);
if (!res.ok) throw new Error("Failed to fetch");
const airports = await res.json();
currentAirports = airports; // Store for click handler
if (!airports.length) {
dropdownDiv.innerHTML =
'<div class="p-2 text-gray-500">No results</div>';
dropdownDiv.style.display = "block";
return;
}
// Escape HTML to prevent XSS
dropdownDiv.innerHTML = airports
.map((a, i) => {
const name = a.name.replace(
/[&<>]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c])
);
return `<div class="p-2 hover:bg-sky-100 cursor-pointer"
data-index="${i}"
role="option"
aria-label="${name} (${a.code})">
${name}
</div>`;
})
.join("");
dropdownDiv.style.display = "block";
} catch (err) {
dropdownDiv.innerHTML =
'<div class="p-2 text-red-500">Error loading airports</div>';
dropdownDiv.style.display = "block";
}
}, 300);
});
// Hide dropdown only when focus leaves input/dropdown
document.addEventListener("click", (e) => {
if (!airportInput.contains(e.target) && !dropdownDiv.contains(e.target)) {
dropdownDiv.style.display = "none";
}
});
}
// --- Map Widget Logic ---
// let selectedAirport = null;
function showMap(lat, lon) {
console.log(lat, lon);
const mapDiv = document.getElementById("map-widget");
if (!mapDiv) return;
mapDiv.innerHTML = "";
mapDiv.style.height = "200px";
mapDiv.style.width = "300px";
// OpenLayers map
if (window.ol && window.ol.Map) {
new ol.Map({
target: mapDiv,
layers: [new ol.layer.Tile({ source: new ol.source.OSM() })],
view: new ol.View({
center: ol.proj.fromLonLat([-79.6248, 43.6532]),
zoom: 13,
}),
});
} else {
mapDiv.innerHTML =
'<div class="text-center text-gray-500">Map library not loaded</div>';
}
}
// --- Distance from Arctic Circle Widget ---
function haversine(lat1, lon1, lat2, lon2) {
const toRad = (deg) => (deg * Math.PI) / 180;
const R = 6371; // Earth radius in km
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function updateDistanceWidget(lat, lon) {
const distDiv = document.getElementById("distance-widget");
if (!distDiv) return;
// Arctic Circle: latitude 66.56333, longitude 0
const arcticLat = 66.56333;
const arcticLon = 0;
const dist = haversine(arcticLat, arcticLon, lat, lon);
distDiv.textContent = dist.toFixed(1) + " KM";
}
// Update distance when airport is selected
function onAirportSelected(airport) {
if (
airport &&
airport.latitude_deg &&
airport.longitude_deg &&
!isNaN(Number(airport.latitude_deg)) &&
!isNaN(Number(airport.longitude_deg))
) {
showMap(Number(airport.latitude_deg), Number(airport.longitude_deg));
updateDistanceWidget(
Number(airport.latitude_deg),
Number(airport.longitude_deg)
);
}
}
// --- Analytic Logging ---
function logWidgetClick(widgetName) {
fetch("/analytic", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
widget_name: widgetName,
browser_type: navigator.userAgent,
}),
});
}
// --- Click Counter Widget ---
async function updateClickCounter() {
try {
const res = await fetch("/analytic/count");
if (!res.ok) throw new Error("Failed to fetch count");
const { count } = await res.json();
const el = document.getElementById("click-counter-widget");
if (el) el.textContent = count;
} catch (err) {
const el = document.getElementById("click-counter-widget");
if (el) el.textContent = "N/A";
}
}
updateClickCounter();
setInterval(updateClickCounter, 60 * 1000);
// --- Export XML Widget ---
const exportBtn = document.querySelector(
"button.bg-sky-400.text-white.font-semibold.px-8.py-2.rounded"
);
if (exportBtn && exportBtn.textContent.trim().toLowerCase() === "export") {
exportBtn.addEventListener("click", function () {
window.location.href = "/analytic/export";
});
}
// --- Reddit News Widget ---
async function updateRedditNews() {
try {
const res = await fetch("/reddit");
if (!res.ok) throw new Error("Failed to fetch reddit");
const posts = await res.json();
const newsDiv = document.getElementById("news-widget");
if (!newsDiv) return;
newsDiv.innerHTML = posts
.map(
(post) => `
<div class="bg-gray-100 p-2 rounded mb-2">
<a href="${post.url}" target="_blank" class="font-semibold text-blue-700 hover:underline">${post.title}</a>
<div class="text-xs text-gray-500">by ${post.author}</div>
</div>
`
)
.join("");
} catch (err) {
const newsDiv = document.getElementById("news-widget");
if (newsDiv)
newsDiv.innerHTML = '<div class="text-red-500">Failed to load news</div>';
}
}
updateRedditNews();
// --- Coin Calculator Widget ---
const coinInput = document.querySelector('input[placeholder*="coin"]');
const coinBtn = document.querySelector(
"button.bg-sky-400.text-white.font-semibold.px-8.py-2.rounded.mt-2"
);
const coinResult = document.getElementById("coin-result-list");
if (coinInput && coinBtn && coinResult) {
coinBtn.addEventListener("click", async function () {
const val = coinInput.value.trim();
if (!val || isNaN(val) || Number(val) < 0) {
coinResult.innerHTML =
'<li class="text-red-500">Enter a valid amount</li>';
return;
}
try {
const res = await fetch("/coin-calc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: val }),
});
if (!res.ok) throw new Error("Failed to calculate");
const data = await res.json();
coinResult.innerHTML = data
.map((c) => `<li>${c.count} x $${c.denomination}</li>`)
.join("");
} catch (err) {
coinResult.innerHTML =
'<li class="text-red-500">Error calculating coins</li>';
}
});
}
// Attach click listeners to widgets
function attachAnalyticListeners() {
const widgets = [
{ id: "weather-widget", name: "weather" },
{ id: "nigeria-clock", name: "nigeria-clock" },
{ id: "london-clock", name: "london-clock" },
{ id: "est-clock", name: "est-clock" },
{ id: "pakistan-clock", name: "pakistan-clock" },
{ id: "map-widget", name: "map" },
{ id: "distance-widget", name: "distance" },
{ id: "airport-autocomplete", name: "airport-autocomplete" },
{ id: "click-counter-widget", name: "click-counter" },
// Add more as needed
];
widgets.forEach((w) => {
const el = document.getElementById(w.id);
if (el) {
el.addEventListener("click", () => logWidgetClick(w.name));
}
});
}
// Call after DOMContentLoaded
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", attachAnalyticListeners);
} else {
attachAnalyticListeners();
}
+576
View File
@@ -0,0 +1,576 @@
/*! tailwindcss v4.1.11 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--color-red-500: oklch(63.7% 0.237 25.331);
--color-sky-100: oklch(95.1% 0.026 236.824);
--color-sky-400: oklch(74.6% 0.16 232.661);
--color-blue-100: oklch(93.2% 0.032 255.585);
--color-gray-100: oklch(96.7% 0.003 264.542);
--color-gray-300: oklch(87.2% 0.01 258.338);
--color-gray-500: oklch(55.1% 0.027 264.364);
--color-white: #fff;
--spacing: 0.25rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-lg: 1.125rem;
--text-lg--line-height: calc(1.75 / 1.125);
--text-3xl: 1.875rem;
--text-3xl--line-height: calc(2.25 / 1.875);
--text-4xl: 2.25rem;
--text-4xl--line-height: calc(2.5 / 2.25);
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--radius-lg: 0.5rem;
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
}
@layer utilities {
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.top-\[calc\(100\%\+10px\)\] {
top: calc(100% + 10px);
}
.z-10 {
z-index: 10;
}
.col-span-1 {
grid-column: span 1 / span 1;
}
.row-span-2 {
grid-row: span 2 / span 2;
}
.mt-2 {
margin-top: calc(var(--spacing) * 2);
}
.mt-8 {
margin-top: calc(var(--spacing) * 8);
}
.mb-1 {
margin-bottom: calc(var(--spacing) * 1);
}
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
.ml-2 {
margin-left: calc(var(--spacing) * 2);
}
.block {
display: block;
}
.flex {
display: flex;
}
.grid {
display: grid;
}
.table {
display: table;
}
.h-24 {
height: calc(var(--spacing) * 24);
}
.h-244 {
height: calc(var(--spacing) * 244);
}
.h-full {
height: 100%;
}
.h-max {
height: max-content;
}
.max-h-48 {
max-height: calc(var(--spacing) * 48);
}
.max-h-\[300px\] {
max-height: 300px;
}
.min-h-screen {
min-height: 100vh;
}
.w-24 {
width: calc(var(--spacing) * 24);
}
.w-full {
width: 100%;
}
.cursor-pointer {
cursor: pointer;
}
.list-inside {
list-style-position: inside;
}
.list-disc {
list-style-type: disc;
}
.grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.justify-center {
justify-content: center;
}
.gap-8 {
gap: calc(var(--spacing) * 8);
}
.space-y-2 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
}
}
.overflow-y-auto {
overflow-y: auto;
}
.rounded {
border-radius: 0.25rem;
}
.rounded-lg {
border-radius: var(--radius-lg);
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
}
.border-gray-300 {
border-color: var(--color-gray-300);
}
.bg-blue-100 {
background-color: var(--color-blue-100);
}
.bg-gray-100 {
background-color: var(--color-gray-100);
}
.bg-inherit {
background-color: inherit;
}
.bg-sky-400 {
background-color: var(--color-sky-400);
}
.bg-white {
background-color: var(--color-white);
}
.p-2 {
padding: calc(var(--spacing) * 2);
}
.p-4 {
padding: calc(var(--spacing) * 4);
}
.p-6 {
padding: calc(var(--spacing) * 6);
}
.p-8 {
padding: calc(var(--spacing) * 8);
}
.px-8 {
padding-inline: calc(var(--spacing) * 8);
}
.py-2 {
padding-block: calc(var(--spacing) * 2);
}
.text-center {
text-align: center;
}
.font-mono {
font-family: var(--font-mono);
}
.text-3xl {
font-size: var(--text-3xl);
line-height: var(--tw-leading, var(--text-3xl--line-height));
}
.text-4xl {
font-size: var(--text-4xl);
line-height: var(--tw-leading, var(--text-4xl--line-height));
}
.text-lg {
font-size: var(--text-lg);
line-height: var(--tw-leading, var(--text-lg--line-height));
}
.text-sm {
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
}
.text-xs {
font-size: var(--text-xs);
line-height: var(--tw-leading, var(--text-xs--line-height));
}
.font-bold {
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
}
.font-medium {
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
}
.font-semibold {
--tw-font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-semibold);
}
.text-gray-500 {
color: var(--color-gray-500);
}
.text-red-500 {
color: var(--color-red-500);
}
.text-white {
color: var(--color-white);
}
.shadow {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-md {
--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.blur {
--tw-blur: blur(8px);
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
.outline-none {
--tw-outline-style: none;
outline-style: none;
}
.hover\:bg-sky-100 {
&:hover {
@media (hover: hover) {
background-color: var(--color-sky-100);
}
}
}
}
@property --tw-space-y-reverse {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-font-weight {
syntax: "*";
inherits: false;
}
@property --tw-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-inset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-ring-inset {
syntax: "*";
inherits: false;
}
@property --tw-ring-offset-width {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --tw-ring-offset-color {
syntax: "*";
inherits: false;
initial-value: #fff;
}
@property --tw-ring-offset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-blur {
syntax: "*";
inherits: false;
}
@property --tw-brightness {
syntax: "*";
inherits: false;
}
@property --tw-contrast {
syntax: "*";
inherits: false;
}
@property --tw-grayscale {
syntax: "*";
inherits: false;
}
@property --tw-hue-rotate {
syntax: "*";
inherits: false;
}
@property --tw-invert {
syntax: "*";
inherits: false;
}
@property --tw-opacity {
syntax: "*";
inherits: false;
}
@property --tw-saturate {
syntax: "*";
inherits: false;
}
@property --tw-sepia {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-drop-shadow-size {
syntax: "*";
inherits: false;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-space-y-reverse: 0;
--tw-border-style: solid;
--tw-font-weight: initial;
--tw-shadow: 0 0 #0000;
--tw-shadow-color: initial;
--tw-shadow-alpha: 100%;
--tw-inset-shadow: 0 0 #0000;
--tw-inset-shadow-color: initial;
--tw-inset-shadow-alpha: 100%;
--tw-ring-color: initial;
--tw-ring-shadow: 0 0 #0000;
--tw-inset-ring-color: initial;
--tw-inset-ring-shadow: 0 0 #0000;
--tw-ring-inset: initial;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
--tw-grayscale: initial;
--tw-hue-rotate: initial;
--tw-invert: initial;
--tw-opacity: initial;
--tw-saturate: initial;
--tw-sepia: initial;
--tw-drop-shadow: initial;
--tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial;
}
}
}
+1 -8
View File
@@ -1,8 +1 @@
body {
padding: 50px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}
a {
color: #00B7FF;
}
@import "tailwindcss";
+159 -3
View File
@@ -1,9 +1,165 @@
var express = require('express');
var express = require("express");
var router = express.Router();
var path = require("path");
const fetch = require("node-fetch");
const fs = require("fs");
const airportData = JSON.parse(
fs.readFileSync(path.join(__dirname, "../airportdata.json"), "utf8")
);
const db = require("../models");
const { create } = require("xmlbuilder2");
/* GET home page. */
router.get('/', function(req, res, next) {
res.render('index', { title: 'Express' });
router.get("/", function (req, res, next) {
res.sendFile(path.join(__dirname, "../views/index.html"));
});
// Weather API route
router.get("/api/weather", async function (req, res) {
try {
// Replace with your actual API key and city
const apiKey = process.env.WEATHER_API_KEY;
const city = "Toronto";
const url = `http://api.weatherapi.com/v1/current.json?key=${apiKey}&q=${city}&aqi=no`;
const response = await fetch(url);
if (!response.ok) throw new Error("Weather API error");
const data = await response.json();
const temp_c = data.current.temp_c;
const text = data.current.condition.text.toLowerCase();
let condition = "sunny";
if (text.includes("rain")) condition = "rain";
else if (text.includes("snow")) condition = "snow";
res.json({ temp_c, condition });
} catch (err) {
res.status(500).json({ error: "Failed to fetch weather" });
console.error(err);
}
});
// UTC time API route
router.get("/api/utc", function (req, res) {
res.json({ utc: new Date().toISOString() });
});
// Autocomplete airports
router.get("/airports", function (req, res) {
const search = (req.query.search || "").trim();
if (search.length < 3) {
return res
.status(400)
.json({ error: "Search term must be at least 3 characters" });
}
const term = search.toLowerCase();
const matches = airportData
.filter(
(a) =>
(a.name && a.name.toLowerCase().includes(term)) ||
(a.code && a.code.toLowerCase().includes(term))
)
.slice(0, 10);
res.json(matches);
});
// Log analytic event
router.post("/analytic", async function (req, res) {
try {
const { widget_name, browser_type } = req.body;
if (!widget_name || !browser_type) {
return res
.status(400)
.json({ error: "widget_name and browser_type required" });
}
await db.analytic.create({ widget_name, browser_type });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: "Failed to log analytic" });
}
});
// Count analytic rows
router.get("/analytic/count", async function (req, res) {
try {
const count = await db.analytic.count();
res.json({ count });
} catch (err) {
res.status(500).json({ error: "Failed to count analytics" });
}
});
// Export analytic as XML
router.get("/analytic/export", async function (req, res) {
try {
const analytics = await db.analytic.findAll({ raw: true });
const root = create({ version: "1.0" }).ele("analytics");
analytics.forEach((a) => {
root
.ele("analytic")
.ele("id")
.txt(a.id)
.up()
.ele("create_at")
.txt(a.create_at)
.up()
.ele("widget_name")
.txt(a.widget_name)
.up()
.ele("browser_type")
.txt(a.browser_type)
.up()
.up();
});
const xml = root.end({ prettyPrint: true });
res.set("Content-Type", "application/xml");
res.set("Content-Disposition", 'attachment; filename="analytics.xml"');
res.send(xml);
} catch (err) {
res.status(500).json({ error: "Failed to export analytics" });
}
});
// Reddit widget route
router.get("/reddit", async function (req, res) {
try {
const response = await fetch("https://www.reddit.com/r/programming.json");
if (!response.ok) throw new Error("Reddit API error");
const data = await response.json();
const posts = (data.data.children || [])
.map((c) => c.data)
.filter((_, i) => i % 2 === 0)
.slice(0, 4)
.map((post) => ({
title: post.title,
url: "https://reddit.com" + post.permalink,
author: post.author,
}));
res.json(posts);
} catch (err) {
res.status(500).json({ error: "Failed to fetch reddit posts" });
}
});
// Coin calculator route
router.post("/coin-calc", function (req, res) {
try {
let { amount } = req.body;
amount = parseFloat(amount);
if (isNaN(amount) || amount < 0)
return res.status(400).json({ error: "Invalid amount" });
const denoms = [20, 10, 5, 1, 0.25, 0.1, 0.05, 0.01];
const result = [];
let remaining = Math.round(amount * 100); // work in cents
for (let d of denoms) {
let denomCents = Math.round(d * 100);
let count = Math.floor(remaining / denomCents);
if (count > 0) {
result.push({ denomination: d, count });
remaining -= count * denomCents;
}
}
res.json(result);
} catch (err) {
res.status(500).json({ error: "Failed to calculate coins" });
}
});
module.exports = router;
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./views/**/*.{html,ejs,pug,hbs}", "./public/**/*.{html,js}"],
};
+153
View File
@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dashboard</title>
<!-- <script src="
https://cdn.jsdelivr.net/npm/tailwindcss@4.1.11/dist/lib.min.js
"></script> -->
<!-- <script src="https://cdn.jsdelivr.net/npm/tailwindcss@latest"></script>`` -->
<link rel="stylesheet" href="/stylesheets/output.css" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/ol@v7.4.0/ol.css"
/>
<script src="https://cdn.jsdelivr.net/npm/ol@v7.4.0/dist/ol.js"></script>
</head>
<body>
<div
class="min-h-screen bg-sky-400 flex flex-col items-center justify-center p-8"
>
<div class="grid grid-cols-5 gap-8 bg-inherit">
<!-- Weather Card -->
<div
id="weather-widget"
class="flex flex-col items-center bg-white rounded-lg shadow-md p-6 row-span-2"
>
<img
class="weather-icon w-24 h-24 mb-2"
src="https://cdn-icons-png.flaticon.com/512/869/869869.png"
alt="Weather"
/>
<div class="weather-temp text-3xl font-semibold mt-2">34 &deg;C</div>
</div>
<!-- Dropdown -->
<div
class="col-span-1 flex items-center justify-center bg-white rounded-lg shadow-md p-4 relative"
>
<input
type="text"
placeholder="Dropdown autocomplete of airport"
class="w-full outline-none"
/>
<span class="ml-2 font-bold text-lg">&#9660;</span>
</div>
<!-- Nigeria Time -->
<div
class="bg-white rounded-lg shadow-md p-6 flex flex-col items-center"
>
<div class="text-sm font-medium text-gray-500">Nigeria Time</div>
<div class="text-3xl font-mono mt-2" id="nigeria-clock">23:01:05</div>
</div>
<!-- London Time -->
<div
class="bg-white rounded-lg shadow-md p-6 flex flex-col items-center"
>
<div class="text-sm font-medium text-gray-500">London Time</div>
<div class="text-3xl font-mono mt-2" id="london-clock">23:01:05</div>
</div>
<!-- EST Time -->
<div
class="bg-white rounded-lg shadow-md p-6 flex flex-col items-center"
>
<div class="text-sm font-medium text-gray-500">EST Time</div>
<div class="text-3xl font-mono mt-2" id="est-clock">23:01:05</div>
</div>
<!-- Click Counter -->
<div
class="bg-white rounded-lg shadow-md p-6 flex flex-col items-center mt-8"
>
<div class="text-xs text-gray-500 mb-1">Number of Clicks</div>
<div class="text-4xl font-bold" id="click-counter-widget">0</div>
</div>
<!-- Export XML -->
<div
class="bg-white rounded-lg shadow-md p-6 flex flex-col items-center mt-8"
>
<div class="text-xs text-gray-500 mb-2">Export XML</div>
<button class="bg-sky-400 text-white font-semibold px-8 py-2 rounded">
Export
</button>
</div>
<!-- Map -->
<div
class="row-span-2 bg-white rounded-lg shadow-md p-2 flex items-center justify-center"
>
<div id="map-widget" class="w-full h-full"></div>
</div>
<!-- Distance -->
<div
class="bg-white rounded-lg shadow-md p-6 flex flex-col items-center mt-8"
>
<div class="text-xs text-gray-500 mb-1">Distance from Arctic</div>
<div class="text-4xl font-bold" id="distance-widget">N/A</div>
</div>
<!-- Pakistan Time -->
<div
class="bg-white rounded-lg shadow-md p-6 flex flex-col items-center"
>
<div class="text-sm font-medium text-gray-500">Pakistan Time</div>
<div class="text-3xl font-mono mt-2" id="pakistan-clock">
23:01:05
</div>
</div>
<!-- News List -->
<div
class="row-span-2 bg-white rounded-lg shadow-md p-4 flex flex-col justify-between"
>
<div class="text-xs font-semibold mb-2">News</div>
<div class="overflow-y-auto h-full max-h-[300px]">
<div id="news-widget" class="h-max"></div>
</div>
</div>
<!-- Coin Calculator -->
<div
class="bg-white rounded-lg shadow-md p-6 flex flex-col items-center"
>
<div class="text-xs text-gray-500 mb-1">Count # of coins</div>
<input
type="number"
step="0.01"
min="0"
placeholder="Enter amount for coin calculator"
class="mb-2 p-2 border rounded w-full text-center"
/>
<button
class="bg-sky-400 text-white font-semibold px-8 py-2 rounded mt-2"
>
Calculate
</button>
<ul
id="coin-result-list"
class="text-xs list-disc list-inside mt-2"
></ul>
</div>
<!-- Upload -->
<div
class="bg-white rounded-lg shadow-md p-6 flex flex-col items-center justify-center mt-8"
>
<div
class="w-24 h-24 flex items-center justify-center bg-blue-100 rounded mb-2"
>
<span class="text-4xl">&#128247;</span>
</div>
<button class="bg-sky-400 text-white font-semibold px-8 py-2 rounded">
Upload
</button>
</div>
</div>
</div>
<script src="/main.js"></script>
</body>
</html>
-5
View File
@@ -1,5 +0,0 @@
extends layout
block content
h1= title
p Welcome to #{title}