Initial commit

This commit is contained in:
Leon Meier 2025-02-19 00:34:19 +01:00
commit d75e5b1f11
44 changed files with 5970 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
dist/
.env
config.json
.vsls.json

20
.prettierrc.json Normal file
View File

@ -0,0 +1,20 @@
{
"tabWidth": 8,
"useTabs": true,
"arrowParens": "always",
"bracketSameLine": true,
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"endOfLine": "lf",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": false,
"printWidth": 225,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": true,
"trailingComma": "none"
}

9
README.MD Normal file
View File

@ -0,0 +1,9 @@
# HydrationHUB
HydrationHUB - TODO: Luistiger slogan?
## Serving static files from node_modules
Files from explicit dirs inside `node_modules` will be served below `/libs`.
## Serving static files from /static
Files from the `/static` folder will be served below `/static`.

3422
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
package.json Normal file
View File

@ -0,0 +1,53 @@
{
"name": "hydration_hub",
"version": "1.0.0",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"prestart": "npm run build",
"start": "node .",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "[Project-Name-Here]",
"license": "GPL-3.0",
"description": "HydrationHUB",
"repository": {
"type": "git",
"url": "https://git.project-name-here.de/Project-Name-Here/hydrationhub"
},
"keywords": [
"hydration",
"pos"
],
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/express-fileupload": "^1.5.1",
"@types/express-session": "^1.18.1",
"@types/joi": "^17.2.2",
"@types/lodash": "^4.17.14",
"@types/node": "^22.10.5",
"@types/passport": "^1.0.17",
"@types/passport-local": "^1.0.38",
"@types/signale": "^1.4.7",
"eslint": "^9.18.0",
"eslint-config-prettier": "^9.1.0",
"prisma": "^6.2.1",
"tsx": "^3.12.10",
"typescript": "^5.7.3"
},
"dependencies": {
"@prisma/client": "^6.4.0",
"bootstrap-icons": "^1.11.3",
"bulma": "^1.0.3",
"eta": "^3.5.0",
"express": "^4.21.2",
"express-fileupload": "^1.5.1",
"helmet": "^8.0.0",
"joi": "^17.13.3",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"tslog": "^4.9.3"
}
}

63
prisma/schema.prisma Normal file
View File

@ -0,0 +1,63 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model user {
id Int @id @unique @default(autoincrement())
name String @unique
code String?
// TODO: Prüfen ob nötig, erstmal vorbereitet.
transactions transactions[]
@@fulltext([name])
}
model transactions {
id Int @id @unique @default(autoincrement())
sales sales[]
user user @relation(fields: [userId], references: [id])
userId Int
total Float
paid Boolean @default(false)
paidAt Boolean?
createdAt DateTime @default(now())
}
model sales {
id Int @id @unique @default(autoincrement())
transactions transactions @relation(fields: [transactionsId], references: [id])
transactionsId Int
product products? @relation(fields: [productId], references: [id])
productId Int?
price Float
}
model products {
id Int @id @unique @default(autoincrement())
name String @unique
price Float
stock Int
visible Boolean @default(true)
sales sales[]
@@fulltext([name])
}

18
src/handlers/config.ts Normal file
View File

@ -0,0 +1,18 @@
import ConfigManager from '../libs/configManager.js';
import __path from './path.js';
import _ from 'lodash';
import log from './log.js';
// Create a new config instance.
const config = new ConfigManager(__path + '/config.json', true, {
db_connection_string: 'mysql://USER:PASSWORD@HOST:3306/DATABASE',
http_listen_address: '0.0.0.0',
http_port: 3000,
http_domain: 'example.org',
http_enable_hsts: false,
devmode: true
});
!config.global.devmode && log.core.error('devmode active! Do NOT use this in prod!');
export default config;

40
src/handlers/db.ts Normal file
View File

@ -0,0 +1,40 @@
import { PrismaClient, Prisma } from '@prisma/client'; // Database
import { Response } from 'express';
import config from './config.js';
import log from './log.js';
// TODO: Add errorhandling with some sort of message.
const prisma = new PrismaClient({
datasources: {
db: {
url: config.global.db_connection_string
}
}
});
// FIXME: any
export function handlePrismaError(errorObj: any, res: Response, source: string) {
log.db.error(source, errorObj);
if (errorObj instanceof Prisma.PrismaClientKnownRequestError) {
switch (errorObj.code) {
// P2002 -> "Unique constraint failed on the {constraint}"
case 'P2002':
res.status(409).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'The object needs to be unique', meta: errorObj.meta });
break;
// P2003 -> "Foreign key constraint failed on the field: {field_name}"
case 'P2003':
res.status(404).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'Relation object does not exist', meta: errorObj.meta });
break;
default:
res.status(500).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'An error occurred during the database operation' });
break;
}
} else {
res.status(500).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'If you can read this something went terribly wrong!' });
}
}
export default prisma;

52
src/handlers/log.ts Normal file
View File

@ -0,0 +1,52 @@
import { Logger,ISettingsParam } from "tslog";
function loggerConfig(name: string): ISettingsParam<unknown> {
return {
type: "pretty", // pretty, json, hidden
name: name,
hideLogPositionForProduction: true,
prettyLogTemplate: "{{dateIsoStr}} {{logLevelName}} {{nameWithDelimiterPrefix}} "
}
}
type log = {
core: Logger<unknown>
db: Logger<unknown>
web: Logger<unknown>
S3: Logger<unknown>
auth: Logger<unknown>
api?: Logger<unknown>
frontend?: Logger<unknown>
};
// FIXME: any type
let log: log = {
core: new Logger(loggerConfig("Core")),
db: new Logger(loggerConfig("DB")),
web: new Logger(loggerConfig("Web")),
S3: new Logger(loggerConfig("S3")),
auth: new Logger(loggerConfig("Auth")),
// helper: new Logger(loggerConfig("HELPER")),
};
log["api"] = log.web.getSubLogger({ name: "API" });
log["frontend"] = log.web.getSubLogger({ name: "Frontend" });
// log.core.silly("Hello from core");
//log.api.trace("Hello from api");
//log.frontend.trace("Hello from frontend");
// log.core.debug("Hello from core");
// log.core.info("Hello from core");
// log.core.warn("Hello from core");
// log.core.error("Hello from core");
// log.db.silly("Hello from db");
// log.db.trace("Hello from db");
// log.web.debug("Hello from db");
// log.auth.info("Hello from db");
// log.helper.warn("Hello from db");
// log.db.error("Hello from db");
// log.core.fatal(new Error("I am a pretty Error with a stacktrace."));
export default log;

4
src/handlers/path.ts Normal file
View File

@ -0,0 +1,4 @@
// Return the app directory as an absolute path
const __path = process.argv[1];
export default __path;

View File

@ -0,0 +1,22 @@
/**
* A function to create a sortBy compatible object from a string
*
* @export
* @param {string} SortField
* @param {string} Order
* @returns {object}
*/
export function parseDynamicSortBy(SortField: string, Order: string) {
return JSON.parse(`{ "${SortField}": "${Order}" }`);
}
/**
* Function to parse a string into a number or return undefined if it is not a number
* Deprecated since all empty strings in bodys are now undefined. This happens in api/v1 router
* @export
* @param {string || any} data
* @returns {object}
*/
export function parseIntOrUndefined(data: any) {
return isNaN(parseInt(data)) ? undefined : parseInt(data);
}

109
src/index.ts Normal file
View File

@ -0,0 +1,109 @@
// MARK: Imports
import path from 'node:path';
import __path from './handlers/path.js';
import log from './handlers/log.js';
import db from './handlers/db.js';
import config from './handlers/config.js';
// Express & more
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import session from 'express-session';
import fileUpload from 'express-fileupload';
import bodyParser, { Options } from 'body-parser';
import { Eta } from 'eta';
import passport from 'passport';
import ChildProcess from 'child_process';
import routes from './routes/index.js';
import fs from 'node:fs';
log.core.trace('Running from path: ' + __path);
// MARK: Express
const app = express();
// Versioning
try {
const rawPkg = fs.readFileSync('package.json', 'utf8');
const pkgJson = JSON.parse(rawPkg);
app.locals.version = pkgJson.version;
} catch (error) {
log.core.error('Failed to get version from package.json.');
app.locals.version = '0.0.0';
}
try {
app.locals.versionRevLong = ChildProcess.execSync('git rev-parse HEAD').toString().trim();
app.locals.versionRev = app.locals.versionRevLong.substring(0, 7);
} catch (error) {
log.core.error('Failed to get git revision hash.');
app.locals.versionRev = '0';
app.locals.versionRevLong = '0';
}
try {
app.locals.versionRevLatest = ChildProcess.execSync('git ls-remote --refs -q').toString().trim().split('\t')[0];
} catch (error) {
log.core.error('Failed to get latest git revision hash.');
app.locals.versionRevLatest = '0';
}
app.locals.versionUpdateAvailable = false;
if (app.locals.versionRevLong === app.locals.versionRevLatest) {
log.core.info(`Running Latest Version (${app.locals.versionRevLong}; ${app.locals.version})`);
} else {
log.core.info(`Running Version: ${app.locals.versionRevLong}; ${app.locals.version} (Latest: ${app.locals.versionRevLatest})`);
app.locals.versionUpdateAvailable = true;
}
// ETA Init
const eta = new Eta({ views: path.join(__path, 'views') });
app.engine('eta', buildEtaEngine());
app.set('view engine', 'eta');
// MARK: Express Middleware & Config
app.set('x-powered-by', false); // helmet does this too. But not in devmode
if (!config.global.devmode) {
app.use(
helmet({
strictTransportSecurity: config.global.http_enable_hsts,
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", config.global.http_domain],
objectSrc: ["'none'"],
upgradeInsecureRequests: config.global.devmode ? null : []
}
}
})
); // Add headers
}
app.use(fileUpload());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.use(routes);
// TODO: Remove hardcoded http
app.listen(config.global.http_port, config.global.http_listen_address, () => {
log.web.info(`Listening at http://${config.global.http_listen_address}:${config.global.http_port}`);
});
// MARK: Helper Functions
function buildEtaEngine() {
return (path: string, opts: Options, callback: CallableFunction) => {
try {
const fileContent = eta.readFile(path);
const renderedTemplate = eta.renderString(fileContent, opts);
callback(null, renderedTemplate);
} catch (error) {
callback(error);
}
};
}

