Ver código fonte

Oasis release 0.6.7

psy 2 dias atrás
pai
commit
c1e2644e8e
45 arquivos alterados com 11298 adições e 370 exclusões
  1. 22 0
      docs/CHANGELOG.md
  2. 5 5
      docs/PUB/deploy.md
  3. 36 10
      src/backend/backend.js
  4. 145 5
      src/client/assets/styles/style.css
  5. 1 1
      src/client/assets/translations/i18n.js
  6. 2654 0
      src/client/assets/translations/oasis_ar.js
  7. 2 1
      src/client/assets/translations/oasis_de.js
  8. 2 1
      src/client/assets/translations/oasis_en.js
  9. 2 1
      src/client/assets/translations/oasis_es.js
  10. 2 1
      src/client/assets/translations/oasis_eu.js
  11. 2 1
      src/client/assets/translations/oasis_fr.js
  12. 2654 0
      src/client/assets/translations/oasis_hi.js
  13. 2 1
      src/client/assets/translations/oasis_it.js
  14. 2 1
      src/client/assets/translations/oasis_pt.js
  15. 2613 0
      src/client/assets/translations/oasis_ru.js
  16. 2655 0
      src/client/assets/translations/oasis_zh.js
  17. 1 1
      src/configs/blockchain-cycle.json
  18. 1 1
      src/configs/oasis-config.json
  19. 2 2
      src/configs/server-config.json
  20. 4 1
      src/configs/snh-invite-code.json
  21. 60 1
      src/models/feed_model.js
  22. 137 11
      src/models/stats_model.js
  23. 1 1
      src/server/package-lock.json
  24. 1 1
      src/server/package.json
  25. 1 1
      src/views/activity_view.js
  26. 9 28
      src/views/audio_view.js
  27. 13 31
      src/views/bookmark_view.js
  28. 1 2
      src/views/cipher_view.js
  29. 3 3
      src/views/courts_view.js
  30. 5 22
      src/views/document_view.js
  31. 15 36
      src/views/event_view.js
  32. 161 16
      src/views/feed_view.js
  33. 5 27
      src/views/image_view.js
  34. 8 1
      src/views/invites_view.js
  35. 8 14
      src/views/jobs_view.js
  36. 2 24
      src/views/market_view.js
  37. 2 1
      src/views/parliament_view.js
  38. 1 4
      src/views/pixelia_view.js
  39. 9 27
      src/views/projects_view.js
  40. 5 1
      src/views/settings_view.js
  41. 1 1
      src/views/stats_view.js
  42. 19 23
      src/views/task_view.js
  43. 7 29
      src/views/transfer_view.js
  44. 8 4
      src/views/tribes_view.js
  45. 9 28
      src/views/video_view.js

+ 22 - 0
docs/CHANGELOG.md

@@ -13,6 +13,28 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.6.7 - 2026-03-04
+
+### Added
+
+ + Writing comments within feeds (Feed plugin).
+ + PUB metadata and invitations (Invites plugin).
+ + Chinese simplified (zh) translation (i18n).
+ + Arab (ar) translation (i18n).
+ + Hindi (hi) translation (i18n).
+ + Russian (ru) translation (i18n).
+
+### Fixed
+
+ + SNH-Mobile Theme (Core plugin).
+ 
+### Changed
+
+ + Multiple alignment of views between modules (Multiple plugins).
+ + Cloud tags (Tags plugin).
+ + Auto-join button for PUB: SNH "La Plaza" (Invites plugin).
+ + i18n languages array expanded: ['en', 'es', 'fr', 'eu', 'de', 'it', 'pt', 'zh', 'ar', 'hi', 'ru'] (Core plugin).
+
 ## v0.6.6 - 2026-02-23
 
 ### Added

+ 5 - 5
docs/PUB/deploy.md

@@ -30,7 +30,7 @@ Paste this:
     "level": "info"
   },
   "caps": {
-    "shs": "1BIWr6Hu+MgtNkkClvg2GAi+0HiAikGOOTd/pIUcH54="
+    "shs": "zTmidAb7t+tKi7W93FIHbOvlbd936x6G/vm8e8Td//A="
   },
   "pub": true,
   "local": false,
@@ -85,7 +85,7 @@ Paste this:
   "autofollow": {
     "enabled": true,
     "suggestions": [
-      "@zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519"
+      "@mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.ed25519"
     ]
   }
 }
@@ -161,7 +161,7 @@ To do this, first get the PUB's ID, with:
    ssb-server whoami
    
    {
-     "id": "@zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519"
+     "id": "@mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.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 @zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519 --address.host solarnethub.com --address.port 8008
+   ssb-server publish --type pub --address.key @mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.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 "@zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519" --following
+   ssb-server publish --type contact --contact "@mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.ed25519" --following
 
 ## 10) Join the Oasis PUB Network
 

+ 36 - 10
src/backend/backend.js

@@ -630,7 +630,7 @@ const { reportView, singleReportView } = require("../views/report_view");
 const { taskView, singleTaskView } = require("../views/task_view");
 const { voteView } = require("../views/vote_view");
 const { bookmarkView, singleBookmarkView } = require("../views/bookmark_view");
-const { feedView, feedCreateView } = require("../views/feed_view");
+const { feedView, feedCreateView, singleFeedView } = require("../views/feed_view");
 const { legacyView } = require("../views/legacy_view");
 const { opinionsView } = require("../views/opinions_view");
 const { peersView } = require("../views/peers_view");
@@ -1456,6 +1456,12 @@ router
     const tag = typeof ctx.query.tag === "string" ? ctx.query.tag : "";
     ctx.body = feedCreateView({ q, tag });
   })
+  .get("/feed/:feedId", async (ctx) => {
+    const feed = await feedModel.getFeedById(ctx.params.feedId);
+    if (!feed) { ctx.redirect('/feed'); return; }
+    const comments = await feedModel.getComments(ctx.params.feedId).catch(() => []);
+    ctx.body = singleFeedView(feed, comments);
+  })
   .get('/forum', async ctx => {
     if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; }
     const filter = qf(ctx, 'recent'), forums = await forumModel.listAll(filter);
@@ -2190,6 +2196,13 @@ router
     await feedModel.createRefeed(ctx.params.id);
     ctx.redirect(ctx.get("Referer") || "/feed");
   })
+  .post("/feed/:feedId/comments", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
+    const text = ctx.request.body?.text != null ? stripDangerousTags(String(ctx.request.body.text)) : "";
+    const imageMarkdown = ctx.request.files?.blob ? await handleBlobUpload(ctx, 'blob') : null;
+    const fullText = imageMarkdown ? (text ? text + '\n' : '') + imageMarkdown : text;
+    await feedModel.addComment(ctx.params.feedId, fullText);
+    ctx.redirect(`/feed/${encodeURIComponent(ctx.params.feedId)}`);
+  })
   .post("/bookmarks/create", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'bookmarksMod')) { ctx.redirect('/modules'); return; }
     const b = ctx.request.body;
@@ -2506,14 +2519,20 @@ router
       ctx.res.on('finish', () => fs.unlinkSync(outputPath));
     } catch (error) { ctx.body = { error: 'Error exporting your blockchain: ' + error.message }; }
   })
