Додані повідомлення та перепрацьована структура застосунку та api

This commit is contained in:
2026-03-15 00:25:10 +02:00
parent 85483b85bb
commit 4bc9c11512
101 changed files with 5763 additions and 2546 deletions

View File

@@ -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 ```bash

View 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();

View File

@@ -13,6 +13,8 @@ class HomesteadsController {
id = req.sheepId; id = req.sheepId;
} else if (mode == "group") { } else if (mode == "group") {
id = req.group_id; id = req.group_id;
} else if (mode == "joint") {
id = req.sheepId;
} }
let result = await HomesteadsService.getList(mode, id); let result = await HomesteadsService.getList(mode, id);

View File

@@ -45,7 +45,7 @@ class SheepsController {
} }
async getListStand(req, res) { async getListStand(req, res) {
if (req.possibilities.can_view_stand) { if (req.possibilities.can_view_stand) {
const result = await SheepsService.getListStand(req.mode); const result = await SheepsService.getListStand(req.mode);
if (result) { if (result) {
@@ -86,7 +86,7 @@ class SheepsController {
const data = req.body; const data = req.body;
if (req.mode == 2) { if (req.mode == 2) {
let result = await SheepsService.updateSheep(data); let result = await SheepsService.updateSheep(data, 2);
if (result) { if (result) {
return res.status(200).send(result); return res.status(200).send(result);
@@ -95,10 +95,24 @@ class SheepsController {
message: 'Unable update sheep.', 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 { } else {
return res return res
.status(403) .status(403)
.send({ message: 'Sheep not foundThe sheep does not have enough rights.' }); .send({ message: 'The sheep does not have enough rights.' });
} }
} }

View File

@@ -9,9 +9,11 @@ const authenticate = (req, res, next) => {
sheeps.*, sheeps.*,
possibilities.can_add_sheeps AS can_add_sheeps, possibilities.can_add_sheeps AS can_add_sheeps,
possibilities.can_view_sheeps AS can_view_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_add_territory AS can_add_territory,
possibilities.can_view_territory AS can_view_territory, possibilities.can_view_territory AS can_view_territory,
possibilities.can_manager_territory AS can_manager_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_add_stand AS can_add_stand,
possibilities.can_view_stand AS can_view_stand, possibilities.can_view_stand AS can_view_stand,
possibilities.can_manager_stand AS can_manager_stand, possibilities.can_manager_stand AS can_manager_stand,
@@ -33,9 +35,11 @@ const authenticate = (req, res, next) => {
req.possibilities = { req.possibilities = {
can_add_sheeps: moderator.can_add_sheeps == 1 ? true : false, can_add_sheeps: moderator.can_add_sheeps == 1 ? true : false,
can_view_sheeps: moderator.can_view_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_add_territory: moderator.can_add_territory == 1 ? true : false,
can_view_territory: moderator.can_view_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_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_add_stand: moderator.can_add_stand == 1 ? true : false,
can_view_stand: moderator.can_view_stand == 1 ? true : false, can_view_stand: moderator.can_view_stand == 1 ? true : false,
can_manager_stand: moderator.can_manager_stand == 1 ? true : false, can_manager_stand: moderator.can_manager_stand == 1 ? true : false,
@@ -51,6 +55,7 @@ const authenticate = (req, res, next) => {
sheeps.*, sheeps.*,
possibilities.can_add_sheeps AS can_add_sheeps, possibilities.can_add_sheeps AS can_add_sheeps,
possibilities.can_view_sheeps AS can_view_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_add_territory AS can_add_territory,
possibilities.can_view_territory AS can_view_territory, possibilities.can_view_territory AS can_view_territory,
possibilities.can_manager_territory AS can_manager_territory, possibilities.can_manager_territory AS can_manager_territory,
@@ -76,8 +81,10 @@ const authenticate = (req, res, next) => {
req.possibilities = { req.possibilities = {
can_add_sheeps: false, can_add_sheeps: false,
can_view_sheeps: false, can_view_sheeps: false,
can_manager_sheeps: false,
can_add_territory: false, can_add_territory: false,
can_manager_territory: false, can_manager_territory: false,
can_joint_territory: false,
can_add_stand: false, can_add_stand: false,
can_manager_stand: false, can_manager_stand: false,
can_add_schedule: false, can_add_schedule: false,

View File

@@ -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();
};

View 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;

View File

@@ -1,13 +1,12 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const metrics = require('../middleware/metrics');
const authRoutes = require('./auth.routes'); const authRoutes = require('./auth.routes');
const sheepsRoutes = require('./sheeps.routes'); const sheepsRoutes = require('./sheeps.routes');
const constructorRoutes = require('./constructor.routes'); const constructorRoutes = require('./constructor.routes');
const housesRoutes = require('./houses.routes'); const housesRoutes = require('./houses.routes');
const homesteadsRoutes = require('./homesteads.routes'); const homesteadsRoutes = require('./homesteads.routes');
const homesteadJointRoutes = require('./homestead.joint.routes');
const buildingsRoutes = require('./buildings.routes'); const buildingsRoutes = require('./buildings.routes');
const entrancesRoutes = require('./entrances.routes'); const entrancesRoutes = require('./entrances.routes');
const apartmentsRoutes = require('./apartments.routes'); const apartmentsRoutes = require('./apartments.routes');
@@ -20,13 +19,11 @@ const pushRoutes = require('./push.routes');
const generatorCardsRoutes = require('./generator.cards.routes'); const generatorCardsRoutes = require('./generator.cards.routes');
const generatorReportTerritoriesRoutes = require('./generator.report.territories.routes'); const generatorReportTerritoriesRoutes = require('./generator.report.territories.routes');
router.use(metrics);
router.use('/auth', authRoutes); router.use('/auth', authRoutes);
router.use('/sheeps?', sheepsRoutes); router.use('/sheeps?', sheepsRoutes);
router.use('/constructor', constructorRoutes); router.use('/constructor', constructorRoutes);
router.use('/houses?', housesRoutes); router.use('/houses?', housesRoutes);
router.use('/homestead/joint/:homestead_id', homesteadJointRoutes);
router.use('/homesteads?', homesteadsRoutes); router.use('/homesteads?', homesteadsRoutes);
router.use('/buildings?', buildingsRoutes); router.use('/buildings?', buildingsRoutes);
router.use('/house/:house_id/entrances', entrancesRoutes); router.use('/house/:house_id/entrances', entrancesRoutes);

View File

@@ -8,9 +8,11 @@ class AuthService {
sheeps.*, sheeps.*,
possibilities.can_add_sheeps AS can_add_sheeps, possibilities.can_add_sheeps AS can_add_sheeps,
possibilities.can_view_sheeps AS can_view_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_add_territory AS can_add_territory,
possibilities.can_view_territory AS can_view_territory, possibilities.can_view_territory AS can_view_territory,
possibilities.can_manager_territory AS can_manager_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_add_stand AS can_add_stand,
possibilities.can_view_stand AS can_view_stand, possibilities.can_view_stand AS can_view_stand,
possibilities.can_manager_stand AS can_manager_stand, possibilities.can_manager_stand AS can_manager_stand,
@@ -42,8 +44,10 @@ class AuthService {
possibilities: { possibilities: {
can_add_sheeps: false, can_add_sheeps: false,
can_view_sheeps: false, can_view_sheeps: false,
can_manager_sheeps: false,
can_add_territory: false, can_add_territory: false,
can_manager_territory: false, can_manager_territory: false,
can_joint_territory: false,
can_add_stand: false, can_add_stand: false,
can_manager_stand: false, can_manager_stand: false,
can_add_schedule: false, can_add_schedule: false,
@@ -55,8 +59,10 @@ class AuthService {
if (mode && (mode == 1 || mode == 2)) { if (mode && (mode == 1 || mode == 2)) {
data.possibilities.can_add_sheeps = sheep.can_add_sheeps == 1 ? true : false; 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_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_add_territory = sheep.can_add_territory == 1 ? true : false;
data.possibilities.can_manager_territory = sheep.can_manager_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_add_stand = sheep.can_add_stand == 1 ? true : false;
data.possibilities.can_manager_stand = sheep.can_manager_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; data.possibilities.can_add_schedule = sheep.can_add_schedule == 1 ? true : false;

View File

@@ -6,6 +6,8 @@ class EntrancesService {
let sql = ` let sql = `
SELECT SELECT
entrance.*, 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, 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.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, (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), "house_id": Number(row.house_id),
"entrance_number": Number(row.entrance_number), "entrance_number": Number(row.entrance_number),
"title": row.title, "title": row.title,
"address": {
"title": row.address_title,
"number": row.address_number,
"points_number": JSON.parse(row.points_number)
},
"description": row.description, "description": row.description,
"created_at": Number(row.created_at), "created_at": Number(row.created_at),
"updated_at": Number(row.updated_at), "updated_at": Number(row.updated_at),

View File

@@ -1,4 +1,5 @@
const db = require("../config/db"); const db = require("../config/db");
const Notification = require("../utils/notification.js");
class HistoryHomesteadService { class HistoryHomesteadService {
getHistoryHomestead(homestead_id) { getHistoryHomestead(homestead_id) {

View 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();

View File

@@ -1,4 +1,5 @@
const db = require("../config/db"); const db = require("../config/db");
const genCards = require("../middleware/genCards");
class HomesteadsService { class HomesteadsService {
getList(mode, id) { getList(mode, id) {
@@ -63,6 +64,27 @@ class HomesteadsService {
AND AND
homestead_history.sheep_id = '${id}'; 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) => { db.all(sql, (err, rows) => {
@@ -88,7 +110,7 @@ class HomesteadsService {
"id": row.homestead_history_id ? Number(row.homestead_history_id) : null, "id": row.homestead_history_id ? Number(row.homestead_history_id) : null,
"name": row.homestead_history_name, "name": row.homestead_history_name,
"group_id": row.homestead_history_group_id ? Number(row.homestead_history_group_id) : null, "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": { "date": {
"start": row.homestead_history_date_start ? Number(row.homestead_history_date_start) : null, "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 "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, 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.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.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.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_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 (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, "id": row.homestead_history_id ? Number(row.homestead_history_id) : null,
"name": row.homestead_history_name, "name": row.homestead_history_name,
"group_id": row.homestead_history_group_id ? Number(row.homestead_history_group_id) : null, "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": { "date": {
"start": row.homestead_history_date_start ? Number(row.homestead_history_date_start) : null, "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 "end": row.homestead_history_date_end ? Number(row.homestead_history_date_end) : null
@@ -192,6 +216,7 @@ class HomesteadsService {
return res(false); return res(false);
} else { } else {
res({ "status": "ok", "id": this.lastID }); res({ "status": "ok", "id": this.lastID });
genCards({type: "homestead", id: this.lastID});
} }
}); });
}); });
@@ -234,6 +259,7 @@ class HomesteadsService {
return res(false); return res(false);
} else { } else {
res({ "status": "ok", "id": homestead_id }); res({ "status": "ok", "id": homestead_id });
genCards({type: "homestead", id: homestead_id});
} }
}); });
}); });

View File

@@ -1,4 +1,5 @@
const db = require("../config/db"); const db = require("../config/db");
const genCards = require("../middleware/genCards");
class HousesService { class HousesService {
getListEntrance() { getListEntrance() {
@@ -228,6 +229,7 @@ class HousesService {
return res(false); return res(false);
} else { } else {
res({ "status": "ok", "id": this.lastID }); res({ "status": "ok", "id": this.lastID });
genCards({type: "house", id: this.lastID});
} }
}); });
}); });
@@ -272,6 +274,8 @@ class HousesService {
return res(false); return res(false);
} else { } else {
res({ "status": "ok", "id": house_id }); res({ "status": "ok", "id": house_id });
genCards({type: "house", id: house_id});
} }
}); });
}); });

View File

@@ -9,9 +9,11 @@ class SheepService {
sheeps.*, sheeps.*,
possibilities.can_add_sheeps, possibilities.can_add_sheeps,
possibilities.can_view_sheeps, possibilities.can_view_sheeps,
possibilities.can_manager_sheeps,
possibilities.can_add_territory, possibilities.can_add_territory,
possibilities.can_view_territory, possibilities.can_view_territory,
possibilities.can_manager_territory, possibilities.can_manager_territory,
possibilities.can_joint_territory,
possibilities.can_add_stand, possibilities.can_add_stand,
possibilities.can_view_stand, possibilities.can_view_stand,
possibilities.can_manager_stand, possibilities.can_manager_stand,
@@ -38,9 +40,11 @@ class SheepService {
const fields = [ const fields = [
"can_add_sheeps", "can_add_sheeps",
"can_view_sheeps", "can_view_sheeps",
"can_manager_sheeps",
"can_add_territory", "can_add_territory",
"can_view_territory", "can_view_territory",
"can_manager_territory", "can_manager_territory",
"can_joint_territory",
"can_add_stand", "can_add_stand",
"can_view_stand", "can_view_stand",
"can_manager_stand", "can_manager_stand",
@@ -83,9 +87,11 @@ class SheepService {
sheeps.*, sheeps.*,
possibilities.can_add_sheeps, possibilities.can_add_sheeps,
possibilities.can_view_sheeps, possibilities.can_view_sheeps,
possibilities.can_manager_sheeps,
possibilities.can_add_territory, possibilities.can_add_territory,
possibilities.can_view_territory, possibilities.can_view_territory,
possibilities.can_manager_territory, possibilities.can_manager_territory,
possibilities.can_joint_territory,
possibilities.can_add_stand, possibilities.can_add_stand,
possibilities.can_view_stand, possibilities.can_view_stand,
possibilities.can_manager_stand, possibilities.can_manager_stand,
@@ -108,9 +114,11 @@ class SheepService {
const fields = [ const fields = [
"can_add_sheeps", "can_add_sheeps",
"can_view_sheeps", "can_view_sheeps",
"can_manager_sheeps",
"can_add_territory", "can_add_territory",
"can_view_territory", "can_view_territory",
"can_manager_territory", "can_manager_territory",
"can_joint_territory",
"can_add_stand", "can_add_stand",
"can_view_stand", "can_view_stand",
"can_manager_stand", "can_manager_stand",
@@ -156,9 +164,11 @@ class SheepService {
sheeps.*, sheeps.*,
possibilities.can_add_sheeps, possibilities.can_add_sheeps,
possibilities.can_view_sheeps, possibilities.can_view_sheeps,
possibilities.can_manager_sheeps,
possibilities.can_add_territory, possibilities.can_add_territory,
possibilities.can_view_territory, possibilities.can_view_territory,
possibilities.can_manager_territory, possibilities.can_manager_territory,
possibilities.can_joint_territory,
possibilities.can_add_stand, possibilities.can_add_stand,
possibilities.can_view_stand, possibilities.can_view_stand,
possibilities.can_manager_stand, possibilities.can_manager_stand,
@@ -183,9 +193,11 @@ class SheepService {
const fields = [ const fields = [
"can_add_sheeps", "can_add_sheeps",
"can_view_sheeps", "can_view_sheeps",
"can_manager_sheeps",
"can_add_territory", "can_add_territory",
"can_view_territory", "can_view_territory",
"can_manager_territory", "can_manager_territory",
"can_joint_territory",
"can_add_stand", "can_add_stand",
"can_view_stand", "can_view_stand",
"can_manager_stand", "can_manager_stand",
@@ -253,71 +265,120 @@ class SheepService {
}); });
} }
updateSheep(data) { updateSheep(data, mode) {
const stmt1 = db.prepare(` if (mode == 2) {
UPDATE const stmt1 = db.prepare(`
sheeps UPDATE
SET sheeps
name = ?, SET
group_id = ?, name = ?,
mode = ?, group_id = ?,
mode_title = ?, mode = ?,
uuid_manager = ? mode_title = ?,
WHERE uuid_manager = ?
uuid = ? WHERE
`); uuid = ?
`);
const stmt2 = db.prepare(` const stmt2 = db.prepare(`
UPDATE UPDATE
possibilities possibilities
SET SET
can_add_sheeps = ?, can_add_sheeps = ?,
can_view_sheeps = ?, can_view_sheeps = ?,
can_add_territory = ?, can_manager_sheeps = ?,
can_view_territory = ?, can_add_territory = ?,
can_manager_territory = ?, can_view_territory = ?,
can_add_stand = ?, can_manager_territory = ?,
can_view_stand = ?, can_joint_territory = ?,
can_manager_stand = ?, can_add_stand = ?,
can_add_schedule = ?, can_view_stand = ?,
can_view_schedule = ? can_manager_stand = ?,
WHERE can_add_schedule = ?,
sheep_id = (SELECT id FROM sheeps WHERE uuid = ? LIMIT 1) can_view_schedule = ?
`); WHERE
sheep_id = (SELECT id FROM sheeps WHERE uuid = ? LIMIT 1)
`);
return new Promise((res, rej) => { return new Promise((res, rej) => {
db.serialize(() => { db.serialize(() => {
let uuid_manager = crypto.randomUUID(); let uuid_manager = crypto.randomUUID();
stmt1.run([ stmt1.run([
data.name, data.name,
Number(data.group_id), Number(data.group_id),
Number(data.mode), Number(data.mode),
data.mode_title, data.mode_title,
Number(data.mode) == 0 ? null : (data.uuid_manager ? data.uuid_manager : uuid_manager), 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,
data.uuid data.uuid
], (err2) => { ], (err) => {
if (err2) return rej(err2); if (err) return rej(err);
res({ status: "ok", id: data.id });
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) { deleteSheep(data) {

View File

@@ -221,7 +221,7 @@ class StandService {
} }
const normalized = normalizeTs(row && row.max_date ? row.max_date : null); const normalized = normalizeTs(row && row.max_date ? row.max_date : null);
if (normalized) { if (normalized && normalized > Date.now()) {
date_start = getNextMonday(normalized); date_start = getNextMonday(normalized);
} else { } else {
date_start = getNextMonday(Date.now()); date_start = getNextMonday(Date.now());
@@ -272,9 +272,26 @@ class StandService {
console.error(err.message); console.error(err.message);
return res(false); 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({ Notification.sendStand({
title: "Додан новий день служіння", title: "Додано новий день служіння",
body: `Стенд «${stand.title}» поповнився, встигніть записатися.`, body: randomMessage,
page: `/stand/card/${stand.id}` page: `/stand/card/${stand.id}`
}); });
@@ -301,7 +318,7 @@ class StandService {
WHERE WHERE
ss.stand_id = ? ss.stand_id = ?
AND AND
date(ss.date / 1000, 'unixepoch') >= date('now') date(ss.date / 1000, 'unixepoch', 'localtime') >= date('now', 'localtime')
ORDER BY ORDER BY
ss.id; ss.id;
`; `;

View File

@@ -1,5 +1,9 @@
const db = require("../config/db"); const db = require("../config/db");
const webpush = require('web-push'); 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_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY;
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY; const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY;
@@ -10,6 +14,11 @@ webpush.setVapidDetails(
VAPID_PRIVATE_KEY 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 { class Notification {
async sendSheep({ sheep_id, title, body, page }) { async sendSheep({ sheep_id, title, body, page }) {
const sql = ` const sql = `
@@ -123,7 +132,7 @@ class Notification {
} }
if (!rows.length) { if (!rows.length) {
console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`); console.log(`🐑 No subscriptions`);
return; return;
} }
@@ -146,6 +155,25 @@ class Notification {
const failed = results.filter(r => r.status === 'rejected').length; const failed = results.filter(r => r.status === 'rejected').length;
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`); 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);
}
} }
}; };

View File

@@ -8,4 +8,8 @@ RUN npm install
COPY . . COPY . .
COPY ./fonts/ /usr/share/fonts/truetype/roboto/
RUN fc-cache -f -v
CMD npm start CMD npm start

View File

@@ -1,13 +1,57 @@
const cron = require("node-cron"); const cron = require("node-cron");
const Backup = require("./tasks/backup"); 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 * * *", () => { cron.schedule("30 22 * * *", () => {
Backup.database(); Backup.database();
console.log(`[${new Date().toLocaleString()}] Завдання «Backup» виконане!`);
const now = new Date().toLocaleString();
console.log(`[${now}] Завдання «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-завдання запущено."); console.log("Cron-завдання запущено.");

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

2026
cron/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,12 @@
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"dependencies": { "dependencies": {
"node-cron": "^4.2.1", "@aws-sdk/client-s3": "^3.1004.0",
"sqlite3": "^5.1.7",
"web-push": "^3.6.7",
"dotenv": "^17.2.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"
} }
} }

