@opengis/cms 0.0.43 → 0.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,7 +43,8 @@ const e = {
43
43
  general: "Основні",
44
44
  apiKeys: "API ключі",
45
45
  appearance: "Вигляд",
46
- logs: "Логи"
46
+ logs: "Логи",
47
+ cms: "ЦМС"
47
48
  },
48
49
  dashboard: {
49
50
  title: "Панель керування",
@@ -316,7 +317,8 @@ const e = {
316
317
  localization: "Локалізація",
317
318
  actions: "Дії",
318
319
  noColumnsFound: "Немає колонок",
319
- editCollection: "Редагувати колекцію",
320
+ editCollection: "Редагувати структуру",
321
+ editPage: "Редагувати сторінку",
320
322
  editContentForYourSite: "Редагувати контент для вашого сайту",
321
323
  deleteObject: "Ви впевнені, що хочете видалити цей об'єкт?",
322
324
  editField: "Редагувати поле",
@@ -484,6 +486,7 @@ const e = {
484
486
  contacts: "Контакти",
485
487
  socials: "Соціальні мережі",
486
488
  showSocialsNumber: "Кількість соціальних мереж для відображення",
489
+ headerBtn: "Текст кнопки в хедері",
487
490
  mapCoords: "Координати для відображення на карті",
488
491
  saveChanges: "Зберегти зміни",
489
492
  cancel: "Скасувати",
@@ -2,46 +2,46 @@
2
2
  "table": "site.contents",
3
3
  "columns": [
4
4
  {
5
- "label": "Slug",
6
- "name": "slug",
7
- "parent": "title",
8
- "required": true,
9
- "type": "slug"
10
- },
11
- {
12
- "label": "Title",
5
+ "label": "Заголовок",
13
6
  "name": "title",
14
7
  "required": true,
15
8
  "type": "text",
16
9
  "localization": true
17
10
  },
18
11
  {
19
- "label": "Status",
12
+ "label": "Посилання",
13
+ "name": "slug",
14
+ "parent": "title",
15
+ "required": true,
16
+ "type": "slug"
17
+ },
18
+ {
19
+ "label": "Статус",
20
20
  "name": "status",
21
21
  "type": "select",
22
22
  "data": "content.status",
23
23
  "options": [
24
24
  {
25
25
  "id": "draft",
26
- "text": "Draft"
26
+ "text": "Чернетка"
27
27
  },
28
28
  {
29
29
  "id": "published",
30
- "text": "Published"
30
+ "text": "Опубліковано"
31
31
  },
32
32
  {
33
33
  "id": "archived",
34
- "text": "Archived"
34
+ "text": "Архівовано"
35
35
  },
36
36
  {
37
37
  "id": "delayPublished",
38
- "text": "Delay Published"
38
+ "text": "Відкладена публікація"
39
39
  }
40
40
  ],
41
41
  "required": true
42
42
  },
43
43
  {
44
- "label": "Publish at",
44
+ "label": "Дата публікації",
45
45
  "name": "published_at",
46
46
  "required": true,
47
47
  "type": "datetime"
@@ -93,4 +93,4 @@
93
93
  "ua": "Дата створення"
94
94
  }
95
95
  ]
96
- }
96
+ }
@@ -2,46 +2,46 @@
2
2
  "table": "site.contents",
3
3
  "columns": [
4
4
  {
5
- "label": "Slug",
6
- "name": "slug",
7
- "parent": "title",
8
- "required": true,
9
- "type": "slug"
10
- },
11
- {
12
- "label": "Title",
5
+ "label": "Заголовок",
13
6
  "name": "title",
14
7
  "required": true,
15
8
  "type": "text",
16
9
  "localization": true
17
10
  },
18
11
  {
19
- "label": "Status",
12
+ "label": "Посилання",
13
+ "name": "slug",
14
+ "parent": "title",
15
+ "required": true,
16
+ "type": "slug"
17
+ },
18
+ {
19
+ "label": "Статус",
20
20
  "name": "status",
21
21
  "type": "select",
22
22
  "data": "content.status",
23
23
  "options": [
24
24
  {
25
25
  "id": "draft",
26
- "text": "Draft"
26
+ "text": "Чернетка"
27
27
  },
28
28
  {
29
29
  "id": "published",
30
- "text": "Published"
30
+ "text": "Опубліковано"
31
31
  },
32
32
  {
33
33
  "id": "archived",
34
- "text": "Archived"
34
+ "text": "Архівовано"
35
35
  },
36
36
  {
37
37
  "id": "delayPublished",
38
- "text": "Delay Published"
38
+ "text": "Відкладена публікація"
39
39
  }
40
40
  ],
41
41
  "required": true
42
42
  },
43
43
  {
44
- "label": "Publish at",
44
+ "label": "Дата публікації",
45
45
  "name": "published_at",
46
46
  "required": true,
47
47
  "type": "datetime"
@@ -82,8 +82,10 @@
82
82
  },
83
83
  {
84
84
  "extra": false,
85
- "label": "Контент",
86
- "name": "single_body",
85
+ "placeholder": "Пошук по тексту",
86
+ "label": "Пошук по тексту",
87
+ "columns": "slug,title",
88
+ "name": "search",
87
89
  "type": "Text"
88
90
  },
89
91
  {
@@ -111,4 +113,4 @@
111
113
  "ua": "Дата створення"
112
114
  }
113
115
  ]
114
- }
116
+ }
package/package.json CHANGED
@@ -1,65 +1,65 @@
1
- {
2
- "name": "@opengis/cms",
3
- "version": "0.0.43",
4
- "description": "cms",
5
- "type": "module",
6
- "author": "Softpro",
7
- "main": "./dist/index.js",
8
- "license": "EULA",
9
- "files": [
10
- "module",
11
- "dist",
12
- "server",
13
- "plugin.js",
14
- "input-types.json"
15
- ],
16
- "scripts": {
17
- "patch": "npm version patch && git push && npm publish",
18
- "test": "node --test test/**/*.test.js",
19
- "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
20
- "dev": "NODE_ENV=dev bun --env-file=.env.softpro server ",
21
- "admin": "vite admin",
22
- "build": "vite build && vite build admin",
23
- "build:lib": "vite build",
24
- "proxy": "vite dev admin",
25
- "start": "bun --env-file=.env.ip server",
26
- "prod": "NODE_ENV=production bun --env-file=.env.ip server ",
27
- "ip": "bun --env-file=.env.ip server",
28
- "demo": "node --env-file=.env.demo --env-file=.env server",
29
- "i18n:sync": "node i18n-sync.cjs",
30
- "prepublishOnly": "bun build:lib",
31
- "softpro": "bun --env-file=.env.softpro server",
32
- "softpro1": "NODE_ENV=production bun --env-file=.env.prod-softpro.local server"
33
- },
34
- "dependencies": {},
35
- "resolutions": {
36
- "rollup": "4.30.0"
37
- },
38
- "devDependencies": {
39
- "@fastify/compress": "^8.1.0",
40
- "@opengis/core": "^0.0.30",
41
- "@opengis/fastify-table": "^2.0.128",
42
- "@opengis/filter": "^0.1.10",
43
- "@opengis/form": "^0.0.70",
44
- "@opengis/richtext": "0.0.38",
45
- "@vueuse/head": "2.0.0",
46
- "js-yaml": "^4.1.0",
47
- "lucide-vue-next": "0.344.0",
48
- "vite": "5.1.4",
49
- "vue": "^3.5.17",
50
- "vue-i18n": "11.1.5",
51
- "vue-router": "4.4.3",
52
- "vuedraggable": "4.1.0",
53
- "@tailwindcss/typography": "0.5.10",
54
- "@tsconfig/node22": "^22.0.2",
55
- "@vitejs/plugin-vue": "5.0.4",
56
- "autoprefixer": "10.4.18",
57
- "eslint": "8.49.0",
58
- "postcss": "8.4.35",
59
- "sass": "^1.92.1",
60
- "tailwindcss": "3.4.1",
61
- "typescript": "~5.8.0",
62
- "vitest": "3.2.4",
63
- "vue-tsc": "^2.2.10"
64
- }
65
- }
1
+ {
2
+ "name": "@opengis/cms",
3
+ "version": "0.0.45",
4
+ "description": "cms",
5
+ "type": "module",
6
+ "author": "Softpro",
7
+ "main": "./dist/index.js",
8
+ "license": "EULA",
9
+ "files": [
10
+ "module",
11
+ "dist",
12
+ "server",
13
+ "plugin.js",
14
+ "input-types.json"
15
+ ],
16
+ "scripts": {
17
+ "patch": "npm version patch && git push && npm publish",
18
+ "test": "node --test test/**/*.test.js",
19
+ "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
20
+ "dev": "NODE_ENV=dev bun --env-file=.env.ip server ",
21
+ "admin": "vite admin",
22
+ "build": "vite build && vite build admin",
23
+ "build:lib": "vite build",
24
+ "proxy": "vite dev admin",
25
+ "start": "bun --env-file=.env.ip server",
26
+ "prod": "NODE_ENV=production bun --env-file=.env.ip server ",
27
+ "ip": "bun --env-file=.env.ip server",
28
+ "demo": "node --env-file=.env.demo --env-file=.env server",
29
+ "i18n:sync": "node i18n-sync.cjs",
30
+ "prepublishOnly": "bun build:lib",
31
+ "softpro": "bun --env-file=.env.softpro server",
32
+ "softpro1": "NODE_ENV=production bun --env-file=.env.prod-softpro.local server"
33
+ },
34
+ "dependencies": {},
35
+ "resolutions": {
36
+ "rollup": "4.30.0"
37
+ },
38
+ "devDependencies": {
39
+ "@fastify/compress": "^8.1.0",
40
+ "@opengis/core": "^0.0.30",
41
+ "@opengis/fastify-table": "^2.0.128",
42
+ "@opengis/filter": "^0.1.31",
43
+ "@opengis/form": "^0.0.108",
44
+ "@opengis/richtext": "0.0.38",
45
+ "@vueuse/head": "2.0.0",
46
+ "js-yaml": "^4.1.0",
47
+ "lucide-vue-next": "0.344.0",
48
+ "vite": "5.1.4",
49
+ "vue": "^3.5.17",
50
+ "vue-i18n": "11.1.5",
51
+ "vue-router": "4.4.3",
52
+ "vuedraggable": "4.1.0",
53
+ "@tailwindcss/typography": "0.5.10",
54
+ "@tsconfig/node22": "^22.0.2",
55
+ "@vitejs/plugin-vue": "5.0.4",
56
+ "autoprefixer": "10.4.18",
57
+ "eslint": "8.49.0",
58
+ "postcss": "8.4.35",
59
+ "sass": "^1.92.1",
60
+ "tailwindcss": "3.4.1",
61
+ "typescript": "~5.8.0",
62
+ "vitest": "3.2.4",
63
+ "vue-tsc": "^2.2.10"
64
+ }
65
+ }
@@ -2,48 +2,83 @@ import path from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { readFile } from 'node:fs/promises';
4
4
 