140
src/libs/configManager.ts Normal file
View File

@ -0,0 +1,140 @@
import fs from 'node:fs';
import _ from 'lodash';
import { randomUUID, randomBytes } from 'crypto';
export type configObject = Record<any, any>;
/**
* This class is responsible to save/edit config files.
*
* @export
* @class config
* @typedef {config}
*/
export default class config {
#configPath: string;
//global = {[key: string] : string}
global: configObject;
replaceSecrets: boolean;
/**
* Creates an instance of config.
*
* @constructor
* @param {string} configPath Path to config file.
* @param {object} configPreset Default config object with default values.
*/
constructor(configPath: string, replaceSecrets: boolean, configPreset: object) {
this.#configPath = configPath;
this.global = configPreset;
this.replaceSecrets = replaceSecrets;
try {
// Read config
const data = fs.readFileSync(this.#configPath, 'utf8');
// Extend config with missing parameters from configPreset.
this.global = _.defaultsDeep(JSON.parse(data), this.global);
// Save config.
this.save_config();
} catch (err: any) {
// If file does not exist, create it.
if (err.code === 'ENOENT') {
console.log(`Config file does not exist. Creating it at ${this.#configPath} now.`);
this.save_config();
return;
}
console.error(`Could not read config file at ${this.#configPath} due to: ${err}`);
// Exit process.
process.exit(1);
}
}
/**
* Saves the jsonified config object to the config file.
*/
save_config() {
try {
// If enabled replace tokens defines as "gen" with random token
if (this.replaceSecrets) {
// Replace tokens with value "gen"
this.generate_secrets(this.global, 'gen')
}
fs.writeFileSync(this.#configPath, JSON.stringify(this.global, null, 8));
} catch (err) {
console.error(`Could not write config file at ${this.#configPath} due to: ${err}`);
return;
}
console.log(`Successfully written config file to ${this.#configPath}`);
}
/**
* Replaces each item matching the value of placeholder with a random UUID.
* Thanks to https://stackoverflow.com/questions/8085004/iterate-through-nested-javascript-objects
* @param {configObject} obj
*/
generate_secrets(obj: configObject, placeholder: string) {
const stack = [obj];
while (stack?.length > 0) {
const currentObj:any = stack.pop();
Object.keys(currentObj).forEach((key) => {
if (currentObj[key] === placeholder) {
console.log('Generating secret: ' + key);
currentObj[key] = randomBytes(48).toString('base64').replace(/\W/g, '');
}
if (typeof currentObj[key] === 'object' && currentObj[key] !== null) {
stack.push(currentObj[key]);
}
});
}
}
}
/*
**** Example ****
import ConfigHandlerNG from './assets/configHandlerNG.js';
// Create a new config instance.
export const config = new ConfigHandler(__path + '/config.json', true, {
test1: 't1',
test2: 't2',
test3: 'gen',
test4: 't4',
test5: 'gen',
testObj: {
local: {
active: true,
users: {
user1: 'gen',
user2: 'gen',
user3: 'gen',
user4: 'gen',
}
},
oidc: {
active: false
}
}
});
console.log('Base Config:');
console.log(config.global);
console.log('Add some new key to config and call save_config().');
config.global.NewKey = 'ThisIsANewKey!'
config.save_config()
console.log('This will add a new key with value gen, but gen gets replaced with a random UUID when save_config() is called.');
config.global.someSecret = 'gen'
config.save_config() // global.someSecret is getting replaced with some random UUID since it was set to 'gen'.
console.log('Complete Config:');
console.log(config.global);
*/

11
src/routes/api/index.ts Normal file
View File

@ -0,0 +1,11 @@
import express from 'express';
// Route imports
import v1_routes from './v1/index.js';
// Router base is '/api'
const Router = express.Router({ strict: false });
Router.use('/v1', v1_routes);
export default Router;

View File

@ -0,0 +1,51 @@
import express from 'express';
import passport from 'passport';
// Route imports
import testRoute from './test.js';
import versionRoute from './version.js'
import userRoute from './user.js';
import userRoute_schema from './user_schema.js';
// import content_route from './content.js';
// import content_schema from './content_schema.js';
// import * as content_s3_sub_route from './content_s3_sub.js';
// import * as content_s3_sub_schema from './content_s3_sub_schema.js';
// Router base is '/api/v1'
const Router = express.Router({ strict: false });
// All empty strings are undefined (not null!) values (body)
Router.use('*', function (req, res, next) {
for (let key in req.body) {
if (req.body[key] === '') {
req.body[key] = undefined;
}
}
next();
});
// All api routes lowercase! Yea I know but when strict: true it matters.
Router.route('/user').get(userRoute.get).post(userRoute.post).patch(userRoute.patch).delete(userRoute.del);
Router.route('/user/describe').get(userRoute_schema);
// Router.route('/content').get(content_route.get).post(content_route.post).patch(content_route.patch).delete(content_route.del);
// Router.route('/content/describe').get(content_schema);
// Router.route('/content/downloadurl').get(content_s3_sub_route.get_downloadurl);
// Router.route('/content/uploadurl').get(content_s3_sub_route.get_uploadurl);
// Router.route('/content/downloadurl/describe').get(content_s3_sub_schema.get_describe_downloadurl);
// Router.route('/content/uploadurl/describe').get(content_s3_sub_schema.get_describe_uploadurl);
Router.route('/version').get(versionRoute.get);
//Router.use('/search', search_routes);
Router.route('/test').get(testRoute.get);
export default Router;

View File

@ -0,0 +1,7 @@
import express, { Request, Response } from 'express';
function get(req: Request, res: Response) {
res.status(200).send('API v1 Test successful!');
};
export default { get };

165
src/routes/api/v1/user.ts Normal file
View File

@ -0,0 +1,165 @@
import { Request, Response } from 'express';
import db, { handlePrismaError } from '../../../handlers/db.js'; // Database
import log from '../../../handlers/log.js';
import { parseDynamicSortBy } from '../../../helpers/prisma_helpers.js';
import { schema_get, schema_post, schema_patch, schema_del } from './user_schema.js';
// MARK: GET user
async function get(req: Request, res: Response) {
const { error, value } = schema_get.validate(req.query);
if (error) {
log.api?.debug('GET user Error:', req.query, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('GET user Success:', req.query, value);
if (value.search !== undefined || value.id !== undefined) {
// if search or get by id
await db
.$transaction([
// Same query for count and findMany
db.user.count({
where: {
OR: [{ id: value.id }, { name: { search: value.search } }]
},
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
}),
db.user.findMany({
where: {
OR: [{ id: value.id }, { name: { search: value.search } }]
},
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
})
])
.then(([count, result]) => {
if (result.length !== 0) {
result.forEach((element: { id: number; name: string; code: string | null | boolean }) => {
// code-> true if code is set
element.code = element.code !== null;
});
res.status(200).json({ count, result });
} else {
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified object' });
}
})
.catch((err) => {
handlePrismaError(err, res, 'GET user');
});
} else {
// get all
await db
.$transaction([
// Same query for count and findMany
db.user.count({
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
}),
db.user.findMany({
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
})
])
.then(([count, result]) => {
if (result.length !== 0) {
result.forEach((element: { id: number; name: string; code: string | null | boolean }) => {
// code-> true if code is set
element.code = element.code !== null;
});
res.status(200).json({ count, result });
} else {
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified object' });
}
})
.catch((err) => {
handlePrismaError(err, res, 'GET user');
});
}
}
}
// MARK: CREATE user
async function post(req: Request, res: Response) {
const { error, value } = schema_post.validate(req.body);
if (error) {
log.api?.debug('POST user Error:', req.body, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('POST user Success:', req.body, value);
await db.user
.create({
data: {
name: value.name,
code: value.code
},
select: {
id: true
}
})
.then((result) => {
res.status(201).json({ status: 'CREATED', message: 'Successfully created user', id: result.id });
})
.catch((err) => {
handlePrismaError(err, res, 'POST user');
});
}
}
// MARK: UPDATE user
async function patch(req: Request, res: Response) {
const { error, value } = schema_patch.validate(req.body);
if (error) {
log.api?.debug('PATCH user Error:', req.body, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('PATCH user Success:', req.body, value);
await db.user
.update({
where: {
id: value.id
},
data: {
name: value.name,
code: value.code
},
select: {
id: true
}
})
.then((result) => {
res.status(200).json({ status: 'UPDATED', message: 'Successfully updated user', id: result.id });
})
.catch((err) => {
handlePrismaError(err, res, 'PATCH user');
});
}
}
// MARK: DELETE user
async function del(req: Request, res: Response) {
const { error, value } = schema_del.validate(req.body);
if (error) {
log.api?.debug('DEL user Error:', req.body, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('DEL user Success:', req.body, value);
await db.user
.delete({
where: {
id: value.id
}
})
.then((result) => {
res.status(200).json({ status: 'DELETED', message: 'Successfully deleted user', id: result.id });
}).catch((err) => {
handlePrismaError(err, res, 'DEL user');
});
}
}
export default { get, post, patch, del };

View File

@ -0,0 +1,58 @@
import { Request, Response } from 'express';
import validator from 'joi'; // DOCS: https://joi.dev/api
import { Prisma } from '@prisma/client';
// MARK: GET user
const schema_get = validator
.object({
sort: validator
.string()
.valid(...Object.keys(Prisma.UserScalarFieldEnum))
.default('id'),
order: validator.string().valid('asc', 'desc').default('asc'),
take: validator.number().min(1).max(512),
skip: validator.number().min(0),
// This regex ensures that the search string does not contain consecutive asterisks (**) and is at least 3 characters long.
search: validator.string().min(3).max(20).regex(new RegExp('^(?!.*\\*{2,}).*$')),
id: validator.number().positive().precision(0)
})
.nand('id', 'search'); // Allow id or search. not both.
// MARK: CREATE alertContact
const schema_post = validator.object({
name: validator.string().min(1).max(32).required(),
code: validator.string().min(4).max(4).trim().regex(new RegExp('/^[0-9]+$/'))
});
// MARK: UPDATE alertContact
const schema_patch = validator
.object({
id: validator.number().positive().precision(0).required(),
name: validator.string().min(1).max(32),
code: validator.string().min(4).max(4).trim().regex(new RegExp('/^[0-9]+$/'))
})
.or('name', 'code');
// MARK: DELETE alertContact
const schema_del = validator.object({
id: validator.number().positive().precision(0).required()
});
// Describe all schemas
const schema_get_desc = schema_get.describe();
const schema_post_desc = schema_post.describe();
const schema_patch_desc = schema_patch.describe();
const schema_del_desc = schema_del.describe();
// GET route
export default async function get(req: Request, res: Response) {
res.status(200).json({
GET: schema_get_desc,
POST: schema_post_desc,
PATCH: schema_patch_desc,
DELETE: schema_del_desc
});
}
export { schema_get, schema_post, schema_patch, schema_del };

View File

@ -0,0 +1,9 @@
import { Request, Response } from 'express';
function get(req: Request, res: Response) {
res.status(200).send({ version: '1.0.0', commit: req.app.locals.versionRev, updateAvailable: req.app.locals.versionUpdateAvailable });
};
export default { get };
// TODO: FIXME!!!!!!

View File

@ -0,0 +1,7 @@
import express, { Request, Response } from 'express';
function get(req: Request, res: Response) {
res.render("contacts")
}
export default { get };

View File

@ -0,0 +1,7 @@
import express, { Request, Response } from 'express';
function get(req: Request, res: Response) {
res.render("lockscreen", { message: "Hello world from eta!" })
}
export default { get };

View File

@ -0,0 +1,25 @@
import express from 'express';
// Route imports
import dashboardRoute from './dashboard.js';
import testRoute from './test.js';
import contactRoute from './contact.js';
// import itemsRoute from './items.js';
// import manage_routes from './manage/index.js';
// Router base is '/'
const Router = express.Router({ strict: false });
// Router.route('/test').get(testRoute.get);
// Router.route('/items').get(itemsRoute.get);
// Router.route('/:id(\\w{8})').get(skuRoute.get);
// Router.route('/s/:id').get(skuRouteDash.get);
// Router.use('/manage', manage_routes);
Router.route('/').get(dashboardRoute.get);
Router.route('/dbTest').get(testRoute.get);
Router.route('/contact').get(contactRoute.get);
export default Router;

View File

@ -0,0 +1,7 @@
import express, { Request, Response } from 'express';
function get(req: Request, res: Response) {
res.render("test", { message: "Hello world from eta!" })
}
export default { get };

34
src/routes/index.ts Normal file
View File

@ -0,0 +1,34 @@
import express from 'express';
import path from 'node:path';
import __path from "../handlers/path.js";
import log from "../handlers/log.js";
// Route imports
import frontend_routes from './frontend/index.js';
import api_routes from './api/index.js';
const Router = express.Router({ strict: false });
// static / libs routes
Router.use('/static', express.static(__path + '/static'));
Router.use('/libs/bulma', express.static(path.join(__path, 'node_modules', 'bulma', 'css'))); // http://192.168.221.10:3000/libs/bulma/bulma.css
Router.use('/libs/jquery', express.static(path.join(__path, 'node_modules', 'jquery', 'dist')));
Router.use('/libs/bootstrap-icons', express.static(path.join(__path, 'node_modules', 'bootstrap-icons')));
// Other routers
Router.use('/api', api_routes);
Router.use('/', frontend_routes);
// Default route.
Router.all('*', function (req, res) {
// TODO: Respond based on content-type (with req.is('application/json'))
if (req.is('application/json')) {
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified page' });
} else {
res.status(404).render('errors/404', { url: req.originalUrl });
}
});
export default Router;

237
static/apiWrapper.js Normal file
View File

@ -0,0 +1,237 @@
_wrapperVersion = '1.0.0';
_minApiVersion = '1.0.0';
_maxApiVersion = '1.0.0';
_defaultTTL = 60000;
_apiConfig = {
basePath: '/api/v1/'
};
if (!window.localStorage) {
console.warn('Local Storage is not available, some features may not work');
}
// Generic driver functions
let _api = {
get: async function (path) {
const options = {
headers: new Headers({ 'content-type': 'application/json' })
};
const response = await fetch(_apiConfig.basePath + path, options);
// Handle the response
if (!response.ok) {
console.error('Failed to fetch:', response.statusText);
_testPageFail(response.statusText);
return;
}
const result = await response.json();
// Handle the result, was json valid?
if (!result) {
// Is it a number instead?
if (typeof result === 'number') {
return result;
}
console.error('Invalid JSON response');
_testPageFail('Invalid JSON response');
return;
}
return result;
},
post: async function (path, data) {
const options = {
method: 'POST',
headers: new Headers({ 'content-type': 'application/json' }),
body: JSON.stringify(data)
};
const response = await fetch(_apiConfig.basePath + path, options);
// Handle the response
if (!response.ok) {
_testPageFail(response.statusText);
return;
}
const result = await response.json();
// Handle the result, was json valid?
if (!result) {
_testPageFail('Invalid JSON response');
return;
}
return result;
},
delete: async function (path, data) {
const options = {
method: 'DELETE',
headers: new Headers({ 'content-type': 'application/json' }),
body: JSON.stringify(data)
};
const response = await fetch(_apiConfig.basePath + path, options);
// Handle the response
if (!response.ok) {
_testPageFail(response.statusText);
return;
}
const result = await response.json();
// Handle the result, was json valid?
if (!result) {
_testPageFail('Invalid JSON response');
return;
}
return result;
},
patch: async function (path, data) {
const options = {
method: 'PATCH',
headers: new Headers({ 'content-type': 'application/json' }),
body: JSON.stringify(data)
};
const response = await fetch(_apiConfig.basePath + path, options);
// Handle the response
if (!response.ok) {
_testPageFail(response.statusText);
return;
}
const result = await response.json();
// Handle the result, was json valid?
if (!result) {
_testPageFail('Invalid JSON response');
return;
}
return result;
}
};
function updateRow(tableName, id, data) {
invalidateCache(tableName);
return _api.patch(`${tableName}`, { id: id, ...data });
}
function deleteRow(tableName, id) {
invalidateCache(tableName);
return _api.delete(`${tableName}`, { id: id });
}
function getApiDescriptionByTable(tableName) {
const keyDesc = `desc:${tableName}`;
const keyTime = `${keyDesc}:time`;
const keyTTL = `${keyDesc}:ttl`;
// Retrieve cached data
const description = JSON.parse(localStorage.getItem(keyDesc));
const timeCreated = parseInt(localStorage.getItem(keyTime));
const ttl = parseInt(localStorage.getItem(keyTTL));
// Check if valid cached data exists
if (description && timeCreated && ttl) {
const currentTime = Date.now();
const age = currentTime - parseInt(timeCreated, 10);
if (age < parseInt(ttl, 10)) {
// Return cached data immediately
return Promise.resolve(description);
} else {
console.warn('Cached description expired; fetching new data');
// Fetch new data, update cache, and return it
return fetchAndUpdateCache(tableName);
}
} else {
console.warn('No cached description; fetching from server');
// Fetch data, update cache, and return it
return fetchAndUpdateCache(tableName);
}
function fetchAndUpdateCache(tableName) {
return _api
.get(`${tableName}/describe`)
.then((data) => {
if (data) {
// Update local storage with new data
localStorage.setItem(keyDesc, JSON.stringify(data));
localStorage.setItem(keyTime, Date.now().toString());
localStorage.setItem(keyTTL, '60000'); // 60 seconds TTL
}
return data; // Return the fetched data
})
.catch((error) => {
console.error('Failed to fetch description:', error);
// Fallback to cached data if available (even if expired)
return description || null;
});
}
}
function returnTableDataByTableName(tableName, search="", orderBy="asc", sort="", take=-1, skip=0) {
var orderBy = orderBy.toLowerCase();
if(orderBy == "") {
orderBy = "asc";
}
var baseString = tableName + "?order=" + orderBy;
if(sort && sort.length > 0) {
baseString += "&sort=" + sort;
}
if(take > 0) {
baseString += "&take=" + take;
}
if(skip > 0) {
baseString += "&skip=" + skip;
}
if (search && search.length > 0) {
return _api.get(baseString + '&search=' + search);
} else {
return _api.get(baseString);
}
}
async function getCountByTable(tableName, search="") {
let baseString = tableName + '?count=true';
if (search && search.length > 0) {
baseString += '&search=' + search;
}
// Stored in `data:count:${tableName}`
let result = await _api.get(baseString);
console.debug('Count result:', result);
if (typeof result !== 'number') {
_testPageWarn('Count was not a number, was: ' + result);
console.warn('Count was not a number, was: ' + result);
return -1;
}
return result;
}
function _testPageFail(reason) {
document.getElementById('heroStatus').classList.remove('is-success');
document.getElementById('heroStatus').classList.add('is-danger');
document.getElementById('heroExplainer').innerHTML = 'API Wrapper Test Failed, reason: ' + reason;
}
function _testPageWarn(reason) {
document.getElementById('heroStatus').classList.remove('is-success');
document.getElementById('heroStatus').classList.add('is-warning');
document.getElementById('heroExplainer').innerHTML = 'API Wrapper Test Warning, reason: ' + reason;
}
function getServerVersion() {
return _api.get('version');
}
function createEntry(tableName, data) {
invalidateCache(tableName);
return _api.post(tableName, data);
}
function invalidateCache(tableName) {
const keyDesc = `desc:${tableName}`;
const keyTime = `${keyDesc}:time`;
const keyTTL = `${keyDesc}:ttl`;
localStorage.removeItem(keyDesc);
localStorage.removeItem(keyTime);
localStorage.removeItem(keyTTL);
}

31
static/css/lockscreen.css Normal file
View File

@ -0,0 +1,31 @@
#clock {
font-size: 120px;
font-weight: 100;
color: #fff;
text-align: center;
bottom: 10%;
right: 5%;
z-index: 900010;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
flex-wrap: wrap;
width: 30%;
}
#time {
margin-bottom: 0px;
padding-bottom: 0px;
text-align: center;
width: 95%;
vertical-align: middle;
font-family: monospace;
}
#date {
font-size: 50px;
margin-top: -40px;
}

3
static/favicon.svg Normal file
View File

@ -0,0 +1,3 @@
<svg id="favicon" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="138.654" height="146.519" viewBox="0 0 36.685 38.767">
<path d="M18.775 0A24.388 24.388 0 0 0 6.82 3.115C3.15 5.165-1.91 9.252.736 13.985c.37.66.9 1.221 1.47 1.713 1.532 1.322 2.98.222 4.554-.457.975-.42 1.95-.842 2.922-1.27.434-.19 1.01-.33 1.328-.698.858-.99.494-2.994.05-4.095a27.25 27.25 0 0 1 3.65-1.24v30.828h7.215V7.671c1.05.184 2.438.432 3.266 1.041.387.284.113.908.076 1.297-.08.827-.027 1.817.344 2.581.308.632 1.16.784 1.765 1.008l4.564 1.704c.628.232 1.33.643 1.979.297 2.822-1.507 3.574-5.39 1.843-8.023-1.165-1.77-3.255-3.13-5.035-4.216C27.037 1.107 22.906.014 18.775 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 678 B

View File

@ -0,0 +1,144 @@
// Image Handler
const baseUrl = "https://api.unsplash.com/photos/random?client_id=[KEY]&orientation=landscape&topics=nature";
const apiKey = "tYOt7Jo94U7dunVcP5gt-kDKDMjWFOGQNsHuhLDLV8k"; // Take from config
const fullUrl = baseUrl.replace("[KEY]", apiKey);
const showModeImage = "/static/media/showModeLockscreen.jpg"
let credits = document.getElementById("credits");
let currentImageHandle;
// Lock screen or show mode
let screenState = "lock";
function handleImage() {
if(screenState === "lock") {
fetch("https://staging.thegreydiamond.de/projects/photoPortfolio/api/getRand.php?uuid=01919dec-b2cd-7adc-8ca2-a071d1169cbc&unsplash=true")
.then(response => response.json())
.then(data => {
// data = {
// urls: {
// regular: "https://imageproxy.thegreydiamond.de/ra5iqxlyve6HpjNvC1tzG50a14oIOgiWP95CxIvbBC8/sm:1/kcr:1/aHR0cHM6Ly9zdGFn/aW5nLnRoZWdyZXlk/aWFtb25kLmRlL3By/b2plY3RzL3Bob3Rv/UG9ydGZvbGlvL2Rl/bW9IaVJlcy9QMTE5/MDgzMC1zY2hpbGQu/anBn.webp"
// },
// user: {
// name: "Sören Oesterwind",
// links: {
// html: "https://thegreydiamond.de"
// }
// }
// }
if(!currentImageHandle) {
// Create a page filling div which contains the image
currentImageHandle = document.createElement("div");
currentImageHandle.style.position = "absolute";
currentImageHandle.style.top = "0";
currentImageHandle.style.left = "0";
currentImageHandle.style.width = "100%";
currentImageHandle.style.height = "100%";
currentImageHandle.style.backgroundImage = `url(${data.urls.regular})`;
currentImageHandle.style.backgroundSize = "cover";
currentImageHandle.style.opacity = 1;
} else {
// Create a new div behind the current one and delete the old one when the new one is loaded
let newImageHandle = document.createElement("div");
newImageHandle.style.position = "absolute";
newImageHandle.style.top = "0";
newImageHandle.style.left = "0";
newImageHandle.style.width = "100%";
newImageHandle.style.height = "100%";
newImageHandle.style.backgroundImage = `url(${data.urls.regular})`;
newImageHandle.style.backgroundSize = "cover";
newImageHandle.style.opacity = 1;
newImageHandle.style.transition = "1s";
newImageHandle.style.zIndex = 19999;
document.body.appendChild(newImageHandle);
currentImageHandle.style.opacity = 0;
setTimeout(() => {
currentImageHandle.remove();
newImageHandle.style.zIndex = 200000;
currentImageHandle = newImageHandle;
}, 1000);
// Set the credits
credits.innerHTML = `Photo by <a href="${data.user.links.html}" target="_blank">${data.user.name}</a> on <a href="https://unsplash.com" target="_blank">Unsplash</a>`;
credits.style.zIndex = 300000;
}
})
.catch(error => {
console.error("Error fetching image: ", error);
});
} else {
if(currentImageHandle) {
// Check if the image is already loaded
if(currentImageHandle.style.backgroundImage === `url("${showModeImage}")`) {
return;
}
// Create a new div behind the current one and delete the old one when the new one is loaded
let newImageHandle = document.createElement("div");
newImageHandle.style.position = "absolute";
newImageHandle.style.top = "0";
newImageHandle.style.left = "0";
newImageHandle.style.width = "100%";
newImageHandle.style.height = "100%";
newImageHandle.style.backgroundImage = `url(${showModeImage})`;
newImageHandle.style.backgroundSize = "cover";
newImageHandle.style.opacity = 1;
newImageHandle.style.transition = "1s";
document.body.appendChild(newImageHandle);
setTimeout(() => {
currentImageHandle.remove();
currentImageHandle = newImageHandle;
}, 1000);
}
}
}
function handleTimeAndDate() {
let time = new Date();
let hours = time.getHours();
let minutes = time.getMinutes();
let day = time.getDate();
let month = time.getMonth();
month += 1;
let year = time.getFullYear();
let timeHandle = document.getElementById("time");
let dateHandle = document.getElementById("date");
timeHandle.innerHTML = `${hours < 10 ? "0" + hours : hours}:${minutes < 10 ? "0" + minutes : minutes}:${time.getSeconds() < 10 ? "0" + time.getSeconds() : time.getSeconds()}`;
// Datum in format Montag, 22.12.2024
dateHandle.innerHTML = `${getDay(time.getDay())}, ${day < 10 ? "0" + day : day}.${month < 10 ? "0" + month : month}.${year}`;
}
function getDay(day) {
switch(day) {
case 0:
return "Sonntag";
case 1:
return "Montag";
case 2:
return "Dienstag";
case 3:
return "Mittwoch";
case 4:
return "Donnerstag";
case 5:
return "Freitag";
case 6:
return "Samstag";
}
}
// Set the image handler to run every 10 minutes
setInterval(handleImage, 60 * 1000 * 10);
handleImage();
handleImage()
// Set the time and date handler to run every minute
setInterval(handleTimeAndDate, 500);
handleTimeAndDate();

3
static/logo.svg Normal file
View File

@ -0,0 +1,3 @@
<svg id="logo" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="498.424" height="148.888" viewBox="0 0 131.875 39.393">
<path d="M118.368 51.177c-3.682 0-6.537.32-8.566.958-1.99.6-3.419 1.635-4.283 3.099-.827 1.466-1.239 3.533-1.239 6.2 0 2.03.356 3.7 1.07 5.016.714 1.315 1.916 2.46 3.607 3.438 1.728.939 4.17 1.878 7.326 2.817 2.517.752 4.452 1.466 5.805 2.142 1.39.64 2.386 1.352 2.987 2.142.601.79.9 1.747.9 2.874 0 1.24-.224 2.198-.675 2.874-.451.64-1.202 1.09-2.254 1.353-1.052.263-2.536.375-4.452.338-1.916-.038-3.4-.226-4.453-.564-1.051-.376-1.822-.977-2.31-1.804-.451-.826-.733-2.01-.845-3.55h-7.045c-.113 3.157.263 5.598 1.127 7.327.864 1.728 2.348 2.95 4.452 3.663 2.142.714 5.166 1.07 9.074 1.07 3.795 0 6.706-.318 8.735-.958 2.066-.638 3.532-1.728 4.396-3.268.864-1.54 1.296-3.72 1.296-6.538 0-2.254-.357-4.095-1.07-5.522-.715-1.466-1.917-2.706-3.608-3.72-1.653-1.015-4.02-2.01-7.1-2.987-2.518-.79-4.49-1.485-5.918-2.085-1.39-.6-2.404-1.202-3.043-1.804-.639-.6-.959-1.277-.959-2.028 0-1.165.207-2.049.62-2.649.414-.601 1.09-1.033 2.03-1.296.976-.263 2.366-.395 4.17-.395 1.728 0 3.061.15 4.001.45.977.264 1.672.734 2.085 1.41.451.638.733 1.578.846 2.818h7.157c.038-2.856-.376-5.054-1.24-6.594-.863-1.54-2.292-2.63-4.283-3.27-1.954-.637-4.734-.957-8.34-.957zm-67.058.12a24.388 24.388 0 0 0-11.954 3.114c-3.67 2.051-8.73 6.137-6.085 10.87.37.66.9 1.222 1.47 1.714 1.53 1.322 2.98.222 4.554-.458.975-.42 1.95-.842 2.922-1.268.433-.19 1.01-.331 1.328-.7.858-.99.494-2.994.05-4.094a27.22 27.22 0 0 1 3.651-1.24v30.828h7.214V58.968c1.05.182 2.439.43 3.266 1.04.387.285.113.91.075 1.298-.08.827-.027 1.816.345 2.58.307.632 1.16.785 1.765 1.009l4.564 1.703c.628.233 1.33.644 1.979.298 2.822-1.508 3.574-5.39 1.842-8.023-1.164-1.771-3.254-3.13-5.034-4.216-3.69-2.254-7.822-3.347-11.952-3.36zm-39.287.443L1.146 90.063h7.045l2.423-8.453h12.962l2.48 8.453h7.101L22.055 51.74H12.023zm67.628.001L68.773 90.063h7.045l2.423-8.453h12.964l2.48 8.453h7.1L89.683 51.74H79.65zm-62.668 6.537h.056l4.903 17.076h-9.637l4.678-17.076zm67.628 0h.056l4.903 17.076h-9.637l4.678-17.076z" style="display:inline;fill:current;fill-opacity:1;stroke:none;stroke-width:.408654;stroke-opacity:1" transform="translate(-1.146 -51.177)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

3
static/main.css Normal file
View File

@ -0,0 +1,3 @@
body {
min-height: 100vh;
}

Binary file not shown.

620
static/pageDriver.js Normal file
View File

@ -0,0 +1,620 @@
_pageDriverVersion = '1.0.1';
// Handle color for icon svg with id="logo" based on the current theme
const logo = document.getElementById('logo');
if (logo) {
logo.style.fill = getComputedStyle(document.documentElement).getPropertyValue('--bulma-text');
}
if (_wrapperVersion === undefined) {
console.error('API Wrapper not found; Please include the API Wrapper before including the Page Driver');
exit();
} else {
console.log('API Wrapper found; Page Driver is ready to use');
}
// Find all tables on the page which have data-dataSource attribute
var tables = document.querySelectorAll('table[data-dataSource]');
//var tables = []
// Get all single values with data-dataSource, data-dataCol and data-dataAction
var singleValues = document.querySelectorAll('span[data-dataSource]');
// Find all search fields with data-searchTargetId
var searchFields = document.querySelectorAll('input[data-searchTargetId]');
// Find all modalForms
var modalForms = document.querySelectorAll('form[data-targetTable]');
console.info('Processing single values');
console.info(singleValues);
// Iterate over all single values
singleValues.forEach(async (singleValue) => {
writeSingelton(singleValue);
});
// Iterate over all tables
tables.forEach(async (table) => {
// Get THs and attach onClick event to sort
const ths = table.querySelectorAll('th');
ths.forEach((th) => {
if(th.getAttribute('fnc') == "actions") {
return;
}
th.style.cursor = 'pointer';
th.style.userSelect = 'none';
th.addEventListener('click', async function () {
const table = th.closest('table');
const order = th.getAttribute('data-order');
// Clear all other order attributes
ths.forEach((th) => {
if (th != this) {
th.removeAttribute('data-order');
th.classList.remove("bi-caret-up-fill")
th.classList.remove("bi-caret-down-fill")
}
});
if (order == 'ASC') {
th.setAttribute('data-order', 'DESC');
th.classList.add("bi-caret-down-fill")
th.classList.remove("bi-caret-up-fill")
} else {
th.setAttribute('data-order', 'ASC');
th.classList.add("bi-caret-up-fill")
th.classList.remove("bi-caret-down-fill")
}
refreshTable(table);
});
});
refreshTable(table);
});
async function writeSingelton(element) {
const table = element.getAttribute('data-dataSource');
console.log('Table: ', table, ' Action: ', element.getAttribute('data-dataAction'), ' Element: ', element);
switch (element.getAttribute('data-dataAction')) {
case 'COUNT': {
console.log('Count action found');
element.innerHTML = await getCountByTable(table);
break;
}
case 'SPECIAL': {
if (table == 'version') {
element.innerHTML = (await getServerVersion())['version'];
break;
}
}
default: {
console.error('Unknown action found: ', element.getAttribute('data-dataAction'));
break;
}
}
element.classList.remove('is-skeleton');
}
// Attach listeners to search fields
searchFields.forEach((searchField) => {
// Apply restrictions to search field (min, max, chars, etc)
getApiDescriptionByTable(document.getElementById(searchField.getAttribute('data-searchTargetId')).getAttribute('data-dataSource')).then((desc) => {
desc = desc['GET']['keys']['search'];
var rules = desc['rules'];
rules.forEach((rule) => {
switch (rule['name']) {
case 'min': {
searchField.setAttribute('minlength', rule['args']['limit']);
break;
}
case 'max': {
searchField.setAttribute('maxlength', rule['args']['limit']);
break;
}
}
});
});
searchField.addEventListener('input', async function () {
console.log('Search field changed: ', searchField);
if (searchField.checkValidity() == false) {
console.log('Invalid input');
searchField.classList.add('is-danger');
return;
} else {
searchField.classList.remove('is-danger');
const targetId = searchField.getAttribute('data-searchTargetId');
const target = document.getElementById(targetId);
const table = target.getAttribute('data-dataSource');
const column = target.getAttribute('data-dataCol');
const value = searchField.value;
console.log('Searching for ', value, ' in ', table, ' column ', column);
refreshTableByName(table);
}
});
});
// Attach listeners to modal forms
modalForms.forEach((modalForm) => {
// Add validation to form by using API description (everything is assumed POST for now)
modalForm.addEventListener('input', async function (event) {
if (event.target.checkValidity() == false) {
modalForm.querySelector("input[type='submit']").setAttribute('disabled', true);
event.target.classList.add('is-danger');
return;
} else {
modalForm.querySelector("input[type='submit']").removeAttribute('disabled');
event.target.classList.remove('is-danger');
}
});
getApiDescriptionByTable(modalForm.getAttribute('data-targetTable')).then((desc) => {
console.log('Description: ', desc);
const keys = desc['POST']['keys'];
// Apply resitrictions and types to form fields
for (key in keys) {
const field = modalForm.querySelector("input[name='" + key + "']");
if (field) {
const rules = keys[key]['rules'];
const flags = keys[key]['flags'];
console.log('Field: ', field, ' Rules: ', rules, ' Flags: ', flags);
rules.forEach((rule) => {
switch (rule['name']) {
case 'min': {
field.setAttribute('minlength', rule['args']['limit']);
break;
}
case 'max': {
field.setAttribute('maxlength', rule['args']['limit']);
break;
}
case 'pattern': {
field.setAttribute('pattern', rule['args']['regex'].substring(1, rule['args']['regex'].length - 1));
//field.setAttribute("pattern", "^[\\+]?[\\(]?[0-9]{3}[\\)]?[\\-\\s\\.]?[0-9]{3}[\\-\\s\\.]?[0-9]{4,9}$");
break;
}
case 'type': {
//field.setAttribute("type", rule["args"]["type"]);
console.log('Type: ', rule['args']['type']);
break;
}
}
});
if (flags) {
flags['presence'] == 'required' ? field.setAttribute('required', true) : field.removeAttribute('required');
}
}
}
console.log('Keys: ', keys);
});
modalForm.addEventListener('submit', async function (event) {
event.preventDefault();
// Check what button submitted the form and if it has data-actionBtn = save
// If not, close modal
const pressedBtn = event.submitter;
if (pressedBtn.getAttribute('data-actionBtn') != 'save') {
modalForm.closest('.modal').classList.remove('is-active');
return;
}
// Find .entryPhase and hide it
const entryPhase = modalForm.querySelector('.entryPhase');
const loadPhase = modalForm.querySelector('.loadPhase');
if (entryPhase) {
entryPhase.classList.add('is-hidden');
}
if (loadPhase) {
loadPhase.classList.remove('is-hidden');
}
console.log('Form submitted: ', modalForm);
const table = modalForm.getAttribute('data-targetTable');
const data = new FormData(modalForm);
// Convert to JSON object
let jsonData = {};
data.forEach((value, key) => {
jsonData[key] = value;
});
console.log('JSON Data: ', jsonData);
let resp = {};
if(modalForm.getAttribute('data-action') == 'edit') {
Rid = modalForm.getAttribute('data-rid');
resp = await updateRow(table, Rid,jsonData);
modalForm.setAttribute('data-action', 'create');
} else {
resp = await createEntry(table, jsonData);
}
console.log('Response: ', resp);
if (resp['status'] == 'CREATED' || resp['status'] == 'UPDATED') {
console.log('Entry created successfully');
modalForm.closest('.modal').classList.remove('is-active');
modalForm.reset();
// Hide loadPhase
if (loadPhase) {
loadPhase.classList.add('is-hidden');
}
// Show entryPhase
if (entryPhase) {
entryPhase.classList.remove('is-hidden');
}
} else {
// Hide loadPhase
if (loadPhase) {
loadPhase.classList.add('is-hidden');
}
// Show entryPhase
if (entryPhase) {
entryPhase.classList.remove('is-hidden');
}
// TODO: Show error message
}
// Find all tables with data-searchTargetId set to table
setTimeout(() => {
refreshTableByName(table);
updateSingeltonsByTableName(table);
}, 500);
});
});
// Helper
async function refreshTable(table) {
// Refresh a table while keeping (optionally set) search value
const searchField = document.querySelector("input[data-searchTargetId='" + table.id + "']");
// Get state of order and sort
const ths = table.querySelectorAll('th');
const columnIndices = [];
ths.forEach((th, index) => {
columnIndices[th.getAttribute('data-dataCol')] = index;
});
let order = '';
let column = '';
ths.forEach((th) => {
if (th.hasAttribute('data-order')) {
order = th.getAttribute('data-order');
column = th.getAttribute('data-dataCol');
}
});
console.log('Order: ', order, ' Column: ', column);
const maxLinesPerPage = table.getAttribute('data-pageSize');
let currentPage = table.getAttribute('data-currentPage');
if(currentPage == null) {
table.setAttribute('data-currentPage', 1);
currentPage = 1;
}
const start = (currentPage - 1) * maxLinesPerPage;
const end = start + maxLinesPerPage;
let paginationPassOnPre = {
'start': start,
'end': end,
'currentPage': currentPage,
'maxLinesPerPage': maxLinesPerPage
};
if (searchField) {
const value = searchField.value;
const dbTable = table.getAttribute('data-dataSource');
const result = await returnTableDataByTableName(dbTable, value, order, column, take= maxLinesPerPage, skip= start);
const totalResultCount = await getCountByTable(dbTable, value);
paginationPassOnPre['dataLength'] = totalResultCount;
var magMiddl = managePaginationMiddleware(result, paginationPassOnPre);
var data = magMiddl[0];
var paginationPassOn = magMiddl[1];
clearTable(table);
writeDataToTable(table, data, paginationPassOn);
} else {
const result = await returnTableDataByTableName(table.getAttribute('data-dataSource'), undefined, order, column, take= maxLinesPerPage, skip= start);
const resultCount = await getCountByTable(table.getAttribute('data-dataSource'));
paginationPassOnPre['dataLength'] = resultCount;
var magMiddl = managePaginationMiddleware(result, paginationPassOnPre);
var data = magMiddl[0];
var paginationPassOn = magMiddl[1];
clearTable(table);
writeDataToTable(table, data, paginationPassOn);
}
}
function managePaginationMiddleware(data, paginationPassOnPre) {
const maxLinesPerPage = paginationPassOnPre['maxLinesPerPage'];
const dataLength = paginationPassOnPre['dataLength'];
const maxPages = Math.ceil(dataLength / maxLinesPerPage);
paginationPassOn = paginationPassOnPre;
paginationPassOn['maxPages'] = maxPages;
// paginationPassOn['dataLength'] = dataLength;
return [data, paginationPassOn];
}
async function refreshTableByName(name) {
const dirtyTables = document.querySelectorAll("table[data-dataSource='" + name + "']");
for (dirty of dirtyTables) {
refreshTable(dirty);
}
}
async function updateSingeltonsByTableName(name) {
const dirtySingles = document.querySelectorAll("span[data-dataSource='" + name + "']");
for (dirty of dirtySingles) {
writeSingelton(dirty);
}
}
function clearTable(table) {
const tbody = table.querySelector('tbody');
tbody.innerHTML = '';
}
function writeDataToTable(table, data, paginationPassOn) {
if(data == undefined || data == null || data.length == 0) {
return;
}
console.log('Writing data to table: ', table, data);
// Get THEAD and TBODY elements
const thead = table.querySelector('thead');
const tbody = table.querySelector('tbody');
// get index per column
const columns = thead.querySelectorAll('th');
const columnIndices = [];
columns.forEach((column, index) => {
columnIndices[column.getAttribute('data-dataCol')] = index;
});
// All required cols
let requiredCols = [];
let actionFields = [];
columns.forEach((column) => {
// console.log('Column: ', column, ' FNC: ', column.getAttribute('data-fnc'), column.attributes);
if(column.getAttribute('data-fnc') == "actions") {
console.log('!!! Found actions column !!!');
actionFields.push(column);
return;
}
requiredCols.push(column.getAttribute('data-dataCol'));
});
// Get paginationPassOn
const start = paginationPassOn['start'];
const end = paginationPassOn['end'];
const currentPage = paginationPassOn['currentPage'];
const maxLinesPerPage = paginationPassOn['maxLinesPerPage'];
const maxPages = paginationPassOn['maxPages'];
const dataLength = paginationPassOn['dataLength'];
// Find nav with class pagination and data-targetTable="table.id"
const paginationElement = document.querySelector("nav.pagination[data-targetTable='" + table.id + "']");
const paginationList = paginationElement.querySelector('ul.pagination-list');
console.log('Data length: ', dataLength, ' Max pages: ', maxPages);
if(maxPages > 1) {
// Clear pagination list
paginationList.innerHTML = '';
for (let i = 1; i <= maxPages; i++) {
const li = document.createElement('li');
li.innerHTML = '<a class="pagination-link" aria-label="Goto page ' + i + '" data-page="' + i + '">' + i + '</a>';
if(i == currentPage) {
li.querySelector('a').classList.add('is-current');
}
paginationList.appendChild(li);
}
// Remove unused pages, only leave first, last, current and 2 neighbors
let pages = paginationList.querySelectorAll('li');
let friends = []
// Always add first and last
friends.push(0);
friends.push(pages.length - 1);
friends.push(currentPage-1);
// Add direct neighbors
// friends.push(currentPage - 2);
friends.push(currentPage);
friends.push(currentPage - 2);
// Deduplicate friends
friends = [...new Set(friends)];
// Sort friends
friends.sort((a, b) => a - b);
// Parse friends (string to int)
friends = friends.map((x) => parseInt(x));
console.log('Friends: ', friends, ' Pages: ', pages.length, ' Current: ', currentPage);
// Remove everyone who is not a friend
for(let i = 0; i < pages.length; i++) {
if(friends.includes(i)) {
continue;
}
pages[i].remove();
}
// Find all gaps (step size bigger then 1) and add an ellipsis in between the two numbers
let last = 0;
for(let i = 0; i < friends.length; i++) {
if(friends[i] - last > 1) {
const li = document.createElement('li');
li.innerHTML = '<span class="pagination-ellipsis">&hellip;</span>';
paginationList.insertBefore(li, pages[friends[i]]);
}
last = friends[i];
}
// Append on click event to all pagination links
paginationList.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', async function() {
const page = link.getAttribute('data-page');
table.setAttribute('data-currentPage', page);
refreshTable(table);
});
});
paginationElement.classList.remove('is-hidden');
} else {
paginationElement.classList.add('is-hidden');
}
for (resultIndex in data) {
const row = data[resultIndex];
const tr = document.createElement('tr');
requiredCols.forEach((column) => {
const td = document.createElement('td');
td.innerText = row[column];
tr.appendChild(td);
});
// Add action fields
actionFields.forEach((actionField) => {
const td = document.createElement('td');
const actions = actionField.getAttribute('data-actions').split(',');
actions.forEach((action) => {
const button = document.createElement('button');
let icon = '';
let color = 'is-primary';
switch(action) {
case 'edit': {
icon = '<i class="bi bi-pencil"></i>';
break;
}
case 'delete': {
icon = '<i class="bi bi-trash"></i>';
color = 'is-danger';
break;
}
}
// Add classes
button.classList.add('button');
button.classList.add('is-small');
button.classList.add(color);
button.classList.add('is-outlined');
button.innerHTML = ` <span class="icon is-small">${icon}</span> `;
button.style.marginRight = '5px';
// Add data-action and data-id
button.setAttribute('data-action', action);
button.setAttribute("data-id", row["id"]);
// Add event listener
button.addEventListener('click', async function() {
const table = actionField.closest('table');
const row = button.closest('tr');
const columns = table.querySelectorAll('th');
const columnIndices = [];
columns.forEach((column, index) => {
columnIndices[column.getAttribute('data-dataCol')] = index;
});
const data = [];
columns.forEach((column) => {
data[column.getAttribute('data-dataCol')] = row.children[columnIndices[column.getAttribute('data-dataCol')]].innerText;
});
console.log('Data: ', data);
switch(action) {
case 'edit': {
// Open modal with form
const form = document.querySelector("form[data-targetTable='" + table.getAttribute('data-dataSource') + "']");
const formTitle = form.querySelector('.title');
const entryPhase = form.querySelector('.entryPhase');
const loadPhase = form.querySelector('.loadPhase');
const fields = form.querySelectorAll('input');
// Set modal to edit mode
form.setAttribute('data-action', 'edit');
form.setAttribute('data-rid', button.getAttribute('data-id'));
formTitle.innerText = 'Edit entry';
fields.forEach((field) => {
// Skip for submit button
if(field.getAttribute('type') == 'submit') {
return;
}
field.value = data[field.getAttribute('name')];
});
form.closest('.modal').classList.add('is-active');
// TBD
break;
}
case 'delete': {
// confirm
const confirm = window.confirm('Do you really want to delete this entry?');
// Delete entry
if(confirm) {
const table = actionField.closest('table');
const id = button.getAttribute('data-id');
const resp = await deleteRow(table.getAttribute('data-dataSource'), id);
if(resp['status'] == 'DELETED') {
refreshTable(table);
updateSingeltonsByTableName(table.getAttribute('data-dataSource'));
} else {
// Show error message
// TODO: Show error message
}
}
break;
}
}
}
);
td.appendChild(button);
});
tr.appendChild(td);
});
tbody.appendChild(tr);
}
}
// Handle modal
document.addEventListener('DOMContentLoaded', () => {
// Functions to open and close a modal
function openModal($el) {
$el.classList.add('is-active');
}
function closeModal($el) {
$el.classList.remove('is-active');
}
function closeAllModals() {
(document.querySelectorAll('.modal') || []).forEach(($modal) => {
closeModal($modal);
});
}
// Add a click event on buttons to open a specific modal
(document.querySelectorAll('.js-modal-trigger') || []).forEach(($trigger) => {
const modal = $trigger.dataset.target;
const $target = document.getElementById(modal);
$trigger.addEventListener('click', () => {
openModal($target);
});
});
// Add a click event on various child elements to close the parent modal
(document.querySelectorAll('.modal-close, .modal-card-head .delete, .modal-card-foot .button') || []).forEach(($close) => {
const $target = $close.closest('.modal');
$close.addEventListener('click', () => {
closeModal($target);
});
});
// Add a keyboard event to close all modals
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeAllModals();
}
});
});

