Compare commits

..

18 Commits

Author SHA1 Message Date
41615303db Frontend> Add testupload to testpage 2025-02-09 19:32:56 +01:00
06767268de Frontend> Add contact page 2025-02-09 19:32:07 +01:00
3c03cc133c use prisma schema for api validation (sort field) 2025-02-09 19:30:50 +01:00
8bbadf36a8 add s3 baseline / Prepare content schema for s3 2025-02-09 19:28:30 +01:00
634f05bc27 prisma schema -> Generate alerthook / content name+filename unique 2025-02-09 16:08:41 +01:00
64d317115c Add some default (security) headers / remove unmaintained deps 2025-02-09 16:07:29 +01:00
5a583a94ff some frontend change 2025-02-03 22:23:53 +01:00
8383080395 schema updates 2025-02-03 22:23:37 +01:00
2e8ee7ca5c table interaction 2025-02-03 22:22:27 +01:00
a0ebf89ef8 Remove todo.. 2025-02-02 20:24:52 +01:00
d565497de1 Fix crash when using FullTextSearch with more then two asterisks in sequence. min(3) validation des the rest 2025-02-02 20:04:25 +01:00
fa26595797 Implement prisma error handling 2025-02-02 18:13:02 +01:00
ee6dd16be2 Support for tables in frontend 2025-01-31 23:49:41 +01:00
e3fba930d2 Remove/Deprecate parseIntOrUndefined 2025-01-31 23:49:16 +01:00
e377af7501 Implemented skip+take in alertContact api and some other optimizations 2025-01-31 23:09:53 +01:00
fb23f73963 Remove whitespace in prisma schema 2025-01-30 01:37:49 +01:00
7715672802 Implemented POST / PATCH / DELETE for alertcontact api route 2025-01-30 01:37:32 +01:00
94034fa29f Add alertContact POST validation 2025-01-30 01:00:24 +01:00
24 changed files with 1351 additions and 32962 deletions

View File

@ -16,3 +16,34 @@ Funktionen:
- Erklärung MP3
- Quittierung MP3
- Verabschiedung MP3
## API Endpoint planning
alertContacts (CRUD Fully implemented)
alerts -> Only get
actionPlan (CRUD)
- select all prios
priorities (CRUD)
- select actionPlan
- Only allow changes to priority
content (CRUD)
- Howto handle upload?
POST /alert/[:alert_hook]
-> Check actionplan if hook exists and select current prios -> Write call request to XYXYX
1. create one or more alertContacts
2. create 4x content (all phases)
3. create actionplan with contents
4. create one or more priorities

File diff suppressed because one or more lines are too long

View File

@ -1,98 +0,0 @@
//// ------------------------------------------------------
//// THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
//// ------------------------------------------------------
Project "AssetFlow" {
database_type: ''
Note: ''
}
Table alerts {
id Int [pk, increment]
type alertType [not null]
message String
actionplan actionPlan
actionplanId Int
date DateTime [not null]
state alertState [not null]
acknowledged_by alertContacts [not null]
acknowledged_at DateTime
}
Table alertContacts {
id Int [pk, increment]
name String [not null]
phone String [unique, not null]
comment String
prios priorities [not null]
alerts alerts [not null]
}
Table actionPlan {
id Int [pk, increment]
name String [not null]
comment String
alert_hook String [unique, not null]
prio priorities [not null]
content content [not null]
alerts alerts [not null]
}
Table priorities {
id Int [pk, increment]
Contact alertContacts [not null]
contactId Int [not null]
priority Int [not null]
actionplan actionPlan [not null]
actionplanId Int [not null]
indexes {
(priority, actionplanId) [unique]
}
}
Table content {
id Int [pk, increment]
type contentType [not null]
name String [not null]
filename String [not null]
actionplan actionPlan [not null]
}
Table alertContactsToalerts {
acknowledged_byId Int [ref: > alertContacts.id]
alertsId Int [ref: > alerts.id]
}
Table actionPlanTocontent {
contentId Int [ref: > content.id]
actionplanId Int [ref: > actionPlan.id]
}
Enum contentType {
voice_alarm
voice_explainer
voice_acknowledgement
voice_ending
}
Enum alertType {
generic
fire
fault
intrusion
clear
}
Enum alertState {
incomming
running
failed
acknowledged
}
Ref: alerts.actionplanId > actionPlan.id
Ref: priorities.contactId > alertContacts.id
Ref: priorities.actionplanId > actionPlan.id