-  .post('/tasks/create', koaBody(), async ctx => {
+  .post('/tasks/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
     const b = ctx.request.body;
-    await tasksModel.createTask(stripDangerousTags(b.title), stripDangerousTags(b.description), b.startTime, b.endTime, b.priority, stripDangerousTags(b.location), b.tags, b.isPublic);
+    const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
+    let desc = stripDangerousTags(b.description);
+    if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown;
+    await tasksModel.createTask(stripDangerousTags(b.title), desc, b.startTime, b.endTime, b.priority, stripDangerousTags(b.location), b.tags, b.isPublic);
     ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks']));
   })
-  .post('/tasks/update/:id', koaBody(), async ctx => {
+  .post('/tasks/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
     const b = ctx.request.body, tags = Array.isArray(b.tags) ? b.tags.filter(Boolean) : (typeof b.tags === 'string' ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : []);
-    await tasksModel.updateTaskById(ctx.params.id, { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), startTime: b.startTime, endTime: b.endTime, priority: b.priority, location: stripDangerousTags(b.location), tags, isPublic: b.isPublic });
+    const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
+    let desc = stripDangerousTags(b.description);
+    if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown;
+    await tasksModel.updateTaskById(ctx.params.id, { title: stripDangerousTags(b.title), description: desc, startTime: b.startTime, endTime: b.endTime, priority: b.priority, location: stripDangerousTags(b.location), tags, isPublic: b.isPublic });
     ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks']));
   })
   .post('/tasks/assign/:id', koaBody(), async ctx => {
@@ -2565,14 +2584,20 @@ router
     ctx.redirect('/reports?filter=mine');
   })
   .post('/reports/:reportId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'reports', 'reportId'))
-  .post('/events/create', koaBody(), async (ctx) => {
+  .post('/events/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     const b = ctx.request.body;
-    await eventsModel.createEvent(stripDangerousTags(b.title), stripDangerousTags(b.description), b.date, stripDangerousTags(b.location), b.price, b.url, b.attendees || [], b.tags, b.isPublic);
-    ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events'])); 
+    const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
+    let desc = stripDangerousTags(b.description);
+    if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown;
+    await eventsModel.createEvent(stripDangerousTags(b.title), desc, b.date, stripDangerousTags(b.location), b.price, b.url, b.attendees || [], b.tags, b.isPublic);
+    ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events']));
   })
-  .post('/events/update/:id', koaBody(), async (ctx) => {
+  .post('/events/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     const b = ctx.request.body, existing = await eventsModel.getEventById(ctx.params.id);
-    await eventsModel.updateEventById(ctx.params.id, { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), date: b.date, location: stripDangerousTags(b.location), price: b.price, url: b.url, attendees: b.attendees, tags: b.tags, isPublic: b.isPublic, createdAt: existing.createdAt, organizer: existing.organizer });
+    const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
+    let desc = stripDangerousTags(b.description);
+    if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown;
+    await eventsModel.updateEventById(ctx.params.id, { title: stripDangerousTags(b.title), description: desc, date: b.date, location: stripDangerousTags(b.location), price: b.price, url: b.url, attendees: b.attendees, tags: b.tags, isPublic: b.isPublic, createdAt: existing.createdAt, organizer: existing.organizer });
     ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events']));
   })
   .post('/events/attend/:id', koaBody(), async ctx => {
@@ -2647,6 +2672,7 @@ router
     const respondent = String(b.respondentId || '').trim(), method = String(b.method || '').trim().toUpperCase();
     if (!titleSuffix && !titlePreset) { ctx.flash = { message: 'Title is required.' }; return ctx.redirect('/courts?filter=cases'); }
     if (!respondent) { ctx.flash = { message: 'Accused / Respondent is required.' }; return ctx.redirect('/courts?filter=cases'); }
+    if (!/^@[A-Za-z0-9+/]+=*\.ed25519$/.test(respondent)) { ctx.flash = { message: 'Invalid respondent ID. Must be a valid SSB ID (@...ed25519).' }; return ctx.redirect('/courts?filter=cases'); }
     if (!new Set(['JUDGE','DICTATOR','POPULAR','MEDIATION','KARMATOCRACY']).has(method)) { ctx.flash = { message: 'Invalid resolution method.' }; return ctx.redirect('/courts?filter=cases'); }
     try { await courtsModel.openCase({ titleBase: [titlePreset, titleSuffix].filter(Boolean).join(' - '), respondentInput: respondent, method }); }
     catch (e) { ctx.flash = { message: String(e?.message || e) }; }

+ 145 - 5
src/client/assets/styles/style.css

@@ -2120,7 +2120,8 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 /* Market */
 .market-grid {
   display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
+  grid-template-columns: repeat(3, 1fr);
+  gap: 20px;
   justify-content: center;
 }
 
@@ -3107,7 +3108,8 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .pub-item{border:1;border-radius:10px;background:none}
 .snh-invite-box{border:1px solid currentColor;border-radius:8px;padding:16px;margin:12px 0;font-family:monospace}
 .snh-invite-name{margin:0 0 8px 0}
-.snh-invite-code{display:block;word-break:break-all;font-size:14px;margin:0;text-decoration:none;cursor:default}
+.snh-invite-date{font-size:13px;opacity:0.7;margin:0 0 10px 0}
+.snh-invite-btn{display:block;word-break:break-all;font-size:14px;margin:0;cursor:pointer;background:none;border:1px solid #FFA500;color:#FFA500;padding:10px;border-radius:5px;width:auto;text-align:left;font-family:monospace}
 .error-box{background:#222326;border:none;color:#f5c242;padding:12px;border-radius:8px;margin-top:8px}
 .error-title{margin:0 0 6px 0;font-weight:600}
 .error-pre{margin:0;white-space:pre-wrap;font-family:monospace}
@@ -3623,7 +3625,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 
 .tribe-grid {
   display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+  grid-template-columns: repeat(3, 1fr);
   gap: 20px;
 }
 
@@ -3663,8 +3665,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 }
 
 .tribe-section-nav .filter-btn {
-  font-size: 12px;
-  padding: 5px 10px;
+  padding: 8px 12px;
   text-transform: uppercase;
 }
 
@@ -4024,3 +4025,142 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .tribe-parent-card-link:hover {
   text-decoration: underline;
 }
+
+.comment-submit-btn {
+  width: auto;
+  max-width: 200px;
+}
+
+.market-owner-actions-inline {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  align-items: center;
+}
+
+.project-goal-highlight {
+  font-size: 1.4em;
+  font-weight: 700;
+}
+
+.project-control-form--progress {
+  max-width: 300px;
+}
+
+.project-progress-input {
+  max-width: 80px;
+}
+
+.applicants-under {
+  color: #4caf50;
+}
+
+.applicants-at {
+  color: #ffeb3b;
+}
+
+.applicants-over {
+  color: #f44336;
+}
+
+.transfer-amount-highlight {
+  font-size: 1.3em;
+  font-weight: 700;
+}
+
+.pixelia-paint-form {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  align-items: center;
+}
+
+.pixelia-paint-form label,
+.pixelia-paint-form input,
+.pixelia-paint-form select,
+.pixelia-paint-form button {
+  width: auto;
+  margin: 0;
+}
+
+.pixelia-paint-form input[type="number"] {
+  max-width: 80px;
+}
+
+.method-image-centered {
+  text-align: center;
+  margin: 12px 0;
+}
+
+.method-image-centered img {
+  max-width: 120px;
+  height: auto;
+  margin: 0 auto;
+}
+
+.tag-cloud-wrap {
+  display: flex;
+  flex-wrap: wrap;
+  max-width: 90%;
+  gap: 14px;
+  justify-content: center;
+  align-items: center;
+  min-height: 220px;
+  padding: 36px;
+  border-radius: 16px;
+}
+
+.tag-cloud-item {
+  position: relative;
+  padding: 6px 14px;
+  border-radius: 999px;
+  font-weight: 500;
+  font-size: 0.95rem;
+  letter-spacing: 0.3px;
+  text-decoration: none;
+  transition: all 0.25s ease;
+}
+
+.tag-cloud-item:hover {
+  background: rgba(255,255,255,0.1);
+  border-color: rgba(255,255,255,0.2);
+  transform: translateY(-3px);
+  color: #ffffff;
+  box-shadow: 0 6px 18px rgba(0,0,0,0.5);
+}
+
+.tag-cloud-item[data-weight="1"] {
+  font-size: 0.85rem;
+  opacity: 0.7;
+}
+
+.tag-cloud-item[data-weight="2"] {
+  font-size: 0.95rem;
+  opacity: 0.8;
+}
+
+.tag-cloud-item[data-weight="3"] {
+  font-size: 1.05rem;
+  opacity: 0.9;
+}
+
+.tag-cloud-item[data-weight="4"] {
+  font-size: 1.15rem;
+  font-weight: 600;
+}
+
+.stats-karma-block {
+  background-color: #222;
+  padding: 24px;
+  border-radius: 8px;
+  border: 1px solid #444;
+  margin-bottom: 24px;
+}
+
+.feed-detail-card {
+  background-color: #222;
+  border: 1px solid #444;
+  border-radius: 8px;
+  padding: 16px;
+  margin-bottom: 16px;
+}

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

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

Diferenças do arquivo suprimidas por serem muito extensas
+ 2654 - 0
src/client/assets/translations/oasis_ar.js


+ 2 - 1
src/client/assets/translations/oasis_de.js

@@ -1164,6 +1164,7 @@ module.exports = {
     transfersUnconfirmedSectionTitle: "Unconfirmed Transfers",
     transfersClosedSectionTitle: "Closed Transfers",
     transfersDiscardedSectionTitle: "Discarded Transfers",
+    transfersCreateSectionTitle: "Transfer erstellen",
     transfersAllSectionTitle: "Überweisungen",
     transfersFilterFavs: "Favoriten",
     transfersFavsSectionTitle: "Favorite transfers",
@@ -1359,7 +1360,7 @@ module.exports = {
     imageTitlePlaceholder: "Optional",
     imageDescriptionLabel: "Beschreibung",
     imageDescriptionPlaceholder: "Optional",
-    imageMemeLabel: "Mark as MEME",
+    imageMemeLabel: "¿MEME?",
     imageNoFile: "No image file provided",
     noImages: "No images available.",
     imageSearchPlaceholder: "Search title, tags, description, author...",

+ 2 - 1
src/client/assets/translations/oasis_en.js

@@ -1164,6 +1164,7 @@ module.exports = {
     transfersUnconfirmedSectionTitle: "Unconfirmed Transfers",
     transfersClosedSectionTitle: "Closed Transfers",
     transfersDiscardedSectionTitle: "Discarded Transfers",
+    transfersCreateSectionTitle: "Create transfer",
     transfersAllSectionTitle: "Transfers",
     transfersFilterFavs: "Favorites",
     transfersFavsSectionTitle: "Favorite transfers",
@@ -1359,7 +1360,7 @@ module.exports = {
     imageTitlePlaceholder: "Optional",
     imageDescriptionLabel: "Description",
     imageDescriptionPlaceholder: "Optional",
-    imageMemeLabel: "Mark as MEME",
+    imageMemeLabel: "¿MEME?",
     imageNoFile: "No image file provided",
     noImages: "No images available.",
     imageSearchPlaceholder: "Search title, tags, description, author...",

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

@@ -1155,6 +1155,7 @@ module.exports = {
     transfersUnconfirmedSectionTitle: "Transferencias No Confirmadas",
     transfersClosedSectionTitle: "Transferencias Cerradas",
     transfersDiscardedSectionTitle: "Transferencias Descartadas",
+    transfersCreateSectionTitle: "Crear transferencia",
     transfersAllSectionTitle: "Transferencias",
     transfersFilterFavs: "Favoritos",
     transfersFavsSectionTitle: "Transferencias favoritas",
@@ -1350,7 +1351,7 @@ module.exports = {
     imageTitlePlaceholder: "Opcional",
     imageDescriptionLabel: "Descripción",
     imageDescriptionPlaceholder: "Opcional",
-    imageMemeLabel: "Marcar como MEME",
+    imageMemeLabel: "¿MEME?",
     imageNoFile: "No se ha proporcionado ningún archivo de imagen",
     noImages: "No hay imágenes disponibles.",
     imageSearchPlaceholder: "Buscar por título, etiquetas, descripción, autor...",

+ 2 - 1
src/client/assets/translations/oasis_eu.js

@@ -1172,6 +1172,7 @@ module.exports = {
     transfersUnconfirmedSectionTitle: "Transferentziak Berretsi Gabe",
     transfersClosedSectionTitle: "Transferentziak Itxita",
     transfersDiscardedSectionTitle: "Transferentziak Baztertuta",
+    transfersCreateSectionTitle: "Sortu transferentzia",
     transfersAllSectionTitle: "Transferentziak",   
     transfersFilterFavs: "Gustukoak",
     transfersFavsSectionTitle: "Gustuko transferentziak",
@@ -1367,7 +1368,7 @@ module.exports = {
     imageTitlePlaceholder: "Aukerakoa",
     imageDescriptionLabel: "Deskribapena",
     imageDescriptionPlaceholder: "Aukerakoa",
-    imageMemeLabel: "MEME gisa markatu",
+    imageMemeLabel: "¿MEME?",
     imageNoFile: "Ez da irudi-fitxategirik eman",
     noImages: "Ez dago irudirik erabilgarri.",
     imageSearchPlaceholder: "Bilatu izenburua, etiketak, deskribapena, egilea...",

+ 2 - 1
src/client/assets/translations/oasis_fr.js

@@ -1155,6 +1155,7 @@ module.exports = {
     transfersUnconfirmedSectionTitle: "Transferts non confirmés",
     transfersClosedSectionTitle: "Transferts fermés",
     transfersDiscardedSectionTitle: "Transferts rejetés",
+    transfersCreateSectionTitle: "Créer un transfert",
     transfersAllSectionTitle: "Transferts",
     transfersFilterFavs: "Favoris",
     transfersFavsSectionTitle: "Transferts favoris",
@@ -1350,7 +1351,7 @@ module.exports = {
     imageTitlePlaceholder: "Facultatif",
     imageDescriptionLabel: "Description",
     imageDescriptionPlaceholder: "Facultatif",
-    imageMemeLabel: "Marquer comme MÈME",
+    imageMemeLabel: "MÈME ?",
     imageNoFile: "Aucun fichier image fourni",
     noImages: "Aucune image disponible.",
     imageSearchPlaceholder: "Rechercher par titre, tags, description, auteur…",

Diferenças do arquivo suprimidas por serem muito extensas
+ 2654 - 0
src/client/assets/translations/oasis_hi.js


+ 2 - 1
src/client/assets/translations/oasis_it.js

@@ -1164,6 +1164,7 @@ module.exports = {
     transfersUnconfirmedSectionTitle: "Unconfirmed Transfers",
     transfersClosedSectionTitle: "Closed Transfers",
     transfersDiscardedSectionTitle: "Discarded Transfers",
+    transfersCreateSectionTitle: "Create Transfer",
     transfersAllSectionTitle: "Trasferimenti",
     transfersFilterFavs: "Preferiti",
     transfersFavsSectionTitle: "Favorite transfers",
@@ -1359,7 +1360,7 @@ module.exports = {
     imageTitlePlaceholder: "Optional",
     imageDescriptionLabel: "Descrizione",
     imageDescriptionPlaceholder: "Optional",
-    imageMemeLabel: "Mark as MEME",
+    imageMemeLabel: "¿MEME?",
     imageNoFile: "No image file provided",
     noImages: "No images available.",
     imageSearchPlaceholder: "Search title, tags, description, author...",

+ 2 - 1
src/client/assets/translations/oasis_pt.js

@@ -1164,6 +1164,7 @@ module.exports = {
     transfersUnconfirmedSectionTitle: "Unconfirmed Transfers",
     transfersClosedSectionTitle: "Closed Transfers",
     transfersDiscardedSectionTitle: "Discarded Transfers",
+    transfersCreateSectionTitle: "Create Transfer",
     transfersAllSectionTitle: "Transferências",
     transfersFilterFavs: "Favoritos",
     transfersFavsSectionTitle: "Favorite transfers",
@@ -1359,7 +1360,7 @@ module.exports = {
     imageTitlePlaceholder: "Optional",
     imageDescriptionLabel: "Descrição",
     imageDescriptionPlaceholder: "Optional",
-    imageMemeLabel: "Mark as MEME",
+    imageMemeLabel: "¿MEME?",
     imageNoFile: "No image file provided",
     noImages: "No images available.",
     imageSearchPlaceholder: "Search title, tags, description, author...",

Diferenças do arquivo suprimidas por serem muito extensas
+ 2613 - 0
src/client/assets/translations/oasis_ru.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 2655 - 0
src/client/assets/translations/oasis_zh.js


+ 1 - 1
src/configs/blockchain-cycle.json

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

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

@@ -59,4 +59,4 @@
   },
   "homePage": "activity",
   "language": "en"
-}
+}

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

@@ -3,7 +3,7 @@
     "level": "notice"
   },
   "caps": {
-    "shs": "1BIWr6Hu+MgtNkkClvg2GAi+0HiAikGOOTd/pIUcH54="
+    "shs": "zTmidAb7t+tKi7W93FIHbOvlbd936x6G/vm8e8Td//A="
   },
   "pub": false,
   "local": true,
@@ -28,7 +28,7 @@
   },
   "connections": {
     "seeds": [
-      "net:solarnethub.com:8008~shs:zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519"
+      "net:solarnethub.com:8008~shs:mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.ed25519"
     ],
     "incoming": {
       "net": [

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

@@ -1,4 +1,7 @@
 {
   "name": "SNH \"La Plaza\"",
-  "code": "solarnethub.com:8008:@zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519~5qLNt94SWwfXwLFBSco0axXLJ1g7640QULTvC2t2eNk="
+  "description": "A shared place to begin a utopia ...",
+  "url": "https://pub.solarnethub.com",
+  "code": "solarnethub.com:8008:@mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.ed25519~jc1zvYi1f5R5fv228U31fwOjjdwBg0sTGuj5fz8mW5g=",
+  "createdAt": "2026-03-04T00:00:00.000Z"
 }

+ 60 - 1
src/models/feed_model.js

@@ -255,6 +255,8 @@ module.exports = ({ cooler }) => {
 
       const opinionsInhabitants = new Set(Array.isArray(content.opinions_inhabitants) ? content.opinions_inhabitants : []);
 
+      let commentCount = 0;
+
       const actions = idx.actionsByRoot.get(root) || [];
       for (const a of actions) {
         const ac = a?.value?.content || {};
@@ -277,12 +279,18 @@ module.exports = ({ cooler }) => {
           }
           continue;
         }
+
+        if (ac.action === "comment") {
+          commentCount++;
+          continue;
+        }
       }
 
       content.refeeds = refeeds;
       content.refeeds_inhabitants = Array.from(refeedsInhabitants);
       content.opinions = opinionsCounts;
       content.opinions_inhabitants = Array.from(opinionsInhabitants);
+      content.commentCount = commentCount;
 
       if (!Array.isArray(content.tags)) content.tags = extractTags(content.text);
 
@@ -325,6 +333,57 @@ module.exports = ({ cooler }) => {
     return feeds;
   };
 
-  return { createFeed, createRefeed, addOpinion, listFeeds, resolveCurrentId };
+  const getFeedById = async (feedId) => {
+    const ssbClient = await openSsb();
+    const idx = await buildIndex(ssbClient);
+    const currentId = idx.resolve(feedId);
+    if (idx.tombstoned.has(currentId)) return null;
+    const msg = idx.feedsById.get(currentId);
+    if (!msg) return null;
+    const actions = idx.actionsByRoot.get(currentId) || [];
+    const content = msg.value?.content || {};
+    const opinions = {};
+    const opinionsInhabitants = [];
+    const refeedsInhabitants = [];
+    let refeeds = 0;
+    let commentCount = 0;
+    for (const a of actions) {
+      const ac = a?.value?.content || {};
+      if (ac.type === "feed-action" && ac.action === "opinion" && ac.category) {
+        opinions[ac.category] = (opinions[ac.category] || 0) + 1;
+        if (ac.author || a?.value?.author) opinionsInhabitants.push(ac.author || a.value.author);
+      }
+      if (ac.type === "feed-action" && ac.action === "refeed") {
+        refeeds++;
+        if (ac.author || a?.value?.author) refeedsInhabitants.push(ac.author || a.value.author);
+      }
+      if (ac.type === "feed-action" && ac.action === "comment") {
+        commentCount++;
+      }
+    }
+    const merged = { ...content, opinions, opinions_inhabitants: opinionsInhabitants, refeeds_inhabitants: refeedsInhabitants, refeeds, commentCount };
+    return { key: currentId, value: { ...msg.value, content: merged } };
+  };
+
+  const getComments = async (feedId) => {
+    const ssbClient = await openSsb();
+    const idx = await buildIndex(ssbClient);
+    const currentId = idx.resolve(feedId);
+    const actions = idx.actionsByRoot.get(currentId) || [];
+    return actions
+      .filter(a => a?.value?.content?.type === "feed-action" && a?.value?.content?.action === "comment")
+      .sort((a, b) => (a?.value?.timestamp || 0) - (b?.value?.timestamp || 0));
+  };
+
+  const addComment = async (feedId, text) => {
+    const ssbClient = await openSsb();
+    const idx = await buildIndex(ssbClient);
+    const currentId = idx.resolve(feedId);
+    await new Promise((resolve, reject) => {
+      ssbClient.publish({ type: "feed-action", action: "comment", root: currentId, text: cleanText(text) }, (err) => (err ? reject(err) : resolve()));
+    });
+  };
+
+  return { createFeed, createRefeed, addOpinion, listFeeds, resolveCurrentId, getFeedById, getComments, addComment };
 };
 

+ 137 - 11
src/models/stats_model.js

@@ -113,6 +113,133 @@ module.exports = ({ cooler }) => {
     return Array.from(pick.values());
   };
 
+  const inferType = (c = {}) => {
+    if (c.vote) return 'vote';
+    if (c.votes) return 'votes';
+    if (c.address && c.coin === 'ECO' && c.type === 'wallet') return 'bankWallet';
+    if (typeof c.amount !== 'undefined' && c.epochId && c.allocationId) return 'bankClaim';
+    if (typeof c.item_type !== 'undefined' && typeof c.status !== 'undefined') return 'market';
+    if (typeof c.goal !== 'undefined' && typeof c.progress !== 'undefined') return 'project';
+    if (typeof c.members !== 'undefined' && typeof c.isAnonymous !== 'undefined') return 'tribe';
+    if (typeof c.date !== 'undefined' && typeof c.location !== 'undefined') return 'event';
+    if (typeof c.priority !== 'undefined' && typeof c.status !== 'undefined' && c.title) return 'task';
+    if (typeof c.confirmations !== 'undefined' && typeof c.severity !== 'undefined') return 'report';
+    if (typeof c.job_type !== 'undefined' && typeof c.status !== 'undefined') return 'job';
+    if (typeof c.url !== 'undefined' && typeof c.mimeType !== 'undefined' && c.type === 'audio') return 'audio';
+    if (typeof c.url !== 'undefined' && typeof c.mimeType !== 'undefined' && c.type === 'video') return 'video';
+    if (typeof c.url !== 'undefined' && c.title && c.key) return 'document';
+    if (typeof c.text !== 'undefined' && typeof c.refeeds !== 'undefined') return 'feed';
+    if (typeof c.text !== 'undefined' && typeof c.contentWarning !== 'undefined') return 'post';
+    if (typeof c.contact !== 'undefined') return 'contact';
+    if (typeof c.about !== 'undefined') return 'about';
+    if (typeof c.concept !== 'undefined' && typeof c.amount !== 'undefined' && c.status) return 'transfer';
+    return '';
+  };
+
+  const normalizeActionType = (a) => {
+    const t = a.type || a.content?.type || inferType(a.content) || '';
+    return String(t).toLowerCase();
+  };
+
+  const priorityBump = (p) => {
+    const s = String(p || '').toUpperCase();
+    if (s === 'HIGH') return 3;
+    if (s === 'MEDIUM') return 1;
+    return 0;
+  };
+
+  const severityBump = (s) => {
+    const x = String(s || '').toUpperCase();
+    if (x === 'CRITICAL') return 6;
+    if (x === 'HIGH') return 4;
+    if (x === 'MEDIUM') return 2;
+    return 0;
+  };
+
+  const calculateOpinionScore = (content) => {
+    const cats = content?.opinions || {};
+    let s = 0;
+    for (const k in cats) {
+      if (!Object.prototype.hasOwnProperty.call(cats, k)) continue;
+      if (k === 'interesting' || k === 'inspiring') s += 5;
+      else if (k === 'boring' || k === 'spam' || k === 'propaganda') s -= 3;
+      else s += 1;
+    }
+    return s;
+  };
+
+  const scoreMarketItem = (c) => {
+    const st = String(c.status || '').toUpperCase();
+    let s = 5;
+    if (st === 'SOLD') s += 8;
+    else if (st === 'ACTIVE') s += 3;
+    const bids = Array.isArray(c.auctions_poll) ? c.auctions_poll.length : 0;
+    s += Math.min(10, bids);
+    return s;
+  };
+
+  const scoreProjectItem = (c) => {
+    const st = String(c.status || 'ACTIVE').toUpperCase();
+    const prog = Number(c.progress || 0);
+    let s = 8 + Math.min(10, prog / 10);
+    if (st === 'FUNDED') s += 10;
+    return s;
+  };
+
+  const computeKarmaFromMsgs = (msgs) => {
+    let score = 0;
+    for (const m of msgs) {
+      const c = m.value?.content || {};
+      const t = normalizeActionType({ type: c.type, content: c });
+      const rawType = String(c.type || '').toLowerCase();
+      if (t === 'post') score += 10;
+      else if (t === 'comment') score += 5;
+      else if (t === 'like') score += 2;
+      else if (t === 'image') score += 8;
+      else if (t === 'video') score += 12;
+      else if (t === 'audio') score += 8;
+      else if (t === 'document') score += 6;
+      else if (t === 'bookmark') score += 2;
+      else if (t === 'feed') score += 6;
+      else if (t === 'forum') score += c.root ? 5 : 10;
+      else if (t === 'vote') score += 3 + calculateOpinionScore(c);
+      else if (t === 'votes') score += Math.min(10, Number(c.totalVotes || 0));
+      else if (t === 'market') score += scoreMarketItem(c);
+      else if (t === 'project') score += scoreProjectItem(c);
+      else if (t === 'tribe') score += 6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0);
+      else if (t === 'event') score += 4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0);
+      else if (t === 'task') score += 3 + priorityBump(c.priority);
+      else if (t === 'report') score += 4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity);
+      else if (t === 'curriculum') score += 5;
+      else if (t === 'aiexchange') score += Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0;
+      else if (t === 'job') score += 4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0);
+      else if (t === 'bankclaim') score += Math.min(20, Math.log(1 + Math.max(0, Number(c.amount) || 0)) * 5);
+      else if (t === 'bankwallet') score += 2;
+      else if (t === 'transfer') score += 1;
+      else if (t === 'about') score += 1;
+      else if (t === 'contact') score += 1;
+      else if (t === 'pub') score += 1;
+      else if (t === 'parliamentcandidature' || rawType === 'parliamentcandidature') score += 12;
+      else if (t === 'parliamentterm' || rawType === 'parliamentterm') score += 25;
+      else if (t === 'parliamentproposal' || rawType === 'parliamentproposal') score += 8;
+      else if (t === 'parliamentlaw' || rawType === 'parliamentlaw') score += 16;
+      else if (t === 'parliamentrevocation' || rawType === 'parliamentrevocation') score += 10;
+      else if (t === 'courts_case' || t === 'courtscase' || rawType === 'courts_case') score += 4;
+      else if (t === 'courts_evidence' || t === 'courtsevidence' || rawType === 'courts_evidence') score += 3;
+      else if (t === 'courts_answer' || t === 'courtsanswer' || rawType === 'courts_answer') score += 4;
+      else if (t === 'courts_verdict' || t === 'courtsverdict' || rawType === 'courts_verdict') score += 10;
+      else if (t === 'courts_settlement' || t === 'courtssettlement' || rawType === 'courts_settlement') score += 8;
+      else if (t === 'courts_nomination' || t === 'courtsnomination' || rawType === 'courts_nomination') score += 6;
+      else if (t === 'courts_nom_vote' || t === 'courtsnomvote' || rawType === 'courts_nom_vote') score += 3;
+      else if (t === 'courts_public_pref' || t === 'courtspublicpref' || rawType === 'courts_public_pref') score += 1;
+      else if (t === 'courts_mediators' || t === 'courtsmediators' || rawType === 'courts_mediators') score += 6;
+      else if (t === 'courts_open_support' || t === 'courtsopensupport' || rawType === 'courts_open_support') score += 2;
+      else if (t === 'courts_verdict_vote' || t === 'courtsverdictvote' || rawType === 'courts_verdict_vote') score += 3;
+      else if (t === 'courts_judge_assign' || t === 'courtsjudgeassign' || rawType === 'courts_judge_assign') score += 5;
+    }
+    return Math.max(0, Math.round(score));
+  };
+
   const getStats = async (filter = 'ALL') => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
@@ -211,21 +338,20 @@ module.exports = ({ cooler }) => {
       opinions[t] = vals.filter(e => Array.isArray(e.opinions_inhabitants) && e.opinions_inhabitants.length > 0).length || 0;
     }
 
-    const karmaMsgsAll = allMsgs.filter(m => m.value?.content?.type === 'karmaScore' && Number.isFinite(Number(m.value.content.karmaScore)));
     if (filter === 'MINE') {
-      const mine = karmaMsgsAll.filter(m => m.value.author === userId).sort((a, b) => (b.value.timestamp || 0) - (a.value.timestamp || 0));
-      const myKarma = mine.length ? Number(mine[0].value.content.karmaScore) || 0 : 0;
-      content['karmaScore'] = myKarma;
+      const myMsgs = allMsgs.filter(m => m.value.author === userId);
+      content['karmaScore'] = computeKarmaFromMsgs(myMsgs);
     } else {
-      const latestByAuthor = new Map();
-      for (const m of karmaMsgsAll) {
+      const msgsByAuthor = new Map();
+      for (const m of allMsgs) {
         const a = m.value.author;
-        const ts = m.value.timestamp || 0;
-        const k = Number(m.value.content.karmaScore) || 0;
-        const prev = latestByAuthor.get(a);
-        if (!prev || ts > prev.ts) latestByAuthor.set(a, { ts, k });
+        if (!msgsByAuthor.has(a)) msgsByAuthor.set(a, []);
+        msgsByAuthor.get(a).push(m);
+      }
+      let sumKarma = 0;
+      for (const authorMsgs of msgsByAuthor.values()) {
+        sumKarma += computeKarmaFromMsgs(authorMsgs);
       }
-      const sumKarma = Array.from(latestByAuthor.values()).reduce((s, x) => s + x.k, 0);
       content['karmaScore'] = sumKarma;
     }
 

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

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

+ 1 - 1
src/server/package.json

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

+ 1 - 1
src/views/activity_view.js

@@ -553,7 +553,7 @@ function renderActionCards(actions, userId, allActions) {
       const { url } = content;
       cardBody.push(
         div({ class: 'card-section image' },
-          img({ src: `/blob/${encodeURIComponent(url)}`, class: 'activity-image-thumb' })
+          img({ src: `/blob/${encodeURIComponent(url)}`, class: 'post-image' })
         )
       );
     }

+ 9 - 28
src/views/audio_view.js

@@ -195,8 +195,6 @@ const renderAudioList = (audios, filter, params = {}) => {
           ),
           title ? h2(title) : null,
           renderAudioPlayer(audioObj),
-          safeText(audioObj.description) ? p(...renderUrl(audioObj.description)) : null,
-          renderTags(audioObj.tags),
           div(
             { class: "card-comments-summary" },
             span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
@@ -229,22 +227,7 @@ const renderAudioList = (audios, filter, params = {}) => {
                   )
                 : null
             );
-          })(),
-          div(
-            { class: "voting-buttons" },
-            opinionCategories.map((category) =>
-              form(
-                { method: "POST", action: `/audios/opinions/${encodeURIComponent(audioObj.key)}/${category}` },
-                input({ type: "hidden", name: "returnTo", value: returnTo }),
-                button(
-                  { class: "vote-btn" },
-                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
-                    audioObj.opinions?.[category] || 0
-                  }]`
-                )
-              )
-            )
-          )
+          })()
         );
       })
     : p(params.q ? i18n.audioNoMatch : i18n.noAudios);
@@ -266,6 +249,14 @@ const renderAudioForm = (filter, audioId, audioToEdit, params = {}) => {
       input({ type: "file", name: "audio", required: filter !== "edit" }),
       br(),
       br(),
+      span(i18n.audioTitleLabel),
+      br(),
+      input({ type: "text", name: "title", placeholder: i18n.audioTitlePlaceholder, value: audioToEdit?.title || "" }),
+      br(),
+      span(i18n.audioDescriptionLabel),
+      br(),
+      textarea({ name: "description", placeholder: i18n.audioDescriptionPlaceholder, rows: "4" }, audioToEdit?.description || ""),
+      br(),
       span(i18n.audioTagsLabel),
       br(),
       input({
@@ -276,16 +267,6 @@ const renderAudioForm = (filter, audioId, audioToEdit, params = {}) => {
       }),
       br(),
       br(),
-      span(i18n.audioTitleLabel),
-      br(),
-      input({ type: "text", name: "title", placeholder: i18n.audioTitlePlaceholder, value: audioToEdit?.title || "" }),
-      br(),
-      br(),
-      span(i18n.audioDescriptionLabel),
-      br(),
-      textarea({ name: "description", placeholder: i18n.audioDescriptionPlaceholder, rows: "4" }, audioToEdit?.description || ""),
-      br(),
-      br(),
       button({ type: "submit" }, filter === "edit" ? i18n.audioUpdateButton : i18n.audioCreateButton)
     )
   );

+ 13 - 31
src/views/bookmark_view.js

@@ -196,8 +196,7 @@ const renderBookmarkList = (filteredBookmarks, filter, params = {}) => {
           renderCardField(i18n.bookmarkUrlLabel + ":", urlLink),
           renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt),
           renderCardField(i18n.bookmarkCategoryLabel + ":", safeText(bookmark.category) || i18n.noCategory),
-          safeText(bookmark.description) ? p(...renderUrl(bookmark.description)) : null,
-          renderTags(bookmark.tags),
+          br,
           div(
             { class: "card-comments-summary" },
             span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
@@ -213,7 +212,6 @@ const renderBookmarkList = (filteredBookmarks, filter, params = {}) => {
               button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
             )
           ),
-          br(),
           (() => {
             const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN;
             const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN;
@@ -230,20 +228,7 @@ const renderBookmarkList = (filteredBookmarks, filter, params = {}) => {
                   )
                 : null
             );
-          })(),
-          div(
-            { class: "voting-buttons" },
-            opinionCategories.map((category) =>
-              form(
-                { method: "POST", action: `/bookmarks/opinions/${encodeURIComponent(bookmark.id)}/${category}` },
-                input({ type: "hidden", name: "returnTo", value: returnTo }),
-                button(
-                  { class: "vote-btn" },
-                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${bookmark.opinions?.[category] || 0}]`
-                )
-              )
-            )
-          )
+          })()
         );
       })
     : p(params.q ? i18n.bookmarkNoMatch : i18n.noBookmarks);
@@ -284,15 +269,13 @@ const renderBookmarkForm = (filter, bookmarkId, bookmarkToEdit, tags, params = {
         filter === "edit" ? bookmarkToEdit.description || "" : ""
       ),
       br(),
-      br(),
-      label(i18n.bookmarkTagsLabel),
+      label(i18n.bookmarkLastVisitLabel),
       br(),
       input({
-        type: "text",
-        name: "tags",
-        id: "tags",
-        placeholder: i18n.bookmarkTagsPlaceholder,
-        value: filter === "edit" ? safeArr(tags).join(", ") : ""
+        type: "datetime-local",
+        name: "lastVisit",
+        max: lastVisitMax,
+        value: filter === "edit" ? lastVisitValue : ""
       }),
       br(),
       br(),
@@ -306,14 +289,14 @@ const renderBookmarkForm = (filter, bookmarkId, bookmarkToEdit, tags, params = {
         value: filter === "edit" ? bookmarkToEdit.category || "" : ""
       }),
       br(),
-      br(),
-      label(i18n.bookmarkLastVisitLabel),
+      label(i18n.bookmarkTagsLabel),
       br(),
       input({
-        type: "datetime-local",
-        name: "lastVisit",
-        max: lastVisitMax,
-        value: filter === "edit" ? lastVisitValue : ""
+        type: "text",
+        name: "tags",
+        id: "tags",
+        placeholder: i18n.bookmarkTagsPlaceholder,
+        value: filter === "edit" ? safeArr(tags).join(", ") : ""
       }),
       br(),
       br(),
@@ -467,7 +450,6 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par
           const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN;
           const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN;
           const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
-
           return p(
             { class: "card-footer" },
             span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),

+ 1 - 2
src/views/cipher_view.js

@@ -49,7 +49,7 @@ const cipherView = async (encryptedText = "", decryptedText = "", iv = "", passw
       value: encryptedText
     }),
     br(),
-    label(i18n.cipherPasswordLabel),
+    label(i18n.cipherPasswordDecryptLabel),
     br(),
     input({
       type: "password",
@@ -102,4 +102,3 @@ const cipherView = async (encryptedText = "", decryptedText = "", iv = "", passw
 };
 
 exports.cipherView = cipherView;
-

+ 3 - 3
src/views/courts_view.js

@@ -83,7 +83,6 @@ const CaseForm = () =>
         placeholder: 'Subject or short description'
       }),
       br(),
-      br(),
       label('Case type'),
       br(),
       select(
@@ -98,10 +97,11 @@ const CaseForm = () =>
         type: 'text',
         name: 'respondentId',
         placeholder: i18n.courtsCaseRespondentPh,
-        required: true
+        required: true,
+        pattern: '^@[A-Za-z0-9+/]+=*\\.ed25519$',
+        title: i18n.courtsRespondentInvalid || 'Must be a valid SSB ID (@...ed25519)'
       }),
       br(),
-      br(),
       label(i18n.courtsCaseMethod),
       br(),
       select(

+ 5 - 22
src/views/document_view.js

@@ -179,8 +179,6 @@ const renderDocumentList = (documents, filter, params = {}) => {
           doc?.url
             ? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` })
             : p(i18n.documentNoFile),
-          safeText(doc.description) ? p(...renderUrl(doc.description)) : null,
-          renderTags(doc.tags),
           div(
             { class: "card-comments-summary" },
             span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
@@ -213,20 +211,7 @@ const renderDocumentList = (documents, filter, params = {}) => {
                   )
                 : null
             );
-          })(),
-          div(
-            { class: "voting-buttons" },
-            opinionCategories.map((category) =>
-              form(
-                { method: "POST", action: `/documents/opinions/${encodeURIComponent(doc.key)}/${category}` },
-                input({ type: "hidden", name: "returnTo", value: returnTo }),
-                button(
-                  { class: "vote-btn" },
-                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${doc.opinions?.[category] || 0}]`
-                )
-              )
-            )
-          )
+          })()
         );
       })
     : p(params.q ? i18n.documentNoMatch : i18n.noDocuments);
