Compare commits
12 Commits
27fe7a03e2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 08615ff590 | |||
| bd70df60b9 | |||
| fe95626d9f | |||
| a5dbf762b6 | |||
| 1ccca077fa | |||
| 5524cabab4 | |||
| d3586f8cab | |||
| 3f73ca3b44 | |||
| 40fb916437 | |||
| fbbadd6fbf | |||
| 845d0eea7b | |||
| f2c4f790ca |
@@ -0,0 +1,5 @@
|
||||
PORT=8000
|
||||
WEATHER_API_KEY=de18eeaec53e4caca18170027252507
|
||||
DB_PASSWORD=ayobamidavid
|
||||
STRIPE_SECRET_KEY=sk_test_51IWQUwH8oljXErmds28KftkL6o6jYIcPgYbBdfEmCPSuAlIh0fgoS4NADcCmsIZbdQ3p5nbAeCOcGkSmo38U9BIe00BdOenrqo
|
||||
STRIPE_PUBLIC_KEY=pk_test_51IWQUwH8oljXErmdg6L4MhsuB6tDdmumlHFfyNaopty2U27pmRcqMX1c868zn838lGQtU1eYV6bKRSQtMFWf36VT00aNsvnTOE
|
||||
@@ -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`).
|
||||
File diff suppressed because one or more lines are too long
@@ -1,26 +1,41 @@
|
||||
var createError = require('http-errors');
|
||||
var express = require('express');
|
||||
var path = require('path');
|
||||
var cookieParser = require('cookie-parser');
|
||||
var logger = require('morgan');
|
||||
var createError = require("http-errors");
|
||||
var express = require("express");
|
||||
var path = require("path");
|
||||
var cookieParser = require("cookie-parser");
|
||||
var logger = require("morgan");
|
||||
var session = require("express-session");
|
||||
|
||||
var indexRouter = require('./routes/index');
|
||||
var usersRouter = require('./routes/users');
|
||||
var indexRouter = require("./routes/index");
|
||||
var usersRouter = require("./routes/users");
|
||||
|
||||
var app = express();
|
||||
|
||||
// view engine setup
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.set('view engine', 'pug');
|
||||
app.set("views", path.join(__dirname, "views"));
|
||||
app.set("view engine", "pug");
|
||||
|
||||
app.use(logger('dev'));
|
||||
app.use(logger("dev"));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(cookieParser());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
app.use('/', indexRouter);
|
||||
app.use('/users', usersRouter);
|
||||
// Session middleware for 2FA
|
||||
app.use(
|
||||
session({
|
||||
secret:
|
||||
process.env.SESSION_SECRET || "your-secret-key-change-in-production",
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
},
|
||||
})
|
||||
);
|
||||
app.use(express.static(path.join(__dirname, "public")));
|
||||
|
||||
app.use("/", indexRouter);
|
||||
app.use("/users", usersRouter);
|
||||
|
||||
// catch 404 and forward to error handler
|
||||
app.use(function (req, res, next) {
|
||||
@@ -31,11 +46,11 @@ app.use(function (req, res, next) {
|
||||
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 : {};
|
||||
res.locals.error = req.app.get("env") === "development" ? err : {};
|
||||
|
||||
// render the error page
|
||||
res.status(err.status || 500);
|
||||
res.render('error');
|
||||
res.render("error");
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const chat = sequelize.define(
|
||||
"chat",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
create_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
chat_messages: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: false,
|
||||
freezeTableName: true,
|
||||
tableName: "chat",
|
||||
}
|
||||
);
|
||||
return chat;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const flow = sequelize.define(
|
||||
"flow",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: false,
|
||||
freezeTableName: true,
|
||||
tableName: "flow",
|
||||
}
|
||||
);
|
||||
return flow;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const flow_log = sequelize.define(
|
||||
"flow_log",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
flow_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
task_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
result: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: "pending",
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: false,
|
||||
freezeTableName: true,
|
||||
tableName: "flow_logs",
|
||||
}
|
||||
);
|
||||
return flow_log;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const task = sequelize.define(
|
||||
"task",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
flow_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "flow",
|
||||
key: "id",
|
||||
},
|
||||
},
|
||||
action_type: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
input_data: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
order_index: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: false,
|
||||
freezeTableName: true,
|
||||
tableName: "task",
|
||||
}
|
||||
);
|
||||
return task;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const upload = sequelize.define(
|
||||
"upload",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
filename: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
mimetype: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
path: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: false,
|
||||
freezeTableName: true,
|
||||
tableName: "upload",
|
||||
}
|
||||
);
|
||||
return upload;
|
||||
};
|
||||
Generated
+3974
-611
File diff suppressed because it is too large
Load Diff
+21
-2
@@ -3,17 +3,36 @@
|
||||
"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",
|
||||
"express-session": "^1.17.3",
|
||||
"http-errors": "~1.6.3",
|
||||
"mariadb": "^3.0.1",
|
||||
"morgan": "~1.9.1",
|
||||
"multer": "^2.0.2",
|
||||
"mysql2": "^2.3.3",
|
||||
"node-fetch": "^2.7.0",
|
||||
"ol": "^7.1.0",
|
||||
"pug": "^3.0.2",
|
||||
"sequelize": "^6.21.6"
|
||||
"redis": "^5.6.1",
|
||||
"sequelize": "^6.21.6",
|
||||
"stripe": "^18.3.0",
|
||||
"speakeasy": "^2.0.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"xmlbuilder2": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
const messageInput = document.getElementById("message-input");
|
||||
const sendBtn = document.getElementById("send-btn");
|
||||
const messageList = document.getElementById("message-list");
|
||||
const saveBtn = document.getElementById("save-btn");
|
||||
const chatMessages = document.getElementById("chat-messages");
|
||||
|
||||
let lastMessageCount = 0;
|
||||
|
||||
// Send message
|
||||
async function sendMessage() {
|
||||
const message = messageInput.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
messageInput.value = "";
|
||||
await fetchMessages();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to send message:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all messages
|
||||
async function fetchMessages() {
|
||||
try {
|
||||
const response = await fetch("/chat/all");
|
||||
const messages = await response.json();
|
||||
|
||||
messageList.innerHTML = "";
|
||||
messages.forEach((msg) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "p-2 bg-gray-100 rounded";
|
||||
li.innerHTML = `
|
||||
<div class="text-sm text-gray-600">${new Date(
|
||||
msg.timestamp
|
||||
).toLocaleString()}</div>
|
||||
<div class="font-medium">${msg.message}</div>
|
||||
`;
|
||||
messageList.appendChild(li);
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
lastMessageCount = messages.length;
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch messages:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for updates
|
||||
async function pollForUpdates() {
|
||||
try {
|
||||
const response = await fetch(`/poll?lastCheck=${lastMessageCount}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.updated) {
|
||||
await fetchMessages();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Poll failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Save chat
|
||||
async function saveChat() {
|
||||
try {
|
||||
const response = await fetch("/chat/save", { method: "POST" });
|
||||
if (response.ok) {
|
||||
alert("Chat saved successfully!");
|
||||
} else {
|
||||
alert("Failed to save chat");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to save chat:", err);
|
||||
alert("Failed to save chat");
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
sendBtn.addEventListener("click", sendMessage);
|
||||
messageInput.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") sendMessage();
|
||||
});
|
||||
saveBtn.addEventListener("click", saveChat);
|
||||
|
||||
// Initial load and start polling
|
||||
fetchMessages();
|
||||
setInterval(pollForUpdates, 2000);
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
// DOM elements
|
||||
const createFlowBtn = document.getElementById("create-flow-btn");
|
||||
const flowNameInput = document.getElementById("flow-name");
|
||||
const flowDescriptionInput = document.getElementById("flow-description");
|
||||
const flowSelect = document.getElementById("flow-select");
|
||||
const actionTypeSelect = document.getElementById("action-type");
|
||||
const taskInput = document.getElementById("task-input");
|
||||
const orderIndexInput = document.getElementById("order-index");
|
||||
const addTaskBtn = document.getElementById("add-task-btn");
|
||||
const flowDetails = document.getElementById("flow-details");
|
||||
const executeFlowSelect = document.getElementById("execute-flow-select");
|
||||
const executePayloadInput = document.getElementById("execute-payload");
|
||||
const executeBtn = document.getElementById("execute-btn");
|
||||
const webhookBtn = document.getElementById("webhook-btn");
|
||||
const executionResults = document.getElementById("execution-results");
|
||||
|
||||
let currentFlows = [];
|
||||
|
||||
// Create new flow
|
||||
async function createFlow() {
|
||||
const name = flowNameInput.value.trim();
|
||||
const description = flowDescriptionInput.value.trim();
|
||||
|
||||
if (!name) {
|
||||
alert("Flow name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/flow", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const flow = await response.json();
|
||||
alert(`Flow "${flow.name}" created successfully!`);
|
||||
flowNameInput.value = "";
|
||||
flowDescriptionInput.value = "";
|
||||
loadFlows();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Error: ${error.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Failed to create flow");
|
||||
}
|
||||
}
|
||||
|
||||
// Load all flows
|
||||
async function loadFlows() {
|
||||
try {
|
||||
const response = await fetch("/flows");
|
||||
const flows = await response.json();
|
||||
currentFlows = flows;
|
||||
|
||||
// Update flow selects
|
||||
flowSelect.innerHTML = '<option value="">Select Flow</option>';
|
||||
executeFlowSelect.innerHTML =
|
||||
'<option value="">Select Flow to Execute</option>';
|
||||
|
||||
flows.forEach((flow) => {
|
||||
flowSelect.innerHTML += `<option value="${flow.id}">${flow.name}</option>`;
|
||||
executeFlowSelect.innerHTML += `<option value="${flow.id}">${flow.name}</option>`;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to load flows");
|
||||
}
|
||||
}
|
||||
|
||||
// Add task to flow
|
||||
async function addTask() {
|
||||
const flowId = flowSelect.value;
|
||||
const actionType = actionTypeSelect.value;
|
||||
const inputData = taskInput.value.trim();
|
||||
const orderIndex = parseInt(orderIndexInput.value);
|
||||
|
||||
if (!flowId || !actionType || !inputData || isNaN(orderIndex)) {
|
||||
alert("All fields are required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/flow/${flowId}/task`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action_type: actionType,
|
||||
input_data: inputData,
|
||||
order_index: orderIndex,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const task = await response.json();
|
||||
alert(`Task "${task.action_type}" added successfully!`);
|
||||
taskInput.value = "";
|
||||
orderIndexInput.value = "";
|
||||
loadFlowDetails(flowId);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Error: ${error.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Failed to add task");
|
||||
}
|
||||
}
|
||||
|
||||
// Load flow details
|
||||
async function loadFlowDetails(flowId) {
|
||||
if (!flowId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/flow/${flowId}`);
|
||||
const data = await response.json();
|
||||
|
||||
flowDetails.innerHTML = `
|
||||
<div class="border rounded p-4">
|
||||
<h3 class="font-semibold text-lg">${data.flow.name}</h3>
|
||||
<p class="text-gray-600">${
|
||||
data.flow.description || "No description"
|
||||
}</p>
|
||||
<div class="mt-4">
|
||||
<h4 class="font-medium">Tasks:</h4>
|
||||
<div class="space-y-2 mt-2">
|
||||
${data.tasks
|
||||
.map(
|
||||
(task) => `
|
||||
<div class="bg-gray-100 p-2 rounded">
|
||||
<div class="font-medium">${task.action_type}</div>
|
||||
<div class="text-sm text-gray-600">Input: ${task.input_data}</div>
|
||||
<div class="text-xs text-gray-500">Order: ${task.order_index}</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error("Failed to load flow details");
|
||||
}
|
||||
}
|
||||
|
||||
// Execute flow
|
||||
async function executeFlow() {
|
||||
const flowId = executeFlowSelect.value;
|
||||
const payload = executePayloadInput.value.trim();
|
||||
|
||||
if (!flowId) {
|
||||
alert("Please select a flow to execute");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/flow/${flowId}/execute`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ payload }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
showExecutionResults(result);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Error: ${error.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Failed to execute flow");
|
||||
}
|
||||
}
|
||||
|
||||
// Show execution results
|
||||
function showExecutionResults(result) {
|
||||
executionResults.innerHTML = `
|
||||
<h4 class="font-semibold mb-2">Execution Results:</h4>
|
||||
<div class="space-y-2">
|
||||
${result.results
|
||||
.map(
|
||||
(r) => `
|
||||
<div class="p-2 ${
|
||||
r.status === "success" ? "bg-green-100" : "bg-red-100"
|
||||
} rounded">
|
||||
<div class="font-medium">Task ${r.task_id}</div>
|
||||
<div class="text-sm">${r.result}</div>
|
||||
<div class="text-xs text-gray-600">Status: ${r.status}</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
executionResults.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Get webhook URL
|
||||
function getWebhookUrl() {
|
||||
const flowId = executeFlowSelect.value;
|
||||
|
||||
if (!flowId) {
|
||||
alert("Please select a flow");
|
||||
return;
|
||||
}
|
||||
|
||||
const webhookUrl = `${window.location.origin}/flow/${flowId}/trigger?payload=test@example.com`;
|
||||
alert(
|
||||
`Webhook URL:\n${webhookUrl}\n\nCopy this URL to trigger the flow via webhook.`
|
||||
);
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
createFlowBtn.addEventListener("click", createFlow);
|
||||
addTaskBtn.addEventListener("click", addTask);
|
||||
executeBtn.addEventListener("click", executeFlow);
|
||||
webhookBtn.addEventListener("click", getWebhookUrl);
|
||||
|
||||
flowSelect.addEventListener("change", (e) => {
|
||||
if (e.target.value) {
|
||||
loadFlowDetails(e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
loadFlows();
|
||||
+666
@@ -0,0 +1,666 @@
|
||||
// main.js
|
||||
|
||||
// --- 2FA Implementation ---
|
||||
let twoFactorVerified = false;
|
||||
|
||||
// Check 2FA status on page load
|
||||
async function check2FAStatus() {
|
||||
try {
|
||||
const res = await fetch("/2fa/status");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
twoFactorVerified = data.verified;
|
||||
if (!twoFactorVerified) {
|
||||
show2FAModal();
|
||||
}
|
||||
} else {
|
||||
show2FAModal();
|
||||
}
|
||||
} catch (err) {
|
||||
show2FAModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Show 2FA modal
|
||||
function show2FAModal() {
|
||||
const modal = document.getElementById("2fa-modal");
|
||||
const setup = document.getElementById("2fa-setup");
|
||||
const verify = document.getElementById("2fa-verify");
|
||||
const loading = document.getElementById("2fa-loading");
|
||||
|
||||
modal.classList.remove("hidden");
|
||||
loading.classList.remove("hidden");
|
||||
setup.classList.add("hidden");
|
||||
verify.classList.add("hidden");
|
||||
|
||||
// Generate 2FA secret
|
||||
generate2FASecret();
|
||||
}
|
||||
|
||||
// Generate 2FA secret
|
||||
async function generate2FASecret() {
|
||||
try {
|
||||
const res = await fetch("/2fa/generate");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
|
||||
const setup = document.getElementById("2fa-setup");
|
||||
const verify = document.getElementById("2fa-verify");
|
||||
const loading = document.getElementById("2fa-loading");
|
||||
|
||||
loading.classList.add("hidden");
|
||||
setup.classList.remove("hidden");
|
||||
|
||||
// Display secret key
|
||||
document.getElementById("secret-key").textContent = data.secret;
|
||||
|
||||
// Generate QR code image
|
||||
const qrCode = document.getElementById("qr-code");
|
||||
qrCode.innerHTML = `
|
||||
<div class="bg-white p-4 rounded text-center">
|
||||
<img src="${data.qrCode}" alt="QR Code for 2FA" class="mx-auto" onerror="this.style.display='none'; this.nextElementSibling.style.display='block';" />
|
||||
<div class="text-xs text-gray-500 mt-2" style="display: none;">
|
||||
<p>QR Code failed to load. Use this URL instead:</p>
|
||||
<p class="font-mono bg-gray-100 px-2 py-1 rounded break-all">${data.qrCodeUrl}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">Scan this QR code with your authenticator app</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
throw new Error("Failed to generate 2FA secret");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("2FA generation error:", err);
|
||||
document.getElementById("2fa-loading").innerHTML =
|
||||
'<p class="text-red-500">Error generating 2FA. Please refresh the page.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Setup 2FA event listeners
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const generateBtn = document.getElementById("generate-2fa");
|
||||
const verifyBtn = document.getElementById("verify-2fa");
|
||||
const backBtn = document.getElementById("back-to-setup");
|
||||
const tokenInput = document.getElementById("2fa-token");
|
||||
|
||||
if (generateBtn) {
|
||||
generateBtn.addEventListener("click", generate2FASecret);
|
||||
}
|
||||
|
||||
if (verifyBtn) {
|
||||
verifyBtn.addEventListener("click", verify2FA);
|
||||
}
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener("click", function () {
|
||||
document.getElementById("2fa-setup").classList.remove("hidden");
|
||||
document.getElementById("2fa-verify").classList.add("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
if (tokenInput) {
|
||||
tokenInput.addEventListener("input", function () {
|
||||
if (this.value.length === 6) {
|
||||
verify2FA();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const proceedBtn = document.getElementById("proceed-to-verify");
|
||||
if (proceedBtn) {
|
||||
proceedBtn.addEventListener("click", function () {
|
||||
document.getElementById("2fa-setup").classList.add("hidden");
|
||||
document.getElementById("2fa-verify").classList.remove("hidden");
|
||||
document.getElementById("2fa-token").focus();
|
||||
});
|
||||
}
|
||||
|
||||
const downloadQrBtn = document.getElementById("download-qr");
|
||||
if (downloadQrBtn) {
|
||||
downloadQrBtn.addEventListener("click", function () {
|
||||
const qrImg = document.querySelector("#qr-code img");
|
||||
if (qrImg && qrImg.src) {
|
||||
const link = document.createElement("a");
|
||||
link.download = "2fa-qr-code.png";
|
||||
link.href = qrImg.src;
|
||||
link.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const copySecretBtn = document.getElementById("copy-secret");
|
||||
if (copySecretBtn) {
|
||||
copySecretBtn.addEventListener("click", function () {
|
||||
const secretKey = document.getElementById("secret-key").textContent;
|
||||
if (secretKey) {
|
||||
navigator.clipboard
|
||||
.writeText(secretKey)
|
||||
.then(() => {
|
||||
// Show temporary success message
|
||||
const originalText = copySecretBtn.textContent;
|
||||
copySecretBtn.textContent = "Copied!";
|
||||
copySecretBtn.classList.remove("bg-gray-500", "hover:bg-gray-600");
|
||||
copySecretBtn.classList.add("bg-green-500");
|
||||
setTimeout(() => {
|
||||
copySecretBtn.textContent = originalText;
|
||||
copySecretBtn.classList.remove("bg-green-500");
|
||||
copySecretBtn.classList.add("bg-gray-500", "hover:bg-gray-600");
|
||||
}, 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
alert("Failed to copy to clipboard. Secret: " + secretKey);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Verify 2FA token
|
||||
async function verify2FA() {
|
||||
const token = document.getElementById("2fa-token").value.trim();
|
||||
|
||||
if (token.length !== 6) {
|
||||
alert("Please enter a 6-digit code");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/2fa/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
twoFactorVerified = true;
|
||||
document.getElementById("2fa-modal").classList.add("hidden");
|
||||
// Enable all widgets
|
||||
enableDashboard();
|
||||
} else {
|
||||
alert("Invalid token. Please try again.");
|
||||
}
|
||||
} else {
|
||||
const errorData = await res.json();
|
||||
alert(errorData.error || "Verification failed");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Verification failed. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
// Enable dashboard after 2FA verification
|
||||
function enableDashboard() {
|
||||
// Dashboard is already visible, just ensure all functionality is enabled
|
||||
console.log("Dashboard enabled after 2FA verification");
|
||||
}
|
||||
|
||||
// Check 2FA status on page load
|
||||
check2FAStatus();
|
||||
|
||||
// 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;
|
||||
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
|
||||
|
||||
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) => ({ "&": "&", "<": "<", ">": ">" }[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,
|
||||
}),
|
||||
}).then(async (res) => {
|
||||
if (res.status === 429) {
|
||||
const data = await res.json();
|
||||
if (data.redirect) window.location.href = data.redirect;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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";
|
||||
});
|
||||
}
|
||||
|
||||
// --- Import XML Widget ---
|
||||
const importXmlInput = document.getElementById("import-xml-input");
|
||||
const importXmlBtn = document.getElementById("import-xml-btn");
|
||||
const importStatus = document.getElementById("import-status");
|
||||
|
||||
if (importXmlBtn && importXmlInput && importStatus) {
|
||||
importXmlBtn.addEventListener("click", async function () {
|
||||
if (!importXmlInput.files || !importXmlInput.files[0]) {
|
||||
importStatus.textContent = "Please select an XML file";
|
||||
importStatus.className = "text-xs mt-2 text-red-500";
|
||||
return;
|
||||
}
|
||||
|
||||
const file = importXmlInput.files[0];
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.includes("xml")) {
|
||||
importStatus.textContent = "Please select a valid XML file";
|
||||
importStatus.className = "text-xs mt-2 text-red-500";
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("xmlfile", file);
|
||||
|
||||
try {
|
||||
importStatus.textContent = "Importing...";
|
||||
importStatus.className = "text-xs mt-2 text-blue-500";
|
||||
|
||||
const res = await fetch("/analytic/import", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
throw new Error(errorData.error || "Import failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
importStatus.textContent = data.message;
|
||||
importStatus.className = "text-xs mt-2 text-green-500";
|
||||
|
||||
// Clear the file input
|
||||
importXmlInput.value = "";
|
||||
|
||||
// Update the click counter to reflect new data
|
||||
setTimeout(updateClickCounter, 1000);
|
||||
} catch (err) {
|
||||
importStatus.textContent = `Error: ${err.message}`;
|
||||
importStatus.className = "text-xs mt-2 text-red-500";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Upload Widget ---
|
||||
const uploadInput = document.querySelector(
|
||||
'input[type="file"][id="upload-input"]'
|
||||
);
|
||||
const uploadBtn = document.getElementById("upload-btn");
|
||||
const uploadPreview = document.getElementById("upload-preview");
|
||||
const uploadError = document.getElementById("upload-error");
|
||||
|
||||
async function fetchLatestUpload() {
|
||||
try {
|
||||
const res = await fetch("/upload/latest");
|
||||
const data = await res.json();
|
||||
if (data.url && uploadPreview) {
|
||||
uploadPreview.src = data.url;
|
||||
uploadPreview.style.display = "";
|
||||
} else if (uploadPreview) {
|
||||
uploadPreview.style.display = "none";
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (uploadBtn && uploadInput) {
|
||||
uploadBtn.addEventListener("click", async function () {
|
||||
if (!uploadInput.files || !uploadInput.files[0]) {
|
||||
if (uploadError) uploadError.textContent = "Please select an image.";
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("image", uploadInput.files[0]);
|
||||
try {
|
||||
const res = await fetch("/upload", { method: "POST", body: formData });
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
if (uploadPreview) {
|
||||
uploadPreview.src = data.url;
|
||||
uploadPreview.style.display = "";
|
||||
}
|
||||
if (uploadError) uploadError.textContent = "";
|
||||
} else {
|
||||
if (uploadError)
|
||||
uploadError.textContent = data.error || "Upload failed.";
|
||||
}
|
||||
} catch (err) {
|
||||
if (uploadError) uploadError.textContent = "Upload failed.";
|
||||
}
|
||||
});
|
||||
}
|
||||
fetchLatestUpload();
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
/*! 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-100: oklch(93.6% 0.032 17.717);
|
||||
--color-red-500: oklch(63.7% 0.237 25.331);
|
||||
--color-orange-500: oklch(70.5% 0.213 47.604);
|
||||
--color-orange-600: oklch(64.6% 0.222 41.116);
|
||||
--color-green-100: oklch(96.2% 0.044 156.743);
|
||||
--color-green-400: oklch(79.2% 0.209 151.711);
|
||||
--color-green-500: oklch(72.3% 0.219 149.579);
|
||||
--color-green-600: oklch(62.7% 0.194 149.214);
|
||||
--color-sky-100: oklch(95.1% 0.026 236.824);
|
||||
--color-sky-400: oklch(74.6% 0.16 232.661);
|
||||
--color-blue-500: oklch(62.3% 0.214 259.815);
|
||||
--color-blue-600: oklch(54.6% 0.245 262.881);
|
||||
--color-blue-700: oklch(48.8% 0.243 264.376);
|
||||
--color-purple-500: oklch(62.7% 0.265 303.9);
|
||||
--color-purple-600: oklch(55.8% 0.288 302.321);
|
||||
--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-gray-600: oklch(44.6% 0.03 256.802);
|
||||
--color-black: #000;
|
||||
--color-white: #fff;
|
||||
--spacing: 0.25rem;
|
||||
--container-md: 28rem;
|
||||
--container-2xl: 42rem;
|
||||
--container-4xl: 56rem;
|
||||
--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-xl: 1.25rem;
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
--text-2xl: 1.5rem;
|
||||
--text-2xl--line-height: calc(2 / 1.5);
|
||||
--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;
|
||||
}
|
||||
.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
.inset-0 {
|
||||
inset: calc(var(--spacing) * 0);
|
||||
}
|
||||
.top-\[calc\(100\%\+10px\)\] {
|
||||
top: calc(100% + 10px);
|
||||
}
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
}
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
}
|
||||
.col-span-1 {
|
||||
grid-column: span 1 / span 1;
|
||||
}
|
||||
.row-span-2 {
|
||||
grid-row: span 2 / span 2;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
@media (width >= 40rem) {
|
||||
max-width: 40rem;
|
||||
}
|
||||
@media (width >= 48rem) {
|
||||
max-width: 48rem;
|
||||
}
|
||||
@media (width >= 64rem) {
|
||||
max-width: 64rem;
|
||||
}
|
||||
@media (width >= 80rem) {
|
||||
max-width: 80rem;
|
||||
}
|
||||
@media (width >= 96rem) {
|
||||
max-width: 96rem;
|
||||
}
|
||||
}
|
||||
.mx-4 {
|
||||
margin-inline: calc(var(--spacing) * 4);
|
||||
}
|
||||
.mx-auto {
|
||||
margin-inline: auto;
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: calc(var(--spacing) * 4);
|
||||
}
|
||||
.mt-8 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
}
|
||||
.mb-1 {
|
||||
margin-bottom: calc(var(--spacing) * 1);
|
||||
}
|
||||
.mb-2 {
|
||||
margin-bottom: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
}
|
||||
.mb-6 {
|
||||
margin-bottom: calc(var(--spacing) * 6);
|
||||
}
|
||||
.ml-2 {
|
||||
margin-left: calc(var(--spacing) * 2);
|
||||
}
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.h-24 {
|
||||
height: calc(var(--spacing) * 24);
|
||||
}
|
||||
.h-96 {
|
||||
height: calc(var(--spacing) * 96);
|
||||
}
|
||||
.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%;
|
||||
}
|
||||
.max-w-2xl {
|
||||
max-width: var(--container-2xl);
|
||||
}
|
||||
.max-w-4xl {
|
||||
max-width: var(--container-4xl);
|
||||
}
|
||||
.max-w-md {
|
||||
max-width: var(--container-md);
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.list-inside {
|
||||
list-style-position: inside;
|
||||
}
|
||||
.list-disc {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
.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-2 {
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
.gap-4 {
|
||||
gap: calc(var(--spacing) * 4);
|
||||
}
|
||||
.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)));
|
||||
}
|
||||
}
|
||||
.space-y-4 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));
|
||||
margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
.space-x-4 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));
|
||||
margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-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-black {
|
||||
background-color: var(--color-black);
|
||||
}
|
||||
.bg-blue-500 {
|
||||
background-color: var(--color-blue-500);
|
||||
}
|
||||
.bg-gray-100 {
|
||||
background-color: var(--color-gray-100);
|
||||
}
|
||||
.bg-gray-500 {
|
||||
background-color: var(--color-gray-500);
|
||||
}
|
||||
.bg-green-100 {
|
||||
background-color: var(--color-green-100);
|
||||
}
|
||||
.bg-green-400 {
|
||||
background-color: var(--color-green-400);
|
||||
}
|
||||
.bg-green-500 {
|
||||
background-color: var(--color-green-500);
|
||||
}
|
||||
.bg-inherit {
|
||||
background-color: inherit;
|
||||
}
|
||||
.bg-orange-500 {
|
||||
background-color: var(--color-orange-500);
|
||||
}
|
||||
.bg-purple-500 {
|
||||
background-color: var(--color-purple-500);
|
||||
}
|
||||
.bg-red-100 {
|
||||
background-color: var(--color-red-100);
|
||||
}
|
||||
.bg-sky-400 {
|
||||
background-color: var(--color-sky-400);
|
||||
}
|
||||
.bg-white {
|
||||
background-color: var(--color-white);
|
||||
}
|
||||
.object-cover {
|
||||
object-fit: cover;
|
||||
}
|
||||
.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-2 {
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
}
|
||||
.px-3 {
|
||||
padding-inline: calc(var(--spacing) * 3);
|
||||
}
|
||||
.px-4 {
|
||||
padding-inline: calc(var(--spacing) * 4);
|
||||
}
|
||||
.px-6 {
|
||||
padding-inline: calc(var(--spacing) * 6);
|
||||
}
|
||||
.px-8 {
|
||||
padding-inline: calc(var(--spacing) * 8);
|
||||
}
|
||||
.py-1 {
|
||||
padding-block: calc(var(--spacing) * 1);
|
||||
}
|
||||
.py-2 {
|
||||
padding-block: calc(var(--spacing) * 2);
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.text-2xl {
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--tw-leading, var(--text-2xl--line-height));
|
||||
}
|
||||
.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-xl {
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--tw-leading, var(--text-xl--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);
|
||||
}
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
.text-blue-500 {
|
||||
color: var(--color-blue-500);
|
||||
}
|
||||
.text-blue-700 {
|
||||
color: var(--color-blue-700);
|
||||
}
|
||||
.text-gray-500 {
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
.text-gray-600 {
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
.text-green-500 {
|
||||
color: var(--color-green-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);
|
||||
}
|
||||
.outline-none {
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
}
|
||||
.hover\:bg-blue-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-blue-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-gray-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-gray-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-green-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-green-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-orange-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-orange-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-purple-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-purple-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-sky-100 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-sky-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:underline {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
.focus\:ring-2 {
|
||||
&:focus {
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
}
|
||||
.focus\:ring-blue-500 {
|
||||
&:focus {
|
||||
--tw-ring-color: var(--color-blue-500);
|
||||
}
|
||||
}
|
||||
.focus\:outline-none {
|
||||
&:focus {
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
}
|
||||
}
|
||||
.md\:grid-cols-2 {
|
||||
@media (width >= 48rem) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.md\:grid-cols-4 {
|
||||
@media (width >= 48rem) {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
@property --tw-space-y-reverse {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
@property --tw-space-x-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;
|
||||
}
|
||||
@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-space-x-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1 @@
|
||||
body {
|
||||
padding: 50px;
|
||||
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #00B7FF;
|
||||
}
|
||||
@import "tailwindcss";
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
+648
-3
@@ -1,9 +1,654 @@
|
||||
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, parse } = require("xmlbuilder2");
|
||||
const speakeasy = require("speakeasy");
|
||||
const QRCode = require("qrcode");
|
||||
const rateLimitMap = new Map();
|
||||
const multer = require("multer");
|
||||
const uploadDir = path.join(__dirname, "../public/uploads");
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const unique = Date.now() + "-" + Math.round(Math.random() * 1e9);
|
||||
cb(null, unique + "-" + file.originalname);
|
||||
},
|
||||
});
|
||||
const uploadMulter = multer({ storage });
|
||||
|
||||
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||
const redis = require("redis");
|
||||
const client = redis.createClient();
|
||||
client.connect().catch(console.error);
|
||||
|
||||
// 2FA middleware
|
||||
function require2FA(req, res, next) {
|
||||
if (req.session && req.session.twoFactorVerified) {
|
||||
next();
|
||||
} else {
|
||||
res.status(403).json({ error: "2FA verification required" });
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 with rate limiting
|
||||
router.post("/analytic", async function (req, res) {
|
||||
try {
|
||||
const ip = req.headers["x-forwarded-for"] || req.connection.remoteAddress;
|
||||
const now = Date.now();
|
||||
const windowMs = 60 * 1000;
|
||||
const maxReq = 10;
|
||||
if (!rateLimitMap.has(ip)) rateLimitMap.set(ip, []);
|
||||
let timestamps = rateLimitMap.get(ip).filter((ts) => now - ts < windowMs);
|
||||
if (timestamps.length >= maxReq) {
|
||||
return res.status(429).json({ redirect: "/pay" });
|
||||
}
|
||||
timestamps.push(now);
|
||||
rateLimitMap.set(ip, timestamps);
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
// Import analytic from XML
|
||||
router.post(
|
||||
"/analytic/import",
|
||||
uploadMulter.single("xmlfile"),
|
||||
async function (req, res) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: "No XML file uploaded" });
|
||||
}
|
||||
|
||||
// Check file type
|
||||
if (
|
||||
req.file.mimetype !== "text/xml" &&
|
||||
req.file.mimetype !== "application/xml"
|
||||
) {
|
||||
return res.status(400).json({ error: "File must be XML format" });
|
||||
}
|
||||
|
||||
// Read and parse the uploaded XML file
|
||||
const xmlContent = fs.readFileSync(req.file.path, "utf8");
|
||||
const doc = parse(xmlContent);
|
||||
|
||||
// Extract analytics data from XML
|
||||
const analytics = [];
|
||||
const analyticNodes = doc.find("//analytic");
|
||||
|
||||
for (const node of analyticNodes) {
|
||||
const id = node.find("id")[0]?.text || null;
|
||||
const createAt = node.find("create_at")[0]?.text || null;
|
||||
const widgetName = node.find("widget_name")[0]?.text || null;
|
||||
const browserType = node.find("browser_type")[0]?.text || null;
|
||||
|
||||
if (widgetName && browserType) {
|
||||
analytics.push({
|
||||
id: id ? parseInt(id) : undefined,
|
||||
create_at: createAt ? new Date(createAt) : new Date(),
|
||||
widget_name: widgetName,
|
||||
browser_type: browserType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (analytics.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No valid analytics data found in XML" });
|
||||
}
|
||||
|
||||
// Insert analytics into database
|
||||
const insertedAnalytics = await db.analytic.bulkCreate(analytics, {
|
||||
ignoreDuplicates: true,
|
||||
updateOnDuplicate: ["widget_name", "browser_type"],
|
||||
});
|
||||
|
||||
// Clean up uploaded file
|
||||
fs.unlinkSync(req.file.path);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Successfully imported ${insertedAnalytics.length} analytics records`,
|
||||
count: insertedAnalytics.length,
|
||||
});
|
||||
} catch (err) {
|
||||
// Clean up uploaded file on error
|
||||
if (req.file && req.file.path) {
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch (cleanupErr) {
|
||||
console.error("Failed to cleanup uploaded file:", cleanupErr);
|
||||
}
|
||||
}
|
||||
|
||||
console.error("Import error:", err);
|
||||
res.status(500).json({ error: "Failed to import analytics from XML" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 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" });
|
||||
}
|
||||
});
|
||||
|
||||
// Stripe payment page
|
||||
router.get("/pay", async function (req, res) {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ["card"],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product_data: { name: "Widget Analytics Access" },
|
||||
unit_amount: 500,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: "payment",
|
||||
success_url: req.protocol + "://" + req.get("host") + "/?paid=1",
|
||||
cancel_url: req.protocol + "://" + req.get("host") + "/pay?cancel=1",
|
||||
});
|
||||
res.send(`
|
||||
<html>
|
||||
<head><title>Pay for Analytics</title></head>
|
||||
<body style="display:flex;align-items:center;justify-content:center;height:100vh;flex-direction:column;">
|
||||
<h2>Rate limit exceeded</h2>
|
||||
<p>You need to pay $5 to continue using analytics.</p>
|
||||
<button id="checkout">Pay with Stripe</button>
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<script>
|
||||
document.getElementById('checkout').onclick = function() {
|
||||
var stripe = Stripe('${STRIPE_PUBLIC_KEY}');
|
||||
stripe.redirectToCheckout({ sessionId: '${session.id}' });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
// Upload image
|
||||
router.post("/upload", uploadMulter.single("image"), async function (req, res) {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: "No file uploaded" });
|
||||
const file = req.file;
|
||||
const dbUpload = await db.upload.create({
|
||||
filename: file.filename,
|
||||
mimetype: file.mimetype,
|
||||
path: "/uploads/" + file.filename,
|
||||
});
|
||||
res.json({ url: "/uploads/" + file.filename });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Failed to upload image" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get latest uploaded image
|
||||
router.get("/upload/latest", async function (req, res) {
|
||||
try {
|
||||
const latest = await db.upload.findOne({ order: [["created_at", "DESC"]] });
|
||||
if (!latest) return res.json({ url: null });
|
||||
res.json({ url: latest.path });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Failed to fetch latest upload" });
|
||||
}
|
||||
});
|
||||
|
||||
// Chat routes
|
||||
router.post("/send", async function (req, res) {
|
||||
try {
|
||||
const { message } = req.body;
|
||||
if (!message) return res.status(400).json({ error: "Message required" });
|
||||
|
||||
const chatMessage = {
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
id: Date.now(),
|
||||
};
|
||||
|
||||
await client.lPush("chatroom", JSON.stringify(chatMessage));
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Failed to send message" });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/chat/all", async function (req, res) {
|
||||
try {
|
||||
const messages = await client.lRange("chatroom", 0, -1);
|
||||
const parsedMessages = messages.map((msg) => JSON.parse(msg));
|
||||
res.json(parsedMessages);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Failed to fetch messages" });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/poll", async function (req, res) {
|
||||
try {
|
||||
const messageCount = await client.lLen("chatroom");
|
||||
const lastCheck = req.query.lastCheck || 0;
|
||||
|
||||
if (messageCount > lastCheck) {
|
||||
res.json({ updated: true, count: messageCount });
|
||||
} else {
|
||||
res.json({ updated: false, count: messageCount });
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Poll failed" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/chat/save", async function (req, res) {
|
||||
try {
|
||||
const messages = await client.lRange("chatroom", 0, -1);
|
||||
const chatMessages = JSON.stringify(messages.map((msg) => JSON.parse(msg)));
|
||||
|
||||
await db.chat.create({
|
||||
chat_messages: chatMessages,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Failed to save chat" });
|
||||
}
|
||||
});
|
||||
|
||||
// Chat page
|
||||
router.get("/chat", function (req, res) {
|
||||
res.sendFile(path.join(__dirname, "../views/chat.html"));
|
||||
});
|
||||
|
||||
// Flow Builder routes
|
||||
router.get("/flow", function (req, res) {
|
||||
res.sendFile(path.join(__dirname, "../views/flow.html"));
|
||||
});
|
||||
|
||||
// Get all flows
|
||||
router.get("/flows", async function (req, res) {
|
||||
try {
|
||||
const flows = await db.flow.findAll({
|
||||
order: [["created_at", "DESC"]],
|
||||
});
|
||||
res.json(flows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Failed to fetch flows" });
|
||||
}
|
||||
});
|
||||
|
||||
// Create new flow
|
||||
router.post("/flow", async function (req, res) {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
if (!name) return res.status(400).json({ error: "Flow name required" });
|
||||
|
||||
const flow = await db.flow.create({ name, description });
|
||||
res.json({ id: flow.id, name: flow.name });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Failed to create flow" });
|
||||
}
|
||||
});
|
||||
|
||||
// Add task to flow
|
||||
router.post("/flow/:id/task", async function (req, res) {
|
||||
try {
|
||||
const flowId = req.params.id;
|
||||
const { action_type, input_data, order_index } = req.body;
|
||||
|
||||
if (!action_type || !input_data || order_index === undefined) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "action_type, input_data, and order_index required" });
|
||||
}
|
||||
|
||||
const task = await db.task.create({
|
||||
flow_id: flowId,
|
||||
action_type,
|
||||
input_data,
|
||||
order_index,
|
||||
});
|
||||
|
||||
res.json({ id: task.id, action_type: task.action_type });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Failed to add task" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get flow details
|
||||
router.get("/flow/:id", async function (req, res) {
|
||||
try {
|
||||
const flowId = req.params.id;
|
||||
const flow = await db.flow.findByPk(flowId);
|
||||
const tasks = await db.task.findAll({
|
||||
where: { flow_id: flowId },
|
||||
order: [["order_index", "ASC"]],
|
||||
});
|
||||
|
||||
res.json({ flow, tasks });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Failed to fetch flow" });
|
||||
}
|
||||
});
|
||||
|
||||
// Execute flow
|
||||
router.post("/flow/:id/execute", async function (req, res) {
|
||||
try {
|
||||
const flowId = req.params.id;
|
||||
const { payload } = req.body;
|
||||
|
||||
const flow = await db.flow.findByPk(flowId);
|
||||
const tasks = await db.task.findAll({
|
||||
where: { flow_id: flowId },
|
||||
order: [["order_index", "ASC"]],
|
||||
});
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
let result = "";
|
||||
let status = "success";
|
||||
|
||||
try {
|
||||
switch (task.action_type) {
|
||||
case "send_test_mail":
|
||||
// Simulate sending email
|
||||
result = `Email sent to: ${task.input_data}`;
|
||||
break;
|
||||
|
||||
case "http_get_request":
|
||||
const response = await fetch(task.input_data);
|
||||
result = `HTTP GET ${task.input_data}: ${response.status}`;
|
||||
break;
|
||||
|
||||
case "mysql_select":
|
||||
const [table, id] = task.input_data.split("|");
|
||||
const query = `SELECT * FROM ${table} WHERE id = ${id}`;
|
||||
result = `Query executed: ${query}`;
|
||||
break;
|
||||
|
||||
case "drive_upload":
|
||||
result = `File uploaded to Google Drive with content: ${task.input_data}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
result = `Unknown action type: ${task.action_type}`;
|
||||
status = "error";
|
||||
}
|
||||
} catch (err) {
|
||||
result = `Error: ${err.message}`;
|
||||
status = "error";
|
||||
}
|
||||
|
||||
// Log the execution
|
||||
await db.flow_log.create({
|
||||
flow_id: flowId,
|
||||
task_id: task.id,
|
||||
result,
|
||||
status,
|
||||
});
|
||||
|
||||
results.push({ task_id: task.id, result, status });
|
||||
}
|
||||
|
||||
res.json({ flow_id: flowId, results });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Failed to execute flow" });
|
||||
}
|
||||
});
|
||||
|
||||
// Webhook trigger
|
||||
router.get("/flow/:id/trigger", async function (req, res) {
|
||||
try {
|
||||
const flowId = req.params.id;
|
||||
const payload = req.query.payload;
|
||||
|
||||
if (!payload) {
|
||||
return res.status(400).json({ error: "Payload required" });
|
||||
}
|
||||
|
||||
// Execute the flow with the payload
|
||||
const response = await fetch(
|
||||
`${req.protocol}://${req.get("host")}/flow/${flowId}/execute`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ payload }),
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Failed to trigger flow" });
|
||||
}
|
||||
});
|
||||
|
||||
// 2FA Routes
|
||||
// Generate 2FA secret
|
||||
router.get("/2fa/generate", async function (req, res) {
|
||||
try {
|
||||
const secret = speakeasy.generateSecret({
|
||||
name: "Dashboard 2FA",
|
||||
length: 20,
|
||||
});
|
||||
|
||||
// Store secret in session for verification
|
||||
req.session = req.session || {};
|
||||
req.session.tempSecret = secret.base32;
|
||||
|
||||
// Generate QR code as data URL
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(secret.otpauth_url, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: "#000000",
|
||||
light: "#FFFFFF",
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
secret: secret.base32,
|
||||
qrCode: qrCodeDataUrl,
|
||||
qrCodeUrl: secret.otpauth_url,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("2FA generation error:", err);
|
||||
res.status(500).json({ error: "Failed to generate 2FA secret" });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify 2FA token
|
||||
router.post("/2fa/verify", function (req, res) {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
const tempSecret = req.session?.tempSecret;
|
||||
|
||||
if (!tempSecret) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No 2FA secret found. Please generate a new one." });
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: "Token required" });
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: tempSecret,
|
||||
encoding: "base32",
|
||||
token: token,
|
||||
window: 2, // Allow 2 time steps for clock skew
|
||||
});
|
||||
|
||||
if (verified) {
|
||||
// Mark 2FA as verified in session
|
||||
req.session = req.session || {};
|
||||
req.session.twoFactorVerified = true;
|
||||
delete req.session.tempSecret; // Clean up temp secret
|
||||
|
||||
res.json({ success: true, message: "2FA verification successful" });
|
||||
} else {
|
||||
res.status(400).json({ error: "Invalid 2FA token" });
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Failed to verify 2FA token" });
|
||||
}
|
||||
});
|
||||
|
||||
// Check 2FA status
|
||||
router.get("/2fa/status", function (req, res) {
|
||||
const verified = req.session?.twoFactorVerified || false;
|
||||
res.json({ verified });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./views/**/*.{html,ejs,pug,hbs}", "./public/**/*.{html,js}"],
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Chat Room</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen">
|
||||
<div class="container mx-auto p-4 max-w-2xl">
|
||||
<h1 class="text-3xl font-bold text-center mb-6">Chat Room</h1>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<div
|
||||
id="chat-messages"
|
||||
class="h-96 overflow-y-auto mb-4 border rounded p-4"
|
||||
>
|
||||
<ul id="message-list" class="space-y-2">
|
||||
<!-- Messages will be populated here -->
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
id="message-input"
|
||||
placeholder="Type your message..."
|
||||
class="flex-1 px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
id="send-btn"
|
||||
class="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="save-btn"
|
||||
class="bg-green-500 text-white px-6 py-2 rounded hover:bg-green-600"
|
||||
>
|
||||
Save Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Flow Builder</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen">
|
||||
<div class="container mx-auto p-4 max-w-4xl">
|
||||
<h1 class="text-3xl font-bold text-center mb-6">Flow Builder</h1>
|
||||
|
||||
<!-- Create Flow Section -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Create New Flow</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
id="flow-name"
|
||||
placeholder="Flow Name"
|
||||
class="px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="flow-description"
|
||||
placeholder="Description"
|
||||
class="px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
id="create-flow-btn"
|
||||
class="mt-4 bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600"
|
||||
>
|
||||
Create Flow
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Task Section -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Add Task to Flow</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<select
|
||||
id="flow-select"
|
||||
class="px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select Flow</option>
|
||||
</select>
|
||||
<select
|
||||
id="action-type"
|
||||
class="px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select Action</option>
|
||||
<option value="send_test_mail">Send Test Mail</option>
|
||||
<option value="http_get_request">HTTP GET Request</option>
|
||||
<option value="mysql_select">MySQL Select</option>
|
||||
<option value="drive_upload">Drive Upload</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
id="task-input"
|
||||
placeholder="Input Data"
|
||||
class="px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
id="order-index"
|
||||
placeholder="Order"
|
||||
min="0"
|
||||
class="px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
id="add-task-btn"
|
||||
class="mt-4 bg-green-500 text-white px-6 py-2 rounded hover:bg-green-600"
|
||||
>
|
||||
Add Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Flow Details Section -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Flow Details</h2>
|
||||
<div id="flow-details" class="space-y-4">
|
||||
<!-- Flow details will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Execute Flow Section -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Execute Flow</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<select
|
||||
id="execute-flow-select"
|
||||
class="px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select Flow to Execute</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
id="execute-payload"
|
||||
placeholder="Payload (optional)"
|
||||
class="px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 space-x-4">
|
||||
<button
|
||||
id="execute-btn"
|
||||
class="bg-purple-500 text-white px-6 py-2 rounded hover:bg-purple-600"
|
||||
>
|
||||
Execute Flow
|
||||
</button>
|
||||
<button
|
||||
id="webhook-btn"
|
||||
class="bg-orange-500 text-white px-6 py-2 rounded hover:bg-orange-600"
|
||||
>
|
||||
Get Webhook URL
|
||||
</button>
|
||||
</div>
|
||||
<div id="execution-results" class="mt-4 p-4 bg-gray-100 rounded hidden">
|
||||
<!-- Execution results will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/flow.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,261 @@
|
||||
<!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 °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">▼</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>
|
||||
<!-- Import 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">Import XML</div>
|
||||
<input
|
||||
type="file"
|
||||
id="import-xml-input"
|
||||
accept=".xml,text/xml,application/xml"
|
||||
class="mb-2 text-xs"
|
||||
/>
|
||||
<button
|
||||
id="import-xml-btn"
|
||||
class="bg-green-400 text-white font-semibold px-8 py-2 rounded"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<div id="import-status" class="text-xs mt-2"></div>
|
||||
</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"
|
||||
>
|
||||
<img
|
||||
id="upload-preview"
|
||||
src=""
|
||||
alt="Latest upload"
|
||||
class="w-24 h-24 object-cover rounded mb-2"
|
||||
style="display: none"
|
||||
/>
|
||||
<input type="file" id="upload-input" accept="image/*" class="mb-2" />
|
||||
<button
|
||||
id="upload-btn"
|
||||
class="bg-sky-400 text-white font-semibold px-8 py-2 rounded"
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
<span id="upload-error" class="text-xs text-red-500 mt-2"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Modal -->
|
||||
<div
|
||||
id="2fa-modal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"
|
||||
>
|
||||
<div class="bg-white rounded-lg p-8 max-w-md w-full mx-4">
|
||||
<h2 class="text-2xl font-bold mb-4 text-center">
|
||||
Two-Factor Authentication
|
||||
</h2>
|
||||
|
||||
<div id="2fa-setup" class="hidden">
|
||||
<p class="text-gray-600 mb-4 text-center">
|
||||
Scan this QR code with your authenticator app:
|
||||
</p>
|
||||
<div id="qr-code" class="flex justify-center mb-4"></div>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button
|
||||
id="download-qr"
|
||||
class="flex-1 bg-purple-500 text-white py-2 px-4 rounded hover:bg-purple-600 text-sm"
|
||||
>
|
||||
Download QR
|
||||
</button>
|
||||
<button
|
||||
id="copy-secret"
|
||||
class="flex-1 bg-gray-500 text-white py-2 px-4 rounded hover:bg-gray-600 text-sm"
|
||||
>
|
||||
Copy Secret
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mb-4 text-center">
|
||||
Or manually enter this secret:
|
||||
<span
|
||||
id="secret-key"
|
||||
class="font-mono bg-gray-100 px-2 py-1 rounded"
|
||||
></span>
|
||||
</p>
|
||||
<button
|
||||
id="generate-2fa"
|
||||
class="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 mb-4"
|
||||
>
|
||||
Generate New Secret
|
||||
</button>
|
||||
<button
|
||||
id="proceed-to-verify"
|
||||
class="w-full bg-green-500 text-white py-2 px-4 rounded hover:bg-green-600"
|
||||
>
|
||||
I've Added the Code, Verify Now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="2fa-verify" class="hidden">
|
||||
<p class="text-gray-600 mb-4 text-center">
|
||||
Enter the 6-digit code from your authenticator app:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
id="2fa-token"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
class="w-full text-center text-2xl font-mono border border-gray-300 rounded px-4 py-2 mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
id="verify-2fa"
|
||||
class="w-full bg-green-500 text-white py-2 px-4 rounded hover:bg-green-600 mb-2"
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
<button
|
||||
id="back-to-setup"
|
||||
class="w-full bg-gray-500 text-white py-2 px-4 rounded hover:bg-gray-600"
|
||||
>
|
||||
Back to Setup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="2fa-loading" class="text-center">
|
||||
<p class="text-gray-600 mb-4">Loading 2FA...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +0,0 @@
|
||||
extends layout
|
||||
|
||||
block content
|
||||
h1= title
|
||||
p Welcome to #{title}
|
||||
Reference in New Issue
Block a user