File diff suppressed because one or more lines are too long

3708
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -33,8 +33,6 @@
"eslint": "^9.18.0",
"eslint-config-prettier": "^9.1.0",
"prisma": "^6.2.1",
"prisma-dbml-generator": "^0.12.0",
"prisma-docs-generator": "^0.8.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
},
@ -46,9 +44,11 @@
"express": "^4.21.2",
"express-fileupload": "^1.5.1",
"express-session": "^1.18.1",
"helmet": "^8.0.0",
"joi": "^17.13.3",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"minio": "^8.0.4",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"signale": "^1.4.0",

View File

@ -13,23 +13,6 @@ datasource db {
url = env("DATABASE_URL")
}
// https://github.com/pantharshit00/prisma-docs-generator
generator docs {
provider = "node node_modules/prisma-docs-generator"
output = "../docs"
}
// https://github.com/notiz-dev/prisma-dbml-generator
// Viewer: https://dbdiagram.io/d
generator dbml {
provider = "prisma-dbml-generator"
output = "../docs"
outputName = "schema.dbml"
projectName = "AssetFlow"
}
enum contentType {
voice_alarm
voice_explainer
@ -46,71 +29,74 @@ enum alertType {
}
enum alertState {
incomming // Incomming alerts
running // Started calling
failed // Failed to get acknowledgement of any alertContacts
acknowledged // Some user acknowledged alert
incoming // Incoming alerts
running // Started calling
failed // Failed to get acknowledgement of any alertContacts
acknowledged // Some user acknowledged alert
}
model alerts {
id Int @id @unique @default(autoincrement())
type alertType
message String?
actionplan actionPlan? @relation(fields: [actionplanId], references: [id])
actionplanId Int?
date DateTime
id Int @id @unique @default(autoincrement())
type alertType
state alertState
description String?
date DateTime
actionplan actionPlan? @relation(fields: [actionplanId], references: [id])
actionplanId Int?
acknowledged_by alertContacts[]
acknowledged_at DateTime?
@@fulltext([message])
@@fulltext([description])
}
model alertContacts {
id Int @id @unique @default(autoincrement())
id Int @id @unique @default(autoincrement())
name String
phone String @unique
phone String @unique
comment String?
prios priorities[]
alerts alerts[]
alerts alerts[]
@@fulltext([name, phone, comment])
}
model actionPlan {
id Int @id @unique @default(autoincrement())
name String
comment String?
alert_hook String @unique
prio priorities[]
content content[] // aka. all voice files
id Int @id @unique @default(autoincrement())
name String @unique
comment String?
alerthook String @unique @default(ulid())
prio priorities[]
content content[] // aka. all voice files
alerts alerts[]
@@fulltext([name, comment])
}
model priorities {
id Int @id @unique @default(autoincrement())
Contact alertContacts @relation(fields: [contactId], references: [id])
contactId Int
priority Int
actionplan actionPlan @relation(fields: [actionplanId], references: [id])
actionplanId Int
id Int @id @unique @default(autoincrement())
Contact alertContacts @relation(fields: [contactId], references: [id])
contactId Int
priority Int
actionplan actionPlan @relation(fields: [actionplanId], references: [id])
actionplanId Int
@@unique([priority, actionplanId])
}
model content {
id Int @id @unique @default(autoincrement())
type contentType
name String
filename String
actionplan actionPlan[]
@@fulltext([name, filename])
//id Int @id @unique @default(autoincrement())
s3_key String @id @unique
name String @unique
type contentType
actionplan actionPlan[]
@@fulltext([name])
}
// https://spacecdn.de/file/bma_stoe_v1.mp3
// https://spacecdn.de/file/quittiert_v1.mp3
// https://spacecdn.de/file/angenehmen_tag_v1.mp3

View File

@ -1,14 +1,23 @@
import ConfigManager from '../libs/configManager.js';
import __path from "./path.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,
debug: true,
http_domain: 'example.org',
http_enable_hsts: false,
devmode: true,
s3: {
endpoint: 'minio.example.org',
port: 443,
use_ssl: true,
access_key: '',
secret_key: ''
},
auth: {
cookie_secret: 'gen',
cookie_secure: true,
@ -25,9 +34,11 @@ const config = new ConfigManager(__path + '/config.json', true, {
// If no local User exists, create the default with a generated password
if (_.isEqual(config.global.auth.local.users, {})) {
config.global.auth.local.users = {
'administrator': 'gen',
administrator: 'gen'
};
config.save_config();
}
!config.global.devmode && log.core.error('devmode active! Do NOT use this in prod!');
export default config;

View File

@ -12,75 +12,29 @@ const prisma = new PrismaClient({
}
});
// FIXME: any
export function handlePrismaError(errorObj: any, res: Response) {
export function handlePrismaError(errorObj: any, res: Response, source: string) {
log.db.error(source, errorObj);
if (errorObj instanceof Prisma.PrismaClientKnownRequestError) {
switch (errorObj.code) {
log.core.debug(errorObj);
res.status(500).json({ status: 'ERROR', meta: errorObj.meta, errorcode: errorObj.code, message: errorObj.message });
// 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;
// if(errorObj instanceof Prisma.PrismaClientKnownRequestError)
// switch (errorObj.code) {
// // P2002 -> "Unique constraint failed on the {constraint}"
// // https://www.prisma.io/docs/reference/api-reference/error-reference
// case 'P2002': //
// log.db.error('');
// break;
// // P2003 -> "Foreign key constraint failed on the field: {field_name}"
// // https://www.prisma.io/docs/reference/api-reference/error-reference
// // FIXME: Is this errormessage right?
// case 'P2003': //
// log.db.error('');
// break;
// case 'xxx': //
// log.db.error('');
// break;
// case 'xxx':
// log.db.error('');
// break;
// case 'xxx':
// log.db.error('');
// break;
// case 'xxx':
// log.db.error('');
// break;
// case 'xxx':
// log.db.error('');
// break;
// case 'xxx':
// log.db.error('');
// break;
// case 'xxx':
// log.db.error('');
// break;
// default:
// break;
// }
// // Check if an entry already exists.
// if (errorcode === 'P2002') {
// // P2002 -> "Unique constraint failed on the {constraint}"
// // https://www.prisma.io/docs/reference/api-reference/error-reference
// res.status(409).json({ status: 'ERROR', errorcode: 'EXISTING', message: 'Item already exists' });
// } else if (errorcode == 'P2003') {
// // P2003 -> "Foreign key constraint failed on the field: {field_name}"
// // https://www.prisma.io/docs/reference/api-reference/error-reference
// // FIXME: Is this errormessage right?
// res.status(404).json({ status: 'ERROR', errorcode: 'NOT_EXISTING', message: 'Item does not exist' });
// } else if (errorcode == 'P2000') {
// // P2000 -> "The provided value for the column is too long for the column's type. Column: {column_name}"
// // https://www.prisma.io/docs/reference/api-reference/error-reference
// res.status(404).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: 'One or more fields exceed the maximum length restriction' });
// } else {
// log.db.error(err);
// res.status(500).json({ status: 'ERROR', errorcode: 'DB_ERROR', error: err, message: 'An error occurred during the database operation' });
// }
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;

View File

@ -14,6 +14,7 @@ type log = {
core: Logger<unknown>
db: Logger<unknown>
web: Logger<unknown>
S3: Logger<unknown>
auth: Logger<unknown>
api?: Logger<unknown>
frontend?: Logger<unknown>
@ -24,6 +25,7 @@ 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")),
};

19
src/handlers/s3.ts Normal file
View File

@ -0,0 +1,19 @@
import * as Minio from 'minio';
import log from './log.js';
import config from './config.js';
const minioClient = new Minio.Client({
endPoint: config.global.s3.endpoint,
port: config.global.s3.port,
useSSL: config.global.s3.use_ssl,
accessKey: config.global.s3.access_key,
secretKey: config.global.s3.secret_key
});
export async function test() {
log.S3.debug('GET', await minioClient.presignedGetObject('atas-dev', 'test', 500));
log.S3.debug('PUT', await minioClient.presignedPutObject('atas-dev', 'test', 500));
}
export default minioClient;

View File

@ -12,11 +12,11 @@ export function parseDynamicSortBy(SortField: string, Order: string) {
/**
* 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);
}
}

View File

@ -1,17 +1,18 @@
// 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 __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 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 { Eta } from 'eta';
import passport from 'passport';
import ChildProcess from 'child_process';
@ -20,29 +21,26 @@ import routes from './routes/index.js';
import fs from 'node:fs';
log.core.trace("Running from path: " + __path);
db.$disconnect();
log.core.trace('Running from path: ' + __path);
// MARK: Express
const app = express();
// Versioning
try {
const rawPkg = fs.readFileSync("package.json", 'utf8');
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";
log.core.error('Failed to get version from package.json.');
app.locals.version = '0.0.0';
}
try {
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.");
log.core.error('Failed to get git revision hash.');
app.locals.versionRev = '0';
app.locals.versionRevLong = '0';
}
@ -50,7 +48,7 @@ try {
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.");
log.core.error('Failed to get latest git revision hash.');
app.locals.versionRevLatest = '0';
}
@ -62,19 +60,31 @@ if (app.locals.versionRevLong === 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")
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);
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(cors());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
@ -91,16 +101,13 @@ app.use(
app.use(passport.authenticate('session'));
app.use(routes);
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) => {
return (path: string, opts: Options, callback: CallableFunction) => {
try {
const fileContent = eta.readFile(path);
const renderedTemplate = eta.renderString(fileContent, opts);
@ -110,4 +117,3 @@ function buildEtaEngine() {
}
};
}

View File

@ -1,70 +1,101 @@
import { Request, Response } from 'express';
import db, { handlePrismaError } from '../../../handlers/db.js'; // Database
import log from '../../../handlers/log.js';
import { parseIntOrUndefined, parseDynamicSortBy } from '../../../helpers/prisma_helpers.js';
import { schema_get } from './alertContacts_schema.js';
import { parseDynamicSortBy } from '../../../helpers/prisma_helpers.js';
import { schema_get, schema_post, schema_patch, schema_del } from './alertContacts_schema.js';
// MARK: GET AlertContact
// MARK: GET alertContact
async function get(req: Request, res: Response) {
const { error, value } = schema_get.validate(req.query);
if (error) {
log.api?.debug('Error:', req.query, value);
res.status(400).json({ error: error.details[0].message });
log.api?.debug('alertContact GET 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('Success:', req.query, value);
log.api?.debug('alertContact GET Success:', req.query, value);
// Query with FullTextSearch
const query_fts = {
where: {
OR: [{ id: parseIntOrUndefined(value.id) }, { name: { search: value.search } }, { phone: { search: value.search } }, { comment: { search: value.search } }]
},
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString())
};
if (value.search !== undefined || value.id !== undefined) {
// with FullTextSearch
if (!value.count) {
// get all entrys
log.api?.trace('get all entrys - with FullTextSearch');
await db.alertContacts.findMany(query_fts).then((result) => {
res.status(200).json(result);
});
await db.alertContacts
.findMany({
where: {
OR: [{ id: value.id }, { name: { search: value.search } }, { phone: { search: value.search } }, { comment: { search: value.search } }]
},
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
})
.then((result) => {
if (result.length !== 0) {
res.status(200).json(result);
} else {
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified object' });
}
}).catch((err) => {
handlePrismaError(err, res, 'GET alertContact');
});
} else {
// count all entrys
log.api?.trace('count all entrys - with FullTextSearch');
await db.alertContacts.count(query_fts).then((result) => {
res.status(200).json(result);
});
await db.alertContacts
.count({
where: {
OR: [{ id: value.id }, { name: { search: value.search } }, { phone: { search: value.search } }, { comment: { search: value.search } }]
},
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString())
})
.then((result) => {
res.status(200).json(result);
}).catch((err) => {
handlePrismaError(err, res, 'GET alertContact');
});
}
} else {
// without FullTextSearch
if (!value.count) {
// get all entrys
log.api?.trace('get all entrys - without FullTextSearch');
await db.alertContacts.findMany({ orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()) }).then((result) => {
res.status(200).json(result);
});
await db.alertContacts
.findMany({ orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()), take: value.take, skip: value.skip })
.then((result) => {
if (result.length !== 0) {
res.status(200).json(result);
} else {
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified object' });
}
}).catch((err) => {
handlePrismaError(err, res, 'GET alertContact');
});
} else {
// count all entrys without FullTextSearch
log.api?.trace('count all entrys - without FullTextSearch');
await db.alertContacts.count().then((result) => {
res.status(200).json(result);
}).catch((err) => {
handlePrismaError(err, res, 'GET alertContact');
});
}
}
}
}
// MARK: CREATE AlertContact
// MARK: CREATE alertContact
async function post(req: Request, res: Response) {
// Check if undefined or null
if (req.body.name != null && req.body.phone != null) {
const { error, value } = schema_post.validate(req.body);
if (error) {
log.api?.debug('alertContact POST 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('alertContact POST Success:', req.body, value);
await db.alertContacts
.create({
data: {
name: req.body.name,
phone: req.body.phone,
comment: req.body.comment
name: value.name,
phone: value.phone,
comment: value.comment
},
select: {
id: true
@ -72,16 +103,62 @@ async function post(req: Request, res: Response) {
})
.then((result) => {
res.status(201).json({ status: 'CREATED', message: 'Successfully created alertContact', id: result.id });
}).catch((err) => {
handlePrismaError(err, res, 'POST alertContact');
});
} else {
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: 'One or more required fields are missing or invalid' });
}
}
// MARK: UPDATE AlertContact
async function patch(req: Request, res: Response) {}
// MARK: UPDATE alertContact
async function patch(req: Request, res: Response) {
const { error, value } = schema_patch.validate(req.body);
if (error) {
log.api?.debug('alertContact PATCH 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('alertContact PATCH Success:', req.body, value);
await db.alertContacts
.update({
where: {
id: value.id
},
data: {
name: value.name,
phone: value.phone,
comment: value.comment
},
select: {
id: true
}
})
.then((result) => {
res.status(200).json({ status: 'UPDATED', message: 'Successfully updated alertContact', id: result.id });
}).catch((err) => {
handlePrismaError(err, res, 'PATCH alertContact');
});
}
}
// MARK: DELETE AlertContact
async function del(req: Request, res: Response) {}
// MARK: DELETE alertContact
async function del(req: Request, res: Response) {
const { error, value } = schema_del.validate(req.body);
if (error) {
log.api?.debug('alertContact DELETE 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('alertContact DELETE Success:', req.body, value);
await db.alertContacts
.delete({
where: {
id: value.id
}
})
.then((result) => {
res.status(200).json({ status: 'DELETED', message: 'Successfully deleted alertContact', id: result.id });
}).catch((err) => {
handlePrismaError(err, res, 'DEL alertContact');
});
}
}
export default { get, post, patch, del };

View File

@ -1,52 +1,54 @@
import { Request, Response } from 'express';
import validator from 'joi'; // DOCS: https://joi.dev/api
import log from '../../../handlers/log.js';
import { Prisma } from '@prisma/client';
// MARK: GET alertContact
const schema_get = validator.object({
sort: validator.string().valid('id', 'name', 'phone', 'comment').default('id'),
//sort: validator.string().valid('id', 'name', 'phone', 'comment').default('id'),
sort: validator.string().valid(...Object.keys(Prisma.AlertContactsScalarFieldEnum)).default('id'),
order: validator.string().valid('asc', 'desc').default('asc'),
search: validator.string().min(3).max(20), // TODO: Check if * or ** or *** -> Due to crashes..
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),
count: validator.boolean()
}).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(),
phone: validator.string().pattern(new RegExp('^\\+(\\d{1,3})\\s*(?:\\(\\s*(\\d{2,5})\\s*\\)|\\s*(\\d{2,5})\\s*)\\s*(\\d{5,15})$')).required(),
comment: validator.string().max(64),
})
// MARK: UPDATE alertContact
const schema_patch = validator.object({
sort: validator.string().valid('id', 'name', 'phone', 'comment').default('id'),
order: validator.string().valid('asc', 'desc').default('asc'),
search: validator.string().min(3).max(20), // TODO: Check if * or ** or *** -> Due to crashes..
id: validator.number().positive().precision(0),
count: validator.boolean()
}).nand('id', 'search'); // Allow id or search. not both.
id: validator.number().positive().precision(0).required(),
name: validator.string().min(1).max(32),
phone: validator.string().pattern(new RegExp('^\\+(\\d{1,3})\\s*(?:\\(\\s*(\\d{2,5})\\s*\\)|\\s*(\\d{2,5})\\s*)\\s*(\\d{5,15})$')),
comment: validator.string().max(64),
}).or('name', 'phone', 'comment')
// MARK: DELETE alertContact
const schema_del = validator.object({
sort: validator.string().valid('id', 'name', 'phone', 'comment').default('id'),
order: validator.string().valid('asc', 'desc').default('asc'),
search: validator.string().min(3).max(20), // TODO: Check if * or ** or *** -> Due to crashes..
id: validator.number().positive().precision(0),
count: validator.boolean()
}).nand('id', 'search'); // Allow id or search. not both.
id: validator.number().positive().precision(0).required()
})
// Describe all schemas
const schema_get_describe = schema_get.describe();
const schema_post_describe = schema_post.describe();
const schema_patch_describe = schema_patch.describe();
const schema_del_describe = schema_del.describe();
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_describe,
POST: schema_post_describe,
PATCH: schema_patch_describe,
DELETE: schema_del_describe
GET: schema_get_desc,
POST: schema_post_desc,
PATCH: schema_patch_desc,
DELETE: schema_del_desc
});
}

View File

@ -12,16 +12,17 @@ import alertContactsRoute_schema from './alertContacts_schema.js';
// Router base is '/api/v1'
const Router = express.Router({ strict: false });
// All empty strings are null values.
// All empty strings are null values (body)
Router.use('*', function (req, res, next) {
for (let key in req.body) {
if (req.body[key] === '') {
req.body[key] = null;
req.body[key] = undefined;
}
}
next();
});
// All api routes lowercase! Yea I know but when strict: true it matters.
Router.route('/alertcontacts').get(alertContactsRoute.get).post(alertContactsRoute.post).patch(alertContactsRoute.patch).delete(alertContactsRoute.del);
Router.route('/alertcontacts/describe').get(alertContactsRoute_schema);

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

@ -1,11 +1,9 @@
import express from 'express';
// Route imports
// import skuRoute from './:id.js';
// import skuRouteDash from './itemInfo.js'
// import testRoute from './test.js';
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';
@ -22,5 +20,6 @@ const Router = express.Router({ strict: false });
Router.route('/').get(dashboardRoute.get);
Router.route('/dbTest').get(testRoute.get);
Router.route('/contact').get(contactRoute.get);
export default Router;

View File

@ -21,12 +21,18 @@ let _api = {
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;
}
@ -54,17 +60,61 @@ let _api = {
return result;
},
getAsync: function (path, callback) {
delete: async function (path, data) {
const options = {
headers: new Headers({ 'content-type': 'application/json' })
method: 'DELETE',
headers: new Headers({ 'content-type': 'application/json' }),
body: JSON.stringify(data)
};
fetch(_apiConfig.basePath + path, options)
.then((response) => response.json())
.then((data) => callback(data))
.catch((error) => _testPageFail(error));
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`;
@ -113,21 +163,37 @@ function getApiDescriptionByTable(tableName) {
}
}
function returnTableDataByTableName(tableName) {
return _api.get(tableName);
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);
}
}
function returnTableDataByTableNameWithSearch(tableName, search) {
return _api.get(tableName + '?search=' + search);
}
function returnTableDataByTableNameAsync(tableName, callback) {
_api.getAsync(tableName, callback);
}
async function getCountByTable(tableName) {
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(tableName + '?count=true');
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);
@ -136,10 +202,6 @@ async function getCountByTable(tableName) {
return result;
}
function getRowsByTableAndColumnList(tableName, columnList) {
//return _api.get(tableName + '/rows/' + columnList.join(','))
return undefined;
}
function _testPageFail(reason) {
document.getElementById('heroStatus').classList.remove('is-success');

View File

@ -26,45 +26,6 @@ var searchFields = document.querySelectorAll('input[data-searchTargetId]');
// Find all modalForms
var modalForms = document.querySelectorAll('form[data-targetTable]');
// Iterate over all tables
tables.forEach(async (table) => {
console.log('Table found: ', table);
// 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 = [];
columns.forEach((column) => {
requiredCols.push(column.getAttribute('data-dataCol'));
});
console.log('Required columns: ', requiredCols);
// Get data from API
//let result = getRowsByTableAndColumnList(table.getAttribute("data-dataSource"), requiredCols);
let result = await returnTableDataByTableName(table.getAttribute('data-dataSource'));
// for (resultIndex in result) {
// const row = result[resultIndex];
// const tr = document.createElement("tr");
// requiredCols.forEach(column => {
// const td = document.createElement("td");
// td.innerHTML = row[column];
// tr.appendChild(td);
// });
// tbody.appendChild(tr);
// }
writeDataToTable(table, result);
console.log('Column indices: ', columnIndices);
});
console.info('Processing single values');
console.info(singleValues);
@ -73,6 +34,45 @@ 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);
@ -131,9 +131,6 @@ searchFields.forEach((searchField) => {
const column = target.getAttribute('data-dataCol');
const value = searchField.value;
console.log('Searching for ', value, ' in ', table, ' column ', column);
//const result = await returnTableDataByTableNameWithSearch(table, value);
//clearTable(target);
//writeDataToTable(target, result);
refreshTableByName(table);
}
});
@ -152,6 +149,8 @@ modalForms.forEach((modalForm) => {
event.target.classList.remove('is-danger');
}
});
getApiDescriptionByTable(modalForm.getAttribute('data-targetTable')).then((desc) => {
console.log('Description: ', desc);
const keys = desc['POST']['keys'];
@ -220,9 +219,17 @@ modalForms.forEach((modalForm) => {
jsonData[key] = value;
});
console.log('JSON Data: ', jsonData);
let resp = await createEntry(table, 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') {
if (resp['status'] == 'CREATED' || resp['status'] == 'UPDATED') {
console.log('Entry created successfully');
modalForm.closest('.modal').classList.remove('is-active');
modalForm.reset();
@ -246,9 +253,6 @@ modalForms.forEach((modalForm) => {
// TODO: Show error message
}
// const target = document.getElementById(table);
// writeDataToTable(target, result);
// Find all tables with data-searchTargetId set to table
setTimeout(() => {
refreshTableByName(table);
@ -261,19 +265,73 @@ modalForms.forEach((modalForm) => {
async function refreshTable(table) {
// Refresh a table while keeping (optionally set) search value
const searchField = document.querySelector("input[data-searchTargetId='" + table.id + "']");
if (searchField && searchField.value != '') {
// 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 returnTableDataByTableNameWithSearch(dbTable, value);
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, result);
writeDataToTable(table, data, paginationPassOn);
} else {
const result = await returnTableDataByTableName(table.getAttribute('data-dataSource'));
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, result);
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) {
@ -293,7 +351,10 @@ function clearTable(table) {
tbody.innerHTML = '';
}
function writeDataToTable(table, data) {
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');
@ -308,22 +369,212 @@ function writeDataToTable(table, data) {
// 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.innerHTML = row[column];
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

View File

@ -96,7 +96,7 @@
// "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. */
"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'. */

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") %>

View File

@ -20,6 +20,7 @@
<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">
@ -32,7 +33,7 @@
</div>-->
</div>
<!--<div class="navbar-end">
<div class="navbar-end">
<div class="navbar-item">
<div class="buttons">
<a class="button is-primary">
@ -41,6 +42,6 @@
<a class="button is-light">Log in</a>
</div>
</div>
</div>-->
</div>
</div>
</nav>

View File

@ -32,6 +32,34 @@
</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>
@ -43,8 +71,9 @@
<i class="bi bi-arrow-clockwise title"></i>
</div>
<div class="box entryPhase">
<h2 class="title">New Contact</h1>
<form data-targetTable="AlertContacts">
<h2 class="title">New Contact</h1>
<div class="field">
<label class="label">Name</label>
<div class="control has-icons-left">
@ -101,18 +130,25 @@
<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" data-dataSource="AlertContacts" id="contactTable">
<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") %>