@@ -251,20 +236,18 @@ const renderDocumentForm = (filter, documentId, docToEdit, params = {}) => {
       input({ type: "file", name: "document", accept: "application/pdf", required: filter !== "edit" }),
       br(),
       br(),
-      label(i18n.documentTagsLabel),
-      br(),
-      input({ type: "text", name: "tags", placeholder: i18n.documentTagsPlaceholder, value: tagsValue }),
-      br(),
-      br(),
       label(i18n.documentTitleLabel),
       br(),
       input({ type: "text", name: "title", placeholder: i18n.documentTitlePlaceholder, value: docToEdit?.title || "" }),
       br(),
-      br(),
       label(i18n.documentDescriptionLabel),
       br(),
       textarea({ name: "description", placeholder: i18n.documentDescriptionPlaceholder, rows: "4" }, docToEdit?.description || ""),
       br(),
+      label(i18n.documentTagsLabel),
+      br(),
+      input({ type: "text", name: "tags", placeholder: i18n.documentTagsPlaceholder, value: tagsValue }),
+      br(),
       br(),
       button({ type: "submit" }, filter === "edit" ? i18n.documentUpdateButton : i18n.documentCreateButton)
     )

+ 15 - 36
src/views/event_view.js

@@ -75,6 +75,7 @@ const renderEventOwnerActions = (e, returnTo) => {
 const renderEventAttendAction = (e, isAttending, returnTo) => {
   const st = normalizeEventStatus(e.status);
   if (st !== "OPEN") return null;
+  if (e.organizer === userId) return null;
   return form(
     { method: "POST", action: `/events/attend/${encodeURIComponent(e.id)}` },
     input({ type: "hidden", name: "returnTo", value: returnTo }),
@@ -214,27 +215,8 @@ const renderEventItem = (e, filter) => {
     renderCardField(i18n.eventStatus + ":", eventStatusLabel(e.status)),
     urlHref ? renderCardField(i18n.eventUrlLabel + ":", a({ href: urlHref, target: "_blank", rel: "noopener noreferrer" }, urlHref)) : null,
     renderCardField(i18n.eventPriceLabel + ":", parseFloat(e.price || 0).toFixed(6) + " ECO"),
-    br(),
-    div(
-      { class: "card-field" },
-      span({ class: "card-label" }, i18n.eventAttendees + ":"),
-      span(
-        { class: "card-value" },
-        attendees.length
-          ? attendees
-              .filter(Boolean)
-              .map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)])
-              .flat()
-          : i18n.noAttendees
-      )
-    ),
-    br(),
-    e.tags && e.tags.filter(Boolean).length
-      ? div(
-          { class: "card-tags" },
-          e.tags.filter(Boolean).map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
-        )
-      : null,
+    renderCardField(i18n.eventAttendees + ":", String(attendees.length)),
+    br,
     div(
       { class: "card-comments-summary" },
       span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
@@ -330,7 +312,8 @@ exports.eventView = async (events, filter, eventId, returnTo) => {
             form(
               {
                 action: currentFilter === "edit" ? `/events/update/${encodeURIComponent(eventId)}` : "/events/create",
-                method: "POST"
+                method: "POST",
+                enctype: "multipart/form-data"
               },
               input({ type: "hidden", name: "returnTo", value: ret }),
               label(i18n.eventTitleLabel),
@@ -343,7 +326,6 @@ exports.eventView = async (events, filter, eventId, returnTo) => {
                 value: currentFilter === "edit" ? eventToEdit.title || "" : ""
               }),
               br(),
-              br(),
               label(i18n.eventDescriptionLabel),
               br(),
               textarea(
@@ -351,6 +333,10 @@ exports.eventView = async (events, filter, eventId, returnTo) => {
                 currentFilter === "edit" ? eventToEdit.description || "" : ""
               ),
               br(),
+              label(i18n.uploadMedia),
+              br(),
+              input({ type: "file", name: "image", accept: "image/*" }),
+              br(),
               br(),
               label(i18n.eventDateLabel),
               br(),
@@ -383,7 +369,6 @@ exports.eventView = async (events, filter, eventId, returnTo) => {
                 value: currentFilter === "edit" ? eventToEdit.location || "" : ""
               }),
               br(),
-              br(),
               label(i18n.eventUrlLabel),
               br(),
               input({ type: "url", name: "url", id: "url", value: currentFilter === "edit" ? eventToEdit.url || "" : "" }),
@@ -451,6 +436,12 @@ exports.singleEventView = async (event, filter, comments = []) => {
         renderCardField(i18n.eventStatus + ":", eventStatusLabel(event.status)),
         urlHref ? renderCardField(i18n.eventUrlLabel + ":", a({ href: urlHref, target: "_blank", rel: "noopener noreferrer" }, urlHref)) : null,
         renderCardField(i18n.eventPriceLabel + ":", parseFloat(event.price || 0).toFixed(6) + " ECO"),
+        event.tags && event.tags.filter(Boolean).length
+          ? div(
+              { class: "card-tags" },
+              event.tags.filter(Boolean).map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
+            )
+          : null,
         br(),
         div(
           { class: "card-field" },
@@ -466,18 +457,6 @@ exports.singleEventView = async (event, filter, comments = []) => {
           )
         ),
         br(),
-        event.tags && event.tags.filter(Boolean).length
-          ? div(
-              { class: "card-tags" },
-              event.tags.filter(Boolean).map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
-            )
-          : null,
-        div(
-          { class: "card-comments-summary" },
-          span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
-          span({ class: "card-value" }, String(commentCount))
-        ),
-        br(),
         p(
           { class: "card-footer" },
           span({ class: "date-link" }, `${moment(event.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),

+ 161 - 16
src/views/feed_view.js

@@ -1,9 +1,11 @@
-const { div, h2, p, section, button, form, a, span, textarea, br, input, h1 } = require("../server/node_modules/hyperaxe");
+const { div, h2, p, section, button, form, a, span, textarea, br, input, h1, label } = require("../server/node_modules/hyperaxe");
 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 moment = require("../server/node_modules/moment");
 const { sanitizeHtml } = require('../backend/sanitizeHtml');
+const { renderUrl } = require("../backend/renderUrl");
 
 const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1);
 const FEED_TEXT_MAX = Number(config?.feed?.maxLength ?? 280);
@@ -70,6 +72,76 @@ const renderVotesSummary = (opinions = {}) => {
   );
 };
 
+const renderCardField = (labelText, value) =>
+  div(
+    { class: "card-field" },
+    span({ class: "card-label" }, labelText),
+    span({ class: "card-value" }, value)
+  );
+
+const renderFeedCommentsSection = (feedKey, comments = []) => {
+  const list = Array.isArray(comments) ? comments : [];
+  const commentsCount = list.length;
+
+  return div(
+    { class: "vote-comments-section" },
+    div(
+      { class: "comments-count" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
+      span({ class: "card-value" }, String(commentsCount))
+    ),
+    div(
+      { class: "comment-form-wrapper" },
+      h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel || i18n.feedPostComment || "Post a comment"),
+      form(
+        { method: "POST", action: `/feed/${encodeURIComponent(feedKey)}/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 || "Upload media"), input({ type: "file", name: "blob" })),
+        br(),
+        button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton || i18n.feedPostComment || "Send")
+      )
+    ),
+    list.length
+      ? div(
+          { class: "comments-list" },
+          list.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() : "";
+            const userName = author && author.includes("@") ? author.split("@")[1] : author;
+
+            const content = c?.value?.content || {};
+            const text = content.text || c?.value?.text || "";
+            const threadRoot = content.fork || content.root || null;
+
+            return div(
+              { class: "votations-comment-card" },
+              span(
+                { class: "created-at" },
+                span(i18n.createdBy),
+                author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
+                absDate ? span(" | ") : "",
+                absDate ? span({ class: "votations-comment-date" }, absDate) : "",
+                relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
+                relDate && threadRoot
+                  ? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate)
+                  : ""
+              ),
+              p({ class: "votations-comment-text" }, ...renderUrl(text))
+            );
+          })
+        )
+      : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet || i18n.noComments || "")
+  );
+};
+
 const renderFeedCard = (feed) => {
     const content = feed.value.content || {};
     const rawText = typeof content.text === "string" ? content.text : "";
@@ -86,6 +158,7 @@ const renderFeedCard = (feed) => {
 
     const authorId = content.author || feed.value.author || "";
     const refeedsNum = Number(content.refeeds || 0) || 0;
+    const commentCount = Number(content.commentCount || 0);
     const styledHtml = rewriteHashtagLinks(renderTextWithStyles(safeText));
 
     return div(
@@ -126,21 +199,15 @@ const renderFeedCard = (feed) => {
             )
         ),
         div(
-            { class: "votes-wrapper" },
-            renderVotesSummary(content.opinions || {}),
-            div(
-                { class: "voting-buttons" },
-                opinionCategories.map((cat) =>
-                    form(
-                        { method: "POST", action: `/feed/opinions/${encodeURIComponent(feed.key)}/${cat}` },
-                        button(
-                            { class: alreadyVoted ? "vote-btn disabled" : "vote-btn", type: "submit", disabled: !!alreadyVoted },
-                            `${i18n["vote" + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${content.opinions?.[cat] || 0}]`
-                        )
-                    )
-                )
-            ),
-            alreadyVoted ? p({ class: "muted" }, i18n.alreadyVoted) : null
+            { class: "card-comments-summary" },
+            span({ class: "card-label" }, `${i18n.voteCommentsLabel || "Comments"}:`),
+            span({ class: "card-value" }, String(commentCount)),
+            br(),
+            br(),
+            form(
+                { method: "GET", action: `/feed/${encodeURIComponent(feed.key)}` },
+                button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton || i18n.feedOpenDiscussion || "Open Discussion")
+            )
         )
     );
 };
