瀏覽代碼

Oasis release 0.4.2

psy 1 周之前
父節點
當前提交
b586142b2d

+ 15 - 0
src/backend/backend.js

@@ -209,6 +209,7 @@ const activityModel = require('../models/activity_model')({ cooler, isPublic: co
 const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: config.public });
 const marketModel = require('../models/market_model')({ cooler, isPublic: config.public });
 const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public });
+const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public });
 
 // starting warmup
 about._startNameWarmup();
@@ -409,6 +410,7 @@ const { trendingView } = require("../views/trending_view");
 const { marketView, singleMarketView } = require("../views/market_view");
 const { aiView } = require("../views/AI_view");
 const { forumView, singleForumView } = require("../views/forum_view");
+const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view");
 
 let sharp;
 
@@ -563,6 +565,19 @@ router
     }
     const pixelArt = await pixeliaModel.listPixels();
     ctx.body = pixeliaView(pixelArt);
+  })
+   // blockexplorer
+  .get('/blockexplorer', async (ctx) => {
+    const userId = SSBconfig.config.keys.id; 
+    const query = ctx.query;
+    const filter = query.filter || 'recent';
+    const blockchainData = await blockchainModel.listBlockchain(filter, userId);
+    ctx.body = renderBlockchainView(blockchainData, filter, userId);
+  })
+  .get('/blockexplorer/block/:id', async (ctx) => {
+    const blockId = ctx.params.id;
+    const block = await blockchainModel.getBlockById(blockId);
+    ctx.body = renderSingleBlockView(block);
   })
   .get("/public/latest", async (ctx) => {
     const latestMod = ctx.cookies.get("latestMod") || 'on';

+ 216 - 0
src/client/assets/styles/style.css

@@ -1865,3 +1865,219 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .forum-comment.level-2 { margin-left: 40px; }
 .forum-comment.level-3 { margin-left: 60px; }
 .forum-comment.level-4 { margin-left: 80px; }
+
+/*blockexplorer*/
+.blockchain-view {
+  font-family: 'Inter', 'Roboto', 'Segoe UI', Arial, sans-serif;
+  background-color: #191b20;
+  padding: 32px 16px;
+  border-radius: 16px;
+  box-shadow: 0 8px 36px rgba(0,0,0,0.16);
+  color: #e7e7e7;
+  max-width: 900px;
+  margin: 0 auto;
+}
+
+.block {
+  background: #23242a;
+  border-radius: 16px;
+  box-shadow: 0 2px 12px rgba(32,35,42,0.11);
+  padding: 22px 24px;
+  margin-bottom: 26px;
+  transition: box-shadow 0.22s;
+  position: relative;
+}
+
+.block-buttons {
+  display: flex;
+  justify-content: right;
+  align-items: center; 
+  gap: 12px;
+}
+
+.button-container {
+  display: flex; 
+  gap: 12px; 
+  justify-content: right;
+  align-items: center;
+}
+
+.block:hover {
+  box-shadow: 0 8px 32px rgba(35,40,50,0.18);
+}
+
+.block-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.blockchain-card-label {
+  color: #ffa300;
+  font-weight: bold;
+  letter-spacing: 1.5px;
+  line-height: 1.2;
+  margin-bottom: 0;
+}
+
+.block-row--content {
+  margin: 10px 0 0 0;
+}
+
+.block-info-table td {
+  padding: 12px 15px;
+  font-size: 14px;
+  border: 1px solid #444;
+}
+
+.block-author {
+  font-weight: 600;
+  font-size: 14px;
+  color: #ffb36a;
+  background: rgba(255,179,106,0.08);
+  padding: 3px 10px;
+  border-radius: 8px;
+  text-decoration: none;
+  transition: color 0.12s;
+}
+
+.block-author:hover {
+  color: #ffe0ba;
+}
+
+.block-timestamp {
+  color: #f5c242;
+  font-size: 12px;
+  margin-right: 6px;
+}
+
+.block-content-preview,
+.block-content {
+  background: #222326;
+  border-radius: 10px;
+  padding: 14px 18px;
+  font-size: 14px;
+  line-height: 1.7;
+  color: #f5c242; 
+  font-family: 'JetBrains Mono', 'Courier New', monospace;
+  overflow-x: auto;
+}
+
+.block-content-preview pre {
+  margin: 0;
+}
+
+.block-info-table {
+  width: 100%;
+  border-collapse: collapse;
+  margin: 15px 0;
+}
+
+.block-info-table td {
+  padding: 10px;
+  border: 1px solid #444;
+  font-size: 14px;
+}
+
+.json-content {
+  white-space: pre-wrap;
+  word-break: break-word;
+  color: #f5c242; 
+}
+
+.block-row--details {
+  margin-top: 14px;
+  position: relative;
+}
+
+.block-url {
+  color: #ffb36a;
+  font-size: 14px;
+  text-decoration: none;
+  font-weight: 500;
+  transition: color 0.13s, border-color 0.13s;
+}
+
+.block-row--details .block-url {
+  position: absolute;
+  top: 0;
+  right: 0;
+  background: #1f2023;
+  padding: 6px 12px;
+  border-radius: 8px;
+  font-size: 14px;
+  font-weight: 500;
+  border: none;
+  transition: background 0.12s, color 0.12s;
+}
+
+.block-row--details .block-url:hover {
+  background: #292b36;
+  color: #ffb36a;
+}
+
+.btn-singleview {
+  background: #1e1f23;
+  border: none;
+  border-radius: 50%;
+  padding: 7px 12px;
+  cursor: pointer;
+  font-size: 1.14em;
+  color: #c6c6c6;
+  box-shadow: none;
+  transition: background 0.14s, color 0.14s;
+  text-decoration: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.btn-singleview:hover {
+  background: #2d2e34;
+  color: #f8b92c;
+}
+
+.block-row--back {
+  margin-top: 15px;
+}
+
+.block-content-label {
+  color: #f5c242;
+  font-size: 1.02em;
+  margin-bottom: 8px;
+  font-weight: 600;
+}
+
+.btn-back {
+  background: #21232b;
+  color: #d7d7d7;
+  padding: 7px 22px;
+  border-radius: 8px;
+  text-decoration: none;
+  border: none;
+  font-size: 1.01rem;
+  font-weight: 500;
+  margin-top: 12px;
+  transition: background 0.12s, color 0.13s;
+  display: inline-block;
+}
+
+.btn-back:hover {
+  background: #292b36;
+  color: #ffb36a;
+}
+
+@media (max-width: 700px) {
+  .blockchain-view {
+    padding: 9vw 2vw;
+  }
+
+  .block, .block-single, .block--single {
+    padding: 15px 5vw;
+  }
+
+  .mode-buttons-row {
+    flex-direction: column;
+    gap: 18px;
+  }
+}

+ 18 - 0
src/client/assets/translations/oasis_en.js

@@ -1281,6 +1281,24 @@ module.exports = {
     pmSubjectHint:        "Enter the subject of the message",
     pmText:               "Message",
     pmFile:               "Attached file",
+    //blockchain visualization
+    blockchain: 'BlockExplorer',
+    blockchainTitle: 'BlockExplorer',
+    blockchainDescription: 'Explore and visualize the blocks in the blockchain.',
+    blockchainNoBlocks: 'No blocks found in the blockchain.',
+    blockchainBlockID: 'Block ID',
+    blockchainBlockAuthor: 'Author',
+    blockchainBlockType: 'Type',
+    blockchainBlockTimestamp: 'Timestamp',
+    blockchainBlockContent: 'Block',
+    blockchainBlockURL: 'URL:',
+    blockchainContent: 'Block',
+    blockchainContentPreview: 'Preview of the block content',
+    blockchainDetails: 'View block details',
+    blockchainBlockInfo: 'Block Information',
+    blockchainBlockDetails: 'Details of the selected block',
+    blockchainBack: 'Back to Blockexplorer',
+    visitContent: "Visit Content",
     //stats
     statsTitle: 'Statistics',
     statistics: "Statistics",

+ 19 - 1
src/client/assets/translations/oasis_es.js

@@ -1272,7 +1272,7 @@ module.exports = {
     publishBlog: "Publicar Blog",
     privateMessage:       "MP",
     pmSendTitle:          "Mensajes Privados",
-    pmSend:               "¡Enviar!",
+    pmSend:               "Enviar!",
     pmDescription:        "Usa este formulario para enviar un mensaje encriptado a otros habitantes.",
     pmRecipients:         "Destinatarios",
     pmRecipientsHint:     "Introduce los IDs de Oasis separados por comas",
@@ -1280,6 +1280,24 @@ module.exports = {
     pmSubjectHint:        "Introduce el asunto del mensaje",
     pmText:               "Mensaje",
     pmFile:               "Archivo adjunto",
+    //blockchain visor
+    blockchain: "BlockExplorer",
+    blockchainTitle: 'BlockExplorer',
+    blockchainDescription: 'Explora y visualiza los bloques en la blockchain.',
+    blockchainNoBlocks: 'No se encontraron bloques en la blockchain.',
+    blockchainBlockID: 'ID del bloque',
+    blockchainBlockAuthor: 'Autoría',
+    blockchainBlockType: 'Tipo',
+    blockchainBlockTimestamp: 'Fecha y hora',
+    blockchainBlockContent: 'Bloque',
+    blockchainBlockURL: 'URL:',
+    blockchainContent: 'Bloque',
+    blockchainContentPreview: 'Vista previa del contenido del bloque',
+    blockchainDetails: 'Ver detalles del bloque',
+    blockchainBlockInfo: 'Información del bloque',
+    blockchainBlockDetails: 'Detalles del bloque seleccionado', 
+    blockchainBack: 'Volver al explorador de bloques',
+    visitContent: 'Visitar Contenido',
     //stats
     statsTitle: 'Estadísticas',
     statistics: "Estadísticas",

+ 18 - 0
src/client/assets/translations/oasis_eu.js

@@ -1281,6 +1281,24 @@ module.exports = {
     pmSubjectHint:        "Sartu mezuaren gaia",
     pmText:               "Mezua",
     pmFile:               "Gehitutako fitxategia",
+    //blockchain visor
+    blockchain: "BlockExplorer",
+    blockchainTitle: 'BlockExplorer',
+    blockchainDescription: 'Blokeak aztertu eta ikusgaitu blockchain-ean.',
+    blockchainNoBlocks: 'Ez da blokeik aurkitu blockchain-ean.',
+    blockchainBlockID: 'Bloke ID-a',
+    blockchainBlockAuthor: 'Egilea',
+    blockchainBlockType: 'Mota',
+    blockchainBlockTimestamp: 'Tzeitza',
+    blockchainBlockContent: 'Bloke',
+    blockchainBlockURL: 'URL:',
+    blockchainContent: 'Bloke',
+    blockchainContentPreview: 'Bloke edukia aurrebista',
+    blockchainDetails: 'Ikusi blokearen xehetasunak',
+    blockchainBlockInfo: 'Blokearen informazioa',
+    blockchainBlockDetails: 'Hautatutako blokearen xehetasunak',
+    blockchainBack: 'Itzuli blokearen azterkira',
+    visitContent: 'Bisitatu Edukia',
     //stats
     statsTitle: 'Estatistikak',
     statistics: "Estatistikak",

+ 102 - 0
src/models/blockchain_model.js

@@ -0,0 +1,102 @@
+const pull = require('../server/node_modules/pull-stream');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
+
+  const hasBlob = async (ssbClient, url) => {
+    return new Promise((resolve) => {
+      ssbClient.blobs.has(url, (err, has) => {
+        resolve(!err && has);
+      });
+    });
+  };
+
+  return {
+    async listBlockchain(filter = 'all') {
+      const ssbClient = await openSsb();
+
+      const results = await new Promise((resolve, reject) => {
+        pull(
+          ssbClient.createLogStream({ reverse: true, limit: 1000 }),
+          pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+        );
+      });
+
+      const tombstoned = new Set();
+      const replaces = new Map();
+      const blocks = new Map();
+
+      for (const msg of results) {
+        const k = msg.key;
+        const c = msg.value?.content;
+        const author = msg.value?.author;
+        if (!c?.type) continue;
+        if (c.type === 'tombstone' && c.target) {
+          tombstoned.add(c.target);
+          continue;
+        }
+        if (c.replaces) replaces.set(c.replaces, k);
+        blocks.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
+      }
+
+      for (const oldId of replaces.keys()) blocks.delete(oldId);
+      for (const t of tombstoned) blocks.delete(t);
+
+      const blockData = await Promise.all(
+        Array.from(blocks.values()).map(async (block) => {
+          if (block.type === 'document') {
+            const url = block.content.url;
+            const validBlob = await hasBlob(ssbClient, url);
+            if (!validBlob) return null;
+          }
+          return block;
+        })
+      );
+
+      if (filter === 'RECENT') {
+        const now = Date.now();
+        return blockData.filter(block => now - block.ts <= 24 * 60 * 60 * 1000);
+      }
+
+      if (filter === 'MINE') {
+        const userId = SSBconfig.config.keys.id;
+        return blockData.filter(block => block.author === userId);
+      }
+
+      return blockData.filter(Boolean);
+    },
+
+    async getBlockById(id) {
+      const ssbClient = await openSsb();
+      return await new Promise((resolve, reject) => {
+        pull(
+          ssbClient.createLogStream({ reverse: true, limit: 1000 }),
+          pull.find((msg) => msg.key === id, async (err, msg) => {
+            if (err || !msg) return resolve(null);
+            const c = msg.value?.content;
+            if (!c?.type) return resolve(null);
+            if (c.type === 'document') {
+              const url = c.url;
+              const validBlob = await hasBlob(ssbClient, url);
+              if (!validBlob) return resolve(null);
+            }
+
+            resolve({
+              id: msg.key,
+              author: msg.value?.author,
+              ts: msg.value?.timestamp,
+              type: c.type,
+              content: c
+            });
+          })
+        );
+      });
+    }
+  };
+};
+

+ 39 - 21
src/models/opinions_model.js

@@ -6,6 +6,14 @@ module.exports = ({ cooler }) => {
     if (!ssb) ssb = await cooler.open();
     return ssb;
   };
+  
+  const hasBlob = async (ssbClient, url) => {
+    return new Promise(resolve => {
+      ssbClient.blobs.has(url, (err, has) => {
+        resolve(!err && has);
+      });
+    });
+  };
 
   const categories = [
     "interesting", "necessary", "funny", "disgusting", "sensible",
@@ -17,7 +25,7 @@ module.exports = ({ cooler }) => {
     'feed', 'image', 'audio', 'video', 'document'
   ];
 
-  const getPreview = (c) => {
+  const getPreview = c => {
     if (c.type === 'bookmark' && c.bookmark) return `🔖 ${c.bookmark}`;
     return c.text || c.description || c.title || '';
   };
@@ -26,24 +34,20 @@ module.exports = ({ cooler }) => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
     if (!categories.includes(category)) throw new Error("Invalid voting category.");
-
     const msg = await new Promise((resolve, reject) =>
       ssbClient.get(contentId, (err, value) => err ? reject(err) : resolve(value))
     );
-
     if (!msg || !msg.content) throw new Error("Opinion not found.");
-	const type = msg.content.type;
-	if (!validTypes.includes(type) || ['task', 'event', 'report'].includes(type)) {
-	  throw new Error("Voting not allowed on this content type.");
-	}
+    const type = msg.content.type;
+    if (!validTypes.includes(type) || ['task', 'event', 'report'].includes(type)) {
+      throw new Error("Voting not allowed on this content type.");
+    }
     if (msg.content.opinions_inhabitants?.includes(userId)) throw new Error("Already voted.");
-
     const tombstone = {
       type: 'tombstone',
       target: contentId,
       deletedAt: new Date().toISOString()
     };
-
     const updated = {
       ...msg.content,
       opinions: {
@@ -54,11 +58,9 @@ module.exports = ({ cooler }) => {
       updatedAt: new Date().toISOString(),
       replaces: contentId
     };
-
     await new Promise((resolve, reject) =>
-      ssbClient.publish(tombstone, (err) => err ? reject(err) : resolve())
+      ssbClient.publish(tombstone, err => err ? reject(err) : resolve())
     );
-
     return new Promise((resolve, reject) =>
       ssbClient.publish(updated, (err, result) => err ? reject(err) : resolve(result))
     );
@@ -67,14 +69,12 @@ module.exports = ({ cooler }) => {
   const listOpinions = async (filter = 'ALL', category = '') => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
-
     const messages = await new Promise((res, rej) => {
       pull(
         ssbClient.createLogStream(),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
     });
-
     const tombstoned = new Set();
     const replaces = new Map();
     const byId = new Map();
@@ -87,16 +87,13 @@ module.exports = ({ cooler }) => {
         tombstoned.add(c.target);
         continue;
       }
-       if (
-	  c.opinions &&
-	  !tombstoned.has(key) &&
-	  !['task', 'event', 'report'].includes(c.type)
-	) {
+      if (c.opinions && !tombstoned.has(key) && !['task', 'event', 'report'].includes(c.type)) {
         if (c.replaces) replaces.set(c.replaces, key);
         byId.set(key, {
           key,
           value: {
             ...msg.value,
+            content: c,
             preview: getPreview(c)
           }
         });
@@ -108,6 +105,23 @@ module.exports = ({ cooler }) => {
     }
 
     let filtered = Array.from(byId.values());
+    const blobTypes = ['document', 'image', 'audio', 'video'];
+    const blobCheckCache = new Map();
+
+    filtered = await Promise.all(
+      filtered.map(async m => {
+        const c = m.value.content;
+        if (blobTypes.includes(c.type) && c.url) {
+          if (!blobCheckCache.has(c.url)) {
+            const valid = await hasBlob(ssbClient, c.url);
+            blobCheckCache.set(c.url, valid);
+          }
+          if (!blobCheckCache.get(c.url)) return null;
+        }
+        return m;
+      })
+    );
+    filtered = filtered.filter(Boolean);
 
     if (filter === 'MINE') {
       filtered = filtered.filter(m => m.value.author === userId);
@@ -130,10 +144,14 @@ module.exports = ({ cooler }) => {
     return filtered;
   };
 
-  const getMessageById = async (id) => {
+  const getMessageById = async id => {
     const ssbClient = await openSsb();
     return new Promise((resolve, reject) =>
-      ssbClient.get(id, (err, msg) => err ? reject(new Error("Error fetching opinion: " + err)) : (!msg?.content ? reject(new Error("Opinion not found")) : resolve(msg)))
+      ssbClient.get(id, (err, msg) =>
+        err ? reject(new Error("Error fetching opinion: " + err)) :
+        !msg?.content ? reject(new Error("Opinion not found")) :
+        resolve(msg)
+      )
     );
   };
 

+ 33 - 27
src/models/trending_model.js

@@ -7,6 +7,14 @@ module.exports = ({ cooler }) => {
     return ssb;
   };
 
+  const hasBlob = async (ssbClient, url) => {
+    return new Promise(resolve => {
+      ssbClient.blobs.has(url, (err, has) => {
+        resolve(!err && has);
+      });
+    });
+  };
+
   const types = [
     'bookmark', 'votes', 'feed',
     'image', 'audio', 'video', 'document', 'transfer'
@@ -20,7 +28,6 @@ module.exports = ({ cooler }) => {
   const listTrending = async (filter = 'ALL') => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
-
     const messages = await new Promise((res, rej) => {
       pull(
         ssbClient.createLogStream(),
@@ -40,21 +47,30 @@ module.exports = ({ cooler }) => {
         tombstoned.add(c.target);
         continue;
       }
-	if (
-	  c.opinions &&
-	  !tombstoned.has(k) &&
-	  !['task', 'event', 'report'].includes(c.type)
-	) {
-	  if (c.replaces) replaces.set(c.replaces, k);
-	  itemsById.set(k, m);
-	}
+      if (c.opinions && !tombstoned.has(k) && !['task', 'event', 'report'].includes(c.type)) {
+        if (c.replaces) replaces.set(c.replaces, k);
+        itemsById.set(k, m);
+      }
     }
 
     for (const replacedId of replaces.keys()) {
       itemsById.delete(replacedId);
     }
 
-    let items = Array.from(itemsById.values());
+    let rawItems = Array.from(itemsById.values());
+    const blobTypes = ['document', 'image', 'audio', 'video'];
+
+    let items = await Promise.all(
+      rawItems.map(async m => {
+        const c = m.value?.content;
+        if (blobTypes.includes(c.type) && c.url) {
+          const valid = await hasBlob(ssbClient, c.url);
+          if (!valid) return null;
+        }
+        return m;
+      })
+    );
+    items = items.filter(Boolean);
 
     if (filter === 'MINE') {
       items = items.filter(m => m.value.author === userId);
@@ -67,7 +83,7 @@ module.exports = ({ cooler }) => {
       items = items.filter(m => m.value.content.type === filter);
     }
 
-    if (filter !== 'ALL') {
+    if (filter !== 'ALL' && !types.includes(filter)) {
       items = items.filter(m => (m.value.content.opinions_inhabitants || []).length > 0);
     }
 
@@ -92,30 +108,20 @@ module.exports = ({ cooler }) => {
   const getMessageById = async id => {
     const ssbClient = await openSsb();
     return new Promise((res, rej) => {
-      ssbClient.get(id, (err, msg) => {
-        if (err) rej(err);
-        else res(msg);
-      });
+      ssbClient.get(id, (err, msg) => err ? rej(err) : res(msg));
     });
   };
 
   const createVote = async (contentId, category) => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
-
     if (!categories.includes(category)) throw new Error('Invalid voting category');
-
     const msg = await getMessageById(contentId);
     if (!msg || !msg.content) throw new Error('Content not found');
-
-	const type = msg.content.type;
-	if (
-	  !types.includes(type) ||
-	  ['task', 'event', 'report'].includes(type)
-	) {
-	  throw new Error('Voting not allowed on this content type');
-	}
-
+    const type = msg.content.type;
+    if (!types.includes(type) || ['task', 'event', 'report'].includes(type)) {
+      throw new Error('Voting not allowed on this content type');
+    }
     if (msg.content.opinions_inhabitants?.includes(userId)) throw new Error('Already voted');
 
     const tombstone = {
@@ -136,7 +142,7 @@ module.exports = ({ cooler }) => {
     };
 
     await new Promise((res, rej) => {
-      ssbClient.publish(tombstone, (err) => err ? rej(err) : res());
+      ssbClient.publish(tombstone, err => err ? rej(err) : res());
     });
 
     return new Promise((res, rej) => {

+ 1 - 1
src/server/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.4.1",
+  "version": "0.4.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {

+ 1 - 1
src/server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.4.1",
+  "version": "0.4.2",
   "description": "Oasis Social Networking Project Utopia",
   "repository": {
     "type": "git",

+ 214 - 0
src/views/blockchain_view.js

@@ -0,0 +1,214 @@
+const { div, h2, p, section, button, form, a, input, span, pre, table, tr, td } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+const moment = require("../server/node_modules/moment");
+
+const FILTER_LABELS = {
+  votes: i18n.typeVotes,
+  vote: i18n.typeVote,
+  recent: i18n.recent,
+  all: i18n.all,
+  mine: i18n.mine,
+  pixelia: i18n.typePixelia,
+  curriculum: i18n.typeCurriculum,
+  document: i18n.typeDocument,
+  bookmark: i18n.typeBookmark,
+  feed: i18n.typeFeed,
+  event: i18n.typeEvent,
+  task: i18n.typeTask,
+  report: i18n.typeReport,
+  image: i18n.typeImage,
+  audio: i18n.typeAudio,
+  video: i18n.typeVideo,
+  post: i18n.typePost,
+  forum: i18n.typeForum,
+  about: i18n.typeAbout,
+  contact: i18n.typeContact,
+  pub: i18n.typePub,
+  transfer: i18n.typeTransfer,
+  market: i18n.typeMarket,
+  tribe: i18n.typeTribe
+};
+
+const BASE_FILTERS = ['recent', 'all', 'mine'];
+const CAT_BLOCK1 = ['votes', 'event', 'task', 'report'];
+const CAT_BLOCK2 = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote'];
+const CAT_BLOCK3 = ['market', 'transfer', 'feed', 'post', 'pixelia'];
+const CAT_BLOCK4 = ['forum', 'bookmark', 'image', 'video', 'audio', 'document'];
+
+const filterBlocks = (blocks, filter, userId) => {
+  if (filter === 'recent') {
+    return blocks.filter(b => Date.now() - b.ts < 24 * 60 * 60 * 1000);
+  } else if (filter === 'mine') {
+    return blocks.filter(b => b.author === userId);
+  } else if (filter === 'all') {
+    return blocks;
+  }
+  return blocks.filter(b => b.type === filter);
+};
+
+const generateFilterButtons = (filters, currentFilter, action) =>
+  div({ class: 'mode-buttons-cols' },
+    filters.map(mode =>
+      form({ method: 'GET', action },
+        input({ type: 'hidden', name: 'filter', value: mode }),
+        button({
+          type: 'submit',
+          class: currentFilter === mode ? 'filter-btn active' : 'filter-btn'
+        },
+          (FILTER_LABELS[mode] || mode).toUpperCase()
+        )
+      )
+    )
+  );
+
+const getViewDetailsAction = (type, block) => {
+  switch (type) {
+    case 'votes': return `/votes/${encodeURIComponent(block.id)}`;
+    case 'transfer': return `/transfers/${encodeURIComponent(block.id)}`;
+    case 'pixelia': return `/pixelia`;
+    case 'tribe': return `/tribe/${encodeURIComponent(block.id)}`;
+    case 'curriculum': return `/inhabitant/${encodeURIComponent(block.author)}`;
+    case 'image': return `/images/${encodeURIComponent(block.id)}`;
+    case 'audio': return `/audios/${encodeURIComponent(block.id)}`;
+    case 'video': return `/videos/${encodeURIComponent(block.id)}`;
+    case 'forum': return `/forum/${encodeURIComponent(block.content?.key || block.id)}`;
+    case 'document': return `/documents/${encodeURIComponent(block.id)}`;
+    case 'bookmark': return `/bookmarks/${encodeURIComponent(block.id)}`;
+    case 'event': return `/events/${encodeURIComponent(block.id)}`;
+    case 'task': return `/tasks/${encodeURIComponent(block.id)}`;
+    case 'about': return `/author/${encodeURIComponent(block.author)}`;
+    case 'post': return `/thread/${encodeURIComponent(block.id)}#${encodeURIComponent(block.id)}`;
+    case 'vote': return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`;
+    case 'contact': return `/inhabitants`;
+    case 'pub': return `/invites`;
+    case 'market': return `/market/${encodeURIComponent(block.id)}`;
+    case 'report': return `/reports/${encodeURIComponent(block.id)}`;
+    default: return null;
+  }
+};
+
+const renderSingleBlockView = (block, filter) =>
+  template(
+    i18n.blockchain,
+    section(
+      div({ class: 'tags-header' },
+        h2(i18n.blockchain),
+        p(i18n.blockchainDescription)
+      ),
+      div({ class: 'mode-buttons-row' },
+        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+          generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer')
+        ),
+        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+          generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer'),
+          generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer')
+        ),
+        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+          generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer'),
+          generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer')
+        )
+      ),
+      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/DD HH:mm:ss')),
+          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)) 
+        )
+      ),
+      div({ class: 'block-row block-row--back' },
+        form({ method: 'GET', action: '/blockexplorer' },
+          button({
+            type: 'submit',
+            class: 'filter-btn'
+          }, `← ${i18n.blockchainBack}`)
+        ),
+        getViewDetailsAction(block.type, block) && form({ method: 'GET', action: getViewDetailsAction(block.type, block) },
+          button({
+            type: 'submit',
+            class: 'filter-btn'
+          }, i18n.visitContent)
+        )
+      )
+    )
+  );
+  
+const renderBlockchainView = (blocks, filter, userId) => {
+  const filteredBlocks = filterBlocks(blocks, filter, userId);
+  return template(
+    i18n.blockchain,
+    section(
+      div({ class: 'tags-header' },
+        h2(i18n.blockchain),
+        p(i18n.blockchainDescription)
+      ),
+      div({ class: 'mode-buttons-row' },
+        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+          generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer')
+        ),
+        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+          generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer'),
+          generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer')
+        ),
+        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+          generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer'),
+          generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer')
+        )
+      ),
+      filteredBlocks.length === 0
+        ? p(i18n.blockchainNoBlocks)
+        : filteredBlocks.map(block => {
+          const blockDetailsAction = getViewDetailsAction(block.type, block);
+          const singleViewUrl = `/blockexplorer/block/${encodeURIComponent(block.id)}`;
+          return div({ class: 'block' },
+	  div({ class: 'block-buttons' },
+	  a({ href: singleViewUrl, class: 'btn-singleview', title: i18n.blockchainDetails }, '⦿'),
+	    blockDetailsAction && form({ method: 'GET', action: blockDetailsAction },
+	      button({
+	        type: 'submit',
+	        class: 'filter-btn'
+	      }, i18n.visitContent)
+	    )
+	  ),
+            div({ class: 'block-row block-row--meta' },
+              table({ class: 'block-info-table' },
+                tr(
+                  td({ class: 'card-label' }, i18n.blockchainBlockTimestamp),
+                  td({ class: 'card-value' }, moment(block.ts).format('YYYY/MM/DD HH:mm:ss'))
+                  
+                ),
+                tr(
+                  td({ class: 'card-label' }, i18n.blockchainBlockID),
+                  td({ class: 'card-value' }, block.id)
+                ),
+                tr(
+                  td({ class: 'card-label' }, i18n.blockchainBlockType),
+                  td({ class: 'card-value' }, (FILTER_LABELS[block.type] || block.type).toUpperCase())
+                ),
+                tr(
+                  td({ class: 'card-label' }, i18n.blockchainBlockAuthor),
+                  td({ class: 'card-value' },
+                    a({ href: `/author/${encodeURIComponent(block.author)}`, class: 'block-author user-link' }, block.author)
+                  )
+                )
+              )
+            )
+          );
+        })
+    )
+  );
+};
+
+module.exports = { renderBlockchainView, renderSingleBlockView };

+ 14 - 8
src/views/inhabitants_view.js

@@ -196,12 +196,18 @@ exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }) => {
           createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null
         )
       ),
-      feed && feed.length
-        ? section({ class: 'profile-feed' },
-            h2(i18n.latestInteractions),
-            feed.map(m => div({ class: 'post' }, p(...renderUrl(m.value.content.text || ''))))
-          )
-        : null
-    )
-  );
+	feed && feed.length
+	  ? section({ class: 'profile-feed' },
+	      h2(i18n.latestInteractions),
+	      feed.map(m => {
+		const contentText = m.value.content.text || '';
+		const cleanText = contentText.replace(/<br\s*\/?>/g, '');	
+		return div({ class: 'post' },
+		  p(...renderUrl(cleanText)) 
+		);
+	      })
+	    )
+          : null
+      )
+    );
 };

+ 2 - 1
src/views/main_views.js

@@ -407,8 +407,8 @@ const template = (titlePrefix, ...elements) => {
              renderCipherLink(),
              navLink({ href: "/pm", emoji: "ꕕ", text: i18n.privateMessage }),
              navLink({ href: "/publish", emoji: "❂", text: i18n.publish }),
-             renderTagsLink(),
              renderAILink(),
+             renderTagsLink(),
              navLink({ href: "/search", emoji: "ꔅ", text: i18n.search })
              )
           ),
@@ -424,6 +424,7 @@ const template = (titlePrefix, ...elements) => {
               navLink({ href: "/inbox", emoji: "☂", text: i18n.inbox }),
               renderAgendaLink(),
               navLink({ href: "/stats", emoji: "ꕷ", text: i18n.statistics }),
+              navLink({ href: "/blockexplorer", emoji: "ꖸ", text: i18n.blockchain }),
               hr,
               renderLatestLink(),
               renderThreadsLink(),

+ 257 - 249
src/views/opinions_view.js

@@ -4,223 +4,236 @@ const { config } = require('../server/SSB_server.js');
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { renderUrl } = require('../backend/renderUrl');
 
-const generateFilterButtons = (filters, currentFilter) => {
-  return filters.map(mode =>
-    form({ method: 'GET', action: '/opinions' },
-      input({ type: 'hidden', name: 'filter', value: mode }),
-      button({ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
-    )
-  );
-};
+const seenDocumentTitles = new Set();
 
 const renderContentHtml = (content, key) => {
   switch (content.type) {
-  case 'bookmark':
-  return div({ class: 'opinion-bookmark' },
-    div({ class: 'card-section bookmark' },
-      form({ method: "GET", action: `/bookmarks/${encodeURIComponent(key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      br,
-      h2(content.url ? div({ class: 'card-field' },
-        span({ class: 'card-label' }, p(a({ href: content.url, target: '_blank', class: "bookmark-url" }, content.url)))
-      ) : ""),
-      content.lastVisit ? div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'),
-        span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())
-      ) : "",
-      content.description 
-	? [
-	  span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ":"),
-	  p(...renderUrl(content.description))
-        ]: null,
-    )
-  );
-  case 'image':
-  return div({ class: 'opinion-image' },
-    div({ class: 'card-section image' },
-      form({ method: "GET", action: `/images/${encodeURIComponent(key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      br,
-      content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : "",
-      content.description 
-	? [
-	  span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"),
-	  p(...renderUrl(content.description))
-        ]: null,
-      content.meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : "",
-      br,
-      div({ class: 'card-field' }, img({ src: `/blob/${encodeURIComponent(content.url)}`, class: 'feed-image' }))
-    )
-  );
-  case 'video':
-  return div({ class: 'opinion-video' },
-    div({ class: 'card-section video' },
-      form({ method: "GET", action: `/videos/${encodeURIComponent(key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      br,
-      content.title ? div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.videoTitleLabel + ':'),
-        span({ class: 'card-value' }, content.title)
-      ) : "",
-      content.description 
-	? [
-	  span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"),
-	  p(...renderUrl(content.description))
-        ]: null,
-      div({ class: 'card-field' },
-        videoHyperaxe({
-          controls: true,
-          src: `/blob/${encodeURIComponent(content.url)}`,
-          type: content.mimeType || 'video/mp4',
-          width: '640',
-          height: '360'
-        })
-      )
-    )
-  );
-  case 'audio':
-  return div({ class: 'opinion-audio' },
-    div({ class: 'card-section audio' },
-      form({ method: "GET", action: `/audios/${encodeURIComponent(key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      br,
-      content.title ? div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.audioTitleLabel + ':'),
-        span({ class: 'card-value' }, content.title)
-      ) : "",
-      content.description 
-	? [
-	  span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"),
-	  p(...renderUrl(content.description))
-        ]: null,
-      div({ class: 'card-field' },
-        audioHyperaxe({
-          controls: true,
-          src: `/blob/${encodeURIComponent(content.url)}`,
-          type: content.mimeType,
-          preload: 'metadata'
-        })
-      )
-    )
-  );
-  case 'document':
-  return div({ class: 'opinion-document' },
-    div({ class: 'card-section document' },
-      form({ method: "GET", action: `/documents/${encodeURIComponent(key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      br,
-      content.title ? div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.documentTitleLabel + ':'),
-        span({ class: 'card-value' }, content.title)
-      ) : "",
-      content.description 
-	? [
-	  span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"),
-	  p(...renderUrl(content.description))
-        ]: null,
-      div({ class: 'card-field' },
-        div({ class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(content.url)}` })
-      )
-    )
-  );
-  case 'feed':
-  return div({ class: 'opinion-feed' },
-    div({ class: 'card-section feed' },
-      div({ class: 'feed-text', innerHTML: renderTextWithStyles(content.text) }),
-      h2({ class: 'card-field' },
-        span({ class: 'card-label' }, `${i18n.tribeFeedRefeeds}: `),
-        span({ class: 'card-value' }, content.refeeds)
-      )
-    )
-  );
-  case 'votes':
-  const votesList = content.votes && typeof content.votes === 'object'
-    ? Object.entries(content.votes).map(([option, count]) => ({ option, count }))
-    : [];
-  return div({ class: 'opinion-votes' },
-    div({ class: 'card-section votes' },
-      form({ method: "GET", action: `/votes/${encodeURIComponent(key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.voteQuestionLabel + ':'),
-        span({ class: 'card-value' }, content.question)
-      ),
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.voteDeadline + ':'),
-        span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '')
-      ),
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.voteTotalVotes + ':'),
-        span({ class: 'card-value' }, content.totalVotes)
-      ),
-      table(
-        tr(...votesList.map(({ option }) => th(i18n[option] || option))),
-        tr(...votesList.map(({ count }) => td(count)))
-      )
-    )
-  );
-  case 'transfer':
-  return div({ class: 'opinion-transfer' },
-    div({ class: 'card-section transfer' },
-      form({ method: "GET", action: `/transfers/${encodeURIComponent(key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      br,
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.concept + ':'),
-        span({ class: 'card-value' }, content.concept)
-      ),
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.deadline + ':'),
-        span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '')
-      ),
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.status + ':'),
-        span({ class: 'card-value' }, content.status)
-      ),
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.amount + ':'),
-        span({ class: 'card-value' }, content.amount)
-      ),
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.from + ':'),
-        span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.from)}`, target: "_blank" }, content.from))
-      ),
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, i18n.to + ':'),
-        span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.to)}`, target: "_blank" }, content.to))
-      ),
-      h2({ class: 'card-field' },
-	 span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
-	 span({ class: 'card-value' }, `${content.confirmedBy.length}/2`)
-      )
-    )
-  );
+    case 'bookmark':
+      return div({ class: 'opinion-bookmark' },
+        div({ class: 'card-section bookmark' },
+          form({ method: "GET", action: `/bookmarks/${encodeURIComponent(key)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ),
+          br,
+          h2(content.url ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, p(a({ href: content.url, target: '_blank', class: "bookmark-url" }, content.url)))
+          ) : ""),
+          content.lastVisit ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'),
+            span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())
+          ) : "",
+          content.description
+            ? [
+                span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ":"),
+                p(...renderUrl(content.description))
+              ]
+            : null
+        )
+      );
+    case 'image':
+      return div({ class: 'opinion-image' },
+        div({ class: 'card-section image' },
+          form({ method: "GET", action: `/images/${encodeURIComponent(key)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ),
+          br,
+          content.title ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.imageTitleLabel + ':'),
+            span({ class: 'card-value' }, content.title)
+          ) : "",
+          content.description
+            ? [
+                span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"),
+                p(...renderUrl(content.description))
+              ]
+            : null,
+          content.meme ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.trendingCategory + ':'),
+            span({ class: 'card-value' }, i18n.meme)
+          ) : "",
+          br,
+          div({ class: 'card-field' },
+            img({ src: `/blob/${encodeURIComponent(content.url)}`, class: 'feed-image' })
+          )
+        )
+      );
+    case 'video':
+      return div({ class: 'opinion-video' },
+        div({ class: 'card-section video' },
+          form({ method: "GET", action: `/videos/${encodeURIComponent(key)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ),
+          br,
+          content.title ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.videoTitleLabel + ':'),
+            span({ class: 'card-value' }, content.title)
+          ) : "",
+          content.description
+            ? [
+                span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"),
+                p(...renderUrl(content.description))
+              ]
+            : null,
+          div({ class: 'card-field' },
+            videoHyperaxe({
+              controls: true,
+              src: `/blob/${encodeURIComponent(content.url)}`,
+              type: content.mimeType || 'video/mp4',
+              width: '640',
+              height: '360'
+            })
+          )
+        )
+      );
+    case 'audio':
+      return div({ class: 'opinion-audio' },
+        div({ class: 'card-section audio' },
+          form({ method: "GET", action: `/audios/${encodeURIComponent(key)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ),
+          br,
+          content.title ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.audioTitleLabel + ':'),
+            span({ class: 'card-value' }, content.title)
+          ) : "",
+          content.description
+            ? [
+                span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"),
+                p(...renderUrl(content.description))
+              ]
+            : null,
+          div({ class: 'card-field' },
+            audioHyperaxe({
+              controls: true,
+              src: `/blob/${encodeURIComponent(content.url)}`,
+              type: content.mimeType,
+              preload: 'metadata'
+            })
+          )
+        )
+      );
+    case 'document': {
+      const t = content.title?.trim();
+      if (t && seenDocumentTitles.has(t)) return null;
+      if (t) seenDocumentTitles.add(t);
+      return div({ class: 'opinion-document' },
+        div({ class: 'card-section document' },
+          form({ method: "GET", action: `/documents/${encodeURIComponent(key)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ),
+          br,
+          t ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.documentTitleLabel + ':'),
+            span({ class: 'card-value' }, t)
+          ) : "",
+          content.description
+            ? [
+                span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"),
+                p(...renderUrl(content.description))
+              ]
+            : null,
+          div({ class: 'card-field' },
+            div({ class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(content.url)}` })
+          )
+        )
+      );
+    }
+    case 'feed':
+      return div({ class: 'opinion-feed' },
+        div({ class: 'card-section feed' },
+          div({ class: 'feed-text', innerHTML: renderTextWithStyles(content.text) }),
+          h2({ class: 'card-field' },
+            span({ class: 'card-label' }, `${i18n.tribeFeedRefeeds}: `),
+            span({ class: 'card-value' }, content.refeeds)
+          )
+        )
+      );
+    case 'votes': {
+      const votesList = content.votes && typeof content.votes === 'object'
+        ? Object.entries(content.votes).map(([option, count]) => ({ option, count }))
+        : [];
+      return div({ class: 'opinion-votes' },
+        div({ class: 'card-section votes' },
+          form({ method: "GET", action: `/votes/${encodeURIComponent(key)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.voteQuestionLabel + ':'),
+            span({ class: 'card-value' }, content.question)
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.voteDeadline + ':'),
+            span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '')
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.voteTotalVotes + ':'),
+            span({ class: 'card-value' }, content.totalVotes)
+          ),
+          table(
+            tr(...votesList.map(({ option }) => th(i18n[option] || option))),
+            tr(...votesList.map(({ count }) => td(count)))
+          )
+        )
+      );
+    }
+    case 'transfer':
+      return div({ class: 'opinion-transfer' },
+        div({ class: 'card-section transfer' },
+          form({ method: "GET", action: `/transfers/${encodeURIComponent(key)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ),
+          br,
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.concept + ':'),
+            span({ class: 'card-value' }, content.concept)
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.deadline + ':'),
+            span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '')
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.status + ':'),
+            span({ class: 'card-value' }, content.status)
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.amount + ':'),
+            span({ class: 'card-value' }, content.amount)
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.from + ':'),
+            span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.from)}`, target: "_blank" }, content.from))
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.to + ':'),
+            span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.to)}`, target: "_blank" }, content.to))
+          ),
+          h2({ class: 'card-field' },
+            span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
+            span({ class: 'card-value' }, `${content.confirmedBy.length}/2`)
+          )
+        )
+      );
     default:
-	return div({ class: 'styled-text' },
-	  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]' })
-	    )
-	  )
-	);
+      return div({ class: 'styled-text' },
+        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]' })
+          )
+        )
+      );
   }
 };
 
 exports.opinionsView = (items, filter) => {
-  items = items.filter(item => {
-    const content = item.value?.content || item.content;
-    if (!content || typeof content !== 'object') return false;
-    if (content.type === 'tombstone') return false;
-    return true;
-  });
+  seenDocumentTitles.clear();
+  items = items
+    .filter(item => {
+      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));
+
   const title = i18n.opinionsTitle;
   const baseFilters = ['RECENT', 'ALL', 'MINE', 'TOP'];
   const categoryFilters = [
@@ -229,17 +242,45 @@ exports.opinionsView = (items, filter) => {
     ['confusing', 'inspiring', 'spam']
   ];
 
-  if (filter !== 'TOP') {
-    items = [...items].sort((a, b) => b.value.timestamp - a.value.timestamp);
-  }
 
-  const hasDocuments = items.some(item => item.value.content?.type === 'document');
+const cards = items
+  .map(item => {
+    const c = item.value.content;
+    const key = item.key;
+    const contentHtml = renderContentHtml(c, key);
+    if (!contentHtml) return null;
+    const voteEntries = Object.entries(c.opinions || {});
+    const total = voteEntries.reduce((sum, [, v]) => sum + v, 0);
+    const voted = c.opinions_inhabitants?.includes(config.keys.id);
+    const created = new Date(item.value.timestamp).toLocaleString();
+    const allCats = categoryFilters.flat();
+    return div(
+      contentHtml,
+      p({ class: 'card-footer' },
+        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}`),
+      div({ class: 'voting-buttons' },
+        allCats.map(cat => {
+          const label = `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)]} [${c.opinions?.[cat] || 0}]`;
+          if (voted) {
+            return button({ class: 'vote-btn', type: 'button' }, label);
+          }
+          return form({ method: 'POST', action: `/opinions/${encodeURIComponent(key)}/${cat}` },
+            button({ class: 'vote-btn' }, label)
+          );
+        })
+      )
+    );
+  })
+  .filter(Boolean);
 
+  const hasDocuments = items.some(item => item.value.content?.type === 'document');
   const header = div({ class: 'tags-header' },
     h2(title),
     p(i18n.shareYourOpinions)
   );
-
   const html = template(
     title,
     section(
@@ -265,41 +306,8 @@ exports.opinionsView = (items, filter) => {
         )
       ),
       section(
-        items.length > 0
-          ? div({ class: 'opinions-container' },
-              items.map(item => {
-                const c = item.value.content;
-                const voteEntries = Object.entries(c.opinions || {});
-                const total = voteEntries.reduce((sum, [, v]) => sum + v, 0);
-                const voted = c.opinions_inhabitants?.includes(config.keys.id);
-                const created = new Date(item.value.timestamp).toLocaleString();
-                const key = item.key;
-                const contentHtml = renderContentHtml(c, key);
-
-                return div(
-                  contentHtml,
-                  p({ class: 'card-footer' },
-     		    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}`),
-		  !voted
-		  ? div({ class: 'voting-buttons' },
-		      ['interesting','necessary','funny','disgusting','sensible','propaganda','adultOnly','boring','confusing','inspiring','spam'].map(cat => 
-			form({
-			  method: 'POST', 
-			  action: `/opinions/${encodeURIComponent(item.key)}/${cat}`
-			},
-			  button({ class: 'vote-btn' }, 
-			    `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)]} [${c.opinions?.[cat] || 0}]`
-			  )
-			)
-		      )
-		    )
-		  : p(i18n.alreadyVoted)
-                );
-              })
-            )
+        cards.length
+          ? div({ class: 'opinions-container' }, ...cards)
           : div({ class: 'no-results' }, p(i18n.noOpinionsFound))
       )
     )

+ 129 - 185
src/views/trending_view.js

@@ -4,10 +4,10 @@ const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
 
-const userId = config.keys.id
+const userId = config.keys.id;
 
-const generateFilterButtons = (filters, currentFilter, action) => {
-  return div({ class: 'filter-buttons-container', style: 'display: flex; gap: 16px; flex-wrap: wrap;' },
+const generateFilterButtons = (filters, currentFilter, action) =>
+  div({ class: 'filter-buttons-container', style: 'display: flex; gap: 16px; flex-wrap: wrap;' },
     filters.map(mode =>
       form({ method: 'GET', action },
         input({ type: 'hidden', name: 'filter', value: mode }),
@@ -15,233 +15,182 @@ const generateFilterButtons = (filters, currentFilter, action) => {
       )
     )
   );
-};
 
-const renderTrendingCard = (item, votes, categories) => {
+const renderTrendingCard = (item, votes, categories, seenTitles) => {
   const c = item.value.content;
   const created = new Date(item.value.timestamp).toLocaleString();
-
   let contentHtml;
-  
   if (c.type === 'bookmark') {
-    const { author, url, tags, description, category, lastVisit } = c;
+    const { url, description, lastVisit } = c;
     contentHtml = div({ class: 'trending-bookmark' },
-    div({ class: 'card-section bookmark' }, 
-      form({ method: "GET", action: `/bookmarks/${encodeURIComponent(item.key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      br,
-      h2(url ? p(a({ href: url, target: '_blank', class: "bookmark-url" }, url)) : ""),
-      lastVisit ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'), span({ class: 'card-value' }, new Date(lastVisit).toLocaleString())) : "",
-      description
-	? [
-	  span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ":"),
-	  p(...renderUrl(description))
-        ]: null,
-    )
-  );
+      div({ class: 'card-section bookmark' },
+        form({ method: "GET", action: `/bookmarks/${encodeURIComponent(item.key)}` },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        br,
+        url ? h2(p(a({ href: url, target: '_blank', class: "bookmark-url" }, url))) : "",
+        lastVisit ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'), span({ class: 'card-value' }, new Date(lastVisit).toLocaleString())) : "",
+        description ? [span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ":"), p(...renderUrl(description))] : null
+      )
+    );
   } else if (c.type === 'image') {
-    const { url, title, description, tags, meme } = c;
+    const { url, title, description, meme } = c;
     contentHtml = div({ class: 'trending-image' },
-    div({ class: 'card-section image' },
-      form({ method: "GET", action: `/images/${encodeURIComponent(item.key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      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)) : "",
-      div({ class: 'card-field' }, img({ src: `/blob/${encodeURIComponent(url)}`, class: 'feed-image' }))
-    )
-  );
+      div({ class: 'card-section image' },
+        form({ method: "GET", action: `/images/${encodeURIComponent(item.key)}` },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        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)) : "",
+        div({ class: 'card-field' }, img({ src: `/blob/${encodeURIComponent(url)}`, class: 'feed-image' }))
+      )
+    );
   } else if (c.type === 'audio') {
     const { url, mimeType, title, description } = c;
     contentHtml = div({ class: 'trending-audio' },
-    div({ class: 'card-section audio' },
-      form({ method: "GET", action: `/audios/${encodeURIComponent(item.key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      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
-        ? div({ class: 'card-field audio-container' },
-            audioHyperaxe({
-              controls: true,
-              src: `/blob/${encodeURIComponent(url)}`,
-              type: mimeType
-            })
-          )
-        : div({ class: 'card-field' }, p(i18n.audioNoFile))
-    )
-  );
+      div({ class: 'card-section audio' },
+        form({ method: "GET", action: `/audios/${encodeURIComponent(item.key)}` },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        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
+          ? div({ class: 'card-field audio-container' }, audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(url)}`, type: mimeType }))
+          : div({ class: 'card-field' }, p(i18n.audioNoFile))
+      )
+    );
   } else if (c.type === 'video') {
     const { url, mimeType, title, description } = c;
     contentHtml = div({ class: 'trending-video' },
-    div({ class: 'card-section video' },
-      form({ method: "GET", action: `/videos/${encodeURIComponent(item.key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      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,
-      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))
-    )
-  );
+      div({ class: 'card-section video' },
+        form({ method: "GET", action: `/videos/${encodeURIComponent(item.key)}` },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        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,
+        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))
+      )
+    );
   } else if (c.type === 'document') {
-    const { url, title, description, tags = [], key } = c;
+    const { url, title, description } = c;
+    const t = title?.trim();
+    if (t && seenTitles.has(t)) return null;
+    if (t) seenTitles.add(t);
     contentHtml = div({ class: 'trending-document' },
-    div({ class: 'card-section document' },
-      form({ method: "GET", action: `/documents/${encodeURIComponent(item.key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      br,
-      title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
-      description
-	? [
-	  span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"),
-	  p(...renderUrl(description))
-        ]: null,
-      div({
-        id: `pdf-container-${key || url}`,
-        class: 'card-field pdf-viewer-container',
-        'data-pdf-url': `/blob/${encodeURIComponent(url)}`
-      })
-    )
-  );
+      div({ class: 'card-section document' },
+        form({ method: "GET", action: `/documents/${encodeURIComponent(item.key)}` },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        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)}` })
+      )
+    );
   } else if (c.type === 'feed') {
     const { text, refeeds } = c;
     contentHtml = div({ class: 'trending-feed' },
-    div({ class: 'card-section feed' },
-      div({ class: 'feed-text', innerHTML: renderTextWithStyles(text) }),
-      h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), span({ class: 'card-label' }, refeeds))
-    )
-  );
+      div({ class: 'card-section feed' },
+        div({ class: 'feed-text', innerHTML: renderTextWithStyles(text) }),
+        h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), span({ class: 'card-value' }, refeeds))
+      )
+    );
   } else if (c.type === 'votes') {
-    const { question, deadline, status, votes, totalVotes } = c;
-    const votesList = votes && typeof votes === 'object'
-    ? Object.entries(votes).map(([option, count]) => ({ option, count }))
-    : [];
+    const { question, deadline, votes, totalVotes } = c;
+    const votesList = votes && typeof votes === 'object' ? Object.entries(votes).map(([o, cnt]) => ({ option: o, count: cnt })) : [];
     contentHtml = div({ class: 'trending-votes' },
-    div({ class: 'card-section votes' },
-      form({ method: "GET", action: `/votes/${encodeURIComponent(item.key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteQuestionLabel + ':'), span({ class: 'card-value' }, question)),
-      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteDeadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
-      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteTotalVotes + ':'), span({ class: 'card-value' }, totalVotes)),
-      table(
-        tr(...votesList.map(({ option }) => th(i18n[option] || option))),
-        tr(...votesList.map(({ count }) => td(count)))
+      div({ class: 'card-section votes' },
+        form({ method: "GET", action: `/votes/${encodeURIComponent(item.key)}` },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteQuestionLabel + ':'), span({ class: 'card-value' }, question)),
+        div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteDeadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
+        div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteTotalVotes + ':'), span({ class: 'card-value' }, totalVotes)),
+        table(
+          tr(...votesList.map(v => th(i18n[v.option] || v.option))),
+          tr(...votesList.map(v => td(v.count)))
+        )
       )
-    )
-  );
+    );
   } else if (c.type === 'transfer') {
-    const { from, to, concept, amount, deadline, status, tags, confirmedBy } = c;
+    const { from, to, concept, amount, deadline, status, confirmedBy } = c;
     contentHtml = div({ class: 'trending-transfer' },
-    div({ class: 'card-section transfer' },
-      form({ method: "GET", action: `/transfers/${encodeURIComponent(item.key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      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)),
-      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.amount + ':'), span({ class: 'card-value' }, amount)),
-      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.from + ':'), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(from)}`, target: "_blank" }, from))),
-      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.to + ':'), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(to)}`, target: "_blank" }, to))),
-      h2({ class: 'card-field' },
-	 span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
-	 span({ class: 'card-value' }, `${confirmedBy.length}/2`)
+      div({ class: 'card-section transfer' },
+        form({ method: "GET", action: `/transfers/${encodeURIComponent(item.key)}` },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        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)),
+        div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.amount + ':'), span({ class: 'card-value' }, amount)),
+        div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.from + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(from)}`, target: '_blank' }, from))),
+        div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.to + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(to)}`, target: '_blank' }, to))),
+        h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ': '), span({ class: 'card-value' }, `${confirmedBy.length}/2`))
       )
