Pārlūkot izejas kodu

Oasis release 0.5.6

psy 5 dienas atpakaļ
vecāks
revīzija
5764e3b337

+ 16 - 0
docs/CHANGELOG.md

@@ -13,6 +13,22 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+
+## v0.5.6 - 2025-11-21
+
+### Added
+
+ + Extended post-commenting into various modules (bookmarks, images, audios, videos, documents, votations, events, tasks, reports, market, projects, jobs).
+ 
+### Changed
+
+ + Added details about current proposals at Courts (Courts plugin).
+ + Parliament proposal listing when voting process has started (Parliament plugin).
+ 
+### Fixed
+
+ + Votations deduplication applied when directly voting from Parliament (Votes plugin).
+ 
 ## v0.5.5 - 2025-11-15
 
 ### Added

+ 406 - 57
src/backend/backend.js

@@ -303,6 +303,19 @@ const bankingModel = require("../models/banking_model")({ services: { cooler },
 const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } });
 const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel } });
 
+//votes (comments)
+const getVoteComments = async (voteId) => {
+  const rawComments = await post.topicComments(voteId);
+  const filtered = (rawComments || []).filter(c => {
+    const content = c.value && c.value.content;
+    if (!content) return false;
+    return content.type === 'post' &&
+           content.root === voteId &&
+           content.dest === voteId;
+  });
+  return filtered;
+};
+
 // starting warmup
 about._startNameWarmup();
 
@@ -857,9 +870,20 @@ router
       ctx.redirect('/modules');
       return;
     }
-    const filter = ctx.query.filter || 'all'
+    const filter = ctx.query.filter || 'all';
     const images = await imagesModel.listAll(filter);