@@ -240,3 +307,81 @@ exports.feedCreateView = (opts = {}) => {
   );
 };
 
+exports.singleFeedView = (feed, comments = []) => {
+  const content = feed.value?.content || {};
+  const rawText = typeof content.text === "string" ? content.text : "";
+  const safeText = rawText.trim();
+  const authorId = content.author || feed.value?.author || "";
+  const createdAt = formatDate(feed);
+  const styledHtml = rewriteHashtagLinks(renderTextWithStyles(safeText));
+  const me = config?.keys?.id;
+  const alreadyVoted = Array.isArray(content.opinions_inhabitants) && me ? content.opinions_inhabitants.includes(me) : false;
+  const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false;
+  const refeedsNum = Number(content.refeeds || 0) || 0;
+  const tags = extractTags(safeText);
+
+  return template(
+    i18n.feedDetailTitle || "Feed",
+    section(
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/feed", class: "ui-toolbar ui-toolbar--filters" },
+          button({ type: "submit", name: "filter", value: "ALL", class: "filter-btn" }, i18n.ALLButton || "ALL"),
+          button({ type: "submit", name: "filter", value: "MINE", class: "filter-btn" }, i18n.MINEButton || "MINE"),
+          button({ type: "submit", name: "filter", value: "TODAY", class: "filter-btn" }, i18n.TODAYButton || "TODAY"),
+          button({ type: "submit", name: "filter", value: "TOP", class: "filter-btn" }, i18n.TOPButton || "TOP"),
+          form({ method: "GET", action: "/feed/create" }, button({ type: "submit", class: "create-button" }, i18n.createFeedTitle || "Create Feed"))
+        )
+      ),
+      div(
+        { class: "bookmark-item card feed-detail-card" },
+        br,
+        div(
+          { class: "feed-row" },
+          div(
+            { class: "refeed-column" },
+            h1(String(refeedsNum)),
+            form(
+              { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
+              button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", disabled: !!alreadyRefeeded }, i18n.refeedButton)
+            ),
+            alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
+          ),
+          div(
+            { class: "feed-main" },
+            div({ class: "feed-text", innerHTML: sanitizeHtml(styledHtml) }),
+            tags.length
+              ? div(
+                  { class: "card-tags" },
+                  tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
+                )
+              : null,
+            br,
+            p(
+              { class: "card-footer" },
+              span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
+              a({ href: `/author/${encodeURIComponent(authorId)}`, class: "user-link" }, authorId),
+              content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
+            )
+          )
+        ),
+        div(
+          { class: "voting-buttons" },
+          opinionCategories.map((cat) =>
+            form(
+              { method: "POST", action: `/feed/opinions/${encodeURIComponent(feed.key)}/${cat}` },
+              button(
+                { class: alreadyVoted ? "vote-btn disabled" : "vote-btn", type: "submit", disabled: !!alreadyVoted },
+                `${i18n["vote" + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${content.opinions?.[cat] || 0}]`
+              )
+            )
+          )
+        ),
+        alreadyVoted ? p({ class: "muted" }, i18n.alreadyVoted) : null
+      ),
+      renderFeedCommentsSection(feed.key, comments)
+    )
+  );
+};
+

