Browse Source

Oasis release 0.4.3

psy 1 day ago
parent
commit
b6a84bea3f
53 changed files with 3081 additions and 1456 deletions
  1. 2 0
      README.md
  2. 9 0
      docs/CHANGELOG.md
  3. 3 1
      src/AI/buildAIContext.js
  4. 194 60
      src/backend/backend.js
  5. 35 0
      src/client/assets/styles/style.css
  6. 96 14
      src/client/assets/translations/oasis_en.js
  7. 101 20
      src/client/assets/translations/oasis_es.js
  8. 80 1
      src/client/assets/translations/oasis_eu.js
  9. 1 1
      src/configs/agenda-config.json
  10. 4 0
      src/configs/config-manager.js
  11. 5 1
      src/configs/oasis-config.json
  12. 97 33
      src/models/activity_model.js
  13. 130 46
      src/models/agenda_model.js
  14. 3 1
      src/models/audios_model.js
  15. 138 52
      src/models/blockchain_model.js
  16. 3 1
      src/models/bookmarking_model.js
  17. 38 13
      src/models/cv_model.js
  18. 3 1
      src/models/documents_model.js
  19. 20 14
      src/models/events_model.js
  20. 3 1
      src/models/feed_model.js
  21. 11 5
      src/models/forum_model.js
  22. 38 31
      src/models/images_model.js
  23. 36 47
      src/models/inhabitants_model.js
  24. 209 0
      src/models/jobs_model.js
  25. 5 2
      src/models/main_models.js
  26. 305 259
      src/models/market_model.js
  27. 45 1
      src/models/opinions_model.js
  28. 4 2
      src/models/pixelia_model.js
  29. 4 1
      src/models/reports_model.js
  30. 3 1
      src/models/search_model.js
  31. 53 29
      src/models/stats_model.js
  32. 3 1
      src/models/tags_model.js
  33. 56 6
      src/models/tasks_model.js
  34. 3 1
      src/models/transfers_model.js
  35. 32 1
      src/models/trending_model.js
  36. 4 2
      src/models/tribes_model.js
  37. 8 9
      src/models/videos_model.js
  38. 52 1
      src/models/votes_model.js
  39. 1 1
      src/server/package-lock.json
  40. 1 1
      src/server/package.json
  41. 76 30
      src/views/activity_view.js
  42. 93 64
      src/views/agenda_view.js
  43. 164 191
      src/views/blockchain_view.js
  44. 1 1
      src/views/event_view.js
  45. 102 109
      src/views/inhabitants_view.js
  46. 315 0
      src/views/jobs_view.js
  47. 136 45
      src/views/main_views.js
  48. 307 349
      src/views/market_view.js
  49. 1 0
      src/views/modules_view.js
  50. 8 2
      src/views/pm_view.js
  51. 18 0
      src/views/settings_view.js
  52. 19 1
      src/views/stats_view.js
  53. 3 3
      src/views/task_view.js

+ 2 - 0
README.md

@@ -60,6 +60,7 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Agenda: Module to manage all your assigned items.
  + AI: Module to talk with a LLM called '42'.
  + Audios: Module to discover and manage audios.
+ + BlockExplorer: Module to navigate the blockchain.
  + Bookmarks: Module to discover and manage bookmarks.	
  + Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).	
  + Documents: Module to discover and manage documents.	
@@ -69,6 +70,7 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Governance: Module to discover and manage votes.	
  + Images: Module to discover and manage images.	
  + Invites: Module to manage and apply invite codes.	
+ + Jobs: Module to discover and manage jobs.	
  + Legacy: Module to manage your secret (private key) quickly and securely.	
  + Latest: Module to receive the most recent posts and discussions.
  + Market: Module to exchange goods or services.

+ 9 - 0
docs/CHANGELOG.md

@@ -13,6 +13,15 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.4.3 - 2025-08-08
+
+### Added
+
+- Limiter to blockchain logstream retrieval.
+
+  + Jobs: Module to discover and manage jobs.
+  + BlockExplorer: Module to navigate the blockchain.
+
 ## v0.4.0 - 2025-07-29
 
 ### Added

+ 3 - 1
src/AI/buildAIContext.js

@@ -1,5 +1,7 @@
 import pull from 'pull-stream';
 import gui from '../client/gui.js';
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 const cooler = gui({ offline: false });
 
@@ -52,7 +54,7 @@ async function buildContext(maxItems = 100) {
   const ssb = await cooler.open();
   return new Promise((resolve, reject) => {
     pull(
-      ssb.createLogStream(),
+      ssb.createLogStream({ limit: logLimit }),
       pull.collect((err, msgs) => {
         if (err) return reject(err);
 

+ 194 - 60
src/backend/backend.js

@@ -89,6 +89,7 @@ debug("Current configuration: %O", config);
 debug(`You can save the above to ${defaultConfigFile} to make \
 these settings the default. See the readme for details.`);
 const { saveConfig, getConfig } = require('../configs/config-manager');
+const configPath = path.join(__dirname, '../configs/oasis-config.json');
 
 const oasisCheckPath = "/.well-known/oasis";
 
@@ -210,6 +211,7 @@ const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: conf
 const marketModel = require('../models/market_model')({ cooler, isPublic: config.public });
 const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public });
 const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public });
+const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public });
 
 // starting warmup
 about._startNameWarmup();
@@ -411,6 +413,7 @@ const { marketView, singleMarketView } = require("../views/market_view");
 const { aiView } = require("../views/AI_view");
 const { forumView, singleForumView } = require("../views/forum_view");
 const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view");
+const { jobsView, singleJobsView, renderJobForm } = require("../views/jobs_view");
 
 let sharp;
 
@@ -528,7 +531,7 @@ router
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 
     'events', 'tasks', 'market', 'tribes', 'governance', 'reports', 'opinions', 'transfers', 
-    'feed', 'pixelia', 'agenda', 'ai', 'forum'
+    'feed', 'pixelia', 'agenda', 'ai', 'forum', 'jobs'
     ];
     const moduleStates = modules.reduce((acc, mod) => {
       acc[`${mod}Mod`] = configMods[`${mod}Mod`];
@@ -677,7 +680,7 @@ router
       return acc;
     }, {});
     ctx.body = await searchView({ results: groupedResults, query, types: [] });
-  })
+   })
   .get('/images', async (ctx) => {
     const imagesMod = ctx.cookies.get("imagesMod") || 'on';
     if (imagesMod !== 'on') {
@@ -688,16 +691,11 @@ router
     const images = await imagesModel.listAll(filter);
     ctx.body = await imageView(images, filter, null);
    })
-  .get('/images/edit/:id', async (ctx) => {
-    const imagesMod = ctx.cookies.get("imagesMod") || 'on';
-    if (imagesMod !== 'on') {
-      ctx.redirect('/modules');
-      return;
-    }
-    const filter = 'edit';
-    const img = await imagesModel.getImageById(ctx.params.id, false);
-    ctx.body = await imageView([img], filter, ctx.params.id);
-  })
+  .get('/images/edit/:id', async ctx => {
+    const imageId = ctx.params.id;
+    const images = await imagesModel.listAll('all');
+    ctx.body = await imageView(images, 'edit', imageId);
+   })
   .get('/images/:imageId', async ctx => {
     const imageId = ctx.params.imageId;
     const filter = ctx.query.filter || 'all'; 
@@ -771,7 +769,8 @@ router
     ctx.body = await createCVView(cv, true)
   })
   .get('/pm', async ctx => {
-    ctx.body = await pmView();
+    const { recipients = '' } = ctx.query;
+    ctx.body = await pmView(recipients);
   })
   .get("/inbox", async (ctx) => {
     const inboxMod = ctx.cookies.get("inboxMod") || 'on';
@@ -832,19 +831,20 @@ router
       query.language = ctx.query.language || '';
       query.skills = ctx.query.skills || '';
     }
+    const userId = SSBconfig.config.keys.id;
     const inhabitants = await inhabitantsModel.listInhabitants({
       filter,
       ...query
     });
-
-    ctx.body = await inhabitantsView(inhabitants, filter, query);
+    ctx.body = await inhabitantsView(inhabitants, filter, query, userId);
   })
   .get('/inhabitant/:id', async (ctx) => {
     const id = ctx.params.id;
-    const about = await inhabitantsModel._getLatestAboutById(id);
+    const about = await inhabitantsModel.getLatestAboutById(id);
     const cv = await inhabitantsModel.getCVByUserId(id);
     const feed = await inhabitantsModel.getFeedByUserId(id);
-    ctx.body = await inhabitantsProfileView({ about, cv, feed });
+    const currentUserId = SSBconfig.config.keys.id;
+    ctx.body = await inhabitantsProfileView({ about, cv, feed }, currentUserId);
   })
   .get('/tribes', async ctx => {
     const filter = ctx.query.filter || 'all';
@@ -888,7 +888,8 @@ router
   .get('/activity', async ctx => {
     const filter = ctx.query.filter || 'recent';
     const actions = await activityModel.listFeed(filter);
-    ctx.body = activityView(actions, filter);
+    const userId = SSBconfig.config.keys.id;
+    ctx.body = activityView(actions, filter, userId);
   })
   .get("/profile", async (ctx) => {
     const myFeedId = await meta.myFeedId();
@@ -1212,35 +1213,75 @@ router
     ctx.body = await singleEventView(event, filter);
   })
   .get('/votes', async ctx => {
-      const filter = ctx.query.filter || 'all';
-      const voteList = await votesModel.listAll(filter);
-      ctx.body = await voteView(voteList, filter, null);
-   })
-   .get('/votes/:voteId', async ctx => {
-     const voteId = ctx.params.voteId;
-     const vote = await votesModel.getVoteById(voteId);
-     ctx.body = await voteView(vote);
+    const filter = ctx.query.filter || 'all';
+    const voteList = await votesModel.listAll(filter);
+    ctx.body = await voteView(voteList, 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 id = ctx.params.id;
+    const vote = await votesModel.getVoteById(id);
+    ctx.body = await voteView([vote], 'edit', id);
+   })
+  .get('/votes/:voteId', async ctx => {
+    const voteId = ctx.params.voteId;
+    const vote = await votesModel.getVoteById(voteId);
+    ctx.body = await voteView(vote);
    })
   .get('/market', async ctx => {
+    const marketMod = ctx.cookies.get("marketMod") || 'on';
+    if (marketMod !== 'on') {
+      ctx.redirect('/modules');
+      return;
+    }
     const filter = ctx.query.filter || 'all';
     const marketItems = await marketModel.listAllItems(filter);
     ctx.body = await marketView(marketItems, filter, null);
-  })
+   })
   .get('/market/edit/:id', async ctx => {
     const id = ctx.params.id;
     const marketItem = await marketModel.getItemById(id);
     ctx.body = await marketView([marketItem], 'edit', marketItem);
-  })
+   })
   .get('/market/:itemId', async ctx => {
     const itemId = ctx.params.itemId;
     const filter = ctx.query.filter || 'all'; 
     const item = await marketModel.getItemById(itemId); 
     ctx.body = await singleMarketView(item, filter);
+   })
+  .get('/jobs', async (ctx) => {
+    const jobsMod = ctx.cookies.get("jobsMod") || 'on';
+    if (jobsMod !== 'on') {
+      ctx.redirect('/modules');
+      return;
+    }
+    const filter = ctx.query.filter || 'ALL';
+    const query = {
+      search: ctx.query.search || '',
+    };
+    if (filter === 'CV') {
+      query.location = ctx.query.location || '';
+      query.language = ctx.query.language || '';
+      query.skills = ctx.query.skills || '';
+      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);
+    ctx.body = await jobsView(jobs, filter, query);
+  })
+  .get('/jobs/edit/:id', async (ctx) => {
+    const id = ctx.params.id;
+    const job = await jobsModel.getJobById(id);
+    ctx.body = await jobsView([job], 'EDIT');
+  })
+  .get('/jobs/:jobId', async (ctx) => {
+    const jobId = ctx.params.jobId;
+    const filter = ctx.query.filter || 'ALL';
+    const job = await jobsModel.getJobById(jobId);
+    ctx.body = await singleJobsView(job, filter);
   })
   .get('/cipher', async (ctx) => {
     const cipherMod = ctx.cookies.get("cipherMod") || 'on';
@@ -2022,7 +2063,9 @@ router
   .post('/tasks/update/:id', koaBody(), async (ctx) => {
     const { title, description, startTime, endTime, priority, location, tags, isPublic } = ctx.request.body;
     const taskId = ctx.params.id;
-    const task = await tasksModel.getTaskById(taskId);
+    const parsedTags = Array.isArray(tags)
+      ? tags.filter(Boolean)
+      : (typeof tags === 'string' ? tags.split(',').map(t => t.trim()).filter(Boolean) : []);
     await tasksModel.updateTaskById(taskId, {
       title,
       description,
@@ -2030,13 +2073,11 @@ router
       endTime,
       priority,
       location,
-      tags,
-      isPublic,
-      createdAt: task.createdAt,
-      author: task.author
+      tags: parsedTags,
+      isPublic
     });
     ctx.redirect('/tasks?filter=mine');
-   })
+  })
   .post('/tasks/assign/:id', koaBody(), async (ctx) => {
     const taskId = ctx.params.id;
     await tasksModel.toggleAssignee(taskId);
@@ -2116,16 +2157,21 @@ router
     ctx.redirect('/events?filter=mine');
   })
   .post('/votes/create', koaBody(), async ctx => {
-    const { question, deadline, options = 'YES,NO,ABSTENTION', tags = '' } = ctx.request.body;
-    const parsedOptions = options.split(',').map(o => o.trim()).filter(Boolean);
+    const { question, deadline, options, tags = '' } = ctx.request.body;
+    const defaultOptions = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'];
+    const parsedOptions = options
+      ? options.split(',').map(o => o.trim()).filter(Boolean)
+      : defaultOptions;
     const parsedTags = tags.split(',').map(t => t.trim()).filter(Boolean);
     await votesModel.createVote(question, deadline, parsedOptions, parsedTags);
     ctx.redirect('/votes');
-    })
+  })
   .post('/votes/update/:id', koaBody(), async ctx => {
     const id = ctx.params.id;
-    const { question, deadline, options = 'YES,NO,ABSTENTION', tags = '' } = ctx.request.body;
-    const parsedOptions = options.split(',').map(o => o.trim()).filter(Boolean);
+    const { question, deadline, options, tags = '' } = ctx.request.body;
+    const parsedOptions = options
+      ? options.split(',').map(o => o.trim()).filter(Boolean)
+      : undefined;
     const parsedTags = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
     await votesModel.updateVoteById(id, { question, deadline, options: parsedOptions, tags: parsedTags });
     ctx.redirect('/votes?filter=mine');
@@ -2150,7 +2196,7 @@ router
       ctx.redirect('/votes');
       return;
     }
-    await opinionsModel.createVote(voteId, category, 'votes');
+    await votesModel.createOpinion(voteId, category);
     ctx.redirect('/votes');
   })
   .post('/market/create', koaBody({ multipart: true }), async ctx => {
@@ -2220,6 +2266,7 @@ router
   })
   .post('/market/bid/:id', koaBody(), async ctx => {
     const id = ctx.params.id;
+    const userId = SSBconfig.config.keys.id;
     const { bidAmount } = ctx.request.body;
     const marketItem = await marketModel.getItemById(id);
     await marketModel.addBidToAuction(id, userId, bidAmount);
@@ -2228,7 +2275,91 @@ router
     }
     ctx.redirect('/market?filter=auctions');
   })
-
+  .post('/jobs/create', koaBody({ multipart: true }), async (ctx) => {
+   const {
+      job_type,
+      title,
+      description,
+      requirements,
+      languages,
+      job_time,
+      tasks,
+      location,
+      vacants,
+      salary
+    } = ctx.request.body;
+    const imageBlob = ctx.request.files?.image
+      ? await handleBlobUpload(ctx, 'image')
+      : null;
+    await jobsModel.createJob({
+      job_type,
+      title,
+      description,
+      requirements,
+      languages,
+      job_time,
+      tasks,
+      location,
+      vacants: vacants ? parseInt(vacants, 10) : 1,
+      salary: salary != null ? parseFloat(salary) : 0,
+      image: imageBlob
+    });
+    ctx.redirect('/jobs?filter=MINE');
+  })
+  .post('/jobs/update/:id', koaBody({ multipart: true }), async (ctx) => {
+    const id = ctx.params.id;
+    const {
+      job_type,
+      title,
+      description,
+      requirements,
+      languages,
+      job_time,
+      tasks,
+      location,
+      vacants,
+      salary
+    } = ctx.request.body;
+    const imageBlob = ctx.request.files?.image
+      ? await handleBlobUpload(ctx, 'image')
+      : undefined;
+    await jobsModel.updateJob(id, {
+      job_type,
+      title,
+      description,
+      requirements,
+      languages,
+      job_time,
+      tasks,
+      location,
+      vacants: vacants ? parseInt(vacants, 10) : undefined,
+      salary: salary != null && salary !== '' ? parseFloat(salary) : undefined,
+      image: imageBlob
+    });
+    ctx.redirect('/jobs?filter=MINE');
+  })
+  .post('/jobs/delete/:id', koaBody(), async (ctx) => {
+    const id = ctx.params.id;
+    await jobsModel.deleteJob(id);
+    ctx.redirect('/jobs?filter=MINE');
+  })
+  .post('/jobs/status/:id', koaBody(), async (ctx) => {
+    const id = ctx.params.id;
+    const { status } = ctx.request.body;
+    await jobsModel.updateJobStatus(id, String(status).toUpperCase());
+    ctx.redirect('/jobs?filter=MINE');
+  })
+  .post('/jobs/subscribe/:id', koaBody(), async (ctx) => {
+    const id = ctx.params.id;
+    await jobsModel.subscribeToJob(id, config.keys.id);
+    ctx.redirect('/jobs');
+  })
+  .post('/jobs/unsubscribe/:id', koaBody(), async (ctx) => {
+    const id = ctx.params.id;
+    await jobsModel.unsubscribeFromJob(id, config.keys.id);
+    ctx.redirect('/jobs');
+  })
+  
   // UPDATE OASIS
   .post("/update", koaBody(), async (ctx) => {
     const util = require("node:util");
@@ -2246,23 +2377,15 @@ router
     await updateTool();
     const referer = new URL(ctx.request.header.referer);
     ctx.redirect(referer.href);
-  }) 
+  })  
   .post("/settings/theme", koaBody(), async (ctx) => {
-    const theme = String(ctx.request.body.theme);
+    const theme = String(ctx.request.body.theme || "").trim();
     const currentConfig = getConfig();
-    if (theme) {
-        currentConfig.themes.current = theme;
-        const configPath = path.join(__dirname, '../configs', 'oasis-config.json');
-        fs.writeFileSync(configPath, JSON.stringify(currentConfig, null, 2));
-        ctx.cookies.set("theme", theme);
-        ctx.redirect("/settings");
-    } else {
-        currentConfig.themes.current = "Dark-SNH";
-        fs.writeFileSync(path.join(__dirname, 'configs', 'oasis-config.json'), JSON.stringify(currentConfig, null, 2));
-        ctx.cookies.set("theme", "Dark-SNH");
-        ctx.redirect("/settings");
-     }
-   })
+    currentConfig.themes.current = theme || "Dark-SNH";
+    fs.writeFileSync(configPath, JSON.stringify(currentConfig, null, 2));
+    ctx.cookies.set("theme", currentConfig.themes.current);
+    ctx.redirect("/settings");
+  })
   .post("/language", koaBody(), async (ctx) => {
     const language = String(ctx.request.body.language);
     ctx.cookies.set("language", language);
@@ -2293,6 +2416,17 @@ router
     }
     ctx.redirect("/invites");
   })