-    ctx.body = await imageView(images, filter, null);
+    const commentsCountByImageId = {};
+    await Promise.all(
+      images.map(async img => {
+        const comments = await getVoteComments(img.key);
+        commentsCountByImageId[img.key] = comments.length;
+      })
+    );
+    const enrichedImages = images.map(img => ({
+      ...img,
+      commentCount: commentsCountByImageId[img.key] || 0
+    }));
+    ctx.body = await imageView(enrichedImages, filter, null);
    })
   .get('/images/edit/:id', async ctx => {
     const imageId = ctx.params.id;
@@ -870,18 +894,31 @@ router
     const imageId = ctx.params.imageId;
     const filter = ctx.query.filter || 'all'; 
     const image = await imagesModel.getImageById(imageId);
-    ctx.body = await singleImageView(image, filter);
+    const comments = await getVoteComments(imageId); // o getImageComments(imageId)
+    const imageWithCount = { ...image, commentCount: comments.length };
+    ctx.body = await singleImageView(imageWithCount, filter, comments);
    })
   .get('/audios', async (ctx) => {
-      const audiosMod = ctx.cookies.get("audiosMod") || 'on';
-      if (audiosMod !== 'on') {
-        ctx.redirect('/modules');
-        return;
-      }
-      const filter = ctx.query.filter || 'all';
-      const audios = await audiosModel.listAll(filter);
-      ctx.body = await audioView(audios, filter, null);
-   })
+    const audiosMod = ctx.cookies.get("audiosMod") || 'on';
+    if (audiosMod !== 'on') {
+      ctx.redirect('/modules');
+      return;
+    }
+    const filter = ctx.query.filter || 'all';
+    const audios = await audiosModel.listAll(filter);
+    const commentsCountByAudioId = {};
+    await Promise.all(
+      audios.map(async a => {
+        const comments = await getVoteComments(a.key);
+        commentsCountByAudioId[a.key] = comments.length;
+      })
+    );
+    const enrichedAudios = audios.map(a => ({
+      ...a,
+      commentCount: commentsCountByAudioId[a.key] || 0
+    }));
+    ctx.body = await audioView(enrichedAudios, filter, null);
+  })
   .get('/audios/edit/:id', async (ctx) => {
       const audiosMod = ctx.cookies.get("audiosMod") || 'on';
       if (audiosMod !== 'on') {
@@ -895,12 +932,25 @@ router
     const audioId = ctx.params.audioId;
     const filter = ctx.query.filter || 'all'; 
     const audio = await audiosModel.getAudioById(audioId);
-    ctx.body = await singleAudioView(audio, filter); 
+    const comments = await getVoteComments(audioId);
+    const audioWithCount = { ...audio, commentCount: comments.length };
+    ctx.body = await singleAudioView(audioWithCount, filter, comments); 
   })
   .get('/videos', async (ctx) => {
-      const filter = ctx.query.filter || 'all';
-      const videos = await videosModel.listAll(filter);
-      ctx.body = await videoView(videos, filter, null);
+    const filter = ctx.query.filter || 'all';
+    const videos = await videosModel.listAll(filter);
+    const commentsCountByVideoId = {};
+    await Promise.all(
+      videos.map(async v => {
+        const comments = await getVoteComments(v.key);
+        commentsCountByVideoId[v.key] = comments.length;
+      })
+    );
+    const enrichedVideos = videos.map(v => ({
+      ...v,
+      commentCount: commentsCountByVideoId[v.key] || 0
+    }));
+    ctx.body = await videoView(enrichedVideos, filter, null);
   })
   .get('/videos/edit/:id', async (ctx) => {
     const video = await videosModel.getVideoById(ctx.params.id);
@@ -910,12 +960,25 @@ router
     const videoId = ctx.params.videoId;
     const filter = ctx.query.filter || 'all'; 
     const video = await videosModel.getVideoById(videoId);
-    ctx.body = await singleVideoView(video, filter); 
+    const comments = await getVoteComments(videoId);
+    const videoWithCount = { ...video, commentCount: comments.length };
+    ctx.body = await singleVideoView(videoWithCount, filter, comments); 
   })
   .get('/documents', async (ctx) => {
     const filter = ctx.query.filter || 'all';
     const documents = await documentsModel.listAll(filter);
-    ctx.body = await documentView(documents, filter, null);
+    const commentsCountByDocumentId = {};
+    await Promise.all(
+      documents.map(async d => {
+        const comments = await getVoteComments(d.key);
+        commentsCountByDocumentId[d.key] = comments.length;
+      })
+    );
+    const enrichedDocuments = documents.map(d => ({
+      ...d,
+      commentCount: commentsCountByDocumentId[d.key] || 0
+    }));
+    ctx.body = await documentView(enrichedDocuments, filter, null);
   })
   .get('/documents/edit/:id', async (ctx) => {
     const document = await documentsModel.getDocumentById(ctx.params.id);
@@ -925,7 +988,9 @@ router
     const documentId = ctx.params.documentId;
     const filter = ctx.query.filter || 'all'; 
     const document = await documentsModel.getDocumentById(documentId);
-    ctx.body = await singleDocumentView(document, filter);
+    const comments = await getVoteComments(documentId);
+    const documentWithCount = { ...document, commentCount: comments.length };
+    ctx.body = await singleDocumentView(documentWithCount, filter, comments);
   })
   .get('/cv', async ctx => {
     const cv = await cvModel.getCVByUserId()
@@ -957,8 +1022,19 @@ router
   })
   .get('/reports', async ctx => {
     const filter = ctx.query.filter || 'all';
-    const reports = await reportsModel.listAll(filter);
-    ctx.body = await reportView(reports, filter, null);
+    const reports = await reportsModel.listAll();
+    const commentsCountById = {};
+    await Promise.all(
+      reports.map(async r => {
+        const comments = await getVoteComments(r.id);
+        commentsCountById[r.id] = comments.length;
+      })
+    );
+    const enrichedReports = reports.map(r => ({
+      ...r,
+      commentCount: commentsCountById[r.id] || 0
+    }));
+    ctx.body = await reportView(enrichedReports, filter, null);
   })
   .get('/reports/edit/:id', async ctx => {
     const report = await reportsModel.getReportById(ctx.params.id);
@@ -967,8 +1043,10 @@ router
   .get('/reports/:reportId', async ctx => {
     const reportId = ctx.params.reportId;
     const filter = ctx.query.filter || 'all'; 
-    const report = await reportsModel.getReportById(reportId, filter);
-    ctx.body = await singleReportView(report, filter);
+    const report = await reportsModel.getReportById(reportId);
+    const comments = await getVoteComments(reportId);
+    const reportWithCount = { ...report, commentCount: comments.length };
+    ctx.body = await singleReportView(reportWithCount, filter, comments);
   })
   .get('/trending', async (ctx) => {
     const filter = ctx.query.filter || 'RECENT'; 
@@ -1591,7 +1669,18 @@ router
     }
     const filter = ctx.query.filter || 'all';
     const bookmarks = await bookmarksModel.listAll(null, filter);
-    ctx.body = await bookmarkView(bookmarks, filter, null); 
+    const commentsCountByBookmarkId = {};
+    await Promise.all(
+      bookmarks.map(async b => {
+        const comments = await getVoteComments(b.id);
+        commentsCountByBookmarkId[b.id] = comments.length;
+      })
+    );
+    const enrichedBookmarks = bookmarks.map(b => ({
+      ...b,
+      commentCount: commentsCountByBookmarkId[b.id] || 0
+    }));
+    ctx.body = await bookmarkView(enrichedBookmarks, filter, null); 
   })
   .get('/bookmarks/edit/:id', async (ctx) => {
     const bookmarksMod = ctx.cookies.get("bookmarksMod") || 'on';
@@ -1611,12 +1700,25 @@ router
     const bookmarkId = ctx.params.bookmarkId;
     const filter = ctx.query.filter || 'all'; 
     const bookmark = await bookmarksModel.getBookmarkById(bookmarkId);
-    ctx.body = await singleBookmarkView(bookmark, filter);
+    const comments = await getVoteComments(bookmarkId);
+    const bookmarkWithCount = { ...bookmark, commentCount: comments.length };
+    ctx.body = await singleBookmarkView(bookmarkWithCount, filter, comments);
   })
-  .get('/tasks', async ctx=>{
-    const filter = ctx.query.filter||'all';
+  .get('/tasks', async ctx => {
+    const filter = ctx.query.filter || 'all';
     const tasks = await tasksModel.listAll(filter);
-    ctx.body = await taskView(tasks,filter,null);
+    const commentsCountByTaskId = {};
+    await Promise.all(
+      tasks.map(async t => {
+        const comments = await getVoteComments(t.id);
+        commentsCountByTaskId[t.id] = comments.length;
+      })
+    );
+    const enrichedTasks = tasks.map(t => ({
+      ...t,
+      commentCount: commentsCountByTaskId[t.id] || 0
+    }));
+    ctx.body = await taskView(enrichedTasks, filter, null);
   })
   .get('/tasks/edit/:id', async ctx=>{
     const id = ctx.params.id;
@@ -1626,8 +1728,10 @@ router
   .get('/tasks/:taskId', async ctx => {
     const taskId = ctx.params.taskId;
     const filter = ctx.query.filter || 'all'; 
-    const task = await tasksModel.getTaskById(taskId, filter);
-    ctx.body = await taskView([task], filter, taskId);
+    const task = await tasksModel.getTaskById(taskId);
+    const comments = await getVoteComments(taskId);
+    const taskWithCount = { ...task, commentCount: comments.length };
+    ctx.body = await singleTaskView(taskWithCount, filter, comments);
   })
   .get('/events', async (ctx) => {
     const eventsMod = ctx.cookies.get("eventsMod") || 'on';
@@ -1637,7 +1741,18 @@ router
     }
     const filter = ctx.query.filter || 'all';
     const events = await eventsModel.listAll(null, filter);
-    ctx.body = await eventView(events, filter, null);
+    const commentsCountByEventId = {};
+    await Promise.all(
+      events.map(async e => {
+        const comments = await getVoteComments(e.id);
+        commentsCountByEventId[e.id] = comments.length;
+      })
+    );
+    const enrichedEvents = events.map(e => ({
+      ...e,
+      commentCount: commentsCountByEventId[e.id] || 0
+    }));
+    ctx.body = await eventView(enrichedEvents, filter, null);
   })
   .get('/events/edit/:id', async (ctx) => {
     const eventsMod = ctx.cookies.get("eventsMod") || 'on';
@@ -1649,27 +1764,42 @@ router
     const event = await eventsModel.getEventById(eventId);
     ctx.body = await eventView([event], 'edit', eventId);
    })
-  .get('/events/:eventId', async ctx => {
+ .get('/events/:eventId', async ctx => {
     const eventId = ctx.params.eventId;
     const filter = ctx.query.filter || 'all'; 
     const event = await eventsModel.getEventById(eventId);
-    ctx.body = await singleEventView(event, filter);
+    const comments = await getVoteComments(eventId);
+    const eventWithCount = { ...event, commentCount: comments.length };
+    ctx.body = await singleEventView(eventWithCount, filter, comments);
   })
   .get('/votes', async ctx => {
     const filter = ctx.query.filter || 'all';
     const voteList = await votesModel.listAll(filter);
-    ctx.body = await voteView(voteList, filter, null);
-   })
+    const commentsCountByVoteId = {};
+    await Promise.all(
+      voteList.map(async v => {
+        const comments = await getVoteComments(v.id);
+        commentsCountByVoteId[v.id] = comments.length;
+      })
+    );
+    const enrichedVotes = voteList.map(v => ({
+      ...v,
+      commentCount: commentsCountByVoteId[v.id] || 0
+    }));
+    ctx.body = await voteView(enrichedVotes, filter, null, []);
+  })
   .get('/votes/edit/:id', async ctx => {
     const id = ctx.params.id;
-    const vote = await votesModel.getVoteById(id);
-    ctx.body = await voteView([vote], 'edit', id);
-   })
+    const voteData = await votesModel.getVoteById(id);
+    ctx.body = await voteView([voteData], 'edit', id, []);
+  })
   .get('/votes/:voteId', async ctx => {
     const voteId = ctx.params.voteId;
-    const vote = await votesModel.getVoteById(voteId);
-    ctx.body = await voteView(vote);
-   })
+    const voteData = await votesModel.getVoteById(voteId);
+    const comments = await getVoteComments(voteId);
+    const voteWithCount = { ...voteData, commentCount: comments.length };
+    ctx.body = await voteView([voteWithCount], 'detail', voteId, comments);
+  })
   .get('/market', async ctx => {
     const marketMod = ctx.cookies.get("marketMod") || 'on';
     if (marketMod !== 'on') {
@@ -1677,9 +1807,20 @@ router
       return;
     }
     const filter = ctx.query.filter || 'all';
-    const marketItems = await marketModel.listAllItems(filter);
+    let marketItems = await marketModel.listAllItems(filter);
+    const commentsCountById = {};
+    await Promise.all(
+      marketItems.map(async item => {
+        const comments = await getVoteComments(item.id);
+        commentsCountById[item.id] = comments.length;
+      })
+    );
+    marketItems = marketItems.map(item => ({
+      ...item,
+      commentCount: commentsCountById[item.id] || 0
+    }));
     ctx.body = await marketView(marketItems, filter, null);
-   })
+  })
   .get('/market/edit/:id', async ctx => {
     const id = ctx.params.id;
     const marketItem = await marketModel.getItemById(id);
@@ -1689,8 +1830,10 @@ router
     const itemId = ctx.params.itemId;
     const filter = ctx.query.filter || 'all'; 
     const item = await marketModel.getItemById(itemId); 
-    ctx.body = await singleMarketView(item, filter);
-   })
+    const comments = await getVoteComments(itemId);
+    const itemWithCount = { ...item, commentCount: comments.length };
+    ctx.body = await singleMarketView(itemWithCount, filter, comments);
+  })
   .get('/jobs', async (ctx) => {
     const jobsMod = ctx.cookies.get("jobsMod") || 'on';
     if (jobsMod !== 'on') {
@@ -1705,14 +1848,25 @@ router
       query.location = ctx.query.location || '';
       query.language = ctx.query.language || '';
       query.skills = ctx.query.skills || '';
-      const inhabitants = await inhabitantsModel.listInhabitants({ 
-        filter: 'CVs', 
-        ...query 
+      const inhabitants = await inhabitantsModel.listInhabitants({
+        filter: 'CVs',
+        ...query
       });
       ctx.body = await jobsView(inhabitants, filter, query);
       return;
     }
-    const jobs = await jobsModel.listJobs(filter, ctx.state.user?.id, query);
+    let jobs = await jobsModel.listJobs(filter, ctx.state.user?.id, query);
+    const commentsCountById = {};
+    await Promise.all(
+      jobs.map(async job => {
+        const comments = await getVoteComments(job.id);
+        commentsCountById[job.id] = comments.length;
+      })
+    );
+    jobs = jobs.map(job => ({
+      ...job,
+      commentCount: commentsCountById[job.id] || 0
+    }));
     ctx.body = await jobsView(jobs, filter, query);
   })
   .get('/jobs/edit/:id', async (ctx) => {
@@ -1724,7 +1878,9 @@ router
     const jobId = ctx.params.jobId;
     const filter = ctx.query.filter || 'ALL';
     const job = await jobsModel.getJobById(jobId);
-    ctx.body = await singleJobsView(job, filter);
+    const comments = await getVoteComments(jobId);
+    const jobWithCount = { ...job, commentCount: comments.length };
+    ctx.body = await singleJobsView(jobWithCount, filter, comments);
   })
   .get('/projects', async (ctx) => {
     const projectsMod = ctx.cookies.get("projectsMod") || 'on';
@@ -1740,6 +1896,17 @@ router
       const userId = SSBconfig.config.keys.id;
       projects = projects.filter(project => project.author === userId);
     }
+    const commentsCountById = {};
+    await Promise.all(
+      projects.map(async pr => {
+        const comments = await getVoteComments(pr.id);
+        commentsCountById[pr.id] = comments.length;
+      })
+    );
+    projects = projects.map(pr => ({
+      ...pr,
+      commentCount: commentsCountById[pr.id] || 0
+    }));
     ctx.body = await projectsView(projects, filter);
   })
   .get('/projects/edit/:id', async (ctx) => {
@@ -1748,10 +1915,12 @@ router
     ctx.body = await projectsView([pr], 'EDIT')
   })
   .get('/projects/:projectId', async (ctx) => {
-    const projectId = ctx.params.projectId
-    const filter = ctx.query.filter || 'ALL'
-    const project = await projectsModel.getProjectById(projectId)
-    ctx.body = await singleProjectView(project, filter)
+    const projectId = ctx.params.projectId;
+    const filter = ctx.query.filter || 'ALL';
+    const project = await projectsModel.getProjectById(projectId);
+    const comments = await getVoteComments(projectId);
+    const projectWithCount = { ...project, commentCount: comments.length };
+    ctx.body = await singleProjectView(projectWithCount, filter, comments);
   })
   .get("/banking", async (ctx) => {
     const bankingMod = ctx.cookies.get("bankingMod") || 'on';
@@ -2438,6 +2607,21 @@ router
     await opinionsModel.createVote(bookmarkId, category, 'bookmark');
     ctx.redirect('/bookmarks');
   })
+  .post('/bookmarks/:bookmarkId/comments', koaBody(), async (ctx) => {
+    const { bookmarkId } = ctx.params;
+    const { text } = ctx.request.body;
+    const trimmed = (text || '').trim();
+    if (!trimmed) {
+      ctx.redirect(`/bookmarks/${encodeURIComponent(bookmarkId)}`);
+      return;
+    }
+    await post.publish({
+      text: trimmed,
+      root: bookmarkId,
+      dest: bookmarkId
+    });
+    ctx.redirect(`/bookmarks/${encodeURIComponent(bookmarkId)}`);
+  })
   .post('/images/create', koaBody({ multipart: true }), async ctx => {
     const blob = await handleBlobUpload(ctx, 'image');
     const { tags, title, description, meme } = ctx.request.body;
@@ -2468,6 +2652,21 @@ router
     await imagesModel.createOpinion(imageId, category, 'image');
     ctx.redirect('/images');
   })
+  .post('/images/:imageId/comments', koaBody(), async ctx => {
+    const { imageId } = ctx.params;
+    const { text } = ctx.request.body;
+    const trimmed = (text || '').trim();
+    if (!trimmed) {
+      ctx.redirect(`/images/${encodeURIComponent(imageId)}`);
+      return;
+    }
+    await post.publish({
+      text: trimmed,
+      root: imageId,
+      dest: imageId
+    });
+    ctx.redirect(`/images/${encodeURIComponent(imageId)}`);
+  })
   .post('/audios/create', koaBody({ multipart: true }), async (ctx) => {
     const audioBlob = await handleBlobUpload(ctx, 'audio');
     const { tags, title, description } = ctx.request.body;
@@ -2496,6 +2695,21 @@ router
     await audiosModel.createOpinion(audioId, category);
     ctx.redirect('/audios');
   })
+  .post('/audios/:audioId/comments', koaBody(), async ctx => {
+    const { audioId } = ctx.params;
+    const { text } = ctx.request.body;
+    const trimmed = (text || '').trim();
+    if (!trimmed) {
+      ctx.redirect(`/audios/${encodeURIComponent(audioId)}`);
+      return;
+    }
+    await post.publish({
+      text: trimmed,
+      root: audioId,
+      dest: audioId
+    });
+    ctx.redirect(`/audios/${encodeURIComponent(audioId)}`);
+  })
   .post('/videos/create', koaBody({ multipart: true }), async (ctx) => {
     const videoBlob = await handleBlobUpload(ctx, 'video');
     const { tags, title, description } = ctx.request.body;
@@ -2524,6 +2738,21 @@ router
     await videosModel.createOpinion(videoId, category);
     ctx.redirect('/videos');
   })
+  .post('/videos/:videoId/comments', koaBody(), async ctx => {
+    const { videoId } = ctx.params;
+    const { text } = ctx.request.body;
+    const trimmed = (text || '').trim();
+    if (!trimmed) {
+      ctx.redirect(`/videos/${encodeURIComponent(videoId)}`);
+      return;
+    }
+    await post.publish({
+      text: trimmed,
+      root: videoId,
+      dest: videoId
+    });
+    ctx.redirect(`/videos/${encodeURIComponent(videoId)}`);
+  })
   .post('/documents/create', koaBody({ multipart: true }), async (ctx) => {
     const docBlob = await handleBlobUpload(ctx, 'document');
     const { tags, title, description } = ctx.request.body;
@@ -2552,6 +2781,21 @@ router
     await documentsModel.createOpinion(documentId, category);
      ctx.redirect('/documents');
   })
+  .post('/documents/:documentId/comments', koaBody(), async (ctx) => {
+    const { documentId } = ctx.params;
+    const { text } = ctx.request.body;
+    const trimmed = (text || '').trim();
+    if (!trimmed) {
+      ctx.redirect(`/documents/${encodeURIComponent(documentId)}`);
+      return;
+    }
+    await post.publish({
+      text: trimmed,
+      root: documentId,
+      dest: documentId
+    });
+    ctx.redirect(`/documents/${encodeURIComponent(documentId)}`);
+  })
   .post('/cv/upload', koaBody({ multipart: true }), async ctx => {
     const photoUrl = await handleBlobUpload(ctx, 'image')
     await cvModel.createCV(ctx.request.body, photoUrl)
@@ -2743,6 +2987,21 @@ router
     await tasksModel.updateTaskStatus(taskId, status);
     ctx.redirect('/tasks?filter=mine');
    })
+   .post('/tasks/:taskId/comments', koaBody(), async ctx => {
+    const { taskId } = ctx.params;
+    const { text } = ctx.request.body;
+    const trimmed = (text || '').trim();
+    if (!trimmed) {
+      ctx.redirect(`/tasks/${encodeURIComponent(taskId)}`);
+      return;
+    }
+    await post.publish({
+      text: trimmed,
+      root: taskId,
+      dest: taskId
+    });
+    ctx.redirect(`/tasks/${encodeURIComponent(taskId)}`);
+  })
   .post('/reports/create', koaBody({ multipart: true }), async ctx => {
       const { title, description, category, tags, severity } = ctx.request.body;
       const image = await handleBlobUpload(ctx, 'image');
@@ -2771,6 +3030,21 @@ router
     await reportsModel.updateReportById(reportId, { status });
     ctx.redirect('/reports?filter=mine');
   })
+  .post('/reports/:reportId/comments', koaBody(), async ctx => {
+    const { reportId } = ctx.params;
+    const { text } = ctx.request.body;
+    const trimmed = (text || '').trim();
+    if (!trimmed) {
+      ctx.redirect(`/reports/${encodeURIComponent(reportId)}`);
+      return;
+    }
+    await post.publish({
+      text: trimmed,
+      root: reportId,
+      dest: reportId
+    });
+    ctx.redirect(`/reports/${encodeURIComponent(reportId)}`);
+  })
   .post('/events/create', koaBody(), async (ctx) => {
     const { title, description, date, location, price, url, attendees, tags, isPublic } = ctx.request.body;
     await eventsModel.createEvent(title, description, date, location, price, url, attendees, tags, isPublic);
@@ -2805,6 +3079,21 @@ router
     await eventsModel.deleteEventById(eventId);
     ctx.redirect('/events?filter=mine');
   })
+  .post('/events/:eventId/comments', koaBody(), async (ctx) => {
+    const { eventId } = ctx.params;
+    const { text } = ctx.request.body;
+    const trimmed = (text || '').trim();
+    if (!trimmed) {
+      ctx.redirect(`/events/${encodeURIComponent(eventId)}`);
+      return;
+    }
+    await post.publish({
+      text: trimmed,
+      root: eventId,
+      dest: eventId
+    });
+    ctx.redirect(`/events/${encodeURIComponent(eventId)}`);
+  })
   .post('/votes/create', koaBody(), async ctx => {
     const { question, deadline, options, tags = '' } = ctx.request.body;
     const defaultOptions = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'];
@@ -2836,11 +3125,11 @@ router
     await votesModel.voteOnVote(id, choice);
     ctx.redirect('/votes?filter=open');
   })
-  .post('/votes/opinions/:voteId/:category', async (ctx) => {
+  .post('/votes/opinions/:voteId/:category', async ctx => {
     const { voteId, category } = ctx.params;
     const voterId = SSBconfig?.keys?.id;
-    const vote = await votesModel.getVoteById(voteId);
-    if (vote.opinions_inhabitants && vote.opinions_inhabitants.includes(voterId)) {
+    const voteData = await votesModel.getVoteById(voteId);
+    if (voteData.opinions_inhabitants && voteData.opinions_inhabitants.includes(voterId)) {
       ctx.flash = { message: "You have already opined." };
       ctx.redirect('/votes');
       return;
@@ -2848,6 +3137,21 @@ router
     await votesModel.createOpinion(voteId, category);
     ctx.redirect('/votes');
   })
+  .post('/votes/:voteId/comments', koaBody(), async ctx => {
+    const { voteId } = ctx.params;
+    const { text } = ctx.request.body;
+    const trimmed = (text || '').trim();
+    if (!trimmed) {
+      ctx.redirect(`/votes/${encodeURIComponent(voteId)}`);
+      return;
+    }
+    await post.publish({
+      text: trimmed,
+      root: voteId,
+      dest: voteId
+    });
+    ctx.redirect(`/votes/${encodeURIComponent(voteId)}`);
+  })
   .post('/parliament/candidatures/propose', koaBody(), async (ctx) => {
     const { candidateId = '', method = '' } = ctx.request.body || {};
     const id = String(candidateId || '').trim();
@@ -3265,6 +3569,21 @@ router
     }
     ctx.redirect('/market?filter=auctions');
   })
+  .post('/market/:itemId/comments', koaBody(), async ctx => {
+    const { itemId } = ctx.params;
+    const { text } = ctx.request.body;
+    const trimmed = (text || '').trim();
+    if (!trimmed) {
+      ctx.redirect(`/market/${encodeURIComponent(itemId)}`);
+      return;
+    }
+    await post.publish({
+      text: trimmed,
+      root: itemId,
+      dest: itemId
+    });
+    ctx.redirect(`/market/${encodeURIComponent(itemId)}`);
+  })
   .post('/jobs/create', koaBody({ multipart: true }), async (ctx) => {
    const {
       job_type,
@@ -3367,6 +3686,21 @@ router
     await pmModel.sendMessage([job.author], subject, text);
     ctx.redirect('/jobs');
   })
+  .post('/jobs/:jobId/comments', koaBody(), async (ctx) => {
+    const { jobId } = ctx.params;
+    const { text } = ctx.request.body;
+    const trimmed = (text || '').trim();
+    if (!trimmed) {
+      ctx.redirect(`/jobs/${encodeURIComponent(jobId)}`);
+      return;
+    }
+    await post.publish({
+      text: trimmed,
+      root: jobId,
+      dest: jobId
+    });
+    ctx.redirect(`/jobs/${encodeURIComponent(jobId)}`);
+  })
  .post('/projects/create', koaBody({ multipart: true }), async (ctx) => {
     const b = ctx.request.body || {};
     const imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
@@ -3573,6 +3907,21 @@ router
     await projectsModel.completeBounty(ctx.params.id, parseInt(ctx.params.index, 10), userId);
     ctx.redirect(`/projects/${encodeURIComponent(ctx.params.id)}`);
   })
+  .post('/projects/:projectId/comments', koaBody(), async (ctx) => {
+    const { projectId } = ctx.params;
+    const { text } = ctx.request.body;
+    const trimmed = (text || '').trim();
+    if (!trimmed) {
+      ctx.redirect(`/projects/${encodeURIComponent(projectId)}`);
+      return;
+    }
+    await post.publish({
+      text: trimmed,
+      root: projectId,
+      dest: projectId
+    });
+    ctx.redirect(`/projects/${encodeURIComponent(projectId)}`);
+  })
   .post("/banking/claim/:id", koaBody(), async (ctx) => {
     const userId = SSBconfig.config.keys.id;
     const allocationId = ctx.params.id;

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

@@ -761,6 +761,9 @@ module.exports = {
     parliamentRulesLaws: "When a proposal meets its threshold, it becomes a Law and appears in the Laws tab with its enactment date.",
     parliamentRulesHistorical: "In the Historical tab you can see every government cycle that has occurred and data about its management.",
     parliamentRulesLeaders: "In the Leaders tab you can see a ranking of inhabitants/tribes that have governed (or stood as candidates), ordered by efficiency.",
+    parliamentProposalVoteStatusLabel: "Vote status",
+    parliamentProposalOnTrackYes: "Threshold reached",
+    parliamentProposalOnTrackNo: "Below threshold",
     //courts
     courtsTitle: "Courts",
     courtsDescription: "Explore forms of conflict resolution and collective justice management.",
@@ -1140,7 +1143,15 @@ module.exports = {
     errorDeletingOldVote: "Error deleting old opinion",
     errorCreatingUpdatedVote: "Error creating updated opinion",
     errorCreatingTombstone: "Error creating tombstone",
-    // CV
+    voteDetailSectionTitle: 'Vote details',
+    voteCommentsLabel: 'Comments',
+    voteCommentsForumButton: 'Open discussion',
+    voteCommentsSectionTitle: 'Open discussion',
+    voteNoCommentsYet: 'There are no comments yet. Be the first to reply.',
+    voteNewCommentPlaceholder: 'Write your comment here…',
+    voteNewCommentButton: 'Post comment',
+    voteNewCommentLabel: 'Add a comment',
+    //CV
     cvTitle: "CV",
     cvLabel: "Curriculum Vitae (CV)",
     cvEditSectionTitle: "Edit CV",

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

@@ -756,6 +756,9 @@ module.exports = {
     parliamentRulesLaws: "Cuando una propuesta alcanza su umbral, se convierte en Ley y aparece en la pestaña Leyes con su fecha de entrada en vigor.",
     parliamentRulesHistorical: "En Histórico se puede ver cada ciclo de gobierno que ha habido y datos sobre su gestión.",
     parliamentRulesLeaders: "En Líderes se puede ver un ranking de habitantes/tribus que han gobernado (o se han presentado), ordenados por eficacia.",
+    parliamentProposalVoteStatusLabel: "Estado de la votación",
+    parliamentProposalOnTrackYes: "Umbral alcanzado",
+    parliamentProposalOnTrackNo: "Por debajo del umbral",
      //courts
     courtsTitle: "Tribunales",
     courtsDescription: "Explora formas de resolución de conflictos y de gestión colectiva de la justicia.",
@@ -1135,7 +1138,15 @@ module.exports = {
     errorDeletingOldVote: "Error al eliminar opinión anterior",
     errorCreatingUpdatedVote: "Error al crear opinión actualizada",
     errorCreatingTombstone: "Error al crear lápida",
-    // CV
+    voteDetailSectionTitle: 'Detalles de la votación',
+    voteCommentsLabel: 'Comentarios',
+    voteCommentsForumButton: 'Abrir conversación',
+    voteCommentsSectionTitle: 'Conversación abierta',
+    voteNoCommentsYet: 'Todavía no hay comentarios. Sé la primera persona en opinar.',
+    voteNewCommentPlaceholder: 'Escribe aquí tu comentario…',
+    voteNewCommentButton: 'Publicar comentario',
+    voteNewCommentLabel: 'Añade un comentario',
+    //CV
     cvTitle: "CV",
     cvLabel: "Currículum Vitae (CV)",
     cvEditSectionTitle: "Editar CV",

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

@@ -757,6 +757,9 @@ module.exports = {
     parliamentRulesLaws: "Proposamenak bere atalasea gainditzen duenean, Lege bihurtzen da eta Legeak fitxan agertzen da indarrean sartzeko datarekin.",
     parliamentRulesHistorical: "Historia fitxan egondako gobernu ziklo bakoitza eta haren kudeaketari buruzko datuak ikus daitezke.",
     parliamentRulesLeaders: "Liderak fitxan, gobernatu duten (edo aurkeztu diren) biztanle/tribuen sailkapena ikus daiteke, eraginkortasunaren arabera ordenatuta.",
+    parliamentProposalVoteStatusLabel: "Bozketa egoera",
+    parliamentProposalOnTrackYes: "Atalasea lortuta",
+    parliamentProposalOnTrackNo: "Atalasearen azpitik",
     //courts
     courtsTitle: "Auzitegiak",
     courtsDescription: "Aztertu gatazkak konpontzeko eta justizia kolektiboa kudeatzeko modu desberdinak.",
@@ -1136,6 +1139,14 @@ module.exports = {
     errorDeletingOldVote: "Errorea bozka zaharra ezabatzerakoan",
     errorCreatingUpdatedVote: "Errorea eguneratutako bozka sortzean",
     errorCreatingTombstone: "Errorea hilarria sortzerakoan",
+    voteDetailSectionTitle: 'Bozketa xehetasunak',
+    voteCommentsLabel: 'Iruzkinak',
+    voteCommentsForumButton: 'Eztabaida ireki',
+    voteCommentsSectionTitle: 'Eztabaida irekia',
+    voteNoCommentsYet: 'Oraindik ez dago iruzkinik. Izan zaitez lehen erantzuten.',
+    voteNewCommentPlaceholder: 'Idatzi hemen zure iruzkina…',
+    voteNewCommentButton: 'Bidali iruzkina',
+    voteNewCommentLabel: 'Gehitu iruzkina',
     // CV
     cvTitle: "CV",
     cvLabel: "Curriculum Vitae (CV)",

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

@@ -756,6 +756,9 @@ module.exports = {
     parliamentRulesLaws: "Lorsqu’une proposition atteint son seuil, elle devient une Loi et apparaît dans l’onglet Lois avec sa date d’entrée en vigueur.",
     parliamentRulesHistorical: "Dans l’onglet Historique, vous pouvez voir chaque cycle de gouvernement qui a eu lieu ainsi que des données sur sa gestion.",
     parliamentRulesLeaders: "Dans l’onglet Dirigeants, vous pouvez voir un classement des habitants/tribus qui ont gouverné (ou se sont présentés), classés par efficacité.",
+    parliamentProposalVoteStatusLabel: "Statut du vote",
+    parliamentProposalOnTrackYes: "Seuil atteint",
+    parliamentProposalOnTrackNo: "En dessous du seuil",
     //courts
     courtsTitle: "Tribunaux",
     courtsDescription: "Explorez des formes de résolution des conflits et de gestion collective de la justice.",
@@ -1135,7 +1138,15 @@ module.exports = {
     errorDeletingOldVote: "Erreur lors de la suppression du vote précédent",
     errorCreatingUpdatedVote: "Erreur lors de la création du vote mis à jour",
     errorCreatingTombstone: "Erreur lors de la création de la pierre tombale",
-    // CV
+    voteDetailSectionTitle: 'Détails du vote',
+    voteCommentsLabel: 'Commentaires',
+    voteCommentsForumButton: 'Ouvrir la discussion',
+    voteCommentsSectionTitle: 'Discussion ouverte',
+    voteNoCommentsYet: 'Il n’y a pas encore de commentaires. Soyez le premier à répondre.',
+    voteNewCommentPlaceholder: 'Écrivez votre commentaire ici…',
+    voteNewCommentButton: 'Publier le commentaire',
+    voteNewCommentLabel: 'Ajouter un commentaire',
+    //CV
     cvTitle: "CV",
     cvLabel: "Curriculum Vitae (CV)",
     cvEditSectionTitle: "Modifier le CV",

+ 184 - 117
src/models/parliament_model.js

@@ -293,24 +293,88 @@ module.exports = ({ cooler, services = {} }) => {
     return { chosen: latest, totalVotes: 0, winnerVotes: 0 };
   }
 
-  async function summarizePoliciesForTerm(termId) {
+  async function summarizePoliciesForTerm(termOrId) {
+    let termId = null;
+    let termStartMs = null;
+    let termEndMs = null;
+    if (termOrId && typeof termOrId === 'object') {
+      termId = termOrId.id || termOrId.startAt;
+      if (termOrId.startAt) termStartMs = new Date(termOrId.startAt).getTime();
+      if (termOrId.endAt) termEndMs = new Date(termOrId.endAt).getTime();
+    } else {
+      termId = termOrId;
+    }
     const proposals = await listByType('parliamentProposal');
-    const mine = proposals.filter(p => p.termId === termId);
+    const mine = proposals.filter(p => {
+      if (termId && p.termId === termId) return true;
+      if (termStartMs != null && termEndMs != null && p.createdAt) {
+        const t = new Date(p.createdAt).getTime();
+        return t >= termStartMs && t <= termEndMs;
+      }
+      return false;
+    });
+    const proposed = mine.length;
+    let approved = 0;
+    let declined = 0;
     let discarded = 0;
     for (const p of mine) {
-      if ((p.status || 'OPEN') === 'OPEN' && p.voteId && services.votes?.getVoteById) {
+      const baseStatus = String(p.status || 'OPEN').toUpperCase();
+      let finalStatus = baseStatus;
+      let isDiscarded = false;
+      if (p.voteId && services.votes?.getVoteById) {
         try {
           const v = await services.votes.getVoteById(p.voteId);
+          const votesMap = v.votes || {};
+          const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
+          const total = Number(v.totalVotes ?? v.total ?? sum);
+          const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
           const dl = v.deadline || v.endAt || v.expiresAt || null;
-          if (dl && moment().isAfter(parseISO(dl))) discarded++;
+          const closed = v.status === 'CLOSED' || (dl && moment(dl).isBefore(moment()));
+          const reached = passesThreshold(p.method, total, yes);
+          if (!closed) {
+            if (dl && moment(dl).isBefore(moment()) && !reached) {
+              isDiscarded = true;
+            } else {
+              finalStatus = 'OPEN';
+            }
+          } else {
+            if (reached) finalStatus = 'APPROVED';
+            else finalStatus = 'REJECTED';
+          }
         } catch {}
+      } else {
+        if (baseStatus === 'OPEN' && p.deadline && moment(p.deadline).isBefore(moment())) {
+          isDiscarded = true;
+        }
+      }
+      if (isDiscarded) {
+        discarded++;
+        continue;
+      }
+      if (finalStatus === 'ENACTED') {
+        approved++;
+        continue;
+      }
+      if (finalStatus === 'APPROVED') {
+        approved++;
+        continue;
+      }
+      if (finalStatus === 'REJECTED') {
+        declined++;
+        continue;
       }
     }
-    const approved = mine.filter(p => p.status === 'APPROVED' || p.status === 'ENACTED').length;
-    const declined = mine.filter(p => p.status === 'REJECTED').length;
     const revs = await listByType('parliamentRevocation');
-    const revocated = revs.filter(r => r.termId === termId && r.status === 'ENACTED').length;
-    return { proposed: mine.length, approved, declined, discarded, revocated };
+    const revocated = revs.filter(r => {
+      if (r.status !== 'ENACTED') return false;
+      if (termId && r.termId === termId) return true;
+      if (termStartMs != null && termEndMs != null && r.createdAt) {
+        const t = new Date(r.createdAt).getTime();
+        return t >= termStartMs && t <= termEndMs;
+      }
+      return false;
+    }).length;
+    return { proposed, approved, declined, discarded, revocated };
   }
 
   async function computeGovernmentCard(term) {
@@ -322,7 +386,7 @@ module.exports = ({ cooler, services = {} }) => {
       const tribe = services.tribes ? await services.tribes.getTribeById(term.powerId) : null;
       members = tribe && Array.isArray(tribe.members) ? tribe.members.length : 0;
     }
-    const pol = await summarizePoliciesForTerm(term.id || term.startAt);
+    const pol = await summarizePoliciesForTerm({ ...term });
     const eff = pol.proposed > 0 ? Math.round((pol.approved / pol.proposed) * 100) : 0;
     return {
       method,
@@ -702,7 +766,7 @@ module.exports = ({ cooler, services = {} }) => {
       if (!map.has(k)) map.set(k, { powerType: t.powerType, powerId: t.powerId, powerTitle: t.powerTitle, inPower: 0, presented: 0, proposed: 0, approved: 0, declined: 0, discarded: 0, revocated: 0 });
       const rec = map.get(k);
       rec.inPower += 1;
-      const sum = await summarizePoliciesForTerm(t.id || t.startAt);
+      const sum = await summarizePoliciesForTerm(t);
       rec.proposed += sum.proposed;
       rec.approved += sum.approved;
       rec.declined += sum.declined;
@@ -728,19 +792,17 @@ module.exports = ({ cooler, services = {} }) => {
   }
 
   async function listProposalsCurrent() {
-    const term = await getCurrentTermBase();
-    if (!term) return [];
     const all = await listByType('parliamentProposal');
     const rows = all
-      .filter(p => p.termId === (term.id || term.startAt))
       .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
     const out = [];
     for (const p of rows) {
-      const meth = String(p.method || '').toUpperCase();
-      if (meth === 'DICTATORSHIP' || meth === 'KARMATOCRACY') continue;
-      let deadline = null;
+      const status = String(p.status || 'OPEN').toUpperCase();
+      if (status === 'ENACTED' || status === 'REJECTED' || status === 'DISCARDED') continue;
+      let deadline = p.deadline || null;
       let yes = 0;
       let total = 0;
+      let voteClosed = false;
       if (p.voteId && services.votes?.getVoteById) {
         try {
           const v = await services.votes.getVoteById(p.voteId);
@@ -748,128 +810,132 @@ module.exports = ({ cooler, services = {} }) => {
           const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
           total = Number(v.totalVotes ?? v.total ?? sum);
           yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
-          deadline = v.deadline || v.endAt || v.expiresAt || null;
-          const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
-          if (closed) {
+          deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
+          voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
+          if (voteClosed) {
             try { await this.closeProposal(p.id); } catch {}
             continue;
           }
           const reached = passesThreshold(p.method, total, yes);
-          if (reached && p.status !== 'APPROVED') {
+          if (reached && status !== 'APPROVED') {
             const ssbClient = await openSsb();
             const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
             await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
           }
         } catch {}
       }
-      if ((p.status || 'OPEN') === 'OPEN') {
-        const needed = requiredVotes(p.method, total);
-        out.push({ ...p, deadline, yes, total, needed, onTrack: passesThreshold(p.method, total, yes) });
-      }
+      const needed = requiredVotes(p.method, total);
+      const onTrack = passesThreshold(p.method, total, yes);
+      out.push({ ...p, deadline, yes, total, needed, onTrack });
     }
     return out;
   }
 
-  async function listFutureLawsCurrent() {
-    const term = await getCurrentTermBase();
-    if (!term) return [];
-    const all = await listByType('parliamentProposal');
-    const rows = all
-      .filter(p => p.termId === (term.id || term.startAt))
-      .filter(p => p.status === 'APPROVED')
-      .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-    const out = [];
-    for (const p of rows) {
-      let yes = 0;
-      let total = 0;
-      let deadline = p.deadline || null;
-      if (p.voteId && services.votes?.getVoteById) {
-        try {
-          const v = await services.votes.getVoteById(p.voteId);
-          const votesMap = v.votes || {};
-          const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
-          total = Number(v.totalVotes ?? v.total ?? sum);
-          yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
-          deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
-        } catch {}
-      }
-      const needed = requiredVotes(p.method, total);
-      out.push({ ...p, deadline, yes, total, needed });
+ async function listFutureLawsCurrent() {
+  const term = await getCurrentTermBase();
+  if (!term) return [];
+  const termId = term.id || term.startAt;
+  const all = await listByType('parliamentProposal');
+  const rows = all
+    .filter(p => p.termId === termId)
+    .filter(p => p.status === 'APPROVED')
+    .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+  const out = [];
+  for (const p of rows) {
+    let yes = 0;
+    let total = 0;
+    let deadline = p.deadline || null;
+    let voteClosed = true;
+    if (p.voteId && services.votes?.getVoteById) {
+      try {
+        const v = await services.votes.getVoteById(p.voteId);
+        const votesMap = v.votes || {};
+        const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
+        total = Number(v.totalVotes ?? v.total ?? sum);
+        yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
+        deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
+        voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
+        if (!voteClosed) continue;
+      } catch {}
     }
-    return out;
+    const needed = requiredVotes(p.method, total);
+    out.push({ ...p, deadline, yes, total, needed });
   }
+  return out;
+ }
 
-  async function listRevocationsCurrent() {
-    const term = await getCurrentTermBase();
-    if (!term) return [];
-    const all = await listByType('parliamentRevocation');
-    const rows = all
-      .filter(p => p.termId === (term.id || term.startAt))
-      .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-    const out = [];
-    for (const p of rows) {
-      const meth = String(p.method || '').toUpperCase();
-      if (meth === 'DICTATORSHIP' || meth === 'KARMATOCRACY') continue;
-      let deadline = null;
-      let yes = 0;
-      let total = 0;
-      if (p.voteId && services.votes?.getVoteById) {
-        try {
-          const v = await services.votes.getVoteById(p.voteId);
-          const votesMap = v.votes || {};
-          const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
-          total = Number(v.totalVotes ?? v.total ?? sum);
-          yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
-          deadline = v.deadline || v.endAt || v.expiresAt || null;
-          const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
-          if (closed) {
-            try { await closeRevocation(p.id); } catch {}
-            continue;
-          }
-          const reached = passesThreshold(p.method, total, yes);
-          if (reached && p.status !== 'APPROVED') {
-            const ssbClient = await openSsb();
-            const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
-            await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
-          }
-        } catch {}
-      }
-      if ((p.status || 'OPEN') === 'OPEN') {
-        const needed = requiredVotes(p.method, total);
-        out.push({ ...p, deadline, yes, total, needed, onTrack: passesThreshold(p.method, total, yes) });
-      }
+ async function listRevocationsCurrent() {
+  const all = await listByType('parliamentRevocation');
+  const rows = all
+    .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+  const out = [];
+  for (const p of rows) {
+    const status = String(p.status || 'OPEN').toUpperCase();
+    if (status === 'ENACTED' || status === 'REJECTED' || status === 'DISCARDED') continue;
+    let deadline = p.deadline || null;
+    let yes = 0;
+    let total = 0;
+    let voteClosed = false;
+    if (p.voteId && services.votes?.getVoteById) {
+      try {
+        const v = await services.votes.getVoteById(p.voteId);
+        const votesMap = v.votes || {};
+        const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
+        total = Number(v.totalVotes ?? v.total ?? sum);
+        yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
+        deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
+        voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
+        if (voteClosed) {
+          try { await closeRevocation(p.id); } catch {}
+          continue;
+        }
+        const reached = passesThreshold(p.method, total, yes);
+        if (reached && status !== 'APPROVED') {
+          const ssbClient = await openSsb();
+          const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
+          await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
+        }
+      } catch {}
     }
-    return out;
+    const needed = requiredVotes(p.method, total);
+    const onTrack = passesThreshold(p.method, total, yes);
+    out.push({ ...p, deadline, yes, total, needed, onTrack });
   }
-
-  async function listFutureRevocationsCurrent() {
-    const term = await getCurrentTermBase();
-    if (!term) return [];
-    const all = await listByType('parliamentRevocation');
-    const rows = all
-      .filter(p => p.termId === (term.id || term.startAt))
-      .filter(p => p.status === 'APPROVED')
-      .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-    const out = [];
-    for (const p of rows) {
-      let yes = 0;
-      let total = 0;
-      let deadline = p.deadline || null;
-      if (p.voteId && services.votes?.getVoteById) {
-        try {
-          const v = await services.votes.getVoteById(p.voteId);
-          const votesMap = v.votes || {};
-          const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
-          total = Number(v.totalVotes ?? v.total ?? sum);
-          yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
-          deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
-        } catch {}
-      }
-      const needed = requiredVotes(p.method, total);
-      out.push({ ...p, deadline, yes, total, needed });
+  return out;
+}
+  
+async function listFutureRevocationsCurrent() {
+  const term = await getCurrentTermBase();
+  if (!term) return [];
+  const termId = term.id || term.startAt;
+  const all = await listByType('parliamentRevocation');
+  const rows = all
+    .filter(p => p.termId === termId)
+    .filter(p => p.status === 'APPROVED')
+    .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+  const out = [];
+  for (const p of rows) {
+    let yes = 0;
+    let total = 0;
+    let deadline = p.deadline || null;
+    let voteClosed = true;
+    if (p.voteId && services.votes?.getVoteById) {
+      try {
+        const v = await services.votes.getVoteById(p.voteId);
+        const votesMap = v.votes || {};
+        const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
+        total = Number(v.totalVotes ?? v.total ?? sum);
+        yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
+        deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
+        voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
+        if (!voteClosed) continue;
+      } catch {}
     }
-    return out;
+    const needed = requiredVotes(p.method, total);
+    out.push({ ...p, deadline, yes, total, needed });
   }
+  return out;
+}
 
   async function countRevocationsEnacted() {
     const all = await listByType('parliamentRevocation');
@@ -999,6 +1065,7 @@ module.exports = ({ cooler, services = {} }) => {
       term = await getCurrentTermBase();
     }
     if (!term) return null;
+    try { await this.sweepProposals(); } catch {}
     const full = await computeGovernmentCard({ ...term, id: term.id || term.startAt });
     return full;
   }

+ 317 - 123
src/models/votes_model.js

@@ -5,50 +5,205 @@ const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
-  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
+
+  const TYPE = 'votes';
+
+  async function getAllMessages(ssbClient) {
+    return new Promise((resolve, reject) => {
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((err, results) => (err ? reject(err) : resolve(results)))
+      );
+    });
+  }
+
+  function buildIndex(messages) {
+    const tombstoned = new Set();
+    const replaced = new Map();
+    const votes = new Map();
+    const parent = new Map();
+
+    for (const m of messages) {
+      const key = m.key;
+      const v = m.value;
+      const c = v && v.content;
+      if (!c) continue;
+      if (c.type === 'tombstone' && c.target) {
+        tombstoned.add(c.target);
+        continue;
+      }
+      if (c.type !== TYPE) continue;
+      const node = {
+        key,
+        ts: v.timestamp || m.timestamp || 0,
+        content: c
+      };
+      votes.set(key, node);
+      if (c.replaces) {
+        replaced.set(c.replaces, key);
+        parent.set(key, c.replaces);
+      }
+    }
+
+    return { tombstoned, replaced, votes, parent };
+  }
+
+  function statusFromContent(content, now) {
+    const raw = String(content.status || 'OPEN').toUpperCase();
+    if (raw === 'OPEN') {
+      const dl = content.deadline ? moment(content.deadline) : null;
+      if (dl && dl.isValid() && dl.isBefore(now)) return 'CLOSED';
+    }
+    return raw;
+  }
+
+  function computeActiveVotes(index) {
+    const { tombstoned, replaced, votes, parent } = index;
+    const active = new Map(votes);
+
+    tombstoned.forEach(id => active.delete(id));
+    replaced.forEach((_, oldId) => active.delete(oldId));
+
+    const rootOf = id => {
+      let cur = id;
+      while (parent.has(cur)) cur = parent.get(cur);
+      return cur;
+    };
+
+    const groups = new Map();
+    for (const [id, node] of active.entries()) {
+      const root = rootOf(id);
+      if (!groups.has(root)) groups.set(root, []);
+      groups.get(root).push(node);
+    }
+
+    const now = moment();
+    const result = [];
+
+    for (const nodes of groups.values()) {
+      if (!nodes.length) continue;
+      let best = nodes[0];
+      let bestStatus = statusFromContent(best.content, now);
+
+      for (let i = 1; i < nodes.length; i++) {
+        const candidate = nodes[i];
+        const cStatus = statusFromContent(candidate.content, now);
+        if (cStatus === bestStatus) {
+          const bestTime = new Date(best.content.updatedAt || best.content.createdAt || best.ts || 0);
+          const cTime = new Date(candidate.content.updatedAt || candidate.content.createdAt || candidate.ts || 0);
+          if (cTime > bestTime) {
+            best = candidate;
+            bestStatus = cStatus;
+          }
+        } else if (cStatus === 'CLOSED' && bestStatus !== 'CLOSED') {
+          best = candidate;
+          bestStatus = cStatus;
+        } else if (cStatus === 'OPEN' && bestStatus !== 'OPEN') {
+          best = candidate;
+          bestStatus = cStatus;
+        }
+      }
+
+      result.push({
+        id: best.key,
+        latestId: best.key,
+        ...best.content,
+        status: bestStatus
+      });
+    }
+
+    return result;
+  }
+
+  async function resolveCurrentId(voteId) {
+    const ssbClient = await openSsb();
+    const messages = await getAllMessages(ssbClient);
+    const forward = new Map();
+
+    for (const m of messages) {
+      const c = m.value && m.value.content;
+      if (!c) continue;
+      if (c.type === TYPE && c.replaces) {
+        forward.set(c.replaces, m.key);
+      }
+    }
+
+    let cur = voteId;
+    while (forward.has(cur)) cur = forward.get(cur);
+    return cur;
+  }
 
   return {
     async createVote(question, deadline, options = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'], tagsRaw = []) {
-      const ssb = await openSsb();
-      const userId = ssb.id;
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
       const parsedDeadline = moment(deadline, moment.ISO_8601, true);
       if (!parsedDeadline.isValid() || parsedDeadline.isBefore(moment())) throw new Error('Invalid deadline');
-      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
+
+      const tags = Array.isArray(tagsRaw)
+        ? tagsRaw.filter(Boolean)
+        : String(tagsRaw).split(',').map(t => t.trim()).filter(Boolean);
+
       const content = {
-        type: 'votes',
+        type: TYPE,
         question,
         options,
         deadline: parsedDeadline.toISOString(),
         createdBy: userId,
         status: 'OPEN',
-        votes: options.reduce((acc, opt) => ({ ...acc, [opt]: 0 }), {}),
+        votes: options.reduce((acc, opt) => {
+          acc[opt] = 0;
+          return acc;
+        }, {}),
         totalVotes: 0,
         voters: [],
         tags,
         opinions: {},
         opinions_inhabitants: [],
-        createdAt: new Date().toISOString()
+        createdAt: new Date().toISOString(),
+        updatedAt: null
       };
-      return new Promise((res, rej) => ssb.publish(content, (err, msg) => err ? rej(err) : res(msg)));
+
+      return new Promise((res, rej) =>
+        ssbClient.publish(content, (err, msg) => (err ? rej(err) : res(msg)))
+      );
     },
 
     async deleteVoteById(id) {
-      const ssb = await openSsb();
-      const userId = ssb.id;
-      const vote = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
-      if (vote.content.createdBy !== userId) throw new Error('Not the author');
-      const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
-      return new Promise((res, rej) => ssb.publish(tombstone, (err, result) => err ? rej(err) : res(result)));
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const tipId = await resolveCurrentId(id);
+      const vote = await new Promise((res, rej) =>
+        ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
+      );
+      if (!vote.content || vote.content.createdBy !== userId) throw new Error('Not the author');
+      const tombstone = {
+        type: 'tombstone',
+        target: tipId,
+        deletedAt: new Date().toISOString(),
+        author: userId
+      };
+      return new Promise((res, rej) =>
+        ssbClient.publish(tombstone, (err, result) => (err ? rej(err) : res(result)))
+      );
     },
-    
-    async updateVoteById(id, { question, deadline, options, tags }) {
-      const ssb = await openSsb();
-      const userId = ssb.id;
+
+    async updateVoteById(id, payload) {
+      const { question, deadline, options, tags } = payload || {};
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const tipId = await resolveCurrentId(id);
+
       const oldMsg = await new Promise((res, rej) =>
-        ssb.get(id, (err, msg) => err || !msg ? rej(new Error('Vote not found')) : res(msg))
+        ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
       );
+
       const c = oldMsg.content;
-      if (c.type !== 'votes') throw new Error('Invalid type');
+      if (!c || c.type !== TYPE) throw new Error('Invalid type');
       if (c.createdBy !== userId) throw new Error('Not the author');
 
       let newDeadline = c.deadline;
@@ -58,31 +213,38 @@ module.exports = ({ cooler }) => {
         newDeadline = parsed.toISOString();
       }
 
-      let newOptions = c.options;
-      let newVotesMap = c.votes;
-      let newTotalVotes = c.totalVotes;
-      const optionsCambiaron = Array.isArray(options) && (
-        options.length !== c.options.length ||
-        options.some((o, i) => o !== c.options[i])
+      let newOptions = c.options || [];
+      let newVotesMap = c.votes || {};
+      let newTotalVotes = c.totalVotes || 0;
+
+      const optionsChanged = Array.isArray(options) && (
+        options.length !== newOptions.length ||
+        options.some((o, i) => o !== newOptions[i])
       );
-      if (optionsCambiaron) {
-        if (c.totalVotes > 0) {
+
+      if (optionsChanged) {
+        if ((c.totalVotes || 0) > 0) {
           throw new Error('Cannot change options after voting has started');
         }
         newOptions = options;
-        newVotesMap = newOptions.reduce((acc, opt) => (acc[opt] = 0, acc), {});
+        newVotesMap = newOptions.reduce((acc, opt) => {
+          acc[opt] = 0;
+          return acc;
+        }, {});
         newTotalVotes = 0;
       }
 
-      const newTags =
-        Array.isArray(tags) ? tags.filter(Boolean)
-        : typeof tags === 'string' ? tags.split(',').map(t => t.trim()).filter(Boolean)
-        : c.tags || [];
+      let newTags = c.tags || [];
+      if (Array.isArray(tags)) {
+        newTags = tags.filter(Boolean);
+      } else if (typeof tags === 'string') {
+        newTags = tags.split(',').map(t => t.trim()).filter(Boolean);
+      }
 
       const updated = {
         ...c,
-        replaces: id,
-        question: question ?? c.question,
+        replaces: tipId,
+        question: question != null ? question : c.question,
         deadline: newDeadline,
         options: newOptions,
         votes: newVotesMap,
@@ -90,122 +252,154 @@ module.exports = ({ cooler }) => {
         tags: newTags,
         updatedAt: new Date().toISOString()
       };
-      return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
+
+      return new Promise((res, rej) =>
+        ssbClient.publish(updated, (err, result) => (err ? rej(err) : res(result)))
+      );
     },
 
     async voteOnVote(id, choice) {
-      const ssb = await openSsb();
-      const userId = ssb.id;
-      const vote = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
-      if (!vote.content.options.includes(choice)) throw new Error('Invalid choice');
-      if (vote.content.voters.includes(userId)) throw new Error('Already voted');
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const tipId = await resolveCurrentId(id);
+
+      const vote = await new Promise((res, rej) =>
+        ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
+      );
 
-      vote.content.votes[choice] += 1;
-      vote.content.voters.push(userId);
-      vote.content.totalVotes += 1;
+      const content = vote.content || {};
+      const options = Array.isArray(content.options) ? content.options : [];
+      if (!options.includes(choice)) throw new Error('Invalid choice');
 
-      const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
-      const updated = { ...vote.content, updatedAt: new Date().toISOString(), replaces: id };
+      const voters = Array.isArray(content.voters) ? content.voters.slice() : [];
+      if (voters.includes(userId)) throw new Error('Already voted');
 
-      await new Promise((res, rej) => ssb.publish(tombstone, err => err ? rej(err) : res()));
-      return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
+      const votesMap = Object.assign({}, content.votes || {});
+      votesMap[choice] = (votesMap[choice] || 0) + 1;
+      voters.push(userId);
+      const totalVotes = (parseInt(content.totalVotes || 0, 10) || 0) + 1;
+
+      const tombstone = {
+        type: 'tombstone',
+        target: tipId,
+        deletedAt: new Date().toISOString(),
+        author: userId
+      };
+
+      const updated = {
+        ...content,
+        votes: votesMap,
+        voters,
+        totalVotes,
+        updatedAt: new Date().toISOString(),
+        replaces: tipId
+      };
+
+      await new Promise((res, rej) =>
+        ssbClient.publish(tombstone, err => (err ? rej(err) : res()))
+      );
+
+      return new Promise((res, rej) =>
+        ssbClient.publish(updated, (err, result) => (err ? rej(err) : res(result)))
+      );
     },
 
     async getVoteById(id) {
-      const ssb = await openSsb();
-      const now = moment();
-
-      const results = await new Promise((resolve, reject) => {
-        pull(
-          ssb.createLogStream({ limit: logLimit }),
-          pull.collect((err, arr) => err ? reject(err) : resolve(arr))
-        );
-      });
+      const ssbClient = await openSsb();
+      const messages = await getAllMessages(ssbClient);
+      const index = buildIndex(messages);
+      const activeList = computeActiveVotes(index);
+      const byId = new Map(activeList.map(v => [v.id, v]));
 
-      const votesByKey = new Map();
-      const latestByRoot = new Map();
-
-      for (const r of results) {
-        const key = r.key;
-        const v = r.value;
-        const c = v && v.content;
-        if (!c) continue;
-        if (c.type === 'votes') {
-          votesByKey.set(key, c);
-          const ts = Number(v.timestamp || r.timestamp || Date.now());
-          const root = c.replaces || key;
-          const prev = latestByRoot.get(root);
-          if (!prev || ts > prev.ts) latestByRoot.set(root, { key, ts });
-        }
+      if (byId.has(id)) {
+        return byId.get(id);
       }
 
-      const latestEntry = latestByRoot.get(id);
-      let latestId = latestEntry ? latestEntry.key : id;
-      let content = votesByKey.get(latestId);
+      const parent = index.parent;
+      const rootOf = key => {
+        let cur = key;
+        while (parent.has(cur)) cur = parent.get(cur);
+        return cur;
+      };
 
-      if (!content) {
-        const orig = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
-        content = orig.content;
-        latestId = id;
+      const root = rootOf(id);
+      const candidate = activeList.find(v => rootOf(v.id) === root);
+      if (candidate) {
+        return candidate;
       }
 
-      const status = content.status === 'OPEN' && moment(content.deadline).isBefore(now) ? 'CLOSED' : content.status;
-      return { id, latestId, ...content, status };
+      const msg = await new Promise((res, rej) =>
+        ssbClient.get(id, (err, vote) => (err || !vote ? rej(new Error('Vote not found')) : res(vote)))
+      );
+
+      const content = msg.content || {};
+      const status = statusFromContent(content, moment());
+
+      return {
+        id,
+        latestId: id,
+        ...content,
+        status
+      };
     },
 
     async listAll(filter = 'all') {
-      const ssb = await openSsb();
-      const userId = ssb.id;
-      const now = moment();
-
-      return new Promise((resolve, reject) => {
-        pull(ssb.createLogStream({ limit: logLimit }), 
-        pull.collect((err, results) => {
-          if (err) return reject(err);
-          const tombstoned = new Set();
-          const replaced = new Map();
-          const votes = new Map();
-
-          for (const r of results) {
-            const { key, value: { content: c } } = r;
-            if (!c) continue;
-            if (c.type === 'tombstone') tombstoned.add(c.target);
-            if (c.type === 'votes') {
-              if (c.replaces) replaced.set(c.replaces, key);
-              const status = c.status === 'OPEN' && moment(c.deadline).isBefore(now) ? 'CLOSED' : c.status;
-              votes.set(key, { id: key, ...c, status });
-            }
-          }
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const messages = await getAllMessages(ssbClient);
+      const index = buildIndex(messages);
+      let list = computeActiveVotes(index);
 
-          tombstoned.forEach(id => votes.delete(id));
-          replaced.forEach((_, oldId) => votes.delete(oldId));
+      if (filter === 'mine') {
+        list = list.filter(v => v.createdBy === userId);
+      } else if (filter === 'open') {
+        list = list.filter(v => v.status === 'OPEN');
+      } else if (filter === 'closed') {
+        list = list.filter(v => v.status === 'CLOSED');
+      }
 
-          const out = [...votes.values()];
-          if (filter === 'mine') return resolve(out.filter(v => v.createdBy === userId));
-          if (filter === 'open') return resolve(out.filter(v => v.status === 'OPEN'));
-          if (filter === 'closed') return resolve(out.filter(v => v.status === 'CLOSED'));
-          resolve(out);
-        }));
-      });
+      return list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
     },
 
     async createOpinion(id, category) {
-      const ssb = await openSsb();
-      const userId = ssb.id;
-      const vote = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
-      if (vote.content.opinions_inhabitants.includes(userId)) throw new Error('Already voted');
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const tipId = await resolveCurrentId(id);
+
+      const vote = await new Promise((res, rej) =>
+        ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
+      );
+
+      const content = vote.content || {};
+      const list = Array.isArray(content.opinions_inhabitants) ? content.opinions_inhabitants : [];
+
+      if (list.includes(userId)) throw new Error('Already voted');
+
+      const opinions = Object.assign({}, content.opinions || {});
+      opinions[category] = (opinions[category] || 0) + 1;
+
+      const tombstone = {
+        type: 'tombstone',
+        target: tipId,
+        deletedAt: new Date().toISOString(),
+        author: userId
+      };
 
-      const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
       const updated = {
-        ...vote.content,
-        opinions: { ...vote.content.opinions, [category]: (vote.content.opinions[category] || 0) + 1 },
-        opinions_inhabitants: [...vote.content.opinions_inhabitants, userId],
+        ...content,
+        opinions,
+        opinions_inhabitants: list.concat(userId),
         updatedAt: new Date().toISOString(),
-        replaces: id
+        replaces: tipId
       };
 
-      await new Promise((res, rej) => ssb.publish(tombstone, err => err ? rej(err) : res()));
-      return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
+      await new Promise((res, rej) =>
+        ssbClient.publish(tombstone, err => (err ? rej(err) : res()))
+      );
+
+      return new Promise((res, rej) =>
+        ssbClient.publish(updated, (err, result) => (err ? rej(err) : res(result)))
+      );
     }
   };
 };

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

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

+ 1 - 1
src/server/package.json

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

+ 26 - 3
src/views/activity_view.js

@@ -101,14 +101,37 @@ function renderActionCards(actions, userId) {
 
     if (type === 'votes') {
       const { question, deadline, status, votes, totalVotes } = content;
+      const commentCount =
+        typeof action.commentCount === 'number'
+          ? action.commentCount
+          : (typeof content.commentCount === 'number' ? content.commentCount : 0);
+
       const votesList = votes && typeof votes === 'object'
         ? Object.entries(votes).map(([option, count]) => ({ option, count }))
         : [];
+
       cardBody.push(
         div({ class: 'card-section votes' },
-          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.question + ':'), span({ class: 'card-value' }, question)),
-          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
-          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteTotalVotes + ':'), span({ class: 'card-value' }, totalVotes)),
+          div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.question + ':'),
+            span({ class: 'card-value' }, question)
+          ),
+          div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.deadline + ':'),
+            span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')
+          ),
+          div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.voteTotalVotes + ':'),
+            span({ class: 'card-value' }, totalVotes)
+          ),
+          div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
+            span({ class: 'card-value' }, String(commentCount))
+          ),
           table(
             tr(...votesList.map(({ option }) => th(i18n[option] || option))),
             tr(...votesList.map(({ count }) => td(count)))

+ 108 - 28
src/views/audio_view.js

@@ -21,6 +21,74 @@ const getFilteredAudios = (filter, audios, userId) => {
   return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
 };
 
+const renderAudioCommentsSection = (audioId, comments = []) => {
+  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+
+  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),
+      form({
+        method: 'POST',
+        action: `/audios/${encodeURIComponent(audioId)}/comments`,
+        class: 'comment-form'
+      },
+        textarea({
+          id: 'comment-text',
+          name: 'text',
+          required: true,
+          rows: 4,
+          class: 'comment-textarea',
+          placeholder: i18n.voteNewCommentPlaceholder
+        }),
+        br(),
+        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+      )
+    ),
+    comments && comments.length
+      ? div({ class: 'comments-list' },
+          comments.map(c => {
+            const author = c.value && c.value.author ? c.value.author : '';
+            const ts = c.value && c.value.timestamp ? 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;
+
+            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
+                  ? a(
+                      {
+                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
+                      },
+                      relDate
+                    )
+                  : ''
+              ),
+              p({
+                class: 'votations-comment-text',
+                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
+              })
+            );
+          })
+        )
+      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+  );
+};
+
 const renderCardField = (label, value) =>
   div({ class: "card-field" }, 
     span({ class: "card-label" }, label), 
@@ -40,12 +108,15 @@ const renderAudioActions = (filter, audio) => {
 
 const renderAudioList = (filteredAudios, filter) => {
   return filteredAudios.length > 0
-    ? filteredAudios.map(audio =>
-        div({ class: "audio-item card" },
-         br,
+    ? filteredAudios.map(audio => {
+        const commentCount = typeof audio.commentCount === 'number' ? audio.commentCount : 0;
+
+        return div({ class: "audio-item card" },
+          br,
           renderAudioActions(filter, audio),
           form({ method: "GET", action: `/audios/${encodeURIComponent(audio.key)}` },
-            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)),
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ),
           audio.title?.trim() ? h2(audio.title) : null,
           audio.url
             ? div({ class: "audio-container" },
@@ -65,11 +136,19 @@ const renderAudioList = (filteredAudios, filter) => {
                 )
               )
             : null,
-         br,
-         p({ class: 'card-footer' },
-           span({ class: 'date-link' }, `${moment(audio.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-           a({ href: `/author/${encodeURIComponent(audio.author)}`, class: 'user-link' }, `${audio.author}`)
-         ),
+          div({ class: 'card-comments-summary' },
+            span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
+            span({ class: 'card-value' }, String(commentCount)),
+            br, br,
+            form({ method: 'GET', action: `/audios/${encodeURIComponent(audio.key)}` },
+              button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+            )
+          ),
+          br,
+          p({ class: 'card-footer' },
+            span({ class: 'date-link' }, `${moment(audio.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
+            a({ href: `/author/${encodeURIComponent(audio.author)}`, class: 'user-link' }, `${audio.author}`)
+          ),
           div({ class: "voting-buttons" },
             ['interesting','necessary','funny','disgusting','sensible',
              'propaganda','adultOnly','boring','confusing','inspiring','spam']
@@ -80,9 +159,9 @@ const renderAudioList = (filteredAudios, filter) => {
                   )
                 )
               )
-          ),
-        )
-      )
+          )
+        );
+      })
     : div(i18n.noAudios);
 };
 
@@ -147,7 +226,7 @@ exports.audioView = async (audios, filter, audioId) => {
   );
 };
 
-exports.singleAudioView = async (audio, filter) => {
+exports.singleAudioView = async (audio, filter, comments = []) => {
   const isAuthor = audio.author === userId; 
   const hasOpinions = Object.keys(audio.opinions || {}).length > 0; 
 
@@ -165,17 +244,18 @@ exports.singleAudioView = async (audio, filter) => {
       ),
       div({ class: "tags-header" },
         isAuthor ? div({ class: "audio-actions" },
-        !hasOpinions
-          ? form({ method: "GET", action: `/audios/edit/${encodeURIComponent(audio.key)}` },
-              button({ class: "update-btn", type: "submit" }, i18n.audioUpdateButton)
-            )
-          : null,
-        form({ method: "POST", action: `/audios/delete/${encodeURIComponent(audio.key)}` },
-          button({ class: "delete-btn", type: "submit" }, i18n.audioDeleteButton)
-        )
-      ) : null,
+          !hasOpinions
+            ? form({ method: "GET", action: `/audios/edit/${encodeURIComponent(audio.key)}` },
+                button({ class: "update-btn", type: "submit" }, i18n.audioUpdateButton)
+              )
+            : null,
+          form({ method: "POST", action: `/audios/delete/${encodeURIComponent(audio.key)}` },
+            button({ class: "delete-btn", type: "submit" }, i18n.audioDeleteButton)
+          )
+        ) : null,
         form({ method: "GET", action: `/audios/${encodeURIComponent(audio.key)}` },
-        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)),
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
         h2(audio.title),
         audio.url
           ? div({ class: "audio-container" },
@@ -195,11 +275,11 @@ exports.singleAudioView = async (audio, filter) => {
               )
             )
           : null,
-          br,
-          p({ class: 'card-footer' },
+        br,
+        p({ class: 'card-footer' },
           span({ class: 'date-link' }, `${moment(audio.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
           a({ href: `/author/${encodeURIComponent(audio.author)}`, class: 'user-link' }, `${audio.author}`)
-          ),
+        )
       ),
       div({ class: "voting-buttons" },
         ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'].map(category =>
@@ -207,8 +287,8 @@ exports.singleAudioView = async (audio, filter) => {
             button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${audio.opinions?.[category] || 0}]`)
           )
         )
-      )
+      ),
+      renderAudioCommentsSection(audio.key, comments)
     )
   );
 };
-

+ 94 - 16
src/views/bookmark_view.js

@@ -19,6 +19,74 @@ const renderBookmarkActions = (filter, bookmark) => {
     : null;
 };
 
+const renderBookmarkCommentsSection = (bookmarkId, comments = []) => {
+  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+
+  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),
+      form({
+        method: 'POST',
+        action: `/bookmarks/${encodeURIComponent(bookmarkId)}/comments`,
+        class: 'comment-form'
+      },
+        textarea({
+          id: 'comment-text',
+          name: 'text',
+          required: true,
+          rows: 4,
+          class: 'comment-textarea',
+          placeholder: i18n.voteNewCommentPlaceholder
+        }),
+        br(),
+        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+      )
+    ),
+    comments && comments.length
+      ? div({ class: 'comments-list' },
+          comments.map(c => {
+            const author = c.value && c.value.author ? c.value.author : '';
+            const ts = c.value && c.value.timestamp ? 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;
+
+            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
+                  ? a(
+                      {
+                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
+                      },
+                      relDate
+                    )
+                  : ''
+              ),
+              p({
+                class: 'votations-comment-text',
+                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
+              })
+            );
+          })
+        )
+      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+  );
+};
+
 const renderCardField = (labelText, value) =>
   div({ class: 'card-field' },
     span({ class: 'card-label' }, labelText),
@@ -27,8 +95,10 @@ const renderCardField = (labelText, value) =>
 
 const renderBookmarkList = (filteredBookmarks, filter) => {
   return filteredBookmarks.length > 0
-    ? filteredBookmarks.map(bookmark =>
-        div({ class: "tags-header" },
+    ? filteredBookmarks.map(bookmark => {
+        const commentCount = typeof bookmark.commentCount === 'number' ? bookmark.commentCount : 0;
+
+        return div({ class: "tags-header" },
           renderBookmarkActions(filter, bookmark),
           form({ method: "GET", action: `/bookmarks/${encodeURIComponent(bookmark.id)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
@@ -46,18 +116,26 @@ const renderBookmarkList = (filteredBookmarks, filter) => {
           ),
           bookmark.category?.trim()
             ? renderCardField(i18n.bookmarkCategory + ":", bookmark.category)
-            : null,  
-	  bookmark.description
-	    ? [
-	      renderCardField(i18n.bookmarkDescriptionLabel + ":"),
-	      p(...renderUrl(bookmark.description))
-	    ]
-	  : null,
+            : null,
+          bookmark.description
+            ? [
+                renderCardField(i18n.bookmarkDescriptionLabel + ":"),
+                p(...renderUrl(bookmark.description))
+              ]
+            : null,
           bookmark.tags?.length
             ? div({ class: "card-tags" }, bookmark.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, br,
+            form({ method: 'GET', action: `/bookmarks/${encodeURIComponent(bookmark.id)}` },
+              button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+            )
+          ),
           br,
           div({ class: 'card-footer' },
             span({ class: 'date-link' }, `${moment(bookmark.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
@@ -70,8 +148,8 @@ const renderBookmarkList = (filteredBookmarks, filter) => {
               )
             )
           )
-        )
-      )
+        );
+      })
     : p(i18n.nobookmarks);
 };
 
@@ -162,7 +240,7 @@ exports.bookmarkView = async (bookmarks, filter, bookmarkId) => {
   );
 };
 
-exports.singleBookmarkView = async (bookmark, filter) => {
+exports.singleBookmarkView = async (bookmark, filter, comments = []) => {
   const isAuthor = bookmark.author === userId; 
   const hasOpinions = Object.keys(bookmark.opinions || {}).length > 0;
 
@@ -181,8 +259,8 @@ exports.singleBookmarkView = async (bookmark, filter) => {
         )
       ),
       div({ class: "bookmark-item card" },
-      br,
-          isAuthor ? div({ class: "bookmark-actions" },
+        br,
+        isAuthor ? div({ class: "bookmark-actions" },
           !hasOpinions
             ? form({ method: "GET", action: `/bookmarks/edit/${encodeURIComponent(bookmark.id)}` },
                 button({ class: "update-btn", type: "submit" }, i18n.bookmarkUpdateButton)
@@ -225,8 +303,8 @@ exports.singleBookmarkView = async (bookmark, filter) => {
             )
           )
         )
-      )
+      ),
+      renderBookmarkCommentsSection(bookmark.id, comments)
     )
   );
 };
-

+ 104 - 24
src/views/document_view.js

@@ -23,6 +23,74 @@ const getFilteredDocuments = (filter, documents, userId) => {
   return filtered;
 };
 
+const renderDocumentCommentsSection = (documentId, comments = []) => {
+  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+
+  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),
+      form({
+        method: 'POST',
+        action: `/documents/${encodeURIComponent(documentId)}/comments`,
+        class: 'comment-form'
+      },
+        textarea({
+          id: 'comment-text',
+          name: 'text',
+          required: true,
+          rows: 4,
+          class: 'comment-textarea',
+          placeholder: i18n.voteNewCommentPlaceholder
+        }),
+        br(),
+        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+      )
+    ),
+    comments && comments.length
+      ? div({ class: 'comments-list' },
+          comments.map(c => {
+            const author = c.value && c.value.author ? c.value.author : '';
+            const ts = c.value && c.value.timestamp ? 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;
+
+            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
+                  ? a(
+                      {
+                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
+                      },
+                      relDate
+                    )
+                  : ''
+              ),
+              p({
+                class: 'votations-comment-text',
+                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
+              })
+            );
+          })
+        )
+      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+  );
+};
+
 const renderDocumentActions = (filter, doc) => {
   return filter === 'mine' ? div({ class: "document-actions" },
     form({ method: "GET", action: `/documents/edit/${encodeURIComponent(doc.key)}` },
@@ -44,8 +112,10 @@ const renderDocumentList = (filteredDocs, filter) => {
   }
 
   return unique.length > 0
-    ? unique.map(doc =>
-        div({ class: "tags-header" },
+    ? unique.map(doc => {
+        const commentCount = typeof doc.commentCount === 'number' ? doc.commentCount : 0;
+
+        return div({ class: "tags-header" },
           renderDocumentActions(filter, doc),
           form({ method: "GET", action: `/documents/${encodeURIComponent(doc.key)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
@@ -62,6 +132,15 @@ const renderDocumentList = (filteredDocs, filter) => {
                 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(),
+            br(),
+            form({ method: 'GET', action: `/documents/${encodeURIComponent(doc.key)}` },
+              button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+            )
+          ),
           br(),
           p({ class: 'card-footer' },
             span({ class: 'date-link' }, `${moment(doc.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
@@ -77,8 +156,8 @@ const renderDocumentList = (filteredDocs, filter) => {
                 )
               )
           )
-        )
-      )
+        );
+      })
     : div(i18n.noDocuments);
 };
 
@@ -145,7 +224,7 @@ exports.documentView = async (documents, filter, documentId) => {
       : ''}`;
 };
 
-exports.singleDocumentView = async (doc, filter) => {
+exports.singleDocumentView = async (doc, filter, comments = []) => {
   const isAuthor = doc.author === userId;
   const hasOpinions = Object.keys(doc.opinions || {}).length > 0;
 
@@ -162,16 +241,16 @@ exports.singleDocumentView = async (doc, filter) => {
         )
       ),
       div({ class: "tags-header" },
-       isAuthor ? div({ class: "document-actions" },
-        !hasOpinions
-          ? form({ method: "GET", action: `/documents/edit/${encodeURIComponent(doc.key)}` },
-              button({ class: "update-btn", type: "submit" }, i18n.documentUpdateButton)
-            )
-          : null,
-        form({ method: "POST", action: `/documents/delete/${encodeURIComponent(doc.key)}` },
-          button({ class: "delete-btn", type: "submit" }, i18n.documentDeleteButton)
-        )
-      ) : null,
+        isAuthor ? div({ class: "document-actions" },
+          !hasOpinions
+            ? form({ method: "GET", action: `/documents/edit/${encodeURIComponent(doc.key)}` },
+                button({ class: "update-btn", type: "submit" }, i18n.documentUpdateButton)
+              )
+            : null,
+          form({ method: "POST", action: `/documents/delete/${encodeURIComponent(doc.key)}` },
+            button({ class: "delete-btn", type: "submit" }, i18n.documentDeleteButton)
+          )
+        ) : null,
         h2(doc.title),
         div({
           id: `pdf-container-${doc.key}`,
@@ -179,16 +258,16 @@ exports.singleDocumentView = async (doc, filter) => {
           'data-pdf-url': `/blob/${encodeURIComponent(doc.url)}`
         }),
         p(...renderUrl(doc.description)),
-          doc.tags.length
-            ? div({ class: "card-tags" }, doc.tags.map(tag =>
-                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
-              ))
-            : null,
-            br,
-          p({ class: 'card-footer' },
+        doc.tags.length
+          ? div({ class: "card-tags" }, doc.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+            ))
+          : null,
+        br(),
+        p({ class: 'card-footer' },
           span({ class: 'date-link' }, `${moment(doc.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
           a({ href: `/author/${encodeURIComponent(doc.author)}`, class: 'user-link' }, `${doc.author}`)
-          ),
+        )
       ),
       div({ class: "voting-buttons" },
         ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'].map(category =>
@@ -196,7 +275,8 @@ exports.singleDocumentView = async (doc, filter) => {
             button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${doc.opinions?.[category] || 0}]`)
           )
         )
-      )
+      ),
+      renderDocumentCommentsSection(doc.key, comments)
     )
   );
 

+ 85 - 7
src/views/event_view.js

@@ -11,6 +11,74 @@ const renderStyledField = (labelText, valueElement) =>
     span({ class: 'card-label' }, labelText),
     span({ class: 'card-value' }, ...renderUrl(valueElement))
   );
+  
+const renderEventCommentsSection = (eventId, comments = []) => {
+  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+
+  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),
+      form({
+        method: 'POST',
+        action: `/events/${encodeURIComponent(eventId)}/comments`,
+        class: 'comment-form'
+      },
+        textarea({
+          id: 'comment-text',
+          name: 'text',
+          required: true,
+          rows: 4,
+          class: 'comment-textarea',
+          placeholder: i18n.voteNewCommentPlaceholder
+        }),
+        br(),
+        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+      )
+    ),
+    comments && comments.length
+      ? div({ class: 'comments-list' },
+          comments.map(c => {
+            const author = c.value && c.value.author ? c.value.author : '';
+            const ts = c.value && c.value.timestamp ? 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;
+
+            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
+                  ? a(
+                      {
+                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
+                      },
+                      relDate
+                    )
+                  : ''
+              ),
+              p({
+                class: 'votations-comment-text',
+                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
+              })
+            );
+          })
+        )
+      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+  );
+};  
 
 const renderEventItem = (e, filter, userId) => {
   const actions = [];
@@ -35,6 +103,9 @@ const renderEventItem = (e, filter, userId) => {
       )
     );
   }
+
+  const commentCount = typeof e.commentCount === 'number' ? e.commentCount : 0;
+
   return div({ class:"card card-section event" },
     actions.length ? div({ class:"event-actions" }, ...actions) : null,
     form({ method:"GET", action:`/events/${encodeURIComponent(e.id)}` },
@@ -70,13 +141,20 @@ const renderEventItem = (e, filter, userId) => {
           )
         )
       : null,
+    div({ class: 'card-comments-summary' },
+      span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
+      span({ class: 'card-value' }, String(commentCount)),
+      br, br,
+      form({ method: 'GET', action: `/events/${encodeURIComponent(e.id)}` },
+        button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+      )
+    ),
     br,
     p({ class: 'card-footer' },
       span({ class: 'date-link' }, `${e.createdAt} ${i18n.performed} `),
       a({ href: `/author/${encodeURIComponent(e.organizer)}`, class: 'user-link' }, `${e.organizer}`)
     )
-    
-  )
+  );
 };
 
 exports.eventView = async (events, filter, eventId) => {
@@ -198,7 +276,7 @@ exports.eventView = async (events, filter, eventId) => {
   )
 }
 
-exports.singleEventView = async (event, filter) => {
+exports.singleEventView = async (event, filter, comments = []) => {
   return template(
     event.title,
     section(
@@ -225,7 +303,7 @@ exports.singleEventView = async (event, filter) => {
       ),
       div({ class: "card card-section event" },
         form({ method:"GET", action:`/events/${encodeURIComponent(event.id)}` },
-         button({ type:"submit", class:"filter-btn" }, i18n.viewDetails)
+          button({ type:"submit", class:"filter-btn" }, i18n.viewDetails)
         ),
         br,
         renderStyledField(i18n.eventTitleLabel + ':', event.title),
@@ -254,13 +332,13 @@ exports.singleEventView = async (event, filter) => {
               )
             )
           : null,
-              br,
+        br,
         p({ class: 'card-footer' },
           span({ class: 'date-link' }, `${moment(event.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
           a({ href: `/author/${encodeURIComponent(event.organizer)}`, class: 'user-link' }, `${event.organizer}`)
         )
-      )
+      ),
+      renderEventCommentsSection(event.id, comments)
     )
   );
 };
-

+ 141 - 33
src/views/image_view.js

@@ -34,40 +34,63 @@ const renderImageActions = (filter, imgObj) => {
 
 const renderImageList = (filteredImages, filter) => {
   return filteredImages.length > 0
-    ? filteredImages.map(imgObj =>
-        div({ class: "tags-header" },
-         renderImageActions(filter, imgObj),
+    ? filteredImages.map(imgObj => {
+        const commentCount = typeof imgObj.commentCount === 'number' ? imgObj.commentCount : 0;
+        return div({ class: "tags-header" },
+          renderImageActions(filter, imgObj),
           form({ method: "GET", action: `/images/${encodeURIComponent(imgObj.key)}` },
             button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-          ),
-          
+          ), 
           imgObj.title ? h2(imgObj.title) : null,
-          a({ href: `#img-${encodeURIComponent(imgObj.key)}` }, img({ src: `/blob/${encodeURIComponent(imgObj.url)}` })),
+          a({ href: `#img-${encodeURIComponent(imgObj.key)}` },
+            img({ src: `/blob/${encodeURIComponent(imgObj.url)}` })
+          ),
           imgObj.description ? p(...renderUrl(imgObj.description)) : null,
           imgObj.tags?.length
             ? div({ class: "card-tags" }, 
                 imgObj.tags.map(tag =>
-                  a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+                  a({
+                      href: `/search?query=%23${encodeURIComponent(tag)}`,
+                      class: "tag-link",
+                      style: "margin-right: 0.8em; margin-bottom: 0.5em;"
+                    },
+                    `#${tag}`
+                  )
                 )
               )
             : null,
+          div({ class: 'card-comments-summary' },
+            span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
+            span({ class: 'card-value' }, String(commentCount)),
+            br, br,
+            form({ method: 'GET', action: `/images/${encodeURIComponent(imgObj.key)}` },
+              button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+            )
+          ),
           br,
           p({ class: 'card-footer' },
-            span({ class: 'date-link' }, `${moment(imgObj.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-            a({ href: `/author/${encodeURIComponent(imgObj.author)}`, class: 'user-link' }, `${imgObj.author}`)
+            span(
+              { class: 'date-link' },
+              `${moment(imgObj.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `
+            ),
+            a(
+              { href: `/author/${encodeURIComponent(imgObj.author)}`, class: 'user-link' },
+              `${imgObj.author}`
+            )
           ),
           div({ class: "voting-buttons" },
             ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam']
               .map(category =>
                 form({ method: "POST", action: `/images/opinions/${encodeURIComponent(imgObj.key)}/${category}` },
-                  button({ class: "vote-btn" },
+                  button(
+                    { class: "vote-btn" },
                     `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${imgObj.opinions?.[category] || 0}]`
                   )
                 )
               )
           )
-        )
-      )
+        );
+      })
     : div(i18n.noImages);
 };
 
@@ -118,6 +141,74 @@ const renderLightbox = (sortedImages) => {
   );
 };
 
+const renderImageCommentsSection = (imageId, comments = []) => {
+  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+
+  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),
+      form({
+        method: 'POST',
+        action: `/images/${encodeURIComponent(imageId)}/comments`,
+        class: 'comment-form'
+      },
+        textarea({
+          id: 'comment-text',
+          name: 'text',
+          required: true,
+          rows: 4,
+          class: 'comment-textarea',
+          placeholder: i18n.voteNewCommentPlaceholder
+        }),
+        br(),
+        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+      )
+    ),
+    comments && comments.length
+      ? div({ class: 'comments-list' },
+          comments.map(c => {
+            const author = c.value && c.value.author ? c.value.author : '';
+            const ts = c.value && c.value.timestamp ? 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;
+
+            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
+                  ? a(
+                      {
+                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
+                      },
+                      relDate
+                    )
+                  : ''
+              ),
+              p({
+                class: 'votations-comment-text',
+                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
+              })
+            );
+          })
+        )
+      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+  );
+};
+
 exports.imageView = async (images, filter, imageId) => {
   const title = filter === 'mine' ? i18n.imageMineSectionTitle :
                 filter === 'create' ? i18n.imageCreateSectionTitle :
@@ -165,7 +256,7 @@ exports.imageView = async (images, filter, imageId) => {
   );
 };
 
-exports.singleImageView = async (image, filter) => {
+exports.singleImageView = async (image, filter, comments = []) => {
   const isAuthor = image.author === userId;
   const hasOpinions = Object.keys(image.opinions || {}).length > 0;
 
@@ -184,38 +275,55 @@ exports.singleImageView = async (image, filter) => {
       ),
       div({ class: "tags-header" },
         isAuthor ? div({ class: "image-actions" },
-        !hasOpinions
-          ? form({ method: "GET", action: `/images/edit/${encodeURIComponent(image.key)}` },
-              button({ class: "update-btn", type: "submit" }, i18n.imageUpdateButton)
-            )
-          : null,
-        form({ method: "POST", action: `/images/delete/${encodeURIComponent(image.key)}` },
-          button({ class: "delete-btn", type: "submit" }, i18n.imageDeleteButton)
-        )
-      ) : null,
+          !hasOpinions
+            ? form({ method: "GET", action: `/images/edit/${encodeURIComponent(image.key)}` },
+                button({ class: "update-btn", type: "submit" }, i18n.imageUpdateButton)
+              )
+            : null,
+          form({ method: "POST", action: `/images/delete/${encodeURIComponent(image.key)}` },
+            button({ class: "delete-btn", type: "submit" }, i18n.imageDeleteButton)
+          )
+        ) : null,
         h2(image.title),
         image.url ? img({ src: `/blob/${encodeURIComponent(image.url)}` }) : null,
         p(...renderUrl(image.description)),
         image.tags?.length
-            ? div({ class: "card-tags" }, 
+          ? div({ class: "card-tags" }, 
               image.tags.map(tag =>
-                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+                a({
+                    href: `/search?query=%23${encodeURIComponent(tag)}`,
+                    class: "tag-link",
+                    style: "margin-right: 0.8em; margin-bottom: 0.5em;"
+                  },
+                  `#${tag}`
+                )
               )
             )
           : null,
-          br,
-          p({ class: 'card-footer' },
-          span({ class: 'date-link' }, `${moment(image.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-          a({ href: `/author/${encodeURIComponent(image.author)}`, class: 'user-link' }, `${image.author}`)
+        br,
+        p({ class: 'card-footer' },
+          span(
+            { class: 'date-link' },
+            `${moment(image.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `
           ),
+          a(
+            { href: `/author/${encodeURIComponent(image.author)}`, class: 'user-link' },
+            `${image.author}`
+          )
+        )
       ),
       div({ class: "voting-buttons" },
-        ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'].map(category =>
-          form({ method: "POST", action: `/images/opinions/${encodeURIComponent(image.key)}/${category}` },
-            button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${image.opinions?.[category] || 0}]`)
+        ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam']
+          .map(category =>
+            form({ method: "POST", action: `/images/opinions/${encodeURIComponent(image.key)}/${category}` },
+              button(
+                { class: "vote-btn" },
+                `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${image.opinions?.[category] || 0}]`
+              )
+            )
           )
-        )
-      )
+      ),
+      renderImageCommentsSection(image.key, comments)
     )
   );
 };

+ 74 - 3
src/views/jobs_view.js

@@ -117,6 +117,14 @@ const renderJobList = (jobs, filter) =>
           br(),
           div({ class: 'card-label' }, h2(`${job.salary} ECO`)),
           br(),
+          div({ class: 'card-comments-summary' },
+            span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
+            span({ class: 'card-value' }, String(job.commentCount || 0)),
+            br(), br(),
+            form({ method: 'GET', action: `/jobs/${encodeURIComponent(job.id)}` },
+              button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+            )
+          ),
           div({ class: 'card-footer' },
             span({ class: 'date-link' }, `${moment(job.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
             a({ href: `/author/${encodeURIComponent(job.author)}`, class: 'user-link' }, job.author)
@@ -202,6 +210,69 @@ const renderCVList = (inhabitants) =>
       : p({ class: 'no-results' }, i18n.noInhabitantsFound)
   );
 
+const renderJobCommentsSection = (jobId, comments = []) => {
+  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+
+  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),
+      form({
+        method: 'POST',
+        action: `/jobs/${encodeURIComponent(jobId)}/comments`,
+        class: 'comment-form'
+      },
+        textarea({
+          id: 'comment-text',
+          name: 'text',
+          required: true,
+          rows: 4,
+          class: 'comment-textarea',
+          placeholder: i18n.voteNewCommentPlaceholder
+        }),
+        br(),
+        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+      )
+    ),
+    comments && comments.length
+      ? div({ class: 'comments-list' },
+          comments.map(c => {
+            const author = c.value && c.value.author ? c.value.author : '';
+            const ts = c.value && c.value.timestamp ? 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 rootId = c.value && c.value.content ? (c.value.content.fork || c.value.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 && rootId
+                  ? a({
+                      href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}`
+                    }, relDate)
+                  : ''
+              ),
+              p({
+                class: 'votations-comment-text',
+                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
+              })
+            );
+          })
+        )
+      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+  );
+};
+
 exports.jobsView = async (jobsOrCVs, filter = "ALL", cvQuery = {}) => {
   const filterObj = FILTERS.find(f => f.key === filter) || FILTERS[0];
   const sectionTitle = i18n[filterObj.title] || i18n.jobsTitle;
@@ -239,10 +310,9 @@ exports.jobsView = async (jobsOrCVs, filter = "ALL", cvQuery = {}) => {
   );
 };
 
-exports.singleJobsView = async (job, filter = "ALL") => {
+exports.singleJobsView = async (job, filter = "ALL", comments = []) => {
   const isAuthor = job.author === userId;
   const isOpen = String(job.status).toUpperCase() === 'OPEN';
-
   return template(
     i18n.jobsTitle,
     section(
@@ -309,7 +379,8 @@ exports.singleJobsView = async (job, filter = "ALL") => {
           span({ class: 'date-link' }, `${moment(job.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
           a({ href: `/author/${encodeURIComponent(job.author)}`, class: 'user-link' }, job.author)
         )
-      )
+      ),
+      renderJobCommentsSection(job.id, comments)
     )
   );
 };

+ 1 - 1
src/views/main_views.js

@@ -790,7 +790,7 @@ const post = ({ msg, aside = false, preview = false }) => {
           { href: url.author },
           img({ class: "avatar-profile", src: url.avatar, alt: "" })
         ),
-        span({ class: "created-at" }, `${i18n.createdBy} `, a({ href: url.author }, "@", name), ` | ${timeAbsolute} | ${i18n.sendTime} `, a({ href: url.link }, timeAgo), ` ${i18n.timeAgo}`),
+        span({ class: "created-at" }, `${i18n.createdBy} `, a({ href: url.author }, "@", name), ` | ${timeAbsolute} | ${i18n.sendTime} `, a({ href: url.link }, timeAgo)),
         isPrivate ? "🔒" : null,
         isPrivate ? recps : null
       )

+ 98 - 25
src/views/market_view.js

@@ -11,7 +11,70 @@ const renderCardField = (labelText, value) =>
     span({ class: 'card-label' }, labelText),
     span({ class: 'card-value' }, ...renderUrl(value))
   );
+  
+const renderMarketCommentsSection = (itemId, comments = []) => {
+  const commentsCount = Array.isArray(comments) ? comments.length : 0;
 
+  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),
+      form({
+        method: 'POST',
+        action: `/market/${encodeURIComponent(itemId)}/comments`,
+        class: 'comment-form'
+      },
+        textarea({
+          id: 'comment-text',
+          name: 'text',
+          required: true,
+          rows: 4,
+          class: 'comment-textarea',
+          placeholder: i18n.voteNewCommentPlaceholder
+        }),
+        br(),
+        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+      )
+    ),
+    comments && comments.length
+      ? div({ class: 'comments-list' },
+          comments.map(c => {
+            const author = c.value && c.value.author ? c.value.author : '';
+            const ts = c.value && c.value.timestamp ? 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 rootId = c.value && c.value.content ? (c.value.content.fork || c.value.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 && rootId
+                  ? a({
+                      href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}`
+                    }, relDate)
+                  : ''
+              ),
+              p({
+                class: 'votations-comment-text',
+                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
+              })
+            );
+          })
+        )
+      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+  );
+};
+  
 exports.marketView = async (items, filter, itemToEdit = null) => {
     const list = Array.isArray(items) ? items : [];
     let title = i18n.marketAllSectionTitle;
@@ -152,10 +215,10 @@ exports.marketView = async (items, filter, itemToEdit = null) => {
                                         ))
                                         : null,
                                 ),
