psy 1 неделя назад
Родитель
Сommit
044152a6ef
86 измененных файлов с 21719 добавлено и 22082 удалено
  1. 3 0
      .gitignore
  2. 17 15
      README.md
  3. 55 0
      docs/CHANGELOG.md
  4. 5 5
      docs/PUB/deploy.md
  5. 2 0
      docs/security.md
  6. 16 9
      scripts/patch-node-modules.js
  7. 709 157
      src/backend/backend.js
  8. 93 10
      src/backend/blobHandler.js
  9. 28 1
      src/backend/renderTextWithStyles.js
  10. 75 14
      src/backend/renderUrl.js
  11. 45 0
      src/backend/sanitizeHtml.js
  12. 0 0
      src/client/assets/images/favicon.svg
  13. 0 0
      src/client/assets/images/snh-oasis.jpg
  14. 0 0
      src/client/assets/styles/highlight.css
  15. 498 0
      src/client/assets/styles/mobile.css
  16. 1085 58
      src/client/assets/styles/style.css
  17. 71 0
      src/client/assets/themes/Clear-SNH.css
  18. 69 0
      src/client/assets/themes/Dark-SNH.css
  19. 63 0
      src/client/assets/themes/Matrix-SNH.css
  20. 732 0
      src/client/assets/themes/OasisMobile.css
  21. 63 0
      src/client/assets/themes/Purple-SNH.css
  22. 1 1
      src/client/assets/translations/i18n.js
  23. 2702 0
      src/client/assets/translations/oasis_de.js
  24. 219 11
      src/client/assets/translations/oasis_en.js
  25. 219 11
      src/client/assets/translations/oasis_es.js
  26. 241 11
      src/client/assets/translations/oasis_eu.js
  27. 221 13
      src/client/assets/translations/oasis_fr.js
  28. 2702 0
      src/client/assets/translations/oasis_it.js
  29. 2702 0
      src/client/assets/translations/oasis_pt.js
  30. 10 10
      src/client/middleware.js
  31. 4 0
      src/configs/blockchain-cycle.json
  32. 2 1
      src/configs/config-manager.js
  33. 2 1
      src/configs/oasis-config.json
  34. 3 3
      src/configs/server-config.json
  35. 14 0
      src/configs/shared-state.js
  36. 4 0
      src/configs/snh-invite-code.json
  37. 1 1
      src/configs/wallet-addresses.json
  38. 6 3
      src/models/activity_model.js
  39. 10 2
      src/models/banking_model.js
  40. 3 2
      src/models/feed_model.js
  41. 16 5
      src/models/inhabitants_model.js
  42. 48 16
      src/models/main_models.js
  43. 1 2
      src/models/market_model.js
  44. 1 3
      src/models/projects_model.js
  45. 2 4
      src/models/reports_model.js
  46. 1 1
      src/models/stats_model.js
  47. 288 0
      src/models/tribes_content_model.js
  48. 148 237
      src/models/tribes_model.js
  49. 6342 20806
      src/server/package-lock.json
  50. 4 51
      src/server/package.json
  51. 63 22
      src/views/activity_view.js
  52. 2 1
      src/views/agenda_view.js
  53. 3 2
      src/views/audio_view.js
  54. 3 4
      src/views/banking_views.js
  55. 121 31
      src/views/blockchain_view.js
  56. 2 2
      src/views/bookmark_view.js
  57. 13 13
      src/views/cipher_view.js
  58. 3 3
      src/views/courts_view.js
  59. 1 1
      src/views/cv_view.js
  60. 2 2
      src/views/document_view.js
  61. 2 2
      src/views/event_view.js
  62. 24 6
      src/views/feed_view.js
  63. 4 4
      src/views/forum_view.js
  64. 2 2
      src/views/image_view.js
  65. 24 15
      src/views/inhabitants_view.js
  66. 71 57
      src/views/invites_view.js
  67. 32 10
      src/views/jobs_view.js
  68. 3 3
      src/views/legacy_view.js
  69. 283 111
      src/views/main_views.js
  70. 21 6
      src/views/market_view.js
  71. 23 0
      src/views/modules_view.js
  72. 35 11
      src/views/opinions_view.js
  73. 1 0
      src/views/parliament_view.js
  74. 55 32
      src/views/peers_view.js
  75. 5 5
      src/views/pixelia_view.js
  76. 48 6
      src/views/projects_view.js
  77. 20 6
      src/views/report_view.js
  78. 25 24
      src/views/search_view.js
  79. 6 2
      src/views/settings_view.js
  80. 91 0
      src/views/stats_view.js
  81. 2 2
      src/views/task_view.js
  82. 3 2
      src/views/transfer_view.js
  83. 31 11
      src/views/trending_view.js
  84. 1144 216
      src/views/tribes_view.js
  85. 3 2
      src/views/video_view.js
  86. 2 2
      src/views/vote_view.js

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+node_modules/
+*.gguf
+.update_required

+ 17 - 15
README.md

@@ -37,7 +37,7 @@ But it has others features that are also really interesting, for example:
  
  
    ![SNH](https://solarnethub.com/git/snh-oasis-modules.png "SolarNET.HuB")
    ![SNH](https://solarnethub.com/git/snh-oasis-modules.png "SolarNET.HuB")
     
     
- +  Support for multiple themes.
+ +  Support for multiple themes (including Mobile Theme).
  
  
    ![SNH](https://solarnethub.com/git/snh-clear-theme.png "SolarNET.HuB")
    ![SNH](https://solarnethub.com/git/snh-clear-theme.png "SolarNET.HuB")
    ![SNH](https://solarnethub.com/git/snh-purple-theme.png "SolarNET.HuB")
    ![SNH](https://solarnethub.com/git/snh-purple-theme.png "SolarNET.HuB")
@@ -46,8 +46,6 @@ But it has others features that are also really interesting, for example:
  +  Even a complex Reddit-styled forum system.
  +  Even a complex Reddit-styled forum system.
  
  
    ![SNH](https://solarnethub.com/git/snh-forum.png "SolarNET.HuB")
    ![SNH](https://solarnethub.com/git/snh-forum.png "SolarNET.HuB")
-   ![SNH](https://solarnethub.com/git/snh-forum-reply.png "SolarNET.HuB")
-   ![SNH](https://solarnethub.com/git/snh-activity-forum.png "SolarNET.HuB")
  
  
 And much more, that we invite you to discover by yourself ;-)
 And much more, that we invite you to discover by yourself ;-)
 
 
@@ -140,6 +138,22 @@ You can also receive a -Universal Basic Income- if you contribute to the Tribes
 Oasis contains its own UBI (Universal Basic Income), distributed weekly using ECOin, and calculated by our AI through positive and efficient participation and trust.
 Oasis contains its own UBI (Universal Basic Income), distributed weekly using ECOin, and calculated by our AI through positive and efficient participation and trust.
 
 
   ![SNH](https://solarnethub.com/git/oasis-banking.png "SolarNET.HuB")
   ![SNH](https://solarnethub.com/git/oasis-banking.png "SolarNET.HuB")
+  
+----------
+
+## Carbon Footprinting (ecology)
+
+Oasis contains its own carbon footprint meter.
+
+  ![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting.png "SolarNET.HuB")
+
+All transmissions, stored files, and interactions are measured to understand the environmental impact of the network.
+
+  ![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting2.png "SolarNET.HuB")
+
+And also of each inhabitant.
+
+  ![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting3.png "SolarNET.HuB")
 
 
 ----------
 ----------
 
 
@@ -221,18 +235,6 @@ Visit ['Settings'](https://wiki.solarnethub.com/socialnet/snh#settings_minimal)
 
 
 ----------
 ----------
 
 
-## Multiverse:
-
-Join ['PUB: "La Plaza"'](https://wiki.solarnethub.com/socialnet/snh-pub) to start to be connected with other interesting projects in the Multiverse.
-
-  ![SNH](https://solarnethub.com/git/snh-oasis_federation-2.png "SolarNET.HuB")
-  
-This allows you to communicate and access content from outside the [project network](https://wiki.solarnethub.com/socialnet/overview). 
-
-  ![SNH](https://solarnethub.com/git/snh-multiverse.png "SolarNET.HuB")
-
-----------
-
 ## SNH-Hub (for HackLabs):
 ## SNH-Hub (for HackLabs):
 
 
 The public content of the ['PUB: "La Plaza"'](https://wiki.solarnethub.com/socialnet/snh-pub) can be visited from outside the [project network](https://wiki.solarnethub.com/socialnet/overview), through the [World Wide Web](https://en.wikipedia.org/wiki/World_Wide_Web) (aka [Clearnet](https://en.wikipedia.org/wiki/Clearnet_(networking))).
 The public content of the ['PUB: "La Plaza"'](https://wiki.solarnethub.com/socialnet/snh-pub) can be visited from outside the [project network](https://wiki.solarnethub.com/socialnet/overview), through the [World Wide Web](https://en.wikipedia.org/wiki/World_Wide_Web) (aka [Clearnet](https://en.wikipedia.org/wiki/Clearnet_(networking))).

+ 55 - 0
docs/CHANGELOG.md

@@ -13,6 +13,61 @@ All notable changes to this project will be documented in this file.
 ### Security
 ### Security
 -->
 -->
 
 
+## v0.6.6 - 2026-02-23
+
+### Added
+
+ + SNH-Mobile Theme (Core plugin).
+ + Added sub-tribes (Core plugin).
+ + Max size control when uploading any file (Core plugin).
+ + File uploading into every single commenting point (Core plugin).
+ + Add an IP/Port for creating direct peering (Peers plugin).
+ + Oasis new updates available notice banner (Core plugin).
+ + Carbon footprint indicator in Statistics based on blobs/blockchain weight (Statistics plugin).
+ + Canvas block visualization in Block Explorer showing last 50 blocks as colored bars (Blockexplorer plugin).
+ + Inbox notification badge showing unread message count in topbar (Inbox plugin).
+ + Feed published success confirmation message banner (Feed plugin).
+ + Default SNH invite code loaded from snh-invite-code.json (Invites plugin).
+ + Peer deduplication by host in Invites (Invites plugin).
+ + Peer deduplication by key and table layout for peer listing (Peers plugin).
+ + "Device source" field showing KIT/DESKTOP/MOBILE based on theme (Inhabitants/Profile plugin).
+ + Module preset buttons for grouped configurations: Basic, Social, Economy, Full (Modules plugin).
+ + Dominant opinion highlight next to Total Opinions (Trending/Opinions plugin).
+ + German (de) translation (i18n).
+ + Italian (it) translation (i18n).
+ + Portuguese (pt) translation (i18n).
+ + Shared state module for cross-module communication (Core plugin).
+
+### Fixed
+
+ + MIME type error when uploading .mp4 (Videos plugin).
+ + URL generation problems when containing "special characters" (Forum plugin).
+ + Language selection between executing instances (Core plugin).
+ + LAN broadcasting features (Core plugin).
+ + Currently online peers discovering (Peers plugin).
+ + Activity level shadowing (Inhabitants plugin).
+ + Strip dangerous HTML tags from markdown output (Core plugin).
+ + Plaintext injection vulnerability: comprehensive stripDangerousTags sanitization across all user text inputs in backend (Core Security).
+ + Activity feed post truncation at 500 chars with "View details" link for long posts (Activity plugin).
+ + Spread content now shows excerpt (300 chars) instead of just hashtag name (Activity plugin).
+ + IN REPLY TO improved display with border-left styling, author name bold, and context preview (Activity plugin).
+ + Activity level dot was empty, now displays colored dot (●) matching inhabitants view (Profile plugin).
+ + Task description layout in Search: description now appears on new line below label (Search plugin).
+ + Projects description label formatting with flex-direction column and word-break (Projects plugin).
+ + Banking addresses from OASIS source now have delete action (Banking plugin).
+ + Direct Connect form moved below networking action buttons for better UX (Peers plugin).
+ + Peer validation and table layout for clearer peer listing (Peers plugin).
+ 
+### Changed
+
+ + Tribes for adding a "fractal" of mods inside (Tribes plugin).
+ + Removing metadata and added strong controls before uploading: PDF, video, audio, image... (Core plugin).
+ + Packages.json (Core plugin).
+ + i18n languages array expanded: ['en', 'es', 'fr', 'eu', 'de', 'it', 'pt'] (Core plugin).
+ + Activity view now truncates long posts instead of expanding full content (Activity plugin).
+ + Peers view uses table layout instead of nested lists (Peers plugin).
+ + Block Explorer shows visual block canvas above the block list (Blockexplorer plugin).
+
 ## v0.6.5 - 2026-01-16
 ## v0.6.5 - 2026-01-16
 
 
 ### Added
 ### Added

+ 5 - 5
docs/PUB/deploy.md

@@ -30,7 +30,7 @@ Paste this:
     "level": "info"
     "level": "info"
   },
   },
   "caps": {
   "caps": {
-    "shs": "iKOzhqNVTcKEZvUhW3A7TuKZ1d6qIbtsGIJ6+SBOaEQ="
+    "shs": "1BIWr6Hu+MgtNkkClvg2GAi+0HiAikGOOTd/pIUcH54="
   },
   },
   "pub": true,
   "pub": true,
   "local": false,
   "local": false,
@@ -85,7 +85,7 @@ Paste this:
   "autofollow": {
   "autofollow": {
     "enabled": true,
     "enabled": true,
     "suggestions": [
     "suggestions": [
-      "@HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519"
+      "@zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519"
     ]
     ]
   }
   }
 }
 }
@@ -161,7 +161,7 @@ To do this, first get the PUB's ID, with:
    ssb-server whoami
    ssb-server whoami
    
    
    {
    {
-     "id": "@HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519"
+     "id": "@zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519"
    }
    }
 
 
 Then, publish a name with the following command:
 Then, publish a name with the following command:
@@ -193,7 +193,7 @@ To announce your PUB, publish this message:
    
    
 For example, to announce `solarnethub.com` PUB: "La Plaza":
 For example, to announce `solarnethub.com` PUB: "La Plaza":
 
 
-   ssb-server publish --type pub --address.key @HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519 --address.host solarnethub.com --address.port 8008
+   ssb-server publish --type pub --address.key @zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519 --address.host solarnethub.com --address.port 8008
     
     
 ## 9) Following another PUB
 ## 9) Following another PUB
 
 
@@ -205,7 +205,7 @@ To follow another PUB's feed, publish this other message:
 For example, to follow `solarnethub.com` PUB: "La Plaza":
 For example, to follow `solarnethub.com` PUB: "La Plaza":
 
 
    cd ~/oasis-pub 
    cd ~/oasis-pub 
-   ssb-server publish --type contact --contact "@HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519" --following
+   ssb-server publish --type contact --contact "@zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519" --following
 
 
 ## 10) Join the Oasis PUB Network
 ## 10) Join the Oasis PUB Network
 
 

+ 2 - 0
docs/security.md

@@ -21,6 +21,8 @@ You should also know:
 
 
 - Information that others can read can be saved, without your permission.
 - Information that others can read can be saved, without your permission.
 - Encryption techniques that are unbreakable today may become compromised in the future; maybe in dozens or hundreds of years.
 - Encryption techniques that are unbreakable today may become compromised in the future; maybe in dozens or hundreds of years.
+- Don't trust your smartphone. 
+- Oasis is designed to run in a safe environment (SNH kit).
 
 
 ## Supported Versions
 ## Supported Versions
 
 

+ 16 - 9
scripts/patch-node-modules.js

@@ -7,16 +7,23 @@ const log = (msg) => console.log(`[OASIS] [PATCH] ${msg}`);
 const ssbRefPath = path.resolve(__dirname, '../src/server/node_modules/ssb-ref/index.js');
 const ssbRefPath = path.resolve(__dirname, '../src/server/node_modules/ssb-ref/index.js');
 if (fs.existsSync(ssbRefPath)) {
 if (fs.existsSync(ssbRefPath)) {
   const data = fs.readFileSync(ssbRefPath, 'utf8');
   const data = fs.readFileSync(ssbRefPath, 'utf8');
-  const patched = data.replace(
-    /exports\.parseAddress\s*=\s*deprecate\([^)]*\)/,
-    'exports.parseAddress = parseAddress'
-  );
-  if (patched !== data) {
-    fs.writeFileSync(ssbRefPath, patched);
-    log('Patched ssb-ref to remove deprecated usage of parseAddress');
-  } else {
-    log('ssb-ref patch skipped: target line not found');
+
+  // Check if already in desired state (no deprecate wrapper on parseAddress)
+  const alreadyClean = /exports\.parseAddress\s*=\s*parseAddress/.test(data);
+  if (!alreadyClean) {
+    const patched = data.replace(
+      /exports\.parseAddress\s*=\s*deprecate\(\s*['"][^'"]*['"]\s*,\s*parseAddress\s*\)/,
+      'exports.parseAddress = parseAddress'
+    );
+    if (patched !== data) {
+      fs.writeFileSync(ssbRefPath, patched);
+      log('Patched ssb-ref to remove deprecated usage of parseAddress');
+    } else {
+      log('ssb-ref patch skipped: unexpected parseAddress export format');
+    }
   }
   }
+} else {
+  log('ssb-ref patch skipped: file not found at ' + ssbRefPath);
 }
 }
 
 
 // === Patch ssb-blobs ===
 // === Patch ssb-blobs ===

Разница между файлами не показана из-за своего большого размера
+ 709 - 157
src/backend/backend.js


+ 93 - 10
src/backend/blobHandler.js

@@ -5,6 +5,59 @@ const ssb = require("../client/gui");
 const config = require("../server/SSB_server").config;
 const config = require("../server/SSB_server").config;
 const cooler = ssb({ offline: config.offline });
 const cooler = ssb({ offline: config.offline });
 
 
+let sharp;
+try {
+  sharp = require("sharp");
+} catch (e) {
+}
+
+const stripImageMetadata = async (buffer) => {
+  if (typeof sharp !== "function") return buffer;
+  try {
+    return await sharp(buffer).rotate().toBuffer();
+  } catch {
+    return buffer;
+  }
+};
+
+const PDF_METADATA_KEYS = [
+  '/Title', '/Author', '/Subject', '/Keywords',
+  '/Creator', '/Producer', '/CreationDate', '/ModDate'
+];
+
+const stripPdfMetadata = (buffer) => {
+  try {
+    let str = buffer.toString('binary');
+    for (const key of PDF_METADATA_KEYS) {
+      const keyBytes = key;
+      const regex = new RegExp(
+        keyBytes.replace(/\//g, '\\/') + '\\s*\\([^)]*\\)',
+        'g'
+      );
+      str = str.replace(regex, keyBytes + ' ()');
+      const hexRegex = new RegExp(
+        keyBytes.replace(/\//g, '\\/') + '\\s*<[^>]*>',
+        'g'
+      );
+      str = str.replace(hexRegex, keyBytes + ' <>');
+    }
+    return Buffer.from(str, 'binary');
+  } catch {
+    return buffer;
+  }
+};
+
+const MAX_BLOB_SIZE = 50 * 1024 * 1024;
+
+class FileTooLargeError extends Error {
+  constructor(fileName, fileSize) {
+    super(`File too large: ${fileName} (${(fileSize / 1024 / 1024).toFixed(1)} MB)`);
+    this.name = 'FileTooLargeError';
+    this.fileName = fileName;
+    this.fileSize = fileSize;
+  }
+}
+
 const handleBlobUpload = async function (ctx, fileFieldName) {
 const handleBlobUpload = async function (ctx, fileFieldName) {
   if (!ctx.request.files || !ctx.request.files[fileFieldName]) {
   if (!ctx.request.files || !ctx.request.files[fileFieldName]) {
     return null;
     return null;
@@ -13,12 +66,49 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
   const blobUpload = ctx.request.files[fileFieldName];
   const blobUpload = ctx.request.files[fileFieldName];
   if (!blobUpload) return null;
   if (!blobUpload) return null;
 
 
-  const data = await promisesFs.readFile(blobUpload.filepath);
+  let data = await promisesFs.readFile(blobUpload.filepath);
   if (data.length === 0) return null;
   if (data.length === 0) return null;
 
 
+  if (data.length > MAX_BLOB_SIZE) {
+    throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || fileFieldName, data.length);
+  }
+
+  const EXTENSION_MIME_MAP = {
+    '.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
+    '.ogv': 'video/ogg', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
+    '.mkv': 'video/x-matroska', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
+    '.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
+    '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
+    '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
+    '.svg': 'image/svg+xml', '.bmp': 'image/bmp'
+  };
+
+  const blob = { name: blobUpload.originalFilename || blobUpload.name || 'file' };
+
+  try {
+    const fileType = await FileType.fromBuffer(data);
+    blob.mime = (fileType && fileType.mime) ? fileType.mime : null;
+  } catch {
+    blob.mime = null;
+  }
+
+  if (!blob.mime && blob.name) {
+    const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase();
+    blob.mime = EXTENSION_MIME_MAP[ext] || 'application/octet-stream';
+  }
+
+  if (!blob.mime) {
+    blob.mime = 'application/octet-stream';
+  }
+
+  if (blob.mime.startsWith('image/')) {
+    data = await stripImageMetadata(data);
+  } else if (blob.mime === 'application/pdf') {
+    data = stripPdfMetadata(data);
+  }
+
   const ssbClient = await cooler.open();
   const ssbClient = await cooler.open();
 
 
-  const blob = { name: blobUpload.name };
   blob.id = await new Promise((resolve, reject) => {
   blob.id = await new Promise((resolve, reject) => {
     pull(
     pull(
       pull.values([data]),
       pull.values([data]),
@@ -26,13 +116,6 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
     );
     );
   });
   });
 
 
-  try {
-    const fileType = await FileType.fromBuffer(data);
-    blob.mime = fileType.mime;
-  } catch {
-    blob.mime = "application/octet-stream";
-  }
-
   if (blob.mime.startsWith("image/")) return `\n![image:${blob.name}](${blob.id})`;
   if (blob.mime.startsWith("image/")) return `\n![image:${blob.name}](${blob.id})`;
   if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
   if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
   if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`;
   if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`;
@@ -171,5 +254,5 @@ const serveBlob = async function (ctx) {
   }
   }
 };
 };
 
 
-module.exports = { handleBlobUpload, serveBlob };
+module.exports = { handleBlobUpload, serveBlob, FileTooLargeError };
 
 

+ 28 - 1
src/backend/renderTextWithStyles.js

@@ -1,11 +1,38 @@
+const i18nBase = require("../client/assets/translations/i18n");
+
+function getI18n() {
+  try {
+    const { i18n } = require("../views/main_views");
+    return i18n;
+  } catch (_) {
+    return i18nBase['en'] || {};
+  }
+}
+
 function renderTextWithStyles(text) {
 function renderTextWithStyles(text) {
   if (!text) return ''
   if (!text) return ''
+  const i18n = getI18n()
   return String(text)
   return String(text)
     .replace(/&/g, '&amp;')
     .replace(/&/g, '&amp;')
     .replace(/</g, '&lt;')
     .replace(/</g, '&lt;')
     .replace(/>/g, '&gt;')
     .replace(/>/g, '&gt;')
+    .replace(/!\[([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
+      `<img src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" alt="${alt}" class="post-image" />`
+    )
+    .replace(/\[video:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
+      `<video controls class="post-video" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></video>`
+    )
+    .replace(/\[audio:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
+      `<audio controls class="post-audio" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></audio>`
+    )
+    .replace(/\[pdf:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
+      `<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
+    )
+    .replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) =>
+      `<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${name}</a>`
+    )
     .replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
     .replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
-      `<a href="/author/${encodeURIComponent('@' + id)}" class="styled-link" target="_blank">@${id}</a>`
+      `<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>`
     )
     )
     .replace(/#(\w+)/g, (_, tag) =>
     .replace(/#(\w+)/g, (_, tag) =>
       `<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${tag}</a>`
       `<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${tag}</a>`

+ 75 - 14
src/backend/renderUrl.js

@@ -1,26 +1,87 @@
-const { a } = require("../server/node_modules/hyperaxe");
+const { a, img, video, audio } = require("../server/node_modules/hyperaxe");
+const i18nBase = require("../client/assets/translations/i18n");
+
+function getI18n() {
+  try {
+    const { i18n } = require("../views/main_views");
+    return i18n;
+  } catch (_) {
+    return i18nBase['en'] || {};
+  }
+}
 
 
 function renderUrl(text) {
 function renderUrl(text) {
   if (typeof text !== 'string') return [text];
   if (typeof text !== 'string') return [text];
+  const blobImageRegex = /!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
+  const blobVideoRegex = /\[video:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
+  const blobAudioRegex = /\[audio:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
+  const blobPdfRegex = /\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
+  const mdMentionRegex = /\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g;
+  const rawMentionRegex = /@([A-Za-z0-9+/=.\-]+\.ed25519)/g;
   const urlRegex = /\b(?:https?:\/\/|www\.)[^\s]+/g;
   const urlRegex = /\b(?:https?:\/\/|www\.)[^\s]+/g;
   const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/gi;
   const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/gi;
+  const allMatches = [];
+  for (const m of text.matchAll(blobImageRegex)) {
+    allMatches.push({ index: m.index, length: m[0].length, type: 'blob-image', name: m[1], blob: m[2] });
+  }
+  for (const m of text.matchAll(blobVideoRegex)) {
+    allMatches.push({ index: m.index, length: m[0].length, type: 'blob-video', name: m[1], blob: m[2] });
+  }
+  for (const m of text.matchAll(blobAudioRegex)) {
+    allMatches.push({ index: m.index, length: m[0].length, type: 'blob-audio', name: m[1], blob: m[2] });
+  }
+  for (const m of text.matchAll(blobPdfRegex)) {
+    allMatches.push({ index: m.index, length: m[0].length, type: 'blob-pdf', name: m[1], blob: m[2] });
+  }
+  for (const m of text.matchAll(mdMentionRegex)) {
+    allMatches.push({ index: m.index, length: m[0].length, type: 'md-mention', name: m[1], feedId: m[2] });
+  }
+  for (const m of text.matchAll(rawMentionRegex)) {
+    allMatches.push({ index: m.index, length: m[0].length, type: 'raw-mention', feedId: m[1] });
+  }
+  for (const m of text.matchAll(urlRegex)) {
+    allMatches.push({ index: m.index, length: m[0].length, type: 'url', text: m[0] });
+  }
+  for (const m of text.matchAll(emailRegex)) {
+    allMatches.push({ index: m.index, length: m[0].length, type: 'email', text: m[0] });
+  }
+  allMatches.sort((a, b) => a.index - b.index);
+  const filtered = [];
+  let lastEnd = 0;
+  for (const m of allMatches) {
+    if (m.index < lastEnd) continue;
+    filtered.push(m);
+    lastEnd = m.index + m.length;
+  }
   const result = [];
   const result = [];
   let cursor = 0;
   let cursor = 0;
-  const matches = [...(text.matchAll(urlRegex)), ...(text.matchAll(emailRegex))]
-    .sort((a, b) => a.index - b.index);
-  for (const match of matches) {
-    const url = match[0];
-    const index = match.index;
-    if (cursor < index) {
-      result.push(text.slice(cursor, index));
+  for (const m of filtered) {
+    if (cursor < m.index) {
+      result.push(text.slice(cursor, m.index));
     }
     }
-    if (url.startsWith('http') || url.startsWith('www.')) {
-      const href = url.startsWith('http') ? url : `https://${url}`;
-      result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, url));
-    } else if (url.includes('@')) {
-      result.push(a({ href: `mailto:${url}` }, url));
+    if (m.type === 'blob-image') {
+      result.push(img({ src: `/blob/${encodeURIComponent(m.blob)}`, alt: m.name || '', class: 'post-image' }));
+    } else if (m.type === 'blob-video') {
+      result.push(video({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(m.blob)}` }));
+    } else if (m.type === 'blob-audio') {
+      result.push(audio({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(m.blob)}` }));
+    } else if (m.type === 'blob-pdf') {
+      const i18n = getI18n();
+      const label = m.name || i18n.pdfFallbackLabel || 'PDF';
+      result.push(a({ href: `/blob/${encodeURIComponent(m.blob)}`, class: 'post-pdf', target: '_blank' }, label));
+    } else if (m.type === 'md-mention') {
+      const feedWithAt = '@' + m.feedId;
+      result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.name));
+    } else if (m.type === 'raw-mention') {
+      const feedWithAt = '@' + m.feedId;
+      result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.feedId.slice(0, 8) + '...'));
+    } else if (m.type === 'url') {
+      const href = m.text.startsWith('http') ? m.text : `https://${m.text}`;
+      result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, m.text));
+    } else if (m.type === 'email') {
+      result.push(a({ href: `mailto:${m.text}` }, m.text));
     }
     }
-    cursor = index + url.length;
+    cursor = m.index + m.length;
   }
   }
   if (cursor < text.length) {
   if (cursor < text.length) {
     result.push(text.slice(cursor));
     result.push(text.slice(cursor));

+ 45 - 0
src/backend/sanitizeHtml.js

@@ -0,0 +1,45 @@
+"use strict";
+
+const { JSDOM } = require('../server/node_modules/jsdom');
+const DOMPurify = require('../server/node_modules/dompurify');
+const window = new JSDOM('').window;
+const purify = DOMPurify(window);
+
+const stripDangerousTags = (input) => {
+  if (typeof input !== 'string') return '';
+  return purify.sanitize(input, {
+    USE_PROFILES: { html: true },
+    ALLOWED_TAGS: [
+      'p', 'br',
+      'b', 'strong', 'i', 'em', 'u',
+      'ul', 'ol', 'li',
+      'blockquote',
+      'code', 'pre'
+    ],
+    ALLOWED_ATTR: [],
+    FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script'],
+    FORBID_ATTR: ['style']
+  });
+};
+
+const sanitizeHtml = (input) => {
+  if (typeof input !== 'string') return '';
+  return purify.sanitize(input, {
+    USE_PROFILES: { html: true },
+    ALLOWED_TAGS: [
+      'p', 'br', 'hr',
+      'b', 'strong', 'i', 'em', 'u', 's', 'del',
+      'ul', 'ol', 'li',
+      'blockquote', 'code', 'pre',
+      'a', 'span', 'div',
+      'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+      'img', 'video', 'audio',
+      'table', 'thead', 'tbody', 'tr', 'th', 'td'
+    ],
+    ALLOWED_ATTR: ['href', 'class', 'target', 'rel', 'src', 'alt', 'title', 'controls'],
+    FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script', 'style', 'link', 'meta'],
+    FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onsubmit', 'onchange', 'style']
+  });
+};
+
+module.exports = { stripDangerousTags, sanitizeHtml };

+ 0 - 0
src/client/assets/images/favicon.svg


+ 0 - 0
src/client/assets/images/snh-oasis.jpg


+ 0 - 0
src/client/assets/styles/highlight.css


+ 498 - 0
src/client/assets/styles/mobile.css

@@ -0,0 +1,498 @@
+html {
+  -webkit-text-size-adjust: 100%;
+  box-sizing: border-box;
+  overflow-x: hidden !important;
+}
+
+*, *::before, *::after {
+  box-sizing: inherit;
+}
+
+body {
+  font-size: 15px;
+  overflow-x: hidden;
+  max-width: 100vw;
+}
+
+img,
+video,
+canvas,
+iframe,
+table {
+  max-width: 100% !important;
+}
+
+pre,
+code {
+  white-space: pre-wrap !important;
+  word-break: break-word !important;
+  overflow-wrap: anywhere !important;
+}
+
+.header {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  padding: 4px !important;
+  gap: 4px !important;
+  overflow: visible !important;
+}
+
+.header-content {
+  display: flex !important;
+  flex-wrap: wrap !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  padding: 2px !important;
+  gap: 4px !important;
+  overflow: visible !important;
+}
+
+.top-bar-left,
+.top-bar-mid,
+.top-bar-right {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  align-items: stretch !important;
+  gap: 6px !important;
+}
+
+.top-bar-left nav ul,
+.top-bar-mid nav ul,
+.top-bar-right nav ul {
+  display: flex !important;
+  flex-wrap: wrap !important;
+  gap: 6px !important;
+  padding: 0 !important;
+  margin: 0 !important;
+  overflow: visible !important;
+}
+
+.top-bar-left nav ul li,
+.top-bar-mid nav ul li,
+.top-bar-right nav ul li {
+  margin: 0 !important;
+}
+
+.top-bar-left nav ul li a,
+.top-bar-mid nav ul li a,
+.top-bar-right nav ul li a {
+  display: inline-flex !important;
+  align-items: center !important;
+  justify-content: center !important;
+  padding: 6px 8px !important;
+  font-size: 0.9rem !important;
+  white-space: nowrap !important;
+}
+
+.search-input,
+.feed-search-input,
+.activity-search-input {
+  width: 100% !important;
+  max-width: 100% !important;
+  min-width: 0 !important;
+  height: 40px !important;
+  font-size: 16px !important;
+}
+
+.main-content {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  gap: 12px !important;
+}
+
+.sidebar-left,
+.sidebar-right,
+.main-column {
+  width: 100% !important;
+  max-width: 100% !important;
+  min-width: 0 !important;
+  padding: 10px !important;
+  border-left: none !important;
+  border-right: none !important;
+}
+
+.sidebar-left {
+  order: 1 !important;
+}
+
+.main-column {
+  order: 3 !important;
+}
+
+.sidebar-right {
+  order: 2 !important;
+}
+
+.sidebar-left nav ul,
+.sidebar-right nav ul {
+  display: flex !important;
+  flex-direction: column !important;
+}
+
+.oasis-nav-header {
+  font-size: 0.85rem !important;
+}
+
+.oasis-nav-list li a {
+  font-size: 0.9rem !important;
+  padding: 8px 12px !important;
+}
+
+button,
+input[type="submit"],
+input[type="button"],
+.filter-btn,
+.create-button,
+.edit-btn,
+.delete-btn,
+.join-btn,
+.leave-btn,
+.buy-btn {
+  min-height: 44px !important;
+  font-size: 16px !important;
+  white-space: normal !important;
+  text-align: center !important;
+}
+
+.feed-row,
+.comment-body-row,
+table {
+  display: block !important;
+  width: 100% !important;
+  overflow-x: auto !important;
+}
+
+textarea,
+input,
+select {
+  width: 100% !important;
+  max-width: 100% !important;
+  font-size: 16px !important;
+}
+
+.gallery {
+  display: grid !important;
+  grid-template-columns: 1fr 1fr !important;
+  gap: 8px !important;
+}
+
+footer,
+.footer {
+  display: block !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  padding: 12px !important;
+  overflow-x: auto !important;
+}
+
+footer div {
+  display: flex !important;
+  flex-direction: column !important;
+  gap: 8px !important;
+}
+
+h1 { font-size: 1.35em !important; }
+h2 { font-size: 1.2em !important; }
+h3 { font-size: 1em !important; }
+
+.small,
+.time {
+  font-size: 0.8rem !important;
+}
+
+.created-at {
+  display: block !important;
+  width: 100% !important;
+  white-space: normal !important;
+  word-break: break-word !important;
+  overflow-wrap: anywhere !important;
+  line-height: 1.5 !important;
+  font-size: 0.8rem !important;
+}
+
+.header-content .created-at {
+  display: block !important;
+  flex: 1 1 100% !important;
+  min-width: 0 !important;
+  max-width: 100% !important;
+}
+
+.post-meta,
+.feed-post-meta,
+.feed-row .small,
+.feed-row .time,
+.feed-row .created-at {
+  display: block !important;
+  width: 100% !important;
+  white-space: normal !important;
+  word-break: break-word !important;
+  overflow-wrap: anywhere !important;
+  line-height: 1.4 !important;
+}
+
+.post-meta,
+.feed-post-meta {
+  flex-direction: column !important;
+  gap: 4px !important;
+}
+
+.mode-buttons {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  gap: 8px !important;
+  grid-template-columns: 1fr !important;
+}
+
+.mode-buttons-cols {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  gap: 8px !important;
+}
+
+.mode-buttons-row {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  gap: 8px !important;
+}
+
+.mode-buttons .column,
+.mode-buttons > div {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  gap: 6px !important;
+  grid-template-columns: 1fr !important;
+}
+
+.mode-buttons form {
+  width: 100% !important;
+}
+
+.mode-buttons .filter-btn,
+.mode-buttons button {
+  width: 100% !important;
+}
+
+.filter-group {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  gap: 6px !important;
+}
+
+.filter-group form {
+  width: 100% !important;
+}
+
+.inhabitant-card {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  overflow: hidden !important;
+}
+
+.inhabitant-left {
+  width: 100% !important;
+  text-align: center !important;
+}
+
+.inhabitant-details {
+  width: 100% !important;
+}
+
+.inhabitant-photo,
+.inhabitant-photo-details {
+  max-width: 200px !important;
+  margin: 0 auto !important;
+}
+
+.inhabitants-list,
+.inhabitants-grid {
+  display: flex !important;
+  flex-direction: column !important;
+  gap: 10px !important;
+}
+
+.tribe-card {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  overflow: hidden !important;
+}
+
+.tribe-grid {
+  display: flex !important;
+  flex-direction: column !important;
+  gap: 10px !important;
+  grid-template-columns: 1fr !important;
+}
+
+.tribes-list,
+.tribes-grid {
+  display: flex !important;
+  flex-direction: column !important;
+  gap: 10px !important;
+}
+
+.tribe-card-image {
+  width: 100% !important;
+  max-width: 100% !important;
+}
+
+.tribe-section-nav {
+  overflow-x: auto !important;
+  flex-wrap: nowrap !important;
+  -webkit-overflow-scrolling: touch;
+}
+
+.tribe-section-group {
+  flex-shrink: 0 !important;
+}
+
+.tribe-section-btn {
+  font-size: 11px !important;
+  padding: 4px 8px !important;
+  white-space: nowrap !important;
+}
+
+.tribe-details {
+  flex-direction: column !important;
+}
+
+.tribe-side {
+  width: 100% !important;
+}
+
+.tribe-main {
+  width: 100% !important;
+}
+
+.tribe-content-grid {
+  grid-template-columns: 1fr !important;
+}
+
+.tribe-inhabitants-grid {
+  grid-template-columns: repeat(2, 1fr) !important;
+}
+
+.tribe-media-grid {
+  grid-template-columns: repeat(2, 1fr) !important;
+}
+
+.tribe-overview-grid {
+  grid-template-columns: 1fr !important;
+}
+
+.tribe-mode-buttons {
+  flex-wrap: wrap !important;
+}
+
+.tribe-card-hero-image {
+  height: 180px !important;
+}
+
+.tribe-votation-option {
+  flex-direction: column !important;
+  align-items: flex-start !important;
+}
+
+.tribe-content-header {
+  flex-direction: column !important;
+  gap: 8px !important;
+}
+
+.tribe-content-filters {
+  flex-wrap: wrap !important;
+}
+
+.tribe-media-filters {
+  flex-wrap: wrap !important;
+}
+
+.forum-card {
+  flex-direction: column !important;
+}
+
+.forum-score-col,
+.forum-main-col,
+.root-vote-col {
+  width: 100% !important;
+}
+
+.forum-header-row {
+  flex-direction: column !important;
+  gap: 4px !important;
+}
+
+.forum-meta {
+  flex-wrap: wrap !important;
+  gap: 8px !important;
+}
+
+.forum-thread-header {
+  flex-direction: column !important;
+}
+
+.forum-comment {
+  margin-left: 0 !important;
+  padding-left: 8px !important;
+}
+
+.comment-body-row {
+  flex-direction: column !important;
+}
+
+.comment-vote-col,
+.comment-text-col {
+  width: 100% !important;
+}
+
+.forum-score-box,
+.forum-score-form {
+  flex-direction: row !important;
+  justify-content: center !important;
+}
+
+.new-message-form textarea,
+.comment-textarea {
+  width: 100% !important;
+}
+
+[style*="grid-template-columns: repeat(6"] {
+  grid-template-columns: 1fr !important;
+}
+
+[style*="grid-template-columns: repeat(3"] {
+  grid-template-columns: 1fr !important;
+}
+
+[style*="grid-template-columns:repeat(6"] {
+  grid-template-columns: 1fr !important;
+}
+
+[style*="grid-template-columns:repeat(3"] {
+  grid-template-columns: 1fr !important;
+}
+
+[style*="grid-template-columns: repeat(auto-fit"] {
+  grid-template-columns: 1fr !important;
+}
+
+[style*="grid-template-columns:repeat(auto-fit"] {
+  grid-template-columns: 1fr !important;
+}
+
+[style*="width:50%"] {
+  width: 100% !important;
+}

Разница между файлами не показана из-за своего большого размера
+ 1085 - 58
src/client/assets/styles/style.css


+ 71 - 0
src/client/assets/themes/Clear-SNH.css

@@ -371,3 +371,74 @@ a.user-link:focus {
   border: 2px solid transparent;
   border: 2px solid transparent;
 }
 }
 
 
+
+.update-banner {
+  background-color: #FFF3E0 !important;
+  border-bottom-color: #FFE0B2 !important;
+  color: #E65100 !important;
+}
+
+.update-banner-link {
+  color: #FF6F00 !important;
+}
+
+.snh-invite-code {
+  color: #007BFF !important;
+}
+
+.carbon-bar-track {
+  background: #e0e0e0 !important;
+}
+
+.carbon-bar-max {
+  background: #ccc !important;
+}
+
+/* Blockexplorer */
+.blockchain-view { background-color: #F4F4F4 !important; color: #2C2C2C !important; }
+.block { background: #FFFFFF !important; box-shadow: 0 2px 12px rgba(0,0,0,0.06) !important; }
+.block:hover { box-shadow: 0 8px 32px rgba(0,0,0,0.10) !important; }
+.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #2D2D2D !important; }
+.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #007BFF !important; }
+.block-content-preview, .block-content { background: #F8F8F8 !important; color: #2C2C2C !important; }
+.block-author { color: #FF6F00 !important; background: rgba(255,111,0,0.08) !important; }
+.block-author:hover { color: #FF8F00 !important; }
+.block-url { color: #007BFF !important; }
+.block-row--details .block-url { background: #F0F0F0 !important; }
+.block-row--details .block-url:hover { background: #E0E0E0 !important; color: #FF6F00 !important; }
+.btn-singleview { background: #F0F0F0 !important; color: #FF6F00 !important; }
+.btn-singleview:hover { background: #E0E0E0 !important; color: #FF8F00 !important; }
+.btn-back { background: #FF6F00 !important; color: #FFFFFF !important; }
+.btn-back:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
+.block-info-table td, .pm-info-table td { border-color: #E0E0E0 !important; }
+.block-diagram { border-color: #E0E0E0 !important; background: #FFFFFF !important; }
+.block-diagram-ruler { color: #2D2D2D !important; background: #F8F8F8 !important; border-bottom-color: #E0E0E0 !important; }
+.block-diagram-ruler span { color: #2D2D2D !important; }
+.block-diagram-cell { border-color: #E0E0E0 !important; background: #FFFFFF !important; }
+.bd-label { color: #2D2D2D !important; }
+.bd-value { color: #007BFF !important; }
+.deleted-label { color: #D32F2F !important; }
+
+/* Tribes */
+.tribe-card { background: #FFFFFF !important; border-color: #E0E0E0 !important; }
+.tribe-card:hover { border-color: #007BFF !important; }
+.tribe-card-title { color: #2D2D2D !important; }
+.tribe-card-description { color: #555 !important; }
+.tribe-info-table td { border-color: #E0E0E0 !important; }
+.tribe-info-label { color: #2D2D2D !important; background: #FFFFFF !important; }
+.tribe-info-value { color: #007BFF !important; background: #FFFFFF !important; }
+.tribe-info-empty { color: #999 !important; }
+.tribe-card-subtribes { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
+.tribe-card-members { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
+.tribe-members-count { color: #FF6F00 !important; }
+.tribe-card-actions { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
+.tribe-action-btn { border-color: #FF6F00 !important; color: #FF6F00 !important; }
+.tribe-action-btn:hover { background: #FF6F00 !important; color: #fff !important; }
+.tribe-subtribe-link { background: #F0F0F0 !important; border-color: #E0E0E0 !important; color: #FF6F00 !important; }
+.tribe-thumb-link { border-color: #E0E0E0 !important; }
+.tribe-thumb-link:hover { border-color: #FF6F00 !important; }
+.tribe-subtribe-link:hover { background: #E0E0E0 !important; }
+.tribe-parent-image { border-color: #E0E0E0 !important; }
+.tribe-parent-box { background: #FFFFFF !important; }
+.tribe-card-parent { background: #F8F8F8 !important; border-color: #E0E0E0 !important; }
+.tribe-parent-card-link { color: #FF6F00 !important; }

+ 69 - 0
src/client/assets/themes/Dark-SNH.css

@@ -288,3 +288,72 @@ a.user-link:focus {
   display: inline-block;
   display: inline-block;
   border: 2px solid transparent;
   border: 2px solid transparent;
 }
 }
+
+.update-banner {
+  background-color: #1a1400;
+  border-bottom-color: #3a2e00;
+  color: #FFD700;
+}
+
+.update-banner-link {
+  color: #FFD700;
+}
+
+.oasis-footer-center a {
+  color: #FFA500;
+}
+
+.oasis-footer-center a:hover {
+  color: #FFD700;
+}
+
+.snh-invite-code {
+  color: #FFA500 !important;
+}
+
+/* Blockexplorer */
+.blockchain-view { background-color: #191b20 !important; color: #FFD700 !important; }
+.block { background: #23242a !important; }
+.block:hover { box-shadow: 0 8px 32px rgba(35,40,50,0.18) !important; }
+.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #ffa300 !important; }
+.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #FFD700 !important; }
+.block-content-preview, .block-content { background: #222326 !important; color: #FFD700 !important; }
+.block-author { color: #FFD700 !important; background: rgba(255,163,0,0.08) !important; }
+.block-author:hover { color: #FFDD44 !important; }
+.block-url { color: #FFD700 !important; }
+.block-row--details .block-url { background: #1f2023 !important; }
+.block-row--details .block-url:hover { background: #292b36 !important; color: #ffa300 !important; }
+.btn-singleview { background: #1e1f23 !important; color: #ffa300 !important; }
+.btn-singleview:hover { background: #2d2e34 !important; color: #FFD700 !important; }
+.btn-back { background: #21232b !important; color: #ffa300 !important; }
+.btn-back:hover { background: #292b36 !important; color: #FFD700 !important; }
+.block-info-table td, .pm-info-table td { border-color: #444 !important; }
+.block-diagram { border-color: #555 !important; background: #191b20 !important; }
+.block-diagram-ruler { color: #ffa300 !important; background: #111 !important; border-bottom-color: #555 !important; }
+.block-diagram-ruler span { color: #ffa300 !important; }
+.block-diagram-cell { border-color: #555 !important; background: #1e1f23 !important; }
+.bd-label { color: #ffa300 !important; }
+.bd-value { color: #FFD700 !important; }
+
+/* Tribes */
+.tribe-card { background: #23242a !important; border-color: #444 !important; }
+.tribe-card:hover { border-color: #ffa300 !important; }
+.tribe-card-description { color: #cfd3e1 !important; }
+.tribe-info-table td { border-color: #444 !important; }
+.tribe-info-label { color: #ffa300 !important; background: #1e1f23 !important; }
+.tribe-info-value { color: #FFD700 !important; background: #1e1f23 !important; }
+.tribe-info-empty { color: #9aa3b2 !important; }
+.tribe-card-subtribes { border-color: #444 !important; background: #1e1f23 !important; }
+.tribe-card-members { border-color: #444 !important; background: #1e1f23 !important; }
+.tribe-members-count { color: #ffa300 !important; }
+.tribe-card-actions { border-color: #444 !important; background: #1e1f23 !important; }
+.tribe-action-btn { border-color: #ffa300 !important; color: #ffa300 !important; }
+.tribe-action-btn:hover { background: #ffa300 !important; color: #000 !important; }
+.tribe-subtribe-link { background: #191b20 !important; border-color: #444 !important; color: #ffa300 !important; }
+.tribe-thumb-link { border-color: #444 !important; }
+.tribe-thumb-link:hover { border-color: #ffa300 !important; }
+.tribe-subtribe-link:hover { background: #333 !important; }
+.tribe-parent-image { border-color: #ffa300 !important; }
+.tribe-parent-box { background: #1e1f23 !important; }
+.tribe-card-parent { background: #1e1f23 !important; border-color: #444 !important; }
+.tribe-parent-card-link { color: #ffa300 !important; }

+ 63 - 0
src/client/assets/themes/Matrix-SNH.css

@@ -390,3 +390,66 @@ a.user-link:focus {
   display: inline-block;
   display: inline-block;
   border: 2px solid transparent;
   border: 2px solid transparent;
 }
 }
+
+.update-banner {
+  background-color: #001a00;
+  border-bottom-color: #003300;
+  color: #00FF00;
+}
+
+.update-banner-link {
+  color: #00FF00;
+}
+
+.snh-invite-code {
+  color: #00FF00 !important;
+}
+
+/* Blockexplorer */
+.blockchain-view { background-color: #000000 !important; color: #00FF00 !important; }
+.block { background: #1A1A1A !important; border: 1px solid #00FF00 !important; }
+.block:hover { box-shadow: 0 0 15px rgba(0,255,0,0.3) !important; }
+.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #00FF00 !important; }
+.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #00FF00 !important; }
+.block-content-preview, .block-content { background: #1A1A1A !important; color: #00FF00 !important; }
+.block-author { color: #00FF00 !important; background: rgba(0,255,0,0.08) !important; }
+.block-author:hover { color: #00FF00 !important; }
+.block-url { color: #00FF00 !important; }
+.block-row--details .block-url { background: #1A1A1A !important; }
+.block-row--details .block-url:hover { background: #00FF00 !important; color: #000000 !important; }
+.btn-singleview { background: #000000 !important; color: #00FF00 !important; border: 1px solid #00FF00 !important; }
+.btn-singleview:hover { background: #00FF00 !important; color: #000000 !important; }
+.btn-back { background: #000000 !important; color: #00FF00 !important; border: 1px solid #00FF00 !important; }
+.btn-back:hover { background: #00FF00 !important; color: #000000 !important; }
+.block-info-table td, .pm-info-table td { border-color: #00FF00 !important; }
+.block-diagram { border-color: #00FF00 !important; background: #000000 !important; }
+.block-diagram-ruler { color: #00FF00 !important; background: #000000 !important; border-bottom-color: #00FF00 !important; }
+.block-diagram-ruler span { color: #00FF00 !important; }
+.block-diagram-cell { border-color: #00FF00 !important; background: #1A1A1A !important; }
+.bd-label { color: #00FF00 !important; }
+.bd-value { color: #00FF00 !important; }
+.deleted-label { color: #ff3333 !important; }
+
+/* Tribes */
+.tribe-card { background: #1A1A1A !important; border-color: #00FF00 !important; }
+.tribe-card:hover { box-shadow: 0 0 15px rgba(0,255,0,0.3) !important; }
+.tribe-card-title { color: #00FF00 !important; }
+.tribe-card-description { color: #00FF00 !important; }
+.tribe-info-table td { border-color: #00FF00 !important; }
+.tribe-info-label { color: #00FF00 !important; background: #1A1A1A !important; }
+.tribe-info-value { color: #00FF00 !important; background: #1A1A1A !important; }
+.tribe-info-empty { color: #006600 !important; }
+.tribe-card-subtribes { border-color: #00FF00 !important; background: #1A1A1A !important; }
+.tribe-card-members { border-color: #00FF00 !important; background: #1A1A1A !important; }
+.tribe-members-count { color: #00FF00 !important; }
+.tribe-card-actions { border-color: #00FF00 !important; background: #1A1A1A !important; }
+.tribe-action-btn { border-color: #00FF00 !important; color: #00FF00 !important; }
+.tribe-action-btn:hover { background: #00FF00 !important; color: #000 !important; }
+.tribe-subtribe-link { background: #000000 !important; border-color: #00FF00 !important; color: #00FF00 !important; }
+.tribe-thumb-link { border-color: #00FF00 !important; }
+.tribe-thumb-link:hover { box-shadow: 0 0 10px rgba(0,255,0,0.3) !important; }
+.tribe-subtribe-link:hover { background: #00FF00 !important; color: #000 !important; }
+.tribe-parent-image { border-color: #00FF00 !important; }
+.tribe-parent-box { background: #1A1A1A !important; }
+.tribe-card-parent { background: #1A1A1A !important; border-color: #00FF00 !important; }
+.tribe-parent-card-link { color: #00FF00 !important; }

+ 732 - 0
src/client/assets/themes/OasisMobile.css

@@ -0,0 +1,732 @@
+body {
+  background-color: #121212;
+  color: #FFD700;
+}
+
+header, footer {
+  background-color: #1F1F1F;
+}
+
+.sidebar-left, .sidebar-right {
+  background-color: #1A1A1A;
+  border: 1px solid #333;
+}
+
+.main-column {
+  background-color: #1C1C1C;
+}
+
+button, input[type="submit"], input[type="button"] {
+  background-color: #444;
+  color: #FFD700;
+  border: 1px solid #444;
+}
+
+button:hover, input[type="submit"]:hover, input[type="button"]:hover {
+  background-color: #333;
+  border-color: #666;
+}
+
+input, textarea, select {
+  background-color: #333;
+  color: #FFD700;
+  border: 1px solid #555;
+}
+
+a {
+  color: #FFD700;
+}
+
+a:hover {
+  color: #FFDD44;
+}
+
+table {
+  background-color: #222;
+  color: #FFD700;
+}
+
+table th {
+  background-color: #333;
+}
+
+table tr:nth-child(even) {
+  background-color: #2A2A2A;
+}
+
+nav ul li a:hover {
+  color: #FFDD44;
+  text-decoration: underline;
+}
+
+.profile {
+  background-color: #222;
+  padding: 15px;
+  border-radius: 8px;
+}
+
+.profile .name {
+  color: #FFD700;
+}
+
+.avatar {
+  border: 3px solid #FFD700;
+}
+
+article, section {
+  background-color: #1C1C1C;
+  color: #FFD700;
+}
+
+.article img {
+  border: 3px solid #FFD700;
+}
+
+.post-preview img {
+  border: 3px solid #FFD700;
+}
+
+.post-preview .image-container {
+  max-width: 100%;
+  overflow: hidden;
+  display: block;
+  margin: 0 auto;
+}
+
+div {
+  background-color: #1A1A1A;
+  border: 1px solid #333;
+}
+
+div .header-content {
+  width: 100%;
+}
+
+::-webkit-scrollbar {
+  width: 10px;
+}
+
+::-webkit-scrollbar-thumb {
+  background-color: #444;
+  border-radius: 10px;
+}
+
+::-webkit-scrollbar-track {
+  background-color: #222;
+}
+
+.action-container {
+  background-color: #1A1A1A;
+  border: 1px solid #333;
+  padding: 10px;
+  border-radius: 8px;
+  color: #FFD700;
+}
+
+footer {
+  background-color: #1F1F1F;
+  padding: 10px 0;
+}
+
+footer a {
+  background-color: #444;
+  color: #FFD700;
+  text-align: center;
+  padding: 8px 16px;
+  border-radius: 5px;
+  text-decoration: none;
+}
+
+footer a:hover {
+  background-color: #333;
+  color: #FFDD44;
+}
+
+.card {
+  border-radius: 16px;
+  padding: 0px 24px 10px 24px; 
+  margin-bottom: 16px;
+  color: #FFD600;
+  font-family: inherit;
+  box-shadow: 0 2px 20px 0 #FFD60024;
+}
+
+.card-section {
+  border:none;
+  padding: 10px 0 0 16px; 
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding-bottom: 0px; 
+  margin-top: 8px;
+  padding-top: 0px;   
+  border: none;
+}
+
+.card-label {
+  color: #ffa300;
+  font-weight: bold;
+  letter-spacing: 1.5px;
+  line-height: 1.2;
+  margin-bottom: 0;
+}
+
+.card-footer {
+  margin-top: 6px;  
+  font-weight: 500;
+  color: #ff9900;
+  font-size: 1.07em;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  background: none;
+  border: none;
+  padding-top: 0;
+  margin-bottom: 6;
+}
+
+.card-body {
+  margin-top: 0; 
+  margin-bottom: 4;
+  padding: 0; 
+}
+
+.card-field {
+  display: flex;
+  align-items: baseline;
+  padding: 0;
+  margin-bottom: 0;
+  border: none;
+  background: none;
+}
+
+.card-tags {
+  margin: 5px 0 3px 0;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 9px;
+}
+
+.card-tags a.tag-link {
+  text-decoration: none;
+  color: #181818;
+  background: #FFD600;
+  padding: 5px 13px 4px 13px;
+  border-radius: 7px;
+  font-size: .98em;
+  border: none;
+  font-weight: bold;
+}
+
+.card-tags a.tag-link:hover {
+  background: #ffe86a;
+  color: #111;
+  cursor: pointer;
+}
+
+a.user-link {
+  background-color: #FFA500; 
+  color: #000;
+  padding: 8px 16px;
+  border-radius: 5px; 
+  text-align: center;
+  font-weight: bold;
+  text-decoration: none; 
+  display: inline-block;
+  border: 2px solid transparent;
+  transition: background-color 0.3s, color 0.3s, border-color 0.3s; 
+  font-size: 0.8em; 
+}
+
+a.user-link:hover {
+  background-color: #FFD700; 
+  border-color: #FFD700; 
+  color: #000; 
+  cursor: pointer; 
+}
+
+a.user-link:focus {
+  background-color: #007B9F; 
+  border-color: #007B9F; 
+  color: #fff;
+}
+
+.date-link {
+  background-color: #444;
+  color: #FFD600;
+  padding: 8px 16px;
+  border-radius: 5px;
+  margin-left: 8px;
+}
+
+.date-link:hover {
+  background-color: #555;
+  color: #FFD700;
+}
+
+.activitySpreadInhabitant2 {
+  background-color: #007B9F; 
+  color: #fff;  
+  padding: 8px 16px;
+  border-radius: 5px;
+  font-weight: bold;
+  text-decoration: none;
+  display: inline-block;
+  border: 2px solid transparent;
+}
+
+.activityVotePost {
+  background-color: #557d3b; 
+  color: #fff;  
+  padding: 8px 16px;
+  border-radius: 5px;
+  font-weight: bold;
+  text-decoration: none;
+  display: inline-block;
+  border: 2px solid transparent;
+}
+html {
+  -webkit-text-size-adjust: 100%;
+  box-sizing: border-box;
+  overflow-x: hidden !important;
+}
+
+*, *::before, *::after {
+  box-sizing: inherit;
+}
+
+body {
+  font-size: 15px;
+  overflow-x: hidden;
+  max-width: 100vw;
+}
+
+img,
+video,
+canvas,
+iframe,
+table {
+  max-width: 100% !important;
+}
+
+pre,
+code {
+  white-space: pre-wrap !important;
+  word-break: break-word !important;
+  overflow-wrap: anywhere !important;
+}
+
+.header {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  padding: 4px !important;
+  gap: 4px !important;
+  overflow: visible !important;
+}
+
+.header-content {
+  display: flex !important;
+  flex-wrap: wrap !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  padding: 2px !important;
+  gap: 4px !important;
+  overflow: visible !important;
+}
+
+.top-bar-left,
+.top-bar-mid,
+.top-bar-right {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  align-items: stretch !important;
+  gap: 6px !important;
+}
+
+.top-bar-left nav ul,
+.top-bar-mid nav ul,
+.top-bar-right nav ul {
+  display: flex !important;
+  flex-wrap: wrap !important;
+  gap: 6px !important;
+  padding: 0 !important;
+  margin: 0 !important;
+  overflow: visible !important;
+}
+
+.top-bar-left nav ul li,
+.top-bar-mid nav ul li,
+.top-bar-right nav ul li {
+  margin: 0 !important;
+}
+
+.top-bar-left nav ul li a,
+.top-bar-mid nav ul li a,
+.top-bar-right nav ul li a {
+  display: inline-flex !important;
+  align-items: center !important;
+  justify-content: center !important;
+  padding: 6px 8px !important;
+  font-size: 0.9rem !important;
+  white-space: nowrap !important;
+}
+
+.search-input,
+.feed-search-input,
+.activity-search-input {
+  width: 100% !important;
+  max-width: 100% !important;
+  min-width: 0 !important;
+  height: 40px !important;
+  font-size: 16px !important;
+}
+
+.main-content {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  gap: 12px !important;
+}
+
+.sidebar-left,
+.sidebar-right,
+.main-column {
+  width: 100% !important;
+  max-width: 100% !important;
+  min-width: 0 !important;
+  padding: 10px !important;
+  border-left: none !important;
+  border-right: none !important;
+}
+
+.sidebar-left {
+  order: 1 !important;
+}
+
+.main-column {
+  order: 3 !important;
+}
+
+.sidebar-right {
+  order: 2 !important;
+}
+
+.sidebar-left nav ul,
+.sidebar-right nav ul {
+  display: flex !important;
+  flex-direction: column !important;
+}
+
+.oasis-nav-header {
+  font-size: 0.85rem !important;
+}
+
+.oasis-nav-list li a {
+  font-size: 0.9rem !important;
+  padding: 8px 12px !important;
+}
+
+button,
+input[type="submit"],
+input[type="button"],
+.filter-btn,
+.create-button,
+.edit-btn,
+.delete-btn,
+.join-btn,
+.leave-btn,
+.buy-btn {
+  min-height: 44px !important;
+  font-size: 16px !important;
+  white-space: normal !important;
+  text-align: center !important;
+}
+
+.feed-row,
+.comment-body-row,
+table {
+  display: block !important;
+  width: 100% !important;
+  overflow-x: auto !important;
+}
+
+textarea,
+input,
+select {
+  width: 100% !important;
+  max-width: 100% !important;
+  font-size: 16px !important;
+}
+
+.gallery {
+  display: grid !important;
+  grid-template-columns: 1fr 1fr !important;
+  gap: 8px !important;
+}
+
+footer,
+.footer {
+  display: block !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  padding: 12px !important;
+  overflow-x: auto !important;
+}
+
+footer div {
+  display: flex !important;
+  flex-direction: column !important;
+  gap: 8px !important;
+}
+
+h1 { font-size: 1.35em !important; }
+h2 { font-size: 1.2em !important; }
+h3 { font-size: 1em !important; }
+
+.small,
+.time {
+  font-size: 0.8rem !important;
+}
+
+.created-at {
+  display: block !important;
+  width: 100% !important;
+  white-space: normal !important;
+  word-break: break-word !important;
+  overflow-wrap: anywhere !important;
+  line-height: 1.5 !important;
+  font-size: 0.8rem !important;
+}
+
+.header-content .created-at {
+  display: block !important;
+  flex: 1 1 100% !important;
+  min-width: 0 !important;
+  max-width: 100% !important;
+}
+
+.post-meta,
+.feed-post-meta,
+.feed-row .small,
+.feed-row .time,
+.feed-row .created-at {
+  display: block !important;
+  width: 100% !important;
+  white-space: normal !important;
+  word-break: break-word !important;
+  overflow-wrap: anywhere !important;
+  line-height: 1.4 !important;
+}
+
+.post-meta,
+.feed-post-meta {
+  flex-direction: column !important;
+  gap: 4px !important;
+}
+
+.mode-buttons {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  gap: 8px !important;
+  grid-template-columns: 1fr !important;
+}
+
+.mode-buttons-cols {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  gap: 8px !important;
+}
+
+.mode-buttons-row {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  gap: 8px !important;
+}
+
+.mode-buttons .column,
+.mode-buttons > div {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  gap: 6px !important;
+  grid-template-columns: 1fr !important;
+}
+
+.mode-buttons form {
+  width: 100% !important;
+}
+
+.mode-buttons .filter-btn,
+.mode-buttons button {
+  width: 100% !important;
+}
+
+.filter-group {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  gap: 6px !important;
+}
+
+.filter-group form {
+  width: 100% !important;
+}
+
+.inhabitant-card {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  overflow: hidden !important;
+}
+
+.inhabitant-left {
+  width: 100% !important;
+  text-align: center !important;
+}
+
+.inhabitant-details {
+  width: 100% !important;
+}
+
+.inhabitant-photo,
+.inhabitant-photo-details {
+  max-width: 200px !important;
+  margin: 0 auto !important;
+}
+
+.inhabitants-list,
+.inhabitants-grid {
+  display: flex !important;
+  flex-direction: column !important;
+  gap: 10px !important;
+}
+
+.tribe-card {
+  display: flex !important;
+  flex-direction: column !important;
+  width: 100% !important;
+  max-width: 100% !important;
+  overflow: hidden !important;
+}
+
+.tribe-grid {
+  display: flex !important;
+  flex-direction: column !important;
+  gap: 10px !important;
+  grid-template-columns: 1fr !important;
+}
+
+.tribes-list,
+.tribes-grid {
+  display: flex !important;
+  flex-direction: column !important;
+  gap: 10px !important;
+}
+
+.tribe-card-image {
+  width: 100% !important;
+  max-width: 100% !important;
+}
+
+.forum-card {
+  flex-direction: column !important;
+}
+
+.forum-score-col,
+.forum-main-col,
+.root-vote-col {
+  width: 100% !important;
+}
+
+.forum-header-row {
+  flex-direction: column !important;
+  gap: 4px !important;
+}
+
+.forum-meta {
+  flex-wrap: wrap !important;
+  gap: 8px !important;
+}
+
+.forum-thread-header {
+  flex-direction: column !important;
+}
+
+.forum-comment {
+  margin-left: 0 !important;
+  padding-left: 8px !important;
+}
+
+.comment-body-row {
+  flex-direction: column !important;
+}
+
+.comment-vote-col,
+.comment-text-col {
+  width: 100% !important;
+}
+
+.forum-score-box,
+.forum-score-form {
+  flex-direction: row !important;
+  justify-content: center !important;
+}
+
+.new-message-form textarea,
+.comment-textarea {
+  width: 100% !important;
+}
+
+[style*="grid-template-columns: repeat(6"] {
+  grid-template-columns: 1fr !important;
+}
+
+[style*="grid-template-columns: repeat(3"] {
+  grid-template-columns: 1fr !important;
+}
+
+[style*="grid-template-columns:repeat(6"] {
+  grid-template-columns: 1fr !important;
+}
+
+[style*="grid-template-columns:repeat(3"] {
+  grid-template-columns: 1fr !important;
+}
+
+[style*="grid-template-columns: repeat(auto-fit"] {
+  grid-template-columns: 1fr !important;
+}
+
+[style*="grid-template-columns:repeat(auto-fit"] {
+  grid-template-columns: 1fr !important;
+}
+
+[style*="width:50%"] {
+  width: 100% !important;
+}
+
+.update-banner {
+  background-color: #1a1400;
+  border-bottom-color: #3a2e00;
+  color: #FFD700;
+}
+
+.update-banner-link {
+  color: #FFD700;
+}
+
+.snh-invite-code {
+  color: #FFA500 !important;
+}

+ 63 - 0
src/client/assets/themes/Purple-SNH.css

@@ -425,3 +425,66 @@ a.user-link:focus {
   display: inline-block;
   display: inline-block;
   border: 2px solid transparent;
   border: 2px solid transparent;
 }
 }
+
+.update-banner {
+  background-color: #1a0025;
+  border-bottom-color: #3d004d;
+  color: #E5E5E5;
+}
+
+.update-banner-link {
+  color: #c44bc1;
+}
+
+.snh-invite-code {
+  color: #9B1C96 !important;
+}
+
+/* Blockexplorer */
+.blockchain-view { background-color: #2D0B47 !important; color: #FFEEDB !important; }
+.block { background: #3C1360 !important; border: 1px solid #B86ADE !important; }
+.block:hover { box-shadow: 0 0 15px rgba(184,106,222,0.3) !important; }
+.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #B86ADE !important; }
+.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #FFEEDB !important; }
+.block-content-preview, .block-content { background: #4B1A72 !important; color: #FFEEDB !important; }
+.block-author { color: #FFD600 !important; background: rgba(255,214,0,0.08) !important; }
+.block-author:hover { color: #FFB600 !important; }
+.block-url { color: #B86ADE !important; }
+.block-row--details .block-url { background: #4B1A72 !important; }
+.block-row--details .block-url:hover { background: #5A1A85 !important; color: #FFD600 !important; }
+.btn-singleview { background: #4B1A72 !important; color: #B86ADE !important; }
+.btn-singleview:hover { background: #5A1A85 !important; color: #FFD600 !important; }
+.btn-back { background: #A34AD8 !important; color: #FFFFFF !important; }
+.btn-back:hover { background: #751E9F !important; color: #FFFFFF !important; }
+.block-info-table td, .pm-info-table td { border-color: #B86ADE !important; }
+.block-diagram { border-color: #B86ADE !important; background: #2D0B47 !important; }
+.block-diagram-ruler { color: #B86ADE !important; background: #1A0030 !important; border-bottom-color: #B86ADE !important; }
+.block-diagram-ruler span { color: #B86ADE !important; }
+.block-diagram-cell { border-color: #B86ADE !important; background: #3C1360 !important; }
+.bd-label { color: #B86ADE !important; }
+.bd-value { color: #FFEEDB !important; }
+.deleted-label { color: #ff5555 !important; }
+
+/* Tribes */
+.tribe-card { background: #3C1360 !important; border-color: #B86ADE !important; }
+.tribe-card:hover { box-shadow: 0 0 15px rgba(184,106,222,0.3) !important; }
+.tribe-card-title { color: #FFEEDB !important; }
+.tribe-card-description { color: #FFEEDB !important; }
+.tribe-info-table td { border-color: #B86ADE !important; }
+.tribe-info-label { color: #B86ADE !important; background: #3C1360 !important; }
+.tribe-info-value { color: #FFEEDB !important; background: #3C1360 !important; }
+.tribe-info-empty { color: #8844aa !important; }
+.tribe-card-subtribes { border-color: #B86ADE !important; background: #2D0B47 !important; }
+.tribe-card-members { border-color: #B86ADE !important; background: #2D0B47 !important; }
+.tribe-members-count { color: #FFD600 !important; }
+.tribe-card-actions { border-color: #B86ADE !important; background: #2D0B47 !important; }
+.tribe-action-btn { border-color: #B86ADE !important; color: #B86ADE !important; }
+.tribe-action-btn:hover { background: #B86ADE !important; color: #000 !important; }
+.tribe-subtribe-link { background: #4B1A72 !important; border-color: #B86ADE !important; color: #FFD600 !important; }
+.tribe-thumb-link { border-color: #B86ADE !important; }
+.tribe-thumb-link:hover { border-color: #FFD600 !important; }
+.tribe-subtribe-link:hover { background: #5A1A85 !important; }
+.tribe-parent-image { border-color: #B86ADE !important; }
+.tribe-parent-box { background: #3C1360 !important; }
+.tribe-card-parent { background: #4B1A72 !important; border-color: #B86ADE !important; }
+.tribe-parent-card-link { color: #FFD600 !important; }

+ 1 - 1
src/client/assets/translations/i18n.js

@@ -1,6 +1,6 @@
 const path = require('path');
 const path = require('path');
 let i18n = {};
 let i18n = {};
-const languages = ['en', 'es', 'fr', 'eu']; // Add more language codes
+const languages = ['en', 'es', 'fr', 'eu', 'de', 'it', 'pt'];
 
 
 languages.forEach(language => {
 languages.forEach(language => {
   try {
   try {

Разница между файлами не показана из-за своего большого размера
+ 2702 - 0
src/client/assets/translations/oasis_de.js


+ 219 - 11
src/client/assets/translations/oasis_en.js

@@ -57,6 +57,9 @@ module.exports = {
     privateDelete: "Delete",
     privateDelete: "Delete",
     pmCreateButton: "Write a PM",
     pmCreateButton: "Write a PM",
     pmReply: "Reply",
     pmReply: "Reply",
+    pmReplies: "replies",
+    pmNew: "new",
+    pmMarkRead: "Mark as read",
     inReplyTo: "IN REPLY TO",
     inReplyTo: "IN REPLY TO",
     pmPreview: "Preview",
     pmPreview: "Preview",
     pmPreviewTitle: "Message preview",
     pmPreviewTitle: "Message preview",
@@ -202,6 +205,8 @@ module.exports = {
     mentionsRelationship: "Relationship",
     mentionsRelationship: "Relationship",
     //settings
     //settings
     updateit: "GET UPDATES!",
     updateit: "GET UPDATES!",
+    updateBannerText: "A new version of Oasis is available.",
+    updateBannerAction: "Update now →",
     info: "Info",
     info: "Info",
     settingsIntro: ({ version }) => [
     settingsIntro: ({ version }) => [
       `[SNH] ꖒ OASIS [ v.${version} ]`,
       `[SNH] ꖒ OASIS [ v.${version} ]`,
@@ -376,6 +381,7 @@ module.exports = {
     videoLabel: "VIDEOS",
     videoLabel: "VIDEOS",
     audioLabel: "AUDIOS",
     audioLabel: "AUDIOS",
     documentLabel: "DOCUMENTS",
     documentLabel: "DOCUMENTS",
+    pdfFallbackLabel: "PDF Document",
     eventLabel: "EVENTS",
     eventLabel: "EVENTS",
     taskLabel: "TASKS",
     taskLabel: "TASKS",
     transferLabel: "TRANSFERS",
     transferLabel: "TRANSFERS",
@@ -390,7 +396,7 @@ module.exports = {
     editProfileDescription:
     editProfileDescription:
       "",
       "",
     profileName: "Name",
     profileName: "Name",
-    profileImage: "Avatar Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    profileImage: "Avatar Image",
     profileDescription: "Description",
     profileDescription: "Description",
     hashtagDescription:
     hashtagDescription:
       "Posts from inhabitants in your network that reference this #hashtag, sorted by recency.",
       "Posts from inhabitants in your network that reference this #hashtag, sorted by recency.",
@@ -678,6 +684,7 @@ module.exports = {
     blockedLabel: "Blocked User",
     blockedLabel: "Blocked User",
     inhabitantviewDetails: "View Details",
     inhabitantviewDetails: "View Details",
     viewDetails: "View Details",
     viewDetails: "View Details",
+    keepReading: "Keep reading...",
     oasisId: "ID",
     oasisId: "ID",
     noInhabitantsFound: "No inhabitants found, yet.",
     noInhabitantsFound: "No inhabitants found, yet.",
     inhabitantActivityLevel: "Activity Level",
     inhabitantActivityLevel: "Activity Level",
@@ -1275,7 +1282,7 @@ module.exports = {
     // blog/post,
     // blog/post,
     blogSubject: "Subject",
     blogSubject: "Subject",
     blogMessage: "Message",
     blogMessage: "Message",
-    blogImage: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    blogImage: "Upload media (max-size: 50MB)",
     blogPublish: "Preview",
     blogPublish: "Preview",
     noPopularMessages: "No popular messages published, yet",
     noPopularMessages: "No popular messages published, yet",
     //forum
     //forum
@@ -1374,6 +1381,7 @@ module.exports = {
     TOPButton:        "Top Feeds",
     TOPButton:        "Top Feeds",
     CREATEButton:     "Create Feed",
     CREATEButton:     "Create Feed",
     totalOpinions:    "Total Opinions",
     totalOpinions:    "Total Opinions",
+    moreVoted:        "More Voted",
     alreadyVoted:     "You have already opined.",
     alreadyVoted:     "You have already opined.",
     noFeedsFound:     "No feeds found.",
     noFeedsFound:     "No feeds found.",
     author:           "By",
     author:           "By",
@@ -1584,7 +1592,7 @@ module.exports = {
     reportsUpdateButton: "Update",
     reportsUpdateButton: "Update",
     reportsDeleteButton: "Delete",
     reportsDeleteButton: "Delete",
     reportsDateLabel: "Date",
     reportsDateLabel: "Date",
-    reportsUploadFile: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    reportsUploadFile: "Upload media (max-size: 50MB)",
     reportsCreatedBy: "By",
     reportsCreatedBy: "By",
     reportsMineSectionTitle: "Your Reports",
     reportsMineSectionTitle: "Your Reports",
     reportsFeaturesSectionTitle: "Feature Requests",
     reportsFeaturesSectionTitle: "Feature Requests",
@@ -1657,7 +1665,6 @@ module.exports = {
     reportsWhyInappropriatePlaceholder: 'Explain the reason and impact.',
     reportsWhyInappropriatePlaceholder: 'Explain the reason and impact.',
     reportsRequestedActionLabel: 'Requested action',
     reportsRequestedActionLabel: 'Requested action',
     reportsRequestedActionPlaceholder: 'Remove, hide, tag, warn, etc.',  
     reportsRequestedActionPlaceholder: 'Remove, hide, tag, warn, etc.',  
-    //tribes
     tribesTitle: "Tribes",
     tribesTitle: "Tribes",
     tribeAllSectionTitle: "Tribes",
     tribeAllSectionTitle: "Tribes",
     tribeMineSectionTitle: "Your Tribes",
     tribeMineSectionTitle: "Your Tribes",
@@ -1675,11 +1682,13 @@ module.exports = {
     tribeFilterRecent: "RECENT",
     tribeFilterRecent: "RECENT",
     tribeFilterLarp: "L.A.R.P.",
     tribeFilterLarp: "L.A.R.P.",
     tribeFilterTop: "TOP",
     tribeFilterTop: "TOP",
+    tribeFilterSubtribes: "SUB-TRIBES",
     tribeFilterGallery: "GALLERY",
     tribeFilterGallery: "GALLERY",
+    tribeMainTribeLabel: "MAIN TRIBE",
     tribeCreateButton: "Create Tribe",
     tribeCreateButton: "Create Tribe",
     tribeUpdateButton: "Update",
     tribeUpdateButton: "Update",
     tribeDeleteButton: "Delete",
     tribeDeleteButton: "Delete",
-    tribeImageLabel: "Tribe Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    tribeImageLabel: "Upload media (max-size: 50MB)",
     tribeTitleLabel: "Title",
     tribeTitleLabel: "Title",
     searchTribesPlaceholder:  "FILTER tribes BY NAME …",
     searchTribesPlaceholder:  "FILTER tribes BY NAME …",
     tribeTitlePlaceholder: "Name of the tribe",
     tribeTitlePlaceholder: "Name of the tribe",
@@ -1706,6 +1715,7 @@ module.exports = {
     tribeGenerateInvite: "GENERATE CODE",
     tribeGenerateInvite: "GENERATE CODE",
     tribeCreatedAt: "Created at",
     tribeCreatedAt: "Created at",
     tribeAuthor: "By",
     tribeAuthor: "By",
+    tribeAuthorLabel: "AUTHOR",
     tribeStrict: "Strict",
     tribeStrict: "Strict",
     tribeOpen: "Open",
     tribeOpen: "Open",
     tribeFeedFilterRECENT:           "RECENT",
     tribeFeedFilterRECENT:           "RECENT",
@@ -1718,7 +1728,178 @@ module.exports = {
     tribeFeedSend:                   "Send",
     tribeFeedSend:                   "Send",
     tribeFeedEmpty:                  "No feed messages available, yet.",
     tribeFeedEmpty:                  "No feed messages available, yet.",
     noTribes: "No tribes found, yet.",
     noTribes: "No tribes found, yet.",
-    //agenda
+    tribeNotFound: "Tribe not found!",
+    createTribeTitle: "Create Tribe",
+    updateTribeTitle: "Update Tribe",
+    tribeSectionOverview: "Overview",
+    tribeSectionInhabitants: "Inhabitants",
+    tribeSectionVotations: "Votations",
+    tribeSectionEvents: "Events",
+    tribeSectionReports: "Reports",
+    tribeSectionTasks: "Tasks",
+    tribeSectionFeed: "Feed",
+    tribeSectionForum: "Forum",
+    tribeSectionMarket: "Market",
+    tribeSectionJobs: "Jobs",
+    tribeSectionProjects: "Projects",
+    tribeSectionMedia: "Media",
+    tribeSectionImages: "IMAGES",
+    tribeSectionAudios: "AUDIOS",
+    tribeSectionVideos: "VIDEOS",
+    tribeSectionDocuments: "DOCUMENTS",
+    tribeSectionBookmarks: "BOOKMARKS",
+    tribeInhabitantsEmpty: "No inhabitants in this tribe, yet.",
+    tribeEventCreate: "Create Event",
+    tribeEventsEmpty: "No events, yet.",
+    tribeEventTitle: "Title",
+    tribeEventDescription: "Description",
+    tribeEventDate: "Date",
+    tribeEventLocation: "Location",
+    tribeEventAttend: "ATTEND",
+    tribeEventUnattend: "LEAVE",
+    tribeEventAttendees: "Attendees",
+    tribeTaskCreate: "Create Task",
+    tribeTasksEmpty: "No tasks, yet.",
+    tribeTaskTitle: "Title",
+    tribeTaskDescription: "Description",
+    tribeTaskPriority: "Priority",
+    tribeTaskDeadline: "Deadline",
+    tribeTaskAssignees: "Assignees",
+    tribeTaskStatusInProgress: "IN PROGRESS",
+    tribeTaskStatusClosed: "CLOSE",
+    tribeTaskAssign: "ASSIGN",
+    tribeTaskUnassign: "UNASSIGN",
+    tribeReportCreate: "Create Report",
+    tribeReportsEmpty: "No reports, yet.",
+    tribeReportTitle: "Title",
+    tribeReportDescription: "Description",
+    tribeReportCategory: "Category",
+    tribeVotationCreate: "Create Votation",
+    tribeVotationsEmpty: "No votations, yet.",
+    tribeVotationTitle: "Title",
+    tribeVotationDescription: "Description",
+    tribeVotationOptions: "Options",
+    tribeVotationDeadline: "Deadline",
+    tribeVotationVote: "VOTE",
+    tribeVotationResults: "Votes",
+    tribeVotationClose: "CLOSE VOTATION",
+    tribeVotationOptionPlaceholder: "Option",
+    tribeForumCreate: "Create Forum",
+    tribeForumEmpty: "No threads, yet.",
+    tribeForumTitle: "Title",
+    tribeForumText: "Message",
+    tribeForumCategory: "Category",
+    tribeForumReply: "Reply",
+    tribeForumReplies: "Replies",
+    tribeMarketCreate: "Create Listing",
+    tribeMarketEmpty: "No listings, yet.",
+    tribeMarketTitle: "Title",
+    tribeMarketDescription: "Description",
+    tribeMarketPrice: "Price",
+    tribeMarketImage: "Image",
+    tribeMarketCategory: "Category",
+    tribeJobCreate: "Create Job",
+    tribeJobsEmpty: "No jobs, yet.",
+    tribeJobTitle: "Title",
+    tribeJobDescription: "Description",
+    tribeJobLocation: "Location",
+    tribeJobSalary: "Salary",
+    tribeJobDeadline: "Deadline",
+    tribeProjectCreate: "Create Project",
+    tribeProjectsEmpty: "No projects, yet.",
+    tribeProjectTitle: "Title",
+    tribeProjectDescription: "Description",
+    tribeProjectGoal: "Goal",
+    tribeProjectFunded: "Funded",
+    tribeProjectDeadline: "Deadline",
+    tribeMediaUpload: "Upload Media",
+    readDocument: "Read Document",
+    tribeCreateImage: "Create Image",
+    tribeCreateAudio: "Create Audio",
+    tribeCreateVideo: "Create Video",
+    tribeCreateDocument: "Create Document",
+    tribeCreateBookmark: "Create Bookmark",
+    tribeMediaEmpty: "No media, yet.",
+    tribeMediaTitle: "Title",
+    tribeMediaDescription: "Description",
+    tribeMediaType: "Type",
+    tribeMediaTypeImage: "Image",
+    tribeMediaTypeVideo: "Video",
+    tribeMediaTypeAudio: "Audio",
+    tribeMediaTypeDocument: "Document",
+    tribeMediaTypeBookmark: "Bookmark",
+    tribeContentDelete: "DELETE",
+    tribeInviteCodeText: "Invite code: ",
+    tribeGroupTribe: "Tribe",
+    tribeGroupOffice: "Office",
+    tribeGroupNetwork: "Network",
+    tribeGroupEconomy: "Economy",
+    tribeGroupMedia: "Media",
+    tribeStatusOpen: "OPEN",
+    tribeStatusClosed: "CLOSED",
+    tribeStatusInProgress: "IN PROGRESS",
+    tribePriorityLow: "LOW",
+    tribePriorityMedium: "MEDIUM",
+    tribePriorityHigh: "HIGH",
+    tribePriorityCritical: "CRITICAL",
+    tribeTaskFilterAll: "ALL",
+    tribeMediaFilterAll: "ALL",
+    tribeReportCatBug: "BUG",
+    tribeReportCatAbuse: "ABUSE",
+    tribeReportCatContent: "CONTENT",
+    tribeReportCatOther: "OTHER",
+    tribeForumCatGeneral: "GENERAL",
+    tribeForumCatProposal: "PROPOSAL",
+    tribeForumCatQuestion: "QUESTION",
+    tribeForumCatAnnouncement: "ANNOUNCEMENT",
+    tribeMarketCatGoods: "GOODS",
+    tribeMarketCatServices: "SERVICES",
+    tribeMarketCatFood: "FOOD",
+    tribeMarketCatOther: "OTHER",
+    tribeStatusLabel: "Status",
+    tribeSubTribes: "SUB-TRIBES",
+    tribeSubTribesCreate: "Create Sub-Tribe",
+    tribeSubTribesEmpty: "No sub-tribes created, yet.",
+    tribeLarpCreateForbidden: "L.A.R.P. tribes cannot be created.",
+    tribeLarpUpdateForbidden: "L.A.R.P. tribes cannot be updated.",
+    tribeActivityJoined: "JOINED",
+    tribeActivityLeft: "LEFT",
+    tribeActivityFeed: "FEED",
+    tribeActivityRefeed: "REFEED",
+    tribeGroupAnalytics: "Analytics",
+    tribeGroupCreative: "Creative",
+    tribeSectionActivity: "ACTIVITY",
+    tribeSectionTrending: "TRENDING",
+    tribeSectionOpinions: "OPINIONS",
+    tribeSectionPixelia: "PIXELIA",
+    tribeSectionTags: "TAGS",
+    tribeSectionSearch: "SEARCH",
+    tribeActivityEmpty: "No activity yet.",
+    tribeActivityCreated: "created",
+    tribeActivityPosted: "posted",
+    tribeActivityReplied: "replied",
+    tribeTrendingEmpty: "No trending content yet.",
+    tribeTrendingPeriodDay: "Today",
+    tribeTrendingPeriodWeek: "This Week",
+    tribeTrendingPeriodAll: "All Time",
+    tribeTrendingEngagement: "engagement",
+    tribeOpinionsEmpty: "No opinions yet.",
+    tribeOpinionsCast: "Vote",
+    tribeOpinionsRankings: "Rankings",
+    tribeOpinionsAlreadyVoted: "Already voted",
+    tribeTopCategory: "More Voted",
+    tribePixeliaTitle: "Pixelia",
+    tribePixeliaDescription: "Collaborative pixel art canvas.",
+    tribePixeliaPaint: "Paint",
+    tribePixeliaContributors: "contributors",
+    tribePixeliaTotalPixels: "pixels painted",
+    tribeTagsEmpty: "No tags found.",
+    tribeTagsCloud: "Tag Cloud",
+    tribeTagsContentWith: "Content with tag",
+    tribeSearchPlaceholder: "Search tribe content...",
+    tribeSearchEmpty: "No results found.",
+    tribeSearchResults: "Results",
+    tribeSearchMinChars: "Enter at least 2 characters to search.",
     agendaTitle: "Agenda",
     agendaTitle: "Agenda",
     agendaDescription: "Here you can find all your assigned items.",
     agendaDescription: "Here you can find all your assigned items.",
     agendaFilterAll: "ALL",
     agendaFilterAll: "ALL",
@@ -1881,6 +2062,8 @@ module.exports = {
     pmToLabel: "To:",
     pmToLabel: "To:",
     pmInvalidMessage: "Invalid message",
     pmInvalidMessage: "Invalid message",
     pmNoSubject: "(no subject)",
     pmNoSubject: "(no subject)",
+    pmSubjectLabel: "Subject:",
+    pmBodyLabel: "Body",
     pmBotJobs: "42-JobsBOT",
     pmBotJobs: "42-JobsBOT",
     pmBotProjects: "42-ProjectsBOT",
     pmBotProjects: "42-ProjectsBOT",
     pmBotMarket: "42-MarketBOT",
     pmBotMarket: "42-MarketBOT",
@@ -1909,6 +2092,8 @@ module.exports = {
     blockchainBlockURL: 'URL:',
     blockchainBlockURL: 'URL:',
     blockchainContent: 'Block',
     blockchainContent: 'Block',
     blockchainContentPreview: 'Preview of the block content',
     blockchainContentPreview: 'Preview of the block content',
+    blockchainLatestDatagram: 'Latest Datagram',
+    blockchainDatagram: 'Datagram',
     blockchainDetails: 'View block details',
     blockchainDetails: 'View block details',
     blockchainBlockInfo: 'Block Information',
     blockchainBlockInfo: 'Block Information',
     blockchainBlockDetails: 'Details of the selected block',
     blockchainBlockDetails: 'Details of the selected block',
@@ -2185,7 +2370,7 @@ module.exports = {
     marketItemSeller: "Seller",
     marketItemSeller: "Seller",
     marketNoItems: "No items available, yet.",
     marketNoItems: "No items available, yet.",
     marketYourBid: "Your Bid",
     marketYourBid: "Your Bid",
-    marketCreateFormImageLabel: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    marketCreateFormImageLabel: "Upload media (max-size: 50MB)",
     marketSearchLabel: "Search",
     marketSearchLabel: "Search",
     marketSearchPlaceholder: "Search title or tags",
     marketSearchPlaceholder: "Search title or tags",
     marketMinPriceLabel: "Min price",
     marketMinPriceLabel: "Min price",
@@ -2246,7 +2431,7 @@ module.exports = {
     jobLocationRemote: "Remote",
     jobLocationRemote: "Remote",
     jobVacantsPlaceholder: "Number of positions",
     jobVacantsPlaceholder: "Number of positions",
     jobSalaryPlaceholder: "Salary in ECO for 1 dedicated hour",
     jobSalaryPlaceholder: "Salary in ECO for 1 dedicated hour",
-    jobImage: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    jobImage: "Upload media (max-size: 50MB)",
     jobTasks: "Tasks",
     jobTasks: "Tasks",
     jobType: "Job Type",
     jobType: "Job Type",
     jobTime: "Job Time",
     jobTime: "Job Time",
@@ -2315,7 +2500,7 @@ module.exports = {
     projectRecentTitle: "Recent Projects",
     projectRecentTitle: "Recent Projects",
     projectTopTitle: "Top Funded",
     projectTopTitle: "Top Funded",
     projectTitlePlaceholder: "Project name",
     projectTitlePlaceholder: "Project name",
-    projectImage: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    projectImage: "Upload media (max-size: 50MB)",
     projectDescription: "Description",
     projectDescription: "Description",
     projectDescriptionPlaceholder: "Tell the story and goals…",
     projectDescriptionPlaceholder: "Tell the story and goals…",
     projectGoal: "Goal (ECO)",
     projectGoal: "Goal (ECO)",
@@ -2478,8 +2663,31 @@ module.exports = {
     modulesBankingLabel: "Banking",
     modulesBankingLabel: "Banking",
     modulesBankingDescription: "Module to determine the real value of ECOIN and distribute a UBI using the common treasury.",
     modulesBankingDescription: "Module to determine the real value of ECOIN and distribute a UBI using the common treasury.",
     modulesFavoritesLabel: "Favorites",
     modulesFavoritesLabel: "Favorites",
-    modulesFavoritesDescription: "Module to manage your favorite content."
-     
+    modulesFavoritesDescription: "Module to manage your favorite content.",
+    fileTooLargeTitle: "File too large",
+    fileTooLargeMessage: "The file exceeds the maximum allowed size (50 MB). Please select a smaller file.",
+    goBack: "Go back",
+    directConnect: "Direct Connect",
+    directConnectDescription: "Connect directly to a peer by entering their IP address, port and public key. The peer will be added as a followed connection.",
+    peerHost: "IP / Hostname",
+    peerPort: "Port (default: 8008)",
+    peerPublicKey: "Public Key (@...ed25519)",
+    connectAndFollow: "Connect",
+    deviceSourceLabel: "Device source",
+    modulesPresetTitle: "Common Configurations",
+    modulesPreset_minimal: "Minimal",
+    modulesPreset_basic: "Basic",
+    modulesPreset_social: "Social",
+    modulesPreset_economy: "Economy",
+    modulesPreset_full: "Full",
+    statsCarbonFootprintTitle: "Carbon Footprint",
+    statsCarbonFootprintNetwork: "Network carbon footprint",
+    statsCarbonFootprintYours: "Your carbon footprint",
+    statsCarbonTombstone: "Tombstoning footprint",
+    feedSuccessMsg: "Feed published successfully!",
+    dominantOpinionLabel: "Dominant opinion",
+    uploadMedia: "Upload media (max-size: 50MB)"
+
      //END
      //END
     }
     }
 };
 };

+ 219 - 11
src/client/assets/translations/oasis_es.js

@@ -198,6 +198,8 @@ module.exports = {
     mentionsRelationship: "Relación",
     mentionsRelationship: "Relación",
     // settings
     // settings
     updateit: "OBTENER ACTUALIZACIONES!",
     updateit: "OBTENER ACTUALIZACIONES!",
+    updateBannerText: "Hay una nueva versión de Oasis disponible.",
+    updateBannerAction: "Actualizar ahora →",
     info: "Info",
     info: "Info",
     settingsIntro: ({ version }) => [
     settingsIntro: ({ version }) => [
       `[SNH] ꖒ OASIS [ v.${version} ]`,
       `[SNH] ꖒ OASIS [ v.${version} ]`,
@@ -371,6 +373,7 @@ module.exports = {
     videoLabel: "VIDEOS",
     videoLabel: "VIDEOS",
     audioLabel: "AUDIOS",
     audioLabel: "AUDIOS",
     documentLabel: "DOCUMENTOS",
     documentLabel: "DOCUMENTOS",
+    pdfFallbackLabel: "Documento PDF",
     eventLabel: "EVENTOS",
     eventLabel: "EVENTOS",
     taskLabel: "TAREAS",
     taskLabel: "TAREAS",
     transferLabel: "TRANSFERENCIAS",
     transferLabel: "TRANSFERENCIAS",
@@ -385,7 +388,7 @@ module.exports = {
     editProfileDescription:
     editProfileDescription:
       "",
       "",
     profileName: "Nombre",
     profileName: "Nombre",
-    profileImage: "Imágen Avatar (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    profileImage: "Imágen Avatar",
     profileDescription: "Descripción",
     profileDescription: "Descripción",
     hashtagDescription:
     hashtagDescription:
       "Posts de habitantes de tu red que referencian éste #hashtag, ordenados por el más reciente.",
       "Posts de habitantes de tu red que referencian éste #hashtag, ordenados por el más reciente.",
@@ -673,6 +676,7 @@ module.exports = {
     blockedLabel:        "Usuario Bloqueado",
     blockedLabel:        "Usuario Bloqueado",
     inhabitantviewDetails: "Ver Detalles",
     inhabitantviewDetails: "Ver Detalles",
     viewDetails: "Ver Detalles",
     viewDetails: "Ver Detalles",
+    keepReading: "Seguir leyendo...",
     oasisId: "ID",
     oasisId: "ID",
     noInhabitantsFound:    "No se encontraron habitantes, aún.",
     noInhabitantsFound:    "No se encontraron habitantes, aún.",
     inhabitantActivityLevel: "Nivel Actividad",
     inhabitantActivityLevel: "Nivel Actividad",
@@ -1269,7 +1273,7 @@ module.exports = {
     // blog/post,
     // blog/post,
     blogSubject: "Asunto",
     blogSubject: "Asunto",
     blogMessage: "Mensaje",
     blogMessage: "Mensaje",
-    blogImage: "Subir Imagen (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
+    blogImage: "Subir contenido multimedia (max: 50MB)",
     blogPublish: "Vista previa",
     blogPublish: "Vista previa",
     noPopularMessages: "No se han publicado mensajes populares, aún",
     noPopularMessages: "No se han publicado mensajes populares, aún",
     // forum
     // forum
@@ -1368,6 +1372,7 @@ module.exports = {
     TOPButton:        "Feeds Principales",
     TOPButton:        "Feeds Principales",
     CREATEButton:     "Crear Feed",
     CREATEButton:     "Crear Feed",
     totalOpinions:    "Total de Opiniones",
     totalOpinions:    "Total de Opiniones",
+    moreVoted:        "Más Votado",
     alreadyVoted:     "Ya has opinado.",
     alreadyVoted:     "Ya has opinado.",
     noFeedsFound:     "No se encontraron feeds.",
     noFeedsFound:     "No se encontraron feeds.",
     author:           "Por",
     author:           "Por",
@@ -1577,7 +1582,7 @@ module.exports = {
     reportsUpdateButton: "Actualizar",
     reportsUpdateButton: "Actualizar",
     reportsDeleteButton: "Eliminar",
     reportsDeleteButton: "Eliminar",
     reportsDateLabel: "Fecha",
     reportsDateLabel: "Fecha",
-    reportsUploadFile: "Subir Imagen (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
+    reportsUploadFile: "Subir contenido multimedia (max: 50MB)",
     reportsCreatedBy: "Por",
     reportsCreatedBy: "Por",
     reportsMineSectionTitle: "Tus Informes",
     reportsMineSectionTitle: "Tus Informes",
     reportsFeaturesSectionTitle: "Solicitudes de Funciones",
     reportsFeaturesSectionTitle: "Solicitudes de Funciones",
@@ -1650,7 +1655,6 @@ module.exports = {
     reportsWhyInappropriatePlaceholder: 'Explica el motivo y el impacto.',
     reportsWhyInappropriatePlaceholder: 'Explica el motivo y el impacto.',
     reportsRequestedActionLabel: 'Acción solicitada',
     reportsRequestedActionLabel: 'Acción solicitada',
     reportsRequestedActionPlaceholder: 'Eliminar, ocultar, marcar, advertir, etc.',
     reportsRequestedActionPlaceholder: 'Eliminar, ocultar, marcar, advertir, etc.',
-    //tribes
     tribesTitle: "Tribus",
     tribesTitle: "Tribus",
     tribeAllSectionTitle: "Tribus",
     tribeAllSectionTitle: "Tribus",
     tribeMineSectionTitle: "Tus Tribus",
     tribeMineSectionTitle: "Tus Tribus",
@@ -1668,11 +1672,13 @@ module.exports = {
     tribeFilterRecent: "RECIENTES",
     tribeFilterRecent: "RECIENTES",
     tribeFilterLarp: "L.A.R.P.",
     tribeFilterLarp: "L.A.R.P.",
     tribeFilterTop: "TOP",
     tribeFilterTop: "TOP",
+    tribeFilterSubtribes: "SUB-TRIBUS",
     tribeFilterGallery: "GALERÍA",
     tribeFilterGallery: "GALERÍA",
+    tribeMainTribeLabel: "TRIBU PRINCIPAL",
     tribeCreateButton: "Crear Tribu",
     tribeCreateButton: "Crear Tribu",
     tribeUpdateButton: "Actualizar",
     tribeUpdateButton: "Actualizar",
     tribeDeleteButton: "Eliminar",
     tribeDeleteButton: "Eliminar",
-    tribeImageLabel: "Imagen de la Tribu (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
+    tribeImageLabel: "Subir contenido multimedia (max: 50MB)",
     tribeTitleLabel: "Título",
     tribeTitleLabel: "Título",
     searchTribesPlaceholder:  "FILTRAR tribus POR NOMBRE …",
     searchTribesPlaceholder:  "FILTRAR tribus POR NOMBRE …",
     tribeTitlePlaceholder: "Nombre de la tribu",
     tribeTitlePlaceholder: "Nombre de la tribu",
@@ -1699,6 +1705,7 @@ module.exports = {
     tribeGenerateInvite: "GENERAR CÓDIGO",
     tribeGenerateInvite: "GENERAR CÓDIGO",
     tribeCreatedAt: "Creado el",
     tribeCreatedAt: "Creado el",
     tribeAuthor: "Por",
     tribeAuthor: "Por",
+    tribeAuthorLabel: "AUTOR",
     tribeStrict: "Estricto",
     tribeStrict: "Estricto",
     tribeOpen: "Abierta",
     tribeOpen: "Abierta",
     tribeFeedFilterRECENT: "RECIENTES",
     tribeFeedFilterRECENT: "RECIENTES",
@@ -1711,7 +1718,178 @@ module.exports = {
     tribeFeedSend: "Enviar",
     tribeFeedSend: "Enviar",
     tribeFeedEmpty: "No hay mensajes de feed disponibles, aún.",
     tribeFeedEmpty: "No hay mensajes de feed disponibles, aún.",
     noTribes: "No se encontraron tribus, aún.",
     noTribes: "No se encontraron tribus, aún.",
-    //agenda
+    tribeNotFound: "Tribu no encontrada!",
+    createTribeTitle: "Crear Tribu",
+    updateTribeTitle: "Actualizar Tribu",
+    tribeSectionOverview: "Resumen",
+    tribeSectionInhabitants: "Habitantes",
+    tribeSectionVotations: "Votaciones",
+    tribeSectionEvents: "Eventos",
+    tribeSectionReports: "Reportes",
+    tribeSectionTasks: "Tareas",
+    tribeSectionFeed: "Feed",
+    tribeSectionForum: "Foro",
+    tribeSectionMarket: "Mercado",
+    tribeSectionJobs: "Empleos",
+    tribeSectionProjects: "Proyectos",
+    tribeSectionMedia: "Media",
+    tribeSectionImages: "IMÁGENES",
+    tribeSectionAudios: "AUDIOS",
+    tribeSectionVideos: "VÍDEOS",
+    tribeSectionDocuments: "DOCUMENTOS",
+    tribeSectionBookmarks: "MARCADORES",
+    tribeInhabitantsEmpty: "No hay habitantes en esta tribu, aún.",
+    tribeEventCreate: "Crear Evento",
+    tribeEventsEmpty: "No hay eventos, aún.",
+    tribeEventTitle: "Título",
+    tribeEventDescription: "Descripción",
+    tribeEventDate: "Fecha",
+    tribeEventLocation: "Ubicación",
+    tribeEventAttend: "ASISTIR",
+    tribeEventUnattend: "NO ASISTIR",
+    tribeEventAttendees: "Asistentes",
+    tribeTaskCreate: "Crear Tarea",
+    tribeTasksEmpty: "No hay tareas, aún.",
+    tribeTaskTitle: "Título",
+    tribeTaskDescription: "Descripción",
+    tribeTaskPriority: "Prioridad",
+    tribeTaskDeadline: "Fecha límite",
+    tribeTaskAssignees: "Asignados",
+    tribeTaskStatusInProgress: "EN PROGRESO",
+    tribeTaskStatusClosed: "CERRAR",
+    tribeTaskAssign: "ASIGNAR",
+    tribeTaskUnassign: "DESASIGNAR",
+    tribeReportCreate: "Crear Reporte",
+    tribeReportsEmpty: "No hay reportes, aún.",
+    tribeReportTitle: "Título",
+    tribeReportDescription: "Descripción",
+    tribeReportCategory: "Categoría",
+    tribeVotationCreate: "Crear Votación",
+    tribeVotationsEmpty: "No hay votaciones, aún.",
+    tribeVotationTitle: "Título",
+    tribeVotationDescription: "Descripción",
+    tribeVotationOptions: "Opciones",
+    tribeVotationDeadline: "Fecha límite",
+    tribeVotationVote: "VOTAR",
+    tribeVotationResults: "Votos",
+    tribeVotationClose: "CERRAR VOTACIÓN",
+    tribeVotationOptionPlaceholder: "Opción",
+    tribeForumCreate: "Crear Forum",
+    tribeForumEmpty: "No hay hilos, aún.",
+    tribeForumTitle: "Título",
+    tribeForumText: "Mensaje",
+    tribeForumCategory: "Categoría",
+    tribeForumReply: "Responder",
+    tribeForumReplies: "Respuestas",
+    tribeMarketCreate: "Crear Anuncio",
+    tribeMarketEmpty: "No hay anuncios, aún.",
+    tribeMarketTitle: "Título",
+    tribeMarketDescription: "Descripción",
+    tribeMarketPrice: "Precio",
+    tribeMarketImage: "Imagen",
+    tribeMarketCategory: "Categoría",
+    tribeJobCreate: "Crear Empleo",
+    tribeJobsEmpty: "No hay empleos, aún.",
+    tribeJobTitle: "Título",
+    tribeJobDescription: "Descripción",
+    tribeJobLocation: "Ubicación",
+    tribeJobSalary: "Salario",
+    tribeJobDeadline: "Fecha límite",
+    tribeProjectCreate: "Crear Proyecto",
+    tribeProjectsEmpty: "No hay proyectos, aún.",
+    tribeProjectTitle: "Título",
+    tribeProjectDescription: "Descripción",
+    tribeProjectGoal: "Objetivo",
+    tribeProjectFunded: "Financiado",
+    tribeProjectDeadline: "Fecha límite",
+    tribeMediaUpload: "Subir Media",
+    readDocument: "Leer Documento",
+    tribeCreateImage: "Crear Imagen",
+    tribeCreateAudio: "Crear Audio",
+    tribeCreateVideo: "Crear Video",
+    tribeCreateDocument: "Crear Documento",
+    tribeCreateBookmark: "Crear Marcador",
+    tribeMediaEmpty: "No hay media, aún.",
+    tribeMediaTitle: "Título",
+    tribeMediaDescription: "Descripción",
+    tribeMediaType: "Tipo",
+    tribeMediaTypeImage: "Imagen",
+    tribeMediaTypeVideo: "Video",
+    tribeMediaTypeAudio: "Audio",
+    tribeMediaTypeDocument: "Documento",
+    tribeMediaTypeBookmark: "Marcador",
+    tribeContentDelete: "ELIMINAR",
+    tribeInviteCodeText: "Código de invitación: ",
+    tribeGroupTribe: "Tribu",
+    tribeGroupOffice: "Oficina",
+    tribeGroupNetwork: "Red",
+    tribeGroupEconomy: "Economía",
+    tribeGroupMedia: "Media",
+    tribeStatusOpen: "ABIERTO",
+    tribeStatusClosed: "CERRADO",
+    tribeStatusInProgress: "EN PROGRESO",
+    tribePriorityLow: "BAJA",
+    tribePriorityMedium: "MEDIA",
+    tribePriorityHigh: "ALTA",
+    tribePriorityCritical: "CRÍTICA",
+    tribeTaskFilterAll: "TODOS",
+    tribeMediaFilterAll: "TODOS",
+    tribeReportCatBug: "ERROR",
+    tribeReportCatAbuse: "ABUSO",
+    tribeReportCatContent: "CONTENIDO",
+    tribeReportCatOther: "OTRO",
+    tribeForumCatGeneral: "GENERAL",
+    tribeForumCatProposal: "PROPUESTA",
+    tribeForumCatQuestion: "PREGUNTA",
+    tribeForumCatAnnouncement: "ANUNCIO",
+    tribeMarketCatGoods: "BIENES",
+    tribeMarketCatServices: "SERVICIOS",
+    tribeMarketCatFood: "ALIMENTACIÓN",
+    tribeMarketCatOther: "OTRO",
+    tribeStatusLabel: "Estado",
+    tribeSubTribes: "SUB-TRIBUS",
+    tribeSubTribesCreate: "Crear Sub-Tribu",
+    tribeSubTribesEmpty: "No se han creado sub-tribus, aún.",
+    tribeLarpCreateForbidden: "No se pueden crear tribus L.A.R.P.",
+    tribeLarpUpdateForbidden: "No se pueden actualizar tribus L.A.R.P.",
+    tribeActivityJoined: "UNIDO",
+    tribeActivityLeft: "SALIDO",
+    tribeActivityFeed: "FEED",
+    tribeActivityRefeed: "REFEED",
+    tribeGroupAnalytics: "Analíticas",
+    tribeGroupCreative: "Creativo",
+    tribeSectionActivity: "ACTIVIDAD",
+    tribeSectionTrending: "TENDENCIAS",
+    tribeSectionOpinions: "OPINIONES",
+    tribeSectionPixelia: "PIXELIA",
+    tribeSectionTags: "ETIQUETAS",
+    tribeSectionSearch: "BUSCAR",
+    tribeActivityEmpty: "Sin actividad aún.",
+    tribeActivityCreated: "creó",
+    tribeActivityPosted: "publicó",
+    tribeActivityReplied: "respondió",
+    tribeTrendingEmpty: "Sin contenido en tendencia aún.",
+    tribeTrendingPeriodDay: "Hoy",
+    tribeTrendingPeriodWeek: "Esta Semana",
+    tribeTrendingPeriodAll: "Todo",
+    tribeTrendingEngagement: "interacción",
+    tribeOpinionsEmpty: "Sin opiniones aún.",
+    tribeOpinionsCast: "Votar",
+    tribeOpinionsRankings: "Rankings",
+    tribeOpinionsAlreadyVoted: "Ya has votado",
+    tribeTopCategory: "Más votado",
+    tribePixeliaTitle: "Pixelia",
+    tribePixeliaDescription: "Lienzo colaborativo de pixel art.",
+    tribePixeliaPaint: "Pintar",
+    tribePixeliaContributors: "contribuidores",
+    tribePixeliaTotalPixels: "píxeles pintados",
+    tribeTagsEmpty: "No se encontraron etiquetas.",
+    tribeTagsCloud: "Nube de Etiquetas",
+    tribeTagsContentWith: "Contenido con etiqueta",
+    tribeSearchPlaceholder: "Buscar contenido de la tribu...",
+    tribeSearchEmpty: "Sin resultados.",
+    tribeSearchResults: "Resultados",
+    tribeSearchMinChars: "Introduce al menos 2 caracteres para buscar.",
     agendaTitle: "Agenda",
     agendaTitle: "Agenda",
     agendaDescription: "Aquí puedes encontrar todos tus elementos asignados.",
     agendaDescription: "Aquí puedes encontrar todos tus elementos asignados.",
     agendaFilterAll: "TODOS",
     agendaFilterAll: "TODOS",
@@ -1871,6 +2049,9 @@ module.exports = {
     pmCreateButton: "Escribir MP",
     pmCreateButton: "Escribir MP",
     noPrivateMessages: "No hay mensajes privados.",
     noPrivateMessages: "No hay mensajes privados.",
     pmReply: "Responder",
     pmReply: "Responder",
+    pmReplies: "respuestas",
+    pmNew: "nuevas",
+    pmMarkRead: "Marcar como leido",
     inReplyTo: "EN RESPUESTA A",
     inReplyTo: "EN RESPUESTA A",
     pmPreview: "Previsualizar",
     pmPreview: "Previsualizar",
     pmPreviewTitle: "Vista previa",
     pmPreviewTitle: "Vista previa",
@@ -1879,6 +2060,8 @@ module.exports = {
     pmToLabel: "Para:",
     pmToLabel: "Para:",
     pmInvalidMessage: "Mensaje no válido",
     pmInvalidMessage: "Mensaje no válido",
     pmNoSubject: "(sin asunto)",
     pmNoSubject: "(sin asunto)",
+    pmSubjectLabel: "Asunto:",
+    pmBodyLabel: "Cuerpo",
     pmBotJobs: "42-JobsBOT",
     pmBotJobs: "42-JobsBOT",
     pmBotProjects: "42-ProjectsBOT",
     pmBotProjects: "42-ProjectsBOT",
     pmBotMarket: "42-MarketBOT",
     pmBotMarket: "42-MarketBOT",
@@ -1907,6 +2090,8 @@ module.exports = {
     blockchainBlockURL: 'URL:',
     blockchainBlockURL: 'URL:',
     blockchainContent: 'Bloque',
     blockchainContent: 'Bloque',
     blockchainContentPreview: 'Vista previa del contenido del bloque',
     blockchainContentPreview: 'Vista previa del contenido del bloque',
+    blockchainLatestDatagram: 'Último Datagrama',
+    blockchainDatagram: 'Datagrama',
     blockchainDetails: 'Ver detalles del bloque',
     blockchainDetails: 'Ver detalles del bloque',
     blockchainBlockInfo: 'Información del Bloque',
     blockchainBlockInfo: 'Información del Bloque',
     blockchainBlockDetails: 'Detalles del bloque seleccionado',
     blockchainBlockDetails: 'Detalles del bloque seleccionado',
@@ -2184,7 +2369,7 @@ module.exports = {
     marketItemSeller: "Vendedor",
     marketItemSeller: "Vendedor",
     marketNoItems: "Aún no hay artículos disponibles.",
     marketNoItems: "Aún no hay artículos disponibles.",
     marketYourBid: "Tu puja",
     marketYourBid: "Tu puja",
-    marketCreateFormImageLabel: "Subir imagen (jpeg, jpg, png, gif) (tamaño máx.: 500px x 400px)",
+    marketCreateFormImageLabel: "Subir contenido multimedia (max: 50MB)",
     marketSearchLabel: "Buscar",
     marketSearchLabel: "Buscar",
     marketSearchPlaceholder: "Buscar por título o etiquetas",
     marketSearchPlaceholder: "Buscar por título o etiquetas",
     marketMinPriceLabel: "Precio mínimo",
     marketMinPriceLabel: "Precio mínimo",
@@ -2245,7 +2430,7 @@ module.exports = {
     jobLocationRemote: "Remoto",
     jobLocationRemote: "Remoto",
     jobVacantsPlaceholder: "Número de vacantes",
     jobVacantsPlaceholder: "Número de vacantes",
     jobSalaryPlaceholder: "Salario en ECO por 1 hora dedicada",
     jobSalaryPlaceholder: "Salario en ECO por 1 hora dedicada",
-    jobImage: "Subir Imagen (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
+    jobImage: "Subir contenido multimedia (max: 50MB)",
     jobTasks: "Tareas",
     jobTasks: "Tareas",
     jobType: "Tipo de Trabajo",
     jobType: "Tipo de Trabajo",
     jobTime: "Tiempo de Trabajo",
     jobTime: "Tiempo de Trabajo",
@@ -2314,7 +2499,7 @@ module.exports = {
     projectRecentTitle: "Proyectos Recientes",
     projectRecentTitle: "Proyectos Recientes",
     projectTopTitle: "Mejor Financiados",
     projectTopTitle: "Mejor Financiados",
     projectTitlePlaceholder: "Nombre del proyecto",
     projectTitlePlaceholder: "Nombre del proyecto",
-    projectImage: "Subir imagen (jpeg, jpg, png, gif) (máx-tamaño: 500px x 400px)",
+    projectImage: "Subir contenido multimedia (max: 50MB)",
     projectDescription: "Descripción",
     projectDescription: "Descripción",
     projectDescriptionPlaceholder: "Cuenta la historia y los objetivos…",
     projectDescriptionPlaceholder: "Cuenta la historia y los objetivos…",
     projectGoal: "Meta (ECO)",
     projectGoal: "Meta (ECO)",
@@ -2477,8 +2662,31 @@ module.exports = {
     modulesBankingLabel: "Banca",
     modulesBankingLabel: "Banca",
     modulesBankingDescription: "Módulo para conocer el valor real de ECOIN y distribuir una RBU utilizando la tesorería común.",
     modulesBankingDescription: "Módulo para conocer el valor real de ECOIN y distribuir una RBU utilizando la tesorería común.",
     modulesFavoritesLabel: "Favoritos",
     modulesFavoritesLabel: "Favoritos",
-    modulesFavoritesDescription: "Módulo para gestionar tu contenido favorito."
-     
+    modulesFavoritesDescription: "Módulo para gestionar tu contenido favorito.",
+    fileTooLargeTitle: "Archivo demasiado grande",
+    fileTooLargeMessage: "El archivo supera el tamaño máximo permitido (50 MB). Por favor, selecciona un archivo más pequeño.",
+    goBack: "Volver",
+    directConnect: "Conexión Directa",
+    directConnectDescription: "Conéctate directamente a un nodo introduciendo su dirección IP, puerto y clave pública. El nodo se añadirá como conexión seguida.",
+    peerHost: "IP / Hostname",
+    peerPort: "Puerto (por defecto: 8008)",
+    peerPublicKey: "Clave Pública (@...ed25519)",
+    connectAndFollow: "Conectar",
+    deviceSourceLabel: "Dispositivo",
+    modulesPresetTitle: "Configuraciones Comunes",
+    modulesPreset_minimal: "Mínimo",
+    modulesPreset_basic: "Básico",
+    modulesPreset_social: "Social",
+    modulesPreset_economy: "Economía",
+    modulesPreset_full: "Completo",
+    statsCarbonFootprintTitle: "Huella de Carbono",
+    statsCarbonFootprintNetwork: "Huella de carbono de la red",
+    statsCarbonFootprintYours: "Tu huella de carbono",
+    statsCarbonTombstone: "Huella del tombstoning",
+    feedSuccessMsg: "¡Feed publicado correctamente!",
+    dominantOpinionLabel: "Opinión predominante",
+    uploadMedia: "Subir contenido multimedia (max: 50MB)"
+
      //END
      //END
     }
     }
 };
 };

+ 241 - 11
src/client/assets/translations/oasis_eu.js

@@ -79,6 +79,7 @@ module.exports = {
     invalidCoordinate: 'Koordenatu okerrak',
     invalidCoordinate: 'Koordenatu okerrak',
     goToMuralButton: "Ikusi Murala",
     goToMuralButton: "Ikusi Murala",
     totalPixels: 'Pixelak guztira',
     totalPixels: 'Pixelak guztira',
+    pixeliaBy: "egilea",
     // modules
     // modules
     modules: "Moduluak",
     modules: "Moduluak",
     modulesViewTitle: "Moduluak",
     modulesViewTitle: "Moduluak",
@@ -198,6 +199,8 @@ module.exports = {
     mentionsRelationship: "Erlazioa",
     mentionsRelationship: "Erlazioa",
     // settings
     // settings
     updateit: "LORTU EGUNERAKETAK!",
     updateit: "LORTU EGUNERAKETAK!",
+    updateBannerText: "Oasis-en bertsio berri bat eskuragarri dago.",
+    updateBannerAction: "Eguneratu orain →",
     info: "Infoa",
     info: "Infoa",
     settingsIntro: ({ version }) => [
     settingsIntro: ({ version }) => [
       `[SNH] ꖒ OASIS [ v.${version} ]`,
       `[SNH] ꖒ OASIS [ v.${version} ]`,
@@ -372,6 +375,7 @@ module.exports = {
     videoLabel: "BIDEOAK",
     videoLabel: "BIDEOAK",
     audioLabel: "AUDIOAK",
     audioLabel: "AUDIOAK",
     documentLabel: "DOKUMENTUAK",
     documentLabel: "DOKUMENTUAK",
+    pdfFallbackLabel: "PDF Dokumentua",
     eventLabel: "EKITALDIAK",
     eventLabel: "EKITALDIAK",
     taskLabel: "ATAZAK",
     taskLabel: "ATAZAK",
     transferLabel: "TRANSFERENTZIAK",
     transferLabel: "TRANSFERENTZIAK",
@@ -386,7 +390,7 @@ module.exports = {
     editProfileDescription:
     editProfileDescription:
       "",
       "",
     profileName: "Izena",
     profileName: "Izena",
-    profileImage: "Abatarraren irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    profileImage: "Avatar Irudia",
     profileDescription: "Deskribapena",
     profileDescription: "Deskribapena",
     hashtagDescription:
     hashtagDescription:
       "Zure sareko bizilagunen bidalketak non #traola hau aipatzen den, gaurkotasunaren arabera antilatuta.",
       "Zure sareko bizilagunen bidalketak non #traola hau aipatzen den, gaurkotasunaren arabera antilatuta.",
@@ -674,6 +678,7 @@ module.exports = {
     blockedLabel:        "Blokeatutako Erabiltzailea",
     blockedLabel:        "Blokeatutako Erabiltzailea",
     inhabitantviewDetails: "Ikusi Xehetasunak",
     inhabitantviewDetails: "Ikusi Xehetasunak",
     viewDetails: "Ikusi Xehetasunak",
     viewDetails: "Ikusi Xehetasunak",
+    keepReading: "Irakurtzen jarraitu...",
     oasisId: "ID-a",
     oasisId: "ID-a",
     noInhabitantsFound:  "Bizilagunik ez, oraindik.",
     noInhabitantsFound:  "Bizilagunik ez, oraindik.",
     inhabitantActivityLevel: "Jarduera Maila",
     inhabitantActivityLevel: "Jarduera Maila",
@@ -842,6 +847,10 @@ module.exports = {
     courtsJudgeId: "Epailea",
     courtsJudgeId: "Epailea",
     courtsJudgeIdPh: "Oasis ID (@...) edo Biztanle izena",
     courtsJudgeIdPh: "Oasis ID (@...) edo Biztanle izena",
     courtsNominateBtn: "Proposatu",
     courtsNominateBtn: "Proposatu",
+    courtsJudge: "Epailea",
+    courtsEvidenceFileLabel: "Froga fitxategia (irudia, audioa, bideoa edo PDF)",
+    courtsCaseMediators: "Bitartekariak",
+    courtsMediatorsLabel: "Bitartekariak",
     courtsAddEvidence: "Frogak gehitu",
     courtsAddEvidence: "Frogak gehitu",
     courtsEvidenceText: "Testua",
     courtsEvidenceText: "Testua",
     courtsEvidenceLink: "Esteka",
     courtsEvidenceLink: "Esteka",
@@ -853,6 +862,8 @@ module.exports = {
     courtsStanceDENY: "Ukatu",
     courtsStanceDENY: "Ukatu",
     courtsStanceADMIT: "Onartu",
     courtsStanceADMIT: "Onartu",
     courtsStancePARTIAL: "Partziala",
     courtsStancePARTIAL: "Partziala",
+    courtsStanceCOUNTERCLAIM: "Kontraerreklamazioa",
+    courtsStanceNEUTRAL: "Neutrala",
     courtsVerdictTitle: "Ebazpena eman",
     courtsVerdictTitle: "Ebazpena eman",
     courtsVerdictResult: "Emaitza",
     courtsVerdictResult: "Emaitza",
     courtsVerdictOrders: "Aginduak",
     courtsVerdictOrders: "Aginduak",
@@ -860,6 +871,8 @@ module.exports = {
     courtsIssueVerdict: "Ebazpena eman",
     courtsIssueVerdict: "Ebazpena eman",
     courtsMediationPropose: "Akordioa proposatu",
     courtsMediationPropose: "Akordioa proposatu",
     courtsSettlementText: "Baldintzak",
     courtsSettlementText: "Baldintzak",
+    courtsSettlementAccepted: "Onartua",
+    courtsSettlementPending: "Zain",
     courtsSettlementProposeBtn: "Proposatu",
     courtsSettlementProposeBtn: "Proposatu",
     courtsNominationsTitle: "Epailetzako izendapenak",
     courtsNominationsTitle: "Epailetzako izendapenak",
     courtsThJudge: "Epailea",
     courtsThJudge: "Epailea",
@@ -896,6 +909,13 @@ module.exports = {
     courtsMethodPOPULAR: "Herritarren bozketa",
     courtsMethodPOPULAR: "Herritarren bozketa",
     courtsMethodMEDIATION: "Bitartekaritza",
     courtsMethodMEDIATION: "Bitartekaritza",
     courtsMethodKARMATOCRACY: "Karmatokrazia",
     courtsMethodKARMATOCRACY: "Karmatokrazia",
+    courtsMethodJUDGES: "Epaile-mahaia",
+    courtsMethodSINGLE_JUDGE: "Epaile bakarra",
+    courtsMethodJURY: "Epaimahaia",
+    courtsMethodCOUNCIL: "Kontseilua",
+    courtsMethodCOMMUNITY: "Komunitatea",
+    courtsMethodARBITRATION: "Arbitrajea",
+    courtsMethodVOTE: "Komunitate-bozketa",
     courtsMethod: "Metodoa",
     courtsMethod: "Metodoa",
     courtsRulesTitle: "Nola funtzionatzen duten Auzitegiek",
     courtsRulesTitle: "Nola funtzionatzen duten Auzitegiek",
     courtsRulesIntro: "Auzitegiak komunitateak kudeatutako prozesuak dira, gatazkak konpontzeko eta justizia zuzentzailea sustatzeko. Elkarrizketa, froga argiak eta neurri proportzionalak lehenesten dira.",
     courtsRulesIntro: "Auzitegiak komunitateak kudeatutako prozesuak dira, gatazkak konpontzeko eta justizia zuzentzailea sustatzeko. Elkarrizketa, froga argiak eta neurri proportzionalak lehenesten dira.",
@@ -1270,7 +1290,7 @@ module.exports = {
     // blog/post,
     // blog/post,
     blogSubject: "Gaia",
     blogSubject: "Gaia",
     blogMessage: "Mezua",
     blogMessage: "Mezua",
-    blogImage: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    blogImage: "Multimedia igo (max: 50MB)",
     blogPublish: "Aurrebista",
     blogPublish: "Aurrebista",
     noPopularMessages: "Pil-pileko mezurike ez, oraindik",
     noPopularMessages: "Pil-pileko mezurike ez, oraindik",
     // forum
     // forum
@@ -1369,6 +1389,7 @@ module.exports = {
     TOPButton:        "Jario Gorenak",
     TOPButton:        "Jario Gorenak",
     CREATEButton:     "Sortu Jarioa",
     CREATEButton:     "Sortu Jarioa",
     totalOpinions:    "Iritziak Guztira",
     totalOpinions:    "Iritziak Guztira",
+    moreVoted:        "Gehien bozkatu",
     alreadyVoted:     "Bozkatu duzu jadanik",
     alreadyVoted:     "Bozkatu duzu jadanik",
     noFeedsFound:     "Ez da jariorik aurkitu.",
     noFeedsFound:     "Ez da jariorik aurkitu.",
     author:           "Nork",
     author:           "Nork",
@@ -1531,7 +1552,7 @@ module.exports = {
     reportsUpdateButton: "Eguneratu",
     reportsUpdateButton: "Eguneratu",
     reportsDeleteButton: "Ezabatu",
     reportsDeleteButton: "Ezabatu",
     reportsDateLabel: "Data",
     reportsDateLabel: "Data",
-    reportsUploadFile: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    reportsUploadFile: "Multimedia igo (max: 50MB)",
     reportsCreatedBy: "Nork",
     reportsCreatedBy: "Nork",
     reportsMineSectionTitle: "Zeure Txostenak",
     reportsMineSectionTitle: "Zeure Txostenak",
     reportsFeaturesSectionTitle: "Gaitasuna Eskatu",
     reportsFeaturesSectionTitle: "Gaitasuna Eskatu",
@@ -1545,7 +1566,7 @@ module.exports = {
     reportsValidationCategory: "Aukeratu kategoria, mesedez.",
     reportsValidationCategory: "Aukeratu kategoria, mesedez.",
     reportsCreatedAt: "Noiz",
     reportsCreatedAt: "Noiz",
     reportsCreatedBy: "Nork",  
     reportsCreatedBy: "Nork",  
-    reportsSeverityi: "Larritasuna",
+    reportsSeverity: "Larritasuna",
     reportsSeverityLow: "Baxua",
     reportsSeverityLow: "Baxua",
     reportsSeverityMedium: "Ertaina",
     reportsSeverityMedium: "Ertaina",
     reportsSeverityHigh: "Altua",
     reportsSeverityHigh: "Altua",
@@ -1604,7 +1625,6 @@ module.exports = {
     reportsWhyInappropriatePlaceholder: 'Azaldu arrazoia eta eragina.',
     reportsWhyInappropriatePlaceholder: 'Azaldu arrazoia eta eragina.',
     reportsRequestedActionLabel: 'Eskatutako ekintza',
     reportsRequestedActionLabel: 'Eskatutako ekintza',
     reportsRequestedActionPlaceholder: 'Kendu, ezkutatu, etiketatu, ohartarazi, etab.',
     reportsRequestedActionPlaceholder: 'Kendu, ezkutatu, etiketatu, ohartarazi, etab.',
-    //tribes
     tribesTitle: "Tribuak",
     tribesTitle: "Tribuak",
     tribeAllSectionTitle: "Tribuak",
     tribeAllSectionTitle: "Tribuak",
     tribeMineSectionTitle: "Zeure Tribuak",
     tribeMineSectionTitle: "Zeure Tribuak",
@@ -1615,6 +1635,7 @@ module.exports = {
     tribeLarpSectionTitle: "Tribuen Galeria",
     tribeLarpSectionTitle: "Tribuen Galeria",
     tribeRecentSectionTitle: "Tribu Berriak",
     tribeRecentSectionTitle: "Tribu Berriak",
     tribeTopSectionTitle: "Tribu Gorenak",
     tribeTopSectionTitle: "Tribu Gorenak",
+    tribeviewTribeButton: "Tribua Bisitatu",
     tribeDescription: "Aurkitu edo sortu tribuak zure sarean.",
     tribeDescription: "Aurkitu edo sortu tribuak zure sarean.",
     tribeFilterAll: "GUZTIAK",
     tribeFilterAll: "GUZTIAK",
     tribeFilterMine: "NEUREAK",
     tribeFilterMine: "NEUREAK",
@@ -1622,11 +1643,13 @@ module.exports = {
     tribeFilterRecent: "BERRIAK",
     tribeFilterRecent: "BERRIAK",
     tribeFilterLarp: "L.A.R.P.",
     tribeFilterLarp: "L.A.R.P.",
     tribeFilterTop: "GORENAK",
     tribeFilterTop: "GORENAK",
+    tribeFilterSubtribes: "AZPI-TRIBUAK",
     tribeFilterGallery: "GALERIA",
     tribeFilterGallery: "GALERIA",
+    tribeMainTribeLabel: "TRIBU NAGUSIA",
     tribeCreateButton: "Sortu Tribua",
     tribeCreateButton: "Sortu Tribua",
     tribeUpdateButton: "Egutneratu",
     tribeUpdateButton: "Egutneratu",
     tribeDeleteButton: "Ezabatu",
     tribeDeleteButton: "Ezabatu",
-    tribeImageLabel: "Tribuaren Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    tribeImageLabel: "Multimedia igo (max: 50MB)",
     tribeTitleLabel: "Izenburua",
     tribeTitleLabel: "Izenburua",
     searchTribesPlaceholder:  "IRAGAZI tribuak IZENAREN ARABERA …",
     searchTribesPlaceholder:  "IRAGAZI tribuak IZENAREN ARABERA …",
     tribeTitlePlaceholder: "Tribuaren izena",
     tribeTitlePlaceholder: "Tribuaren izena",
@@ -1653,6 +1676,7 @@ module.exports = {
     tribeGenerateInvite: "SORTU KODEA",
     tribeGenerateInvite: "SORTU KODEA",
     tribeCreatedAt: "Noiz",
     tribeCreatedAt: "Noiz",
     tribeAuthor: "Nork",
     tribeAuthor: "Nork",
+    tribeAuthorLabel: "EGILEA",
     tribeStrict: "Zorrotza",
     tribeStrict: "Zorrotza",
     tribeOpen: "Irekita",
     tribeOpen: "Irekita",
     tribeFeedFilterRECENT:           "BERRIAK",
     tribeFeedFilterRECENT:           "BERRIAK",
@@ -1665,7 +1689,178 @@ module.exports = {
     tribeFeedSend:                   "Bidali",
     tribeFeedSend:                   "Bidali",
     tribeFeedEmpty:                  "Jariorik ez, oraindik",
     tribeFeedEmpty:                  "Jariorik ez, oraindik",
     noTribes:                        "Triburik ez, oraindik.",
     noTribes:                        "Triburik ez, oraindik.",
-    //agenda
+    tribeNotFound: "Tribua ez da aurkitu!",
+    createTribeTitle: "Sortu Tribua",
+    updateTribeTitle: "Eguneratu Tribua",
+    tribeSectionOverview: "Ikuspegi orokorra",
+    tribeSectionInhabitants: "Biztanleak",
+    tribeSectionVotations: "Bozkatzeak",
+    tribeSectionEvents: "Ekitaldiak",
+    tribeSectionReports: "Txostenak",
+    tribeSectionTasks: "Atazak",
+    tribeSectionFeed: "Jarioa",
+    tribeSectionForum: "Foroa",
+    tribeSectionMarket: "Merkatua",
+    tribeSectionJobs: "Lanak",
+    tribeSectionProjects: "Proiektuak",
+    tribeSectionMedia: "Media",
+    tribeSectionImages: "IRUDIAK",
+    tribeSectionAudios: "AUDIOAK",
+    tribeSectionVideos: "BIDEOAK",
+    tribeSectionDocuments: "DOKUMENTUAK",
+    tribeSectionBookmarks: "LASTER-MARKAK",
+    tribeInhabitantsEmpty: "Tribu honetan biztanlerik ez, oraindik.",
+    tribeEventCreate: "Sortu Ekitaldia",
+    tribeEventsEmpty: "Ekitaldirik ez, oraindik.",
+    tribeEventTitle: "Izenburua",
+    tribeEventDescription: "Deskribapena",
+    tribeEventDate: "Data",
+    tribeEventLocation: "Kokalekua",
+    tribeEventAttend: "JOAN",
+    tribeEventUnattend: "UTZI",
+    tribeEventAttendees: "Parte-hartzaileak",
+    tribeTaskCreate: "Sortu Ataza",
+    tribeTasksEmpty: "Atazorik ez, oraindik.",
+    tribeTaskTitle: "Izenburua",
+    tribeTaskDescription: "Deskribapena",
+    tribeTaskPriority: "Lehentasuna",
+    tribeTaskDeadline: "Epemuga",
+    tribeTaskAssignees: "Esleituak",
+    tribeTaskStatusInProgress: "ABIAN",
+    tribeTaskStatusClosed: "ITXI",
+    tribeTaskAssign: "ESLEITU",
+    tribeTaskUnassign: "KENDU",
+    tribeReportCreate: "Sortu Txostena",
+    tribeReportsEmpty: "Txostenik ez, oraindik.",
+    tribeReportTitle: "Izenburua",
+    tribeReportDescription: "Deskribapena",
+    tribeReportCategory: "Kategoria",
+    tribeVotationCreate: "Sortu Bozketa",
+    tribeVotationsEmpty: "Bozketarik ez, oraindik.",
+    tribeVotationTitle: "Izenburua",
+    tribeVotationDescription: "Deskribapena",
+    tribeVotationOptions: "Aukerak",
+    tribeVotationDeadline: "Epemuga",
+    tribeVotationVote: "BOZKATU",
+    tribeVotationResults: "Botoak",
+    tribeVotationClose: "ITXI BOZKETA",
+    tribeVotationOptionPlaceholder: "Aukera",
+    tribeForumCreate: "Forum Sortu",
+    tribeForumEmpty: "Harik ez, oraindik.",
+    tribeForumTitle: "Izenburua",
+    tribeForumText: "Mezua",
+    tribeForumCategory: "Kategoria",
+    tribeForumReply: "Erantzun",
+    tribeForumReplies: "Erantzunak",
+    tribeMarketCreate: "Sortu Iragarkia",
+    tribeMarketEmpty: "Iragarkirik ez, oraindik.",
+    tribeMarketTitle: "Izenburua",
+    tribeMarketDescription: "Deskribapena",
+    tribeMarketPrice: "Prezioa",
+    tribeMarketImage: "Irudia",
+    tribeMarketCategory: "Kategoria",
+    tribeJobCreate: "Sortu Lana",
+    tribeJobsEmpty: "Lanik ez, oraindik.",
+    tribeJobTitle: "Izenburua",
+    tribeJobDescription: "Deskribapena",
+    tribeJobLocation: "Kokalekua",
+    tribeJobSalary: "Soldata",
+    tribeJobDeadline: "Epemuga",
+    tribeProjectCreate: "Sortu Proiektua",
+    tribeProjectsEmpty: "Proiekturik ez, oraindik.",
+    tribeProjectTitle: "Izenburua",
+    tribeProjectDescription: "Deskribapena",
+    tribeProjectGoal: "Helburua",
+    tribeProjectFunded: "Finantzatua",
+    tribeProjectDeadline: "Epemuga",
+    tribeMediaUpload: "Igo Media",
+    readDocument: "Dokumentua Irakurri",
+    tribeCreateImage: "Irudia Sortu",
+    tribeCreateAudio: "Audioa Sortu",
+    tribeCreateVideo: "Bideoa Sortu",
+    tribeCreateDocument: "Dokumentua Sortu",
+    tribeCreateBookmark: "Laster-marka Sortu",
+    tribeMediaEmpty: "Mediarik ez, oraindik.",
+    tribeMediaTitle: "Izenburua",
+    tribeMediaDescription: "Deskribapena",
+    tribeMediaType: "Mota",
+    tribeMediaTypeImage: "Irudia",
+    tribeMediaTypeVideo: "Bideoa",
+    tribeMediaTypeAudio: "Audioa",
+    tribeMediaTypeDocument: "Dokumentua",
+    tribeMediaTypeBookmark: "Laster-marka",
+    tribeContentDelete: "EZABATU",
+    tribeInviteCodeText: "Gonbidapen kodea: ",
+    tribeGroupTribe: "Tribua",
+    tribeGroupOffice: "Bulegoa",
+    tribeGroupNetwork: "Sarea",
+    tribeGroupEconomy: "Ekonomia",
+    tribeGroupMedia: "Media",
+    tribeStatusOpen: "IREKITA",
+    tribeStatusClosed: "ITXITA",
+    tribeStatusInProgress: "ABIAN",
+    tribePriorityLow: "BAXUA",
+    tribePriorityMedium: "ERTAINA",
+    tribePriorityHigh: "ALTUA",
+    tribePriorityCritical: "KRITIKOA",
+    tribeTaskFilterAll: "GUZTIAK",
+    tribeMediaFilterAll: "GUZTIAK",
+    tribeReportCatBug: "AKATSA",
+    tribeReportCatAbuse: "ABUSUA",
+    tribeReportCatContent: "EDUKIA",
+    tribeReportCatOther: "BESTEA",
+    tribeForumCatGeneral: "OROKORRA",
+    tribeForumCatProposal: "PROPOSAMENA",
+    tribeForumCatQuestion: "GALDERA",
+    tribeForumCatAnnouncement: "IRAGARPENA",
+    tribeMarketCatGoods: "ONDASUNAK",
+    tribeMarketCatServices: "ZERBITZUAK",
+    tribeMarketCatFood: "ELIKADURA",
+    tribeMarketCatOther: "BESTEA",
+    tribeStatusLabel: "Egoera",
+    tribeSubTribes: "AZPI-TRIBUAK",
+    tribeSubTribesCreate: "Azpi-Tribua Sortu",
+    tribeSubTribesEmpty: "Ez da azpi-triburik sortu, oraindik.",
+    tribeLarpCreateForbidden: "L.A.R.P. tribuak ezin dira sortu.",
+    tribeLarpUpdateForbidden: "L.A.R.P. tribuak ezin dira eguneratu.",
+    tribeActivityJoined: "BATUTA",
+    tribeActivityLeft: "IRTEN",
+    tribeActivityFeed: "FEED",
+    tribeActivityRefeed: "REFEED",
+    tribeGroupAnalytics: "Analitikak",
+    tribeGroupCreative: "Sormenez",
+    tribeSectionActivity: "JARDUERA",
+    tribeSectionTrending: "JOERAK",
+    tribeSectionOpinions: "IRITZIAK",
+    tribeSectionPixelia: "PIXELIA",
+    tribeSectionTags: "ETIKETAK",
+    tribeSectionSearch: "BILATU",
+    tribeActivityEmpty: "Ez dago jarduerarik oraindik.",
+    tribeActivityCreated: "sortu du",
+    tribeActivityPosted: "argitaratu du",
+    tribeActivityReplied: "erantzun du",
+    tribeTrendingEmpty: "Ez dago joera-edukirik oraindik.",
+    tribeTrendingPeriodDay: "Gaur",
+    tribeTrendingPeriodWeek: "Aste Honetan",
+    tribeTrendingPeriodAll: "Dena",
+    tribeTrendingEngagement: "interakzioa",
+    tribeOpinionsEmpty: "Ez dago iritzirik oraindik.",
+    tribeOpinionsCast: "Bozkatu",
+    tribeOpinionsRankings: "Sailkapenak",
+    tribeOpinionsAlreadyVoted: "Dagoeneko bozkatu duzu",
+    tribeTopCategory: "Gehien bozkatua",
+    tribePixeliaTitle: "Pixelia",
+    tribePixeliaDescription: "Pixel art oihal kolaboratiboa.",
+    tribePixeliaPaint: "Pintatu",
+    tribePixeliaContributors: "laguntzaileak",
+    tribePixeliaTotalPixels: "pixel margotuta",
+    tribeTagsEmpty: "Ez da etiketarik aurkitu.",
+    tribeTagsCloud: "Etiketa Hodeia",
+    tribeTagsContentWith: "Etiketadun edukia",
+    tribeSearchPlaceholder: "Bilatu tribuaren edukian...",
+    tribeSearchEmpty: "Emaitzarik ez.",
+    tribeSearchResults: "Emaitzak",
+    tribeSearchMinChars: "Idatzi gutxienez 2 karaktere bilatzeko.",
     agendaTitle: "Agenda",
     agendaTitle: "Agenda",
     agendaDescription: "Eskeituta dauzkazun elementu guztiak aurki ditzakezu hemen.",
     agendaDescription: "Eskeituta dauzkazun elementu guztiak aurki ditzakezu hemen.",
     agendaFilterAll: "GUZTIAK",
     agendaFilterAll: "GUZTIAK",
@@ -1679,6 +1874,7 @@ module.exports = {
     agendaFilterTransfers: "TRANSFERENTZIAK",
     agendaFilterTransfers: "TRANSFERENTZIAK",
     agendaFilterJobs: "LANPOSTUAK",
     agendaFilterJobs: "LANPOSTUAK",
     agendaFilterProjects: "PROIEKTUAK",
     agendaFilterProjects: "PROIEKTUAK",
+    agendaInviteModeLabel: "Egoera",
     agendaNoItems: "Esleipenik ez.",
     agendaNoItems: "Esleipenik ez.",
     agendaDiscardButton: "Baztertu",
     agendaDiscardButton: "Baztertu",
     agendaRestoreButton: "Berrezarri",
     agendaRestoreButton: "Berrezarri",
@@ -1825,6 +2021,9 @@ module.exports = {
     pmCreateButton: "MP idatzi",
     pmCreateButton: "MP idatzi",
     noPrivateMessages: "Ez dago mezu pribaturik.",
     noPrivateMessages: "Ez dago mezu pribaturik.",
     pmReply: "Erantzun",
     pmReply: "Erantzun",
+    pmReplies: "erantzunak",
+    pmNew: "berriak",
+    pmMarkRead: "Irakurritako gisa markatu",
     inReplyTo: "HONI ERANTZUNEZ",
     inReplyTo: "HONI ERANTZUNEZ",
     pmPreview: "Aurrebista",
     pmPreview: "Aurrebista",
     pmPreviewTitle: "Mezuaren aurrebista",
     pmPreviewTitle: "Mezuaren aurrebista",
@@ -1833,6 +2032,8 @@ module.exports = {
     pmToLabel: "Nori:",
     pmToLabel: "Nori:",
     pmInvalidMessage: "Mezu baliogabea",
     pmInvalidMessage: "Mezu baliogabea",
     pmNoSubject: "(gaia gabe)",
     pmNoSubject: "(gaia gabe)",
+    pmSubjectLabel: "Gaia:",
+    pmBodyLabel: "Gorputza",
     pmBotJobs: "42-JobsBOT",
     pmBotJobs: "42-JobsBOT",
     pmBotProjects: "42-ProjectsBOT",
     pmBotProjects: "42-ProjectsBOT",
     pmBotMarket: "42-MarketBOT",
     pmBotMarket: "42-MarketBOT",
@@ -1861,6 +2062,8 @@ module.exports = {
     blockchainBlockURL: 'URL:',
     blockchainBlockURL: 'URL:',
     blockchainContent: 'Bloke',
     blockchainContent: 'Bloke',
     blockchainContentPreview: 'Bloke edukia aurrebista',
     blockchainContentPreview: 'Bloke edukia aurrebista',
+    blockchainLatestDatagram: 'Azken Datagrama',
+    blockchainDatagram: 'Datagrama',
     blockchainDetails: 'Ikusi blokearen xehetasunak',
     blockchainDetails: 'Ikusi blokearen xehetasunak',
     blockchainBlockInfo: 'Blokearen informazioa',
     blockchainBlockInfo: 'Blokearen informazioa',
     blockchainBlockDetails: 'Hautatutako blokearen xehetasunak',
     blockchainBlockDetails: 'Hautatutako blokearen xehetasunak',
@@ -2138,7 +2341,7 @@ module.exports = {
     marketItemSeller: "Saltzailea",
     marketItemSeller: "Saltzailea",
     marketNoItems: "Oraindik ez dago artikulurik eskuragarri.",
     marketNoItems: "Oraindik ez dago artikulurik eskuragarri.",
     marketYourBid: "Zure puja",
     marketYourBid: "Zure puja",
-    marketCreateFormImageLabel: "Irudia igo (jpeg, jpg, png, gif) (geh. tamaina: 500px x 400px)",
+    marketCreateFormImageLabel: "Multimedia igo (max: 50MB)",
     marketSearchLabel: "Bilatu",
     marketSearchLabel: "Bilatu",
     marketSearchPlaceholder: "Izenburuan edo etiketetan bilatu",
     marketSearchPlaceholder: "Izenburuan edo etiketetan bilatu",
     marketMinPriceLabel: "Gutxieneko prezioa",
     marketMinPriceLabel: "Gutxieneko prezioa",
@@ -2161,6 +2364,10 @@ module.exports = {
     jobsFilterRemote: "ERREMOTAK",
     jobsFilterRemote: "ERREMOTAK",
     jobsFilterOpen: "IREKIAK",
     jobsFilterOpen: "IREKIAK",
     jobsFilterClosed: "ITXITA",
     jobsFilterClosed: "ITXITA",
+    jobsFilterTop: "GORENAK",
+    jobsTopTitle: "Soldata Onena duten Lanak",
+    jobTypeFreelance: "Autonomoa",
+    jobTypeSalary: "Langilea",
     jobsCV: "CV-ak",
     jobsCV: "CV-ak",
     jobsCreateJob: "Argitaratu Lana",
     jobsCreateJob: "Argitaratu Lana",
     jobsRecentTitle: "Lana Azkenak",
     jobsRecentTitle: "Lana Azkenak",
@@ -2199,7 +2406,7 @@ module.exports = {
     jobLocationRemote: "Urrunekoa",
     jobLocationRemote: "Urrunekoa",
     jobVacantsPlaceholder: "Postu kopurua",
     jobVacantsPlaceholder: "Postu kopurua",
     jobSalaryPlaceholder: "Ordubeteko soldata ECO",
     jobSalaryPlaceholder: "Ordubeteko soldata ECO",
-    jobImage: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    jobImage: "Multimedia igo (max: 50MB)",
     jobTasks: "Eginkizunak",
     jobTasks: "Eginkizunak",
     jobType: "Lanen Motak",
     jobType: "Lanen Motak",
     jobTime: "Lanen Denbora",
     jobTime: "Lanen Denbora",
@@ -2266,7 +2473,7 @@ module.exports = {
     projectRecentTitle: "Proiektu Azkenak",
     projectRecentTitle: "Proiektu Azkenak",
     projectTopTitle: "Finantzatuenak",
     projectTopTitle: "Finantzatuenak",
     projectTitlePlaceholder: "Proiektuaren izena",
     projectTitlePlaceholder: "Proiektuaren izena",
-    projectImage: "Irudia kargatu (jpeg, jpg, png, gif) (gehienez tamaina: 500px x 400px)",
+    projectImage: "Multimedia igo (max: 50MB)",
     projectDescription: "Deskribapena",
     projectDescription: "Deskribapena",
     projectDescriptionPlaceholder: "Kontatu istorioa eta helburuak...",
     projectDescriptionPlaceholder: "Kontatu istorioa eta helburuak...",
     projectGoal: "Helburu (ECO)",
     projectGoal: "Helburu (ECO)",
@@ -2429,7 +2636,30 @@ module.exports = {
     modulesBankingLabel: "Bankua",
     modulesBankingLabel: "Bankua",
     modulesBankingDescription: "ECOINen benetako balioa zehazteko eta UBI bat altxortegi komuna erabiliz banatzeko modulua.",
     modulesBankingDescription: "ECOINen benetako balioa zehazteko eta UBI bat altxortegi komuna erabiliz banatzeko modulua.",
     modulesFavoritesLabel: "Gogokoak",
     modulesFavoritesLabel: "Gogokoak",
-    modulesFavoritesDescription: "Zure gogoko edukia kudeatzeko modulua."
+    modulesFavoritesDescription: "Zure gogoko edukia kudeatzeko modulua.",
+    fileTooLargeTitle: "Fitxategia handiegia",
+    fileTooLargeMessage: "Fitxategiak onartutako gehienezko tamaina gainditzen du (50 MB). Mesedez, hautatu fitxategi txikiago bat.",
+    goBack: "Itzuli",
+    directConnect: "Zuzeneko Konexioa",
+    directConnectDescription: "Konektatu zuzenean parekide batera bere IP helbidea, portua eta gako publikoa sartuz. Parekidea jarraipen-konexio gisa gehituko da.",
+    peerHost: "IP / Ostalari-izena",
+    peerPort: "Portua (lehenetsia: 8008)",
+    peerPublicKey: "Gako Publikoa (@...ed25519)",
+    connectAndFollow: "Konektatu",
+    deviceSourceLabel: "Gailua",
+    modulesPresetTitle: "Konfigurazio Arruntak",
+    modulesPreset_minimal: "Minimoa",
+    modulesPreset_basic: "Oinarrizkoa",
+    modulesPreset_social: "Soziala",
+    modulesPreset_economy: "Ekonomia",
+    modulesPreset_full: "Osoa",
+    statsCarbonFootprintTitle: "Karbono Aztarna",
+    statsCarbonFootprintNetwork: "Sarearen karbono aztarna",
+    statsCarbonFootprintYours: "Zure karbono aztarna",
+    statsCarbonTombstone: "Tombstoning-aren aztarna",
+    feedSuccessMsg: "Feeda arrakastaz argitaratu da!",
+    dominantOpinionLabel: "Iritzi nagusia",
+    uploadMedia: "Multimedia igo (max: 50MB)",
 
 
      //END
      //END
   }
   }

+ 221 - 13
src/client/assets/translations/oasis_fr.js

@@ -198,6 +198,8 @@ module.exports = {
     mentionsRelationship: "Relation",
     mentionsRelationship: "Relation",
     // settings
     // settings
     updateit: "OBTENIR DES MISES À JOUR !",
     updateit: "OBTENIR DES MISES À JOUR !",
+    updateBannerText: "Une nouvelle version d'Oasis est disponible.",
+    updateBannerAction: "Mettre à jour →",
     info: "Info",
     info: "Info",
     settingsIntro: ({ version }) => [
     settingsIntro: ({ version }) => [
       `[SNH] ꖒ OASIS [ v.${version} ]`,
       `[SNH] ꖒ OASIS [ v.${version} ]`,
@@ -371,6 +373,7 @@ module.exports = {
     videoLabel: "VIDÉOS",
     videoLabel: "VIDÉOS",
     audioLabel: "AUDIOS",
     audioLabel: "AUDIOS",
     documentLabel: "DOCUMENTS",
     documentLabel: "DOCUMENTS",
+    pdfFallbackLabel: "Document PDF",
     eventLabel: "ÉVÉNEMENTS",
     eventLabel: "ÉVÉNEMENTS",
     taskLabel: "TÂCHES",
     taskLabel: "TÂCHES",
     transferLabel: "TRANSFERTS",
     transferLabel: "TRANSFERTS",
@@ -385,7 +388,7 @@ module.exports = {
     editProfileDescription:
     editProfileDescription:
       "",
       "",
     profileName: "Nom",
     profileName: "Nom",
-    profileImage: "Image Avatar (jpeg, jpg, png, gif) (taille max : 500px x 400px)",
+    profileImage: "Image Avatar",
     profileDescription: "Description",
     profileDescription: "Description",
     hashtagDescription:
     hashtagDescription:
       "Publications des habitants de votre réseau qui référencent ce #hashtag, triées par la plus récente.",
       "Publications des habitants de votre réseau qui référencent ce #hashtag, triées par la plus récente.",
@@ -673,6 +676,7 @@ module.exports = {
     blockedLabel:        "Utilisateur bloqué",
     blockedLabel:        "Utilisateur bloqué",
     inhabitantviewDetails: "Voir les détails",
     inhabitantviewDetails: "Voir les détails",
     viewDetails: "Voir les détails",
     viewDetails: "Voir les détails",
+    keepReading: "Lire la suite...",
     oasisId: "ID",
     oasisId: "ID",
     noInhabitantsFound:    "Aucun habitant trouvé pour l’instant.",
     noInhabitantsFound:    "Aucun habitant trouvé pour l’instant.",
     inhabitantActivityLevel: "Niveau Activité",
     inhabitantActivityLevel: "Niveau Activité",
@@ -1269,7 +1273,7 @@ module.exports = {
     // blog/post,
     // blog/post,
     blogSubject: "Sujet",
     blogSubject: "Sujet",
     blogMessage: "Message",
     blogMessage: "Message",
-    blogImage: "Téléverser une image (jpeg, jpg, png, gif) (taille maximale : 500px x 400px)",
+    blogImage: "Télécharger un média (max: 50 Mo)",
     blogPublish: "Aperçu",
     blogPublish: "Aperçu",
     noPopularMessages: "Aucun message populaire publié pour l’instant",
     noPopularMessages: "Aucun message populaire publié pour l’instant",
     // forum
     // forum
@@ -1367,7 +1371,8 @@ module.exports = {
     TODAYButton:      "AUJOURD’HUI",
     TODAYButton:      "AUJOURD’HUI",
     TOPButton:        "Feeds principaux",
     TOPButton:        "Feeds principaux",
     CREATEButton:     "Créer un feed",
     CREATEButton:     "Créer un feed",
-    totalOpinions:    "Total d’opinions",
+    totalOpinions:    "Total d'opinions",
+    moreVoted:        "Plus voté",
     alreadyVoted:     "Vous avez déjà donné votre opinion.",
     alreadyVoted:     "Vous avez déjà donné votre opinion.",
     noFeedsFound:     "Aucun feed trouvé.",
     noFeedsFound:     "Aucun feed trouvé.",
     author:           "Par",
     author:           "Par",
@@ -1577,7 +1582,7 @@ module.exports = {
     reportsUpdateButton: "Mettre à jour",
     reportsUpdateButton: "Mettre à jour",
     reportsDeleteButton: "Supprimer",
     reportsDeleteButton: "Supprimer",
     reportsDateLabel: "Date",
     reportsDateLabel: "Date",
-    reportsUploadFile: "Téléverser une image (jpeg, jpg, png, gif) (taille maximale : 500px x 400px)",
+    reportsUploadFile: "Télécharger un média (max: 50 Mo)",
     reportsCreatedBy: "Par",
     reportsCreatedBy: "Par",
     reportsMineSectionTitle: "Vos rapports",
     reportsMineSectionTitle: "Vos rapports",
     reportsFeaturesSectionTitle: "Demandes de fonctions",
     reportsFeaturesSectionTitle: "Demandes de fonctions",
@@ -1650,7 +1655,6 @@ module.exports = {
     reportsWhyInappropriatePlaceholder: 'Expliquez la raison et l’impact.',
     reportsWhyInappropriatePlaceholder: 'Expliquez la raison et l’impact.',
     reportsRequestedActionLabel: 'Action demandée',
     reportsRequestedActionLabel: 'Action demandée',
     reportsRequestedActionPlaceholder: 'Supprimer, masquer, étiqueter, avertir, etc.',
     reportsRequestedActionPlaceholder: 'Supprimer, masquer, étiqueter, avertir, etc.',
-    //tribes
     tribesTitle: "Tribus",
     tribesTitle: "Tribus",
     tribeAllSectionTitle: "Tribus",
     tribeAllSectionTitle: "Tribus",
     tribeMineSectionTitle: "Vos tribus",
     tribeMineSectionTitle: "Vos tribus",
@@ -1668,11 +1672,13 @@ module.exports = {
     tribeFilterRecent: "RÉCENTS",
     tribeFilterRecent: "RÉCENTS",
     tribeFilterLarp: "L.A.R.P.",
     tribeFilterLarp: "L.A.R.P.",
     tribeFilterTop: "TOP",
     tribeFilterTop: "TOP",
+    tribeFilterSubtribes: "SOUS-TRIBUS",
     tribeFilterGallery: "GALERIE",
     tribeFilterGallery: "GALERIE",
+    tribeMainTribeLabel: "TRIBU PRINCIPALE",
     tribeCreateButton: "Créer une tribu",
     tribeCreateButton: "Créer une tribu",
     tribeUpdateButton: "Mettre à jour",
     tribeUpdateButton: "Mettre à jour",
     tribeDeleteButton: "Supprimer",
     tribeDeleteButton: "Supprimer",
-    tribeImageLabel: "Image de la tribu (jpeg, jpg, png, gif) (taille maximale : 500px x 400px)",
+    tribeImageLabel: "Télécharger un média (max: 50 Mo)",
     tribeTitleLabel: "Titre",
     tribeTitleLabel: "Titre",
     searchTribesPlaceholder:  "FILTRER les tribus PAR NOM …",
     searchTribesPlaceholder:  "FILTRER les tribus PAR NOM …",
     tribeTitlePlaceholder: "Nom de la tribu",
     tribeTitlePlaceholder: "Nom de la tribu",
@@ -1699,6 +1705,7 @@ module.exports = {
     tribeGenerateInvite: "GÉNÉRER UN CODE",
     tribeGenerateInvite: "GÉNÉRER UN CODE",
     tribeCreatedAt: "Créé le",
     tribeCreatedAt: "Créé le",
     tribeAuthor: "Par",
     tribeAuthor: "Par",
+    tribeAuthorLabel: "AUTEUR",
     tribeStrict: "Strict",
     tribeStrict: "Strict",
     tribeOpen: "Ouverte",
     tribeOpen: "Ouverte",
     tribeFeedFilterRECENT: "RÉCENTS",
     tribeFeedFilterRECENT: "RÉCENTS",
@@ -1710,8 +1717,179 @@ module.exports = {
     tribeFeedMessagePlaceholder: "Écrivez un feed…",
     tribeFeedMessagePlaceholder: "Écrivez un feed…",
     tribeFeedSend: "Envoyer",
     tribeFeedSend: "Envoyer",
     tribeFeedEmpty: "Aucun message de feed disponible pour l’instant.",
     tribeFeedEmpty: "Aucun message de feed disponible pour l’instant.",
-    noTribes: "Aucune tribu trouvée pour l’instant.",
-    //agenda
+    noTribes: "Aucune tribu trouvée pour l'instant.",
+    tribeNotFound: "Tribu introuvable!",
+    createTribeTitle: "Creer une tribu",
+    updateTribeTitle: "Mettre a jour la tribu",
+    tribeSectionOverview: "Apercu",
+    tribeSectionInhabitants: "Habitants",
+    tribeSectionVotations: "Votations",
+    tribeSectionEvents: "Evenements",
+    tribeSectionReports: "Rapports",
+    tribeSectionTasks: "Taches",
+    tribeSectionFeed: "Fil",
+    tribeSectionForum: "Forum",
+    tribeSectionMarket: "Marche",
+    tribeSectionJobs: "Emplois",
+    tribeSectionProjects: "Projets",
+    tribeSectionMedia: "Media",
+    tribeSectionImages: "IMAGES",
+    tribeSectionAudios: "AUDIOS",
+    tribeSectionVideos: "VIDÉOS",
+    tribeSectionDocuments: "DOCUMENTS",
+    tribeSectionBookmarks: "SIGNETS",
+    tribeInhabitantsEmpty: "Aucun habitant dans cette tribu pour l'instant.",
+    tribeEventCreate: "Creer un evenement",
+    tribeEventsEmpty: "Aucun evenement pour l'instant.",
+    tribeEventTitle: "Titre",
+    tribeEventDescription: "Description",
+    tribeEventDate: "Date",
+    tribeEventLocation: "Lieu",
+    tribeEventAttend: "PARTICIPER",
+    tribeEventUnattend: "QUITTER",
+    tribeEventAttendees: "Participants",
+    tribeTaskCreate: "Creer une tache",
+    tribeTasksEmpty: "Aucune tache pour l'instant.",
+    tribeTaskTitle: "Titre",
+    tribeTaskDescription: "Description",
+    tribeTaskPriority: "Priorite",
+    tribeTaskDeadline: "Date limite",
+    tribeTaskAssignees: "Assignes",
+    tribeTaskStatusInProgress: "EN COURS",
+    tribeTaskStatusClosed: "FERMER",
+    tribeTaskAssign: "ASSIGNER",
+    tribeTaskUnassign: "DESASSIGNER",
+    tribeReportCreate: "Creer un rapport",
+    tribeReportsEmpty: "Aucun rapport pour l'instant.",
+    tribeReportTitle: "Titre",
+    tribeReportDescription: "Description",
+    tribeReportCategory: "Categorie",
+    tribeVotationCreate: "Creer une votation",
+    tribeVotationsEmpty: "Aucune votation pour l'instant.",
+    tribeVotationTitle: "Titre",
+    tribeVotationDescription: "Description",
+    tribeVotationOptions: "Options",
+    tribeVotationDeadline: "Date limite",
+    tribeVotationVote: "VOTER",
+    tribeVotationResults: "Votes",
+    tribeVotationClose: "FERMER LA VOTATION",
+    tribeVotationOptionPlaceholder: "Option",
+    tribeForumCreate: "Créer Forum",
+    tribeForumEmpty: "Aucun fil pour l'instant.",
+    tribeForumTitle: "Titre",
+    tribeForumText: "Message",
+    tribeForumCategory: "Categorie",
+    tribeForumReply: "Repondre",
+    tribeForumReplies: "Reponses",
+    tribeMarketCreate: "Creer une annonce",
+    tribeMarketEmpty: "Aucune annonce pour l'instant.",
+    tribeMarketTitle: "Titre",
+    tribeMarketDescription: "Description",
+    tribeMarketPrice: "Prix",
+    tribeMarketImage: "Image",
+    tribeMarketCategory: "Categorie",
+    tribeJobCreate: "Creer un emploi",
+    tribeJobsEmpty: "Aucun emploi pour l'instant.",
+    tribeJobTitle: "Titre",
+    tribeJobDescription: "Description",
+    tribeJobLocation: "Lieu",
+    tribeJobSalary: "Salaire",
+    tribeJobDeadline: "Date limite",
+    tribeProjectCreate: "Creer un projet",
+    tribeProjectsEmpty: "Aucun projet pour l'instant.",
+    tribeProjectTitle: "Titre",
+    tribeProjectDescription: "Description",
+    tribeProjectGoal: "Objectif",
+    tribeProjectFunded: "Finance",
+    tribeProjectDeadline: "Date limite",
+    tribeMediaUpload: "Telecharger un media",
+    readDocument: "Lire le Document",
+    tribeCreateImage: "Créer Image",
+    tribeCreateAudio: "Créer Audio",
+    tribeCreateVideo: "Créer Vidéo",
+    tribeCreateDocument: "Créer Document",
+    tribeCreateBookmark: "Créer Signet",
+    tribeMediaEmpty: "Aucun media pour l'instant.",
+    tribeMediaTitle: "Titre",
+    tribeMediaDescription: "Description",
+    tribeMediaType: "Type",
+    tribeMediaTypeImage: "Image",
+    tribeMediaTypeVideo: "Video",
+    tribeMediaTypeAudio: "Audio",
+    tribeMediaTypeDocument: "Document",
+    tribeMediaTypeBookmark: "Signet",
+    tribeContentDelete: "SUPPRIMER",
+    tribeInviteCodeText: "Code d'invitation : ",
+    tribeGroupTribe: "Tribu",
+    tribeGroupOffice: "Bureau",
+    tribeGroupNetwork: "Réseau",
+    tribeGroupEconomy: "Économie",
+    tribeGroupMedia: "Média",
+    tribeStatusOpen: "OUVERT",
+    tribeStatusClosed: "FERMÉ",
+    tribeStatusInProgress: "EN COURS",
+    tribePriorityLow: "BASSE",
+    tribePriorityMedium: "MOYENNE",
+    tribePriorityHigh: "HAUTE",
+    tribePriorityCritical: "CRITIQUE",
+    tribeTaskFilterAll: "TOUS",
+    tribeMediaFilterAll: "TOUS",
+    tribeReportCatBug: "BUG",
+    tribeReportCatAbuse: "ABUS",
+    tribeReportCatContent: "CONTENU",
+    tribeReportCatOther: "AUTRE",
+    tribeForumCatGeneral: "GÉNÉRAL",
+    tribeForumCatProposal: "PROPOSITION",
+    tribeForumCatQuestion: "QUESTION",
+    tribeForumCatAnnouncement: "ANNONCE",
+    tribeMarketCatGoods: "BIENS",
+    tribeMarketCatServices: "SERVICES",
+    tribeMarketCatFood: "ALIMENTATION",
+    tribeMarketCatOther: "AUTRE",
+    tribeStatusLabel: "Statut",
+    tribeSubTribes: "SOUS-TRIBUS",
+    tribeSubTribesCreate: "Créer Sous-Tribu",
+    tribeSubTribesEmpty: "Aucune sous-tribu créée, pour l'instant.",
+    tribeLarpCreateForbidden: "Les tribus L.A.R.P. ne peuvent pas être créées.",
+    tribeLarpUpdateForbidden: "Les tribus L.A.R.P. ne peuvent pas être mises à jour.",
+    tribeActivityJoined: "REJOINT",
+    tribeActivityLeft: "QUITTÉ",
+    tribeActivityFeed: "FEED",
+    tribeActivityRefeed: "REFEED",
+    tribeGroupAnalytics: "Analytiques",
+    tribeGroupCreative: "Créatif",
+    tribeSectionActivity: "ACTIVITÉ",
+    tribeSectionTrending: "TENDANCES",
+    tribeSectionOpinions: "OPINIONS",
+    tribeSectionPixelia: "PIXELIA",
+    tribeSectionTags: "TAGS",
+    tribeSectionSearch: "RECHERCHE",
+    tribeActivityEmpty: "Aucune activité pour le moment.",
+    tribeActivityCreated: "a créé",
+    tribeActivityPosted: "a publié",
+    tribeActivityReplied: "a répondu",
+    tribeTrendingEmpty: "Aucun contenu tendance pour le moment.",
+    tribeTrendingPeriodDay: "Aujourd'hui",
+    tribeTrendingPeriodWeek: "Cette Semaine",
+    tribeTrendingPeriodAll: "Tout",
+    tribeTrendingEngagement: "engagement",
+    tribeOpinionsEmpty: "Aucune opinion pour le moment.",
+    tribeOpinionsCast: "Voter",
+    tribeOpinionsRankings: "Classements",
+    tribeOpinionsAlreadyVoted: "Déjà voté",
+    tribeTopCategory: "Plus voté",
+    tribePixeliaTitle: "Pixelia",
+    tribePixeliaDescription: "Toile collaborative de pixel art.",
+    tribePixeliaPaint: "Peindre",
+    tribePixeliaContributors: "contributeurs",
+    tribePixeliaTotalPixels: "pixels peints",
+    tribeTagsEmpty: "Aucun tag trouvé.",
+    tribeTagsCloud: "Nuage de Tags",
+    tribeTagsContentWith: "Contenu avec le tag",
+    tribeSearchPlaceholder: "Rechercher dans la tribu...",
+    tribeSearchEmpty: "Aucun résultat.",
+    tribeSearchResults: "Résultats",
+    tribeSearchMinChars: "Entrez au moins 2 caractères pour rechercher.",
     agendaTitle: "Agenda",
     agendaTitle: "Agenda",
     agendaDescription: "Ici vous pouvez trouver tous vos éléments assignés.",
     agendaDescription: "Ici vous pouvez trouver tous vos éléments assignés.",
     agendaFilterAll: "TOUS",
     agendaFilterAll: "TOUS",
@@ -1871,6 +2049,9 @@ module.exports = {
     pmCreateButton: "Écrire un MP",
     pmCreateButton: "Écrire un MP",
     noPrivateMessages: "Aucun message privé.",
     noPrivateMessages: "Aucun message privé.",
     pmReply: "Répondre",
     pmReply: "Répondre",
+    pmReplies: "réponses",
+    pmNew: "nouvelles",
+    pmMarkRead: "Marquer comme lu",
     inReplyTo: "EN RÉPONSE À",
     inReplyTo: "EN RÉPONSE À",
     pmPreview: "Aperçu",
     pmPreview: "Aperçu",
     pmPreviewTitle: "Aperçu",
     pmPreviewTitle: "Aperçu",
@@ -1879,6 +2060,8 @@ module.exports = {
     pmToLabel: "À :",
     pmToLabel: "À :",
     pmInvalidMessage: "Message non valide",
     pmInvalidMessage: "Message non valide",
     pmNoSubject: "(sans objet)",
     pmNoSubject: "(sans objet)",
+    pmSubjectLabel: "Objet :",
+    pmBodyLabel: "Corps",
     pmBotJobs: "42-JobsBOT",
     pmBotJobs: "42-JobsBOT",
     pmBotProjects: "42-ProjectsBOT",
     pmBotProjects: "42-ProjectsBOT",
     pmBotMarket: "42-MarketBOT",
     pmBotMarket: "42-MarketBOT",
@@ -1907,6 +2090,8 @@ module.exports = {
     blockchainBlockURL: 'URL :',
     blockchainBlockURL: 'URL :',
     blockchainContent: 'Bloc',
     blockchainContent: 'Bloc',
     blockchainContentPreview: 'Aperçu du contenu du bloc',
     blockchainContentPreview: 'Aperçu du contenu du bloc',
+    blockchainLatestDatagram: 'Dernier Datagramme',
+    blockchainDatagram: 'Datagramme',
     blockchainDetails: 'Voir les détails du bloc',
     blockchainDetails: 'Voir les détails du bloc',
     blockchainBlockInfo: 'Informations du bloc',
     blockchainBlockInfo: 'Informations du bloc',
     blockchainBlockDetails: 'Détails du bloc sélectionné',
     blockchainBlockDetails: 'Détails du bloc sélectionné',
@@ -2184,7 +2369,7 @@ module.exports = {
     marketItemSeller: "Vendeur",
     marketItemSeller: "Vendeur",
     marketNoItems: "Aucun article disponible pour le moment.",
     marketNoItems: "Aucun article disponible pour le moment.",
     marketYourBid: "Votre offre",
     marketYourBid: "Votre offre",
-    marketCreateFormImageLabel: "Téléverser une image (jpeg, jpg, png, gif) (taille max : 500px x 400px)",
+    marketCreateFormImageLabel: "Télécharger un média (max: 50 Mo)",
     marketSearchLabel: "Rechercher",
     marketSearchLabel: "Rechercher",
     marketSearchPlaceholder: "Rechercher par titre ou tags",
     marketSearchPlaceholder: "Rechercher par titre ou tags",
     marketMinPriceLabel: "Prix minimum",
     marketMinPriceLabel: "Prix minimum",
@@ -2245,7 +2430,7 @@ module.exports = {
     jobLocationRemote: "À distance",
     jobLocationRemote: "À distance",
     jobVacantsPlaceholder: "Nombre de postes vacants",
     jobVacantsPlaceholder: "Nombre de postes vacants",
     jobSalaryPlaceholder: "Salaire en ECO pour 1 heure",
     jobSalaryPlaceholder: "Salaire en ECO pour 1 heure",
-    jobImage: "Téléverser une image (jpeg, jpg, png, gif) (taille maximale : 500px x 400px)",
+    jobImage: "Télécharger un média (max: 50 Mo)",
     jobTasks: "Tâches",
     jobTasks: "Tâches",
     jobType: "Type d’emploi",
     jobType: "Type d’emploi",
     jobTime: "Temps de travail",
     jobTime: "Temps de travail",
@@ -2314,7 +2499,7 @@ module.exports = {
     projectRecentTitle: "Projets Récents",
     projectRecentTitle: "Projets Récents",
     projectTopTitle: "Mieux Financé",
     projectTopTitle: "Mieux Financé",
     projectTitlePlaceholder: "Nom du projet",
     projectTitlePlaceholder: "Nom du projet",
-    projectImage: "Télécharger une image (jpeg, jpg, png, gif) (taille max : 500px x 400px)",
+    projectImage: "Télécharger un média (max: 50 Mo)",
     projectDescription: "Description",
     projectDescription: "Description",
     projectDescriptionPlaceholder: "Racontez l'histoire et les objectifs…",
     projectDescriptionPlaceholder: "Racontez l'histoire et les objectifs…",
     projectGoal: "Objectif (ECO)",
     projectGoal: "Objectif (ECO)",
@@ -2477,8 +2662,31 @@ module.exports = {
     modulesBankingLabel: "Banque",
     modulesBankingLabel: "Banque",
     modulesBankingDescription: "Module pour connaître la valeur réelle de ECOIN et distribuer une RBU en utilisant la trésorerie commune.",
     modulesBankingDescription: "Module pour connaître la valeur réelle de ECOIN et distribuer une RBU en utilisant la trésorerie commune.",
     modulesFavoritesLabel: "Favoris",
     modulesFavoritesLabel: "Favoris",
-    modulesFavoritesDescription: "Module pour gérer votre contenu favori."
-     
+    modulesFavoritesDescription: "Module pour gérer votre contenu favori.",
+    fileTooLargeTitle: "Fichier trop volumineux",
+    fileTooLargeMessage: "Le fichier dépasse la taille maximale autorisée (50 Mo). Veuillez sélectionner un fichier plus petit.",
+    goBack: "Retour",
+    directConnect: "Connexion Directe",
+    directConnectDescription: "Connectez-vous directement à un pair en saisissant son adresse IP, port et clé publique. Le pair sera ajouté comme connexion suivie.",
+    peerHost: "IP / Nom d'hôte",
+    peerPort: "Port (par défaut : 8008)",
+    peerPublicKey: "Clé publique (@...ed25519)",
+    connectAndFollow: "Connecter",
+    deviceSourceLabel: "Appareil",
+    modulesPresetTitle: "Configurations Communes",
+    modulesPreset_minimal: "Minimal",
+    modulesPreset_basic: "Basique",
+    modulesPreset_social: "Social",
+    modulesPreset_economy: "Économie",
+    modulesPreset_full: "Complet",
+    statsCarbonFootprintTitle: "Empreinte Carbone",
+    statsCarbonFootprintNetwork: "Empreinte carbone du réseau",
+    statsCarbonFootprintYours: "Votre empreinte carbone",
+    statsCarbonTombstone: "Empreinte du tombstoning",
+    feedSuccessMsg: "Feed publié avec succès !",
+    dominantOpinionLabel: "Opinion dominante",
+    uploadMedia: "Télécharger un média (max: 50 Mo)"
+
      //END
      //END
     }
     }
 };
 };

Разница между файлами не показана из-за своего большого размера
+ 2702 - 0
src/client/assets/translations/oasis_it.js


Разница между файлами не показана из-за своего большого размера
+ 2702 - 0
src/client/assets/translations/oasis_pt.js


+ 10 - 10
src/client/middleware.js

@@ -60,25 +60,25 @@ module.exports = ({ host, port, middleware, allowHost }) => {
     //console.log("Requesting:", ctx.path); // uncomment to check for HTTP requests
     //console.log("Requesting:", ctx.path); // uncomment to check for HTTP requests
     
     
     const csp = [
     const csp = [
-      "default-src 'self' blob:", 
+      "default-src 'self'",
+      "script-src 'self' http://localhost:3000/js",
+      "style-src 'self'",
       "img-src 'self'",
       "img-src 'self'",
+      "media-src 'self' blob:",
+      "worker-src 'self' blob:",
       "form-action 'self'",
       "form-action 'self'",
-      "media-src 'self'",
-      "style-src 'self'",
-      "script-src 'self' http://localhost:3000/js",  // pdfviewer
+      "object-src 'none'",
+      "base-uri 'none'",
+      "frame-ancestors 'none'"
     ].join("; ");
     ].join("; ");
 
 
     ctx.set("Content-Security-Policy", csp);
     ctx.set("Content-Security-Policy", csp);
     ctx.set("X-Frame-Options", "SAMEORIGIN");
     ctx.set("X-Frame-Options", "SAMEORIGIN");
 
 
-    const isBlobPath = ctx.path.startsWith("/blob/");
-
-    if (isBlobPath === false) {
-      ctx.set("X-Content-Type-Options", "nosniff");
-    }
+    ctx.set("X-Content-Type-Options", "nosniff");
 
 
     ctx.set("Referrer-Policy", "same-origin");
     ctx.set("Referrer-Policy", "same-origin");
-    ctx.set("Feature-Policy", "speaker 'self'");
+    ctx.set("Permissions-Policy", "speaker=(self)");
 
 
     const validHostsString = validHosts.join(" or ");
     const validHostsString = validHosts.join(" or ");
 
 

+ 4 - 0
src/configs/blockchain-cycle.json

@@ -0,0 +1,4 @@
+{
+  "cycle": 4,
+  "url": "https://laplaza.solarnethub.com"
+}

+ 2 - 1
src/configs/config-manager.js

@@ -63,7 +63,8 @@ if (!fs.existsSync(configFilePath)) {
     "ssbLogStream": {
     "ssbLogStream": {
       "limit": 2000
       "limit": 2000
     },
     },
-    "homePage": "activity"
+    "homePage": "activity",
+    "language": "en"
   };
   };
   fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
   fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
 }
 }

+ 2 - 1
src/configs/oasis-config.json

@@ -57,5 +57,6 @@
   "ssbLogStream": {
   "ssbLogStream": {
     "limit": 2000
     "limit": 2000
   },
   },
-  "homePage": "activity"
+  "homePage": "activity",
+  "language": "en"
 }
 }

+ 3 - 3
src/configs/server-config.json

@@ -3,7 +3,7 @@
     "level": "notice"
     "level": "notice"
   },
   },
   "caps": {
   "caps": {
-    "shs": "iKOzhqNVTcKEZvUhW3A7TuKZ1d6qIbtsGIJ6+SBOaEQ="
+    "shs": "1BIWr6Hu+MgtNkkClvg2GAi+0HiAikGOOTd/pIUcH54="
   },
   },
   "pub": false,
   "pub": false,
   "local": true,
   "local": true,
@@ -28,12 +28,12 @@
   },
   },
   "connections": {
   "connections": {
     "seeds": [
     "seeds": [
-      "net:solarnethub.com:8008~shs:HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519"
+      "net:solarnethub.com:8008~shs:zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519"
     ],
     ],
     "incoming": {
     "incoming": {
       "net": [
       "net": [
         {
         {
-          "scope": "device",
+          "scope": ["device", "local"],
           "transform": "shs",
           "transform": "shs",
           "port": 8008
           "port": 8008
         }
         }

+ 14 - 0
src/configs/shared-state.js

@@ -0,0 +1,14 @@
+let _inboxCount = 0;
+let _carbonHcT = 0;
+let _carbonHcH = 0;
+let _lastRefresh = 0;
+module.exports = {
+  getInboxCount: () => _inboxCount,
+  setInboxCount: (n) => { _inboxCount = n; },
+  getCarbonHcT: () => _carbonHcT,
+  setCarbonHcT: (n) => { _carbonHcT = n; },
+  getCarbonHcH: () => _carbonHcH,
+  setCarbonHcH: (n) => { _carbonHcH = n; },
+  getLastRefresh: () => _lastRefresh,
+  setLastRefresh: (t) => { _lastRefresh = t; }
+};

+ 4 - 0
src/configs/snh-invite-code.json

@@ -0,0 +1,4 @@
+{
+  "name": "SNH \"La Plaza\"",
+  "code": "solarnethub.com:8008:@zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519~rfJZF2kLO8E2UHFhquXNnjsRPW4CTuL+cgB7bBdsRiw="
+}

+ 1 - 1
src/configs/wallet-addresses.json

@@ -1 +1 @@
-{}
+{}

+ 6 - 3
src/models/activity_model.js

@@ -488,10 +488,13 @@ module.exports = ({ cooler }) => {
 
 
       deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
       deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
 
 
+      const tribeInternalTypes = new Set(['tribeLeave', 'tribeFeedPost', 'tribeFeedRefeed', 'tribe-content']);
+      const isAllowedTribeActivity = (a) => !tribeInternalTypes.has(a.type);
+
       let out;
       let out;
-      if (filter === 'mine') out = deduped.filter(a => a.author === userId);
-      else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff) }
-      else if (filter === 'all') out = deduped;
+      if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a));
+      else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a)) }
+      else if (filter === 'all') out = deduped.filter(isAllowedTribeActivity);
       else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim');
       else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim');
       else if (filter === 'karma') out = deduped.filter(a => a.type === 'karmaScore');
       else if (filter === 'karma') out = deduped.filter(a => a.type === 'karmaScore');
       else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
       else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));

+ 10 - 2
src/models/banking_model.js

@@ -272,6 +272,7 @@ module.exports = ({ services } = {}) => {
 
 
   async function getUserAddress(userId) {
   async function getUserAddress(userId) {
     const v = readAddrMap()[userId];
     const v = readAddrMap()[userId];
+    if (v === "__removed__") return null;
     const local = typeof v === "string" ? v : (v && v.address) || null;
     const local = typeof v === "string" ? v : (v && v.address) || null;
     if (local) return local;
     if (local) return local;
     const ssbAddr = await getWalletFromSSB(userId);
     const ssbAddr = await getWalletFromSSB(userId);
@@ -299,8 +300,14 @@ module.exports = ({ services } = {}) => {
   async function removeAddress({ userId }) {
   async function removeAddress({ userId }) {
     if (!userId) return { status: "invalid" };
     if (!userId) return { status: "invalid" };
     const m = readAddrMap();
     const m = readAddrMap();
-    if (!m[userId]) return { status: "not_found" };
-    delete m[userId];
+    if (m[userId]) {
+      delete m[userId];
+      writeAddrMap(m);
+      return { status: "deleted" };
+    }
+    const ssbAll = await scanAllWalletsSSB();
+    if (!ssbAll[userId]) return { status: "not_found" };
+    m[userId] = "__removed__";
     writeAddrMap(m);
     writeAddrMap(m);
     return { status: "deleted" };
     return { status: "deleted" };
   }
   }
@@ -311,6 +318,7 @@ module.exports = ({ services } = {}) => {
     const keys = new Set([...Object.keys(local), ...Object.keys(ssbAll)]);
     const keys = new Set([...Object.keys(local), ...Object.keys(ssbAll)]);
     const out = [];
     const out = [];
     for (const id of keys) {
     for (const id of keys) {
+      if (local[id] === "__removed__") continue;
       if (local[id]) out.push({ id, address: typeof local[id] === "string" ? local[id] : local[id].address, source: "local" });
       if (local[id]) out.push({ id, address: typeof local[id] === "string" ? local[id] : local[id].address, source: "local" });
       else if (ssbAll[id]) out.push({ id, address: ssbAll[id], source: "ssb" });
       else if (ssbAll[id]) out.push({ id, address: ssbAll[id], source: "ssb" });
     }
     }

+ 3 - 2
src/models/feed_model.js

@@ -95,7 +95,7 @@ module.exports = ({ cooler }) => {
     return idx.resolve(id);
     return idx.resolve(id);
   };
   };
 
 
-  const createFeed = async (text) => {
+  const createFeed = async (text, mentions) => {
     const ssbClient = await openSsb();
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
     const userId = ssbClient.id;
 
 
@@ -113,7 +113,8 @@ module.exports = ({ cooler }) => {
       text: cleaned,
       text: cleaned,
       author: userId,
       author: userId,
       createdAt: new Date().toISOString(),
       createdAt: new Date().toISOString(),
-      tags: extractTags(cleaned)
+      tags: extractTags(cleaned),
+      mentions: Array.isArray(mentions) && mentions.length > 0 ? mentions : undefined
     };
     };
 
 
     return new Promise((resolve, reject) => {
     return new Promise((resolve, reject) => {

+ 16 - 5
src/models/inhabitants_model.js

@@ -116,17 +116,25 @@ module.exports = ({ cooler }) => {
 
 
   return {
   return {
     async listInhabitants(options = {}) {
     async listInhabitants(options = {}) {
-      const { filter = 'all', search = '', location = '', language = '', skills = '' } = options;
+      const { filter = 'all', search = '', location = '', language = '', skills = '', includeInactive = false } = options;
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       const userId = ssbClient.id;
 
 
+      const filterInactive = (users) => {
+        if (includeInactive) return users;
+        return users.filter(u => u.lastActivityBucket !== 'red');
+      };
+
       if (filter === 'GALLERY') {
       if (filter === 'GALLERY') {
         const users = await listAllBase(ssbClient);
         const users = await listAllBase(ssbClient);
-        return users;
+        return filterInactive(users);
       }
       }
 
 
       if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY') {
       if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY') {
         let users = await listAllBase(ssbClient);
         let users = await listAllBase(ssbClient);
+        if (filter !== 'TOP ACTIVITY') {
+          users = filterInactive(users);
+        }
         if (search) {
         if (search) {
           const q = search.toLowerCase();
           const q = search.toLowerCase();
           users = users.filter(u =>
           users = users.filter(u =>
@@ -155,7 +163,7 @@ module.exports = ({ cooler }) => {
       }
       }
 
 
       if (filter === 'blocked') {
       if (filter === 'blocked') {
-        const all = await this.listInhabitants({ filter: 'all' });
+        const all = await this.listInhabitants({ filter: 'all', includeInactive: true });
         const result = [];
         const result = [];
         for (const user of all) {
         for (const user of all) {
           const rel = await friend.getRelationship(user.id).catch(() => ({}));
           const rel = await friend.getRelationship(user.id).catch(() => ({}));
@@ -167,8 +175,9 @@ module.exports = ({ cooler }) => {
 
 
       if (filter === 'SUGGESTED') {
       if (filter === 'SUGGESTED') {
         const base = await listAllBase(ssbClient);
         const base = await listAllBase(ssbClient);
+        const active = filterInactive(base);
         const rels = await Promise.all(
         const rels = await Promise.all(
-          base.map(async u => {
+          active.map(async u => {
             if (u.id === userId) return null;
             if (u.id === userId) return null;
             const rel = await friend.getRelationship(u.id).catch(() => ({}));
             const rel = await friend.getRelationship(u.id).catch(() => ({}));
             const n = normalizeRel(rel);
             const n = normalizeRel(rel);
@@ -209,6 +218,7 @@ module.exports = ({ cooler }) => {
             const base = this._normalizeCurriculum(c, photo);
             const base = this._normalizeCurriculum(c, photo);
             return { ...base, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
             return { ...base, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
           }));
           }));
+          out = filterInactive(out);
           if (search) {
           if (search) {
             const q = search.toLowerCase();
             const q = search.toLowerCase();
             out = out.filter(u =>
             out = out.filter(u =>
@@ -227,13 +237,14 @@ module.exports = ({ cooler }) => {
         }
         }
 
 
         if (filter === 'MATCHSKILLS') {
         if (filter === 'MATCHSKILLS') {
-          const base = await Promise.all(cvs.map(async c => {
+          let base = await Promise.all(cvs.map(async c => {
             const photo = await fetchUserImageUrl(c.author, 256);
             const photo = await fetchUserImageUrl(c.author, 256);
             const lastActivityTs = await getLastActivityTimestamp(c.author);
             const lastActivityTs = await getLastActivityTimestamp(c.author);
             const { bucket, range } = bucketLastActivity(lastActivityTs);
             const { bucket, range } = bucketLastActivity(lastActivityTs);
             const norm = this._normalizeCurriculum(c, photo);
             const norm = this._normalizeCurriculum(c, photo);
             return { ...norm, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
             return { ...norm, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
           }));
           }));
+          base = filterInactive(base);
           const mecv = await this.getCVByUserId();
           const mecv = await this.getCVByUserId();
           const userSkills = mecv
           const userSkills = mecv
             ? [
             ? [

+ 48 - 16
src/models/main_models.js

@@ -117,11 +117,16 @@ const canonicalizePubId = (s) => {
 };
 };
 
 
 const parseRemote = (remote) => {
 const parseRemote = (remote) => {
-  const m = /^net:([^:]+):\d+~shs:([^=]+)=/.exec(remote);
-  if (!m) return { host: null, pubId: null };
-  const host = m[1];
-  const pubId = canonicalizePubId(m[2]);
-  return { host, pubId };
+  // net: format (TCP)
+  let m = /^net:([^:]+):\d+~shs:([^=]+)=/.exec(remote);
+  if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) };
+  // ws/wss format (WebSocket)
+  m = /^wss?:\/\/([^:/]+)(?::\d+)?.*~shs:([^=]+)=/.exec(remote);
+  if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) };
+  // Generic: extract ~shs: part from any format
+  m = /~shs:([^=]+)=/.exec(remote);
+  if (m) return { host: null, pubId: canonicalizePubId(m[1]) };
+  return { host: null, pubId: null };
 };
 };
 
 
 async function ensureJSONFile(p, initial = []) {
 async function ensureJSONFile(p, initial = []) {
@@ -263,8 +268,9 @@ module.exports = ({ cooler, isPublic }) => {
   return Promise.all(
   return Promise.all(
     entries.map(async ([remote, data]) => {
     entries.map(async ([remote, data]) => {
       const { host, pubId } = parseRemote(remote);
       const { host, pubId } = parseRemote(remote);
-      const name = host || (pubId ? await models.about.name(pubId).catch(() => pubId) : remote);
-      const users = pubId && ebtMap.has(pubId) ? ebtMap.get(pubId) : [];
+      const effectiveKey = pubId || (data && data.key ? canonicalizePubId(data.key) : null);
+      const name = host || (effectiveKey ? await models.about.name(effectiveKey).catch(() => (effectiveKey || '').slice(0, 10)) : remote);
+      const users = effectiveKey && ebtMap.has(effectiveKey) ? ebtMap.get(effectiveKey) : [];
       const usersWithNames = await Promise.all(
       const usersWithNames = await Promise.all(
         users.map(async (user) => {
         users.map(async (user) => {
           const userName = await models.about.name(user.id).catch(() => user.id);
           const userName = await models.about.name(user.id).catch(() => user.id);
@@ -275,7 +281,7 @@ module.exports = ({ cooler, isPublic }) => {
         remote,
         remote,
         {
         {
           ...data,
           ...data,
-          key: pubId || remote,
+          key: effectiveKey || remote,
           name,
           name,
           users: usersWithNames
           users: usersWithNames
         }
         }
@@ -611,13 +617,40 @@ models.meta = {
     discovered: async () => {
     discovered: async () => {
       const ssb = await cooler.open();
       const ssb = await cooler.open();
       const snapshot = await ssb.conn.dbPeers();
       const snapshot = await ssb.conn.dbPeers();
-      const discoveredPeers = await enrichEntries(snapshot);
-      const discoveredIds = new Set(discoveredPeers.map(([, d]) => d.key));
+      // Read gossip.json to merge announcers data
+      const gossipPath = path.join(os.homedir(), '.ssb', 'gossip.json');
+      let gossipMap = new Map();
+      try {
+        const gossipData = JSON.parse(await fs.readFile(gossipPath, 'utf8'));
+        if (Array.isArray(gossipData)) {
+          for (const g of gossipData) {
+            if (g.key) gossipMap.set(canonicalizePubId(g.key), g);
+          }
+        }
+      } catch {}
+      const allDbPeers = await enrichEntries(snapshot);
+      // Merge announcers from gossip.json into enriched peers
+      for (const [, peerData] of allDbPeers) {
+        if ((!peerData.announcers || peerData.announcers === 0) && gossipMap.has(peerData.key)) {
+          const gossipEntry = gossipMap.get(peerData.key);
+          if (gossipEntry.announcers) peerData.announcers = gossipEntry.announcers;
+        }
+      }
+      const connectedEntries = await models.meta.connectedPeers();
+      const onlineKeys = new Set(connectedEntries.map(([remote]) => {
+        const m = /~shs:([^=]+)=/.exec(remote);
+        if (!m) return null;
+        let core = m[1].replace(/-/g, '+').replace(/_/g, '/');
+        if (!core.endsWith('=')) core += '=';
+        return `@${core}.ed25519`;
+      }).filter(Boolean));
+      const discoveredPeers = allDbPeers.filter(([, d]) => !onlineKeys.has(d.key));
+      const discoveredIds = new Set(allDbPeers.map(([, d]) => d.key));
       const ebtList = await loadPeersFromEbt();
       const ebtList = await loadPeersFromEbt();
       const ebtMap = new Map(ebtList.map(e => [e.pub, e.users]));
       const ebtMap = new Map(ebtList.map(e => [e.pub, e.users]));
       const unknownPeers = [];
       const unknownPeers = [];
       for (const { pub } of ebtList) {
       for (const { pub } of ebtList) {
-        if (!discoveredIds.has(pub)) {
+        if (!discoveredIds.has(pub) && !onlineKeys.has(pub)) {
           const name = await models.about.name(pub).catch(() => pub);
           const name = await models.about.name(pub).catch(() => pub);
           unknownPeers.push([pub, { key: pub, name, users: ebtMap.get(pub) || [] }]);
           unknownPeers.push([pub, { key: pub, name, users: ebtMap.get(pub) || [] }]);
         }
         }
@@ -1044,12 +1077,11 @@ const post = {
           }
           }
         }
         }
         const mentionsText = lodash.get(content, "text", "");
         const mentionsText = lodash.get(content, "text", "");
-        const mentionRegex = /<a class="mention" href="\/author\/([^"]+)">(@[^<]+)<\/a>/g;
+        if (mentionsText.includes(myFeedId) || mentionsText.includes(myFeedId.slice(1))) return true;
+        const mdMentionRegex = /\[@[^\]]*\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g;
         let match;
         let match;
-        while ((match = mentionRegex.exec(mentionsText))) {
-          if (match[1] === myFeedId || match[2] === myUsername || match[2] === '@' + myUsername) {
-            return true; 
-          }
+        while ((match = mdMentionRegex.exec(mentionsText))) {
+          if ('@' + match[1] === myFeedId || match[1] === myFeedId.slice(1)) return true;
         }
         }
         return false; 
         return false; 
       },
       },

+ 1 - 2
src/models/market_model.js

@@ -103,8 +103,7 @@ module.exports = ({ cooler }) => {
 
 
       let blobId = null
       let blobId = null
       if (image) {
       if (image) {
-        const match = String(image).match(/\(([^)]+)\)/)
-        blobId = match ? match[1] : image
+        blobId = String(image).trim() || null
       }
       }
 
 
       const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(",").map((t) => t.trim()).filter(Boolean)
       const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(",").map((t) => t.trim()).filter(Boolean)

+ 1 - 3
src/models/projects_model.js

@@ -28,9 +28,7 @@ module.exports = ({ cooler }) => {
   }
   }
 
 
   function extractBlobId(possibleMarkdownImage) {
   function extractBlobId(possibleMarkdownImage) {
-    let blobId = possibleMarkdownImage
-    if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1]
-    return blobId
+    return possibleMarkdownImage || null
   }
   }
 
 
   function normalizeMilestonesFrom(data) {
   function normalizeMilestonesFrom(data) {

+ 2 - 4
src/models/reports_model.js

@@ -54,8 +54,7 @@ module.exports = ({ cooler }) => {
 
 
       let blobId = null;
       let blobId = null;
       if (image) {
       if (image) {
-        const match = String(image).match(/\(([^)]+)\)/);
-        blobId = match ? match[1] : image;
+        blobId = String(image).trim() || null;
       }
       }
 
 
       const tags = Array.isArray(tagsRaw)
       const tags = Array.isArray(tagsRaw)
@@ -97,8 +96,7 @@ module.exports = ({ cooler }) => {
 
 
       let blobId = report.content.image || null;
       let blobId = report.content.image || null;
       if (updatedContent.image) {
       if (updatedContent.image) {
-        const match = String(updatedContent.image).match(/\(([^)]+)\)/);
-        blobId = match ? match[1] : updatedContent.image;
+        blobId = String(updatedContent.image).trim() || null;
       }
       }
 
 
       const nextStatus = Object.prototype.hasOwnProperty.call(updatedContent, 'status')
       const nextStatus = Object.prototype.hasOwnProperty.call(updatedContent, 'status')

+ 1 - 1
src/models/stats_model.js

@@ -119,7 +119,7 @@ module.exports = ({ cooler }) => {
 
 
     const messages = await new Promise((res, rej) => {
     const messages = await new Promise((res, rej) => {
       pull(
       pull(
-        ssbClient.createLogStream({ limit: logLimit }),
+        ssbClient.createLogStream({ limit: logLimit, reverse: true }),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
       );
     });
     });

+ 288 - 0
src/models/tribes_content_model.js

@@ -0,0 +1,288 @@
+const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
+
+const VALID_CONTENT_TYPES = ['event', 'task', 'report', 'votation', 'forum', 'forum-reply', 'market', 'job', 'project', 'media', 'feed', 'pixelia'];
+const categories = require('../backend/opinion_categories');
+const VALID_STATUSES = ['OPEN', 'CLOSED', 'IN-PROGRESS'];
+const VALID_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+
+  const TYPE = 'tribe-content';
+
+  const publish = async (content) => {
+    const ssbClient = await openSsb();
+    return new Promise((resolve, reject) =>
+      ssbClient.publish(content, (err, result) => err ? reject(err) : resolve(result))
+    );
+  };
+
+  const readLog = async () => {
+    const ssbClient = await openSsb();
+    return new Promise((resolve, reject) =>
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+      )
+    );
+  };
+
+  const buildIndex = (msgs, tribeId, contentType) => {
+    const tombstoned = new Set();
+    const replaced = new Map();
+    const items = new Map();
+
+    for (const m of msgs) {
+      const c = m.value?.content;
+      if (!c) continue;
+      if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
+      if (c.type !== TYPE) continue;
+      if (tribeId && c.tribeId !== tribeId) continue;
+      if (contentType && c.contentType !== contentType) continue;
+      if (c.replaces) replaced.set(c.replaces, m.key);
+      items.set(m.key, { id: m.key, ...c, _ts: m.value?.timestamp });
+    }
+
+    for (const id of tombstoned) items.delete(id);
+    for (const oldId of replaced.keys()) items.delete(oldId);
+
+    return [...items.values()].sort((a, b) => {
+      const ta = Date.parse(a.updatedAt || a.createdAt) || a._ts || 0;
+      const tb = Date.parse(b.updatedAt || b.createdAt) || b._ts || 0;
+      return tb - ta;
+    });
+  };
+
+  return {
+    async create(tribeId, contentType, data) {
+      if (!VALID_CONTENT_TYPES.includes(contentType)) {
+        throw new Error('Invalid content type');
+      }
+      if (data.status && !VALID_STATUSES.includes(data.status)) {
+        throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
+      }
+      if (data.priority && !VALID_PRIORITIES.includes(data.priority)) {
+        throw new Error('Invalid priority. Must be LOW, MEDIUM, HIGH, or CRITICAL');
+      }
+      const ssbClient = await openSsb();
+      const now = new Date().toISOString();
+      const content = {
+        type: TYPE,
+        tribeId,
+        contentType,
+        title: data.title || '',
+        description: data.description || '',
+        status: data.status || 'OPEN',
+        date: data.date || null,
+        location: data.location || null,
+        price: data.price || null,
+        salary: data.salary || null,
+        priority: data.priority || null,
+        assignees: data.assignees || [],
+        options: data.options || [],
+        votes: data.votes || {},
+        category: data.category || null,
+        parentId: data.parentId || null,
+        tags: data.tags || [],
+        image: data.image || null,
+        mediaType: data.mediaType || null,
+        url: data.url || null,
+        attendees: data.attendees || [],
+        deadline: data.deadline || null,
+        goal: data.goal || null,
+        funded: data.funded || 0,
+        refeeds: data.refeeds || 0,
+        refeeds_inhabitants: data.refeeds_inhabitants || [],
+        opinions: data.opinions || {},
+        opinions_inhabitants: data.opinions_inhabitants || [],
+        author: ssbClient.id,
+        createdAt: now,
+        updatedAt: now,
+      };
+      return publish(content);
+    },
+
+    async update(contentId, data, existing) {
+      if (!existing) existing = await this.getById(contentId);
+      if (!existing) throw new Error('Content not found');
+      if (data.status && !VALID_STATUSES.includes(data.status)) {
+        throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
+      }
+      if (data.priority && !VALID_PRIORITIES.includes(data.priority)) {
+        throw new Error('Invalid priority. Must be LOW, MEDIUM, HIGH, or CRITICAL');
+      }
+      const now = new Date().toISOString();
+      const updated = {
+        type: TYPE,
+        replaces: contentId,
+        tribeId: existing.tribeId,
+        contentType: existing.contentType,
+        title: data.title !== undefined ? data.title : existing.title,
+        description: data.description !== undefined ? data.description : existing.description,
+        status: data.status !== undefined ? data.status : existing.status,
+        date: data.date !== undefined ? data.date : existing.date,
+        location: data.location !== undefined ? data.location : existing.location,
+        price: data.price !== undefined ? data.price : existing.price,
+        salary: data.salary !== undefined ? data.salary : existing.salary,
+        priority: data.priority !== undefined ? data.priority : existing.priority,
+        assignees: data.assignees !== undefined ? data.assignees : existing.assignees,
+        options: data.options !== undefined ? data.options : existing.options,
+        votes: data.votes !== undefined ? data.votes : existing.votes,
+        category: data.category !== undefined ? data.category : existing.category,
+        parentId: data.parentId !== undefined ? data.parentId : existing.parentId,
+        tags: data.tags !== undefined ? data.tags : existing.tags,
+        image: data.image !== undefined ? data.image : existing.image,
+        mediaType: data.mediaType !== undefined ? data.mediaType : existing.mediaType,
+        url: data.url !== undefined ? data.url : existing.url,
+        attendees: data.attendees !== undefined ? data.attendees : existing.attendees,
+        deadline: data.deadline !== undefined ? data.deadline : existing.deadline,
+        goal: data.goal !== undefined ? data.goal : existing.goal,
+        funded: data.funded !== undefined ? data.funded : existing.funded,
+        refeeds: data.refeeds !== undefined ? data.refeeds : existing.refeeds,
+        refeeds_inhabitants: data.refeeds_inhabitants !== undefined ? data.refeeds_inhabitants : existing.refeeds_inhabitants,
+        opinions: data.opinions !== undefined ? data.opinions : existing.opinions,
+        opinions_inhabitants: data.opinions_inhabitants !== undefined ? data.opinions_inhabitants : existing.opinions_inhabitants,
+        author: existing.author,
+        createdAt: existing.createdAt,
+        updatedAt: now,
+      };
+      return publish(updated);
+    },
+
+    async deleteById(contentId) {
+      const ssbClient = await openSsb();
+      return publish({
+        type: 'tombstone',
+        target: contentId,
+        deletedAt: new Date().toISOString(),
+        author: ssbClient.id,
+      });
+    },
+
+    async getById(contentId) {
+      const msgs = await readLog();
+      const tombstoned = new Set();
+      const replaced = new Map();
+      const items = new Map();
+
+      for (const m of msgs) {
+        const c = m.value?.content;
+        if (!c) continue;
+        if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
+        if (c.type !== TYPE) continue;
+        if (c.replaces) replaced.set(c.replaces, m.key);
+        items.set(m.key, { id: m.key, ...c, _ts: m.value?.timestamp });
+      }
+
+      let latestId = contentId;
+      while (replaced.has(latestId)) latestId = replaced.get(latestId);
+      if (tombstoned.has(latestId)) return null;
+      return items.get(latestId) || null;
+    },
+
+    async listByTribe(tribeId, contentType, filter) {
+      const msgs = await readLog();
+      let items = buildIndex(msgs, tribeId, contentType);
+
+      if (filter === 'open') items = items.filter(i => i.status === 'OPEN');
+      if (filter === 'closed') items = items.filter(i => i.status === 'CLOSED');
+      if (filter === 'in-progress') items = items.filter(i => i.status === 'IN-PROGRESS');
+
+      return items;
+    },
+
+    async toggleAttendee(contentId) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const item = await this.getById(contentId);
+      if (!item) throw new Error('Content not found');
+      const attendees = Array.isArray(item.attendees) ? [...item.attendees] : [];
+      const idx = attendees.indexOf(userId);
+      if (idx === -1) attendees.push(userId);
+      else attendees.splice(idx, 1);
+      return this.update(contentId, { attendees }, item);
+    },
+
+    async toggleAssignee(contentId) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const item = await this.getById(contentId);
+      if (!item) throw new Error('Content not found');
+      const assignees = Array.isArray(item.assignees) ? [...item.assignees] : [];
+      const idx = assignees.indexOf(userId);
+      if (idx === -1) assignees.push(userId);
+      else assignees.splice(idx, 1);
+      return this.update(contentId, { assignees }, item);
+    },
+
+    async updateStatus(contentId, status) {
+      if (!VALID_STATUSES.includes(status)) {
+        throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
+      }
+      return this.update(contentId, { status });
+    },
+
+    async castVote(votationId, optionIndex) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const item = await this.getById(votationId);
+      if (!item) throw new Error('Votation not found');
+      if (item.status === 'CLOSED') throw new Error('Votation is closed');
+      const options = item.options || [];
+      if (!Number.isInteger(optionIndex) || optionIndex < 0 || optionIndex >= options.length) {
+        throw new Error('Invalid option index');
+      }
+      const votes = item.votes || {};
+      for (const key of Object.keys(votes)) {
+        const arr = Array.isArray(votes[key]) ? votes[key] : [];
+        if (arr.includes(userId)) throw new Error('Already voted');
+      }
+      const key = String(optionIndex);
+      if (!votes[key]) votes[key] = [];
+      votes[key].push(userId);
+      return this.update(votationId, { votes }, item);
+    },
+
+    async toggleRefeed(contentId) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const item = await this.getById(contentId);
+      if (!item) throw new Error('Content not found');
+      const inhabitants = Array.isArray(item.refeeds_inhabitants) ? [...item.refeeds_inhabitants] : [];
+      if (inhabitants.includes(userId)) return item;
+      inhabitants.push(userId);
+      return this.update(contentId, { refeeds: (item.refeeds || 0) + 1, refeeds_inhabitants: inhabitants }, item);
+    },
+
+    async castOpinion(contentId, category) {
+      if (!categories.includes(category)) throw new Error('Invalid opinion category');
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const item = await this.getById(contentId);
+      if (!item) throw new Error('Content not found');
+      const inhabitants = Array.isArray(item.opinions_inhabitants) ? [...item.opinions_inhabitants] : [];
+      if (inhabitants.includes(userId)) throw new Error('Already voted');
+      inhabitants.push(userId);
+      const opinions = { ...(item.opinions || {}), [category]: (item.opinions?.[category] || 0) + 1 };
+      return this.update(contentId, { opinions, opinions_inhabitants: inhabitants }, item);
+    },
+
+    async getThread(forumId) {
+      const msgs = await readLog();
+      const allItems = buildIndex(msgs, null, null);
+      const parent = allItems.find(i => i.id === forumId);
+      if (!parent) return { parent: null, replies: [] };
+      const replies = allItems
+        .filter(i => i.parentId === forumId && i.contentType === 'forum-reply')
+        .sort((a, b) => {
+          const ta = Date.parse(a.createdAt) || 0;
+          const tb = Date.parse(b.createdAt) || 0;
+          return ta - tb;
+        });
+      return { parent, replies };
+    },
+  };
+};

+ 148 - 237
src/models/tribes_model.js

@@ -1,21 +1,70 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
+const crypto = require('crypto');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
+const INVITE_CODE_BYTES = 16;
+const VALID_INVITE_MODES = ['strict', 'open'];
+
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
 
 
+  let tribeIndex = null;
+  let tribeIndexTs = 0;
+
+  const buildTribeIndex = async () => {
+    if (tribeIndex && Date.now() - tribeIndexTs < 5000) return tribeIndex;
+    const client = await openSsb();
+    return new Promise((resolve, reject) => {
+      pull(
+        client.createLogStream({ limit: logLimit }),
+        pull.collect((err, msgs) => {
+          if (err) return reject(err);
+          const tombstoned = new Set();
+          const parent = new Map();
+          const child = new Map();
+          const tribes = new Map();
+          for (const msg of msgs) {
+            const k = msg.key;
+            const c = msg.value?.content;
+            if (!c) continue;
+            if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
+            if (c.type !== 'tribe') continue;
+            if (c.replaces) {
+              parent.set(k, c.replaces);
+              child.set(c.replaces, k);
+            }
+            tribes.set(k, { id: k, content: c, _ts: msg.value?.timestamp });
+          }
+          const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur; };
+          const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur; };
+          const tipByRoot = new Map();
+          for (const k of tribes.keys()) {
+            const root = rootOf(k);
+            const tip = tipOf(root);
+            tipByRoot.set(root, tip);
+          }
+          tribeIndex = { tribes, tombstoned, parent, child, tipByRoot };
+          tribeIndexTs = Date.now();
+          resolve(tribeIndex);
+        })
+      );
+    });
+  };
+
   return {
   return {
     type: 'tribe',
     type: 'tribe',
 
 
-    async createTribe(title, description, image, location, tagsRaw = [], isLARP = false, isAnonymous = true, inviteMode = 'strict') {
+    async createTribe(title, description, image, location, tagsRaw = [], isLARP = false, isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN') {
+      if (!VALID_INVITE_MODES.includes(inviteMode)) {
+        throw new Error('Invalid invite mode. Must be "strict" or "open"');
+      }
       const ssb = await openSsb();
       const ssb = await openSsb();
       const userId = ssb.id;
       const userId = ssb.id;
       let blobId = null;
       let blobId = null;
       if (image) {
       if (image) {
-        const match = image.match(/\(([^)]+)\)/);
-        blobId = match ? match[1] : image;
+        blobId = String(image).trim() || null;
       }
       }
       const tags = Array.isArray(tagsRaw)
       const tags = Array.isArray(tagsRaw)
         ? tagsRaw.filter(Boolean)
         ? tagsRaw.filter(Boolean)
@@ -32,12 +81,15 @@ module.exports = ({ cooler }) => {
         members: [userId],
         members: [userId],
         invites: [],
         invites: [],
         inviteMode,
         inviteMode,
+        status: status || 'OPEN',
+        parentTribeId: parentTribeId || null,
         createdAt: new Date().toISOString(),
         createdAt: new Date().toISOString(),
         updatedAt: new Date().toISOString(),
         updatedAt: new Date().toISOString(),
         author: userId,
         author: userId,
-        feed: [],
       };
       };
-      return new Promise((res, rej) => ssb.publish(content, (e, r) => e ? rej(e) : res(r)));
+      const result = await new Promise((res, rej) => ssb.publish(content, (e, r) => e ? rej(e) : res(r)));
+      tribeIndex = null;
+      return result;
     },
     },
 
 
     async generateInvite(tribeId) {
     async generateInvite(tribeId) {
@@ -50,34 +102,14 @@ module.exports = ({ cooler }) => {
       if (tribe.inviteMode === 'open' && !tribe.members.includes(userId)) {
       if (tribe.inviteMode === 'open' && !tribe.members.includes(userId)) {
         throw new Error('Only tribe members can generate invites in open mode');
         throw new Error('Only tribe members can generate invites in open mode');
       }
       }
-      const code = Math.random().toString(36).substring(2, 10);
+      const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
       const invites = Array.isArray(tribe.invites) ? [...tribe.invites, code] : [code];
       const invites = Array.isArray(tribe.invites) ? [...tribe.invites, code] : [code];
       await this.updateTribeInvites(tribeId, invites);
       await this.updateTribeInvites(tribeId, invites);
       return code;
       return code;
     },
     },
 
 
     async updateTribeInvites(tribeId, invites) {
     async updateTribeInvites(tribeId, invites) {
-      const ssb = await openSsb();
-      const tribe = await this.getTribeById(tribeId);
-      const updatedTribe = {
-        type: 'tribe',
-        replaces: tribeId,
-        title: tribe.title,
-        description: tribe.description,
-        image: tribe.image,
-        location: tribe.location,
-        tags: tribe.tags,
-        isLARP: tribe.isLARP,
-        isAnonymous: tribe.isAnonymous,
-        members: tribe.members,
-        invites: invites,
-        inviteMode: tribe.inviteMode,
-        createdAt: tribe.createdAt,
-        updatedAt: new Date().toISOString(),
-        author: tribe.author,
-        feed: tribe.feed
-      };
-      return this.publishUpdatedTribe(tribeId, updatedTribe);
+      return this.updateTribeById(tribeId, { invites });
     },
     },
 
 
     async leaveTribe(tribeId) {
     async leaveTribe(tribeId) {
@@ -85,68 +117,29 @@ module.exports = ({ cooler }) => {
       const userId = ssb.id;
       const userId = ssb.id;
       const tribe = await this.getTribeById(tribeId);
       const tribe = await this.getTribeById(tribeId);
       if (!tribe) throw new Error('Tribe not found');
       if (!tribe) throw new Error('Tribe not found');
+      if (tribe.author === userId) {
+        throw new Error('Tribe author cannot leave their own tribe');
+      }
       const members = Array.isArray(tribe.members) ? [...tribe.members] : [];
       const members = Array.isArray(tribe.members) ? [...tribe.members] : [];
       const idx = members.indexOf(userId);
       const idx = members.indexOf(userId);
-      if (idx === -1) throw new Error('Inhabitant is not a member of the tribe');
+      if (idx === -1) throw new Error('User is not a member of this tribe');
       members.splice(idx, 1);
       members.splice(idx, 1);
-      const updatedTribe = {
-        type: 'tribe',
-        replaces: tribeId,
-        title: tribe.title,
-        description: tribe.description,
-        image: tribe.image,
-        location: tribe.location,
-        tags: tribe.tags,
-        isLARP: tribe.isLARP,
-        isAnonymous: tribe.isAnonymous,
-        members: members,
-        invites: tribe.invites,
-        inviteMode: tribe.inviteMode,
-        createdAt: tribe.createdAt,
-        updatedAt: new Date().toISOString(),
-        author: tribe.author,
-        feed: tribe.feed
-      };
-      return new Promise((resolve, reject) => {
-        ssb.publish(updatedTribe, (err, result) => err ? reject(err) : resolve(result));
-      });
+      return this.updateTribeById(tribeId, { members });
     },
     },
 
 
     async joinByInvite(code) {
     async joinByInvite(code) {
       const ssb = await openSsb();
       const ssb = await openSsb();
       const userId = ssb.id;
       const userId = ssb.id;
       const tribes = await this.listAll();
       const tribes = await this.listAll();
-      const latestTribe = tribes.find(tribe => tribe.invites && tribe.invites.includes(code));
-      if (!latestTribe) {
-        return new Promise((_, rej) => rej(new Error('Invalid or expired invite code.')));
-      }
-      const tribe = latestTribe;
-      if (!tribe.invites.includes(code)) {
-        return new Promise((_, rej) => rej(new Error('Invalid or expired invite code.')));
+      const tribe = tribes.find(t => t.invites && t.invites.includes(code));
+      if (!tribe) throw new Error('Invalid or expired invite code');
+      if (tribe.members.includes(userId)) {
+        throw new Error('Already a member of this tribe');
       }
       }
-      const members = Array.isArray(tribe.members) ? [...tribe.members] : [];
-      if (!members.includes(userId)) members.push(userId);
-      const updatedInvites = tribe.invites.filter(c => c !== code);
-      const updatedTribe = {
-        type: 'tribe',
-        replaces: tribe.id,
-        title: tribe.title,
-        description: tribe.description,
-        image: tribe.image,
-        location: tribe.location,
-        tags: tribe.tags,
-        isLARP: tribe.isLARP,
-        isAnonymous: tribe.isAnonymous,
-        members: members,
-        invites: updatedInvites,
-        inviteMode: tribe.inviteMode,
-        createdAt: tribe.createdAt,
-        updatedAt: new Date().toISOString(),
-        author: tribe.author,
-        feed: tribe.feed
-      };
-      await this.publishUpdatedTribe(tribe.id, updatedTribe);
-      return new Promise((res) => res(tribe.id));
+      const members = [...tribe.members, userId];
+      const invites = tribe.invites.filter(c => c !== code);
+      await this.updateTribeById(tribe.id, { members, invites });
+      return tribe.id;
     },
     },
 
 
     async deleteTribeById(tribeId) {
     async deleteTribeById(tribeId) {
@@ -154,51 +147,7 @@ module.exports = ({ cooler }) => {
     },
     },
 
 
     async updateTribeMembers(tribeId, members) {
     async updateTribeMembers(tribeId, members) {
-      const ssb = await openSsb();
-      const tribe = await this.getTribeById(tribeId);
-      const updatedTribe = {
-        type: 'tribe',
-        replaces: tribeId,
-        title: tribe.title,
-        description: tribe.description,
-        image: tribe.image,
-        location: tribe.location,
-        tags: tribe.tags,
-        isLARP: tribe.isLARP,
-        isAnonymous: tribe.isAnonymous,
-        members: members,
-        invites: tribe.invites,
-        inviteMode: tribe.inviteMode,
-        createdAt: tribe.createdAt,
-        updatedAt: new Date().toISOString(),
-        author: tribe.author,
-        feed: tribe.feed
-      };
-      return this.publishUpdatedTribe(tribeId, updatedTribe);
-    },
-
-    async updateTribeFeed(tribeId, newFeed) {
-      const ssb = await openSsb();
-      const tribe = await this.getTribeById(tribeId);
-      const updatedTribe = {
-        type: 'tribe',
-        replaces: tribeId,
-        title: tribe.title,
-        description: tribe.description,
-        image: tribe.image,
-        location: tribe.location,
-        tags: tribe.tags,
-        isLARP: tribe.isLARP,
-        isAnonymous: tribe.isAnonymous,
-        members: tribe.members,
-        invites: tribe.invites,
-        inviteMode: tribe.inviteMode,
-        createdAt: tribe.createdAt,
-        updatedAt: new Date().toISOString(),
-        author: tribe.author,
-        feed: newFeed
-      };
-      return this.publishUpdatedTribe(tribeId, updatedTribe);
+      return this.updateTribeById(tribeId, { members });
     },
     },
 
 
     async publishUpdatedTribe(tribeId, updatedTribe) {
     async publishUpdatedTribe(tribeId, updatedTribe) {
@@ -216,103 +165,85 @@ module.exports = ({ cooler }) => {
         members: updatedTribe.members,
         members: updatedTribe.members,
         invites: updatedTribe.invites,
         invites: updatedTribe.invites,
         inviteMode: updatedTribe.inviteMode,
         inviteMode: updatedTribe.inviteMode,
+        status: updatedTribe.status || 'OPEN',
+        parentTribeId: updatedTribe.parentTribeId || null,
         createdAt: updatedTribe.createdAt,
         createdAt: updatedTribe.createdAt,
         updatedAt: new Date().toISOString(),
         updatedAt: new Date().toISOString(),
         author: updatedTribe.author,
         author: updatedTribe.author,
-        feed: updatedTribe.feed
       };
       };
-      return new Promise((resolve, reject) => {
+      const result = await new Promise((resolve, reject) => {
          ssb.publish(updatedTribeData, (err, result) => err ? reject(err) : resolve(result));
          ssb.publish(updatedTribeData, (err, result) => err ? reject(err) : resolve(result));
       });
       });
+      tribeIndex = null;
+      return result;
     },
     },
 
 
     async getTribeById(tribeId) {
     async getTribeById(tribeId) {
-      const ssb = await openSsb();
-      return new Promise((res, rej) => pull(
-        ssb.createLogStream({ limit: logLimit }),
-        pull.collect((err, msgs) => {
-          if (err) return rej(err);
-          const tombstoned = new Set();
-          const replaces = new Map();
-          const tribes = new Map();
-          for (const msg of msgs) {
-            const k = msg.key;
-            const c = msg.value?.content;
-            if (!c) continue;
-            if (c.type === 'tombstone' && c.target) tombstoned.add(c.target);
-            if (c.type === 'tribe') {
-              if (tombstoned.has(k)) continue;
-              if (c.replaces) replaces.set(c.replaces, k);
-              tribes.set(k, { id: k, content: c });
-            }
-          }
-          let latestId = tribeId;
-          while (replaces.has(latestId)) latestId = replaces.get(latestId);
-          const tribe = tribes.get(latestId);
-          if (!tribe) return rej(new Error('Tribe not found'));
-          res({
-            id: tribe.id,
-            title: tribe.content.title,
-            description: tribe.content.description,
-            image: tribe.content.image || null,
-            location: tribe.content.location,
-            tags: Array.isArray(tribe.content.tags) ? tribe.content.tags : [],
-            isLARP: tribe.content.isLARP,
-            isAnonymous: tribe.content.isAnonymous,
-            members: Array.isArray(tribe.content.members) ? tribe.content.members : [],
-            invites: Array.isArray(tribe.content.invites) ? tribe.content.invites : [],
-            inviteMode: tribe.content.inviteMode || 'strict',
-            createdAt: tribe.content.createdAt,
-            updatedAt: tribe.content.updatedAt,
-            author: tribe.content.author,
-            feed: Array.isArray(tribe.content.feed) ? tribe.content.feed : []
-          });
-        })
-      ));
+      const { tribes, tombstoned, child } = await buildTribeIndex();
+      let latestId = tribeId;
+      while (child.has(latestId)) latestId = child.get(latestId);
+      if (tombstoned.has(latestId)) throw new Error('Tribe not found');
+      const tribe = tribes.get(latestId);
+      if (!tribe) throw new Error('Tribe not found');
+      return {
+        id: tribe.id,
+        title: tribe.content.title,
+        description: tribe.content.description,
+        image: tribe.content.image || null,
+        location: tribe.content.location,
+        tags: Array.isArray(tribe.content.tags) ? tribe.content.tags : [],
+        isLARP: !!tribe.content.isLARP,
+        isAnonymous: tribe.content.isAnonymous,
+        members: Array.isArray(tribe.content.members) ? tribe.content.members : [],
+        invites: Array.isArray(tribe.content.invites) ? tribe.content.invites : [],
+        inviteMode: tribe.content.inviteMode || 'strict',
+        status: tribe.content.status || 'OPEN',
+        parentTribeId: tribe.content.parentTribeId || null,
+        createdAt: tribe.content.createdAt,
+        updatedAt: tribe.content.updatedAt,
+        author: tribe.content.author,
+      };
     },
     },
 
 
-     async listAll() {
-      const ssb = await openSsb();
-      return new Promise((res, rej) => pull(
-        ssb.createLogStream({ limit: logLimit }),
-        pull.collect((err, msgs) => {
-          if (err) return rej(err);
-          const norm = s => (s || '').toString().trim().toLowerCase();
-          const pickNewest = (a, b) => {
-            const ta = Date.parse(a.updatedAt || a.createdAt) || a._ts || 0;
-            const tb = Date.parse(b.updatedAt || b.createdAt) || b._ts || 0;
-            return tb > ta ? b : a;
-          };
-          const byKey = new Map();
-          for (const m of msgs) {
-            const c = m.value?.content;
-            if (!c || c.type !== 'tribe') continue;
-            const item = {
-              id: m.key,
-              type: c.type,
-              title: c.title,
-              description: c.description,
-              image: c.image || null,
-              location: c.location,
-              tags: Array.isArray(c.tags) ? c.tags : [],
-              isLARP: !!c.isLARP,
-              isAnonymous: c.isAnonymous !== false,
-              members: Array.isArray(c.members) ? c.members : [],
-              invites: Array.isArray(c.invites) ? c.invites : [],
-              inviteMode: c.inviteMode || 'strict',
-              createdAt: c.createdAt,
-              updatedAt: c.updatedAt,
-              author: c.author,
-              feed: Array.isArray(c.feed) ? c.feed : [],
-              _ts: m.value?.timestamp
-            };
-            const key = `${norm(item.title)}::${norm(item.author)}`;
-            if (!byKey.has(key)) byKey.set(key, item);
-            else byKey.set(key, pickNewest(byKey.get(key), item));
-          }
-          res(Array.from(byKey.values()));
-        })
-      ));
+    async listAll() {
+      const { tribes, tombstoned, tipByRoot } = await buildTribeIndex();
+      const items = [];
+      for (const [root, tip] of tipByRoot) {
+        if (tombstoned.has(root) || tombstoned.has(tip)) continue;
+        const entry = tribes.get(tip);
+        if (!entry) continue;
+        const c = entry.content;
+        items.push({
+          id: tip,
+          title: c.title,
+          description: c.description,
+          image: c.image || null,
+          location: c.location,
+          tags: Array.isArray(c.tags) ? c.tags : [],
+          isLARP: !!c.isLARP,
+          isAnonymous: c.isAnonymous !== false,
+          members: Array.isArray(c.members) ? c.members : [],
+          invites: Array.isArray(c.invites) ? c.invites : [],
+          inviteMode: c.inviteMode || 'strict',
+          status: c.status || 'OPEN',
+          parentTribeId: c.parentTribeId || null,
+          createdAt: c.createdAt,
+          updatedAt: c.updatedAt,
+          author: c.author,
+          _ts: entry._ts
+        });
+      }
+      return items;
+    },
+
+    async getChainIds(tribeId) {
+      const { parent, child } = await buildTribeIndex();
+      let root = tribeId;
+      while (parent.has(root)) root = parent.get(root);
+      const ids = [root];
+      let cur = root;
+      while (child.has(cur)) { cur = child.get(cur); ids.push(cur); }
+      return ids;
     },
     },
     
     
     async updateTribeById(tribeId, updatedContent) {
     async updateTribeById(tribeId, updatedContent) {
@@ -344,35 +275,15 @@ module.exports = ({ cooler }) => {
           resolve();
           resolve();
         });
         });
       });
       });
-      return;
-    },
-
-    async refeed(tribeId, messageId) {
-      const ssb = await openSsb();
-      const userId = ssb.id;
-      const tribe = await this.getTribeById(tribeId);
-      if (!tribe.isAnonymous && !tribe.members.includes(userId)) throw new Error('Not a member');
-      const feed = tribe.feed.map(item => {
-        item.refeeds_inhabitants = item.refeeds_inhabitants || [];
-        if (item.id === messageId && !item.refeeds_inhabitants.includes(userId)) {
-          item.refeeds = (item.refeeds || 0) + 1;
-          item.refeeds_inhabitants.push(userId);
-        }
-        return item;
-      });
-      await this.updateTribeFeed(tribeId, feed);
+      tribeIndex = null;
     },
     },
 
 
-    async postMessage(tribeId, message) {
-      const ssb = await openSsb();
-      const userId = ssb.id;
-      const tribe = await this.getTribeById(tribeId);
-      if (!tribe.isAnonymous && !tribe.members.includes(userId)) throw new Error('Not a member');
-      const now = Date.now();
-      const feedItem = { type: 'feed', id: now.toString(), date: now, author: userId, message, refeeds: 0, refeeds_inhabitants: [] };
-      const feed = [...tribe.feed, feedItem];
-      await this.updateTribeFeed(tribeId, feed);
-      return feedItem;
+    async listSubTribes(parentId) {
+      const idx = await buildTribeIndex();
+      const rootOf = (id) => { let cur = id; while (idx.parent.has(cur)) cur = idx.parent.get(cur); return cur; };
+      const parentRoot = rootOf(parentId);
+      const all = await this.listAll();
+      return all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot);
     }
     }
   };
   };
 };
 };

Разница между файлами не показана из-за своего большого размера
+ 6342 - 20806
src/server/package-lock.json


+ 4 - 51
src/server/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@krakenslab/oasis",
   "name": "@krakenslab/oasis",
-  "version": "0.6.5",
+  "version": "0.6.6",
   "description": "Oasis Social Networking Project Utopia",
   "description": "Oasis Social Networking Project Utopia",
   "repository": {
   "repository": {
     "type": "git",
     "type": "git",
@@ -16,46 +16,32 @@
     "start": "npm run start:ssb && sleep 10 && npm run start:backend",
     "start": "npm run start:ssb && sleep 10 && npm run start:backend",
     "start:backend": "node ../backend/backend.js",
     "start:backend": "node ../backend/backend.js",
     "start:ssb": "node SSB_server.js start &",
     "start:ssb": "node SSB_server.js start &",
-    "fix": "common-good fix",
-    "postinstall": "node ../../scripts/patch-node-modules.js",
-    "prestart": "",
-    "test": "tap --timeout 240 && common-good test",
-    "preversion": "npm test",
-    "version": "mv docs/CHANGELOG.md ./ && mv CHANGELOG.md docs/ && git add docs/CHANGELOG.md"
+    "postinstall": "node ../../scripts/patch-node-modules.js"
   },
   },
   "dependencies": {
   "dependencies": {
     "@koa/router": "^13.1.0",
     "@koa/router": "^13.1.0",
     "@open-rpc/client-js": "^1.8.1",
     "@open-rpc/client-js": "^1.8.1",
     "abstract-level": "^2.0.1",
     "abstract-level": "^2.0.1",
     "archiver": "^7.0.1",
     "archiver": "^7.0.1",
-    "await-exec": "^0.1.2",
     "axios": "^1.10.0",
     "axios": "^1.10.0",
-    "base64-url": "^2.3.3",
-    "broadcast-stream": "^0.2.1",
-    "caller-path": "^4.0.0",
     "cors": "^2.8.5",
     "cors": "^2.8.5",
-    "crypto": "^1.0.1",
     "debug": "^4.3.1",
     "debug": "^4.3.1",
+    "dompurify": "^3.3.1",
     "env-paths": "^2.2.1",
     "env-paths": "^2.2.1",
     "epidemic-broadcast-trees": "^9.0.4",
     "epidemic-broadcast-trees": "^9.0.4",
     "express": "^5.1.0",
     "express": "^5.1.0",
     "file-type": "^16.5.4",
     "file-type": "^16.5.4",
-    "gpt-3-encoder": "^1.1.4",
-    "has-network": "0.0.1",
     "highlight.js": "11.0.0",
     "highlight.js": "11.0.0",
     "hyperaxe": "^2.0.1",
     "hyperaxe": "^2.0.1",
     "ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz",
     "ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz",
     "is-svg": "^4.4.0",
     "is-svg": "^4.4.0",
-    "is-valid-domain": "^0.1.6",
+    "jsdom": "^28.0.0",
     "koa": "^2.7.0",
     "koa": "^2.7.0",
     "koa-body": "^6.0.1",
     "koa-body": "^6.0.1",
-    "koa-bodyparser": "^4.4.1",
     "koa-mount": "^4.0.0",
     "koa-mount": "^4.0.0",
     "koa-static": "^5.0.0",
     "koa-static": "^5.0.0",
     "lodash": "^4.17.21",
     "lodash": "^4.17.21",
-    "lodash.shuffle": "^4.2.0",
     "minimist": "^1.2.8",
     "minimist": "^1.2.8",
-    "mkdirp": "^3.0.1",
     "module-alias": "^2.2.3",
     "module-alias": "^2.2.3",
     "moment": "^2.30.1",
     "moment": "^2.30.1",
     "multiblob": "^1.13.0",
     "multiblob": "^1.13.0",
@@ -64,14 +50,12 @@
     "muxrpc": "^8.0.0",
     "muxrpc": "^8.0.0",
     "muxrpc-validation": "^3.0.2",
     "muxrpc-validation": "^3.0.2",
     "muxrpcli": "^3.1.2",
     "muxrpcli": "^3.1.2",
-    "node-iframe": "^1.8.5",
     "node-llama-cpp": "^3.10.0",
     "node-llama-cpp": "^3.10.0",
     "non-private-ip": "^2.2.0",
     "non-private-ip": "^2.2.0",
     "open": "^8.4.2",
     "open": "^8.4.2",
     "packet-stream": "^2.0.6",
     "packet-stream": "^2.0.6",
     "packet-stream-codec": "^1.2.0",
     "packet-stream-codec": "^1.2.0",
     "pdfjs-dist": "^5.2.133",
     "pdfjs-dist": "^5.2.133",
-    "piexifjs": "^1.0.4",
     "pretty-ms": "^7.0.1",
     "pretty-ms": "^7.0.1",
     "pull-abortable": "^4.1.1",
     "pull-abortable": "^4.1.1",
     "pull-cat": "~1.1.5",
     "pull-cat": "~1.1.5",
@@ -83,8 +67,6 @@
     "pull-stream": "^3.7.0",
     "pull-stream": "^3.7.0",
     "punycode.js": "^2.3.1",
     "punycode.js": "^2.3.1",
     "qrcode": "^1.5.4",
     "qrcode": "^1.5.4",
-    "remark-html": "^16.0.1",
-    "require-style": "^1.1.0",
     "secret-stack": "^6.3.1",
     "secret-stack": "^6.3.1",
     "ssb-about": "^2.0.1",
     "ssb-about": "^2.0.1",
     "ssb-autofollow": "^1.1.0",
     "ssb-autofollow": "^1.1.0",
@@ -110,7 +92,6 @@
     "ssb-lan": "^1.0.0",
     "ssb-lan": "^1.0.0",
     "ssb-legacy-conn": "^1.0.17",
     "ssb-legacy-conn": "^1.0.17",
     "ssb-links": "^3.0.10",
     "ssb-links": "^3.0.10",
-    "ssb-local": "^1.0.0",
     "ssb-logging": "^1.0.0",
     "ssb-logging": "^1.0.0",
     "ssb-markdown": "^3.6.0",
     "ssb-markdown": "^3.6.0",
     "ssb-master": "^1.0.3",
     "ssb-master": "^1.0.3",
@@ -125,23 +106,18 @@
     "ssb-query": "^2.4.5",
     "ssb-query": "^2.4.5",
     "ssb-ref": "^2.16.0",
     "ssb-ref": "^2.16.0",
     "ssb-replication-scheduler": "^3.0.0",
     "ssb-replication-scheduler": "^3.0.0",
-    "ssb-room": "^0.0.10",
     "ssb-search": "^1.3.0",
     "ssb-search": "^1.3.0",
     "ssb-server": "file:packages/ssb-server",
     "ssb-server": "file:packages/ssb-server",
     "ssb-tangle": "^1.0.1",
     "ssb-tangle": "^1.0.1",
     "ssb-thread-schema": "^1.1.1",
     "ssb-thread-schema": "^1.1.1",
     "ssb-threads": "^10.0.4",
     "ssb-threads": "^10.0.4",
-    "ssb-tunnel": "^2.0.0",
     "ssb-unix-socket": "^1.0.0",
     "ssb-unix-socket": "^1.0.0",
     "ssb-ws": "^6.2.3",
     "ssb-ws": "^6.2.3",
-    "tokenizers-linux-x64-gnu": "^0.13.4-rc1",
-    "unzipper": "^0.12.3",
     "util": "^0.12.5",
     "util": "^0.12.5",
     "yargs": "^17.7.2"
     "yargs": "^17.7.2"
   },
   },
   "overrides": {
   "overrides": {
     "caller-path": "^4.0.0",
     "caller-path": "^4.0.0",
-    "is-valid-domain": "^0.1.6",
     "highlight.js": "11.0.0",
     "highlight.js": "11.0.0",
     "@babel/traverse": "7.23.2",
     "@babel/traverse": "7.23.2",
     "trim": "0.0.3",
     "trim": "0.0.3",
@@ -157,29 +133,6 @@
     "ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz",
     "ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz",
     "lodash.set": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
     "lodash.set": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
   },
   },
-  "devDependencies": {
-    "@types/debug": "^4.1.5",
-    "@types/koa": "^2.11.3",
-    "@types/koa__router": "^12.0.4",
-    "@types/koa-mount": "^4.0.0",
-    "@types/koa-static": "^4.0.1",
-    "@types/lodash": "^4.14.150",
-    "@types/mkdirp": "^2.0.0",
-    "@types/nodemon": "^1.19.0",
-    "@types/pull-stream": "^3.6.0",
-    "@types/sharp": "^0.32.0",
-    "@types/supertest": "^6.0.2",
-    "@types/yargs": "^17.0.2",
-    "changelog-version": "^2.0.0",
-    "common-good": "^4.0.3",
-    "husky": "^9.1.7",
-    "nodemon": "^3.1.7",
-    "npm-force-resolutions": "^0.0.10",
-    "patch-package": "^8.0.0",
-    "stylelint-config-recommended": "^14.0.1",
-    "supertest": "^7.0.0",
-    "tap": "^21.0.1"
-  },
   "optionalDependencies": {
   "optionalDependencies": {
     "fsevents": "^2.3.2",
     "fsevents": "^2.3.2",
     "sharp": "^0.33.5"
     "sharp": "^0.33.5"

+ 63 - 22
src/views/activity_view.js

@@ -3,6 +3,21 @@ const { template, i18n } = require('./main_views');
 const moment = require("../server/node_modules/moment");
 const moment = require("../server/node_modules/moment");
 const { renderUrl } = require('../backend/renderUrl');
 const { renderUrl } = require('../backend/renderUrl');
 const { getConfig } = require("../configs/config-manager.js");
 const { getConfig } = require("../configs/config-manager.js");
+const { sanitizeHtml } = require('../backend/sanitizeHtml');
+
+const renderMediaBlob = (value, fallbackSrc = null) => {
+  if (!value) return fallbackSrc ? img({ src: fallbackSrc }) : null
+  const s = String(value).trim()
+  if (!s) return fallbackSrc ? img({ src: fallbackSrc }) : null
+  if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}` })
+  const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
+  if (mVideo) return videoHyperaxe({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` })
+  const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
+  if (mAudio) return audioHyperaxe({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` })
+  const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
+  if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: 'post-image' })
+  return fallbackSrc ? img({ src: fallbackSrc }) : null
+}
 
 
 const FEED_TEXT_MIN = Number(getConfig().feed?.minLength ?? 1);
 const FEED_TEXT_MIN = Number(getConfig().feed?.minLength ?? 1);
 const FEED_TEXT_MAX = Number(getConfig().feed?.maxLength ?? 280);
 const FEED_TEXT_MAX = Number(getConfig().feed?.maxLength ?? 280);
@@ -436,7 +451,7 @@ function renderActionCards(actions, userId, allActions) {
          ), 
          ), 
           Array.isArray(members) ? h2(`${i18n.tribeMembersCount}: ${members.length}`) : "",
           Array.isArray(members) ? h2(`${i18n.tribeMembersCount}: ${members.length}`) : "",
           image  
           image  
-            ? img({ src: `/blob/${encodeURIComponent(image)}`, class: 'feed-image tribe-image' })
+            ? renderMediaBlob(image, '/assets/images/default-tribe.png')
             : img({ src: '/assets/images/default-tribe.png', class: 'feed-image tribe-image' }),
             : img({ src: '/assets/images/default-tribe.png', class: 'feed-image tribe-image' }),
           p({ class: 'tribe-description' }, ...renderUrl(description || '')),
           p({ class: 'tribe-description' }, ...renderUrl(description || '')),
           validTags.length
           validTags.length
@@ -675,7 +690,7 @@ function renderActionCards(actions, userId, allActions) {
       const refeedsNum = Number(refeeds || 0) || 0;
       const refeedsNum = Number(refeeds || 0) || 0;
       cardBody.push(
       cardBody.push(
         div({ class: 'card-section feed' },
         div({ class: 'card-section feed' },
-          div({ class: 'feed-text', innerHTML: htmlText }),
+          div({ class: 'feed-text', innerHTML: sanitizeHtml(htmlText) }),
           refeedsNum > 0
           refeedsNum > 0
             ? h2({ class: 'card-field' },
             ? h2({ class: 'card-field' },
                 span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '),
                 span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '),
@@ -689,20 +704,23 @@ function renderActionCards(actions, userId, allActions) {
   if (type === 'post') {
   if (type === 'post') {
       const { contentWarning, text } = content || {};
       const { contentWarning, text } = content || {};
       const rawText = text || '';
       const rawText = text || '';
-      const isHtml = typeof rawText === 'string' && /<\/?[a-z][\s\S]*>/i.test(rawText);
+      const POST_TRUNCATE_LEN = 300;
+      const isTruncated = rawText.length > POST_TRUNCATE_LEN;
+      const displayText = isTruncated ? rawText.slice(0, POST_TRUNCATE_LEN) + '…' : rawText;
+      const isHtml = typeof displayText === 'string' && /<\/?[a-z][\s\S]*>/i.test(displayText);
       let bodyNode;
       let bodyNode;
       if (isHtml) {
       if (isHtml) {
-        const hasAnchor = /<a\b[^>]*>/i.test(rawText);
+        const hasAnchor = /<a\b[^>]*>/i.test(displayText);
         const linkified = hasAnchor
         const linkified = hasAnchor
-          ? rawText
-          : rawText.replace(
+          ? displayText
+          : displayText.replace(
               /(https?:\/\/[^\s<]+)/g,
               /(https?:\/\/[^\s<]+)/g,
               (url) =>
               (url) =>
                 `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
                 `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
             );
             );
-       bodyNode = div({ class: 'post-text', innerHTML: linkified });
+       bodyNode = div({ class: 'post-text', style: 'max-height:180px;overflow:hidden;', innerHTML: sanitizeHtml(linkified) });
       } else {
       } else {
-        bodyNode = p({ class: 'post-text post-text-pre' }, ...renderUrlPreserveNewlines(rawText));
+        bodyNode = p({ class: 'post-text post-text-pre', style: 'max-height:180px;overflow:hidden;' }, ...renderUrlPreserveNewlines(displayText));
       }
       }
       const threadId = getThreadIdFromPost(action);
       const threadId = getThreadIdFromPost(action);
       const replyToId = getReplyToIdFromPost(action, byIdAll);
       const replyToId = getReplyToIdFromPost(action, byIdAll);
@@ -711,19 +729,27 @@ function renderActionCards(actions, userId, allActions) {
       const parent = isReply ? (byIdAll.get(replyToId) || byIdAll.get(threadId)) : null;
       const parent = isReply ? (byIdAll.get(replyToId) || byIdAll.get(threadId)) : null;
       const parentContent = parent ? (parent.value?.content || parent.content || {}) : {};
       const parentContent = parent ? (parent.value?.content || parent.content || {}) : {};
       const parentAuthor = parent?.author || '';
       const parentAuthor = parent?.author || '';
+      const parentName = parent?.authorName || parentAuthor;
       const parentText = parent ? excerptPostText(parentContent, 220) : '';
       const parentText = parent ? excerptPostText(parentContent, 220) : '';
       cardBody.push(
       cardBody.push(
         div({ class: 'card-section post' },
         div({ class: 'card-section post' },
           isReply
           isReply
             ? div(
             ? div(
-                { class: 'reply-context' },
-                a({ href: ctxHref, class: 'tag-link' }, i18n.inReplyTo || 'IN REPLY TO'),
-                parentAuthor ? span({ class: 'reply-context-author' }, a({ href: `/author/${encodeURIComponent(parentAuthor)}`, class: 'user-link' }, parentAuthor)) : '',
-                parentText ? p({ class: 'post-text reply-context-text post-text-pre' }, ...renderUrlPreserveNewlines(parentText)) : ''
+                { class: 'reply-context', style: 'border-left:3px solid #666;padding-left:10px;margin-bottom:8px;opacity:0.85;' },
+                span({ style: 'font-size:0.85em;' },
+                  a({ href: ctxHref, class: 'tag-link' }, i18n.inReplyTo || 'IN REPLY TO'),
+                  parentAuthor ? span(' ', a({ href: `/author/${encodeURIComponent(parentAuthor)}`, class: 'user-link', style: 'font-weight:bold;' }, parentName)) : ''
+                ),
+                parentText ? p({ class: 'post-text reply-context-text post-text-pre', style: 'font-size:0.85em;max-height:80px;overflow:hidden;margin-top:4px;' }, ...renderUrlPreserveNewlines(parentText)) : ''
               )
               )
             : '',
             : '',
           contentWarning ? h2({ class: 'content-warning' }, contentWarning) : '',
           contentWarning ? h2({ class: 'content-warning' }, contentWarning) : '',
-          bodyNode
+          bodyNode,
+          isTruncated && threadId
+            ? div({ style: 'margin-top:6px;' },
+                a({ href: `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(action.id || threadId)}`, class: 'filter-btn' }, i18n.keepReading || 'Keep reading...')
+              )
+            : ''
         )
         )
       );
       );
     }
     }
@@ -794,16 +820,22 @@ function renderActionCards(actions, userId, allActions) {
         );
         );
       } else {
       } else {
         const rootId = typeof root === 'string' ? root : (root?.key || root?.id || '');
         const rootId = typeof root === 'string' ? root : (root?.key || root?.id || '');
-        const parentForum = actions.find(a => a.type === 'forum' && !a.content?.root && (a.id === rootId || a.content?.key === rootId));
-        const parentTitle = (parentForum?.content?.title && String(parentForum.content.title).trim()) ? parentForum.content.title : ((rootTitle && String(rootTitle).trim()) ? rootTitle : '');
+        const parentForum = byIdAll.get(rootId) || actions.find(a => a.type === 'forum' && !a.content?.root && (a.id === rootId || a.content?.key === rootId));
+        const parentContent = parentForum ? (parentForum.value?.content || parentForum.content || {}) : {};
+        const parentTitle = (parentContent?.title && String(parentContent.title).trim()) ? parentContent.title : ((rootTitle && String(rootTitle).trim()) ? rootTitle : '');
+        const parentAuthor = parentForum?.author || '';
+        const parentName = parentForum?.authorName || parentAuthor;
         const hrefKey = rootKey || rootId;
         const hrefKey = rootKey || rootId;
         cardBody.push(
         cardBody.push(
           div({ class: 'card-section forum' },
           div({ class: 'card-section forum' },
-            div({ class: 'card-field', style: "font-size:1.12em; margin-bottom:5px;" },
-              span({ class: 'card-label', style: "font-weight:800;color:#ff9800;" }, i18n.title + ': '),
-              a({ href: `/forum/${encodeURIComponent(hrefKey)}`, style: "font-weight:800;color:#4fc3f7;" }, parentTitle)
+            div(
+              { class: 'reply-context', style: 'border-left:3px solid #666;padding-left:10px;margin-bottom:8px;opacity:0.85;' },
+              span({ style: 'font-size:0.85em;' },
+                a({ href: `/forum/${encodeURIComponent(hrefKey)}`, class: 'tag-link' }, i18n.inReplyTo || 'IN REPLY TO'),
+                parentAuthor ? span(' ', a({ href: `/author/${encodeURIComponent(parentAuthor)}`, class: 'user-link', style: 'font-weight:bold;' }, parentName)) : ''
+              ),
+              parentTitle ? p({ class: 'post-text reply-context-text', style: 'font-size:0.85em;max-height:60px;overflow:hidden;margin-top:4px;font-weight:bold;color:#4fc3f7;' }, parentTitle) : ''
             ),
             ),
-            br(),
             div({ class: 'card-field', style: 'margin-bottom:12px;' },
             div({ class: 'card-field', style: 'margin-bottom:12px;' },
               p({ style: "margin:0 0 8px 0; word-break:break-all;" }, ...renderUrl(text))
               p({ style: "margin:0 0 8px 0; word-break:break-all;" }, ...renderUrl(text))
             )
             )
@@ -842,13 +874,17 @@ function renderActionCards(actions, userId, allActions) {
   const totalChron = link && spreadsByLink.has(link) ? spreadsByLink.get(link).length : 0;
   const totalChron = link && spreadsByLink.has(link) ? spreadsByLink.get(link).length : 0;
   const label = (i18n.spreadChron || 'Spread') + ':';
   const label = (i18n.spreadChron || 'Spread') + ':';
   const value = ord && totalChron ? `${ord}/${totalChron}` : (ord ? String(ord) : '');
   const value = ord && totalChron ? `${ord}/${totalChron}` : (ord ? String(ord) : '');
+  const spreadExcerpt = spreadText || excerptPostText(content, 300);
   cardBody.push(
   cardBody.push(
     div({ class: 'card-section vote' },
     div({ class: 'card-section vote' },
       spreadTitle ? h2({ class: 'post-title activity-spread-title' }, spreadTitle) : '',
       spreadTitle ? h2({ class: 'post-title activity-spread-title' }, spreadTitle) : '',
       spreadContentWarning ? h2({ class: 'content-warning' }, spreadContentWarning) : '',
       spreadContentWarning ? h2({ class: 'content-warning' }, spreadContentWarning) : '',
-      spreadText ? div({ class: 'post-text activity-spread-text post-text-pre' }, ...renderUrlPreserveNewlines(spreadText)) : '',
+      spreadExcerpt
+        ? div({ class: 'post-text activity-spread-text post-text-pre', style: 'max-height:200px;overflow:hidden;' }, ...renderUrlPreserveNewlines(spreadExcerpt))
+        : div({ class: 'post-text activity-spread-text', style: 'opacity:0.6;font-style:italic;' }, i18n.spreadContentUnavailable || 'Content not yet available (pending replication)'),
       spreadOriginalAuthor
       spreadOriginalAuthor
         ? div({ class: 'card-field' },
         ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, (i18n.spreadBy || 'By') + ': '),
             span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(spreadOriginalAuthor)}`, class: 'user-link' }, spreadOriginalAuthor))
             span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(spreadOriginalAuthor)}`, class: 'user-link' }, spreadOriginalAuthor))
           )
           )
         : '',
         : '',
@@ -857,6 +893,11 @@ function renderActionCards(actions, userId, allActions) {
             span({ class: 'card-label' }, label),
             span({ class: 'card-label' }, label),
             span({ class: 'card-value' }, value)
             span({ class: 'card-value' }, value)
           )
           )
+        : '',
+      link
+        ? div({ style: 'margin-top:6px;' },
+            a({ href: `/thread/${encodeURIComponent(link)}#${encodeURIComponent(link)}`, class: 'filter-btn' }, i18n.viewDetails || 'View details')
+          )
         : ''
         : ''
     )
     )
   );
   );
@@ -935,7 +976,7 @@ function renderActionCards(actions, userId, allActions) {
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, stock)),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, stock)),
           br(),
           br(),
           image
           image
-            ? img({ src: `/blob/${encodeURIComponent(image)}` })
+            ? renderMediaBlob(image, '/assets/images/default-market.png')
             : img({ src: '/assets/images/default-market.png', alt: title }),
             : img({ src: '/assets/images/default-market.png', alt: title }),
           br(),
           br(),
           div({ class: "market-card price" },
           div({ class: "market-card price" },
@@ -1034,7 +1075,7 @@ function renderActionCards(actions, userId, allActions) {
             )
             )
           ),
           ),
           div(
           div(
-            p({ innerHTML: msgHtml })
+            p({ innerHTML: sanitizeHtml(msgHtml) })
           ),
           ),
           p({ class: 'card-footer' },
           p({ class: 'card-footer' },
             span({ class: 'date-link' }, `${action.ts ? new Date(action.ts).toLocaleString() : ''} ${i18n.performed} `),
             span({ class: 'date-link' }, `${action.ts ? new Date(action.ts).toLocaleString() : ''} ${i18n.performed} `),

+ 2 - 1
src/views/agenda_view.js

@@ -191,7 +191,8 @@ const renderAgendaItem = (item, userId, filter) => {
 };
 };
 
 
 exports.agendaView = async (data, filter) => {
 exports.agendaView = async (data, filter) => {
-  const { items, counts } = data;
+  const { items = [], counts: _c = {} } = data || {};
+  const counts = { all: 0, open: 0, closed: 0, events: 0, tasks: 0, reports: 0, tribes: 0, jobs: 0, market: 0, projects: 0, transfers: 0, discarded: 0, ..._c };
   return template(
   return template(
     i18n.agendaTitle,
     i18n.agendaTitle,
     section(
     section(

+ 3 - 2
src/views/audio_view.js

@@ -12,6 +12,7 @@ const {
   span,
   span,
   textarea,
   textarea,
   select,
   select,
+  label,
   option
   option
 } = require("../server/node_modules/hyperaxe");
 } = require("../server/node_modules/hyperaxe");
 
 
@@ -112,16 +113,16 @@ const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) =>
       { class: "comment-form-wrapper" },
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       form(
       form(
-        { method: "POST", action: `/audios/${encodeURIComponent(audioId)}/comments`, class: "comment-form" },
+        { method: "POST", action: `/audios/${encodeURIComponent(audioId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
         returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
         returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
         textarea({
         textarea({
           id: "comment-text",
           id: "comment-text",
           name: "text",
           name: "text",
-          required: true,
           rows: 4,
           rows: 4,
           class: "comment-textarea",
           class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         }),
+        div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
         br(),
         br(),
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
       )

+ 3 - 4
src/views/banking_views.js

@@ -252,14 +252,13 @@ const renderAddresses = (data, userId) => {
                     td(r.address),
                     td(r.address),
                     td(r.source === "local" ? i18n.bankLocal : i18n.bankFromOasis),
                     td(r.source === "local" ? i18n.bankLocal : i18n.bankFromOasis),
 			td(
 			td(
-			  r.source === "local"
-			    ? div({ class: "row-actions" },
+			  div({ class: "row-actions" },
 				form({ method: "POST", action: "/banking/addresses/delete", class: "addr-del" },
 				form({ method: "POST", action: "/banking/addresses/delete", class: "addr-del" },
 				  input({ type: "hidden", name: "userId", value: r.id }),
 				  input({ type: "hidden", name: "userId", value: r.id }),
+				  input({ type: "hidden", name: "source", value: r.source || "local" }),
 				  button({ type: "submit", class: "delete-btn", onclick: `return confirm(${JSON.stringify(i18n.bankAddressDeleteConfirm)})` }, i18n.bankAddressDelete)
 				  button({ type: "submit", class: "delete-btn", onclick: `return confirm(${JSON.stringify(i18n.bankAddressDeleteConfirm)})` }, i18n.bankAddressDelete)
 				)
 				)
-			      )
-			    : null
+			  )
 			)
 			)
                   )
                   )
                 )
                 )

+ 121 - 31
src/views/blockchain_view.js

@@ -1,4 +1,4 @@
-const { div, h2, p, section, button, form, a, input, span, pre, table, tr, td } = require("../server/node_modules/hyperaxe");
+const { div, h2, h3, p, section, button, form, a, input, span, pre, table, tr, td, strong } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require("../views/main_views");
 const { template, i18n } = require("../views/main_views");
 const moment = require("../server/node_modules/moment");
 const moment = require("../server/node_modules/moment");
 
 
@@ -121,7 +121,85 @@ const getViewDetailsAction = (type, block) => {
   }
   }
 };
 };
 
 
-const renderSingleBlockView = (block, filter = 'recent', userId, search = {}) => {
+const TYPE_COLORS = {
+  post:'#3498db', vote:'#9b59b6', votes:'#9b59b6', about:'#1abc9c', contact:'#16a085',
+  pub:'#2ecc71', tribe:'#e67e22', event:'#e74c3c', task:'#f39c12', report:'#c0392b',
+  image:'#2980b9', audio:'#8e44ad', video:'#d35400', document:'#27ae60', bookmark:'#f1c40f',
+  forum:'#1abc9c', feed:'#95a5a6', transfer:'#e74c3c', market:'#e67e22', job:'#3498db',
+  project:'#2ecc71', banking:'#f39c12', bankWallet:'#f39c12', bankClaim:'#f39c12',
+  pixelia:'#9b59b6', curriculum:'#1abc9c', aiExchange:'#3498db', tombstone:'#7f8c8d',
+  parliamentTerm:'#8e44ad', parliamentProposal:'#8e44ad', parliamentLaw:'#8e44ad',
+  parliamentCandidature:'#8e44ad', parliamentRevocation:'#8e44ad',
+  courtsCase:'#c0392b', courtsEvidence:'#c0392b', courtsAnswer:'#c0392b',
+  courtsVerdict:'#c0392b', courtsSettlement:'#c0392b', courtsNomination:'#c0392b'
+};
+
+const renderBlockDiagram = (blocks, qs) => {
+  const last2 = blocks.slice(0, 2);
+  if (!last2.length) return null;
+
+  return div({ class: 'block-diagram-section' },
+    h3({ class: 'block-diagram-title' }, i18n.blockchainLatestDatagram || 'Latest Datagram'),
+    ...last2.map(block => {
+      const ts = moment(block.ts).format('YYYY-MM-DD HH:mm:ss');
+      const typeLabel = (FILTER_LABELS[block.type] || block.type).toUpperCase();
+      const color = TYPE_COLORS[block.type] || '#95a5a6';
+      const shortId = block.id.length > 20 ? block.id.slice(0, 10) + '…' + block.id.slice(-8) : block.id;
+      const shortAuthor = block.author.length > 20 ? block.author.slice(0, 10) + '…' + block.author.slice(-8) : block.author;
+      const contentKeys = Object.keys(block.content || {}).filter(k => k !== 'type').join(', ');
+      const flags = [
+        block.isTombstoned ? 'TOMBSTONED' : null,
+        block.isReplaced ? 'REPLACED' : null,
+        block.content?.replaces ? 'EDIT' : null
+      ].filter(Boolean).join(' | ') || '—';
+
+      const datagramQs = qs ? `${qs}&view=datagram` : '?view=datagram';
+      return a({ href: `/blockexplorer/block/${encodeURIComponent(block.id)}${datagramQs}`, class: 'block-diagram-link' },
+        div({ class: 'block-diagram', style: `border-color:${color};` },
+          div({ class: 'block-diagram-ruler', style: `border-bottom-color:${color};` },
+            span('0'), span('4'), span('8'), span('16'), span('24'), span('31')
+          ),
+          div({ class: 'block-diagram-grid' },
+            div({ class: 'block-diagram-cell bd-seq' },
+              span({ class: 'bd-label' }, 'SEQ:'),
+              span({ class: 'bd-value' }, String(block.content?.sequence || '—'))
+            ),
+            div({ class: 'block-diagram-cell bd-type' },
+              span({ class: 'bd-label' }, 'TYPE:'),
+              span({ class: 'bd-value' }, typeLabel)
+            ),
+            div({ class: 'block-diagram-cell bd-ts' },
+              span({ class: 'bd-label' }, 'TIMESTAMP:'),
+              span({ class: 'bd-value' }, ts)
+            ),
+            div({ class: 'block-diagram-cell bd-id' },
+              span({ class: 'bd-label' }, 'BLOCK ID:'),
+              span({ class: 'bd-value' }, shortId)
+            ),
+            div({ class: 'block-diagram-cell bd-author' },
+              span({ class: 'bd-label' }, 'AUTHOR:'),
+              span({ class: 'bd-value' }, shortAuthor)
+            ),
+            div({ class: 'block-diagram-cell bd-flags' },
+              span({ class: 'bd-label' }, 'FLAGS:'),
+              span({ class: 'bd-value' }, flags)
+            ),
+            div({ class: 'block-diagram-cell bd-ctype' },
+              span({ class: 'bd-label' }, 'CONTENT.TYPE:'),
+              span({ class: 'bd-value' }, block.content?.type || '—')
+            ),
+            div({ class: 'block-diagram-cell bd-data' },
+              span({ class: 'bd-label' }, 'CONTENT:'),
+              span({ class: 'bd-value' }, contentKeys || '—')
+            )
+          )
+        )
+      );
+    })
+  );
+};
+
+const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, viewMode = 'block') => {
   if (!block) {
   if (!block) {
     return template(
     return template(
       i18n.blockchain,
       i18n.blockchain,
@@ -135,6 +213,34 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}) =>
     );
     );
   }
   }
 
 
+  const qs = toQueryString(filter, search);
+  const isDatagram = viewMode === 'datagram';
+
+  const blockContent = isDatagram
+    ? renderBlockDiagram([block], qs)
+    : div(
+        div({ class: 'block-single' },
+          div({ class: 'block-row block-row--meta' },
+            span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID}:`),
+            span({ class: 'blockchain-card-value' }, block.id)
+          ),
+          div({ class: 'block-row block-row--meta' },
+            span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`),
+            span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ')),
+            span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`),
+            span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
+          ),
+          div({ class: 'block-row block-row--meta block-row--meta-spaced' },
+            a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)
+          )
+        ),
+        div({ class:'block-row block-row--content' },
+          div({ class:'block-content-preview' },
+            pre({ class:'json-content' }, JSON.stringify(block.content,null,2))
+          )
+        )
+      );
+
   return template(
   return template(
     i18n.blockchain,
     i18n.blockchain,
     section(
     section(
@@ -143,38 +249,19 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}) =>
         p(i18n.blockchainDescription)
         p(i18n.blockchainDescription)
       ),
       ),
       div({ class: 'mode-buttons-row' },
       div({ class: 'mode-buttons-row' },
-        div({ style: 'display:flex;flex-direction:column;gap:8px;' },
+        div({ class: 'filter-column' },
           generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer', search)
           generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer', search)
         ),
         ),
-        div({ style: 'display:flex;flex-direction:column;gap:8px;' },
+        div({ class: 'filter-column' },
           generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer', search),
           generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer', search),
           generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer', search)
           generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer', search)
         ),
         ),
-        div({ style: 'display:flex;flex-direction:column;gap:8px;' },
+        div({ class: 'filter-column' },
           generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer', search),
           generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer', search),
           generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer', search)
           generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer', search)
         )
         )
       ),
       ),
-      div({ class: 'block-single' },
-        div({ class: 'block-row block-row--meta' },
-          span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID}:`),
-          span({ class: 'blockchain-card-value' }, block.id)
-        ),
-        div({ class: 'block-row block-row--meta' },
-          span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`),
-          span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ')),
-          span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`),
-          span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
-        ),
-        div({ class: 'block-row block-row--meta', style:'margin-top:8px;' },
-          a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)
-        )
-      ),
-      div({ class:'block-row block-row--content' },
-        div({ class:'block-content-preview' },
-          pre({ class:'json-content' }, JSON.stringify(block.content,null,2))
-        )
-      ),
+      blockContent,
       div({ class:'block-row block-row--back' },
       div({ class:'block-row block-row--back' },
         form({ method:'GET', action:'/blockexplorer' },
         form({ method:'GET', action:'/blockexplorer' },
           input({ type: 'hidden', name: 'filter', value: filter }),
           input({ type: 'hidden', name: 'filter', value: filter }),
@@ -186,7 +273,7 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}) =>
             button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
             button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
           )
           )
         : (block.isTombstoned || block.isReplaced) ?
         : (block.isTombstoned || block.isReplaced) ?
-          div({ class: 'deleted-label', style: 'color:#b00;font-weight:bold;margin-top:8px;' },
+          div({ class: 'deleted-label' },
             i18n.blockchainContentDeleted || "This content has been deleted."
             i18n.blockchainContentDeleted || "This content has been deleted."
           )
           )
         : null
         : null
@@ -213,14 +300,14 @@ const renderBlockchainView = (blocks, filter, userId, search = {}) => {
         p(i18n.blockchainDescription)
         p(i18n.blockchainDescription)
       ),
       ),
       div({ class:'mode-buttons-row' },
       div({ class:'mode-buttons-row' },
-        div({ style:'display:flex;flex-direction:column;gap:8px;' },
+        div({ class: 'filter-column' },
           generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer', s)
           generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer', s)
         ),
         ),
-        div({ style:'display:flex;flex-direction:column;gap:8px;' },
+        div({ class: 'filter-column' },
           generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer', s),
           generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer', s),
           generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer', s)
           generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer', s)
         ),
         ),
-        div({ style:'display:flex;flex-direction:column;gap:8px;' },
+        div({ class: 'filter-column' },
           generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer', s),
           generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer', s),
           generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer', s)
           generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer', s)
         )
         )
@@ -243,6 +330,8 @@ const renderBlockchainView = (blocks, filter, userId, search = {}) => {
 	    )
 	    )
 	  )
 	  )
 	),
 	),
+      renderBlockDiagram(shown, qs),
+      h2({ class: 'block-diagram-title' }, 'Blockchain Blocks'),
       shown.length === 0
       shown.length === 0
         ? div(p(i18n.blockchainNoBlocks))
         ? div(p(i18n.blockchainNoBlocks))
         : shown
         : shown
@@ -258,13 +347,14 @@ const renderBlockchainView = (blocks, filter, userId, search = {}) => {
             .map(block=>
             .map(block=>
               div({ class:'block' },
               div({ class:'block' },
                 div({ class:'block-buttons' },
                 div({ class:'block-buttons' },
-                  a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails },'⦿'),
+                  a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, '⦿'),
+                  a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram || 'Datagram' }, '⊞'),
                   !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
                   !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
                     form({ method:'GET', action:getViewDetailsAction(block.type, block) },
                     form({ method:'GET', action:getViewDetailsAction(block.type, block) },
                       button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
                       button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
                     )
                     )
                   : (block.isTombstoned || block.isReplaced) ?
                   : (block.isTombstoned || block.isReplaced) ?
-                    div({ class: 'deleted-label', style: 'color:#b00;font-weight:bold;margin-top:8px;' },
+                    div({ class: 'deleted-label' },
                       i18n.blockchainContentDeleted || "This content has been deleted."
                       i18n.blockchainContentDeleted || "This content has been deleted."
                     )
                     )
                   : null
                   : null

+ 2 - 2
src/views/bookmark_view.js

@@ -73,17 +73,17 @@ const renderBookmarkCommentsSection = (bookmarkId, rootId, comments = [], return
       { class: "comment-form-wrapper" },
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       form(
       form(
-        { method: "POST", action: `/bookmarks/${encodeURIComponent(bookmarkId)}/comments`, class: "comment-form" },
+        { method: "POST", action: `/bookmarks/${encodeURIComponent(bookmarkId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
         returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
         returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
         rootId ? input({ type: "hidden", name: "rootId", value: rootId }) : null,
         rootId ? input({ type: "hidden", name: "rootId", value: rootId }) : null,
         textarea({
         textarea({
           id: "comment-text",
           id: "comment-text",
           name: "text",
           name: "text",
-          required: true,
           rows: 4,
           rows: 4,
           class: "comment-textarea",
           class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         }),
+        div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
         br(),
         br(),
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
       )

+ 13 - 13
src/views/cipher_view.js

@@ -23,9 +23,9 @@ const cipherView = async (encryptedText = "", decryptedText = "", iv = "", passw
       placeholder: i18n.cipherTextPlaceholder,
       placeholder: i18n.cipherTextPlaceholder,
       rows: 4
       rows: 4
     }),
     }),
-    br,
+    br(),
     label(i18n.cipherPasswordLabel),
     label(i18n.cipherPasswordLabel),
-    br,
+    br(),
     input({
     input({
       type: "password",
       type: "password",
       name: "password",
       name: "password",
@@ -34,7 +34,7 @@ const cipherView = async (encryptedText = "", decryptedText = "", iv = "", passw
       placeholder: i18n.cipherPasswordPlaceholder,
       placeholder: i18n.cipherPasswordPlaceholder,
       minlength: 32
       minlength: 32
     }),
     }),
-    br,
+    br(),
     button({ type: "submit" }, i18n.cipherEncryptButton)
     button({ type: "submit" }, i18n.cipherEncryptButton)
   );
   );
 
 
@@ -48,9 +48,9 @@ const cipherView = async (encryptedText = "", decryptedText = "", iv = "", passw
       rows: 4,
       rows: 4,
       value: encryptedText
       value: encryptedText
     }),
     }),
-    br,
+    br(),
     label(i18n.cipherPasswordLabel),
     label(i18n.cipherPasswordLabel),
-    br,
+    br(),
     input({
     input({
       type: "password",
       type: "password",
       name: "password",
       name: "password",
@@ -59,17 +59,17 @@ const cipherView = async (encryptedText = "", decryptedText = "", iv = "", passw
       placeholder: i18n.cipherPasswordPlaceholder,
       placeholder: i18n.cipherPasswordPlaceholder,
       minlength: 32
       minlength: 32
     }),
     }),
-    br,
+    br(),
     button({ type: "submit" }, i18n.cipherDecryptButton)
     button({ type: "submit" }, i18n.cipherDecryptButton)
   );
   );
 
 
   const encryptResult = encryptedText 
   const encryptResult = encryptedText 
     ? div({ class: "cipher-result visible encrypted-result" }, 
     ? div({ class: "cipher-result visible encrypted-result" }, 
         label(i18n.cipherEncryptedMessageLabel),
         label(i18n.cipherEncryptedMessageLabel),
-        br,br,
+        br(),br(),
         div({ class: "cipher-text" }, encryptedText),
         div({ class: "cipher-text" }, encryptedText),
         label(i18n.cipherPasswordUsedLabel),
         label(i18n.cipherPasswordUsedLabel),
-        br,br,
+        br(),br(),
         div({ class: "cipher-text" }, password) 
         div({ class: "cipher-text" }, password) 
       )
       )
     : null;
     : null;
@@ -77,7 +77,7 @@ const cipherView = async (encryptedText = "", decryptedText = "", iv = "", passw
   const decryptResult = decryptedText 
   const decryptResult = decryptedText 
     ? div({ class: "cipher-result visible decrypted-result" }, 
     ? div({ class: "cipher-result visible decrypted-result" }, 
         label(i18n.cipherDecryptedMessageLabel),
         label(i18n.cipherDecryptedMessageLabel),
-        br,br,
+        br(),br(),
         div({ class: "cipher-text" }, decryptedText) 
         div({ class: "cipher-text" }, decryptedText) 
       )
       )
     : null;
     : null;
@@ -91,11 +91,11 @@ const cipherView = async (encryptedText = "", decryptedText = "", iv = "", passw
       ),
       ),
       div({ class: "div-center" },
       div({ class: "div-center" },
         encryptForm,
         encryptForm,
-        br,
-        encryptResult, 
+        br(),
+        encryptResult,
         decryptForm,
         decryptForm,
-        br,
-        decryptResult 
+        br(),
+        decryptResult
       )
       )
     )
     )
   );
   );

+ 3 - 3
src/views/courts_view.js

@@ -160,9 +160,9 @@ const EvidenceForm = (caseId) =>
       }),
       }),
       br(),
       br(),
       br(),
       br(),
-      label('Evidence image'),
+      label(i18n.uploadMedia || 'Upload media (max-size: 50MB)'),
       br(),
       br(),
-      input({ type: 'file', name: 'image', accept: 'image/*' }),
+      input({ type: 'file', name: 'image' }),
       br(),
       br(),
       br(),
       br(),
       button({ type: 'submit', class: 'create-button' }, i18n.courtsEvidenceSubmit)
       button({ type: 'submit', class: 'create-button' }, i18n.courtsEvidenceSubmit)
@@ -1239,7 +1239,7 @@ const CaseDetailsBlock = (c) => {
             }
             }
             if (e.imageUrl && String(e.imageUrl).trim()) {
             if (e.imageUrl && String(e.imageUrl).trim()) {
               bodyChildren.push(
               bodyChildren.push(
-                br,br,
+                br(),br(),
                 img({
                 img({
                   class: 'evidence-image',
                   class: 'evidence-image',
                   src: `/blob/${encodeURIComponent(e.imageUrl)}`,
                   src: `/blob/${encodeURIComponent(e.imageUrl)}`,

+ 1 - 1
src/views/cv_view.js

@@ -206,7 +206,7 @@ exports.cvView = async (cv) => {
           ]) : null,
           ]) : null,
           hasOasis ? div({ class: "cv-box oasis" }, ...[
           hasOasis ? div({ class: "cv-box oasis" }, ...[
             h2(i18n.cvOasisContributorView),
             h2(i18n.cvOasisContributorView),
-            p(...renderUrl(`${cv.oasisExperiences}`)),
+            cv.oasisExperiences ? p(...renderUrl(`${cv.oasisExperiences}`)) : null,
             (cv.oasisSkills && cv.oasisSkills.length)
             (cv.oasisSkills && cv.oasisSkills.length)
               ? div(
               ? div(
                   cv.oasisSkills.map(tag =>
                   cv.oasisSkills.map(tag =>

+ 2 - 2
src/views/document_view.js

@@ -88,17 +88,17 @@ const renderDocumentCommentsSection = (documentKey, rootId, comments = [], retur
       { class: "comment-form-wrapper" },
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       form(
       form(
-        { method: "POST", action: `/documents/${encodeURIComponent(documentKey)}/comments`, class: "comment-form" },
+        { method: "POST", action: `/documents/${encodeURIComponent(documentKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
         returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
         returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
         rootId ? input({ type: "hidden", name: "rootId", value: rootId }) : null,
         rootId ? input({ type: "hidden", name: "rootId", value: rootId }) : null,
         textarea({
         textarea({
           id: "comment-text",
           id: "comment-text",
           name: "text",
           name: "text",
-          required: true,
           rows: 4,
           rows: 4,
           class: "comment-textarea",
           class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         }),
+        div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
         br(),
         br(),
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
       )

+ 2 - 2
src/views/event_view.js

@@ -147,16 +147,16 @@ const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all
       { class: "comment-form-wrapper" },
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       form(
       form(
-        { method: "POST", action: `/events/${encodeURIComponent(eventId)}/comments`, class: "comment-form" },
+        { method: "POST", action: `/events/${encodeURIComponent(eventId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
         input({ type: "hidden", name: "returnTo", value: returnTo }),
         input({ type: "hidden", name: "returnTo", value: returnTo }),
         textarea({
         textarea({
           id: "comment-text",
           id: "comment-text",
           name: "text",
           name: "text",
-          required: true,
           rows: 4,
           rows: 4,
           class: "comment-textarea",
           class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         }),
+        div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
         br(),
         br(),
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
       )

+ 24 - 6
src/views/feed_view.js

@@ -3,17 +3,19 @@ const { template, i18n } = require("./main_views");
 const { config } = require("../server/SSB_server.js");
 const { config } = require("../server/SSB_server.js");
 const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
 const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
 const opinionCategories = require("../backend/opinion_categories");
 const opinionCategories = require("../backend/opinion_categories");
+const { sanitizeHtml } = require('../backend/sanitizeHtml');
 
 
 const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1);
 const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1);
 const FEED_TEXT_MAX = Number(config?.feed?.maxLength ?? 280);
 const FEED_TEXT_MAX = Number(config?.feed?.maxLength ?? 280);
 
 
 const normalizeOptions = (opts) => {
 const normalizeOptions = (opts) => {
-  if (typeof opts === "string") return { filter: String(opts || "ALL").toUpperCase(), q: "", tag: "" };
-  if (!opts || typeof opts !== "object") return { filter: "ALL", q: "", tag: "" };
+  if (typeof opts === "string") return { filter: String(opts || "ALL").toUpperCase(), q: "", tag: "", msg: "" };
+  if (!opts || typeof opts !== "object") return { filter: "ALL", q: "", tag: "", msg: "" };
   return {
   return {
     filter: String(opts.filter || "ALL").toUpperCase(),
     filter: String(opts.filter || "ALL").toUpperCase(),
     q: typeof opts.q === "string" ? opts.q : "",
     q: typeof opts.q === "string" ? opts.q : "",
-    tag: typeof opts.tag === "string" ? opts.tag : ""
+    tag: typeof opts.tag === "string" ? opts.tag : "",
+    msg: typeof opts.msg === "string" ? opts.msg : ""
   };
   };
 };
 };
 
 
@@ -101,8 +103,20 @@ const renderFeedCard = (feed) => {
             ),
             ),
             div(
             div(
                 { class: "feed-main" },
                 { class: "feed-main" },
-                div({ class: "feed-text", innerHTML: styledHtml }),
-                h2(`${i18n.totalOpinions}: ${totalCount}`),
+                div({ class: "feed-text", innerHTML: sanitizeHtml(styledHtml) }),
+                h2(
+                    `${i18n.totalOpinions}: ${totalCount}`,
+                    ...(() => {
+                        const entries = voteEntries.filter(([, v]) => Number(v) > 0);
+                        if (!entries.length) return [];
+                        const maxVal = Math.max(...entries.map(([, v]) => Number(v)));
+                        const dominant = entries.filter(([, v]) => Number(v) === maxVal).map(([k]) => i18n['vote' + k.charAt(0).toUpperCase() + k.slice(1)] || k);
+                        return [
+                            span({ style: 'margin:0 8px;opacity:0.5;' }, '|'),
+                            span({ style: 'font-weight:700;' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
+                        ];
+                    })()
+                ),
                 p(
                 p(
                     { class: "card-footer" },
                     { class: "card-footer" },
                     span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
                     span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
@@ -132,7 +146,7 @@ const renderFeedCard = (feed) => {
 };
 };
 
 
 exports.feedView = (feeds, opts = "ALL") => {
 exports.feedView = (feeds, opts = "ALL") => {
-  const { filter, q, tag } = normalizeOptions(opts);
+  const { filter, q, tag, msg } = normalizeOptions(opts);
 
 
   const title =
   const title =
     filter === "MINE"
     filter === "MINE"
@@ -150,6 +164,9 @@ exports.feedView = (feeds, opts = "ALL") => {
                 : i18n.feedTitle;
                 : i18n.feedTitle;
 
 
   const header = div({ class: "tags-header" }, h2(title), p(i18n.FeedshareYourOpinions));
   const header = div({ class: "tags-header" }, h2(title), p(i18n.FeedshareYourOpinions));
+  const successBanner = msg === 'feedPublished'
+    ? div({ class: 'feed-success-msg' }, p('✓ ' + (i18n.feedPublishedSuccess || 'Feed published successfully!')))
+    : null;
 
 
   const extra = { q, tag };
   const extra = { q, tag };
 
 
@@ -157,6 +174,7 @@ exports.feedView = (feeds, opts = "ALL") => {
     title,
     title,
     section(
     section(
       header,
       header,
+      successBanner,
       div(
       div(
         { class: "mode-buttons-row" },
         { class: "mode-buttons-row" },
         ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], filter, "/feed", extra),
         ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], filter, "/feed", extra),

+ 4 - 4
src/views/forum_view.js

@@ -190,8 +190,8 @@ const renderForumList = (forums, currentFilter) =>
               span({ class: 'forum-participants' },
               span({ class: 'forum-participants' },
                 `${i18n.forumParticipants.toUpperCase()}: ${f.participants?.length || 1}`),
                 `${i18n.forumParticipants.toUpperCase()}: ${f.participants?.length || 1}`),
               span({ class: 'forum-messages' },
               span({ class: 'forum-messages' },
-                `${i18n.forumMessages.toUpperCase()}: ${f.messagesCount - 1}`),
-              form({ method: 'GET', action: `/forum/${encodeURIComponent(f.key)}`, style: 'visit-forum-form' },
+                `${i18n.forumMessages.toUpperCase()}: ${(f.messagesCount || 1) - 1}`),
+              form({ method: 'GET', action: `/forum/${encodeURIComponent(f.key)}`, class: 'visit-forum-form' },
                 button({ type: 'submit', class: 'filter-btn' }, i18n.forumVisitButton)
                 button({ type: 'submit', class: 'filter-btn' }, i18n.forumVisitButton)
               )
               )
             ),
             ),
@@ -208,7 +208,7 @@ const renderForumList = (forums, currentFilter) =>
               ? div({ class: 'forum-owner-actions' },
               ? div({ class: 'forum-owner-actions' },
                 form({
                 form({
                   method: 'POST',
                   method: 'POST',
-                  action: `/forum/delete/${f.key}`,
+                  action: `/forum/delete/${encodeURIComponent(f.key)}`,
                   class: 'forum-delete-form'
                   class: 'forum-delete-form'
                 },
                 },
                   button({ type: 'submit', class: 'delete-btn' },
                   button({ type: 'submit', class: 'delete-btn' },
@@ -248,7 +248,7 @@ exports.forumView = async (forums, currentFilter) => {
       currentFilter === 'create'
       currentFilter === 'create'
         ? renderForumForm()
         ? renderForumForm()
         : renderForumList(
         : renderForumList(
-          getFilteredForums(currentFilter || 'hot', forums),
+          getFilteredForums(currentFilter || 'all', forums),
           currentFilter
           currentFilter
         )
         )
     )
     )

+ 2 - 2
src/views/image_view.js

@@ -285,16 +285,16 @@ const renderImageCommentsSection = (imageKey, comments = [], returnTo = null) =>
       { class: "comment-form-wrapper" },
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       form(
       form(
-        { method: "POST", action: `/images/${encodeURIComponent(imageKey)}/comments`, class: "comment-form" },
+        { method: "POST", action: `/images/${encodeURIComponent(imageKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
         returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
         returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
         textarea({
         textarea({
           id: "comment-text",
           id: "comment-text",
           name: "text",
           name: "text",
-          required: true,
           rows: 4,
           rows: 4,
           class: "comment-textarea",
           class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         }),
+        div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
         br(),
         br(),
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
       )

+ 24 - 15
src/views/inhabitants_view.js

@@ -1,6 +1,7 @@
 const { div, h2, p, section, button, form, img, a, textarea, input, br, span, strong } = require("../server/node_modules/hyperaxe");
 const { div, h2, p, section, button, form, img, a, textarea, input, br, span, strong } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const { template, i18n } = require('./main_views');
 const { renderUrl } = require('../backend/renderUrl');
 const { renderUrl } = require('../backend/renderUrl');
+const { getConfig } = require('../configs/config-manager');
 
 
 const DEFAULT_HASH_ENC = "%260000000000000000000000000000000000000000000%3D.sha256";
 const DEFAULT_HASH_ENC = "%260000000000000000000000000000000000000000000%3D.sha256";
 const DEFAULT_HASH_PATH_RE = /\/image\/\d+\/%260000000000000000000000000000000000000000000%3D\.sha256$/;
 const DEFAULT_HASH_PATH_RE = /\/image\/\d+\/%260000000000000000000000000000000000000000000%3D\.sha256$/;
@@ -54,15 +55,26 @@ const generateFilterButtons = (filters, currentFilter) =>
     )
     )
   );
   );
 
 
-function lastActivityBadge(user) {
-  const label = i18n.inhabitantActivityLevel;
-  const bucket = user.lastActivityBucket || 'red';
-  const dotClass = bucket === 'green' ? 'green' : bucket === 'orange' ? 'orange' : 'red';
-  return div(
-    { class: 'inhabitant-last-activity' },
-    span({ class: 'label' }, `${label}: `),
-    span({ class: `activity-dot ${dotClass}` }, '')
-  );
+function lastActivityBadge(user, isMe) {
+  const bucket = user && user.lastActivityBucket;
+  const dotClass =
+    bucket === 'green' ? 'green' : bucket === 'orange' ? 'orange' : bucket === 'red' ? 'red' : null;
+  if (!dotClass) return [];
+  const items = [
+    span({ class: 'inhabitant-last-activity' },
+      `${i18n.inhabitantActivityLevel}: `,
+      span({ class: `activity-dot ${dotClass}` }, '●'))
+  ];
+  const currentTheme = getConfig().themes.current;
+  const src = isMe ? (currentTheme === 'OasisKIT' ? 'KIT' : currentTheme === 'OasisMobile' ? 'MOBILE' : 'DESKTOP') : (user && user.deviceSource) || null;
+  if (src) {
+    const upper = String(src).toUpperCase();
+    const deviceClass = upper === 'KIT' ? 'device-kit' : upper === 'MOBILE' ? 'device-mobile' : 'device-desktop';
+    items.push(span({ class: 'inhabitant-last-activity' },
+      `${i18n.deviceLabel || 'Device'}: `,
+      span({ class: deviceClass }, src)));
+  }
+  return [div({ class: 'inhabitant-activity-group' }, ...items)];
 }
 }
 
 
 const lightboxId = (id) => 'inhabitant_' + String(id || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
 const lightboxId = (id) => 'inhabitant_' + String(id || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -78,7 +90,7 @@ const renderInhabitantCard = (user, filter, currentUserId) => {
       br(),
       br(),
       span(`${i18n.bankingUserEngagementScore}: `),
       span(`${i18n.bankingUserEngagementScore}: `),
       h2(strong(typeof user.karmaScore === 'number' ? user.karmaScore : 0)),
       h2(strong(typeof user.karmaScore === 'number' ? user.karmaScore : 0)),
-      lastActivityBadge(user)
+      ...lastActivityBadge(user, isMe)
     ),
     ),
     div({ class: 'inhabitant-details' },
     div({ class: 'inhabitant-details' },
       h2(user.name || 'Anonymous'),
       h2(user.name || 'Anonymous'),
@@ -282,11 +294,7 @@ exports.inhabitantsProfileView = (payload, currentUserId) => {
           h2(name || 'Anonymous'),
           h2(name || 'Anonymous'),
           span(`${i18n.bankingUserEngagementScore}: `),
           span(`${i18n.bankingUserEngagementScore}: `),
           h2(strong(karmaScore)),
           h2(strong(karmaScore)),
-          div(
-            { class: 'inhabitant-last-activity' },
-            span({ class: 'label' }, `${i18n.inhabitantActivityLevel}:`),
-            span({ class: `activity-dot ${dotClass}` }, '')
-          ),
+          ...lastActivityBadge({ lastActivityBucket: dotClass, deviceSource: safe.deviceSource }, isMe),
           (!isMe && (id || viewedId))
           (!isMe && (id || viewedId))
             ? form(
             ? form(
                 { method: 'GET', action: '/pm' },
                 { method: 'GET', action: '/pm' },
@@ -321,3 +329,4 @@ exports.inhabitantsProfileView = (payload, currentUserId) => {
   );
   );
 };
 };
 
 
+exports.lastActivityBadge = lastActivityBadge;

+ 71 - 57
src/views/invites_view.js

@@ -1,4 +1,4 @@
-const { form, button, div, h2, p, section, ul, li, a, br, hr, input, span } = require("../server/node_modules/hyperaxe");
+const { form, button, div, h2, h3, p, section, ul, li, a, br, hr, input, span, table, tr, td } = require("../server/node_modules/hyperaxe");
 const path = require("path");
 const path = require("path");
 const fs = require('fs');
 const fs = require('fs');
 const { template, i18n } = require('./main_views');
 const { template, i18n } = require('./main_views');
@@ -13,6 +13,23 @@ const encodePubLink = (key) => {
   return `/author/${encodeURIComponent('@' + core)}.ed25519`;
   return `/author/${encodeURIComponent('@' + core)}.ed25519`;
 };
 };
 
 
+const snhInvitePath = path.join(__dirname, '..', 'configs', 'snh-invite-code.json');
+
+let snhInvite = null;
+try {
+  snhInvite = JSON.parse(fs.readFileSync(snhInvitePath, 'utf8'));
+} catch {}
+
+const deduplicateByHost = (list) => {
+  const seen = new Set();
+  return list.filter(p => {
+    const host = (p.host || '').replace(/:\d+$/, '');
+    if (!host || seen.has(host)) return false;
+    seen.add(host);
+    return true;
+  });
+};
+
 const invitesView = ({ invitesEnabled }) => {
 const invitesView = ({ invitesEnabled }) => {
   let pubs = [];
   let pubs = [];
   let pubsValue = "false";
   let pubsValue = "false";
@@ -39,63 +56,33 @@ const invitesView = ({ invitesEnabled }) => {
   }
   }
 
 
   const filteredPubs = pubsValue === "true"
   const filteredPubs = pubsValue === "true"
-    ? pubs.filter(pubItem => !unfollowed.find(u => u.key === pubItem.key))
+    ? deduplicateByHost(pubs.filter(pubItem => !unfollowed.find(u => u.key === pubItem.key)))
     : [];
     : [];
 
 
   const hasError = (pubItem) => pubItem && (pubItem.error || (typeof pubItem.failure === 'number' && pubItem.failure > 0));
   const hasError = (pubItem) => pubItem && (pubItem.error || (typeof pubItem.failure === 'number' && pubItem.failure > 0));
 
 
   const unreachableLabel = i18n.currentlyUnreachable || i18n.currentlyUnrecheable || 'ERROR!';
   const unreachableLabel = i18n.currentlyUnreachable || i18n.currentlyUnrecheable || 'ERROR!';
 
 
-  const pubItems = filteredPubs.filter(pubItem => !hasError(pubItem)).map(pubItem =>
-    li(
-      div(
-        { class: 'pub-item' },
-        h2('PUB: ', pubItem.host),
-        h2(`${i18n.inhabitants}: ${pubItem.announcers || 0}`),
-        a({ href: encodePubLink(pubItem.key), class: 'user-link' }, pubItem.key),
-        form(
-          { action: '/settings/invite/unfollow', method: 'post' },
-          input({ type: 'hidden', name: 'key', value: pubItem.key }),
-          button({ type: 'submit' }, i18n.invitesUnfollow)
-        ),
-      )
-    )
+  const pubTableHeader = () => tr(
+    td({ class: 'card-label' }, 'PUB'),
+    td({ class: 'card-label' }, i18n.invitesPort || 'Port'),
+    td({ class: 'card-label' }, i18n.inhabitants),
+    td({ class: 'card-label' }, 'Key'),
+    td({ class: 'card-label' }, '')
   );
   );
 
 
-  const unfollowedItems = unfollowed.length
-    ? unfollowed.map(pubItem =>
-        li(
-          div(
-            { class: 'pub-item' },
-            h2('PUB: ', pubItem.host),
-            h2(`${i18n.inhabitants}: ${pubItem.announcers || 0}`),
-            a({ href: encodePubLink(pubItem.key), class: 'user-link' }, pubItem.key),
-            form(
-              { action: '/settings/invite/follow', method: 'post' },
-              input({ type: 'hidden', name: 'key', value: pubItem.key }),
-              input({ type: 'hidden', name: 'host', value: pubItem.host || '' }),
-              input({ type: 'hidden', name: 'port', value: String(pubItem.port || 8008) }),
-              button({ type: 'submit', disabled: hasError(pubItem) }, i18n.invitesFollow)
-            ),
-          )
-        )
-      )
-    : [];
-
-  const unreachableItems = pubs.filter(hasError).map(pubItem =>
-    li(
-      div(
-        { class: 'pub-item' },
-        h2('PUB: ', pubItem.host),
-        h2(`${i18n.inhabitants}: ${pubItem.announcers || 0}`),
-        a({ href: encodePubLink(pubItem.key), class: 'user-link' }, pubItem.key),
-        div(
-          { class: 'error-box' },
-          p({ class: 'error-title' }, i18n.errorDetails),
-          p({ class: 'error-pre' }, String(pubItem.error || i18n.genericError))
-        ),
-      )
-    )
+  const activePubs = filteredPubs.filter(pubItem => !hasError(pubItem));
+  const unreachablePubs = pubs.filter(hasError);
+
+  const renderPubTable = (items, actionFn) => table({ class: 'block-info-table' },
+    pubTableHeader(),
+    items.map(pubItem => tr(
+      td(pubItem.host || '—'),
+      td(String(pubItem.port || 8008)),
+      td(String(pubItem.announcers || 0)),
+      td(a({ href: encodePubLink(pubItem.key), class: 'user-link' }, pubItem.key)),
+      td(actionFn(pubItem))
+    ))
   );
   );
 
 
   const title = i18n.invites;
   const title = i18n.invites;
@@ -129,16 +116,43 @@ const invitesView = ({ invitesEnabled }) => {
           br(),
           br(),
           button({ type: 'submit' }, i18n.invitesAcceptInvite)
           button({ type: 'submit' }, i18n.invitesAcceptInvite)
         ),
         ),
-        br,
+        br(),
+        snhInvite ? div({ class: 'snh-invite-box' },
+          h3({ class: 'snh-invite-name' }, snhInvite.name),
+          span({ class: 'snh-invite-code' }, snhInvite.code)
+        ) : null,
         hr(),
         hr(),
-        h2(`${i18n.invitesAcceptedInvites} (${pubItems.length})`),
-        pubItems.length ? ul(pubItems) : p(i18n.invitesNoFederatedPubs),
+        h2(`${i18n.invitesAcceptedInvites} (${activePubs.length})`),
+        activePubs.length
+          ? renderPubTable(activePubs, pubItem =>
+              form({ action: '/settings/invite/unfollow', method: 'post' },
+                input({ type: 'hidden', name: 'key', value: pubItem.key }),
+                button({ type: 'submit' }, i18n.invitesUnfollow)
+              )
+            )
+          : p(i18n.invitesNoFederatedPubs),
         hr(),
         hr(),
-        h2(`${i18n.invitesUnfollowedInvites} (${unfollowedItems.length})`),
-        unfollowedItems.length ? ul(unfollowedItems) : p(i18n.invitesNoUnfollowed),
+        h2(`${i18n.invitesUnfollowedInvites} (${unfollowed.length})`),
+        unfollowed.length
+          ? renderPubTable(unfollowed, pubItem =>
+              form({ action: '/settings/invite/follow', method: 'post' },
+                input({ type: 'hidden', name: 'key', value: pubItem.key }),
+                input({ type: 'hidden', name: 'host', value: pubItem.host || '' }),
+                input({ type: 'hidden', name: 'port', value: String(pubItem.port || 8008) }),
+                button({ type: 'submit', disabled: hasError(pubItem) }, i18n.invitesFollow)
+              )
+            )
+          : p(i18n.invitesNoUnfollowed),
         hr(),
         hr(),
-        h2(`${i18n.invitesUnreachablePubs} (${unreachableItems.length})`),
-        unreachableItems.length ? ul(unreachableItems) : p(i18n.invitesNoUnreachablePubs)
+        h2(`${i18n.invitesUnreachablePubs} (${unreachablePubs.length})`),
+        unreachablePubs.length
+          ? renderPubTable(unreachablePubs, pubItem =>
+              div({ class: 'error-box' },
+                p({ class: 'error-title' }, i18n.errorDetails),
+                p({ class: 'error-pre' }, String(pubItem.error || i18n.genericError))
+              )
+            )
+          : p(i18n.invitesNoUnreachablePubs)
       )
       )
     )
     )
   );
   );

+ 32 - 10
src/views/jobs_view.js

@@ -1,9 +1,23 @@
-const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, progress } = require("../server/node_modules/hyperaxe")
+const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, progress, video, audio } = require("../server/node_modules/hyperaxe")
 const { template, i18n } = require("./main_views")
 const { template, i18n } = require("./main_views")
 const moment = require("../server/node_modules/moment")
 const moment = require("../server/node_modules/moment")
 const { config } = require("../server/SSB_server.js")
 const { config } = require("../server/SSB_server.js")
 const { renderUrl } = require("../backend/renderUrl")
 const { renderUrl } = require("../backend/renderUrl")
 
 
+const renderMediaBlob = (value) => {
+  if (!value) return null
+  const s = String(value).trim()
+  if (!s) return null
+  if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}` })
+  const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
+  if (mVideo) return video({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` })
+  const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
+  if (mAudio) return audio({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` })
+  const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
+  if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: 'post-image' })
+  return null
+}
+
 const userId = config.keys.id
 const userId = config.keys.id
 
 
 const FILTERS = [
 const FILTERS = [
@@ -220,7 +234,7 @@ const renderJobList = (jobs, filter, params = {}) => {
           { class: "job-card" },
           { class: "job-card" },
           topbar ? topbar : null,
           topbar ? topbar : null,
           safeText(job.title) ? h2(job.title) : null,
           safeText(job.title) ? h2(job.title) : null,
-          job.image ? div({ class: "activity-image-preview" }, img({ src: `/blob/${encodeURIComponent(job.image)}` })) : null,
+          job.image ? div({ class: "activity-image-preview" }, renderMediaBlob(job.image)) : null,
           tagsNode ? tagsNode : null,
           tagsNode ? tagsNode : null,
           br(),
           br(),
           safeText(job.description) ? renderCardFieldRich(`${i18n.jobDescription}:`, renderUrl(job.description)) : null,
           safeText(job.description) ? renderCardFieldRich(`${i18n.jobDescription}:`, renderUrl(job.description)) : null,
@@ -292,9 +306,9 @@ const renderJobForm = (job = {}, mode = "create") => {
       br(),
       br(),
       label(i18n.jobImage),
       label(i18n.jobImage),
       br(),
       br(),
-      input({ type: "file", name: "image", accept: "image/*" }),
+      input({ type: "file", name: "image" }),
       br(),
       br(),
-      job.image ? img({ src: `/blob/${encodeURIComponent(job.image)}`, class: "existing-image" }) : null,
+      job.image ? renderMediaBlob(job.image) : null,
       br(),
       br(),
       label(i18n.jobDescription),
       label(i18n.jobDescription),
       br(),
       br(),
@@ -362,10 +376,15 @@ const renderCVList = (inhabitants) =>
           const isMe = String(user.id) === String(userId)
           const isMe = String(user.id) === String(userId)
           return div(
           return div(
             { class: "inhabitant-card" },
             { class: "inhabitant-card" },
-            img({ class: "inhabitant-photo", src: resolvePhoto(user.photo) }),
+            div(
+              { class: "inhabitant-left" },
+              a({ href: `/author/${encodeURIComponent(user.id)}` },
+                img({ class: "inhabitant-photo", src: resolvePhoto(user.photo) })
+              ),
+              h2(user.name)
+            ),
             div(
             div(
               { class: "inhabitant-details" },
               { class: "inhabitant-details" },
-              h2(user.name),
               user.description ? p(...renderUrl(user.description)) : null,
               user.description ? p(...renderUrl(user.description)) : null,
               p(a({ class: "user-link", href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
               p(a({ class: "user-link", href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
               div(
               div(
@@ -416,7 +435,9 @@ exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => {
               input({ type: "text", name: "location", placeholder: i18n.filterLocation, value: params.location || "" }),
               input({ type: "text", name: "location", placeholder: i18n.filterLocation, value: params.location || "" }),
               input({ type: "text", name: "language", placeholder: i18n.filterLanguage, value: params.language || "" }),
               input({ type: "text", name: "language", placeholder: i18n.filterLanguage, value: params.language || "" }),
               input({ type: "text", name: "skills", placeholder: i18n.filterSkills, value: params.skills || "" }),
               input({ type: "text", name: "skills", placeholder: i18n.filterSkills, value: params.skills || "" }),
-              button({ type: "submit", class: "filter-btn" }, i18n.applyFilters)
+              div({ class: "cv-filter-submit" },
+                button({ type: "submit", class: "filter-btn" }, i18n.applyFilters)
+              )
             ),
             ),
             br(),
             br(),
             renderCVList(jobsOrCVs)
             renderCVList(jobsOrCVs)
@@ -472,9 +493,10 @@ const renderJobCommentsSection = (jobId, returnTo, comments = []) => {
       { class: "comment-form-wrapper" },
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       form(
       form(
-        { method: "POST", action: `/jobs/${encodeURIComponent(jobId)}/comments`, class: "comment-form" },
+        { method: "POST", action: `/jobs/${encodeURIComponent(jobId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
         input({ type: "hidden", name: "returnTo", value: returnTo }),
         input({ type: "hidden", name: "returnTo", value: returnTo }),
-        textarea({ id: "comment-text", name: "text", required: true, rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
+        textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
+        div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
         br(),
         br(),
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
       )
@@ -538,7 +560,7 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
         { class: "job-card" },
         { class: "job-card" },
         topbar ? topbar : null,
         topbar ? topbar : null,
         safeText(job.title) ? h2(job.title) : null,
         safeText(job.title) ? h2(job.title) : null,
-        job.image ? div({ class: "activity-image-preview" }, img({ src: `/blob/${encodeURIComponent(job.image)}` })) : null,
+        job.image ? div({ class: "activity-image-preview" }, renderMediaBlob(job.image)) : null,
         tagsNode ? tagsNode : null,
         tagsNode ? tagsNode : null,
         br(),
         br(),
         safeText(job.description) ? renderCardFieldRich(`${i18n.jobDescription}:`, renderUrl(job.description)) : null,
         safeText(job.description) ? renderCardFieldRich(`${i18n.jobDescription}:`, renderUrl(job.description)) : null,

+ 3 - 3
src/views/legacy_view.js

@@ -40,12 +40,12 @@ const legacyView = async () => {
           p({ class: "file-info" }, i18n.fileInfo),
           p({ class: "file-info" }, i18n.fileInfo),
           button({ type: "submit" }, i18n.legacyExportButton)
           button({ type: "submit" }, i18n.legacyExportButton)
         ),
         ),
-        br,
+        br(),
         p(i18n.importDescription),
         p(i18n.importDescription),
         form(
         form(
           { action: "/legacy/import", method: "POST", enctype: "multipart/form-data" },
           { action: "/legacy/import", method: "POST", enctype: "multipart/form-data" },
           input({ type: "file", name: "uploadedFile", required: true }),
           input({ type: "file", name: "uploadedFile", required: true }),
-          br,
+          br(),
           p(i18n.passwordImport),
           p(i18n.passwordImport),
           input({
           input({
             type: "password",
             type: "password",
@@ -54,7 +54,7 @@ const legacyView = async () => {
             placeholder: i18n.importPasswordPlaceholder,
             placeholder: i18n.importPasswordPlaceholder,
             minlength: 32
             minlength: 32
           }),
           }),
-          br,
+          br(),
           button({ type: "submit" }, i18n.legacyImportButton)
           button({ type: "submit" }, i18n.legacyImportButton)
         )
         )
       )
       )

+ 283 - 111
src/views/main_views.js

@@ -12,6 +12,7 @@ const { renderUrl } = require('../backend/renderUrl');
 const ssbClientGUI = require("../client/gui");
 const ssbClientGUI = require("../client/gui");
 const config = require("../server/ssb_config");
 const config = require("../server/ssb_config");
 const cooler = ssbClientGUI({ offline: config.offline });
 const cooler = ssbClientGUI({ offline: config.offline });
+const sharedState = require('../configs/shared-state');
 
 
 let ssb, userId;
 let ssb, userId;
 
 
@@ -21,12 +22,12 @@ const getUserId = async () => {
   return userId;
   return userId;
 };
 };
 
 
-const { a, article, br, body, button, details, div, em, footer, form, h1, h2, h3, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, section, select, span, summary, textarea, title, tr, ul, strong, video: videoHyperaxe, audio: audioHyperaxe } = require("../server/node_modules/hyperaxe");
+const { a, article, br, body, button, details, div, em, footer, form, h1, h2, h3, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, section, select, span, summary, table, td, textarea, title, tr, ul, strong, video: videoHyperaxe, audio: audioHyperaxe } = require("../server/node_modules/hyperaxe");
 
 
 const lodash = require("../server/node_modules/lodash");
 const lodash = require("../server/node_modules/lodash");
 const markdown = require("./markdown");
 const markdown = require("./markdown");
+const { sanitizeHtml } = require('../backend/sanitizeHtml');
 
 
-// set language
 const i18nBase = require("../client/assets/translations/i18n");
 const i18nBase = require("../client/assets/translations/i18n");
 let selectedLanguage = "en";
 let selectedLanguage = "en";
 let i18n = {};
 let i18n = {};
@@ -38,7 +39,7 @@ exports.setLanguage = (language) => {
   Object.assign(i18n, newLang);
   Object.assign(i18n, newLang);
 };
 };
 exports.i18n = i18n;
 exports.i18n = i18n;
-exports.selectedLanguage = selectedLanguage;
+Object.defineProperty(exports, 'selectedLanguage', { get: () => selectedLanguage });
 
 
 // markdown
 // markdown
 const markdownUrl = "https://commonmark.org/help/";
 const markdownUrl = "https://commonmark.org/help/";
@@ -73,6 +74,16 @@ const renderFooter = () => {
   const pkgName = pkg?.name || "@krakenslab/oasis";
   const pkgName = pkg?.name || "@krakenslab/oasis";
   const pkgVersion = pkg?.version || "?";
   const pkgVersion = pkg?.version || "?";
 
 
+  let blockchainCycle = {};
+  try {
+    blockchainCycle = JSON.parse(fs.readFileSync(path.join(__dirname, "../configs/blockchain-cycle.json"), "utf8"));
+  } catch (_) {}
+  const cycleVal = blockchainCycle.cycle || "?";
+  const cycleUrl = blockchainCycle.url || "https://laplaza.solarnethub.com";
+
+  const hcT = sharedState.getCarbonHcT();
+  const hcH = sharedState.getCarbonHcH();
+
   return div(
   return div(
     { class: "oasis-footer" },
     { class: "oasis-footer" },
     div(
     div(
@@ -85,8 +96,9 @@ const renderFooter = () => {
           alt: "Oasis"
           alt: "Oasis"
         })
         })
       ),
       ),
+      br(),
       a(
       a(
-        { href: "https://code.03c8.net/krakenslab/oasis", target: "_blank", rel: "noreferrer noopener", class: "oasis-footer-license-link" },
+        { href: "https://code.03c8.net/krakenslab/oasis", target: "_blank", rel: "noreferrer noopener" },
       span(pkgName),
       span(pkgName),
       ),
       ),
       span("["),
       span("["),
@@ -94,11 +106,21 @@ const renderFooter = () => {
       span("]"),
       span("]"),
       span({ class: "oasis-footer-sep" }, " - "),
       span({ class: "oasis-footer-sep" }, " - "),
       a(
       a(
-        { href: "https://www.gnu.org/licenses/gpl-3.0.html", target: "_blank", rel: "noreferrer noopener", class: "oasis-footer-license-link" },
+        { href: "https://www.gnu.org/licenses/gpl-3.0.html", target: "_blank", rel: "noreferrer noopener" },
         i18n.footerLicense
         i18n.footerLicense
       ),
       ),
       span({ class: "oasis-footer-sep" }, " - "),
       span({ class: "oasis-footer-sep" }, " - "),
-      span({ class: "oasis-footer-year" }, year)
+      span({ class: "oasis-footer-year" }, year),
+      br(),
+      span("BLOCKCHAIN CYCLE: "),
+      a({ href: cycleUrl, target: "_blank", rel: "noreferrer noopener" }, String(cycleVal)),
+      br(),
+      span({ class: "oasis-footer-carbon" },
+        span("HcT: "),
+        a({ href: "/stats?filter=ALL" }, hcT != null ? String(hcT) : '–'),
+        span(" | HcH: "),
+        a({ href: "/stats?filter=MINE" }, hcH != null ? String(hcH) : '–')
+      )
     )
     )
   );
   );
 };
 };
@@ -132,8 +154,10 @@ const customCSS = (filename) => {
   }
   }
 };
 };
 
 
-const navGroup = ({ id, emoji, title, defaultOpen = false }, ...items) =>
-  li(
+const navGroup = ({ id, emoji, title, defaultOpen = false }, ...items) => {
+  const active = items.filter(Boolean);
+  if (!active.length) return null;
+  return li(
     { class: "oasis-nav-group" },
     { class: "oasis-nav-group" },
     input({
     input({
       type: "checkbox",
       type: "checkbox",
@@ -148,8 +172,9 @@ const navGroup = ({ id, emoji, title, defaultOpen = false }, ...items) =>
       title,
       title,
       span({ class: "oasis-nav-arrow" }, "▾")
       span({ class: "oasis-nav-arrow" }, "▾")
     ),
     ),
-    ul({ class: "oasis-nav-list" }, ...items)
+    ul({ class: "oasis-nav-list" }, ...active)
   );
   );
+};
 
 
 const renderPopularLink = () => {
 const renderPopularLink = () => {
   const popularMod = getConfig().modules.popularMod === "on";
   const popularMod = getConfig().modules.popularMod === "on";
@@ -655,6 +680,7 @@ const template = (titlePrefix, ...elements) => {
       title(titlePrefix, " | Oasis"),
       title(titlePrefix, " | Oasis"),
       link({ rel: "stylesheet", href: "/assets/styles/style.css" }),
       link({ rel: "stylesheet", href: "/assets/styles/style.css" }),
       themeLink,
       themeLink,
+      link({ rel: "stylesheet", href: "/assets/styles/mobile.css", media: "(max-width: 768px)" }),
       link({ rel: "icon", href: "/assets/images/favicon.svg" }),
       link({ rel: "icon", href: "/assets/images/favicon.svg" }),
       meta({ charset: "utf-8" }),
       meta({ charset: "utf-8" }),
       meta({ name: "description", content: i18n.oasisDescription }),
       meta({ name: "description", content: i18n.oasisDescription }),
@@ -681,11 +707,15 @@ const template = (titlePrefix, ...elements) => {
           ),
           ),
           nav(
           nav(
             ul(
             ul(
-              navLink({
-                href: "/inbox",
-                emoji: "☂",
-                text: i18n.inbox
-              }),
+              (() => {
+                const inboxCount = sharedState.getInboxCount();
+                const badge = inboxCount > 0 ? span({ class: 'inbox-badge' }, String(inboxCount)) : '';
+                return li(
+                  a({ href: "/inbox" },
+                    span({ class: "emoji" }, "☂"), nbsp, i18n.inbox, badge
+                  )
+                );
+              })(),
               navLink({
               navLink({
                 href: "/pm",
                 href: "/pm",
                 emoji: "ꕕ",
                 emoji: "ꕕ",
@@ -705,6 +735,18 @@ const template = (titlePrefix, ...elements) => {
           )
           )
         )
         )
       ),
       ),
+      (() => {
+        const updateFlagPath = path.join(__dirname, '../server/.update_required');
+        if (fs.existsSync(updateFlagPath)) {
+          return div(
+            { class: "update-banner" },
+            span({ class: "update-banner-icon" }, "⟳"),
+            span({ class: "update-banner-text" }, i18n.updateBannerText),
+            a({ href: "/settings", class: "update-banner-link" }, i18n.updateBannerAction)
+          );
+        }
+        return null;
+      })(),
       div(
       div(
         { class: "main-content" },
         { class: "main-content" },
         div(
         div(
@@ -1282,7 +1324,7 @@ const post = ({ msg, aside = false, preview = false }) => {
                 (u) => `<a href="${u}" target="_blank" rel="noopener noreferrer">${u}</a>`
                 (u) => `<a href="${u}" target="_blank" rel="noopener noreferrer">${u}</a>`
             );
             );
         }
         }
-        articleElement = article({ class: "content", innerHTML: html });
+        articleElement = article({ class: "content", innerHTML: sanitizeHtml(html) });
     } else {
     } else {
         articleElement = article(
         articleElement = article(
             { class: "content" },
             { class: "content" },
@@ -1444,17 +1486,17 @@ exports.editProfileView = ({ name, description }) =>
         },
         },
         label(
         label(
           i18n.profileImage,
           i18n.profileImage,
-          br,
+          br(),
           input({ type: "file", name: "image", accept: "image/*" })
           input({ type: "file", name: "image", accept: "image/*" })
         ),
         ),
-        br,br,
-        label(i18n.profileName, 
-        br,
+        br(),br(),
+        label(i18n.profileName,
+        br(),
         input({ name: "name", value: name })),
         input({ name: "name", value: name })),
-        br,br,
+        br(),br(),
         label(
         label(
           i18n.profileDescription,
           i18n.profileDescription,
-          br,
+          br(),
           textarea(
           textarea(
             {
             {
               autofocus: true,
               autofocus: true,
@@ -1464,7 +1506,7 @@ exports.editProfileView = ({ name, description }) =>
             description
             description
           )
           )
         ),
         ),
-        br,
+        br(),
         button(
         button(
           {
           {
             type: "submit",
             type: "submit",
@@ -1520,7 +1562,8 @@ exports.authorView = ({
   })();
   })();
 
 
   const bucket = lastActivityBucket || 'red';
   const bucket = lastActivityBucket || 'red';
-  const dotClass = bucket === "green" ? "green" : bucket === "orange" ? "orange" : "red";
+
+  const { lastActivityBadge } = require('./inhabitants_view');
 
 
   const prefix = section(
   const prefix = section(
     { class: "message" },
     { class: "message" },
@@ -1530,20 +1573,18 @@ exports.authorView = ({
         img({ class: "inhabitant-photo-details", src: avatarUrl }),
         img({ class: "inhabitant-photo-details", src: avatarUrl }),
         h1({ class: "name" }, name),
         h1({ class: "name" }, name),
       ),
       ),
-      pre({ class: "md-mention", innerHTML: markdownMention }),
+      pre({ class: "md-mention", innerHTML: sanitizeHtml(markdownMention) }),
       p(a({ class: "user-link", href: `/author/${encodeURIComponent(feedId)}` }, feedId)),
       p(a({ class: "user-link", href: `/author/${encodeURIComponent(feedId)}` }, feedId)),
       div({ class: "profile-metrics" },
       div({ class: "profile-metrics" },
         p(`${i18n.bankingUserEngagementScore}: `, strong(karmaScore !== undefined ? karmaScore : 0)),
         p(`${i18n.bankingUserEngagementScore}: `, strong(karmaScore !== undefined ? karmaScore : 0)),
-        div({ class: "inhabitant-last-activity" },
-          span({ class: "label" }, `${i18n.inhabitantActivityLevel}:`),
-          span({ class: `activity-dot ${dotClass}` }, "")
-        ),
-        ecoAddress
-          ? div({ class: "eco-wallet" }, p(`${i18n.bankWalletConnected}: `, strong(ecoAddress)))
-          : div({ class: "eco-wallet" }, p(i18n.ecoWalletNotConfigured || "ECOin Wallet not configured"))
+        ...lastActivityBadge({ lastActivityBucket: bucket }, true),
+        div({ class: "eco-wallet" },
+          p(`${i18n.statsEcoWalletLabel || 'ECOin Wallet'}: `,
+            a({ href: '/wallet' }, ecoAddress || i18n.statsEcoWalletNotConfigured || 'Not configured!'))
+        )
       )
       )
     ),
     ),
-    description !== "" ? article({ innerHTML: markdown(description) }) : null,
+    description !== "" ? article({ innerHTML: sanitizeHtml(markdown(description)) }) : null,
     footer(
     footer(
       div(
       div(
         { class: "profile" },
         { class: "profile" },
@@ -1728,7 +1769,7 @@ exports.commentView = async (
     form(
     form(
       { action, method, enctype: "multipart/form-data" },
       { action, method, enctype: "multipart/form-data" },
       i18n.blogSubject,
       i18n.blogSubject,
-      br,
+      br(),
       label(
       label(
         i18n.contentWarningLabel,
         i18n.contentWarningLabel,
         input({
         input({
@@ -1739,9 +1780,9 @@ exports.commentView = async (
           placeholder: i18n.contentWarningPlaceholder
           placeholder: i18n.contentWarningPlaceholder
         })
         })
       ),
       ),
-      br,
+      br(),
       label({ for: "text" }, i18n.blogMessage),
       label({ for: "text" }, i18n.blogMessage),
-      br,
+      br(),
 	textarea(
 	textarea(
 	  {
 	  {
 	    autofocus: true,
 	    autofocus: true,
@@ -1753,14 +1794,14 @@ exports.commentView = async (
 	  },
 	  },
 	  text ? text : null
 	  text ? text : null
 	),
 	),
-      br,
+      br(),
       label(
       label(
         { for: "blob" },
         { for: "blob" },
-        i18n.blogImage || "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)"
+        i18n.blogImage || "Upload media (max-size: 50MB)"
       ),
       ),
       input({ type: "file", id: "blob", name: "blob" }),
       input({ type: "file", id: "blob", name: "blob" }),
-      br,
-      br,
+      br(),
+      br(),
       button({ type: "submit" }, i18n.blogPublish)
       button({ type: "submit" }, i18n.blogPublish)
     ),
     ),
     preview ? div({ class: "comment-preview" }, preview) : ""
     preview ? div({ class: "comment-preview" }, preview) : ""
@@ -1769,15 +1810,53 @@ exports.commentView = async (
 
 
 const renderMessage = (msg) => {
 const renderMessage = (msg) => {
   const content = lodash.get(msg, "value.content", {});
   const content = lodash.get(msg, "value.content", {});
-  const author = msg.value.author || "Anonymous";
+  const authorId = msg.value.author || "Anonymous";
+  const authorName = lodash.get(msg, "value.meta.author.name") || authorId.slice(0, 10) + '...';
   const createdAt = new Date(msg.value.timestamp).toLocaleString();
   const createdAt = new Date(msg.value.timestamp).toLocaleString();
   const mentionsText = content.text || '';
   const mentionsText = content.text || '';
+  const isTribe = content.type === 'tribe-content';
+  const visitUrl = isTribe
+    ? `/tribe/${encodeURIComponent(content.tribeId)}`
+    : content.root
+      ? `/thread/${encodeURIComponent(content.root)}#${encodeURIComponent(msg.key)}`
+      : msg.key
+        ? `/thread/${encodeURIComponent(msg.key)}#${encodeURIComponent(msg.key)}`
+        : null;
+  const badge = isTribe && content.tribeName
+    ? span({ class: 'tribe-badge' }, content.tribeName)
+    : null;
+
+  return div({ class: "mention-item" },
+    div({ class: "mention-content" },
+      badge,
+      ...renderUrl(mentionsText || '[No content]')
+    ),
+    p(a({ class: 'user-link', href: `/author/${encodeURIComponent(authorId)}` }, authorName)),
+    p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`),
+    visitUrl
+      ? form({ method: 'GET', action: visitUrl },
+          button({ type: 'submit', class: 'filter-btn' }, i18n.visitContent || 'Visit')
+        )
+      : null
+  );
+};
 
 
-  return div({ class: "mention-item" }, [
-    div({ class: "mention-content", innerHTML: mentionsText || '[No content]' }),
-    p(a({ class: 'user-link', href: `/author/${encodeURIComponent(author)}` }, author)),
-    p(`${i18n.createdAtLabel || i18n.mentionsCreatedAt}: ${createdAt}`)
-  ]);
+const hasMention = (msg, feedId) => {
+  const content = lodash.get(msg, "value.content", {});
+  const mentions = content.mentions;
+  if (mentions) {
+    if (Array.isArray(mentions)) {
+      if (mentions.some(m => m.link === feedId || m.feed === feedId)) return true;
+    } else if (typeof mentions === 'object') {
+      for (const arr of Object.values(mentions)) {
+        if (Array.isArray(arr) && arr.some(m => m.link === feedId || m.feed === feedId)) return true;
+        if (arr && (arr.link === feedId || arr.feed === feedId)) return true;
+      }
+    }
+  }
+  const text = content.text || '';
+  if (text.includes(feedId) || text.includes(feedId.slice(1))) return true;
+  return false;
 };
 };
 
 
 exports.mentionsView = ({ messages, myFeedId }) => {
 exports.mentionsView = ({ messages, myFeedId }) => {
@@ -1799,10 +1878,9 @@ exports.mentionsView = ({ messages, myFeedId }) => {
       )
       )
     );
     );
   }
   }
-  const filteredMessages = messages.filter(msg => {
-    const mentions = lodash.get(msg, "value.content.mentions", {});
-    return Object.keys(mentions).some(key => mentions[key].link === myFeedId);
-  });
+  const filteredMessages = messages
+    .filter(msg => hasMention(msg, myFeedId))
+    .sort((a, b) => (b.value.timestamp || 0) - (a.value.timestamp || 0));
   if (filteredMessages.length === 0) {
   if (filteredMessages.length === 0) {
     return template(
     return template(
       title,
       title,
@@ -1864,11 +1942,24 @@ exports.privateView = async (messagesInput, filter) => {
 
 
   const chip = (txt) => span({ class: 'chip' }, txt)
   const chip = (txt) => span({ class: 'chip' }, txt)
 
 
-  function headerLine({ sentAt, from, toLinks, textLen }) {
-    return div({ class: 'pm-header' },
-      span({ class: 'date-link' }, `${moment(sentAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
-      span({ class: 'pm-from' }, ' ', i18n.pmFromLabel, ' ', linkAuthor(from)),
-      span({ class: 'pm-to' }, ' ', '→', ' ', i18n.pmToLabel, ' ', toLinks)
+  function headerLine({ sentAt, from, toLinks, subject }) {
+    return table({ class: 'pm-info-table' },
+      tr(
+        td({ class: 'card-label' }, i18n.pmFromLabel || 'From:'),
+        td({ class: 'card-value' }, linkAuthor(from))
+      ),
+      tr(
+        td({ class: 'card-label' }, i18n.privateDate || 'Date'),
+        td({ class: 'card-value' }, moment(sentAt).format('YYYY/MM/DD HH:mm:ss'))
+      ),
+      tr(
+        td({ class: 'card-label' }, i18n.pmToLabel || 'To:'),
+        td({ class: 'card-value' }, ...toLinks.reduce((acc, lnk, i) => i > 0 ? [...acc, br(), lnk] : [lnk], []))
+      ),
+      tr(
+        td({ class: 'card-label' }, i18n.pmSubjectLabel || 'Subject:'),
+        td({ class: 'card-value' }, subject || i18n.pmNoSubject || '(no subject)')
+      )
     )
     )
   }
   }
 
 
@@ -1929,7 +2020,25 @@ exports.privateView = async (messagesInput, filter) => {
   }
   }
 
 
   function clickableLinks(str) {
   function clickableLinks(str) {
-    return str
+    const lines = str.split('\n')
+    const parts = []
+    let quoteBuffer = []
+    const flushQuote = () => {
+      if (quoteBuffer.length) {
+        parts.push(`<div class="pm-quote">${quoteBuffer.join('<br>')}</div>`)
+        quoteBuffer = []
+      }
+    }
+    for (const line of lines) {
+      if (/^>\s?/.test(line)) {
+        quoteBuffer.push(line.replace(/^>\s?/, ''))
+      } else {
+        flushQuote()
+        parts.push(line)
+      }
+    }
+    flushQuote()
+    return parts.join('<br>')
       .replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g, (match, id) => `<a class="user-link" href="/author/${encodeURIComponent(id)}">${match}</a>`)
       .replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g, (match, id) => `<a class="user-link" href="/author/${encodeURIComponent(id)}">${match}</a>`)
       .replace(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="job-link" href="${hrefFor.job(id)}">${match}</a>`)
       .replace(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="job-link" href="${hrefFor.job(id)}">${match}</a>`)
       .replace(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="project-link" href="${hrefFor.project(id)}">${match}</a>`)
       .replace(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="project-link" href="${hrefFor.project(id)}">${match}</a>`)
@@ -1976,7 +2085,7 @@ exports.privateView = async (messagesInput, filter) => {
     const href = jobId ? hrefFor.job(jobId) : null
     const href = jobId ? hrefFor.job(jobId) : null
     return div(
     return div(
       clickableCardProps(href, `job-notification thread-level-0`),
       clickableCardProps(href, `job-notification thread-level-0`),
-      headerLine({ sentAt, from, toLinks, textLen: text.length }),
+      headerLine({ sentAt, from, toLinks, subject: type }),
       h2({ class: 'pm-title' }, `${icon} ${i18n.pmBotJobs} · ${titleH}`),
       h2({ class: 'pm-title' }, `${icon} ${i18n.pmBotJobs} · ${titleH}`),
       p(
       p(
         i18n.pmInhabitantWithId, ' ',
         i18n.pmInhabitantWithId, ' ',
@@ -2000,7 +2109,7 @@ exports.privateView = async (messagesInput, filter) => {
     const href = projectId ? hrefFor.project(projectId) : null
     const href = projectId ? hrefFor.project(projectId) : null
     return div(
     return div(
       clickableCardProps(href, `project-${isFollow ? 'follow' : 'unfollow'}-notification thread-level-0`),
       clickableCardProps(href, `project-${isFollow ? 'follow' : 'unfollow'}-notification thread-level-0`),
-      headerLine({ sentAt, from, toLinks, textLen: text.length }),
+      headerLine({ sentAt, from, toLinks, subject: type }),
       h2({ class: 'pm-title' }, `${icon} ${i18n.pmBotProjects} · ${titleH}`),
       h2({ class: 'pm-title' }, `${icon} ${i18n.pmBotProjects} · ${titleH}`),
       p(
       p(
         i18n.pmInhabitantWithId, ' ',
         i18n.pmInhabitantWithId, ' ',
@@ -2022,7 +2131,7 @@ exports.privateView = async (messagesInput, filter) => {
     const href = marketId ? hrefFor.market(marketId) : null
     const href = marketId ? hrefFor.market(marketId) : null
     return div(
     return div(
       clickableCardProps(href, 'market-sold-notification thread-level-0'),
       clickableCardProps(href, 'market-sold-notification thread-level-0'),
-      headerLine({ sentAt, from, toLinks, textLen: text.length }),
+      headerLine({ sentAt, from, toLinks, subject }),
       h2({ class: 'pm-title' }, `💰 ${i18n.pmBotMarket} · ${i18n.inboxMarketItemSoldTitle}`),
       h2({ class: 'pm-title' }, `💰 ${i18n.pmBotMarket} · ${i18n.inboxMarketItemSoldTitle}`),
       p(
       p(
         i18n.pmYourItem, ' ',
         i18n.pmYourItem, ' ',
@@ -2043,7 +2152,7 @@ exports.privateView = async (messagesInput, filter) => {
     const href = projectId ? hrefFor.project(projectId) : null
     const href = projectId ? hrefFor.project(projectId) : null
     return div(
     return div(
       clickableCardProps(href, 'project-pledge-notification thread-level-0'),
       clickableCardProps(href, 'project-pledge-notification thread-level-0'),
-      headerLine({ sentAt, from, toLinks, textLen: text.length }),
+      headerLine({ sentAt, from, toLinks, subject: 'PROJECT_PLEDGE' }),
       h2({ class: 'pm-title' }, `💚 ${i18n.pmBotProjects} · ${i18n.inboxProjectPledgedTitle}`),
       h2({ class: 'pm-title' }, `💚 ${i18n.pmBotProjects} · ${i18n.inboxProjectPledgedTitle}`),
       p(
       p(
         i18n.pmInhabitantWithId, ' ',
         i18n.pmInhabitantWithId, ' ',
@@ -2089,40 +2198,77 @@ exports.privateView = async (messagesInput, filter) => {
         ])
         ])
       ),
       ),
       div({ class: 'message-list' },
       div({ class: 'message-list' },
-        sorted.length
-          ? sorted.map(msg => {
-              const content = msg.value.content
-              const author = msg.value.author
-              const subjectRaw = content.subject || ''
-              const subjectU = subjectRaw.toUpperCase()
-              const text = content.text || ''
-              const sentAt = new Date(content.sentAt || msg.timestamp)
-              const fromResolved = content.from || author
-              const toLinks = Array.isArray(content.to) ? content.to.map(addr => linkAuthor(addr)) : []
-              const level = threadLevel(subjectRaw)
-
-              if (subjectU === 'JOB_SUBSCRIBED' || subjectU === 'JOB_UNSUBSCRIBED') {
-                return JobCard({ type: subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key })
-              }
-              if (subjectU === 'PROJECT_FOLLOWED' || subjectU === 'PROJECT_UNFOLLOWED') {
-                return ProjectFollowCard({ type: subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key })
-              }
-              if (subjectU === 'MARKET_SOLD') {
-                return MarketSoldCard({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, text, key: msg.key })
-              }
-              if (subjectU === 'PROJECT_PLEDGE' || content.meta?.type === 'project-pledge') {
-                return ProjectPledgeCard({ sentAt, from: fromResolved, toLinks, content, text, key: msg.key })
-              }
-
-              return div(
-                { class: `pm-card normal-pm thread-level-${level}` },
-                headerLine({ sentAt, from: fromResolved, toLinks, textLen: text.length }),
-                h2(subjectRaw || i18n.pmNoSubject),
-                p({ class: 'message-text' }, ...renderUrl(clickableLinks(text))),
-                actions({ key: msg.key, replyId: fromResolved, subjectRaw, text })
+        (() => {
+          function renderMsg(msg) {
+            const content = msg.value.content
+            const author = msg.value.author
+            const subjectRaw = content.subject || ''
+            const subjectU = subjectRaw.toUpperCase()
+            const text = content.text || ''
+            const sentAt = new Date(content.sentAt || msg.timestamp)
+            const fromResolved = content.from || author
+            const toLinks = Array.isArray(content.to) ? content.to.map(addr => linkAuthor(addr)) : []
+            const level = threadLevel(subjectRaw)
+
+            if (subjectU === 'JOB_SUBSCRIBED' || subjectU === 'JOB_UNSUBSCRIBED') {
+              return JobCard({ type: subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key })
+            }
+            if (subjectU === 'PROJECT_FOLLOWED' || subjectU === 'PROJECT_UNFOLLOWED') {
+              return ProjectFollowCard({ type: subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key })
+            }
+            if (subjectU === 'MARKET_SOLD') {
+              return MarketSoldCard({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, text, key: msg.key })
+            }
+            if (subjectU === 'PROJECT_PLEDGE' || content.meta?.type === 'project-pledge') {
+              return ProjectPledgeCard({ sentAt, from: fromResolved, toLinks, content, text, key: msg.key })
+            }
+
+            return div(
+              { class: 'pm-card normal-pm' },
+              headerLine({ sentAt, from: fromResolved, toLinks, subject: subjectRaw }),
+              div({ class: 'message-text', innerHTML: clickableLinks(text) }),
+              actions({ key: msg.key, replyId: fromResolved, subjectRaw, text })
+            )
+          }
+
+          const threadGroups = {}
+          const threadOrder = []
+          for (const msg of sorted) {
+            const tid = threadId(msg)
+            if (!threadGroups[tid]) {
+              threadGroups[tid] = []
+              threadOrder.push(tid)
+            }
+            threadGroups[tid].push(msg)
+          }
+
+          if (!threadOrder.length) return p({ class: 'empty' }, i18n.noPrivateMessages)
+
+          return threadOrder.map(tid => {
+            const msgs = threadGroups[tid]
+            const original = msgs[0]
+            const replies = msgs.slice(1)
+
+            if (!replies.length) {
+              return renderMsg(original)
+            }
+
+            const replyLabel = `${replies.length} ${replies.length === 1 ? (i18n.pmReply || 'reply') : (i18n.pmReplies || 'replies')}`
+
+            return div({ class: 'pm-thread' },
+              renderMsg(original),
+              details({ class: 'pm-thread-details' },
+                summary({ class: 'pm-thread-toggle' },
+                  span({ class: 'pm-thread-icon' }, '▶'),
+                  span(replyLabel)
+                ),
+                div({ class: 'pm-thread-replies' },
+                  ...replies.map(renderMsg)
+                )
               )
               )
-            })
-          : p({ class: 'empty' }, i18n.noPrivateMessages)
+            )
+          })
+        })()
       )
       )
     )
     )
   )
   )
@@ -2154,8 +2300,8 @@ exports.publishCustomView = async () => {
           '  "hello": "world"\n',
           '  "hello": "world"\n',
           "}"
           "}"
         ),
         ),
-        br,
-        br,
+        br(),
+        br(),
         button({ type: "submit" }, i18n.submit)
         button({ type: "submit" }, i18n.submit)
       )
       )
     ),
     ),
@@ -2233,7 +2379,7 @@ exports.publishView = (preview, text, contentWarning) => {
               text || ""
               text || ""
             ),
             ),
             br(),
             br(),
-            label({ for: "blob" }, i18n.blogImage || "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)"),
+            label({ for: "blob" }, i18n.blogImage || "Upload media (max-size: 50MB)"),
             br(),
             br(),
             input({ type: "file", id: "blob", name: "blob" }),
             input({ type: "file", id: "blob", name: "blob" }),
             br(), br(),
             br(), br(),
@@ -2313,12 +2459,29 @@ const markdownMentionsToHtml = (markdownText) => {
   const escaped = escapeHtml(String(markdownText || ""))
   const escaped = escapeHtml(String(markdownText || ""))
   const withBr = escaped.replace(/\r\n|\r|\n/g, "<br>")
   const withBr = escaped.replace(/\r\n|\r|\n/g, "<br>")
 
 
+  const unescapeBlob = (b) => b.replace(/&amp;/g, '&')
+
   const withImages = withBr.replace(
   const withImages = withBr.replace(
-    /!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g,
-    (_m, alt, blob) => `<img src="/blob/${encodeURIComponent(blob)}" alt="${escapeHtml(alt)}">`
+    /!\[([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g,
+    (_m, alt, blob) => `<img src="/blob/${encodeURIComponent(unescapeBlob(blob))}" alt="${alt}" class="post-image">`
   )
   )
 
 
-  const withMentions = withImages.replace(
+  const withVideos = withImages.replace(
+    /\[video:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g,
+    (_m, _name, blob) => `<video controls class="post-video" src="/blob/${encodeURIComponent(unescapeBlob(blob))}"></video>`
+  )
+
+  const withAudios = withVideos.replace(
+    /\[audio:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g,
+    (_m, _name, blob) => `<audio controls class="post-audio" src="/blob/${encodeURIComponent(unescapeBlob(blob))}"></audio>`
+  )
+
+  const withPdfs = withAudios.replace(
+    /\[pdf:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g,
+    (_m, name, blob) => `<a class="post-pdf" href="/blob/${encodeURIComponent(unescapeBlob(blob))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
+  )
+
+  const withMentions = withPdfs.replace(
     /\[@([^\]]+)\]\(\s*@?([^) \t\r\n]+\.ed25519)\s*\)/g,
     /\[@([^\]]+)\]\(\s*@?([^) \t\r\n]+\.ed25519)\s*\)/g,
     (_m, label, feed) => {
     (_m, label, feed) => {
       const href = authorHref(feed)
       const href = authorHref(feed)
@@ -2361,8 +2524,18 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
       const nameText = nameRaw.startsWith("@") ? nameRaw : `@${nameRaw}`
       const nameText = nameRaw.startsWith("@") ? nameRaw : `@${nameRaw}`
 
 
       const rel = first.rel || {}
       const rel = first.rel || {}
-      const relText = rel.followsMe ? i18n.relationshipMutuals : i18n.relationshipNotMutuals
-      const emoji = rel.followsMe ? "☍" : "⚼"
+
+      const relationshipBadge = rel.me
+        ? span({ class: "status you" }, i18n.relationshipYou)
+        : rel.blocking
+          ? span({ class: "status blocked" }, i18n.relationshipBlocking)
+          : rel.following && rel.followsMe
+            ? span({ class: "status mutual" }, i18n.relationshipMutuals)
+            : rel.following
+              ? span({ class: "status supporting" }, i18n.relationshipFollowing)
+              : rel.followsMe
+                ? span({ class: "status supported-by" }, i18n.relationshipTheyFollow)
+                : span({ class: "status" }, i18n.relationshipNone)
 
 
       const avatar = first.img || first.image || ""
       const avatar = first.img || first.image || ""
       const avatarUrl =
       const avatarUrl =
@@ -2373,7 +2546,7 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
       return div(
       return div(
         { class: "mention-card" },
         { class: "mention-card" },
         a({ href: authorHref(feed) }, img({ src: avatarUrl, class: "avatar-profile" })),
         a({ href: authorHref(feed) }, img({ src: avatarUrl, class: "avatar-profile" })),
-        br,
+        br(),
         div(
         div(
           { class: "mention-name" },
           { class: "mention-name" },
           span({ class: "label" }, `${i18n.mentionsName}: `),
           span({ class: "label" }, `${i18n.mentionsName}: `),
@@ -2381,11 +2554,10 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
         ),
         ),
         div(
         div(
           { class: "mention-relationship" },
           { class: "mention-relationship" },
-          span({ class: "label" }, `${i18n.mentionsRelationship}:`),
-          span({ class: "relationship" }, relText),
+          span({ class: "label" }, `${i18n.mentionsRelationship}: `),
+          relationshipBadge,
           div(
           div(
             { class: "mention-relationship-details" },
             { class: "mention-relationship-details" },
-            span({ class: "emoji" }, emoji),
             span(
             span(
               { class: "mentions-listing" },
               { class: "mentions-listing" },
               a({ class: "user-link", href: authorHref(feed) }, `@${stripAt(feed)}`)
               a({ class: "user-link", href: authorHref(feed) }, `@${stripAt(feed)}`)
@@ -2623,7 +2795,7 @@ exports.subtopicView = async (
     form(
     form(
       { action: subtopicForm, method: "post", enctype: "multipart/form-data" },
       { action: subtopicForm, method: "post", enctype: "multipart/form-data" },
       i18n.blogSubject,
       i18n.blogSubject,
-      br, 
+      br(),
       label(
       label(
         i18n.contentWarningLabel,
         i18n.contentWarningLabel,
         input({
         input({
@@ -2634,9 +2806,9 @@ exports.subtopicView = async (
           placeholder: i18n.contentWarningPlaceholder,
           placeholder: i18n.contentWarningPlaceholder,
         })
         })
       ),
       ),
-      br,
+      br(),
       label({ for: "text" }, i18n.blogMessage),
       label({ for: "text" }, i18n.blogMessage),
-      br,
+      br(),
       textarea(
       textarea(
         {
         {
           autofocus: true,
           autofocus: true,
@@ -2648,14 +2820,14 @@ exports.subtopicView = async (
         },
         },
         text ? text : markdownMention
         text ? text : markdownMention
       ),
       ),
-      br,
+      br(),
       label(
       label(
         { for: "blob" },
         { for: "blob" },
-        i18n.blogImage || "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)"
+        i18n.blogImage || "Upload media (max-size: 50MB)"
       ),
       ),
       input({ type: "file", id: "blob", name: "blob" }),
       input({ type: "file", id: "blob", name: "blob" }),
-      br,
-      br,
+      br(),
+      br(),
       button({ type: "submit" }, i18n.blogPublish)
       button({ type: "submit" }, i18n.blogPublish)
     ),
     ),
     preview ? div({ class: "comment-preview" }, preview) : ""
     preview ? div({ class: "comment-preview" }, preview) : ""

+ 21 - 6
src/views/market_view.js

@@ -1,9 +1,23 @@
-const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, th, td, progress } = require("../server/node_modules/hyperaxe")
+const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe")
 const { template, i18n } = require("./main_views")
 const { template, i18n } = require("./main_views")
 const moment = require("../server/node_modules/moment")
 const moment = require("../server/node_modules/moment")
 const { config } = require("../server/SSB_server.js")
 const { config } = require("../server/SSB_server.js")
 const { renderUrl } = require("../backend/renderUrl")
 const { renderUrl } = require("../backend/renderUrl")
 
 
+const renderMediaBlob = (value, fallbackSrc = null) => {
+  if (!value) return fallbackSrc ? img({ src: fallbackSrc }) : null
+  const s = String(value).trim()
+  if (!s) return fallbackSrc ? img({ src: fallbackSrc }) : null
+  if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}` })
+  const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
+  if (mVideo) return video({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` })
+  const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
+  if (mAudio) return audio({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` })
+  const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
+  if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: 'post-image' })
+  return fallbackSrc ? img({ src: fallbackSrc }) : null
+}
+
 const userId = config.keys.id
 const userId = config.keys.id
 
 
 const parseBidEntry = (raw) => {
 const parseBidEntry = (raw) => {
@@ -115,9 +129,10 @@ const renderMarketCommentsSection = (itemId, returnTo, comments = []) => {
       { class: "comment-form-wrapper" },
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       form(
       form(
-        { method: "POST", action: `/market/${encodeURIComponent(itemId)}/comments`, class: "comment-form" },
+        { method: "POST", action: `/market/${encodeURIComponent(itemId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
         input({ type: "hidden", name: "returnTo", value: returnTo }),
         input({ type: "hidden", name: "returnTo", value: returnTo }),
-        textarea({ id: "comment-text", name: "text", required: true, rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
+        textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
+        div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
         br(),
         br(),
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
       )
@@ -410,7 +425,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
               br(),
               br(),
               label(i18n.marketCreateFormImageLabel),
               label(i18n.marketCreateFormImageLabel),
               br(),
               br(),
-              input({ type: "file", name: "image", id: "image", accept: "image/*" }),
+              input({ type: "file", name: "image", id: "image" }),
               br(),
               br(),
               br(),
               br(),
               label(i18n.marketItemStatus),
               label(i18n.marketItemStatus),
@@ -541,7 +556,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
                       br(),
                       br(),
                       div(
                       div(
                         { class: "market-card image" },
                         { class: "market-card image" },
-                        item.image ? img({ src: `/blob/${encodeURIComponent(item.image)}` }) : img({ src: "/assets/images/default-market.png", alt: item.title })
+                        renderMediaBlob(item.image, "/assets/images/default-market.png")
                       ),
                       ),
                       p(...renderUrl(item.description)),
                       p(...renderUrl(item.description)),
                       item.tags && item.tags.filter(Boolean).length
                       item.tags && item.tags.filter(Boolean).length
@@ -656,7 +671,7 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
         br(),
         br(),
         div(
         div(
           { class: "market-item image" },
           { class: "market-item image" },
-          item.image ? img({ src: `/blob/${encodeURIComponent(item.image)}` }) : img({ src: "/assets/images/default-market.png", alt: item.title })
+          renderMediaBlob(item.image, "/assets/images/default-market.png")
         ),
         ),
         renderCardField(`${i18n.marketItemDescription}:`, ""),
         renderCardField(`${i18n.marketItemDescription}:`, ""),
         p(...renderUrl(item.description)),
         p(...renderUrl(item.description)),

+ 23 - 0
src/views/modules_view.js

@@ -71,10 +71,33 @@ const modulesView = () => {
     )
     )
   );
   );
 
 
+  const PRESETS = {
+    minimal: ['feed', 'forum', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'],
+    social: ['agenda', 'audios', 'bookmarks', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'images', 'invites', 'legacy', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
+    economy: ['agenda', 'audios', 'bookmarks', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'images', 'invites', 'legacy', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs'],
+    full: modules.map(m => m.name)
+  };
+
+  const presetButtons = div({ class: 'preset-group', style: 'display:flex;gap:8px;flex-wrap:nowrap;margin-bottom:16px;' },
+    Object.entries(PRESETS).map(([key, mods]) => {
+      const presetLabel = (i18n[`modulesPreset_${key}`] || key).toUpperCase();
+      const isActive = modules.every(m => mods.includes(m.name) === (moduleStates[`${m.name}Mod`] === 'on'));
+      return form({ action: "/modules/preset", method: "post", style: "display:inline;margin:0;" },
+        input({ type: "hidden", name: "preset", value: key }),
+        button({
+          type: 'submit',
+          class: isActive ? 'filter-btn active' : 'filter-btn',
+        }, presetLabel)
+      );
+    })
+  );
+
   return template(
   return template(
     i18n.modules,
     i18n.modules,
     section(header),
     section(header),
     section(
     section(
+      h2(i18n.modulesPresetTitle || "Common Configurations"),
+      presetButtons,
       form(
       form(
         { action: "/save-modules", method: "post" },
         { action: "/save-modules", method: "post" },
         table(
         table(

+ 35 - 11
src/views/opinions_view.js

@@ -4,6 +4,7 @@ const { config } = require('../server/SSB_server.js');
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { renderUrl } = require('../backend/renderUrl');
 const { renderUrl } = require('../backend/renderUrl');
 const opinionCategories = require('../backend/opinion_categories');
 const opinionCategories = require('../backend/opinion_categories');
+const { sanitizeHtml } = require('../backend/sanitizeHtml');
 
 
 const seenDocumentTitles = new Set();
 const seenDocumentTitles = new Set();
 
 
@@ -15,7 +16,7 @@ const renderContentHtml = (content, key) => {
           form({ method: "GET", action: `/bookmarks/${encodeURIComponent(key)}` },
           form({ method: "GET", action: `/bookmarks/${encodeURIComponent(key)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           ),
           ),
-          br,
+          br(),
           h2(content.url ? div({ class: 'card-field' },
           h2(content.url ? div({ class: 'card-field' },
             span({ class: 'card-label' }, p(a({ href: content.url, target: '_blank', class: "bookmark-url" }, content.url)))
             span({ class: 'card-label' }, p(a({ href: content.url, target: '_blank', class: "bookmark-url" }, content.url)))
           ) : ""),
           ) : ""),
@@ -37,7 +38,7 @@ const renderContentHtml = (content, key) => {
           form({ method: "GET", action: `/images/${encodeURIComponent(key)}` },
           form({ method: "GET", action: `/images/${encodeURIComponent(key)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           ),
           ),
-          br,
+          br(),
           content.title ? div({ class: 'card-field' },
           content.title ? div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.imageTitleLabel + ':'),
             span({ class: 'card-label' }, i18n.imageTitleLabel + ':'),
             span({ class: 'card-value' }, content.title)
             span({ class: 'card-value' }, content.title)
@@ -52,7 +53,7 @@ const renderContentHtml = (content, key) => {
             span({ class: 'card-label' }, i18n.trendingCategory + ':'),
             span({ class: 'card-label' }, i18n.trendingCategory + ':'),
             span({ class: 'card-value' }, i18n.meme)
             span({ class: 'card-value' }, i18n.meme)
           ) : "",
           ) : "",
-          br,
+          br(),
           div({ class: 'card-field' },
           div({ class: 'card-field' },
             img({ src: `/blob/${encodeURIComponent(content.url)}`, class: 'feed-image' })
             img({ src: `/blob/${encodeURIComponent(content.url)}`, class: 'feed-image' })
           )
           )
@@ -64,7 +65,7 @@ const renderContentHtml = (content, key) => {
           form({ method: "GET", action: `/videos/${encodeURIComponent(key)}` },
           form({ method: "GET", action: `/videos/${encodeURIComponent(key)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           ),
           ),
-          br,
+          br(),
           content.title ? div({ class: 'card-field' },
           content.title ? div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.videoTitleLabel + ':'),
             span({ class: 'card-label' }, i18n.videoTitleLabel + ':'),
             span({ class: 'card-value' }, content.title)
             span({ class: 'card-value' }, content.title)
@@ -92,7 +93,7 @@ const renderContentHtml = (content, key) => {
           form({ method: "GET", action: `/audios/${encodeURIComponent(key)}` },
           form({ method: "GET", action: `/audios/${encodeURIComponent(key)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           ),
           ),
-          br,
+          br(),
           content.title ? div({ class: 'card-field' },
           content.title ? div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.audioTitleLabel + ':'),
             span({ class: 'card-label' }, i18n.audioTitleLabel + ':'),
             span({ class: 'card-value' }, content.title)
             span({ class: 'card-value' }, content.title)
@@ -122,7 +123,7 @@ const renderContentHtml = (content, key) => {
           form({ method: "GET", action: `/documents/${encodeURIComponent(key)}` },
           form({ method: "GET", action: `/documents/${encodeURIComponent(key)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           ),
           ),
-          br,
+          br(),
           t ? div({ class: 'card-field' },
           t ? div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.documentTitleLabel + ':'),
             span({ class: 'card-label' }, i18n.documentTitleLabel + ':'),
             span({ class: 'card-value' }, t)
             span({ class: 'card-value' }, t)
@@ -142,7 +143,7 @@ const renderContentHtml = (content, key) => {
     case 'feed':
     case 'feed':
       return div({ class: 'opinion-feed' },
       return div({ class: 'opinion-feed' },
         div({ class: 'card-section feed' },
         div({ class: 'card-section feed' },
-          div({ class: 'feed-text', innerHTML: renderTextWithStyles(content.text) }),
+          div({ class: 'feed-text', innerHTML: sanitizeHtml(renderTextWithStyles(content.text)) }),
           h2({ class: 'card-field' },
           h2({ class: 'card-field' },
             span({ class: 'card-label' }, `${i18n.tribeFeedRefeeds}: `),
             span({ class: 'card-label' }, `${i18n.tribeFeedRefeeds}: `),
             span({ class: 'card-value' }, content.refeeds)
             span({ class: 'card-value' }, content.refeeds)
@@ -183,7 +184,7 @@ const renderContentHtml = (content, key) => {
           form({ method: "GET", action: `/transfers/${encodeURIComponent(key)}` },
           form({ method: "GET", action: `/transfers/${encodeURIComponent(key)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           ),
           ),
-          br,
+          br(),
           div({ class: 'card-field' },
           div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.concept + ':'),
             span({ class: 'card-label' }, i18n.concept + ':'),
             span({ class: 'card-value' }, content.concept)
             span({ class: 'card-value' }, content.concept)
@@ -219,7 +220,7 @@ const renderContentHtml = (content, key) => {
         div({ class: 'card-section styled-text-content' },
         div({ class: 'card-section styled-text-content' },
           div({ class: 'card-field' },
           div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.textContentLabel + ':'),
             span({ class: 'card-label' }, i18n.textContentLabel + ':'),
-            span({ class: 'card-value', innerHTML: content.text || content.description || content.title || '[no content]' })
+            span({ class: 'card-value', innerHTML: sanitizeHtml(content.text || content.description || content.title || '[no content]') })
           )
           )
         )
         )
       );
       );
@@ -233,7 +234,14 @@ exports.opinionsView = (items, filter) => {
       const c = item.value?.content || item.content;
       const c = item.value?.content || item.content;
       return c && typeof c === 'object' && c.type !== 'tombstone';
       return c && typeof c === 'object' && c.type !== 'tombstone';
     })
     })
-    .sort((a, b) => (filter !== 'TOP' ? b.value.timestamp - a.value.timestamp : 0));
+    .sort((a, b) => {
+      if (filter === 'TOP') {
+        const aVotes = (a.value.content.opinions_inhabitants || []).length;
+        const bVotes = (b.value.content.opinions_inhabitants || []).length;
+        return bVotes !== aVotes ? bVotes - aVotes : b.value.timestamp - a.value.timestamp;
+      }
+      return b.value.timestamp - a.value.timestamp;
+    });
 
 
   const title = i18n.opinionsTitle;
   const title = i18n.opinionsTitle;
   const baseFilters = ['RECENT', 'ALL', 'MINE', 'TOP'];
   const baseFilters = ['RECENT', 'ALL', 'MINE', 'TOP'];
@@ -256,7 +264,23 @@ exports.opinionsView = (items, filter) => {
           span({ class: 'date-link' }, `${created} ${i18n.performed} `),
           span({ class: 'date-link' }, `${created} ${i18n.performed} `),
           a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, item.value.author)
           a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, item.value.author)
         ),
         ),
-        h2(`${i18n.totalOpinions || i18n.opinionsTotalCount}: ${total}`),
+        (() => {
+          const entries = voteEntries.filter(([, v]) => v > 0);
+          const dominantPart = (() => {
+            if (!entries.length) return null;
+            const maxVal = Math.max(...entries.map(([, v]) => v));
+            const dominant = entries.filter(([, v]) => v === maxVal).map(([k]) => i18n['vote' + k.charAt(0).toUpperCase() + k.slice(1)] || k);
+            return [
+              span({ style: 'margin:0 8px;opacity:0.5;' }, '|'),
+              span({ style: 'font-weight:700;' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
+            ];
+          })();
+          return h2(
+            `${i18n.totalOpinions || i18n.opinionsTotalCount}: `,
+            span({ style: 'font-weight:700;' }, String(total)),
+            ...(dominantPart || [])
+          );
+        })(),
         div({ class: 'voting-buttons' },
         div({ class: 'voting-buttons' },
           allCats.map(cat => {
           allCats.map(cat => {
             const label = `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${c.opinions?.[cat] || 0}]`;
             const label = `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${c.opinions?.[cat] || 0}]`;

+ 1 - 0
src/views/parliament_view.js

@@ -238,6 +238,7 @@ const pickLeader = (arr) => {
 const CandidatureStats = (cands, govCard, leaderMeta) => {
 const CandidatureStats = (cands, govCard, leaderMeta) => {
   if (!cands || !cands.length) return null;
   if (!cands || !cands.length) return null;
   const leader = pickLeader(cands || []);
   const leader = pickLeader(cands || []);
+  if (!leader) return null;
   const methodKey = String(leader.method || '').toUpperCase();
   const methodKey = String(leader.method || '').toUpperCase();
   const methodLabel = String(i18n[`parliamentMethod${methodKey}`] || methodKey).toUpperCase();
   const methodLabel = String(i18n[`parliamentMethod${methodKey}`] || methodKey).toUpperCase();
   const votes = String(leader.votes || 0);
   const votes = String(leader.votes || 0);

+ 55 - 32
src/views/peers_view.js

@@ -1,5 +1,5 @@
 const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
 const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
-  const { form, button, div, h2, p, section, ul, li, a, hr } = require("../server/node_modules/hyperaxe");
+  const { form, button, div, h2, p, section, a, hr, input, label, br, span, table, tr, td } = require("../server/node_modules/hyperaxe");
   const { template, i18n } = require('./main_views');
   const { template, i18n } = require('./main_views');
 
 
   const startButton = form({ action: "/settings/conn/start", method: "post" }, button({ type: "submit" }, i18n.startNetworking));
   const startButton = form({ action: "/settings/conn/start", method: "post" }, button({ type: "submit" }, i18n.startNetworking));
@@ -8,49 +8,58 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
   const syncButton = form({ action: "/settings/conn/sync", method: "post" }, button({ type: "submit" }, i18n.sync));
   const syncButton = form({ action: "/settings/conn/sync", method: "post" }, button({ type: "submit" }, i18n.sync));
   const connButtons = [startButton, restartButton, stopButton, syncButton];
   const connButtons = [startButton, restartButton, stopButton, syncButton];
 
 
-  const encodePubKey = (pubId) => {
-    let core = pubId.replace(/^@/, '').replace(/\.ed25519$/, '').replace(/_/g, '/');
-    if (!core.endsWith('=')) core += '=';
-    return `/author/${encodeURIComponent('@' + core)}.ed25519`;
-  };
-
-  const renderInhabitants = (users, pubID) => {
-    const filteredUsers = users.filter(user => user.id !== pubID);
-    if (filteredUsers.length === 0) {
-      return li(i18n.noDiscovered);
-    }
-    return filteredUsers.map((user) => {
-      const userUrl = `/author/${encodeURIComponent(user.id)}`;
-      return li(
-        a({ href: userUrl, class:"user-link" }, `${user.id}`)
-      );
+  const deduplicatePeers = (peers) => {
+    const seen = new Set();
+    return peers.filter(p => {
+      const key = p[1]?.key;
+      if (!key || seen.has(key)) return false;
+      seen.add(key);
+      return true;
     });
     });
   };
   };
 
 
-  const renderPeer = (peerData) => {
+  const renderPeerRow = (peerData) => {
     const peer = peerData[1];
     const peer = peerData[1];
     const { name, users, key } = peer;
     const { name, users, key } = peer;
-    const pubUrl = encodePubKey(key);
-    const inhabitants = renderInhabitants(users, peerData[0]);
-    return li(
-      `${i18n.pub}: ${name} `,
-      a({ href: pubUrl, class:"user-link" }, `${key}`),
-      inhabitants.length > 0 ? ul(inhabitants) : p(i18n.noDiscovered)
+    const peerUrl = `/author/${encodeURIComponent(key)}`;
+    const filteredUsers = (users || []).filter(u => u.id !== key);
+    const userCount = filteredUsers.length || peer.announcers || 0;
+    return tr(
+      td(a({ href: peerUrl, class: "user-link" }, name || key.slice(0, 20) + '…')),
+      td(span({ style: 'word-break:break-all;font-size:12px;color:#888;' }, key)),
+      td(String(userCount))
     );
     );
   };
   };
 
 
+  const dedupOnline = deduplicatePeers(onlinePeers);
+  const dedupDiscovered = deduplicatePeers(discoveredPeers);
+  const dedupUnknown = deduplicatePeers(unknownPeers);
+
   const countPeers = (list) => {
   const countPeers = (list) => {
     let usersTotal = 0;
     let usersTotal = 0;
     for (const item of list) {
     for (const item of list) {
-      const users = (item[1].users || []).filter(u => u.id !== item[0]);
-      usersTotal += users.length;
+      const peerKey = item[1].key;
+      const users = (item[1].users || []).filter(u => u.id !== peerKey);
+      usersTotal += users.length || item[1].announcers || 0;
     }
     }
     return list.length + usersTotal;
     return list.length + usersTotal;
   };
   };
 
 
-  const onlineCount = countPeers(onlinePeers);
-  const discoveredCount = countPeers(discoveredPeers);
-  const unknownCount = countPeers(unknownPeers);
+  const onlineCount = countPeers(dedupOnline);
+  const discoveredCount = countPeers(dedupDiscovered);
+  const unknownCount = countPeers(dedupUnknown);
+
+  const renderPeerTable = (peers) => {
+    if (peers.length === 0) return p(i18n.noConnections || i18n.noDiscovered);
+    return table({ class: 'block-info-table' },
+      tr(
+        td({ class: 'card-label' }, i18n.peerHost || 'Pub'),
+        td({ class: 'card-label' }, 'Key'),
+        td({ class: 'card-label' }, i18n.inhabitants || 'Inhabitants')
+      ),
+      ...peers.map(renderPeerRow)
+    );
+  };
 
 
   return template(
   return template(
     i18n.peers,
     i18n.peers,
@@ -60,15 +69,29 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
         p(i18n.peerConnectionsIntro)
         p(i18n.peerConnectionsIntro)
       ),
       ),
       div({ class: "conn-actions" }, ...connButtons),
       div({ class: "conn-actions" }, ...connButtons),
+      div({ class: 'tags-header', style: 'margin-top:16px;' },
+        h2(i18n.directConnect),
+        p(i18n.directConnectDescription),
+        form({ action: "/peers/connect", method: "post" },
+          label({ for: "peer_host" }, i18n.peerHost), br(),
+          input({ type: "text", id: "peer_host", name: "host", required: true, placeholder: "192.168.1.100", pattern: "(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)*)", title: i18n.peerHostValidation || "Valid IPv4 (e.g. 192.168.1.100) or hostname (e.g. pub.example.com)", maxlength: 253 }), br(),
+          label({ for: "peer_port" }, i18n.peerPort), br(),
+          input({ type: "number", id: "peer_port", name: "port", placeholder: "8008", value: "8008", min: 1, max: 65535, required: true, title: i18n.peerPortValidation || "Port 1-65535" }), br(), br(),
+          label({ for: "peer_key" }, i18n.peerPublicKey), br(),
+          input({ type: "text", id: "peer_key", name: "key", required: true, placeholder: "@...=.ed25519", pattern: "@[A-Za-z0-9+/_\\-]{43}=\\.ed25519", title: i18n.peerKeyValidation || "SSB ed25519 public key (@<44 chars base64>=.ed25519)", maxlength: 56 }), br(), br(),
+          button({ type: "submit" }, i18n.connectAndFollow)
+        )
+      ),
+      hr(),
       div({ class: "peers-list" },
       div({ class: "peers-list" },
         h2(`${i18n.online} (${onlineCount})`),
         h2(`${i18n.online} (${onlineCount})`),
-        onlinePeers.length > 0 ? ul(onlinePeers.map(renderPeer)) : p(i18n.noConnections),
+        renderPeerTable(dedupOnline),
         hr(),
         hr(),
         h2(`${i18n.discovered} (${discoveredCount})`),
         h2(`${i18n.discovered} (${discoveredCount})`),
-        discoveredPeers.length > 0 ? ul(discoveredPeers.map(renderPeer)) : p(i18n.noDiscovered),
+        renderPeerTable(dedupDiscovered),
         hr(),
         hr(),
         h2(`${i18n.unknown} (${unknownCount})`),
         h2(`${i18n.unknown} (${unknownCount})`),
-        unknownPeers.length > 0 ? ul(unknownPeers.map(renderPeer)) : p(i18n.noDiscovered),
+        renderPeerTable(dedupUnknown),
         p(i18n.connectionActionIntro)
         p(i18n.connectionActionIntro)
       )
       )
     )
     )

+ 5 - 5
src/views/pixelia_view.js

@@ -40,13 +40,13 @@ exports.pixeliaView = (pixelArt, errorMessage) => {
     ),
     ),
     section(
     section(
       div({ class: "pixelia-form-wrap" },
       div({ class: "pixelia-form-wrap" },
-        form({ method: "POST", action: "/pixelia/paint"}, [
+        form({ method: "POST", action: "/pixelia/paint"},
           label({ for: "x" }, "X (1-50):"),
           label({ for: "x" }, "X (1-50):"),
           input({ type: "number", id: "x", name: "x", min: 1, max: gridWidth, required: true }),
           input({ type: "number", id: "x", name: "x", min: 1, max: gridWidth, required: true }),
-          br,br,
+          br(),br(),
           label({ for: "y" }, "Y (1-200):"),
           label({ for: "y" }, "Y (1-200):"),
           input({ type: "number", id: "y", name: "y", min: 1, max: gridHeight, required: true }),
           input({ type: "number", id: "y", name: "y", min: 1, max: gridHeight, required: true }),
-          br,br,
+          br(),br(),
           label({ for: "color" }, i18n.colorLabel),
           label({ for: "color" }, i18n.colorLabel),
           select({ id: "color", name: "color", required: true },
           select({ id: "color", name: "color", required: true },
             option({ value: "#000000", style: "background-color:#000000;" }, "Black"),
             option({ value: "#000000", style: "background-color:#000000;" }, "Black"),
@@ -66,9 +66,9 @@ exports.pixeliaView = (pixelArt, errorMessage) => {
             option({ value: "#d3d3d3", style: "background-color:#d3d3d3;" }, "Light Grey"),
             option({ value: "#d3d3d3", style: "background-color:#d3d3d3;" }, "Light Grey"),
             option({ value: "#ff6347", style: "background-color:#ff6347;" }, "Tomato")
             option({ value: "#ff6347", style: "background-color:#ff6347;" }, "Tomato")
           ),
           ),
-          br,br,
+          br(),br(),
           button({ type: "submit" }, i18n.paintButton)
           button({ type: "submit" }, i18n.paintButton)
-        ])
+        )
       ),
       ),
       errorMessage ? div({ class: "error-message" }, errorMessage) : null,
       errorMessage ? div({ class: "error-message" }, errorMessage) : null,
       div({ class: "total-pixels" },
       div({ class: "total-pixels" },

+ 48 - 6
src/views/projects_view.js

@@ -1,9 +1,23 @@
-const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, ul, li, table, thead, tbody, tr, th, td, progress } = require("../server/node_modules/hyperaxe")
+const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, ul, li, table, thead, tbody, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe")
 const { template, i18n } = require("./main_views")
 const { template, i18n } = require("./main_views")
 const moment = require("../server/node_modules/moment")
 const moment = require("../server/node_modules/moment")
 const { config } = require("../server/SSB_server.js")
 const { config } = require("../server/SSB_server.js")
 const { renderUrl } = require("../backend/renderUrl")
 const { renderUrl } = require("../backend/renderUrl")
 
 
+const renderMediaBlob = (value) => {
+  if (!value) return null
+  const s = String(value).trim()
+  if (!s) return null
+  if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}` })
+  const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
+  if (mVideo) return video({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` })
+  const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
+  if (mAudio) return audio({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` })
+  const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
+  if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: 'post-image' })
+  return null
+}
+
 const userId = config.keys.id
 const userId = config.keys.id
 
 
 const FILTERS = [
 const FILTERS = [
@@ -480,7 +494,7 @@ const renderProjectList = (projects, filter) => {
           { class: `project-card ${statusClass}` },
           { class: `project-card ${statusClass}` },
           topbar ? topbar : null,
           topbar ? topbar : null,
           h2(pr.title),
           h2(pr.title),
-          pr.image ? div({ class: "activity-image-preview" }, img({ src: `/blob/${encodeURIComponent(pr.image)}` })) : null,
+          pr.image ? div({ class: "activity-image-preview" }, renderMediaBlob(pr.image)) : null,
           safeText(pr.description) ? renderCardFieldRich(i18n.projectDescription + ":", renderUrl(pr.description)) : null,
           safeText(pr.description) ? renderCardFieldRich(i18n.projectDescription + ":", renderUrl(pr.description)) : null,
           renderCardField(i18n.projectStatus + ":", i18n["projectStatus" + statusUpper] || statusUpper),
           renderCardField(i18n.projectStatus + ":", i18n["projectStatus" + statusUpper] || statusUpper),
           renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100),
           renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100),
@@ -612,9 +626,9 @@ const renderProjectForm = (project, mode) => {
       br(),
       br(),
       label(i18n.projectImage),
       label(i18n.projectImage),
       br(),
       br(),
-      input({ type: "file", name: "image", accept: "image/*" }),
+      input({ type: "file", name: "image" }),
       br(),
       br(),
-      pr.image ? img({ src: `/blob/${encodeURIComponent(pr.image)}`, class: "existing-image" }) : null,
+      pr.image ? renderMediaBlob(pr.image) : null,
       br(),
       br(),
       label(i18n.projectGoal),
       label(i18n.projectGoal),
       br(),
       br(),
@@ -716,7 +730,7 @@ exports.singleProjectView = async (project, filter, comments) => {
         topbar ? topbar : null,
         topbar ? topbar : null,
         !isAuthor && safeArr(pr.followers).includes(userId) ? p({ class: "hint" }, i18n.projectYouFollowHint) : null,
         !isAuthor && safeArr(pr.followers).includes(userId) ? p({ class: "hint" }, i18n.projectYouFollowHint) : null,
         h2(pr.title),
         h2(pr.title),
-        pr.image ? div({ class: "activity-image-preview" }, img({ src: `/blob/${encodeURIComponent(pr.image)}` })) : null,
+        pr.image ? div({ class: "activity-image-preview" }, renderMediaBlob(pr.image)) : null,
         safeText(pr.description) ? renderCardFieldRich(i18n.projectDescription + ":", renderUrl(pr.description)) : null,
         safeText(pr.description) ? renderCardFieldRich(i18n.projectDescription + ":", renderUrl(pr.description)) : null,
         renderCardField(i18n.projectStatus + ":", i18n["projectStatus" + statusUpper] || statusUpper),
         renderCardField(i18n.projectStatus + ":", i18n["projectStatus" + statusUpper] || statusUpper),
         renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100),
         renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100),
@@ -739,7 +753,35 @@ exports.singleProjectView = async (project, filter, comments) => {
           span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
           span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
           a({ href: `/author/${encodeURIComponent(pr.author)}`, class: "user-link" }, pr.author)
           a({ href: `/author/${encodeURIComponent(pr.author)}`, class: "user-link" }, pr.author)
         )
         )
-      )
+      ),
+      div(
+        { class: "comment-form-wrapper" },
+        h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
+        form(
+          { method: "POST", action: `/projects/${encodeURIComponent(pr.id || pr.key)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
+          textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
+          div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
+          br(),
+          button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
+        )
+      ),
+      comments && comments.length
+        ? div(
+            { class: "comments-list" },
+            comments.map((c) => {
+              const author = c?.value?.author || ""
+              const ts = c?.value?.timestamp || c?.timestamp
+              const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
+              const relDate = ts ? moment(ts).fromNow() : ""
+              return div(
+                { class: "comment-card" },
+                div({ class: "comment-header" }, a({ href: `/author/${encodeURIComponent(author)}`, class: "user-link" }, author)),
+                div({ class: "comment-date" }, span({ title: absDate }, relDate)),
+                div({ class: "comment-body" }, ...renderUrl(c?.value?.content?.text || ""))
+              )
+            })
+          )
+        : null
     )
     )
   )
   )
 }
 }

+ 20 - 6
src/views/report_view.js

@@ -1,9 +1,23 @@
-const { div, h2, p, section, button, form, a, textarea, br, input, img, span, label, select, option } = require("../server/node_modules/hyperaxe");
+const { div, h2, p, section, button, form, a, textarea, br, input, img, span, label, select, option, video, audio } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require("./main_views");
 const { template, i18n } = require("./main_views");
 const { config } = require("../server/SSB_server.js");
 const { config } = require("../server/SSB_server.js");
 const moment = require("../server/node_modules/moment");
 const moment = require("../server/node_modules/moment");
 const { renderUrl } = require("../backend/renderUrl");
 const { renderUrl } = require("../backend/renderUrl");
 
 
+const renderMediaBlob = (value) => {
+  if (!value) return null;
+  const s = String(value).trim();
+  if (!s) return null;
+  if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}` });
+  const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/);
+  if (mVideo) return video({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` });
+  const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/);
+  if (mAudio) return audio({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` });
+  const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/);
+  if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: 'post-image' });
+  return null;
+};
+
 const userId = config.keys.id;
 const userId = config.keys.id;
 
 
 const normU = (v) => String(v || "").trim().toUpperCase();
 const normU = (v) => String(v || "").trim().toUpperCase();
@@ -44,7 +58,6 @@ const renderStackedTextField = (lbl, val) =>
     ? div(
     ? div(
         { class: "card-field card-field-stacked" },
         { class: "card-field card-field-stacked" },
         span({ class: "card-label" }, lbl),
         span({ class: "card-label" }, lbl),
-        br(),
         span({ class: "card-value" }, ...renderUrl(String(val)))
         span({ class: "card-value" }, ...renderUrl(String(val)))
       )
       )
     : null;
     : null;
@@ -187,16 +200,17 @@ const renderReportCommentsSection = (reportId, comments = []) => {
         {
         {
           method: "POST",
           method: "POST",
           action: `/reports/${encodeURIComponent(reportId)}/comments`,
           action: `/reports/${encodeURIComponent(reportId)}/comments`,
-          class: "comment-form"
+          class: "comment-form",
+          enctype: "multipart/form-data"
         },
         },
         textarea({
         textarea({
           id: "comment-text",
           id: "comment-text",
           name: "text",
           name: "text",
-          required: true,
           rows: 4,
           rows: 4,
           class: "comment-textarea",
           class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         }),
+        div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
         br(),
         br(),
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
       )
@@ -358,7 +372,7 @@ const renderReportCard = (report, userId, currentFilter = "all") => {
     renderCardField(i18n.reportsSeverity + ":", severity),
     renderCardField(i18n.reportsSeverity + ":", severity),
     renderCardField(i18n.reportsCategory + ":", report.category),
     renderCardField(i18n.reportsCategory + ":", report.category),
     report.image ? br() : null,
     report.image ? br() : null,
-    report.image ? div({ class: "card-field" }, img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" })) : null,
+    report.image ? div({ class: "card-field" }, renderMediaBlob(report.image)) : null,
     report.image && details ? br() : null,
     report.image && details ? br() : null,
     details ? details : null,
     details ? details : null,
     br(),
     br(),
@@ -624,7 +638,7 @@ exports.singleReportView = async (report, filter, comments = []) => {
         renderCardField(i18n.reportsSeverity + ":", severity),
         renderCardField(i18n.reportsSeverity + ":", severity),
         renderCardField(i18n.reportsCategory + ":", report.category),
         renderCardField(i18n.reportsCategory + ":", report.category),
         report.image ? br() : null,
         report.image ? br() : null,
-        report.image ? div({ class: "card-field" }, img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" })) : null,
+        report.image ? div({ class: "card-field" }, renderMediaBlob(report.image)) : null,
         report.image && details ? br() : null,
         report.image && details ? br() : null,
         details ? details : null,
         details ? details : null,
         br(),
         br(),

+ 25 - 24
src/views/search_view.js

@@ -3,6 +3,7 @@ const { template, i18n } = require('./main_views');
 const moment = require("../server/node_modules/moment");
 const moment = require("../server/node_modules/moment");
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { renderUrl } = require('../backend/renderUrl');
 const { renderUrl } = require('../backend/renderUrl');
+const { sanitizeHtml } = require('../backend/sanitizeHtml');
 
 
 const decodeMaybe = (s) => {
 const decodeMaybe = (s) => {
   try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); }
   try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); }
@@ -96,7 +97,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
       case 'post':
       case 'post':
         return div({ class: 'search-post' },
         return div({ class: 'search-post' },
           content.contentWarning ? h2({ class: 'card-field' }, span({ class: 'card-value' }, content.contentWarning)) : null,
           content.contentWarning ? h2({ class: 'card-field' }, span({ class: 'card-value' }, content.contentWarning)) : null,
-          content.text ? div({ class: 'card-field' }, span({ class: 'card-value', innerHTML: content.text })) : null
+          content.text ? div({ class: 'card-field' }, span({ class: 'card-value', innerHTML: sanitizeHtml(content.text) })) : null
         );
         );
       case 'about':
       case 'about':
         return div({ class: 'search-about' },
         return div({ class: 'search-about' },
@@ -109,7 +110,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
         const htmlText = rawText ? rewriteHashtagLinks(renderTextWithStyles(rawText)) : '';
         const htmlText = rawText ? rewriteHashtagLinks(renderTextWithStyles(rawText)) : '';
         const refeedsNum = Number(content.refeeds || 0) || 0;
         const refeedsNum = Number(content.refeeds || 0) || 0;
         return div({ class: 'search-feed' },
         return div({ class: 'search-feed' },
-          rawText ? div({ class: 'card-field' }, span({ class: 'card-value', innerHTML: htmlText })) : null,
+          rawText ? div({ class: 'card-field' }, span({ class: 'card-value', innerHTML: sanitizeHtml(htmlText) })) : null,
           refeedsNum > 0
           refeedsNum > 0
             ? h2({ class: 'card-field' },
             ? h2({ class: 'card-field' },
                 span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ':'),
                 span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ':'),
@@ -139,7 +140,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
           ? Object.entries(content.votes).map(([option, count]) => ({ option, count }))
           ? Object.entries(content.votes).map(([option, count]) => ({ option, count }))
           : [];
           : [];
         return div({ class: 'search-vote' },
         return div({ class: 'search-vote' },
-          br,
+          br(),
           content.question ? div({ class: 'card-field' },
           content.question ? div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.voteQuestionLabel + ':' ),
             span({ class: 'card-label' }, i18n.voteQuestionLabel + ':' ),
             span({ class: 'card-value' }, content.question)
             span({ class: 'card-value' }, content.question)
@@ -156,7 +157,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
             span({ class: 'card-label' }, i18n.voteTotalVotes + ':' ),
             span({ class: 'card-label' }, i18n.voteTotalVotes + ':' ),
             span({ class: 'card-value' }, content.totalVotes !== undefined ? content.totalVotes : '0')
             span({ class: 'card-value' }, content.totalVotes !== undefined ? content.totalVotes : '0')
           ),
           ),
-          br,
+          br(),
           votesList.length > 0 ? div({ class: 'card-votes' },
           votesList.length > 0 ? div({ class: 'card-votes' },
             table(
             table(
               tr(...votesList.map(({ option }) => th(i18n[option] || option))),
               tr(...votesList.map(({ option }) => th(i18n[option] || option))),
@@ -168,9 +169,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
       return div({ class: 'search-tribe' },
       return div({ class: 'search-tribe' },
         content.title ? h2(content.title) : null,
         content.title ? h2(content.title) : null,
         content.image ? img({ src: `/blob/${encodeURIComponent(content.image)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }),
         content.image ? img({ src: `/blob/${encodeURIComponent(content.image)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }),
-        br,
+        br(),
         content.description ? content.description : null,
         content.description ? content.description : null,
-        br,br,
+        br(),br(),
         div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
         div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
           content.location ? p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLocationLabel.toUpperCase()}: `, ...renderUrl(content.location)) : null,
           content.location ? p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLocationLabel.toUpperCase()}: `, ...renderUrl(content.location)) : null,
           p({ style: 'color:#9aa3b2;' }, `${i18n.tribeIsAnonymousLabel}: ${content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
           p({ style: 'color:#9aa3b2;' }, `${i18n.tribeIsAnonymousLabel}: ${content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
@@ -194,9 +195,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
         return content.url ? div({ class: 'search-audio' },
         return content.url ? div({ class: 'search-audio' },
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
           content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
-          br,
+          br(),
           audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType, preload: 'metadata' }),
           audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType, preload: 'metadata' }),
-          br,
+          br(),
           content.tags && content.tags.length
           content.tags && content.tags.length
             ? div({ class: 'card-tags' }, content.tags.map(tag =>
             ? div({ class: 'card-tags' }, content.tags.map(tag =>
               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
@@ -208,9 +209,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
           content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
           content.meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : null,
           content.meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : null,
-          br,
+          br(),
           img({ src: `/blob/${encodeURIComponent(content.url)}` }),
           img({ src: `/blob/${encodeURIComponent(content.url)}` }),
-          br,
+          br(),
           content.tags && content.tags.length
           content.tags && content.tags.length
             ? div({ class: 'card-tags' }, content.tags.map(tag =>
             ? div({ class: 'card-tags' }, content.tags.map(tag =>
               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
@@ -221,9 +222,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
         return content.url ? div({ class: 'search-video' },
         return content.url ? div({ class: 'search-video' },
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
           content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
-          br,
+          br(),
           videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType || 'video/mp4', width: '640', height: '360' }),
           videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType || 'video/mp4', width: '640', height: '360' }),
-          br,
+          br(),
           content.tags && content.tags.length
           content.tags && content.tags.length
             ? div({ class: 'card-tags' }, content.tags.map(tag =>
             ? div({ class: 'card-tags' }, content.tags.map(tag =>
               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
@@ -233,15 +234,15 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
       case 'document':
       case 'document':
         return div({ class: 'search-document' },
         return div({ class: 'search-document' },
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
-          br,
+          br(),
           content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
           content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
-          br,
+          br(),
           div({
           div({
             id: `pdf-container-${content.key || content.url}`,
             id: `pdf-container-${content.key || content.url}`,
             class: 'pdf-viewer-container',
             class: 'pdf-viewer-container',
             'data-pdf-url': `/blob/${encodeURIComponent(content.url)}`
             'data-pdf-url': `/blob/${encodeURIComponent(content.url)}`
           }),
           }),
-          br,
+          br(),
           content.tags && content.tags.length
           content.tags && content.tags.length
             ? div({ class: 'card-tags' }, content.tags.map(tag =>
             ? div({ class: 'card-tags' }, content.tags.map(tag =>
               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
@@ -255,9 +256,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
           content.item_type ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemType + ':'), span({ class: 'card-value' }, content.item_type.toUpperCase())) : null,
           content.item_type ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemType + ':'), span({ class: 'card-value' }, content.item_type.toUpperCase())) : null,
           content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemCondition + ':'), span({ class: 'card-value' }, content.status)) : null,
           content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemCondition + ':'), span({ class: 'card-value' }, content.status)) : null,
           content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemDeadline + ':'), span({ class: 'card-value' }, new Date(content.deadline).toLocaleString())) : null,
           content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemDeadline + ':'), span({ class: 'card-value' }, new Date(content.deadline).toLocaleString())) : null,
-          br,
+          br(),
           content.image ? img({ src: `/blob/${encodeURIComponent(content.image)}`, class: 'market-image' }) : null,
           content.image ? img({ src: `/blob/${encodeURIComponent(content.image)}`, class: 'market-image' }) : null,
-          br,
+          br(),
           content.seller ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemSeller + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.seller)}` }, content.seller))) : null,
           content.seller ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemSeller + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.seller)}` }, content.seller))) : null,
           content.stock ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock || 'N/A')) : null,
           content.stock ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock || 'N/A')) : null,
           content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriceLabel + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null,
           content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriceLabel + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null,
@@ -304,7 +305,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
       case 'task':
       case 'task':
         return div({ class: 'search-task' },
         return div({ class: 'search-task' },
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.taskTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.taskTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
-          content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null,
+          content.description ? div({ class: 'card-field', style: 'display:flex;flex-direction:column;' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), p({ class: 'card-value', style: 'white-space:pre-wrap;margin-top:4px;' }, content.description)) : null,
           content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchLocationLabel + ':'), span({ class: 'card-value' }, content.location)) : null,
           content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchLocationLabel + ':'), span({ class: 'card-value' }, content.location)) : null,
           content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null,
           content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null,
           content.priority ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriorityLabel + ':'), span({ class: 'card-value' }, content.priority)) : null,
           content.priority ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriorityLabel + ':'), span({ class: 'card-value' }, content.priority)) : null,
@@ -325,9 +326,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
           content.severity ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.reportsSeverity + ':'), span({ class: 'card-value' }, content.severity)) : null,
           content.severity ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.reportsSeverity + ':'), span({ class: 'card-value' }, content.severity)) : null,
           content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchCategoryLabel + ':'), span({ class: 'card-value' }, content.category)) : null,
           content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchCategoryLabel + ':'), span({ class: 'card-value' }, content.category)) : null,
           content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null,
           content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null,
-          br,
+          br(),
           content.image ? img({ src: `/blob/${encodeURIComponent(content.image)}` }) : null,
           content.image ? img({ src: `/blob/${encodeURIComponent(content.image)}` }) : null,
-          br,
+          br(),
           typeof content.confirmations === 'number' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.reportsConfirmations + ':'), span({ class: 'card-value' }, content.confirmations)) : null,
           typeof content.confirmations === 'number' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.reportsConfirmations + ':'), span({ class: 'card-value' }, content.confirmations)) : null,
           content.tags && content.tags.length
           content.tags && content.tags.length
             ? div({ class: 'card-tags' }, content.tags.map(tag =>
             ? div({ class: 'card-tags' }, content.tags.map(tag =>
@@ -341,10 +342,10 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
           content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersDeadline + ':'), span({ class: 'card-value' }, content.deadline)) : null,
           content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersDeadline + ':'), span({ class: 'card-value' }, content.deadline)) : null,
           content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersStatus + ':'), span({ class: 'card-value' }, content.status)) : null,
           content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersStatus + ':'), span({ class: 'card-value' }, content.status)) : null,
           content.amount ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersAmount + ':'), span({ class: 'card-value' }, content.amount)) : null,
           content.amount ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersAmount + ':'), span({ class: 'card-value' }, content.amount)) : null,
-          br,
+          br(),
           content.from ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersFrom + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.from)}` }, content.from))) : null,
           content.from ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersFrom + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.from)}` }, content.from))) : null,
           content.to ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersTo + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.to)}` }, content.to))) : null,
           content.to ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersTo + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.to)}` }, content.to))) : null,
-          br,
+          br(),
           content.confirmedBy && content.confirmedBy.length
           content.confirmedBy && content.confirmedBy.length
             ? h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ':'), span({ class: 'card-value' }, content.confirmedBy.length))
             ? h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ':'), span({ class: 'card-value' }, content.confirmedBy.length))
             : null,
             : null,
@@ -439,7 +440,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
           content.address && content.address.key ? p(a({ href: `/author/${encodeURIComponent(content.address.key)}`, class: 'activitySpreadInhabitant2' }, content.address.key)) : null
           content.address && content.address.key ? p(a({ href: `/author/${encodeURIComponent(content.address.key)}`, class: 'activitySpreadInhabitant2' }, content.address.key)) : null
         );
         );
       default:
       default:
-        return div({ class: 'styled-text', innerHTML: renderTextWithStyles(content.text || content.description || content.title || '[no content]') });
+        return div({ class: 'styled-text', innerHTML: sanitizeHtml(renderTextWithStyles(content.text || content.description || content.title || '[no content]')) });
     }
     }
   };
   };
 
 
@@ -485,7 +486,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
 
 
           return div({ class: 'result-item' }, [
           return div({ class: 'result-item' }, [
             detailsButton,
             detailsButton,
-            br,
+            br(),
             contentHtml,
             contentHtml,
             author
             author
               ? p({ class: 'card-footer' },
               ? p({ class: 'card-footer' },

+ 6 - 2
src/views/settings_view.js

@@ -33,7 +33,8 @@ const settingsView = ({ version, aiPrompt }) => {
     option({ value: "Dark-SNH", selected: theme === "Dark-SNH" ? true : undefined }, "Dark-SNH"),
     option({ value: "Dark-SNH", selected: theme === "Dark-SNH" ? true : undefined }, "Dark-SNH"),
     option({ value: "Clear-SNH", selected: theme === "Clear-SNH" ? true : undefined }, "Clear-SNH"),
     option({ value: "Clear-SNH", selected: theme === "Clear-SNH" ? true : undefined }, "Clear-SNH"),
     option({ value: "Purple-SNH", selected: theme === "Purple-SNH" ? true : undefined }, "Purple-SNH"),
     option({ value: "Purple-SNH", selected: theme === "Purple-SNH" ? true : undefined }, "Purple-SNH"),
-    option({ value: "Matrix-SNH", selected: theme === "Matrix-SNH" ? true : undefined }, "Matrix-SNH")
+    option({ value: "Matrix-SNH", selected: theme === "Matrix-SNH" ? true : undefined }, "Matrix-SNH"),
+    option({ value: "OasisMobile", selected: theme === "OasisMobile" ? true : undefined }, "Oasis-Mobile")
   ];
   ];
 
 
   const languageOption = (longName, shortName) => {
   const languageOption = (longName, shortName) => {
@@ -88,7 +89,10 @@ const settingsView = ({ version, aiPrompt }) => {
             languageOption("English", "en"),
             languageOption("English", "en"),
             languageOption("Español", "es"),
             languageOption("Español", "es"),
             languageOption("Français", "fr"),
             languageOption("Français", "fr"),
-            languageOption("Euskara", "eu")
+            languageOption("Euskara", "eu"),
+            languageOption("Deutsch", "de"),
+            languageOption("Italiano", "it"),
+            languageOption("Português", "pt")
           ]),
           ]),
           br(),
           br(),
           br(),
           br(),

+ 91 - 0
src/views/stats_view.js

@@ -100,6 +100,97 @@ exports.statsView = (stats, filter) => {
           )
           )
         ),
         ),
         div({ style: headerStyle }, h3(`${i18n.bankingUserEngagementScore}: ${C(stats, 'karmaScore')}`)),
         div({ style: headerStyle }, h3(`${i18n.bankingUserEngagementScore}: ${C(stats, 'karmaScore')}`)),
+        div({ style: headerStyle },
+          h3(i18n.statsCarbonFootprintTitle || 'Carbon Footprint'),
+          (() => {
+            const parseSize = (s) => {
+              if (!s) return 0;
+              const m = String(s).match(/([\d.]+)\s*(GB|MB|KB|B)/i);
+              if (!m) return 0;
+              const v = parseFloat(m[1]);
+              const u = m[2].toUpperCase();
+              if (u === 'GB') return v * 1024;
+              if (u === 'MB') return v;
+              if (u === 'KB') return v / 1024;
+              return v / (1024 * 1024);
+            };
+            const blobsMB = parseSize(stats.statsBlobsSize);
+            const chainMB = parseSize(stats.statsBlockchainSize);
+            const totalMB = blobsMB + chainMB;
+            const kWhPerMB = 0.0002;
+            const gCO2PerKWh = 475;
+            const networkCO2 = parseFloat((totalMB * kWhPerMB * gCO2PerKWh).toFixed(2));
+            const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1;
+            const userCO2 = parseFloat((networkCO2 / Math.max(1, inhabitants)).toFixed(2));
+            const maxAnnualCO2 = 500;
+
+            if (filter === 'MINE') {
+              const pct = networkCO2 > 0 ? Math.min(100, (userCO2 / networkCO2) * 100).toFixed(1) : '0.0';
+              return div({ class: 'carbon-chart' },
+                div({ class: 'carbon-bar-label' },
+                  span(i18n.statsCarbonUser || 'Your footprint'),
+                  span(`${userCO2} g CO₂`)
+                ),
+                div({ class: 'carbon-bar-track' },
+                  div({ class: 'carbon-bar-fill carbon-bar-mine', style: `width:${pct}%;` })
+                ),
+                div({ class: 'carbon-bar-label' },
+                  span(i18n.statsCarbonNetwork || 'Network total'),
+                  span(`${networkCO2} g CO₂`)
+                ),
+                div({ class: 'carbon-bar-track' },
+                  div({ class: 'carbon-bar-fill carbon-bar-network', style: 'width:100%;' })
+                ),
+                p({ class: 'carbon-bar-note' }, strong(`${pct}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'}`),
+                p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
+              );
+            }
+            if (filter === 'TOMBSTONE') {
+              const tombCount = stats.tombstoneKPIs?.networkTombstoneCount || 0;
+              const avgTombBytes = 500;
+              const tombMB = (tombCount * avgTombBytes) / (1024 * 1024);
+              const tombCO2 = parseFloat((tombMB * kWhPerMB * gCO2PerKWh).toFixed(4));
+              const tombPct = networkCO2 > 0 ? Math.min(100, (tombCO2 / networkCO2) * 100).toFixed(1) : '0.0';
+              return div({ class: 'carbon-chart' },
+                div({ class: 'carbon-bar-label' },
+                  span(i18n.statsCarbonTombstone || 'Tombstoning footprint'),
+                  span(`${tombCO2} g CO₂`)
+                ),
+                div({ class: 'carbon-bar-track' },
+                  div({ class: 'carbon-bar-fill carbon-bar-mine', style: `width:${tombPct}%;` })
+                ),
+                div({ class: 'carbon-bar-label' },
+                  span(i18n.statsCarbonNetwork || 'Network total'),
+                  span(`${networkCO2} g CO₂`)
+                ),
+                div({ class: 'carbon-bar-track' },
+                  div({ class: 'carbon-bar-fill carbon-bar-network', style: 'width:100%;' })
+                ),
+                p({ class: 'carbon-bar-note' }, strong(`${tombPct}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'} (${tombCount} tombstones × ~${avgTombBytes} bytes)`),
+                p({ class: 'carbon-bar-formula' }, 'Based on estimated tombstone message size ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
+              );
+            }
+            const pct = Math.min(100, (networkCO2 / maxAnnualCO2) * 100).toFixed(1);
+            return div({ class: 'carbon-chart' },
+              div({ class: 'carbon-bar-label' },
+                span(i18n.statsCarbonNetwork || 'Network footprint'),
+                span(`${networkCO2} g CO₂`)
+              ),
+              div({ class: 'carbon-bar-track' },
+                div({ class: 'carbon-bar-fill carbon-bar-network', style: `width:${pct}%;` })
+              ),
+              div({ class: 'carbon-bar-label' },
+                span(i18n.statsCarbonMaxAnnual || 'Annual max estimate'),
+                span(`${maxAnnualCO2} g CO₂`)
+              ),
+              div({ class: 'carbon-bar-track' },
+                div({ class: 'carbon-bar-fill carbon-bar-max', style: 'width:100%;' })
+              ),
+              p({ class: 'carbon-bar-note' }, strong(`${pct}%`), ` ${i18n.statsCarbonOfEstMax || 'of estimated max capacity'}`),
+              p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
+            );
+          })()
+        ),
         div({ style: headerStyle },
         div({ style: headerStyle },
           h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsBankingTitle),
           h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsBankingTitle),
           ul({ style: 'list-style-type:none; padding:0; margin:0;' },
           ul({ style: 'list-style-type:none; padding:0; margin:0;' },

+ 2 - 2
src/views/task_view.js

@@ -148,16 +148,16 @@ const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all")
       { class: "comment-form-wrapper" },
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       form(
       form(
-        { method: "POST", action: `/tasks/${encodeURIComponent(taskId)}/comments`, class: "comment-form" },
+        { method: "POST", action: `/tasks/${encodeURIComponent(taskId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
         input({ type: "hidden", name: "returnTo", value: returnTo }),
         input({ type: "hidden", name: "returnTo", value: returnTo }),
         textarea({
         textarea({
           id: "comment-text",
           id: "comment-text",
           name: "text",
           name: "text",
-          required: true,
           rows: 4,
           rows: 4,
           class: "comment-textarea",
           class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         }),
+        div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
         br(),
         br(),
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
       )

+ 3 - 2
src/views/transfer_view.js

@@ -61,9 +61,10 @@ const renderConfirmationsBar = (confirmedCount, required) => {
   const cc = Math.max(0, Number(confirmedCount || 0))
   const cc = Math.max(0, Number(confirmedCount || 0))
   return div(
   return div(
     { class: "confirmations-block" },
     { class: "confirmations-block" },
-      { class: "card-field" },
+    div({ class: "card-field" },
       span({ class: "card-label" }, `${i18n.transfersConfirmations}: `),
       span({ class: "card-label" }, `${i18n.transfersConfirmations}: `),
-      span({ class: "card-value" }, `${cc}/${req}`),
+      span({ class: "card-value" }, `${cc}/${req}`)
+    ),
     progress({ class: "confirmations-progress", value: cc, max: req })
     progress({ class: "confirmations-progress", value: cc, max: req })
   )
   )
 }
 }

+ 31 - 11
src/views/trending_view.js

@@ -4,6 +4,7 @@ const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { config } = require('../server/SSB_server.js');
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
 const { renderUrl } = require('../backend/renderUrl');
 const opinionCategories = require('../backend/opinion_categories');
 const opinionCategories = require('../backend/opinion_categories');
+const { sanitizeHtml } = require('../backend/sanitizeHtml');
 
 
 const userId = config.keys.id;
 const userId = config.keys.id;
 
 
@@ -36,7 +37,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         form({ method: "GET", action: `/bookmarks/${encodeURIComponent(item.key)}` },
         form({ method: "GET", action: `/bookmarks/${encodeURIComponent(item.key)}` },
           button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
         ),
         ),
-        br,
+        br(),
         url ? h2(p(a({ href: url, target: '_blank', class: "bookmark-url" }, url))) : "",
         url ? h2(p(a({ href: url, target: '_blank', class: "bookmark-url" }, url))) : "",
         lastVisit
         lastVisit
           ? div(
           ? div(
@@ -55,7 +56,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         form({ method: "GET", action: `/images/${encodeURIComponent(item.key)}` },
         form({ method: "GET", action: `/images/${encodeURIComponent(item.key)}` },
           button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
         ),
         ),
-        br,
+        br(),
         title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
         title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
         description ? [span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"), p(...renderUrl(description))] : null,
         description ? [span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"), p(...renderUrl(description))] : null,
         meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : "",
         meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : "",
@@ -69,7 +70,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         form({ method: "GET", action: `/audios/${encodeURIComponent(item.key)}` },
         form({ method: "GET", action: `/audios/${encodeURIComponent(item.key)}` },
           button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
         ),
         ),
-        br,
+        br(),
         title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
         title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
         description ? [span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"), p(...renderUrl(description))] : null,
         description ? [span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"), p(...renderUrl(description))] : null,
         url
         url
@@ -84,10 +85,10 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         form({ method: "GET", action: `/videos/${encodeURIComponent(item.key)}` },
         form({ method: "GET", action: `/videos/${encodeURIComponent(item.key)}` },
           button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
         ),
         ),
-        br,
+        br(),
         title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
         title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
         description ? [span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"), p(...renderUrl(description))] : null,
         description ? [span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"), p(...renderUrl(description))] : null,
-        br,
+        br(),
         url
         url
           ? div({ class: 'card-field video-container' }, videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(url)}`, type: mimeType, preload: 'metadata', width: '640', height: '360' }))
           ? div({ class: 'card-field video-container' }, videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(url)}`, type: mimeType, preload: 'metadata', width: '640', height: '360' }))
           : div({ class: 'card-field' }, p(i18n.videoNoFile))
           : div({ class: 'card-field' }, p(i18n.videoNoFile))
@@ -104,7 +105,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         form({ method: "GET", action: `/documents/${encodeURIComponent(item.key)}` },
         form({ method: "GET", action: `/documents/${encodeURIComponent(item.key)}` },
           button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
         ),
         ),
-        br,
+        br(),
         t ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), span({ class: 'card-value' }, t)) : "",
         t ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), span({ class: 'card-value' }, t)) : "",
         description ? [span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"), p(...renderUrl(description))] : null,
         description ? [span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"), p(...renderUrl(description))] : null,
         div({ id: `pdf-container-${item.key}`, class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(url)}` })
         div({ id: `pdf-container-${item.key}`, class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(url)}` })
@@ -114,7 +115,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
     const { text, refeeds } = c;
     const { text, refeeds } = c;
     contentHtml = div({ class: 'trending-feed' },
     contentHtml = div({ class: 'trending-feed' },
       div({ class: 'card-section feed' },
       div({ class: 'card-section feed' },
-        div({ class: 'feed-text', innerHTML: renderTextWithStyles(text) }),
+        div({ class: 'feed-text', innerHTML: sanitizeHtml(renderTextWithStyles(text)) }),
         h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), span({ class: 'card-value' }, refeeds))
         h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), span({ class: 'card-value' }, refeeds))
       )
       )
     );
     );
@@ -144,7 +145,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         form({ method: "GET", action: `/transfers/${encodeURIComponent(item.key)}` },
         form({ method: "GET", action: `/transfers/${encodeURIComponent(item.key)}` },
           button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
         ),
         ),
-        br,
+        br(),
         div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.concept + ':'), span({ class: 'card-value' }, concept)),
         div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.concept + ':'), span({ class: 'card-value' }, concept)),
         div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
         div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
         div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)),
         div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)),
@@ -160,7 +161,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         div(
         div(
           { class: 'card-field' },
           { class: 'card-field' },
           span({ class: 'card-label' }, i18n.textContentLabel + ':'),
           span({ class: 'card-label' }, i18n.textContentLabel + ':'),
-          span({ class: 'card-value', innerHTML: renderTextWithStyles(c.text || c.description || c.title || '[no content]') })
+          span({ class: 'card-value', innerHTML: sanitizeHtml(renderTextWithStyles(c.text || c.description || c.title || '[no content]')) })
         )
         )
       )
       )
     );
     );
@@ -174,7 +175,24 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
       span({ class: 'date-link' }, `${created} ${i18n.performed} `),
       span({ class: 'date-link' }, `${created} ${i18n.performed} `),
       a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, item.value.author)
       a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, item.value.author)
     ),
     ),
-    h2(`${i18n.trendingTotalOpinions || i18n.trendingTotalCount}: ${votes}`),
+    (() => {
+      const ops = c.opinions || {};
+      const entries = Object.entries(ops).filter(([, v]) => v > 0);
+      const dominantPart = (() => {
+        if (!entries.length) return null;
+        const maxVal = Math.max(...entries.map(([, v]) => v));
+        const dominant = entries.filter(([, v]) => v === maxVal).map(([k]) => voteLabelFor(k));
+        return [
+          span({ style: 'margin:0 8px;opacity:0.5;' }, '|'),
+          span({ style: 'font-weight:700;' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
+        ];
+      })();
+      return h2(
+        `${i18n.trendingTotalOpinions || i18n.trendingTotalCount}: `,
+        span({ style: 'font-weight:700;' }, String(votes)),
+        ...(dominantPart || [])
+      );
+    })(),
     div(
     div(
       { class: 'voting-buttons' },
       { class: 'voting-buttons' },
       categories.map(cat =>
       categories.map(cat =>
@@ -221,7 +239,9 @@ exports.trendingView = (items, filter, categories = opinionCategories) => {
     filteredItems = filteredItems.filter(item => (item.value.content.opinions_inhabitants || []).length > 0);
     filteredItems = filteredItems.filter(item => (item.value.content.opinions_inhabitants || []).length > 0);
   }
   }
 
 
-  filteredItems.sort((a, b) => b.value.timestamp - a.value.timestamp);
+  if (filter !== 'TOP') {
+    filteredItems.sort((a, b) => b.value.timestamp - a.value.timestamp);
+  }
 
 
   const header = div({ class: 'tags-header' }, h2(title), p(i18n.exploreTrending));
   const header = div({ class: 'tags-header' }, h2(title), p(i18n.exploreTrending));
   const cards = filteredItems
   const cards = filteredItems

Разница между файлами не показана из-за своего большого размера
+ 1144 - 216
src/views/tribes_view.js


+ 3 - 2
src/views/video_view.js

@@ -11,6 +11,7 @@ const {
   video: videoHyperaxe,
   video: videoHyperaxe,
   span,
   span,
   textarea,
   textarea,
+  label,
   select,
   select,
   option
   option
 } = require("../server/node_modules/hyperaxe");
 } = require("../server/node_modules/hyperaxe");
@@ -128,16 +129,16 @@ const renderVideoCommentsSection = (videoId, comments = [], returnTo = null) =>
       { class: "comment-form-wrapper" },
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       form(
       form(
-        { method: "POST", action: `/videos/${encodeURIComponent(videoId)}/comments`, class: "comment-form" },
+        { method: "POST", action: `/videos/${encodeURIComponent(videoId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
         returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
         returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
         textarea({
         textarea({
           id: "comment-text",
           id: "comment-text",
           name: "text",
           name: "text",
-          required: true,
           rows: 4,
           rows: 4,
           class: "comment-textarea",
           class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         }),
+        div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
         br(),
         br(),
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
       )

+ 2 - 2
src/views/vote_view.js

@@ -251,16 +251,16 @@ const renderCommentsSection = (voteId, comments, activeFilter) => {
       { class: "comment-form-wrapper" },
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       form(
       form(
-        { method: "POST", action: `/votes/${encodeURIComponent(voteId)}/comments`, class: "comment-form" },
+        { method: "POST", action: `/votes/${encodeURIComponent(voteId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
         input({ type: "hidden", name: "returnTo", value: returnTo }),
         input({ type: "hidden", name: "returnTo", value: returnTo }),
         textarea({
         textarea({
           id: "comment-text",
           id: "comment-text",
           name: "text",
           name: "text",
-          required: true,
           rows: 4,
           rows: 4,
           class: "comment-textarea",
           class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         }),
+        div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
         br(),
         br(),
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
       )