+ 5 - 27
src/views/image_view.js

@@ -134,8 +134,6 @@ const renderImageList = (images, filter, params = {}) => {
           ),
           title ? h2(title) : null,
           renderImageMedia(imgObj, filter, params),
-          safeText(imgObj.description) ? p(...renderUrl(imgObj.description)) : null,
-          renderTags(imgObj.tags),
           div(
             { class: "card-comments-summary" },
             span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
@@ -168,22 +166,7 @@ const renderImageList = (images, filter, params = {}) => {
                   )
                 : null
             );
-          })(),
-          div(
-            { class: "voting-buttons" },
-            opinionCategories.map((category) =>
-              form(
-                { method: "POST", action: `/images/opinions/${encodeURIComponent(imgObj.key)}/${category}` },
-                input({ type: "hidden", name: "returnTo", value: returnTo }),
-                button(
-                  { class: "vote-btn" },
-                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
-                    imgObj.opinions?.[category] || 0
-                  }]`
-                )
-              )
-            )
-          )
+          })()
         );
       })
     : p(params.q ? i18n.imageNoMatch : i18n.noImages);
@@ -211,25 +194,20 @@ const renderImageForm = (filter, imageId, imageToEdit, params = {}) => {
       imageToEdit?.url
         ? img({ src: `/blob/${encodeURIComponent(imageToEdit.url)}`, class: "media-preview", alt: imageToEdit?.title || "" })
         : null,
-      br(),
-      label(i18n.imageTagsLabel),
-      br(),
-      input({ type: "text", name: "tags", placeholder: i18n.imageTagsPlaceholder, value: tagsValue }),
-      br(),
-      br(),
       label(i18n.imageTitleLabel),
       br(),
       input({ type: "text", name: "title", placeholder: i18n.imageTitlePlaceholder, value: imageToEdit?.title || "" }),
       br(),
-      br(),
       label(i18n.imageDescriptionLabel),
       br(),
       textarea({ name: "description", placeholder: i18n.imageDescriptionPlaceholder, rows: "4" }, imageToEdit?.description || ""),
       br(),
-      br(),
       input({ type: "hidden", name: "meme", value: "0" }),
-      label(i18n.imageMemeLabel),
+      label(i18n.imageTagsLabel),
       br(),
+      input({ type: "text", name: "tags", placeholder: i18n.imageTagsPlaceholder, value: tagsValue }),
+      br(),
+      label(i18n.imageMemeLabel),
       input({
         id: "meme-checkbox",
         type: "checkbox",

+ 8 - 1
src/views/invites_view.js

@@ -1,6 +1,7 @@
 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 { renderUrl } = require("../backend/renderUrl");
 const { template, i18n } = require('./main_views');
 
 const homedir = require('os').homedir();
@@ -119,7 +120,13 @@ const invitesView = ({ invitesEnabled }) => {
         br(),
         snhInvite ? div({ class: 'snh-invite-box' },
           h3({ class: 'snh-invite-name' }, snhInvite.name),
-          span({ class: 'snh-invite-code' }, snhInvite.code)
+          p({ class: 'snh-invite-name' }, snhInvite.description),
+          p({ class: 'snh-invite-name' }, renderUrl(snhInvite.url)),
+          snhInvite.createdAt ? p({ class: 'snh-invite-date' }, `${i18n.statsCreatedAt || 'Created'}: ${new Date(snhInvite.createdAt).toLocaleDateString()}`) : null,
+          form({ action: '/settings/invite/accept', method: 'post' },
+            input({ type: 'hidden', name: 'invite', value: snhInvite.code }),
+            button({ type: 'submit', class: 'filter-btn' }, snhInvite.code)
+          )
         ) : null,
         hr(),
         h2(`${i18n.invitesAcceptedInvites} (${activePubs.length})`),

+ 8 - 14
src/views/jobs_view.js

@@ -104,12 +104,13 @@ const renderTags = (tags = []) => {
 const renderApplicantsProgress = (subsCount, vacants) => {
   const s = Math.max(0, Number(subsCount || 0))
   const v = Math.max(1, Number(vacants || 1))
+  const colorClass = s < v ? "applicants-under" : s === v ? "applicants-at" : "applicants-over"
   return div(
     { class: "confirmations-block" },
     div(
       { class: "card-field" },
       span({ class: "card-label" }, `${i18n.jobsApplicants}: `),
-      span({ class: "card-value" }, `${s}/${v}`)
+      span({ class: `card-value ${colorClass}` }, `${s}/${v}`)
     ),
     progress({ class: "confirmations-progress", value: s, max: v })
   )
@@ -235,20 +236,14 @@ const renderJobList = (jobs, filter, params = {}) => {
           topbar ? topbar : null,
           safeText(job.title) ? h2(job.title) : 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,
           br(),
           renderApplicantsProgress(subs.length, job.vacants),
-          renderSubscribers(subs),
-          renderCardField(`${i18n.jobStatus}:`, i18n["jobStatus" + String(job.status || "").toUpperCase()] || String(job.status || "").toUpperCase()),
           renderCardField(`${i18n.jobLanguages}:`, String(job.languages || "").toUpperCase()),
           renderCardField(`${i18n.jobType}:`, i18n["jobType" + String(job.job_type || "").toUpperCase()] || String(job.job_type || "").toUpperCase()),
           renderCardField(`${i18n.jobLocation}:`, String(job.location || "").toUpperCase()),
           renderCardField(`${i18n.jobTime}:`, i18n["jobTime" + String(job.job_time || "").toUpperCase()] || String(job.job_time || "").toUpperCase()),
-          renderCardField(`${i18n.jobVacants}:`, job.vacants),
-          safeText(job.requirements) ? renderCardFieldRich(`${i18n.jobRequirements}:`, renderUrl(job.requirements)) : null,
-          safeText(job.tasks) ? renderCardFieldRich(`${i18n.jobTasks}:`, renderUrl(job.tasks)) : null,
           renderCardFieldRich(`${i18n.jobSalary}:`, [span({ class: "card-salary" }, salaryText)]),
           br(),
           div(
@@ -561,21 +556,20 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
         topbar ? topbar : null,
         safeText(job.title) ? h2(job.title) : 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,
-        br(),
-        renderApplicantsProgress(subs.length, job.vacants),
-        renderSubscribers(subs),
         renderCardField(`${i18n.jobStatus}:`, i18n["jobStatus" + String(job.status || "").toUpperCase()] || String(job.status || "").toUpperCase()),
+        renderCardFieldRich(`${i18n.jobSalary}:`, [span({ class: "card-salary" }, salaryText)]),
+        renderCardField(`${i18n.jobVacants}:`, job.vacants),
         renderCardField(`${i18n.jobLanguages}:`, String(job.languages || "").toUpperCase()),
         renderCardField(`${i18n.jobType}:`, i18n["jobType" + String(job.job_type || "").toUpperCase()] || String(job.job_type || "").toUpperCase()),
         renderCardField(`${i18n.jobLocation}:`, String(job.location || "").toUpperCase()),
         renderCardField(`${i18n.jobTime}:`, i18n["jobTime" + String(job.job_time || "").toUpperCase()] || String(job.job_time || "").toUpperCase()),
-        renderCardField(`${i18n.jobVacants}:`, job.vacants),
         safeText(job.requirements) ? renderCardFieldRich(`${i18n.jobRequirements}:`, renderUrl(job.requirements)) : null,
         safeText(job.tasks) ? renderCardFieldRich(`${i18n.jobTasks}:`, renderUrl(job.tasks)) : null,
-        renderCardFieldRich(`${i18n.jobSalary}:`, [span({ class: "card-salary" }, salaryText)]),
+        renderApplicantsProgress(subs.length, job.vacants),
+        renderSubscribers(subs),
+        br(),
+        tagsNode ? tagsNode : null,
         br(),
         p(
           { class: "card-footer" },

+ 2 - 24
src/views/market_view.js

@@ -515,25 +515,12 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
                       ].filter(Boolean)
 
                   const actionNodes = Array.isArray(actionNodesRaw) ? actionNodesRaw.filter(Boolean) : []
-                  const buttonsBlock =
-                    actionNodes.length > 0
-                      ? div(
-                          { class: "market-card buttons" },
-                          div({ style: "display:flex;gap:8px;flex-wrap:wrap;align-items:center;" }, ...actionNodes)
-                        )
-                      : stockLeft <= 0
-                        ? div(
-                            { class: "market-card buttons" },
-                            div({ class: "card-field" }, span({ class: "card-value" }, i18n.marketOutOfStock))
-                          )
-                        : null
-
                   return div(
                     { class: "market-item" },
                     div(
                       { class: "market-card left-col" },
                       div(
-                        { style: "display:flex;gap:8px;flex-wrap:wrap;align-items:center;" },
+                        { class: "market-owner-actions-inline" },
                         form(
                           { method: "GET", action: `/market/${encodeURIComponent(item.id)}` },
                           input({ type: "hidden", name: "returnTo", value: returnTo }),
@@ -558,15 +545,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
                         { class: "market-card image" },
                         renderMediaBlob(item.image, "/assets/images/default-market.png")
                       ),
-                      p(...renderUrl(item.description)),
-                      item.tags && item.tags.filter(Boolean).length
-                        ? div(
-                            { class: "card-tags" },
-                            item.tags
-                              .filter(Boolean)
-                              .map((tag) => a({ class: "tag-link", href: `/search?query=%23${encodeURIComponent(tag)}` }, `#${tag}`))
-                          )
-                        : null
+                      p(...renderUrl(item.description))
                     ),
                     div(
                       { class: "market-card right-col" },
@@ -611,7 +590,6 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
                           button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
                         )
                       ),