1
static/placeholder.json Normal file
View File

@ -0,0 +1 @@
{}

1
static/test.js Normal file
View File

@ -0,0 +1 @@
console.log('test.js');

117
tsconfig.json Normal file
View File

@ -0,0 +1,117 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "esnext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "nodenext", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
"paths": {
"*": [
"./node_modules/*"
]
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
"sourceRoot": "src", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": ["src"],
"exclude": ["node_modules", "dist"],
}

119
views/contacts.eta Normal file
View File

@ -0,0 +1,119 @@
<%~ include("partials/base_head.eta", {"title": "Kontakte"}) %>
<%~ include("partials/nav.eta") %>
<section class="hero is-primary" id="heroStatus">
<div class="hero-body">
<p class="title" data-tK="start-hero-header-welcome">Kontaktverwaltung</p>
<p class="subtitle" data-tK="start-hero-header-subtitle-default" id="heroExplainer">Erklärungstext</p>
</div>
</section>
<section class="section">
<nav class="level">
<div class="level-item has-text-centered">
<div>
<p class="heading">Kontakte</p>
<p class="title"><span data-dataSource="AlertContacts" data-dataAction="COUNT" class="is-skeleton">Load.</span></p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading"><button class="js-modal-trigger button" data-target="modal-js-example">
Neuen Konakt anlegen
</button></p>
</div>
</div>
</nav>
</section>
<!-- TODO: Mark required fields as required; add handling for validation -->
<div id="modal-js-example" class="modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box entryPhase is-hidden">
<h2 class="title">Neuer Kontakt</h1>
<i class="bi bi-arrow-clockwise title"></i>
</div>
<div class="box entryPhase">
<form data-targetTable="AlertContacts">
<h2 class="title">Neuer Kontakt</h1>
<div class="field">
<label class="label">Name</label>
<div class="control has-icons-left">
<input class="input" type="text" placeholder="John Doe" value="" name="name">
<span class="icon is-small is-left">
<i class="bi bi-file-earmark-person-fill"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">Telefonummer</label>
<div class="control has-icons-left">
<input class="input" type="text" placeholder="+49 1234 5678912" value="" name="phone">
<span class="icon is-small is-left">
<i class="bi bi-telephone-fill"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">Anmerkung</label>
<div class="control has-icons-left">
<input class="input" type="text" placeholder="" value="" name="comment">
<span class="icon is-small is-left">
<i class="bi bi-chat-fill"></i>
</span>
</div>
</div>
<br>
<div class="field is-grouped">
<div class="control">
<input type="submit" class="button is-link" value="Save" data-actionBtn="save">
</div>
<!--<div class="control">
<button type="button" class="button is-link is-light" data-actionBtn="cancel">Cancel</button>
</div>-->
</div>
</form>
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</div>
<section class="section">
<h1 class="title" data-tK="start-recent-header">Kontaktübersicht</h1>
<input class="input" type="text" data-searchTargetId="contactTable" placeholder="Search..." />
<table class="table is-striped is-fullwidth is-hoverable" data-dataSource="AlertContacts" id="contactTable" data-pageSize="5">
<thead>
<tr>
<th data-dataCol = "id"><abbr title="Position">Pos</abbr></th>
<th data-dataCol = "name">Name</th>
<th data-dataCol = "phone">Telefonnummer</th>
<th data-dataCol = "comment">Kommentar</th>
<th data-fnc="actions" data-actions="edit,delete">Aktionen</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<nav class="pagination is-hidden" role="navigation" aria-label="pagination" data-targetTable="contactTable">
<ul class="pagination-list">
</ul>
</nav>
</section>
<%~ include("partials/footer.eta") %>
<%~ include("partials/base_foot.eta") %>

