Додані повідомлення та перепрацьована структура застосунку та api
This commit is contained in:
33
README.md
33
README.md
@@ -238,6 +238,39 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
### Приклад конфігурації для балонсування серверів
|
||||
|
||||
```nginx
|
||||
upstream backend {
|
||||
server 5.58.145.96 max_fails=3 fail_timeout=10s;
|
||||
server 95.47.167.120 max_fails=3 fail_timeout=10s;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name sheep-service.com www.sheep-service.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://backend;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Активація сайту
|
||||
|
||||
```bash
|
||||
|
||||
91
api/controllers/homestead.joint.controller.js
Normal file
91
api/controllers/homestead.joint.controller.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const HomesteadJointService = require('../services/homestead.joint.service');
|
||||
|
||||
class HomesteadJointController {
|
||||
async getList(req, res) {
|
||||
const { homestead_id } = req.params;
|
||||
|
||||
if (homestead_id) {
|
||||
if (req.possibilities.can_joint_territory) {
|
||||
let result = await HomesteadJointService.getList(homestead_id);
|
||||
if (result) {
|
||||
return res
|
||||
.status(200)
|
||||
.send(result);
|
||||
} else {
|
||||
return res
|
||||
.status(500)
|
||||
.send({ message: 'Internal server error.' });
|
||||
}
|
||||
} else {
|
||||
return res
|
||||
.status(403)
|
||||
.send({ message: 'The user does not have enough rights.' });
|
||||
}
|
||||
} else {
|
||||
return res
|
||||
.status(404)
|
||||
.send({ message: 'Users not found.' });
|
||||
}
|
||||
}
|
||||
|
||||
async createJoint(req, res) {
|
||||
const { homestead_id } = req.params;
|
||||
const data = req.body;
|
||||
|
||||
if (homestead_id) {
|
||||
if (req.possibilities.can_joint_territory) {
|
||||
let result = await HomesteadJointService.createJoint(
|
||||
homestead_id,
|
||||
data
|
||||
);
|
||||
|
||||
if (result) {
|
||||
return res.status(200).send(result);
|
||||
} else {
|
||||
return res.status(500).send({
|
||||
message: 'Unable create joint homestead.',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return res
|
||||
.status(404)
|
||||
.send({ message: 'User not found.' });
|
||||
}
|
||||
} else {
|
||||
return res
|
||||
.status(404)
|
||||
.send({ message: 'Users not found.' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteJoint(req, res) {
|
||||
const { homestead_id } = req.params;
|
||||
const data = req.body;
|
||||
|
||||
if (homestead_id) {
|
||||
if (req.possibilities.can_joint_territory) {
|
||||
let result = await HomesteadJointService.deleteJoint(
|
||||
homestead_id,
|
||||
data
|
||||
);
|
||||
if (result) {
|
||||
return res.status(200).send(result);
|
||||
} else {
|
||||
return res.status(500).send({
|
||||
message: 'Unable delete joint homestead.',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return res
|
||||
.status(404)
|
||||
.send({ message: 'User not found.' });
|
||||
}
|
||||
} else {
|
||||
return res
|
||||
.status(404)
|
||||
.send({ message: 'Users not found.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new HomesteadJointController();
|
||||
@@ -13,6 +13,8 @@ class HomesteadsController {
|
||||
id = req.sheepId;
|
||||
} else if (mode == "group") {
|
||||
id = req.group_id;
|
||||
} else if (mode == "joint") {
|
||||
id = req.sheepId;
|
||||
}
|
||||
|
||||
let result = await HomesteadsService.getList(mode, id);
|
||||
|
||||
@@ -45,7 +45,7 @@ class SheepsController {
|
||||
}
|
||||
|
||||
async getListStand(req, res) {
|
||||
if (req.possibilities.can_view_stand) {
|
||||
if (req.possibilities.can_view_stand) {
|
||||
const result = await SheepsService.getListStand(req.mode);
|
||||
|
||||
if (result) {
|
||||
@@ -86,7 +86,7 @@ class SheepsController {
|
||||
const data = req.body;
|
||||
|
||||
if (req.mode == 2) {
|
||||
let result = await SheepsService.updateSheep(data);
|
||||
let result = await SheepsService.updateSheep(data, 2);
|
||||
|
||||
if (result) {
|
||||
return res.status(200).send(result);
|
||||
@@ -95,10 +95,24 @@ class SheepsController {
|
||||
message: 'Unable update sheep.',
|
||||
});
|
||||
}
|
||||
} if (req.possibilities.can_manager_sheeps) {
|
||||
if (Number(data.mode) == 2) {
|
||||
return res.status(403).send({ message: 'The sheep does not have enough rights.' });
|
||||
} else {
|
||||
let result = await SheepsService.updateSheep(data, 1);
|
||||
|
||||
if (result) {
|
||||
return res.status(200).send(result);
|
||||
} else {
|
||||
return res.status(500).send({
|
||||
message: 'Unable update sheep.',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return res
|
||||
.status(403)
|
||||
.send({ message: 'Sheep not foundThe sheep does not have enough rights.' });
|
||||
.send({ message: 'The sheep does not have enough rights.' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,11 @@ const authenticate = (req, res, next) => {
|
||||
sheeps.*,
|
||||
possibilities.can_add_sheeps AS can_add_sheeps,
|
||||
possibilities.can_view_sheeps AS can_view_sheeps,
|
||||
possibilities.can_manager_sheeps AS can_manager_sheeps,
|
||||
possibilities.can_add_territory AS can_add_territory,
|
||||
possibilities.can_view_territory AS can_view_territory,
|
||||
possibilities.can_manager_territory AS can_manager_territory,
|
||||
possibilities.can_joint_territory AS can_joint_territory,
|
||||
possibilities.can_add_stand AS can_add_stand,
|
||||
possibilities.can_view_stand AS can_view_stand,
|
||||
possibilities.can_manager_stand AS can_manager_stand,
|
||||
@@ -33,9 +35,11 @@ const authenticate = (req, res, next) => {
|
||||
req.possibilities = {
|
||||
can_add_sheeps: moderator.can_add_sheeps == 1 ? true : false,
|
||||
can_view_sheeps: moderator.can_view_sheeps == 1 ? true : false,
|
||||
can_manager_sheeps: moderator.can_manager_sheeps == 1 ? true : false,
|
||||
can_add_territory: moderator.can_add_territory == 1 ? true : false,
|
||||
can_view_territory: moderator.can_view_territory == 1 ? true : false,
|
||||
can_manager_territory: moderator.can_manager_territory == 1 ? true : false,
|
||||
can_joint_territory: moderator.can_joint_territory == 1 ? true : false,
|
||||
can_add_stand: moderator.can_add_stand == 1 ? true : false,
|
||||
can_view_stand: moderator.can_view_stand == 1 ? true : false,
|
||||
can_manager_stand: moderator.can_manager_stand == 1 ? true : false,
|
||||
@@ -51,6 +55,7 @@ const authenticate = (req, res, next) => {
|
||||
sheeps.*,
|
||||
possibilities.can_add_sheeps AS can_add_sheeps,
|
||||
possibilities.can_view_sheeps AS can_view_sheeps,
|
||||
possibilities.can_manager_sheeps AS can_manager_sheeps,
|
||||
possibilities.can_add_territory AS can_add_territory,
|
||||
possibilities.can_view_territory AS can_view_territory,
|
||||
possibilities.can_manager_territory AS can_manager_territory,
|
||||
@@ -76,8 +81,10 @@ const authenticate = (req, res, next) => {
|
||||
req.possibilities = {
|
||||
can_add_sheeps: false,
|
||||
can_view_sheeps: false,
|
||||
can_manager_sheeps: false,
|
||||
can_add_territory: false,
|
||||
can_manager_territory: false,
|
||||
can_joint_territory: false,
|
||||
can_add_stand: false,
|
||||
can_manager_stand: false,
|
||||
can_add_schedule: false,
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
module.exports = (req, res, next) => {
|
||||
const start = performance.now();
|
||||
|
||||
res.on("finish", () => {
|
||||
const duration = performance.now() - start;
|
||||
|
||||
fetch("http://metrics:4005/push", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "rest",
|
||||
path: req.originalUrl,
|
||||
method: req.method,
|
||||
status: res.statusCode,
|
||||
time: duration,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}).catch(err => console.error(err));
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
12
api/routes/homestead.joint.routes.js
Normal file
12
api/routes/homestead.joint.routes.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router({ mergeParams: true });
|
||||
const HomesteadJointController = require('../controllers/homestead.joint.controller');
|
||||
const authenticate = require("../middleware/auth");
|
||||
|
||||
router
|
||||
.route('/')
|
||||
.get(authenticate, HomesteadJointController.getList)
|
||||
.post(authenticate, HomesteadJointController.createJoint)
|
||||
.delete(authenticate, HomesteadJointController.deleteJoint);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,13 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const metrics = require('../middleware/metrics');
|
||||
|
||||
const authRoutes = require('./auth.routes');
|
||||
const sheepsRoutes = require('./sheeps.routes');
|
||||
const constructorRoutes = require('./constructor.routes');
|
||||
const housesRoutes = require('./houses.routes');
|
||||
const homesteadsRoutes = require('./homesteads.routes');
|
||||
const homesteadJointRoutes = require('./homestead.joint.routes');
|
||||
const buildingsRoutes = require('./buildings.routes');
|
||||
const entrancesRoutes = require('./entrances.routes');
|
||||
const apartmentsRoutes = require('./apartments.routes');
|
||||
@@ -20,13 +19,11 @@ const pushRoutes = require('./push.routes');
|
||||
const generatorCardsRoutes = require('./generator.cards.routes');
|
||||
const generatorReportTerritoriesRoutes = require('./generator.report.territories.routes');
|
||||
|
||||
|
||||
router.use(metrics);
|
||||
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/sheeps?', sheepsRoutes);
|
||||
router.use('/constructor', constructorRoutes);
|
||||
router.use('/houses?', housesRoutes);
|
||||
router.use('/homestead/joint/:homestead_id', homesteadJointRoutes);
|
||||
router.use('/homesteads?', homesteadsRoutes);
|
||||
router.use('/buildings?', buildingsRoutes);
|
||||
router.use('/house/:house_id/entrances', entrancesRoutes);
|
||||
|
||||
@@ -8,9 +8,11 @@ class AuthService {
|
||||
sheeps.*,
|
||||
possibilities.can_add_sheeps AS can_add_sheeps,
|
||||
possibilities.can_view_sheeps AS can_view_sheeps,
|
||||
possibilities.can_manager_sheeps AS can_manager_sheeps,
|
||||
possibilities.can_add_territory AS can_add_territory,
|
||||
possibilities.can_view_territory AS can_view_territory,
|
||||
possibilities.can_manager_territory AS can_manager_territory,
|
||||
possibilities.can_joint_territory AS can_joint_territory,
|
||||
possibilities.can_add_stand AS can_add_stand,
|
||||
possibilities.can_view_stand AS can_view_stand,
|
||||
possibilities.can_manager_stand AS can_manager_stand,
|
||||
@@ -42,8 +44,10 @@ class AuthService {
|
||||
possibilities: {
|
||||
can_add_sheeps: false,
|
||||
can_view_sheeps: false,
|
||||
can_manager_sheeps: false,
|
||||
can_add_territory: false,
|
||||
can_manager_territory: false,
|
||||
can_joint_territory: false,
|
||||
can_add_stand: false,
|
||||
can_manager_stand: false,
|
||||
can_add_schedule: false,
|
||||
@@ -55,8 +59,10 @@ class AuthService {
|
||||
if (mode && (mode == 1 || mode == 2)) {
|
||||
data.possibilities.can_add_sheeps = sheep.can_add_sheeps == 1 ? true : false;
|
||||
data.possibilities.can_view_sheeps = sheep.can_view_sheeps == 1 ? true : false;
|
||||
data.possibilities.can_manager_sheeps = sheep.can_manager_sheeps == 1 ? true : false;
|
||||
data.possibilities.can_add_territory = sheep.can_add_territory == 1 ? true : false;
|
||||
data.possibilities.can_manager_territory = sheep.can_manager_territory == 1 ? true : false;
|
||||
data.possibilities.can_joint_territory = sheep.can_joint_territory == 1 ? true : false;
|
||||
data.possibilities.can_add_stand = sheep.can_add_stand == 1 ? true : false;
|
||||
data.possibilities.can_manager_stand = sheep.can_manager_stand == 1 ? true : false;
|
||||
data.possibilities.can_add_schedule = sheep.can_add_schedule == 1 ? true : false;
|
||||
|
||||
@@ -6,6 +6,8 @@ class EntrancesService {
|
||||
let sql = `
|
||||
SELECT
|
||||
entrance.*,
|
||||
(SELECT house.title FROM house WHERE house.id = entrance.house_id) AS address_title,
|
||||
(SELECT house.number FROM house WHERE house.id = entrance.house_id) AS address_number,
|
||||
COALESCE((SELECT entrance_history.working FROM entrance_history WHERE entrance_history.entrance_id = entrance.id ORDER BY entrance_history.date_start DESC LIMIT 1), 0) AS working,
|
||||
(SELECT entrance_history.name FROM entrance_history WHERE entrance_history.entrance_id = entrance.id ORDER BY entrance_history.date_start DESC LIMIT 1) AS entrance_history_name,
|
||||
(SELECT entrance_history.group_id FROM entrance_history WHERE entrance_history.entrance_id = entrance.id ORDER BY entrance_history.date_start DESC LIMIT 1) AS entrance_history_group_id,
|
||||
@@ -30,6 +32,11 @@ class EntrancesService {
|
||||
"house_id": Number(row.house_id),
|
||||
"entrance_number": Number(row.entrance_number),
|
||||
"title": row.title,
|
||||
"address": {
|
||||
"title": row.address_title,
|
||||
"number": row.address_number,
|
||||
"points_number": JSON.parse(row.points_number)
|
||||
},
|
||||
"description": row.description,
|
||||
"created_at": Number(row.created_at),
|
||||
"updated_at": Number(row.updated_at),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const db = require("../config/db");
|
||||
const Notification = require("../utils/notification.js");
|
||||
|
||||
class HistoryHomesteadService {
|
||||
getHistoryHomestead(homestead_id) {
|
||||
|
||||
87
api/services/homestead.joint.service.js
Normal file
87
api/services/homestead.joint.service.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const db = require("../config/db");
|
||||
const Notification = require("../utils/notification.js");
|
||||
|
||||
class HomesteadJointService {
|
||||
getList(homestead_id) {
|
||||
return new Promise((res, rej) => {
|
||||
let sql = `
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
homestead_joint
|
||||
WHERE
|
||||
homestead_joint.homestead_id = '${homestead_id}'
|
||||
ORDER BY
|
||||
homestead_joint.created_at
|
||||
`;
|
||||
|
||||
db.all(sql, (err, rows) => {
|
||||
if (err) {
|
||||
console.error(err.message);
|
||||
return res(false);
|
||||
} else {
|
||||
let data = rows.map((row) => {
|
||||
return {
|
||||
"id": Number(row.id),
|
||||
"homestead_id": Number(row.homestead_id),
|
||||
"sheep_id": Number(row.sheep_id),
|
||||
"created_at": Number(row.created_at)
|
||||
}
|
||||
})
|
||||
|
||||
return res(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createJoint(homestead_id, data) {
|
||||
return new Promise((res, rej) => {
|
||||
let sql = 'INSERT INTO homestead_joint(homestead_id, sheep_id, created_at) VALUES (?, ?, ?)';
|
||||
|
||||
db.run(sql, [
|
||||
Number(homestead_id),
|
||||
Number(data.sheep_id),
|
||||
Date.now()
|
||||
], function (err) {
|
||||
if (err) {
|
||||
console.error(err.message);
|
||||
return res(false);
|
||||
} else if (this.changes === 0) {
|
||||
return res(false);
|
||||
} else {
|
||||
Notification.sendSheep({
|
||||
sheep_id: Number(data.sheep_id),
|
||||
title: "Тимчасова територія",
|
||||
body: "Вам надали спільний доступ до території"
|
||||
});
|
||||
|
||||
res({ "create": "ok", "id": this.lastID });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
deleteJoint(homestead_id, data) {
|
||||
return new Promise((res, rej) => {
|
||||
db.run('DELETE FROM homestead_joint WHERE homestead_id = ? AND sheep_id = ?', [Number(homestead_id), Number(data.sheep_id)], function (err) {
|
||||
if (err) {
|
||||
console.error(err.message);
|
||||
return res(false);
|
||||
} else if (this.changes === 0) {
|
||||
return res(false);
|
||||
} else {
|
||||
Notification.sendSheep({
|
||||
sheep_id: Number(data.sheep_id),
|
||||
title: "Тимчасова територія",
|
||||
body: "Вам відкликанно спільний доступ до території"
|
||||
});
|
||||
|
||||
res({ "delete": "ok", "homestead_id": Number(homestead_id), "sheep_id": Number(data.sheep_id)});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new HomesteadJointService();
|
||||
@@ -1,4 +1,5 @@
|
||||
const db = require("../config/db");
|
||||
const genCards = require("../middleware/genCards");
|
||||
|
||||
class HomesteadsService {
|
||||
getList(mode, id) {
|
||||
@@ -63,6 +64,27 @@ class HomesteadsService {
|
||||
AND
|
||||
homestead_history.sheep_id = '${id}';
|
||||
`;
|
||||
} else if (mode == "joint") {
|
||||
sql = `
|
||||
SELECT
|
||||
homestead.*,
|
||||
homestead_history.id AS homestead_history_id,
|
||||
homestead_history.name AS homestead_history_name,
|
||||
homestead_history.group_id AS homestead_history_group_id,
|
||||
homestead_history.sheep_id AS homestead_history_sheep_id,
|
||||
homestead_history.date_start AS homestead_history_date_start,
|
||||
homestead_history.date_end AS homestead_history_date_end
|
||||
FROM
|
||||
homestead
|
||||
JOIN
|
||||
homestead_history ON homestead.id = homestead_history.homestead_id
|
||||
JOIN
|
||||
homestead_joint ON homestead.id = homestead_joint.homestead_id
|
||||
WHERE
|
||||
homestead_joint.sheep_id = '${id}'
|
||||
ORDER BY
|
||||
homestead_history.date_start DESC;
|
||||
`;
|
||||
}
|
||||
|
||||
db.all(sql, (err, rows) => {
|
||||
@@ -88,7 +110,7 @@ class HomesteadsService {
|
||||
"id": row.homestead_history_id ? Number(row.homestead_history_id) : null,
|
||||
"name": row.homestead_history_name,
|
||||
"group_id": row.homestead_history_group_id ? Number(row.homestead_history_group_id) : null,
|
||||
"sheep_id": row.entrance_history_sheep_id ? Number(row.entrance_history_sheep_id) : null,
|
||||
"sheep_id": row.homestead_history_sheep_id ? Number(row.homestead_history_sheep_id) : null,
|
||||
"date": {
|
||||
"start": row.homestead_history_date_start ? Number(row.homestead_history_date_start) : null,
|
||||
"end": row.homestead_history_date_end ? Number(row.homestead_history_date_end) : null
|
||||
@@ -111,6 +133,7 @@ class HomesteadsService {
|
||||
COALESCE((SELECT homestead_history.working FROM homestead_history WHERE homestead_history.homestead_id = homestead.id ORDER BY homestead_history.date_start DESC LIMIT 1), 0) AS working,
|
||||
(SELECT homestead_history.name FROM homestead_history WHERE homestead_history.homestead_id = homestead.id ORDER BY homestead_history.date_start DESC LIMIT 1) AS homestead_history_name,
|
||||
(SELECT homestead_history.group_id FROM homestead_history WHERE homestead_history.homestead_id = homestead.id ORDER BY homestead_history.date_start DESC LIMIT 1) AS homestead_history_group_id,
|
||||
(SELECT homestead_history.sheep_id FROM homestead_history WHERE homestead_history.homestead_id = homestead.id ORDER BY homestead_history.date_start DESC LIMIT 1) AS homestead_history_sheep_id,
|
||||
(SELECT homestead_history.id FROM homestead_history WHERE homestead_history.homestead_id = homestead.id ORDER BY homestead_history.date_start DESC LIMIT 1) AS homestead_history_id,
|
||||
(SELECT homestead_history.date_start FROM homestead_history WHERE homestead_history.homestead_id = homestead.id ORDER BY homestead_history.date_start DESC LIMIT 1) AS homestead_history_date_start,
|
||||
(SELECT homestead_history.date_end FROM homestead_history WHERE homestead_history.homestead_id = homestead.id ORDER BY homestead_history.date_start DESC LIMIT 1) AS homestead_history_date_end
|
||||
@@ -144,6 +167,7 @@ class HomesteadsService {
|
||||
"id": row.homestead_history_id ? Number(row.homestead_history_id) : null,
|
||||
"name": row.homestead_history_name,
|
||||
"group_id": row.homestead_history_group_id ? Number(row.homestead_history_group_id) : null,
|
||||
"sheep_id": row.homestead_history_sheep_id ? Number(row.homestead_history_sheep_id) : null,
|
||||
"date": {
|
||||
"start": row.homestead_history_date_start ? Number(row.homestead_history_date_start) : null,
|
||||
"end": row.homestead_history_date_end ? Number(row.homestead_history_date_end) : null
|
||||
@@ -192,6 +216,7 @@ class HomesteadsService {
|
||||
return res(false);
|
||||
} else {
|
||||
res({ "status": "ok", "id": this.lastID });
|
||||
genCards({type: "homestead", id: this.lastID});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -234,6 +259,7 @@ class HomesteadsService {
|
||||
return res(false);
|
||||
} else {
|
||||
res({ "status": "ok", "id": homestead_id });
|
||||
genCards({type: "homestead", id: homestead_id});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const db = require("../config/db");
|
||||
const genCards = require("../middleware/genCards");
|
||||
|
||||
class HousesService {
|
||||
getListEntrance() {
|
||||
@@ -228,6 +229,7 @@ class HousesService {
|
||||
return res(false);
|
||||
} else {
|
||||
res({ "status": "ok", "id": this.lastID });
|
||||
genCards({type: "house", id: this.lastID});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -272,6 +274,8 @@ class HousesService {
|
||||
return res(false);
|
||||
} else {
|
||||
res({ "status": "ok", "id": house_id });
|
||||
|
||||
genCards({type: "house", id: house_id});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,11 @@ class SheepService {
|
||||
sheeps.*,
|
||||
possibilities.can_add_sheeps,
|
||||
possibilities.can_view_sheeps,
|
||||
possibilities.can_manager_sheeps,
|
||||
possibilities.can_add_territory,
|
||||
possibilities.can_view_territory,
|
||||
possibilities.can_manager_territory,
|
||||
possibilities.can_joint_territory,
|
||||
possibilities.can_add_stand,
|
||||
possibilities.can_view_stand,
|
||||
possibilities.can_manager_stand,
|
||||
@@ -38,9 +40,11 @@ class SheepService {
|
||||
const fields = [
|
||||
"can_add_sheeps",
|
||||
"can_view_sheeps",
|
||||
"can_manager_sheeps",
|
||||
"can_add_territory",
|
||||
"can_view_territory",
|
||||
"can_manager_territory",
|
||||
"can_joint_territory",
|
||||
"can_add_stand",
|
||||
"can_view_stand",
|
||||
"can_manager_stand",
|
||||
@@ -83,9 +87,11 @@ class SheepService {
|
||||
sheeps.*,
|
||||
possibilities.can_add_sheeps,
|
||||
possibilities.can_view_sheeps,
|
||||
possibilities.can_manager_sheeps,
|
||||
possibilities.can_add_territory,
|
||||
possibilities.can_view_territory,
|
||||
possibilities.can_manager_territory,
|
||||
possibilities.can_joint_territory,
|
||||
possibilities.can_add_stand,
|
||||
possibilities.can_view_stand,
|
||||
possibilities.can_manager_stand,
|
||||
@@ -108,9 +114,11 @@ class SheepService {
|
||||
const fields = [
|
||||
"can_add_sheeps",
|
||||
"can_view_sheeps",
|
||||
"can_manager_sheeps",
|
||||
"can_add_territory",
|
||||
"can_view_territory",
|
||||
"can_manager_territory",
|
||||
"can_joint_territory",
|
||||
"can_add_stand",
|
||||
"can_view_stand",
|
||||
"can_manager_stand",
|
||||
@@ -156,9 +164,11 @@ class SheepService {
|
||||
sheeps.*,
|
||||
possibilities.can_add_sheeps,
|
||||
possibilities.can_view_sheeps,
|
||||
possibilities.can_manager_sheeps,
|
||||
possibilities.can_add_territory,
|
||||
possibilities.can_view_territory,
|
||||
possibilities.can_manager_territory,
|
||||
possibilities.can_joint_territory,
|
||||
possibilities.can_add_stand,
|
||||
possibilities.can_view_stand,
|
||||
possibilities.can_manager_stand,
|
||||
@@ -183,9 +193,11 @@ class SheepService {
|
||||
const fields = [
|
||||
"can_add_sheeps",
|
||||
"can_view_sheeps",
|
||||
"can_manager_sheeps",
|
||||
"can_add_territory",
|
||||
"can_view_territory",
|
||||
"can_manager_territory",
|
||||
"can_joint_territory",
|
||||
"can_add_stand",
|
||||
"can_view_stand",
|
||||
"can_manager_stand",
|
||||
@@ -253,71 +265,120 @@ class SheepService {
|
||||
});
|
||||
}
|
||||
|
||||
updateSheep(data) {
|
||||
const stmt1 = db.prepare(`
|
||||
UPDATE
|
||||
sheeps
|
||||
SET
|
||||
name = ?,
|
||||
group_id = ?,
|
||||
mode = ?,
|
||||
mode_title = ?,
|
||||
uuid_manager = ?
|
||||
WHERE
|
||||
uuid = ?
|
||||
`);
|
||||
updateSheep(data, mode) {
|
||||
if (mode == 2) {
|
||||
const stmt1 = db.prepare(`
|
||||
UPDATE
|
||||
sheeps
|
||||
SET
|
||||
name = ?,
|
||||
group_id = ?,
|
||||
mode = ?,
|
||||
mode_title = ?,
|
||||
uuid_manager = ?
|
||||
WHERE
|
||||
uuid = ?
|
||||
`);
|
||||
|
||||
const stmt2 = db.prepare(`
|
||||
UPDATE
|
||||
possibilities
|
||||
SET
|
||||
can_add_sheeps = ?,
|
||||
can_view_sheeps = ?,
|
||||
can_add_territory = ?,
|
||||
can_view_territory = ?,
|
||||
can_manager_territory = ?,
|
||||
can_add_stand = ?,
|
||||
can_view_stand = ?,
|
||||
can_manager_stand = ?,
|
||||
can_add_schedule = ?,
|
||||
can_view_schedule = ?
|
||||
WHERE
|
||||
sheep_id = (SELECT id FROM sheeps WHERE uuid = ? LIMIT 1)
|
||||
`);
|
||||
const stmt2 = db.prepare(`
|
||||
UPDATE
|
||||
possibilities
|
||||
SET
|
||||
can_add_sheeps = ?,
|
||||
can_view_sheeps = ?,
|
||||
can_manager_sheeps = ?,
|
||||
can_add_territory = ?,
|
||||
can_view_territory = ?,
|
||||
can_manager_territory = ?,
|
||||
can_joint_territory = ?,
|
||||
can_add_stand = ?,
|
||||
can_view_stand = ?,
|
||||
can_manager_stand = ?,
|
||||
can_add_schedule = ?,
|
||||
can_view_schedule = ?
|
||||
WHERE
|
||||
sheep_id = (SELECT id FROM sheeps WHERE uuid = ? LIMIT 1)
|
||||
`);
|
||||
|
||||
return new Promise((res, rej) => {
|
||||
db.serialize(() => {
|
||||
let uuid_manager = crypto.randomUUID();
|
||||
return new Promise((res, rej) => {
|
||||
db.serialize(() => {
|
||||
let uuid_manager = crypto.randomUUID();
|
||||
|
||||
stmt1.run([
|
||||
data.name,
|
||||
Number(data.group_id),
|
||||
Number(data.mode),
|
||||
data.mode_title,
|
||||
Number(data.mode) == 0 ? null : (data.uuid_manager ? data.uuid_manager : uuid_manager),
|
||||
data.uuid
|
||||
], (err) => {
|
||||
if (err) return rej(err);
|
||||
|
||||
stmt2.run([
|
||||
data.possibilities.can_add_sheeps,
|
||||
data.possibilities.can_view_sheeps,
|
||||
data.possibilities.can_add_territory,
|
||||
data.possibilities.can_view_territory,
|
||||
data.possibilities.can_manager_territory,
|
||||
data.possibilities.can_add_stand,
|
||||
data.possibilities.can_view_stand,
|
||||
data.possibilities.can_manager_stand,
|
||||
data.possibilities.can_add_schedule,
|
||||
data.possibilities.can_view_schedule,
|
||||
stmt1.run([
|
||||
data.name,
|
||||
Number(data.group_id),
|
||||
Number(data.mode),
|
||||
data.mode_title,
|
||||
Number(data.mode) == 0 ? null : (data.uuid_manager ? data.uuid_manager : uuid_manager),
|
||||
data.uuid
|
||||
], (err2) => {
|
||||
if (err2) return rej(err2);
|
||||
res({ status: "ok", id: data.id });
|
||||
], (err) => {
|
||||
if (err) return rej(err);
|
||||
|
||||
stmt2.run([
|
||||
data.possibilities.can_add_sheeps,
|
||||
data.possibilities.can_view_sheeps,
|
||||
data.possibilities.can_manager_sheeps,
|
||||
data.possibilities.can_add_territory,
|
||||
data.possibilities.can_view_territory,
|
||||
data.possibilities.can_manager_territory,
|
||||
data.possibilities.can_joint_territory,
|
||||
data.possibilities.can_add_stand,
|
||||
data.possibilities.can_view_stand,
|
||||
data.possibilities.can_manager_stand,
|
||||
data.possibilities.can_add_schedule,
|
||||
data.possibilities.can_view_schedule,
|
||||
data.uuid
|
||||
], (err2) => {
|
||||
if (err2) return rej(err2);
|
||||
res({ status: "ok", id: data.id });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else if(mode == 1){
|
||||
const stmt1 = db.prepare(`
|
||||
UPDATE
|
||||
sheeps
|
||||
SET
|
||||
name = ?,
|
||||
group_id = ?
|
||||
WHERE
|
||||
uuid = ?
|
||||
`);
|
||||
|
||||
const stmt2 = db.prepare(`
|
||||
UPDATE
|
||||
possibilities
|
||||
SET
|
||||
can_view_territory = ?,
|
||||
can_view_stand = ?,
|
||||
can_view_schedule = ?
|
||||
WHERE
|
||||
sheep_id = (SELECT id FROM sheeps WHERE uuid = ? LIMIT 1)
|
||||
`);
|
||||
|
||||
return new Promise((res, rej) => {
|
||||
db.serialize(() => {
|
||||
stmt1.run([
|
||||
data.name,
|
||||
Number(data.group_id),
|
||||
data.uuid
|
||||
], (err) => {
|
||||
if (err) return rej(err);
|
||||
|
||||
stmt2.run([
|
||||
data.possibilities.can_view_territory,
|
||||
data.possibilities.can_view_stand,
|
||||
data.possibilities.can_view_schedule,
|
||||
data.uuid
|
||||
], (err2) => {
|
||||
if (err2) return rej(err2);
|
||||
res({ status: "ok", id: data.id });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deleteSheep(data) {
|
||||
|
||||
@@ -221,7 +221,7 @@ class StandService {
|
||||
}
|
||||
|
||||
const normalized = normalizeTs(row && row.max_date ? row.max_date : null);
|
||||
if (normalized) {
|
||||
if (normalized && normalized > Date.now()) {
|
||||
date_start = getNextMonday(normalized);
|
||||
} else {
|
||||
date_start = getNextMonday(Date.now());
|
||||
@@ -272,9 +272,26 @@ class StandService {
|
||||
console.error(err.message);
|
||||
return res(false);
|
||||
}
|
||||
let text = [
|
||||
`Стенд «${stand.title}» отримав новий графік. Можна сміливо записуватися 🙂`,
|
||||
`Для «${stand.title}» відкрито новий розклад. Хто планував — саме час.`,
|
||||
`Новий графік для «${stand.title}» вже доступний. Обирайте зручний час 👍`,
|
||||
`Стенд «${stand.title}» оновив розклад. Запис розпочато.`,
|
||||
`З’явилися нові дати у «${stand.title}». Встигніть обрати свою 😉`,
|
||||
`«${stand.title}» відкрив новий період запису. Плануємо заздалегідь 🙂`,
|
||||
`Оновлення для «${stand.title}». Додано новий графік.`,
|
||||
`Новий розклад для «${stand.title}» вже чекає на охочих 📋`,
|
||||
`Стенд «${stand.title}» додав нові години для запису ⏰`,
|
||||
`Графік «${stand.title}» поповнено. Можна бронювати час 😊`,
|
||||
`У «${stand.title}» з’явилися нові можливості для запису`,
|
||||
`Свіжий графік для «${stand.title}» уже доступний 🚀`
|
||||
];
|
||||
|
||||
let randomMessage = text[Math.floor(Math.random() * text.length)];
|
||||
|
||||
Notification.sendStand({
|
||||
title: "Додан новий день служіння",
|
||||
body: `Стенд «${stand.title}» поповнився, встигніть записатися.`,
|
||||
title: "Додано новий день служіння",
|
||||
body: randomMessage,
|
||||
page: `/stand/card/${stand.id}`
|
||||
});
|
||||
|
||||
@@ -301,7 +318,7 @@ class StandService {
|
||||
WHERE
|
||||
ss.stand_id = ?
|
||||
AND
|
||||
date(ss.date / 1000, 'unixepoch') >= date('now')
|
||||
date(ss.date / 1000, 'unixepoch', 'localtime') >= date('now', 'localtime')
|
||||
ORDER BY
|
||||
ss.id;
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
const db = require("../config/db");
|
||||
const webpush = require('web-push');
|
||||
const TelegramBot = require("node-telegram-bot-api");
|
||||
|
||||
const util = require('util');
|
||||
const dbRun = util.promisify(db.run).bind(db);
|
||||
|
||||
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY;
|
||||
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY;
|
||||
@@ -10,6 +14,11 @@ webpush.setVapidDetails(
|
||||
VAPID_PRIVATE_KEY
|
||||
);
|
||||
|
||||
const TOKEN = process.env.TELEGRAM_TOKEN;
|
||||
const STAND_CHAT_ID = process.env.STAND_CHAT_ID;
|
||||
|
||||
const bot = new TelegramBot(TOKEN, { polling: false });
|
||||
|
||||
class Notification {
|
||||
async sendSheep({ sheep_id, title, body, page }) {
|
||||
const sql = `
|
||||
@@ -123,7 +132,7 @@ class Notification {
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`);
|
||||
console.log(`🐑 No subscriptions`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,6 +155,25 @@ class Notification {
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
|
||||
});
|
||||
|
||||
// Формуємо повне повідомлення
|
||||
const fullMessage = `📢 <b>${title}</b>\n\n${body.replace('«', '«<b>').replace('»', '</b>»')}`;
|
||||
|
||||
try {
|
||||
const sentMessage = await bot.sendMessage(STAND_CHAT_ID, fullMessage, {
|
||||
parse_mode: 'HTML'
|
||||
});
|
||||
|
||||
// Зберігаємо ID нового повідомлення у базі
|
||||
await dbRun(
|
||||
`INSERT INTO sent_messages (last_message_id, created_at) VALUES (?, ?)`, [sentMessage.message_id, Date.now()]
|
||||
);
|
||||
|
||||
console.log(`✅ Сповіщення надіслано для стенду: ${stand.title}`);
|
||||
} catch (err) {
|
||||
console.error('❌ Помилка відправки тексту:', err.message);
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,4 +8,8 @@ RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
COPY ./fonts/ /usr/share/fonts/truetype/roboto/
|
||||
|
||||
RUN fc-cache -f -v
|
||||
|
||||
CMD npm start
|
||||
|
||||
52
cron/cron.js
52
cron/cron.js
@@ -1,13 +1,57 @@
|
||||
const cron = require("node-cron");
|
||||
const Backup = require("./tasks/backup");
|
||||
const Stand = require("./tasks/stands");
|
||||
const Messages = require('./tasks/messages');
|
||||
const Rept = require('./tasks/rept');
|
||||
|
||||
// Завдання: резервна копія БД кожен день в 22:30
|
||||
// 1. Резервна копія БД щодня о 22:35
|
||||
cron.schedule("30 22 * * *", () => {
|
||||
Backup.database();
|
||||
|
||||
const now = new Date().toLocaleString();
|
||||
console.log(`[${now}] Завдання «Backup» виконане!`);
|
||||
console.log(`[${new Date().toLocaleString()}] Завдання «Backup» виконане!`);
|
||||
});
|
||||
|
||||
// 2. Перевірка стендів без графіку щосуботи о 18:00
|
||||
cron.schedule("0 18 * * 6", async () => {
|
||||
console.log(`[${new Date().toLocaleString()}] Запуск перевірки стендів без графіку...`);
|
||||
try {
|
||||
await Stand.check_add();
|
||||
console.log(`[${new Date().toLocaleString()}] Завдання «Stand check_add» виконане!`);
|
||||
} catch (err) {
|
||||
console.error("Помилка Stand check_add:", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Відправка графіку стендів щодня о 18:00
|
||||
cron.schedule("0 18 * * *", async () => {
|
||||
console.log(`[${new Date().toLocaleString()}] Запуск відправки графіків...`);
|
||||
try {
|
||||
await Stand.check_entries();
|
||||
console.log(`[${new Date().toLocaleString()}] Завдання «Stand check_entries» виконане!`);
|
||||
} catch (err) {
|
||||
console.error("Помилка Stand check_entries:", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Очищення старих повідомлень щодня о 17:59
|
||||
cron.schedule('59 17 * * *', async () => {
|
||||
console.log(`[${new Date().toLocaleString()}] Запуск очищення старих повідомлень...`);
|
||||
try {
|
||||
await Messages.cleanup_old();
|
||||
console.log(`[${new Date().toLocaleString()}] Завдання «Messages cleanup_old» виконане успішно!`);
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toLocaleString()}] Помилка Messages cleanup_old:`, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Нагадування про звіт щомісяця о 10:00
|
||||
cron.schedule('00 10 1 * *', async () => {
|
||||
console.log(`[${new Date().toLocaleString()}] Запуск відправки повідомлень...`);
|
||||
try {
|
||||
await Rept.send_notification();
|
||||
console.log(`[${new Date().toLocaleString()}] Завдання Rept send_notification виконане успішно!`);
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toLocaleString()}] Помилка Rept send_notification:`, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Cron-завдання запущено.");
|
||||
BIN
cron/font/RobotoMono-Bold.ttf
Normal file
BIN
cron/font/RobotoMono-Bold.ttf
Normal file
Binary file not shown.
BIN
cron/font/RobotoMono-Light.ttf
Normal file
BIN
cron/font/RobotoMono-Light.ttf
Normal file
Binary file not shown.
BIN
cron/font/RobotoMono-Medium.ttf
Normal file
BIN
cron/font/RobotoMono-Medium.ttf
Normal file
Binary file not shown.
BIN
cron/font/RobotoMono-Regular.ttf
Normal file
BIN
cron/font/RobotoMono-Regular.ttf
Normal file
Binary file not shown.
BIN
cron/fonts/RobotoMono-Bold.ttf
Normal file
BIN
cron/fonts/RobotoMono-Bold.ttf
Normal file
Binary file not shown.
BIN
cron/fonts/RobotoMono-Light.ttf
Normal file
BIN
cron/fonts/RobotoMono-Light.ttf
Normal file
Binary file not shown.
BIN
cron/fonts/RobotoMono-Medium.ttf
Normal file
BIN
cron/fonts/RobotoMono-Medium.ttf
Normal file
Binary file not shown.
BIN
cron/fonts/RobotoMono-Regular.ttf
Normal file
BIN
cron/fonts/RobotoMono-Regular.ttf
Normal file
Binary file not shown.
2026
cron/package-lock.json
generated
2026
cron/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,12 @@
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"node-cron": "^4.2.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"web-push": "^3.6.7",
|
||||
"@aws-sdk/client-s3": "^3.1004.0",
|
||||
"dotenv": "^17.2.0",
|
||||
"node-telegram-bot-api": "^0.66.0"
|
||||
"node-cron": "^4.2.1",
|
||||
"node-telegram-bot-api": "^0.66.0",
|
||||
"sharp": "^0.34.5",
|
||||
"sqlite3": "^5.1.7",
|
||||
"web-push": "^3.6.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const TelegramBot = require("node-telegram-bot-api");
|
||||
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
|
||||
|
||||
const TOKEN = process.env.TELEGRAM_TOKEN;
|
||||
const CHAT_ID = process.env.CHAT_ID;
|
||||
@@ -9,6 +10,16 @@ const FILE = path.join(DB_PATH, "database.sqlite");
|
||||
|
||||
const bot = new TelegramBot(TOKEN, { polling: false });
|
||||
|
||||
// Настройки S3 / R2
|
||||
const s3Client = new S3Client({
|
||||
region: "auto",
|
||||
endpoint: process.env.S3_ENDPOINT, // Напр: https://<id>.r2.cloudflarestorage.com
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
class Backup {
|
||||
async database() {
|
||||
try {
|
||||
@@ -17,13 +28,25 @@ class Backup {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📤 Надсилаю файл: ${FILE}`);
|
||||
const fileName = `backup_${new Date().toISOString().replace(/[:.]/g, "-")}.sqlite`;
|
||||
|
||||
// 1. Отправка в Telegram
|
||||
await bot.sendDocument(CHAT_ID, fs.createReadStream(FILE), {
|
||||
caption: "📦 Резервна копія бази даних",
|
||||
caption: `📦 Резервна копія бази даних`,
|
||||
});
|
||||
console.log("✅ Telegram: успішно надіслано");
|
||||
|
||||
console.log("✅ Файл успішно надіслано!");
|
||||
// 2. Отправка в S3 (R2)
|
||||
const fileBuffer = fs.readFileSync(FILE);
|
||||
const uploadParams = {
|
||||
Bucket: process.env.S3_BUCKET_NAME,
|
||||
Key: `sheep-service.com/database/${fileName}`, // Путь внутри бакета
|
||||
Body: fileBuffer,
|
||||
ContentType: "application/x-sqlite3",
|
||||
};
|
||||
|
||||
await s3Client.send(new PutObjectCommand(uploadParams));
|
||||
console.log("✅ S3: успішно завантажено в хмару");
|
||||
} catch (err) {
|
||||
console.error("❌ Помилка при надсиланні файлу:", err.message);
|
||||
}
|
||||
|
||||
55
cron/tasks/messages.js
Normal file
55
cron/tasks/messages.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const db = require("../config/db");
|
||||
const TelegramBot = require("node-telegram-bot-api");
|
||||
|
||||
const util = require('util');
|
||||
const dbAll = util.promisify(db.all).bind(db);
|
||||
const dbRun = util.promisify(db.run).bind(db);
|
||||
|
||||
const TOKEN = process.env.TELEGRAM_TOKEN;
|
||||
const STAND_CHAT_ID = process.env.STAND_CHAT_ID;
|
||||
|
||||
const bot = new TelegramBot(TOKEN, { polling: false });
|
||||
|
||||
class Messages {
|
||||
async cleanup_old() {
|
||||
try {
|
||||
// "Зараз мінус 24 години"
|
||||
const oneDayAgo = Date.now() - (24 * 60 * 55 * 1000);
|
||||
|
||||
// 1. Отримуємо повідомлення, які старші за добу
|
||||
const oldMessages = await dbAll(
|
||||
`SELECT last_message_id FROM sent_messages WHERE created_at < ?`,
|
||||
[oneDayAgo]
|
||||
);
|
||||
|
||||
if (!oldMessages || oldMessages.length === 0) {
|
||||
console.log('🧹 Застарілих повідомлень не знайдено.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🧹 Знайдено ${oldMessages.length} повідомлень для видалення...`);
|
||||
|
||||
// 2. Видаляємо повідомлення з Telegram по черзі
|
||||
for (const msg of oldMessages) {
|
||||
try {
|
||||
await bot.deleteMessage(STAND_CHAT_ID, msg.last_message_id);
|
||||
await new Promise(resolve => setTimeout(resolve, 50)); // пауза 50мс
|
||||
} catch (e) {
|
||||
// Код помилки 400 зазвичай означає, що повідомлення вже видалено вручну
|
||||
// або минуло понад 48 годин
|
||||
console.log(`⚠️ Повідомлення ${msg.last_message_id} не знайдено в Telegram або застаре.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Видаляємо записи з бази даних одним махом
|
||||
await dbRun(`DELETE FROM sent_messages WHERE created_at < ?`, [oneDayAgo]);
|
||||
|
||||
console.log(`✅ Очистка завершена.`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('❌ Помилка під час виконання cleanup_old:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Messages();
|
||||
28
cron/tasks/rept.js
Normal file
28
cron/tasks/rept.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const Notification = require("../utils/notification");
|
||||
|
||||
class Rept {
|
||||
async send_notification() {
|
||||
let text = [
|
||||
'Час підбивати підсумки! Не забудьте здати свій звіт про служіння за минулий місяць 📋',
|
||||
'Нагадуємо про щомісячний звіт. Будь ласка, надішліть його сьогодні 😊',
|
||||
'Кінець місяця вже тут, а це значить — пора здавати звіти про служіння 🕒',
|
||||
'Ваш звіт дуже важливий! Не забудьте поділитися результатами служіння за цей місяць.',
|
||||
'Маленьке нагадування: пора заповнити звіт про служіння за місяць. Дякуємо за вашу працю!',
|
||||
'Ще не здали звіт? Саме час це зробити! 😉',
|
||||
'Звітність — це порядок. Будь ласка, надішліть дані про своє служіння за минулий місяць.',
|
||||
'Місяць завершився, тож не забудьте прозвітувати про ваші успіхи в служінні 📝',
|
||||
'Чекаємо на ваш звіт! Це допоможе нам мати загальну картину нашого спільного служіння.',
|
||||
'Пора здавати звіти! Дякуємо кожному за активність у цьому місяці ✨',
|
||||
'Не відкладайте на потім — здайте звіт про служіння вже зараз 📋'
|
||||
];
|
||||
let randomMessage = text[Math.floor(Math.random() * text.length)];
|
||||
|
||||
Notification.sendAll({
|
||||
title: "Звіт про служіння",
|
||||
body: randomMessage,
|
||||
page: `/`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Rept();
|
||||
260
cron/tasks/stands.js
Normal file
260
cron/tasks/stands.js
Normal file
@@ -0,0 +1,260 @@
|
||||
const db = require("../config/db");
|
||||
const Notification = require("../utils/notification");
|
||||
const sharp = require('sharp');
|
||||
const TelegramBot = require("node-telegram-bot-api");
|
||||
|
||||
const util = require('util');
|
||||
const dbAll = util.promisify(db.all).bind(db);
|
||||
const dbRun = util.promisify(db.run).bind(db);
|
||||
|
||||
const TOKEN = process.env.TELEGRAM_TOKEN;
|
||||
const STAND_CHAT_ID = process.env.STAND_CHAT_ID;
|
||||
|
||||
const bot = new TelegramBot(TOKEN, { polling: false });
|
||||
|
||||
|
||||
class Stands {
|
||||
async check_add() {
|
||||
const sqlStands = `
|
||||
SELECT id
|
||||
FROM stand_list
|
||||
WHERE status = '1'
|
||||
ORDER BY id
|
||||
`;
|
||||
|
||||
db.all(sqlStands, (err, stands) => {
|
||||
if (err) {
|
||||
console.error('DB error:', err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stands.length) {
|
||||
console.log('There are no active stands');
|
||||
return;
|
||||
}
|
||||
|
||||
const dateNow = Date.now();
|
||||
|
||||
const sqlSchedule = `
|
||||
SELECT 1
|
||||
FROM stand_schedule
|
||||
WHERE stand_id = ?
|
||||
AND date >= ?
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
let checked = 0;
|
||||
let emptyStands = 0;
|
||||
|
||||
stands.forEach(stand => {
|
||||
db.get(sqlSchedule, [stand.id, dateNow], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Schedule error:', err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!row) emptyStands++;
|
||||
|
||||
checked++;
|
||||
|
||||
if (checked === stands.length) {
|
||||
console.log('Empty stands:', emptyStands);
|
||||
|
||||
if (emptyStands > 0) {
|
||||
Notification.sendStandAdd({
|
||||
title: 'Додайте нові дні служіння',
|
||||
body: `${emptyStands} з ${stands.length} стендів потребують додавання днів служіння.`,
|
||||
page: '/stand'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async check_entries() {
|
||||
try {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// 1. Отримуємо вісників і одразу робимо Map для швидкого пошуку
|
||||
const sheeps = await dbAll(`SELECT id, name FROM sheeps`);
|
||||
if (!sheeps.length) return console.log('There are no sheeps');
|
||||
|
||||
const sheepMap = new Map(sheeps.map(s => [s.id, s.name]));
|
||||
|
||||
const startTomorrow = today.getTime() + 86400000;
|
||||
const startDayAfterTomorrow = today.getTime() + 86400000 * 2;
|
||||
|
||||
const sqlStands = `
|
||||
SELECT
|
||||
stand_schedule.*,
|
||||
(SELECT stand_list.title FROM stand_list WHERE stand_list.id = stand_schedule.stand_id) AS title
|
||||
FROM
|
||||
stand_schedule
|
||||
WHERE
|
||||
stand_schedule.date >= ? AND stand_schedule.date < ?
|
||||
ORDER BY
|
||||
stand_schedule.stand_id
|
||||
`;
|
||||
|
||||
const schedule = await dbAll(sqlStands, [startTomorrow, startDayAfterTomorrow]);
|
||||
if (!schedule.length) return console.log('No active schedule');
|
||||
|
||||
// 2. Угруповання даних (Transform)
|
||||
const standsData = schedule.reduce((acc, item) => {
|
||||
if (!acc[item.stand_id]) {
|
||||
acc[item.stand_id] = { id: item.stand_id, title: item.title || `Стенд ${sId}`, hours: {}, maxSheepIdx: 0, date: item.date };
|
||||
}
|
||||
if (!acc[item.stand_id].hours[item.hour]) {
|
||||
acc[item.stand_id].hours[item.hour] = {};
|
||||
}
|
||||
acc[item.stand_id].hours[item.hour][item.number_sheep] = item.sheep_id;
|
||||
acc[item.stand_id].maxSheepIdx = Math.max(acc[item.stand_id].maxSheepIdx, item.number_sheep);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 3. Генерація
|
||||
for (const standId in standsData) {
|
||||
const data = standsData[standId];
|
||||
const title = data.title;
|
||||
const sortedHours = Object.keys(data.hours).map(Number).sort((a, b) => a - b);
|
||||
|
||||
// Визначаємо крок (мінімальна різниця між годинником або 1)
|
||||
let step = 1;
|
||||
if (sortedHours.length > 1) {
|
||||
step = sortedHours[1] - sortedHours[0];
|
||||
}
|
||||
|
||||
const sheepIdsMatrix = sortedHours.map(h => {
|
||||
const row = [];
|
||||
for (let i = 0; i <= data.maxSheepIdx; i++) {
|
||||
row.push(data.hours[h][i] || null);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
const formattedDate = new Date(data.date).toLocaleDateString('ru-RU');
|
||||
|
||||
const result = await generateSchedule({
|
||||
title: title,
|
||||
date: formattedDate,
|
||||
options: {
|
||||
start: sortedHours[0],
|
||||
quantity: data.maxSheepIdx + 1,
|
||||
step: step
|
||||
},
|
||||
sheep_ids: sheepIdsMatrix,
|
||||
sheepMap // Передаємо Map для імен
|
||||
});
|
||||
|
||||
if (result && result.buffer) {
|
||||
try {
|
||||
let isNotFull = result.isNotFull ? '\n\n✏️ Ще є вільні місця, встигніть записатись' : '';
|
||||
|
||||
// 1. Надсилаємо нове фото
|
||||
const sentMessage = await bot.sendPhoto(STAND_CHAT_ID, result.buffer, {
|
||||
caption: `📍 *${title}*\n📅 ${formattedDate}${isNotFull}`,
|
||||
parse_mode: 'Markdown'
|
||||
}, {
|
||||
filename: result.fileName,
|
||||
contentType: 'image/png'
|
||||
});
|
||||
|
||||
// 2. Зберігаємо ID нового повідомлення у базі
|
||||
await dbRun(
|
||||
`INSERT INTO sent_messages (last_message_id, created_at) VALUES (?, ?)`, [sentMessage.message_id, Date.now()]
|
||||
);
|
||||
|
||||
console.log(`✅ Обновлено: ${title}`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Помилка відправки в Telegram:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Logic error:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Генерування зображення графіка
|
||||
async function generateSchedule({
|
||||
title, date, options, sheep_ids, sheepMap
|
||||
}) {
|
||||
const { start, step, quantity } = options;
|
||||
const rowsCount = sheep_ids.length;
|
||||
|
||||
const rowHeight = 50;
|
||||
const timeWidth = 120;
|
||||
const gap = 10;
|
||||
const padding = 10;
|
||||
|
||||
const totalWidth = 200 + (200 * quantity);
|
||||
const headerHeight = 85;
|
||||
const totalHeight = 136 + (rowHeight * (rowsCount - 1));
|
||||
|
||||
const mainRectWidth = totalWidth - (padding * 2);
|
||||
const availableForBlocks = mainRectWidth - timeWidth - 5;
|
||||
const blockWidth = (availableForBlocks - (gap * (quantity - 1))) / quantity;
|
||||
|
||||
let isNotFull = false;
|
||||
|
||||
const formatTime = (h) => {
|
||||
const hh = Math.floor(h).toString().padStart(2, '0');
|
||||
const mm = (h % 1 * 60).toString().padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
};
|
||||
|
||||
let rowsSvg = '';
|
||||
sheep_ids.forEach((rowSheepIds, i) => {
|
||||
const y = headerHeight + (i * rowHeight);
|
||||
const hStart = formatTime(start + (i * step));
|
||||
const hEnd = formatTime(start + ((i + 1) * step));
|
||||
const isEven = i % 2 === 0;
|
||||
|
||||
// Фон рядка
|
||||
rowsSvg += `<rect x="${padding}" y="${y}" width="${totalWidth - padding * 2}" height="40" rx="8" fill="${isEven ? '#fbfbfb' : '#e7e7e1'}" />`;
|
||||
|
||||
// Текст часу
|
||||
rowsSvg += `<text x="${padding + 7}" y="${y + 27}" font-family="Roboto Mono" font-size="15" fill="#313131">${hStart}-${hEnd}</text>`;
|
||||
|
||||
// Блоки з іменами
|
||||
rowSheepIds.forEach((id, j) => {
|
||||
const x = padding + timeWidth + (j * (blockWidth + gap));
|
||||
const name = sheepMap.get(id) || '';
|
||||
if(!sheepMap.get(id)) isNotFull = true; // Якщо є вільне місце
|
||||
|
||||
rowsSvg += `
|
||||
<g>
|
||||
<rect x="${x}" y="${y + 5}" width="${blockWidth}" height="30" rx="5" fill="${isEven ? '#e7e7e1' : '#fbfbfb'}" />
|
||||
<text x="${x + 5}" y="${y + 25}" font-family="Roboto Mono" font-size="15" fill="#313131">${name}</text>
|
||||
</g>`;
|
||||
});
|
||||
});
|
||||
|
||||
const finalSvg = `
|
||||
<svg width="${totalWidth * 2}" height="${totalHeight * 2}" viewBox="0 0 ${totalWidth} ${totalHeight}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect height="${totalHeight}" width="${totalWidth}" fill="#e7e7e1"/>
|
||||
<text font-weight="bold" text-anchor="middle" font-family="'Roboto Mono'" font-size="24" y="52" x="${totalWidth / 2}" fill="#313131">
|
||||
${date} • ${title}
|
||||
</text>
|
||||
${rowsSvg}
|
||||
</svg>`;
|
||||
|
||||
try {
|
||||
const buffer = await sharp(Buffer.from(finalSvg))
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
return { buffer, fileName: `${title}_${date}.png`, isNotFull };
|
||||
} catch (e) {
|
||||
console.error('Sharp error:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Stands();
|
||||
@@ -11,6 +11,44 @@ webpush.setVapidDetails(
|
||||
);
|
||||
|
||||
class Notification {
|
||||
async sendAll({ title, body, page }) {
|
||||
const sql = `
|
||||
SELECT * FROM subscription
|
||||
ORDER BY id
|
||||
`;
|
||||
|
||||
db.all(sql, async (err, rows) => {
|
||||
if (err) {
|
||||
console.error('DB error:', err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
console.log(`🐑 No subscriptions`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: title ?? "Тестове повідомлення",
|
||||
body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
|
||||
url: `https://${process.env.DOMAIN}${page ?? ""}`
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(rows.map(row => {
|
||||
const subscription = {
|
||||
endpoint: row.endpoint,
|
||||
keys: JSON.parse(row.keys),
|
||||
};
|
||||
return webpush.sendNotification(subscription, payload);
|
||||
}));
|
||||
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
|
||||
});
|
||||
}
|
||||
|
||||
async sendSheep({ sheep_id, title, body, page }) {
|
||||
const sql = `
|
||||
SELECT * FROM subscription
|
||||
@@ -73,7 +111,7 @@ class Notification {
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`);
|
||||
console.log(`🐑 No subscriptions found for group_id: ${group_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,7 +136,7 @@ class Notification {
|
||||
});
|
||||
}
|
||||
|
||||
async sendStand({ title, body, page }) {
|
||||
async sendStandAdd({title, body, page }) {
|
||||
const sql = `
|
||||
SELECT
|
||||
subscription.*
|
||||
@@ -111,7 +149,7 @@ class Notification {
|
||||
possibilities
|
||||
ON possibilities.sheep_id = sheeps.id
|
||||
WHERE
|
||||
possibilities.can_view_stand = '1'
|
||||
possibilities.can_add_stand = '1'
|
||||
ORDER BY
|
||||
subscription.id;
|
||||
`;
|
||||
@@ -147,6 +185,22 @@ class Notification {
|
||||
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
|
||||
});
|
||||
}
|
||||
|
||||
async sendEndpoint({ endpoint, keys, title, body, page }) {
|
||||
const payload = JSON.stringify({
|
||||
title: title ?? "Тестове повідомлення",
|
||||
body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
|
||||
url: `https://${process.env.DOMAIN}${page ?? ""}`
|
||||
});
|
||||
|
||||
const subscription = {
|
||||
endpoint,
|
||||
keys: typeof keys === "string" ? JSON.parse(keys) : keys
|
||||
};
|
||||
|
||||
await webpush.sendNotification(subscription, payload);
|
||||
console.log("✅ Push sent");
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = new Notification();
|
||||
163
dash.json
163
dash.json
@@ -1,163 +0,0 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"target": {
|
||||
"limit": 100,
|
||||
"matchAny": false,
|
||||
"tags": [],
|
||||
"type": "dashboard"
|
||||
},
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"graphTooltip": 1,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
|
||||
|
||||
{
|
||||
"datasource": { "type": "datasource", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisPlacement": "auto",
|
||||
"drawStyle": "line",
|
||||
"fillBorderColor": "rgba(255, 255, 255, 1)",
|
||||
"gradientMode": "none",
|
||||
"lineStyle": "solid",
|
||||
"lineWidth": 1,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"unit": "ms"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 0 },
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"targets": [
|
||||
{ "expr": "frontend_load_time", "legendFormat": "Завантаження: {{instance}}", "refId": "A" }
|
||||
],
|
||||
"title": "Час завантаження Frontend (ms)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
|
||||
{
|
||||
"datasource": { "type": "datasource", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"unit": "short"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 0 },
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["last"],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"targets": [
|
||||
{ "expr": "users_online", "legendFormat": "Онлайн користувачі", "refId": "A" }
|
||||
],
|
||||
"title": "Онлайн користувачі (Gauge)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
|
||||
{
|
||||
"datasource": { "type": "datasource", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 0 },
|
||||
"id": 10,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rest_request_duration_ms",
|
||||
"legendFormat": "{{path}} ({{status}})",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "REST Запити (Duration)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
|
||||
{
|
||||
"datasource": { "type": "datasource", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 8 },
|
||||
"id": 6,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(ws_in_bytes_total[5m])",
|
||||
"legendFormat": "Вхідний WS-трафік (байт/сек)",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "rate(ws_out_bytes_total[5m])",
|
||||
"legendFormat": "Вихідний WS-трафік (байт/сек)",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"type": "timeseries",
|
||||
"title": "WS трафік (Rate)"
|
||||
},
|
||||
|
||||
{
|
||||
"datasource": { "type": "datasource", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 8 },
|
||||
"id": 16,
|
||||
"targets": [
|
||||
{ "expr": "frontend_resource_count", "legendFormat": "Кількість ресурсів", "refId": "A" }
|
||||
],
|
||||
"title": "Кількість завантажених ресурсів",
|
||||
"type": "timeseries"
|
||||
},
|
||||
|
||||
{
|
||||
"datasource": { "type": "datasource", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 8 },
|
||||
"id": 14,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "frontend_memory_used",
|
||||
"legendFormat": "Використано JS Heap (MB)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Використання JS Heap (MB)",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
|
||||
"refresh": "5s",
|
||||
"schemaVersion": 38,
|
||||
"style": "dark",
|
||||
"tags": ["Sheep Service", "pushgateway"],
|
||||
|
||||
"time": { "from": "now-30m", "to": "now" },
|
||||
"timezone": "browser",
|
||||
"title": "Sheep Service Metrics",
|
||||
"version": 1
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
global:
|
||||
scrape_interval: 5s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'pushgateway'
|
||||
static_configs:
|
||||
- targets: ['pushgateway:9091']
|
||||
BIN
dock/Інструкції/Інструкція. Записи на стенд.mp4
Normal file
BIN
dock/Інструкції/Інструкція. Записи на стенд.mp4
Normal file
Binary file not shown.
@@ -31,11 +31,11 @@ services:
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
- DOMAIN=${DOMAIN}
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
|
||||
- STAND_CHAT_ID=${STAND_CHAT_ID}
|
||||
shm_size: '1gb'
|
||||
networks:
|
||||
- network
|
||||
depends_on:
|
||||
- metrics
|
||||
|
||||
ws:
|
||||
image: sheep-service/ws
|
||||
@@ -48,57 +48,12 @@ services:
|
||||
environment:
|
||||
- TZ=${TZ}
|
||||
- DATABASE_PATH=/app/data/
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
- DOMAIN=${DOMAIN}
|
||||
networks:
|
||||
- network
|
||||
|
||||
metrics:
|
||||
image: sheep-service/metrics
|
||||
build: ./metrics
|
||||
ports:
|
||||
- "4005:4005" # HTTP push
|
||||
- "4006:4006" # WebSocket
|
||||
networks:
|
||||
- network
|
||||
depends_on:
|
||||
- pushgateway
|
||||
|
||||
pushgateway:
|
||||
image: prom/pushgateway
|
||||
ports:
|
||||
- "4007:9091"
|
||||
networks:
|
||||
- network
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus
|
||||
volumes:
|
||||
- "${DATA_PATH:-./data}/prometheus.yml:/etc/prometheus/prometheus.yml"
|
||||
ports:
|
||||
- "4008:9090"
|
||||
networks:
|
||||
- network
|
||||
depends_on:
|
||||
- pushgateway
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=7d"
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana
|
||||
ports:
|
||||
- "4009:3000"
|
||||
networks:
|
||||
- network
|
||||
depends_on:
|
||||
- prometheus
|
||||
environment:
|
||||
- GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s/grafana/
|
||||
- GF_SECURITY_ADMIN_USER=${ADMIN_USER}
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${ADMIN_TOKEN}
|
||||
volumes:
|
||||
- ${DATA_PATH:-./data}/grafana:/var/lib/grafana
|
||||
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
restart: always
|
||||
@@ -116,8 +71,6 @@ services:
|
||||
command: /bin/sh -c "envsubst '\$DOMAIN' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"
|
||||
networks:
|
||||
- network
|
||||
depends_on:
|
||||
- grafana
|
||||
|
||||
cron:
|
||||
image: sheep-service/cron
|
||||
@@ -129,7 +82,11 @@ services:
|
||||
- TZ=${TZ}
|
||||
- DATABASE_PATH=/app/data/
|
||||
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
|
||||
- STAND_CHAT_ID=${STAND_CHAT_ID}
|
||||
- CHAT_ID=${CHAT_ID}
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
- DOMAIN=${DOMAIN}
|
||||
networks:
|
||||
- network
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM node:20.18
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 4005
|
||||
EXPOSE 4006
|
||||
|
||||
CMD npm start
|
||||
@@ -1,133 +0,0 @@
|
||||
const express = require("express");
|
||||
const cors = require("cors");
|
||||
const WebSocket = require("ws");
|
||||
const app = express();
|
||||
|
||||
const PORT_HTTP = 4005;
|
||||
const PORT_WS = 4006;
|
||||
const PUSHGATEWAY = "http://pushgateway:9091";
|
||||
|
||||
// --- HTTP server ---
|
||||
app.use(cors({ origin: "*", methods: ["GET", "POST"], allowedHeaders: ["Content-Type"], credentials: true }));
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
|
||||
app.post("/push", (req, res) => {
|
||||
const metric = req.body;
|
||||
if (metric) pushToProm(metric);
|
||||
res.send("ok");
|
||||
});
|
||||
|
||||
// --- WS server ---
|
||||
const wss = new WebSocket.Server({ port: PORT_WS });
|
||||
wss.on("connection", ws => {
|
||||
console.log("New WS connection. Online users:", wss.clients.size);
|
||||
pushOnlineCount();
|
||||
|
||||
ws.on("message", msg => {
|
||||
try {
|
||||
const m = JSON.parse(msg);
|
||||
pushToProm(m);
|
||||
} catch (e) {
|
||||
console.error("Invalid WS message:", e);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => pushOnlineCount());
|
||||
});
|
||||
|
||||
// --- Push metrics to Pushgateway ---
|
||||
async function pushToProm(metric) {
|
||||
if (!metric || !metric.type) return;
|
||||
|
||||
const lines = [];
|
||||
let job = "webapp"; // default job
|
||||
let groupingKey = {};
|
||||
|
||||
switch (metric.type) {
|
||||
case "frontend_metrics":
|
||||
job = "webapp_frontend";
|
||||
|
||||
lines.push(`# HELP frontend_load_time Frontend page load time in ms`);
|
||||
lines.push(`# TYPE frontend_load_time gauge`);
|
||||
lines.push(`frontend_load_time{instance="${metric.id || 'default'}"} ${metric.navigation?.loadEventEnd || 0}`);
|
||||
|
||||
if (metric.memory?.usedJSHeapSize) {
|
||||
lines.push(`# HELP frontend_memory_used JS heap used in MB`);
|
||||
lines.push(`# TYPE frontend_memory_used gauge`);
|
||||
lines.push(`frontend_memory_used{instance="${metric.id || 'default'}"} ${metric.memory.usedJSHeapSize / 1024 / 1024}`);
|
||||
}
|
||||
|
||||
if (metric.resources?.length) {
|
||||
lines.push(`# HELP frontend_resource_count Number of resources loaded`);
|
||||
lines.push(`# TYPE frontend_resource_count gauge`);
|
||||
lines.push(`frontend_resource_count{instance="${metric.id || 'default'}"} ${metric.resources.length}`);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "rest":
|
||||
job = "webapp_api";
|
||||
lines.push(`# HELP rest_request_duration_ms REST request duration in ms`);
|
||||
lines.push(`# TYPE rest_request_duration_ms gauge`);
|
||||
lines.push(`rest_request_duration_ms{path="${metric.path}",status="${metric.status}"} ${metric.time}`);
|
||||
break;
|
||||
|
||||
case "ws_in":
|
||||
job = "webapp_ws";
|
||||
lines.push(`# HELP ws_in_bytes_total WS bytes in`);
|
||||
lines.push(`# TYPE ws_in_bytes_total counter`);
|
||||
lines.push(`ws_in_bytes_total ${metric.length}`);
|
||||
break;
|
||||
|
||||
case "ws_out":
|
||||
job = "webapp_ws";
|
||||
lines.push(`# HELP ws_out_bytes_total WS bytes out`);
|
||||
lines.push(`# TYPE ws_out_bytes_total counter`);
|
||||
lines.push(`ws_out_bytes_total ${metric.length}`);
|
||||
break;
|
||||
|
||||
case "connection_status":
|
||||
job = "webapp_connection";
|
||||
lines.push(`# HELP users_online Users online`);
|
||||
lines.push(`# TYPE users_online gauge`);
|
||||
lines.push(`users_online ${metric.count || wss.clients.size}`);
|
||||
|
||||
const putBody = lines.join("\n") + "\n";
|
||||
|
||||
// Отправляем как PUT для Gauge, чтобы сбросить старые значения
|
||||
return await pushToPrometheus(`${PUSHGATEWAY}/metrics/job/${job}`, putBody, "PUT");
|
||||
}
|
||||
|
||||
const body = lines.join("\n") + "\n";
|
||||
console.log(`Pushing metric for job=${job}:\n${body}\n`);
|
||||
|
||||
// Используем POST по умолчанию для инкремента/добавления
|
||||
await pushToPrometheus(`${PUSHGATEWAY}/metrics/job/${job}`, body, "POST");
|
||||
}
|
||||
|
||||
async function pushToPrometheus(url, body, method) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
console.error(`PushGateway error (${method} ${url}):`, response.status, await response.text());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("PushGateway network error:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function pushOnlineCount() {
|
||||
pushToProm({ type: "connection_status", count: wss.clients.size });
|
||||
}
|
||||
|
||||
// --- Start HTTP server ---
|
||||
app.listen(PORT_HTTP, () => console.log("Metrics HTTP listening on", PORT_HTTP));
|
||||
console.log("Metrics WS listening on", PORT_WS);
|
||||
|
||||
// Запуск интервала после инициализации wss
|
||||
setInterval(pushOnlineCount, 5000);
|
||||
796
metrics/package-lock.json
generated
796
metrics/package-lock.json
generated
@@ -1,796 +0,0 @@
|
||||
{
|
||||
"name": "metrics",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "metrics",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.2.1",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"dependencies": {
|
||||
"mime-types": "^3.0.0",
|
||||
"negotiator": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
|
||||
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.0",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.5",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
"content-disposition": "^1.0.0",
|
||||
"content-type": "^1.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"cookie-signature": "^1.2.1",
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"finalhandler": "^2.1.0",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"merge-descriptors": "^2.0.0",
|
||||
"mime-types": "^3.0.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"once": "^1.4.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"qs": "^6.14.0",
|
||||
"range-parser": "^1.2.1",
|
||||
"router": "^2.2.0",
|
||||
"send": "^1.1.0",
|
||||
"serve-static": "^2.2.0",
|
||||
"statuses": "^2.0.1",
|
||||
"type-is": "^2.0.1",
|
||||
"vary": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"parseurl": "^1.3.3",
|
||||
"statuses": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.7.0",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
"is-promise": "^4.0.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"path-to-regexp": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"ms": "^2.1.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"range-parser": "^1.2.1",
|
||||
"statuses": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"parseurl": "^1.3.3",
|
||||
"send": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "metrics",
|
||||
"version": "1.0.0",
|
||||
"main": "metrics.js",
|
||||
"scripts": {
|
||||
"start": "node metrics.js"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.2.1",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,6 @@ upstream ws_backend {
|
||||
server ws:4004;
|
||||
}
|
||||
|
||||
upstream metrics_backend {
|
||||
server metrics:4006;
|
||||
}
|
||||
|
||||
# Загальні CORS-заголовки
|
||||
map $request_method $cors_preflight {
|
||||
OPTIONS 1;
|
||||
@@ -58,25 +54,6 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Metrics
|
||||
location /metrics {
|
||||
proxy_pass http://metrics_backend$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Grafana
|
||||
location /grafana/ {
|
||||
proxy_pass http://grafana:3000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://frontend$request_uri;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const CONFIG = {
|
||||
"web": "https://test.sheep-service.com/",
|
||||
"api": "https://test.sheep-service.com/api/",
|
||||
"wss": "wss://test.sheep-service.com/ws",
|
||||
"metrics": "wss://test.sheep-service.com/metrics"
|
||||
"wss": "wss://test.sheep-service.com/ws"
|
||||
}
|
||||
370
web/css/main.css
370
web/css/main.css
@@ -1,4 +1,42 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap');
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('../fonts/Roboto/Roboto-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('../fonts/Roboto/Roboto-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('../fonts/Roboto/Roboto-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('../fonts/Roboto/Roboto-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('../fonts/Roboto/Roboto-Black.ttf') format('truetype');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--FontSize1: 12px;
|
||||
@@ -28,13 +66,14 @@
|
||||
/* BGColor */
|
||||
--ColorThemes0: #fbfbfb;
|
||||
--ColorThemes1: #f3f3f3;
|
||||
--ColorThemes2: #dbdbd1;
|
||||
--ColorThemes2: #e7e7e1;
|
||||
/* TextColor */
|
||||
--ColorThemes3: #313131;
|
||||
|
||||
--ColorAnimation: linear-gradient(90deg, #f3f3f3, #efefef, #f3f3f3);
|
||||
|
||||
--shadow-l1: 0px 2px 4px rgba(0, 0, 0, 0.02), 0px 0px 2px rgba(0, 0, 0, 0.04), 0px 0px 1px rgba(0, 0, 0, 0.04);
|
||||
/* --shadow-l1: 0px 2px 4px rgba(0, 0, 0, 0.02), 0px 0px 2px rgba(0, 0, 0, 0.04), 0px 0px 1px rgba(0, 0, 0, 0.04); */
|
||||
--shadow-l1: 0;
|
||||
--border-radius: 15px;
|
||||
|
||||
|
||||
@@ -51,13 +90,14 @@
|
||||
/* BGColor */
|
||||
--ColorThemes0: #1c1c19;
|
||||
--ColorThemes1: #21221d;
|
||||
--ColorThemes2: #525151;
|
||||
--ColorThemes2: #3d3c3c;
|
||||
/* TextColor */
|
||||
--ColorThemes3: #f3f3f3;
|
||||
|
||||
--ColorAnimation: linear-gradient(90deg, #21221d, #242520, #21221d);
|
||||
|
||||
--shadow-l1: 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 0px 2px rgba(0, 0, 0, 0.06), 0px 0px 1px rgba(0, 0, 0, 0.04);
|
||||
/* --shadow-l1: 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 0px 2px rgba(0, 0, 0, 0.06), 0px 0px 1px rgba(0, 0, 0, 0.04); */
|
||||
--shadow-l1: 0;
|
||||
--border-radius: 15px;
|
||||
|
||||
|
||||
@@ -81,6 +121,16 @@
|
||||
cursor: no-drop !important;
|
||||
}
|
||||
|
||||
@media(hover: hover) {
|
||||
a:hover,
|
||||
button:hover,
|
||||
select:hover,
|
||||
input:hover,
|
||||
textarea:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
* {
|
||||
scroll-snap-type: none !important;
|
||||
@@ -238,314 +288,6 @@ body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Банер з прохання встановлення PWA */
|
||||
#blur-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.pwa-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup {
|
||||
background: var(--ColorThemes0);
|
||||
padding: 24px 32px;
|
||||
border-radius: var(--border-radius);
|
||||
max-width: 90%;
|
||||
width: 320px;
|
||||
text-align: center;
|
||||
font-family: sans-serif;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup h2 {
|
||||
margin-bottom: 12px;
|
||||
color: var(--ColorThemes3);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup p {
|
||||
margin-bottom: 10px;
|
||||
color: var(--ColorThemes3);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup ol {
|
||||
text-align: justify;
|
||||
font-size: var(--FontSize4);
|
||||
margin-bottom: 10px;
|
||||
max-width: 290px;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup li {
|
||||
list-style-type: none;
|
||||
font-size: var(--FontSize3);
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup li span {
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup li span svg {
|
||||
fill: var(--PrimaryColor);
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup>div {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup>div>button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: calc(var(--border-radius) - 8px);
|
||||
cursor: pointer;
|
||||
font-size: var(--FontSize3);
|
||||
}
|
||||
|
||||
#pwa-install-button {
|
||||
background-color: var(--PrimaryColor);
|
||||
color: var(--PrimaryColorText);
|
||||
}
|
||||
|
||||
#pwa-close-button,
|
||||
#pwa-ios-close-button {
|
||||
background-color: #ccc;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pwa-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 450px) {
|
||||
.pwa-overlay>.popup {
|
||||
padding: 17px 10px;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup p {
|
||||
font-size: var(--FontSize4);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
#swipe_updater {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#swipe_block {
|
||||
width: calc(100% - 252px);
|
||||
margin-left: 252px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#swipe_icon {
|
||||
width: 20px;
|
||||
fill: var(--ColorThemes3);
|
||||
transform: rotate(0deg);
|
||||
position: absolute;
|
||||
margin-top: -45px;
|
||||
top: -45px;
|
||||
background: var(--ColorThemes2);
|
||||
border: 2px solid var(--ColorThemes3);
|
||||
border-radius: 50%;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
transition: height 0ms 400ms, opacity 400ms 0ms;
|
||||
}
|
||||
|
||||
#swipe_icon[data-state="active"] {
|
||||
height: 20px;
|
||||
opacity: 1;
|
||||
transition: height 0ms 0ms, opacity 400ms 0ms;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
#swipe_block {
|
||||
width: calc(100% - 122px);
|
||||
margin-left: 122px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
#swipe_block {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Стили для меню */
|
||||
#navigation {
|
||||
position: fixed;
|
||||
width: 230px;
|
||||
height: calc(100vh - 60px);
|
||||
min-height: 510px;
|
||||
background: var(--ColorThemes2);
|
||||
padding: 36px 10px;
|
||||
-webkit-transition: width .2s ease 0s;
|
||||
-o-transition: width .2s ease 0s;
|
||||
transition: width .2s ease 0s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#navigation>nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 290px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
#navigation>nav>li {
|
||||
width: 180px;
|
||||
height: 50px;
|
||||
list-style-type: none;
|
||||
position: relative;
|
||||
-webkit-transition: all .2s ease 0s;
|
||||
-o-transition: all .2s ease 0s;
|
||||
transition: all .2s ease 0s;
|
||||
z-index: 1;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
#navigation>nav>li>div {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
height: 50px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--border-radius);
|
||||
-webkit-transition: all .2s ease 0s;
|
||||
-o-transition: all .2s ease 0s;
|
||||
transition: all .2s ease 0s;
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
border: 2px;
|
||||
border: 2px solid var(--ColorThemes2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#navigation>nav>li>div[data-state="active"] {
|
||||
background: var(--ColorThemes3);
|
||||
border: 2px solid var(--ColorThemes3);
|
||||
box-shadow: var(--shadow-l1);
|
||||
}
|
||||
|
||||
#navigation>nav>li:has(div[data-state="active"]) {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#navigation>nav>li>div>svg {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
min-width: 25px;
|
||||
min-height: 25px;
|
||||
fill: var(--ColorThemes3);
|
||||
}
|
||||
|
||||
#navigation>nav>li>div[data-state="active"] svg {
|
||||
fill: var(--ColorThemes2);
|
||||
}
|
||||
|
||||
#navigation>nav>li>div>b {
|
||||
margin-left: 15px;
|
||||
font-size: var(--FontSize3);
|
||||
font-weight: 300;
|
||||
color: var(--ColorThemes3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#navigation>nav>li>div[data-state="active"] b {
|
||||
color: var(--ColorThemes2);
|
||||
}
|
||||
|
||||
#navigation>nav>li>a {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
|
||||
@media (hover: hover) {
|
||||
#navigation>nav>li:hover {
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
#navigation>nav>li:hover>div {
|
||||
border: 2px solid var(--ColorThemes3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
#navigation {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
#navigation>nav>li {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
#navigation>nav>li>div {
|
||||
width: 30px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#navigation>nav>li>div>b {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
background: var(--ColorThemes0);
|
||||
position: absolute;
|
||||
@@ -659,7 +401,7 @@ body.modal-open {
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 8px 10px !important;
|
||||
margin: 10px 10px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
Copyright (c) 2011, ParaType Ltd. (http://www.paratype.com/public),
|
||||
with Reserved Font Names "PT Sans", "PT Serif", "PT Mono" and "ParaType".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
BIN
web/fonts/Roboto/Roboto-Black.ttf
Normal file
BIN
web/fonts/Roboto/Roboto-Black.ttf
Normal file
Binary file not shown.
BIN
web/fonts/Roboto/Roboto-Bold.ttf
Normal file
BIN
web/fonts/Roboto/Roboto-Bold.ttf
Normal file
Binary file not shown.
BIN
web/fonts/Roboto/Roboto-Light.ttf
Normal file
BIN
web/fonts/Roboto/Roboto-Light.ttf
Normal file
Binary file not shown.
BIN
web/fonts/Roboto/Roboto-Medium.ttf
Normal file
BIN
web/fonts/Roboto/Roboto-Medium.ttf
Normal file
Binary file not shown.
BIN
web/fonts/Roboto/Roboto-Regular.ttf
Normal file
BIN
web/fonts/Roboto/Roboto-Regular.ttf
Normal file
Binary file not shown.
BIN
web/fonts/Roboto_Mono/RobotoMono-Bold.ttf
Normal file
BIN
web/fonts/Roboto_Mono/RobotoMono-Bold.ttf
Normal file
Binary file not shown.
BIN
web/fonts/Roboto_Mono/RobotoMono-Light.ttf
Normal file
BIN
web/fonts/Roboto_Mono/RobotoMono-Light.ttf
Normal file
Binary file not shown.
BIN
web/fonts/Roboto_Mono/RobotoMono-Medium.ttf
Normal file
BIN
web/fonts/Roboto_Mono/RobotoMono-Medium.ttf
Normal file
Binary file not shown.
BIN
web/fonts/Roboto_Mono/RobotoMono-Regular.ttf
Normal file
BIN
web/fonts/Roboto_Mono/RobotoMono-Regular.ttf
Normal file
Binary file not shown.
@@ -1,96 +0,0 @@
|
||||
-------------------------------
|
||||
UBUNTU FONT LICENCE Version 1.0
|
||||
-------------------------------
|
||||
|
||||
PREAMBLE
|
||||
This licence allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely. The fonts, including any derivative works, can be
|
||||
bundled, embedded, and redistributed provided the terms of this licence
|
||||
are met. The fonts and derivatives, however, cannot be released under
|
||||
any other licence. The requirement for fonts to remain under this
|
||||
licence does not require any document created using the fonts or their
|
||||
derivatives to be published under this licence, as long as the primary
|
||||
purpose of the document is not to be a vehicle for the distribution of
|
||||
the fonts.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this licence and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as received under this licence.
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to
|
||||
a new environment.
|
||||
|
||||
"Copyright Holder(s)" refers to all individuals and companies who have a
|
||||
copyright ownership of the Font Software.
|
||||
|
||||
"Substantially Changed" refers to Modified Versions which can be easily
|
||||
identified as dissimilar to the Font Software by users of the Font
|
||||
Software comparing the Original Version with the Modified Version.
|
||||
|
||||
To "Propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification and with or without charging
|
||||
a redistribution fee), making available to the public, and in some
|
||||
countries other activities as well.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
This licence does not grant any rights under trademark law and all such
|
||||
rights are reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to propagate the Font Software, subject to
|
||||
the below conditions:
|
||||
|
||||
1) Each copy of the Font Software must contain the above copyright
|
||||
notice and this licence. These can be included either as stand-alone
|
||||
text files, human-readable headers or in the appropriate machine-
|
||||
readable metadata fields within text or binary files as long as those
|
||||
fields can be easily viewed by the user.
|
||||
|
||||
2) The font name complies with the following:
|
||||
(a) The Original Version must retain its name, unmodified.
|
||||
(b) Modified Versions which are Substantially Changed must be renamed to
|
||||
avoid use of the name of the Original Version or similar names entirely.
|
||||
(c) Modified Versions which are not Substantially Changed must be
|
||||
renamed to both (i) retain the name of the Original Version and (ii) add
|
||||
additional naming elements to distinguish the Modified Version from the
|
||||
Original Version. The name of such Modified Versions must be the name of
|
||||
the Original Version, with "derivative X" where X represents the name of
|
||||
the new work, appended to that name.
|
||||
|
||||
3) The name(s) of the Copyright Holder(s) and any contributor to the
|
||||
Font Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except (i) as required by this licence, (ii) to
|
||||
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
|
||||
their explicit written permission.
|
||||
|
||||
4) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this licence, and must not be distributed
|
||||
under any other licence. The requirement for fonts to remain under this
|
||||
licence does not affect any document created using the Font Software,
|
||||
except any version of the Font Software extracted from a document
|
||||
created using the Font Software may only be distributed under this
|
||||
licence.
|
||||
|
||||
TERMINATION
|
||||
This licence becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
169
web/index.html
169
web/index.html
@@ -41,12 +41,16 @@
|
||||
<!-- Конфигурация SW -->
|
||||
<script src="/sw.js"></script>
|
||||
|
||||
<!-- Кастомні елементи -->
|
||||
<script src="/lib/customElements/notification.js" defer></script>
|
||||
<script src="/lib/customElements/territoryCard.js" defer></script>
|
||||
|
||||
<link rel="stylesheet" href="/css/main.css" />
|
||||
|
||||
<!-- Кастомні елементи -->
|
||||
<script src="/lib/customElements/notifManager.js" defer></script>
|
||||
<script src="/lib/customElements/pwaInstallBanner.js" defer></script>
|
||||
<script src="/lib/customElements/territoryCard.js" defer></script>
|
||||
<script src="/lib/customElements/swipeUpdater.js" defer></script>
|
||||
<script src="/lib/customElements/menuContainer.js" defer></script>
|
||||
<script src="/lib/customElements/smartSelect.js" defer></script>
|
||||
|
||||
<script src="/config.js" defer></script>
|
||||
|
||||
<script src="/lib/router/router.js" defer></script>
|
||||
@@ -63,12 +67,11 @@
|
||||
|
||||
<script src="/lib/components/cloud.js" defer></script>
|
||||
|
||||
<script src="/lib/components/metrics.js" defer></script>
|
||||
<!-- <script src="/lib/components/metrics.js" defer></script> -->
|
||||
|
||||
<script src="/lib/components/clipboard.js" defer></script>
|
||||
<script src="/lib/components/colorGroup.js" defer></script>
|
||||
<script src="/lib/components/makeid.js" defer></script>
|
||||
<script src="/lib/components/swipeUpdater.js" defer></script>
|
||||
<script src="/lib/components/detectBrowser.js" defer></script>
|
||||
<script src="/lib/components/detectOS.js" defer></script>
|
||||
<script src="/lib/components/formattedDate.js" defer></script>
|
||||
@@ -130,148 +133,28 @@
|
||||
</head>
|
||||
<body>
|
||||
<!-- Банер з прохання встановлення PWA -->
|
||||
<div id="blur-backdrop" class="pwa-hidden"></div>
|
||||
<div id="pwa-install-overlay" class="pwa-overlay pwa-hidden">
|
||||
<div class="popup">
|
||||
<h2>Встановити застосунок?</h2>
|
||||
<p>Додайте його на головний екран для швидкого доступу.</p>
|
||||
<div>
|
||||
<button id="pwa-install-button">Встановити</button>
|
||||
<button id="pwa-close-button">Пізніше</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pwa-ios-overlay" class="pwa-overlay pwa-hidden">
|
||||
<div class="popup">
|
||||
<h2>Встановлення застосунку</h2>
|
||||
<p>Щоб встановити застосунок, виконайте наступні кроки:</p>
|
||||
<pwa-install-banner></pwa-install-banner>
|
||||
|
||||
<ol>
|
||||
<li>1. Відкрийте посилання в браузері Safari.</li>
|
||||
|
||||
<li>
|
||||
2. Натисніть кнопку
|
||||
<span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
|
||||
<path
|
||||
d="M 14.984375 1 A 1.0001 1.0001 0 0 0 14.292969 1.2929688 L 10.292969 5.2929688 A 1.0001 1.0001 0 1 0 11.707031 6.7070312 L 14 4.4140625 L 14 17 A 1.0001 1.0001 0 1 0 16 17 L 16 4.4140625 L 18.292969 6.7070312 A 1.0001 1.0001 0 1 0 19.707031 5.2929688 L 15.707031 1.2929688 A 1.0001 1.0001 0 0 0 14.984375 1 z M 9 9 C 7.3550302 9 6 10.35503 6 12 L 6 24 C 6 25.64497 7.3550302 27 9 27 L 21 27 C 22.64497 27 24 25.64497 24 24 L 24 12 C 24 10.35503 22.64497 9 21 9 L 19 9 L 19 11 L 21 11 C 21.56503 11 22 11.43497 22 12 L 22 24 C 22 24.56503 21.56503 25 21 25 L 9 25 C 8.4349698 25 8 24.56503 8 24 L 8 12 C 8 11.43497 8.4349698 11 9 11 L 11 11 L 11 9 L 9 9 z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
в нижній частині екрана Safari.
|
||||
</li>
|
||||
<li>
|
||||
3. У меню, що з’явиться, виберіть
|
||||
<span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M 6 3 C 4.3550302 3 3 4.3550302 3 6 L 3 18 C 3 19.64497 4.3550302 21 6 21 L 18 21 C 19.64497 21 21 19.64497 21 18 L 21 6 C 21 4.3550302 19.64497 3 18 3 L 6 3 z M 6 5 L 18 5 C 18.56503 5 19 5.4349698 19 6 L 19 18 C 19 18.56503 18.56503 19 18 19 L 6 19 C 5.4349698 19 5 18.56503 5 18 L 5 6 C 5 5.4349698 5.4349698 5 6 5 z M 11.984375 6.9863281 A 1.0001 1.0001 0 0 0 11 8 L 11 11 L 8 11 A 1.0001 1.0001 0 1 0 8 13 L 11 13 L 11 16 A 1.0001 1.0001 0 1 0 13 16 L 13 13 L 16 13 A 1.0001 1.0001 0 1 0 16 11 L 13 11 L 13 8 A 1.0001 1.0001 0 0 0 11.984375 6.9863281 z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
«На Початковий екран».
|
||||
</li>
|
||||
</ol>
|
||||
<div>
|
||||
<button id="pwa-ios-close-button">Зрозуміло</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-notification-container
|
||||
id="notif-manager"
|
||||
position="top-right"
|
||||
max-visible="5"
|
||||
timeout="4000">
|
||||
</app-notification-container>
|
||||
<notification-container
|
||||
id="notif-manager"
|
||||
position="top-right"
|
||||
max-visible="5"
|
||||
timeout="4000"
|
||||
>
|
||||
</notification-container>
|
||||
|
||||
<!-- Анімація оновлення сторінки свайпом -->
|
||||
<div id="swipe_updater">
|
||||
<div id="swipe_block">
|
||||
<svg
|
||||
id="swipe_icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 448 512"
|
||||
data-state="active"
|
||||
>
|
||||
<path
|
||||
d="M413.1 222.5l22.2 22.2c9.4 9.4 9.4 24.6 0 33.9L241 473c-9.4 9.4-24.6 9.4-33.9 0L12.7 278.6c-9.4-9.4-9.4-24.6 0-33.9l22.2-22.2c9.5-9.5 25-9.3 34.3.4L184 343.4V56c0-13.3 10.7-24 24-24h32c13.3 0 24 10.7 24 24v287.4l114.8-120.5c9.3-9.8 24.8-10 34.3-.4z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<swipe-updater></swipe-updater>
|
||||
|
||||
<!-- Меню застосунку -->
|
||||
<div id="navigation">
|
||||
<nav>
|
||||
<li>
|
||||
<div id="nav-home">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M9 2H4C2.897 2 2 2.897 2 4v7c0 1.103.897 2 2 2h5c1.103 0 2-.897 2-2V4C11 2.897 10.103 2 9 2zM20 2h-5c-1.103 0-2 .897-2 2v3c0 1.103.897 2 2 2h5c1.103 0 2-.897 2-2V4C22 2.897 21.103 2 20 2zM9 15H4c-1.103 0-2 .897-2 2v3c0 1.103.897 2 2 2h5c1.103 0 2-.897 2-2v-3C11 15.897 10.103 15 9 15zM20 11h-5c-1.103 0-2 .897-2 2v7c0 1.103.897 2 2 2h5c1.103 0 2-.897 2-2v-7C22 11.897 21.103 11 20 11z"
|
||||
/>
|
||||
</svg>
|
||||
<b>Головна</b>
|
||||
</div>
|
||||
<a href="/" data-route></a>
|
||||
</li>
|
||||
<li id="li-territory" style="display: none">
|
||||
<div id="nav-territory">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
|
||||
<path
|
||||
d="M24 2H14c-.55 0-1 .45-1 1v4l3.6 2.7c.25.19.4.49.4.8V14h8V3C25 2.45 24.55 2 24 2zM15.5 7C15.22 7 15 6.78 15 6.5v-2C15 4.22 15.22 4 15.5 4h2C17.78 4 18 4.22 18 4.5v2C18 6.78 17.78 7 17.5 7h-1.17H15.5zM23 4.5v2C23 6.78 22.78 7 22.5 7h-2C20.22 7 20 6.78 20 6.5v-2C20 4.22 20.22 4 20.5 4h2C22.78 4 23 4.22 23 4.5zM22.5 12h-2c-.28 0-.5-.22-.5-.5v-2C20 9.22 20.22 9 20.5 9h2C22.78 9 23 9.22 23 9.5v2C23 11.78 22.78 12 22.5 12zM1 11.51V27c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V11.51c0-.32-.16-.62-.42-.81l-6-4.28C8.41 6.29 8.2 6.23 8 6.23S7.59 6.29 7.42 6.42l-6 4.28C1.16 10.89 1 11.19 1 11.51zM6.5 20h-2C4.22 20 4 19.78 4 19.5v-2C4 17.22 4.22 17 4.5 17h2C6.78 17 7 17.22 7 17.5v2C7 19.78 6.78 20 6.5 20zM7 22.5v2C7 24.78 6.78 25 6.5 25h-2C4.22 25 4 24.78 4 24.5v-2C4 22.22 4.22 22 4.5 22h2C6.78 22 7 22.22 7 22.5zM6.5 15h-2C4.22 15 4 14.78 4 14.5v-2C4 12.22 4.22 12 4.5 12h2C6.78 12 7 12.22 7 12.5v2C7 14.78 6.78 15 6.5 15zM9.5 17h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2C9.22 20 9 19.78 9 19.5v-2C9 17.22 9.22 17 9.5 17zM9 14.5v-2C9 12.22 9.22 12 9.5 12h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2C9.22 15 9 14.78 9 14.5zM9.5 22h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2C9.22 25 9 24.78 9 24.5v-2C9 22.22 9.22 22 9.5 22zM17 17v10c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V17c0-.55-.45-1-1-1H18C17.45 16 17 16.45 17 17zM19.5 18h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2c-.28 0-.5-.22-.5-.5v-2C19 18.22 19.22 18 19.5 18zM27 18.5v2c0 .28-.22.5-.5.5h-2c-.28 0-.5-.22-.5-.5v-2c0-.28.22-.5.5-.5h2C26.78 18 27 18.22 27 18.5zM26.5 26h-2c-.28 0-.5-.22-.5-.5v-2c0-.28.22-.5.5-.5h2c.28 0 .5.22.5.5v2C27 25.78 26.78 26 26.5 26zM19.5 23h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2c-.28 0-.5-.22-.5-.5v-2C19 23.22 19.22 23 19.5 23z"
|
||||
/>
|
||||
</svg>
|
||||
<b>Території</b>
|
||||
</div>
|
||||
<a href="/territory" data-route></a>
|
||||
</li>
|
||||
<li id="li-sheeps" style="display: none">
|
||||
<div id="nav-sheeps">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M 42.5 14 C 37.813 14 34 18.038 34 23 C 34 27.962 37.813 32 42.5 32 C 47.187 32 51 27.962 51 23 C 51 18.038 47.187 14 42.5 14 z M 21.5 17 C 16.813 17 13 21.038 13 26 C 13 30.962 16.813 35 21.5 35 C 26.187 35 30 30.962 30 26 C 30 21.038 26.187 17 21.5 17 z M 42.5 18 C 44.981 18 47 20.243 47 23 C 47 25.757 44.981 28 42.5 28 C 40.019 28 38 25.757 38 23 C 38 20.243 40.019 18 42.5 18 z M 42.498047 34.136719 C 37.579021 34.136719 33.07724 35.947963 30.054688 38.962891 C 27.67058 37.796576 24.915421 37.136719 22 37.136719 C 14.956 37.136719 8.8129375 40.942422 6.7109375 46.607422 C 5.7409375 49.220422 7.7121406 52 10.494141 52 L 33.505859 52 C 35.43112 52 36.95694 50.674804 37.404297 49 L 53.431641 49 C 56.437641 49 59.121453 45.844281 57.564453 42.613281 C 55.084453 37.463281 49.169047 34.136719 42.498047 34.136719 z M 42.5 38.136719 C 47.565 38.136719 52.171937 40.633609 53.960938 44.349609 C 54.119938 44.687609 53.741687 45 53.429688 45 L 36.544922 45 C 35.777257 43.585465 34.746773 42.317451 33.503906 41.234375 C 35.78496 39.306575 39.034912 38.136719 42.5 38.136719 z"
|
||||
/>
|
||||
</svg>
|
||||
<b>Вісники</b>
|
||||
</div>
|
||||
<a href="/sheeps" data-route></a>
|
||||
</li>
|
||||
<li id="li-schedule" style="display: none">
|
||||
<div id="nav-schedule">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M47 23c3.314 0 6 2.686 6 6v17c0 3.309-2.691 6-6 6H17c-3.309 0-6-2.691-6-6V29c0-3.314 2.686-6 6-6H47zM22 45v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C21.552 46 22 45.552 22 45zM22 38v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C21.552 39 22 38.552 22 38zM30 45v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C29.552 46 30 45.552 30 45zM30 38v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C29.552 39 30 38.552 30 38zM30 31v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C29.552 32 30 31.552 30 31zM38 45v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C37.552 46 38 45.552 38 45zM38 38v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C37.552 39 38 38.552 38 38zM38 31v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C37.552 32 38 31.552 38 31zM46 38v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C45.552 39 46 38.552 46 38zM46 31v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C45.552 32 46 31.552 46 31zM17 20c-2.308 0-4.407.876-6 2.305V18c0-3.309 2.691-6 6-6h30c3.309 0 6 2.691 6 6v4.305C51.407 20.876 49.308 20 47 20H17z"
|
||||
/>
|
||||
</svg>
|
||||
<b>Графік зібрань</b>
|
||||
</div>
|
||||
<a href="/schedule" data-route></a>
|
||||
</li>
|
||||
<li id="li-stand" style="display: none">
|
||||
<div id="nav-stand">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
|
||||
<path
|
||||
d="M 6.9707031 4 C 6.8307031 4 6.6807813 4.039375 6.5507812 4.109375 L 2.5507812 6.109375 C 2.0607813 6.349375 1.859375 6.9492188 2.109375 7.4492188 C 2.349375 7.9392188 2.9492187 8.140625 3.4492188 7.890625 L 6.4902344 6.3691406 L 12.5 20.650391 C 12.73 21.180391 13.040156 21.650547 13.410156 22.060547 C 12.040156 22.340547 11 23.56 11 25 C 11 26.65 12.35 28 14 28 C 15.65 28 17 26.65 17 25 C 17 24.52 16.869922 24.070156 16.669922 23.660156 C 17.479922 23.740156 18.319141 23.639062 19.119141 23.289062 L 26.400391 20.099609 C 26.910391 19.889609 27.159219 19.310781 26.949219 18.800781 C 26.749219 18.290781 26.160391 18.040234 25.650391 18.240234 C 25.630391 18.250234 25.619609 18.259531 25.599609 18.269531 L 18.320312 21.460938 C 16.770312 22.130938 14.999609 21.429141 14.349609 19.869141 L 7.9199219 4.609375 C 7.7599219 4.229375 7.3807031 3.99 6.9707031 4 z M 21.359375 8.0605469 C 21.229375 8.0605469 21.100703 8.090625 20.970703 8.140625 L 13.609375 11.269531 C 13.099375 11.479531 12.860078 12.070078 13.080078 12.580078 L 16.029297 19.179688 C 16.249297 19.689688 16.829844 19.930937 17.339844 19.710938 L 24.710938 16.589844 C 25.210938 16.369844 25.450234 15.789297 25.240234 15.279297 L 22.279297 8.6699219 C 22.119297 8.2899219 21.749375 8.0605469 21.359375 8.0605469 z M 14 24 C 14.56 24 15 24.44 15 25 C 15 25.56 14.56 26 14 26 C 13.44 26 13 25.56 13 25 C 13 24.44 13.44 24 14 24 z"
|
||||
/>
|
||||
</svg>
|
||||
<b>Графік стенду</b>
|
||||
</div>
|
||||
<a href="/stand" data-route></a>
|
||||
</li>
|
||||
<li id="li-options" style="display: none">
|
||||
<div id="nav-options">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 172 172">
|
||||
<path
|
||||
d="M75.18001,14.33333c-3.43283,0 -6.36736,2.42659 -7.02669,5.79492l-2.39355,12.28971c-5.8821,2.22427 -11.32102,5.33176 -16.097,9.25228l-11.78581,-4.05924c-3.2465,-1.118 -6.81841,0.22441 -8.53841,3.19141l-10.80599,18.72852c-1.71283,2.97417 -1.08945,6.74999 1.49772,9.00033l9.44824,8.21647c-0.49137,3.0197 -0.81185,6.09382 -0.81185,9.25228c0,3.15846 0.32048,6.23258 0.81185,9.25228l-9.44824,8.21647c-2.58717,2.25033 -3.21055,6.02616 -1.49772,9.00032l10.80599,18.72852c1.71283,2.97417 5.29191,4.31623 8.53841,3.2054l11.78581,-4.05924c4.77441,3.91806 10.21756,7.01501 16.097,9.23828l2.39355,12.28972c0.65933,3.36833 3.59386,5.79492 7.02669,5.79492h21.63998c3.43283,0 6.36735,-2.42659 7.02669,-5.79492l2.39356,-12.28972c5.88211,-2.22427 11.32102,-5.33176 16.097,-9.25227l11.78581,4.05924c3.2465,1.118 6.81841,-0.21724 8.53841,-3.1914l10.80599,-18.74252c1.71284,-2.97417 1.08945,-6.73599 -1.49772,-8.98633l-9.44824,-8.21647c0.49137,-3.0197 0.81185,-6.09382 0.81185,-9.25228c0,-3.15846 -0.32048,-6.23258 -0.81185,-9.25228l9.44824,-8.21647c2.58717,-2.25033 3.21056,-6.02616 1.49772,-9.00033l-10.80599,-18.72852c-1.71283,-2.97417 -5.29191,-4.31624 -8.53841,-3.2054l-11.78581,4.05924c-4.7744,-3.91806 -10.21755,-7.01501 -16.097,-9.23828l-2.39356,-12.28971c-0.65933,-3.36833 -3.59385,-5.79492 -7.02669,-5.79492zM86,57.33333c15.83117,0 28.66667,12.8355 28.66667,28.66667c0,15.83117 -12.8355,28.66667 -28.66667,28.66667c-15.83117,0 -28.66667,-12.8355 -28.66667,-28.66667c0,-15.83117 12.8355,-28.66667 28.66667,-28.66667z"
|
||||
></path>
|
||||
</svg>
|
||||
<b>Опції</b>
|
||||
</div>
|
||||
<a href="/options" data-route></a>
|
||||
</li>
|
||||
</nav>
|
||||
</div>
|
||||
<navigation-container id="main-nav" data-os="ios">
|
||||
<nav-item
|
||||
id="menu-home"
|
||||
title="Головна"
|
||||
icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <path d="M9 2H4C2.897 2 2 2.897 2 4v7c0 1.103.897 2 2 2h5c1.103 0 2-.897 2-2V4C11 2.897 10.103 2 9 2zM20 2h-5c-1.103 0-2 .897-2 2v3c0 1.103.897 2 2 2h5c1.103 0 2-.897 2-2V4C22 2.897 21.103 2 20 2zM9 15H4c-1.103 0-2 .897-2 2v3c0 1.103.897 2 2 2h5c1.103 0 2-.897 2-2v-3C11 15.897 10.103 15 9 15zM20 11h-5c-1.103 0-2 .897-2 2v7c0 1.103.897 2 2 2h5c1.103 0 2-.897 2-2v-7C22 11.897 21.103 11 20 11z" /> </svg>'
|
||||
href="/"
|
||||
></nav-item>
|
||||
</navigation-container>
|
||||
|
||||
<!-- Блок контенту застосунка -->
|
||||
<div id="app"></div>
|
||||
|
||||
134
web/lib/app.js
134
web/lib/app.js
@@ -4,6 +4,7 @@ let swRegistration = null;
|
||||
|
||||
// Реєструємо CustomElements
|
||||
const Notifier = document.getElementById('notif-manager');
|
||||
const Updater = document.querySelector('swipe-updater');
|
||||
|
||||
// Определение ID главного блока
|
||||
let app = document.getElementById('app');
|
||||
@@ -13,32 +14,23 @@ Router.config({ mode: 'history' });
|
||||
|
||||
async function appReload() {
|
||||
|
||||
location.reload();
|
||||
// location.reload();
|
||||
|
||||
// Router.navigate(window.location.pathname, false).check();
|
||||
// Router.check().listen().delegateLinks();
|
||||
|
||||
// // Закрытие старого соединения WebSocket
|
||||
// if (socket) socket.close(1000, "Перезапуск соединения");
|
||||
// if (Cloud.socket) Cloud.socket.close(1000, "Перезапуск з'єднання");
|
||||
// Cloud.start();
|
||||
// listEntrances = []
|
||||
// listApartment = []
|
||||
}
|
||||
|
||||
// Перевизначення функції, яка викликається при "свайпі"
|
||||
// Updater.setReloadFunction(() => appReload());
|
||||
|
||||
// Функция загрузки приложения
|
||||
window.addEventListener('load', async function () {
|
||||
console.log('[OS] ', detectOS());
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
if (detectOS() == 'Android') {
|
||||
document.getElementById('navigation').dataset.state = '';
|
||||
} else if (detectOS() == 'iOS') {
|
||||
document.getElementById('navigation').dataset.state = 'ios';
|
||||
localStorage.setItem('backToTop', 'false');
|
||||
} else if (detectOS() == 'MacOS') {
|
||||
document.getElementById('navigation').dataset.state = 'ios';
|
||||
localStorage.setItem('backToTop', 'false');
|
||||
} else {
|
||||
document.getElementById('navigation').dataset.state = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (Router.getParams().uuid) {
|
||||
localStorage.setItem("uuid", Router.getParams().uuid)
|
||||
@@ -65,14 +57,47 @@ window.addEventListener('load', async function () {
|
||||
|
||||
console.log("[APP] USER Info: ", USER);
|
||||
|
||||
|
||||
if (USER.possibilities.can_view_sheeps) document.getElementById("li-sheeps").style.display = "";
|
||||
if (USER.possibilities.can_view_schedule) document.getElementById("li-schedule").style.display = "";
|
||||
if (USER.possibilities.can_manager_territory) document.getElementById("li-territory").style.display = "";
|
||||
if (USER.possibilities.can_view_stand) document.getElementById("li-stand").style.display = "";
|
||||
document.getElementById("li-options").style.display = "";
|
||||
|
||||
if (USER.possibilities.can_view_sheeps) await Sheeps.sheeps_list.loadAPI();
|
||||
if (USER.possibilities.can_view_stand) {
|
||||
newMenuItems({
|
||||
id: 'menu-stand',
|
||||
title: 'Графік стенду',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path d="M 6.9707031 4 C 6.8307031 4 6.6807813 4.039375 6.5507812 4.109375 L 2.5507812 6.109375 C 2.0607813 6.349375 1.859375 6.9492188 2.109375 7.4492188 C 2.349375 7.9392188 2.9492187 8.140625 3.4492188 7.890625 L 6.4902344 6.3691406 L 12.5 20.650391 C 12.73 21.180391 13.040156 21.650547 13.410156 22.060547 C 12.040156 22.340547 11 23.56 11 25 C 11 26.65 12.35 28 14 28 C 15.65 28 17 26.65 17 25 C 17 24.52 16.869922 24.070156 16.669922 23.660156 C 17.479922 23.740156 18.319141 23.639062 19.119141 23.289062 L 26.400391 20.099609 C 26.910391 19.889609 27.159219 19.310781 26.949219 18.800781 C 26.749219 18.290781 26.160391 18.040234 25.650391 18.240234 C 25.630391 18.250234 25.619609 18.259531 25.599609 18.269531 L 18.320312 21.460938 C 16.770312 22.130938 14.999609 21.429141 14.349609 19.869141 L 7.9199219 4.609375 C 7.7599219 4.229375 7.3807031 3.99 6.9707031 4 z M 21.359375 8.0605469 C 21.229375 8.0605469 21.100703 8.090625 20.970703 8.140625 L 13.609375 11.269531 C 13.099375 11.479531 12.860078 12.070078 13.080078 12.580078 L 16.029297 19.179688 C 16.249297 19.689688 16.829844 19.930937 17.339844 19.710938 L 24.710938 16.589844 C 25.210938 16.369844 25.450234 15.789297 25.240234 15.279297 L 22.279297 8.6699219 C 22.119297 8.2899219 21.749375 8.0605469 21.359375 8.0605469 z M 14 24 C 14.56 24 15 24.44 15 25 C 15 25.56 14.56 26 14 26 C 13.44 26 13 25.56 13 25 C 13 24.44 13.44 24 14 24 z"/></svg>`,
|
||||
href: '/stand'
|
||||
});
|
||||
}
|
||||
if (USER.possibilities.can_view_schedule) {
|
||||
newMenuItems({
|
||||
id: 'menu-schedule',
|
||||
title: 'Графіки зібрань',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M47 23c3.314 0 6 2.686 6 6v17c0 3.309-2.691 6-6 6H17c-3.309 0-6-2.691-6-6V29c0-3.314 2.686-6 6-6H47zM22 45v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C21.552 46 22 45.552 22 45zM22 38v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C21.552 39 22 38.552 22 38zM30 45v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C29.552 46 30 45.552 30 45zM30 38v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C29.552 39 30 38.552 30 38zM30 31v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C29.552 32 30 31.552 30 31zM38 45v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C37.552 46 38 45.552 38 45zM38 38v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C37.552 39 38 38.552 38 38zM38 31v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C37.552 32 38 31.552 38 31zM46 38v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C45.552 39 46 38.552 46 38zM46 31v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C45.552 32 46 31.552 46 31zM17 20c-2.308 0-4.407.876-6 2.305V18c0-3.309 2.691-6 6-6h30c3.309 0 6 2.691 6 6v4.305C51.407 20.876 49.308 20 47 20H17z"/></svg>`,
|
||||
href: '/schedule'
|
||||
});
|
||||
}
|
||||
if (USER.possibilities.can_view_sheeps) {
|
||||
newMenuItems({
|
||||
id: 'menu-sheeps',
|
||||
title: 'Вісники',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M 42.5 14 C 37.813 14 34 18.038 34 23 C 34 27.962 37.813 32 42.5 32 C 47.187 32 51 27.962 51 23 C 51 18.038 47.187 14 42.5 14 z M 21.5 17 C 16.813 17 13 21.038 13 26 C 13 30.962 16.813 35 21.5 35 C 26.187 35 30 30.962 30 26 C 30 21.038 26.187 17 21.5 17 z M 42.5 18 C 44.981 18 47 20.243 47 23 C 47 25.757 44.981 28 42.5 28 C 40.019 28 38 25.757 38 23 C 38 20.243 40.019 18 42.5 18 z M 42.498047 34.136719 C 37.579021 34.136719 33.07724 35.947963 30.054688 38.962891 C 27.67058 37.796576 24.915421 37.136719 22 37.136719 C 14.956 37.136719 8.8129375 40.942422 6.7109375 46.607422 C 5.7409375 49.220422 7.7121406 52 10.494141 52 L 33.505859 52 C 35.43112 52 36.95694 50.674804 37.404297 49 L 53.431641 49 C 56.437641 49 59.121453 45.844281 57.564453 42.613281 C 55.084453 37.463281 49.169047 34.136719 42.498047 34.136719 z M 42.5 38.136719 C 47.565 38.136719 52.171937 40.633609 53.960938 44.349609 C 54.119938 44.687609 53.741687 45 53.429688 45 L 36.544922 45 C 35.777257 43.585465 34.746773 42.317451 33.503906 41.234375 C 35.78496 39.306575 39.034912 38.136719 42.5 38.136719 z" /></svg>`,
|
||||
href: '/sheeps',
|
||||
hidden: true
|
||||
});
|
||||
await Sheeps.sheeps_list.loadAPI();
|
||||
}
|
||||
if (USER.possibilities.can_manager_territory) {
|
||||
newMenuItems({
|
||||
id: 'menu-territory',
|
||||
title: 'Території',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path d="M24 2H14c-.55 0-1 .45-1 1v4l3.6 2.7c.25.19.4.49.4.8V14h8V3C25 2.45 24.55 2 24 2zM15.5 7C15.22 7 15 6.78 15 6.5v-2C15 4.22 15.22 4 15.5 4h2C17.78 4 18 4.22 18 4.5v2C18 6.78 17.78 7 17.5 7h-1.17H15.5zM23 4.5v2C23 6.78 22.78 7 22.5 7h-2C20.22 7 20 6.78 20 6.5v-2C20 4.22 20.22 4 20.5 4h2C22.78 4 23 4.22 23 4.5zM22.5 12h-2c-.28 0-.5-.22-.5-.5v-2C20 9.22 20.22 9 20.5 9h2C22.78 9 23 9.22 23 9.5v2C23 11.78 22.78 12 22.5 12zM1 11.51V27c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V11.51c0-.32-.16-.62-.42-.81l-6-4.28C8.41 6.29 8.2 6.23 8 6.23S7.59 6.29 7.42 6.42l-6 4.28C1.16 10.89 1 11.19 1 11.51zM6.5 20h-2C4.22 20 4 19.78 4 19.5v-2C4 17.22 4.22 17 4.5 17h2C6.78 17 7 17.22 7 17.5v2C7 19.78 6.78 20 6.5 20zM7 22.5v2C7 24.78 6.78 25 6.5 25h-2C4.22 25 4 24.78 4 24.5v-2C4 22.22 4.22 22 4.5 22h2C6.78 22 7 22.22 7 22.5zM6.5 15h-2C4.22 15 4 14.78 4 14.5v-2C4 12.22 4.22 12 4.5 12h2C6.78 12 7 12.22 7 12.5v2C7 14.78 6.78 15 6.5 15zM9.5 17h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2C9.22 20 9 19.78 9 19.5v-2C9 17.22 9.22 17 9.5 17zM9 14.5v-2C9 12.22 9.22 12 9.5 12h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2C9.22 15 9 14.78 9 14.5zM9.5 22h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2C9.22 25 9 24.78 9 24.5v-2C9 22.22 9.22 22 9.5 22zM17 17v10c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V17c0-.55-.45-1-1-1H18C17.45 16 17 16.45 17 17zM19.5 18h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2c-.28 0-.5-.22-.5-.5v-2C19 18.22 19.22 18 19.5 18zM27 18.5v2c0 .28-.22.5-.5.5h-2c-.28 0-.5-.22-.5-.5v-2c0-.28.22-.5.5-.5h2C26.78 18 27 18.22 27 18.5zM26.5 26h-2c-.28 0-.5-.22-.5-.5v-2c0-.28.22-.5.5-.5h2c.28 0 .5.22.5.5v2C27 25.78 26.78 26 26.5 26zM19.5 23h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2c-.28 0-.5-.22-.5-.5v-2C19 23.22 19.22 23 19.5 23z"/></svg>`,
|
||||
href: '/territory',
|
||||
hidden: true
|
||||
});
|
||||
}
|
||||
newMenuItems({
|
||||
id: 'menu-options',
|
||||
title: 'Опції',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 172 172"><path d="M75.18001,14.33333c-3.43283,0 -6.36736,2.42659 -7.02669,5.79492l-2.39355,12.28971c-5.8821,2.22427 -11.32102,5.33176 -16.097,9.25228l-11.78581,-4.05924c-3.2465,-1.118 -6.81841,0.22441 -8.53841,3.19141l-10.80599,18.72852c-1.71283,2.97417 -1.08945,6.74999 1.49772,9.00033l9.44824,8.21647c-0.49137,3.0197 -0.81185,6.09382 -0.81185,9.25228c0,3.15846 0.32048,6.23258 0.81185,9.25228l-9.44824,8.21647c-2.58717,2.25033 -3.21055,6.02616 -1.49772,9.00032l10.80599,18.72852c1.71283,2.97417 5.29191,4.31623 8.53841,3.2054l11.78581,-4.05924c4.77441,3.91806 10.21756,7.01501 16.097,9.23828l2.39355,12.28972c0.65933,3.36833 3.59386,5.79492 7.02669,5.79492h21.63998c3.43283,0 6.36735,-2.42659 7.02669,-5.79492l2.39356,-12.28972c5.88211,-2.22427 11.32102,-5.33176 16.097,-9.25227l11.78581,4.05924c3.2465,1.118 6.81841,-0.21724 8.53841,-3.1914l10.80599,-18.74252c1.71284,-2.97417 1.08945,-6.73599 -1.49772,-8.98633l-9.44824,-8.21647c0.49137,-3.0197 0.81185,-6.09382 0.81185,-9.25228c0,-3.15846 -0.32048,-6.23258 -0.81185,-9.25228l9.44824,-8.21647c2.58717,-2.25033 3.21056,-6.02616 1.49772,-9.00033l-10.80599,-18.72852c-1.71283,-2.97417 -5.29191,-4.31624 -8.53841,-3.2054l-11.78581,4.05924c-4.7744,-3.91806 -10.21755,-7.01501 -16.097,-9.23828l-2.39356,-12.28971c-0.65933,-3.36833 -3.59385,-5.79492 -7.02669,-5.79492zM86,57.33333c15.83117,0 28.66667,12.8355 28.66667,28.66667c0,15.83117 -12.8355,28.66667 -28.66667,28.66667c-15.83117,0 -28.66667,-12.8355 -28.66667,-28.66667c0,-15.83117 12.8355,-28.66667 28.66667,-28.66667z"/></svg>`,
|
||||
href: '/options'
|
||||
});
|
||||
|
||||
if (Cloud.socket) Cloud.socket.close(1000, "Перезапуск з'єднання");
|
||||
Cloud.start();
|
||||
@@ -80,11 +105,19 @@ window.addEventListener('load', async function () {
|
||||
editFontStyle();
|
||||
|
||||
Router.check().listen().delegateLinks();
|
||||
|
||||
setupFrontendMetrics();
|
||||
}
|
||||
});
|
||||
|
||||
function newMenuItems({ id, title, icon, href, hidden=false }) {
|
||||
const newItem = document.createElement('nav-item');
|
||||
newItem.setAttribute('id', id);
|
||||
newItem.setAttribute('title', title);
|
||||
newItem.setAttribute('icon', icon);
|
||||
newItem.setAttribute('href', href);
|
||||
newItem.setAttribute('data-hidden', hidden);
|
||||
document.querySelector('navigation-container').appendChild(newItem);
|
||||
}
|
||||
|
||||
let offlineNode = null;
|
||||
window.addEventListener("offline", () => {
|
||||
console.log("[APP] Інтернет зник");
|
||||
@@ -104,9 +137,6 @@ window.addEventListener("online", () => {
|
||||
title: 'Онлайн',
|
||||
text: 'Інтернет знову працює'
|
||||
}, { timeout: 3000 });
|
||||
|
||||
if (Cloud.socket) Cloud.socket.close(1000, "Перезапуск з'єднання");
|
||||
Cloud.start();
|
||||
});
|
||||
|
||||
function editFontStyle() {
|
||||
@@ -154,52 +184,6 @@ function applyFontMode(mode) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Банер з прохання встановлення PWA
|
||||
let deferredPrompt;
|
||||
const isInStandaloneMode = () =>
|
||||
('standalone' in window.navigator && window.navigator.standalone === true);
|
||||
|
||||
if (detectOS() == 'iOS' && !isInStandaloneMode()) {
|
||||
setTimeout(() => {
|
||||
document.getElementById('blur-backdrop').classList.remove('pwa-hidden');
|
||||
document.getElementById('pwa-ios-overlay').classList.remove('pwa-hidden');
|
||||
document.body.classList.add('modal-open');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
if (localStorage.getItem('modal') != "false") {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
|
||||
document.getElementById("blur-backdrop").classList.remove("pwa-hidden");
|
||||
document.getElementById("pwa-install-overlay").classList.remove("pwa-hidden");
|
||||
document.body.classList.add("modal-open");
|
||||
}
|
||||
});
|
||||
document.getElementById("pwa-install-button").addEventListener("click", async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
console.log(`[APP] Результат встановлення PWA: ${outcome}`);
|
||||
|
||||
closePopup();
|
||||
});
|
||||
document.getElementById("pwa-close-button").addEventListener("click", closePopup);
|
||||
document.getElementById('pwa-ios-close-button').addEventListener('click', closePopup);
|
||||
|
||||
function closePopup() {
|
||||
document.getElementById("pwa-install-overlay").classList.add("pwa-hidden");
|
||||
document.getElementById("blur-backdrop").classList.add("pwa-hidden");
|
||||
document.getElementById('pwa-ios-overlay').classList.add('pwa-hidden');
|
||||
document.body.classList.remove("modal-open");
|
||||
deferredPrompt = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
let refreshing = false;
|
||||
let updateNode = null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
clipboard = (text) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => alert("Посилання скопійовано!"))
|
||||
.then(() => Notifier.success("Посилання скопійовано!", {timeout: 2000}))
|
||||
.catch(err => console.error(err))
|
||||
}
|
||||
@@ -9,8 +9,6 @@ const Cloud = {
|
||||
Cloud.status = 'sync';
|
||||
const uuid = localStorage.getItem("uuid");
|
||||
|
||||
if(!navigator.onLine) alert("[APP] Інтернет з'єднання відсутнє!")
|
||||
|
||||
if (Cloud.socket && Cloud.socket.readyState <= 1) return;
|
||||
|
||||
const ws = new WebSocket(CONFIG.wss, uuid);
|
||||
@@ -79,13 +77,18 @@ const Cloud = {
|
||||
} else {
|
||||
Cloud.reconnecting = false;
|
||||
|
||||
if (confirm("З'єднання розірвано! Перепідключитись?")) {
|
||||
Cloud.reconnecting = true;
|
||||
Cloud.reconnectAttempts = 0;
|
||||
Cloud.start();
|
||||
} else {
|
||||
console.warn("[WebSocket] Перепідключення відмінено користувачем");
|
||||
}
|
||||
Notifier.click({
|
||||
title: `З'єднання розірвано!`,
|
||||
text: `Натисніть, щоб перепідключитись!`
|
||||
}, {
|
||||
type: 'warn',
|
||||
f: () => {
|
||||
Cloud.reconnecting = true;
|
||||
Cloud.reconnectAttempts = 0;
|
||||
Cloud.start();
|
||||
},
|
||||
timeout: 0
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ const RECONNECT_INTERVAL = 3000;
|
||||
let isConnectedMetrics = false;
|
||||
|
||||
function setupFrontendMetrics() {
|
||||
console.log("[Metrics] Спроба підключення до метрик...");
|
||||
mws = new WebSocket(CONFIG.metrics);
|
||||
|
||||
mws.onopen = () => {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// Скрипт перезагрузки страници свайпом.
|
||||
let app_scroll = false;
|
||||
let animID = document.getElementById('swipe_updater');
|
||||
let animIconID = document.getElementById('swipe_icon');
|
||||
|
||||
|
||||
window.addEventListener('scroll', function(e) {
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
let a = window.scrollY;
|
||||
let b = 50;
|
||||
let c = 125;
|
||||
a = -a;
|
||||
a = a;
|
||||
|
||||
animIconID.style.top = a/1.5;
|
||||
|
||||
console.log(window.scrollY);
|
||||
|
||||
if(window.scrollY <= -10){
|
||||
animID.style.zIndex = 115;
|
||||
} else {
|
||||
animID.style.zIndex = 0;
|
||||
}
|
||||
|
||||
if(window.scrollY <= -120){
|
||||
if(app_scroll == false){
|
||||
app_scroll = true;
|
||||
animIconID.style.transform = 'rotate(180deg)';
|
||||
animIconID.setAttribute('data-state', '')
|
||||
}
|
||||
} else if(window.scrollY >= 0){
|
||||
if(app_scroll == true){
|
||||
appReload();
|
||||
app_scroll = false;
|
||||
animIconID.style.transform = 'rotate(0deg)';
|
||||
animIconID.setAttribute('data-state', 'active')
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
511
web/lib/customElements/menuContainer.js
Normal file
511
web/lib/customElements/menuContainer.js
Normal file
@@ -0,0 +1,511 @@
|
||||
// =========================================================
|
||||
// Клас 1: Пункт Меню (nav-item)
|
||||
// =========================================================
|
||||
|
||||
class NavigationItem extends HTMLElement {
|
||||
|
||||
// 1. Відстежувані атрибути
|
||||
static get observedAttributes() {
|
||||
return ['title', 'icon', 'href', 'click'];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
|
||||
// Створюємо базову структуру елементів
|
||||
this.renderBaseStructure();
|
||||
}
|
||||
|
||||
/**
|
||||
* Створює базовий DOM та стилі.
|
||||
*/
|
||||
renderBaseStructure() {
|
||||
const shadow = this.shadowRoot;
|
||||
shadow.innerHTML = '';
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.item-wrapper{
|
||||
width: 184px;
|
||||
height: 54px;
|
||||
list-style-type: none;
|
||||
position: relative;
|
||||
-webkit-transition: width .2s ease 0s;
|
||||
-o-transition: width .2s ease 0s;
|
||||
transition: width .2s ease 0s;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
border-radius: var(--border-radius, 15px);
|
||||
-webkit-transition: width .2s ease 0s;
|
||||
-o-transition: width .2s ease 0s;
|
||||
transition: width .2s ease 0s;
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
border: 2px;
|
||||
border: 2px solid var(--ColorThemes2, #525151);
|
||||
color: var(--ColorThemes3, #f3f3f3);
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.nav-icon-img, .nav-icon-wrapper {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav-icon-wrapper svg {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
min-width: 25px;
|
||||
min-height: 25px;
|
||||
fill: currentColor;
|
||||
}
|
||||
.nav-title {
|
||||
font-size: var(--FontSize3, 14px);
|
||||
font-weight: 300;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
:host([data-state="active"]) .nav-item {
|
||||
color: var(--ColorThemes2, #525151);
|
||||
background: var(--ColorThemes3, #f3f3f3);
|
||||
border: 2px solid var(--ColorThemes3, #f3f3f3);
|
||||
box-shadow: var(--shadow-l1, 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 0px 2px rgba(0, 0, 0, 0.06), 0px 0px 1px rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.nav-item:hover {
|
||||
border: 2px solid var(--ColorThemes3);
|
||||
}
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.item-wrapper{
|
||||
width: 54px;
|
||||
}
|
||||
.nav-item{
|
||||
width: 50px;
|
||||
}
|
||||
.nav-title {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px), (max-height: 540px) {
|
||||
.item-wrapper{
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.nav-item {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--ColorThemes0, #1c1c19);
|
||||
border-radius: 50%;
|
||||
}
|
||||
:host([data-state="active"]) .nav-item {
|
||||
color: var(--PrimaryColor, #cb9e44);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.nav-title {
|
||||
display: none;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.nav-item:hover {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
shadow.appendChild(style);
|
||||
|
||||
// Створюємо порожній контейнер, який буде замінено на <div> або <a>
|
||||
this.containerWrapper = document.createElement('div');
|
||||
this.containerWrapper.setAttribute('class', 'item-wrapper');
|
||||
shadow.appendChild(this.containerWrapper);
|
||||
|
||||
// Первинне заповнення контентом
|
||||
this.updateContent();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Обробник кліку додається після того, як this.itemElement створено в updateContent
|
||||
this.containerWrapper.addEventListener('click', this.handleItemClick.bind(this));
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.containerWrapper.removeEventListener('click', this.handleItemClick.bind(this));
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if (oldValue !== newValue) {
|
||||
this.updateContent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Зчитує атрибути та оновлює внутрішній HTML і тег-контейнер.
|
||||
*/
|
||||
updateContent() {
|
||||
const title = this.getAttribute('title') || 'Без назви';
|
||||
const iconContent = this.getAttribute('icon');
|
||||
const href = this.getAttribute('href');
|
||||
|
||||
// 1. Формуємо HTML для іконки та заголовка
|
||||
let iconHTML = '';
|
||||
if (iconContent) {
|
||||
const trimmedIcon = iconContent.trim();
|
||||
const isSVG = trimmedIcon.startsWith('<svg') && trimmedIcon.endsWith('</svg>');
|
||||
|
||||
if (isSVG) {
|
||||
iconHTML = `<span class="nav-icon-wrapper">${trimmedIcon}</span>`;
|
||||
} else {
|
||||
iconHTML = `<img src="${iconContent}" alt="${title} icon" class="nav-icon-img">`;
|
||||
}
|
||||
}
|
||||
const innerContent = `${iconHTML}<span class="nav-title">${title}</span>`;
|
||||
|
||||
// 2. Визначаємо, який тег використовувати (<a> чи <div>)
|
||||
const currentTag = this.containerWrapper.firstChild ? this.containerWrapper.firstChild.tagName.toLowerCase() : null;
|
||||
const requiredTag = href ? 'a' : 'div';
|
||||
|
||||
// Якщо тип тега потрібно змінити, створюємо новий елемент
|
||||
if (currentTag !== requiredTag) {
|
||||
// Створюємо новий елемент <a> або <div>
|
||||
this.itemElement = document.createElement(requiredTag);
|
||||
this.itemElement.setAttribute('class', 'nav-item');
|
||||
|
||||
// Замінюємо старий елемент новим
|
||||
this.containerWrapper.innerHTML = '';
|
||||
this.containerWrapper.appendChild(this.itemElement);
|
||||
} else {
|
||||
// Елемент вже правильний, використовуємо його
|
||||
this.itemElement = this.containerWrapper.firstChild;
|
||||
}
|
||||
|
||||
// 3. Встановлюємо атрибути
|
||||
this.itemElement.innerHTML = innerContent;
|
||||
|
||||
if (href) {
|
||||
// Це посилання: встановлюємо href та data-route
|
||||
this.itemElement.setAttribute('href', href);
|
||||
this.itemElement.setAttribute('data-route', href); // <-- ДОДАНО data-route
|
||||
this.itemElement.removeAttribute('role');
|
||||
this.itemElement.removeAttribute('tabindex');
|
||||
} else {
|
||||
// Це кнопка: видаляємо посилальні атрибути та встановлюємо роль
|
||||
this.itemElement.removeAttribute('href');
|
||||
this.itemElement.removeAttribute('data-route');
|
||||
this.itemElement.setAttribute('role', 'button');
|
||||
this.itemElement.setAttribute('tabindex', '0');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обробляє клік, виконуючи код з атрибута 'click' для не-посилань.
|
||||
*/
|
||||
handleItemClick(event) {
|
||||
const clickAction = this.getAttribute('click');
|
||||
const href = this.getAttribute('href');
|
||||
|
||||
if (href) {
|
||||
// Якщо це тег <a>, дозволяємо браузеру обробляти клік (або JS-роутеру)
|
||||
return;
|
||||
}
|
||||
|
||||
if (clickAction) {
|
||||
try {
|
||||
event.preventDefault(); // Запобігаємо стандартній дії (якщо була встановлена роль кнопки)
|
||||
console.log(`Executing click action: ${clickAction}`);
|
||||
eval(clickAction);
|
||||
} catch (e) {
|
||||
console.error(`Error executing click action "${clickAction}":`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Клас 2: Контейнер Меню (navigation-container)
|
||||
// =========================================================
|
||||
|
||||
class NavigationContainer extends HTMLElement {
|
||||
/**
|
||||
* Обробляє клік на документі, щоб приховати меню, якщо клік був за його межами.
|
||||
*/
|
||||
handleOutsideClick = (event) => {
|
||||
// Перевіряємо, чи містить наш компонент елемент, на який клікнули
|
||||
// (this.shadowRoot.host - це сам <navigation-container>)
|
||||
// або this.menuContainer.contains(event.target)
|
||||
|
||||
if (!this.contains(event.target) && !this.shadowRoot.contains(event.target)) {
|
||||
// Клік був за межами компонента
|
||||
this.hideHiddenMenu();
|
||||
}
|
||||
}
|
||||
handleScroll = () => {
|
||||
this.hideHiddenMenu();
|
||||
}
|
||||
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const shadow = this.attachShadow({ mode: 'open' });
|
||||
this.standalone = window.matchMedia('(display-mode: standalone)').matches;
|
||||
this.os = this.detectOS();
|
||||
|
||||
// Стилі контейнера
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.navigation-menu{
|
||||
position: fixed;
|
||||
width: 230px;
|
||||
height: calc(100vh - 60px);
|
||||
min-height: 510px;
|
||||
background: var(--ColorThemes2, #525151);
|
||||
margin: 0;
|
||||
padding: 40px 10px;
|
||||
-webkit-transition: width .2s ease 0s;
|
||||
-o-transition: width .2s ease 0s;
|
||||
transition: width .2s ease 0s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.navigation-items,
|
||||
.navigation-items-hidden {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 55px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
border-radius: 30px;
|
||||
}
|
||||
.navigation-items-hidden {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.more-button-item {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.navigation-menu {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px), (max-height: 540px) {
|
||||
.navigation-menu {
|
||||
width: calc(100% - 30px);
|
||||
height: 60px;
|
||||
min-height: 60px;
|
||||
padding: 0;
|
||||
z-index: 9991;
|
||||
bottom: 0px;
|
||||
background: transparent;
|
||||
left: 15px;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
bottom: 0px;
|
||||
}
|
||||
.navigation-items {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
z-index: 9998;
|
||||
bottom: 10px;
|
||||
box-shadow: 0px -4px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.navigation-items::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--ColorThemes2, #525151);
|
||||
background: var(--ColorThemes3, #f3f3f3);
|
||||
opacity: 0.97;
|
||||
z-index: 0;
|
||||
border-radius: 30px;
|
||||
}
|
||||
.navigation-items-hidden{
|
||||
flex-direction: row;
|
||||
height: fit-content;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 2px;
|
||||
width: calc(100% - 4px);
|
||||
margin: 0;
|
||||
-webkit-transition: .2s ease 0s;
|
||||
-o-transition: .2s ease 0s;
|
||||
transition: .2s ease 0s;
|
||||
z-index: 9992;
|
||||
opacity: 0;
|
||||
}
|
||||
.navigation-items-hidden::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--ColorThemes2, #525151);
|
||||
background: var(--ColorThemes3, #f3f3f3);
|
||||
opacity: 0.98;
|
||||
z-index: 0;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
.more-button-item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navigation-menu.expanded .navigation-items-hidden {
|
||||
bottom: 75px;
|
||||
opacity: 1;
|
||||
box-shadow: 0px -4px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.navigation-menu[data-os="iOS"] .navigation-items {
|
||||
bottom: 15px;
|
||||
}
|
||||
.navigation-menu[data-os="iOS"] .navigation-items-hidden {
|
||||
bottom: 17px;
|
||||
}
|
||||
.navigation-menu[data-os="iOS"].expanded .navigation-items-hidden {
|
||||
bottom: 80px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.navigation-menu {
|
||||
-webkit-transition: 0s ease 0s;
|
||||
-o-transition: 0s ease 0s;
|
||||
transition: 0s ease 0s;
|
||||
}
|
||||
}
|
||||
`;
|
||||
shadow.appendChild(style);
|
||||
|
||||
this.menuContainer = document.createElement('menu');
|
||||
this.menuContainer.setAttribute('class', 'navigation-menu');
|
||||
if (this.standalone) this.menuContainer.setAttribute('data-os', this.os);
|
||||
|
||||
this.itemsContainer = document.createElement('items');
|
||||
this.itemsContainer.setAttribute('class', 'navigation-items');
|
||||
|
||||
this.itemsHiddenContainer = document.createElement('items-hidden');
|
||||
this.itemsHiddenContainer.setAttribute('class', 'navigation-items-hidden');
|
||||
|
||||
// Слот дозволяє відображати дочірні елементи <nav-item>
|
||||
const slot = document.createElement('slot');
|
||||
this.itemsContainer.appendChild(slot);
|
||||
|
||||
this.menuContainer.appendChild(this.itemsContainer);
|
||||
this.menuContainer.appendChild(this.itemsHiddenContainer);
|
||||
shadow.appendChild(this.menuContainer);
|
||||
|
||||
// MutationObserver для відстеження динамічного додавання/видалення пунктів
|
||||
this.observer = new MutationObserver(this.handleMutations.bind(this));
|
||||
// Спостерігаємо за зміною дочірніх елементів
|
||||
this.observer.observe(this, { childList: true, subtree: false });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Додаємо обробник кліків до документа
|
||||
document.addEventListener('click', this.handleOutsideClick);
|
||||
// Додаємо обробник прокручування до вікна
|
||||
window.addEventListener('scroll', this.handleScroll);
|
||||
|
||||
// Повторна перевірка елементів на випадок, якщо вони вже були в DOM до реєстрації
|
||||
this.reassignItems();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.observer.disconnect();
|
||||
// Видаляємо обробник кліків з документа
|
||||
document.removeEventListener('click', this.handleOutsideClick);
|
||||
// Видаляємо обробник прокручування з вікна
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
|
||||
handleMutations(mutationsList, observer) {
|
||||
// Логіка оновлення при додаванні/видаленні дочірніх елементів (наприклад, для логування)
|
||||
this.reassignItems();
|
||||
}
|
||||
|
||||
reassignItems() {
|
||||
// Отримуємо всі дочірні елементи (які можуть бути nav-item)
|
||||
const allItems = Array.from(this.children);
|
||||
allItems.forEach(item => {
|
||||
// Перевіряємо, чи має елемент атрибут data-hidden
|
||||
if (item.getAttribute('data-hidden') == 'true') {
|
||||
this.itemsHiddenContainer.appendChild(item);
|
||||
this.createMoreButton();
|
||||
} else if (item.parentNode !== this) {
|
||||
this.appendChild(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Утиліти ---
|
||||
hideHiddenMenu() {
|
||||
this.menuContainer.classList.remove('expanded');
|
||||
}
|
||||
|
||||
toggleHiddenMenu() {
|
||||
this.menuContainer.classList.toggle('expanded');
|
||||
}
|
||||
|
||||
createMoreButton() {
|
||||
let moreButton = this.itemsContainer.querySelector('.more-button-item');
|
||||
|
||||
if (!moreButton) {
|
||||
const button = document.createElement('nav-item');
|
||||
button.setAttribute('class', 'more-button-item');
|
||||
button.setAttribute('title', 'Більше');
|
||||
button.setAttribute('icon',
|
||||
`<svg viewBox="0 0 24 24"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>`
|
||||
);
|
||||
button.addEventListener('click', this.toggleHiddenMenu.bind(this));
|
||||
this.itemsContainer.appendChild(button);
|
||||
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
||||
detectOS() {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
||||
if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
|
||||
return 'iOS';
|
||||
}
|
||||
return 'Other';
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Реєстрація компонентів у браузері
|
||||
// =========================================================
|
||||
|
||||
customElements.define('nav-item', NavigationItem);
|
||||
customElements.define('navigation-container', NavigationContainer);
|
||||
@@ -1,20 +1,28 @@
|
||||
class AppNotificationContainer extends HTMLElement {
|
||||
/**
|
||||
* Клас NotificationContainer
|
||||
* Веб-компонент для відображення системних сповіщень.
|
||||
* Використовує Shadow DOM для інкапсуляції стилів.
|
||||
*/
|
||||
class NotificationContainer extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
|
||||
// Настройки по умолчанию
|
||||
this._timeout = 4000;
|
||||
this._maxVisible = 5;
|
||||
this._position = 'top-right';
|
||||
this._mobileBottomEnabled = false;
|
||||
|
||||
// *** Вдосконалення: Відстежуємо ноди, що видаляються ***
|
||||
this._removingNodes = new Set();
|
||||
|
||||
this._container = document.createElement('div');
|
||||
this._container.className = 'app-notification-container';
|
||||
this.shadowRoot.appendChild(this._container);
|
||||
|
||||
this._insertStyles();
|
||||
|
||||
// SVG іконки для різних типів сповіщень (залишаємо як є)
|
||||
this._icons = {
|
||||
info: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path d="M 15 3 C 13.895 3 13 3.895 13 5 L 13 5.2929688 C 10.109011 6.1538292 8 8.8293311 8 12 L 8 14.757812 C 8 17.474812 6.921 20.079 5 22 A 1 1 0 0 0 4 23 A 1 1 0 0 0 5 24 L 25 24 A 1 1 0 0 0 26 23 A 1 1 0 0 0 25 22 C 23.079 20.079 22 17.474812 22 14.757812 L 22 12 C 22 8.8293311 19.890989 6.1538292 17 5.2929688 L 17 5 C 17 3.895 16.105 3 15 3 z M 3.9550781 7.9882812 A 1.0001 1.0001 0 0 0 3.1054688 8.5527344 C 3.1054688 8.5527344 2 10.666667 2 13 C 2 15.333333 3.1054687 17.447266 3.1054688 17.447266 A 1.0001165 1.0001165 0 0 0 4.8945312 16.552734 C 4.8945312 16.552734 4 14.666667 4 13 C 4 11.333333 4.8945313 9.4472656 4.8945312 9.4472656 A 1.0001 1.0001 0 0 0 3.9550781 7.9882812 z M 26.015625 7.9882812 A 1.0001 1.0001 0 0 0 25.105469 9.4472656 C 25.105469 9.4472656 26 11.333333 26 13 C 26 14.666667 25.105469 16.552734 25.105469 16.552734 A 1.0001163 1.0001163 0 1 0 26.894531 17.447266 C 26.894531 17.447266 28 15.333333 28 13 C 28 10.666667 26.894531 8.5527344 26.894531 8.5527344 A 1.0001 1.0001 0 0 0 26.015625 7.9882812 z M 12 26 C 12 27.657 13.343 29 15 29 C 16.657 29 18 27.657 18 26 L 12 26 z"/></svg>`,
|
||||
success: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8' style="width: 17px;height: 17px;"><path d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/></svg>`,
|
||||
@@ -43,7 +51,6 @@ class AppNotificationContainer extends HTMLElement {
|
||||
this._timeout = parseInt(this.getAttribute('timeout')) || 4000;
|
||||
|
||||
const mobilePosAttr = this.getAttribute('mobile-position');
|
||||
// Если атрибут установлен в 'bottom' или присутствует (как пустая строка, если это булевый атрибут)
|
||||
this._mobileBottomEnabled = mobilePosAttr === 'bottom' || mobilePosAttr === '';
|
||||
|
||||
this._container.setAttribute('data-position', this._position);
|
||||
@@ -51,9 +58,6 @@ class AppNotificationContainer extends HTMLElement {
|
||||
this._applyMobileStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Динамически применяет класс, который активирует мобильные стили "только снизу".
|
||||
*/
|
||||
_applyMobileStyles() {
|
||||
if (this._mobileBottomEnabled) {
|
||||
this._container.classList.add('mobile-bottom');
|
||||
@@ -62,15 +66,14 @@ class AppNotificationContainer extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Публичный метод для изменения настройки мобильной позиции во время выполнения.
|
||||
* @param {boolean} enable - true, чтобы принудительно устанавливать позицию снизу на мобильных, false, чтобы использовать обычные @media стили.
|
||||
*/
|
||||
setMobileBottom(enable) {
|
||||
this._mobileBottomEnabled = !!enable;
|
||||
this._applyMobileStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Показує нове сповіщення.
|
||||
*/
|
||||
show(message, options = {}) {
|
||||
const {
|
||||
type = 'info',
|
||||
@@ -84,15 +87,28 @@ class AppNotificationContainer extends HTMLElement {
|
||||
? { title: title || '', text: message }
|
||||
: message;
|
||||
|
||||
while (this._container.children.length >= this._maxVisible) {
|
||||
const first = this._container.firstElementChild;
|
||||
if (first) first.remove();
|
||||
else break;
|
||||
}
|
||||
// **Оновлена логіка обмеження кількості:**
|
||||
// Визначаємо кількість "видимих" нод (які не перебувають у процесі видалення)
|
||||
const totalChildren = this._container.children.length;
|
||||
const visibleNodesCount = totalChildren - this._removingNodes.size;
|
||||
|
||||
while (visibleNodesCount >= this._maxVisible) {
|
||||
// Шукаємо найстаріший елемент, який ще НЕ видаляється
|
||||
const first = Array.from(this._container.children).find(n => !this._removingNodes.has(n));
|
||||
|
||||
if (first) {
|
||||
this._removeNode(first);
|
||||
} else {
|
||||
// Якщо всі елементи в процесі видалення, виходимо
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Кінець оновленої логіки обмеження
|
||||
|
||||
// Створення DOM елементів (залишаємо як є)
|
||||
const node = document.createElement('div');
|
||||
node.className = `app-notification ${type}`;
|
||||
if (onClick) node.style.cursor = "pointer"
|
||||
if (onClick) node.style.cursor = "pointer";
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'icon';
|
||||
@@ -114,6 +130,7 @@ class AppNotificationContainer extends HTMLElement {
|
||||
node.appendChild(icon);
|
||||
node.appendChild(body);
|
||||
|
||||
// Додаємо кнопку закриття
|
||||
if (!onClick && !lock) {
|
||||
const closeDiv = document.createElement('div');
|
||||
closeDiv.className = 'blockClose';
|
||||
@@ -121,28 +138,35 @@ class AppNotificationContainer extends HTMLElement {
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'close';
|
||||
closeBtn.setAttribute('aria-label', 'Закрыть уведомление');
|
||||
closeBtn.setAttribute('aria-label', 'Закрити повідомлення');
|
||||
closeBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26"><path d="M 6.65625 4 C 6.367188 4 6.105469 4.113281 5.90625 4.3125 L 4.3125 5.90625 C 3.914063 6.304688 3.914063 7 4.3125 7.5 L 9.8125 13 L 4.3125 18.5 C 3.914063 19 3.914063 19.695313 4.3125 20.09375 L 5.90625 21.6875 C 6.40625 22.085938 7.101563 22.085938 7.5 21.6875 L 13 16.1875 L 18.5 21.6875 C 19 22.085938 19.695313 22.085938 20.09375 21.6875 L 21.6875 20.09375 C 22.085938 19.59375 22.085938 18.898438 21.6875 18.5 L 16.1875 13 L 21.6875 7.5 C 22.085938 7 22.085938 6.304688 21.6875 5.90625 L 20.09375 4.3125 C 19.59375 3.914063 18.898438 3.914063 18.5 4.3125 L 13 9.8125 L 7.5 4.3125 C 7.25 4.113281 6.945313 4 6.65625 4 Z"></path></svg>';
|
||||
closeDiv.appendChild(closeBtn);
|
||||
closeBtn.addEventListener('click', () => this._removeNode(node));
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // Запобігаємо спрацьовуванню onClick на самій ноді
|
||||
this._removeNode(node);
|
||||
});
|
||||
}
|
||||
|
||||
this._container.appendChild(node);
|
||||
// Запускаємо анімацію появи через requestAnimationFrame
|
||||
requestAnimationFrame(() => node.classList.add('show'));
|
||||
|
||||
let timer = null;
|
||||
const startTimer = () => {
|
||||
if (timeout === 0) return;
|
||||
if (timeout === 0 || lock) return;
|
||||
timer = setTimeout(() => this._removeNode(node), timeout);
|
||||
};
|
||||
const clearTimer = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
||||
|
||||
// Зупинка таймауту при наведенні
|
||||
node.addEventListener('mouseenter', clearTimer);
|
||||
node.addEventListener('mouseleave', startTimer);
|
||||
|
||||
// Обробка кліку на сповіщенні
|
||||
if (typeof onClick === 'function') {
|
||||
node.addEventListener('click', () => {
|
||||
try { onClick(); } catch (e) { }
|
||||
clearTimer(); // Зупиняємо таймаут, якщо він був
|
||||
try { onClick(); } catch (e) { console.error(e); }
|
||||
this._removeNode(node);
|
||||
});
|
||||
}
|
||||
@@ -152,29 +176,50 @@ class AppNotificationContainer extends HTMLElement {
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Приватний метод для видалення ноди з анімацією.
|
||||
* **Тепер перевіряє, чи нода вже видаляється.**
|
||||
*/
|
||||
_removeNode(node) {
|
||||
if (!node || !node.parentElement) return;
|
||||
if (!node || !node.parentElement || this._removingNodes.has(node)) return;
|
||||
|
||||
this._removingNodes.add(node); // Позначаємо як ту, що видаляється
|
||||
|
||||
node.classList.remove('show');
|
||||
// Чекаємо завершення анімації зникнення (200мс)
|
||||
setTimeout(() => {
|
||||
if (node && node.parentElement) node.parentElement.removeChild(node);
|
||||
if (node && node.parentElement) {
|
||||
node.parentElement.removeChild(node);
|
||||
}
|
||||
this._removingNodes.delete(node); // Видаляємо зі списку після фізичного видалення
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Видаляє всі видимі сповіщення.
|
||||
*/
|
||||
clearAll() {
|
||||
if (!this._container) return;
|
||||
// Використовуємо _removeNode, який тепер безпечно обробляє повторні виклики
|
||||
Array.from(this._container.children).forEach(n => this._removeNode(n));
|
||||
}
|
||||
|
||||
// Допоміжні методи з фіксованим типом сповіщення
|
||||
info(message, opts = {}) { return this.show(message, { ...opts, type: 'info' }); }
|
||||
success(message, opts = {}) { return this.show(message, { ...opts, type: 'success' }); }
|
||||
warn(message, opts = {}) { return this.show(message, { ...opts, type: 'warn' }); }
|
||||
error(message, opts = {}) { return this.show(message, { ...opts, type: 'error' }); }
|
||||
// Метод для сповіщень, що реагують на клік (псевдонім 'click' для 'show' з 'onClick')
|
||||
click(message, opts = {}) { return this.show(message, { ...opts, onClick: opts.f }); }
|
||||
|
||||
|
||||
/**
|
||||
* Вставляє необхідні CSS стилі в Shadow DOM (залишаємо як є).
|
||||
*/
|
||||
_insertStyles() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
/* Контейнер */
|
||||
.app-notification-container {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
@@ -184,11 +229,13 @@ class AppNotificationContainer extends HTMLElement {
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
/* Позиціонування контейнера */
|
||||
.app-notification-container[data-position="top-right"] { top: 8px; right: 8px; align-items: flex-end; }
|
||||
.app-notification-container[data-position="top-left"] { top: 8px; left: 8px; align-items: flex-start; }
|
||||
.app-notification-container[data-position="bottom-right"] { bottom: 8px; right: 8px; align-items: flex-end; }
|
||||
.app-notification-container[data-position="bottom-left"] { bottom: 8px; left: 8px; align-items: flex-start; }
|
||||
|
||||
/* Одне сповіщення */
|
||||
.app-notification {
|
||||
pointer-events: auto;
|
||||
min-width: 220px;
|
||||
@@ -230,6 +277,8 @@ class AppNotificationContainer extends HTMLElement {
|
||||
}
|
||||
.app-notification .body { flex:1; }
|
||||
.app-notification .title { font-weight: 600; margin-bottom: 4px; font-size: 13px; }
|
||||
|
||||
/* Кнопка закриття */
|
||||
.app-notification .blockClose {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@@ -253,6 +302,7 @@ class AppNotificationContainer extends HTMLElement {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Стилі за типами */
|
||||
.app-notification.info {
|
||||
background: var(--ColorThemes3, #2196F3);
|
||||
color: var(--ColorThemes0, #ffffff);
|
||||
@@ -273,6 +323,7 @@ class AppNotificationContainer extends HTMLElement {
|
||||
.app-notification.error .icon { background: #c45050; }
|
||||
.app-notification.error .close svg{fill: #fff;}
|
||||
|
||||
/* Адаптивність для мобільних пристроїв */
|
||||
@media (max-width: 700px) {
|
||||
.app-notification-container {
|
||||
left: 0;
|
||||
@@ -281,10 +332,10 @@ class AppNotificationContainer extends HTMLElement {
|
||||
align-items: center !important;
|
||||
}
|
||||
.app-notification-container .app-notification {
|
||||
max-width: 95%;
|
||||
min-width: 95%;
|
||||
max-width: calc(100% - 30px);
|
||||
min-width: calc(100% - 30px);
|
||||
}
|
||||
|
||||
/* Спеціальна мобільна позиція знизу */
|
||||
.app-notification-container.mobile-bottom {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
@@ -295,32 +346,62 @@ class AppNotificationContainer extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('app-notification-container', AppNotificationContainer);
|
||||
// Реєструємо веб-компонент у браузері
|
||||
customElements.define('notification-container', NotificationContainer);
|
||||
|
||||
/* <app-notification-container
|
||||
|
||||
/*
|
||||
============================
|
||||
ПРИКЛАД ВИКОРИСТАННЯ
|
||||
============================
|
||||
*/
|
||||
|
||||
/*
|
||||
1. Додайте цей елемент у свій HTML:
|
||||
<notification-container
|
||||
id="notif-manager"
|
||||
position="top-right"
|
||||
max-visible="5"
|
||||
timeout="4000"
|
||||
mobile-position>
|
||||
</app-notification-container> */
|
||||
// const Notifier = document.getElementById('notif-manager');
|
||||
</notification-container>
|
||||
|
||||
// 💡 Включить принудительную позицию снизу для мобильных
|
||||
// Notifier.setMobileBottom(true);
|
||||
2. Отримайте посилання на компонент у JS:
|
||||
const Notifier = document.getElementById('notif-manager');
|
||||
|
||||
// 💡 Отключить принудительную позицию снизу (вернется к поведению @media или position)
|
||||
// Notifier.setMobileBottom(false);
|
||||
3. Приклади викликів:
|
||||
|
||||
💡 Базові сповіщення
|
||||
Notifier.info('Налаштування мобільної позиції змінено.');
|
||||
Notifier.success('Успішна операція.');
|
||||
Notifier.warn('Увага: низький рівень заряду батареї.');
|
||||
Notifier.error('Критична помилка!');
|
||||
|
||||
// Пример использования
|
||||
💡 Сповіщення із заголовком
|
||||
Notifier.info('Це повідомлення має чіткий заголовок.', {
|
||||
title: 'Важлива інформація'
|
||||
});
|
||||
|
||||
// Notifier.info('Настройки мобильной позиции изменены.');
|
||||
// Notifier.info('Привет! Это ваше первое уведомление через Web Component.', {
|
||||
// title: 'Успешная инициализация',
|
||||
// onClick: () => alert('Вы кликнули!'),
|
||||
// lock: false
|
||||
// });
|
||||
// Notifier.success('Успешная операция.');
|
||||
// Notifier.error('Критическая ошибка!', { timeout: 0, lock: true });
|
||||
// Notifier.warn({ title: `Metrics`, text: `З'єднання встановлено` }, { timeout: 0 });
|
||||
💡 Сповіщення з об'єктом (заголовок та текст)
|
||||
Notifier.warn({
|
||||
title: `Metrics`,
|
||||
text: `З'єднання встановлено`
|
||||
});
|
||||
|
||||
💡 Сповіщення, яке не зникає (timeout: 0 або lock: true)
|
||||
Notifier.error('Критична помилка! Необхідне втручання.', {
|
||||
timeout: 0,
|
||||
lock: true
|
||||
});
|
||||
|
||||
💡 Сповіщення з обробником кліку (автоматично закривається після кліку)
|
||||
Notifier.info('Натисніть тут, щоб побачити деталі.', {
|
||||
onClick: () => alert('Ви клацнули! Дякую.'),
|
||||
lock: false
|
||||
});
|
||||
|
||||
💡 Програмне керування
|
||||
Notifier.setMobileBottom(true); // Включити примусову позицію знизу для мобільних
|
||||
Notifier.setMobileBottom(false); // Вимкнути примусову позицію знизу
|
||||
Notifier.clearAll(); // Видалити всі сповіщення
|
||||
*/
|
||||
301
web/lib/customElements/pwaInstallBanner.js
Normal file
301
web/lib/customElements/pwaInstallBanner.js
Normal file
@@ -0,0 +1,301 @@
|
||||
class PwaInstallBanner extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.deferredPrompt = null;
|
||||
this.STORAGE_KEY = 'PwaInstallBanner'; // Визначаємо ключ localStorage
|
||||
this.isInStandaloneMode = () => ('standalone' in window.navigator && window.navigator.standalone === true);
|
||||
this.os = this.detectOS();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Додаємо стилі та розмітку до Shadow DOM
|
||||
this.shadowRoot.innerHTML = this.getStyles() + this.getTemplate();
|
||||
|
||||
this.elements = {
|
||||
backdrop: this.shadowRoot.getElementById('blur-backdrop'),
|
||||
installOverlay: this.shadowRoot.getElementById('pwa-install-overlay'),
|
||||
iosOverlay: this.shadowRoot.getElementById('pwa-ios-overlay'),
|
||||
installButton: this.shadowRoot.getElementById('pwa-install-button'),
|
||||
closeButton: this.shadowRoot.getElementById('pwa-close-button'),
|
||||
iosCloseButton: this.shadowRoot.getElementById('pwa-ios-close-button'),
|
||||
};
|
||||
|
||||
this.setupListeners();
|
||||
this.checkInitialDisplay();
|
||||
}
|
||||
|
||||
// --- Утиліти ---
|
||||
|
||||
detectOS() {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
||||
if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
|
||||
return 'iOS';
|
||||
}
|
||||
// ... (можна додати Android, Windows, але для PWA нас цікавить в першу чергу iOS)
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
shouldShowBanner() {
|
||||
return localStorage.getItem(this.STORAGE_KEY) !== 'false';
|
||||
}
|
||||
|
||||
checkInitialDisplay() {
|
||||
if (!this.shouldShowBanner()) {
|
||||
return; // Не показуємо, якщо localStorage = 'false'
|
||||
}
|
||||
|
||||
// Логіка для iOS
|
||||
if (this.os === 'iOS' && !this.isInStandaloneMode()) {
|
||||
// Затримка відображення, як у вихідному коді
|
||||
setTimeout(() => {
|
||||
this.openPopup(this.elements.iosOverlay);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
openPopup(overlayElement) {
|
||||
this.elements.backdrop.classList.remove('pwa-hidden');
|
||||
overlayElement.classList.remove('pwa-hidden');
|
||||
document.body.classList.add('modal-open');
|
||||
}
|
||||
|
||||
closePopup = () => {
|
||||
this.elements.installOverlay.classList.add('pwa-hidden');
|
||||
this.elements.iosOverlay.classList.add('pwa-hidden');
|
||||
this.elements.backdrop.classList.add('pwa-hidden');
|
||||
document.body.classList.remove('modal-open');
|
||||
this.deferredPrompt = null;
|
||||
}
|
||||
|
||||
// --- Обробники подій ---
|
||||
|
||||
setupListeners() {
|
||||
window.addEventListener("beforeinstallprompt", this.handleBeforeInstallPrompt);
|
||||
|
||||
// Обробники кнопок
|
||||
this.elements.installButton.addEventListener("click", this.handleInstallClick);
|
||||
this.elements.closeButton.addEventListener("click", this.closePopup);
|
||||
this.elements.iosCloseButton.addEventListener('click', this.closePopup);
|
||||
}
|
||||
|
||||
handleBeforeInstallPrompt = (e) => {
|
||||
// Вихідний код перевіряв localStorage, але для простоти прикладу я її пропускаю.
|
||||
if (!this.shouldShowBanner()) {
|
||||
return; // Не показуємо, якщо localStorage = 'false'
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
this.deferredPrompt = e;
|
||||
|
||||
// Показуємо стандартний банер, якщо доступно і не в режимі iOS
|
||||
if (this.os !== 'iOS') {
|
||||
this.openPopup(this.elements.installOverlay);
|
||||
}
|
||||
}
|
||||
|
||||
handleInstallClick = async () => {
|
||||
if (!this.deferredPrompt) return;
|
||||
|
||||
this.deferredPrompt.prompt();
|
||||
const { outcome } = await this.deferredPrompt.userChoice;
|
||||
console.log(`[APP] Результат встановлення PWA: ${outcome}`);
|
||||
|
||||
this.closePopup();
|
||||
}
|
||||
|
||||
// --- Шаблон (Template) та Стилі (Styles) ---
|
||||
|
||||
getTemplate() {
|
||||
// HTML розмітка з вихідного коду
|
||||
return `
|
||||
<div id="blur-backdrop" class="pwa-hidden"></div>
|
||||
<div id="pwa-install-overlay" class="pwa-overlay pwa-hidden">
|
||||
<div class="popup">
|
||||
<h2>Встановити застосунок?</h2>
|
||||
<p>Додайте його на головний екран для швидкого доступу.</p>
|
||||
<div>
|
||||
<button id="pwa-install-button">Встановити</button>
|
||||
<button id="pwa-close-button">Пізніше</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pwa-ios-overlay" class="pwa-overlay pwa-hidden">
|
||||
<div class="popup">
|
||||
<h2>Встановлення застосунку</h2>
|
||||
<p>Щоб встановити застосунок, виконайте наступні кроки:</p>
|
||||
|
||||
<ol>
|
||||
<li>1. Відкрийте посилання в браузері Safari.</li>
|
||||
<li>
|
||||
2. Натисніть кнопку
|
||||
<span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
|
||||
<path
|
||||
d="M 14.984375 1 A 1.0001 1.0001 0 0 0 14.292969 1.2929688 L 10.292969 5.2929688 A 1.0001 1.0001 0 1 0 11.707031 6.7070312 L 14 4.4140625 L 14 17 A 1.0001 1.0001 0 1 0 16 17 L 16 4.4140625 L 18.292969 6.7070312 A 1.0001 1.0001 0 1 0 19.707031 5.2929688 L 15.707031 1.2929688 A 1.0001 1.0001 0 0 0 14.984375 1 z M 9 9 C 7.3550302 9 6 10.35503 6 12 L 6 24 C 6 25.64497 7.3550302 27 9 27 L 21 27 C 22.64497 27 24 25.64497 24 24 L 24 12 C 24 10.35503 22.64497 9 21 9 L 19 9 L 19 11 L 21 11 C 21.56503 11 22 11.43497 22 12 L 22 24 C 22 24.56503 21.56503 25 21 25 L 9 25 C 8.4349698 25 8 24.56503 8 24 L 8 12 C 8 11.43497 8.4349698 11 9 11 L 11 11 L 11 9 L 9 9 z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
в нижній частині екрана Safari.
|
||||
</li>
|
||||
<li>
|
||||
3. У меню, що з’явиться, виберіть
|
||||
<span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M 6 3 C 4.3550302 3 3 4.3550302 3 6 L 3 18 C 3 19.64497 4.3550302 21 6 21 L 18 21 C 19.64497 21 21 19.64497 21 18 L 21 6 C 21 4.3550302 19.64497 3 18 3 L 6 3 z M 6 5 L 18 5 C 18.56503 5 19 5.4349698 19 6 L 19 18 C 19 18.56503 18.56503 19 18 19 L 6 19 C 5.4349698 19 5 18.56503 5 18 L 5 6 C 5 5.4349698 5.4349698 5 6 5 z M 11.984375 6.9863281 A 1.0001 1.0001 0 0 0 11 8 L 11 11 L 8 11 A 1.0001 1.0001 0 1 0 8 13 L 11 13 L 11 16 A 1.0001 1.0001 0 1 0 13 16 L 13 13 L 16 13 A 1.0001 1.0001 0 1 0 16 11 L 13 11 L 13 8 A 1.0001 1.0001 0 0 0 11.984375 6.9863281 z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
«На Початковий екран».
|
||||
</li>
|
||||
</ol>
|
||||
<div>
|
||||
<button id="pwa-ios-close-button">Зрозуміло</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
getStyles() {
|
||||
// CSS стилі, які були у вихідному коді, але адаптовані для Shadow DOM
|
||||
// Примітки:
|
||||
// 1. Змінні CSS (наприклад, --ColorThemes0) мають бути визначені в основному документі
|
||||
// або передані через властивості, інакше вони не працюватимуть в Shadow DOM.
|
||||
// Я залишаю їх як є, припускаючи, що вони глобально доступні.
|
||||
// 2. Стилі для body.modal-open потрібно додати в основний CSS.
|
||||
|
||||
return `
|
||||
<style>
|
||||
#blur-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.pwa-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup {
|
||||
background: var(--ColorThemes0, #ffffff); /* Fallback */
|
||||
padding: 24px 32px;
|
||||
border-radius: var(--border-radius, 15px);
|
||||
max-width: 90%;
|
||||
width: 320px;
|
||||
text-align: center;
|
||||
font-family: sans-serif;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup h2 {
|
||||
margin-bottom: 12px;
|
||||
color: var(--ColorThemes3, #333);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup p {
|
||||
margin-bottom: 10px;
|
||||
color: var(--ColorThemes3, #333);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup ol {
|
||||
text-align: justify;
|
||||
font-size: var(--FontSize4, 15px);
|
||||
margin-bottom: 10px;
|
||||
max-width: 290px;
|
||||
padding-left: 0; /* Виправлення відступу списку */
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup li {
|
||||
list-style-type: none;
|
||||
font-size: var(--FontSize3, 14px);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup li span {
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup li span svg {
|
||||
fill: var(--PrimaryColor, #007bff);
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup>div {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup>div>button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: calc(var(--border-radius, 15px) - 8px);
|
||||
cursor: pointer;
|
||||
font-size: var(--FontSize3, 14px);
|
||||
}
|
||||
|
||||
#pwa-install-button {
|
||||
background-color: var(--PrimaryColor, #007bff);
|
||||
color: var(--PrimaryColorText, #ffffff);
|
||||
}
|
||||
|
||||
#pwa-close-button,
|
||||
#pwa-ios-close-button {
|
||||
background-color: #ccc;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pwa-hidden {
|
||||
display: none !important; /* Важливо для скриптів */
|
||||
}
|
||||
|
||||
@media (max-width: 450px) {
|
||||
.pwa-overlay>.popup {
|
||||
padding: 17px 10px;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.pwa-overlay>.popup p {
|
||||
font-size: var(--FontSize4, 15px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Реєстрація веб-компонента
|
||||
customElements.define('pwa-install-banner', PwaInstallBanner);
|
||||
292
web/lib/customElements/smartSelect.js
Normal file
292
web/lib/customElements/smartSelect.js
Normal file
@@ -0,0 +1,292 @@
|
||||
const SMART_SELECT_STYLES_CSS = `
|
||||
:host { display: block; position: relative; width: 100%; font-family: system-ui, sans-serif; }
|
||||
:host ::-webkit-scrollbar { height: 5px; width: 8px; }
|
||||
:host ::-webkit-scrollbar-track { background: transparent; }
|
||||
:host ::-webkit-scrollbar-thumb { background: var(--smart-select-chip-background, #475569); border-radius: 4px; }
|
||||
@media (hover: hover) {
|
||||
:host ::-webkit-scrollbar-thumb:hover { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Стили для скролла */
|
||||
.trigger::-webkit-scrollbar { height: 4px; }
|
||||
.trigger::-webkit-scrollbar-thumb { background: var(--smart-select-chip-background, #475569); border-radius: 4px; }
|
||||
|
||||
.wrapper {
|
||||
min-height: 35px;
|
||||
border: 1px solid var(--smart-select-border-color, #ccc);
|
||||
border-radius: var(--smart-select-border-radius-1, 6px);
|
||||
display: flex;
|
||||
padding: 0 6px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: var(--smart-select-background, #fff);
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
color: var(--smart-select-chip-color);
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
font-size: var(--smart-select-font-size-2);
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--smart-select-chip-background, #dbe3ea);
|
||||
color: var(--smart-select-chip-color, #000);
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--smart-select-border-radius-2, 4px);
|
||||
font-size: var(--smart-select-font-size-1, 12px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip button {
|
||||
display: flex;
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 15px;
|
||||
justify-content: flex-end;
|
||||
fill: var(--smart-select-chip-fill, #e91e63);
|
||||
}
|
||||
|
||||
.chip button svg{ width: 15px; height: 15px; }
|
||||
|
||||
.dropdown {
|
||||
display: none; position: absolute; top: 100%; left: 0; right: 0;
|
||||
background: var(--smart-select-background, #ffffff);
|
||||
border: 1px solid var(--smart-select-border-color, #ccc);
|
||||
border-radius: var(--smart-select-border-radius-1, 6px); z-index: 9999; margin-top: 4px;
|
||||
flex-direction: column; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
:host([open]) .dropdown { display: flex; }
|
||||
|
||||
input[type="search"] {
|
||||
margin: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--smart-select-border-radius-2, 4px);
|
||||
border: 1px solid var(--smart-select-search-border, #ccc);
|
||||
background: transparent;
|
||||
color: var(--smart-select-search-color, #000);
|
||||
}
|
||||
|
||||
.options-list {max-height: 200px; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
::slotted([slot="option"]) {
|
||||
padding: 8px 12px !important;
|
||||
cursor: pointer;
|
||||
border-radius: var(--smart-select-border-radius-2, 4px);
|
||||
display: block;
|
||||
color: var(--smart-select-option-color, #000);
|
||||
font-size: var(--smart-select-font-size-2, 14px);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
::slotted([slot="option"]:hover) {
|
||||
background: var(--smart-select-hover-background, #475569);
|
||||
color: var(--smart-select-hover-color, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
::slotted([slot="option"].selected) {
|
||||
background: var(--smart-select-selected-background, #dbe3eb) !important;
|
||||
color: var(--smart-select-selected-color, #000) !important;
|
||||
}
|
||||
`;
|
||||
|
||||
// Створення об'єкта CSSStyleSheet (якщо підтримується)
|
||||
let SmartSelectStyles = null;
|
||||
if (typeof CSSStyleSheet !== 'undefined' && CSSStyleSheet.prototype.replaceSync) {
|
||||
SmartSelectStyles = new CSSStyleSheet(); // (2) Визначення об'єкта тут
|
||||
SmartSelectStyles.replaceSync(SMART_SELECT_STYLES_CSS);
|
||||
}
|
||||
|
||||
class SmartSelect extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this._selectedValues = new Set();
|
||||
|
||||
// Додаємо стилі в конструкторі, якщо це adoptable
|
||||
if (this.shadowRoot.adoptedStyleSheets) {
|
||||
this.shadowRoot.adoptedStyleSheets = [SmartSelectStyles];
|
||||
} else {
|
||||
// FALLBACK для старих браузерів (наприклад, iOS < 16.4)
|
||||
const style = document.createElement('style');
|
||||
style.textContent = SMART_SELECT_STYLES_CSS;
|
||||
this.shadowRoot.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
|
||||
const searchInput = this.shadowRoot.querySelector('input[type="search"]');
|
||||
searchInput.addEventListener('input', (e) => this.handleSearch(e));
|
||||
|
||||
const wrapper = this.shadowRoot.querySelector('.wrapper');
|
||||
wrapper.addEventListener('click', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
this.toggleAttribute('open');
|
||||
if (this.hasAttribute('open')) {
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
});
|
||||
|
||||
// Слушаем клики по элементам в слоте
|
||||
this.addEventListener('click', (e) => {
|
||||
const opt = e.target.closest('[slot="option"]');
|
||||
if (opt) {
|
||||
this.toggleValue(opt.getAttribute('data-value'), opt);
|
||||
}
|
||||
});
|
||||
|
||||
// Слушаем изменение слота для инициализации (фикс Safari)
|
||||
this.shadowRoot.querySelector('slot').addEventListener('slotchange', () => {
|
||||
this.syncOptions();
|
||||
});
|
||||
|
||||
this.syncOptions();
|
||||
}
|
||||
|
||||
_formatValue(val) {
|
||||
const isNumber = this.getAttribute('type') === 'number';
|
||||
return isNumber ? Number(val) : String(val);
|
||||
}
|
||||
|
||||
syncOptions() {
|
||||
const options = Array.from(this.querySelectorAll('[slot="option"]'));
|
||||
options.forEach(opt => {
|
||||
// Используем data-selected вместо атрибута selected
|
||||
if (opt.hasAttribute('data-selected')) {
|
||||
// Зберігаємо вже у потрібному форматі
|
||||
const val = this._formatValue(opt.getAttribute('data-value'));
|
||||
this._selectedValues.add(val);
|
||||
}
|
||||
});
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
handleSearch(e) {
|
||||
const term = e.target.value.toLowerCase().trim();
|
||||
const options = this.querySelectorAll('[slot="option"]');
|
||||
options.forEach(opt => {
|
||||
const text = opt.textContent.toLowerCase();
|
||||
opt.style.display = text.includes(term) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
toggleValue(val) {
|
||||
const max = this.getAttribute('max') ? parseInt(this.getAttribute('max')) : null;
|
||||
|
||||
const formattedVal = this._formatValue(val);
|
||||
|
||||
if (this._selectedValues.has(formattedVal)) {
|
||||
this._selectedValues.delete(formattedVal);
|
||||
this._click = {
|
||||
value: formattedVal,
|
||||
state: "delete"
|
||||
};
|
||||
} else {
|
||||
if (max && this._selectedValues.size >= max) return;
|
||||
this._selectedValues.add(formattedVal);
|
||||
this._click = {
|
||||
value: formattedVal,
|
||||
state: "add"
|
||||
};
|
||||
}
|
||||
|
||||
this.updateDisplay();
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
updateDisplay() {
|
||||
const container = this.shadowRoot.getElementById('tags');
|
||||
const placeholder = this.shadowRoot.getElementById('placeholder');
|
||||
const optionsElements = this.querySelectorAll('[slot="option"]');
|
||||
|
||||
// Керування видимістю плейсхолдера
|
||||
if (this._selectedValues.size > 0) {
|
||||
placeholder.style.display = 'none';
|
||||
} else {
|
||||
placeholder.style.display = 'block';
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
this._selectedValues.forEach(val => {
|
||||
// Важливо: при пошуку елемента в DOM атрибут data-value завжди рядок,
|
||||
// тому використовуємо == для порівняння числа з рядком
|
||||
const opt = Array.from(optionsElements).find(o => o.getAttribute('data-value') == val);
|
||||
if (opt) {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'chip';
|
||||
chip.innerHTML = `${opt.textContent} <button><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26"><path d="M 6.65625 4 C 6.367188 4 6.105469 4.113281 5.90625 4.3125 L 4.3125 5.90625 C 3.914063 6.304688 3.914063 7 4.3125 7.5 L 9.8125 13 L 4.3125 18.5 C 3.914063 19 3.914063 19.695313 4.3125 20.09375 L 5.90625 21.6875 C 6.40625 22.085938 7.101563 22.085938 7.5 21.6875 L 13 16.1875 L 18.5 21.6875 C 19 22.085938 19.695313 22.085938 20.09375 21.6875 L 21.6875 20.09375 C 22.085938 19.59375 22.085938 18.898438 21.6875 18.5 L 16.1875 13 L 21.6875 7.5 C 22.085938 7 22.085938 6.304688 21.6875 5.90625 L 20.09375 4.3125 C 19.59375 3.914063 18.898438 3.914063 18.5 4.3125 L 13 9.8125 L 7.5 4.3125 C 7.25 4.113281 6.945313 4 6.65625 4 Z"></path></svg></button>`;
|
||||
chip.querySelector('button').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleValue(val);
|
||||
};
|
||||
container.appendChild(chip);
|
||||
}
|
||||
});
|
||||
|
||||
const max = this.getAttribute('max') ? parseInt(this.getAttribute('max')) : null;
|
||||
const isFull = max && this._selectedValues.size >= max;
|
||||
|
||||
optionsElements.forEach(opt => {
|
||||
const optVal = this._formatValue(opt.getAttribute('data-value'));
|
||||
const isSelected = this._selectedValues.has(optVal);
|
||||
opt.classList.toggle('selected', isSelected);
|
||||
|
||||
// Если лимит исчерпан, делаем невыбранные опции полупрозрачными
|
||||
if (isFull && !isSelected) {
|
||||
opt.style.opacity = '0.5';
|
||||
opt.style.cursor = 'not-allowed';
|
||||
} else {
|
||||
opt.style.opacity = '1';
|
||||
opt.style.cursor = 'pointer';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get value() {
|
||||
return Array.from(this._selectedValues);
|
||||
}
|
||||
|
||||
get getClick() {
|
||||
return this._click;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<div class="wrapper">
|
||||
<div class="trigger" id="tags"></div>
|
||||
<div id="placeholder" class="placeholder-text">
|
||||
${this.getAttribute('placeholder') || 'Оберіть значення...'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<input type="search" placeholder="Пошук...">
|
||||
<div class="options-list">
|
||||
<slot name="option"></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('smart-select', SmartSelect);
|
||||
234
web/lib/customElements/swipeUpdater.js
Normal file
234
web/lib/customElements/swipeUpdater.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Вебкомпонент для ініціації оновлення сторінки (Pull-to-Refresh)
|
||||
* за допомогою свайпу вниз на пристроях з iOS/iPadOS у режимі PWA.
|
||||
*/
|
||||
class SwipeUpdater extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// 1. Створення Shadow DOM
|
||||
// Використовуємо тіньовий DOM для інкапсуляції стилів та структури
|
||||
const shadow = this.attachShadow({ mode: 'open' });
|
||||
|
||||
// 2. Внутрішня функція оновлення за замовчуванням
|
||||
this._appReload = () => {
|
||||
console.log('Стандартна функція: Перезавантаження сторінки');
|
||||
// Стандартна дія - перезавантаження сторінки
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// 3. Внутрішній стан
|
||||
this._isReadyToReload = false; // Прапорець, що вказує на готовність до оновлення
|
||||
|
||||
// 4. Створення елементів (Внутрішній HTML)
|
||||
shadow.innerHTML = `
|
||||
<div id="swipe_updater">
|
||||
<div id="swipe_block">
|
||||
<svg id="swipe_icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" data-state="active">
|
||||
<path d="M413.1 222.5l22.2 22.2c9.4 9.4 9.4 24.6 0 33.9L241 473c-9.4 9.4-24.6 9.4-33.9 0L12.7 278.6c-9.4-9.4-9.4-24.6 0-33.9l22.2-22.2c9.5-9.5 25-9.3 34.3.4L184 343.4V56c0-13.3 10.7-24 24-24h32c13.3 0 24 10.7 24 24v287.4l114.8-120.5c9.3-9.8 24.8-10 34.3-.4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:host {
|
||||
display: block; /* Важливо для позиціонування кореневого елемента */
|
||||
}
|
||||
|
||||
#swipe_updater {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
width: 100%;
|
||||
z-index: 0; /* Базовий z-index */
|
||||
/* Використання CSS-змінних для кастомізації кольорів */
|
||||
--swipe-color-theme1: var(--ColorThemes2, #525151); /* Колір фону іконки */
|
||||
--swipe-color-theme2: var(--ColorThemes3, #f3f3f3); /* Колір іконки та рамки */
|
||||
}
|
||||
|
||||
#swipe_block {
|
||||
/* Розрахунок ширини та відступу для центрифікації в певних макетах */
|
||||
width: calc(100% - 252px);
|
||||
margin-left: 252px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#swipe_icon {
|
||||
width: 20px;
|
||||
fill: var(--swipe-color-theme2);
|
||||
transform: rotate(0deg); /* Початковий стан: стрілка вниз */
|
||||
position: absolute;
|
||||
/* Початкове приховане позиціонування */
|
||||
margin-top: -30px;
|
||||
top: -30px;
|
||||
background: var(--swipe-color-theme1);
|
||||
border: 2px solid var(--swipe-color-theme2);
|
||||
border-radius: 50%;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
/* Анімація: прихована іконка плавно з'являється (активується/деактивується) */
|
||||
transition: height 0ms 450ms, opacity 450ms 0ms, transform 450ms;
|
||||
}
|
||||
|
||||
#swipe_icon[data-state="active"] {
|
||||
height: 20px;
|
||||
margin-top: -45px;
|
||||
top: -45px;
|
||||
opacity: 1;
|
||||
/* Анімація: активна іконка видима */
|
||||
transition: height 0ms 0ms, opacity 450ms 0ms, transform 450ms;
|
||||
}
|
||||
|
||||
/* Адаптивні стилі для зміни центрифікації на різних екранах */
|
||||
@media (max-width: 1100px){
|
||||
#swipe_block {
|
||||
width: calc(100% - 122px);
|
||||
margin-left: 122px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px), (max-height: 540px) {
|
||||
#swipe_block {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
/* Зміна кольорів для менших екранів */
|
||||
#swipe_updater {
|
||||
--swipe-color-theme1: var(--ColorThemes0, #525151);
|
||||
--swipe-color-theme2: var(--ColorThemes3, #f3f3f3);;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// 5. Збереження посилань на елементи Shadow DOM
|
||||
this._animID = shadow.getElementById('swipe_updater');
|
||||
this._animIconID = shadow.getElementById('swipe_icon');
|
||||
|
||||
// 6. Прив'язка контексту `this` для обробника подій (важливо для коректної роботи `this.handleScroll`)
|
||||
this.handleScroll = this.handleScroll.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Метод для встановлення користувацької функції оновлення.
|
||||
* Замінює стандартне перезавантаження сторінки.
|
||||
* @param {function} func - Користувацька функція, що буде викликана при свайпі.
|
||||
*/
|
||||
setReloadFunction(func) {
|
||||
if (typeof func === 'function') {
|
||||
this._appReload = func;
|
||||
} else {
|
||||
console.error('setReloadFunction вимагає передати функцію.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обробник події скролу (головна логіка Pull-to-Refresh).
|
||||
* Відстежує прокручування вище верхньої межі сторінки (`window.scrollY < 0`).
|
||||
*/
|
||||
handleScroll() {
|
||||
// Перевірка на режим Standalone (PWA) - функціональність актуальна переважно тут
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
|
||||
|
||||
if (isStandalone) {
|
||||
let scrollY = window.scrollY;
|
||||
|
||||
// 1. Анімація іконки під час прокручування за верхню межу (scrollY < 0)
|
||||
if (scrollY <= -10) {
|
||||
// Зміщення іконки разом із прокруткою
|
||||
this._animIconID.style.top = `${scrollY / 1.5}px`;
|
||||
this._animID.style.zIndex = '115'; // Піднімаємо z-index для видимості над контентом
|
||||
} else {
|
||||
this._animID.style.zIndex = '0'; // Повертаємо базовий z-index
|
||||
}
|
||||
|
||||
const threshold = -125; // Поріг прокрутки (наприклад, -125px) для активації оновлення
|
||||
|
||||
// 2. Логіка активації "готовий до оновлення"
|
||||
if (scrollY <= threshold) {
|
||||
if (!this._isReadyToReload) {
|
||||
this._isReadyToReload = true;
|
||||
// Поворот іконки на 180 градусів
|
||||
this._animIconID.style.transform = 'rotate(180deg)';
|
||||
this._animIconID.setAttribute('data-state', ''); // Деактивація стану "active"
|
||||
}
|
||||
}
|
||||
// 3. Логіка виклику оновлення та скидання стану
|
||||
// Якщо користувач відпускає свайп (scrollY повертається до >= 0) І був готовий до оновлення
|
||||
else if (scrollY >= 0) {
|
||||
if (this._isReadyToReload) {
|
||||
// Виклик користувацької функції (або стандартного window.location.reload())
|
||||
this._appReload();
|
||||
|
||||
// Скидання стану та анімації
|
||||
this._isReadyToReload = false;
|
||||
this._animIconID.style.transform = 'rotate(0deg)';
|
||||
this._animIconID.setAttribute('data-state', 'active');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Lifecycle hook: викликається при додаванні елемента в DOM.
|
||||
* Додаємо обробник події прокручування.
|
||||
*/
|
||||
connectedCallback() {
|
||||
// Прослуховування глобальної події скролу
|
||||
window.addEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: викликається при видаленні елемента з DOM.
|
||||
* Видаляємо обробник події прокручування для запобігання витоку пам'яті.
|
||||
*/
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
}
|
||||
|
||||
// Реєстрація веб-компонента
|
||||
customElements.define('swipe-updater', SwipeUpdater);
|
||||
|
||||
|
||||
/*
|
||||
============================
|
||||
ПРИКЛАД ВИКОРИСТАННЯ
|
||||
============================
|
||||
*/
|
||||
|
||||
/*
|
||||
1. Додайте цей елемент у свій HTML:
|
||||
<swipe-updater id="swipe-updater"></swipe-updater>
|
||||
|
||||
2. Отримайте посилання на компонент у JS:
|
||||
const Updater = document.querySelector('swipe-updater');
|
||||
|
||||
3. Користувацька функція оновлення
|
||||
function customReload() {
|
||||
const now = new Date().toLocaleTimeString();
|
||||
console.log(`Користувацьке оновлення: оновлено о ${now}`);
|
||||
document.querySelector('h1').textContent = `Сторінка оновлена о ${now}`;
|
||||
// Тут можна виконати AJAX-запит, оновити DOM тощо.
|
||||
}
|
||||
|
||||
4. Перевизначення функції оновлення компонента
|
||||
if (Updater && Updater.setReloadFunction) {
|
||||
Updater.setReloadFunction(customReload);
|
||||
} else {
|
||||
console.error('Компонент SwipeUpdater не знайдено або не готовий.');
|
||||
}
|
||||
|
||||
|
||||
💡 Приклад стандартного використання (якщо не викликати setReloadFunction):
|
||||
<swipe-updater></swipe-updater>
|
||||
При свайпі буде викликано window.location.reload();
|
||||
|
||||
*/
|
||||
@@ -1,5 +1,5 @@
|
||||
const appTerritoryCardStyles = new CSSStyleSheet();
|
||||
appTerritoryCardStyles.replaceSync(`
|
||||
// Заміна вмісту таблиці стилів на надані CSS-правила.
|
||||
const CARD_STYLES_CSS = `
|
||||
:host {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
@@ -54,14 +54,15 @@ appTerritoryCardStyles.replaceSync(`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: blur(3px);
|
||||
border-radius: calc(var(--border-radius, 15px) - 5px);
|
||||
}
|
||||
.contents {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
filter: blur(3px);
|
||||
}
|
||||
.contents {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background: rgb(64 64 64 / 0.7);
|
||||
width: 100%;
|
||||
@@ -89,7 +90,7 @@ appTerritoryCardStyles.replaceSync(`
|
||||
|
||||
|
||||
|
||||
/* Стили для режима 'sheep' */
|
||||
/* Стилі для режиму 'sheep' */
|
||||
.sheep {
|
||||
margin: 10px;
|
||||
max-height: 50px;
|
||||
@@ -116,7 +117,7 @@ appTerritoryCardStyles.replaceSync(`
|
||||
}
|
||||
|
||||
|
||||
/* Стили для режима 'info' (прогресс) */
|
||||
/* Стилі для режиму 'info' (прогресс) */
|
||||
.info {
|
||||
margin: 10px;
|
||||
}
|
||||
@@ -133,7 +134,7 @@ appTerritoryCardStyles.replaceSync(`
|
||||
}
|
||||
.info span {
|
||||
z-index: 2;
|
||||
font-size: var(--FontSize1, 12px);
|
||||
font-size: var(--FontSize3, 14px);
|
||||
color: var(--ColorThemes3, #f3f3f3);
|
||||
}
|
||||
.info p {
|
||||
@@ -158,24 +159,45 @@ appTerritoryCardStyles.replaceSync(`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
border-radius: calc(var(--border-radius, 15px) - 5px);
|
||||
}
|
||||
`);
|
||||
`;
|
||||
|
||||
// Створення об'єкта CSSStyleSheet (якщо підтримується)
|
||||
let appTerritoryCardStyles = null;
|
||||
if (typeof CSSStyleSheet !== 'undefined' && CSSStyleSheet.prototype.replaceSync) {
|
||||
appTerritoryCardStyles = new CSSStyleSheet(); // (2) Визначення об'єкта тут
|
||||
appTerritoryCardStyles.replaceSync(CARD_STYLES_CSS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Веб-компонент AppTerritoryCard.
|
||||
* Відображає картку території з фоновим зображенням та різними режимами відображення.
|
||||
*/
|
||||
class AppTerritoryCard extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
|
||||
// Додаємо стилі в конструкторі, якщо це adoptable
|
||||
if (this.shadowRoot.adoptedStyleSheets) {
|
||||
this.shadowRoot.adoptedStyleSheets = [appTerritoryCardStyles];
|
||||
} else {
|
||||
// FALLBACK для старих браузерів (наприклад, iOS < 16.4)
|
||||
const style = document.createElement('style');
|
||||
style.textContent = CARD_STYLES_CSS;
|
||||
this.shadowRoot.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
// Определяем, какие атрибуты будем отслеживать
|
||||
// Вказуємо, які атрибути ми хочемо відстежувати
|
||||
static get observedAttributes() {
|
||||
return ['image', 'address', 'sheep', 'link', 'atWork', 'quantity'];
|
||||
return ['image', 'address', 'sheep', 'link', 'atWork', 'quantity', 'overdue'];
|
||||
}
|
||||
|
||||
// Геттери та Сеттери для атрибутів
|
||||
// Вони спрощують роботу з атрибутами як з властивостями DOM-елемента
|
||||
|
||||
get image() {
|
||||
return this.getAttribute('image');
|
||||
}
|
||||
@@ -198,6 +220,11 @@ class AppTerritoryCard extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** * Атрибут 'sheep' може приймати три стани:
|
||||
* 1. null / відсутній: відключення блоку sheep та info
|
||||
* 2. порожній рядок ('') / присутній без значення: Режим "Територія не опрацьовується"
|
||||
* 3. рядок зі значенням: Режим "Територію опрацьовує: [значення]"
|
||||
*/
|
||||
get sheep() {
|
||||
return this.getAttribute('sheep');
|
||||
}
|
||||
@@ -228,6 +255,7 @@ class AppTerritoryCard extends HTMLElement {
|
||||
if (newValue === null) {
|
||||
this.removeAttribute('atWork');
|
||||
} else {
|
||||
// Приводимо до рядка, оскільки атрибути завжди є рядками
|
||||
this.setAttribute('atWork', String(newValue));
|
||||
}
|
||||
}
|
||||
@@ -239,41 +267,74 @@ class AppTerritoryCard extends HTMLElement {
|
||||
if (newValue === null) {
|
||||
this.removeAttribute('quantity');
|
||||
} else {
|
||||
// Приводимо до рядка
|
||||
this.setAttribute('quantity', String(newValue));
|
||||
}
|
||||
}
|
||||
|
||||
// Вызывается при добавлении элемента в DOM
|
||||
get overdue() {
|
||||
return this.getAttribute('address');
|
||||
}
|
||||
set overdue(newValue) {
|
||||
if (newValue === null) {
|
||||
this.removeAttribute('overdue');
|
||||
} else {
|
||||
this.setAttribute('overdue', newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* connectedCallback викликається, коли елемент додається в DOM.
|
||||
* Тут ми викликаємо початкове рендеринг.
|
||||
*/
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
// Вызывается при изменении одного из отслеживаемых атрибутов
|
||||
/**
|
||||
* attributeChangedCallback викликається при зміні одного зі спостережуваних атрибутів.
|
||||
*/
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if (oldValue !== newValue) {
|
||||
this.render();
|
||||
this.render(); // Перерендеринг при зміні атрибута
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Логіка рендерингу (відображення) вмісту компонента.
|
||||
*/
|
||||
render() {
|
||||
const image = this.getAttribute('image') || '';
|
||||
const address = this.getAttribute('address') || '';
|
||||
const sheep = this.getAttribute('sheep'); // Может быть null или ""
|
||||
const sheep = this.getAttribute('sheep');
|
||||
const link = this.getAttribute('link') || '#';
|
||||
const atWork = this.getAttribute('atWork'); // Может быть null
|
||||
const quantity = this.getAttribute('quantity'); // Может быть null
|
||||
const atWork = this.getAttribute('atWork');
|
||||
const quantity = this.getAttribute('quantity');
|
||||
const overdue = this.getAttribute('overdue') == 'true' ? true : false;
|
||||
|
||||
this.shadowRoot.innerHTML = ``;
|
||||
|
||||
// Додаємо стилі для старих браузерів
|
||||
if (!this.shadowRoot.adoptedStyleSheets) {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = CARD_STYLES_CSS;
|
||||
this.shadowRoot.appendChild(style);
|
||||
}
|
||||
|
||||
|
||||
// --- Логика определения контента ---
|
||||
let contentHTML = '';
|
||||
|
||||
// Перевіряємо, чи має бути увімкнений режим прогресу ('info'):
|
||||
// обидва атрибути 'atWork' та 'quantity' присутні і є коректними числами.
|
||||
const isProgressMode = atWork !== null && quantity !== null && !isNaN(parseInt(atWork)) && !isNaN(parseInt(quantity));
|
||||
const hasSheep = sheep !== null && sheep !== '';
|
||||
|
||||
if (isProgressMode) {
|
||||
// Режим прогресса (свободные подъезды)
|
||||
// Режим прогресу (вільні під'їзди)
|
||||
const atWorkNum = parseInt(atWork);
|
||||
const quantityNum = parseInt(quantity);
|
||||
|
||||
const free = quantityNum - atWorkNum;
|
||||
// Обчислення відсотка прогресу. Уникнення ділення на нуль.
|
||||
const progressPercent = quantityNum > 0 ? (atWorkNum / quantityNum) * 100 : 100;
|
||||
|
||||
contentHTML = `
|
||||
@@ -286,15 +347,15 @@ class AppTerritoryCard extends HTMLElement {
|
||||
</div>
|
||||
`;
|
||||
} else if (sheep !== null && sheep !== '') {
|
||||
// Режим ответственного
|
||||
// Режим опрацювання (значення атрибута 'sheep' є ім'ям опрацювача)
|
||||
contentHTML = `
|
||||
<div class="sheep">
|
||||
<div class="sheep" ${overdue ? `style="background: #bb4444;"` : ``}>
|
||||
<span>Територію опрацьовує:</span>
|
||||
<p>${sheep}</p>
|
||||
</div>
|
||||
`;
|
||||
} else if (sheep !== null) {
|
||||
// Режим "не опрацьовується"
|
||||
} else if (sheep !== null && sheep === '') {
|
||||
// Режим "не опрацьовується" (атрибут 'sheep' присутній, але порожній)
|
||||
contentHTML = `
|
||||
<div class="sheep">
|
||||
<span>Територія не опрацьовується</span>
|
||||
@@ -302,9 +363,9 @@ class AppTerritoryCard extends HTMLElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Сборка всего шаблона ---
|
||||
this.shadowRoot.innerHTML = `
|
||||
<div class="card">
|
||||
// --- Складання всього шаблону ---
|
||||
this.shadowRoot.innerHTML += `
|
||||
<div class="card" ${overdue ? `title="Термін опрацювання минув!"` : ``}>
|
||||
<img src="${image}" alt="${address}" />
|
||||
<div class="contents">
|
||||
<h1 class="address">${address}</h1>
|
||||
@@ -316,8 +377,42 @@ class AppTerritoryCard extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Регистрируем веб-компонент
|
||||
// Реєструємо веб-компонент у браузері
|
||||
customElements.define('app-territory-card', AppTerritoryCard);
|
||||
|
||||
// document.getElementById('app-territory-card-1').setAttribute('sheep', 'test')
|
||||
|
||||
/*
|
||||
============================
|
||||
ПРИКЛАД ВИКОРИСТАННЯ
|
||||
============================
|
||||
*/
|
||||
|
||||
/*
|
||||
<app-territory-card
|
||||
address="Вул. Прикладна, 15А"
|
||||
image="https://example.com/images/territory-1.jpg"
|
||||
link="/territory/15a"
|
||||
atWork="12"
|
||||
quantity="20"
|
||||
></app-territory-card>
|
||||
|
||||
<app-territory-card
|
||||
address="Просп. Науковий, 5"
|
||||
image="https://example.com/images/territory-2.jpg"
|
||||
link="/territory/naukovyi-5"
|
||||
sheep="Іван Петренко"
|
||||
></app-territory-card>
|
||||
|
||||
<app-territory-card
|
||||
address="Майдан Свободи, 1"
|
||||
image="https://example.com/images/territory-3.jpg"
|
||||
link="/territory/svobody-1"
|
||||
sheep=""
|
||||
></app-territory-card>
|
||||
|
||||
<app-territory-card
|
||||
address="Вул. Безіменна, 99"
|
||||
image="https://example.com/images/territory-4.jpg"
|
||||
link="/territory/bezymenna-99"
|
||||
></app-territory-card>
|
||||
*/
|
||||
@@ -2,7 +2,7 @@
|
||||
<form id="page-auth-form">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
type="password"
|
||||
name="uuid"
|
||||
id="auth-forms-uuid"
|
||||
placeholder="UUID"
|
||||
|
||||
@@ -6,12 +6,10 @@ const Auth = {
|
||||
document.getElementById("page-auth-form").addEventListener("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
let uuid = document.getElementById("auth-forms-uuid").value;
|
||||
|
||||
uuid = uuid.replace("https://sheep-service.com/?uuid=", "");
|
||||
uuid = uuid.replace("https://sheep-service.com?uuid=", "");
|
||||
uuid = uuid.replace("https://sheep-service.com?/hash=", "");
|
||||
uuid = uuid.replace("https://sheep-service.com?hash=", "");
|
||||
const uuid = document
|
||||
.getElementById("auth-forms-uuid")
|
||||
.value
|
||||
.replace(/^https?:\/\/sheep-service\.com\/?\?(uuid|hash)=/, "");
|
||||
|
||||
console.log(uuid);
|
||||
|
||||
@@ -38,11 +36,47 @@ const Auth = {
|
||||
console.log("USER Info: ", USER);
|
||||
|
||||
|
||||
if (USER.possibilities.can_view_sheeps) document.getElementById("li-sheeps").style.display = "";
|
||||
if (USER.possibilities.can_add_schedule) document.getElementById("li-schedule").style.display = "";
|
||||
if (USER.possibilities.can_manager_territory) document.getElementById("li-territory").style.display = "";
|
||||
if (USER.possibilities.can_view_stand) document.getElementById("li-stand").style.display = "";
|
||||
document.getElementById("li-options").style.display = "";
|
||||
if (USER.possibilities.can_view_stand) {
|
||||
newMenuItems({
|
||||
id: 'menu-stand',
|
||||
title: 'Графік стенду',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path d="M 6.9707031 4 C 6.8307031 4 6.6807813 4.039375 6.5507812 4.109375 L 2.5507812 6.109375 C 2.0607813 6.349375 1.859375 6.9492188 2.109375 7.4492188 C 2.349375 7.9392188 2.9492187 8.140625 3.4492188 7.890625 L 6.4902344 6.3691406 L 12.5 20.650391 C 12.73 21.180391 13.040156 21.650547 13.410156 22.060547 C 12.040156 22.340547 11 23.56 11 25 C 11 26.65 12.35 28 14 28 C 15.65 28 17 26.65 17 25 C 17 24.52 16.869922 24.070156 16.669922 23.660156 C 17.479922 23.740156 18.319141 23.639062 19.119141 23.289062 L 26.400391 20.099609 C 26.910391 19.889609 27.159219 19.310781 26.949219 18.800781 C 26.749219 18.290781 26.160391 18.040234 25.650391 18.240234 C 25.630391 18.250234 25.619609 18.259531 25.599609 18.269531 L 18.320312 21.460938 C 16.770312 22.130938 14.999609 21.429141 14.349609 19.869141 L 7.9199219 4.609375 C 7.7599219 4.229375 7.3807031 3.99 6.9707031 4 z M 21.359375 8.0605469 C 21.229375 8.0605469 21.100703 8.090625 20.970703 8.140625 L 13.609375 11.269531 C 13.099375 11.479531 12.860078 12.070078 13.080078 12.580078 L 16.029297 19.179688 C 16.249297 19.689688 16.829844 19.930937 17.339844 19.710938 L 24.710938 16.589844 C 25.210938 16.369844 25.450234 15.789297 25.240234 15.279297 L 22.279297 8.6699219 C 22.119297 8.2899219 21.749375 8.0605469 21.359375 8.0605469 z M 14 24 C 14.56 24 15 24.44 15 25 C 15 25.56 14.56 26 14 26 C 13.44 26 13 25.56 13 25 C 13 24.44 13.44 24 14 24 z"/></svg>`,
|
||||
href: '/stand'
|
||||
});
|
||||
}
|
||||
if (USER.possibilities.can_view_schedule) {
|
||||
newMenuItems({
|
||||
id: 'menu-schedule',
|
||||
title: 'Графіки зібрань',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M47 23c3.314 0 6 2.686 6 6v17c0 3.309-2.691 6-6 6H17c-3.309 0-6-2.691-6-6V29c0-3.314 2.686-6 6-6H47zM22 45v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C21.552 46 22 45.552 22 45zM22 38v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C21.552 39 22 38.552 22 38zM30 45v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C29.552 46 30 45.552 30 45zM30 38v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C29.552 39 30 38.552 30 38zM30 31v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C29.552 32 30 31.552 30 31zM38 45v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C37.552 46 38 45.552 38 45zM38 38v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C37.552 39 38 38.552 38 38zM38 31v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C37.552 32 38 31.552 38 31zM46 38v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C45.552 39 46 38.552 46 38zM46 31v-2c0-.552-.448-1-1-1h-2c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1h2C45.552 32 46 31.552 46 31zM17 20c-2.308 0-4.407.876-6 2.305V18c0-3.309 2.691-6 6-6h30c3.309 0 6 2.691 6 6v4.305C51.407 20.876 49.308 20 47 20H17z"/></svg>`,
|
||||
href: '/schedule'
|
||||
});
|
||||
}
|
||||
if (USER.possibilities.can_view_sheeps) {
|
||||
newMenuItems({
|
||||
id: 'menu-sheeps',
|
||||
title: 'Вісники',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M 42.5 14 C 37.813 14 34 18.038 34 23 C 34 27.962 37.813 32 42.5 32 C 47.187 32 51 27.962 51 23 C 51 18.038 47.187 14 42.5 14 z M 21.5 17 C 16.813 17 13 21.038 13 26 C 13 30.962 16.813 35 21.5 35 C 26.187 35 30 30.962 30 26 C 30 21.038 26.187 17 21.5 17 z M 42.5 18 C 44.981 18 47 20.243 47 23 C 47 25.757 44.981 28 42.5 28 C 40.019 28 38 25.757 38 23 C 38 20.243 40.019 18 42.5 18 z M 42.498047 34.136719 C 37.579021 34.136719 33.07724 35.947963 30.054688 38.962891 C 27.67058 37.796576 24.915421 37.136719 22 37.136719 C 14.956 37.136719 8.8129375 40.942422 6.7109375 46.607422 C 5.7409375 49.220422 7.7121406 52 10.494141 52 L 33.505859 52 C 35.43112 52 36.95694 50.674804 37.404297 49 L 53.431641 49 C 56.437641 49 59.121453 45.844281 57.564453 42.613281 C 55.084453 37.463281 49.169047 34.136719 42.498047 34.136719 z M 42.5 38.136719 C 47.565 38.136719 52.171937 40.633609 53.960938 44.349609 C 54.119938 44.687609 53.741687 45 53.429688 45 L 36.544922 45 C 35.777257 43.585465 34.746773 42.317451 33.503906 41.234375 C 35.78496 39.306575 39.034912 38.136719 42.5 38.136719 z" /></svg>`,
|
||||
href: '/sheeps',
|
||||
hidden: true
|
||||
});
|
||||
await Sheeps.sheeps_list.loadAPI();
|
||||
}
|
||||
if (USER.possibilities.can_manager_territory) {
|
||||
newMenuItems({
|
||||
id: 'menu-territory',
|
||||
title: 'Території',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path d="M24 2H14c-.55 0-1 .45-1 1v4l3.6 2.7c.25.19.4.49.4.8V14h8V3C25 2.45 24.55 2 24 2zM15.5 7C15.22 7 15 6.78 15 6.5v-2C15 4.22 15.22 4 15.5 4h2C17.78 4 18 4.22 18 4.5v2C18 6.78 17.78 7 17.5 7h-1.17H15.5zM23 4.5v2C23 6.78 22.78 7 22.5 7h-2C20.22 7 20 6.78 20 6.5v-2C20 4.22 20.22 4 20.5 4h2C22.78 4 23 4.22 23 4.5zM22.5 12h-2c-.28 0-.5-.22-.5-.5v-2C20 9.22 20.22 9 20.5 9h2C22.78 9 23 9.22 23 9.5v2C23 11.78 22.78 12 22.5 12zM1 11.51V27c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V11.51c0-.32-.16-.62-.42-.81l-6-4.28C8.41 6.29 8.2 6.23 8 6.23S7.59 6.29 7.42 6.42l-6 4.28C1.16 10.89 1 11.19 1 11.51zM6.5 20h-2C4.22 20 4 19.78 4 19.5v-2C4 17.22 4.22 17 4.5 17h2C6.78 17 7 17.22 7 17.5v2C7 19.78 6.78 20 6.5 20zM7 22.5v2C7 24.78 6.78 25 6.5 25h-2C4.22 25 4 24.78 4 24.5v-2C4 22.22 4.22 22 4.5 22h2C6.78 22 7 22.22 7 22.5zM6.5 15h-2C4.22 15 4 14.78 4 14.5v-2C4 12.22 4.22 12 4.5 12h2C6.78 12 7 12.22 7 12.5v2C7 14.78 6.78 15 6.5 15zM9.5 17h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2C9.22 20 9 19.78 9 19.5v-2C9 17.22 9.22 17 9.5 17zM9 14.5v-2C9 12.22 9.22 12 9.5 12h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2C9.22 15 9 14.78 9 14.5zM9.5 22h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2C9.22 25 9 24.78 9 24.5v-2C9 22.22 9.22 22 9.5 22zM17 17v10c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V17c0-.55-.45-1-1-1H18C17.45 16 17 16.45 17 17zM19.5 18h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2c-.28 0-.5-.22-.5-.5v-2C19 18.22 19.22 18 19.5 18zM27 18.5v2c0 .28-.22.5-.5.5h-2c-.28 0-.5-.22-.5-.5v-2c0-.28.22-.5.5-.5h2C26.78 18 27 18.22 27 18.5zM26.5 26h-2c-.28 0-.5-.22-.5-.5v-2c0-.28.22-.5.5-.5h2c.28 0 .5.22.5.5v2C27 25.78 26.78 26 26.5 26zM19.5 23h2c.28 0 .5.22.5.5v2c0 .28-.22.5-.5.5h-2c-.28 0-.5-.22-.5-.5v-2C19 23.22 19.22 23 19.5 23z"/></svg>`,
|
||||
href: '/territory',
|
||||
hidden: true
|
||||
});
|
||||
}
|
||||
newMenuItems({
|
||||
id: 'menu-options',
|
||||
title: 'Опції',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 172 172"><path d="M75.18001,14.33333c-3.43283,0 -6.36736,2.42659 -7.02669,5.79492l-2.39355,12.28971c-5.8821,2.22427 -11.32102,5.33176 -16.097,9.25228l-11.78581,-4.05924c-3.2465,-1.118 -6.81841,0.22441 -8.53841,3.19141l-10.80599,18.72852c-1.71283,2.97417 -1.08945,6.74999 1.49772,9.00033l9.44824,8.21647c-0.49137,3.0197 -0.81185,6.09382 -0.81185,9.25228c0,3.15846 0.32048,6.23258 0.81185,9.25228l-9.44824,8.21647c-2.58717,2.25033 -3.21055,6.02616 -1.49772,9.00032l10.80599,18.72852c1.71283,2.97417 5.29191,4.31623 8.53841,3.2054l11.78581,-4.05924c4.77441,3.91806 10.21756,7.01501 16.097,9.23828l2.39355,12.28972c0.65933,3.36833 3.59386,5.79492 7.02669,5.79492h21.63998c3.43283,0 6.36735,-2.42659 7.02669,-5.79492l2.39356,-12.28972c5.88211,-2.22427 11.32102,-5.33176 16.097,-9.25227l11.78581,4.05924c3.2465,1.118 6.81841,-0.21724 8.53841,-3.1914l10.80599,-18.74252c1.71284,-2.97417 1.08945,-6.73599 -1.49772,-8.98633l-9.44824,-8.21647c0.49137,-3.0197 0.81185,-6.09382 0.81185,-9.25228c0,-3.15846 -0.32048,-6.23258 -0.81185,-9.25228l9.44824,-8.21647c2.58717,-2.25033 3.21056,-6.02616 1.49772,-9.00033l-10.80599,-18.72852c-1.71283,-2.97417 -5.29191,-4.31624 -8.53841,-3.2054l-11.78581,4.05924c-4.7744,-3.91806 -10.21755,-7.01501 -16.097,-9.23828l-2.39356,-12.28971c-0.65933,-3.36833 -3.59385,-5.79492 -7.02669,-5.79492zM86,57.33333c15.83117,0 28.66667,12.8355 28.66667,28.66667c0,15.83117 -12.8355,28.66667 -28.66667,28.66667c-15.83117,0 -28.66667,-12.8355 -28.66667,-28.66667c0,-15.83117 12.8355,-28.66667 28.66667,-28.66667z"/></svg>`,
|
||||
href: '/options'
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -28,4 +28,12 @@
|
||||
|
||||
<div id="home-group-territory-list"></div>
|
||||
</details>
|
||||
|
||||
<details id="details-joint-territory" open style="display: none">
|
||||
<summary>
|
||||
<span>Тимчасові території</span>
|
||||
</summary>
|
||||
|
||||
<div id="home-joint-territory-list"></div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ const Home = {
|
||||
|
||||
Home.group.house.setHTML();
|
||||
Home.group.homestead.setHTML();
|
||||
|
||||
Home.joint.homestead.setHTML();
|
||||
}
|
||||
},
|
||||
personal: {
|
||||
@@ -119,6 +121,34 @@ const Home = {
|
||||
}
|
||||
}
|
||||
},
|
||||
joint: {
|
||||
homestead: {
|
||||
list: [],
|
||||
loadAPI: async () => {
|
||||
const uuid = localStorage.getItem("uuid");
|
||||
const URL = `${CONFIG.api}homestead/list?mode=joint`;
|
||||
const res = await fetch(URL, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": uuid
|
||||
}
|
||||
});
|
||||
Home.joint.homestead.list = await res.json();
|
||||
return Home.joint.homestead.list;
|
||||
},
|
||||
setHTML: async () => {
|
||||
const list = Home.joint.homestead.list.length > 0
|
||||
? Home.joint.homestead.list
|
||||
: await Home.joint.homestead.loadAPI();
|
||||
|
||||
if (USER.possibilities.can_view_territory && list.length)
|
||||
document.getElementById('details-joint-territory').style.display = "";
|
||||
|
||||
list.sort((a, b) => b.id - a.id);
|
||||
Home.renderCards(list, "homestead", "joint");
|
||||
}
|
||||
}
|
||||
},
|
||||
renderCards: (list, type, block) => {
|
||||
const container = document.getElementById(`home-${block}-territory-list`);
|
||||
const fragment = document.createDocumentFragment();
|
||||
@@ -126,7 +156,7 @@ const Home = {
|
||||
for (const el of list) {
|
||||
const card = document.createElement('app-territory-card');
|
||||
card.image = `${CONFIG.web}cards/${type}/${type === "house" ? "T" : "H"}${el.id}.webp`;
|
||||
card.address = `${el.title} ${el.number})`;
|
||||
card.address = `${el.title} ${el.number}`;
|
||||
card.link = `/territory/card/${type}/${el.id}`;
|
||||
fragment.appendChild(card);
|
||||
}
|
||||
|
||||
@@ -91,8 +91,9 @@
|
||||
|
||||
|
||||
.page-home #home-personal-territory-list,
|
||||
.page-home #home-group-territory-list {
|
||||
width: 100%;
|
||||
.page-home #home-group-territory-list,
|
||||
.page-home #home-joint-territory-list {
|
||||
width: calc(100% - 20px);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<div class="page-schedule-constructor">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,6 +3,12 @@ const Schedule_constructor = {
|
||||
let html = await fetch('/lib/pages/schedule/constructor/index.html').then((response) => response.text());
|
||||
app.innerHTML = html;
|
||||
|
||||
|
||||
const newItem = document.createElement('nav-item');
|
||||
const uniqueId = `nav-dynamic-${Date.now()}`;
|
||||
newItem.setAttribute('id', uniqueId);
|
||||
newItem.setAttribute('title', 'Динамічний Пункт');
|
||||
newItem.setAttribute('icon', '/img/0.svg');
|
||||
newItem.setAttribute('click', `alert('${uniqueId} clicked!')`);
|
||||
document.querySelector('navigation-container').appendChild(newItem);
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@
|
||||
id="sheep-editor-can_view_sheeps"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="sheep-editor-can_view_sheeps"> View Sheeps </label>
|
||||
<label for="sheep-editor-can_view_sheeps"> Перегляд списку вісників </label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<input
|
||||
@@ -130,7 +130,16 @@
|
||||
id="sheep-editor-can_add_sheeps"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="sheep-editor-can_add_sheeps"> Create Sheeps </label>
|
||||
<label for="sheep-editor-can_add_sheeps"> Додавання вісників </label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<input
|
||||
name="can_manager_sheeps"
|
||||
class="custom-checkbox"
|
||||
id="sheep-editor-can_manager_sheeps"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="sheep-editor-can_manager_sheeps"> Керування дозволами вісників </label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<input
|
||||
@@ -140,7 +149,7 @@
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="sheep-editor-can_add_territory">
|
||||
Create Territory
|
||||
Створення територій
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
@@ -151,7 +160,18 @@
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="sheep-editor-can_manager_territory">
|
||||
Manager Territory
|
||||
Керування територіями
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<input
|
||||
name="can_joint_territory"
|
||||
class="custom-checkbox"
|
||||
id="sheep-editor-can_joint_territory"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="sheep-editor-can_joint_territory">
|
||||
Спільний доступ до території
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
@@ -161,7 +181,7 @@
|
||||
id="sheep-editor-can_add_stand"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="sheep-editor-can_add_stand"> Create Stand </label>
|
||||
<label for="sheep-editor-can_add_stand"> Створення стендів </label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<input
|
||||
@@ -170,7 +190,7 @@
|
||||
id="sheep-editor-can_manager_stand"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="sheep-editor-can_manager_stand"> Manager Stand </label>
|
||||
<label for="sheep-editor-can_manager_stand"> Керування стендами </label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<input
|
||||
@@ -179,7 +199,7 @@
|
||||
id="sheep-editor-can_add_schedule"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="sheep-editor-can_add_schedule"> Create Schedule </label>
|
||||
<label for="sheep-editor-can_add_schedule"> Створення розкладу зібрань </label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,7 +214,7 @@
|
||||
id="sheep-editor-can_view_schedule"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="sheep-editor-can_view_schedule"> View Schedule </label>
|
||||
<label for="sheep-editor-can_view_schedule"> Перегляд розкладу зібрань </label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<input
|
||||
@@ -203,7 +223,7 @@
|
||||
id="sheep-editor-can_view_stand"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="sheep-editor-can_view_stand"> View Stand </label>
|
||||
<label for="sheep-editor-can_view_stand"> Перегляд стендів </label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<input
|
||||
@@ -213,7 +233,7 @@
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="sheep-editor-can_view_territory">
|
||||
View Territory
|
||||
Перегляд територій
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,8 @@ const SheepsEvents = {
|
||||
const sheepEditorButton = document.getElementById('sheep-editor-button');
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
console.log(formData);
|
||||
|
||||
const uuidValue = form.elements["uuid"].value;
|
||||
const sheep = Sheeps.sheeps_list.list.find(item => item.uuid === uuidValue);
|
||||
|
||||
@@ -50,7 +52,7 @@ const SheepsEvents = {
|
||||
|
||||
sheep.name = form.elements["name"].value;
|
||||
sheep.group_id = Number(formData.get("group_id"));
|
||||
sheep.mode = formData.get("mode");
|
||||
sheep.mode = formData.get("mode") || sheep.mode;
|
||||
sheep.mode_title = ["Користувач", "Модератор", "Адміністратор"][sheep.mode] || "Користувач";
|
||||
|
||||
const permKeys = [
|
||||
@@ -59,8 +61,10 @@ const SheepsEvents = {
|
||||
"can_view_stand",
|
||||
"can_view_territory",
|
||||
"can_add_sheeps",
|
||||
"can_manager_sheeps",
|
||||
"can_add_territory",
|
||||
"can_manager_territory",
|
||||
"can_joint_territory",
|
||||
"can_add_stand",
|
||||
"can_manager_stand",
|
||||
"can_add_schedule"
|
||||
@@ -73,8 +77,7 @@ const SheepsEvents = {
|
||||
|
||||
try {
|
||||
const uuid = localStorage.getItem('uuid');
|
||||
const URL = `${CONFIG.api}sheep`;
|
||||
const response = await fetch(URL, {
|
||||
const response = await fetch(`${CONFIG.api}sheep`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -84,26 +87,23 @@ const SheepsEvents = {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
sheepEditorButton.innerText = "Успішно збережено";
|
||||
sheepEditorButton.innerText = "Зберегти";
|
||||
Notifier.success("Успішно збережено!", {timeout: 2000})
|
||||
const data = await response.json();
|
||||
console.log(data);
|
||||
|
||||
Sheeps.sheeps_list.list = [];
|
||||
await Sheeps.sheeps_list.setHTML();
|
||||
|
||||
setTimeout(() => {
|
||||
sheepEditorButton.innerText = "Зберегти";
|
||||
}, 3000);
|
||||
} else {
|
||||
sheepEditorButton.innerText = "Зберегти";
|
||||
console.error('Помилка збереження');
|
||||
sheepEditorButton.innerText = "Помилка збереження";
|
||||
Notifier.error("Помилка збереження!", {timeout: 3000});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
sheepEditorButton.innerText = "Помилка збереження";
|
||||
Notifier.error("Помилка збереження!", {timeout: 3000});
|
||||
}
|
||||
|
||||
// тот же код, что был в _onSheepEditorSubmit, но обращаемся к editorForm
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -120,8 +120,7 @@ const SheepsEvents = {
|
||||
|
||||
try {
|
||||
const uuid = localStorage.getItem('uuid');
|
||||
const URL = `${CONFIG.api}sheep`;
|
||||
const response = await fetch(URL, {
|
||||
const response = await fetch(`${CONFIG.api}sheep`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -131,7 +130,7 @@ const SheepsEvents = {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
sheepAddedsButton.innerText = "Вісника додано";
|
||||
sheepAddedsButton.innerText = "Додати";
|
||||
const data = await response.json();
|
||||
console.log(data);
|
||||
|
||||
@@ -141,17 +140,14 @@ const SheepsEvents = {
|
||||
|
||||
Sheeps.addeds.close();
|
||||
await Sheeps.editor.setHTML(data.id, randomNumber);
|
||||
|
||||
setTimeout(() => {
|
||||
sheepAddedsButton.innerText = "Додати";
|
||||
}, 3000);
|
||||
} else {
|
||||
sheepEditorButton.innerText = "Додати";
|
||||
console.error('Помилка додавання');
|
||||
sheepAddedsButton.innerText = "Помилка додавання";
|
||||
Notifier.error("Помилка додавання!", {timeout: 3000});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
sheepAddedsButton.innerText = "Помилка додавання";
|
||||
Notifier.error("Помилка додавання!", {timeout: 3000});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -176,9 +172,7 @@ const Sheeps = {
|
||||
loadAPI: async () => {
|
||||
let uuid = localStorage.getItem("uuid");
|
||||
|
||||
const URL = `${CONFIG.api}sheeps/list`;
|
||||
|
||||
Sheeps.sheeps_list.list = await fetch(URL, {
|
||||
Sheeps.sheeps_list.list = await fetch(`${CONFIG.api}sheeps/list`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -238,9 +232,11 @@ const Sheeps = {
|
||||
if (p.can_view_territory) perms.push("View Territory");
|
||||
if (p.can_add_sheeps) perms.push("Create Sheeps");
|
||||
if (p.can_add_territory) perms.push("Create Territory");
|
||||
if (p.can_manager_sheeps) perms.push("Manager Sheeps");
|
||||
if (p.can_manager_territory) perms.push("Manager Territory");
|
||||
if (p.can_add_stand) perms.push("Create Stand");
|
||||
if (p.can_manager_stand) perms.push("Manager Stand");
|
||||
if (p.can_joint_territory) perms.push("Joint Territory");
|
||||
if (p.can_add_stand) perms.push("Create Stand");
|
||||
if (p.can_add_schedule) perms.push("Create Schedule");
|
||||
return perms.map(p => `<b>${p}</b>`).join('');
|
||||
};
|
||||
@@ -274,8 +270,7 @@ const Sheeps = {
|
||||
loadAPI: async (id) => {
|
||||
let uuid = localStorage.getItem("uuid");
|
||||
|
||||
const URL = `${CONFIG.api}sheep?id=${id}`;
|
||||
return await fetch(URL, {
|
||||
return await fetch(`${CONFIG.api}sheep?id=${id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -327,8 +322,11 @@ const Sheeps = {
|
||||
|
||||
let sheep_editor_can_view_sheeps = document.getElementById('sheep-editor-can_view_sheeps');
|
||||
let sheep_editor_can_add_sheeps = document.getElementById('sheep-editor-can_add_sheeps');
|
||||
let sheep_editor_can_manager_sheeps = document.getElementById('sheep-editor-can_manager_sheeps');
|
||||
let sheep_editor_can_add_territory = document.getElementById('sheep-editor-can_add_territory');
|
||||
let sheep_editor_can_manager_territory = document.getElementById('sheep-editor-can_manager_territory');
|
||||
let sheep_editor_can_joint_territory = document.getElementById('sheep-editor-can_joint_territory');
|
||||
|
||||
let sheep_editor_can_add_stand = document.getElementById('sheep-editor-can_add_stand');
|
||||
let sheep_editor_can_manager_stand = document.getElementById('sheep-editor-can_manager_stand');
|
||||
let sheep_editor_can_add_schedule = document.getElementById('sheep-editor-can_add_schedule');
|
||||
@@ -367,8 +365,10 @@ const Sheeps = {
|
||||
|
||||
sheep_editor_can_view_sheeps.checked = sheep.possibilities.can_view_sheeps;
|
||||
sheep_editor_can_add_sheeps.checked = sheep.possibilities.can_add_sheeps;
|
||||
sheep_editor_can_manager_sheeps.checked = sheep.possibilities.can_manager_sheeps;
|
||||
sheep_editor_can_add_territory.checked = sheep.possibilities.can_add_territory;
|
||||
sheep_editor_can_manager_territory.checked = sheep.possibilities.can_manager_territory;
|
||||
sheep_editor_can_joint_territory.checked = sheep.possibilities.can_joint_territory;
|
||||
sheep_editor_can_add_stand.checked = sheep.possibilities.can_add_stand;
|
||||
sheep_editor_can_manager_stand.checked = sheep.possibilities.can_manager_stand;
|
||||
sheep_editor_can_add_schedule.checked = sheep.possibilities.can_add_schedule;
|
||||
@@ -387,6 +387,9 @@ const Sheeps = {
|
||||
if (USER.mode == 2) {
|
||||
document.getElementById('sheep-editor-button').style.display = "";
|
||||
sheep_editor_mode.disabled = false;
|
||||
} else if (USER.possibilities.can_manager_sheeps) {
|
||||
document.getElementById('sheep-editor-button').style.display = "";
|
||||
sheep_editor_mode.disabled = true;
|
||||
} else {
|
||||
sheep_editor_mode.disabled = true;
|
||||
}
|
||||
@@ -458,9 +461,9 @@ const Sheeps = {
|
||||
}
|
||||
},
|
||||
territory: {
|
||||
async loadAPI(URL) {
|
||||
async loadAPI(url) {
|
||||
const uuid = localStorage.getItem("uuid");
|
||||
const res = await fetch(URL, {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": uuid
|
||||
@@ -470,8 +473,7 @@ const Sheeps = {
|
||||
},
|
||||
|
||||
async house(id) {
|
||||
const URL = `${CONFIG.api}house/list?mode=admin&sheep_id=${id}`;
|
||||
const list = await Sheeps.territory.loadAPI(URL);
|
||||
const list = await Sheeps.territory.loadAPI(`${CONFIG.api}house/list?mode=admin&sheep_id=${id}`);
|
||||
|
||||
if ((USER.possibilities.can_view_territory || USER.mode == 2) && list.length > 0) {
|
||||
document.getElementById('editor-blocks-territory').style.display = "";
|
||||
@@ -480,8 +482,7 @@ const Sheeps = {
|
||||
},
|
||||
|
||||
async homestead(id) {
|
||||
const URL = `${CONFIG.api}homestead/list?mode=admin&sheep_id=${id}`;
|
||||
const list = await Sheeps.territory.loadAPI(URL);
|
||||
const list = await Sheeps.territory.loadAPI(`${CONFIG.api}homestead/list?mode=admin&sheep_id=${id}`);
|
||||
|
||||
if ((USER.possibilities.can_view_territory || USER.mode == 2) && list.length > 0) {
|
||||
document.getElementById('editor-blocks-territory').style.display = "";
|
||||
|
||||
@@ -54,15 +54,15 @@
|
||||
}
|
||||
|
||||
#stand-info div span {
|
||||
opacity: 0.8;
|
||||
font-weight: 400;
|
||||
font-size: var(--FontSize2);
|
||||
font-weight: 500;
|
||||
font-size: var(--FontSize3);
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
#stand-info div p {
|
||||
font-weight: 300;
|
||||
font-size: var(--FontSize4);
|
||||
opacity: 0.9;
|
||||
font-weight: 400;
|
||||
font-size: var(--FontSize3);
|
||||
}
|
||||
|
||||
#stand-info img {
|
||||
@@ -143,11 +143,11 @@
|
||||
}
|
||||
|
||||
#stand-schedule>.block-day h3 {
|
||||
font-size: var(--FontSize5);
|
||||
font-size: var(--FontSize6);
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
padding: 20px;
|
||||
text-transform: capitalize;
|
||||
width: calc(100% - 20px);
|
||||
width: calc(100% - 40px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@
|
||||
|
||||
#stand-schedule>.block-day div select:disabled {
|
||||
opacity: 0.9 !important;
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%2217%2010%2038%2050%22%3E%3Cpath%20fill%3D%22%23F2BD53%22%20d%3D%22M%2036%2010%20C%2028.28%2010%2022%2016.28%2022%2024%20L%2022%2028.587891%20C%2019.069798%2029.775473%2017%2032.643974%2017%2036%20L%2017%2052%20C%2017%2056.418%2020.582%2060%2025%2060%20L%2047%2060%20C%2051.418%2060%2055%2056.418%2055%2052%20L%2055%2036%20C%2055%2032.643974%2052.930202%2029.775473%2050%2028.587891%20L%2050%2024%20C%2050%2016.28%2043.72%2010%2036%2010%20z%20M%2036%2018%20C%2039.309%2018%2042%2020.691%2042%2024%20L%2042%2028%20L%2030%2028%20L%2030%2024%20C%2030%2020.691%2032.691%2018%2036%2018%20z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
#stand-schedule>.block-day div:nth-child(2n) {
|
||||
|
||||
@@ -52,6 +52,9 @@ const Stand_constructor = {
|
||||
button.innerText = "Стенд додано";
|
||||
Notifier.success('Стенд створено');
|
||||
|
||||
Stand_list.list = [];
|
||||
Stand_list.loadAPI();
|
||||
|
||||
return response.json()
|
||||
} else {
|
||||
console.log('err');
|
||||
|
||||
@@ -85,6 +85,9 @@ const Stand_editor = {
|
||||
button.innerText = "Стенд відредаговано";
|
||||
Notifier.success('Стенд відредаговано');
|
||||
|
||||
Stand_list.list = [];
|
||||
Stand_list.loadAPI();
|
||||
|
||||
return response.json()
|
||||
} else {
|
||||
console.log('err');
|
||||
|
||||
@@ -1,206 +1,11 @@
|
||||
<style>
|
||||
*[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
select {
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
/* Arrow */
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%237a899d%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.3rem top 50%;
|
||||
background-size: 0.55rem auto;
|
||||
}
|
||||
|
||||
#list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
details {
|
||||
color: var(--ColorThemes3);
|
||||
width: 100%;
|
||||
min-width: 320px;
|
||||
background: var(--ColorThemes1);
|
||||
margin: 20px 0px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.04), 0px 0px 2px rgba(0, 0, 0, 0.04),
|
||||
0px 0px 1px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
#list {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
details {
|
||||
width: calc(50% - 10px);
|
||||
}
|
||||
}
|
||||
details[disabled] summary,
|
||||
details.disabled summary {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
details summary::-webkit-details-marker,
|
||||
details summary::marker {
|
||||
display: none;
|
||||
content: "";
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
background: #7a8a9d;
|
||||
background: var(--PrimaryColor);
|
||||
color: var(--PrimaryColorText);
|
||||
height: 45px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
summary p {
|
||||
padding: 0 10px;
|
||||
color: var(--PrimaryColorText);
|
||||
font-size: var(--FontSize3);
|
||||
font-weight: 500;
|
||||
}
|
||||
summary svg {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
padding: 0 10px;
|
||||
fill: var(--PrimaryColorText);
|
||||
}
|
||||
|
||||
.apartments_list {
|
||||
padding: 10px;
|
||||
border-bottom: 2px solid var(--PrimaryColor);
|
||||
border-left: 2px solid var(--PrimaryColor);
|
||||
border-right: 2px solid var(--PrimaryColor);
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.apartments_list {
|
||||
padding: 1px;
|
||||
}
|
||||
}
|
||||
.apartments_list > p {
|
||||
font-size: var(--FontSize5);
|
||||
text-align: center;
|
||||
color: var(--ColorThemes3);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.card_info {
|
||||
display: flex;
|
||||
font-size: var(--FontSize3);
|
||||
border-radius: 8px;
|
||||
margin: 10px 10px 15px 10px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--ColorThemes3);
|
||||
background: var(--ColorThemes2);
|
||||
}
|
||||
.card_info_homestead {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.card_info > .info {
|
||||
display: flex;
|
||||
font-size: var(--FontSize3);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.card_info > .info > span {
|
||||
min-width: 40px;
|
||||
font-size: var(--FontSize1);
|
||||
position: relative;
|
||||
margin: 5px;
|
||||
}
|
||||
.card_info > .info > select {
|
||||
color: #3d3d3d;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #eaebef;
|
||||
margin: 5px;
|
||||
background-color: var(--ColorThemes3);
|
||||
min-width: 110px;
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
height: 30px;
|
||||
}
|
||||
.card_info > .info > input,
|
||||
.card_info > .info > button {
|
||||
font-size: var(--FontSize4);
|
||||
font-weight: 400;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
border-radius: 6px;
|
||||
margin: 5px;
|
||||
background-color: var(--ColorThemes0);
|
||||
border: 1px solid var(--ColorThemes1);
|
||||
color: var(--ColorThemes3);
|
||||
width: 100%;
|
||||
max-width: 170px;
|
||||
min-width: 70px;
|
||||
padding: 0 4px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card_info > .info > button > svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
fill: var(--ColorThemes3);
|
||||
}
|
||||
.card_info > textarea {
|
||||
border-radius: 6px;
|
||||
font-size: var(--FontSize3);
|
||||
margin: 5px;
|
||||
background-color: var(--ColorThemes0);
|
||||
border: 1px solid var(--ColorThemes1);
|
||||
color: var(--ColorThemes3);
|
||||
width: calc(100% - 22px);
|
||||
min-width: 70px;
|
||||
padding: 5px;
|
||||
min-height: 40px;
|
||||
appearance: none;
|
||||
resize: vertical;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.card_info > textarea::placeholder {
|
||||
color: var(--ColorThemes3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.card_info > textarea::-webkit-input-placeholder {
|
||||
color: var(--ColorThemes3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#map_card {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: calc(100vh - 100px);
|
||||
background: var(--ColorThemes1);
|
||||
color: var(--ColorThemes3);
|
||||
border: 1px solid var(--ColorThemes2);
|
||||
box-shadow: var(--shadow-l1);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="page-card">
|
||||
<div class="list-controls" id="page-card-controls" style="display: none">
|
||||
<div id="page-card-sort">
|
||||
<button id="sort_1" onclick="Territory_card.sort('2')" data-state="active">
|
||||
<button
|
||||
id="sort_1"
|
||||
onclick="Territory_card.sort('2')"
|
||||
data-state="active"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<path
|
||||
d="M 32.476562 5.9785156 A 1.50015 1.50015 0 0 0 31 7.5 L 31 37.878906 L 26.560547 33.439453 A 1.50015 1.50015 0 1 0 24.439453 35.560547 L 31.439453 42.560547 A 1.50015 1.50015 0 0 0 33.560547 42.560547 L 40.560547 35.560547 A 1.50015 1.50015 0 1 0 38.439453 33.439453 L 34 37.878906 L 34 7.5 A 1.50015 1.50015 0 0 0 32.476562 5.9785156 z M 14.375 8.0058594 C 14.257547 8.01575 14.139641 8.0379219 14.025391 8.0761719 L 11.025391 9.0761719 C 10.239391 9.3381719 9.8141719 10.188609 10.076172 10.974609 C 10.338172 11.760609 11.190609 12.188828 11.974609 11.923828 L 13 11.580078 L 13 20.5 C 13 21.329 13.671 22 14.5 22 C 15.329 22 16 21.329 16 20.5 L 16 9.5 C 16 9.018 15.767953 8.5652031 15.376953 8.2832031 C 15.082953 8.0717031 14.727359 7.9761875 14.375 8.0058594 z M 14 27 C 11.344 27 9.387625 28.682109 9.015625 31.287109 C 8.898625 32.107109 9.4671094 32.867375 10.287109 32.984375 C 11.106109 33.102375 11.867375 32.533891 11.984375 31.712891 C 12.096375 30.931891 12.537 30 14 30 C 15.103 30 16 30.897 16 32 C 16 33.103 15.103 34 14 34 C 11.592 34 9 35.721 9 39.5 C 9 40.329 9.672 41 10.5 41 L 17.5 41 C 18.329 41 19 40.329 19 39.5 C 19 38.671 18.329 38 17.5 38 L 12.308594 38 C 12.781594 37.093 13.664 37 14 37 C 16.757 37 19 34.757 19 32 C 19 29.243 16.757 27 14 27 z"
|
||||
@@ -394,6 +199,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-card-info" style="display: none"></div>
|
||||
|
||||
<div id="list"></div>
|
||||
<div id="map_card"></div>
|
||||
</div>
|
||||
@@ -403,4 +210,4 @@
|
||||
<button id="card-new-date-button">Оновити дату</button>
|
||||
<input type="datetime-local" id="card-new-date-input" placeholder="Дата" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,6 @@ const Territory_card = {
|
||||
|
||||
// Застосовуємо режим сортування
|
||||
this.sort(localStorage.getItem('territory_card_sort'), false);
|
||||
this.getEntrances({ update: false });
|
||||
} else if (type === "homestead") {
|
||||
this.getHomestead.map({});
|
||||
}
|
||||
@@ -40,7 +39,7 @@ const Territory_card = {
|
||||
const ids = ['cloud_1', 'cloud_2', 'cloud_3'];
|
||||
ids.forEach((id, idx) => {
|
||||
const el = document.getElementById(id);
|
||||
if(!el) return;
|
||||
if (!el) return;
|
||||
el.setAttribute('data-state', ['sync', 'ok', 'err'].indexOf(Cloud.status) === idx ? 'active' : '');
|
||||
});
|
||||
|
||||
@@ -134,10 +133,18 @@ const Territory_card = {
|
||||
if (navigator.onLine && Cloud.socket?.readyState === WebSocket.OPEN) {
|
||||
Cloud.socket.send(JSON.stringify(message));
|
||||
} else {
|
||||
if (confirm("З'єднання розірвано! Перепідключитись?")) {
|
||||
Cloud.start();
|
||||
Territory_card.getEntrances({ update: true });
|
||||
}
|
||||
Notifier.click({
|
||||
title: `Запис не додано!`,
|
||||
text: `Натисніть, щоб перепідключитись!`
|
||||
}, {
|
||||
type: 'warn',
|
||||
f: () => {
|
||||
Cloud.reconnecting = true;
|
||||
Cloud.reconnectAttempts = 0;
|
||||
Cloud.start();
|
||||
},
|
||||
timeout: 0
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -181,9 +188,18 @@ const Territory_card = {
|
||||
if (navigator.onLine && Cloud.socket?.readyState === WebSocket.OPEN) {
|
||||
Cloud.socket.send(JSON.stringify(message));
|
||||
} else {
|
||||
if (confirm("З'єднання розірвано! Перепідключитись?")) {
|
||||
Territory_card.cloud.start();
|
||||
}
|
||||
Notifier.click({
|
||||
title: `Запис не додано!`,
|
||||
text: `Натисніть, щоб перепідключитись!`
|
||||
}, {
|
||||
type: 'warn',
|
||||
f: () => {
|
||||
Cloud.reconnecting = true;
|
||||
Cloud.reconnectAttempts = 0;
|
||||
Cloud.start();
|
||||
},
|
||||
timeout: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -211,12 +227,14 @@ const Territory_card = {
|
||||
return;
|
||||
}
|
||||
|
||||
Territory_card.info(data);
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
const canManage = USER.mode === 2 || (USER.mode === 1 && USER.possibilities.can_manager_territory);
|
||||
|
||||
for (const element of data) {
|
||||
const { id, entrance_number, title, history, working } = element;
|
||||
const { id, entrance_number, title, history, working, address } = element;
|
||||
const isMy = ((history.name === "Групова" && history.group_id == USER.group_id) || history.name === USER.name);
|
||||
|
||||
const show = (isMy && working) ? "open" : canManage ? "close" : null;
|
||||
@@ -310,8 +328,9 @@ const Territory_card = {
|
||||
div.style = style;
|
||||
|
||||
div.innerHTML = `
|
||||
<span>Квартира ${apt.title}</span>
|
||||
<hr>
|
||||
<div class="info">
|
||||
<span>кв.${apt.title}</span>
|
||||
<select id="status_${apt.id}" onchange="Territory_card.cloud.messApartment({number:${number},id:${apt.id},update:true})" style="${style}">
|
||||
${statusOptions(apt.status)}
|
||||
</select>
|
||||
@@ -350,9 +369,6 @@ const Territory_card = {
|
||||
async map({ homestead_id = Territory_card.id }) {
|
||||
let data = await this.loadAPI({ url: `${CONFIG.api}homestead/${homestead_id}` });
|
||||
|
||||
console.log(data);
|
||||
|
||||
|
||||
let lat = data.geo?.lat ?? data.points?.[0]?.[0]?.[0]?.lat ?? 49.5629016;
|
||||
let lng = data.geo?.lng ?? data.points?.[0]?.[0]?.[0]?.lng ?? 25.6145625;
|
||||
let zoom = 15;
|
||||
@@ -483,10 +499,116 @@ const Territory_card = {
|
||||
|
||||
Territory_card.getHomestead.markers[element.id] = marker; // сохраним ссылку на маркер
|
||||
}
|
||||
|
||||
if((USER.possibilities.can_joint_territory && data.history.sheep_id == USER.id) || USER.mode == 2){
|
||||
this.joint.setHTML(homestead_id);
|
||||
}
|
||||
},
|
||||
|
||||
joint: {
|
||||
async setHTML(homestead_id){
|
||||
let lest = await this.getJoint(homestead_id);
|
||||
|
||||
let block_info = document.getElementById('page-card-info');
|
||||
|
||||
block_info.style.display = "flex";
|
||||
block_info.innerHTML = `
|
||||
<h2>Надати спільний доступ:</h2>
|
||||
<smart-select type="number" id="joint-${homestead_id}" onchange="Territory_card.getHomestead.joint.setJoint('${homestead_id}')" max="30" placeholder="Оберіть вісників..." title="Оберіть вісників, з якими хочете поділитись територією">
|
||||
${Sheeps.sheeps_list.list.map(p => {
|
||||
const isSelected = lest.some(item => item.sheep_id === p.id);
|
||||
if(USER.id === Number(p.id) && USER.mode != 2) return
|
||||
return `<div
|
||||
slot="option"
|
||||
data-value="${Number(p.id)}"
|
||||
${isSelected ? 'data-selected' : ''}>
|
||||
${p.name}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</smart-select>
|
||||
`;
|
||||
},
|
||||
|
||||
setJoint(homestead_id){
|
||||
const select = document.getElementById(`joint-${homestead_id}`);
|
||||
if (!select) return;
|
||||
console.log(select.getClick);
|
||||
|
||||
if(select.getClick.state == "add"){
|
||||
this.addSheep(homestead_id, select.getClick.value);
|
||||
} else if(select.getClick.state == "delete"){
|
||||
this.delSheep(homestead_id, select.getClick.value);
|
||||
}
|
||||
},
|
||||
|
||||
async getJoint(homestead_id){
|
||||
let uuid = localStorage.getItem("uuid");
|
||||
|
||||
return await fetch(`${CONFIG.api}homestead/joint/${homestead_id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": uuid
|
||||
}
|
||||
}).then((response) => response.json());
|
||||
},
|
||||
|
||||
async addSheep(homestead_id, sheep_id){
|
||||
const uuid = localStorage.getItem('uuid');
|
||||
|
||||
if (!homestead_id) {
|
||||
console.warn("Невірні дані для наданя доступу.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${CONFIG.api}homestead/joint/${homestead_id}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": uuid
|
||||
},
|
||||
body: JSON.stringify({"sheep_id": sheep_id})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to assign");
|
||||
|
||||
Notifier.success('Віснику успішно надано доступ.');
|
||||
} catch (err) {
|
||||
console.error('❌ Error:', err);
|
||||
Notifier.error('Помилка надання доступу.');
|
||||
}
|
||||
},
|
||||
async delSheep(homestead_id, sheep_id){
|
||||
const uuid = localStorage.getItem('uuid');
|
||||
|
||||
if (!homestead_id) {
|
||||
console.warn("Невірні дані для відкликання доступу.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${CONFIG.api}homestead/joint/${homestead_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": uuid
|
||||
},
|
||||
body: JSON.stringify({"sheep_id": sheep_id})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to assign");
|
||||
|
||||
Notifier.success('Доступ успішно відкликанно.');
|
||||
} catch (err) {
|
||||
console.error('❌ Error:', err);
|
||||
Notifier.error('Помилка при відкликанні доступу.');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async reload(){
|
||||
async reload() {
|
||||
Territory_card.getEntrances({ update: true });
|
||||
},
|
||||
|
||||
@@ -547,5 +669,39 @@ const Territory_card = {
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
|
||||
info(data) {
|
||||
let block_info = document.getElementById('page-card-info');
|
||||
|
||||
block_info.style.display = "flex";
|
||||
block_info.innerHTML = `
|
||||
<a href="https://www.google.com/maps?q=${data[0].address.points_number.lat},${data[0].address.points_number.lng}">${data[0].address.title} ${data[0].address.number}</a>
|
||||
<hr>
|
||||
<h2>Терміни опрацювання:</h2>
|
||||
`
|
||||
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
const element = data[index];
|
||||
|
||||
const canManage = USER.mode === 2 || (USER.mode === 1 && USER.possibilities.can_manager_territory);
|
||||
const isMy = ((element.history.name === "Групова" && element.history.group_id == USER.group_id) || element.history.name === USER.name);
|
||||
|
||||
let date_start = element.history.date.start;
|
||||
let date_end = date_start + (1000 * 2629743 * 4);
|
||||
let red = () => {
|
||||
if(Date.now() > date_end) return `color: #ec2d2d;`
|
||||
return
|
||||
}
|
||||
|
||||
if (element.working && (isMy || canManage)) {
|
||||
block_info.innerHTML += `
|
||||
<div>
|
||||
<h3>${element.title}</h3>
|
||||
<h4>${formattedDate(date_start)} — <span style="${red()}">${formattedDate(date_end)}</span></h4>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,48 @@
|
||||
}
|
||||
|
||||
|
||||
#page-card-info {
|
||||
padding: 10px;
|
||||
margin: 0px 0 10px 0;
|
||||
background: var(--ColorThemes1);
|
||||
color: var(--ColorThemes3);
|
||||
border: 1px solid var(--ColorThemes2);
|
||||
box-shadow: var(--shadow-l1);
|
||||
border-radius: var(--border-radius);
|
||||
/* overflow: auto; */
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#page-card-info>a {
|
||||
font-size: var(--FontSize5);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#page-card-info>h2 {
|
||||
font-size: var(--FontSize4);
|
||||
}
|
||||
|
||||
#page-card-info>div {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#page-card-info>div>h3 {
|
||||
font-size: var(--FontSize3);
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#page-card-info>div>h4 {
|
||||
font-size: var(--FontSize2);
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
#card-new-date {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -149,4 +191,221 @@
|
||||
font-weight: 400;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
.page-card *[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.page-card #list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.page-card details {
|
||||
color: var(--ColorThemes3);
|
||||
width: 100%;
|
||||
min-width: 320px;
|
||||
background: var(--ColorThemes1);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ColorThemes2);
|
||||
box-shadow: var(--shadow-l1);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.page-card #list {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-card details {
|
||||
width: calc(50% - 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.page-card details[disabled] .page-card details summary,
|
||||
.page-card details.disabled .page-card details summary {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.page-card details .page-card details summary::-webkit-.page-card details-marker,
|
||||
.page-card details .page-card details summary::marker {
|
||||
display: none;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.page-card details summary {
|
||||
cursor: pointer;
|
||||
height: 45px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.page-card details summary p {
|
||||
padding: 0 10px;
|
||||
font-size: var(--FontSize3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-card details summary svg {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
padding: 0 10px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.page-card .apartments_list {
|
||||
padding: 10px;
|
||||
gap: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-card .apartments_list>p {
|
||||
font-size: var(--FontSize5);
|
||||
text-align: center;
|
||||
color: var(--ColorThemes3);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.page-card .card_info {
|
||||
display: flex;
|
||||
font-size: var(--FontSize3);
|
||||
border-radius: 8px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--ColorThemes3);
|
||||
background-color: var(--ColorThemes2);
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.card_info_homestead {
|
||||
border-radius: 6px !important;
|
||||
width: calc(100% - 15px);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-card .card_info>span {
|
||||
font-size: var(--FontSize3);
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-card .card_info>hr {
|
||||
height: 1px;
|
||||
background-color: currentColor;
|
||||
opacity: 0.1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-card .card_info>.info {
|
||||
display: flex;
|
||||
font-size: var(--FontSize3);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: 8px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.page-card .card_info>.info>select {
|
||||
color: #3d3d3d;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #eaebef;
|
||||
background-color: var(--ColorThemes3);
|
||||
min-width: 110px;
|
||||
width: 50%;
|
||||
padding: 4px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.page-card .card_info>.info>input,
|
||||
.page-card .card_info>.info>button {
|
||||
font-size: var(--FontSize2);
|
||||
font-weight: 400;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
border-radius: 6px;
|
||||
background-color: var(--ColorThemes0);
|
||||
border: 1px solid var(--ColorThemes1);
|
||||
color: var(--ColorThemes3);
|
||||
width: 50%;
|
||||
min-width: 70px;
|
||||
padding: 0 4px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-card .card_info>.info>button>svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
fill: var(--ColorThemes3);
|
||||
}
|
||||
|
||||
.page-card .card_info>textarea {
|
||||
border-radius: 6px;
|
||||
font-size: var(--FontSize3);
|
||||
background-color: var(--ColorThemes0);
|
||||
border: 0;
|
||||
color: var(--ColorThemes3);
|
||||
width: calc(100% - 10px);
|
||||
min-width: 70px;
|
||||
padding: 5px;
|
||||
min-height: 40px;
|
||||
appearance: none;
|
||||
resize: vertical;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.page-card .card_info>textarea::placeholder {
|
||||
color: var(--ColorThemes3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.page-card .card_info>textarea::-webkit-input-placeholder {
|
||||
color: var(--ColorThemes3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.page-card #map_card {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: calc(100vh - 100px);
|
||||
background: var(--ColorThemes1);
|
||||
color: var(--ColorThemes3);
|
||||
border: 1px solid var(--ColorThemes2);
|
||||
box-shadow: var(--shadow-l1);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
|
||||
smart-select {
|
||||
--smart-select-option-color: var(--ColorThemes3);
|
||||
--smart-select-border-color: var(--ColorThemes2);
|
||||
--smart-select-background: var(--ColorThemes0);
|
||||
--smart-select-color: var(--PrimaryColor);
|
||||
--smart-select-chip-fill: var(--PrimaryColorText);
|
||||
--smart-select-chip-background: var(--PrimaryColor);
|
||||
--smart-select-chip-color: var(--ColorThemes3);
|
||||
--smart-select-search-color: var(--ColorThemes3);
|
||||
--smart-select-hover-background: var(--ColorThemes2);
|
||||
--smart-select-hover-color: var(--ColorThemes3);
|
||||
--smart-select-selected-background: var(--PrimaryColor);
|
||||
--smart-select-selected-color: var(--PrimaryColorText);
|
||||
--smart-select-font-size-1: var(--FontSize1);
|
||||
--smart-select-font-size-2: var(--FontSize3);
|
||||
--smart-select-border-radius-1: 8px;
|
||||
--smart-select-border-radius-2: 4px;
|
||||
}
|
||||
@@ -13,12 +13,20 @@
|
||||
name="address"
|
||||
required
|
||||
value=""
|
||||
onchange="Territory_editor.info.title=this.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="info-number">Номер будинку / частини</label>
|
||||
<input type="text" id="info-number" name="number" required value="" />
|
||||
<input
|
||||
type="text"
|
||||
id="info-number"
|
||||
name="number"
|
||||
required
|
||||
value=""
|
||||
onchange="Territory_editor.info.number=this.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -29,6 +37,7 @@
|
||||
name="settlement"
|
||||
required
|
||||
value="Тернопіль"
|
||||
onchange="Territory_editor.info.settlement=this.value"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
@@ -66,14 +75,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<span>або</span>
|
||||
<button onclick="Territory_editor.osm.newPoligon()">Обрати на карті</button>
|
||||
<button onclick="Territory_editor.osm.newPoligon()">
|
||||
Обрати на карті
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="block-map">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="part-2-button" onclick="Territory_editor.save()">Зберегти</button>
|
||||
<button type="button" id="part-2-button" onclick="Territory_editor.save()">
|
||||
Зберегти
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="part-3" class="part_block" style="display: none">
|
||||
|
||||
@@ -40,10 +40,11 @@ const Territory_list = {
|
||||
},
|
||||
|
||||
house: {
|
||||
url: null,
|
||||
list: [],
|
||||
loadAPI: async function (url) {
|
||||
const uuid = localStorage.getItem("uuid");
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(url ?? Territory_list.house.url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -60,6 +61,7 @@ const Territory_list = {
|
||||
const territory_list_filter = Number(localStorage.getItem("territory_list_filter") ?? 0);
|
||||
|
||||
const url = `${CONFIG.api}houses/list${territory_entrances ? '/entrances' : ''}`;
|
||||
Territory_list.house.url = url;
|
||||
let list = this.list.length > 0 ? this.list : await this.loadAPI(url);
|
||||
|
||||
const isEnd = territory_list_filter === "2";
|
||||
@@ -94,11 +96,13 @@ const Territory_list = {
|
||||
const person = working
|
||||
? `${element.history.name === 'Групова' ? 'Група ' + element.history.group_id : element.history.name}`
|
||||
: ``;
|
||||
const overdue = working && (element.history.date.start + (1000 * 2629743 * 4)) <= Date.now();
|
||||
|
||||
card.image = `${CONFIG.web}cards/house/T${element.house.id}.webp`;
|
||||
card.address = `${element.house.title} ${element.house.number} (${element.title})`;
|
||||
card.link = `/territory/manager/house/${element.house.id}`;
|
||||
card.sheep = person;
|
||||
card.overdue = overdue;
|
||||
block.appendChild(card);
|
||||
} else {
|
||||
const qty = element.entrance.quantity;
|
||||
@@ -154,8 +158,8 @@ const Territory_list = {
|
||||
}
|
||||
});
|
||||
|
||||
this.list = await response.json();
|
||||
return this.list;
|
||||
Territory_list.homestead.list = await response.json();
|
||||
return Territory_list.homestead.list;
|
||||
},
|
||||
setHTML: async function () {
|
||||
const block = document.getElementById('list-homestead');
|
||||
@@ -186,12 +190,15 @@ const Territory_list = {
|
||||
const person = working
|
||||
? `${element.history.name === 'Групова' ? 'Група ' + element.history.group_id : element.history.name}`
|
||||
: ``;
|
||||
|
||||
const overdue = working && (element.history.date.start + (1000 * 2629743 * 4)) <= Date.now();
|
||||
|
||||
const card = document.createElement('app-territory-card');
|
||||
card.image = `${CONFIG.web}cards/homestead/H${element.id}.webp`;
|
||||
card.address = `${element.title} ${element.number}`;
|
||||
card.link = `/territory/manager/homestead/${element.id}`;
|
||||
card.sheep = person;
|
||||
card.overdue = overdue;
|
||||
block.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,6 +358,11 @@ const Territory_Manager = {
|
||||
Territory_Manager.mess.close();
|
||||
Territory_Manager.entrances.list = [];
|
||||
await Territory_Manager.entrances.setHTML(type, id);
|
||||
|
||||
Territory_list.house.list = [];
|
||||
Territory_list.homestead.list = [];
|
||||
Territory_list.house.loadAPI();
|
||||
Territory_list.homestead.loadAPI();
|
||||
} catch (err) {
|
||||
console.error('❌ Error:', err);
|
||||
Notifier.error('Помилка призначення території');
|
||||
@@ -389,6 +394,11 @@ const Territory_Manager = {
|
||||
Territory_Manager.entrances.list = [];
|
||||
await Territory_Manager.entrances.setHTML(type, id);
|
||||
|
||||
Territory_list.house.list = [];
|
||||
Territory_list.homestead.list = [];
|
||||
Territory_list.house.loadAPI();
|
||||
Territory_list.homestead.loadAPI();
|
||||
|
||||
Notifier.success('Територія забрана успішно');
|
||||
} catch (error) {
|
||||
console.error("❌ Помилка зняття призначення:", error);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
let map_all;
|
||||
let map_all, free_entrance, free_homesteads;
|
||||
|
||||
const Territory_Map = {
|
||||
init: async () => {
|
||||
async init() {
|
||||
let html = await fetch('/lib/pages/territory/map/index.html').then((response) => response.text());
|
||||
app.innerHTML = html;
|
||||
|
||||
@@ -9,7 +9,7 @@ const Territory_Map = {
|
||||
Territory_Map.info.setHTML();
|
||||
},
|
||||
info: {
|
||||
loadAPI: async (url) => {
|
||||
async loadAPI(url) {
|
||||
const uuid = localStorage.getItem("uuid");
|
||||
|
||||
const response = await fetch(url, {
|
||||
@@ -23,22 +23,22 @@ const Territory_Map = {
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
setHTML: async () => {
|
||||
async setHTML() {
|
||||
const houses = await Territory_Map.info.loadAPI(`${CONFIG.api}houses/list`);
|
||||
const homestead = await Territory_Map.info.loadAPI(`${CONFIG.api}homestead/list`);
|
||||
|
||||
Territory_Map.map.added({ type: "houses", data: houses });
|
||||
Territory_Map.map.added({ type: "house", data: houses });
|
||||
Territory_Map.map.added({ type: "homestead", data: homestead });
|
||||
}
|
||||
},
|
||||
map: {
|
||||
polygons: [],
|
||||
|
||||
init: () => {
|
||||
init() {
|
||||
if (map_all && map_all.remove) map_all.remove();
|
||||
|
||||
const mapElement = document.getElementById('map');
|
||||
if (!mapElement) return;
|
||||
let firstLocate = true;
|
||||
|
||||
let googleHybrid = L.tileLayer('http://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}', {
|
||||
maxZoom: 20,
|
||||
@@ -55,6 +55,9 @@ const Territory_Map = {
|
||||
tms: true
|
||||
});
|
||||
|
||||
free_entrance = new L.FeatureGroup();
|
||||
free_homesteads = new L.FeatureGroup();
|
||||
|
||||
map_all = L.map(mapElement, {
|
||||
renderer: L.canvas(),
|
||||
center: [49.5629016, 25.6145625],
|
||||
@@ -63,22 +66,72 @@ const Territory_Map = {
|
||||
layers: [
|
||||
googleHybrid,
|
||||
osm,
|
||||
mytile
|
||||
mytile,
|
||||
free_entrance,
|
||||
free_homesteads
|
||||
]
|
||||
});
|
||||
|
||||
map_all.locate({
|
||||
setView: true, // 🔥 сразу центрирует карту
|
||||
maxZoom: 16
|
||||
});
|
||||
|
||||
let baseMaps = {
|
||||
"Google Hybrid": googleHybrid,
|
||||
"OpenStreetMap": osm,
|
||||
"Sheep Service Map": mytile,
|
||||
};
|
||||
|
||||
let layerControl = L.control.layers(baseMaps, [], { position: 'bottomright' }).addTo(map_all);
|
||||
let baseLayer = {
|
||||
"Вільні під'їзди": free_entrance,
|
||||
"Вільні райони": free_homesteads
|
||||
};
|
||||
|
||||
|
||||
L.control.layers(baseMaps, baseLayer, { position: 'bottomright' }).addTo(map_all);
|
||||
|
||||
map_all.pm.setLang("ua");
|
||||
|
||||
map_all.on('zoomend', () => {
|
||||
const z = map_all.getZoom();
|
||||
|
||||
if (z <= 15) {
|
||||
map_all.removeLayer(free_homesteads);
|
||||
} else {
|
||||
map_all.addLayer(free_homesteads);
|
||||
}
|
||||
|
||||
if (z <= 16) {
|
||||
map_all.removeLayer(free_entrance);
|
||||
} else {
|
||||
map_all.addLayer(free_entrance);
|
||||
}
|
||||
});
|
||||
|
||||
// слежение в реальном времени
|
||||
map_all.locate({ setView: false, watch: true, enableHighAccuracy: true });
|
||||
map_all.on('locationfound', (e) => {
|
||||
if (firstLocate) map_all.setView(e.latlng, 16);
|
||||
|
||||
if (!map_all._userMarker) {
|
||||
map_all._userMarker = L.marker(e.latlng).addTo(map_all).bindPopup("");
|
||||
|
||||
map_all._userMarker.on("popupopen", () => {
|
||||
const div = document.createElement("div");
|
||||
div.className = 'marker_popup'
|
||||
div.innerHTML = `<p>Ви тут!</p>`;
|
||||
map_all._userMarker.setPopupContent(div);
|
||||
});
|
||||
} else {
|
||||
map_all._userMarker.setLatLng(e.latlng);
|
||||
}
|
||||
|
||||
firstLocate = false;
|
||||
});
|
||||
},
|
||||
|
||||
added: ({ type, data }) => {
|
||||
added({ type, data }) {
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
const element = data[index];
|
||||
let posPersonal, posGroup;
|
||||
@@ -108,6 +161,13 @@ const Territory_Map = {
|
||||
}
|
||||
}
|
||||
|
||||
this.marker({
|
||||
id: element.id,
|
||||
type: 'homestead',
|
||||
free: element.working ? 0 : 1,
|
||||
geo: element.geo
|
||||
})
|
||||
|
||||
} else {
|
||||
posPersonal = Home.personal.house.list.map(e => e.id).indexOf(element.id);
|
||||
posGroup = Home.group.house.list.map(e => e.id).indexOf(element.id);
|
||||
@@ -119,6 +179,13 @@ const Territory_Map = {
|
||||
fillOpacity: 0.8
|
||||
}
|
||||
}
|
||||
|
||||
this.marker({
|
||||
id: element.id,
|
||||
type: 'house',
|
||||
free: element.entrance.quantity - element.entrance.working,
|
||||
geo: element.geo
|
||||
})
|
||||
}
|
||||
|
||||
const polygon = L.polygon(element.points, polygonOptions).addTo(map_all);
|
||||
@@ -129,13 +196,14 @@ const Territory_Map = {
|
||||
polygon.on("popupopen", () => {
|
||||
const div = document.createElement("div");
|
||||
let text = () => {
|
||||
if (posPersonal != -1) return "<span>Моя територія</span>"
|
||||
else if (posGroup != -1) return "<span>Групова територія</span>"
|
||||
return ""
|
||||
if (posPersonal != -1) return `<span>Моя територія</span> <p>${element.title} ${element.number}</p> <a href="/territory/card/${type}/${element.id}" data-route>Перейти до території</a>`
|
||||
else if (posGroup != -1) return `<span>Групова територія</span> <p>${element.title} ${element.number}</p> <a href="/territory/card/${type}/${element.id}" data-route>Перейти до території</a>`
|
||||
return `<p>${element.title} ${element.number}</p> `
|
||||
}
|
||||
|
||||
div.innerHTML = `${text()} ${element.title} ${element.number}`;
|
||||
div.className = "leaflet_drop"
|
||||
div.className = 'marker_popup'
|
||||
div.innerHTML = `${text()}`;
|
||||
if (USER.possibilities.can_manager_territory || USER.mode == 2) div.innerHTML += `<a href="/territory/manager/${type}/${element.id}" data-route>Керування</a>`;
|
||||
|
||||
polygon.setPopupContent(div);
|
||||
});
|
||||
@@ -144,40 +212,29 @@ const Territory_Map = {
|
||||
}
|
||||
},
|
||||
|
||||
marker: ({ data, personal = false, group = false }) => {
|
||||
console.log(data);
|
||||
marker({ id, type, free, geo }) {
|
||||
if (!USER.possibilities.can_manager_territory || USER.mode != 2) return;
|
||||
if (free <= 0) return;
|
||||
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
const element = data[index];
|
||||
const redDot = L.divIcon({
|
||||
className: "marker",
|
||||
html: `${free}`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15]
|
||||
});
|
||||
|
||||
// создаём маркер
|
||||
const marker = L.marker([geo.lat, geo.lng], { icon: redDot }).addTo(type == 'homestead' ? free_homesteads : free_entrance);
|
||||
marker.bindPopup("");
|
||||
|
||||
console.log(element);
|
||||
// при открытии popup генерим div заново
|
||||
marker.on("popupopen", () => {
|
||||
const div = document.createElement("div");
|
||||
div.className = 'marker_popup'
|
||||
div.innerHTML = `<a href="/territory/manager/${type}/${id}" data-route>Перейти до території</a>`;
|
||||
|
||||
const redDot = L.divIcon({
|
||||
className: "leaflet_drop",
|
||||
html: `<div id="redDot_${element.id}"></div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8]
|
||||
});
|
||||
|
||||
// создаём маркер
|
||||
const marker = L.marker([element.geo.lat, element.geo.lng], { icon: redDot }).addTo(map_all);
|
||||
marker.bindPopup("");
|
||||
|
||||
// при открытии popup генерим div заново
|
||||
marker.on("popupopen", () => {
|
||||
const div = document.createElement("div");
|
||||
let text = () => {
|
||||
if (personal) return "Моя територія"
|
||||
else if (group) return "Групова територія"
|
||||
return ""
|
||||
}
|
||||
div.innerHTML = text();
|
||||
|
||||
marker.setPopupContent(div);
|
||||
});
|
||||
|
||||
}
|
||||
marker.setPopupContent(div);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,53 @@
|
||||
.page-territory_map {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-territory_map>#map {
|
||||
margin: 20px;
|
||||
width: calc(100% - 40px);
|
||||
height: calc(100% - 40px);
|
||||
border-radius: calc(var(--border-radius) - 5px);
|
||||
}
|
||||
|
||||
.page-territory_map .marker {
|
||||
background: var(--ColorThemes2);
|
||||
color: var(--ColorThemes3);
|
||||
font-size: var(--FontSize1);
|
||||
border-radius: var(--border-radius);
|
||||
border: 2px solid var(--ColorThemes3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-territory_map .marker_popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.page-territory_map .marker_popup>p {
|
||||
margin: 0;
|
||||
}
|
||||
.page-territory_map .marker_popup>span {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-territory_map .marker_popup>a {
|
||||
color: var(--ColorThemes3);
|
||||
cursor: pointer;
|
||||
border-radius: calc(var(--border-radius) - 8px);
|
||||
padding: 5px 10px;
|
||||
min-width: fit-content;
|
||||
background: var(--PrimaryColor);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 400;
|
||||
font-size: var(--FontSize1);
|
||||
min-width: calc(100% - 15px);
|
||||
}
|
||||
@@ -94,12 +94,55 @@ Router
|
||||
pageActive();
|
||||
})
|
||||
|
||||
function pageActive(element) {
|
||||
const active = document.querySelector("nav li [data-state='active']");
|
||||
if (active) active.setAttribute('data-state', '');
|
||||
// function pageActive(element) {
|
||||
// const active = document.querySelector("nav-item[data-state='active']");
|
||||
// if (active) active.setAttribute('data-state', '');
|
||||
|
||||
// if (element) {
|
||||
// const target = document.getElementById(`menu-${element}`);
|
||||
// if (target) target.setAttribute('data-state', 'active');
|
||||
// }
|
||||
// }
|
||||
function pageActive(element) {
|
||||
// 1. Знаходимо контейнер меню
|
||||
const navContainer = document.querySelector('navigation-container');
|
||||
|
||||
if (!navContainer) {
|
||||
console.warn('Компонент <navigation-container> не знайдено.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Видаляємо активний стан у всіх елементів.
|
||||
// Шукаємо як у Light DOM (через querySelectorAll на документі),
|
||||
// так і у Shadow DOM (через shadowRoot).
|
||||
const activeInLight = document.querySelector("nav-item[data-state='active']");
|
||||
if (activeInLight) {
|
||||
activeInLight.setAttribute('data-state', '');
|
||||
}
|
||||
|
||||
const activeInShadow = navContainer.shadowRoot.querySelector("nav-item[data-state='active']");
|
||||
if (activeInShadow) {
|
||||
activeInShadow.setAttribute('data-state', '');
|
||||
}
|
||||
|
||||
// 3. Знаходимо цільовий елемент
|
||||
if (element) {
|
||||
const target = document.getElementById(`nav-${element}`);
|
||||
if (target) target.setAttribute('data-state', 'active');
|
||||
const targetId = `menu-${element}`;
|
||||
let target = null;
|
||||
|
||||
// Спробуємо знайти в основному DOM
|
||||
target = document.getElementById(targetId);
|
||||
|
||||
// Якщо не знайдено, шукаємо у Shadow DOM контейнера
|
||||
if (!target) {
|
||||
// Використовуємо querySelector для пошуку по всьому shadowRoot
|
||||
// Якщо елементи переміщені у itemsHiddenContainer, вони будуть тут
|
||||
target = navContainer.shadowRoot.querySelector(`#${targetId}`);
|
||||
}
|
||||
|
||||
// 4. Встановлюємо активний стан, якщо знайдено
|
||||
if (target) {
|
||||
target.setAttribute('data-state', 'active');
|
||||
}
|
||||
}
|
||||
}
|
||||
16
web/sw.js
16
web/sw.js
@@ -1,4 +1,4 @@
|
||||
const STATIC_CACHE_NAME = 'v2.0.103';
|
||||
const STATIC_CACHE_NAME = 'v2.2.1';
|
||||
|
||||
const FILES_TO_CACHE = [
|
||||
'/',
|
||||
@@ -7,7 +7,12 @@ const FILES_TO_CACHE = [
|
||||
"/lib/router/router.js",
|
||||
"/lib/router/routes.js",
|
||||
|
||||
"/lib/customElements/notification.js",
|
||||
"/lib/customElements/notifManager.js",
|
||||
"/lib/customElements/pwaInstallBanner.js",
|
||||
"/lib/customElements/swipeUpdater.js",
|
||||
"/lib/customElements/menuContainer.js",
|
||||
"/lib/customElements/territoryCard.js",
|
||||
"/lib/customElements/smartSelect.js",
|
||||
|
||||
"/lib/components/leaflet/leaflet.css",
|
||||
"/lib/components/leaflet/leaflet.js",
|
||||
@@ -19,12 +24,9 @@ const FILES_TO_CACHE = [
|
||||
|
||||
"/lib/components/cloud.js",
|
||||
|
||||
"/lib/components/metrics.js",
|
||||
|
||||
"/lib/components/clipboard.js",
|
||||
"/lib/components/colorGroup.js",
|
||||
"/lib/components/makeid.js",
|
||||
"/lib/components/swipeUpdater.js",
|
||||
"/lib/components/detectBrowser.js",
|
||||
"/lib/components/detectOS.js",
|
||||
"/lib/components/formattedDate.js",
|
||||
@@ -141,7 +143,7 @@ self.addEventListener("push", event => {
|
||||
try { data = event.data.json(); } catch { data = { title: "Повідомлення", body: event.data?.text() }; }
|
||||
|
||||
console.log('[ServiceWorker] ', data);
|
||||
|
||||
|
||||
|
||||
const title = data.title || "Повідомлення";
|
||||
const options = {
|
||||
@@ -157,7 +159,7 @@ self.addEventListener("push", event => {
|
||||
self.addEventListener("notificationclick", event => {
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: "window", includeUncontrolled: true }).then(clientList => {
|
||||
clients.matchAll({ type: "window", includeUncontrolled: true }).then(clientList => {
|
||||
for (const client of clientList) {
|
||||
if (client.url === event.notification.data && "focus" in client) return client.focus();
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
async function pushToMetrics(metric) {
|
||||
if (!metric || !metric.type) return;
|
||||
|
||||
const payload = { ...metric, timestamp: Date.now() };
|
||||
|
||||
fetch("http://metrics:4005/push", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(err => console.error("Metrics push error:", err));
|
||||
}
|
||||
|
||||
module.exports = { pushToMetrics };
|
||||
@@ -13,6 +13,7 @@
|
||||
"sqlite3": "^5.1.7",
|
||||
"url": "^0.11.4",
|
||||
"ws": "^8.18.0",
|
||||
"dotenv": "^17.2.0"
|
||||
"dotenv": "^17.2.0",
|
||||
"web-push": "^3.6.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,9 @@ const { updateApartment } = require("../services/apartments.service");
|
||||
const { updateBuilding } = require("../services/buildings.service");
|
||||
const { lockingStand, unlockingStand, updateStand } = require("../services/stand.service");
|
||||
const { broadcast } = require("../utils/broadcaster");
|
||||
const { pushToMetrics } = require("../middleware/pushToMetrics");
|
||||
|
||||
module.exports = async (wss, ws, message) => {
|
||||
try {
|
||||
pushToMetrics({
|
||||
type: "ws_out",
|
||||
length: message.length,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
switch (message.type) {
|
||||
case "apartment":
|
||||
await updateApartment(ws.user, message.data);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const db = require("../config/db");
|
||||
const Notification = require("../utils/notification.js");
|
||||
|
||||
function lockingStand(user, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sheepId = Number(data.sheep_id) || null;
|
||||
|
||||
|
||||
if (!user.possibilities.can_view_stand) {
|
||||
return reject(new Error("Forbidden: no rights to view stand"));
|
||||
}
|
||||
@@ -18,7 +19,7 @@ function lockingStand(user, data) {
|
||||
function unlockingStand(user, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sheepId = Number(data.sheep_id) || null;
|
||||
|
||||
|
||||
if (!user.possibilities.can_view_stand) {
|
||||
return reject(new Error("Forbidden: no rights to view stand"));
|
||||
}
|
||||
@@ -33,7 +34,7 @@ function unlockingStand(user, data) {
|
||||
function updateStand(user, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sheepId = Number(data.sheep_id) || null;
|
||||
|
||||
|
||||
if (!user.possibilities.can_view_stand) {
|
||||
return reject(new Error("Forbidden: no rights to view stand"));
|
||||
}
|
||||
@@ -52,11 +53,36 @@ function updateStand(user, data) {
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO stand_schedule_history
|
||||
(stand_schedule_id, sheep_id, created_at)
|
||||
VALUES (?, ?, ?)`;
|
||||
(stand_schedule_id, sheep_id, editor, created_at)
|
||||
VALUES (?, ?, ?, ?)`;
|
||||
|
||||
db.run(insertSql, [Number(data.id), sheepId, Date.now()], function (err) {
|
||||
db.run(insertSql, [Number(data.id), sheepId, user.id, Date.now()], function (err) {
|
||||
if (err) return reject(err);
|
||||
|
||||
if (sheepId === null) {
|
||||
let text = [
|
||||
'Звільнилося місце на одному зі стендів. Хто перший — той встигне 😉',
|
||||
'Є одне вільне місце на стенді. Запис відкрито — не проґавте 😉',
|
||||
'У одного зі стендів з’явилося вільне місце. Встигніть записатися!',
|
||||
'Раптова можливість! На стенді є вільне місце. Забронюйте його зараз 📋',
|
||||
'Одне місце стало вільним. Можливо, це саме ваше? 😉',
|
||||
'Стенд чекає нового учасника. Вільне місце вже доступне 📋',
|
||||
'Є шанс приєднатися — одне місце звільнилося 😊',
|
||||
'Вільне місце на стенді довго не чекатиме. Записуйтеся!',
|
||||
'Оголошуємо міні-набір: доступне одне місце на стенді.',
|
||||
'Щойно звільнилося місце. Хто швидший — той з нами 🚀',
|
||||
'З’явилася можливість долучитися до стенду. Кількість місць обмежена!',
|
||||
'Останнє вільне місце на стенді шукає свого власника.'
|
||||
];
|
||||
let randomMessage = text[Math.floor(Math.random() * text.length)];
|
||||
|
||||
Notification.sendStand({
|
||||
title: "Звільнилось місце",
|
||||
body: randomMessage,
|
||||
page: `/stand/card/${data.stand_id}`
|
||||
});
|
||||
}
|
||||
|
||||
resolve({ update: "ok", id: data.id, historyId: this.lastID });
|
||||
});
|
||||
});
|
||||
|
||||
152
ws/utils/notification.js
Normal file
152
ws/utils/notification.js
Normal file
@@ -0,0 +1,152 @@
|
||||
const db = require("../config/db");
|
||||
const webpush = require('web-push');
|
||||
|
||||
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY;
|
||||
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY;
|
||||
|
||||
webpush.setVapidDetails(
|
||||
'mailto:rozenrod320@gmail.com',
|
||||
VAPID_PUBLIC_KEY,
|
||||
VAPID_PRIVATE_KEY
|
||||
);
|
||||
|
||||
class Notification {
|
||||
async sendSheep({ sheep_id, title, body, page }) {
|
||||
const sql = `
|
||||
SELECT * FROM subscription
|
||||
WHERE sheep_id = ?
|
||||
ORDER BY id
|
||||
`;
|
||||
|
||||
db.all(sql, [sheep_id], async (err, rows) => {
|
||||
if (err) {
|
||||
console.error('DB error:', err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: title ?? "Тестове повідомлення",
|
||||
body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
|
||||
url: `https://${process.env.DOMAIN}${page ?? ""}`
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(rows.map(row => {
|
||||
const subscription = {
|
||||
endpoint: row.endpoint,
|
||||
keys: JSON.parse(row.keys),
|
||||
};
|
||||
return webpush.sendNotification(subscription, payload);
|
||||
}));
|
||||
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
|
||||
});
|
||||
}
|
||||
|
||||
async sendGroup({ group_id, title, body, page }) {
|
||||
const sql = `
|
||||
SELECT
|
||||
subscription.*
|
||||
FROM
|
||||
subscription
|
||||
JOIN
|
||||
sheeps
|
||||
ON
|
||||
sheeps.id = subscription.sheep_id
|
||||
WHERE
|
||||
sheeps.group_id = ?
|
||||
ORDER BY
|
||||
subscription.id;
|
||||
`;
|
||||
|
||||
db.all(sql, [group_id], async (err, rows) => {
|
||||
if (err) {
|
||||
console.error('DB error:', err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: title ?? "Тестове повідомлення",
|
||||
body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
|
||||
url: `https://${process.env.DOMAIN}${page ?? ""}`
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(rows.map(row => {
|
||||
const subscription = {
|
||||
endpoint: row.endpoint,
|
||||
keys: JSON.parse(row.keys),
|
||||
};
|
||||
return webpush.sendNotification(subscription, payload);
|
||||
}));
|
||||
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
|
||||
});
|
||||
}
|
||||
|
||||
async sendStand({ title, body, page }) {
|
||||
const sql = `
|
||||
SELECT
|
||||
subscription.*
|
||||
FROM
|
||||
subscription
|
||||
JOIN
|
||||
sheeps
|
||||
ON sheeps.id = subscription.sheep_id
|
||||
JOIN
|
||||
possibilities
|
||||
ON possibilities.sheep_id = sheeps.id
|
||||
WHERE
|
||||
possibilities.can_view_stand = '1'
|
||||
ORDER BY
|
||||
subscription.id;
|
||||
`;
|
||||
|
||||
db.all(sql, async (err, rows) => {
|
||||
if (err) {
|
||||
console.error('DB error:', err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
console.log(`🐑 No subscriptions`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: title ?? "Тестове повідомлення",
|
||||
body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
|
||||
url: `https://${process.env.DOMAIN}${page ?? ""}`
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(rows.map(row => {
|
||||
const subscription = {
|
||||
endpoint: row.endpoint,
|
||||
keys: JSON.parse(row.keys),
|
||||
};
|
||||
return webpush.sendNotification(subscription, payload);
|
||||
}));
|
||||
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = new Notification();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user