View File

@@ -1,6 +1,7 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const TelegramBot = require("node-telegram-bot-api"); const TelegramBot = require("node-telegram-bot-api");
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
const TOKEN = process.env.TELEGRAM_TOKEN; const TOKEN = process.env.TELEGRAM_TOKEN;
const CHAT_ID = process.env.CHAT_ID; 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 }); 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 { class Backup {
async database() { async database() {
try { try {
@@ -17,13 +28,25 @@ class Backup {
return; return;
} }
console.log(`📤 Надсилаю файл: ${FILE}`); const fileName = `backup_${new Date().toISOString().replace(/[:.]/g, "-")}.sqlite`;
// 1. Отправка в Telegram
await bot.sendDocument(CHAT_ID, fs.createReadStream(FILE), { 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) { } catch (err) {
console.error("❌ Помилка при надсиланні файлу:", err.message); console.error("❌ Помилка при надсиланні файлу:", err.message);
} }

55
cron/tasks/messages.js Normal file
View 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
View 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
View 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();

View File

@@ -11,6 +11,44 @@ webpush.setVapidDetails(
); );
class Notification { 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 }) { async sendSheep({ sheep_id, title, body, page }) {
const sql = ` const sql = `
SELECT * FROM subscription SELECT * FROM subscription
@@ -73,7 +111,7 @@ class Notification {
} }
if (!rows.length) { if (!rows.length) {
console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`); console.log(`🐑 No subscriptions found for group_id: ${group_id}`);
return; return;
} }
@@ -98,7 +136,7 @@ class Notification {
}); });
} }
async sendStand({ title, body, page }) { async sendStandAdd({title, body, page }) {
const sql = ` const sql = `
SELECT SELECT
subscription.* subscription.*
@@ -111,7 +149,7 @@ class Notification {
possibilities possibilities
ON possibilities.sheep_id = sheeps.id ON possibilities.sheep_id = sheeps.id
WHERE WHERE
possibilities.can_view_stand = '1' possibilities.can_add_stand = '1'
ORDER BY ORDER BY
subscription.id; subscription.id;
`; `;
@@ -147,6 +185,22 @@ class Notification {
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`); 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(); module.exports = new Notification();

163
dash.json
View File

@@ -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
}

View File

@@ -1,7 +0,0 @@
global:
scrape_interval: 5s
scrape_configs:
- job_name: 'pushgateway'
static_configs:
- targets: ['pushgateway:9091']

View File

@@ -31,11 +31,11 @@ services:
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- DOMAIN=${DOMAIN} - DOMAIN=${DOMAIN}
- ADMIN_TOKEN=${ADMIN_TOKEN} - ADMIN_TOKEN=${ADMIN_TOKEN}
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
- STAND_CHAT_ID=${STAND_CHAT_ID}
shm_size: '1gb' shm_size: '1gb'
networks: networks:
- network - network
depends_on:
- metrics
ws: ws:
image: sheep-service/ws image: sheep-service/ws
@@ -48,57 +48,12 @@ services:
environment: environment:
- TZ=${TZ} - TZ=${TZ}
- DATABASE_PATH=/app/data/ - DATABASE_PATH=/app/data/
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- DOMAIN=${DOMAIN}
networks: networks:
- network - 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: nginx:
image: nginx:latest image: nginx:latest
restart: always 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;'" command: /bin/sh -c "envsubst '\$DOMAIN' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"
networks: networks:
- network - network
depends_on:
- grafana
cron: cron:
image: sheep-service/cron image: sheep-service/cron
@@ -129,7 +82,11 @@ services:
- TZ=${TZ} - TZ=${TZ}
- DATABASE_PATH=/app/data/ - DATABASE_PATH=/app/data/
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN} - TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
- STAND_CHAT_ID=${STAND_CHAT_ID}
- CHAT_ID=${CHAT_ID} - CHAT_ID=${CHAT_ID}
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- DOMAIN=${DOMAIN}
networks: networks:
- network - network

View File

@@ -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

View File

@@ -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);

View File

@@ -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
}
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -10,10 +10,6 @@ upstream ws_backend {
server ws:4004; server ws:4004;
} }
upstream metrics_backend {
server metrics:4006;
}
# Загальні CORS-заголовки # Загальні CORS-заголовки
map $request_method $cors_preflight { map $request_method $cors_preflight {
OPTIONS 1; OPTIONS 1;
@@ -58,25 +54,6 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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 # Frontend
location / { location / {
proxy_pass http://frontend$request_uri; proxy_pass http://frontend$request_uri;

View File

@@ -1,6 +1,5 @@
const CONFIG = { const CONFIG = {
"web": "https://test.sheep-service.com/", "web": "https://test.sheep-service.com/",
"api": "https://test.sheep-service.com/api/", "api": "https://test.sheep-service.com/api/",
"wss": "wss://test.sheep-service.com/ws", "wss": "wss://test.sheep-service.com/ws"
"metrics": "wss://test.sheep-service.com/metrics"
} }

View File

@@ -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 { :root {
--FontSize1: 12px; --FontSize1: 12px;
@@ -28,13 +66,14 @@
/* BGColor */ /* BGColor */
--ColorThemes0: #fbfbfb; --ColorThemes0: #fbfbfb;
--ColorThemes1: #f3f3f3; --ColorThemes1: #f3f3f3;
--ColorThemes2: #dbdbd1; --ColorThemes2: #e7e7e1;
/* TextColor */ /* TextColor */
--ColorThemes3: #313131; --ColorThemes3: #313131;
--ColorAnimation: linear-gradient(90deg, #f3f3f3, #efefef, #f3f3f3); --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; --border-radius: 15px;
@@ -51,13 +90,14 @@
/* BGColor */ /* BGColor */
--ColorThemes0: #1c1c19; --ColorThemes0: #1c1c19;
--ColorThemes1: #21221d; --ColorThemes1: #21221d;
--ColorThemes2: #525151; --ColorThemes2: #3d3c3c;
/* TextColor */ /* TextColor */
--ColorThemes3: #f3f3f3; --ColorThemes3: #f3f3f3;
--ColorAnimation: linear-gradient(90deg, #21221d, #242520, #21221d); --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; --border-radius: 15px;
@@ -81,6 +121,16 @@
cursor: no-drop !important; cursor: no-drop !important;
} }
@media(hover: hover) {
a:hover,
button:hover,
select:hover,
input:hover,
textarea:hover {
opacity: 0.8;
}
}
@media (min-width: 800px) { @media (min-width: 800px) {
* { * {
scroll-snap-type: none !important; scroll-snap-type: none !important;
@@ -238,314 +288,6 @@ body.modal-open {
overflow: hidden; 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 { #app {
background: var(--ColorThemes0); background: var(--ColorThemes0);
position: absolute; position: absolute;
@@ -659,7 +401,7 @@ body.modal-open {
} }
.leaflet-popup-content { .leaflet-popup-content {
margin: 8px 10px !important; margin: 10px 10px !important;
padding: 0 !important; padding: 0 !important;
} }

View File

@@ -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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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.

View File

@@ -41,12 +41,16 @@
<!-- Конфигурация SW --> <!-- Конфигурация SW -->
<script src="/sw.js"></script> <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" /> <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="/config.js" defer></script>
<script src="/lib/router/router.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/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/clipboard.js" defer></script>
<script src="/lib/components/colorGroup.js" defer></script> <script src="/lib/components/colorGroup.js" defer></script>
<script src="/lib/components/makeid.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/detectBrowser.js" defer></script>
<script src="/lib/components/detectOS.js" defer></script> <script src="/lib/components/detectOS.js" defer></script>
<script src="/lib/components/formattedDate.js" defer></script> <script src="/lib/components/formattedDate.js" defer></script>
@@ -130,148 +133,28 @@
</head> </head>
<body> <body>
<!-- Банер з прохання встановлення PWA --> <!-- Банер з прохання встановлення PWA -->
<div id="blur-backdrop" class="pwa-hidden"></div> <pwa-install-banner></pwa-install-banner>
<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> <notification-container
<li>1. Відкрийте посилання в браузері Safari.</li> id="notif-manager"
position="top-right"
<li> max-visible="5"
2. Натисніть кнопку timeout="4000"
<span> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"> </notification-container>
<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>
<!-- Анімація оновлення сторінки свайпом --> <!-- Анімація оновлення сторінки свайпом -->
<div id="swipe_updater"> <swipe-updater></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>
<!-- Меню застосунку --> <!-- Меню застосунку -->
<div id="navigation"> <navigation-container id="main-nav" data-os="ios">
<nav> <nav-item
<li> id="menu-home"
<div id="nav-home"> title="Головна"
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> 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>'
<path href="/"
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" ></nav-item>
/> </navigation-container>
</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>
<!-- Блок контенту застосунка --> <!-- Блок контенту застосунка -->
<div id="app"></div> <div id="app"></div>

View File

@@ -4,6 +4,7 @@ let swRegistration = null;
// Реєструємо CustomElements // Реєструємо CustomElements
const Notifier = document.getElementById('notif-manager'); const Notifier = document.getElementById('notif-manager');
const Updater = document.querySelector('swipe-updater');
// Определение ID главного блока // Определение ID главного блока
let app = document.getElementById('app'); let app = document.getElementById('app');
@@ -13,32 +14,23 @@ Router.config({ mode: 'history' });
async function appReload() { async function appReload() {
location.reload(); // location.reload();
// Router.navigate(window.location.pathname, false).check(); // Router.check().listen().delegateLinks();
// // Закрытие старого соединения WebSocket // // Закрытие старого соединения WebSocket
// if (socket) socket.close(1000, "Перезапуск соединения"); // if (Cloud.socket) Cloud.socket.close(1000, "Перезапуск з'єднання");
// Cloud.start();
// listEntrances = [] // listEntrances = []
// listApartment = [] // listApartment = []
} }
// Перевизначення функції, яка викликається при "свайпі"
// Updater.setReloadFunction(() => appReload());
// Функция загрузки приложения // Функция загрузки приложения
window.addEventListener('load', async function () { window.addEventListener('load', async function () {
console.log('[OS] ', detectOS()); 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) { if (Router.getParams().uuid) {
localStorage.setItem("uuid", Router.getParams().uuid) localStorage.setItem("uuid", Router.getParams().uuid)
@@ -65,14 +57,47 @@ window.addEventListener('load', async function () {
console.log("[APP] USER Info: ", USER); console.log("[APP] USER Info: ", USER);
if (USER.possibilities.can_view_stand) {
if (USER.possibilities.can_view_sheeps) document.getElementById("li-sheeps").style.display = ""; newMenuItems({
if (USER.possibilities.can_view_schedule) document.getElementById("li-schedule").style.display = ""; id: 'menu-stand',
if (USER.possibilities.can_manager_territory) document.getElementById("li-territory").style.display = ""; title: 'Графік стенду',
if (USER.possibilities.can_view_stand) document.getElementById("li-stand").style.display = ""; 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>`,
document.getElementById("li-options").style.display = ""; href: '/stand'
});
if (USER.possibilities.can_view_sheeps) await Sheeps.sheeps_list.loadAPI(); }
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, "Перезапуск з'єднання"); if (Cloud.socket) Cloud.socket.close(1000, "Перезапуск з'єднання");
Cloud.start(); Cloud.start();
@@ -80,11 +105,19 @@ window.addEventListener('load', async function () {
editFontStyle(); editFontStyle();
Router.check().listen().delegateLinks(); 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; let offlineNode = null;
window.addEventListener("offline", () => { window.addEventListener("offline", () => {
console.log("[APP] Інтернет зник"); console.log("[APP] Інтернет зник");
@@ -104,9 +137,6 @@ window.addEventListener("online", () => {
title: 'Онлайн', title: 'Онлайн',
text: 'Інтернет знову працює' text: 'Інтернет знову працює'
}, { timeout: 3000 }); }, { timeout: 3000 });
if (Cloud.socket) Cloud.socket.close(1000, "Перезапуск з'єднання");
Cloud.start();
}); });
function editFontStyle() { 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) { if ('serviceWorker' in navigator) {
let refreshing = false; let refreshing = false;
let updateNode = null; let updateNode = null;

View File

@@ -1,5 +1,5 @@
clipboard = (text) => { clipboard = (text) => {
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text)
.then(() => alert("Посилання скопійовано!")) .then(() => Notifier.success("Посилання скопійовано!", {timeout: 2000}))
.catch(err => console.error(err)) .catch(err => console.error(err))
} }

View File

@@ -9,8 +9,6 @@ const Cloud = {
Cloud.status = 'sync'; Cloud.status = 'sync';
const uuid = localStorage.getItem("uuid"); const uuid = localStorage.getItem("uuid");
if(!navigator.onLine) alert("[APP] Інтернет з'єднання відсутнє!")
if (Cloud.socket && Cloud.socket.readyState <= 1) return; if (Cloud.socket && Cloud.socket.readyState <= 1) return;
const ws = new WebSocket(CONFIG.wss, uuid); const ws = new WebSocket(CONFIG.wss, uuid);
@@ -79,13 +77,18 @@ const Cloud = {
} else { } else {
Cloud.reconnecting = false; Cloud.reconnecting = false;
if (confirm("З'єднання розірвано! Перепідключитись?")) { Notifier.click({
Cloud.reconnecting = true; title: `З'єднання розірвано!`,
Cloud.reconnectAttempts = 0; text: `Натисніть, щоб перепідключитись!`
Cloud.start(); }, {
} else { type: 'warn',
console.warn("[WebSocket] Перепідключення відмінено користувачем"); f: () => {
} Cloud.reconnecting = true;
Cloud.reconnectAttempts = 0;
Cloud.start();
},
timeout: 0
});
} }
}; };

View File

@@ -3,7 +3,6 @@ const RECONNECT_INTERVAL = 3000;
let isConnectedMetrics = false; let isConnectedMetrics = false;
function setupFrontendMetrics() { function setupFrontendMetrics() {
console.log("[Metrics] Спроба підключення до метрик...");
mws = new WebSocket(CONFIG.metrics); mws = new WebSocket(CONFIG.metrics);
mws.onopen = () => { mws.onopen = () => {

View File

@@ -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')
}
}
}
});

View 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);

View File

@@ -1,20 +1,28 @@
class AppNotificationContainer extends HTMLElement { /**
* Клас NotificationContainer
* Веб-компонент для відображення системних сповіщень.
* Використовує Shadow DOM для інкапсуляції стилів.
*/
class NotificationContainer extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: 'open' });
// Настройки по умолчанию
this._timeout = 4000; this._timeout = 4000;
this._maxVisible = 5; this._maxVisible = 5;
this._position = 'top-right'; this._position = 'top-right';
this._mobileBottomEnabled = false; this._mobileBottomEnabled = false;
// *** Вдосконалення: Відстежуємо ноди, що видаляються ***
this._removingNodes = new Set();
this._container = document.createElement('div'); this._container = document.createElement('div');
this._container.className = 'app-notification-container'; this._container.className = 'app-notification-container';
this.shadowRoot.appendChild(this._container); this.shadowRoot.appendChild(this._container);
this._insertStyles(); this._insertStyles();
// SVG іконки для різних типів сповіщень (залишаємо як є)
this._icons = { 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>`, 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>`, 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; this._timeout = parseInt(this.getAttribute('timeout')) || 4000;
const mobilePosAttr = this.getAttribute('mobile-position'); const mobilePosAttr = this.getAttribute('mobile-position');
// Если атрибут установлен в 'bottom' или присутствует (как пустая строка, если это булевый атрибут)
this._mobileBottomEnabled = mobilePosAttr === 'bottom' || mobilePosAttr === ''; this._mobileBottomEnabled = mobilePosAttr === 'bottom' || mobilePosAttr === '';
this._container.setAttribute('data-position', this._position); this._container.setAttribute('data-position', this._position);
@@ -51,9 +58,6 @@ class AppNotificationContainer extends HTMLElement {
this._applyMobileStyles(); this._applyMobileStyles();
} }
/**
* Динамически применяет класс, который активирует мобильные стили "только снизу".
*/
_applyMobileStyles() { _applyMobileStyles() {
if (this._mobileBottomEnabled) { if (this._mobileBottomEnabled) {
this._container.classList.add('mobile-bottom'); this._container.classList.add('mobile-bottom');
@@ -62,15 +66,14 @@ class AppNotificationContainer extends HTMLElement {
} }
} }
/**
* Публичный метод для изменения настройки мобильной позиции во время выполнения.
* @param {boolean} enable - true, чтобы принудительно устанавливать позицию снизу на мобильных, false, чтобы использовать обычные @media стили.
*/
setMobileBottom(enable) { setMobileBottom(enable) {
this._mobileBottomEnabled = !!enable; this._mobileBottomEnabled = !!enable;
this._applyMobileStyles(); this._applyMobileStyles();
} }
/**
* Показує нове сповіщення.
*/
show(message, options = {}) { show(message, options = {}) {
const { const {
type = 'info', type = 'info',
@@ -84,15 +87,28 @@ class AppNotificationContainer extends HTMLElement {
? { title: title || '', text: message } ? { title: title || '', text: message }
: message; : message;
while (this._container.children.length >= this._maxVisible) { // **Оновлена логіка обмеження кількості:**
const first = this._container.firstElementChild; // Визначаємо кількість "видимих" нод (які не перебувають у процесі видалення)
if (first) first.remove(); const totalChildren = this._container.children.length;
else break; 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'); const node = document.createElement('div');
node.className = `app-notification ${type}`; node.className = `app-notification ${type}`;
if (onClick) node.style.cursor = "pointer" if (onClick) node.style.cursor = "pointer";
const icon = document.createElement('div'); const icon = document.createElement('div');
icon.className = 'icon'; icon.className = 'icon';
@@ -114,6 +130,7 @@ class AppNotificationContainer extends HTMLElement {
node.appendChild(icon); node.appendChild(icon);
node.appendChild(body); node.appendChild(body);
// Додаємо кнопку закриття
if (!onClick && !lock) { if (!onClick && !lock) {
const closeDiv = document.createElement('div'); const closeDiv = document.createElement('div');
closeDiv.className = 'blockClose'; closeDiv.className = 'blockClose';
@@ -121,28 +138,35 @@ class AppNotificationContainer extends HTMLElement {
const closeBtn = document.createElement('button'); const closeBtn = document.createElement('button');
closeBtn.className = 'close'; 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>'; 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); closeDiv.appendChild(closeBtn);
closeBtn.addEventListener('click', () => this._removeNode(node)); closeBtn.addEventListener('click', (e) => {
e.stopPropagation(); // Запобігаємо спрацьовуванню onClick на самій ноді
this._removeNode(node);
});
} }
this._container.appendChild(node); this._container.appendChild(node);
// Запускаємо анімацію появи через requestAnimationFrame
requestAnimationFrame(() => node.classList.add('show')); requestAnimationFrame(() => node.classList.add('show'));
let timer = null; let timer = null;
const startTimer = () => { const startTimer = () => {
if (timeout === 0) return; if (timeout === 0 || lock) return;
timer = setTimeout(() => this._removeNode(node), timeout); timer = setTimeout(() => this._removeNode(node), timeout);
}; };
const clearTimer = () => { if (timer) { clearTimeout(timer); timer = null; } }; const clearTimer = () => { if (timer) { clearTimeout(timer); timer = null; } };
// Зупинка таймауту при наведенні
node.addEventListener('mouseenter', clearTimer); node.addEventListener('mouseenter', clearTimer);
node.addEventListener('mouseleave', startTimer); node.addEventListener('mouseleave', startTimer);
// Обробка кліку на сповіщенні
if (typeof onClick === 'function') { if (typeof onClick === 'function') {
node.addEventListener('click', () => { node.addEventListener('click', () => {
try { onClick(); } catch (e) { } clearTimer(); // Зупиняємо таймаут, якщо він був
try { onClick(); } catch (e) { console.error(e); }
this._removeNode(node); this._removeNode(node);
}); });
} }
@@ -152,29 +176,50 @@ class AppNotificationContainer extends HTMLElement {
return node; return node;
} }
/**
* Приватний метод для видалення ноди з анімацією.
* **Тепер перевіряє, чи нода вже видаляється.**
*/
_removeNode(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'); node.classList.remove('show');
// Чекаємо завершення анімації зникнення (200мс)
setTimeout(() => { setTimeout(() => {
if (node && node.parentElement) node.parentElement.removeChild(node); if (node && node.parentElement) {
node.parentElement.removeChild(node);
}
this._removingNodes.delete(node); // Видаляємо зі списку після фізичного видалення
}, 200); }, 200);
} }
/**
* Видаляє всі видимі сповіщення.
*/
clearAll() { clearAll() {
if (!this._container) return; if (!this._container) return;
// Використовуємо _removeNode, який тепер безпечно обробляє повторні виклики
Array.from(this._container.children).forEach(n => this._removeNode(n)); Array.from(this._container.children).forEach(n => this._removeNode(n));
} }
// Допоміжні методи з фіксованим типом сповіщення
info(message, opts = {}) { return this.show(message, { ...opts, type: 'info' }); } info(message, opts = {}) { return this.show(message, { ...opts, type: 'info' }); }
success(message, opts = {}) { return this.show(message, { ...opts, type: 'success' }); } success(message, opts = {}) { return this.show(message, { ...opts, type: 'success' }); }
warn(message, opts = {}) { return this.show(message, { ...opts, type: 'warn' }); } warn(message, opts = {}) { return this.show(message, { ...opts, type: 'warn' }); }
error(message, opts = {}) { return this.show(message, { ...opts, type: 'error' }); } error(message, opts = {}) { return this.show(message, { ...opts, type: 'error' }); }
// Метод для сповіщень, що реагують на клік (псевдонім 'click' для 'show' з 'onClick')
click(message, opts = {}) { return this.show(message, { ...opts, onClick: opts.f }); } click(message, opts = {}) { return this.show(message, { ...opts, onClick: opts.f }); }
/**
* Вставляє необхідні CSS стилі в Shadow DOM (залишаємо як є).
*/
_insertStyles() { _insertStyles() {
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = ` style.textContent = `
/* Контейнер */
.app-notification-container { .app-notification-container {
position: fixed; position: fixed;
z-index: 9999; z-index: 9999;
@@ -184,11 +229,13 @@ class AppNotificationContainer extends HTMLElement {
gap: 10px; gap: 10px;
padding: 12px; padding: 12px;
} }
/* Позиціонування контейнера */
.app-notification-container[data-position="top-right"] { top: 8px; right: 8px; align-items: flex-end; } .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="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-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-container[data-position="bottom-left"] { bottom: 8px; left: 8px; align-items: flex-start; }
/* Одне сповіщення */
.app-notification { .app-notification {
pointer-events: auto; pointer-events: auto;
min-width: 220px; min-width: 220px;
@@ -230,6 +277,8 @@ class AppNotificationContainer extends HTMLElement {
} }
.app-notification .body { flex:1; } .app-notification .body { flex:1; }
.app-notification .title { font-weight: 600; margin-bottom: 4px; font-size: 13px; } .app-notification .title { font-weight: 600; margin-bottom: 4px; font-size: 13px; }
/* Кнопка закриття */
.app-notification .blockClose { .app-notification .blockClose {
width: 20px; width: 20px;
height: 20px; height: 20px;
@@ -253,6 +302,7 @@ class AppNotificationContainer extends HTMLElement {
display: block; display: block;
} }
/* Стилі за типами */
.app-notification.info { .app-notification.info {
background: var(--ColorThemes3, #2196F3); background: var(--ColorThemes3, #2196F3);
color: var(--ColorThemes0, #ffffff); color: var(--ColorThemes0, #ffffff);
@@ -273,6 +323,7 @@ class AppNotificationContainer extends HTMLElement {
.app-notification.error .icon { background: #c45050; } .app-notification.error .icon { background: #c45050; }
.app-notification.error .close svg{fill: #fff;} .app-notification.error .close svg{fill: #fff;}
/* Адаптивність для мобільних пристроїв */
@media (max-width: 700px) { @media (max-width: 700px) {
.app-notification-container { .app-notification-container {
left: 0; left: 0;
@@ -281,10 +332,10 @@ class AppNotificationContainer extends HTMLElement {
align-items: center !important; align-items: center !important;
} }
.app-notification-container .app-notification { .app-notification-container .app-notification {
max-width: 95%; max-width: calc(100% - 30px);
min-width: 95%; min-width: calc(100% - 30px);
} }
/* Спеціальна мобільна позиція знизу */
.app-notification-container.mobile-bottom { .app-notification-container.mobile-bottom {
top: auto; top: auto;
bottom: 0; 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" id="notif-manager"
position="top-right" position="top-right"
max-visible="5" max-visible="5"
timeout="4000" timeout="4000"
mobile-position> mobile-position>
</app-notification-container> */ </notification-container>
// const Notifier = document.getElementById('notif-manager');
// 💡 Включить принудительную позицию снизу для мобильных 2. Отримайте посилання на компонент у JS:
// Notifier.setMobileBottom(true); const Notifier = document.getElementById('notif-manager');
// 💡 Отключить принудительную позицию снизу (вернется к поведению @media или position) 3. Приклади викликів:
// Notifier.setMobileBottom(false);
💡 Базові сповіщення
Notifier.info('Налаштування мобільної позиції змінено.');
Notifier.success('Успішна операція.');
Notifier.warn('Увага: низький рівень заряду батареї.');
Notifier.error('Критична помилка!');
// Пример использования 💡 Сповіщення із заголовком
Notifier.info('Це повідомлення має чіткий заголовок.', {
title: 'Важлива інформація'
});
// Notifier.info('Настройки мобильной позиции изменены.'); 💡 Сповіщення з об'єктом (заголовок та текст)
// Notifier.info('Привет! Это ваше первое уведомление через Web Component.', { Notifier.warn({
// title: 'Успешная инициализация', title: `Metrics`,
// onClick: () => alert('Вы кликнули!'), text: `З'єднання встановлено`
// lock: false });
// });
// Notifier.success('Успешная операция.'); 💡 Сповіщення, яке не зникає (timeout: 0 або lock: true)
// Notifier.error('Критическая ошибка!', { timeout: 0, lock: true }); Notifier.error('Критична помилка! Необхідне втручання.', {
// Notifier.warn({ title: `Metrics`, text: `З'єднання встановлено` }, { timeout: 0 }); timeout: 0,
lock: true
});
💡 Сповіщення з обробником кліку (автоматично закривається після кліку)
Notifier.info('Натисніть тут, щоб побачити деталі.', {
onClick: () => alert('Ви клацнули! Дякую.'),
lock: false
});
💡 Програмне керування
Notifier.setMobileBottom(true); // Включити примусову позицію знизу для мобільних
Notifier.setMobileBottom(false); // Вимкнути примусову позицію знизу
Notifier.clearAll(); // Видалити всі сповіщення
*/

View 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);

View 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);

View 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();
*/

View File

@@ -1,5 +1,5 @@
const appTerritoryCardStyles = new CSSStyleSheet(); // Заміна вмісту таблиці стилів на надані CSS-правила.
appTerritoryCardStyles.replaceSync(` const CARD_STYLES_CSS = `
:host { :host {
display: inline-block; display: inline-block;
box-sizing: border-box; box-sizing: border-box;
@@ -54,14 +54,15 @@ appTerritoryCardStyles.replaceSync(`
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
position: relative;
z-index: 1;
filter: blur(3px);
border-radius: calc(var(--border-radius, 15px) - 5px);
}
.contents {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
z-index: 1;
filter: blur(3px);
}
.contents {
position: relative;
z-index: 2; z-index: 2;
background: rgb(64 64 64 / 0.7); background: rgb(64 64 64 / 0.7);
width: 100%; width: 100%;
@@ -89,7 +90,7 @@ appTerritoryCardStyles.replaceSync(`
/* Стили для режима 'sheep' */ /* Стилі для режиму 'sheep' */
.sheep { .sheep {
margin: 10px; margin: 10px;
max-height: 50px; max-height: 50px;
@@ -116,7 +117,7 @@ appTerritoryCardStyles.replaceSync(`
} }
/* Стили для режима 'info' (прогресс) */ /* Стилі для режиму 'info' (прогресс) */
.info { .info {
margin: 10px; margin: 10px;
} }
@@ -133,7 +134,7 @@ appTerritoryCardStyles.replaceSync(`
} }
.info span { .info span {
z-index: 2; z-index: 2;
font-size: var(--FontSize1, 12px); font-size: var(--FontSize3, 14px);
color: var(--ColorThemes3, #f3f3f3); color: var(--ColorThemes3, #f3f3f3);
} }
.info p { .info p {
@@ -158,24 +159,45 @@ appTerritoryCardStyles.replaceSync(`
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 10; 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 { class AppTerritoryCard extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: 'open' });
// Додаємо стилі в конструкторі, якщо це adoptable
if (this.shadowRoot.adoptedStyleSheets) { if (this.shadowRoot.adoptedStyleSheets) {
this.shadowRoot.adoptedStyleSheets = [appTerritoryCardStyles]; 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() { static get observedAttributes() {
return ['image', 'address', 'sheep', 'link', 'atWork', 'quantity']; return ['image', 'address', 'sheep', 'link', 'atWork', 'quantity', 'overdue'];
} }
// Геттери та Сеттери для атрибутів
// Вони спрощують роботу з атрибутами як з властивостями DOM-елемента
get image() { get image() {
return this.getAttribute('image'); return this.getAttribute('image');
} }
@@ -198,6 +220,11 @@ class AppTerritoryCard extends HTMLElement {
} }
} }
/** * Атрибут 'sheep' може приймати три стани:
* 1. null / відсутній: відключення блоку sheep та info
* 2. порожній рядок ('') / присутній без значення: Режим "Територія не опрацьовується"
* 3. рядок зі значенням: Режим "Територію опрацьовує: [значення]"
*/
get sheep() { get sheep() {
return this.getAttribute('sheep'); return this.getAttribute('sheep');
} }
@@ -228,6 +255,7 @@ class AppTerritoryCard extends HTMLElement {
if (newValue === null) { if (newValue === null) {
this.removeAttribute('atWork'); this.removeAttribute('atWork');
} else { } else {
// Приводимо до рядка, оскільки атрибути завжди є рядками
this.setAttribute('atWork', String(newValue)); this.setAttribute('atWork', String(newValue));
} }
} }
@@ -239,41 +267,74 @@ class AppTerritoryCard extends HTMLElement {
if (newValue === null) { if (newValue === null) {
this.removeAttribute('quantity'); this.removeAttribute('quantity');
} else { } else {
// Приводимо до рядка
this.setAttribute('quantity', String(newValue)); 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() { connectedCallback() {
this.render(); this.render();
} }
// Вызывается при изменении одного из отслеживаемых атрибутов /**
* attributeChangedCallback викликається при зміні одного зі спостережуваних атрибутів.
*/
attributeChangedCallback(name, oldValue, newValue) { attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) { if (oldValue !== newValue) {
this.render(); this.render(); // Перерендеринг при зміні атрибута
} }
} }
/**
* Логіка рендерингу (відображення) вмісту компонента.
*/
render() { render() {
const image = this.getAttribute('image') || ''; const image = this.getAttribute('image') || '';
const address = this.getAttribute('address') || ''; const address = this.getAttribute('address') || '';
const sheep = this.getAttribute('sheep'); // Может быть null или "" const sheep = this.getAttribute('sheep');
const link = this.getAttribute('link') || '#'; const link = this.getAttribute('link') || '#';
const atWork = this.getAttribute('atWork'); // Может быть null const atWork = this.getAttribute('atWork');
const quantity = this.getAttribute('quantity'); // Может быть null 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 = ''; let contentHTML = '';
// Перевіряємо, чи має бути увімкнений режим прогресу ('info'):
// обидва атрибути 'atWork' та 'quantity' присутні і є коректними числами.
const isProgressMode = atWork !== null && quantity !== null && !isNaN(parseInt(atWork)) && !isNaN(parseInt(quantity)); const isProgressMode = atWork !== null && quantity !== null && !isNaN(parseInt(atWork)) && !isNaN(parseInt(quantity));
const hasSheep = sheep !== null && sheep !== '';
if (isProgressMode) { if (isProgressMode) {
// Режим прогресса (свободные подъезды) // Режим прогресу (вільні під'їзди)
const atWorkNum = parseInt(atWork); const atWorkNum = parseInt(atWork);
const quantityNum = parseInt(quantity); const quantityNum = parseInt(quantity);
const free = quantityNum - atWorkNum; const free = quantityNum - atWorkNum;
// Обчислення відсотка прогресу. Уникнення ділення на нуль.
const progressPercent = quantityNum > 0 ? (atWorkNum / quantityNum) * 100 : 100; const progressPercent = quantityNum > 0 ? (atWorkNum / quantityNum) * 100 : 100;
contentHTML = ` contentHTML = `
@@ -286,15 +347,15 @@ class AppTerritoryCard extends HTMLElement {
</div> </div>
`; `;
} else if (sheep !== null && sheep !== '') { } else if (sheep !== null && sheep !== '') {
// Режим ответственного // Режим опрацювання (значення атрибута 'sheep' є ім'ям опрацювача)
contentHTML = ` contentHTML = `
<div class="sheep"> <div class="sheep" ${overdue ? `style="background: #bb4444;"` : ``}>
<span>Територію опрацьовує:</span> <span>Територію опрацьовує:</span>
<p>${sheep}</p> <p>${sheep}</p>
</div> </div>
`; `;
} else if (sheep !== null) { } else if (sheep !== null && sheep === '') {
// Режим "не опрацьовується" // Режим "не опрацьовується" (атрибут 'sheep' присутній, але порожній)
contentHTML = ` contentHTML = `
<div class="sheep"> <div class="sheep">
<span>Територія не опрацьовується</span> <span>Територія не опрацьовується</span>
@@ -302,9 +363,9 @@ class AppTerritoryCard extends HTMLElement {
`; `;
} }
// --- Сборка всего шаблона --- // --- Складання всього шаблону ---
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML += `
<div class="card"> <div class="card" ${overdue ? `title="Термін опрацювання минув!"` : ``}>
<img src="${image}" alt="${address}" /> <img src="${image}" alt="${address}" />
<div class="contents"> <div class="contents">
<h1 class="address">${address}</h1> <h1 class="address">${address}</h1>
@@ -316,8 +377,42 @@ class AppTerritoryCard extends HTMLElement {
} }
} }
// Регистрируем веб-компонент // Реєструємо веб-компонент у браузері
customElements.define('app-territory-card', AppTerritoryCard); 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>
*/

View File

@@ -2,7 +2,7 @@
<form id="page-auth-form"> <form id="page-auth-form">
<div> <div>
<input <input
type="text" type="password"
name="uuid" name="uuid"
id="auth-forms-uuid" id="auth-forms-uuid"
placeholder="UUID" placeholder="UUID"

View File

@@ -6,12 +6,10 @@ const Auth = {
document.getElementById("page-auth-form").addEventListener("submit", async function (event) { document.getElementById("page-auth-form").addEventListener("submit", async function (event) {
event.preventDefault(); event.preventDefault();
let uuid = document.getElementById("auth-forms-uuid").value; const uuid = document
.getElementById("auth-forms-uuid")
uuid = uuid.replace("https://sheep-service.com/?uuid=", ""); .value
uuid = uuid.replace("https://sheep-service.com?uuid=", ""); .replace(/^https?:\/\/sheep-service\.com\/?\?(uuid|hash)=/, "");
uuid = uuid.replace("https://sheep-service.com?/hash=", "");
uuid = uuid.replace("https://sheep-service.com?hash=", "");
console.log(uuid); console.log(uuid);
@@ -38,11 +36,47 @@ const Auth = {
console.log("USER Info: ", USER); console.log("USER Info: ", USER);
if (USER.possibilities.can_view_sheeps) document.getElementById("li-sheeps").style.display = ""; if (USER.possibilities.can_view_stand) {
if (USER.possibilities.can_add_schedule) document.getElementById("li-schedule").style.display = ""; newMenuItems({
if (USER.possibilities.can_manager_territory) document.getElementById("li-territory").style.display = ""; id: 'menu-stand',
if (USER.possibilities.can_view_stand) document.getElementById("li-stand").style.display = ""; title: 'Графік стенду',
document.getElementById("li-options").style.display = ""; 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'
});
}); });
} }
} }

View File

@@ -28,4 +28,12 @@
<div id="home-group-territory-list"></div> <div id="home-group-territory-list"></div>
</details> </details>
<details id="details-joint-territory" open style="display: none">
<summary>
<span>Тимчасові території</span>
</summary>
<div id="home-joint-territory-list"></div>
</details>
</div> </div>

View File

@@ -9,6 +9,8 @@ const Home = {
Home.group.house.setHTML(); Home.group.house.setHTML();
Home.group.homestead.setHTML(); Home.group.homestead.setHTML();
Home.joint.homestead.setHTML();
} }
}, },
personal: { 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) => { renderCards: (list, type, block) => {
const container = document.getElementById(`home-${block}-territory-list`); const container = document.getElementById(`home-${block}-territory-list`);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
@@ -126,7 +156,7 @@ const Home = {
for (const el of list) { for (const el of list) {
const card = document.createElement('app-territory-card'); const card = document.createElement('app-territory-card');
card.image = `${CONFIG.web}cards/${type}/${type === "house" ? "T" : "H"}${el.id}.webp`; 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}`; card.link = `/territory/card/${type}/${el.id}`;
fragment.appendChild(card); fragment.appendChild(card);
} }

View File

@@ -91,8 +91,9 @@
.page-home #home-personal-territory-list, .page-home #home-personal-territory-list,
.page-home #home-group-territory-list { .page-home #home-group-territory-list,
width: 100%; .page-home #home-joint-territory-list {
width: calc(100% - 20px);
margin: 0; margin: 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -3,6 +3,12 @@ const Schedule_constructor = {
let html = await fetch('/lib/pages/schedule/constructor/index.html').then((response) => response.text()); let html = await fetch('/lib/pages/schedule/constructor/index.html').then((response) => response.text());
app.innerHTML = html; 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);
} }
} }

View File

@@ -121,7 +121,7 @@
id="sheep-editor-can_view_sheeps" id="sheep-editor-can_view_sheeps"
type="checkbox" type="checkbox"
/> />
<label for="sheep-editor-can_view_sheeps"> View Sheeps </label> <label for="sheep-editor-can_view_sheeps"> Перегляд списку вісників </label>
</div> </div>
<div class="checkbox"> <div class="checkbox">
<input <input
@@ -130,7 +130,16 @@
id="sheep-editor-can_add_sheeps" id="sheep-editor-can_add_sheeps"
type="checkbox" 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>
<div class="checkbox"> <div class="checkbox">
<input <input
@@ -140,7 +149,7 @@
type="checkbox" type="checkbox"
/> />
<label for="sheep-editor-can_add_territory"> <label for="sheep-editor-can_add_territory">
Create Territory Створення територій
</label> </label>
</div> </div>
<div class="checkbox"> <div class="checkbox">
@@ -151,7 +160,18 @@
type="checkbox" type="checkbox"
/> />
<label for="sheep-editor-can_manager_territory"> <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> </label>
</div> </div>
<div class="checkbox"> <div class="checkbox">
@@ -161,7 +181,7 @@
id="sheep-editor-can_add_stand" id="sheep-editor-can_add_stand"
type="checkbox" type="checkbox"
/> />
<label for="sheep-editor-can_add_stand"> Create Stand </label> <label for="sheep-editor-can_add_stand"> Створення стендів </label>
</div> </div>
<div class="checkbox"> <div class="checkbox">
<input <input
@@ -170,7 +190,7 @@
id="sheep-editor-can_manager_stand" id="sheep-editor-can_manager_stand"
type="checkbox" type="checkbox"
/> />
<label for="sheep-editor-can_manager_stand"> Manager Stand </label> <label for="sheep-editor-can_manager_stand"> Керування стендами </label>
</div> </div>
<div class="checkbox"> <div class="checkbox">
<input <input
@@ -179,7 +199,7 @@
id="sheep-editor-can_add_schedule" id="sheep-editor-can_add_schedule"
type="checkbox" type="checkbox"
/> />
<label for="sheep-editor-can_add_schedule"> Create Schedule </label> <label for="sheep-editor-can_add_schedule"> Створення розкладу зібрань </label>
</div> </div>
</div> </div>
</div> </div>
@@ -194,7 +214,7 @@
id="sheep-editor-can_view_schedule" id="sheep-editor-can_view_schedule"
type="checkbox" type="checkbox"
/> />
<label for="sheep-editor-can_view_schedule"> View Schedule </label> <label for="sheep-editor-can_view_schedule"> Перегляд розкладу зібрань </label>
</div> </div>
<div class="checkbox"> <div class="checkbox">
<input <input
@@ -203,7 +223,7 @@
id="sheep-editor-can_view_stand" id="sheep-editor-can_view_stand"
type="checkbox" type="checkbox"
/> />
<label for="sheep-editor-can_view_stand"> View Stand </label> <label for="sheep-editor-can_view_stand"> Перегляд стендів </label>
</div> </div>
<div class="checkbox"> <div class="checkbox">
<input <input
@@ -213,7 +233,7 @@
type="checkbox" type="checkbox"
/> />
<label for="sheep-editor-can_view_territory"> <label for="sheep-editor-can_view_territory">
View Territory Перегляд територій
</label> </label>
</div> </div>
</div> </div>

View File

@@ -40,6 +40,8 @@ const SheepsEvents = {
const sheepEditorButton = document.getElementById('sheep-editor-button'); const sheepEditorButton = document.getElementById('sheep-editor-button');
const form = event.target; const form = event.target;
const formData = new FormData(form); const formData = new FormData(form);
console.log(formData);
const uuidValue = form.elements["uuid"].value; const uuidValue = form.elements["uuid"].value;
const sheep = Sheeps.sheeps_list.list.find(item => item.uuid === uuidValue); const sheep = Sheeps.sheeps_list.list.find(item => item.uuid === uuidValue);
@@ -50,7 +52,7 @@ const SheepsEvents = {
sheep.name = form.elements["name"].value; sheep.name = form.elements["name"].value;
sheep.group_id = Number(formData.get("group_id")); 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] || "Користувач"; sheep.mode_title = ["Користувач", "Модератор", "Адміністратор"][sheep.mode] || "Користувач";
const permKeys = [ const permKeys = [
@@ -59,8 +61,10 @@ const SheepsEvents = {
"can_view_stand", "can_view_stand",
"can_view_territory", "can_view_territory",
"can_add_sheeps", "can_add_sheeps",
"can_manager_sheeps",
"can_add_territory", "can_add_territory",
"can_manager_territory", "can_manager_territory",
"can_joint_territory",
"can_add_stand", "can_add_stand",
"can_manager_stand", "can_manager_stand",
"can_add_schedule" "can_add_schedule"
@@ -73,8 +77,7 @@ const SheepsEvents = {
try { try {
const uuid = localStorage.getItem('uuid'); const uuid = localStorage.getItem('uuid');
const URL = `${CONFIG.api}sheep`; const response = await fetch(`${CONFIG.api}sheep`, {
const response = await fetch(URL, {
method: 'PUT', method: 'PUT',
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -84,26 +87,23 @@ const SheepsEvents = {
}); });
if (response.ok) { if (response.ok) {
sheepEditorButton.innerText = "Успішно збережено"; sheepEditorButton.innerText = "Зберегти";
Notifier.success("Успішно збережено!", {timeout: 2000})
const data = await response.json(); const data = await response.json();
console.log(data); console.log(data);
Sheeps.sheeps_list.list = []; Sheeps.sheeps_list.list = [];
await Sheeps.sheeps_list.setHTML(); await Sheeps.sheeps_list.setHTML();
setTimeout(() => {
sheepEditorButton.innerText = "Зберегти";
}, 3000);
} else { } else {
sheepEditorButton.innerText = "Зберегти";
console.error('Помилка збереження'); console.error('Помилка збереження');
sheepEditorButton.innerText = "Помилка збереження"; Notifier.error("Помилка збереження!", {timeout: 3000});
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
sheepEditorButton.innerText = "Помилка збереження"; Notifier.error("Помилка збереження!", {timeout: 3000});
} }
// тот же код, что был в _onSheepEditorSubmit, но обращаемся к editorForm
return; return;
} }
@@ -120,8 +120,7 @@ const SheepsEvents = {
try { try {
const uuid = localStorage.getItem('uuid'); const uuid = localStorage.getItem('uuid');
const URL = `${CONFIG.api}sheep`; const response = await fetch(`${CONFIG.api}sheep`, {
const response = await fetch(URL, {
method: 'POST', method: 'POST',
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -131,7 +130,7 @@ const SheepsEvents = {
}); });
if (response.ok) { if (response.ok) {
sheepAddedsButton.innerText = "Вісника додано"; sheepAddedsButton.innerText = "Додати";
const data = await response.json(); const data = await response.json();
console.log(data); console.log(data);
@@ -141,17 +140,14 @@ const SheepsEvents = {
Sheeps.addeds.close(); Sheeps.addeds.close();
await Sheeps.editor.setHTML(data.id, randomNumber); await Sheeps.editor.setHTML(data.id, randomNumber);
setTimeout(() => {
sheepAddedsButton.innerText = "Додати";
}, 3000);
} else { } else {
sheepEditorButton.innerText = "Додати";
console.error('Помилка додавання'); console.error('Помилка додавання');
sheepAddedsButton.innerText = "Помилка додавання"; Notifier.error("Помилка додавання!", {timeout: 3000});
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
sheepAddedsButton.innerText = "Помилка додавання"; Notifier.error("Помилка додавання!", {timeout: 3000});
} }
return; return;
} }
@@ -176,9 +172,7 @@ const Sheeps = {
loadAPI: async () => { loadAPI: async () => {
let uuid = localStorage.getItem("uuid"); let uuid = localStorage.getItem("uuid");
const URL = `${CONFIG.api}sheeps/list`; Sheeps.sheeps_list.list = await fetch(`${CONFIG.api}sheeps/list`, {
Sheeps.sheeps_list.list = await fetch(URL, {
method: 'GET', method: 'GET',
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -238,9 +232,11 @@ const Sheeps = {
if (p.can_view_territory) perms.push("View Territory"); if (p.can_view_territory) perms.push("View Territory");
if (p.can_add_sheeps) perms.push("Create Sheeps"); if (p.can_add_sheeps) perms.push("Create Sheeps");
if (p.can_add_territory) perms.push("Create Territory"); 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_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_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"); if (p.can_add_schedule) perms.push("Create Schedule");
return perms.map(p => `<b>${p}</b>`).join(''); return perms.map(p => `<b>${p}</b>`).join('');
}; };
@@ -274,8 +270,7 @@ const Sheeps = {
loadAPI: async (id) => { loadAPI: async (id) => {
let uuid = localStorage.getItem("uuid"); let uuid = localStorage.getItem("uuid");
const URL = `${CONFIG.api}sheep?id=${id}`; return await fetch(`${CONFIG.api}sheep?id=${id}`, {
return await fetch(URL, {
method: 'GET', method: 'GET',
headers: { headers: {
"Content-Type": "application/json", "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_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_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_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_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_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_manager_stand = document.getElementById('sheep-editor-can_manager_stand');
let sheep_editor_can_add_schedule = document.getElementById('sheep-editor-can_add_schedule'); 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_view_sheeps.checked = sheep.possibilities.can_view_sheeps;
sheep_editor_can_add_sheeps.checked = sheep.possibilities.can_add_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_add_territory.checked = sheep.possibilities.can_add_territory;
sheep_editor_can_manager_territory.checked = sheep.possibilities.can_manager_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_add_stand.checked = sheep.possibilities.can_add_stand;
sheep_editor_can_manager_stand.checked = sheep.possibilities.can_manager_stand; sheep_editor_can_manager_stand.checked = sheep.possibilities.can_manager_stand;
sheep_editor_can_add_schedule.checked = sheep.possibilities.can_add_schedule; sheep_editor_can_add_schedule.checked = sheep.possibilities.can_add_schedule;
@@ -387,6 +387,9 @@ const Sheeps = {
if (USER.mode == 2) { if (USER.mode == 2) {
document.getElementById('sheep-editor-button').style.display = ""; document.getElementById('sheep-editor-button').style.display = "";
sheep_editor_mode.disabled = false; 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 { } else {
sheep_editor_mode.disabled = true; sheep_editor_mode.disabled = true;
} }
@@ -458,9 +461,9 @@ const Sheeps = {
} }
}, },
territory: { territory: {
async loadAPI(URL) { async loadAPI(url) {
const uuid = localStorage.getItem("uuid"); const uuid = localStorage.getItem("uuid");
const res = await fetch(URL, { const res = await fetch(url, {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": uuid "Authorization": uuid
@@ -470,8 +473,7 @@ const Sheeps = {
}, },
async house(id) { async house(id) {
const URL = `${CONFIG.api}house/list?mode=admin&sheep_id=${id}`; const list = await Sheeps.territory.loadAPI(`${CONFIG.api}house/list?mode=admin&sheep_id=${id}`);
const list = await Sheeps.territory.loadAPI(URL);
if ((USER.possibilities.can_view_territory || USER.mode == 2) && list.length > 0) { if ((USER.possibilities.can_view_territory || USER.mode == 2) && list.length > 0) {
document.getElementById('editor-blocks-territory').style.display = ""; document.getElementById('editor-blocks-territory').style.display = "";
@@ -480,8 +482,7 @@ const Sheeps = {
}, },
async homestead(id) { async homestead(id) {
const URL = `${CONFIG.api}homestead/list?mode=admin&sheep_id=${id}`; const list = await Sheeps.territory.loadAPI(`${CONFIG.api}homestead/list?mode=admin&sheep_id=${id}`);
const list = await Sheeps.territory.loadAPI(URL);
if ((USER.possibilities.can_view_territory || USER.mode == 2) && list.length > 0) { if ((USER.possibilities.can_view_territory || USER.mode == 2) && list.length > 0) {
document.getElementById('editor-blocks-territory').style.display = ""; document.getElementById('editor-blocks-territory').style.display = "";

View File

@@ -54,15 +54,15 @@
} }
#stand-info div span { #stand-info div span {
opacity: 0.8; font-weight: 500;
font-weight: 400; font-size: var(--FontSize3);
font-size: var(--FontSize2);
margin-right: 5px; margin-right: 5px;
} }
#stand-info div p { #stand-info div p {
font-weight: 300; opacity: 0.9;
font-size: var(--FontSize4); font-weight: 400;
font-size: var(--FontSize3);
} }
#stand-info img { #stand-info img {
@@ -143,11 +143,11 @@
} }
#stand-schedule>.block-day h3 { #stand-schedule>.block-day h3 {
font-size: var(--FontSize5); font-size: var(--FontSize6);
font-weight: 500; font-weight: 500;
padding: 10px; padding: 20px;
text-transform: capitalize; text-transform: capitalize;
width: calc(100% - 20px); width: calc(100% - 40px);
text-align: center; text-align: center;
} }
@@ -181,6 +181,7 @@
#stand-schedule>.block-day div select:disabled { #stand-schedule>.block-day div select:disabled {
opacity: 0.9 !important; 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) { #stand-schedule>.block-day div:nth-child(2n) {

View File

@@ -52,6 +52,9 @@ const Stand_constructor = {
button.innerText = "Стенд додано"; button.innerText = "Стенд додано";
Notifier.success('Стенд створено'); Notifier.success('Стенд створено');
Stand_list.list = [];
Stand_list.loadAPI();
return response.json() return response.json()
} else { } else {
console.log('err'); console.log('err');

View File

@@ -85,6 +85,9 @@ const Stand_editor = {
button.innerText = "Стенд відредаговано"; button.innerText = "Стенд відредаговано";
Notifier.success('Стенд відредаговано'); Notifier.success('Стенд відредаговано');
Stand_list.list = [];
Stand_list.loadAPI();
return response.json() return response.json()
} else { } else {
console.log('err'); console.log('err');

View File

@@ -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="page-card">
<div class="list-controls" id="page-card-controls" style="display: none"> <div class="list-controls" id="page-card-controls" style="display: none">
<div id="page-card-sort"> <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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<path <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" 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> </div>
<div id="page-card-info" style="display: none"></div>
<div id="list"></div> <div id="list"></div>
<div id="map_card"></div> <div id="map_card"></div>
</div> </div>

View File

@@ -32,7 +32,6 @@ const Territory_card = {
// Застосовуємо режим сортування // Застосовуємо режим сортування
this.sort(localStorage.getItem('territory_card_sort'), false); this.sort(localStorage.getItem('territory_card_sort'), false);
this.getEntrances({ update: false });
} else if (type === "homestead") { } else if (type === "homestead") {
this.getHomestead.map({}); this.getHomestead.map({});
} }
@@ -40,7 +39,7 @@ const Territory_card = {
const ids = ['cloud_1', 'cloud_2', 'cloud_3']; const ids = ['cloud_1', 'cloud_2', 'cloud_3'];
ids.forEach((id, idx) => { ids.forEach((id, idx) => {
const el = document.getElementById(id); const el = document.getElementById(id);
if(!el) return; if (!el) return;
el.setAttribute('data-state', ['sync', 'ok', 'err'].indexOf(Cloud.status) === idx ? 'active' : ''); 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) { if (navigator.onLine && Cloud.socket?.readyState === WebSocket.OPEN) {
Cloud.socket.send(JSON.stringify(message)); Cloud.socket.send(JSON.stringify(message));
} else { } else {
if (confirm("З'єднання розірвано! Перепідключитись?")) { Notifier.click({
Cloud.start(); title: `Запис не додано!`,
Territory_card.getEntrances({ update: true }); 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) { if (navigator.onLine && Cloud.socket?.readyState === WebSocket.OPEN) {
Cloud.socket.send(JSON.stringify(message)); Cloud.socket.send(JSON.stringify(message));
} else { } else {
if (confirm("З'єднання розірвано! Перепідключитись?")) { Notifier.click({
Territory_card.cloud.start(); title: `Запис не додано!`,
} text: `Натисніть, щоб перепідключитись!`
}, {
type: 'warn',
f: () => {
Cloud.reconnecting = true;
Cloud.reconnectAttempts = 0;
Cloud.start();
},
timeout: 0
});
} }
} }
}, },
@@ -211,12 +227,14 @@ const Territory_card = {
return; return;
} }
Territory_card.info(data);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
const canManage = USER.mode === 2 || (USER.mode === 1 && USER.possibilities.can_manager_territory); const canManage = USER.mode === 2 || (USER.mode === 1 && USER.possibilities.can_manager_territory);
for (const element of data) { 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 isMy = ((history.name === "Групова" && history.group_id == USER.group_id) || history.name === USER.name);
const show = (isMy && working) ? "open" : canManage ? "close" : null; const show = (isMy && working) ? "open" : canManage ? "close" : null;
@@ -310,8 +328,9 @@ const Territory_card = {
div.style = style; div.style = style;
div.innerHTML = ` div.innerHTML = `
<span>Квартира ${apt.title}</span>
<hr>
<div class="info"> <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}"> <select id="status_${apt.id}" onchange="Territory_card.cloud.messApartment({number:${number},id:${apt.id},update:true})" style="${style}">
${statusOptions(apt.status)} ${statusOptions(apt.status)}
</select> </select>
@@ -350,9 +369,6 @@ const Territory_card = {
async map({ homestead_id = Territory_card.id }) { async map({ homestead_id = Territory_card.id }) {
let data = await this.loadAPI({ url: `${CONFIG.api}homestead/${homestead_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 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 lng = data.geo?.lng ?? data.points?.[0]?.[0]?.[0]?.lng ?? 25.6145625;
let zoom = 15; let zoom = 15;
@@ -483,10 +499,116 @@ const Territory_card = {
Territory_card.getHomestead.markers[element.id] = marker; // сохраним ссылку на маркер 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 }); Territory_card.getEntrances({ update: true });
}, },
@@ -547,5 +669,39 @@ const Territory_card = {
} }
this.close(); 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>
`;
}
}
} }
} }

View File

@@ -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 { #card-new-date {
display: flex; display: flex;
width: 100%; width: 100%;
@@ -150,3 +192,220 @@
-webkit-appearance: none; -webkit-appearance: none;
-moz-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;
}

View File

@@ -13,12 +13,20 @@
name="address" name="address"
required required
value="" value=""
onchange="Territory_editor.info.title=this.value"
/> />
</div> </div>
<div> <div>
<label for="info-number">Номер будинку / частини</label> <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>
<div> <div>
@@ -29,6 +37,7 @@
name="settlement" name="settlement"
required required
value="Тернопіль" value="Тернопіль"
onchange="Territory_editor.info.settlement=this.value"
/> />
</div> </div>
</form> </form>
@@ -66,14 +75,18 @@
</div> </div>
</div> </div>
<span>або</span> <span>або</span>
<button onclick="Territory_editor.osm.newPoligon()">Обрати на карті</button> <button onclick="Territory_editor.osm.newPoligon()">
Обрати на карті
</button>
</div> </div>
<div class="block-map"> <div class="block-map">
<div id="map"></div> <div id="map"></div>
</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>
<div id="part-3" class="part_block" style="display: none"> <div id="part-3" class="part_block" style="display: none">

View File

@@ -40,10 +40,11 @@ const Territory_list = {
}, },
house: { house: {
url: null,
list: [], list: [],
loadAPI: async function (url) { loadAPI: async function (url) {
const uuid = localStorage.getItem("uuid"); const uuid = localStorage.getItem("uuid");
const response = await fetch(url, { const response = await fetch(url ?? Territory_list.house.url, {
method: 'GET', method: 'GET',
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -60,6 +61,7 @@ const Territory_list = {
const territory_list_filter = Number(localStorage.getItem("territory_list_filter") ?? 0); const territory_list_filter = Number(localStorage.getItem("territory_list_filter") ?? 0);
const url = `${CONFIG.api}houses/list${territory_entrances ? '/entrances' : ''}`; 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); let list = this.list.length > 0 ? this.list : await this.loadAPI(url);
const isEnd = territory_list_filter === "2"; const isEnd = territory_list_filter === "2";
@@ -94,11 +96,13 @@ const Territory_list = {
const person = working const person = working
? `${element.history.name === 'Групова' ? 'Група ' + element.history.group_id : element.history.name}` ? `${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.image = `${CONFIG.web}cards/house/T${element.house.id}.webp`;
card.address = `${element.house.title} ${element.house.number} (${element.title})`; card.address = `${element.house.title} ${element.house.number} (${element.title})`;
card.link = `/territory/manager/house/${element.house.id}`; card.link = `/territory/manager/house/${element.house.id}`;
card.sheep = person; card.sheep = person;
card.overdue = overdue;
block.appendChild(card); block.appendChild(card);
} else { } else {
const qty = element.entrance.quantity; const qty = element.entrance.quantity;
@@ -154,8 +158,8 @@ const Territory_list = {
} }
}); });
this.list = await response.json(); Territory_list.homestead.list = await response.json();
return this.list; return Territory_list.homestead.list;
}, },
setHTML: async function () { setHTML: async function () {
const block = document.getElementById('list-homestead'); const block = document.getElementById('list-homestead');
@@ -187,11 +191,14 @@ const Territory_list = {
? `${element.history.name === 'Групова' ? 'Група ' + element.history.group_id : element.history.name}` ? `${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'); const card = document.createElement('app-territory-card');
card.image = `${CONFIG.web}cards/homestead/H${element.id}.webp`; card.image = `${CONFIG.web}cards/homestead/H${element.id}.webp`;
card.address = `${element.title} ${element.number}`; card.address = `${element.title} ${element.number}`;
card.link = `/territory/manager/homestead/${element.id}`; card.link = `/territory/manager/homestead/${element.id}`;
card.sheep = person; card.sheep = person;
card.overdue = overdue;
block.appendChild(card); block.appendChild(card);
} }
} }

View File

@@ -358,6 +358,11 @@ const Territory_Manager = {
Territory_Manager.mess.close(); Territory_Manager.mess.close();
Territory_Manager.entrances.list = []; Territory_Manager.entrances.list = [];
await Territory_Manager.entrances.setHTML(type, id); 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) { } catch (err) {
console.error('❌ Error:', err); console.error('❌ Error:', err);
Notifier.error('Помилка призначення території'); Notifier.error('Помилка призначення території');
@@ -389,6 +394,11 @@ const Territory_Manager = {
Territory_Manager.entrances.list = []; Territory_Manager.entrances.list = [];
await Territory_Manager.entrances.setHTML(type, id); 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('Територія забрана успішно'); Notifier.success('Територія забрана успішно');
} catch (error) { } catch (error) {
console.error("❌ Помилка зняття призначення:", error); console.error("❌ Помилка зняття призначення:", error);

View File

@@ -1,7 +1,7 @@
let map_all; let map_all, free_entrance, free_homesteads;
const Territory_Map = { const Territory_Map = {
init: async () => { async init() {
let html = await fetch('/lib/pages/territory/map/index.html').then((response) => response.text()); let html = await fetch('/lib/pages/territory/map/index.html').then((response) => response.text());
app.innerHTML = html; app.innerHTML = html;
@@ -9,7 +9,7 @@ const Territory_Map = {
Territory_Map.info.setHTML(); Territory_Map.info.setHTML();
}, },
info: { info: {
loadAPI: async (url) => { async loadAPI(url) {
const uuid = localStorage.getItem("uuid"); const uuid = localStorage.getItem("uuid");
const response = await fetch(url, { const response = await fetch(url, {
@@ -23,22 +23,22 @@ const Territory_Map = {
return await response.json(); return await response.json();
}, },
setHTML: async () => { async setHTML() {
const houses = await Territory_Map.info.loadAPI(`${CONFIG.api}houses/list`); const houses = await Territory_Map.info.loadAPI(`${CONFIG.api}houses/list`);
const homestead = await Territory_Map.info.loadAPI(`${CONFIG.api}homestead/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 }); Territory_Map.map.added({ type: "homestead", data: homestead });
} }
}, },
map: { map: {
polygons: [], polygons: [],
init: () => { init() {
if (map_all && map_all.remove) map_all.remove(); if (map_all && map_all.remove) map_all.remove();
const mapElement = document.getElementById('map'); const mapElement = document.getElementById('map');
if (!mapElement) return; if (!mapElement) return;
let firstLocate = true;
let googleHybrid = L.tileLayer('http://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}', { let googleHybrid = L.tileLayer('http://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}', {
maxZoom: 20, maxZoom: 20,
@@ -55,6 +55,9 @@ const Territory_Map = {
tms: true tms: true
}); });
free_entrance = new L.FeatureGroup();
free_homesteads = new L.FeatureGroup();
map_all = L.map(mapElement, { map_all = L.map(mapElement, {
renderer: L.canvas(), renderer: L.canvas(),
center: [49.5629016, 25.6145625], center: [49.5629016, 25.6145625],
@@ -63,22 +66,72 @@ const Territory_Map = {
layers: [ layers: [
googleHybrid, googleHybrid,
osm, osm,
mytile mytile,
free_entrance,
free_homesteads
] ]
}); });
map_all.locate({
setView: true, // 🔥 сразу центрирует карту
maxZoom: 16
});
let baseMaps = { let baseMaps = {
"Google Hybrid": googleHybrid, "Google Hybrid": googleHybrid,
"OpenStreetMap": osm, "OpenStreetMap": osm,
"Sheep Service Map": mytile, "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.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++) { for (let index = 0; index < data.length; index++) {
const element = data[index]; const element = data[index];
let posPersonal, posGroup; 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 { } else {
posPersonal = Home.personal.house.list.map(e => e.id).indexOf(element.id); posPersonal = Home.personal.house.list.map(e => e.id).indexOf(element.id);
posGroup = Home.group.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 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); const polygon = L.polygon(element.points, polygonOptions).addTo(map_all);
@@ -129,13 +196,14 @@ const Territory_Map = {
polygon.on("popupopen", () => { polygon.on("popupopen", () => {
const div = document.createElement("div"); const div = document.createElement("div");
let text = () => { let text = () => {
if (posPersonal != -1) return "<span>Моя територія</span>" 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>" else if (posGroup != -1) return `<span>Групова територія</span> <p>${element.title} ${element.number}</p> <a href="/territory/card/${type}/${element.id}" data-route>Перейти до території</a>`
return "" return `<p>${element.title} ${element.number}</p> `
} }
div.innerHTML = `${text()} ${element.title} ${element.number}`; div.className = 'marker_popup'
div.className = "leaflet_drop" 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); polygon.setPopupContent(div);
}); });
@@ -144,40 +212,29 @@ const Territory_Map = {
} }
}, },
marker: ({ data, personal = false, group = false }) => { marker({ id, type, free, geo }) {
console.log(data); if (!USER.possibilities.can_manager_territory || USER.mode != 2) return;
if (free <= 0) return;
for (let index = 0; index < data.length; index++) { const redDot = L.divIcon({
const element = data[index]; 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({ marker.setPopupContent(div);
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);
});
}
} }
} }
} }

View File

@@ -1,11 +1,53 @@
.page-territory_map { .page-territory_map {
width: 100%; width: 100%;
display: flex; display: flex;
position: relative; position: relative;
} }
.page-territory_map>#map { .page-territory_map>#map {
margin: 20px; margin: 20px;
width: calc(100% - 40px); width: calc(100% - 40px);
height: calc(100% - 40px); height: calc(100% - 40px);
border-radius: calc(var(--border-radius) - 5px); 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);
}

View File

@@ -94,12 +94,55 @@ Router
pageActive(); pageActive();
}) })
function pageActive(element) { // function pageActive(element) {
const active = document.querySelector("nav li [data-state='active']"); // const active = document.querySelector("nav-item[data-state='active']");
if (active) active.setAttribute('data-state', ''); // 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) { if (element) {
const target = document.getElementById(`nav-${element}`); const targetId = `menu-${element}`;
if (target) target.setAttribute('data-state', 'active'); 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');
}
} }
} }

View File

@@ -1,4 +1,4 @@
const STATIC_CACHE_NAME = 'v2.0.103'; const STATIC_CACHE_NAME = 'v2.2.1';
const FILES_TO_CACHE = [ const FILES_TO_CACHE = [
'/', '/',
@@ -7,7 +7,12 @@ const FILES_TO_CACHE = [
"/lib/router/router.js", "/lib/router/router.js",
"/lib/router/routes.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.css",
"/lib/components/leaflet/leaflet.js", "/lib/components/leaflet/leaflet.js",
@@ -19,12 +24,9 @@ const FILES_TO_CACHE = [
"/lib/components/cloud.js", "/lib/components/cloud.js",
"/lib/components/metrics.js",
"/lib/components/clipboard.js", "/lib/components/clipboard.js",
"/lib/components/colorGroup.js", "/lib/components/colorGroup.js",
"/lib/components/makeid.js", "/lib/components/makeid.js",
"/lib/components/swipeUpdater.js",
"/lib/components/detectBrowser.js", "/lib/components/detectBrowser.js",
"/lib/components/detectOS.js", "/lib/components/detectOS.js",
"/lib/components/formattedDate.js", "/lib/components/formattedDate.js",

View File

@@ -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 };

View File

@@ -13,6 +13,7 @@
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"url": "^0.11.4", "url": "^0.11.4",
"ws": "^8.18.0", "ws": "^8.18.0",
"dotenv": "^17.2.0" "dotenv": "^17.2.0",
"web-push": "^3.6.7"
} }
} }

View File

@@ -2,16 +2,9 @@ const { updateApartment } = require("../services/apartments.service");
const { updateBuilding } = require("../services/buildings.service"); const { updateBuilding } = require("../services/buildings.service");
const { lockingStand, unlockingStand, updateStand } = require("../services/stand.service"); const { lockingStand, unlockingStand, updateStand } = require("../services/stand.service");
const { broadcast } = require("../utils/broadcaster"); const { broadcast } = require("../utils/broadcaster");
const { pushToMetrics } = require("../middleware/pushToMetrics");
module.exports = async (wss, ws, message) => { module.exports = async (wss, ws, message) => {
try { try {
pushToMetrics({
type: "ws_out",
length: message.length,
timestamp: Date.now()
});
switch (message.type) { switch (message.type) {
case "apartment": case "apartment":
await updateApartment(ws.user, message.data); await updateApartment(ws.user, message.data);

View File

@@ -1,4 +1,5 @@
const db = require("../config/db"); const db = require("../config/db");
const Notification = require("../utils/notification.js");
function lockingStand(user, data) { function lockingStand(user, data) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -52,11 +53,36 @@ function updateStand(user, data) {
const insertSql = ` const insertSql = `
INSERT INTO stand_schedule_history INSERT INTO stand_schedule_history
(stand_schedule_id, sheep_id, created_at) (stand_schedule_id, sheep_id, editor, created_at)
VALUES (?, ?, ?)`; 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 (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 }); resolve({ update: "ok", id: data.id, historyId: this.lastID });
}); });
}); });

152
ws/utils/notification.js Normal file
View 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();

View File

@@ -2,7 +2,6 @@ const WebSocket = require("ws");
const { URL } = require('url'); const { URL } = require('url');
const { routeMessage } = require("./routes"); const { routeMessage } = require("./routes");
const { auth } = require("./middleware/auth"); const { auth } = require("./middleware/auth");
const { pushToMetrics } = require("./middleware/pushToMetrics");
const { setupPing } = require("./utils/ping"); const { setupPing } = require("./utils/ping");
require("dotenv").config(); require("dotenv").config();
@@ -29,19 +28,10 @@ wss.on("connection", async (ws, request) => {
ws.user = user; ws.user = user;
ws.send(JSON.stringify({ connection: "success", api_version, user: {name: ws.user.name, id: ws.user.id } })); ws.send(JSON.stringify({ connection: "success", api_version, user: {name: ws.user.name, id: ws.user.id } }));
pushToMetrics({ type: "connection_status", status: "online", api_version, user: {name: ws.user.name, id: ws.user.id } });
// Periodic ping to maintain a connection // Periodic ping to maintain a connection
setupPing(ws); setupPing(ws);
ws.on("message", (raw) => { ws.on("message", (raw) => {
pushToMetrics({
type: "ws_in",
length: raw.length,
timestamp: Date.now()
});
try { try {
const message = JSON.parse(raw); const message = JSON.parse(raw);
routeMessage(wss, ws, message); routeMessage(wss, ws, message);
@@ -53,7 +43,6 @@ wss.on("connection", async (ws, request) => {
ws.on("close", () => { ws.on("close", () => {
console.log("🔌 Client disconnected"); console.log("🔌 Client disconnected");
pushToMetrics({ type: "connection_status", status: "offline" });
}); });
ws.on("error", (err) => console.error("❌ WS error:", err)); ws.on("error", (err) => console.error("❌ WS error:", err));
} catch (err) { } catch (err) {