5
- import { config, getFolder, pgClients } from "@opengis/fastify-table/utils.js";
5
+ import sharp from "sharp";
6
+ import { imageSize } from "image-size";
7
+
8
+ import { config, getFolder, pgClients, grpc, getMimeType } from "@opengis/fastify-table/utils.js";
9
+
10
+ const { resizeImage } = grpc();
6
11
 
7
12
  // path.resolve() converts POSIX paths from getFolder to valid Windows paths (Bun/Node fs require this on Windows)
8
13
  const rootDir = path.resolve(getFolder(config, 'local'));
9
14
 
15
+ const previewWidth = 200;
16
+ const resizeQuality = 75;
17
+
10
18
  export default async function downloadMedia({
11
- pg = pgClients.client, params = {},
19
+ pg = pgClients.client, params = {}, query,
12
20
  }, reply) {
13
21
  if (!params?.id) {
14
- return reply.status(400).send('not enough params: id');
22
+ return reply.status(400).send({ error: 'not enough params: id', code: 400 });
15
23
  }
16
24
 
17
25
  if (!pg.pk?.['site.media']) {
18
- return reply.status(404).send('table not found');
26
+ return reply.status(404).send({ error: 'table not found', code: 404 });
19
27
  }
20
28
 
21
- const { filename, mime, id, url: relpath } = await pg.query(
22
- 'select media_id as id, filename, mime, url from site.media where media_id = $1 and url is not null',
29
+ const media = await pg.query(
30
+ `select media_id as id, filename, mime, url from site.media where media_id = $1 and url is not null`,
23
31
  [params.id],
24
32
  ).then(el => el.rows?.[0] || {});
25
33
 
26
- if (!id) {
27
- return reply.status(404).send('media not found: ' + params.id);
34
+ if (!media.id) {
35
+ // return reply.status(404).send('media not found: ' + params.id);
36
+ const decodedPath = Buffer.from(params.id, 'base64url').toString('utf-8');
37
+ Object.assign(media, { url: decodedPath, mime: getMimeType(decodedPath) });
38
+ }
39
+
40
+ if (!media.url || media.url.includes('..')) {
41
+ return reply.status(403).send({ error: "wrong params", code: 403 });
28
42
  }
29
43
 
30
- const filepath = path.join(rootDir, relpath);
44
+ const filepath = path.join(rootDir, media.url);
31
45
 
32
46
  if (!existsSync(filepath)) {
33
- return reply.status(404).send('file not found');
47
+ return reply.status(404).send({ error: 'file not found', code: 404 });
34
48
  }
35
49
 
36
50
  const buffer = await readFile(filepath, { buffer: true });
51
+ const headers = { 'Content-Type': media.mime, 'Cache-Control': query.nocache ? 'no-store, no-cache, must-revalidate' : 'public, max-age=2592000' };
52
+
53
+ // without nocache - cache resized images for 30d
54
+ if (params.type === 'preview' && media.mime && media.mime.startsWith('image/') && !media.mime.endsWith('/svg+xml')) {
55
+ const { width, height } = imageSize(buffer) || {};
56
+ const ratio = width / height;
57
+ const resizeWidth = Math.min(previewWidth, width);
58
+ const resizeHeight = resizeWidth / ratio;
59
+
60
+ const { result } = await resizeImage({
61
+ base64: buffer.toString("base64"),
62
+ width: resizeWidth,
63
+ height: resizeHeight,
64
+ quality: resizeQuality,
65
+ });
66
+ if (media.mime && media.mime.endsWith('/webp')) {
67
+ const webp = await sharp(Buffer.from(result, "base64")).webp({ quality: resizeQuality }).toBuffer({ resolveWithObject: false });
68
+ return reply.headers(headers).send(webp);
69
+ }
70
+ return reply.headers(headers).send(Buffer.from(result, "base64"));
71
+ }
37
72
 
38
73
  // skip xml load for preview
39
- if (params.type === 'preview' && path.extname(filename) !== '.xml') {
40
- return reply.headers({ 'Content-Type': mime }).send(buffer);
74
+ if (params.type === 'preview' && ['.xml'].includes(path.extname(filepath))) {
75
+ return reply
76
+ .headers({
77
+ 'Content-Type': media.mime,
78
+ 'Content-Disposition': `attachment; filename=${path.basename(filepath)}`,
79
+ })
80
+ .send(buffer);
41
81
  }
42
82
 
43
- return reply
44
- .headers({
45
- 'Content-Type': mime,
46
- 'Content-Disposition': `attachment; filename=${filename || path.basename(filepath)}`,
47
- })
48
- .send(buffer);
83
+ return reply.headers(headers).send(buffer);
49
84
  }
@@ -1,16 +1,16 @@
1
- import { pgClients } from '@opengis/fastify-table/utils.js';
2
-
3
- export default async function getPermissions(req, reply) {
4
- const { pg = pgClients.client, params = {}, user = {} } = req;
5
-
6
- if (!user?.uid) {
7
- return reply.status(401).send('unauthorized');
8
- }
9
-
10
- const { rows = [] } = await pg.query(
11
- `select * from site.permissions where ${params.id ? 'user_id=$1' : 'true'}`,
12
- [params.id].filter(Boolean),
13
- );
14
-
15
- return { permissions: rows };
1
+ import { pgClients } from '@opengis/fastify-table/utils.js';
2
+
3
+ export default async function getPermissions(req, reply) {
4
+ const { pg = pgClients.client, params = {}, user = {} } = req;
5
+
6
+ if (!user?.uid) {
7
+ return reply.status(401).send('unauthorized');
8
+ }
9
+
10
+ const { rows = [] } = await pg.query(
11
+ `select * from site.permissions where ${params.id ? 'user_id=$1' : 'true'}`,
12
+ [params.id].filter(Boolean),
13
+ );
14
+
15
+ return { permissions: rows };
16
16
  }
@@ -9,6 +9,9 @@ import { createHash } from 'node:crypto';
9
9
  const rootDir = path.resolve(getFolder(config, 'local'));
10
10
  const dir = '/files';
11
11
 
12
+ const filesizeCache = {};
13
+ const previewWidth = 300;
14
+
12
15
  mkdirSync(path.join(rootDir, dir), { recursive: true });
13
16
 
14
17
  export default async function listMedia(req, reply) {
@@ -63,31 +66,42 @@ export default async function listMedia(req, reply) {
63
66
  Object.assign(result, { rootDir });
64
67
  }
65
68
 
66
- const files = (search ? allItems.filter(el => el.name.includes(search)) : items)
67
- .filter(el => el.isFile())
68
- .map(el => {
69
- const media = rows.find(row => row.filename === el.name);
70
- const filepath = media ? media.url : ('/files/' + (el.path.split('/files/')[1] ? el.path.split('/files/')[1] + '/' : '') + el.name);
71
- return media ? {
72
- type: 'file',
73
- ...media,
74
- url: `${req.routeOptions.url}/${media.id}/file`,
75
- preview: `${req.routeOptions.url}/${media.id}/preview`,
76
- filepath,
77
- metadata: `${req.routeOptions.url}/${media.id}`,
78
- } : {
79
- id: createHash('md5').update(filepath).digest('hex'),
80
- hash: true,
81
- type: 'file',
82
- filename: el.name,
83
- filepath,
84
- filetype: getMimeType(el.name)?.startsWith('image/') ? "image" : "other",
85
- filesize: 0,
86
- url: filepath,
87
- mime: getMimeType(el.name),
88
- preview: filepath,
89
- }
90
- });
69
+ const filteredFiles = (search ? allItems.filter(el => el.name.includes(search)) : items).filter(el => el.isFile());
70
+
71
+ const files = await Promise.all(filteredFiles.map(async el => {
72
+ const media = rows.find(row => row.filename === el.name);
73
+ const filepath = media ? media.url : ('/files/' + (el.path.split('/files/')[1] ? el.path.split('/files/')[1] + '/' : '') + el.name);
74
+
75
+ if (!filesizeCache[filepath]) {
76
+ const { size } = await stat(path.join(rootDir, filepath));
77
+ Object.assign(filesizeCache, { [filepath]: size });
78
+ }
79
+
80
+ const mime = getMimeType(el.name) || '';
81
+ const preview = mime.startsWith('image/') /* && mime !== 'image/svg+xml' */
82
+ ? `${req.routeOptions.url}/${media ? media.id : Buffer.from(filepath).toString('base64url')}/preview`
83
+ : undefined;
84
+
85
+ return media ? {
86
+ type: 'file',
87
+ ...media,
88
+ url: `${req.routeOptions.url}/${media.id}/file`,
89
+ preview,
90
+ filepath,
91
+ metadata: `${req.routeOptions.url}/${media.id}`,
92
+ } : {
93
+ id: createHash('md5').update(filepath).digest('hex'),
94
+ hash: true,
95
+ type: 'file',
96
+ filename: el.name,
97
+ filepath,
98
+ filetype: mime.startsWith('image/') ? "image" : "other",
99
+ filesize: filesizeCache[filepath] || 0,
100
+ url: filepath,
101
+ mime,
102
+ preview,
103
+ }
104
+ }));
91
105
 
92
106
  Object.assign(result, { data: subdirs.concat(files) });
93
107
  return result;
@@ -1,50 +1,50 @@
1
- import { logger, pgClients, dataInsert } from '@opengis/fastify-table/utils.js';
2
-
3
- export default async function setPermissions(req, reply) {
4
- const { pg = pgClients.client, params = {}, user = {}, body = {} } = req;
5
-
6
- if (!user?.uid) {
7
- return reply.status(401).send('unauthorized');
8
- }
9
-
10
- if (!params.id) {
11
- return reply.status(400).send('not enough params: id');
12
- }
13
-
14
- const client = await pg.connect()
15
- const result = {};
16
- try {
17
- await client.query('BEGIN');
18
-
19
- const { rowCount = 0 } = await client.query(
20
- `delete from site.permissions where user_id=$1`,
21
- [params.id].filter(Boolean),
22
- );
23
-
24
- Object.assign(result, { deleted: rowCount });
25
-
26
- if (Array.isArray(body.permissions) && body.permissions?.length) {
27
- body.permissions.forEach((el) => {
28
- Object.assign(el, { user_id: el.user_id || params.id });
29
- });
30
-
31
- await Promise.all(body.permissions.map(async (el) => dataInsert({
32
- pg: client,
33
- table: 'site.permissions',
34
- data: el,
35
- uid: user.uid,
36
- })));
37
-
38
- Object.assign(result, { inserted: body.permissions.length });
39
- }
40
-
41
- await client.query('COMMIT');
42
- return reply.status(200).send(result);
43
- } catch (err) {
44
- await client.query('ROLLBACK');
45
- logger.file('cms/permissions', { error: err.toString(), stack: err.stack });
46
- return reply.status(500).send('set permissions error');
47
- } finally {
48
- client.release();
49
- }
1
+ import { logger, pgClients, dataInsert } from '@opengis/fastify-table/utils.js';
2
+
3
+ export default async function setPermissions(req, reply) {
4
+ const { pg = pgClients.client, params = {}, user = {}, body = {} } = req;
5
+
6
+ if (!user?.uid) {
7
+ return reply.status(401).send('unauthorized');
8
+ }
9
+
10
+ if (!params.id) {
11
+ return reply.status(400).send('not enough params: id');
12
+ }
13
+
14
+ const client = await pg.connect()
15
+ const result = {};
16
+ try {
17
+ await client.query('BEGIN');
18
+
19
+ const { rowCount = 0 } = await client.query(
20
+ `delete from site.permissions where user_id=$1`,
21
+ [params.id].filter(Boolean),
22
+ );
23
+
24
+ Object.assign(result, { deleted: rowCount });
25
+
26
+ if (Array.isArray(body.permissions) && body.permissions?.length) {
27
+ body.permissions.forEach((el) => {
28
+ Object.assign(el, { user_id: el.user_id || params.id });
29
+ });
30
+
31
+ await Promise.all(body.permissions.map(async (el) => dataInsert({
32
+ pg: client,
33
+ table: 'site.permissions',
34
+ data: el,
35
+ uid: user.uid,
36
+ })));
37
+
38
+ Object.assign(result, { inserted: body.permissions.length });
39
+ }
40
+
41
+ await client.query('COMMIT');
42
+ return reply.status(200).send(result);
43
+ } catch (err) {
44
+ await client.query('ROLLBACK');
45
+ logger.file('cms/permissions', { error: err.toString(), stack: err.stack });
46
+ return reply.status(500).send('set permissions error');
47
+ } finally {
48
+ client.release();
49
+ }
50
50
  }
@@ -1,2 +1,2 @@
1
- select uid, coalesce(sur_name,'')||coalesce(' '||user_name,'') as text, email from admin.users
1
+ select uid, coalesce(sur_name,'')||coalesce(' '||user_name,'') as text, email from admin.users
2
2
  where enabled order by coalesce(sur_name,'')||coalesce(' '||user_name,'')