-                                div({ class: "market-card right-col" },
+                                 div({ class: "market-card right-col" },
                                     renderCardField(`${i18n.marketItemStock}:`, item.stock > 0 ? item.stock : i18n.marketOutOfStock),
                                     div({ class: "market-card price" },
-                                        renderCardField(`${i18n.marketItemPrice}:`, `${item.price} ECO`),
+                                        renderCardField(`${i18n.marketItemPrice}:`, `${item.price} ECO`)
                                     ),
                                     renderCardField(`${i18n.marketItemCondition}:`, item.item_status),
                                     renderCardField(`${i18n.marketItemIncludesShipping}:`, item.includesShipping ? i18n.YESLabel : i18n.NOLabel),
@@ -163,7 +226,8 @@ exports.marketView = async (items, filter, itemToEdit = null) => {
                                     div({ class: "market-card image" },
                                         div({ class: 'card-field' },
                                             a({ class: 'user-link', href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)
-                                        )),
+                                        )
+                                    ),
                                     item.item_type === 'auction' && item.auctions_poll.length > 0
                                         ? div({ class: "auction-info" },
                                             p({ class: "auction-bid-text" }, i18n.marketAuctionBids),
@@ -184,6 +248,14 @@ exports.marketView = async (items, filter, itemToEdit = null) => {
                                             )
                                         )
                                         : null,
+                                    div({ class: 'card-comments-summary' },
+                                      span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
+                                      span({ class: 'card-value' }, String(item.commentCount || 0)),
+                                      br(), br(),
+                                      form({ method: 'GET', action: `/market/${encodeURIComponent(item.id)}` },
+                                        button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+                                      )
+                                    ),
                                     div({ class: "market-card buttons" },
                                         (item.seller === userId) ? [
                                             form({ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
@@ -225,7 +297,7 @@ exports.marketView = async (items, filter, itemToEdit = null) => {
     );
 };
 
-exports.singleMarketView = async (item, filter) => {
+exports.singleMarketView = async (item, filter, comments = []) => {
     return template(
         item.title,
         section(
@@ -269,7 +341,7 @@ exports.singleMarketView = async (item, filter) => {
                 renderCardField(`${i18n.marketItemPrice}:`),
                 br,
                 div({ class: 'card-label' },
-                    h2(`${item.price} ECO`),
+                    h2(`${item.price} ECO`)
                 ),
                 br,
                 renderCardField(`${i18n.marketItemStock}:`, item.stock > 0 ? item.stock : i18n.marketOutOfStock),
@@ -308,26 +380,27 @@ exports.singleMarketView = async (item, filter) => {
                         : null
                 )
                 : null,
-		div({ class: "market-item actions" },
-		    (item.seller === userId && item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.item_type !== 'auction') ? [
-			form({ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
-			    button({ class: "delete-btn", type: "submit" }, i18n.marketActionsDelete)
-			),
-			form({ method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` },
-			    button({ class: "update-btn", type: "submit" }, i18n.marketActionsUpdate)
-			),
-			(item.status === 'FOR SALE')
-			    ? form({ method: "POST", action: `/market/sold/${encodeURIComponent(item.id)}` },
-				button({ class: "sold-btn", type: "submit" }, i18n.marketActionsSold)
-			    )
-			    : null
-		    ] : null,
-		    (item.status === 'FOR SALE' && item.item_type !== 'auction' && item.seller !== userId)
-			? form({ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
-			    button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
-			)
-			: null
-		)
+            div({ class: "market-item actions" },
+                (item.seller === userId && item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.item_type !== 'auction') ? [
+                    form({ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
+                        button({ class: "delete-btn", type: "submit" }, i18n.marketActionsDelete)
+                    ),
+                    form({ method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` },
+                        button({ class: "update-btn", type: "submit" }, i18n.marketActionsUpdate)
+                    ),
+                    (item.status === 'FOR SALE')
+                        ? form({ method: "POST", action: `/market/sold/${encodeURIComponent(item.id)}` },
+                            button({ class: "sold-btn", type: "submit" }, i18n.marketActionsSold)
+                        )
+                        : null
+                ] : null,
+                (item.status === 'FOR SALE' && item.item_type !== 'auction' && item.seller !== userId)
+                    ? form({ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
+                        button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
+                    )
+                    : null
+            ),
+            renderMarketCommentsSection(item.id, comments)
         )
     );
 };

+ 105 - 19
src/views/parliament_view.js

@@ -212,6 +212,7 @@ const CandidatureForm = () =>
       button({ type: 'submit', class: 'create-button' }, i18n.parliamentCandidatureProposeBtn)
     )
   );
+  
 const pickLeader = (arr) => {
   if (!arr || !arr.length) return null;
   const sorted = [...arr].sort((a, b) => {
@@ -227,6 +228,7 @@ const pickLeader = (arr) => {
   });
   return sorted[0];
 };
+
 const CandidatureStats = (cands, govCard, leaderMeta) => {
   if (!cands || !cands.length) return null;
   const leader      = pickLeader(cands || []);
@@ -272,6 +274,7 @@ const CandidatureStats = (cands, govCard, leaderMeta) => {
     )
   );
 };
+
 const CandidaturesTable = (candidatures) => {
   const rows = (candidatures || []).map(c => {
     const idLink =
@@ -303,6 +306,7 @@ const CandidaturesTable = (candidatures) => {
     ])
   );
 };
+
 const ProposalForm = () =>
   div(
     { class: 'div-center' },
@@ -316,35 +320,89 @@ const ProposalForm = () =>
       button({ type: 'submit', class: 'create-button' }, i18n.parliamentProposalPublish)
     )
   );
+
 const ProposalsList = (proposals) => {
   if (!proposals || !proposals.length) return null;
-  const cards = proposals.map(pItem =>
-    div(
+  const cards = proposals.map(pItem => {
+    const titleNode = pItem && pItem.voteId
+      ? a({ class: 'proposal-title-link', href: `/votes/${encodeURIComponent(pItem.voteId)}` }, pItem.title || '')
+      : (pItem.title || '');
+    const onTrackLabel = pItem && pItem.onTrack
+      ? (i18n.parliamentProposalOnTrackYes || 'THRESHOLD REACHED')
+      : (i18n.parliamentProposalOnTrackNo || 'BELOW THRESHOLD');
+    return div(
       { class: 'card' },
       br(),
-      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))),
-      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer))),
-      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)),
+      div(
+        { class: 'card-field' },
+        span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '),
+        span({ class: 'card-value' }, fmt(pItem.createdAt))
+      ),
+      div(
+        { class: 'card-field' },
+        span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '),
+        span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer))
+      ),
+      div(
+        { class: 'card-field' },
+        span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '),
+        span({ class: 'card-value' }, pItem.method)
+      ),
       br(),
       div(
-      h2(pItem.title || ''),
-      p(pItem.description || '')
+        h2(titleNode),
+        p(pItem.description || '')
       ),
-      pItem.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentProposalDeadlineLabel.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.deadline))) : null,
-      pItem.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentProposalTimeLeft.toUpperCase() + ': '), span({ class: 'card-value' }, timeLeft(pItem.deadline))) : null,
-      showVoteMetrics(pItem.method) ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentVotesNeeded.toUpperCase() + ': '), span({ class: 'card-value' }, String(pItem.needed || reqVotes(pItem.method, pItem.total)))) : null,
-      showVoteMetrics(pItem.method) ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentVotesSlashTotal.toUpperCase() + ': '), span({ class: 'card-value' }, `${Number(pItem.yes||0)}/${Number(pItem.total||0)}`)) : null,
+      pItem.deadline
+        ? div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.parliamentProposalDeadlineLabel.toUpperCase() + ': '),
+            span({ class: 'card-value' }, fmt(pItem.deadline))
+          )
+        : null,
+      pItem.deadline
+        ? div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.parliamentProposalTimeLeft.toUpperCase() + ': '),
+            span({ class: 'card-value' }, timeLeft(pItem.deadline))
+          )
+        : null,
+      showVoteMetrics(pItem.method)
+        ? div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.parliamentVotesNeeded.toUpperCase() + ': '),
+            span({ class: 'card-value' }, String(pItem.needed || reqVotes(pItem.method, pItem.total)))
+          )
+        : null,
+      showVoteMetrics(pItem.method)
+        ? div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.parliamentVotesSlashTotal.toUpperCase() + ': '),
+            span({ class: 'card-value' }, `${Number(pItem.yes || 0)}/${Number(pItem.total || 0)}`)
+          )
+        : null,
+      showVoteMetrics(pItem.method)
+        ? div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.parliamentProposalVoteStatusLabel.toUpperCase() + ': '),
+            span({ class: 'card-value' }, onTrackLabel)
+          )
+        : null,
       pItem && pItem.voteId