26
views/errors/404.eta Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>404</title>
<link rel="stylesheet" href="/libs/bulma/bulma.min.css">
</head>
<body>
<section class="hero is-fullheight">
<div class="hero-body has-text-centered">
<div class="container">
<div class="box">
<h1>404</h1>
<h2>An error occured</h2>
</div>
</div>
</div>
</section>
</body>
</html>

73
views/index.eta Normal file
View File

@ -0,0 +1,73 @@
<%~ include("partials/base_head.eta", {"title": "Dashboard"}) %>
<%~ include("partials/nav.eta") %>
<section class="hero is-primary">
<div class="hero-body">
<p class="title" data-tK="start-hero-header-welcome">Willkommen</p>
<p class="subtitle" data-tK="start-hero-header-subtitle-default">Alles gut!</p>
</div>
</section>
<section class="section">
<h1 class="title" data-tK="start-sysinfo-header">Systeminformationen</h1>
<nav class="level">
<div class="level-item has-text-centered">
<div>
<p class="heading">Aktionspläne</p>
<p class="title">π</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Alarmkonakte</p>
<p class="title"><span data-dataSource="AlertContacts" data-dataAction="COUNT" class="is-skeleton">Load.</span></p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Alarme in den letzten 24h</p>
<p class="title">Keine</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Metrik 4</p>
<p class="title">Dreieck</p>
</div>
</div>
</nav>
</section>
<section class="section">
<h1 class="title" data-tK="start-recent-header">Letze Alarme</h1>
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th><abbr title="Position">Pos</abbr></th>
<th>Alarmierungszeit</th>
<th><abbr title="Quitierungszeit">Quit.zeit</abbr></th>
<th>Quelle</th>
<th><abbr title="Niveau">Niv.</abbr></th>
</tr>
</thead>
<tbody>
<tr class="is-danger">
<th>1</th>
<td>1.1.2025 12:00</td>
<td>-</td>
<td>BMA</td>
<td>Brandmeldung</td>
</tr>
<tr class="is-warning">
<th>1</th>
<td>1.1.2025 10:00</td>
<td>-</td>
<td>EMA</td>
<td>Störung</td>
</tr>
</tbody>
</table>
</section>
<%~ include("partials/footer.eta") %>
<%~ include("partials/base_foot.eta") %>