-    )
-  );
+    );
   } else {
     contentHtml = div({ class: 'styled-text' },
-    div({ class: 'card-section styled-text-content' },
-    div({ class: 'card-field' }, 
-      span({ class: 'card-label' }, i18n.textContentLabel + ':'), 
-      span({ class: 'card-value', innerHTML: renderTextWithStyles(c.text || c.description || c.title || '[no content]') })
-     )
-    )
-   );
+      div({ class: 'card-section styled-text-content' },
+        div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.textContentLabel + ':'), span({ class: 'card-value', innerHTML: renderTextWithStyles(c.text || c.description || c.title || '[no content]') }))
+      )
+    );
   }
 
   return div({ class: 'trending-card', style: 'background-color:#2c2f33;border-radius:8px;padding:16px;border:1px solid #444;' },
     contentHtml,
-    p({ class: 'card-footer' },
-      span({ class: 'date-link' }, `${created} ${i18n.performed} `),
-      a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, `${item.value.author}`)
-    ),  
+    p({ class: 'card-footer' }, 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}`),
-    div({ class: "voting-buttons" },
-      categories.map(cat =>
-        form({ method: "POST", action: `/trending/${encodeURIComponent(item.key)}/${cat}` },
-          button({ class: "vote-btn" }, `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)]} [${c.opinions?.[cat] || 0}]`)
-        )
-      )
-    )
+    div({ class: 'voting-buttons' }, categories.map(cat => form({ method: 'POST', action: `/trending/${encodeURIComponent(item.key)}/${cat}` }, button({ class: 'vote-btn' }, `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)]} [${c.opinions?.[cat]||0}]`))))
   );
 };
 
 exports.trendingView = (items, filter, categories) => {
+  const seenDocumentTitles = new Set();
   const title = i18n.trendingTitle;
   const baseFilters = ['RECENT', 'ALL', 'MINE', 'TOP'];
   const contentFilters = [
     ['votes', 'feed', 'transfer'],
     ['bookmark', 'image', 'video', 'audio', 'document']
   ];
+
   let filteredItems = items.filter(item => {
-    const content = item.value?.content || item.content;
-    if (!content || typeof content !== 'object') return false;
-    if (content.type === 'tombstone') return false;
-    return true;
+    const c = item.value?.content || item.content;
+    return c && typeof c === 'object' && c.type !== 'tombstone';
   });
-  if (filter === 'ALL') {
-  } else if (filter === 'MINE') {
+
+  if (filter === 'MINE') {
     filteredItems = filteredItems.filter(item => item.value.author === userId);
   } else if (filter === 'RECENT') {
     const now = Date.now();
-    filteredItems = filteredItems.filter(item => now - item.value.timestamp < 24 * 60 * 60 * 1000); 
+    filteredItems = filteredItems.filter(item => now - item.value.timestamp < 24 * 60 * 60 * 1000);
   } else if (filter === 'TOP') {
-    filteredItems = filteredItems.sort((a, b) => {
+    filteredItems.sort((a, b) => {
       const aVotes = (a.value.content.opinions_inhabitants || []).length;
       const bVotes = (b.value.content.opinions_inhabitants || []).length;
-      if (bVotes !== aVotes) return bVotes - aVotes;
-      return b.value.timestamp - a.value.timestamp;
+      return bVotes !== aVotes ? bVotes - aVotes : b.value.timestamp - a.value.timestamp;
     });
-  } else if (contentFilters.some(row => row.includes(filter))) {
+  } else if (contentFilters.flat().includes(filter)) {
     filteredItems = filteredItems.filter(item => item.value.content.type === filter);
+  } else if (filter !== 'ALL') {
+    filteredItems = filteredItems.filter(item => (item.value.content.opinions_inhabitants || []).length > 0);
   }
+
   filteredItems.sort((a, b) => b.value.timestamp - a.value.timestamp);
-  const header = div({ class: 'tags-header' },
-    h2(title),
-    p(i18n.exploreTrending)
-  );
+
+  const header = div({ class: 'tags-header' }, h2(title), p(i18n.exploreTrending));
+  const cards = filteredItems
+    .map(item => renderTrendingCard(item, Object.values(item.value.content.opinions || {}).reduce((s, n) => s + n, 0), categories, seenDocumentTitles))
+    .filter(Boolean);
+
+  const hasDocument = filteredItems.some(item => item.value.content.type === 'document');
 
   let html = template(
     title,
@@ -261,19 +210,13 @@ exports.trendingView = (items, filter, categories) => {
         )
       ),
       section(
-        filteredItems.length
-          ? div({ class: 'trending-container', style: 'display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px;' },
-              filteredItems.map(item => {
-                const c = item.value.content;
-                const votes = Object.values(c.opinions || {}).reduce((s, n) => s + n, 0);
-                return renderTrendingCard(item, votes, categories);
-              })
-            )
+        cards.length
+          ? div({ class: 'trending-container', style: 'display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px;' }, ...cards)
           : div({ class: 'no-results' }, p(i18n.trendingNoContentMessage))
       )
     )
   );
-  const hasDocument = filteredItems.some(item => item.value.content.type === 'document');
+
   if (hasDocument) {
     html += `
       <script type="module" src="/js/pdf.min.mjs"></script>
@@ -283,3 +226,4 @@ exports.trendingView = (items, filter, categories) => {
 
   return html;
 };
+