-        ? form({ method: 'GET', action: `/votes/${encodeURIComponent(pItem.voteId)}` }, button({ type: 'submit', class: 'vote-btn' }, i18n.parliamentVoteAction))
+        ? form(
+            { method: 'GET', action: `/votes/${encodeURIComponent(pItem.voteId)}` },
+            button({ type: 'submit', class: 'vote-btn' }, i18n.parliamentVoteAction)
+          )
         : null
-    )
-  );
+    );
+  });
   return div(
     { class: 'cards' },
     h2(i18n.parliamentCurrentProposalsTitle),
     applyEl(div, null, cards)
   );
 };
+
 const FutureLawsList = (rows) => {
   if (!rows || !rows.length) return null;
   const cards = rows.map(pItem =>
@@ -364,6 +422,7 @@ const FutureLawsList = (rows) => {
     applyEl(div, null, cards)
   );
 };
+
 const RevocationForm = (laws = []) =>
   div(
     { class: 'div-center' },
@@ -390,10 +449,17 @@ const RevocationForm = (laws = []) =>
       button({ type: 'submit', class: 'create-button' }, i18n.parliamentRevocationPublish || 'Publish Revocation')
     )
   );
+  
 const RevocationsList = (revocations) => {
   if (!revocations || !revocations.length) return null;
-  const cards = revocations.map(pItem =>
-    div(
+  const cards = revocations.map(pItem => {
+    const titleNode = pItem && pItem.voteId
+      ? a({ class: 'revocation-title-link', href: `/votes/${encodeURIComponent(pItem.voteId)}` }, pItem.title || pItem.lawTitle || '')
+      : (pItem.title || pItem.lawTitle || '');
+    const onTrackLabel = pItem && pItem.onTrack
+      ? (i18n.parliamentProposalOnTrackYes || 'THRESHOLD REACHED')
+      : (i18n.parliamentProposalOnTrackNo || 'BELOW THRESHOLD');
+    return div(
       { class: 'card' },
       br(),
       div(
@@ -416,7 +482,7 @@ const RevocationsList = (revocations) => {
       ),
       br(),
       div(
-        h2(pItem.title || pItem.lawTitle || ''),
+        h2(titleNode),
         p(pItem.reasons || '')
       ),
       pItem.deadline
@@ -447,20 +513,28 @@ const RevocationsList = (revocations) => {
             span({ class: 'card-value' }, `${Number(pItem.yes || 0)}/${Number(pItem.total || 0)}`)
           )
         : null,
+      showVoteMetrics(pItem.method)
+        ? div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.parliamentProposalVoteStatusLabel.toUpperCase() + ': '),
+            span({ class: 'card-value' }, onTrackLabel)
+          )
+        : null,
       pItem && pItem.voteId
         ? form(
             { method: 'GET', action: `/votes/${encodeURIComponent(pItem.voteId)}` },
             button({ type: 'submit', class: 'vote-btn' }, i18n.parliamentVoteAction)
           )
         : null
-    )
-  );
+    );
+  });
   return div(
     { class: 'cards' },
     h2(i18n.parliamentCurrentRevocationsTitle),
     applyEl(div, null, cards)
   );
 };