18
views/lockscreen.eta Normal file
View File

@ -0,0 +1,18 @@
<%~ include("partials/base_head.eta", {"title": "Dashboard"}) %>
<%~ include("partials/nav.eta") %>
<link rel="stylesheet" href="/static/css/lockscreen.css">
<div id="credits">
</div>
<div id="clock">
<div id="time"></div>
<div id="date"></div>
</div>
<script src="/static/js/lockscreenBgHandler.js"></script>
<%~ include("partials/footer.eta") %>
<%~ include("partials/base_foot.eta") %>

View File

@ -0,0 +1,4 @@
<script src="/static/apiWrapper.js"></script>
<script src="/static/pageDriver.js"></script>
</body>
</html>

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HydrationHUB - <%= it.title %></title>
<meta name="author" content="[Project-Name-Here]"/>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<script src="/libs/jquery/jquery.min.js"></script>
<link rel="stylesheet" href="/libs/bulma/bulma.min.css">
<link rel="stylesheet" href="/static/main.css">
<link rel="stylesheet" href="/libs/bootstrap-icons/font/bootstrap-icons.min.css">
</head>
<body>
<!-- The body and html tag need to be left open! -->

View File

@ -0,0 +1,9 @@
<footer class="footer">
<div class="content has-text-centered">
<p>
<i class="bi bi-universal-access"></i>
<strong>HydrationHUB</strong> by <a href="https://pnh.fyi">[Project-name-here]</a>.<br>
Running Version <span data-dataSource="version" data-dataAction="SPECIAL" class="is-skeleton">Load.</span>
</p>
</div>
</footer>