+  .post("/settings/ssb-logstream", koaBody(), async (ctx) => {
+    const logLimit = parseInt(ctx.request.body.ssb_log_limit, 10);
+    if (!isNaN(logLimit) && logLimit > 0 && logLimit <= 100000) {
+      const configData = fs.readFileSync(configPath, 'utf8');
+      const config = JSON.parse(configData);
+      if (!config.ssbLogStream) config.ssbLogStream = {};
+      config.ssbLogStream.limit = logLimit;
+      fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
+    }
+    ctx.redirect("/settings");
+  })
   .post("/settings/rebuild", async (ctx) => {
     meta.rebuild();
     ctx.redirect("/settings");
@@ -2302,7 +2436,7 @@ router
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet',
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending',
     'events', 'tasks', 'market', 'tribes', 'governance', 'reports', 'opinions', 'transfers',
-    'feed', 'pixelia', 'agenda', 'ai', 'forum'
+    'feed', 'pixelia', 'agenda', 'ai', 'forum', 'jobs'
     ];
     const currentConfig = getConfig();
     modules.forEach(mod => {

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

@@ -1236,12 +1236,24 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
     margin-top: 10px;
 }
 
+.job-actions {
+    display: flex;
+    gap: 10px;
+    margin-top: 10px;
+}
+
 .tribe-actions {
     display: flex;
     gap: 10px;
     margin-top: 10px;
 }
 
+.pm-actions{
+    display: flex;
+    gap: 10px;
+    margin-top: 10px;
+}
+
 .audio-actions {
     display: flex;
     gap: 10px;
@@ -2081,3 +2093,26 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
     gap: 18px;
   }
 }
+
+/*inbox/pm*/
+.pm-card {
+  border-radius: 10px;
+  margin-bottom: 24px;
+  background: #262626;
+  box-shadow: 0 1px 6px rgba(0,0,0,0.16);
+  padding: 22px 28px;
+}
+
+.pm-header {
+  display: flex;
+  align-items: center;
+  gap: 18px;
+  margin-bottom: 12px;
+  font-size: 1em;
+  color: #ffd54f;
+}
+
+.pm-title {
+  font-weight: bold;
+  margin-bottom: 8px;
+}

+ 96 - 14
src/client/assets/translations/oasis_en.js

@@ -197,6 +197,9 @@ module.exports = {
     legacyDescription: "Manage your secret (private key) quickly and safely.",   
     legacyExportButton: "Export",
     legacyImportButton: "Import",
+    ssbLogStream: "Blokchain",
+    ssbLogStreamDescription: "Configure the message limit for Blockchain streams.",
+    saveSettings: "Save Settings",
     exportTitle: "Export data",
     exportDescription: "Set password (min 32 characters long) to encrypt your key",
     exportDataTitle: "Backup",
@@ -943,7 +946,7 @@ module.exports = {
     imageFilterMine: "MINE",
     imageCreateButton: "Upload Image",
     imageEditDescription: "Edit your image details.",
-    imageCreateDescription: "Create a new image.",
+    imageCreateDescription: "Create Image.",
     imageTagsLabel: "Tags",
     imageTagsPlaceholder: "Enter tags separated by commas",
     imageUpdateButton: "Update",
@@ -1019,27 +1022,29 @@ module.exports = {
     playVideo:            "Play video",
     typeRecent:           "RECENT",
     errorActivity:        "Error retrieving activity",
-    typePost:             "POSTS",
-    typeTribe:            "TRIBES",
-    typeAbout:            "INHABITANTS",
+    typePost:             "POST",
+    typeTribe:            "TRIBE",
+    typeAbout:            "INHABITANT",
     typeCurriculum:       "CV",
-    typeImage:            "IMAGES",
-    typeBookmark:         "BOOKMARKS",
-    typeDocument:         "DOCUMENTS",
+    typeImage:            "IMAGE",
+    typeBookmark:         "BOOKMARK",
+    typeDocument:         "DOCUMENT",
     typeVotes:            "GOVERNANCE",
-    typeAudio:            "AUDIOS",
+    typeAudio:            "AUDIO",
     typeMarket:           "MARKET",
-    typeVideo:            "VIDEOS",
+    typeJob:              "JOB",
+    typeVideo:            "VIDEO",
     typeVote:             "SPREAD",
-    typeEvent:            "EVENTS",
-    typeTransfer:         "TRANSFERS",
+    typeEvent:            "EVENT",
+    typeTransfer:         "TRANSFER",
     typeTask:             "TASKS",
     typePixelia: 	  "PIXELIA",
-    typeForum: 	          "FORUMS",
-    typeReport:           "REPORTS",
+    typeForum: 	          "FORUM",
+    typeReport:           "REPORT",
     typeFeed:             "FEED",
     typeContact:          "CONTACT",
     typePub:              "PUB",
+    typeTombstone:	  "TOMBSTONE",
     activitySupport:      "New alliance forged",
     activityJoin:         "New PUB joined",
     question:             "Question",
@@ -1196,6 +1201,7 @@ module.exports = {
     agendaFilterEvents: "EVENTS",
     agendaFilterReports: "REPORTS",
     agendaFilterTransfers: "TRANSFERS",
+    agendaFilterJobs: "JOBS",
     agendaNoItems: "No assignments found.",
     agendaAuthor: "By",
     agendaDiscardButton: "Discard",
@@ -1298,6 +1304,7 @@ module.exports = {
     blockchainBlockInfo: 'Block Information',
     blockchainBlockDetails: 'Details of the selected block',
     blockchainBack: 'Back to Blockexplorer',
+    blockchainContentDeleted: "This content has been tombstoned",
     visitContent: "Visit Content",
     //stats
     statsTitle: 'Statistics',
@@ -1318,14 +1325,22 @@ module.exports = {
     statsDiscoveredTribes: "Tribes",
     statsNetworkContent: "Content",
     statsYourMarket: "Market",
+    statsYourJob: "Jobs",
+    statsYourTransfer: "Transfers",
+    statsYourForum: "Forums",   
     statsNetworkOpinions: "Opinions",
     statsDiscoveredMarket: "Market",
+    statsDiscoveredJob: "Jobs",
+    statsDiscoveredTransfer: "Transfers",
+    statsDiscoveredForum: "Forums",
     statsNetworkTombstone: "Tombstones",
     statsBookmark: "Bookmarks",
     statsEvent: "Events",
     statsTask: "Tasks",
     statsVotes: "Votes",
     statsMarket: "Market",
+    statsForum: "Forums",
+    statsJob: "Jobs",
     statsReport: "Reports",
     statsFeed: "Feeds",
     statsTribe: "Tribes",
@@ -1354,7 +1369,7 @@ module.exports = {
     aiClearHistory: "Clear chat history",
     //market
     marketMineSectionTitle: "Your Items",
-    marketCreateSectionTitle: "Create a New Item",
+    marketCreateSectionTitle: "Create Item",
     marketUpdateSectionTitle: "Update",
     marketAllSectionTitle: "Market",
     marketRecentSectionTitle: "Recent Market",
@@ -1400,6 +1415,71 @@ module.exports = {
     marketNoItems: "No items available, yet.",
     marketYourBid: "Your Bid",
     marketCreateFormImageLabel: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",    
+    //jobs
+    jobsTitle: "Jobs",
+    jobsDescription: "Discover and manage jobs in your network.",
+    jobsFilterRecent: "RECENT",
+    jobsFilterMine: "MINE",
+    jobsFilterAll: "ALL",
+    jobsFilterRemote: "REMOTE",
+    jobsFilterOpen: "OPEN",
+    jobsFilterClosed: "CLOSED",
+    jobsCV: "CVs",
+    jobsCreateJob: "Create Job",
+    jobsRecentTitle: "Recent Jobs",
+    jobsMineTitle: "Your Jobs",
+    jobsAllTitle: "Jobs",
+    jobsRemoteTitle: "Remote Jobs",
+    jobsOpenTitle: "Open Jobs",
+    jobsClosedTitle: "Closed Jobs",
+    jobsCVTitle: "CVs",
+    jobsFilterPresencial: "PRESENCIAL",
+    jobsFilterFreelancer: "FREELANCER",
+    jobsFilterEmployee:   "EMPLOYEE",
+    jobsPresencialTitle:  "Presential Jobs",
+    jobsFreelancerTitle:  "Freelance Jobs",
+    jobsEmployeeTitle:    "Employee Jobs",
+    jobTitle: "Title",
+    jobLocation: "Location",
+    jobSalary: "Salary (1h)",
+    jobVacants: "Vacants",
+    jobDescription: "Description",
+    jobRequirements: "Requirements",
+    jobLanguages: "Languages",
+    jobStatus: "Status",
+    jobStatusOPEN: "OPEN",
+    jobStatusCLOSED: "CLOSED",
+    jobSetOpen: "Set as OPEN",
+    jobSetClosed: "Set as CLOSED",
+    jobSubscribeButton: "Join this offer!",
+    jobUnsubscribeButton: "Leave this offer!",
+    jobTitlePlaceholder: "Enter job title",
+    jobDescriptionPlaceholder: "Describe the job",
+    jobRequirementsPlaceholder: "Enter requirements",
+    jobLanguagesPlaceholder: "English, Spanish, Basque",
+    jobTasksPlaceholder: "List tasks",
+    jobLocationPresencial: "On-place",
+    jobLocationRemote: "Remote",
+    jobVacantsPlaceholder: "Number of positions",
+    jobSalaryPlaceholder: "Salary amount (1h)",
+    jobImage: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    jobTasks: "Tasks",
+    jobType: "Job Type",
+    jobTime: "Job Time",
+    jobSubscribers:  "Subscribers",
+    noSubscribers:   "No subscribers",
+    jobsFilterTop: "TOP",
+    jobsTopTitle: "Top Salary Jobs",
+    createJobButton: "Publish Job",
+    viewDetailsButton: "View Details",
+    noJobsFound: "No job offers found.",
+    jobAuthor: "By",
+    jobTypeFreelance: "Freelancer",
+    jobTypeSalary: "Employee",
+    jobTimePartial: "Part-time",
+    jobTimeComplete: "Full-time",
+    jobsDeleteButton: "DELETE",
+    jobsUpdateButton: "UPDATE",
     //modules
     modulesModuleName: "Name",
     modulesModuleDescription: "Description",
@@ -1467,6 +1547,8 @@ module.exports = {
     modulesAIDescription: "Module to talk with a LLM called '42'.",
     modulesForumLabel: "Forums",
     modulesForumDescription: "Module to discover and manage forums.",
+    modulesJobsLabel: "Jobs",
+    modulesJobsDescription: "Module to discover and manage jobs.",
      
      //END
     }

+ 101 - 20
src/client/assets/translations/oasis_es.js

@@ -197,6 +197,9 @@ module.exports = {
     legacyDescription: "Maneja tu secreto (llave privada) de forma rápida y segura.",   
     legacyExportButton: "Exportar",
     legacyImportButton: "Importar",
+    ssbLogStream: "Blockchain",
+    ssbLogStreamDescription: "Configura el límite de mensajes para los flujos de la blockchain.",
+    saveSettings: "Guardar configuración",
     exportTitle: "Exportar datos",
     exportDescription: "Establece una contraseña (min 32 caracteres de longitud) para cifrar tu secreto",
     exportDataTitle: "Backup",
@@ -421,7 +424,7 @@ module.exports = {
     cipherDecryptDescription: "Introduce el texto para desencriptar",
     cipherEncryptedMessageLabel: "Texto Encriptado",
     cipherDecryptedMessageLabel: "Texto Desencriptado",
-    cipherPasswordUsedLabel: "Contraseña usada para encriptar (¡guárdala!)",
+    cipherPasswordUsedLabel: "Contraseña usada para encriptar (guárdala!)",
     cipherEncryptedTextPlaceholder: "Introduce el texto encriptado...",
     cipherIvLabel: "IV",
     cipherIvPlaceholder: "Introduce el vector de inicialización...",
@@ -453,7 +456,7 @@ module.exports = {
     bookmarkCreateButton: "Crear Marcador",
     existingbookmarksTitle: "Marcadores Existentes",
     nobookmarks: "No hay marcadores disponibles.",
-    newbookmarkSuccess: "¡Nuevo marcador creado con éxito!",
+    newbookmarkSuccess: "Nuevo marcador creado con éxito!",
     bookmarkFilterAll: "TODOS",
     bookmarkFilterMine: "MIOS",
     bookmarkUpdateButton: "Actualizar",
@@ -942,7 +945,7 @@ module.exports = {
     imageFilterMine: "MIAS",
     imageCreateButton: "Subir Imagen",
     imageEditDescription: "Edita los detalles de tu imagen.",
-    imageCreateDescription: "Crea una nueva imagen.",
+    imageCreateDescription: "Crea una imagen.",
     imageTagsLabel: "Etiquetas",
     imageTagsPlaceholder: "Introduce etiquetas separadas por comas",
     imageUpdateButton: "Actualizar",
@@ -968,7 +971,7 @@ module.exports = {
     //feed
     feedTitle:        "Feed",
     createFeedTitle:  "Crear Feed",
-    createFeedButton: "¡Enviar Feed!",
+    createFeedButton: "Enviar Feed!",
     feedPlaceholder:  "¿Qué está pasando? (máximo 280 caracteres)",
     ALLButton:        "Feeds",
     MINEButton:       "Tus Feeds",
@@ -1018,24 +1021,25 @@ module.exports = {
     playVideo:            "Reproducir video",
     typeRecent:           "RECIENTES",
     errorActivity:        "Error al recuperar la actividad",
-    typePost:             "PUBLICACIONES",
+    typePost:             "PUBLICACIÓN",
     typeTribe:            "TRIBUS",
-    typeAbout:            "HABITANTES",
+    typeAbout:            "HABITANTE",
     typeCurriculum:       "CV",
-    typeImage:            "IMÁGENES",
-    typeBookmark:         "MARCADORES",
-    typeDocument:         "DOCUMENTOS",
+    typeImage:            "IMÁGEN",
+    typeBookmark:         "MARCADOR",
+    typeDocument:         "DOCUMENTO",
     typeVotes:            "GOBIERNO",
-    typeAudio:            "AUDIOS",
+    typeAudio:            "AUDIO",
     typeMarket:           "MERCADO",
-    typeVideo:            "VIDEOS",
+    typeJob:              "TRABAJO",
+    typeVideo:            "VIDEO",
     typeVote:             "PROPAGACIÓN",
-    typeEvent:            "EVENTOS",
-    typeTransfer:         "TRANSFERENCIAS",
-    typeTask:             "TAREAS",
+    typeEvent:            "EVENTO",
+    typeTransfer:         "TRANSFERENCIA",
+    typeTask:             "TAREA",
     typePixelia:          "PIXELIA",
-    typeForum: 	          "FOROS",
-    typeReport:           "INFORMES",
+    typeForum: 	          "FORO",
+    typeReport:           "REPORT",
     typeFeed:             "FEED",
     typeContact:          "CONTACTO",
     typePub:              "PUB",
@@ -1114,7 +1118,7 @@ module.exports = {
     reportsUpdateStatusButton: "Actualizar Estado",
     reportsAnonymityOption: "Enviar de forma anónima",
     reportsAnonymousAuthor: "Anónimo",
-    reportsConfirmButton: "¡CONFIRMAR INFORME!",
+    reportsConfirmButton: "CONFIRMAR INFORME!",
     reportsConfirmations: "Confirmaciones",
     reportsConfirmedSectionTitle: "Informes Confirmados",
     reportsCreateTaskButton: "CREAR TAREA",
@@ -1195,6 +1199,7 @@ module.exports = {
     agendaFilterEvents: "EVENTOS",
     agendaFilterReports: "INFORMES",
     agendaFilterTransfers: "TRANSFERENCIAS",
+    agendaFilterJobs: "TRABAJOS",
     agendaNoItems: "No se encontraron asignaciones.",
     agendaDiscardButton: "Descartar",
     agendaRestoreButton: "Restaurar",
@@ -1297,6 +1302,7 @@ module.exports = {
     blockchainBlockInfo: 'Información del bloque',
     blockchainBlockDetails: 'Detalles del bloque seleccionado', 
     blockchainBack: 'Volver al explorador de bloques',
+    blockchainContentDeleted: "Este contenido ha sido eliminado",
     visitContent: 'Visitar Contenido',
     //stats
     statsTitle: 'Estadísticas',
@@ -1317,14 +1323,22 @@ module.exports = {
     statsDiscoveredTribes: "Tribus",
     statsNetworkContent: "Contenido",
     statsYourMarket: "Mercado",
+    statsYourJob: "Trabajos",
+    statsYourTransfer:     "Transferencias",
+    statsYourForum:        "Foros",   
     statsNetworkOpinions: "Opiniones",
     statsDiscoveredMarket: "Mercado",
+    statsDiscoveredJob: "Trabajos",
+    statsDiscoveredTransfer: "Transferencias",
+    statsDiscoveredForum: "Foros",
     statsNetworkTombstone: "Lápidas",
     statsBookmark: "Marcadores",
     statsEvent: "Eventos",
     statsTask: "Tareas",
     statsVotes: "Votos",
     statsMarket: "Mercado",
+    statsForum: "Foros",
+    statsJob: "Trabajos",
     statsReport: "Informes",
     statsFeed: "Feeds",
     statsTribe: "Tribus",
@@ -1353,7 +1367,7 @@ module.exports = {
     aiClearHistory: "Borrar historial de chat",
     //market
     marketMineSectionTitle: "Tus Artículos",
-    marketCreateSectionTitle: "Crear un Nuevo Artículo",
+    marketCreateSectionTitle: "Crear un Artículo",
     marketUpdateSectionTitle: "Actualizar",
     marketAllSectionTitle: "Mercado",
     marketRecentSectionTitle: "Mercado Reciente",
@@ -1389,16 +1403,81 @@ module.exports = {
     marketOutOfStock: "Sin existencias",
     marketItemBidTime: "Tiempo de Oferta",
     marketActionsUpdate: "Actualizar",
-    marketUpdateButton: "¡Actualizar Artículo!",
+    marketUpdateButton: "Actualizar Artículo!",
     marketActionsDelete: "Eliminar",
     marketActionsSold: "Marcar como Vendido",
-    marketActionsBuy: "¡COMPRAR!",
+    marketActionsBuy: "COMPRAR!",
     marketAuctionBids: "Ofertas Actuales",
     marketPlaceBidButton: "Hacer Oferta",
     marketItemSeller: "Vendedor",
     marketNoItems: "No hay artículos disponibles, aún.",
     marketYourBid: "Tu Oferta",
     marketCreateFormImageLabel: "Subir Imagen (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
+    //jobs
+    jobsTitle: "Ofertas de trabajo",
+    jobsDescription: "Descubre y gestiona ofertas de trabajo en tu red.",
+    jobsFilterRecent: "RECIENTES",
+    jobsFilterMine: "MIOS",
+    jobsFilterAll: "TODOS",
+    jobsFilterRemote: "REMOTOS",
+    jobsFilterOpen: "ABIERTOS",
+    jobsFilterClosed: "CERRADOS",
+    jobsCV: "CVs",
+    jobsCreateJob: "Publicar Trabajo",
+    jobsRecentTitle: "Trabajos Recientes",
+    jobsMineTitle: "Tus Trabajos",
+    jobsAllTitle: "Ofertas de Trabajo",
+    jobsRemoteTitle: "Trabajos Remotos",
+    jobsOpenTitle: "Trabajos Abiertos",
+    jobsClosedTitle: "Trabajos Cerrados",
+    jobsCVTitle: "CVs",
+    jobsFilterPresencial: "PRESENCIAL",
+    jobsFilterFreelancer: "FREELANCE",
+    jobsFilterEmployee:   "EMPLEADO",
+    jobsPresencialTitle:  "Trabajos Presenciales",
+    jobsFreelancerTitle:  "Trabajos Freelance",
+    jobsEmployeeTitle:    "Trabajos de Empleado",
+    jobTitle: "Título",
+    jobLocation: "Ubicación",
+    jobSalary: "Salario (1h)",
+    jobVacants: "Vacantes",
+    jobDescription: "Descripción",
+    jobRequirements: "Requisitos",
+    jobLanguages: "Idiomas",
+    jobStatus: "Estado",
+    jobStatusOPEN: "ABIERTA",
+    jobStatusCLOSED: "CERRADA",
+    jobSetOpen: "Marcar como ABIERTA",
+    jobSetClosed: "Marcar como CERRADA",
+    jobSubscribeButton: "Unirse a esta oferta!",
+    jobUnsubscribeButton: "Salir de esta oferta!",
+    jobTitlePlaceholder: "Introduce el título del trabajo",
+    jobDescriptionPlaceholder: "Describe el puesto",
+    jobRequirementsPlaceholder: "Introduce los requisitos",
+    jobLanguagesPlaceholder: "Inglés, Español, Euskera",
+    jobTasksPlaceholder: "Lista de tareas",
+    jobLocationPresencial: "Presencial",
+    jobLocationRemote: "Remoto",
+    jobVacantsPlaceholder: "Número de vacantes",
+    jobSalaryPlaceholder: "Salario (1h)",
+    jobImage: "Subir Imagen (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
+    jobTasks: "Tareas",
+    jobType: "Tipo de Trabajo",
+    jobTime: "Tiempo de Trabajo",
+    jobSubscribers:  "Suscriptores",
+    noSubscribers:   "Sin suscriptores",
+    jobsFilterTop: "TOP",
+    jobsTopTitle: "Trabajos Mejor Pagados",
+    createJobButton: "Publicar Trabajo",
+    viewDetailsButton: "Ver Detalles",
+    noJobsFound: "No se encontraron ofertas de trabajo.",
+    jobAuthor: "Por",
+    jobTypeFreelance: "Autónomo",
+    jobTypeSalary: "Empleado",
+    jobTimePartial: "Parcial",
+    jobTimeComplete: "Completo",
+    jobsDeleteButton: "ELIMINAR",
+    jobsUpdateButton: "ACTUALIZAR",
     //modules
     modulesModuleName: "Nombre",
     modulesModuleDescription: "Descripción",
@@ -1466,6 +1545,8 @@ module.exports = {
     modulesAIDescription: "Módulo para hablar con un LLM llamado '42'.",
     modulesForumLabel: "Foros",
     modulesForumDescription: "Módulo para descubrir y gestionar foros.",
+    modulesJobsLabel: "Trabajos",
+    modulesJobsDescription: "Modulo para descubrir y gestionar ofertas de trabajo.",
      
      //END
     }

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

@@ -197,6 +197,9 @@ module.exports = {
     legacyDescription: "Kudeatu zure sekretua (gako pribatua) bizkor eta segurtasunez.",   
     legacyExportButton: "Esportatu",
     legacyImportButton: "Inportatu",
+    ssbLogStream: "Blockchain",
+    ssbLogStreamDescription: "Konfiguratu blockchain fluxuetarako mezu-muga.",
+    saveSettings: "Ezarpenak gorde",
     exportTitle: "Esportatu datuak",
     exportDescription: "Ezarri pasahitza (gutxienez 32 karaktere) zure gakoa zifratzeko",
     exportDataTitle: "Babeskopia",
@@ -1029,6 +1032,7 @@ module.exports = {
     typeVotes:       "BOZKAK",
     typeAudio:       "AUIDOAK",
     typeMarket:      "MERKATUA",
+    typeJob:         "LANAK",
     typeVideo:       "BIDEOAK",
     typeVote:        "ZABALPENAK",
     typeEvent:       "EKITALDIAK",
@@ -1196,6 +1200,7 @@ module.exports = {
     agendaFilterEvents: "EKITALDIAK",
     agendaFilterReports: "TXOSTENAK",
     agendaFilterTransfers: "TRANSFERENTZIAK",
+    agendaFilterJobs: "LANPOSTUAK",
     agendaNoItems: "Esleipenik ez.",
     agendaDiscardButton: "Baztertu",
     agendaRestoreButton: "Berrezarri",
@@ -1298,6 +1303,7 @@ module.exports = {
     blockchainBlockInfo: 'Blokearen informazioa',
     blockchainBlockDetails: 'Hautatutako blokearen xehetasunak',
     blockchainBack: 'Itzuli blokearen azterkira',
+    blockchainContentDeleted: "Edukia ezabatu egin da",
     visitContent: 'Bisitatu Edukia',
     //stats
     statsTitle: 'Estatistikak',
@@ -1318,14 +1324,22 @@ module.exports = {
     statsDiscoveredTribes: "Tribuak",
     statsNetworkContent:   "Edukia",
     statsYourMarket:       "Merkatua",
+    statsYourJob:          "Lanak",
+    statsYourTransfer:     "Transferentziak",
+    statsYourForum:        "Foroak",   
     statsNetworkOpinions:  "Iritziak",
     statsDiscoveredMarket: "Merkatua",
+    statsDiscoveredJobs:   "Lanak",
+    statsDiscoveredTransfer: "Transferentziak",
+    statsDiscoveredForum: "Foroak",
     statsNetworkTombstone: "Hilarriak",
     statsBookmarks: "Markagailuak",
     statsEvents: "Ekitaldiak",
     statsTasks: "Atazak",
     statsVotes: "Bozkak",
     statsMarket: "Merkatua",
+    statsForum: "Foroak",
+    statsJob: "Lanak",
     statsReports: "Txostenak",
     statsFeeds: "Jarioak",
     statsTribes: "Tribuak",
@@ -1400,6 +1414,69 @@ module.exports = {
     marketNoItems: "Elementurik ez, oraindik.",
     marketYourBid: "Zeure eskaintza",
     marketCreateFormImageLabel: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    //jobs
+    jobsTitle: "Lanak",
+    jobsDescription: "Aurki eta kudeatu zure sarean lan eskaintzak.",
+    jobsFilterRecent: "BERANDUENAK",
+    jobsFilterMine: "NIRE LANEK",
+    jobsFilterAll: "GUZTIAK",
+    jobsFilterRemote: "ERREMOTAK",
+    jobsFilterOpen: "IREKIAK",
+    jobsFilterClosed: "ITXITA",
+    jobsCV: "CV-ak",
+    jobsCreateJob: "Argitaratu Lana",
+    jobsRecentTitle: "Lana Azkenak",
+    jobsMineTitle: "Zure Lankideak",
+    jobsAllTitle: "Lanen Guztiak",
+    jobsRemoteTitle: "Erremotako Lanak",
+    jobsOpenTitle: "Irekitako Lanak",
+    jobsClosedTitle: "Itxitako Lanak",
+    jobsCVTitle: "CV-ak",
+    jobsFilterPresencial: "PRESENTZIALA",
+    jobsFilterFreelancer: "FREELANCE",
+    jobsFilterEmployee:   "LANGILE",
+    jobsPresencialTitle:  "Presentzial Lanak",
+    jobsFreelancerTitle:  "Freelance Lanak",
+    jobsEmployeeTitle:    "Langile Lanak",
+    jobTitle: "Izenburua",
+    jobLocation: "Kokalekua",
+    jobSalary: "Soldata (1h)",
+    jobVacants: "Postu Libreak",
+    jobDescription: "Deskribapena",
+    jobRequirements: "Eskakizunak",
+    jobLanguages: "Hizkuntzak",
+    jobStatus: "Egoera",
+    jobStatusOPEN: "IREKITA",
+    jobStatusCLOSED: "ITXITA",
+    jobSetOpen: "IREKI gisa markatu",
+    jobSetClosed: "ITXI gisa markatu",
+    jobSubscribeButton: "Batu eskaintza honetara!",
+    jobUnsubscribeButton: "Utzi eskaintza hau!",
+    jobTitlePlaceholder: "Idatzi lanaren titulua",
+    jobDescriptionPlaceholder: "Deskribatu lana",
+    jobRequirementsPlaceholder: "Idatzi eskakizunak",
+    jobLanguagesPlaceholder: "Ingelesa, Espainiera, Euskara",
+    jobTasksPlaceholder: "Zeregin zerrenda",
+    jobLocationPresencial: "Aurrez aurrekoa",
+    jobLocationRemote: "Urrunekoa",
+    jobVacantsPlaceholder: "Postu kopurua",
+    jobSalaryPlaceholder: "Soldata kopurua",
+    jobImage: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    jobTasks: "Eginkizunak",
+    jobType: "Lanen Motak",
+    jobTime: "Lanen Denbora",
+    jobSubscribers:  "Harpidetzaileak",
+    noSubscribers:   "Ez da harpidetzailerik",
+    createJobButton: "Argitaratu Lana",
+    viewDetailsButton: "Ikusi Xehetasunak",
+    noJobsFound: "Ez daude lan eskaintzak.",
+    jobAuthor: "Egilea",
+    jobTypeEmployee: "Enplegatu",
+    jobTypeFreelancer: "Lanikide",
+    jobTimePartial: "Partziala",
+    jobTimeComplete: "Osotua",
+    jobsDeleteButton: "EZABATU",
+    jobsUpdateButton: "EGUNERATU",
     //modules
     modulesModuleName: "Izena",
     modulesModuleDescription: "Deskribapena",
@@ -1466,7 +1543,9 @@ module.exports = {
     modulesAILabel: "AI",
     modulesAIDescription: "'42' izeneko LLM batekin hitz egiteko modulua.",
     modulesForumLabel: "Foroak",
-    modulesForumDescription: "Foroak deskubritu eta kudeatzeko modulua."
+    modulesForumDescription: "Foroak deskubritu eta kudeatzeko modulua.",
+    modulesJobsLabel: "Lanpostuak",
+    modulesJobsDescription: "Lan eskaintzak aurkitu eta kudeatzeko modulu.",
 
      //END
   }

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

@@ -1,3 +1,3 @@
 {
   "discardedItems": []
-}
+}

+ 4 - 0
src/configs/config-manager.js

@@ -39,6 +39,7 @@ if (!fs.existsSync(configFilePath)) {
       "agendaMod": "on",
       "aiMod": "on",
       "forumMod": "on",
+      "jobsMod": "on"
     },
     "wallet": {
       "url": "http://localhost:7474",
@@ -48,6 +49,9 @@ if (!fs.existsSync(configFilePath)) {
     },
     "ai": {
       "prompt": "Provide an informative and precise response."
+    },
+    "ssbLogStream": {
+      "limit": 1000
     }
   };
   fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));

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

@@ -32,7 +32,8 @@
     "pixeliaMod": "on",
     "agendaMod": "on",
     "aiMod": "on",
-    "forumMod": "on"
+    "forumMod": "on",
+    "jobsMod": "on"
   },
   "wallet": {
     "url": "http://localhost:7474",
@@ -42,5 +43,8 @@
   },
   "ai": {
     "prompt": "Provide an informative and precise response."
+  },
+  "ssbLogStream": {
+    "limit": 1000
   }
 }

+ 97 - 33
src/models/activity_model.js

@@ -1,4 +1,13 @@
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
+
+const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_');
+const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD'];
+const SCORE_MARKET = s => {
+  const i = ORDER_MARKET.indexOf(N(s));
+  return i < 0 ? -1 : i;
+};
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -23,56 +32,111 @@ module.exports = ({ cooler }) => {
 
       const results = await new Promise((resolve, reject) => {
         pull(
-          ssbClient.createLogStream({ reverse: true, limit: 1000 }),
+          ssbClient.createLogStream({ reverse: true, limit: logLimit }),
           pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
         );
       });
 
       const tombstoned = new Set();
-      const replaces = new Map();
-      const latest = new Map();
+      const parentOf = new Map();
+      const idToAction = new Map();
 
       for (const msg of results) {
         const k = msg.key;
-        const c = msg.value?.content;
-        const author = msg.value?.author;
+        const v = msg.value;
+        const c = v?.content;
         if (!c?.type) continue;
         if (c.type === 'tombstone' && c.target) {
           tombstoned.add(c.target);
           continue;
         }
-        if (c.replaces) replaces.set(c.replaces, k);
-        latest.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
+        idToAction.set(k, {
+          id: k,
+          author: v?.author,
+          ts: v?.timestamp || 0,
+          type: c.type,
+          content: c
+        });
+        if (c.replaces) parentOf.set(k, c.replaces);
       }
 
-      for (const oldId of replaces.keys()) latest.delete(oldId);
-      for (const t of tombstoned) latest.delete(t);
+      const rootOf = (id) => {
+        let cur = id;
+        while (parentOf.has(cur)) cur = parentOf.get(cur);
+        return cur;
+      };
 
-      const actions = await Promise.all(
-        Array.from(latest.values()).map(async (a) => {
-          if (a.type === 'document') {
-            const url = a.content.url;
-            const validBlob = await hasBlob(ssbClient, url);
-            if (!validBlob) return null;
-          }
-          if (
-            a.type !== 'tombstone' &&
-            !tombstoned.has(a.id) &&
-            !(a.content?.root && tombstoned.has(a.content.root)) &&
-            !(a.type === 'vote' && tombstoned.has(a.content.vote.link))
-          ) {
-            return a;
+      const groups = new Map();
+      for (const [id, action] of idToAction.entries()) {
+        const root = rootOf(id);
+        if (!groups.has(root)) groups.set(root, []);
+        groups.get(root).push(action);
+      }
+
+      const idToTipId = new Map();
+
+      for (const [root, arr] of groups.entries()) {
+        if (!arr.length) continue;
+        const type = arr[0].type;
+
+        let tip;
+        if (type === 'market') {
+          tip = arr[0];
+          let bestScore = SCORE_MARKET(tip.content.status);
+          for (const a of arr) {
+            const s = SCORE_MARKET(a.content.status);
+            if (s > bestScore || (s === bestScore && a.ts > tip.ts)) {
+              tip = a;
+              bestScore = s;
+            }
           }
-          return null;
-        })
-      );
-      const validActions = actions.filter(Boolean);
-      if (filter === 'mine')
-        return validActions
-          .filter(a => a.author === userId)
-          .sort((a, b) => b.ts - a.ts);
-
-      return validActions.sort((a, b) => b.ts - a.ts);
+        } else {
+          tip = arr.reduce((best, a) => (a.ts > best.ts ? a : best), arr[0]);
+        }
+
+        if (tombstoned.has(tip.id)) {
+          const nonTomb = arr.filter(a => !tombstoned.has(a.id));
+          if (!nonTomb.length) continue;
+          tip = nonTomb.reduce((best, a) => (a.ts > best.ts ? a : best), nonTomb[0]);
+        }
+
+        for (const a of arr) idToTipId.set(a.id, tip.id);
+      }
+
+    const latest = [];
+    for (const a of idToAction.values()) {
+      if (tombstoned.has(a.id)) continue;
+      const c = a.content || {};
+      if (c.root && tombstoned.has(c.root)) continue;
+      if (a.type === 'vote' && tombstoned.has(c.vote?.link)) continue;
+      if (c.key && tombstoned.has(c.key)) continue;
+      if (c.branch && tombstoned.has(c.branch)) continue;
+      if (c.target && tombstoned.has(c.target)) continue;
+
+      if (a.type === 'document') {
+        const url = c.url;
+        const ok = await hasBlob(ssbClient, url);
+        if (!ok) continue;
+      }
+      latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id });
+    }
+
+      let out;
+      if (filter === 'mine') {
+        out = latest.filter(a => a.author === userId);
+      } else if (filter === 'recent') {
+        const cutoff = Date.now() - 24 * 60 * 60 * 1000;
+        out = latest.filter(a => (a.ts || 0) >= cutoff);
+      } else if (filter === 'all') {
+        out = latest;
+      } else {
+        out = latest.filter(a => a.type === filter);
+      }
+
+      out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
+
+      return out;
     }
   };
 };
+

+ 130 - 46
src/models/agenda_model.js

@@ -4,6 +4,8 @@ const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 
 const agendaConfigPath = path.join(__dirname, '../configs/agenda-config.json');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 function readAgendaConfig() {
   if (!fs.existsSync(agendaConfigPath)) {
@@ -20,32 +22,112 @@ module.exports = ({ cooler }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
-  const fetchItems = (targetType, filterFn) =>
+  const STATUS_ORDER = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
+  const sIdx = s => STATUS_ORDER.indexOf(String(s || '').toUpperCase());
+
+  const fetchItems = (targetType) =>
     new Promise((resolve, reject) => {
       openSsb().then((ssbClient) => {
-        const userId = ssbClient.id;
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => {
             if (err) return reject(err);
-            const tombstoned = new Set();
-            const replacesMap = new Map();
-            const latestMap = new Map();
-            for (const msg of msgs) {
-              const c = msg.value?.content;
-              const k = msg.key;
+
+            const tomb = new Set();
+            const nodes = new Map();
+            const parent = new Map();
+            const child = new Map();
+
+            for (const m of msgs) {
+              const k = m.key;
+              const v = m.value;
+              const c = v?.content;
               if (!c) continue;
-              if (c.type === 'tombstone' && c.target) tombstoned.add(c.target);
-              else if (c.type === targetType) {
-                if (c.replaces) replacesMap.set(c.replaces, k);
-                latestMap.set(k, { key: k, value: msg.value });
+              if (c.type === 'tombstone' && c.target) { tomb.add(c.target); continue; }
+              if (c.type !== targetType) continue;
+              nodes.set(k, { key: k, ts: v.timestamp || 0, content: c });
+              if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k); }
+            }
+
+            const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur; };
+
+            const groups = new Map();
+            for (const id of nodes.keys()) {
+              const r = rootOf(id);
+              if (!groups.has(r)) groups.set(r, new Set());
+              groups.get(r).add(id);
+            }
+
+            const statusOrder = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
+            const sIdx = s => statusOrder.indexOf(String(s || '').toUpperCase());
+
+            const out = [];
+
+            for (const [root, ids] of groups.entries()) {
+              const items = Array.from(ids).map(id => nodes.get(id)).filter(n => n && !tomb.has(n.key));
+              if (!items.length) continue;
+
+              let tipId = Array.from(ids).find(id => !child.has(id));
+              let tip = tipId ? nodes.get(tipId) : items.reduce((a, b) => a.ts > b.ts ? a : b);
+
+              if (targetType === 'market') {
+                let chosen = items[0];
+                for (const n of items) {
+                  const a = sIdx(n.content.status);
+                  const b = sIdx(chosen.content.status);
+                  if (a > b || (a === b && n.ts > chosen.ts)) chosen = n;
+                }
+                const c = chosen.content;
+                let status = c.status;
+                if (c.deadline) {
+                  const dl = moment(c.deadline);
+                  if (dl.isValid() && dl.isBefore(moment()) && String(status).toUpperCase() !== 'SOLD') status = 'DISCARDED';
+                }
+                if (status === 'FOR SALE' && (c.stock || 0) === 0) continue;
+
+                out.push({
+                  ...c,
+                  status,
+                  id: chosen.key,
+                  tipId: chosen.key,
+                  createdAt: c.createdAt || chosen.ts
+                });
+                continue;
               }
+
+              if (targetType === 'job') {
+                const latest = items.sort((a, b) => b.ts - a.ts)[0];
+                const withSubsNode = items
+                  .filter(n => Array.isArray(n.content.subscribers))
+                  .sort((a, b) => b.ts - a.ts)[0];
+                const subscribers = withSubsNode ? withSubsNode.content.subscribers : [];
+                const latestWithStatus = items
+                  .filter(n => typeof n.content.status !== 'undefined')
+                  .sort((a, b) => b.ts - a.ts)[0];
+                const resolvedStatus = latestWithStatus
+                  ? latestWithStatus.content.status
+                  : latest.content.status;
+
+                const c = { ...latest.content, status: resolvedStatus, subscribers };
+
+                out.push({
+                  ...c,
+                  id: latest.key,
+                  tipId: latest.key,
+                  createdAt: c.createdAt || latest.ts
+                });
+                continue;
+              }
+
+              out.push({
+                ...tip.content,
+                id: tip.key,
+                tipId: tip.key,
+                createdAt: tip.content.createdAt || tip.ts
+              });
             }
-            for (const [oldId, newId] of replacesMap.entries()) latestMap.delete(oldId);
-            const results = Array.from(latestMap.values()).filter(
-              (msg) => !tombstoned.has(msg.key) && filterFn(msg.value.content, userId)
-            );
-            resolve(results.map(item => ({ ...item.value.content, id: item.key })));
+
+            resolve(out);
           })
         );
       }).catch(reject);
@@ -58,54 +140,55 @@ module.exports = ({ cooler }) => {
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
 
-      const [tasks, events, transfers, tribes, marketItems, reports] = await Promise.all([
-        fetchItems('task', (c, id) => Array.isArray(c.assignees) && c.assignees.includes(id)),
-        fetchItems('event', (c, id) => Array.isArray(c.attendees) && c.attendees.includes(id)),
-        fetchItems('transfer', (c, id) => c.from === id || c.to === id),
-        fetchItems('tribe', (c, id) => Array.isArray(c.members) && c.members.includes(id)),
-        fetchItems('market', (c, id) => c.seller === id || (Array.isArray(c.auctions_poll) && c.auctions_poll.some(b => b.split(':')[0] === id))),
-        fetchItems('report', (c, id) => c.author === id || (Array.isArray(c.confirmations) && c.confirmations.includes(id)))
+      const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll] = await Promise.all([
+        fetchItems('task'),
+        fetchItems('event'),
+        fetchItems('transfer'),
+        fetchItems('tribe'),
+        fetchItems('market'),
+        fetchItems('report'),
+        fetchItems('job')
       ]);
 
+      const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
+      const events = eventsAll.filter(c => Array.isArray(c.attendees) && c.attendees.includes(userId)).map(e => ({ ...e, type: 'event' }));
+      const transfers = transfersAll.filter(c => c.from === userId || c.to === userId).map(tr => ({ ...tr, type: 'transfer' }));
+      const tribes = tribesAll.filter(c => Array.isArray(c.members) && c.members.includes(userId)).map(t => ({ ...t, type: 'tribe', title: t.title }));
+      const marketItems = marketAll.filter(c =>
+        c.seller === userId || (Array.isArray(c.auctions_poll) && c.auctions_poll.some(b => String(b).split(':')[0] === userId))
+      ).map(m => ({ ...m, type: 'market' }));
+      const reports = reportsAll.filter(c => c.author === userId || (Array.isArray(c.confirmations) && c.confirmations.includes(userId))).map(r => ({ ...r, type: 'report' }));
+      const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
+
       let combined = [
         ...tasks,
         ...events,
         ...transfers,
-        ...tribes.map(t => ({ ...t, type: 'tribe', title: t.title })),
-        ...marketItems.map(m => ({ ...m, type: 'market' })),
-        ...reports.map(r => ({ ...r, type: 'report' }))
+        ...tribes,
+        ...marketItems,
+        ...reports,
+        ...jobs
       ];
-      const dedup = {};
-      for (const item of combined) {
-        const dA = item.startTime || item.date || item.deadline || item.createdAt;
-        if (!dedup[item.id]) dedup[item.id] = item;
-        else {
-          const existing = dedup[item.id];
-          const dB = existing.startTime || existing.date || existing.deadline || existing.createdAt;
-          if (new Date(dA) > new Date(dB)) dedup[item.id] = item;
-        }
-      }
-      combined = Object.values(dedup);
 
       let filtered;
       if (filter === 'discarded') {
         filtered = combined.filter(i => discardedItems.includes(i.id));
       } else {
         filtered = combined.filter(i => !discardedItems.includes(i.id));
-
         if (filter === 'tasks') filtered = filtered.filter(i => i.type === 'task');
         else if (filter === 'events') filtered = filtered.filter(i => i.type === 'event');
         else if (filter === 'transfers') filtered = filtered.filter(i => i.type === 'transfer');
         else if (filter === 'tribes') filtered = filtered.filter(i => i.type === 'tribe');
         else if (filter === 'market') filtered = filtered.filter(i => i.type === 'market');
         else if (filter === 'reports') filtered = filtered.filter(i => i.type === 'report');
-        else if (filter === 'open') filtered = filtered.filter(i => i.status === 'OPEN');
-        else if (filter === 'closed') filtered = filtered.filter(i => i.status === 'CLOSED');
+        else if (filter === 'open') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'OPEN');
+        else if (filter === 'closed') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'CLOSED');
+        else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job');
       }
 
       filtered.sort((a, b) => {
-        const dateA = a.startTime || a.date || a.deadline || a.createdAt;
-        const dateB = b.startTime || b.date || b.deadline || b.createdAt;
+        const dateA = a.startTime || a.date || a.deadline || a.createdAt || 0;
+        const dateB = b.startTime || b.date || b.deadline || b.createdAt || 0;
         return new Date(dateA) - new Date(dateB);
       });
 
@@ -116,14 +199,15 @@ module.exports = ({ cooler }) => {
         items: filtered,
         counts: {
           all: mainItems.length,
-          open: mainItems.filter(i => i.status === 'OPEN').length,
-          closed: mainItems.filter(i => i.status === 'CLOSED').length,
+          open: mainItems.filter(i => String(i.status).toUpperCase() === 'OPEN').length,
+          closed: mainItems.filter(i => String(i.status).toUpperCase() === 'CLOSED').length,
           tasks: mainItems.filter(i => i.type === 'task').length,
           events: mainItems.filter(i => i.type === 'event').length,
           transfers: mainItems.filter(i => i.type === 'transfer').length,
           tribes: mainItems.filter(i => i.type === 'tribe').length,
           market: mainItems.filter(i => i.type === 'market').length,
           reports: mainItems.filter(i => i.type === 'report').length,
+          jobs: mainItems.filter(i => i.type === 'job').length,
           discarded: discarded.length
         }
       };

+ 3 - 1
src/models/audios_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream')
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb
@@ -79,7 +81,7 @@ module.exports = ({ cooler }) => {
       const ssbClient = await openSsb()
       const messages = await new Promise((res, rej) => {
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         )
       })

+ 138 - 52
src/models/blockchain_model.js

@@ -1,4 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
+const { config } = require('../server/SSB_server.js');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -8,28 +11,22 @@ module.exports = ({ cooler }) => {
     return ssb;
   };
 
-  const hasBlob = async (ssbClient, url) => {
-    return new Promise((resolve) => {
-      ssbClient.blobs.has(url, (err, has) => {
-        resolve(!err && has);
-      });
-    });
-  };
+  const hasBlob = async (ssbClient, url) =>
+    new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
 
   return {
     async listBlockchain(filter = 'all') {
       const ssbClient = await openSsb();
-
-      const results = await new Promise((resolve, reject) => {
+      const results = await new Promise((resolve, reject) =>
         pull(
-          ssbClient.createLogStream({ reverse: true, limit: 1000 }),
+          ssbClient.createLogStream({ reverse: true, limit: logLimit }),
           pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
-        );
-      });
+        )
+      );
 
       const tombstoned = new Set();
-      const replaces = new Map();
-      const blocks = new Map();
+      const idToBlock = new Map();
+      const referencedAsReplaces = new Set();
 
       for (const msg of results) {
         const k = msg.key;
@@ -38,64 +35,153 @@ module.exports = ({ cooler }) => {
         if (!c?.type) continue;
         if (c.type === 'tombstone' && c.target) {
           tombstoned.add(c.target);
+          idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
           continue;
         }
-        if (c.replaces) replaces.set(c.replaces, k);
-        blocks.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
+        if (c.replaces) referencedAsReplaces.add(c.replaces);
+        idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
       }
 
-      for (const oldId of replaces.keys()) blocks.delete(oldId);
-      for (const t of tombstoned) blocks.delete(t);
+      const tipBlocks = [];
+      for (const [id, block] of idToBlock.entries()) {
+        if (!referencedAsReplaces.has(id) && block.content.replaces) tipBlocks.push(block);
+      }
+      for (const [id, block] of idToBlock.entries()) {
+        if (!block.content.replaces && !referencedAsReplaces.has(id)) tipBlocks.push(block);
+      }
+
+      const groups = {};
+      for (const block of tipBlocks) {
+        const ancestor = block.content.replaces || block.id;
+        if (!groups[ancestor]) groups[ancestor] = [];
+        groups[ancestor].push(block);
+      }
 
-      const blockData = await Promise.all(
-        Array.from(blocks.values()).map(async (block) => {
-          if (block.type === 'document') {
-            const url = block.content.url;
-            const validBlob = await hasBlob(ssbClient, url);
-            if (!validBlob) return null;
+      const liveTipIds = new Set();
+      for (const groupBlocks of Object.values(groups)) {
+        let best = groupBlocks[0];
+        for (const block of groupBlocks) {
+          if (block.type === 'market') {
+            const isClosedSold = s => s === 'SOLD' || s === 'CLOSED';
+            if (isClosedSold(block.content.status) && !isClosedSold(best.content.status)) {
+              best = block;
+            } else if ((block.content.status === best.content.status) && block.ts > best.ts) {
+              best = block;
+            }
+          } else if (block.type === 'job' || block.type === 'forum') {
+            if (block.ts > best.ts) best = block;
+          } else {
+            if (block.ts > best.ts) best = block;
           }
-          return block;
-        })
-      );
+        }
+        liveTipIds.add(best.id);
+      }
+
+      const blockData = Array.from(idToBlock.values()).map(block => {
+        const c = block.content;
+        const rootDeleted = c?.type === 'forum' && c.root && tombstoned.has(c.root);
+        return {
+          ...block,
+          isTombstoned: tombstoned.has(block.id),
+          isReplaced: c.replaces
+            ? (!liveTipIds.has(block.id) || tombstoned.has(block.id))
+            : referencedAsReplaces.has(block.id) || tombstoned.has(block.id) || rootDeleted
+        };
+      });
 
+      let filtered = blockData;
       if (filter === 'RECENT') {
         const now = Date.now();
-        return blockData.filter(block => now - block.ts <= 24 * 60 * 60 * 1000);
+        filtered = blockData.filter(b => b && now - b.ts <= 24 * 60 * 60 * 1000);
       }
-
       if (filter === 'MINE') {
-        const userId = SSBconfig.config.keys.id;
-        return blockData.filter(block => block.author === userId);
+        filtered = blockData.filter(b => b && b.author === config.keys.id);
       }
 
-      return blockData.filter(Boolean);
+      return filtered.filter(Boolean);
     },
 
     async getBlockById(id) {
       const ssbClient = await openSsb();
-      return await new Promise((resolve, reject) => {
+      const results = await new Promise((resolve, reject) =>
         pull(
-          ssbClient.createLogStream({ reverse: true, limit: 1000 }),
-          pull.find((msg) => msg.key === id, async (err, msg) => {
-            if (err || !msg) return resolve(null);
-            const c = msg.value?.content;
-            if (!c?.type) return resolve(null);
-            if (c.type === 'document') {
-              const url = c.url;
-              const validBlob = await hasBlob(ssbClient, url);
-              if (!validBlob) return resolve(null);
+          ssbClient.createLogStream({ reverse: true, limit: logLimit }),
+          pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+        )
+      );
+
+      const tombstoned = new Set();
+      const idToBlock = new Map();
+      const referencedAsReplaces = new Set();
+
+      for (const msg of results) {
+        const k = msg.key;
+        const c = msg.value?.content;
+        const author = msg.value?.author;
+        if (!c?.type) continue;
+        if (c.type === 'tombstone' && c.target) {
+          tombstoned.add(c.target);
+          idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
+          continue;
+        }
+        if (c.replaces) referencedAsReplaces.add(c.replaces);
+        idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
+      }
+
+      const tipBlocks = [];
+      for (const [bid, block] of idToBlock.entries()) {
+        if (!referencedAsReplaces.has(bid) && block.content.replaces) tipBlocks.push(block);
+      }
+      for (const [bid, block] of idToBlock.entries()) {
+        if (!block.content.replaces && !referencedAsReplaces.has(bid)) tipBlocks.push(block);
+      }
+
+      const groups = {};
+      for (const block of tipBlocks) {
+        const ancestor = block.content.replaces || block.id;
+        if (!groups[ancestor]) groups[ancestor] = [];
+        groups[ancestor].push(block);
+      }
+
+      const liveTipIds = new Set();
+      for (const groupBlocks of Object.values(groups)) {
+        let best = groupBlocks[0];
+        for (const block of groupBlocks) {
+          if (block.type === 'market') {
+            const isClosedSold = s => s === 'SOLD' || s === 'CLOSED';
+            if (isClosedSold(block.content.status) && !isClosedSold(best.content.status)) {
+              best = block;
+            } else if ((block.content.status === best.content.status) && block.ts > best.ts) {
+              best = block;
             }
+          } else if (block.type === 'job' || block.type === 'forum') {
+            if (block.ts > best.ts) best = block;
+          } else {
+            if (block.ts > best.ts) best = block;
+          }
+        }
+        liveTipIds.add(best.id);
+      }
 
-            resolve({
-              id: msg.key,
-              author: msg.value?.author,
-              ts: msg.value?.timestamp,
-              type: c.type,
-              content: c
-            });
-          })
-        );
-      });
+      const block = idToBlock.get(id);
+      if (!block) return null;
+      if (block.type === 'document') {
+        const valid = await hasBlob(ssbClient, block.content.url);
+        if (!valid) return null;
+      }
+
+      const c = block.content;
+      const rootDeleted = c?.type === 'forum' && c.root && tombstoned.has(c.root);
+      const isTombstoned = tombstoned.has(block.id);
+      const isReplaced = c.replaces
+        ? (!liveTipIds.has(block.id) || tombstoned.has(block.id))
+        : referencedAsReplaces.has(block.id) || tombstoned.has(block.id) || rootDeleted;
+
+      return {
+        ...block,
+        isTombstoned,
+        isReplaced
+      };
     }
   };
 };

+ 3 - 1
src/models/bookmarking_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream')
 const moment = require('../server/node_modules/moment')
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb
@@ -42,7 +44,7 @@ module.exports = ({ cooler }) => {
       const userId = ssbClient.id
       const results = await new Promise((res, rej) => {
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         )
       })

+ 38 - 13
src/models/cv_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 const extractBlobId = str => {
   if (!str || typeof str !== 'string') return null;
@@ -6,11 +8,17 @@ const extractBlobId = str => {
   return match ? match[1] : str.trim();
 };
 
-const parseCSV = str => str ? str.split(',').map(s => s.trim()).filter(Boolean) : [];
+const parseCSV = str => str
+  ? str.split(',').map(s => s.trim()).filter(Boolean)
+  : [];
 
 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;
+  };
 
   return {
     type: 'curriculum',
@@ -47,16 +55,22 @@ module.exports = ({ cooler }) => {
     async updateCV(id, data, photoBlobId) {
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
+
       const old = await new Promise((res, rej) =>
         ssbClient.get(id, (err, msg) =>
-          err || !msg?.content ? rej(err || new Error('CV not found')) : res(msg)
+          err || !msg?.content
+            ? rej(err || new Error('CV not found'))
+            : res(msg)
         )
       );
-      if (old.content.author !== userId) throw new Error('Not the author');
+
+      if (old.content.author !== userId) {
+        throw new Error('Not the author');
+      }
 
       const tombstone = {
         type: 'tombstone',
-        id,
+        target: id,
         deletedAt: new Date().toISOString()
       };
 
@@ -95,17 +109,25 @@ module.exports = ({ cooler }) => {
     async deleteCVById(id) {
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
+
       const msg = await new Promise((res, rej) =>
         ssbClient.get(id, (err, msg) =>
-          err || !msg?.content ? rej(new Error('CV not found')) : res(msg)
+          err || !msg?.content
+            ? rej(new Error('CV not found'))
+            : res(msg)
         )
       );
-      if (msg.content.author !== userId) throw new Error('Not the author');
+
+      if (msg.content.author !== userId) {
+        throw new Error('Not the author');
+      }
+
       const tombstone = {
         type: 'tombstone',
-        id,
+        target: id,
         deletedAt: new Date().toISOString()
       };
+
       return new Promise((resolve, reject) => {
         ssbClient.publish(tombstone, (err, result) => err ? reject(err) : resolve(result));
       });
@@ -115,27 +137,30 @@ module.exports = ({ cooler }) => {
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       const authorId = targetUserId || userId;
+
       return new Promise((resolve, reject) => {
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => {
             if (err) return reject(err);
 
             const tombstoned = new Set(
               msgs
-                .filter(m => m.value?.content?.type === 'tombstone' && m.value?.content?.id)
-                .map(m => m.value.content.id)
+                .filter(m => m.value?.content?.type === 'tombstone' && m.value.content.target)
+                .map(m => m.value.content.target)
             );
 
             const cvMsgs = msgs
               .filter(m =>
                 m.value?.content?.type === 'curriculum' &&
-                m.value?.content?.author === authorId &&
+                m.value.content.author === authorId &&
                 !tombstoned.has(m.key)
               )
               .sort((a, b) => b.value.timestamp - a.value.timestamp);
 
-            if (!cvMsgs.length) return resolve(null);
+            if (!cvMsgs.length) {
+              return resolve(null);
+            }
 
             const latest = cvMsgs[0];
             resolve({ id: latest.key, ...latest.value.content });

+ 3 - 1
src/models/documents_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream')
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 const extractBlobId = str => {
   if (!str || typeof str !== 'string') return null
@@ -83,7 +85,7 @@ module.exports = ({ cooler }) => {
       const userId = ssbClient.id
       const messages = await new Promise((res, rej) => {
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         )
       })

+ 20 - 14
src/models/events_model.js

@@ -1,6 +1,8 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const { config } = require('../server/SSB_server.js');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 const userId = config.keys.id;
 
@@ -162,31 +164,35 @@ module.exports = ({ cooler }) => {
       });
     },
 
-async listAll(author = null, filter = 'all') {
-  const ssbClient = await openSsb();
-  return new Promise((resolve, reject) => {
-    pull(
-      ssbClient.createLogStream(),
+    async listAll(author = null, filter = 'all') {
+      const ssbClient = await openSsb();
+      return new Promise((resolve, reject) => {
+      pull(
+      ssbClient.createLogStream({ limit: logLimit }),
       pull.collect((err, results) => {
         if (err) return reject(new Error("Error listing events: " + err.message));
         const tombstoned = new Set();
         const replaces = new Map();
         const byId = new Map();
+
         for (const r of results) {
           const k = r.key;
           const c = r.value.content;
           if (!c) continue;
+
           if (c.type === 'tombstone' && c.target) {
             tombstoned.add(c.target);
             continue;
           }
+
           if (c.type === 'event') {
-            if (tombstoned.has(k)) continue;
             if (c.replaces) replaces.set(c.replaces, k);
             if (author && c.organizer !== author) continue;
+
             let status = c.status || 'OPEN';
             const dateM = moment(c.date);
             if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED';
+
             byId.set(k, {
               id: k,
               title: c.title,
@@ -204,19 +210,19 @@ async listAll(author = null, filter = 'all') {
             });
           }
         }
-        for (const replaced of replaces.keys()) {
-          byId.delete(replaced);
-        }
+        replaces.forEach((_, oldId) => byId.delete(oldId));
+        tombstoned.forEach((id) => byId.delete(id));
+
         let out = Array.from(byId.values());
         if (filter === 'mine') out = out.filter(e => e.organizer === userId);
         if (filter === 'open') out = out.filter(e => e.status === 'OPEN');
         if (filter === 'closed') out = out.filter(e => e.status === 'CLOSED');
         resolve(out);
-      })
-    );
-  });
-}
-    
+        })
+       );
+     });
+    }
+
   };
 };
 

+ 3 - 1
src/models/feed_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -77,7 +79,7 @@ module.exports = ({ cooler }) => {
     const now = Date.now();
     const messages = await new Promise((res, rej) => {
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
     });

+ 11 - 5
src/models/forum_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb, userId;
@@ -15,7 +17,7 @@ module.exports = ({ cooler }) => {
     return new Promise((resolve, reject) => {
       const tomb = new Set();
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.filter(m => m.value.content?.type === 'tombstone' && m.value.content.target),
         pull.drain(m => tomb.add(m.value.content.target), err => err ? reject(err) : resolve(tomb))
       );
@@ -68,7 +70,8 @@ module.exports = ({ cooler }) => {
   async function getMessageById(id) {
     const ssbClient = await openSsb();
     const msgs = await new Promise((res, rej) =>
-      pull(ssbClient.createLogStream(), pull.collect((err, data) => err ? rej(err) : res(data)))
+      pull(ssbClient.createLogStream({ limit: logLimit }), 
+      pull.collect((err, data) => err ? rej(err) : res(data)))
     );
     const msg = msgs.find(m => m.key === id && m.value.content?.type === 'forum');
     if (!msg) throw new Error('Message not found');
@@ -154,7 +157,8 @@ module.exports = ({ cooler }) => {
     listAll: async filter => {
       const ssbClient = await openSsb();
       const msgs = await new Promise((res, rej) =>
-        pull(ssbClient.createLogStream(), pull.collect((err, data) => err ? rej(err) : res(data)))
+        pull(ssbClient.createLogStream({ limit: logLimit }), 
+        pull.collect((err, data) => err ? rej(err) : res(data)))
       );
       const deleted = new Set(
         msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
@@ -220,7 +224,8 @@ module.exports = ({ cooler }) => {
     getForumById: async id => {
       const ssbClient = await openSsb();
       const msgs = await new Promise((res, rej) =>
-        pull(ssbClient.createLogStream(), pull.collect((err, data) => err ? rej(err) : res(data)))
+        pull(ssbClient.createLogStream({ limit: logLimit }), 
+        pull.collect((err, data) => err ? rej(err) : res(data)))
       );
       const deleted = new Set(
         msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
@@ -241,7 +246,8 @@ module.exports = ({ cooler }) => {
     getMessagesByForumId: async forumId => {
       const ssbClient = await openSsb();
       const msgs = await new Promise((res, rej) =>
-        pull(ssbClient.createLogStream(), pull.collect((err, data) => err ? rej(err) : res(data)))
+        pull(ssbClient.createLogStream({ limit: logLimit }), 
+        pull.collect((err, data) => err ? rej(err) : res(data)))
       );
       const deleted = new Set(
         msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)

+ 38 - 31
src/models/images_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream')
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb
@@ -44,16 +46,16 @@ module.exports = ({ cooler }) => {
           const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : oldMsg.content.tags
           const match = blobMarkdown?.match(/\(([^)]+)\)/)
           const blobId = match ? match[1] : blobMarkdown
-          const updated = {
-            ...oldMsg.content,
-            replaces: id,
-            url: blobId || oldMsg.content.url,
-            tags,
-            title: title || '',
-            description: description || '',
-            meme: !!meme,
-            updatedAt: new Date().toISOString()
-          }
+	  const updated = {
+	    ...oldMsg.content,
+	    replaces: id,
+	    url: blobId || oldMsg.content.url,
+	    tags,
+	    title: title ?? oldMsg.content.title,
+	    description: description ?? oldMsg.content.description,
+	    meme: meme != null ? !!meme : !!oldMsg.content.meme,
+	    updatedAt: new Date().toISOString()
+	  }
           ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result))
         })
       })
@@ -61,19 +63,28 @@ module.exports = ({ cooler }) => {
 
     async deleteImageById(id) {
       const ssbClient = await openSsb()
-      return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'image') return reject(new Error('Image not found'))
-          if (msg.content.author !== userId) return reject(new Error('Not the author'))
-          const tombstone = {
-            type: 'tombstone',
-            target: id,
-            deletedAt: new Date().toISOString(),
-            author: userId
-          }
-          ssbClient.publish(tombstone, (err2, res) => err2 ? reject(err2) : resolve(res))
-        })
+      const author = ssbClient.id
+      const getMsg = (mid) => new Promise((resolve, reject) => {
+        ssbClient.get(mid, (err, msg) => err || !msg ? reject(new Error('Image not found')) : resolve(msg))
       })
+      const publishTomb = (target) => new Promise((resolve, reject) => {
+        ssbClient.publish({
+          type: 'tombstone',
+          target,
+          deletedAt: new Date().toISOString(),
+          author
+        }, (err, res) => err ? reject(err) : resolve(res))
+      })
+      const tip = await getMsg(id)
+      if (tip.content?.type !== 'image') throw new Error('Image not found')
+      if (tip.content.author !== author) throw new Error('Not the author')
+      let currentId = id
+      while (currentId) {
+        const msg = await getMsg(currentId)
+        await publishTomb(currentId)
+        currentId = msg.content?.replaces || null
+      }
+      return { ok: true }
     },
 
     async listAll(filter = 'all') {
@@ -81,26 +92,23 @@ module.exports = ({ cooler }) => {
       const userId = ssbClient.id
       const messages = await new Promise((res, rej) => {
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         )
       })
-
       const tombstoned = new Set(
         messages
           .filter(m => m.value.content?.type === 'tombstone')
           .map(m => m.value.content.target)
       )
-
       const replaces = new Map()
       const latest = new Map()
-
       for (const m of messages) {
         const k = m.key
         const c = m.value?.content
         if (!c || c.type !== 'image') continue
-        if (tombstoned.has(k)) continue
         if (c.replaces) replaces.set(c.replaces, k)
+        if (tombstoned.has(k)) continue
         latest.set(k, {
           key: k,
           url: c.url,
@@ -115,13 +123,13 @@ module.exports = ({ cooler }) => {
           opinions_inhabitants: c.opinions_inhabitants || []
         })
       }
-
       for (const oldId of replaces.keys()) {
         latest.delete(oldId)
       }
-
+      for (const delId of tombstoned) {
+        latest.delete(delId)
+      }
       let images = Array.from(latest.values())
-
       if (filter === 'mine') {
         images = images.filter(img => img.author === userId)
       } else if (filter === 'recent') {
@@ -138,7 +146,6 @@ module.exports = ({ cooler }) => {
       } else {
         images = images.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
       }
-
       return images
     },
 

+ 36 - 47
src/models/inhabitants_model.js

@@ -6,6 +6,8 @@ const { about, friend } = models({
   cooler: coolerInstance,
   isPublic: require('../server/ssb_config').public,
 });
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -23,11 +25,10 @@ module.exports = ({ cooler }) => {
           timeoutPromise(5000) 
         ]).catch(() => '/assets/images/default-avatar.png'); 
       };
-
       if (filter === 'GALLERY') {
         const feedIds = await new Promise((res, rej) => {
           pull(
-            ssbClient.createLogStream(),
+            ssbClient.createLogStream({ limit: logLimit }),
             pull.filter(msg => {
               const c = msg.value?.content;
               const a = msg.value?.author;
@@ -43,7 +44,6 @@ module.exports = ({ cooler }) => {
         });
 
         const uniqueFeedIds = Array.from(new Set(feedIds.map(r => r.value.author).filter(Boolean)));
-
         const users = await Promise.all(
           uniqueFeedIds.map(async (feedId) => {
             const name = await about.name(feedId);
@@ -56,14 +56,12 @@ module.exports = ({ cooler }) => {
             return { id: feedId, name, description, photo };
           })
         );
-
         return users;
       }
-
       if (filter === 'all') {
         const feedIds = await new Promise((res, rej) => {
           pull(
-            ssbClient.createLogStream(),
+            ssbClient.createLogStream({ limit: logLimit }),
             pull.filter(msg => {
               const c = msg.value?.content;
               const a = msg.value?.author;
@@ -79,7 +77,6 @@ module.exports = ({ cooler }) => {
         });
 
         const uniqueFeedIds = Array.from(new Set(feedIds.map(r => r.value.author).filter(Boolean)));
-
         const users = await Promise.all(
           uniqueFeedIds.map(async (feedId) => {
             const name = await about.name(feedId);
@@ -93,10 +90,8 @@ module.exports = ({ cooler }) => {
             return { id: feedId, name, description, photo };
           })
         );
-
         const deduplicated = Array.from(new Map(users.filter(u => u && u.id).map(u => [u.id, u])).values());
         let filtered = deduplicated;
-
         if (search) {
           const q = search.toLowerCase();
           filtered = filtered.filter(u =>
@@ -105,10 +100,8 @@ module.exports = ({ cooler }) => {
             u.id?.toLowerCase().includes(q)
           );
         }
-
         return filtered;
       }
-
       if (filter === 'contacts') {
         const all = await this.listInhabitants({ filter: 'all' });
         const result = [];
@@ -118,7 +111,6 @@ module.exports = ({ cooler }) => {
         }
         return Array.from(new Map(result.map(u => [u.id, u])).values());
       }
-
       if (filter === 'blocked') {
         const all = await this.listInhabitants({ filter: 'all' });
         const result = [];
@@ -128,7 +120,6 @@ module.exports = ({ cooler }) => {
         }
         return Array.from(new Map(result.map(u => [u.id, u])).values());
       }
-
       if (filter === 'SUGGESTED') {
         const all = await this.listInhabitants({ filter: 'all' });
         const result = [];
@@ -143,11 +134,10 @@ module.exports = ({ cooler }) => {
         return Array.from(new Map(result.map(u => [u.id, u])).values())
           .sort((a, b) => (b.mutualCount || 0) - (a.mutualCount || 0));
       }
-
       if (filter === 'CVs' || filter === 'MATCHSKILLS') {
         const records = await new Promise((res, rej) => {
           pull(
-            ssbClient.createLogStream(),
+            ssbClient.createLogStream({ limit: logLimit }),
             pull.filter(msg =>
               msg.value.content?.type === 'curriculum' &&
               msg.value.content?.type !== 'tombstone'
@@ -180,7 +170,6 @@ module.exports = ({ cooler }) => {
           }
           return cvs;
         }
-
         if (filter === 'MATCHSKILLS') {
           const cv = await this.getCVByUserId();
           const userSkills = cv
@@ -196,12 +185,12 @@ module.exports = ({ cooler }) => {
             if (c.id === userId) return null;
             const common = c.skills.map(s => s.toLowerCase()).filter(s => userSkills.includes(s));
             if (!common.length) return null;
-            return { ...c, commonSkills: common };
+            const matchScore = common.length / userSkills.length;
+            return { ...c, commonSkills: common, matchScore };
           }).filter(Boolean);
-          return matches.sort((a, b) => b.commonSkills.length - a.commonSkills.length);
+          return matches.sort((a, b) => b.matchScore - a.matchScore);
         }
       }
-
       return [];
     },
 
@@ -229,26 +218,10 @@ module.exports = ({ cooler }) => {
         createdAt: c.createdAt
       };
     },
-
-    async getCVByUserId(id) {
-      const ssbClient = await openSsb();
-      const targetId = id || ssbClient.id;
-      const records = await new Promise((res, rej) => {
-        pull(
-          ssbClient.createUserStream({ id: targetId }),
-          pull.filter(msg =>
-            msg.value.content?.type === 'curriculum' &&
-            msg.value.content?.type !== 'tombstone'
-          ),
-          pull.collect((err, msgs) => err ? rej(err) : res(msgs))
-        );
-      });
-      return records.length ? records[records.length - 1].value.content : null;
-    },
-
-    async _getLatestAboutById(id) {
-      const ssbClient = await openSsb();
-      const records = await new Promise((res, rej) => {
+    
+      async getLatestAboutById(id) {
+        const ssbClient = await openSsb();
+        const records = await new Promise((res, rej) => {
         pull(
           ssbClient.createUserStream({ id }),
           pull.filter(msg =>
@@ -262,26 +235,42 @@ module.exports = ({ cooler }) => {
       const latest = records.sort((a, b) => b.value.timestamp - a.value.timestamp)[0];
       return latest.value.content;
     },
-
+    
     async getFeedByUserId(id) {
+      const ssbClient = await openSsb();
+      const targetId = id || ssbClient.id;
+      const records = await new Promise((res, rej) => {
+      pull(
+      ssbClient.createUserStream({ id: targetId }),
+      pull.filter(msg =>
+        msg.value &&
+        msg.value.content &&
+        typeof msg.value.content.text === 'string' &&
+        msg.value.content?.type !== 'tombstone'
+      ),
+      pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+      );
+    });
+    return records
+    .filter(m => typeof m.value.content.text === 'string')
+    .sort((a, b) => b.value.timestamp - a.value.timestamp)
+    .slice(0, 10);
+    },
+
+    async getCVByUserId(id) {
       const ssbClient = await openSsb();
       const targetId = id || ssbClient.id;
       const records = await new Promise((res, rej) => {
         pull(
           ssbClient.createUserStream({ id: targetId }),
           pull.filter(msg =>
-            msg.value &&
-            msg.value.content &&
-            typeof msg.value.content.text === 'string' &&
+            msg.value.content?.type === 'curriculum' &&
             msg.value.content?.type !== 'tombstone'
           ),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         );
       });
-      return records
-        .filter(m => typeof m.value.content.text === 'string')
-        .sort((a, b) => b.value.timestamp - a.value.timestamp)
-        .slice(0, 10);
+      return records.length ? records[records.length - 1].value.content : null;
     }
   };
 };

+ 209 - 0
src/models/jobs_model.js

@@ -0,0 +1,209 @@
+const pull = require('../server/node_modules/pull-stream')
+const moment = require('../server/node_modules/moment')
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
+
+module.exports = ({ cooler }) => {
+  let ssb
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open()
+    return ssb
+  }
+
+  return {
+    type: 'job',
+
+    async createJob(jobData) {
+      const ssbClient = await openSsb()
+      let blobId = jobData.image
+      if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1]
+      const content = {
+        type: 'job',
+        ...jobData,
+        image: blobId,
+        author: ssbClient.id,
+        createdAt: new Date().toISOString(),
+        status: 'OPEN',
+        subscribers: []
+      }
+      return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
+    },
+
+    async updateJob(id, jobData) {
+      const ssbClient = await openSsb()
+      const job = await this.getJobById(id)
+      if (job.author !== ssbClient.id) throw new Error('Unauthorized')
+      let blobId = jobData.image || job.image
+      if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1]
+      const tomb = { type: 'tombstone', target: job.id, deletedAt: new Date().toISOString(), author: ssbClient.id }
+      const updated = {
+        type: 'job',
+        ...job,
+        ...jobData,
+        image: blobId,
+        updatedAt: new Date().toISOString(),
+        replaces: job.id
+      }
+      await new Promise((res, rej) => ssbClient.publish(tomb, e => e ? rej(e) : res()))
+      return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => e ? rej(e) : res(m)))
+    },
+
+    async updateJobStatus(id, status) {
+      return this.updateJob(id, { status })
+    },
+
+    async deleteJob(id) {
+      const ssbClient = await openSsb();
+      const latestId = await this.getJobTipId(id);
+      const job = await this.getJobById(latestId);
+      if (job.author !== ssbClient.id) throw new Error('Unauthorized');
+      const tomb = {
+        type: 'tombstone',
+        target: latestId,
+        deletedAt: new Date().toISOString(),
+        author: ssbClient.id
+      };
+      return new Promise((res, rej) =>
+        ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r))
+      );
+    },
+
+    async listJobs(filter) {
+      const ssbClient = await openSsb();
+      const currentUserId = ssbClient.id;
+      return new Promise((res, rej) => {
+      pull(
+      ssbClient.createLogStream({ limit: logLimit }),
+      pull.collect((e, msgs) => {
+        if (e) return rej(e);
+        const tomb = new Set();
+        const replaces = new Map();
+        const referencedAsReplaces = new Set();
+        const jobs = new Map();
+        msgs.forEach(m => {
+          const k = m.key;
+          const c = m.value.content;
+          if (!c) return;
+          if (c.type === 'tombstone' && c.target) { tomb.add(c.target); return; }
+          if (c.type !== 'job') return;
+          if (c.replaces) { replaces.set(c.replaces, k); referencedAsReplaces.add(c.replaces); }
+          jobs.set(k, { key: k, content: c });
+        });
+        const tipJobs = [];
+        for (const [id, job] of jobs.entries()) {
+          if (!referencedAsReplaces.has(id)) tipJobs.push(job);
+        }
+        const groups = {};
+        for (const job of tipJobs) {
+          const ancestor = job.content.replaces || job.key;
+          if (!groups[ancestor]) groups[ancestor] = [];
+          groups[ancestor].push(job);
+        }
+
+        const liveTipIds = new Set();
+        for (const groupJobs of Object.values(groups)) {
+          let best = groupJobs[0];
+          for (const job of groupJobs) {
+            if (
+              job.content.status === 'CLOSED' ||
+              (best.content.status !== 'CLOSED' &&
+               new Date(job.content.updatedAt || job.content.createdAt || 0) >
+               new Date(best.content.updatedAt || best.content.createdAt || 0))
+            ) {
+              best = job;
+            }
+          }
+          liveTipIds.add(best.key);
+        }
+        let list = Array.from(jobs.values())
+          .filter(j => liveTipIds.has(j.key) && !tomb.has(j.key))
+          .map(j => ({ id: j.key, ...j.content }));
+        const F = String(filter).toUpperCase();
+        if (F === 'MINE')           list = list.filter(j => j.author === currentUserId);
+        else if (F === 'REMOTE')    list = list.filter(j => (j.location||'').toUpperCase() === 'REMOTE');
+        else if (F === 'PRESENCIAL')list = list.filter(j => (j.location||'').toUpperCase() === 'PRESENCIAL');
+        else if (F === 'FREELANCER')list = list.filter(j => (j.job_type||'').toUpperCase() === 'FREELANCER');
+        else if (F === 'EMPLOYEE')  list = list.filter(j => (j.job_type||'').toUpperCase() === 'EMPLOYEE');
+        else if (F === 'OPEN')      list = list.filter(j => (j.status||'').toUpperCase() === 'OPEN');
+        else if (F === 'CLOSED')    list = list.filter(j => (j.status||'').toUpperCase() === 'CLOSED');
+        else if (F === 'RECENT')    list = list.filter(j => moment(j.createdAt).isAfter(moment().subtract(24, 'hours')));
+        if (F === 'TOP') list.sort((a, b) => parseFloat(b.salary||0) - parseFloat(a.salary||0));
+        else list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+        res(list);
+        })
+        );
+      });
+    },
+
+    async getJobById(id) {
+      const ssbClient = await openSsb();
+      const all = await new Promise((r, j) => {
+        pull(
+            ssbClient.createLogStream({ limit: logLimit }),
+            pull.collect((e, m) => e ? j(e) : r(m))
+        )
+      });
+      const tomb = new Set();
+      const replaces = new Map();
+      all.forEach(m => {
+        const c = m.value.content;
+        if (!c) return;
+        if (c.type === 'tombstone' && c.target) {
+            tomb.add(c.target);
+        } else if (c.type === 'job' && c.replaces) {
+            replaces.set(c.replaces, m.key);
+        }
+      });
+      let key = id;
+      while (replaces.has(key)) key = replaces.get(key);
+      if (tomb.has(key)) throw new Error('Job not found');
+      const msg = await new Promise((r, j) => ssbClient.get(key, (e, m) => e ? j(e) : r(m)));
+      if (!msg) throw new Error('Job not found');
+      return { id: key, ...msg.content };
+    },
+    
+    async getJobTipId(id) {
+      const ssbClient = await openSsb();
+      const all = await new Promise((r, j) => {
+        pull(
+            ssbClient.createLogStream({ limit: logLimit }),
+            pull.collect((e, m) => e ? j(e) : r(m))
+        )
+    });
+      const tomb = new Set();
+      const replaces = new Map();
+      all.forEach(m => {
+        const c = m.value.content;
+        if (!c) return;
+        if (c.type === 'tombstone' && c.target) {
+            tomb.add(c.target);
+        } else if (c.type === 'job' && c.replaces) {
+            replaces.set(c.replaces, m.key);
+        }
+    });
+    let key = id;
+    while (replaces.has(key)) key = replaces.get(key);
+    if (tomb.has(key)) throw new Error('Job not found');
+      return key;
+    },
+
+    async subscribeToJob(id, userId) {
+      const latestId = await this.getJobTipId(id);
+      const job = await this.getJobById(latestId);
+      if (!job.subscribers) job.subscribers = [];
+      if (job.subscribers.includes(userId)) throw new Error('Already subscribed');
+      job.subscribers.push(userId);
+      return this.updateJob(latestId, { subscribers: job.subscribers });
+    },
+
+    async unsubscribeFromJob(id, userId) {
+      const latestId = await this.getJobTipId(id);
+      const job = await this.getJobById(latestId);
+      if (!job.subscribers) job.subscribers = [];
+      if (!job.subscribers.includes(userId)) throw new Error('Not subscribed');
+      job.subscribers = job.subscribers.filter(uid => uid !== userId);
+      return this.updateJob(latestId, { subscribers: job.subscribers });
+    }
+    
+  }
+}

+ 5 - 2
src/models/main_models.js

@@ -15,6 +15,9 @@ const os = require('os');
 
 const ssbRef = require("../server/node_modules/ssb-ref");
 
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
+
 const isEncrypted = (message) => typeof message.value.content === "string";
 const isNotEncrypted = (message) => isEncrypted(message) === false;
 
@@ -583,7 +586,7 @@ models.meta = {
     query,
     filter = null,
   }) => {
-    const source = ssb.createLogStream({ reverse: true, limit: 100 });
+    const source = ssb.createLogStream({ reverse: true,  limit: logLimit });
 
     return new Promise((resolve, reject) => {
       pull(
@@ -1705,7 +1708,7 @@ const post = {
       const myFeedId = ssb.id;
       const rawMessages = await new Promise((resolve, reject) => {
         pull(
-          ssb.createLogStream({ reverse: true, limit: 1000 }),
+          ssb.createLogStream({ reverse: true, limit: logLimit }),
           pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
         );
       });

+ 305 - 259
src/models/market_model.js

@@ -1,5 +1,12 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
+
+const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_');
+const D = s => ({FOR_SALE:'FOR SALE',OPEN:'OPEN',RESERVED:'RESERVED',CLOSED:'CLOSED',SOLD:'SOLD'})[s] || (s ? s.replace(/_/g,' ') : s);
+const ORDER = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD'];
+const OI = s => ORDER.indexOf(N(s));
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -8,61 +15,69 @@ module.exports = ({ cooler }) => {
   return {
     type: 'market',
 
-  async createItem(item_type, title, description, image, price, tagsRaw = [], item_status, deadline, includesShipping = false, stock = 0) {
-    const ssbClient = await openSsb();
-    const userId = ssbClient.id;
-    const formattedDeadline = deadline ? moment(deadline, moment.ISO_8601, true).toISOString() : null;
-    let blobId = null;
-    if (image) {
-      const match = image.match(/\(([^)]+)\)/);
-      blobId = match ? match[1] : image;
-    }
-    const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
-    const itemContent = {
-      type: "market",
-      item_type,
-      title,
-      description,
-      image: blobId,
-      price: parseFloat(price).toFixed(6),
-      tags,
-      item_status,
-      status: 'FOR SALE',
-      deadline: formattedDeadline,
-      includesShipping,
-      stock,
-      createdAt: new Date().toISOString(),
-      updatedAt: new Date().toISOString(),
-      seller: userId,
-      auctions_poll: []
-    };
-    return new Promise((resolve, reject) => {
-      ssbClient.publish(itemContent, (err, res) => err ? reject(err) : resolve(res));
-    });
-  },
-
-   async updateItemById(itemId, updatedData) {
+    async createItem(item_type, title, description, image, price, tagsRaw = [], item_status, deadline, includesShipping = false, stock = 0) {
+      const ssbClient = await openSsb();
+      const formattedDeadline = deadline ? moment(deadline, moment.ISO_8601, true).toISOString() : null;
+      let blobId = null;
+      if (image) {
+        const match = image.match(/\(([^)]+)\)/);
+        blobId = match ? match[1] : image;
+      }
+      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(',').map(t => t.trim()).filter(Boolean);
+      const itemContent = {
+        type: "market",
+        item_type,
+        title,
+        description,
+        image: blobId,
+        price: parseFloat(price).toFixed(6),
+        tags,
+        item_status,
+        status: 'FOR SALE',
+        deadline: formattedDeadline,
+        includesShipping,
+        stock,
+        createdAt: new Date().toISOString(),
+        updatedAt: new Date().toISOString(),
+        seller: ssbClient.id,
+        auctions_poll: []
+      };
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(itemContent, (err, res) => err ? reject(err) : resolve(res));
+      });
+    },
+
+    async resolveCurrentId(itemId) {
+      const ssbClient = await openSsb();
+      const messages = await new Promise((resolve, reject) =>
+        pull(
+          ssbClient.createLogStream({ limit: logLimit }),
+          pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+        )
+      );
+      const fwd = new Map();
+      for (const m of messages) {
+        const c = m.value?.content;
+        if (!c || c.type !== 'market') continue;
+        if (c.replaces) fwd.set(c.replaces, m.key);
+      }
+      let cur = itemId;
+      while (fwd.has(cur)) cur = fwd.get(cur);
+      return cur;
+    },
+
+    async updateItemById(itemId, updatedData) {
+      const tipId = await this.resolveCurrentId(itemId);
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       return new Promise((resolve, reject) => {
-        ssbClient.get(itemId, (err, item) => {
+        ssbClient.get(tipId, (err, item) => {
           if (err || !item?.content) return reject(new Error("Item not found"));
           if (item.content.seller !== userId) return reject(new Error("Not the seller"));
-          if (['SOLD', 'DISCARDED'].includes(item.content.status)) return reject(new Error("Cannot update this item"));
-          const updated = {
-            ...item.content,
-            ...updatedData,
-            tags: updatedData.tags || item.content.tags,
-            updatedAt: new Date().toISOString(),
-            replaces: itemId
-          };
-          const tombstone = {
-            type: 'tombstone',
-            target: itemId,
-            deletedAt: new Date().toISOString(),
-            author: userId
-          };
-          ssbClient.publish(tombstone, (err) => {
+          if (['SOLD','DISCARDED'].includes(D(N(item.content.status)))) return reject(new Error("Cannot update this item"));
+          const updated = { ...item.content, ...updatedData, tags: updatedData.tags || item.content.tags, updatedAt: new Date().toISOString(), replaces: tipId };
+          const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
+          ssbClient.publish(tombstone, err => {
             if (err) return reject(err);
             ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
           });
@@ -71,18 +86,14 @@ module.exports = ({ cooler }) => {
     },
 
     async deleteItemById(itemId) {
+      const tipId = await this.resolveCurrentId(itemId);
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       return new Promise((resolve, reject) => {
-        ssbClient.get(itemId, (err, item) => {
+        ssbClient.get(tipId, (err, item) => {
           if (err || !item?.content) return reject(new Error("Item not found"));
           if (item.content.seller !== userId) return reject(new Error("Not the seller"));
-          const tombstone = {
-            type: 'tombstone',
-            target: itemId,
-            deletedAt: new Date().toISOString(),
-            author: userId
-          };
+          const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
           ssbClient.publish(tombstone, (err) => err ? reject(err) : resolve({ message: "Item deleted successfully" }));
         });
       });
@@ -91,107 +102,171 @@ module.exports = ({ cooler }) => {
     async listAllItems(filter = 'all') {
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
-      return new Promise((resolve, reject) => {
+      const messages = await new Promise((resolve, reject) =>
         pull(
-          ssbClient.createLogStream(),
-          pull.collect(async (err, results) => {
-            if (err) return reject(new Error("Error listing items: " + err.message));
-            const tombstoned = new Set();
-            const replaces = new Map();
-            const itemsById = new Map();
-            const now = moment();
-            for (const r of results) {
-              const k = r.key;
-              const c = r.value.content;
-              if (!c) continue;
-              if (c.type === 'tombstone' && c.target) {
-                tombstoned.add(c.target);
-                continue;
-              }
-              if (c.type === 'market') {
-                if (tombstoned.has(k)) continue;
-                if (c.replaces) replaces.set(c.replaces, k);
-                let status = c.status || 'FOR SALE';
-                if (c.deadline) {
-                  const deadline = moment(c.deadline);
-                  if (deadline.isValid() && deadline.isBefore(now) && status !== 'SOLD') {
-                    status = 'DISCARDED';
-                  }
-                }
-                if (c.stock === 0 && c.status === 'FOR SALE') continue;
-                itemsById.set(k, {
-                  id: k,
-                  title: c.title,
-                  description: c.description,
-                  image: c.image,
-                  price: c.price,
-                  tags: c.tags || [],
-                  item_status: c.item_status || 'NEW',
-                  status,
-                  createdAt: c.createdAt,
-                  updatedAt: c.updatedAt,
-                  seller: c.seller,
-                  includesShipping: c.includesShipping || false,
-                  stock: c.stock || 0,
-                  deadline: c.deadline,
-                  auctions_poll: c.auctions_poll || [],
-                  item_type: c.item_type
-                });
-              }
-            }
-            for (const replacedId of replaces.keys()) {
-              itemsById.delete(replacedId);
-            }
-            let filteredItems = Array.from(itemsById.values());
-            switch (filter) {
-              case 'mine':
-                filteredItems = filteredItems.filter(e => e.seller === userId);
-                break;
-              case 'exchange':
-                filteredItems = filteredItems.filter(e => e.item_type === 'exchange' && e.status === 'FOR SALE');
-                break;
-              case 'auctions':
-                filteredItems = filteredItems.filter(e => e.item_type === 'auction' && e.status === 'FOR SALE');
-                break;
-              case 'new':
-                filteredItems = filteredItems.filter(e => e.item_status === 'NEW' && e.status === 'FOR SALE');
-                break;
-              case 'used':
-                filteredItems = filteredItems.filter(e => e.item_status === 'USED' && e.status === 'FOR SALE');
-                break;
-              case 'broken':
-                filteredItems = filteredItems.filter(e => e.item_status === 'BROKEN' && e.status === 'FOR SALE');
-                break;
-              case 'for sale':
-                filteredItems = filteredItems.filter(e => e.status === 'FOR SALE');
-                break;
-              case 'sold':
-                filteredItems = filteredItems.filter(e => e.status === 'SOLD');
-                break;
-              case 'discarded':
-                filteredItems = filteredItems.filter(e => e.status === 'DISCARDED');
-                break;
-              default:
-                break;
-            }
-            filteredItems = filteredItems.filter(item => !(item.status === 'FOR SALE' && item.stock === 0));
-            filteredItems = filteredItems.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-            resolve(filteredItems);
-          })
-        );
-      });
+          ssbClient.createLogStream({ limit: logLimit }),
+          pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+        )
+      );
+
+      const tomb = new Set();
+      const nodes = new Map();
+      const parent = new Map();
+      const child = new Map();
+
+      for (const m of messages) {
+        const k = m.key;
+        const c = m.value?.content;
+        if (!c) continue;
+        if (c.type === 'tombstone' && c.target) { tomb.add(c.target); continue; }
+        if (c.type !== 'market') continue;
+        nodes.set(k, { key: k, ts: m.value.timestamp, c });
+        if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k); }
+      }
+
+      const rootOf = id => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur; };
+      const groups = new Map();
+      for (const id of nodes.keys()) {
+        const r = rootOf(id);
+        if (!groups.has(r)) groups.set(r, new Set());
+        groups.get(r).add(id);
+      }
+
+      const items = [];
+      for (const [root, ids] of groups.entries()) {
+        let tip = Array.from(ids).find(id => !child.has(id)) || Array.from(ids).reduce((a,b)=> nodes.get(a).ts>nodes.get(b).ts?a:b);
+        if (tomb.has(tip)) continue;
+
+        let best = nodes.get(tip);
+        let bestS = N(best.c.status || 'FOR_SALE');
+        for (const id of ids) {
+          const s = N(nodes.get(id).c.status);
+          if (OI(s) > OI(bestS)) { best = nodes.get(id); bestS = s; }
+        }
+
+        const c = best.c;
+        let status = D(bestS);
+        if (c.deadline) {
+          const dl = moment(c.deadline);
+          if (dl.isValid() && dl.isBefore(moment()) && status !== 'SOLD') status = 'DISCARDED';
+        }
+        if (status === 'FOR SALE' && (c.stock || 0) === 0) continue;
+
+        items.push({
+          id: tip,
+          title: c.title,
+          description: c.description,
+          image: c.image,
+          price: c.price,
+          tags: c.tags || [],
+          item_type: c.item_type,
+          item_status: c.item_status || 'NEW',
+          status,
+          createdAt: c.createdAt || best.ts,
+          updatedAt: c.updatedAt,
+          seller: c.seller,
+          includesShipping: !!c.includesShipping,
+          stock: c.stock || 0,
+          deadline: c.deadline || null,
+          auctions_poll: c.auctions_poll || []
+        });
+      }
+
+      let list = items;
+      switch (filter) {
+        case 'mine':       list = list.filter(i => i.seller === userId); break;
+        case 'exchange':   list = list.filter(i => i.item_type === 'exchange' && i.status === 'FOR SALE'); break;
+        case 'auctions':   list = list.filter(i => i.item_type === 'auction'  && i.status === 'FOR SALE'); break;
+        case 'new':        list = list.filter(i => i.item_status === 'NEW'    && i.status === 'FOR SALE'); break;
+        case 'used':       list = list.filter(i => i.item_status === 'USED'   && i.status === 'FOR SALE'); break;
+        case 'broken':     list = list.filter(i => i.item_status === 'BROKEN' && i.status === 'FOR SALE'); break;
+        case 'for sale':   list = list.filter(i => i.status === 'FOR SALE'); break;
+        case 'sold':       list = list.filter(i => i.status === 'SOLD'); break;
+        case 'discarded':  list = list.filter(i => i.status === 'DISCARDED'); break;
+        case 'recent':
+          const oneDayAgo = moment().subtract(1, 'days');
+          list = list.filter(i => i.status === 'FOR SALE' && moment(i.createdAt).isAfter(oneDayAgo));
+          break;
+      }
+
+      return list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+    },
+
+    async getItemById(itemId) {
+      const ssbClient = await openSsb();
+      const messages = await new Promise((resolve, reject) =>
+        pull(
+          ssbClient.createLogStream({ limit: logLimit }),
+          pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+        )
+      );
+
+      const nodes = new Map();
+      const parent = new Map();
+      const child = new Map();
+      for (const m of messages) {
+        const k = m.key;
+        const c = m.value?.content;
+        if (!c || c.type !== 'market') continue;
+        nodes.set(k, { key: k, ts: m.value.timestamp, c });
+        if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k); }
+      }
+
+      let tip = itemId;
+      while (child.has(tip)) tip = child.get(tip);
+
+      const ids = new Set();
+      let cur = tip;
+      ids.add(cur);
+      while (parent.has(cur)) { cur = parent.get(cur); ids.add(cur); }
+
+      let best = nodes.get(tip) || (await new Promise(resolve => ssbClient.get(tip, (e, m) => resolve(m ? { key: tip, ts: m.timestamp, c: m.content } : null))));
+      if (!best) return null;
+      let bestS = N(best.c.status || 'FOR_SALE');
+      for (const id of ids) {
+        const n = nodes.get(id);
+        if (!n) continue;
+        const s = N(n.c.status);
+        if (OI(s) > OI(bestS)) { best = n; bestS = s; }
+      }
+
+      const c = best.c;
+      let status = D(bestS);
+      if (c.deadline) {
+        const dl = moment(c.deadline);
+        if (dl.isValid() && dl.isBefore(moment()) && status !== 'SOLD') status = 'DISCARDED';
+      }
+
+      return {
+        id: tip,
+        title: c.title,
+        description: c.description,
+        image: c.image,
+        price: c.price,
+        tags: c.tags || [],
+        item_type: c.item_type,
+        item_status: c.item_status,
+        status,
+        createdAt: c.createdAt || best.ts,
+        updatedAt: c.updatedAt,
+        seller: c.seller,
+        includesShipping: c.includesShipping,
+        stock: c.stock,
+        deadline: c.deadline,
+        auctions_poll: c.auctions_poll || []
+      };
     },
 
     async checkAuctionItemsStatus(items) {
       const now = new Date().toISOString();
       for (let item of items) {
         if ((item.item_type === 'auction' || item.item_type === 'exchange') && item.deadline && now > item.deadline) {
-          if (['SOLD', 'DISCARDED'].includes(item.status)) continue;
+          if (['SOLD','DISCARDED'].includes(D(N(item.status)))) continue;
           let status = item.status;
           if (item.item_type === 'auction') {
-            const highestBid = item.auctions_poll.reduce((prev, curr) => {
-              const [_, bidAmount] = curr.split(':');
-              return parseFloat(bidAmount) > prev ? parseFloat(bidAmount) : prev;
+            const highestBid = (item.auctions_poll || []).reduce((prev, curr) => {
+              const parts = String(curr).split(':'); const bidAmount = parseFloat(parts[1] || 0);
+              return bidAmount > prev ? bidAmount : prev;
             }, 0);
             status = highestBid > 0 ? 'SOLD' : 'DISCARDED';
           } else if (item.item_type === 'exchange') {
@@ -202,30 +277,65 @@ module.exports = ({ cooler }) => {
       }
     },
 
-  async setItemAsSold(itemId) {
+    async setItemAsSold(itemId) {
+      const tipId = await this.resolveCurrentId(itemId);
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       return new Promise((resolve, reject) => {
-        ssbClient.get(itemId, (err, item) => {
+        ssbClient.get(tipId, (err, item) => {
           if (err || !item?.content) return reject(new Error("Item not found"));
-          if (['SOLD', 'DISCARDED'].includes(item.content.status)) return reject(new Error("Already sold/discarded"));
+          if (['SOLD','DISCARDED'].includes(String(item.content.status).toUpperCase().replace(/\s+/g,'_')))
+            return reject(new Error("Already sold/discarded"));
           if (item.content.stock <= 0) return reject(new Error("Out of stock"));
 
-          const updated = {
+          const soldMsg = {
             ...item.content,
             stock: 0,
             status: 'SOLD',
             updatedAt: new Date().toISOString(),
-            replaces: itemId
+            replaces: tipId
           };
+          const tomb1 = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
 
-          const tombstone = {
-            type: 'tombstone',
-            target: itemId,
-            deletedAt: new Date().toISOString(),
-            author: userId
-          };
+          ssbClient.publish(tomb1, err => {
+            if (err) return reject(err);
+            ssbClient.publish(soldMsg, (err2, soldRes) => {
+              if (err2) return reject(err2);
+
+              const touchMsg = {
+                ...soldMsg,
+                updatedAt: new Date().toISOString(),
+                replaces: soldRes.key
+              };
+              const tomb2 = { type: 'tombstone', target: soldRes.key, deletedAt: new Date().toISOString(), author: userId };
 
+              ssbClient.publish(tomb2, err3 => {
+                if (err3) return reject(err3);
+                ssbClient.publish(touchMsg, (err4, finalRes) => err4 ? reject(err4) : resolve(finalRes));
+              });
+            });
+          });
+        });
+      });
+    },
+
+    async addBidToAuction(itemId, userId, bidAmount) {
+      const tipId = await this.resolveCurrentId(itemId);
+      const ssbClient = await openSsb();
+      return new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Item not found"));
+          if (item.content.item_type !== 'auction') return reject(new Error("Not an auction"));
+          if (item.content.seller === userId) return reject(new Error("Cannot bid on your own item"));
+          if (parseFloat(bidAmount) <= parseFloat(item.content.price)) return reject(new Error("Bid too low"));
+          const highestBid = (item.content.auctions_poll || []).reduce((prev, curr) => {
+            const parts = String(curr).split(':'); const bid = parseFloat(parts[1] || 0);
+            return Math.max(prev, bid);
+          }, 0);
+          if (parseFloat(bidAmount) <= highestBid) return reject(new Error("Bid not highest"));
+          const bid = `${userId}:${bidAmount}:${new Date().toISOString()}`;
+          const updated = { ...item.content, auctions_poll: [...(item.content.auctions_poll || []), bid], stock: item.content.stock - 1, updatedAt: new Date().toISOString(), replaces: tipId };
+          const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
           ssbClient.publish(tombstone, (err) => {
             if (err) return reject(err);
             ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
@@ -234,113 +344,49 @@ module.exports = ({ cooler }) => {
       });
     },
 
-    async getItemById(itemId) {
+    async decrementStock(itemId) {
+      const tipId = await this.resolveCurrentId(itemId);
       const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(itemId, async (err, item) => {
+        ssbClient.get(tipId, (err, item) => {
           if (err || !item?.content) return reject(new Error("Item not found"));
-          const c = item.content;
-          let status = c.status || 'FOR SALE';
-          if (c.deadline) {
-            const deadlineMoment = moment(c.deadline);
-            if (deadlineMoment.isValid() && deadlineMoment.isBefore(moment()) && status !== 'SOLD') {
-              status = 'DISCARDED';
-              const tombstone = {
-                type: 'tombstone',
-                target: itemId,
-                deletedAt: new Date().toISOString(),
-                author: c.seller
-              };
-              const updated = { ...c, status, updatedAt: new Date().toISOString() };
-              await ssbClient.publish(tombstone);
-              await ssbClient.publish(updated);
-            }
+
+          const curStatus = String(item.content.status).toUpperCase().replace(/\s+/g,'_');
+          if (['SOLD','DISCARDED'].includes(curStatus)) {
+            return resolve({ ok: true, noop: true });
           }
-          resolve({
-            id: itemId,
-            title: c.title,
-            description: c.description,
-            price: c.price,
-            stock: c.stock,
-            status,
-            item_status: c.item_status,
-            seller: c.seller,
-            createdAt: c.createdAt,
-            updatedAt: c.updatedAt,
-            image: c.image || null,
-            tags: c.tags || [],
-            includesShipping: c.includesShipping,
-            deadline: c.deadline,
-            auctions_poll: c.auctions_poll || [],
-            item_type: c.item_type
+
+          const current = Number(item.content.stock) || 0;
+          if (current <= 0) {
+            return resolve({ ok: true, noop: true });
+          }
+
+          const newStock = current - 1;
+          const updated = {
+            ...item.content,
+            stock: newStock,
+            status: newStock === 0 ? 'SOLD' : item.content.status,
+            updatedAt: new Date().toISOString(),
+            replaces: tipId
+          };
+
+          const tombstone = {
+            type: 'tombstone',
+            target: tipId,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          };
+
+          ssbClient.publish(tombstone, (e1) => {
+            if (e1) return reject(e1);
+            ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res));
           });
         });
       });
-    },
+    }
     
-  async addBidToAuction(itemId, userId, bidAmount) {
-    const ssbClient = await openSsb();
-    return new Promise((resolve, reject) => {
-    ssbClient.get(itemId, (err, item) => {
-      if (err || !item?.content) return reject(new Error("Item not found"));
-      if (item.content.item_type !== 'auction') return reject(new Error("Not an auction"));
-      if (item.content.seller === userId) return reject(new Error("Cannot bid on your own item"));
-      if (parseFloat(bidAmount) <= parseFloat(item.content.price)) return reject(new Error("Bid too low"));
-      const highestBid = item.content.auctions_poll.reduce((prev, curr) => {
-        const [_, bid] = curr.split(':');
-        return Math.max(prev, parseFloat(bid));
-      }, 0);
-      if (parseFloat(bidAmount) <= highestBid) return reject(new Error("Bid not highest"));
-      const bid = `${userId}:${bidAmount}:${new Date().toISOString()}`;
-      const updated = {
-        ...item.content,
-        auctions_poll: [...(item.content.auctions_poll || []), bid],
-        stock: item.content.stock - 1,
-        updatedAt: new Date().toISOString(),
-        replaces: itemId
-      };
-      const tombstone = {
-        type: 'tombstone',
-        target: itemId,
-        deletedAt: new Date().toISOString(),
-        author: userId
-      };
-      ssbClient.publish(tombstone, (err) => {
-        if (err) return reject(err);
-        ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
-      });
-     });
-    });
-  },
-  
-  async decrementStock(itemId) {
-    const ssbClient = await openSsb();
-    const userId = ssbClient.id;
-    return new Promise((resolve, reject) => {
-      ssbClient.get(itemId, (err, item) => {
-        if (err || !item?.content) return reject(new Error("Item not found"));
-        if (item.content.stock <= 0) return reject(new Error("No stock left"));
-        const updated = {
-          ...item.content,
-          stock: item.content.stock - 1,
-          updatedAt: new Date().toISOString(),
-          replaces: itemId
-        };
-
-        const tombstone = {
-          type: 'tombstone',
-          target: itemId,
-          deletedAt: new Date().toISOString(),
-          author: userId
-        };
-        ssbClient.publish(tombstone, (err) => {
-          if (err) return reject(err);
-          ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
-        });
-      });
-    });
-  }
-  
   };
 };
 

+ 45 - 1
src/models/opinions_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -71,7 +73,7 @@ module.exports = ({ cooler }) => {
     const userId = ssbClient.id;
     const messages = await new Promise((res, rej) => {
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
     });
@@ -122,6 +124,48 @@ module.exports = ({ cooler }) => {
       })
     );
     filtered = filtered.filter(Boolean);
+    const signatureOf = (m) => {
+    const c = m.value?.content || {};
+    switch (c.type) {
+      case 'document':
+      case 'image':
+      case 'audio':
+      case 'video':
+        return `${c.type}::${(c.url || '').trim()}`;
+      case 'bookmark': {
+        const u = (c.url || c.bookmark || '').trim().toLowerCase();
+        return `bookmark::${u}`;
+      }
+      case 'feed': {
+        const t = (c.text || '').replace(/\s+/g, ' ').trim();
+        return `feed::${t}`;
+      }
+      case 'votes': {
+        const q = (c.question || '').replace(/\s+/g, ' ').trim();
+        return `votes::${q}`;
+      }
+      case 'transfer': {
+        const concept = (c.concept || '').trim();
+        const amount = c.amount || '';
+        const from = c.from || '';
+        const to = c.to || '';
+        const deadline = c.deadline || '';
+        return `transfer::${concept}|${amount}|${from}|${to}|${deadline}`;
+      }
+      default:
+        return `key::${m.key}`;
+    }
+  };
+
+    const bySig = new Map();
+    for (const m of filtered) {
+      const sig = signatureOf(m);
+      const prev = bySig.get(sig);
+      if (!prev || (m.value?.timestamp || 0) > (prev.value?.timestamp || 0)) {
+        bySig.set(sig, m);
+      }
+    }
+    filtered = Array.from(bySig.values());
 
     if (filter === 'MINE') {
       filtered = filtered.filter(m => m.value.author === userId);

+ 4 - 2
src/models/pixelia_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -12,7 +14,7 @@ module.exports = ({ cooler }) => {
     const ssbClient = await openSsb();
     const messages = await new Promise((res, rej) => {
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
     });
@@ -89,7 +91,7 @@ module.exports = ({ cooler }) => {
     const ssbClient = await openSsb();
     const messages = await new Promise((res, rej) => {
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
     });

+ 4 - 1
src/models/reports_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -98,7 +100,8 @@ module.exports = ({ cooler }) => {
     async listAll() {
       const ssb = await openSsb();
       return new Promise((resolve, reject) => {
-        pull(ssb.createLogStream(), pull.collect((err, results) => {
+        pull(ssb.createLogStream({ limit: logLimit }), 
+          pull.collect((err, results) => {
           if (err) return reject(err);
           const tombstoned = new Set();
           const replaced = new Map();

+ 3 - 1
src/models/search_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -59,7 +61,7 @@ module.exports = ({ cooler }) => {
 
     const messages = await new Promise((res, rej) => {
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
     });

+ 53 - 29
src/models/stats_model.js

@@ -1,17 +1,16 @@
 const pull = require('../server/node_modules/pull-stream');
 const os = require('os');
 const fs = require('fs');
+const { getConfig } = require('../configs/config-manager.js');
+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 types = [
-    'bookmark', 'event', 'task', 'votes', 'report', 'feed',
-    'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe', 'market'
+    'bookmark','event','task','votes','report','feed',
+    'image','audio','video','document','transfer','post','tribe','market','forum','job'
   ];
 
   const getFolderSize = (folderPath) => {
@@ -40,50 +39,75 @@ module.exports = ({ cooler }) => {
 
     const messages = await new Promise((res, rej) => {
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
     });
 
     const allMsgs = messages.filter(m => m.value?.content);
-    const tombstoned = new Set(allMsgs.filter(m => m.value.content.type === 'tombstone' && m.value.content.target).map(m => m.value.content.target));
-    const replacesMap = new Map();
-    const userMsgs = filter === 'MINE' ? allMsgs.filter(m => m.value.author === userId) : allMsgs;
+    const tombTargets = new Set(
+      allMsgs
+        .filter(m => m.value.content.type === 'tombstone' && m.value.content.target)
+        .map(m => m.value.content.target)
+    );
 
-    const latestByType = {};
-    const opinions = {};
-    const content = {};
+    const scopedMsgs = filter === 'MINE' ? allMsgs.filter(m => m.value.author === userId) : allMsgs;
 
+    const byType = {};
+    const parentOf = {};
     for (const t of types) {
-      latestByType[t] = new Map();
-      opinions[t] = 0;
-      content[t] = 0;
+      byType[t] = new Map();
+      parentOf[t] = new Map();
     }
 
-    for (const m of userMsgs) {
+    for (const m of scopedMsgs) {
       const k = m.key;
       const c = m.value.content;
       const t = c.type;
       if (!types.includes(t)) continue;
-      if (tombstoned.has(k)) continue;
-      if (c.replaces) replacesMap.set(c.replaces, k);
-      latestByType[t].set(k, { msg: m, content: c });
+      byType[t].set(k, { key: k, ts: m.value.timestamp, content: c });
+      if (c.replaces) parentOf[t].set(k, c.replaces);
     }
 
-    for (const replacedId of replacesMap.keys()) {
-      for (const t of types) {
-        latestByType[t].delete(replacedId);
+    const findRoot = (t, id) => {
+      let cur = id;
+      const pMap = parentOf[t];
+      while (pMap.has(cur)) cur = pMap.get(cur);
+      return cur;
+    };
+
+    const tipOf = {};
+    for (const t of types) {
+      tipOf[t] = new Map();
+      const pMap = parentOf[t];
+      const fwd = new Map();
+      for (const [child, parent] of pMap.entries()) {
+        fwd.set(parent, child);
+      }
+      const allMap = byType[t];
+      const roots = new Set(Array.from(allMap.keys()).map(id => findRoot(t, id)));
+      for (const root of roots) {
+        let tip = root;
+        while (fwd.has(tip)) tip = fwd.get(tip);
+        if (tombTargets.has(tip)) continue;
+        const node = allMap.get(tip) || allMap.get(root);
+        if (node) tipOf[t].set(root, node);
       }
     }
 
+    const content = {};
+    const opinions = {};
     for (const t of types) {
-      const values = Array.from(latestByType[t].values());
-      content[t] = values.length;
-      opinions[t] = values.filter(e => (e.content.opinions_inhabitants || []).length > 0).length;
+      let vals = Array.from(tipOf[t].values()).map(v => v.content);
+      if (t === 'forum') {
+        vals = vals.filter(c => !(c.root && tombTargets.has(c.root)));
+      }
+      content[t] = vals.length;
+      opinions[t] = vals.filter(e => Array.isArray(e.opinions_inhabitants) && e.opinions_inhabitants.length > 0).length;
     }
 
-    const tribeContents = Array.from(latestByType['tribe'].values()).map(e => e.content);
-    const memberTribes = tribeContents
+    const tribeVals = Array.from(tipOf['tribe'].values()).map(v => v.content);
+    const memberTribes = tribeVals
       .filter(c => Array.isArray(c.members) && c.members.includes(userId))
       .map(c => c.name || c.title || c.id);
 
@@ -103,7 +127,7 @@ module.exports = ({ cooler }) => {
       content,
       opinions,
       memberTribes,
-      userTombstoneCount: userMsgs.filter(m => m.value.content.type === 'tombstone').length,
+      userTombstoneCount: scopedMsgs.filter(m => m.value.content.type === 'tombstone').length,
       networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
       folderSize: formatSize(folderSize),
       statsBlockchainSize: formatSize(flumeSize),

+ 3 - 1
src/models/tags_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -13,7 +15,7 @@ module.exports = ({ cooler }) => {
       const ssbClient = await openSsb();
       return new Promise((resolve, reject) => {
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.filter(msg => {
             const c = msg.value.content;
             return c && Array.isArray(c.tags) && c.tags.length && c.type !== 'tombstone'; 

+ 56 - 6
src/models/tasks_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -46,11 +48,57 @@ module.exports = ({ cooler }) => {
     async updateTaskById(taskId, updatedData) {
       const ssb = await openSsb();
       const userId = ssb.id;
-      const task = await new Promise((res, rej) => ssb.get(taskId, (err, task) => err ? rej(new Error('Task not found')) : res(task)));
-      if (task.content.status === 'CLOSED') throw new Error('Cannot edit a closed task');
-      const updated = { ...task.content, ...updatedData, updatedAt: new Date().toISOString(), replaces: taskId };
-      const tombstone = { type: 'tombstone', target: taskId, deletedAt: new Date().toISOString(), author: userId };
-      await new Promise((res, rej) => ssb.publish(tombstone, err => err ? rej(err) : res()));
+      const old = await new Promise((res, rej) =>
+        ssb.get(taskId, (err, msg) => err || !msg ? rej(new Error('Task not found')) : res(msg))
+      );
+      const c = old.content;
+      if (c.type !== 'task') throw new Error('Invalid type');
+      if (c.author !== userId) throw new Error('Not the author');
+      if (c.status === 'CLOSED') throw new Error('Cannot edit a closed task');
+      let newStart = c.startTime;
+      if (updatedData.startTime != null && updatedData.startTime !== '') {
+        const m = moment(updatedData.startTime);
+        if (!m.isValid()) throw new Error('Invalid startTime');
+        newStart = m.toISOString();
+      }
+      let newEnd = c.endTime;
+      if (updatedData.endTime != null && updatedData.endTime !== '') {
+        const m = moment(updatedData.endTime);
+        if (!m.isValid()) throw new Error('Invalid endTime');
+        newEnd = m.toISOString();
+      }
+      if (moment(newEnd).isBefore(moment(newStart))) {
+        throw new Error('Invalid time range');
+      }
+      let newTags = c.tags || [];
+      if (updatedData.tags !== undefined) {
+        if (Array.isArray(updatedData.tags)) {
+          newTags = updatedData.tags.filter(Boolean);
+        } else if (typeof updatedData.tags === 'string') {
+          newTags = updatedData.tags.split(',').map(t => t.trim()).filter(Boolean);
+        } else {
+          newTags = [];
+        }
+      }
+      let newVisibility = c.isPublic;
+      if (updatedData.isPublic !== undefined) {
+        const v = String(updatedData.isPublic).toUpperCase();
+        newVisibility = (v === 'PUBLIC' || v === 'PRIVATE') ? v : c.isPublic;
+      }
+      const updated = {
+        ...c,
+        title: updatedData.title ?? c.title,
+        description: updatedData.description ?? c.description,
+        startTime: newStart,
+        endTime: newEnd,
+        priority: updatedData.priority ?? c.priority,
+        location: updatedData.location ?? c.location,
+        tags: newTags,
+        isPublic: newVisibility,
+        status: updatedData.status ?? c.status,
+        updatedAt: new Date().toISOString(),
+        replaces: taskId
+      };
       return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
     },
 
@@ -87,7 +135,8 @@ module.exports = ({ cooler }) => {
       const ssb = await openSsb();
       const now = moment();
       return new Promise((resolve, reject) => {
-        pull(ssb.createLogStream(), pull.collect((err, results) => {
+        pull(ssb.createLogStream({ limit: logLimit }), 
+        pull.collect((err, results) => {
           if (err) return reject(err);
           const tombstoned = new Set();
           const replaced = new Map();
@@ -114,3 +163,4 @@ module.exports = ({ cooler }) => {
   };
 };
 
+

+ 3 - 1
src/models/transfers_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -116,7 +118,7 @@ module.exports = ({ cooler }) => {
 	  const ssb = await openSsb();
 	  return new Promise((resolve, reject) => {
 	    pull(
-	      ssb.createLogStream(),
+	      ssb.createLogStream({ limit: logLimit }),
 	      pull.collect(async (err, results) => {
 		if (err) return reject(err);
 		const tombstoned = new Set();

+ 32 - 1
src/models/trending_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -30,7 +32,7 @@ module.exports = ({ cooler }) => {
     const userId = ssbClient.id;
     const messages = await new Promise((res, rej) => {
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, xs) => err ? rej(err) : res(xs))
       );
     });
@@ -71,6 +73,35 @@ module.exports = ({ cooler }) => {
       })
     );
     items = items.filter(Boolean);
+    const signatureOf = (m) => {
+    const c = m.value?.content || {};
+    switch (c.type) {
+      case 'document':
+      case 'image':
+      case 'audio':
+      case 'video':
+        return `${c.type}::${(c.url || '').trim()}`;
+      case 'bookmark':
+        return `bookmark::${(c.url || '').trim().toLowerCase()}`;
+      case 'feed':
+        return `feed::${(c.text || '').replace(/\s+/g, ' ').trim()}`;
+      case 'votes':
+       return `votes::${(c.question || '').replace(/\s+/g, ' ').trim()}`;
+      case 'transfer':
+        return `transfer::${(c.concept || '')}|${c.amount || ''}|${c.from || ''}|${c.to || ''}|${c.deadline || ''}`;
+      default:
+        return `key::${m.key}`;
+    }
+    };
+    const bySig = new Map();
+    for (const m of items) {
+      const sig = signatureOf(m);
+      const prev = bySig.get(sig);
+      if (!prev || (m.value?.timestamp || 0) > (prev.value?.timestamp || 0)) {
+        bySig.set(sig, m);
+      }
+    }
+    items = Array.from(bySig.values());
 
     if (filter === 'MINE') {
       items = items.filter(m => m.value.author === userId);

+ 4 - 2
src/models/tribes_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -233,7 +235,7 @@ module.exports = ({ cooler }) => {
     async getTribeById(tribeId) {
       const ssb = await openSsb();
       return new Promise((res, rej) => pull(
-        ssb.createLogStream(),
+        ssb.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => {
           if (err) return rej(err);
           const tombstoned = new Set();
@@ -278,7 +280,7 @@ module.exports = ({ cooler }) => {
     async listAll() {
       const ssb = await openSsb();
       return new Promise((res, rej) => pull(
-        ssb.createLogStream(),
+        ssb.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => {
           if (err) return rej(err);
           const tombstoned = new Set();

+ 8 - 9
src/models/videos_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -87,7 +89,7 @@ module.exports = ({ cooler }) => {
       const userId = ssbClient.id;
       const messages = await new Promise((res, rej) => {
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         );
       });
@@ -103,13 +105,12 @@ module.exports = ({ cooler }) => {
           continue;
         }
         if (c.type !== 'video') continue;
-        if (tombstoned.has(k)) continue;
         if (c.replaces) replaces.set(c.replaces, k);
         videos.set(k, {
           key: k,
           url: c.url,
           createdAt: c.createdAt,
-          updatedAt: c.updatedAt || null,
+         updatedAt: c.updatedAt || null,
           tags: c.tags || [],
           author: c.author,
           title: c.title || '',
@@ -118,9 +119,8 @@ module.exports = ({ cooler }) => {
           opinions_inhabitants: c.opinions_inhabitants || []
         });
       }
-      for (const replaced of replaces.keys()) {
-        videos.delete(replaced);
-      }
+      for (const oldId of replaces.keys()) videos.delete(oldId);
+      for (const delId of tombstoned.values()) videos.delete(delId);
       let out = Array.from(videos.values());
       if (filter === 'mine') {
         out = out.filter(v => v.author === userId);
@@ -129,9 +129,8 @@ module.exports = ({ cooler }) => {
         out = out.filter(v => new Date(v.createdAt).getTime() >= now - 86400000);
       } else if (filter === 'top') {
         out = out.sort((a, b) => {
-          const sumA = Object.values(a.opinions).reduce((s, v) => s + v, 0);
-          const sumB = Object.values(b.opinions).reduce((s, v) => s + v, 0);
-          return sumB - sumA;
+          const sum = o => Object.values(o || {}).reduce((s, n) => s + (n || 0), 0);
+          return sum(b.opinions) - sum(a.opinions);
         });
       } else {
         out = out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));

+ 52 - 1
src/models/votes_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -38,6 +40,54 @@ module.exports = ({ cooler }) => {
       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)));
     },
+    
+    async updateVoteById(id, { question, deadline, options, tags }) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      const oldMsg = await new Promise((res, rej) =>
+        ssb.get(id, (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.createdBy !== userId) throw new Error('Not the author');
+      let newDeadline = c.deadline;
+      if (deadline != null && deadline !== '') {
+        const parsed = moment(deadline, moment.ISO_8601, true);
+        if (!parsed.isValid() || parsed.isBefore(moment())) throw new Error('Invalid deadline');
+        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])
+      );
+      if (optionsCambiaron) {
+        if (c.totalVotes > 0) {
+          throw new Error('Cannot change options after voting has started');
+        }
+        newOptions = options;
+        newVotesMap = newOptions.reduce((acc, opt) => (acc[opt] = 0, acc), {});
+        newTotalVotes = 0;
+      }
+      const newTags =
+        Array.isArray(tags) ? tags.filter(Boolean)
+        : typeof tags === 'string' ? tags.split(',').map(t => t.trim()).filter(Boolean)
+        : c.tags || [];
+      const updated = {
+        ...c,
+        replaces: id,
+        question: question ?? c.question,
+        deadline: newDeadline,
+        options: newOptions,
+        votes: newVotesMap,
+        totalVotes: newTotalVotes,
+        tags: newTags,
+        updatedAt: new Date().toISOString()
+      };
+      return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
+    },
 
     async voteOnVote(id, choice) {
       const ssb = await openSsb();
@@ -68,7 +118,8 @@ module.exports = ({ cooler }) => {
       const userId = ssb.id;
       const now = moment();
       return new Promise((resolve, reject) => {
-        pull(ssb.createLogStream(), pull.collect((err, results) => {
+        pull(ssb.createLogStream({ limit: logLimit }), 
+        pull.collect((err, results) => {
           if (err) return reject(err);
           const tombstoned = new Set();
           const replaced = new Map();

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

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

+ 1 - 1
src/server/package.json

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

+ 76 - 30
src/views/activity_view.js

@@ -7,7 +7,7 @@ function capitalize(str) {
   return typeof str === 'string' && str.length ? str[0].toUpperCase() + str.slice(1) : '';
 }
 
-function renderActionCards(actions) {
+function renderActionCards(actions, userId) {
   const validActions = actions
     .filter(action => {
       const content = action.value?.content || action.content;
@@ -363,23 +363,24 @@ function renderActionCards(actions) {
     }
 
     if (type === 'market') {
-      const { item_type, title, price, status, deadline, stock, image, auctions_poll } = content;
+      const { item_type, title, price, status, deadline, stock, image, auctions_poll, seller } = content;
+      const isSeller = seller && userId && seller === userId;
       cardBody.push(
         div({ class: 'card-section market' }, 
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemTitle + ':'), span({ class: 'card-value' }, title)),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemType + ':'), span({ class: 'card-value' }, item_type.toUpperCase())),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStatus + ": " ), span({ class: 'card-value' }, status.toUpperCase())),
           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.marketItemStock + ':'), span({ class: 'card-value' }, stock)),
           br,
           image
             ? img({ src: `/blob/${encodeURIComponent(image)}` })
             : img({ src: '/assets/images/default-market.png', alt: title }),
-          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, stock)),
           br,
           div({ class: "market-card price" },
             p(`${i18n.marketItemPrice}: ${price} ECO`)
           ),
-          item_type === 'auction' && status !== 'SOLD' && status !== 'DISCARDED'
+            item_type === 'auction' && status !== 'SOLD' && status !== 'DISCARDED' && !isSeller
             ? div({ class: "auction-info" },
                 auctions_poll && auctions_poll.length > 0
                   ? [
@@ -390,8 +391,8 @@ function renderActionCards(actions) {
                           th(i18n.marketAuctionUser),
                           th(i18n.marketAuctionBidAmount)
                         ),
-                        ...auctions_poll.map(bid => {
-                          const [userId, bidAmount, bidTime] = bid.split(':');
+                            ...auctions_poll.map(bid => {
+                            const [bidderId, bidAmount, bidTime] = bid.split(':');
                           return tr(
                             td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
                             td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
@@ -407,7 +408,7 @@ function renderActionCards(actions) {
                   button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
                 )
               ) : "",
-          item_type === 'exchange' && status !== 'SOLD' && status !== 'DISCARDED'
+            item_type === 'exchange' && status !== 'SOLD' && status !== 'DISCARDED' && !isSeller
             ? form({ method: "POST", action: `/market/buy/${encodeURIComponent(action.id)}` },
                 button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
               ) : ""
@@ -426,13 +427,55 @@ function renderActionCards(actions) {
         )
       );
     }
+    
+    if (type === 'job') {
+      const { title, job_type, tasks, location, vacants, salary, status, subscribers } = content;
+      cardBody.push(
+        div({ class: 'card-section report' },
+            div({ class: 'card-field' },
+                span({ class: 'card-label' }, i18n.title + ':'),
+                span({ class: 'card-value' }, title)
+            ),
+            salary && div({ class: 'card-field' },
+                span({ class: 'card-label' }, i18n.jobSalary + ':'),
+                span({ class: 'card-value' }, salary + ' ECO')
+            ),
+            status && div({ class: 'card-field' },
+                span({ class: 'card-label' }, i18n.jobStatus + ':'),
+                span({ class: 'card-value' }, status.toUpperCase())
+            ),
+            job_type && div({ class: 'card-field' },
+                span({ class: 'card-label' }, i18n.jobType + ':'),
+                span({ class: 'card-value' }, job_type.toUpperCase())
+            ),
+            location && div({ class: 'card-field' },
+                span({ class: 'card-label' }, i18n.jobLocation + ':'),
+                span({ class: 'card-value' }, location.toUpperCase())
+            ),
+            vacants && div({ class: 'card-field' },
+                span({ class: 'card-label' }, i18n.jobVacants + ':'),
+                span({ class: 'card-value' }, vacants)
+            ),
+            div({ class: 'card-field' },
+                span({ class: 'card-label' }, i18n.jobSubscribers + ':'),
+                span({ class: 'card-value' },
+                    Array.isArray(subscribers) && subscribers.length > 0
+                        ? `${subscribers.length}`
+                        : i18n.noSubscribers.toUpperCase()
+                )
+            ),
+        )
+      );
+    }
 
 return div({ class: 'card card-rpg' },
   div({ class: 'card-header' },
     h2({ class: 'card-label' }, `[${typeLabel}]`),
-    type !== 'feed' ? form({ method: "GET", action: getViewDetailsAction(type, action) },
-      button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-    ) : ''
+	type !== 'feed' && (!action.tipId || action.tipId === action.id)
+	  ? form({ method: "GET", action: getViewDetailsAction(type, action) },
+	      button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+	    )
+	  : ''
   ),
   div({ class: 'card-body' }, ...cardBody),
   p({ class: 'card-footer' },
@@ -445,30 +488,32 @@ return div({ class: 'card card-rpg' },
 }
 
 function getViewDetailsAction(type, action) {
+  const id = encodeURIComponent(action.tipId || action.id);
   switch (type) {
-    case 'votes': return `/votes/${encodeURIComponent(action.id)}`;
-    case 'transfer': return `/transfers/${encodeURIComponent(action.id)}`;
+    case 'votes': return `/votes/${id}`;
+    case 'transfer': return `/transfers/${id}`;
     case 'pixelia': return `/pixelia`;
-    case 'tribe': return `/tribe/${encodeURIComponent(action.id)}`;
+    case 'tribe': return `/tribe/${id}`;
     case 'curriculum': return `/inhabitant/${encodeURIComponent(action.author)}`;
-    case 'image': return `/images/${encodeURIComponent(action.id)}`;
-    case 'audio': return `/audios/${encodeURIComponent(action.id)}`;
-    case 'video': return `/videos/${encodeURIComponent(action.id)}`;
-    case 'forum': 
-      return `/forum/${encodeURIComponent(action.content?.key || action.id)}`;
-    case 'document': return `/documents/${encodeURIComponent(action.id)}`;
-    case 'bookmark': return `/bookmarks/${encodeURIComponent(action.id)}`;
-    case 'event': return `/events/${encodeURIComponent(action.id)}`;
-    case 'task': return `/tasks/${encodeURIComponent(action.id)}`;    
+    case 'image': return `/images/${id}`;
+    case 'audio': return `/audios/${id}`;
+    case 'video': return `/videos/${id}`;
+    case 'forum':
+      return `/forum/${encodeURIComponent(action.content?.key || action.tipId || action.id)}`;
+    case 'document': return `/documents/${id}`;
+    case 'bookmark': return `/bookmarks/${id}`;
+    case 'event': return `/events/${id}`;
+    case 'task': return `/tasks/${id}`;
     case 'about': return `/author/${encodeURIComponent(action.author)}`;
-    case 'post': return `/thread/${encodeURIComponent(action.id)}#${encodeURIComponent(action.id)}`;
+    case 'post': return `/thread/${id}#${id}`;
     case 'vote': return `/thread/${encodeURIComponent(action.content.vote.link)}#${encodeURIComponent(action.content.vote.link)}`;
     case 'contact': return `/inhabitants`;
     case 'pub': return `/invites`;
-    case 'market': return `/market/${encodeURIComponent(action.id)}`;
-    case 'report': return `/reports/${encodeURIComponent(action.id)}`;
-    }
-   }
+    case 'market': return `/market/${id}`;
+    case 'job': return `/jobs/${id}`;
+    case 'report': return `/reports/${id}`;
+  }
+}
 
 exports.activityView = (actions, filter, userId) => {
   const title = filter === 'mine' ? i18n.yourActivity : i18n.globalActivity;
@@ -486,6 +531,7 @@ exports.activityView = (actions, filter, userId) => {
     { type: 'about', label: i18n.typeAbout },
     { type: 'curriculum', label: i18n.typeCurriculum },
     { type: 'market', label: i18n.typeMarket },
+    { type: 'job', label: i18n.typeJob },
     { type: 'transfer', label: i18n.typeTransfer },
     { type: 'feed', label: i18n.typeFeed },
     { type: 'post', label: i18n.typePost },
@@ -551,7 +597,7 @@ exports.activityView = (actions, filter, userId) => {
             div({
               style: 'display: flex; flex-direction: column; gap: 8px;'
             },
-              activityTypes.slice(11, 15).map(({ type, label }) =>
+              activityTypes.slice(11, 16).map(({ type, label }) =>
                 form({ method: 'GET', action: '/activity' },
                   input({ type: 'hidden', name: 'filter', value: type }),
                   button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
@@ -561,7 +607,7 @@ exports.activityView = (actions, filter, userId) => {
             div({
               style: 'display: flex; flex-direction: column; gap: 8px;'
             },
-              activityTypes.slice(15, 21).map(({ type, label }) =>
+              activityTypes.slice(16, 22).map(({ type, label }) =>
                 form({ method: 'GET', action: '/activity' },
                   input({ type: 'hidden', name: 'filter', value: type }),
                   button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
@@ -570,7 +616,7 @@ exports.activityView = (actions, filter, userId) => {
             )
           )
         ),
-      section({ class: 'feed-container' }, renderActionCards(filteredActions))
+       section({ class: 'feed-container' }, renderActionCards(filteredActions, userId))
     )
   );
   const hasDocument = actions.some(a => a && a.type === 'document');

+ 93 - 64
src/views/agenda_view.js

@@ -3,37 +3,44 @@ const { template, i18n } = require('./main_views');
 const moment = require('../server/node_modules/moment');
 const { config } = require('../server/SSB_server.js');
 
-userId = config.keys.id;
+const userId = config.keys.id;
 
 const renderCardField = (labelText, value) =>
   div({ class: 'card-field' },
     span({ class: 'card-label' }, labelText),
-    span({ class: 'card-value' }, value)
+    span(
+      { class: 'card-value' },
+      ...(Array.isArray(value) ? value : [value ?? ''])
+    )
   );
-  
+
 function getViewDetailsAction(item) {
   switch (item.type) {
     case 'transfer': return `/transfers/${encodeURIComponent(item.id)}`;
     case 'tribe': return `/tribe/${encodeURIComponent(item.id)}`;
     case 'event': return `/events/${encodeURIComponent(item.id)}`;
-    case 'task': return `/tasks/${encodeURIComponent(item.id)}`;    
+    case 'task': return `/tasks/${encodeURIComponent(item.id)}`;
     case 'market': return `/market/${encodeURIComponent(item.id)}`;
     case 'report': return `/reports/${encodeURIComponent(item.id)}`;
-    }
-   }
+    case 'job': return `/jobs/${encodeURIComponent(item.id)}`;
+    default: return `/messages/${encodeURIComponent(item.id)}`;
+  }
+}
 
 const renderAgendaItem = (item, userId, filter) => {
   const fmt = d => moment(d).format('YYYY/MM/DD HH:mm:ss');
-  const author = item.seller || item.organizer || item.from || item.author;
+  const author = item.seller || item.organizer || item.from || item.author || '';
 
   const commonFields = [
     p({ class: 'card-footer' },
-      span({ class: 'date-link' }, `${moment(item.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-      a({ href: `/author/${encodeURIComponent(author)}`, class: 'user-link' }, `${author}`)
+      span({ class: 'date-link' }, `${item.createdAt ? moment(item.createdAt).format('YYYY/MM/DD HH:mm:ss') : ''} ${i18n.performed} `),
+      author ? a({ href: `/author/${encodeURIComponent(author)}`, class: 'user-link' }, `${author}`) : ''
     )
   ];
+
   let details = [];
   let actionButton = null;
+
   if (filter === 'discarded') {
     actionButton = form({ method: 'POST', action: `/agenda/restore/${encodeURIComponent(item.id)}` },
       button({ type: 'submit', class: 'restore-btn' }, i18n.agendaRestoreButton)
@@ -43,66 +50,56 @@ const renderAgendaItem = (item, userId, filter) => {
       button({ type: 'submit', class: 'discard-btn' }, i18n.agendaDiscardButton)
     );
   }
+
   if (item.type === 'market') {
-  details = [
-    renderCardField(i18n.marketItemType + ":", item.item_type.toUpperCase()),
-    renderCardField(i18n.marketItemStatus + ":", item.status),
-    renderCardField(i18n.marketItemStock + ":", item.stock), 
-    renderCardField(i18n.marketItemPrice + ":", `${item.price} ECO`),
-    renderCardField(i18n.marketItemIncludesShipping + ":", item.includesShipping ? i18n.agendaYes : i18n.agendaNo),
-    renderCardField(i18n.deadline + ":", new Date(item.deadline).toLocaleString()),
-   ];
-    if (item.item_type === 'auction') {
-      const bids = item.auctions_poll.map(bid => parseFloat(bid.split(':')[1]))
+    details = [
+      renderCardField(i18n.marketItemType + ":", String(item.item_type || '').toUpperCase()),
+      renderCardField(i18n.marketItemStatus + ":", item.status),
+      renderCardField(i18n.marketItemStock + ":", item.stock),
+      renderCardField(i18n.marketItemPrice + ":", `${item.price} ECO`),
+      renderCardField(i18n.marketItemIncludesShipping + ":", item.includesShipping ? i18n.agendaYes : i18n.agendaNo),
+      renderCardField(i18n.deadline + ":", item.deadline ? new Date(item.deadline).toLocaleString() : '')
+    ];
+    if (String(item.item_type || '').toLowerCase() === 'auction') {
+      const bids = Array.isArray(item.auctions_poll) ? item.auctions_poll.map(bid => parseFloat(String(bid).split(':')[1])).filter(n => !isNaN(n)) : [];
       const maxBid = bids.length ? Math.max(...bids) : 0;
-          details.push(
-      renderCardField(i18n.marketItemHighestBid + ":", `${maxBid} ECO`));
-    };
-    const seller = p(a({ class: "user-link", href: `/author/${encodeURIComponent(item.seller)}` }, item.seller))
-      details.push(
-      br,
-      div({ class: 'members-list' }, i18n.marketItemSeller + ': ', seller)
-    );
+      details.push(renderCardField(i18n.marketItemHighestBid + ":", `${maxBid} ECO`));
+    }
+    const seller = author ? p(a({ class: "user-link", href: `/author/${encodeURIComponent(author)}` }, author)) : '';
+    details.push(br(), div({ class: 'members-list' }, i18n.marketItemSeller + ': ', seller));
   }
 
   if (item.type === 'tribe') {
     details = [
       renderCardField(i18n.agendaAnonymousLabel + ":", item.isAnonymous ? i18n.agendaYes : i18n.agendaNo),
-      renderCardField(i18n.agendaInviteModeLabel + ":", item.inviteMode.toUpperCase() || i18n.noInviteMode),
+      renderCardField(i18n.agendaInviteModeLabel + ":", (item.inviteMode ? String(item.inviteMode).toUpperCase() : i18n.noInviteMode)),
       renderCardField(i18n.agendaLARPLabel + ":", item.isLARP ? i18n.agendaYes : i18n.agendaNo),
       renderCardField(i18n.agendaLocationLabel + ":", item.location || i18n.noLocation),
-      renderCardField(i18n.agendaMembersCount + ":", item.members.length || 0),
-      br(),
+      renderCardField(i18n.agendaMembersCount + ":", Array.isArray(item.members) ? item.members.length : 0),
+      br()
     ];
-    const membersList = item.members.map(member =>
-      p(a({ class: "user-link", href: `/author/${encodeURIComponent(member)}` }, member))
-    );
-    details.push(
-      div({ class: 'members-list' }, `${i18n.agendaMembersLabel}:`, membersList)
-    );
+    const membersList = Array.isArray(item.members) ? item.members.map(member => p(a({ class: "user-link", href: `/author/${encodeURIComponent(member)}` }, member))) : [];
+    details.push(div({ class: 'members-list' }, `${i18n.agendaMembersLabel}:`, membersList));
   }
 
   if (item.type === 'report') {
     details = [
       renderCardField(i18n.agendareportStatus + ":", item.status || i18n.noStatus),
       renderCardField(i18n.agendareportCategory + ":", item.category || i18n.noCategory),
-      renderCardField(i18n.agendareportSeverity + ":", item.severity.toUpperCase() || i18n.noSeverity),
+      renderCardField(i18n.agendareportSeverity + ":", (item.severity ? String(item.severity).toUpperCase() : i18n.noSeverity))
     ];
   }
 
   if (item.type === 'event') {
     details = [
-      renderCardField(i18n.eventDateLabel + ":", fmt(item.date)),
-      renderCardField(i18n.eventLocationLabel + ":", item.location),
+      renderCardField(i18n.eventDateLabel + ":", item.date ? fmt(item.date) : ''),
+      renderCardField(i18n.eventLocationLabel + ":", item.location || ''),
       renderCardField(i18n.eventPriceLabel + ":", `${item.price} ECO`),
       renderCardField(
-  	i18n.eventUrlLabel + ":",
- 	 item.url
-   	 ? p(a({href: item.url, target: "_blank" }, item.url))
-         : p(i8n.noUrl)
-         ),
+        i18n.eventUrlLabel + ":",
+        item.url ? p(a({ href: item.url, target: "_blank" }, item.url)) : p(i18n.noUrl)
+      )
     ];
-
     actionButton = actionButton || form({ method: 'POST', action: `/events/attend/${encodeURIComponent(item.id)}` },
       button({ type: 'submit', class: 'assign-btn' }, `${i18n.eventAttendButton}`)
     );
@@ -112,16 +109,13 @@ const renderAgendaItem = (item, userId, filter) => {
     details = [
       renderCardField(i18n.taskStatus + ":", item.status),
       renderCardField(i18n.taskPriorityLabel + ":", item.priority),
-      renderCardField(i18n.taskStartTimeLabel + ":",  new Date(item.startTime).toLocaleString()),
-      renderCardField(i18n.taskEndTimeLabel + ":", new Date(item.endTime).toLocaleString()),
-      renderCardField(i18n.taskLocationLabel + ":", item.location),
+      renderCardField(i18n.taskStartTimeLabel + ":", item.startTime ? new Date(item.startTime).toLocaleString() : ''),
+      renderCardField(i18n.taskEndTimeLabel + ":", item.endTime ? new Date(item.endTime).toLocaleString() : ''),
+      renderCardField(i18n.taskLocationLabel + ":", item.location || '')
     ];
-
     const assigned = Array.isArray(item.assignees) && item.assignees.includes(userId);
     actionButton = actionButton || form({ method: 'POST', action: `/tasks/assign/${encodeURIComponent(item.id)}` },
-      button({ type: 'submit', class: 'assign-btn' },
-        assigned ? i18n.taskUnassignButton : i18n.taskAssignButton
-      )
+      button({ type: 'submit', class: 'assign-btn' }, assigned ? i18n.taskUnassignButton : i18n.taskAssignButton)
     );
   }
 
@@ -129,31 +123,64 @@ const renderAgendaItem = (item, userId, filter) => {
     details = [
       renderCardField(i18n.agendaTransferConcept + ":", item.concept),
       renderCardField(i18n.agendaTransferAmount + ":", item.amount),
-      renderCardField(i18n.agendaTransferDeadline + ":", fmt(item.deadline)),
-      br,
+      renderCardField(i18n.agendaTransferDeadline + ":", item.deadline ? fmt(item.deadline) : ''),
+      br()
     ];
-    const membersList = p(a({ class: "user-link", href: `/author/${encodeURIComponent(item.to)}` }, item.to))
-    details.push(
-      div({ class: 'members-list' }, i18n.to + ': ', membersList)
-    );
+    const membersList = item.to ? p(a({ class: "user-link", href: `/author/${encodeURIComponent(item.to)}` }, item.to)) : '';
+    details.push(div({ class: 'members-list' }, i18n.to + ': ', membersList));
+  }
+
+  if (item.type === 'job') {
+    const subs = Array.isArray(item.subscribers)
+      ? item.subscribers
+      : (typeof item.subscribers === 'string'
+          ? item.subscribers.split(',').map(s => s.trim()).filter(Boolean)
+          : (item.subscribers && typeof item.subscribers.length === 'number'
+              ? Array.from(item.subscribers)
+              : []));
+
+    const subsInterleaved = subs
+      .map((id, i) => [i > 0 ? ', ' : '', a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)])
+      .flat();
+
+    details = [
+      renderCardField(i18n.jobStatus + ":", item.status),
+      renderCardField(i18n.jobLocation + ":", (item.location || '').toUpperCase()),
+      renderCardField(i18n.jobType + ":", (item.job_type || '').toUpperCase()),
+      renderCardField(i18n.jobSalary + ":", `${item.salary} ECO`),
+      renderCardField(i18n.jobVacants + ":", item.vacants),
+      renderCardField(i18n.jobLanguages + ":", (item.languages || '').toUpperCase()),
+      br(),
+      div(
+        { class: 'members-list' },
+        i18n.jobSubscribers + ': ',br(),br(),
+        ...(subs.length ? subsInterleaved : [i18n.noSubscribers.toUpperCase()])
+      ),
+    ];
+
+    const subscribed = subs.includes(userId);
+    if (!subscribed && String(item.status).toUpperCase() !== 'CLOSED' && item.author !== userId) {
+      actionButton = form({ method: 'GET', action: `/jobs/subscribe/${encodeURIComponent(item.id)}` },
+        button({ type: 'submit', class: 'subscribe-btn' }, i18n.jobSubscribeButton)
+      );
+    }
   }
 
   return div({ class: 'agenda-item card' },
-    h2(`[${item.type.toUpperCase()}] ${item.title || item.name || item.concept}`),
+    h2(`[${String(item.type || '').toUpperCase()}] ${item.title || item.name || item.concept || ''}`),
     form({ method: "GET", action: getViewDetailsAction(item) },
       button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-    ),    
+    ),
     actionButton,
-    br,
+    br(),
     ...details,
-    br,
-    ...commonFields,
+    br(),
+    ...commonFields
   );
 };
 
 exports.agendaView = async (data, filter) => {
   const { items, counts } = data;
-
   return template(
     i18n.agendaTitle,
     section(
@@ -181,6 +208,8 @@ exports.agendaView = async (data, filter) => {
             `${i18n.agendaFilterMarket} (${counts.market})`),
           button({ type: 'submit', name: 'filter', value: 'transfers', class: filter === 'transfers' ? 'filter-btn active' : 'filter-btn' },
             `${i18n.agendaFilterTransfers} (${counts.transfers})`),
+          button({ type: 'submit', name: 'filter', value: 'jobs', class: filter === 'jobs' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterJobs} (${counts.jobs})`),
           button({ type: 'submit', name: 'filter', value: 'discarded', class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' },
             `DISCARDED (${counts.discarded})`)
         )

+ 164 - 191
src/views/blockchain_view.js

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

+ 1 - 1
src/views/event_view.js

@@ -94,7 +94,7 @@ exports.eventView = async (events, filter, eventId) => {
 
   let filtered
   if (filter === 'all') {
-    filtered = list.filter(e => e.isPublic === "public")
+    filtered = list.filter(e => String(e.isPublic).toLowerCase() === 'public')
   } else if (filter === 'mine') {
     filtered = list.filter(e => e.organizer === userId)
   } else if (filter === 'today') {

+ 102 - 109
src/views/inhabitants_view.js

@@ -1,4 +1,4 @@
-const { div, h2, p, section, button, form, img, a, textarea, input, br } = require("../server/node_modules/hyperaxe");
+const { div, h2, p, section, button, form, img, a, textarea, input, br, span } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const { renderUrl } = require('../backend/renderUrl');
 
@@ -12,8 +12,8 @@ function resolvePhoto(photoField, size = 256) {
   return '/assets/images/default-avatar.png';
 }
 
-const generateFilterButtons = (filters, currentFilter) => {
-  return filters.map(mode => 
+const generateFilterButtons = (filters, currentFilter) =>
+  filters.map(mode =>
     form({ method: 'GET', action: '/inhabitants' },
       input({ type: 'hidden', name: 'filter', value: mode }),
       button({
@@ -22,71 +22,77 @@ const generateFilterButtons = (filters, currentFilter) => {
       }, i18n[mode + 'Button'] || i18n[mode + 'SectionTitle'] || mode)
     )
   );
-};
 
-const renderInhabitantCard = (user, filter) => {
+const renderInhabitantCard = (user, filter, currentUserId) => {
+  const isMe = user.id === currentUserId;
   return div({ class: 'inhabitant-card' },
-  img({
-    class: 'inhabitant-photo',
-    src: resolvePhoto(user.photo),
-  }),
+    img({ class: 'inhabitant-photo', src: resolvePhoto(user.photo) }),
     div({ class: 'inhabitant-details' },
       h2(user.name),
-        user.description ? p(...renderUrl(user.description)) : null,
+      user.description ? p(...renderUrl(user.description)) : null,
       filter === 'MATCHSKILLS' && user.commonSkills?.length
-        ? p(`${i18n.commonSkills}: ${user.commonSkills.join(', ')}`) : null,
+        ? div({ class: 'matchskills' },
+            p(`${i18n.commonSkills}: ${user.commonSkills.join(', ')}`),
+            p(`${i18n.matchScore}: ${Math.round(user.matchScore * 100)}%`)
+          )
+        : null,
       filter === 'SUGGESTED' && user.mutualCount
         ? p(`${i18n.mutualFollowers}: ${user.mutualCount}`) : null,
       filter === 'blocked' && user.isBlocked
         ? p(i18n.blockedLabel) : null,
       p(a({ class: 'user-link', href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
-      ['CVs', 'MATCHSKILLS', 'SUGGESTED'].includes(filter)
-        ? a({ href: `/inhabitant/${encodeURIComponent(user.id)}`, class: 'view-profile-btn' }, i18n.inhabitantviewDetails)
-        : null
+
+      div(
+        { class: 'cv-actions', style: 'display:flex; flex-direction:column; gap:8px; margin-top:12px;' },
+        isMe
+          ? p(i18n.relationshipYou)
+          : (filter === 'CVs' || filter === 'MATCHSKILLS' || filter === 'SUGGESTED')
+            ? form(
+                { method: 'GET', action: `/inhabitant/${encodeURIComponent(user.id)}` },
+                button({ type: 'submit', class: 'btn' }, i18n.inhabitantviewDetails)
+              )
+            : null,
+        !isMe
+          ? form(
+              { method: 'GET', action: '/pm' },
+              input({ type: 'hidden', name: 'recipients', value: user.id }),
+              button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton)
+            )
+          : null
+      )
     )
   );
 };
 
-const renderGalleryInhabitants = (inhabitants) => {
-  return div({ class: "gallery", style: 'display:grid; grid-template-columns: repeat(3, 1fr); gap:16px;' },
+const renderGalleryInhabitants = inhabitants =>
+  div(
+    { class: "gallery", style: 'display:grid; grid-template-columns: repeat(3, 1fr); gap:16px;' },
     inhabitants.length
-      ? inhabitants.map(u => {
-          const photo = resolvePhoto(u.photo);
-          return a({ href: `#inhabitant-${encodeURIComponent(u.id)}`, class: "gallery-item" },
-            img({
-              src: photo,
-              alt: u.name || "Anonymous",
-              class: "gallery-image"
-            })
-          );
-        })
+      ? inhabitants.map(u =>
+          a({ href: `#inhabitant-${encodeURIComponent(u.id)}`, class: "gallery-item" },
+            img({ src: resolvePhoto(u.photo), alt: u.name || "Anonymous", class: "gallery-image" })
+          )
+        )
       : p(i18n.noInhabitantsFound)
   );
-};
 
-const renderLightbox = (inhabitants) => {
-  return inhabitants.map(u => {
-  const photoUrl = resolvePhoto(u.photo);
-    return div(
+const renderLightbox = inhabitants =>
+  inhabitants.map(u =>
+    div(
       { id: `inhabitant-${encodeURIComponent(u.id)}`, class: "lightbox" },
       a({ href: "#", class: "lightbox-close" }, "×"),
-      img({ 
-        src: photoUrl, 
-        class: "lightbox-image", 
-        alt: u.name || "Anonymous" 
-      })
-    );
-  });
-};
+      img({ src: resolvePhoto(u.photo), class: "lightbox-image", alt: u.name || "Anonymous" })
+    )
+  );
 
-exports.inhabitantsView = (inhabitants, filter, query) => {
-  const title = filter === 'contacts'    ? i18n.yourContacts              :
-                filter === 'CVs'         ? i18n.allCVs                    :
-                filter === 'MATCHSKILLS' ? i18n.matchSkills               :
-                filter === 'SUGGESTED'   ? i18n.suggestedSectionTitle     :
-                filter === 'blocked'     ? i18n.blockedSectionTitle       :
-                filter === 'GALLERY'     ? i18n.gallerySectionTitle       :
-                                            i18n.allInhabitants;
+exports.inhabitantsView = (inhabitants, filter, query, currentUserId) => {
+  const title = filter === 'contacts'    ? i18n.yourContacts
+               : filter === 'CVs'         ? i18n.allCVs
+               : filter === 'MATCHSKILLS' ? i18n.matchSkills
+               : filter === 'SUGGESTED'   ? i18n.suggestedSectionTitle
+               : filter === 'blocked'     ? i18n.blockedSectionTitle
+               : filter === 'GALLERY'     ? i18n.gallerySectionTitle
+                                          : i18n.allInhabitants;
 
   const showCVFilters = filter === 'CVs' || filter === 'MATCHSKILLS';
   const filters = ['all', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
@@ -109,39 +115,23 @@ exports.inhabitantsView = (inhabitants, filter, query) => {
           }),
           showCVFilters
             ? [
-                input({
-                  type: 'text',
-                  name: 'location',
-                  placeholder: i18n.filterLocation,
-                  value: query.location || ''
-                }),
-                input({
-                  type: 'text',
-                  name: 'language',
-                  placeholder: i18n.filterLanguage,
-                  value: query.language || ''
-                }),
-                input({
-                  type: 'text',
-                  name: 'skills',
-                  placeholder: i18n.filterSkills,
-                  value: query.skills || ''
-                })
+                input({ type: 'text', name: 'location', placeholder: i18n.filterLocation, value: query.location || '' }),
+                input({ type: 'text', name: 'language', placeholder: i18n.filterLanguage, value: query.language || '' }),
+                input({ type: 'text', name: 'skills', placeholder: i18n.filterSkills, value: query.skills || '' })
               ]
             : null,
           br(),
           button({ type: 'submit' }, i18n.applyFilters)
         )
       ),
-      div({ class: 'inhabitant-action', style: 'margin-top: 1em;' },
-        generateFilterButtons(filters, filter)
+      div({ class: 'inhabitant-action', style: 'margin-top:1em;' },
+        ...generateFilterButtons(filters, filter)
       ),
-      
-      filter === 'GALLERY' 
+      filter === 'GALLERY'
         ? renderGalleryInhabitants(inhabitants)
         : div({ class: 'inhabitants-list' },
-            inhabitants && inhabitants.length > 0
-              ? inhabitants.map(user => renderInhabitantCard(user, filter))
+            inhabitants.length
+              ? inhabitants.map(user => renderInhabitantCard(user, filter, currentUserId))
               : p({ class: 'no-results' }, i18n.noInhabitantsFound)
           ),
       ...renderLightbox(inhabitants)
@@ -149,38 +139,37 @@ exports.inhabitantsView = (inhabitants, filter, query) => {
   );
 };
 
-exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }) => {
-  const profile = cv || about || {};
-  const id = cv?.author || about?.about || 'unknown';
-  const name = cv?.name || about?.name || 'Unnamed';
-  const description = cv?.description || about?.description || '';
-  const image = resolvePhoto(cv?.photo) || '/assets/images/default-oasis.jpg';
-  const location = cv?.location || '';
-  const languages = typeof cv?.languages === 'string'
+exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }, currentUserId) => {
+  const profile = Object.keys(cv).length ? cv : about;
+  const id = cv.author || about.about || 'unknown';
+  const name = cv.name || about.name || 'Unnamed';
+  const description = cv.description || about.description || '';
+  const image = resolvePhoto(cv.photo) || '/assets/images/default-oasis.jpg';
+  const location = cv.location || '';
+  const languages = typeof cv.languages === 'string'
     ? cv.languages.split(',').map(x => x.trim()).filter(Boolean)
-    : Array.isArray(cv?.languages) ? cv.languages : [];
+    : Array.isArray(cv.languages) ? cv.languages : [];
   const skills = [
-    ...(cv?.personalSkills || []),
-    ...(cv?.oasisSkills || []),
-    ...(cv?.educationalSkills || []),
-    ...(cv?.professionalSkills || [])
+    ...(cv.personalSkills || []),
+    ...(cv.oasisSkills || []),
+    ...(cv.educationalSkills || []),
+    ...(cv.professionalSkills || [])
   ];
-  const status = cv?.status || '';
-  const preferences = cv?.preferences || '';
-  const createdAt = cv?.createdAt ? new Date(cv.createdAt).toLocaleString() : '';
+  const status = cv.status || '';
+  const preferences = cv.preferences || '';
+  const createdAt = cv.createdAt ? new Date(cv.createdAt).toLocaleString() : '';
+  const isMe = id === currentUserId;
   const title = i18n.inhabitantProfileTitle || i18n.inhabitantviewDetails;
 
-  const header = div({ class: 'tags-header' },
-    h2(title),
-    p(i18n.discoverPeople)
-  );
-
   return template(
     name,
     section(
-      header,
+      div({ class: 'tags-header' },
+        h2(title),
+        p(i18n.discoverPeople)
+      ),
       div({ class: 'mode-buttons', style: 'display:flex; gap:8px; margin-top:16px;' },
-        generateFilterButtons(['all', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
+        ...generateFilterButtons(['all', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
       ),
       div({ class: 'inhabitant-card', style: 'margin-top:32px;' },
         img({ class: 'inhabitant-photo', src: image, alt: name }),
@@ -193,21 +182,25 @@ exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }) => {
           skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null,
           status ? p(`${i18n.statusLabel || 'Status'}: ${status}`) : null,
           preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${preferences}`) : null,
-          createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null
+          createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null,
+          !isMe
+            ? form(
+                { method: 'GET', action: '/pm' },
+                input({ type: 'hidden', name: 'recipients', value: id }),
+                button({ type: 'submit', class: 'btn', style: 'margin-top:1em;' }, i18n.pmCreateButton)
+              )
+            : null
         )
       ),
-	feed && feed.length
-	  ? section({ class: 'profile-feed' },
-	      h2(i18n.latestInteractions),
-	      feed.map(m => {
-		const contentText = m.value.content.text || '';
-		const cleanText = contentText.replace(/<br\s*\/?>/g, '');	
-		return div({ class: 'post' },
-		  p(...renderUrl(cleanText)) 
-		);
-	      })
-	    )
-          : null
-      )
-    );
+      feed.length
+        ? section({ class: 'profile-feed' },
+            h2(i18n.latestInteractions),
+            ...feed.map(m => {
+              const text = (m.value.content.text || '').replace(/<br\s*\/?>/g, '');
+              return div({ class: 'post' }, p(...renderUrl(text)));
+            })
+          )
+        : null
+    )
+  );
 };

+ 315 - 0
src/views/jobs_view.js

@@ -0,0 +1,315 @@
+const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+const moment = require("../server/node_modules/moment");
+const { config } = require('../server/SSB_server.js');
+const { renderUrl } = require('../backend/renderUrl');
+
+const userId = config.keys.id;
+
+const FILTERS = [
+  { key: 'ALL',        i18n: 'jobsFilterAll',        title: 'jobsAllTitle' },
+  { key: 'MINE',       i18n: 'jobsFilterMine',       title: 'jobsMineTitle' },
+  { key: 'REMOTE',     i18n: 'jobsFilterRemote',     title: 'jobsRemoteTitle' },
+  { key: 'PRESENCIAL', i18n: 'jobsFilterPresencial', title: 'jobsPresencialTitle' },
+  { key: 'FREELANCER', i18n: 'jobsFilterFreelancer', title: 'jobsFreelancerTitle' },
+  { key: 'EMPLOYEE',   i18n: 'jobsFilterEmployee',   title: 'jobsEmployeeTitle' },
+  { key: 'OPEN',       i18n: 'jobsFilterOpen',       title: 'jobsOpenTitle' },
+  { key: 'CLOSED',     i18n: 'jobsFilterClosed',     title: 'jobsClosedTitle' },
+  { key: 'RECENT',     i18n: 'jobsFilterRecent',     title: 'jobsRecentTitle' },
+  { key: 'CV',         i18n: 'jobsCV',               title: 'jobsCVTitle' },
+  { key: 'TOP',        i18n: 'jobsFilterTop',        title: 'jobsTopTitle' }
+];
+
+function resolvePhoto(photoField, size = 256) {
+  if (typeof photoField === 'string' && photoField.startsWith('/image/')) return photoField;
+  if (/^&[A-Za-z0-9+/=]+\.sha256$/.test(photoField)) return `/image/${size}/${encodeURIComponent(photoField)}`;
+  return '/assets/images/default-avatar.png';
+}
+
+const renderCardField = (labelText, value) =>
+  div({ class: 'card-field' },
+    span({ class: 'card-label' }, labelText),
+    span({ class: 'card-value' }, value)
+  );
+
+const renderSubscribers = (subs = []) =>
+  div({ class: 'card-field' },
+    span({ class: 'card-label' }, i18n.jobSubscribers + ':'),
+    span({ class: 'card-value' }, subs && subs.length > 0 ? `${subs.length}` : i18n.noSubscribers.toUpperCase())
+  );
+
+const renderJobList = (jobs, filter) =>
+  jobs.length > 0
+    ? jobs.map(job => {
+        const isMineFilter = String(filter).toUpperCase() === 'MINE';
+        const isAuthor = job.author === userId;
+        const isOpen = String(job.status).toUpperCase() === 'OPEN';
+
+        return div({ class: "job-card" },
+          isMineFilter && isAuthor
+            ? (
+                isOpen
+                  ? div({ class: "job-actions" },
+                      form({ method: "GET", action: `/jobs/edit/${encodeURIComponent(job.id)}` },
+                        button({ class: "update-btn", type: "submit" }, i18n.jobsUpdateButton)
+                      ),
+                      form({ method: "POST", action: `/jobs/delete/${encodeURIComponent(job.id)}` },
+                        button({ class: "delete-btn", type: "submit" }, i18n.jobsDeleteButton)
+                      ),
+                      form({ method: "POST", action: `/jobs/status/${encodeURIComponent(job.id)}` },
+                        button({
+                          class: "status-btn", type: "submit",
+                          name: "status", value: "CLOSED"
+                        }, i18n.jobSetClosed)
+                      )
+                    )
+                  : div({ class: "job-actions" },
+                      form({ method: "POST", action: `/jobs/delete/${encodeURIComponent(job.id)}` },
+                        button({ class: "delete-btn", type: "submit" }, i18n.jobsDeleteButton)
+                      )
+                    )
+              )
+            : null,
+
+          !isMineFilter && !isAuthor && isOpen
+            ? (
+                Array.isArray(job.subscribers) && job.subscribers.includes(userId)
+                  ? form({ method: "POST", action: `/jobs/unsubscribe/${encodeURIComponent(job.id)}` },
+                      button({ type: "submit", class: "unsubscribe-btn" }, i18n.jobUnsubscribeButton)
+                    )
+                  : form({ method: "POST", action: `/jobs/subscribe/${encodeURIComponent(job.id)}` },
+                      button({ type: "submit", class: "subscribe-btn" }, i18n.jobSubscribeButton)
+                    )
+              )
+            : null,
+
+          form({ method: "GET", action: `/jobs/${encodeURIComponent(job.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetailsButton)
+          ),
+          br(),
+          h2(job.title),
+          job.image
+            ? div({ class: 'activity-image-preview' }, img({ src: `/blob/${encodeURIComponent(job.image)}` }))
+            : null,
+          renderCardField(i18n.jobDescription + ':', ''),
+          p(...renderUrl(job.description)),
+          renderSubscribers(job.subscribers),
+          renderCardField(
+            i18n.jobStatus + ':',
+            i18n['jobStatus' + (String(job.status || '').toUpperCase())] || (String(job.status || '').toUpperCase())
+          ),
+          renderCardField(i18n.jobLanguages + ':', (job.languages || '').toUpperCase()),
+          renderCardField(
+            i18n.jobType + ':',
+            i18n['jobType' + (String(job.job_type || '').toUpperCase())] || (String(job.job_type || '').toUpperCase())
+          ),
+          renderCardField(i18n.jobLocation + ':', (job.location || '').toUpperCase()),
+          renderCardField(
+            i18n.jobTime + ':',
+            i18n['jobTime' + (String(job.job_time || '').toUpperCase())] || (String(job.job_time || '').toUpperCase())
+          ),
+          renderCardField(i18n.jobVacants + ':', job.vacants),
+          renderCardField(i18n.jobRequirements + ':', ''),
+          p(...renderUrl(job.requirements)),
+          renderCardField(i18n.jobTasks + ':', ''),
+          p(...renderUrl(job.tasks)),
+          renderCardField(i18n.jobSalary + ':', ''),
+          br(),
+          div({ class: 'card-label' }, h2(`${job.salary} ECO`)),
+          br(),
+          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)
+          )
+        );
+      })
+    : p(i18n.noJobsFound);
+
+const renderJobForm = (job = {}, mode = 'create') => {
+  const isEdit = mode === 'edit';
+  return div({ class: "div-center job-form" },
+    form({
+        action: isEdit ? `/jobs/update/${encodeURIComponent(job.id)}` : "/jobs/create",
+        method: "POST",
+        enctype: "multipart/form-data"
+      },
+      label(i18n.jobType), br(),
+      select({ name: "job_type", required: true },
+        option({ value: "freelancer", selected: job.job_type === 'freelancer' }, i18n.jobTypeFreelance),
+        option({ value: "employee",  selected: job.job_type === 'employee'  }, i18n.jobTypeSalary)
+      ), br(), br(),
+      label(i18n.jobTitle), br(),
+      input({ type: "text", name: "title", required: true, placeholder: i18n.jobTitlePlaceholder, value: job.title || "" }), br(), br(),
+      label(i18n.jobImage), br(),
+      input({ type: "file", name: "image", accept: "image/*" }), br(),
+      job.image ? img({ src: `/blob/${encodeURIComponent(job.image)}`, class: 'existing-image' }) : null,
+      br(),
+      label(i18n.jobDescription), br(),
+      textarea({ name: "description", rows: "6", required: true, placeholder: i18n.jobDescriptionPlaceholder }, job.description || ""), br(), br(),
+      label(i18n.jobRequirements), br(),
+      textarea({ name: "requirements", rows: "6", placeholder: i18n.jobRequirementsPlaceholder }, job.requirements || ""), br(), br(),
+      label(i18n.jobLanguages), br(),
+      input({ type: "text", name: "languages", placeholder: i18n.jobLanguagesPlaceholder, value: job.languages || "" }), br(), br(),
+      label(i18n.jobTime), br(),
+      select({ name: "job_time", required: true },
+        option({ value: "partial",  selected: job.job_time === 'partial'  }, i18n.jobTimePartial),
+        option({ value: "complete", selected: job.job_time === 'complete' }, i18n.jobTimeComplete)
+      ), br(), br(),
+      label(i18n.jobTasks), br(),
+      textarea({ name: "tasks", rows: "6", placeholder: i18n.jobTasksPlaceholder }, job.tasks || ""), br(), br(),
+      label(i18n.jobLocation), br(),
+      select({ name: "location", required: true },
+        option({ value: "remote",     selected: job.location === 'remote'     }, i18n.jobLocationRemote),
+        option({ value: "presencial", selected: job.location === 'presencial' }, i18n.jobLocationPresencial)
+      ), br(), br(),
+      label(i18n.jobVacants), br(),
+      input({ type: "number", name: "vacants", min: "1", placeholder: i18n.jobVacantsPlaceholder, value: job.vacants || 1, required: true }), br(), br(),
+      label(i18n.jobSalary), br(),
+      input({ type: "number", name: "salary", step: "0.01", placeholder: i18n.jobSalaryPlaceholder, value: job.salary || "" }), br(), br(),
+      button({ type: "submit" }, isEdit ? i18n.jobsUpdateButton : i18n.createJobButton)
+    )
+  );
+};
+
+const renderCVList = (inhabitants) =>
+  div({ class: "cv-list" },
+    inhabitants && inhabitants.length > 0
+      ? inhabitants.map(user => {
+          const isMe = user.id === userId;
+          return div({ class: 'inhabitant-card' },
+            img({ class: 'inhabitant-photo', src: resolvePhoto(user.photo) }),
+            div({ class: 'inhabitant-details' },
+              h2(user.name),
+              user.description ? p(...renderUrl(user.description)) : null,
+              p(a({ class: 'user-link', href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
+              div(
+                { class: 'cv-actions', style: 'display:flex; flex-direction:column; gap:8px; margin-top:12px;' },
+                form(
+                  { method: 'GET', action: `/inhabitant/${encodeURIComponent(user.id)}` },
+                  button({ type: 'submit', class: 'btn' }, i18n.inhabitantviewDetails)
+                ),
+                !isMe
+                  ? form(
+                      { method: 'GET', action: '/pm' },
+                      input({ type: 'hidden', name: 'recipients', value: user.id }),
+                      button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton)
+                    )
+                  : null
+              )
+            )
+          )
+        })
+      : p({ class: 'no-results' }, i18n.noInhabitantsFound)
+  );
+
+exports.jobsView = async (jobsOrCVs, filter = "ALL", cvQuery = {}) => {
+  const filterObj = FILTERS.find(f => f.key === filter) || FILTERS[0];
+  const sectionTitle = i18n[filterObj.title] || i18n.jobsTitle;
+
+  return template(
+    i18n.jobsTitle,
+    section(
+      div({ class: "tags-header" }, h2(sectionTitle), p(i18n.jobsDescription)),
+      div({ class: "filters" },
+        form({ method: "GET", action: "/jobs", style: "display:flex;gap:12px;" },
+          FILTERS.map(f =>
+            button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
+          ).concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.jobsCreateJob))
+        )
+      ),
+      filter === 'CV'
+        ? section(
+            form({ method: "GET", action: "/jobs" },
+              input({ type: "hidden", name: "filter", value: "CV" }),
+              input({ type: "text", name: "location", placeholder: i18n.filterLocation, value: cvQuery.location || "" }),
+              input({ type: "text", name: "language", placeholder: i18n.filterLanguage, value: cvQuery.language || "" }),
+              input({ type: "text", name: "skills", placeholder: i18n.filterSkills, value: cvQuery.skills || "" }),
+              br(), button({ type: "submit" }, i18n.applyFilters)
+            ),
+            br(),
+            renderCVList(jobsOrCVs)
+          )
+        : filter === 'CREATE' || filter === 'EDIT'
+          ? (() => {
+              const jobToEdit = filter === 'EDIT' ? jobsOrCVs[0] : {};
+              return renderJobForm(jobToEdit, filter === 'EDIT' ? 'edit' : 'create');
+            })()
+          : div({ class: "jobs-list" }, renderJobList(jobsOrCVs, filter))
+    )
+  );
+};
+
+exports.singleJobsView = async (job, filter = "ALL") => {
+  const isAuthor = job.author === userId;
+  const isOpen = String(job.status).toUpperCase() === 'OPEN';
+
+  return template(
+    i18n.jobsTitle,
+    section(
+      div({ class: "tags-header" }, h2(i18n.jobsTitle), p(i18n.jobsDescription)),
+      div({ class: "filters" },
+        form({ method: "GET", action: "/jobs", style: "display:flex;gap:12px;" },
+          FILTERS.map(f =>
+            button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
+          ).concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.jobsCreateJob))
+        )
+      ),
+      div({ class: "job-card" },
+        isAuthor
+          ? (
+              isOpen
+                ? div({ class: "job-actions" },
+                    form({ method: "GET", action: `/jobs/edit/${encodeURIComponent(job.id)}` },
+                      button({ class: "update-btn", type: "submit" }, i18n.jobsUpdateButton)
+                    ),
+                    form({ method: "POST", action: `/jobs/delete/${encodeURIComponent(job.id)}` },
+                      button({ class: "delete-btn", type: "submit" }, i18n.jobsDeleteButton)
+                    ),
+                    form({ method: "POST", action: `/jobs/status/${encodeURIComponent(job.id)}` },
+                      button({ class: "status-btn", type: "submit", name: "status", value: "CLOSED" }, i18n.jobSetClosed)
+                    )
+                  )
+                : div({ class: "job-actions" },
+                    form({ method: "POST", action: `/jobs/delete/${encodeURIComponent(job.id)}` },
+                      button({ class: "delete-btn", type: "submit" }, i18n.jobsDeleteButton)
+                    )
+                  )
+            )
+          : null,
+        h2(job.title),
+        job.image ? div({ class: 'activity-image-preview' }, img({ src: `/blob/${encodeURIComponent(job.image)}` })) : null,
+        renderCardField(i18n.jobDescription + ':', ''), p(...renderUrl(job.description)),
+        renderSubscribers(job.subscribers),
+        renderCardField(i18n.jobStatus + ':', i18n['jobStatus' + (String(job.status || '').toUpperCase())] || (String(job.status || '').toUpperCase())),
+        renderCardField(i18n.jobLanguages + ':', (job.languages || '').toUpperCase()),
+        renderCardField(i18n.jobType + ':', i18n['jobType' + (String(job.job_type || '').toUpperCase())] || (String(job.job_type || '').toUpperCase())),
+        renderCardField(i18n.jobLocation + ':', (job.location || '').toUpperCase()),
+        renderCardField(i18n.jobTime + ':', i18n['jobTime' + (String(job.job_time || '').toUpperCase())] || (String(job.job_time || '').toUpperCase())),
+        renderCardField(i18n.jobVacants + ':', job.vacants),
+        renderCardField(i18n.jobRequirements + ':', ''), p(...renderUrl(job.requirements)),
+        renderCardField(i18n.jobTasks + ':', ''), p(...renderUrl(job.tasks)),
+        renderCardField(i18n.jobSalary + ':', ''), br(),
+        div({ class: 'card-label' }, h2(`${job.salary} ECO`)), br(),
+        (isOpen && !isAuthor)
+          ? (
+              Array.isArray(job.subscribers) && job.subscribers.includes(userId)
+                ? div({ class: "subscribe-actions" },
+                    form({ method: "POST", action: `/jobs/unsubscribe/${encodeURIComponent(job.id)}` },
+                      button({ class: "filter-btn", type: "submit" }, i18n.jobUnsubscribeButton.toUpperCase())
+                    )
+                  )
+                : div({ class: "subscribe-actions" },
+                    form({ method: "POST", action: `/jobs/subscribe/${encodeURIComponent(job.id)}` },
+                      button({ class: "filter-btn", type: "submit" }, i18n.jobSubscribeButton.toUpperCase())
+                    )
+                  )
+            )
+          : null,
+        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)
+        )
+      )
+    )
+  );
+};

+ 136 - 45
src/views/main_views.js

@@ -242,6 +242,15 @@ const renderMarketLink = () => {
     : '';
 };
 
+const renderJobsLink = () => {
+  const jobsMod = getConfig().modules.jobsMod === 'on';
+  return jobsMod 
+    ? [
+      navLink({ href: "/jobs", emoji: "ꗒ", text: i18n.jobsTitle }),
+      ]
+    : '';
+};
+
 const renderTribesLink = () => {
   const tribesMod = getConfig().modules.tribesMod === 'on';
   return tribesMod 
@@ -453,6 +462,7 @@ const template = (titlePrefix, ...elements) => {
               renderFeedLink(),
               renderPixeliaLink(),
               renderMarketLink(),
+              renderJobsLink(),
               renderTransfersLink(),
               renderBookmarksLink(),
               renderImagesLink(),
@@ -940,8 +950,17 @@ const prefix = section(
     relationship.me
       ? a({ href: `/profile/edit`, class: "btn" }, nbsp, i18n.editProfile)
       : null,
-    a({ href: `/likes/${encodeURIComponent(feedId)}`, class: "btn" }, i18n.viewLikes)
-    )
+    a({ href: `/likes/${encodeURIComponent(feedId)}`, class: "btn" }, i18n.viewLikes),
+     !relationship.me
+        ? a(
+            { 
+              href: `/pm?recipients=${encodeURIComponent(feedId)}`, 
+              class: "btn" 
+            },
+            i18n.pmCreateButton
+          )
+        : null
+      )
    )
   );
 
@@ -1186,19 +1205,58 @@ exports.mentionsView = ({ messages, myFeedId }) => {
   );
 };
 
-exports.privateView = async (input, filter) => {
-  const messages = Array.isArray(input) ? input : input.messages;
+exports.privateView = async (messagesInput, filter) => {
+  const messages = Array.isArray(messagesInput) ? messagesInput : messagesInput.messages;
   const userId = await getUserId();
-  const counts = {
-    inbox: messages.filter(m => m.value.content.to?.includes(userId)).length,
-    sent: messages.filter(m => m.value.content.from === userId).length
-  };
-
   const filtered =
     filter === 'sent' ? messages.filter(m => m.value.content.from === userId) :
     filter === 'inbox' ? messages.filter(m => m.value.content.to?.includes(userId)) :
     messages;
 
+  function header({ sentAt, from, toLinks, botIcon = '', botLabel = '' }) {
+    return div({ class: 'pm-header' },
+      span({ class: 'date-link' }, `${moment(sentAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
+      botIcon || botLabel ? span({ class: 'pm-from' }, `${botIcon} ${botLabel}`) : null,
+      !botIcon && !botLabel
+        ? [
+            span({ class: 'pm-from' },
+              'From: ', a({ href: `/author/${encodeURIComponent(from)}`, class: 'user-link' }, from)
+            ),
+            span({ class: 'pm-to' },
+              'To: ', toLinks
+            )
+          ] : null
+    );
+  }
+
+  function actions({ key, replyId }) {
+    return div({ class: 'pm-actions' },
+      form({ method: 'POST', action: `/inbox/delete/${encodeURIComponent(key)}`, class: 'delete-message-form', style: 'display:inline-block;margin-right:8px;' },
+        button({ type: 'submit', class: 'delete-btn' }, i18n.privateDelete)
+      ),
+      form({ method: 'GET', action: '/pm', style: 'display:inline-block;' },
+        input({ type: 'hidden', name: 'recipients', value: replyId }),
+        button({ type: 'submit', class: 'reply-btn' }, i18n.pmCreateButton || 'Write a PM')
+      )
+    );
+  }
+  
+  function clickableLinks(str) {
+    return str
+      .replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g,
+        (match, userId) =>
+          `<a class="user-link" href="/author/${encodeURIComponent(userId)}">${match}</a>`
+      )
+      .replace(/\/jobs\/([%a-zA-Z0-9/+._=-]+\.sha256)/g,
+        (match, jobId) =>
+          `<a class="job-link" href="/jobs/${jobId}">${match}</a>`
+      )
+      .replace(/\/market\/([%a-zA-Z0-9/+._=-]+\.sha256)/g,
+        (match, itemId) =>
+          `<a class="market-link" href="/market/${itemId}">${match}</a>`
+      );
+  }
+
   return template(
     i18n.private,
     section(
@@ -1206,55 +1264,88 @@ exports.privateView = async (input, filter) => {
         h2(i18n.private),
         p(i18n.privateDescription)
       ),
-	div({ class: 'filters' },
-	  form({ method: 'GET', action: '/inbox' }, [
-	    button({
-	      type: 'submit',
-	      name: 'filter',
-	      value: 'inbox',
-	      class: filter === 'inbox' ? 'filter-btn active' : 'filter-btn'
-	    }, i18n.privateInbox),
-	    button({
-	      type: 'submit',
-	      name: 'filter',
-	      value: 'sent',
-	      class: filter === 'sent' ? 'filter-btn active' : 'filter-btn'
-	    }, i18n.privateSent),
-	    button({
-	      type: 'submit',
-	      name: 'filter',
-	      value: 'create',
-	      class: 'create-button',
-	      formaction: '/pm',
-	      formmethod: 'GET'
-	    }, i18n.pmCreateButton)
-	  ])
-	),
+      div({ class: 'filters' },
+        form({ method: 'GET', action: '/inbox' }, [
+          button({
+            type: 'submit',
+            name: 'filter',
+            value: 'inbox',
+            class: filter === 'inbox' ? 'filter-btn active' : 'filter-btn'
+          }, i18n.privateInbox),
+          button({
+            type: 'submit',
+            name: 'filter',
+            value: 'sent',
+            class: filter === 'sent' ? 'filter-btn active' : 'filter-btn'
+          }, i18n.privateSent),
+          button({
+            type: 'submit',
+            name: 'filter',
+            value: 'create',
+            class: 'create-button',
+            formaction: '/pm',
+            formmethod: 'GET'
+          }, i18n.pmCreateButton)
+        ])
+      ),
       div({ class: 'message-list' },
         filtered.length
           ? filtered.map(msg => {
               const content = msg?.value?.content;
               const author = msg?.value?.author;
-              if (!content || !author) {
+              if (!content || !author)
                 return div({ class: 'malformed-message' }, 'Invalid message');
-              }
               const subject = content.subject || '(no subject)';
               const text = content.text || '';
-              const sentAt = new Date(content.sentAt || msg.timestamp).toLocaleString();
+              const sentAt = new Date(content.sentAt || msg.timestamp);
               const from = content.from;
               const toLinks = (content.to || []).map(addr =>
                 a({ class: 'user-link', href: `/author/${encodeURIComponent(addr)}` }, addr)
               );
-              return div({ class: 'message-item' },
-                p({ class: 'card-footer' },
-                span({ class: 'date-link' }, `${sentAt} ${i18n.performed} `),
-                 a({ href: `/author/${encodeURIComponent(from)}`, class: 'user-link' }, `${from}`)
-                ),
+              let jobMatch = text.match(/has subscribed to your job offer "([^"]+)"/);
+              let jobLinkMatch = text.match(/\/jobs\/([%a-zA-Z0-9/+._-]+\.sha256)/);
+              if (jobMatch && jobLinkMatch) {
+                const jobTitle = jobMatch[1];
+                const jobId = jobLinkMatch[1];
+                return div({ class: 'pm-card job-sub-notification' },
+                  header({ sentAt, from, toLinks, botIcon: '🟡', botLabel: '42-JobsBOT' }),
+                  h2({ class: 'pm-title', style: 'color:#ffe082;' }, 'New subscription to your job offer'),
+                  p(
+                    'Inhabitant with OASIS ID: ',
+                    a({ class: 'user-link', href: `/author/${encodeURIComponent(from)}` }, from),
+                    ' has subscribed to your job offer ',
+                    a({ class: "job-link", href: `/jobs/${encodeURIComponent(decodeURIComponent(jobId))}` }, `"${jobTitle}"`)
+                  ),
+                  actions({ key: msg.key, replyId: from })
+                );
+              }
+              let saleMatch = subject.match(/item "([^"]+)" has been sold/);
+              let buyerMatch = text.match(/OASIS ID: ([\w=/+.-]+)/);
+              let priceMatch = text.match(/for: \$([\d.]+)/);
+              let marketIdMatch = text.match(/\/market\/([%a-zA-Z0-9/+._-]+\.sha256)/);
+              if (saleMatch && buyerMatch && priceMatch && marketIdMatch) {
+                const itemTitle = saleMatch[1];
+                const buyerId = buyerMatch[1];
+                const price = priceMatch[1];
+                const marketId = marketIdMatch[1];
+                return div({ class: 'pm-card market-sold-notification' },
+                  header({ sentAt, from, toLinks, botIcon: '💰', botLabel: '42-MarketBOT' }),
+                  h2({ class: 'pm-title', style: 'color:#80cbc4;' }, 'Item Sold'),
+                  p(
+                    'Your item ',
+                    a({ class: 'market-link', href: `/market/${encodeURIComponent(decodeURIComponent(marketId))}` }, `"${itemTitle}"`),
+                    ' has been sold to ',
+                    a({ class: 'user-link', href: `/author/${encodeURIComponent(buyerId)}` }, buyerId),
+                    ` for $${price}.`
+                  ),
+                  actions({ key: msg.key, replyId: buyerId })
+                );
+              }
+              return div({ class: 'pm-card normal-pm' },
+                header({ sentAt, from, toLinks }),
                 h2(subject),
                 p({ class: 'message-text' }, ...renderUrl(text)),
-                form({ method: 'POST', action: `/inbox/delete/${encodeURIComponent(msg.key)}`, class: 'delete-message-form' },
-                  button({ type: 'submit', class: 'delete-btn' }, i18n.privateDelete)
-                )
+                actions({ key: msg.key, replyId: from })
               );
             })
           : p({ class: 'empty' }, i18n.noPrivateMessages)
@@ -1464,7 +1555,7 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
       form(
         { action, method: "post" },
         [
-          input({ type: "hidden", name: "text", value: renderedText }), // Pass the formatted text
+          input({ type: "hidden", name: "text", value: renderedText }),
           input({ type: "hidden", name: "contentWarning", value: contentWarning || "" }),
           input({ type: "hidden", name: "mentions", value: JSON.stringify(mentions) }),
           button({ type: "submit" }, i18n.publish)

+ 307 - 349
src/views/market_view.js

@@ -13,363 +13,321 @@ const renderCardField = (labelText, value) =>
   );
 
 exports.marketView = async (items, filter, itemToEdit = null) => {
-  const list = Array.isArray(items) ? items : [];
-  let title = i18n.marketAllSectionTitle;
+    const list = Array.isArray(items) ? items : [];
+    let title = i18n.marketAllSectionTitle;
 
-  switch (filter) {
-    case 'mine':
-      title = i18n.marketMineSectionTitle;
-      break;
-    case 'create':
-      title = i18n.marketCreateSectionTitle;
-      break;
-    case 'edit':
-      title = i18n.marketUpdateSectionTitle;
-      break;
-  }
+    switch (filter) {
+        case 'mine':
+            title = i18n.marketMineSectionTitle;
+            break;
+        case 'create':
+            title = i18n.marketCreateSectionTitle;
+            break;
+        case 'edit':
+            title = i18n.marketUpdateSectionTitle;
+            break;
+    }
 
-  let filtered = [];
+    let filtered = [];
+    switch (filter) {
+        case 'all':         filtered = list; break;
+        case 'mine':        filtered = list.filter(e => e.seller === userId); break;
+        case 'exchange':    filtered = list.filter(e => e.item_type === 'exchange' && e.status === 'FOR SALE'); break;
+        case 'auctions':    filtered = list.filter(e => e.item_type === 'auction' && e.status === 'FOR SALE'); break;
+        case 'new':         filtered = list.filter(e => e.item_status === 'NEW' && e.status === 'FOR SALE'); break;
+        case 'used':        filtered = list.filter(e => e.item_status === 'USED' && e.status === 'FOR SALE'); break;
+        case 'broken':      filtered = list.filter(e => e.item_status === 'BROKEN' && e.status === 'FOR SALE'); break;
+        case 'for sale':    filtered = list.filter(e => e.status === 'FOR SALE'); break;
+        case 'sold':        filtered = list.filter(e => e.status === 'SOLD'); break;
+        case 'discarded':   filtered = list.filter(e => e.status === 'DISCARDED'); break;
+        case 'recent':
+            const oneDayAgo = moment().subtract(1, 'days').toISOString();
+            filtered = list.filter(e => e.status === 'FOR SALE' && e.createdAt >= oneDayAgo);
+            break;
+        default: break;
+    }
+    filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
 
-  switch (filter) {
-    case 'all':
-      filtered = list;
-      break;
-    case 'mine':
-      filtered = list.filter(e => e.seller === userId);
-      break;
-    case 'exchange':
-      filtered = list.filter(e => e.item_type === 'exchange' && e.status === 'FOR SALE');
-      break;
-    case 'auctions':
-      filtered = list.filter(e => e.item_type === 'auction' && e.status === 'FOR SALE');
-      break;
-    case 'new':
-      filtered = list.filter(e => e.item_status === 'NEW' && e.status === 'FOR SALE');
-      break;
-    case 'used':
-      filtered = list.filter(e => e.item_status === 'USED' && e.status === 'FOR SALE');
-      break;
-    case 'broken':
-      filtered = list.filter(e => e.item_status === 'BROKEN' && e.status === 'FOR SALE');
-      break;
-    case 'for sale':
-      filtered = list.filter(e => e.status === 'FOR SALE');
-      break;
-    case 'sold':
-      filtered = list.filter(e => e.status === 'SOLD');
-      break;
-    case 'discarded':
-      filtered = list.filter(e => e.status === 'DISCARDED');
-      break;
-    case 'recent':
-      const oneDayAgo = moment().subtract(1, 'days').toISOString();
-      filtered = list.filter(e => e.status === 'FOR SALE' && e.createdAt >= oneDayAgo);
-      break;
-    default:
-      break;
-  }
-
-  filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-
-  return template(
-    title,
-    section(
-      div({ class: "tags-header" },
-        h2(i18n.marketTitle),
-        p(i18n.marketDescription)
-      ),
-      div({ class: "filters" },
-        form({ method: "GET", action: "/market" },
-          button({ type:"submit", name:"filter", value:"all", class:filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAll),
-          button({ type:"submit", name:"filter", value:"mine", class:filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterMine),
-          button({ type:"submit", name:"filter", value:"exchange", class:filter === 'exchange' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterItems),
-          button({ type:"submit", name:"filter", value:"auctions", class:filter === 'auctions' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAuctions),
-          button({ type:"submit", name:"filter", value:"new", class:filter === 'new' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterNew),
-          button({ type:"submit", name:"filter", value:"used", class:filter === 'used' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterUsed),
-          button({ type:"submit", name:"filter", value:"broken", class:filter === 'broken' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterBroken),
-          button({ type:"submit", name:"filter", value:"for sale", class:filter === 'for sale' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterForSale),
-          button({ type:"submit", name:"filter", value:"sold", class:filter === 'sold' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterSold),
-          button({ type:"submit", name:"filter", value:"discarded", class:filter === 'discarded' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterDiscarded),
-          button({ type:"submit", name:"filter", value:"recent", class:filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterRecent),
-          button({ type:"submit", name:"filter", value:"create", class:"create-button" }, i18n.marketCreateButton)
-        )
-      )
-    ),
-    section(
-      (filter === 'create' || filter === 'edit') ? (
-        div({ class: "market-form" },
-          form({
-            action: filter === 'edit' ? `/market/update/${encodeURIComponent(itemToEdit.id)}` : "/market/create",
-            method: "POST",
-            enctype: "multipart/form-data"
-          },
-            label(i18n.marketItemType), br(),
-            select({ name: "item_type", id: "item_type", required: true },
-              option({ value: "auction", selected: itemToEdit?.item_type === 'auction' ? true : false }, "Auction"),
-              option({ value: "exchange", selected: itemToEdit?.item_type === 'exchange' ? true : false }, "Exchange")
-            ), br(), br(),
-            
-            label(i18n.marketItemTitle), br(),
-            input({ type: "text", name: "title", id: "title", value: itemToEdit?.title || '', required: true }), br(), br(),
-            
-            label(i18n.marketItemDescription), br(),
-            textarea({ name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, rows:"6", innerHTML: itemToEdit?.description || '', required: true }), br(), br(),
-            
-            label(i18n.marketCreateFormImageLabel), br(),
-            input({ type: "file", name: "image", id: "image", accept: "image/*" }), br(), br(),
-            
-            label(i18n.marketItemStatus), br(),
-            select({ name: "item_status", id: "item_status" },
-              option({ value: "BROKEN", selected: itemToEdit?.item_status === 'BROKEN' ? true : false }, "BROKEN"),
-              option({ value: "USED", selected: itemToEdit?.item_status === 'USED' ? true : false }, "USED"),
-              option({ value: "NEW", selected: itemToEdit?.item_status === 'NEW' ? true : false }, "NEW")
-            ), br(), br(),
-            
-            label(i18n.marketItemStock), br(),
-	    input({ 
-	      type: "number", 
-	      name: "stock", 
-	      id: "stock", 
-	      value: itemToEdit?.stock || 1, 
-	      required: true, 
-	      min: "1", 
-	      step: "1" 
-	    }), br(), br(),
-            
-            label(i18n.marketItemPrice), br(),
-            input({ type: "number", name: "price", id: "price", value: itemToEdit?.price || '', required: true, step: "0.000001", min: "0.000001" }), br(), br(),
-            
-            label(i18n.marketItemTags), br(),
-            input({ type: "text", name: "tags", id: "tags", placeholder: i18n.marketItemTagsPlaceholder, value: itemToEdit?.tags?.join(', ') || '' }), br(), br(),
-            
-            label(i18n.marketItemDeadline), br(),
-            input({
-              type: "datetime-local",
-              name: "deadline",
-              id: "deadline",
-              required: true,
-              min: moment().format("YYYY-MM-DDTHH:mm"),
-              value: itemToEdit?.deadline ? moment(itemToEdit.deadline).format("YYYY-MM-DDTHH:mm") : ''
-            }), br(), br(),
-            
-            label(i18n.marketItemIncludesShipping), br(),
-            input({ type: "checkbox", name: "includesShipping", id: "includesShipping", checked: itemToEdit?.includesShipping }), br(), br(),
-
-            button({ type: "submit" }, filter === 'edit' ? i18n.marketUpdateButton : i18n.marketCreateButton)
-          )
+    return template(
+        title,
+        section(
+            div({ class: "tags-header" },
+                h2(i18n.marketTitle),
+                p(i18n.marketDescription)
+            ),
+            div({ class: "filters" },
+                form({ method: "GET", action: "/market" },
+                    button({ type: "submit", name: "filter", value: "all", class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAll),
+                    button({ type: "submit", name: "filter", value: "mine", class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterMine),
+                    button({ type: "submit", name: "filter", value: "exchange", class: filter === 'exchange' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterItems),
+                    button({ type: "submit", name: "filter", value: "auctions", class: filter === 'auctions' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAuctions),
+                    button({ type: "submit", name: "filter", value: "new", class: filter === 'new' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterNew),
+                    button({ type: "submit", name: "filter", value: "used", class: filter === 'used' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterUsed),
+                    button({ type: "submit", name: "filter", value: "broken", class: filter === 'broken' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterBroken),
+                    button({ type: "submit", name: "filter", value: "for sale", class: filter === 'for sale' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterForSale),
+                    button({ type: "submit", name: "filter", value: "sold", class: filter === 'sold' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterSold),
+                    button({ type: "submit", name: "filter", value: "discarded", class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterDiscarded),
+                    button({ type: "submit", name: "filter", value: "recent", class: filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterRecent),
+                    button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.marketCreateButton)
+                )
+            )
+        ),
+        section(
+            (filter === 'create' || filter === 'edit') ? (
+                div({ class: "market-form" },
+                    form({
+                        action: filter === 'edit' ? `/market/update/${encodeURIComponent(itemToEdit.id)}` : "/market/create",
+                        method: "POST",
+                        enctype: "multipart/form-data"
+                    },
+                        label(i18n.marketItemType), br(),
+                        select({ name: "item_type", id: "item_type", required: true },
+                            option({ value: "auction", selected: itemToEdit?.item_type === 'auction' ? true : false }, "Auction"),
+                            option({ value: "exchange", selected: itemToEdit?.item_type === 'exchange' ? true : false }, "Exchange")
+                        ), br(), br(),
+                        label(i18n.marketItemTitle), br(),
+                        input({ type: "text", name: "title", id: "title", value: itemToEdit?.title || '', required: true }), br(), br(),
+                        label(i18n.marketItemDescription), br(),
+                        textarea({ name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, rows: "6", innerHTML: itemToEdit?.description || '', required: true }), br(), br(),
+                        label(i18n.marketCreateFormImageLabel), br(),
+                        input({ type: "file", name: "image", id: "image", accept: "image/*" }), br(), br(),
+                        label(i18n.marketItemStatus), br(),
+                        select({ name: "item_status", id: "item_status" },
+                            option({ value: "BROKEN", selected: itemToEdit?.item_status === 'BROKEN' ? true : false }, "BROKEN"),
+                            option({ value: "USED", selected: itemToEdit?.item_status === 'USED' ? true : false }, "USED"),
+                            option({ value: "NEW", selected: itemToEdit?.item_status === 'NEW' ? true : false }, "NEW")
+                        ), br(), br(),
+                        label(i18n.marketItemStock), br(),
+                        input({
+                            type: "number",
+                            name: "stock",
+                            id: "stock",
+                            value: itemToEdit?.stock || 1,
+                            required: true,
+                            min: "1",
+                            step: "1"
+                        }), br(), br(),
+                        label(i18n.marketItemPrice), br(),
+                        input({ type: "number", name: "price", id: "price", value: itemToEdit?.price || '', required: true, step: "0.000001", min: "0.000001" }), br(), br(),
+                        label(i18n.marketItemTags), br(),
+                        input({ type: "text", name: "tags", id: "tags", placeholder: i18n.marketItemTagsPlaceholder, value: itemToEdit?.tags?.join(', ') || '' }), br(), br(),
+                        label(i18n.marketItemDeadline), br(),
+                        input({
+                            type: "datetime-local",
+                            name: "deadline",
+                            id: "deadline",
+                            required: true,
+                            min: moment().format("YYYY-MM-DDTHH:mm"),
+                            value: itemToEdit?.deadline ? moment(itemToEdit.deadline).format("YYYY-MM-DDTHH:mm") : ''
+                        }), br(), br(),
+                        label(i18n.marketItemIncludesShipping), br(),
+                        input({ type: "checkbox", name: "includesShipping", id: "includesShipping", checked: itemToEdit?.includesShipping }), br(), br(),
+                        button({ type: "submit" }, filter === 'edit' ? i18n.marketUpdateButton : i18n.marketCreateButton)
+                    )
+                )
+            ) : (
+                div({ class: "market-grid" },
+                    filtered.length > 0
+                        ? filtered.map((item, index) =>
+                            div({ class: "market-item" },
+                                div({ class: "market-card left-col" },
+                                    form({ method: "GET", action: `/market/${encodeURIComponent(item.id)}` },
+                                        button({ class: "filter-btn", type: "submit" }, i18n.viewDetails)
+                                    ),
+                                    h2({ class: "market-card type" }, `${i18n.marketItemType}: ${item.item_type.toUpperCase()}`),
+                                    h2(item.title),
+                                    renderCardField(`${i18n.marketItemStatus}:`, item.status),
+                                    item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, moment(item.deadline).format('YYYY/MM/DD HH:mm:ss')) : null,
+                                    br, br,
+                                    div({ class: "market-card image" },
+                                        item.image
+                                            ? img({ src: `/blob/${encodeURIComponent(item.image)}` })
+                                            : img({ src: '/assets/images/default-market.png', alt: item.title })
+                                    ),
+                                    p(...renderUrl(item.description)),
+                                    item.tags && item.tags.filter(Boolean).length
+                                        ? div({ class: 'card-tags' }, item.tags.filter(Boolean).map(tag =>
+                                            a({ class: "tag-link", href: `/search?query=%23${encodeURIComponent(tag)}` },
+                                                `#${tag}`)
+                                        ))
+                                        : null,
+                                ),
+                                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.marketItemCondition}:`, item.item_status),
+                                    renderCardField(`${i18n.marketItemIncludesShipping}:`, item.includesShipping ? i18n.YESLabel : i18n.NOLabel),
+                                    renderCardField(`${i18n.marketItemSeller}:`),
+                                    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),
+                                            table({ class: 'auction-bid-table' },
+                                                tr(
+                                                    th(i18n.marketAuctionBidTime),
+                                                    th(i18n.marketAuctionUser),
+                                                    th(i18n.marketAuctionBidAmount)
+                                                ),
+                                                item.auctions_poll.map(bid => {
+                                                    const [userId, bidAmount, bidTime] = bid.split(':');
+                                                    return tr(
+                                                        td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
+                                                        td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
+                                                        td(`${parseFloat(bidAmount).toFixed(6)} ECO`)
+                                                    );
+                                                })
+                                            )
+                                        )
+                                        : null,
+                                    div({ class: "market-card buttons" },
+                                        (item.seller === userId) ? [
+                                            form({ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
+                                                button({ class: "delete-btn", type: "submit" }, i18n.marketActionsDelete)
+                                            ),
+                                            (item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.auctions_poll.length === 0)
+                                                ? form({ method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` },
+                                                    button({ class: "update-btn", type: "submit" }, i18n.marketActionsUpdate)
+                                                )
+                                                : null,
+                                            (item.status === 'FOR SALE')
+                                                ? form({ method: "POST", action: `/market/sold/${encodeURIComponent(item.id)}` },
+                                                    button({ class: "sold-btn", type: "submit" }, i18n.marketActionsSold)
+                                                )
+                                                : null
+                                        ] : [
+                                            (item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.item_type === 'auction')
+                                                ? form({ method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
+                                                    input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
+                                                    br,
+                                                    button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
+                                                )
+                                                : null,
+                                            (item.status === 'FOR SALE' && item.item_type !== 'auction' && item.seller !== userId)
+                                                ? form({ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
+                                                    input({ type: "hidden", name: "buyerId", value: userId }),
+                                                    button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
+                                                )
+                                                : null
+                                        ]
+                                    )
+                                )
+                            )
+                        )
+                        : p(i18n.marketNoItems)
+                )
+            )
         )
-      ) : (
-	div({ class: "market-grid" },
-	  filtered.length > 0
-	    ? filtered.map((item, index) =>     
-	      div({ class: "market-item" }, 
-		div({ class: "market-card left-col" },
-		  form({ method: "GET", action: `/market/${encodeURIComponent(item.id)}` },
-		      button({ class: "filter-btn", type: "submit" }, i18n.viewDetails)
-		  ),
-		  h2({ class: "market-card type" }, `${i18n.marketItemType}: ${item.item_type.toUpperCase()}`),
-		  h2(item.title),
-		  renderCardField(`${i18n.marketItemStatus}:`, item.status),
-		  item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, moment(item.deadline).format('YYYY/MM/DD HH:mm:ss')) : null,
-		  br,br,
-		  div({ class: "market-card image" },
-		    item.image
-		      ? img({ src: `/blob/${encodeURIComponent(item.image)}` })
-		      : img({ src: '/assets/images/default-market.png', alt: item.title })
-		  ),
-		  p(...renderUrl(item.description)),
-		  item.tags && item.tags.filter(Boolean).length
-		    ? div({ class: 'card-tags' }, item.tags.filter(Boolean).map(tag =>
-		        a({ class: "tag-link", href: `/search?query=%23${encodeURIComponent(tag)}` },
-		          `#${tag}`)
-		      ))
-		    : null,
-		),
-		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.marketItemCondition}:`, item.item_status),
-		  renderCardField(`${i18n.marketItemIncludesShipping}:`, item.includesShipping ? i18n.YESLabel : i18n.NOLabel),
-		  renderCardField(`${i18n.marketItemSeller}:`),
-		  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),
-		      table({ class: 'auction-bid-table' },
-		        tr(
-		          th(i18n.marketAuctionBidTime),
-		          th(i18n.marketAuctionUser),
-		          th(i18n.marketAuctionBidAmount)
-		        ),
-		        item.auctions_poll.map(bid => {
-		          const [userId, bidAmount, bidTime] = bid.split(':');
-		          return tr(
-		            td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
-		            td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
-		            td(`${parseFloat(bidAmount).toFixed(6)} ECO`)
-		          );
-		        })
-		      )
-		    )
-		  : null,
-		  div({ class: "market-card buttons" },
-		    (filter === 'mine') ? [
-		      form({ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
-		        button({ class: "delete-btn", type: "submit" }, i18n.marketActionsDelete)
-		      ),
-		      (item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.auctions_poll.length === 0) 
-		        ? form({ method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` },
-		        button({ class: "update-btn", type: "submit" }, i18n.marketActionsUpdate)
-		      )
-		        : null,
-		      (item.status === 'FOR SALE') 
-		        ? form({ method: "POST", action: `/market/sold/${encodeURIComponent(item.id)}` },
-		        button({ class: "sold-btn", type: "submit" }, i18n.marketActionsSold)
-		      )
-		        : null
-		    ] : [
-		      (item.status !== 'SOLD' && item.status !== 'DISCARDED')
-		        ? (item.item_type === 'auction'
-		          ? form({ method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
-		              input({ type: "number", name: "bidAmount", step:"0.000001", min:"0.000001", placeholder: i18n.marketYourBid, required: true }),
-		              br,
-		              button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
-		            )
-		          : form({ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
-		              input({ type: "hidden", name: "buyerId", value: userId }),
-		              button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
-		            )
-		        )
-		        : null
-		    ]
-		  )
-		)
-	      )
-	    )
-	  : p(i18n.marketNoItems)
-	)
-      )
-    )
-  );
+    );
 };
 
 exports.singleMarketView = async (item, filter) => {
-  return template(
-    item.title,
-    section(
-      div({ class: "filters" },
-        form({ method: 'GET', action: '/market' },
-          button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAll),
-          button({ type: 'submit', name: 'filter', value: 'mine', class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterMine),
-          button({ type: 'submit', name: 'filter', value: 'exchange', class: filter === 'exchange' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterItems),
-          button({ type: 'submit', name: 'filter', value: 'auctions', class: filter === 'auctions' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAuctions),
-          button({ type: 'submit', name: 'filter', value: 'new', class: filter === 'new' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterNew),
-          button({ type: 'submit', name: 'filter', value: 'used', class: filter === 'used' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterUsed),
-          button({ type: 'submit', name: 'filter', value: 'broken', class: filter === 'broken' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterBroken),
-          button({ type: 'submit', name: 'filter', value: 'for sale', class: filter === 'for sale' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterForSale),
-          button({ type: 'submit', name: 'filter', value: 'sold', class: filter === 'sold' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterSold),
-          button({ type: 'submit', name: 'filter', value: 'discarded', class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterDiscarded),
-          button({ type: 'submit', name: 'filter', value: 'recent', class: filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterRecent),
-          button({ type: 'submit', name: 'filter', value: 'create', class: "create-button" }, i18n.marketCreateButton)
-        )
-      ),
-      div({ class: "tags-header" },
-        h2(item.title),
-        renderCardField(`${i18n.marketItemType}:`, `${item.item_type.toUpperCase()}`),
-        renderCardField(`${i18n.marketItemStatus}:`, item.status),
-        renderCardField(`${i18n.marketItemCondition}:`, item.item_status),
-        br,
-        div({ class: "market-item image" },
-          item.image
-            ? img({ src: `/blob/${encodeURIComponent(item.image)}` })
-            : img({ src: '/assets/images/default-market.png', alt: item.title })
-        ),
-        renderCardField(`${i18n.marketItemDescription}:`),
-        p(...renderUrl(item.description)),   
-        item.tags && item.tags.length
-          ? div({ class: 'card-tags' },
-              item.tags.map(tag =>
-                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
-              )
-            )
-          : null,
-          br,
-        renderCardField(`${i18n.marketItemPrice}:`),
-        br,
-        div({ class: 'card-label' },
-          h2(`${item.price} ECO`),
-          ),
-        br,
-        renderCardField(`${i18n.marketItemStock}:`, item.stock > 0 ? item.stock : i18n.marketOutOfStock),
-        renderCardField(`${i18n.marketItemIncludesShipping}:`, `${item.includesShipping ? i18n.YESLabel : i18n.NOLabel}`),
-        item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, `${moment(item.deadline).format('YYYY/MM/DD HH:mm:ss')}`) : null,
-        renderCardField(`${i18n.marketItemSeller}:`),
-        br,
-	div({ class: 'card-field' },
-	  a({ class: 'user-link', href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)
-	)
-      ),
-      item.item_type === 'auction' 
-        ? div({ class: "auction-info" },
-            p({ class: "auction-bid-text" }, i18n.marketAuctionBids),
-            table({ class: 'auction-bid-table' },
-              tr(
-                th(i18n.marketAuctionBidTime),
-                th(i18n.marketAuctionUser),
-                th(i18n.marketAuctionBidAmount)
-              ),
-              item.auctions_poll.map(bid => {
-                const [userId, bidAmount, bidTime] = bid.split(':');
-                return tr(
-                  td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
-                  td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
-                  td(`${parseFloat(bidAmount).toFixed(6)} ECO`)
-                );
-              })
+    return template(
+        item.title,
+        section(
+            div({ class: "filters" },
+                form({ method: 'GET', action: '/market' },
+                    button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAll),
+                    button({ type: 'submit', name: 'filter', value: 'mine', class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterMine),
+                    button({ type: 'submit', name: 'filter', value: 'exchange', class: filter === 'exchange' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterItems),
+                    button({ type: 'submit', name: 'filter', value: 'auctions', class: filter === 'auctions' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAuctions),
+                    button({ type: 'submit', name: 'filter', value: 'new', class: filter === 'new' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterNew),
+                    button({ type: 'submit', name: 'filter', value: 'used', class: filter === 'used' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterUsed),
+                    button({ type: 'submit', name: 'filter', value: 'broken', class: filter === 'broken' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterBroken),
+                    button({ type: 'submit', name: 'filter', value: 'for sale', class: filter === 'for sale' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterForSale),
+                    button({ type: 'submit', name: 'filter', value: 'sold', class: filter === 'sold' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterSold),
+                    button({ type: 'submit', name: 'filter', value: 'discarded', class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterDiscarded),
+                    button({ type: 'submit', name: 'filter', value: 'recent', class: filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterRecent),
+                    button({ type: 'submit', name: 'filter', value: 'create', class: "create-button" }, i18n.marketCreateButton)
+                )
             ),
-            item.status !== 'SOLD' && item.status !== 'DISCARDED'
-              ? form({ method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
-                  input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
-                  br(),
-                  button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
+            div({ class: "tags-header" },
+                h2(item.title),
+                renderCardField(`${i18n.marketItemType}:`, `${item.item_type.toUpperCase()}`),
+                renderCardField(`${i18n.marketItemStatus}:`, item.status),
+                renderCardField(`${i18n.marketItemCondition}:`, item.item_status),
+                br,
+                div({ class: "market-item image" },
+                    item.image
+                        ? img({ src: `/blob/${encodeURIComponent(item.image)}` })
+                        : img({ src: '/assets/images/default-market.png', alt: item.title })
+                ),
+                renderCardField(`${i18n.marketItemDescription}:`),
+                p(...renderUrl(item.description)),
+                item.tags && item.tags.length
+                    ? div({ class: 'card-tags' },
+                        item.tags.map(tag =>
+                            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+                        )
+                    )
+                    : null,
+                br,
+                renderCardField(`${i18n.marketItemPrice}:`),
+                br,
+                div({ class: 'card-label' },
+                    h2(`${item.price} ECO`),
+                ),
+                br,
+                renderCardField(`${i18n.marketItemStock}:`, item.stock > 0 ? item.stock : i18n.marketOutOfStock),
+                renderCardField(`${i18n.marketItemIncludesShipping}:`, `${item.includesShipping ? i18n.YESLabel : i18n.NOLabel}`),
+                item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, `${moment(item.deadline).format('YYYY/MM/DD HH:mm:ss')}`) : null,
+                renderCardField(`${i18n.marketItemSeller}:`),
+                br,
+                div({ class: 'card-field' },
+                    a({ class: 'user-link', href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)
+                )
+            ),
+            item.item_type === 'auction'
+                ? div({ class: "auction-info" },
+                    p({ class: "auction-bid-text" }, i18n.marketAuctionBids),
+                    table({ class: 'auction-bid-table' },
+                        tr(
+                            th(i18n.marketAuctionBidTime),
+                            th(i18n.marketAuctionUser),
+                            th(i18n.marketAuctionBidAmount)
+                        ),
+                        item.auctions_poll.map(bid => {
+                            const [userId, bidAmount, bidTime] = bid.split(':');
+                            return tr(
+                                td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
+                                td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
+                                td(`${parseFloat(bidAmount).toFixed(6)} ECO`)
+                            );
+                        })
+                    ),
+                    item.status !== 'SOLD' && item.status !== 'DISCARDED'
+                        ? form({ method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
+                            input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
+                            br(),
+                            button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
+                        )
+                        : null
                 )
-              : null
-          )
-        : null,
-      div({ class: "market-item actions" },
-        (filter === 'mine' && item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.seller === userId && item.item_type !== 'auction') ||
-        (item.status === 'FOR SALE' && item.item_type !== 'auction' && item.seller === userId) ||
-        (item.status === 'FOR SALE' && item.item_type === 'exchange') ||
-        (item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.item_type !== 'auction' && item.seller !== userId)
-          ? [
-              (filter === 'mine' && item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.seller === userId && item.item_type !== 'auction')
-                ? form({ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
-                    button({ class: "delete-btn", type: "submit" }, i18n.marketActionsDelete)
-                  )
-                : null,
-              (item.status === 'FOR SALE' && item.item_type !== 'auction' && item.seller === userId)
-                ? form({ method: "POST", action: `/market/sold/${encodeURIComponent(item.id)}` },
-                    button({ class: "sold-btn", type: "submit" }, i18n.marketActionsSold)
-                  )
-                : null,
-              (item.status === 'FOR SALE' && item.item_type === 'exchange')
-                ? form({ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
-                    button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
-                  )
                 : null,
-              (item.status !== 'SOLD' && item.status !== 'DISCARDED' && 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
-            ]
-          : 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
+		)
+        )
+    );
 };

+ 1 - 0
src/views/modules_view.js

@@ -17,6 +17,7 @@ const modulesView = () => {
     { name: 'governance', label: i18n.modulesGovernanceLabel, description: i18n.modulesGovernanceDescription },
     { name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription },
     { name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
+    { name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
     { name: 'legacy', label: i18n.modulesLegacyLabel, description: i18n.modulesLegacyDescription },
     { name: 'latest', label: i18n.modulesLatestLabel, description: i18n.modulesLatestDescription },
     { name: 'market', label: i18n.modulesMarketLabel, description: i18n.modulesMarketDescription },

+ 8 - 2
src/views/pm_view.js

@@ -1,7 +1,7 @@
 const { div, h2, p, section, button, form, input, textarea, br, label } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 
-exports.pmView = async () => {
+exports.pmView = async (initialRecipients = '') => {
   const title = i18n.pmSendTitle;
   const description = i18n.pmDescription;
 
@@ -17,7 +17,13 @@ exports.pmView = async () => {
           form({ method: "POST", action: "/pm" },
             label({ for: "recipients" }, i18n.pmRecipients),
             br(),
-            input({ type: "text", name: "recipients", placeholder: i18n.pmRecipientsHint, required: true }),
+            input({
+              type: "text",
+              name: "recipients",
+              placeholder: i18n.pmRecipientsHint,
+              required: true,
+              value: initialRecipients
+            }),
             br(),
             label({ for: "subject" }, i18n.pmSubject),
             br(),

+ 18 - 0
src/views/settings_view.js

@@ -132,6 +132,24 @@ const settingsView = ({ version, aiPrompt }) => {
         )
       )
     ),
+    section(
+      div({ class: "tags-header" },
+      h2(i18n.ssbLogStream),
+      p(i18n.ssbLogStreamDescription),
+      form(
+        { action: "/settings/ssb-logstream", method: "POST" },
+        input({
+          type: "number",
+          id: "ssb_log_limit",
+          name: "ssb_log_limit",
+          min: 1,
+          max: 100000,
+          value: currentConfig.ssbLogStream?.limit || 1000
+        }), br(),br(),
+        button({ type: "submit" }, i18n.saveSettings)
+      )
+     )
+    ),
     section(
       div({ class: "tags-header" },
         h2(i18n.indexes),

+ 19 - 1
src/views/stats_view.js

@@ -7,7 +7,7 @@ exports.statsView = (stats, filter) => {
   const modes = ['ALL', 'MINE', 'TOMBSTONE'];
   const types = [
     'bookmark', 'event', 'task', 'votes', 'report', 'feed',
-    'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe', 'market'
+    'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe', 'market', 'forum', 'job'
   ];
   const totalContent = types.reduce((sum, t) => sum + (stats.content[t] || 0), 0);
   const totalOpinions = types.reduce((sum, t) => sum + (stats.opinions[t] || 0), 0);
@@ -63,6 +63,15 @@ exports.statsView = (stats, filter) => {
             div({ style: blockStyle },
               h2(`${i18n.statsDiscoveredMarket}: ${stats.content.market}`)
             ),
+            div({ style: blockStyle },
+              h2(`${i18n.statsDiscoveredJob}: ${stats.content.job}`)
+            ),
+            div({ style: blockStyle },
+              h2(`${i18n.statsDiscoveredTransfer}: ${stats.content.transfer}`)
+            ),
+            div({ style: blockStyle },
+              h2(`${i18n.statsDiscoveredForum}: ${stats.content.forum}`)
+            ),
             div({ style: blockStyle },
               h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`),
               ul(types.map(t =>
@@ -89,6 +98,15 @@ exports.statsView = (stats, filter) => {
               div({ style: blockStyle },
                 h2(`${i18n.statsYourMarket}: ${stats.content.market}`)
               ),
+              div({ style: blockStyle },
+                h2(`${i18n.statsYourJob}: ${stats.content.job}`)
+              ),
+              div({ style: blockStyle },
+                h2(`${i18n.statsYourTransfer}: ${stats.content.transfer}`)
+              ),
+              div({ style: blockStyle },
+                h2(`${i18n.statsYourForum}: ${stats.content.forum}`)
+              ),
               div({ style: blockStyle },
                 h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
                 ul(types.map(t =>

+ 3 - 3
src/views/task_view.js

@@ -55,7 +55,7 @@ const renderTaskItem = (task, filter, userId) => {
       )
     ),
     br,
-    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}`)
@@ -205,7 +205,7 @@ exports.singleTaskView = async (task, filter) => {
               : i18n.noAssignees
           )
         ),
-        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}`)
@@ -214,7 +214,7 @@ exports.singleTaskView = async (task, filter) => {
           : null
       ),
       div({ class: "task-actions" },
-        form({ method: "POST", action: `/tasks/attend/${encodeURIComponent(task.id)}` },
+        form({ method: "POST", action: `/tasks/assign/${encodeURIComponent(task.id)}` },
           button({ type: "submit" },
             task.assignees.includes(userId)
               ? i18n.taskUnassignButton