psy преди 1 седмица
родител
ревизия
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")
     
- +  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-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.
  
    ![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 ;-)
 
@@ -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.
 
   ![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):
 
 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
 -->
 
+## 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
 
 ### Added

+ 5 - 5
docs/PUB/deploy.md

@@ -30,7 +30,7 @@ Paste this:
     "level": "info"
   },
   "caps": {
-    "shs": "iKOzhqNVTcKEZvUhW3A7TuKZ1d6qIbtsGIJ6+SBOaEQ="
+    "shs": "1BIWr6Hu+MgtNkkClvg2GAi+0HiAikGOOTd/pIUcH54="
   },
   "pub": true,
   "local": false,
@@ -85,7 +85,7 @@ Paste this:
   "autofollow": {
     "enabled": true,
     "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
    
    {
-     "id": "@HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519"
+     "id": "@zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519"
    }
 
 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":
 
-   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
 
@@ -205,7 +205,7 @@ To follow another PUB's feed, publish this other message:
 For example, to follow `solarnethub.com` PUB: "La Plaza":
 
    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
 

+ 2 - 0
docs/security.md

@@ -21,6 +21,8 @@ You should also know:
 
 - 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.
+- Don't trust your smartphone. 
+- Oasis is designed to run in a safe environment (SNH kit).
 
 ## 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');
 if (fs.existsSync(ssbRefPath)) {
   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 ===

Файловите разлики са ограничени, защото са твърде много
+ 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 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) {
   if (!ctx.request.files || !ctx.request.files[fileFieldName]) {
     return null;
@@ -13,12 +66,49 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
   const blobUpload = ctx.request.files[fileFieldName];
   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 > 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 blob = { name: blobUpload.name };
   blob.id = await new Promise((resolve, reject) => {
     pull(
       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("audio/")) return `\n[audio:${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) {
   if (!text) return ''
+  const i18n = getI18n()
   return String(text)
     .replace(/&/g, '&amp;')
     .replace(/</g, '&lt;')
     .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) =>
-      `<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) =>
       `<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) {
   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 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 = [];
   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) {
     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;
 }
 
+
+.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;
   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;
   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;
   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');
 let i18n = {};
-const languages = ['en', 'es', 'fr', 'eu']; // Add more language codes
+const languages = ['en', 'es', 'fr', 'eu', 'de', 'it', 'pt'];
 
 languages.forEach(language => {
   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",
     pmCreateButton: "Write a PM",
     pmReply: "Reply",
+    pmReplies: "replies",
+    pmNew: "new",
+    pmMarkRead: "Mark as read",
     inReplyTo: "IN REPLY TO",
     pmPreview: "Preview",
     pmPreviewTitle: "Message preview",
@@ -202,6 +205,8 @@ module.exports = {
     mentionsRelationship: "Relationship",
     //settings
     updateit: "GET UPDATES!",
+    updateBannerText: "A new version of Oasis is available.",
+    updateBannerAction: "Update now →",
     info: "Info",
     settingsIntro: ({ version }) => [
       `[SNH] ꖒ OASIS [ v.${version} ]`,
@@ -376,6 +381,7 @@ module.exports = {
     videoLabel: "VIDEOS",
     audioLabel: "AUDIOS",
     documentLabel: "DOCUMENTS",
+    pdfFallbackLabel: "PDF Document",
     eventLabel: "EVENTS",
     taskLabel: "TASKS",
     transferLabel: "TRANSFERS",
@@ -390,7 +396,7 @@ module.exports = {
     editProfileDescription:
       "",
     profileName: "Name",
-    profileImage: "Avatar Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    profileImage: "Avatar Image",
     profileDescription: "Description",
     hashtagDescription:
       "Posts from inhabitants in your network that reference this #hashtag, sorted by recency.",
@@ -678,6 +684,7 @@ module.exports = {
     blockedLabel: "Blocked User",
     inhabitantviewDetails: "View Details",
     viewDetails: "View Details",
+    keepReading: "Keep reading...",
     oasisId: "ID",
     noInhabitantsFound: "No inhabitants found, yet.",
     inhabitantActivityLevel: "Activity Level",
@@ -1275,7 +1282,7 @@ module.exports = {
     // blog/post,
     blogSubject: "Subject",
     blogMessage: "Message",
-    blogImage: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    blogImage: "Upload media (max-size: 50MB)",
     blogPublish: "Preview",
     noPopularMessages: "No popular messages published, yet",
     //forum
@@ -1374,6 +1381,7 @@ module.exports = {
     TOPButton:        "Top Feeds",
     CREATEButton:     "Create Feed",
     totalOpinions:    "Total Opinions",
+    moreVoted:        "More Voted",
     alreadyVoted:     "You have already opined.",
     noFeedsFound:     "No feeds found.",
     author:           "By",
@@ -1584,7 +1592,7 @@ module.exports = {
     reportsUpdateButton: "Update",
     reportsDeleteButton: "Delete",
     reportsDateLabel: "Date",
-    reportsUploadFile: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    reportsUploadFile: "Upload media (max-size: 50MB)",
     reportsCreatedBy: "By",
     reportsMineSectionTitle: "Your Reports",
     reportsFeaturesSectionTitle: "Feature Requests",
@@ -1657,7 +1665,6 @@ module.exports = {
     reportsWhyInappropriatePlaceholder: 'Explain the reason and impact.',
     reportsRequestedActionLabel: 'Requested action',
     reportsRequestedActionPlaceholder: 'Remove, hide, tag, warn, etc.',  
-    //tribes
     tribesTitle: "Tribes",
     tribeAllSectionTitle: "Tribes",
     tribeMineSectionTitle: "Your Tribes",
@@ -1675,11 +1682,13 @@ module.exports = {
     tribeFilterRecent: "RECENT",
     tribeFilterLarp: "L.A.R.P.",
     tribeFilterTop: "TOP",
+    tribeFilterSubtribes: "SUB-TRIBES",
     tribeFilterGallery: "GALLERY",
+    tribeMainTribeLabel: "MAIN TRIBE",
     tribeCreateButton: "Create Tribe",
     tribeUpdateButton: "Update",
     tribeDeleteButton: "Delete",
-    tribeImageLabel: "Tribe Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    tribeImageLabel: "Upload media (max-size: 50MB)",
     tribeTitleLabel: "Title",
     searchTribesPlaceholder:  "FILTER tribes BY NAME …",
     tribeTitlePlaceholder: "Name of the tribe",
@@ -1706,6 +1715,7 @@ module.exports = {
     tribeGenerateInvite: "GENERATE CODE",
     tribeCreatedAt: "Created at",
     tribeAuthor: "By",
+    tribeAuthorLabel: "AUTHOR",
     tribeStrict: "Strict",
     tribeOpen: "Open",
     tribeFeedFilterRECENT:           "RECENT",
@@ -1718,7 +1728,178 @@ module.exports = {
     tribeFeedSend:                   "Send",
     tribeFeedEmpty:                  "No feed messages available, 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",
     agendaDescription: "Here you can find all your assigned items.",
     agendaFilterAll: "ALL",
@@ -1881,6 +2062,8 @@ module.exports = {
     pmToLabel: "To:",
     pmInvalidMessage: "Invalid message",
     pmNoSubject: "(no subject)",
+    pmSubjectLabel: "Subject:",
+    pmBodyLabel: "Body",
     pmBotJobs: "42-JobsBOT",
     pmBotProjects: "42-ProjectsBOT",
     pmBotMarket: "42-MarketBOT",
@@ -1909,6 +2092,8 @@ module.exports = {
     blockchainBlockURL: 'URL:',
     blockchainContent: 'Block',
     blockchainContentPreview: 'Preview of the block content',
+    blockchainLatestDatagram: 'Latest Datagram',
+    blockchainDatagram: 'Datagram',
     blockchainDetails: 'View block details',
     blockchainBlockInfo: 'Block Information',
     blockchainBlockDetails: 'Details of the selected block',
@@ -2185,7 +2370,7 @@ module.exports = {
     marketItemSeller: "Seller",
     marketNoItems: "No items available, yet.",
     marketYourBid: "Your Bid",
-    marketCreateFormImageLabel: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    marketCreateFormImageLabel: "Upload media (max-size: 50MB)",
     marketSearchLabel: "Search",
     marketSearchPlaceholder: "Search title or tags",
     marketMinPriceLabel: "Min price",
@@ -2246,7 +2431,7 @@ module.exports = {
     jobLocationRemote: "Remote",
     jobVacantsPlaceholder: "Number of positions",
     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",
     jobType: "Job Type",
     jobTime: "Job Time",
@@ -2315,7 +2500,7 @@ module.exports = {
     projectRecentTitle: "Recent Projects",
     projectTopTitle: "Top Funded",
     projectTitlePlaceholder: "Project name",
-    projectImage: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    projectImage: "Upload media (max-size: 50MB)",
     projectDescription: "Description",
     projectDescriptionPlaceholder: "Tell the story and goals…",
     projectGoal: "Goal (ECO)",
@@ -2478,8 +2663,31 @@ module.exports = {
     modulesBankingLabel: "Banking",
     modulesBankingDescription: "Module to determine the real value of ECOIN and distribute a UBI using the common treasury.",
     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
     }
 };

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

@@ -198,6 +198,8 @@ module.exports = {
     mentionsRelationship: "Relación",
     // settings
     updateit: "OBTENER ACTUALIZACIONES!",
+    updateBannerText: "Hay una nueva versión de Oasis disponible.",
+    updateBannerAction: "Actualizar ahora →",
     info: "Info",
     settingsIntro: ({ version }) => [
       `[SNH] ꖒ OASIS [ v.${version} ]`,
@@ -371,6 +373,7 @@ module.exports = {
     videoLabel: "VIDEOS",
     audioLabel: "AUDIOS",
     documentLabel: "DOCUMENTOS",
+    pdfFallbackLabel: "Documento PDF",
     eventLabel: "EVENTOS",
     taskLabel: "TAREAS",
     transferLabel: "TRANSFERENCIAS",
@@ -385,7 +388,7 @@ module.exports = {
     editProfileDescription:
       "",
     profileName: "Nombre",
-    profileImage: "Imágen Avatar (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    profileImage: "Imágen Avatar",
     profileDescription: "Descripción",
     hashtagDescription:
       "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",
     inhabitantviewDetails: "Ver Detalles",
     viewDetails: "Ver Detalles",
+    keepReading: "Seguir leyendo...",
     oasisId: "ID",
     noInhabitantsFound:    "No se encontraron habitantes, aún.",
     inhabitantActivityLevel: "Nivel Actividad",
@@ -1269,7 +1273,7 @@ module.exports = {
     // blog/post,
     blogSubject: "Asunto",
     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",
     noPopularMessages: "No se han publicado mensajes populares, aún",
     // forum
@@ -1368,6 +1372,7 @@ module.exports = {
     TOPButton:        "Feeds Principales",
     CREATEButton:     "Crear Feed",
     totalOpinions:    "Total de Opiniones",
+    moreVoted:        "Más Votado",
     alreadyVoted:     "Ya has opinado.",
     noFeedsFound:     "No se encontraron feeds.",
     author:           "Por",
@@ -1577,7 +1582,7 @@ module.exports = {
     reportsUpdateButton: "Actualizar",
     reportsDeleteButton: "Eliminar",
     reportsDateLabel: "Fecha",
-    reportsUploadFile: "Subir Imagen (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
+    reportsUploadFile: "Subir contenido multimedia (max: 50MB)",
     reportsCreatedBy: "Por",
     reportsMineSectionTitle: "Tus Informes",
     reportsFeaturesSectionTitle: "Solicitudes de Funciones",
@@ -1650,7 +1655,6 @@ module.exports = {
     reportsWhyInappropriatePlaceholder: 'Explica el motivo y el impacto.',
     reportsRequestedActionLabel: 'Acción solicitada',
     reportsRequestedActionPlaceholder: 'Eliminar, ocultar, marcar, advertir, etc.',
-    //tribes
     tribesTitle: "Tribus",
     tribeAllSectionTitle: "Tribus",
     tribeMineSectionTitle: "Tus Tribus",
@@ -1668,11 +1672,13 @@ module.exports = {
     tribeFilterRecent: "RECIENTES",
     tribeFilterLarp: "L.A.R.P.",
     tribeFilterTop: "TOP",
+    tribeFilterSubtribes: "SUB-TRIBUS",
     tribeFilterGallery: "GALERÍA",
+    tribeMainTribeLabel: "TRIBU PRINCIPAL",
     tribeCreateButton: "Crear Tribu",
     tribeUpdateButton: "Actualizar",
     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",
     searchTribesPlaceholder:  "FILTRAR tribus POR NOMBRE …",
     tribeTitlePlaceholder: "Nombre de la tribu",
@@ -1699,6 +1705,7 @@ module.exports = {
     tribeGenerateInvite: "GENERAR CÓDIGO",
     tribeCreatedAt: "Creado el",
     tribeAuthor: "Por",
+    tribeAuthorLabel: "AUTOR",
     tribeStrict: "Estricto",
     tribeOpen: "Abierta",
     tribeFeedFilterRECENT: "RECIENTES",
@@ -1711,7 +1718,178 @@ module.exports = {
     tribeFeedSend: "Enviar",
     tribeFeedEmpty: "No hay mensajes de feed disponibles, 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",
     agendaDescription: "Aquí puedes encontrar todos tus elementos asignados.",
     agendaFilterAll: "TODOS",
@@ -1871,6 +2049,9 @@ module.exports = {
     pmCreateButton: "Escribir MP",
     noPrivateMessages: "No hay mensajes privados.",
     pmReply: "Responder",
+    pmReplies: "respuestas",
+    pmNew: "nuevas",
+    pmMarkRead: "Marcar como leido",
     inReplyTo: "EN RESPUESTA A",
     pmPreview: "Previsualizar",
     pmPreviewTitle: "Vista previa",
@@ -1879,6 +2060,8 @@ module.exports = {
     pmToLabel: "Para:",
     pmInvalidMessage: "Mensaje no válido",
     pmNoSubject: "(sin asunto)",
+    pmSubjectLabel: "Asunto:",
+    pmBodyLabel: "Cuerpo",
     pmBotJobs: "42-JobsBOT",
     pmBotProjects: "42-ProjectsBOT",
     pmBotMarket: "42-MarketBOT",
@@ -1907,6 +2090,8 @@ module.exports = {
     blockchainBlockURL: 'URL:',
     blockchainContent: 'Bloque',
     blockchainContentPreview: 'Vista previa del contenido del bloque',
+    blockchainLatestDatagram: 'Último Datagrama',
+    blockchainDatagram: 'Datagrama',
     blockchainDetails: 'Ver detalles del bloque',
     blockchainBlockInfo: 'Información del Bloque',
     blockchainBlockDetails: 'Detalles del bloque seleccionado',
@@ -2184,7 +2369,7 @@ module.exports = {
     marketItemSeller: "Vendedor",
     marketNoItems: "Aún no hay artículos disponibles.",
     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",
     marketSearchPlaceholder: "Buscar por título o etiquetas",
     marketMinPriceLabel: "Precio mínimo",
@@ -2245,7 +2430,7 @@ module.exports = {
     jobLocationRemote: "Remoto",
     jobVacantsPlaceholder: "Número de vacantes",
     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",
     jobType: "Tipo de Trabajo",
     jobTime: "Tiempo de Trabajo",
@@ -2314,7 +2499,7 @@ module.exports = {
     projectRecentTitle: "Proyectos Recientes",
     projectTopTitle: "Mejor Financiados",
     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",
     projectDescriptionPlaceholder: "Cuenta la historia y los objetivos…",
     projectGoal: "Meta (ECO)",
@@ -2477,8 +2662,31 @@ module.exports = {
     modulesBankingLabel: "Banca",
     modulesBankingDescription: "Módulo para conocer el valor real de ECOIN y distribuir una RBU utilizando la tesorería común.",
     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
     }
 };

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

@@ -79,6 +79,7 @@ module.exports = {
     invalidCoordinate: 'Koordenatu okerrak',
     goToMuralButton: "Ikusi Murala",
     totalPixels: 'Pixelak guztira',
+    pixeliaBy: "egilea",
     // modules
     modules: "Moduluak",
     modulesViewTitle: "Moduluak",
@@ -198,6 +199,8 @@ module.exports = {
     mentionsRelationship: "Erlazioa",
     // settings
     updateit: "LORTU EGUNERAKETAK!",
+    updateBannerText: "Oasis-en bertsio berri bat eskuragarri dago.",
+    updateBannerAction: "Eguneratu orain →",
     info: "Infoa",
     settingsIntro: ({ version }) => [
       `[SNH] ꖒ OASIS [ v.${version} ]`,
@@ -372,6 +375,7 @@ module.exports = {
     videoLabel: "BIDEOAK",
     audioLabel: "AUDIOAK",
     documentLabel: "DOKUMENTUAK",
+    pdfFallbackLabel: "PDF Dokumentua",
     eventLabel: "EKITALDIAK",
     taskLabel: "ATAZAK",
     transferLabel: "TRANSFERENTZIAK",
@@ -386,7 +390,7 @@ module.exports = {
     editProfileDescription:
       "",
     profileName: "Izena",
-    profileImage: "Abatarraren irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    profileImage: "Avatar Irudia",
     profileDescription: "Deskribapena",
     hashtagDescription:
       "Zure sareko bizilagunen bidalketak non #traola hau aipatzen den, gaurkotasunaren arabera antilatuta.",
@@ -674,6 +678,7 @@ module.exports = {
     blockedLabel:        "Blokeatutako Erabiltzailea",
     inhabitantviewDetails: "Ikusi Xehetasunak",
     viewDetails: "Ikusi Xehetasunak",
+    keepReading: "Irakurtzen jarraitu...",
     oasisId: "ID-a",
     noInhabitantsFound:  "Bizilagunik ez, oraindik.",
     inhabitantActivityLevel: "Jarduera Maila",
@@ -842,6 +847,10 @@ module.exports = {
     courtsJudgeId: "Epailea",
     courtsJudgeIdPh: "Oasis ID (@...) edo Biztanle izena",
     courtsNominateBtn: "Proposatu",
+    courtsJudge: "Epailea",
+    courtsEvidenceFileLabel: "Froga fitxategia (irudia, audioa, bideoa edo PDF)",
+    courtsCaseMediators: "Bitartekariak",
+    courtsMediatorsLabel: "Bitartekariak",
     courtsAddEvidence: "Frogak gehitu",
     courtsEvidenceText: "Testua",
     courtsEvidenceLink: "Esteka",
@@ -853,6 +862,8 @@ module.exports = {
     courtsStanceDENY: "Ukatu",
     courtsStanceADMIT: "Onartu",
     courtsStancePARTIAL: "Partziala",
+    courtsStanceCOUNTERCLAIM: "Kontraerreklamazioa",
+    courtsStanceNEUTRAL: "Neutrala",
     courtsVerdictTitle: "Ebazpena eman",
     courtsVerdictResult: "Emaitza",
     courtsVerdictOrders: "Aginduak",
@@ -860,6 +871,8 @@ module.exports = {
     courtsIssueVerdict: "Ebazpena eman",
     courtsMediationPropose: "Akordioa proposatu",
     courtsSettlementText: "Baldintzak",
+    courtsSettlementAccepted: "Onartua",
+    courtsSettlementPending: "Zain",
     courtsSettlementProposeBtn: "Proposatu",
     courtsNominationsTitle: "Epailetzako izendapenak",
     courtsThJudge: "Epailea",
@@ -896,6 +909,13 @@ module.exports = {
     courtsMethodPOPULAR: "Herritarren bozketa",
     courtsMethodMEDIATION: "Bitartekaritza",
     courtsMethodKARMATOCRACY: "Karmatokrazia",
+    courtsMethodJUDGES: "Epaile-mahaia",
+    courtsMethodSINGLE_JUDGE: "Epaile bakarra",
+    courtsMethodJURY: "Epaimahaia",
+    courtsMethodCOUNCIL: "Kontseilua",
+    courtsMethodCOMMUNITY: "Komunitatea",
+    courtsMethodARBITRATION: "Arbitrajea",
+    courtsMethodVOTE: "Komunitate-bozketa",
     courtsMethod: "Metodoa",
     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.",
@@ -1270,7 +1290,7 @@ module.exports = {
     // blog/post,
     blogSubject: "Gaia",
     blogMessage: "Mezua",
-    blogImage: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    blogImage: "Multimedia igo (max: 50MB)",
     blogPublish: "Aurrebista",
     noPopularMessages: "Pil-pileko mezurike ez, oraindik",
     // forum
@@ -1369,6 +1389,7 @@ module.exports = {
     TOPButton:        "Jario Gorenak",
     CREATEButton:     "Sortu Jarioa",
     totalOpinions:    "Iritziak Guztira",
+    moreVoted:        "Gehien bozkatu",
     alreadyVoted:     "Bozkatu duzu jadanik",
     noFeedsFound:     "Ez da jariorik aurkitu.",
     author:           "Nork",
@@ -1531,7 +1552,7 @@ module.exports = {
     reportsUpdateButton: "Eguneratu",
     reportsDeleteButton: "Ezabatu",
     reportsDateLabel: "Data",
-    reportsUploadFile: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    reportsUploadFile: "Multimedia igo (max: 50MB)",
     reportsCreatedBy: "Nork",
     reportsMineSectionTitle: "Zeure Txostenak",
     reportsFeaturesSectionTitle: "Gaitasuna Eskatu",
@@ -1545,7 +1566,7 @@ module.exports = {
     reportsValidationCategory: "Aukeratu kategoria, mesedez.",
     reportsCreatedAt: "Noiz",
     reportsCreatedBy: "Nork",  
-    reportsSeverityi: "Larritasuna",
+    reportsSeverity: "Larritasuna",
     reportsSeverityLow: "Baxua",
     reportsSeverityMedium: "Ertaina",
     reportsSeverityHigh: "Altua",
@@ -1604,7 +1625,6 @@ module.exports = {
     reportsWhyInappropriatePlaceholder: 'Azaldu arrazoia eta eragina.',
     reportsRequestedActionLabel: 'Eskatutako ekintza',
     reportsRequestedActionPlaceholder: 'Kendu, ezkutatu, etiketatu, ohartarazi, etab.',
-    //tribes
     tribesTitle: "Tribuak",
     tribeAllSectionTitle: "Tribuak",
     tribeMineSectionTitle: "Zeure Tribuak",
@@ -1615,6 +1635,7 @@ module.exports = {
     tribeLarpSectionTitle: "Tribuen Galeria",
     tribeRecentSectionTitle: "Tribu Berriak",
     tribeTopSectionTitle: "Tribu Gorenak",
+    tribeviewTribeButton: "Tribua Bisitatu",
     tribeDescription: "Aurkitu edo sortu tribuak zure sarean.",
     tribeFilterAll: "GUZTIAK",
     tribeFilterMine: "NEUREAK",
@@ -1622,11 +1643,13 @@ module.exports = {
     tribeFilterRecent: "BERRIAK",
     tribeFilterLarp: "L.A.R.P.",
     tribeFilterTop: "GORENAK",
+    tribeFilterSubtribes: "AZPI-TRIBUAK",
     tribeFilterGallery: "GALERIA",
+    tribeMainTribeLabel: "TRIBU NAGUSIA",
     tribeCreateButton: "Sortu Tribua",
     tribeUpdateButton: "Egutneratu",
     tribeDeleteButton: "Ezabatu",
-    tribeImageLabel: "Tribuaren Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    tribeImageLabel: "Multimedia igo (max: 50MB)",
     tribeTitleLabel: "Izenburua",
     searchTribesPlaceholder:  "IRAGAZI tribuak IZENAREN ARABERA …",
     tribeTitlePlaceholder: "Tribuaren izena",
@@ -1653,6 +1676,7 @@ module.exports = {
     tribeGenerateInvite: "SORTU KODEA",
     tribeCreatedAt: "Noiz",
     tribeAuthor: "Nork",
+    tribeAuthorLabel: "EGILEA",
     tribeStrict: "Zorrotza",
     tribeOpen: "Irekita",
     tribeFeedFilterRECENT:           "BERRIAK",
@@ -1665,7 +1689,178 @@ module.exports = {
     tribeFeedSend:                   "Bidali",
     tribeFeedEmpty:                  "Jariorik 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",
     agendaDescription: "Eskeituta dauzkazun elementu guztiak aurki ditzakezu hemen.",
     agendaFilterAll: "GUZTIAK",
@@ -1679,6 +1874,7 @@ module.exports = {
     agendaFilterTransfers: "TRANSFERENTZIAK",
     agendaFilterJobs: "LANPOSTUAK",
     agendaFilterProjects: "PROIEKTUAK",
+    agendaInviteModeLabel: "Egoera",
     agendaNoItems: "Esleipenik ez.",
     agendaDiscardButton: "Baztertu",
     agendaRestoreButton: "Berrezarri",
@@ -1825,6 +2021,9 @@ module.exports = {
     pmCreateButton: "MP idatzi",
     noPrivateMessages: "Ez dago mezu pribaturik.",
     pmReply: "Erantzun",
+    pmReplies: "erantzunak",
+    pmNew: "berriak",
+    pmMarkRead: "Irakurritako gisa markatu",
     inReplyTo: "HONI ERANTZUNEZ",
     pmPreview: "Aurrebista",
     pmPreviewTitle: "Mezuaren aurrebista",
@@ -1833,6 +2032,8 @@ module.exports = {
     pmToLabel: "Nori:",
     pmInvalidMessage: "Mezu baliogabea",
     pmNoSubject: "(gaia gabe)",
+    pmSubjectLabel: "Gaia:",
+    pmBodyLabel: "Gorputza",
     pmBotJobs: "42-JobsBOT",
     pmBotProjects: "42-ProjectsBOT",
     pmBotMarket: "42-MarketBOT",
@@ -1861,6 +2062,8 @@ module.exports = {
     blockchainBlockURL: 'URL:',
     blockchainContent: 'Bloke',
     blockchainContentPreview: 'Bloke edukia aurrebista',
+    blockchainLatestDatagram: 'Azken Datagrama',
+    blockchainDatagram: 'Datagrama',
     blockchainDetails: 'Ikusi blokearen xehetasunak',
     blockchainBlockInfo: 'Blokearen informazioa',
     blockchainBlockDetails: 'Hautatutako blokearen xehetasunak',
@@ -2138,7 +2341,7 @@ module.exports = {
     marketItemSeller: "Saltzailea",
     marketNoItems: "Oraindik ez dago artikulurik eskuragarri.",
     marketYourBid: "Zure puja",
-    marketCreateFormImageLabel: "Irudia igo (jpeg, jpg, png, gif) (geh. tamaina: 500px x 400px)",
+    marketCreateFormImageLabel: "Multimedia igo (max: 50MB)",
     marketSearchLabel: "Bilatu",
     marketSearchPlaceholder: "Izenburuan edo etiketetan bilatu",
     marketMinPriceLabel: "Gutxieneko prezioa",
@@ -2161,6 +2364,10 @@ module.exports = {
     jobsFilterRemote: "ERREMOTAK",
     jobsFilterOpen: "IREKIAK",
     jobsFilterClosed: "ITXITA",
+    jobsFilterTop: "GORENAK",
+    jobsTopTitle: "Soldata Onena duten Lanak",
+    jobTypeFreelance: "Autonomoa",
+    jobTypeSalary: "Langilea",
     jobsCV: "CV-ak",
     jobsCreateJob: "Argitaratu Lana",
     jobsRecentTitle: "Lana Azkenak",
@@ -2199,7 +2406,7 @@ module.exports = {
     jobLocationRemote: "Urrunekoa",
     jobVacantsPlaceholder: "Postu kopurua",
     jobSalaryPlaceholder: "Ordubeteko soldata ECO",
-    jobImage: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    jobImage: "Multimedia igo (max: 50MB)",
     jobTasks: "Eginkizunak",
     jobType: "Lanen Motak",
     jobTime: "Lanen Denbora",
@@ -2266,7 +2473,7 @@ module.exports = {
     projectRecentTitle: "Proiektu Azkenak",
     projectTopTitle: "Finantzatuenak",
     projectTitlePlaceholder: "Proiektuaren izena",
-    projectImage: "Irudia kargatu (jpeg, jpg, png, gif) (gehienez tamaina: 500px x 400px)",
+    projectImage: "Multimedia igo (max: 50MB)",
     projectDescription: "Deskribapena",
     projectDescriptionPlaceholder: "Kontatu istorioa eta helburuak...",
     projectGoal: "Helburu (ECO)",
@@ -2429,7 +2636,30 @@ module.exports = {
     modulesBankingLabel: "Bankua",
     modulesBankingDescription: "ECOINen benetako balioa zehazteko eta UBI bat altxortegi komuna erabiliz banatzeko modulua.",
     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
   }

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

@@ -198,6 +198,8 @@ module.exports = {
     mentionsRelationship: "Relation",
     // settings
     updateit: "OBTENIR DES MISES À JOUR !",
+    updateBannerText: "Une nouvelle version d'Oasis est disponible.",
+    updateBannerAction: "Mettre à jour →",
     info: "Info",
     settingsIntro: ({ version }) => [
       `[SNH] ꖒ OASIS [ v.${version} ]`,
@@ -371,6 +373,7 @@ module.exports = {
     videoLabel: "VIDÉOS",
     audioLabel: "AUDIOS",
     documentLabel: "DOCUMENTS",
+    pdfFallbackLabel: "Document PDF",
     eventLabel: "ÉVÉNEMENTS",
     taskLabel: "TÂCHES",
     transferLabel: "TRANSFERTS",
@@ -385,7 +388,7 @@ module.exports = {
     editProfileDescription:
       "",
     profileName: "Nom",
-    profileImage: "Image Avatar (jpeg, jpg, png, gif) (taille max : 500px x 400px)",
+    profileImage: "Image Avatar",
     profileDescription: "Description",
     hashtagDescription:
       "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é",
     inhabitantviewDetails: "Voir les détails",
     viewDetails: "Voir les détails",
+    keepReading: "Lire la suite...",
     oasisId: "ID",
     noInhabitantsFound:    "Aucun habitant trouvé pour l’instant.",
     inhabitantActivityLevel: "Niveau Activité",
@@ -1269,7 +1273,7 @@ module.exports = {
     // blog/post,
     blogSubject: "Sujet",
     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",
     noPopularMessages: "Aucun message populaire publié pour l’instant",
     // forum
@@ -1367,7 +1371,8 @@ module.exports = {
     TODAYButton:      "AUJOURD’HUI",
     TOPButton:        "Feeds principaux",
     CREATEButton:     "Créer un feed",
-    totalOpinions:    "Total d’opinions",
+    totalOpinions:    "Total d'opinions",
+    moreVoted:        "Plus voté",
     alreadyVoted:     "Vous avez déjà donné votre opinion.",
     noFeedsFound:     "Aucun feed trouvé.",
     author:           "Par",
@@ -1577,7 +1582,7 @@ module.exports = {
     reportsUpdateButton: "Mettre à jour",
     reportsDeleteButton: "Supprimer",
     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",
     reportsMineSectionTitle: "Vos rapports",
     reportsFeaturesSectionTitle: "Demandes de fonctions",
@@ -1650,7 +1655,6 @@ module.exports = {
     reportsWhyInappropriatePlaceholder: 'Expliquez la raison et l’impact.',
     reportsRequestedActionLabel: 'Action demandée',
     reportsRequestedActionPlaceholder: 'Supprimer, masquer, étiqueter, avertir, etc.',
-    //tribes
     tribesTitle: "Tribus",
     tribeAllSectionTitle: "Tribus",
     tribeMineSectionTitle: "Vos tribus",
@@ -1668,11 +1672,13 @@ module.exports = {
     tribeFilterRecent: "RÉCENTS",
     tribeFilterLarp: "L.A.R.P.",
     tribeFilterTop: "TOP",
+    tribeFilterSubtribes: "SOUS-TRIBUS",
     tribeFilterGallery: "GALERIE",
+    tribeMainTribeLabel: "TRIBU PRINCIPALE",
     tribeCreateButton: "Créer une tribu",
     tribeUpdateButton: "Mettre à jour",
     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",
     searchTribesPlaceholder:  "FILTRER les tribus PAR NOM …",
     tribeTitlePlaceholder: "Nom de la tribu",
@@ -1699,6 +1705,7 @@ module.exports = {
     tribeGenerateInvite: "GÉNÉRER UN CODE",
     tribeCreatedAt: "Créé le",
     tribeAuthor: "Par",
+    tribeAuthorLabel: "AUTEUR",
     tribeStrict: "Strict",
     tribeOpen: "Ouverte",
     tribeFeedFilterRECENT: "RÉCENTS",
@@ -1710,8 +1717,179 @@ module.exports = {
     tribeFeedMessagePlaceholder: "Écrivez un feed…",
     tribeFeedSend: "Envoyer",
     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",
     agendaDescription: "Ici vous pouvez trouver tous vos éléments assignés.",
     agendaFilterAll: "TOUS",
@@ -1871,6 +2049,9 @@ module.exports = {
     pmCreateButton: "Écrire un MP",
     noPrivateMessages: "Aucun message privé.",
     pmReply: "Répondre",
+    pmReplies: "réponses",
+    pmNew: "nouvelles",
+    pmMarkRead: "Marquer comme lu",
     inReplyTo: "EN RÉPONSE À",
     pmPreview: "Aperçu",
     pmPreviewTitle: "Aperçu",
@@ -1879,6 +2060,8 @@ module.exports = {
     pmToLabel: "À :",
     pmInvalidMessage: "Message non valide",
     pmNoSubject: "(sans objet)",
+    pmSubjectLabel: "Objet :",
+    pmBodyLabel: "Corps",
     pmBotJobs: "42-JobsBOT",
     pmBotProjects: "42-ProjectsBOT",
     pmBotMarket: "42-MarketBOT",
@@ -1907,6 +2090,8 @@ module.exports = {
     blockchainBlockURL: 'URL :',
     blockchainContent: 'Bloc',
     blockchainContentPreview: 'Aperçu du contenu du bloc',
+    blockchainLatestDatagram: 'Dernier Datagramme',
+    blockchainDatagram: 'Datagramme',
     blockchainDetails: 'Voir les détails du bloc',
     blockchainBlockInfo: 'Informations du bloc',
     blockchainBlockDetails: 'Détails du bloc sélectionné',
@@ -2184,7 +2369,7 @@ module.exports = {
     marketItemSeller: "Vendeur",
     marketNoItems: "Aucun article disponible pour le moment.",
     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",
     marketSearchPlaceholder: "Rechercher par titre ou tags",
     marketMinPriceLabel: "Prix minimum",
@@ -2245,7 +2430,7 @@ module.exports = {
     jobLocationRemote: "À distance",
     jobVacantsPlaceholder: "Nombre de postes vacants",
     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",
     jobType: "Type d’emploi",
     jobTime: "Temps de travail",
@@ -2314,7 +2499,7 @@ module.exports = {
     projectRecentTitle: "Projets Récents",
     projectTopTitle: "Mieux Financé",
     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",
     projectDescriptionPlaceholder: "Racontez l'histoire et les objectifs…",
     projectGoal: "Objectif (ECO)",
@@ -2477,8 +2662,31 @@ module.exports = {
     modulesBankingLabel: "Banque",
     modulesBankingDescription: "Module pour connaître la valeur réelle de ECOIN et distribuer une RBU en utilisant la trésorerie commune.",
     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
     }
 };

Файловите разлики са ограничени, защото са твърде много
+ 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
     
     const csp = [
-      "default-src 'self' blob:", 
+      "default-src 'self'",
+      "script-src 'self' http://localhost:3000/js",
+      "style-src 'self'",
       "img-src 'self'",
+      "media-src 'self' blob:",
+      "worker-src 'self' blob:",
       "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("; ");
 
     ctx.set("Content-Security-Policy", csp);
     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("Feature-Policy", "speaker 'self'");
+    ctx.set("Permissions-Policy", "speaker=(self)");
 
     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": {
       "limit": 2000
     },
-    "homePage": "activity"
+    "homePage": "activity",
+    "language": "en"
   };
   fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
 }

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

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

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

@@ -3,7 +3,7 @@
     "level": "notice"
   },
   "caps": {
-    "shs": "iKOzhqNVTcKEZvUhW3A7TuKZ1d6qIbtsGIJ6+SBOaEQ="
+    "shs": "1BIWr6Hu+MgtNkkClvg2GAi+0HiAikGOOTd/pIUcH54="
   },
   "pub": false,
   "local": true,
@@ -28,12 +28,12 @@
   },
   "connections": {
     "seeds": [
-      "net:solarnethub.com:8008~shs:HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519"
+      "net:solarnethub.com:8008~shs:zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519"
     ],
     "incoming": {
       "net": [
         {
-          "scope": "device",
+          "scope": ["device", "local"],
           "transform": "shs",
           "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 });
 
+      const tribeInternalTypes = new Set(['tribeLeave', 'tribeFeedPost', 'tribeFeedRefeed', 'tribe-content']);
+      const isAllowedTribeActivity = (a) => !tribeInternalTypes.has(a.type);
+
       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 === 'karma') out = deduped.filter(a => a.type === 'karmaScore');
       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) {
     const v = readAddrMap()[userId];
+    if (v === "__removed__") return null;
     const local = typeof v === "string" ? v : (v && v.address) || null;
     if (local) return local;
     const ssbAddr = await getWalletFromSSB(userId);
@@ -299,8 +300,14 @@ module.exports = ({ services } = {}) => {
   async function removeAddress({ userId }) {
     if (!userId) return { status: "invalid" };
     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);
     return { status: "deleted" };
   }
@@ -311,6 +318,7 @@ module.exports = ({ services } = {}) => {
     const keys = new Set([...Object.keys(local), ...Object.keys(ssbAll)]);
     const out = [];
     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" });
       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);
   };
 
-  const createFeed = async (text) => {
+  const createFeed = async (text, mentions) => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
 
@@ -113,7 +113,8 @@ module.exports = ({ cooler }) => {
       text: cleaned,
       author: userId,
       createdAt: new Date().toISOString(),
-      tags: extractTags(cleaned)
+      tags: extractTags(cleaned),
+      mentions: Array.isArray(mentions) && mentions.length > 0 ? mentions : undefined
     };
 
     return new Promise((resolve, reject) => {

+ 16 - 5
src/models/inhabitants_model.js

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

+ 48 - 16
src/models/main_models.js

@@ -117,11 +117,16 @@ const canonicalizePubId = (s) => {
 };
 
 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 = []) {
@@ -263,8 +268,9 @@ module.exports = ({ cooler, isPublic }) => {
   return Promise.all(
     entries.map(async ([remote, data]) => {
       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(
         users.map(async (user) => {
           const userName = await models.about.name(user.id).catch(() => user.id);
@@ -275,7 +281,7 @@ module.exports = ({ cooler, isPublic }) => {
         remote,
         {
           ...data,
-          key: pubId || remote,
+          key: effectiveKey || remote,
           name,
           users: usersWithNames
         }
@@ -611,13 +617,40 @@ models.meta = {
     discovered: async () => {
       const ssb = await cooler.open();
       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 ebtMap = new Map(ebtList.map(e => [e.pub, e.users]));
       const unknownPeers = [];
       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);
           unknownPeers.push([pub, { key: pub, name, users: ebtMap.get(pub) || [] }]);
         }
@@ -1044,12 +1077,11 @@ const post = {
           }
         }
         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;
-        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; 
       },

+ 1 - 2
src/models/market_model.js

@@ -103,8 +103,7 @@ module.exports = ({ cooler }) => {
 
       let blobId = null
       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)

+ 1 - 3
src/models/projects_model.js

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

+ 2 - 4
src/models/reports_model.js

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

+ 1 - 1
src/models/stats_model.js

@@ -119,7 +119,7 @@ module.exports = ({ cooler }) => {
 
     const messages = await new Promise((res, rej) => {
       pull(
-        ssbClient.createLogStream({ limit: logLimit }),
+        ssbClient.createLogStream({ limit: logLimit, reverse: true }),
         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 crypto = require('crypto');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
+const INVITE_CODE_BYTES = 16;
+const VALID_INVITE_MODES = ['strict', 'open'];
+
 module.exports = ({ cooler }) => {
   let 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 {
     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 userId = ssb.id;
       let blobId = null;
       if (image) {
-        const match = image.match(/\(([^)]+)\)/);
-        blobId = match ? match[1] : image;
+        blobId = String(image).trim() || null;
       }
       const tags = Array.isArray(tagsRaw)
         ? tagsRaw.filter(Boolean)
@@ -32,12 +81,15 @@ module.exports = ({ cooler }) => {
         members: [userId],
         invites: [],
         inviteMode,
+        status: status || 'OPEN',
+        parentTribeId: parentTribeId || null,
         createdAt: new Date().toISOString(),
         updatedAt: new Date().toISOString(),
         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) {
@@ -50,34 +102,14 @@ module.exports = ({ cooler }) => {
       if (tribe.inviteMode === 'open' && !tribe.members.includes(userId)) {
         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];
       await this.updateTribeInvites(tribeId, invites);
       return code;
     },
 
     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) {
@@ -85,68 +117,29 @@ module.exports = ({ cooler }) => {
       const userId = ssb.id;
       const tribe = await this.getTribeById(tribeId);
       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 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);
-      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) {
       const ssb = await openSsb();
       const userId = ssb.id;
       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) {
@@ -154,51 +147,7 @@ module.exports = ({ cooler }) => {
     },
 
     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) {
@@ -216,103 +165,85 @@ module.exports = ({ cooler }) => {
         members: updatedTribe.members,
         invites: updatedTribe.invites,
         inviteMode: updatedTribe.inviteMode,
+        status: updatedTribe.status || 'OPEN',
+        parentTribeId: updatedTribe.parentTribeId || null,
         createdAt: updatedTribe.createdAt,
         updatedAt: new Date().toISOString(),
         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));
       });
+      tribeIndex = null;
+      return result;
     },
 
     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) {
@@ -344,35 +275,15 @@ module.exports = ({ cooler }) => {
           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",
-  "version": "0.6.5",
+  "version": "0.6.6",
   "description": "Oasis Social Networking Project Utopia",
   "repository": {
     "type": "git",
@@ -16,46 +16,32 @@
     "start": "npm run start:ssb && sleep 10 && npm run start:backend",
     "start:backend": "node ../backend/backend.js",
     "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": {
     "@koa/router": "^13.1.0",
     "@open-rpc/client-js": "^1.8.1",
     "abstract-level": "^2.0.1",
     "archiver": "^7.0.1",
-    "await-exec": "^0.1.2",
     "axios": "^1.10.0",
-    "base64-url": "^2.3.3",
-    "broadcast-stream": "^0.2.1",
-    "caller-path": "^4.0.0",
     "cors": "^2.8.5",
-    "crypto": "^1.0.1",
     "debug": "^4.3.1",
+    "dompurify": "^3.3.1",
     "env-paths": "^2.2.1",
     "epidemic-broadcast-trees": "^9.0.4",
     "express": "^5.1.0",
     "file-type": "^16.5.4",
-    "gpt-3-encoder": "^1.1.4",
-    "has-network": "0.0.1",
     "highlight.js": "11.0.0",
     "hyperaxe": "^2.0.1",
     "ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz",
     "is-svg": "^4.4.0",
-    "is-valid-domain": "^0.1.6",
+    "jsdom": "^28.0.0",
     "koa": "^2.7.0",
     "koa-body": "^6.0.1",
-    "koa-bodyparser": "^4.4.1",
     "koa-mount": "^4.0.0",
     "koa-static": "^5.0.0",
     "lodash": "^4.17.21",
-    "lodash.shuffle": "^4.2.0",
     "minimist": "^1.2.8",
-    "mkdirp": "^3.0.1",
     "module-alias": "^2.2.3",
     "moment": "^2.30.1",
     "multiblob": "^1.13.0",
@@ -64,14 +50,12 @@
     "muxrpc": "^8.0.0",
     "muxrpc-validation": "^3.0.2",
     "muxrpcli": "^3.1.2",
-    "node-iframe": "^1.8.5",
     "node-llama-cpp": "^3.10.0",
     "non-private-ip": "^2.2.0",
     "open": "^8.4.2",
     "packet-stream": "^2.0.6",
     "packet-stream-codec": "^1.2.0",
     "pdfjs-dist": "^5.2.133",
-    "piexifjs": "^1.0.4",
     "pretty-ms": "^7.0.1",
     "pull-abortable": "^4.1.1",
     "pull-cat": "~1.1.5",
@@ -83,8 +67,6 @@
     "pull-stream": "^3.7.0",
     "punycode.js": "^2.3.1",
     "qrcode": "^1.5.4",
-    "remark-html": "^16.0.1",
-    "require-style": "^1.1.0",
     "secret-stack": "^6.3.1",
     "ssb-about": "^2.0.1",
     "ssb-autofollow": "^1.1.0",
@@ -110,7 +92,6 @@
     "ssb-lan": "^1.0.0",
     "ssb-legacy-conn": "^1.0.17",
     "ssb-links": "^3.0.10",
-    "ssb-local": "^1.0.0",
     "ssb-logging": "^1.0.0",
     "ssb-markdown": "^3.6.0",
     "ssb-master": "^1.0.3",
@@ -125,23 +106,18 @@
     "ssb-query": "^2.4.5",
     "ssb-ref": "^2.16.0",
     "ssb-replication-scheduler": "^3.0.0",
-    "ssb-room": "^0.0.10",
     "ssb-search": "^1.3.0",
     "ssb-server": "file:packages/ssb-server",
     "ssb-tangle": "^1.0.1",
     "ssb-thread-schema": "^1.1.1",
     "ssb-threads": "^10.0.4",
-    "ssb-tunnel": "^2.0.0",
     "ssb-unix-socket": "^1.0.0",
     "ssb-ws": "^6.2.3",
-    "tokenizers-linux-x64-gnu": "^0.13.4-rc1",
-    "unzipper": "^0.12.3",
     "util": "^0.12.5",
     "yargs": "^17.7.2"
   },
   "overrides": {
     "caller-path": "^4.0.0",
-    "is-valid-domain": "^0.1.6",
     "highlight.js": "11.0.0",
     "@babel/traverse": "7.23.2",
     "trim": "0.0.3",
@@ -157,29 +133,6 @@
     "ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.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": {
     "fsevents": "^2.3.2",
     "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 { renderUrl } = require('../backend/renderUrl');
 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_MAX = Number(getConfig().feed?.maxLength ?? 280);
@@ -436,7 +451,7 @@ function renderActionCards(actions, userId, allActions) {
          ), 
           Array.isArray(members) ? h2(`${i18n.tribeMembersCount}: ${members.length}`) : "",
           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' }),
           p({ class: 'tribe-description' }, ...renderUrl(description || '')),
           validTags.length
@@ -675,7 +690,7 @@ function renderActionCards(actions, userId, allActions) {
       const refeedsNum = Number(refeeds || 0) || 0;
       cardBody.push(
         div({ class: 'card-section feed' },
-          div({ class: 'feed-text', innerHTML: htmlText }),
+          div({ class: 'feed-text', innerHTML: sanitizeHtml(htmlText) }),
           refeedsNum > 0
             ? h2({ class: 'card-field' },
                 span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '),
@@ -689,20 +704,23 @@ function renderActionCards(actions, userId, allActions) {
   if (type === 'post') {
       const { contentWarning, text } = content || {};
       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;
       if (isHtml) {
-        const hasAnchor = /<a\b[^>]*>/i.test(rawText);
+        const hasAnchor = /<a\b[^>]*>/i.test(displayText);
         const linkified = hasAnchor
-          ? rawText
-          : rawText.replace(
+          ? displayText
+          : displayText.replace(
               /(https?:\/\/[^\s<]+)/g,
               (url) =>
                 `<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 {
-        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 replyToId = getReplyToIdFromPost(action, byIdAll);
@@ -711,19 +729,27 @@ function renderActionCards(actions, userId, allActions) {
       const parent = isReply ? (byIdAll.get(replyToId) || byIdAll.get(threadId)) : null;
       const parentContent = parent ? (parent.value?.content || parent.content || {}) : {};
       const parentAuthor = parent?.author || '';
+      const parentName = parent?.authorName || parentAuthor;
       const parentText = parent ? excerptPostText(parentContent, 220) : '';
       cardBody.push(
         div({ class: 'card-section post' },
           isReply
             ? 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) : '',
-          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 {
         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;
         cardBody.push(
           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;' },
               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 label = (i18n.spreadChron || 'Spread') + ':';
   const value = ord && totalChron ? `${ord}/${totalChron}` : (ord ? String(ord) : '');
+  const spreadExcerpt = spreadText || excerptPostText(content, 300);
   cardBody.push(
     div({ class: 'card-section vote' },
       spreadTitle ? h2({ class: 'post-title activity-spread-title' }, spreadTitle) : '',
       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
         ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, (i18n.spreadBy || 'By') + ': '),
             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-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)),
           br(),
           image
-            ? img({ src: `/blob/${encodeURIComponent(image)}` })
+            ? renderMediaBlob(image, '/assets/images/default-market.png')
             : img({ src: '/assets/images/default-market.png', alt: title }),
           br(),
           div({ class: "market-card price" },
@@ -1034,7 +1075,7 @@ function renderActionCards(actions, userId, allActions) {
             )
           ),
           div(
-            p({ innerHTML: msgHtml })
+            p({ innerHTML: sanitizeHtml(msgHtml) })
           ),
           p({ class: 'card-footer' },
             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) => {
-  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(
     i18n.agendaTitle,
     section(

+ 3 - 2
src/views/audio_view.js

@@ -12,6 +12,7 @@ const {
   span,
   textarea,
   select,
+  label,
   option
 } = require("../server/node_modules/hyperaxe");
 
@@ -112,16 +113,16 @@ const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) =>
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       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,
         textarea({
           id: "comment-text",
           name: "text",
-          required: true,
           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)
       )

+ 3 - 4
src/views/banking_views.js

@@ -252,14 +252,13 @@ const renderAddresses = (data, userId) => {
                     td(r.address),
                     td(r.source === "local" ? i18n.bankLocal : i18n.bankFromOasis),
 			td(
-			  r.source === "local"
-			    ? div({ class: "row-actions" },
+			  div({ class: "row-actions" },
 				form({ method: "POST", action: "/banking/addresses/delete", class: "addr-del" },
 				  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)
 				)
-			      )
-			    : 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 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) {
     return template(
       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(
     i18n.blockchain,
     section(
@@ -143,38 +249,19 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}) =>
         p(i18n.blockchainDescription)
       ),
       div({ class: 'mode-buttons-row' },
-        div({ style: 'display:flex;flex-direction:column;gap:8px;' },
+        div({ class: 'filter-column' },
           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_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_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' },
         form({ method:'GET', action:'/blockexplorer' },
           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)
           )
         : (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."
           )
         : null
@@ -213,14 +300,14 @@ const renderBlockchainView = (blocks, filter, userId, search = {}) => {
         p(i18n.blockchainDescription)
       ),
       div({ class:'mode-buttons-row' },
-        div({ style:'display:flex;flex-direction:column;gap:8px;' },
+        div({ class: 'filter-column' },
           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_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_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
         ? div(p(i18n.blockchainNoBlocks))
         : shown
@@ -258,13 +347,14 @@ const renderBlockchainView = (blocks, filter, userId, search = {}) => {
             .map(block=>
               div({ class:'block' },
                 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) ?
                     form({ method:'GET', action:getViewDetailsAction(block.type, block) },
                       button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
                     )
                   : (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."
                     )
                   : null

+ 2 - 2
src/views/bookmark_view.js

@@ -73,17 +73,17 @@ const renderBookmarkCommentsSection = (bookmarkId, rootId, comments = [], return
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       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,
         rootId ? input({ type: "hidden", name: "rootId", value: rootId }) : null,
         textarea({
           id: "comment-text",
           name: "text",
-          required: true,
           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)
       )

+ 13 - 13
src/views/cipher_view.js

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

+ 3 - 3
src/views/courts_view.js

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

+ 1 - 1
src/views/cv_view.js

@@ -206,7 +206,7 @@ exports.cvView = async (cv) => {
           ]) : null,
           hasOasis ? div({ class: "cv-box oasis" }, ...[
             h2(i18n.cvOasisContributorView),
-            p(...renderUrl(`${cv.oasisExperiences}`)),
+            cv.oasisExperiences ? p(...renderUrl(`${cv.oasisExperiences}`)) : null,
             (cv.oasisSkills && cv.oasisSkills.length)
               ? div(
                   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" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       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,
         rootId ? input({ type: "hidden", name: "rootId", value: rootId }) : null,
         textarea({
           id: "comment-text",
           name: "text",
-          required: true,
           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)
       )

+ 2 - 2
src/views/event_view.js

@@ -147,16 +147,16 @@ const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       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 }),
         textarea({
           id: "comment-text",
           name: "text",
-          required: true,
           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)
       )

+ 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 { renderTextWithStyles } = require("../backend/renderTextWithStyles");
 const opinionCategories = require("../backend/opinion_categories");
+const { sanitizeHtml } = require('../backend/sanitizeHtml');
 
 const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1);
 const FEED_TEXT_MAX = Number(config?.feed?.maxLength ?? 280);
 
 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 {
     filter: String(opts.filter || "ALL").toUpperCase(),
     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(
                 { 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(
                     { class: "card-footer" },
                     span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
@@ -132,7 +146,7 @@ const renderFeedCard = (feed) => {
 };
 
 exports.feedView = (feeds, opts = "ALL") => {
-  const { filter, q, tag } = normalizeOptions(opts);
+  const { filter, q, tag, msg } = normalizeOptions(opts);
 
   const title =
     filter === "MINE"
@@ -150,6 +164,9 @@ exports.feedView = (feeds, opts = "ALL") => {
                 : i18n.feedTitle;
 
   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 };
 
@@ -157,6 +174,7 @@ exports.feedView = (feeds, opts = "ALL") => {
     title,
     section(
       header,
+      successBanner,
       div(
         { class: "mode-buttons-row" },
         ...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' },
                 `${i18n.forumParticipants.toUpperCase()}: ${f.participants?.length || 1}`),
               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)
               )
             ),
@@ -208,7 +208,7 @@ const renderForumList = (forums, currentFilter) =>
               ? div({ class: 'forum-owner-actions' },
                 form({
                   method: 'POST',
-                  action: `/forum/delete/${f.key}`,
+                  action: `/forum/delete/${encodeURIComponent(f.key)}`,
                   class: 'forum-delete-form'
                 },
                   button({ type: 'submit', class: 'delete-btn' },
@@ -248,7 +248,7 @@ exports.forumView = async (forums, currentFilter) => {
       currentFilter === 'create'
         ? renderForumForm()
         : renderForumList(
-          getFilteredForums(currentFilter || 'hot', forums),
+          getFilteredForums(currentFilter || 'all', forums),
           currentFilter
         )
     )

+ 2 - 2
src/views/image_view.js

@@ -285,16 +285,16 @@ const renderImageCommentsSection = (imageKey, comments = [], returnTo = null) =>
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       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,
         textarea({
           id: "comment-text",
           name: "text",
-          required: true,
           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)
       )

+ 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 { template, i18n } = require('./main_views');
 const { renderUrl } = require('../backend/renderUrl');
+const { getConfig } = require('../configs/config-manager');
 
 const DEFAULT_HASH_ENC = "%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, '_');
@@ -78,7 +90,7 @@ const renderInhabitantCard = (user, filter, currentUserId) => {
       br(),
       span(`${i18n.bankingUserEngagementScore}: `),
       h2(strong(typeof user.karmaScore === 'number' ? user.karmaScore : 0)),
-      lastActivityBadge(user)
+      ...lastActivityBadge(user, isMe)
     ),
     div({ class: 'inhabitant-details' },
       h2(user.name || 'Anonymous'),
@@ -282,11 +294,7 @@ exports.inhabitantsProfileView = (payload, currentUserId) => {
           h2(name || 'Anonymous'),
           span(`${i18n.bankingUserEngagementScore}: `),
           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))
             ? form(
                 { 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 fs = require('fs');
 const { template, i18n } = require('./main_views');
@@ -13,6 +13,23 @@ const encodePubLink = (key) => {
   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 }) => {
   let pubs = [];
   let pubsValue = "false";
@@ -39,63 +56,33 @@ const invitesView = ({ invitesEnabled }) => {
   }
 
   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 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;
@@ -129,16 +116,43 @@ const invitesView = ({ invitesEnabled }) => {
           br(),
           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(),
-        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(),
-        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(),
-        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 moment = require("../server/node_modules/moment")
 const { config } = require("../server/SSB_server.js")
 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 FILTERS = [
@@ -220,7 +234,7 @@ const renderJobList = (jobs, filter, params = {}) => {
           { class: "job-card" },
           topbar ? topbar : 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,
           br(),
           safeText(job.description) ? renderCardFieldRich(`${i18n.jobDescription}:`, renderUrl(job.description)) : null,
@@ -292,9 +306,9 @@ const renderJobForm = (job = {}, mode = "create") => {
       br(),
       label(i18n.jobImage),
       br(),
-      input({ type: "file", name: "image", accept: "image/*" }),
+      input({ type: "file", name: "image" }),
       br(),
-      job.image ? img({ src: `/blob/${encodeURIComponent(job.image)}`, class: "existing-image" }) : null,
+      job.image ? renderMediaBlob(job.image) : null,
       br(),
       label(i18n.jobDescription),
       br(),
@@ -362,10 +376,15 @@ const renderCVList = (inhabitants) =>
           const isMe = String(user.id) === String(userId)
           return div(
             { 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(
               { class: "inhabitant-details" },
-              h2(user.name),
               user.description ? p(...renderUrl(user.description)) : null,
               p(a({ class: "user-link", href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
               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: "language", placeholder: i18n.filterLanguage, value: params.language || "" }),
               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(),
             renderCVList(jobsOrCVs)
@@ -472,9 +493,10 @@ const renderJobCommentsSection = (jobId, returnTo, comments = []) => {
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       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 }),
-        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(),
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
@@ -538,7 +560,7 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
         { class: "job-card" },
         topbar ? topbar : 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,
         br(),
         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),
           button({ type: "submit" }, i18n.legacyExportButton)
         ),
-        br,
+        br(),
         p(i18n.importDescription),
         form(
           { action: "/legacy/import", method: "POST", enctype: "multipart/form-data" },
           input({ type: "file", name: "uploadedFile", required: true }),
-          br,
+          br(),
           p(i18n.passwordImport),
           input({
             type: "password",
@@ -54,7 +54,7 @@ const legacyView = async () => {
             placeholder: i18n.importPasswordPlaceholder,
             minlength: 32
           }),
-          br,
+          br(),
           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 config = require("../server/ssb_config");
 const cooler = ssbClientGUI({ offline: config.offline });
+const sharedState = require('../configs/shared-state');
 
 let ssb, userId;
 
@@ -21,12 +22,12 @@ const getUserId = async () => {
   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 markdown = require("./markdown");
+const { sanitizeHtml } = require('../backend/sanitizeHtml');
 
-// set language
 const i18nBase = require("../client/assets/translations/i18n");
 let selectedLanguage = "en";
 let i18n = {};
@@ -38,7 +39,7 @@ exports.setLanguage = (language) => {
   Object.assign(i18n, newLang);
 };
 exports.i18n = i18n;
-exports.selectedLanguage = selectedLanguage;
+Object.defineProperty(exports, 'selectedLanguage', { get: () => selectedLanguage });
 
 // markdown
 const markdownUrl = "https://commonmark.org/help/";
@@ -73,6 +74,16 @@ const renderFooter = () => {
   const pkgName = pkg?.name || "@krakenslab/oasis";
   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(
     { class: "oasis-footer" },
     div(
@@ -85,8 +96,9 @@ const renderFooter = () => {
           alt: "Oasis"
         })
       ),
+      br(),
       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("["),
@@ -94,11 +106,21 @@ const renderFooter = () => {
       span("]"),
       span({ class: "oasis-footer-sep" }, " - "),
       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
       ),
       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" },
     input({
       type: "checkbox",
@@ -148,8 +172,9 @@ const navGroup = ({ id, emoji, title, defaultOpen = false }, ...items) =>
       title,
       span({ class: "oasis-nav-arrow" }, "▾")
     ),
-    ul({ class: "oasis-nav-list" }, ...items)
+    ul({ class: "oasis-nav-list" }, ...active)
   );
+};
 
 const renderPopularLink = () => {
   const popularMod = getConfig().modules.popularMod === "on";
@@ -655,6 +680,7 @@ const template = (titlePrefix, ...elements) => {
       title(titlePrefix, " | Oasis"),
       link({ rel: "stylesheet", href: "/assets/styles/style.css" }),
       themeLink,
+      link({ rel: "stylesheet", href: "/assets/styles/mobile.css", media: "(max-width: 768px)" }),
       link({ rel: "icon", href: "/assets/images/favicon.svg" }),
       meta({ charset: "utf-8" }),
       meta({ name: "description", content: i18n.oasisDescription }),
@@ -681,11 +707,15 @@ const template = (titlePrefix, ...elements) => {
           ),
           nav(
             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({
                 href: "/pm",
                 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(
         { class: "main-content" },
         div(
@@ -1282,7 +1324,7 @@ const post = ({ msg, aside = false, preview = false }) => {
                 (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 {
         articleElement = article(
             { class: "content" },
@@ -1444,17 +1486,17 @@ exports.editProfileView = ({ name, description }) =>
         },
         label(
           i18n.profileImage,
-          br,
+          br(),
           input({ type: "file", name: "image", accept: "image/*" })
         ),
-        br,br,
-        label(i18n.profileName, 
-        br,
+        br(),br(),
+        label(i18n.profileName,
+        br(),
         input({ name: "name", value: name })),
-        br,br,
+        br(),br(),
         label(
           i18n.profileDescription,
-          br,
+          br(),
           textarea(
             {
               autofocus: true,
@@ -1464,7 +1506,7 @@ exports.editProfileView = ({ name, description }) =>
             description
           )
         ),
-        br,
+        br(),
         button(
           {
             type: "submit",
@@ -1520,7 +1562,8 @@ exports.authorView = ({
   })();
 
   const bucket = lastActivityBucket || 'red';
-  const dotClass = bucket === "green" ? "green" : bucket === "orange" ? "orange" : "red";
+
+  const { lastActivityBadge } = require('./inhabitants_view');
 
   const prefix = section(
     { class: "message" },
@@ -1530,20 +1573,18 @@ exports.authorView = ({
         img({ class: "inhabitant-photo-details", src: avatarUrl }),
         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)),
       div({ class: "profile-metrics" },
         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(
       div(
         { class: "profile" },
@@ -1728,7 +1769,7 @@ exports.commentView = async (
     form(
       { action, method, enctype: "multipart/form-data" },
       i18n.blogSubject,
-      br,
+      br(),
       label(
         i18n.contentWarningLabel,
         input({
@@ -1739,9 +1780,9 @@ exports.commentView = async (
           placeholder: i18n.contentWarningPlaceholder
         })
       ),
-      br,
+      br(),
       label({ for: "text" }, i18n.blogMessage),
-      br,
+      br(),
 	textarea(
 	  {
 	    autofocus: true,
@@ -1753,14 +1794,14 @@ exports.commentView = async (
 	  },
 	  text ? text : null
 	),
-      br,
+      br(),
       label(
         { 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" }),
-      br,
-      br,
+      br(),
+      br(),
       button({ type: "submit" }, i18n.blogPublish)
     ),
     preview ? div({ class: "comment-preview" }, preview) : ""
@@ -1769,15 +1810,53 @@ exports.commentView = async (
 
 const renderMessage = (msg) => {
   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 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 }) => {
@@ -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) {
     return template(
       title,
@@ -1864,11 +1942,24 @@ exports.privateView = async (messagesInput, filter) => {
 
   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) {
-    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(/\/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>`)
@@ -1976,7 +2085,7 @@ exports.privateView = async (messagesInput, filter) => {
     const href = jobId ? hrefFor.job(jobId) : null
     return div(
       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}`),
       p(
         i18n.pmInhabitantWithId, ' ',
@@ -2000,7 +2109,7 @@ exports.privateView = async (messagesInput, filter) => {
     const href = projectId ? hrefFor.project(projectId) : null
     return div(
       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}`),
       p(
         i18n.pmInhabitantWithId, ' ',
@@ -2022,7 +2131,7 @@ exports.privateView = async (messagesInput, filter) => {
     const href = marketId ? hrefFor.market(marketId) : null
     return div(
       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}`),
       p(
         i18n.pmYourItem, ' ',
@@ -2043,7 +2152,7 @@ exports.privateView = async (messagesInput, filter) => {
     const href = projectId ? hrefFor.project(projectId) : null
     return div(
       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}`),
       p(
         i18n.pmInhabitantWithId, ' ',
@@ -2089,40 +2198,77 @@ exports.privateView = async (messagesInput, filter) => {
         ])
       ),
       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',
           "}"
         ),
-        br,
-        br,
+        br(),
+        br(),
         button({ type: "submit" }, i18n.submit)
       )
     ),
@@ -2233,7 +2379,7 @@ exports.publishView = (preview, text, contentWarning) => {
               text || ""
             ),
             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(),
             input({ type: "file", id: "blob", name: "blob" }),
             br(), br(),
@@ -2313,12 +2459,29 @@ const markdownMentionsToHtml = (markdownText) => {
   const escaped = escapeHtml(String(markdownText || ""))
   const withBr = escaped.replace(/\r\n|\r|\n/g, "<br>")
 
+  const unescapeBlob = (b) => b.replace(/&amp;/g, '&')
+
   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,
     (_m, label, feed) => {
       const href = authorHref(feed)
@@ -2361,8 +2524,18 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
       const nameText = nameRaw.startsWith("@") ? nameRaw : `@${nameRaw}`
 
       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 avatarUrl =
@@ -2373,7 +2546,7 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
       return div(
         { class: "mention-card" },
         a({ href: authorHref(feed) }, img({ src: avatarUrl, class: "avatar-profile" })),
-        br,
+        br(),
         div(
           { class: "mention-name" },
           span({ class: "label" }, `${i18n.mentionsName}: `),
@@ -2381,11 +2554,10 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
         ),
         div(
           { class: "mention-relationship" },
-          span({ class: "label" }, `${i18n.mentionsRelationship}:`),
-          span({ class: "relationship" }, relText),
+          span({ class: "label" }, `${i18n.mentionsRelationship}: `),
+          relationshipBadge,
           div(
             { class: "mention-relationship-details" },
-            span({ class: "emoji" }, emoji),
             span(
               { class: "mentions-listing" },
               a({ class: "user-link", href: authorHref(feed) }, `@${stripAt(feed)}`)
@@ -2623,7 +2795,7 @@ exports.subtopicView = async (
     form(
       { action: subtopicForm, method: "post", enctype: "multipart/form-data" },
       i18n.blogSubject,
-      br, 
+      br(),
       label(
         i18n.contentWarningLabel,
         input({
@@ -2634,9 +2806,9 @@ exports.subtopicView = async (
           placeholder: i18n.contentWarningPlaceholder,
         })
       ),
-      br,
+      br(),
       label({ for: "text" }, i18n.blogMessage),
-      br,
+      br(),
       textarea(
         {
           autofocus: true,
@@ -2648,14 +2820,14 @@ exports.subtopicView = async (
         },
         text ? text : markdownMention
       ),
-      br,
+      br(),
       label(
         { 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" }),
-      br,
-      br,
+      br(),
+      br(),
       button({ type: "submit" }, i18n.blogPublish)
     ),
     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 moment = require("../server/node_modules/moment")
 const { config } = require("../server/SSB_server.js")
 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 parseBidEntry = (raw) => {
@@ -115,9 +129,10 @@ const renderMarketCommentsSection = (itemId, returnTo, comments = []) => {
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       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 }),
-        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(),
         button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
@@ -410,7 +425,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
               br(),
               label(i18n.marketCreateFormImageLabel),
               br(),
-              input({ type: "file", name: "image", id: "image", accept: "image/*" }),
+              input({ type: "file", name: "image", id: "image" }),
               br(),
               br(),
               label(i18n.marketItemStatus),
@@ -541,7 +556,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
                       br(),
                       div(
                         { 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)),
                       item.tags && item.tags.filter(Boolean).length
@@ -656,7 +671,7 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
         br(),
         div(
           { 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}:`, ""),
         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(
     i18n.modules,
     section(header),
     section(
+      h2(i18n.modulesPresetTitle || "Common Configurations"),
+      presetButtons,
       form(
         { action: "/save-modules", method: "post" },
         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 { renderUrl } = require('../backend/renderUrl');
 const opinionCategories = require('../backend/opinion_categories');
+const { sanitizeHtml } = require('../backend/sanitizeHtml');
 
 const seenDocumentTitles = new Set();
 
@@ -15,7 +16,7 @@ const renderContentHtml = (content, key) => {
           form({ method: "GET", action: `/bookmarks/${encodeURIComponent(key)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           ),
-          br,
+          br(),
           h2(content.url ? div({ class: 'card-field' },
             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)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           ),
-          br,
+          br(),
           content.title ? div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.imageTitleLabel + ':'),
             span({ class: 'card-value' }, content.title)
@@ -52,7 +53,7 @@ const renderContentHtml = (content, key) => {
             span({ class: 'card-label' }, i18n.trendingCategory + ':'),
             span({ class: 'card-value' }, i18n.meme)
           ) : "",
-          br,
+          br(),
           div({ class: 'card-field' },
             img({ src: `/blob/${encodeURIComponent(content.url)}`, class: 'feed-image' })
           )
@@ -64,7 +65,7 @@ const renderContentHtml = (content, key) => {
           form({ method: "GET", action: `/videos/${encodeURIComponent(key)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           ),
-          br,
+          br(),
           content.title ? div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.videoTitleLabel + ':'),
             span({ class: 'card-value' }, content.title)
@@ -92,7 +93,7 @@ const renderContentHtml = (content, key) => {
           form({ method: "GET", action: `/audios/${encodeURIComponent(key)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           ),
-          br,
+          br(),
           content.title ? div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.audioTitleLabel + ':'),
             span({ class: 'card-value' }, content.title)
@@ -122,7 +123,7 @@ const renderContentHtml = (content, key) => {
           form({ method: "GET", action: `/documents/${encodeURIComponent(key)}` },
             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)
@@ -142,7 +143,7 @@ const renderContentHtml = (content, key) => {
     case 'feed':
       return div({ class: 'opinion-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' },
             span({ class: 'card-label' }, `${i18n.tribeFeedRefeeds}: `),
             span({ class: 'card-value' }, content.refeeds)
@@ -183,7 +184,7 @@ const renderContentHtml = (content, key) => {
           form({ method: "GET", action: `/transfers/${encodeURIComponent(key)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
           ),
-          br,
+          br(),
           div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.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-field' },
             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;
       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 baseFilters = ['RECENT', 'ALL', 'MINE', 'TOP'];
@@ -256,7 +264,23 @@ exports.opinionsView = (items, filter) => {
           span({ class: 'date-link' }, `${created} ${i18n.performed} `),
           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' },
           allCats.map(cat => {
             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) => {
   if (!cands || !cands.length) return null;
   const leader = pickLeader(cands || []);
+  if (!leader) return null;
   const methodKey = String(leader.method || '').toUpperCase();
   const methodLabel = String(i18n[`parliamentMethod${methodKey}`] || methodKey).toUpperCase();
   const votes = String(leader.votes || 0);

+ 55 - 32
src/views/peers_view.js

@@ -1,5 +1,5 @@
 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 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 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 { 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) => {
     let usersTotal = 0;
     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;
   };
 
-  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(
     i18n.peers,
@@ -60,15 +69,29 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
         p(i18n.peerConnectionsIntro)
       ),
       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" },
         h2(`${i18n.online} (${onlineCount})`),
-        onlinePeers.length > 0 ? ul(onlinePeers.map(renderPeer)) : p(i18n.noConnections),
+        renderPeerTable(dedupOnline),
         hr(),
         h2(`${i18n.discovered} (${discoveredCount})`),
-        discoveredPeers.length > 0 ? ul(discoveredPeers.map(renderPeer)) : p(i18n.noDiscovered),
+        renderPeerTable(dedupDiscovered),
         hr(),
         h2(`${i18n.unknown} (${unknownCount})`),
-        unknownPeers.length > 0 ? ul(unknownPeers.map(renderPeer)) : p(i18n.noDiscovered),
+        renderPeerTable(dedupUnknown),
         p(i18n.connectionActionIntro)
       )
     )

+ 5 - 5
src/views/pixelia_view.js

@@ -40,13 +40,13 @@ exports.pixeliaView = (pixelArt, errorMessage) => {
     ),
     section(
       div({ class: "pixelia-form-wrap" },
-        form({ method: "POST", action: "/pixelia/paint"}, [
+        form({ method: "POST", action: "/pixelia/paint"},
           label({ for: "x" }, "X (1-50):"),
           input({ type: "number", id: "x", name: "x", min: 1, max: gridWidth, required: true }),
-          br,br,
+          br(),br(),
           label({ for: "y" }, "Y (1-200):"),
           input({ type: "number", id: "y", name: "y", min: 1, max: gridHeight, required: true }),
-          br,br,
+          br(),br(),
           label({ for: "color" }, i18n.colorLabel),
           select({ id: "color", name: "color", required: true },
             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: "#ff6347", style: "background-color:#ff6347;" }, "Tomato")
           ),
-          br,br,
+          br(),br(),
           button({ type: "submit" }, i18n.paintButton)
-        ])
+        )
       ),
       errorMessage ? div({ class: "error-message" }, errorMessage) : null,
       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 moment = require("../server/node_modules/moment")
 const { config } = require("../server/SSB_server.js")
 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 FILTERS = [
@@ -480,7 +494,7 @@ const renderProjectList = (projects, filter) => {
           { class: `project-card ${statusClass}` },
           topbar ? topbar : null,
           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,
           renderCardField(i18n.projectStatus + ":", i18n["projectStatus" + statusUpper] || statusUpper),
           renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100),
@@ -612,9 +626,9 @@ const renderProjectForm = (project, mode) => {
       br(),
       label(i18n.projectImage),
       br(),
-      input({ type: "file", name: "image", accept: "image/*" }),
+      input({ type: "file", name: "image" }),
       br(),
-      pr.image ? img({ src: `/blob/${encodeURIComponent(pr.image)}`, class: "existing-image" }) : null,
+      pr.image ? renderMediaBlob(pr.image) : null,
       br(),
       label(i18n.projectGoal),
       br(),
@@ -716,7 +730,7 @@ exports.singleProjectView = async (project, filter, comments) => {
         topbar ? topbar : null,
         !isAuthor && safeArr(pr.followers).includes(userId) ? p({ class: "hint" }, i18n.projectYouFollowHint) : null,
         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,
         renderCardField(i18n.projectStatus + ":", i18n["projectStatus" + statusUpper] || statusUpper),
         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} `),
           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 { config } = require("../server/SSB_server.js");
 const moment = require("../server/node_modules/moment");
 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 normU = (v) => String(v || "").trim().toUpperCase();
@@ -44,7 +58,6 @@ const renderStackedTextField = (lbl, val) =>
     ? div(
         { class: "card-field card-field-stacked" },
         span({ class: "card-label" }, lbl),
-        br(),
         span({ class: "card-value" }, ...renderUrl(String(val)))
       )
     : null;
@@ -187,16 +200,17 @@ const renderReportCommentsSection = (reportId, comments = []) => {
         {
           method: "POST",
           action: `/reports/${encodeURIComponent(reportId)}/comments`,
-          class: "comment-form"
+          class: "comment-form",
+          enctype: "multipart/form-data"
         },
         textarea({
           id: "comment-text",
           name: "text",
-          required: true,
           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)
       )
@@ -358,7 +372,7 @@ const renderReportCard = (report, userId, currentFilter = "all") => {
     renderCardField(i18n.reportsSeverity + ":", severity),
     renderCardField(i18n.reportsCategory + ":", report.category),
     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,
     details ? details : null,
     br(),
@@ -624,7 +638,7 @@ exports.singleReportView = async (report, filter, comments = []) => {
         renderCardField(i18n.reportsSeverity + ":", severity),
         renderCardField(i18n.reportsCategory + ":", report.category),
         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,
         details ? details : null,
         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 { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { renderUrl } = require('../backend/renderUrl');
+const { sanitizeHtml } = require('../backend/sanitizeHtml');
 
 const decodeMaybe = (s) => {
   try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); }
@@ -96,7 +97,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
       case 'post':
         return div({ class: 'search-post' },
           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':
         return div({ class: 'search-about' },
@@ -109,7 +110,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
         const htmlText = rawText ? rewriteHashtagLinks(renderTextWithStyles(rawText)) : '';
         const refeedsNum = Number(content.refeeds || 0) || 0;
         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
             ? h2({ class: 'card-field' },
                 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 }))
           : [];
         return div({ class: 'search-vote' },
-          br,
+          br(),
           content.question ? div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.voteQuestionLabel + ':' ),
             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-value' }, content.totalVotes !== undefined ? content.totalVotes : '0')
           ),
-          br,
+          br(),
           votesList.length > 0 ? div({ class: 'card-votes' },
             table(
               tr(...votesList.map(({ option }) => th(i18n[option] || option))),
@@ -168,9 +169,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
       return div({ class: 'search-tribe' },
         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' }),
-        br,
+        br(),
         content.description ? content.description : null,
-        br,br,
+        br(),br(),
         div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
           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}`),
@@ -194,9 +195,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
         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.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' }),
-          br,
+          br(),
           content.tags && content.tags.length
             ? div({ class: 'card-tags' }, content.tags.map(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.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,
-          br,
+          br(),
           img({ src: `/blob/${encodeURIComponent(content.url)}` }),
-          br,
+          br(),
           content.tags && content.tags.length
             ? div({ class: 'card-tags' }, content.tags.map(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' },
           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,
-          br,
+          br(),
           videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType || 'video/mp4', width: '640', height: '360' }),
-          br,
+          br(),
           content.tags && content.tags.length
             ? div({ class: 'card-tags' }, content.tags.map(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':
         return div({ class: 'search-document' },
           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,
-          br,
+          br(),
           div({
             id: `pdf-container-${content.key || content.url}`,
             class: 'pdf-viewer-container',
             'data-pdf-url': `/blob/${encodeURIComponent(content.url)}`
           }),
-          br,
+          br(),
           content.tags && content.tags.length
             ? div({ class: 'card-tags' }, content.tags.map(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.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,
-          br,
+          br(),
           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.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,
@@ -304,7 +305,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
       case '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.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.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,
@@ -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.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,
-          br,
+          br(),
           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,
           content.tags && content.tags.length
             ? 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.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,
-          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.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
             ? h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ':'), span({ class: 'card-value' }, content.confirmedBy.length))
             : 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
         );
       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' }, [
             detailsButton,
-            br,
+            br(),
             contentHtml,
             author
               ? 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: "Clear-SNH", selected: theme === "Clear-SNH" ? true : undefined }, "Clear-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) => {
@@ -88,7 +89,10 @@ const settingsView = ({ version, aiPrompt }) => {
             languageOption("English", "en"),
             languageOption("Español", "es"),
             languageOption("Français", "fr"),
-            languageOption("Euskara", "eu")
+            languageOption("Euskara", "eu"),
+            languageOption("Deutsch", "de"),
+            languageOption("Italiano", "it"),
+            languageOption("Português", "pt")
           ]),
           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.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 },
           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;' },

+ 2 - 2
src/views/task_view.js

@@ -148,16 +148,16 @@ const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all")
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       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 }),
         textarea({
           id: "comment-text",
           name: "text",
-          required: true,
           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)
       )

+ 3 - 2
src/views/transfer_view.js

@@ -61,9 +61,10 @@ const renderConfirmationsBar = (confirmedCount, required) => {
   const cc = Math.max(0, Number(confirmedCount || 0))
   return div(
     { class: "confirmations-block" },
-      { class: "card-field" },
+    div({ class: "card-field" },
       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 })
   )
 }

+ 31 - 11
src/views/trending_view.js

@@ -4,6 +4,7 @@ const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
 const opinionCategories = require('../backend/opinion_categories');
+const { sanitizeHtml } = require('../backend/sanitizeHtml');
 
 const userId = config.keys.id;
 
@@ -36,7 +37,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         form({ method: "GET", action: `/bookmarks/${encodeURIComponent(item.key)}` },
           button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
         ),
-        br,
+        br(),
         url ? h2(p(a({ href: url, target: '_blank', class: "bookmark-url" }, url))) : "",
         lastVisit
           ? div(
@@ -55,7 +56,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         form({ method: "GET", action: `/images/${encodeURIComponent(item.key)}` },
           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)) : "",
         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)) : "",
@@ -69,7 +70,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         form({ method: "GET", action: `/audios/${encodeURIComponent(item.key)}` },
           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)) : "",
         description ? [span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"), p(...renderUrl(description))] : null,
         url
@@ -84,10 +85,10 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         form({ method: "GET", action: `/videos/${encodeURIComponent(item.key)}` },
           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)) : "",
         description ? [span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"), p(...renderUrl(description))] : null,
-        br,
+        br(),
         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' }, p(i18n.videoNoFile))
@@ -104,7 +105,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         form({ method: "GET", action: `/documents/${encodeURIComponent(item.key)}` },
           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)) : "",
         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)}` })
@@ -114,7 +115,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
     const { text, refeeds } = c;
     contentHtml = div({ class: 'trending-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))
       )
     );
@@ -144,7 +145,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         form({ method: "GET", action: `/transfers/${encodeURIComponent(item.key)}` },
           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.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)),
@@ -160,7 +161,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
         div(
           { class: 'card-field' },
           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} `),
       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(
       { class: 'voting-buttons' },
       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.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 cards = filteredItems

Файловите разлики са ограничени, защото са твърде много
+ 1144 - 216
src/views/tribes_view.js


+ 3 - 2
src/views/video_view.js

@@ -11,6 +11,7 @@ const {
   video: videoHyperaxe,
   span,
   textarea,
+  label,
   select,
   option
 } = require("../server/node_modules/hyperaxe");
@@ -128,16 +129,16 @@ const renderVideoCommentsSection = (videoId, comments = [], returnTo = null) =>
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       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,
         textarea({
           id: "comment-text",
           name: "text",
-          required: true,
           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)
       )

+ 2 - 2
src/views/vote_view.js

@@ -251,16 +251,16 @@ const renderCommentsSection = (voteId, comments, activeFilter) => {
       { class: "comment-form-wrapper" },
       h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
       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 }),
         textarea({
           id: "comment-text",
           name: "text",
-          required: true,
           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)
       )