47
views/partials/nav.eta Normal file
View File

@ -0,0 +1,47 @@
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item primary" href="/">
<svg id="logo" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="498.424" height="148.888" viewBox="0 0 131.875 39.393">
<path d="M118.368 51.177c-3.682 0-6.537.32-8.566.958-1.99.6-3.419 1.635-4.283 3.099-.827 1.466-1.239 3.533-1.239 6.2 0 2.03.356 3.7 1.07 5.016.714 1.315 1.916 2.46 3.607 3.438 1.728.939 4.17 1.878 7.326 2.817 2.517.752 4.452 1.466 5.805 2.142 1.39.64 2.386 1.352 2.987 2.142.601.79.9 1.747.9 2.874 0 1.24-.224 2.198-.675 2.874-.451.64-1.202 1.09-2.254 1.353-1.052.263-2.536.375-4.452.338-1.916-.038-3.4-.226-4.453-.564-1.051-.376-1.822-.977-2.31-1.804-.451-.826-.733-2.01-.845-3.55h-7.045c-.113 3.157.263 5.598 1.127 7.327.864 1.728 2.348 2.95 4.452 3.663 2.142.714 5.166 1.07 9.074 1.07 3.795 0 6.706-.318 8.735-.958 2.066-.638 3.532-1.728 4.396-3.268.864-1.54 1.296-3.72 1.296-6.538 0-2.254-.357-4.095-1.07-5.522-.715-1.466-1.917-2.706-3.608-3.72-1.653-1.015-4.02-2.01-7.1-2.987-2.518-.79-4.49-1.485-5.918-2.085-1.39-.6-2.404-1.202-3.043-1.804-.639-.6-.959-1.277-.959-2.028 0-1.165.207-2.049.62-2.649.414-.601 1.09-1.033 2.03-1.296.976-.263 2.366-.395 4.17-.395 1.728 0 3.061.15 4.001.45.977.264 1.672.734 2.085 1.41.451.638.733 1.578.846 2.818h7.157c.038-2.856-.376-5.054-1.24-6.594-.863-1.54-2.292-2.63-4.283-3.27-1.954-.637-4.734-.957-8.34-.957zm-67.058.12a24.388 24.388 0 0 0-11.954 3.114c-3.67 2.051-8.73 6.137-6.085 10.87.37.66.9 1.222 1.47 1.714 1.53 1.322 2.98.222 4.554-.458.975-.42 1.95-.842 2.922-1.268.433-.19 1.01-.331 1.328-.7.858-.99.494-2.994.05-4.094a27.22 27.22 0 0 1 3.651-1.24v30.828h7.214V58.968c1.05.182 2.439.43 3.266 1.04.387.285.113.91.075 1.298-.08.827-.027 1.816.345 2.58.307.632 1.16.785 1.765 1.009l4.564 1.703c.628.233 1.33.644 1.979.298 2.822-1.508 3.574-5.39 1.842-8.023-1.164-1.771-3.254-3.13-5.034-4.216-3.69-2.254-7.822-3.347-11.952-3.36zm-39.287.443L1.146 90.063h7.045l2.423-8.453h12.962l2.48 8.453h7.101L22.055 51.74H12.023zm67.628.001L68.773 90.063h7.045l2.423-8.453h12.964l2.48 8.453h7.1L89.683 51.74H79.65zm-62.668 6.537h.056l4.903 17.076h-9.637l4.678-17.076zm67.628 0h.056l4.903 17.076h-9.637l4.678-17.076z" style="display:inline;fill:current;fill-opacity:1;stroke:none;stroke-width:.408654;stroke-opacity:1" transform="translate(-1.146 -51.177)"/>
</svg>
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="/">Home</a>
<a class="navbar-item" href="/dbTest">API Integration <span class="tag is-info">Dev</span></a>
<a class="navbar-item" href="/contact">Kontakte <span class="tag is-primary">Neu!</span></a>
<!--<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">More</a>
<div class="navbar-dropdown">
<a class="navbar-item">About</a>
<a class="navbar-item is-selected">Jobs</a>
<a class="navbar-item">Contact</a>
<hr class="navbar-divider">
<a class="navbar-item">Report an issue</a>
</div>
</div>-->
</div>
<div class="navbar-end">
<div class="navbar-item">
<div class="buttons">
<a class="button is-primary">
<strong>Sign up</strong>
</a>
<a class="button is-light">Log in</a>
</div>
</div>
</div>
</div>
</nav>