+
 const FutureRevocationsList = (rows) => {
   if (!rows || !rows.length) return null;
   const cards = rows.map(pItem =>
@@ -480,6 +554,7 @@ const FutureRevocationsList = (rows) => {
     applyEl(div, null, cards)
   );
 };
+
 const LawsStats = (laws = [], revocatedCount = 0) => {
   const proposed = laws.length;
   const approved = laws.length;
@@ -509,6 +584,7 @@ const LawsStats = (laws = [], revocatedCount = 0) => {
     ])
   );
 };
+
 const LawsList = (laws) => {
   if (!laws || !laws.length) return NoLaws();
   const cards = laws.map(l => {
@@ -534,6 +610,7 @@ const LawsList = (laws) => {
     applyEl(div, null, cards)
   );
 };
+
 const HistoricalGovsSummary = (rows = []) => {
   const byMethod = new Map();
   for (const g of rows) {
@@ -553,6 +630,7 @@ const HistoricalGovsSummary = (rows = []) => {
     ])
   );
 };
+
 const HistoricalList = (rows, metasByKey = {}) => {
   if (!rows || !rows.length) return NoGovernments();
   const cards = rows.map(g => {
@@ -635,6 +713,7 @@ const HistoricalList = (rows, metasByKey = {}) => {
     applyEl(div, null, cards)
   );
 };
+
 const countCandidaturesByActor = (cands = []) => {
   const m = new Map();
   for (const c of cands) {
@@ -643,6 +722,7 @@ const countCandidaturesByActor = (cands = []) => {
   }
   return m;
 };
+
 const LeadersSummary = (leaders = [], candidatures = []) => {
   const candCounts = countCandidaturesByActor(candidatures);
   const totals = leaders.reduce((acc, l) => {
@@ -688,6 +768,7 @@ const LeadersSummary = (leaders = [], candidatures = []) => {
     ])
   );
 };
+
 const LeadersList = (leaders, metas = {}, candidatures = []) => {
   if (!leaders || !leaders.length) return div({ class: 'empty' }, p(i18n.parliamentNoLeaders));
   const rows = leaders.map(l => {
@@ -725,6 +806,7 @@ const LeadersList = (leaders, metas = {}, candidatures = []) => {
     ])
   );
 };
+
 const RulesContent = () =>
   div(
     { class: 'card' },
@@ -745,6 +827,7 @@ const RulesContent = () =>
       li(i18n.parliamentRulesLeaders)
     )
   );
+  
 const CandidaturesSection = (governmentCard, candidatures, leaderMeta) => {
   const termStart = governmentCard && governmentCard.since ? governmentCard.since : moment().toISOString();
   const termEnd   = governmentCard && governmentCard.end   ? governmentCard.end   : moment(termStart).add(1, 'minutes').toISOString();
@@ -756,6 +839,7 @@ const CandidaturesSection = (governmentCard, candidatures, leaderMeta) => {
     candidatures && candidatures.length ? CandidaturesTable(candidatures) : null
   );
 };
+
 const ProposalsSection = (governmentCard, proposals, futureLaws, canPropose) => {
   const has = proposals && proposals.length > 0;
   const fl = FutureLawsList(futureLaws || []);
@@ -763,6 +847,7 @@ const ProposalsSection = (governmentCard, proposals, futureLaws, canPropose) =>
   if (!has && !canPropose) return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), NoProposals(), fl);
   return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), ProposalForm(), ProposalsList(proposals), fl);
 };