-                      buttonsBlock
                     )
                   )
                 })

+ 2 - 1
src/views/parliament_view.js

@@ -153,6 +153,7 @@ const GovernmentCard = (g, meta) => {
     { class: 'card' },
     h2(i18n.parliamentGovernmentCard),
     GovHeader(g),
+    div({ class: 'method-image-centered' }, img({ src: methodImageSrc(methodKey), alt: methodLabel })),
     div(
       { class: 'table-wrap' },
       applyEl(table, { class: 'table table--centered gov-overview' }, [
@@ -166,7 +167,7 @@ const GovernmentCard = (g, meta) => {
           th(i18n.parliamentEfficiency || '% EFFICIENCY')
         )),
         tbody(tr(
-          td(div({ class: 'method-cell' }, img({ src: methodImageSrc(methodKey), alt: methodLabel }))),
+          td(methodLabel),
           td(String(g.proposed || 0)),
           td(String(g.approved || 0)),
           td(String(g.declined || 0)),

+ 1 - 4
src/views/pixelia_view.js

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

+ 9 - 27
src/views/projects_view.js

@@ -129,9 +129,6 @@ const renderBudget = (project) => {
   const pct = S.goal > 0 ? clamp(Math.round((S.assigned / S.goal) * 100), 0, 100) : 0
   return div(
     { class: `budget-summary${S.exceeded ? " over" : ""}` },
-    renderCardField(i18n.projectBudgetGoal + ":", `${S.goal} ECO`),
-    renderCardField(i18n.projectBudgetAssigned + ":", `${S.assigned} ECO`),
-    renderCardField(i18n.projectBudgetRemaining + ":", `${S.remaining} ECO`),
     S.goal > 0 ? renderProgressBlock(i18n.projectBudgetAssigned + ":", `${S.assigned}/${S.goal}`, pct, 100) : null,
     S.exceeded ? p({ class: "warning" }, i18n.projectBudgetOver) : null
   )
@@ -494,16 +491,12 @@ const renderProjectList = (projects, filter) => {
           { class: `project-card ${statusClass}` },
           topbar ? topbar : null,
           h2(pr.title),
-          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),
+          pr.image ? div({ class: "activity-image-preview" }, renderMediaBlob(pr.image)) : null,
+          div({ class: "project-goal-highlight" }, renderCardField(i18n.projectGoal + ":", `${pr.goal} ECO`)),
+          div({ class: "project-goal-highlight" }, renderCardField(i18n.projectFollowers + ":", String(followersCount(pr)))),
           renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100),
-          renderCardField(i18n.projectGoal + ":", `${pr.goal} ECO`),
-          renderCardField(i18n.projectPledged + ":", `${pr.pledged || 0} ECO`),
           renderProgressBlock(i18n.projectFunding + ":", `${fundingPct}%`, fundingPct, 100),
-          renderCardField(i18n.projectMilestones + ":", `${mileDone}/${mileTotal}`),
-          renderCardField(i18n.projectFollowers + ":", String(followersCount(pr))),
-          renderCardField(i18n.projectBackers + ":", `${backersCount(pr)} · ${backersTotal(pr)} ECO`),
           isMineAuthor
             ? div(
                 { class: "project-admin-block" },
@@ -578,7 +571,6 @@ const renderProjectList = (projects, filter) => {
                 )
               )
             : null,
-            br(),
           div(
             { class: "card-comments-summary" },
             span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
@@ -618,12 +610,10 @@ const renderProjectForm = (project, mode) => {
       br(),
       input({ type: "text", name: "title", required: true, placeholder: i18n.projectTitlePlaceholder, value: pr.title || "" }),
       br(),
-      br(),
       label(i18n.projectDescription),
       br(),
       textarea({ name: "description", rows: "6", required: true, placeholder: i18n.projectDescriptionPlaceholder }, pr.description || ""),
       br(),
-      br(),
       label(i18n.projectImage),
       br(),
       input({ type: "file", name: "image" }),
@@ -645,12 +635,10 @@ const renderProjectForm = (project, mode) => {
       br(),
       input({ type: "text", name: "milestoneTitle", required: true, placeholder: i18n.projectMilestoneTitlePlaceholder }),
       br(),
-      br(),
       label(i18n.projectMilestoneDescription),
       br(),
       textarea({ name: "milestoneDescription", rows: "3", placeholder: i18n.projectMilestoneDescriptionPlaceholder }),
       br(),
-      br(),
       label(i18n.projectMilestoneTargetPercent),
       br(),
       input({ type: "number", name: "milestoneTargetPercent", min: "0", max: "100", step: "1", value: "0" }),
@@ -730,23 +718,16 @@ 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" }, renderMediaBlob(pr.image)) : null,
         safeText(pr.description) ? renderCardFieldRich(i18n.projectDescription + ":", renderUrl(pr.description)) : null,
+        pr.image ? renderMediaBlob(pr.image) : null,
+        div({ class: "project-goal-highlight" }, renderCardField(i18n.projectGoal + ":", `${pr.goal} ECO`)),
+        renderBackers(pr, f),
         renderCardField(i18n.projectStatus + ":", i18n["projectStatus" + statusUpper] || statusUpper),
         renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100),
-        renderCardField(i18n.projectGoal + ":", `${pr.goal} ECO`),
-        renderCardField(i18n.projectPledged + ":", `${pr.pledged || 0} ECO`),
         renderProgressBlock(i18n.projectFunding + ":", `${fundingPct}%`, fundingPct, 100),
-        div(
-          { class: "social-stats" },
-          renderCardField(i18n.projectFollowers + ":", String(followersCount(pr))),
-          renderCardField(i18n.projectBackers + ":", `${backersCount(pr)} · ${backersTotal(pr)} ECO`)
-        ),
         renderBudget(pr),
         renderMilestonesAndBounties(pr, f, isAuthor),
         renderFollowers(pr),
-        br(),
-        renderBackers(pr, f),
         renderPledgeBox(pr, f, isAuthor),
         div(
           { class: "card-footer" },
@@ -760,7 +741,8 @@ exports.singleProjectView = async (project, filter, comments) => {
         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" })),
+          div({ class: "comment-file-upload" }, label(i18n.uploadMedia), br(),
+          input({ type: "file", name: "blob" })),
           br(),
           button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
         )
@@ -781,7 +763,7 @@ exports.singleProjectView = async (project, filter, comments) => {
               )
             })
           )
-        : null
+       : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
     )
   )
 }

+ 5 - 1
src/views/settings_view.js

@@ -92,7 +92,11 @@ const settingsView = ({ version, aiPrompt }) => {
             languageOption("Euskara", "eu"),
             languageOption("Deutsch", "de"),
             languageOption("Italiano", "it"),
-            languageOption("Português", "pt")
+            languageOption("Português", "pt"),
+            languageOption("中文", "zh"),
+            languageOption("العربية", "ar"),
+            languageOption("हिन्दी", "hi"),
+            languageOption("Русский", "ru")
           ]),
           br(),
           br(),

+ 1 - 1
src/views/stats_view.js

@@ -99,7 +99,7 @@ exports.statsView = (stats, filter) => {
             )
           )
         ),