155
views/test.eta Normal file
View File

@ -0,0 +1,155 @@
<%~ include("partials/base_head.eta", {"title": "API Test"}) %>
<%~ include("partials/nav.eta") %>
<section class="hero is-primary" id="heroStatus">
<div class="hero-body">
<p class="title" data-tK="start-hero-header-welcome">Test Page</p>
<p class="subtitle" data-tK="start-hero-header-subtitle-default" id="heroExplainer">API Integration test page</p>
</div>
</section>
<section class="section">
<h1 class="title" data-tK="start-sysinfo-header">Singelton Query</h1>
<nav class="level">
<div class="level-item has-text-centered">
<div>
<p class="heading">Kontakte</p>
<p class="title"><span data-dataSource="AlertContacts" data-dataAction="COUNT" class="is-skeleton">Load.</span></p>
</div>
</div>
<!--<div class="level-item has-text-centered">
<div>
<p class="heading">Alarme</p>
<p class="title"><span data-dataSource="Alerts" data-dataCol="id" data-dataAction="COUNT" class="is-skeleton">Load.</span></p>
</div>
</div>-->
<div class="level-item has-text-centered">
<div>
<p class="heading">Version</p>
<p class="title"><span data-dataSource="version" data-dataAction="SPECIAL" class="is-skeleton">Load.</span></p>
</div>
</div>
</nav>
</section>
<section class="section">
<h1 class="title" data-tK="start-sysinfo-header">File test</h1>
<form method="put" action="/api/upload" enctype="multipart/form-data" id="uploadForm">
<div class="field">
<label class="label">File</label>
<div class="control">
<input class="input" type="file" name="file" id="file">
</div>
</div>
<!-- URL field -->
<div class="field">
<label class="label">URL</label>
<div class="control">
<input class="input" type="text" name="url" id="url">
</div>
<div class="field is-grouped">
<div class="control">
<input type="submit" class="button is-link" value="Upload" data-actionBtn="upload">
</div>
</div>
<script>
document.getElementById("url").addEventListener("input", function() {
document.getElementById("uploadForm").action = document.getElementById("url").value;
});
</script>
</form>
</section>
<!-- TODO: Mark required fields as required; add handling for validation -->
<div id="modal-js-example" class="modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box entryPhase is-hidden">
<h2 class="title">New Contact</h1>
<i class="bi bi-arrow-clockwise title"></i>
</div>
<div class="box entryPhase">
<form data-targetTable="AlertContacts">
<h2 class="title">New Contact</h1>
<div class="field">
<label class="label">Name</label>
<div class="control has-icons-left">
<input class="input" type="text" placeholder="John Doe" value="" name="name">
<span class="icon is-small is-left">
<i class="bi bi-file-earmark-person-fill"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">Telephone</label>
<div class="control has-icons-left">
<input class="input" type="text" placeholder="+49 1234 5678912" value="" name="phone">
<span class="icon is-small is-left">
<i class="bi bi-telephone-fill"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">Comment</label>
<div class="control has-icons-left">
<input class="input" type="text" placeholder="" value="" name="comment">
<span class="icon is-small is-left">
<i class="bi bi-chat-fill"></i>
</span>
</div>
</div>
<br>
<div class="field is-grouped">
<div class="control">
<input type="submit" class="button is-link" value="Save" data-actionBtn="save">
</div>
<!--<div class="control">
<button type="button" class="button is-link is-light" data-actionBtn="cancel">Cancel</button>
</div>-->
</div>
</form>
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</div>
<button class="js-modal-trigger button" data-target="modal-js-example">
Create new Contact
</button>
<section class="section">
<h1 class="title" data-tK="start-recent-header">Alarm Kontakte</h1>
<input class="input" type="text" data-searchTargetId="contactTable" placeholder="Search..." />
<table class="table is-striped is-fullwidth is-hoverable" data-dataSource="AlertContacts" id="contactTable" data-pageSize="5">
<thead>
<tr>
<th data-dataCol = "id"><abbr title="Position">Pos</abbr></th>
<th data-dataCol = "name">Name</th>
<th data-dataCol = "phone">Telefon Nummer</th>
<th data-dataCol = "comment">Kommentar</th>
<th data-fnc="actions" data-actions="edit,delete">Aktionen</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<nav class="pagination is-hidden" role="navigation" aria-label="pagination" data-targetTable="contactTable">
<ul class="pagination-list">
</ul>
</nav>
</section>
<%~ include("partials/footer.eta") %>
<%~ include("partials/base_foot.eta") %>