+
 const RevocationsSection = (governmentCard, laws, revocations, futureRevocations) =>
   div(
     h2(i18n.parliamentGovernmentCard),
@@ -771,6 +856,7 @@ const RevocationsSection = (governmentCard, laws, revocations, futureRevocations
     RevocationsList(revocations || []) || '',
     FutureRevocationsList(futureRevocations || []) || ''
   );
+  
 const parliamentView = async (state) => {
   const {
     filter,

+ 75 - 3
src/views/projects_view.js

@@ -23,6 +23,69 @@ const field = (labelText, value) =>
     span({ class: 'card-label' }, labelText),
     span({ class: 'card-value' }, value)
   )
+  
+const renderProjectCommentsSection = (projectId, comments = []) => {
+  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+
+  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),
+      form({
+        method: 'POST',
+        action: `/projects/${encodeURIComponent(projectId)}/comments`,
+        class: 'comment-form'
+      },
+        textarea({
+          id: 'comment-text',
+          name: 'text',
+          required: true,
+          rows: 4,
+          class: 'comment-textarea',
+          placeholder: i18n.voteNewCommentPlaceholder
+        }),
+        br(),
+        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+      )
+    ),
+    comments && comments.length
+      ? div({ class: 'comments-list' },
+          comments.map(c => {
+            const author = c.value && c.value.author ? c.value.author : '';
+            const ts = c.value && c.value.timestamp ? 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 rootId = c.value && c.value.content ? (c.value.content.fork || c.value.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 && rootId
+                  ? a({
+                      href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}`
+                    }, relDate)
+                  : ''
+              ),
+              p({
+                class: 'votations-comment-text',
+                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
+              })
+            );
+          })
+        )
+      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+  );
+};  
 
 function sumAmounts(list = []) {
   return list.reduce((s, x) => s + (parseFloat(x.amount || 0) || 0), 0)
@@ -354,6 +417,14 @@ const renderProjectList = (projects, filter) =>
           )
         )
       ] : null,
+      div({ class: 'card-comments-summary' },
+        span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
+        span({ class: 'card-value' }, String(pr.commentCount || 0)),
+        br(), br(),
+        form({ method: 'GET', action: `/projects/${encodeURIComponent(pr.id)}` },
+          button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+        )
+      ),
 
       div({ class: 'card-footer' },
         span({ class: 'date-link' }, `${moment(pr.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
@@ -426,7 +497,7 @@ exports.projectsView = async (projectsOrForm, filter="ALL") => {
   )
 }
 
-exports.singleProjectView = async (project, filter="ALL") => {
+exports.singleProjectView = async (project, filter="ALL", comments = []) => {
   const isAuthor = project.author === userId
   const statusUpper = String(project.status || 'ACTIVE').toUpperCase()
   const isActive = statusUpper === 'ACTIVE'
@@ -530,11 +601,12 @@ exports.singleProjectView = async (project, filter="ALL") => {
             button({ class: 'btn submit-bounty', type: 'submit' }, remain > 0 ? i18n.projectBountyCreateButton : i18n.projectNoRemainingBudget)
           )
         ) : null,
-        div({ class: 'card-footer' },
+                div({ class: 'card-footer' },
           span({ class: 'date-link' }, `${moment(project.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
           a({ href: `/author/${encodeURIComponent(project.author)}`, class: 'user-link' }, project.author)
         )
-      )
+      ),
+      renderProjectCommentsSection(project.id, comments)
     )
   )
 }

+ 79 - 6
src/views/report_view.js

@@ -11,6 +11,68 @@ const renderCardField = (labelText, value) =>
     span({ class: 'card-label' }, labelText),
     span({ class: 'card-value' }, ...renderUrl(value))
   );
+  
+const renderReportCommentsSection = (reportId, comments = []) => {
+  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+
+  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),
+      form({
+        method: 'POST',
+        action: `/reports/${encodeURIComponent(reportId)}/comments`,
+        class: 'comment-form'
+      },
+        textarea({
+          id: 'comment-text',
+          name: 'text',
+          required: true,
+          rows: 4,
+          class: 'comment-textarea',
+          placeholder: i18n.voteNewCommentPlaceholder
+        }),
+        br(),
+        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+      )
+    ),
+    comments && comments.length
+      ? div({ class: 'comments-list' },
+          comments.map(c => {
+            const author = c.value && c.value.author ? c.value.author : '';
+            const ts = c.value && c.value.timestamp ? 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;
+
+            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
+                  ? a({
+                      href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
+                    }, relDate)
+                  : ''
+              ),
+              p({
+                class: 'votations-comment-text',
+                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
+              })
+            );
+          })
+        )
+      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+  );
+};  
 
 const renderReportCard = (report, userId) => {
   const actions = report.author === userId ? [
@@ -28,6 +90,8 @@ const renderReportCard = (report, userId) => {
     )
   ] : [];
 
+  const commentCount = typeof report.commentCount === 'number' ? report.commentCount : 0;
+
   return div({ class: "card card-section report" },
     actions.length ? div({ class: "report-actions" }, ...actions) : null,
     form({ method: 'GET', action: `/reports/${encodeURIComponent(report.id)}` },
@@ -42,7 +106,7 @@ const renderReportCard = (report, userId) => {
     p(...renderUrl(report.description)), 
     div({ class: 'card-field' },
       report.image ? div({ class: 'card-field' },
-        img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" }),
+        img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" })
       ) : null
     ),
     br,
@@ -62,10 +126,18 @@ const renderReportCard = (report, userId) => {
           )
         )
       : null,
+    div({ class: 'card-comments-summary' },
+      span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
+      span({ class: 'card-value' }, String(commentCount)),
+      br(), br(),
+      form({ method: 'GET', action: `/reports/${encodeURIComponent(report.id)}` },
+        button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+      )
+    ),
     br,
     p({ class: 'card-footer' },
       span({ class: 'date-link' }, `${moment(report.createdAt).format('YYYY-MM-DD HH:mm')} ${i18n.performed} `),
-        a({ class: "user-link", href: `/author/${encodeURIComponent(report.author)}`, class: 'user-link' }, report.author)
+      a({ class: "user-link", href: `/author/${encodeURIComponent(report.author)}` }, report.author)
     )
   );
 };
@@ -156,7 +228,7 @@ exports.reportView = async (reports, filter, reportId) => {
   );
 };
 
-exports.singleReportView = async (report, filter) => {
+exports.singleReportView = async (report, filter, comments = []) => {
   return template(
     report.title,
     section(
@@ -189,7 +261,7 @@ exports.singleReportView = async (report, filter) => {
         p(...renderUrl(report.description)), 
         div({ class: 'card-field' },
           report.image ? div({ class: 'card-field' },
-            img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" }),
+            img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" })
           ) : null
         ),
         br,
@@ -212,9 +284,10 @@ exports.singleReportView = async (report, filter) => {
         br,
         p({ class: 'card-footer' },
           span({ class: 'date-link' }, `${moment(report.createdAt).format('YYYY-MM-DD HH:mm')} ${i18n.performed} `),
-            a({ class: "user-link", href: `/author/${encodeURIComponent(report.author)}`, class: 'user-link' }, report.author)
+          a({ class: "user-link", href: `/author/${encodeURIComponent(report.author)}` }, report.author)
         )
-      )
+      ),
+      renderReportCommentsSection(report.id, comments)
     )
   );
 };

+ 101 - 13
src/views/task_view.js

@@ -11,13 +11,85 @@ const renderStyledField = (labelText, valueElement) =>
     span({ class: 'card-label' }, labelText),
     span({ class: 'card-value' }, ...renderUrl(valueElement))
   );
+  
+const renderTaskCommentsSection = (taskId, comments = []) => {
+  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+
+  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),
+      form({
+        method: 'POST',
+        action: `/tasks/${encodeURIComponent(taskId)}/comments`,
+        class: 'comment-form'
+      },
+        textarea({
+          id: 'comment-text',
+          name: 'text',
+          required: true,
+          rows: 4,
+          class: 'comment-textarea',
+          placeholder: i18n.voteNewCommentPlaceholder
+        }),
+        br(),
+        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+      )
+    ),
+    comments && comments.length
+      ? div({ class: 'comments-list' },
+          comments.map(c => {
+            const author = c.value && c.value.author ? c.value.author : '';
+            const ts = c.value && c.value.timestamp ? 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;
+
+            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
+                  ? a(
+                      {
+                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
+                      },
+                      relDate
+                    )
+                  : ''
+              ),
+              p({
+                class: 'votations-comment-text',
+                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
+              })
+            );
+          })
+        )
+      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+  );
+};  
 
 const renderTaskItem = (task, filter, userId) => {
   const actions = [];
   if (filter === 'mine' && task.author === userId) {
     actions.push(
-      form({ method: 'GET', action: `/tasks/edit/${encodeURIComponent(task.id)}` }, button({ type: 'submit', class: 'update-btn' }, i18n.taskUpdateButton)),
-      form({ method: 'POST', action: `/tasks/delete/${encodeURIComponent(task.id)}` }, button({ type: 'submit', class: 'delete-btn' }, i18n.taskDeleteButton)),
+      form({ method: 'GET', action: `/tasks/edit/${encodeURIComponent(task.id)}` },
+        button({ type: 'submit', class: 'update-btn' }, i18n.taskUpdateButton)
+      ),
+      form({ method: 'POST', action: `/tasks/delete/${encodeURIComponent(task.id)}` },
+        button({ type: 'submit', class: 'delete-btn' }, i18n.taskDeleteButton)
+      ),
       form({ method: 'POST', action: `/tasks/status/${encodeURIComponent(task.id)}` },
         button({ type: 'submit', name: 'status', value: 'OPEN' }, i18n.taskStatusOpen), br(),
         button({ type: 'submit', name: 'status', value: 'IN-PROGRESS' }, i18n.taskStatusInProgress), br(),
@@ -28,13 +100,21 @@ const renderTaskItem = (task, filter, userId) => {
   if (task.status !== 'CLOSED') {
     actions.push(
       form({ method: 'POST', action: `/tasks/assign/${encodeURIComponent(task.id)}` },
-        button({ type: 'submit' }, task.assignees.includes(userId) ? i18n.taskUnassignButton : i18n.taskAssignButton)
+        button(
+          { type: 'submit' },
+          task.assignees.includes(userId) ? i18n.taskUnassignButton : i18n.taskAssignButton
+        )
       )
     );
   }
+
+  const commentCount = typeof task.commentCount === 'number' ? task.commentCount : 0;
+
   return div({ class: 'card card-section task' },
     actions.length > 0 ? div({ class: 'task-actions' }, ...actions) : null,
-    form({ method: 'GET', action: `/tasks/${encodeURIComponent(task.id)}` }, button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails)),
+    form({ method: 'GET', action: `/tasks/${encodeURIComponent(task.id)}` },
+      button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails)
+    ),
     br,
     renderStyledField(i18n.taskTitleLabel + ':', task.title),
     renderStyledField(i18n.taskDescriptionLabel + ':'),
@@ -44,29 +124,37 @@ const renderTaskItem = (task, filter, userId) => {
     renderStyledField(i18n.taskPriorityLabel + ':', task.priority),
     renderStyledField(i18n.taskVisibilityLabel + ':', task.isPublic),
     renderStyledField(i18n.taskStartTimeLabel + ':', moment(task.startTime).format('YYYY/MM/DD HH:mm:ss')),
-    renderStyledField(i18n.taskEndTimeLabel + ':', moment(task.endTime).format('YYYY/MM/DD HH:mm:ss')),  
+    renderStyledField(i18n.taskEndTimeLabel + ':', moment(task.endTime).format('YYYY/MM/DD HH:mm:ss')),
     br,
     div({ class: 'card-field' },
       span({ class: 'card-label' }, i18n.taskAssignedTo + ':'),
       span({ class: 'card-value' },
         Array.isArray(task.assignees) && task.assignees.length
-          ? task.assignees.map((id, i) => [i > 0 ? ', ' : '', a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
+          ? task.assignees.map((id, i) => [i > 0 ? ', ' : '', a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
           : i18n.noAssignees
       )
     ),
     br,
-      Array.isArray(task.tags) && task.tags.length
+    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, 
+      : null,
+    div({ class: 'card-comments-summary' },
+      span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
+      span({ class: 'card-value' }, String(commentCount)),
+      br, br,
+      form({ method: 'GET', action: `/tasks/${encodeURIComponent(task.id)}` },
+        button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+      )
+    ),
     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}`)
-    )   
+    )
   );
 };
 
@@ -166,7 +254,7 @@ exports.taskView = async (tasks, filter, taskId) => {
   );
 };
 
-exports.singleTaskView = async (task, filter) => {
+exports.singleTaskView = async (task, filter, comments = []) => {
   return template(
     task.title,
     section(
@@ -205,7 +293,7 @@ exports.singleTaskView = async (task, filter) => {
               : i18n.noAssignees
           )
         ),
-          Array.isArray(task.tags) && task.tags.length
+        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}`)
@@ -221,8 +309,8 @@ exports.singleTaskView = async (task, filter) => {
               : i18n.taskAssignButton
           )
         )
-      )
+      ),
+      renderTaskCommentsSection(task.id, comments)
     )
   );
 };
-

+ 102 - 23
src/views/video_view.js

@@ -21,6 +21,74 @@ const getFilteredVideos = (filter, videos, userId) => {
   return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
 };
 
+const renderVideoCommentsSection = (videoId, comments = []) => {
+  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+
+  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),
+      form({
+        method: 'POST',
+        action: `/videos/${encodeURIComponent(videoId)}/comments`,
+        class: 'comment-form'
+      },
+        textarea({
+          id: 'comment-text',
+          name: 'text',
+          required: true,
+          rows: 4,
+          class: 'comment-textarea',
+          placeholder: i18n.voteNewCommentPlaceholder
+        }),
+        br(),
+        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+      )
+    ),
+    comments && comments.length
+      ? div({ class: 'comments-list' },
+          comments.map(c => {
+            const author = c.value && c.value.author ? c.value.author : '';
+            const ts = c.value && c.value.timestamp ? 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;
+
+            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
+                  ? a(
+                      {
+                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
+                      },
+                      relDate
+                    )
+                  : ''
+              ),
+              p({
+                class: 'votations-comment-text',
+                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
+              })
+            );
+          })
+        )
+      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+  );
+};
+
 const renderVideoActions = (filter, video) => {
   return filter === 'mine' ? div({ class: "video-actions" },
     form({ method: "GET", action: `/videos/edit/${encodeURIComponent(video.key)}` },
@@ -34,11 +102,14 @@ const renderVideoActions = (filter, video) => {
 
 const renderVideoList = (filteredVideos, filter) => {
   return filteredVideos.length > 0
-    ? filteredVideos.map(video =>
-        div({ class: "tags-header" },
+    ? filteredVideos.map(video => {
+        const commentCount = typeof video.commentCount === 'number' ? video.commentCount : 0;
+
+        return div({ class: "tags-header" },
           renderVideoActions(filter, video),
           form({ method: "GET", action: `/videos/${encodeURIComponent(video.key)}` },
-            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)),
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ),
           video.title?.trim() ? h2(video.title) : null,
           video.url
             ? div({ class: "video-container" },
@@ -51,7 +122,7 @@ const renderVideoList = (filteredVideos, filter) => {
                   height: '360'
                 })
               )
-            : p(i18n.videoNoFile),        
+            : p(i18n.videoNoFile),
           video.description?.trim() ? p(...renderUrl(video.description)) : null,
           video.tags?.length
             ? div({ class: "card-tags" },
@@ -60,10 +131,18 @@ const renderVideoList = (filteredVideos, filter) => {
                 )
               )
             : null,
+          div({ class: 'card-comments-summary' },
+            span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
+            span({ class: 'card-value' }, String(commentCount)),
+            br, br,
+            form({ method: 'GET', action: `/videos/${encodeURIComponent(video.key)}` },
+              button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+            )
+          ),
           br,
           p({ class: 'card-footer' },
-          span({ class: 'date-link' }, `${moment(video.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-          a({ href: `/author/${encodeURIComponent(video.author)}`, class: 'user-link' }, `${video.author}`)
+            span({ class: 'date-link' }, `${moment(video.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
+            a({ href: `/author/${encodeURIComponent(video.author)}`, class: 'user-link' }, `${video.author}`)
           ),
           div({ class: "voting-buttons" },
             ['interesting','necessary','funny','disgusting','sensible',
@@ -76,8 +155,8 @@ const renderVideoList = (filteredVideos, filter) => {
                 )
               )
           )
-        )
-      )
+        );
+      })
     : div(i18n.noVideos);
 };
 
@@ -142,7 +221,7 @@ exports.videoView = async (videos, filter, videoId) => {
   );
 };
 
-exports.singleVideoView = async (video, filter) => {
+exports.singleVideoView = async (video, filter, comments = []) => {
   const isAuthor = video.author === userId;
   const hasOpinions = Object.keys(video.opinions || {}).length > 0; 
 
@@ -160,15 +239,15 @@ exports.singleVideoView = async (video, filter) => {
       ),
       div({ class: "tags-header" },
         isAuthor ? div({ class: "video-actions" },
-        !hasOpinions
-          ? form({ method: "GET", action: `/videos/edit/${encodeURIComponent(video.key)}` },
-              button({ class: "update-btn", type: "submit" }, i18n.videoUpdateButton)
-            )
-          : null,
-        form({ method: "POST", action: `/videos/delete/${encodeURIComponent(video.key)}` },
-          button({ class: "delete-btn", type: "submit" }, i18n.videoDeleteButton)
-        )
-      ) : null,
+          !hasOpinions
+            ? form({ method: "GET", action: `/videos/edit/${encodeURIComponent(video.key)}` },
+                button({ class: "update-btn", type: "submit" }, i18n.videoUpdateButton)
+              )
+            : null,
+          form({ method: "POST", action: `/videos/delete/${encodeURIComponent(video.key)}` },
+            button({ class: "delete-btn", type: "submit" }, i18n.videoDeleteButton)
+          )
+        ) : null,
         h2(video.title),
         video.url
           ? div({ class: "video-container" },
@@ -190,11 +269,11 @@ exports.singleVideoView = async (video, filter) => {
                 )
               )
             : null,
-          br,
-          p({ class: 'card-footer' },
+        br,
+        p({ class: 'card-footer' },
           span({ class: 'date-link' }, `${moment(video.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
           a({ href: `/author/${encodeURIComponent(video.author)}`, class: 'user-link' }, `${video.author}`)
-          ),
+        )
       ),
       div({ class: "voting-buttons" },
         ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'].map(category =>
@@ -202,8 +281,8 @@ exports.singleVideoView = async (video, filter) => {
             button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${video.opinions?.[category] || 0}]`)
           )
         )
-      )
+      ),
+      renderVideoCommentsSection(video.key, comments)
     )
   );
 };
-

+ 81 - 3
src/views/vote_view.js

@@ -27,6 +27,9 @@ const renderVoteCard = (v, voteOptions, firstRow, secondRow, userId, filter) =>
   const showUpdateButton = filter === 'mine' && !Object.values(v.opinions || {}).length;
   const showDeleteButton = filter === 'mine';
 
+  const commentCount = typeof v.commentCount === 'number' ? v.commentCount : 0;
+  const showCommentsSummaryInCard = filter !== 'detail';
+
   return div({ class: 'card card-section vote' },
     filter === 'mine' ? div({ class: 'vote-actions' },
       showUpdateButton
@@ -85,7 +88,17 @@ const renderVoteCard = (v, voteOptions, firstRow, secondRow, userId, filter) =>
             a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
           )
         )
-      : null,    
+      : null,
+    showCommentsSummaryInCard
+      ? div({ class: 'card-comments-summary' },
+          span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
+          span({ class: 'card-value' }, String(commentCount)),
+          br,br,
+          form({ method: 'GET', action: `/votes/${encodeURIComponent(v.id)}` },
+            button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+          )
+        )
+      : null,
     br,
     p({ class: 'card-footer' },
       span({ class: 'date-link' }, `${moment(v.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
@@ -101,7 +114,70 @@ const renderVoteCard = (v, voteOptions, firstRow, secondRow, userId, filter) =>
   );
 };
 
-exports.voteView = async (votes, filter, voteId) => {
+const renderCommentsSection = (voteId, comments) => {
+  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+
+  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),
+      form({ method: 'POST', action: `/votes/${encodeURIComponent(voteId)}/comments`, class: 'comment-form' },
+        textarea({
+          id: 'comment-text',
+          name: 'text',
+          required: true,
+          rows: 4,
+          class: 'comment-textarea',
+          placeholder: i18n.voteNewCommentPlaceholder
+        }),
+        br(),
+        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+      )
+    ),
+    comments && comments.length
+      ? div({ class: 'comments-list' },
+          comments.map(c => {
+            const author = c.value && c.value.author ? c.value.author : '';
+            const ts = c.value && c.value.timestamp ? 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.split('@')[1]; 
+            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
+                  ? a(
+                      { 
+                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
+                      },
+                      relDate
+                    )
+                  : ''
+              ),
+              p({
+                class: 'votations-comment-text',
+                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
+              })
+            );
+          })
+        )
+      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+  );
+};
+
+exports.voteView = async (votes, filter, voteId, comments = []) => {
   const list = Array.isArray(votes) ? votes : [votes];
   const title =
     filter === 'mine'   ? i18n.voteMineSectionTitle :
@@ -109,6 +185,7 @@ exports.voteView = async (votes, filter, voteId) => {
     filter === 'edit'   ? i18n.voteUpdateSectionTitle :
     filter === 'open'   ? i18n.voteOpenTitle :
     filter === 'closed' ? i18n.voteClosedTitle :
+    filter === 'detail' ? (i18n.voteDetailSectionTitle || i18n.voteAllSectionTitle) :
                            i18n.voteAllSectionTitle;
 
   const voteToEdit = list.find(v => v.id === voteId) || {};
@@ -164,7 +241,8 @@ exports.voteView = async (votes, filter, voteId) => {
             filtered.length > 0
               ? filtered.map(v => renderVoteCard(v, voteOptions, firstRow, secondRow, userId, filter))
               : p(i18n.novotes)
-          )
+          ),
+      (filter === 'detail' && voteId) ? renderCommentsSection(voteId, comments) : null
     )
   );
 };