-        div({ style: headerStyle }, h3(`${i18n.bankingUserEngagementScore}: ${C(stats, 'karmaScore')}`)),
+        div({ class: "stats-karma-block" }, h3(`${i18n.bankingUserEngagementScore}: ${C(stats, 'karmaScore')}`)),
         div({ style: headerStyle },
           h3(i18n.statsCarbonFootprintTitle || 'Carbon Footprint'),
           (() => {

+ 19 - 23
src/views/task_view.js

@@ -226,12 +226,6 @@ const renderTaskItem = (task, filter) => {
       )
     ),
     br(),
-    Array.isArray(task.tags) && task.tags.length
-      ? div(
-          { class: "card-tags" },
-          task.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
-        )
-      : null,
     div(
       { class: "card-comments-summary" },
       span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
@@ -333,12 +327,14 @@ exports.taskView = async (tasks, filter, taskId, returnTo) => {
         ? div(
             { class: "task-form" },
             form(
-              { action: currentFilter === "edit" ? `/tasks/update/${encodeURIComponent(taskId)}` : "/tasks/create", method: "POST" },
+              { action: currentFilter === "edit" ? `/tasks/update/${encodeURIComponent(taskId)}` : "/tasks/create", method: "POST", enctype: "multipart/form-data" },
               input({ type: "hidden", name: "returnTo", value: ret }),
               label(i18n.taskTitleLabel), br(),
-              input({ type: "text", name: "title", required: true, value: currentFilter === "edit" ? (editTask.title || "") : "" }), br(), br(),
+              input({ type: "text", name: "title", required: true, value: currentFilter === "edit" ? (editTask.title || "") : "" }), br(),
               label(i18n.taskDescriptionLabel), br(),
-              textarea({ name: "description", required: true, placeholder: i18n.taskDescriptionPlaceholder, rows: "4" }, currentFilter === "edit" ? (editTask.description || "") : ""), br(), br(),
+              textarea({ name: "description", required: true, placeholder: i18n.taskDescriptionPlaceholder, rows: "4" }, currentFilter === "edit" ? (editTask.description || "") : ""), br(),
+              label(i18n.uploadMedia), br(),
+              input({ type: "file", name: "image", accept: "image/*" }), br(),br(),
               label(i18n.taskStartTimeLabel), br(),
               input({
                 type: "datetime-local",
@@ -364,9 +360,9 @@ exports.taskView = async (tasks, filter, taskId, returnTo) => {
                 opt("LOW", !editTask.priority || String(editTask.priority || "").toUpperCase() === "LOW", i18n.taskPriorityLow)
               ), br(), br(),
               label(i18n.taskLocationLabel), br(),
-              input({ type: "text", name: "location", value: editTask.location || "" }), br(), br(),
+              input({ type: "text", name: "location", value: editTask.location || "" }), br(),
               label(i18n.taskTagsLabel), br(),
-              input({ type: "text", name: "tags", value: editTags.join(", ") }), br(), br(),
+              input({ type: "text", name: "tags", value: editTags.join(", ") }), br(),
               label(i18n.taskVisibilityLabel), br(),
               select(
                 { name: "isPublic", id: "isPublic" },
@@ -423,10 +419,15 @@ exports.singleTaskView = async (task, filter, comments = []) => {
         renderCardField(i18n.taskEndTimeLabel + ":", task.endTime ? moment(task.endTime).format("YYYY/MM/DD HH:mm:ss") : ""),
         renderCardField(i18n.taskPriorityLabel + ":", task.priority),
         task.location && String(task.location).trim() ? renderCardField(i18n.taskLocationLabel + ":", task.location) : null,
-        renderCardField(i18n.taskCreatedAt + ":", task.createdAt ? moment(task.createdAt).format(i18n.dateFormat) : ""),
-        renderCardField(i18n.taskBy + ":", a({ href: `/author/${encodeURIComponent(task.author)}`, class: "user-link" }, task.author)),
         renderCardField(i18n.taskStatus + ":", statusLabel(task.status)),
         renderCardField(i18n.taskVisibilityLabel + ":", visibilityLabel(task.isPublic)),
+        Array.isArray(task.tags) && task.tags.length
+          ? div(
+              { class: "card-tags" },
+              task.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
+            )
+          : null,
+        br,
         div(
           { class: "card-field" },
           span({ class: "card-label" }, i18n.taskAssignedTo + ":"),
@@ -437,16 +438,11 @@ exports.singleTaskView = async (task, filter, comments = []) => {
               : i18n.noAssignees
           )
         ),
-        Array.isArray(task.tags) && task.tags.length
-          ? div(
-              { class: "card-tags" },
-              task.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
-            )
-          : null,
-        div(
-          { class: "card-comments-summary" },
-          span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
-          span({ class: "card-value" }, String(commentCount))
+        br,
+        p(
+          { class: "card-footer" },
+          span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+          a({ href: `/author/${encodeURIComponent(task.author)}`, class: "user-link" }, `${task.author}`)
         )
       ),
       renderTaskCommentsSection(task.id, comments, currentFilter)

+ 7 - 29
src/views/transfer_view.js

@@ -105,7 +105,6 @@ const renderTransferTopbar = (transfer, filter, params = {}) => {
 
   const chips = []
   if (isExpired) chips.push(span({ class: "chip chip-warn" }, i18n.transfersExpiredBadge))
-  if (isExpiringSoon) chips.push(span({ class: "chip chip-warn" }, i18n.transfersExpiringSoonBadge))
 
   const leftActions = []
 
@@ -163,7 +162,6 @@ const generateTransferCard = (transfer, filter, params = {}) => {
   const showConfirm = isUnconfirmed && transfer.to === userId && !confirmedBy.includes(userId) && !isExpired
 
   const topbar = renderTransferTopbar(transfer, filter, params)
-  const tagsNode = renderTags(transfer.tags)
 
   return div(
     { class: "transfer-item" },
@@ -173,12 +171,9 @@ const generateTransferCard = (transfer, filter, params = {}) => {
       renderCardField(`${i18n.transfersConcept}:`, transfer.concept || ""),
       renderCardField(`${i18n.transfersDeadline}:`, dl && dl.isValid() ? dl.format("YYYY-MM-DD HH:mm") : ""),
       renderCardField(`${i18n.transfersStatus}:`, i18n[statusKey(transfer.status)] || String(transfer.status || "")),
-      renderCardField(`${i18n.transfersAmount}:`, `${fmtAmount(transfer.amount)} ECO`),
-      renderCardField(`${i18n.transfersFrom}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.from)}` }, transfer.from)),
-      renderCardField(`${i18n.transfersTo}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.to)}` }, transfer.to)),
-      br(),
+      br,
+      div({ class: "transfer-amount-highlight" }, renderCardField(`${i18n.transfersAmount}:`, `${fmtAmount(transfer.amount)} ECO`)),
       renderConfirmationsBar(confirmedCount, required),
-      br(),
       showConfirm
         ? form(
             { method: "POST", action: `/transfers/confirm/${encodeURIComponent(transfer.id)}` },
@@ -188,26 +183,11 @@ const generateTransferCard = (transfer, filter, params = {}) => {
             br()
           )
         : null,
-      tagsNode ? tagsNode : null,
-      tagsNode ? br() : null,
       p(
         { class: "card-footer" },
         span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
         a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: "user-link" }, `${transfer.from}`),
         renderUpdatedLabel(transfer.createdAt, transfer.updatedAt)
-      ),
-      div(
-        { class: "voting-buttons transfer-voting-buttons" },
-        opinionCategories.map(category =>
-          form(
-            { method: "POST", action: `/transfers/opinions/${encodeURIComponent(transfer.id)}/${category}` },
-            input({ type: "hidden", name: "returnTo", value: returnTo }),
-            button(
-              { class: "vote-btn" },
-              `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${transfer.opinions?.[category] || 0}]`
-            )
-          )
-        )
       )
     )
   )
@@ -307,12 +287,10 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => {
               br(),
               input({ type: "text", name: "to", required: true, pattern: "^@[A-Za-z0-9+/]+={0,2}\\.ed25519$", title: i18n.transfersToUserValidation, value: transferToEdit.to || "" }),
               br(),
-              br(),
               label(i18n.transfersConcept),
               br(),
               input({ type: "text", name: "concept", required: true, value: transferToEdit.concept || "" }),
               br(),
-              br(),
               label(i18n.transfersAmount),
               br(),
               input({ type: "number", name: "amount", step: "0.000001", required: true, min: "0.000001", value: transferToEdit.amount || "" }),
@@ -411,15 +389,15 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
         div(
           { class: "card-section transfer" },
           topbar ? topbar : null,
+          renderCardField(`${i18n.transfersFrom}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.from)}` }, transfer.from)),
+          renderCardField(`${i18n.transfersTo}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.to)}` }, transfer.to)),
+          br,
+          div({ class: "transfer-amount-highlight" }, renderCardField(`${i18n.transfersAmount}:`, `${fmtAmount(transfer.amount)} ECO`)),
           renderCardField(`${i18n.transfersConcept}:`, transfer.concept || ""),
           renderCardField(`${i18n.transfersDeadline}:`, dl && dl.isValid() ? dl.format("YYYY-MM-DD HH:mm") : ""),
           renderCardField(`${i18n.transfersStatus}:`, i18n[statusKey(transfer.status)] || String(transfer.status || "")),
-          renderCardField(`${i18n.transfersAmount}:`, `${fmtAmount(transfer.amount)} ECO`),
-          renderCardField(`${i18n.transfersFrom}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.from)}` }, transfer.from)),
-          renderCardField(`${i18n.transfersTo}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.to)}` }, transfer.to)),
-          br(),
+          br,
           renderConfirmationsBar(confirmedCount, required),
-          br(),
           showConfirm
             ? form(
                 { method: "POST", action: `/transfers/confirm/${encodeURIComponent(transfer.id)}` },

+ 8 - 4
src/views/tribes_view.js

@@ -270,9 +270,11 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
           ) : null,
           tr(
             td({ class: 'tribe-info-label' }, i18n.tribeIsAnonymousLabel || 'STATUS'),
-            td({ class: 'tribe-info-value' }, t.isAnonymous ? i18n.tribePrivate : i18n.tribePublic),
+            td({ class: 'tribe-info-value', colspan: '3' }, t.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)
+          ),
+          tr(
             td({ class: 'tribe-info-label' }, i18n.tribeModeLabel || 'MODE'),
-            td({ class: 'tribe-info-value' }, String(inviteModeI18n()[t.inviteMode] || t.inviteMode).toUpperCase())
+            td({ class: 'tribe-info-value', colspan: '3' }, String(inviteModeI18n()[t.inviteMode] || t.inviteMode).toUpperCase())
           ),
           tr(
             td({ class: 'tribe-info-label' }, i18n.tribeLARPLabel || 'L.A.R.P.'),
@@ -1303,9 +1305,11 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
         ) : null,
         tr(
           td({ class: 'tribe-info-label' }, i18n.tribeStatusLabel || 'STATUS'),
-          td({ class: 'tribe-info-value' }, String(statusI18n()[tribe.status] || i18n.tribeStatusOpen).toUpperCase()),
+          td({ class: 'tribe-info-value', colspan: '3' }, String(statusI18n()[tribe.status] || i18n.tribeStatusOpen).toUpperCase())
+        ),
+        tr(
           td({ class: 'tribe-info-label' }, i18n.tribeModeLabel || 'MODE'),
-          td({ class: 'tribe-info-value' }, String(inviteModeI18n()[tribe.inviteMode] || tribe.inviteMode).toUpperCase())
+          td({ class: 'tribe-info-value', colspan: '3' }, String(inviteModeI18n()[tribe.inviteMode] || tribe.inviteMode).toUpperCase())
         ),
         tr(
           td({ class: 'tribe-info-label' }, i18n.tribeLARPLabel || 'L.A.R.P.'),

+ 9 - 28
src/views/video_view.js

@@ -205,8 +205,6 @@ const renderVideoList = (videos, filter, params = {}) => {
           ),
           title ? h2(title) : null,
           renderVideoPlayer(videoObj),
-          safeText(videoObj.description) ? p(...renderUrl(videoObj.description)) : null,
-          renderTags(videoObj.tags),
           div(
             { class: "card-comments-summary" },
             span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
@@ -239,22 +237,7 @@ const renderVideoList = (videos, filter, params = {}) => {
                   )
                 : null
             );
-          })(),
-          div(
-            { class: "voting-buttons" },
-            opinionCategories.map((category) =>
-              form(
-                { method: "POST", action: `/videos/opinions/${encodeURIComponent(videoObj.key)}/${category}` },
-                input({ type: "hidden", name: "returnTo", value: returnTo }),
-                button(
-                  { class: "vote-btn" },
-                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
-                    videoObj.opinions?.[category] || 0
-                  }]`
-                )
-              )
-            )
-          )
+          })()
         );
       })
     : p(params.q ? i18n.videoNoMatch : i18n.noVideos);
@@ -277,6 +260,14 @@ const renderVideoForm = (filter, videoId, videoToEdit, params = {}) => {
       input({ type: "file", name: "video", required: filter !== "edit" }),
       br(),
       br(),
+      span(i18n.videoTitleLabel),
+      br(),
+      input({ type: "text", name: "title", placeholder: i18n.videoTitlePlaceholder, value: videoToEdit?.title || "" }),
+      br(),
+      span(i18n.videoDescriptionLabel),
+      br(),
+      textarea({ name: "description", placeholder: i18n.videoDescriptionPlaceholder, rows: "4" }, videoToEdit?.description || ""),
+      br(),
       span(i18n.videoTagsLabel),
       br(),
       input({
@@ -287,16 +278,6 @@ const renderVideoForm = (filter, videoId, videoToEdit, params = {}) => {
       }),
       br(),
       br(),
-      span(i18n.videoTitleLabel),
-      br(),
-      input({ type: "text", name: "title", placeholder: i18n.videoTitlePlaceholder, value: videoToEdit?.title || "" }),
-      br(),
-      br(),
-      span(i18n.videoDescriptionLabel),
-      br(),
-      textarea({ name: "description", placeholder: i18n.videoDescriptionPlaceholder, rows: "4" }, videoToEdit?.description || ""),
-      br(),
-      br(),
       button({ type: "submit" }, filter === "edit" ? i18n.videoUpdateButton : i18n.videoCreateButton)
     )
   );