Kaynağa Gözat

Oasis release 0.6.2

psy 2 gün önce
ebeveyn
işleme
6117e0636d
45 değiştirilmiş dosya ile 12597 ekleme ve 6665 silme
  1. 2 1
      README.md
  2. 28 0
      docs/CHANGELOG.md
  3. 1327 844
      src/backend/backend.js
  4. 118 0
      src/backend/media-favorites.js
  5. 321 24
      src/client/assets/styles/style.css
  6. 232 69
      src/client/assets/translations/oasis_en.js
  7. 279 116
      src/client/assets/translations/oasis_es.js
  8. 306 143
      src/client/assets/translations/oasis_eu.js
  9. 267 104
      src/client/assets/translations/oasis_fr.js
  10. 2 1
      src/configs/config-manager.js
  11. 7 0
      src/configs/media-favorites.json
  12. 2 1
      src/configs/oasis-config.json
  13. 281 146
      src/models/audios_model.js
  14. 305 173
      src/models/bookmarking_model.js
  15. 280 124
      src/models/documents_model.js
  16. 179 167
      src/models/events_model.js
  17. 143 0
      src/models/favorites_model.js
  18. 278 148
      src/models/images_model.js
  19. 468 263
      src/models/jobs_model.js
  20. 485 251
      src/models/market_model.js
  21. 364 267
      src/models/projects_model.js
  22. 163 39
      src/models/reports_model.js
  23. 79 47
      src/models/tasks_model.js
  24. 285 199
      src/models/transfers_model.js
  25. 272 132
      src/models/videos_model.js
  26. 9 4
      src/models/votes_model.js
  27. 1 1
      src/server/package-lock.json
  28. 1 1
      src/server/package.json
  29. 386 221
      src/views/audio_view.js
  30. 424 237
      src/views/bookmark_view.js
  31. 381 208
      src/views/document_view.js
  32. 419 272
      src/views/event_view.js
  33. 144 0
      src/views/favorites_view.js
  34. 437 244
      src/views/image_view.js
  35. 509 326
      src/views/jobs_view.js
  36. 67 4
      src/views/main_views.js
  37. 682 386
      src/views/market_view.js
  38. 1 0
      src/views/modules_view.js
  39. 628 495
      src/views/projects_view.js
  40. 572 211
      src/views/report_view.js
  41. 1 0
      src/views/settings_view.js
  42. 362 222
      src/views/task_view.js
  43. 399 178
      src/views/transfer_view.js
  44. 391 229
      src/views/video_view.js
  45. 310 167
      src/views/vote_view.js

+ 2 - 1
README.md

@@ -66,7 +66,8 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).	
  + Courts: Module to resolve conflicts and emit veredicts.	
  + Documents: Module to discover and manage documents.	
- + Events: Module to discover and manage events.	
+ + Events: Module to discover and manage events.
+ + Favorites: Module to manage your favorite content.
  + Feed: Module to discover and share short-texts (feeds).
  + Forums: Module to discover and manage forums.	
  + Governance: Module to discover and manage votes.	

+ 28 - 0
docs/CHANGELOG.md

@@ -13,6 +13,34 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.6.2 - 2025-12-05
+
+### Added
+
+ + Added a footer (Core plugin).
+ + Added favorites to media related modules (Favorites plugin).
+ + Added advanced search engine integration into modules (Search plugin).
+ 
+### Changed
+
+ * Added Oasis version at GUI (Core plugin).
+ + Added templates for reporting standardization (Reports plugin).
+ + Added market new functionalities (Market plugin).
+ + Added bookmarks new functionalities (Bookmarks plugin).
+ + Added jobs new filters (Jobs plugin).
+ 
+### Fixed
+
+ + Security fixes (Core plugin).
+ + Reports filters (Reports plugin).
+ + Tasks minor changes (Tasks plugin).
+ + Events minor changes (Events plugin).
+ + Votations minor changes (Votations plugin).
+ + Market minor changes (Market plugin).
+ + Projects minor changes (Projects plugin).
+ + Jobs minor changes (Jobs plugin).
+ + Transfers minor changes (Transfers plugin).
+
 ## v0.6.1 - 2025-12-01
 
 ### Changed

Dosya farkı çok büyük olduğundan ihmal edildi
+ 1327 - 844
src/backend/backend.js


+ 118 - 0
src/backend/media-favorites.js

@@ -0,0 +1,118 @@
+const fs = require("fs");
+const path = require("path");
+
+const FILE = path.join(__dirname, "../configs/media-favorites.json");
+
+const DEFAULT = {
+  audios: [],
+  bookmarks: [],
+  documents: [],
+  images: [],
+  videos: []
+};
+
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+
+let queue = Promise.resolve();
+
+const withLock = (fn) => {
+  queue = queue.then(fn, fn);
+  return queue;
+};
+
+const normalize = (raw) => {
+  const out = {};
+  for (const k of Object.keys(DEFAULT)) {
+    const list = safeArr(raw?.[k]).map((x) => String(x || "").trim()).filter(Boolean);
+    out[k] = Array.from(new Set(list));
+  }
+  return out;
+};
+
+const ensureFile = async () => {
+  try {
+    await fs.promises.access(FILE);
+  } catch (e) {
+    const dir = path.dirname(FILE);
+    await fs.promises.mkdir(dir, { recursive: true });
+    await fs.promises.writeFile(FILE, JSON.stringify(DEFAULT, null, 2), "utf8");
+  }
+};
+
+const readAll = async () => {
+  await ensureFile();
+  try {
+    const txt = await fs.promises.readFile(FILE, "utf8");
+    return normalize(JSON.parse(txt || "{}"));
+  } catch (e) {
+    const fixed = normalize(DEFAULT);
+    await fs.promises.writeFile(FILE, JSON.stringify(fixed, null, 2), "utf8");
+    return fixed;
+  }
+};
+
+const writeAll = async (data) => {
+  const dir = path.dirname(FILE);
+  const tmp = path.join(dir, `.media-favorites.${process.pid}.${Date.now()}.tmp`);
+  const txt = JSON.stringify(normalize(data), null, 2);
+  await fs.promises.writeFile(tmp, txt, "utf8");
+  await fs.promises.rename(tmp, FILE);
+};
+
+const assertKind = (kind) => {
+  const k = String(kind || "").trim();
+  if (!Object.prototype.hasOwnProperty.call(DEFAULT, k)) throw new Error("Invalid favorites kind");
+  return k;
+};
+
+const lastArg = (args, n) => args[args.length - n];
+
+const kindFromArgs = (args) => {
+  const k = String(lastArg(args, 1) || "").trim();
+  return assertKind(k);
+};
+
+const idFromArgs = (args) => String(lastArg(args, 1) || "").trim();
+
+exports.getFavoriteSet = async (kind) => {
+  const k = assertKind(kind);
+  const data = await readAll();
+  return new Set(safeArr(data[k]).map(String));
+};
+
+exports.addFavorite = async (kind, id) =>
+  withLock(async () => {
+    const k = assertKind(kind);
+    const favId = String(id || "").trim();
+    if (!favId) return;
+    const data = await readAll();
+    const set = new Set(safeArr(data[k]).map(String));
+    set.add(favId);
+    data[k] = Array.from(set);
+    await writeAll(data);
+  });
+
+exports.removeFavorite = async (kind, id) =>
+  withLock(async () => {
+    const k = assertKind(kind);
+    const favId = String(id || "").trim();
+    if (!favId) return;
+    const data = await readAll();
+    const set = new Set(safeArr(data[k]).map(String));
+    set.delete(favId);
+    data[k] = Array.from(set);
+    await writeAll(data);
+  });
+
+exports.getFavoritesSet = async (...args) => exports.getFavoriteSet(kindFromArgs(args));
+exports.addToFavorites = async (...args) => {
+  const kind = kindFromArgs(args.slice(0, -1));
+  const id = idFromArgs(args);
+  return exports.addFavorite(kind, id);
+};
+exports.removeFromFavorites = async (...args) => {
+  const kind = kindFromArgs(args.slice(0, -1));
+  const id = idFromArgs(args);
+  return exports.removeFavorite(kind, id);
+};
+

+ 321 - 24
src/client/assets/styles/style.css

@@ -853,21 +853,6 @@ button.create-button:hover {
     padding: 6px;
 }
 
-.bookmark-actions {
-    display: flex;
-    gap: 10px;
-    margin-top: 10px;
-}
-
-.bookmark-actions form {
-    margin: 0;
-}
-
-.bookmark-actions button {
-    flex: 1;
-    padding: 5px 10px;
-}
-
 .bookmark-tags {
     margin-top: 1em;
 }
@@ -885,6 +870,83 @@ button.create-button:hover {
   text-decoration: underline;
 }
 
+.filter-box {
+  padding: 16px 18px;
+  border-radius: 8px;
+  border: 1px solid rgba(255, 255, 255, 0.08);
+  background: rgba(0, 0, 0, 0.18);
+}
+
+.filter-box__input {
+  width: 100%;
+  box-sizing: border-box;
+  height: 42px;
+  padding: 10px 12px;
+  border-radius: 4px;
+  border: 1px solid rgba(255, 255, 255, 0.12);
+  background: rgba(255, 255, 255, 0.12);
+  color: #ffd700;
+  outline: none;
+}
+
+.filter-box__input::placeholder {
+  color: rgba(255, 215, 0, 0.75);
+}
+
+.filter-box__controls {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+  margin-top: 14px;
+  flex-wrap: wrap;
+}
+
+.filter-box__select {
+  box-sizing: border-box;
+  height: 36px;
+  padding: 6px 10px;
+  border-radius: 6px;
+  border: 1px solid rgba(255, 255, 255, 0.12);
+  background: rgba(255, 255, 255, 0.12);
+  color: inherit;
+  min-width: 170px;
+}
+
+.filter-box__button {
+  height: 36px;
+  padding: 0 14px;
+  border-radius: 6px;
+  border: 1px solid rgba(255, 255, 255, 0.12);
+  background: rgba(255, 255, 255, 0.16);
+  color: #ffd700;
+}
+
+.bookmark-topbar {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  align-items: center;
+  border:0px;
+}
+
+.bookmark-topbar-left {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  align-items: center;
+}
+
+.bookmark-topbar-left form {
+  margin: 0;
+}
+
+.bookmark-actions {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  border: 0px;
+}
+
 .tags-header h2 {
   color: #ffa300;
 }
@@ -1382,6 +1444,16 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   border: 0px;
 }
 
+.media-preview {
+  max-width: 100%;
+  height: auto;
+  display: block;
+}
+
+.meme-checkbox {
+  width: 1%;
+}
+
 .image-detail {
   width: 100%;
   max-width: 400px;
@@ -1519,9 +1591,65 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 }
 
 .transfer-actions {
-    display: flex;
-    gap: 10px;
-    margin-top: 10px;
+  display: flex;
+  gap: 10px;
+  margin-top: 10px;
+}
+
+.transfer-topbar-left {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  align-items: center;
+}
+
+.transfer-chips {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  align-items: center;
+}
+
+.transfer-voting-buttons {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.confirmations-block {
+  margin-top: 6px;
+}
+
+.confirmations-progress {
+  width: 260px;
+  max-width: 100%;
+  height: 8px;
+  border-radius: 999px;
+  overflow: hidden;
+  background: rgba(255, 255, 255, 0.12);
+  appearance: none;
+}
+
+.confirmations-progress::-webkit-progress-bar {
+  background: rgba(255, 255, 255, 0.12);
+}
+
+.confirmations-progress::-webkit-progress-value {
+  background: rgba(255, 255, 255, 0.65);
+}
+
+.confirmations-progress::-moz-progress-bar {
+  background: rgba(255, 255, 255, 0.65);
+}
+
+.transfer-range {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.transfer-amount-input {
+  width: 120px;
 }
 
 .market-item-actions {
@@ -1581,19 +1709,68 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .task-actions {
     display: flex;
     gap: 10px;
-    margin-top: 10px;
+    align-items: center;
+    flex-wrap: wrap;
+}
+
+.task-actions > form {
+    margin: 0;
 }
 
-.report-actions {
+.task-actions .project-control-form,
+.task-actions .project-control-form--status {
+    margin: 0;
+    padding: 0;
+}
+
+.task-actions .project-control-form--status {
     display: flex;
+    align-items: center;
     gap: 10px;
-    margin-top: 10px;
+}
+
+.task-actions .project-control-select,
+.task-actions .project-control-btn {
+    margin: 0;
+}
+
+.task-actions button {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+}
+
+.report-actions{
+  display:flex;
+  align-items:center;
+  gap:10px;
+  flex-wrap:wrap;
+}
+.report-actions form{ margin:0; }
+.report-actions button,
+.report-actions select{
+  height:40px;
+}
+
+.cv-filter-form {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  align-items: flex-end;
 }
 
 .cv-actions {
-    display: flex;
-    gap: 10px;
-    margin-top: 10px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  margin-top: 12px;
+}
+
+.card-salary {
+  display: inline-block;
+  font-weight: 700;
+  font-size: 1.2em;
+  letter-spacing: 0.02em;
 }
 
 .mode-buttons {
@@ -1789,6 +1966,20 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   text-align: center;
 }
 
+.market-comments {
+  background: #2f2f2f;
+  border-radius: 12px;
+  padding: 12px;
+}
+
+.market-comments .votations-comment-card {
+  background: #3a3a3a;
+}
+
+.countdown-strong {
+  font-weight: 700;
+}
+
 .market-card-image {
   width: 100%;
   max-height: 150px; 
@@ -2425,6 +2616,56 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 
 .project-card.status-completed .badge{ background:rgba(59,130,246,.12); color:#93c5fd; border-color:rgba(59,130,246,.25); }
 
+.bookmark-actions.project-actions {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 14px;
+}
+
+.bookmark-actions.project-actions form {
+  margin: 0;
+}
+
+.project-control-form {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: nowrap;
+}
+
+.project-control-select,
+.project-control-input,
+.project-control-btn {
+  height: 42px;
+}
+
+.project-control-select,
+.project-control-input {
+  min-width: 220px;
+}
+
+.project-control-btn {
+  white-space: nowrap;
+}
+
+.project-progress-input {
+  max-width: 140px;
+  min-width: 120px;
+}
+
+@media (max-width: 820px) {
+  .project-control-select,
+  .project-control-input {
+    min-width: 180px;
+  }
+
+  .project-progress-input {
+    min-width: 110px;
+  }
+}
+
 .badge{
   position:absolute;
   top:14px; right:14px;
@@ -2634,3 +2875,59 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .parliament-members-list li {
   margin: .125rem 0;
 }
+
+.oasis-footer {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 2px 10px;
+  border-top: 1px solid rgba(255,255,255,0.08);
+  line-height: 1;
+  min-height: 0;
+}
+
+.oasis-footer-center {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  flex-wrap: nowrap;
+  white-space: nowrap;
+  line-height: 1;
+  border:0px;
+}
+
+.oasis-footer-logo-link {
+  display: inline-flex;
+  align-items: center;
+  line-height: 0;
+}
+
+.oasis-footer-logo {
+  width: 48px;
+  height: 48px;
+  border-radius: 10px;
+  object-fit: cover;
+  display: block;
+}
+
+.oasis-footer-package-link,
+.oasis-footer-package-name,
+.oasis-footer-version,
+.oasis-footer-sep {
+  line-height: 1;
+}
+
+.oasis-footer-package-link {
+  text-decoration: none;
+}
+
+.oasis-footer-package-link,
+.oasis-footer-center a,
+.oasis-footer-center span,
+.oasis-footer-center strong {
+  padding-top: 0;
+  padding-bottom: 0;
+  margin: 0;
+}
+

+ 232 - 69
src/client/assets/translations/oasis_en.js

@@ -114,6 +114,7 @@ module.exports = {
     marketTitle: "Market",
     opinionsTitle: "Opinions",
     saveSettings: "Save configuration",
+    apply: "Apply",
     // menu categories
     menuPersonal: "Personal",
     menuContent: "Content",
@@ -478,100 +479,124 @@ module.exports = {
     //bookmarking
     bookmarkTitle: "Bookmarks",
     bookmarkDescription: "Discover and manage bookmarks in your network.",
-    bookmarkCreateTitle: "Create a bookmark",
-    bookmarkTitleLabel: "Title",
-    bookmarkDescriptionLabel: "Description",
-    bookmarkCreatedAt: "Created At",  
-    bookmarkAuthor: "By",
-    bookmarkUrlLabel: "Link",
-    bookmarkLink: "Link",
-    bookmarkCreateButton: "Create Bookmark",
-    existingbookmarksTitle: "Existing Bookmarks",
-    nobookmarks: "No bookmarks available.",
-    newbookmarkSuccess: "New bookmark successfully created!",
-    bookmarkFilterAll: "ALL",
-    bookmarkFilterMine: "MINE",
-    bookmarkUpdateButton: "Update",
-    bookmarkDeleteButton: "Delete",
     bookmarkAllSectionTitle: "Bookmarks",
     bookmarkMineSectionTitle: "Your Bookmarks",
+    bookmarkRecentSectionTitle: "Recent Bookmarks",
+    bookmarkTopSectionTitle: "Top Bookmarks",
+    bookmarkFavoritesSectionTitle: "Favorites",
     bookmarkCreateSectionTitle: "Create Bookmark",
     bookmarkUpdateSectionTitle: "Update Bookmark",
-    bookmarkTagsLabel: "Tags",
-    bookmarkTagsPlaceholder: "Enter tags separated by commas",
-    bookmarkFilterInternal: "INTERNAL",
-    bookmarkFilterExternal: "EXTERNAL",
+    bookmarkFilterAll: "ALL",
+    bookmarkFilterMine: "MINE",
     bookmarkFilterTop: "TOP",
+    bookmarkFilterFavorites: "FAVORITES",
     bookmarkFilterRecent: "RECENT",
-    bookmarkInternalTitle: "Internal Bookmarks", 
-    bookmarkExternalTitle: "External Bookmarks",
-    bookmarkTopTitle: "Top Bookmarks",
-    bookmarkRecentTitle: "Recent Bookmarks",   
+    bookmarkCreateButton: "Create Bookmark",
+    bookmarkUpdateButton: "Update",
+    bookmarkDeleteButton: "Delete",
+    bookmarkAddFavoriteButton: "Add favorite",
+    bookmarkRemoveFavoriteButton: "Remove favorite",
+    bookmarkUrlLabel: "Link",
+    bookmarkUrlPlaceholder: "https://example.com",
+    bookmarkDescriptionLabel: "Description",
+    bookmarkDescriptionPlaceholder: "Optional",
+    bookmarkTagsLabel: "Tags",
+    bookmarkTagsPlaceholder: "Enter tags separated by commas",
     bookmarkCategoryLabel: "Category",
-    bookmarkCategoryPlaceholder: "Enter category",
+    bookmarkCategoryPlaceholder: "Optional",
     bookmarkLastVisitLabel: "Last Visit",
-    bookmarkDescriptionLabel: "Description",
-    bookmarkDescriptionText: "Description",
-    bookmarkLinkLabel: "Link",
-    bookmarkCategory: "Category",
-    bookmarkLastVisit: "Last Visit",
+    bookmarkSearchPlaceholder: "Search URL, tags, category, author...",
+    bookmarkSortRecent: "Most recent",
+    bookmarkSortOldest: "Oldest",
+    bookmarkSortTop: "Most voted",
+    bookmarkSearchButton: "Search",
+    bookmarkUpdatedAt: "Updated",
+    bookmarkNoMatch: "No bookmarks match your search.",
+    noBookmarks: "No bookmarks available.",
+    noUrl: "No link",
+    noCategory: "No category",
+    noLastVisit: "No last visit",
     //videos
     videoTitle: "Videos",
-    videoFileLabel: "Upload Video (.mp4, .webm, .ogv, .mov)",
-    videoDescription: "Discover and manage videos in your network.",
+    videoDescription: "Explore and manage video content in your network.",
+    videoPluginTitle: "Title",
+    videoPluginDescription: "Description",
     videoMineSectionTitle: "Your Videos",
     videoCreateSectionTitle: "Upload Video",
     videoUpdateSectionTitle: "Update Video",
     videoAllSectionTitle: "Videos",
+    videoRecentSectionTitle: "Recent Videos",
+    videoTopSectionTitle: "Top Videos",
+    videoFavoritesSectionTitle: "Favorites",
     videoFilterAll: "ALL",
     videoFilterMine: "MINE",
     videoFilterRecent: "RECENT",
     videoFilterTop: "TOP",
-    videoRecentSectionTitle: "Recent Videos",
-    videoTopSectionTitle: "Top Videos",   
+    videoFilterFavorites: "FAVORITES",
     videoCreateButton: "Upload Video",
     videoUpdateButton: "Update",
     videoDeleteButton: "Delete",
+    videoAddFavoriteButton: "Add favorite",
+    videoRemoveFavoriteButton: "Remove favorite",
+    videoFileLabel: "Select a video file (.mp4, .webm, .ogv, .mov)",
     videoTagsLabel: "Tags",
     videoTagsPlaceholder: "Enter tags separated by commas",
     videoTitleLabel: "Title",
     videoTitlePlaceholder: "Optional",
     videoDescriptionLabel: "Description",
     videoDescriptionPlaceholder: "Optional",
+    videoNoFile: "No video file provided",
     noVideos: "No videos available.",
-    videoCreatedAt: "Created At",
-    videoAuthor: "By",
+    videoSearchPlaceholder: "Search title, tags, author...",
+    videoSortRecent: "Most recent",
+    videoSortOldest: "Oldest",
+    videoSortTop: "Most voted",
+    videoSearchButton: "Search",
+    videoMessageAuthorButton: "PM",
+    videoUpdatedAt: "Updated",
+    videoNoMatch: "No videos match your search.",
     //documents
     documentTitle: "Documents",
-    documentFileLabel: "Upload Document (.pdf)",
     documentDescription: "Discover and manage documents in your network.",
+    documentAllSectionTitle: "Documents",
     documentMineSectionTitle: "Your Documents",
     documentRecentSectionTitle: "Recent Documents",
     documentTopSectionTitle: "Top Documents",
+    documentFavoritesSectionTitle: "Favorites",
     documentCreateSectionTitle: "Upload Document",
     documentUpdateSectionTitle: "Edit Document",
-    documentAllSectionTitle: "Documents",
     documentFilterAll: "ALL",
     documentFilterMine: "MINE",
     documentFilterRecent: "RECENT",
     documentFilterTop: "TOP",
+    documentFilterFavorites: "FAVORITES",
     documentCreateButton: "Upload Document",
     documentUpdateButton: "Update",
     documentDeleteButton: "Delete",
+    documentAddFavoriteButton: "Add favorite",
+    documentRemoveFavoriteButton: "Remove from favorites",
+    documentMessageAuthorButton: "PM",
+    documentFileLabel: "Upload Document (.pdf)",
     documentTagsLabel: "Tags",
     documentTagsPlaceholder: "Enter tags separated by commas",
     documentTitleLabel: "Title",
     documentTitlePlaceholder: "Optional",
     documentDescriptionLabel: "Description",
     documentDescriptionPlaceholder: "Optional",
+    documentNoFile: "No file.",
     noDocuments: "No documents available.",
-    documentCreatedAt: "Created At",
-    documentAuthor: "By",
+    documentSearchPlaceholder: "Search title, tags, description, author...",
+    documentSortRecent: "Most recent",
+    documentSortOldest: "Oldest",
+    documentSortTop: "Most voted",
+    documentSearchButton: "Search",
+    documentNoMatch: "No documents match your search.",
+    documentUpdatedAt: "Updated",
     //audios
     audioTitle: "Audios",
     audioDescription: "Explore and manage audio content in your network.",
     audioPluginTitle: "Title",
-    audioPluginDescription: "Description",
+    audioPluginDescription: "Description", 
     audioMineSectionTitle: "Your Audios",
     audioCreateSectionTitle: "Upload Audio",
     audioUpdateSectionTitle: "Update Audio",
@@ -585,6 +610,8 @@ module.exports = {
     audioCreateButton: "Upload Audio",
     audioUpdateButton: "Update",
     audioDeleteButton: "Delete",
+    audioAddFavoriteButton: "Add favorite",
+    audioRemoveFavoriteButton: "Remove favorite",
     audioFileLabel: "Select an audio file (.mp3, .wav, .ogg)",
     audioTagsLabel: "Tags",
     audioTagsPlaceholder: "Enter tags separated by commas",
@@ -592,11 +619,30 @@ module.exports = {
     audioTitlePlaceholder: "Optional",
     audioDescriptionLabel: "Description",
     audioDescriptionPlaceholder: "Optional",
-    audioCreatedAt: "Created At",
-    audioAuthor: "By",
     audioNoFile: "No audio file provided",
-    audioNotSupported: "Your browser does not support the audio element.",
     noAudios: "No audios available.",
+    audioSearchPlaceholder: "Search title, tags, author...",
+    audioSortRecent: "Most recent",
+    audioSortOldest: "Oldest",
+    audioSortTop: "Most voted",
+    audioSearchButton: "Search",
+    audioMessageAuthorButton: "PM",
+    audioUpdatedAt: "Updated",
+    audioNoMatch: "No audios match your search.",
+    audioFavoritesSectionTitle: "Favorites",
+    audioFilterFavorites: "FAVORITES",
+    //favorites
+    favoritesTitle: "Favorites",
+    favoritesDescription: "All your favorited media in one place.",
+    favoritesFilterAll: "ALL",
+    favoritesFilterRecent: "RECENT",
+    favoritesFilterAudios: "AUDIOS",
+    favoritesFilterBookmarks: "BOOKMARKS",
+    favoritesFilterDocuments: "DOCUMENTS",
+    favoritesFilterImages: "IMAGES",
+    favoritesFilterVideos: "VIDEOS",
+    favoritesRemoveButton: "Remove from favorites",
+    favoritesNoItems: "No favorites yet.",
     //inhabitants
     yourContacts: "Your Contacts",
     allInhabitants: "Inhabitants",
@@ -991,6 +1037,7 @@ module.exports = {
     taskPrivateTitle: "Private Tasks",
     notasks: "No tasks available.",
     noLocation: "No location specified",
+    taskSetStatus: "Set Status",
     //events
     eventTitle: "Events",
     eventDateLabel: "Date",
@@ -1055,6 +1102,10 @@ module.exports = {
     eventNoImage: "No image uploaded",
     eventAttendConfirmation: "You are now attending this event",
     eventUnattendConfirmation: "You are no longer attending this event",
+    eventAttended: "Attended",
+    eventUnattended: "Unattended",
+    eventStatusOpen: "Open",
+    eventStatusClosed: "Closed",
     //tags 
     tagsTitle: "Tags",
     tagsDescription: "Discover and explore taxonomy patterns in your network.",
@@ -1105,6 +1156,24 @@ module.exports = {
     transfersClosedSectionTitle: "Closed Transfers",
     transfersDiscardedSectionTitle: "Discarded Transfers",
     transfersAllSectionTitle: "Transfers",
+    transfersFilterFavs: "Favorites",
+    transfersFavsSectionTitle: "Favorite transfers",
+    transfersSearchLabel: "Search",
+    transfersSearchPlaceholder: "Search concept, tags, users...",
+    transfersMinAmountLabel: "Min amount",
+    transfersMaxAmountLabel: "Max amount",
+    transfersSortLabel: "Sort by",
+    transfersSortRecent: "Most recent",
+    transfersSortAmount: "Highest amount",
+    transfersSortDeadline: "Closest deadline",
+    transfersSearchButton: "Search",
+    transfersFavoriteButton: "Favorite",
+    transfersUnfavoriteButton: "Unfavorite",
+    transfersMessageUserButton: "Message",
+    transfersExpiringSoonBadge: "EXPIRING",
+    transfersExpiredBadge: "EXPIRED",
+    transfersUpdatedAt: "Updated",
+    transfersNoMatch: "No transfers match your search.",
     //votations (voting/polls)
     votationsTitle: "Votations",
     votationsDescription: "Discover and manage votations in your network.",
@@ -1250,41 +1319,48 @@ module.exports = {
     forumCatSURVIVALISM: "Survivalism",
     //images
     imageTitle: "Images",
+    imageDescription: "Explore and manage image content in your network.",
     imagePluginTitle: "Title",
     imagePluginDescription: "Description",
-    imageFileLabel: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
-    imageDescription: "Discover and manage images in your network.",
     imageMineSectionTitle: "Your Images",
     imageCreateSectionTitle: "Upload Image",
     imageUpdateSectionTitle: "Update Image",
     imageAllSectionTitle: "Images",
+    imageRecentSectionTitle: "Recent Images",
+    imageTopSectionTitle: "Top Images",
+    imageFavoritesSectionTitle: "Favorites",
+    imageGallerySectionTitle: "Gallery",
+    imageMemeSectionTitle: "Memes",
     imageFilterAll: "ALL",
     imageFilterMine: "MINE",
-    imageCreateButton: "Upload Image",
-    imageEditDescription: "Edit your image details.",
-    imageCreateDescription: "Create Image.",
-    imageTagsLabel: "Tags",
-    imageTagsPlaceholder: "Enter tags separated by commas",
-    imageUpdateButton: "Update",
-    imageDeleteButton: "Delete",
-    imageCreatedAt: "Created At",
-    imageAuthor: "By",
-    imagePreview: "Image Preview",
-    noImages: "No images available.",
     imageFilterRecent: "RECENT",
-    imageFilterPopular: "POPULAR",
-    imageFilterGallery: "GALLERY",
     imageFilterTop: "TOP",
+    imageFilterFavorites: "FAVORITES",
+    imageFilterGallery: "GALLERY",
     imageFilterMeme: "MEMES",
+    imageCreateButton: "Upload Image",
+    imageUpdateButton: "Update",
+    imageDeleteButton: "Delete",
+    imageAddFavoriteButton: "Add favorite",
+    imageRemoveFavoriteButton: "Remove favorite",
+    imageFileLabel: "Select an image file (.jpeg, .jpg, .png, .gif)",
+    imageTagsLabel: "Tags",
+    imageTagsPlaceholder: "Enter tags separated by commas",
     imageTitleLabel: "Title",
-    imageGallerySectionTitle: "Images Gallery",
-    imageMemeSectionTitle: "Memes",
-    imageTopSectionTitle: "Top Images",
-    imageRecentSectionTitle: "Recent Images",
     imageTitlePlaceholder: "Optional",
     imageDescriptionLabel: "Description",
     imageDescriptionPlaceholder: "Optional",
     imageMemeLabel: "Mark as MEME",
+    imageNoFile: "No image file provided",
+    noImages: "No images available.",
+    imageSearchPlaceholder: "Search title, tags, description, author...",
+    imageSortRecent: "Most recent",
+    imageSortOldest: "Oldest",
+    imageSortTop: "Most voted",
+    imageSearchButton: "Search",
+    imageMessageAuthorButton: "Message",
+    imageUpdatedAt: "Updated",
+    imageNoMatch: "No images match your search.",
     //feed
     feedTitle:        "Feed",
     createFeedTitle:  "Create Feed",
@@ -1540,6 +1616,44 @@ module.exports = {
     reportsUnderReviewSectionTitle: "Under Review Reports",
     reportsResolvedSectionTitle: "Resolved Reports",
     reportsInvalidSectionTitle: "Invalid Reports",
+    reportsTemplateSectionTitle: 'Report template',
+    reportsBugTemplateTitle: 'Reproduction details (Bugs)',
+    reportsFeatureTemplateTitle: 'Details (Features)',
+    reportsAbuseTemplateTitle: 'Details (Abuse)',
+    reportsContentTemplateTitle: 'Details (Content Issues)',
+    reportsStepsToReproduceLabel: 'Steps to reproduce',
+    reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
+    reportsExpectedBehaviorLabel: 'Expected result',
+    reportsExpectedBehaviorPlaceholder: 'What should happen?',
+    reportsActualBehaviorLabel: 'Actual result',
+    reportsActualBehaviorPlaceholder: 'What actually happens?',
+    reportsEnvironmentLabel: 'Environment',
+    reportsEnvironmentPlaceholder: 'Version, device, OS, browser, settings, logs, etc.',
+    reportsReproduceRateLabel: 'Reproduction rate',
+    reportsReproduceRateAlways: 'Always',
+    reportsReproduceRateOften: 'Often',
+    reportsReproduceRateSometimes: 'Sometimes',
+    reportsReproduceRateRarely: 'Rarely',
+    reportsReproduceRateUnable: 'Unable to reproduce',
+    reportsReproduceRateUnknown: 'Not specified',
+    reportsProblemStatementLabel: 'Problem / need',
+    reportsProblemStatementPlaceholder: 'What problem does this feature solve?',
+    reportsUserStoryLabel: 'Inhabitant story',
+    reportsUserStoryPlaceholder: 'As a <inhabitant>, I want <action>, so that <benefit>.',
+    reportsAcceptanceCriteriaLabel: 'Acceptance criteria',
+    reportsAcceptanceCriteriaPlaceholder: '- Given...\n- When...\n- Then...',
+    reportsWhatHappenedLabel: 'What happened?',
+    reportsWhatHappenedPlaceholder: 'Describe the incident with context and approximate dates.',
+    reportsReportedUserLabel: 'Reported inhabitant / entity',
+    reportsReportedUserPlaceholder: '@inhabitant, feed, ID, link, etc.',
+    reportsEvidenceLinksLabel: 'Evidence / links',
+    reportsEvidenceLinksPlaceholder: 'Paste links, message IDs, screenshots (if applicable), etc.',
+    reportsContentLocationLabel: 'Where the content is',
+    reportsContentLocationPlaceholder: 'Link, ID, channel, thread, author, etc.',
+    reportsWhyInappropriateLabel: "Why it's inappropriate",
+    reportsWhyInappropriatePlaceholder: 'Explain the reason and impact.',
+    reportsRequestedActionLabel: 'Requested action',
+    reportsRequestedActionPlaceholder: 'Remove, hide, tag, warn, etc.',  
     //tribes
     tribesTitle: "Tribes",
     tribeAllSectionTitle: "Tribes",
@@ -2027,17 +2141,18 @@ module.exports = {
     marketRecentSectionTitle: "Recent Market",
     marketTitle: "Market",
     marketDescription: "A marketplace for exchanging goods or services in your network.",
-    marketFilterAll: "ALL",
+    marketFilterAll: "ALL",  
     marketFilterMine: "MINE",
     marketFilterAuctions: "AUCTIONS",
     marketFilterItems: "EXCHANGE",
     marketFilterNew: "NEW",
-    marketFilterUsed: "USED",
+    marketFilterUsed: "USED", 
     marketFilterBroken: "BROKEN",
     marketFilterForSale: "FOR SALE",
     marketFilterSold: "SOLD",
     marketFilterDiscarded: "DISCARDED",
     marketFilterRecent: "RECENT",
+    marketFilterMyBids: "BIDS",
     marketCreateButton: "Create Item",
     marketItemType: "Type",
     marketItemTitle: "Title",
@@ -2045,7 +2160,7 @@ module.exports = {
     marketItemDescription: "Description",
     marketItemDescriptionPlaceholder: "Describe the item you're selling",
     marketItemStatus: "Status",
-    marketItemCondition: "Condition",   
+    marketItemCondition: "Condition",
     marketItemPrice: "Price",
     marketItemTags: "Tags",
     marketItemTagsPlaceholder: "Enter tags separated by commas",
@@ -2059,14 +2174,28 @@ module.exports = {
     marketActionsUpdate: "Update",
     marketUpdateButton: "Update Item!",
     marketActionsDelete: "Delete",
-    marketActionsSold: "Mark as Sold",
+    marketActionsSold: "Mark as Sold", 
+    marketActionsChangeStatus: "CHANGE STATUS",
     marketActionsBuy: "BUY!",
     marketAuctionBids: "Current Bids",
     marketPlaceBidButton: "Place Bid",
     marketItemSeller: "Seller",
     marketNoItems: "No items available, yet.",
     marketYourBid: "Your Bid",
-    marketCreateFormImageLabel: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",    
+    marketCreateFormImageLabel: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    marketSearchLabel: "Search",
+    marketSearchPlaceholder: "Search title or tags",
+    marketMinPriceLabel: "Min price",
+    marketMaxPriceLabel: "Max price",
+    marketSortLabel: "Sort by",
+    marketSortRecent: "Most recent",
+    marketSortPrice: "Price",
+    marketSortDeadline: "Deadline",
+    marketSearchButton: "Search",
+    marketAuctionEndsIn: "Ends",
+    marketAuctionEnded: "Ended",
+    marketMyBidBadge: "You bid",
+    marketNoItemsMatch: "No items match your search.",
     //jobs
     jobsTitle: "Jobs",
     jobsDescription: "Discover and manage jobs in your network.",
@@ -2108,7 +2237,7 @@ module.exports = {
     jobTitlePlaceholder: "Enter job title",
     jobDescriptionPlaceholder: "Describe the job",
     jobRequirementsPlaceholder: "Enter requirements",
-    jobLanguagesPlaceholder: "English, Spanish, Basque",
+    jobLanguagesPlaceholder: "English, Spanish, French, Basque...",
     jobTasksPlaceholder: "List tasks",
     jobLocationPresencial: "On-place",
     jobLocationRemote: "Remote",
@@ -2132,6 +2261,32 @@ module.exports = {
     jobTimeComplete: "Full-time",
     jobsDeleteButton: "DELETE",
     jobsUpdateButton: "UPDATE",
+    jobsFilterApplied: "APPLIED",
+    jobsAppliedTitle: "My applications",
+    jobsAppliedBadge: "Applied",
+    jobsFilterFavs: "Favorites",
+    jobsFavsTitle: "Favorites",
+    jobsFilterNeeds: "Needs help",
+    jobsNeedsTitle: "Needs help",
+    jobsSearchLabel: "Search",
+    jobsSearchPlaceholder: "Search title, tags, description...",
+    jobsMinSalaryLabel: "Min salary",
+    jobsMaxSalaryLabel: "Max salary",
+    jobsSortLabel: "Sort by",
+    jobsSortRecent: "Most recent",
+    jobsSortSalary: "Highest salary",
+    jobsSortSubscribers: "Most applicants",
+    jobsSearchButton: "Search",
+    jobsFavoriteButton: "Favorite",
+    jobsUnfavoriteButton: "Unfavorite",
+    jobsMessageAuthorButton: "PM",
+    jobsApplicants: "Applicants",
+    jobsUpdatedAt: "Updated",
+    jobSetOpen: "Set open",
+    jobNewBadge: "NEW",
+    jobsTagsLabel: "Tags",
+    jobsTagsPlaceholder: "tag1, tag2, tag3",
+    noJobsMatch: "No jobs match your search.",
     //projects
     projectsTitle: "Projects",
     projectsDescription: "Create, fund, and follow community-driven projects in your network.",
@@ -2228,6 +2383,8 @@ module.exports = {
     projectBackersNone: "No pledges yet.",
     projectNoRemainingBudget: "No remaining budget.",
     projectFilterBackers: "BACKERS",
+    projectFilterApplied: "APPLIED",
+    projectAppliedTitle: "APPLIED",
     projectBackersLeaderboardTitle: "Top Backers",
     projectNoBackersFound: "No backers found.",
     projectBackerAmount: "Total contributed",
@@ -2236,6 +2393,10 @@ module.exports = {
     projectPledgeAmount: "Amount",
     projectSelectMilestoneOrBounty: "Select Milestone or Bounty",
     projectPledgeButton: "Pledge",
+    //footer
+    footerLicense: "GPLv3",
+    footerPackage: "Package",
+    footerVersion: "Version",
     //modules
     modulesModuleName: "Name",
     modulesModuleDescription: "Description",
@@ -2312,7 +2473,9 @@ module.exports = {
     modulesProjectsLabel: "Projects",
     modulesProjectsDescription: "Module to explore, crowd-funding and manage projects.",
     modulesBankingLabel: "Banking",
-    modulesBankingDescription: "Module to determine the real value of ECOIN and distribute a UBI using the common treasury."
+    modulesBankingDescription: "Module to determine the real value of ECOIN and distribute a UBI using the common treasury.",
+    modulesFavoritesLabel: "Favorites",
+    modulesFavoritesDescription: "Module to manage your favorite content."
      
      //END
     }

+ 279 - 116
src/client/assets/translations/oasis_es.js

@@ -111,6 +111,7 @@ module.exports = {
     marketTitle: "Mercado",
     opinionsTitle: "Opiniones",
     saveSettings: "Guardar configuración",
+    apply: "Aplicar",
     // menu categories
     menuPersonal: "Personal",
     menuContent: "Contenido",
@@ -472,115 +473,141 @@ module.exports = {
     encryptionError: "Error al encriptar el texto.",
     decryptionError: "Error al desencriptar el texto.",
     //bookmarking
-     bookmarkTitle: "Marcadores",
+    bookmarkTitle: "Marcadores",
     bookmarkDescription: "Descubre y gestiona marcadores en tu red.",
-    bookmarkCreateTitle: "Crear un marcador",
-    bookmarkTitleLabel: "Título",
-    bookmarkDescriptionLabel: "Descripción",
-    bookmarkCreatedAt: "Creado el",  
-    bookmarkAuthor: "Por",
-    bookmarkUrlLabel: "Enlace",
-    bookmarkLink: "Enlace",
-    bookmarkCreateButton: "Crear Marcador",
-    existingbookmarksTitle: "Marcadores Existentes",
-    nobookmarks: "No hay marcadores disponibles.",
-    newbookmarkSuccess: "Nuevo marcador creado con éxito!",
-    bookmarkFilterAll: "TODOS",
-    bookmarkFilterMine: "MIOS",
-    bookmarkUpdateButton: "Actualizar",
-    bookmarkDeleteButton: "Eliminar",
     bookmarkAllSectionTitle: "Marcadores",
     bookmarkMineSectionTitle: "Tus Marcadores",
-    bookmarkCreateSectionTitle: "Crear Marcador",
-    bookmarkUpdateSectionTitle: "Actualizar Marcador",
-    bookmarkTagsLabel: "Etiquetas",
-    bookmarkTagsPlaceholder: "Introduce etiquetas separadas por comas",
-    bookmarkFilterInternal: "INTERNO",
-    bookmarkFilterExternal: "EXTERNO",
+    bookmarkRecentSectionTitle: "Marcadores recientes",
+    bookmarkTopSectionTitle: "Marcadores top",
+    bookmarkFavoritesSectionTitle: "Favoritos",
+    bookmarkCreateSectionTitle: "Crear marcador",
+    bookmarkUpdateSectionTitle: "Actualizar marcador",
+    bookmarkFilterAll: "TODOS",
+    bookmarkFilterMine: "MÍOS",
     bookmarkFilterTop: "TOP",
+    bookmarkFilterFavorites: "FAVORITOS",
     bookmarkFilterRecent: "RECIENTES",
-    bookmarkInternalTitle: "Marcadores Internos", 
-    bookmarkExternalTitle: "Marcadores Externos",
-    bookmarkTopTitle: "Marcadores Principales",
-    bookmarkRecentTitle: "Marcadores Recientes",   
-    bookmarkCategoryLabel: "Categoría",
-    bookmarkCategoryPlaceholder: "Introduce la categoría",
-    bookmarkLastVisitLabel: "Última Visita",
+    bookmarkCreateButton: "Crear marcador",
+    bookmarkUpdateButton: "Actualizar",
+    bookmarkDeleteButton: "Eliminar",
+    bookmarkAddFavoriteButton: "Añadir favorito",
+    bookmarkRemoveFavoriteButton: "Quitar favorito",
+    bookmarkUrlLabel: "Enlace",
+    bookmarkUrlPlaceholder: "https://ejemplo.com",
     bookmarkDescriptionLabel: "Descripción",
-    bookmarkDescriptionText: "Descripción",
-    bookmarkLinkLabel: "Enlace",
-    bookmarkCategory: "Categoría",
-    bookmarkLastVisit: "Última Visita",
+    bookmarkDescriptionPlaceholder: "Opcional",
+    bookmarkTagsLabel: "Tags",
+    bookmarkTagsPlaceholder: "Introduce tags separadas por comas",
+    bookmarkCategoryLabel: "Categoría",
+    bookmarkCategoryPlaceholder: "Opcional",
+    bookmarkLastVisitLabel: "Última visita",
+    bookmarkSearchPlaceholder: "Buscar URL, tags, categoría, autor...",
+    bookmarkSortRecent: "Más recientes",
+    bookmarkSortOldest: "Más antiguos",
+    bookmarkSortTop: "Más votados",
+    bookmarkSearchButton: "Buscar",
+    bookmarkUpdatedAt: "Actualizado",
+    bookmarkNoMatch: "No hay marcadores que coincidan con tu búsqueda.",
+    noBookmarks: "No hay marcadores disponibles.",
+    noUrl: "Sin enlace",
+    noCategory: "Sin categoría",
+    noLastVisit: "Sin última visita",
     //videos
-    videoTitle: "Videos",
-    videoFileLabel: "Subir Video (.mp4, .webm, .ogv, .mov)",
-    videoDescription: "Descubre y gestiona videos en tu red.",
-    videoMineSectionTitle: "Tus Videos",
-    videoCreateSectionTitle: "Subir Video",
-    videoUpdateSectionTitle: "Actualizar Video",
-    videoAllSectionTitle: "Videos",
+    videoTitle: "Vídeos",
+    videoDescription: "Explora y gestiona contenido de vídeo en tu red.",
+    videoPluginTitle: "Título",
+    videoPluginDescription: "Descripción",
+    videoMineSectionTitle: "Tus vídeos",
+    videoCreateSectionTitle: "Subir vídeo",
+    videoUpdateSectionTitle: "Actualizar vídeo",
+    videoAllSectionTitle: "Vídeos",
+    videoRecentSectionTitle: "Vídeos recientes",
+    videoTopSectionTitle: "Vídeos más votados",
+    videoFavoritesSectionTitle: "Favoritos",
     videoFilterAll: "TODOS",
-    videoFilterMine: "MIOS",
+    videoFilterMine: "MÍOS",
     videoFilterRecent: "RECIENTES",
     videoFilterTop: "TOP",
-    videoRecentSectionTitle: "Videos Recientes",
-    videoTopSectionTitle: "Videos Principales",   
-    videoCreateButton: "Subir Video",
+    videoFilterFavorites: "FAVORITOS",
+    videoCreateButton: "Subir vídeo",
     videoUpdateButton: "Actualizar",
     videoDeleteButton: "Eliminar",
+    videoAddFavoriteButton: "Añadir a favoritos",
+    videoRemoveFavoriteButton: "Quitar de favoritos",
+    videoFileLabel: "Selecciona un archivo de vídeo (.mp4, .webm, .ogv, .mov)",
     videoTagsLabel: "Etiquetas",
     videoTagsPlaceholder: "Introduce etiquetas separadas por comas",
     videoTitleLabel: "Título",
     videoTitlePlaceholder: "Opcional",
     videoDescriptionLabel: "Descripción",
     videoDescriptionPlaceholder: "Opcional",
-    noVideos: "No hay videos disponibles.",
-    videoCreatedAt: "Creado el",
-    videoAuthor: "Por",
+    videoNoFile: "No se ha proporcionado ningún archivo de vídeo",
+    noVideos: "No hay vídeos disponibles.",
+    videoSearchPlaceholder: "Buscar por título, etiquetas, autor...",
+    videoSortRecent: "Más recientes",
+    videoSortOldest: "Más antiguos",
+    videoSortTop: "Más votados",
+    videoSearchButton: "Buscar",
+    videoMessageAuthorButton: "MP",
+    videoUpdatedAt: "Actualizado",
+    videoNoMatch: "Ningún vídeo coincide con tu búsqueda.",
     //documents
     documentTitle: "Documentos",
-    documentFileLabel: "Subir Documento (.pdf)",
     documentDescription: "Descubre y gestiona documentos en tu red.",
-    documentMineSectionTitle: "Tus Documentos",
-    documentRecentSectionTitle: "Documentos Recientes",
-    documentTopSectionTitle: "Documentos Principales",
-    documentCreateSectionTitle: "Subir Documento",
-    documentUpdateSectionTitle: "Editar Documento",
     documentAllSectionTitle: "Documentos",
-    documentFilterAll: "TODOS",
-    documentFilterMine: "MIOS",
+    documentMineSectionTitle: "Tus documentos",
+    documentRecentSectionTitle: "Documentos recientes",
+    documentTopSectionTitle: "Documentos top",
+    documentFavoritesSectionTitle: "Favoritos",
+    documentCreateSectionTitle: "Subir documento",
+    documentUpdateSectionTitle: "Editar documento",
+    documentFilterAll: "TODO",
+    documentFilterMine: "MÍOS",
     documentFilterRecent: "RECIENTES",
     documentFilterTop: "TOP",
-    documentCreateButton: "Subir Documento",
+    documentFilterFavorites: "FAVORITOS",
+    documentCreateButton: "Subir documento",
     documentUpdateButton: "Actualizar",
     documentDeleteButton: "Eliminar",
+    documentAddFavoriteButton: "Añadir a favoritos",
+    documentRemoveFavoriteButton: "Quitar de favoritos",
+    documentMessageAuthorButton: "MP",
+    documentFileLabel: "Subir documento (.pdf)",
     documentTagsLabel: "Etiquetas",
     documentTagsPlaceholder: "Introduce etiquetas separadas por comas",
     documentTitleLabel: "Título",
     documentTitlePlaceholder: "Opcional",
     documentDescriptionLabel: "Descripción",
     documentDescriptionPlaceholder: "Opcional",
+    documentNoFile: "Sin archivo.",
     noDocuments: "No hay documentos disponibles.",
-    documentCreatedAt: "Creado el",
-    documentAuthor: "Por",
+    documentSearchPlaceholder: "Buscar título, etiquetas, descripción, autor...",
+    documentSortRecent: "Más recientes",
+    documentSortOldest: "Más antiguos",
+    documentSortTop: "Más votados",
+    documentSearchButton: "Buscar",
+    documentNoMatch: "No hay documentos que coincidan con tu búsqueda.",
+    documentUpdatedAt: "Actualizado",
     //audios
     audioTitle: "Audios",
     audioDescription: "Explora y gestiona contenido de audio en tu red.",
     audioPluginTitle: "Título",
     audioPluginDescription: "Descripción",
-    audioMineSectionTitle: "Tus Audios",
-    audioCreateSectionTitle: "Subir Audio",
-    audioUpdateSectionTitle: "Actualizar Audio",
+    audioMineSectionTitle: "Tus audios",
+    audioCreateSectionTitle: "Subir audio",
+    audioUpdateSectionTitle: "Actualizar audio",
     audioAllSectionTitle: "Audios",
-    audioRecentSectionTitle: "Audios Recientes",
-    audioTopSectionTitle: "Audios Principales",
+    audioRecentSectionTitle: "Audios recientes",
+    audioTopSectionTitle: "Audios más votados",
     audioFilterAll: "TODOS",
-    audioFilterMine: "MIOS",
+    audioFilterMine: "MÍOS",
     audioFilterRecent: "RECIENTES",
     audioFilterTop: "TOP",
-    audioCreateButton: "Subir Audio",
+    audioCreateButton: "Subir audio",
     audioUpdateButton: "Actualizar",
     audioDeleteButton: "Eliminar",
+    audioAddFavoriteButton: "Añadir a favoritos",
+    audioRemoveFavoriteButton: "Quitar de favoritos",
     audioFileLabel: "Selecciona un archivo de audio (.mp3, .wav, .ogg)",
     audioTagsLabel: "Etiquetas",
     audioTagsPlaceholder: "Introduce etiquetas separadas por comas",
@@ -588,11 +615,30 @@ module.exports = {
     audioTitlePlaceholder: "Opcional",
     audioDescriptionLabel: "Descripción",
     audioDescriptionPlaceholder: "Opcional",
-    audioCreatedAt: "Creado el",
-    audioAuthor: "Por",
-    audioNoFile: "No se proporcionó ningún archivo de audio",
-    audioNotSupported: "Tu navegador no soporta el elemento de audio.",
+    audioNoFile: "No se ha proporcionado ningún archivo de audio",
     noAudios: "No hay audios disponibles.",
+    audioSearchPlaceholder: "Buscar por título, etiquetas, autor...",
+    audioSortRecent: "Más recientes",
+    audioSortOldest: "Más antiguos",
+    audioSortTop: "Más votados",
+    audioSearchButton: "Buscar",
+    audioMessageAuthorButton: "MP",
+    audioUpdatedAt: "Actualizado",
+    audioNoMatch: "Ningún audio coincide con tu búsqueda.",
+    audioFavoritesSectionTitle: "Favoritos",
+    audioFilterFavorites: "FAVORITOS",
+    //favorites
+    favoritesTitle: "Favoritos",
+    favoritesDescription: "Todos tus elementos marcados como favoritos en un solo lugar.",
+    favoritesFilterAll: "TODOS",
+    favoritesFilterRecent: "RECIENTES",
+    favoritesFilterAudios: "AUDIOS",
+    favoritesFilterBookmarks: "MARCADORES",
+    favoritesFilterDocuments: "DOCUMENTOS",
+    favoritesFilterImages: "IMÁGENES",
+    favoritesFilterVideos: "VÍDEOS",
+    favoritesRemoveButton: "Quitar de favoritos",
+    favoritesNoItems: "Todavía no hay favoritos.",
     //inhabitants
     yourContacts:       "Tus Contactos",
     allInhabitants:     "Habitantes",
@@ -986,6 +1032,7 @@ module.exports = {
     taskPrivateTitle: "Tareas Privadas",
     notasks: "No hay tareas disponibles.",
     noLocation: "No se especificó ubicación",
+    taskSetStatus: "Establecer estado",
     //events
     eventTitle: "Eventos",
     eventDateLabel: "Fecha",
@@ -1050,6 +1097,10 @@ module.exports = {
     eventNoImage: "No se ha subido ninguna imagen",
     eventAttendConfirmation: "Ahora estás asistiendo a este evento",
     eventUnattendConfirmation: "Ya no estás asistiendo a este evento",
+    eventAttended: "Asisto",
+    eventUnattended: "No asisto",
+    eventStatusOpen: "Abierto",
+    eventStatusClosed: "Cerrado",
     //tags 
     tagsTitle: "Etiquetas",
     tagsDescription: "Descubre y explora patrones de taxonomía en tu red.",
@@ -1100,6 +1151,24 @@ module.exports = {
     transfersClosedSectionTitle: "Transferencias Cerradas",
     transfersDiscardedSectionTitle: "Transferencias Descartadas",
     transfersAllSectionTitle: "Transferencias",
+    transfersFilterFavs: "Favoritos",
+    transfersFavsSectionTitle: "Transferencias favoritas",
+    transfersSearchLabel: "Buscar",
+    transfersSearchPlaceholder: "Buscar por concepto, tags, usuarios...",
+    transfersMinAmountLabel: "Importe mín.",
+    transfersMaxAmountLabel: "Importe máx.",
+    transfersSortLabel: "Ordenar por",
+    transfersSortRecent: "Más recientes",
+    transfersSortAmount: "Mayor importe",
+    transfersSortDeadline: "Deadline más próximo",
+    transfersSearchButton: "Buscar",
+    transfersFavoriteButton: "Favorito",
+    transfersUnfavoriteButton: "Quitar favorito",
+    transfersMessageUserButton: "Mensaje",
+    transfersExpiringSoonBadge: "CADUCA PRONTO",
+    transfersExpiredBadge: "CADUCADA",
+    transfersUpdatedAt: "Actualizado",
+    transfersNoMatch: "No hay transferencias que coincidan con tu búsqueda.",
     //votations (voting/polls)
     votationsTitle: "Votaciones",
     votationsDescription: "Descubre y gestiona votaciones en tu red.",
@@ -1243,43 +1312,50 @@ module.exports = {
     forumCatPRIVACY: "Privacidad",
     forumCatCYBERWARFARE: "Ciberguerra",
     forumCatSURVIVALISM: "Supervivencia",
-    // images
+    //images
     imageTitle: "Imágenes",
+    imageDescription: "Explora y gestiona contenido de imágenes en tu red.",
     imagePluginTitle: "Título",
     imagePluginDescription: "Descripción",
-    imageFileLabel: "Subir Imagen (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
-    imageDescription: "Descubre y gestiona imágenes en tu red.",
-    imageMineSectionTitle: "Tus Imágenes",
-    imageCreateSectionTitle: "Subir Imagen (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
-    imageUpdateSectionTitle: "Actualizar Imagen",
+    imageMineSectionTitle: "Tus imágenes",
+    imageCreateSectionTitle: "Subir imagen",
+    imageUpdateSectionTitle: "Actualizar imagen",
     imageAllSectionTitle: "Imágenes",
-    imageFilterAll: "TODOS",
+    imageRecentSectionTitle: "Imágenes recientes",
+    imageTopSectionTitle: "Top imágenes",
+    imageFavoritesSectionTitle: "Favoritos",
+    imageGallerySectionTitle: "Galería",
+    imageMemeSectionTitle: "Memes",
+    imageFilterAll: "TODAS",
     imageFilterMine: "MÍAS",
-    imageCreateButton: "Subir Imagen",
-    imageEditDescription: "Edita los detalles de tu imagen.",
-    imageCreateDescription: "Crea una imagen.",
-    imageTagsLabel: "Etiquetas",
-    imageTagsPlaceholder: "Introduce etiquetas separadas por comas",
-    imageUpdateButton: "Actualizar",
-    imageDeleteButton: "Eliminar",
-    imageCreatedAt: "Creado el",
-    imageAuthor: "Por",
-    imagePreview: "Vista previa de la Imagen",
-    noImages: "No hay imágenes disponibles.",
     imageFilterRecent: "RECIENTES",
-    imageFilterPopular: "POPULARES",
-    imageFilterGallery: "GALERÍA",
     imageFilterTop: "TOP",
+    imageFilterFavorites: "FAVORITOS",
+    imageFilterGallery: "GALERÍA",
     imageFilterMeme: "MEMES",
+    imageCreateButton: "Subir imagen",
+    imageUpdateButton: "Actualizar",
+    imageDeleteButton: "Eliminar",
+    imageAddFavoriteButton: "Añadir favorito",
+    imageRemoveFavoriteButton: "Quitar favorito",
+    imageFileLabel: "Selecciona un archivo de imagen (.jpeg, .jpg, .png, .gif)",
+    imageTagsLabel: "Etiquetas",
+    imageTagsPlaceholder: "Introduce etiquetas separadas por comas",
     imageTitleLabel: "Título",
-    imageGallerySectionTitle: "Galería de Imágenes",
-    imageMemeSectionTitle: "Memes",
-    imageTopSectionTitle: "Imágenes Principales",
-    imageRecentSectionTitle: "Imágenes Recientes",
     imageTitlePlaceholder: "Opcional",
     imageDescriptionLabel: "Descripción",
     imageDescriptionPlaceholder: "Opcional",
     imageMemeLabel: "Marcar como MEME",
+    imageNoFile: "No se ha proporcionado ningún archivo de imagen",
+    noImages: "No hay imágenes disponibles.",
+    imageSearchPlaceholder: "Buscar por título, etiquetas, descripción, autor...",
+    imageSortRecent: "Más recientes",
+    imageSortOldest: "Más antiguas",
+    imageSortTop: "Más votadas",
+    imageSearchButton: "Buscar",
+    imageMessageAuthorButton: "Mensaje",
+    imageUpdatedAt: "Actualizado",
+    imageNoMatch: "Ninguna imagen coincide con tu búsqueda.",
     //feed
     feedTitle:        "Feed",
     createFeedTitle:  "Crear Feed",
@@ -1534,6 +1610,44 @@ module.exports = {
     reportsUnderReviewSectionTitle: "Informes en Revisión",
     reportsResolvedSectionTitle: "Informes Resueltos",
     reportsInvalidSectionTitle: "Informes Inválidos",
+    reportsTemplateSectionTitle: 'Plantilla de reporte',
+    reportsBugTemplateTitle: 'Datos para reproducir (Bugs)',
+    reportsFeatureTemplateTitle: 'Detalles (Features)',
+    reportsAbuseTemplateTitle: 'Detalles (Abuso)',
+    reportsContentTemplateTitle: 'Detalles (Problemas de Contenido)',
+    reportsStepsToReproduceLabel: 'Pasos para reproducir',
+    reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
+    reportsExpectedBehaviorLabel: 'Resultado esperado',
+    reportsExpectedBehaviorPlaceholder: '¿Qué debería pasar?',
+    reportsActualBehaviorLabel: 'Resultado actual',
+    reportsActualBehaviorPlaceholder: '¿Qué pasa realmente?',
+    reportsEnvironmentLabel: 'Entorno',
+    reportsEnvironmentPlaceholder: 'Versión, dispositivo, OS, navegador, configuración, logs, etc.',
+    reportsReproduceRateLabel: 'Frecuencia',
+    reportsReproduceRateAlways: 'Siempre',
+    reportsReproduceRateOften: 'A menudo',
+    reportsReproduceRateSometimes: 'A veces',
+    reportsReproduceRateRarely: 'Rara vez',
+    reportsReproduceRateUnable: 'No se puede reproducir',
+    reportsReproduceRateUnknown: 'No especificado',
+    reportsProblemStatementLabel: 'Problema / necesidad',
+    reportsProblemStatementPlaceholder: '¿Qué problema resuelve esta feature?',
+    reportsUserStoryLabel: 'Historia de habitante',
+    reportsUserStoryPlaceholder: 'Como <habitante>, quiero <acción>, para <beneficio>.',
+    reportsAcceptanceCriteriaLabel: 'Criterios de aceptación',
+    reportsAcceptanceCriteriaPlaceholder: '- Dado...\n- Cuando...\n- Entonces...',
+    reportsWhatHappenedLabel: '¿Qué ocurrió?',
+    reportsWhatHappenedPlaceholder: 'Describe el incidente con contexto y fechas aproximadas.',
+    reportsReportedUserLabel: 'Habitante / entidad reportada',
+    reportsReportedUserPlaceholder: '@habitante, feed, ID, enlace, etc.',
+    reportsEvidenceLinksLabel: 'Evidencia / enlaces',
+    reportsEvidenceLinksPlaceholder: 'Pega enlaces, IDs de mensajes, capturas (si aplica), etc.',
+    reportsContentLocationLabel: 'Dónde está el contenido',
+    reportsContentLocationPlaceholder: 'Enlace, ID, canal, hilo, autor, etc.',
+    reportsWhyInappropriateLabel: 'Por qué es inapropiado',
+    reportsWhyInappropriatePlaceholder: 'Explica el motivo y el impacto.',
+    reportsRequestedActionLabel: 'Acción solicitada',
+    reportsRequestedActionPlaceholder: 'Eliminar, ocultar, marcar, advertir, etc.',
     //tribes
     tribesTitle: "Tribus",
     tribeAllSectionTitle: "Tribus",
@@ -2019,15 +2133,15 @@ module.exports = {
     aiCustomAnswerPlaceholder: "Escribe tu respuesta personalizada…",
     statsAIExchanges: "Intercambio de Modelos",
     //market
-    marketMineSectionTitle: "Tus Artículos",
-    marketCreateSectionTitle: "Crear un Artículo",
+    marketMineSectionTitle: "Tus artículos",
+    marketCreateSectionTitle: "Crear artículo",
     marketUpdateSectionTitle: "Actualizar",
     marketAllSectionTitle: "Mercado",
-    marketRecentSectionTitle: "Mercado Reciente",
+    marketRecentSectionTitle: "Mercado reciente",
     marketTitle: "Mercado",
     marketDescription: "Un mercado para intercambiar bienes o servicios en tu red.",
     marketFilterAll: "TODOS",
-    marketFilterMine: "MIOS",
+    marketFilterMine: "MÍOS",
     marketFilterAuctions: "SUBASTAS",
     marketFilterItems: "INTERCAMBIO",
     marketFilterNew: "NUEVO",
@@ -2037,35 +2151,50 @@ module.exports = {
     marketFilterSold: "VENDIDO",
     marketFilterDiscarded: "DESCARTADO",
     marketFilterRecent: "RECIENTE",
-    marketCreateButton: "Crear Artículo",
+    marketFilterMyBids: "PUJAS",
+    marketCreateButton: "Crear artículo",
     marketItemType: "Tipo",
     marketItemTitle: "Título",
-    marketItemAvailable: "Plazo",
+    marketItemAvailable: "Fecha límite",
     marketItemDescription: "Descripción",
     marketItemDescriptionPlaceholder: "Describe el artículo que estás vendiendo",
     marketItemStatus: "Estado",
-    marketItemCondition: "Condición",   
+    marketItemCondition: "Condición",
     marketItemPrice: "Precio",
     marketItemTags: "Etiquetas",
     marketItemTagsPlaceholder: "Introduce etiquetas separadas por comas",
-    marketItemDeadline: "Plazo",
-    marketItemIncludesShipping: "¿Incluye Envío?",
-    marketItemHighestBid: "Oferta Más Alta",
-    marketItemHighestBidder: "Mayor Postor",
-    marketItemStock: "Existencias",
-    marketOutOfStock: "Sin existencias",
-    marketItemBidTime: "Tiempo de Oferta",
+    marketItemDeadline: "Fecha límite",
+    marketItemIncludesShipping: "¿Incluye envío?",
+    marketItemHighestBid: "Puja más alta",
+    marketItemHighestBidder: "Mejor postor",
+    marketItemStock: "Stock",
+    marketOutOfStock: "Sin stock",
+    marketItemBidTime: "Fecha de la puja",
     marketActionsUpdate: "Actualizar",
-    marketUpdateButton: "Actualizar Artículo!",
+    marketUpdateButton: "¡Actualizar artículo!",
     marketActionsDelete: "Eliminar",
-    marketActionsSold: "Marcar como Vendido",
-    marketActionsBuy: "COMPRAR!",
-    marketAuctionBids: "Ofertas Actuales",
-    marketPlaceBidButton: "Hacer Oferta",
+    marketActionsSold: "Marcar como vendido",
+    marketActionsChangeStatus: "CAMBIAR ESTADO",
+    marketActionsBuy: "¡COMPRAR!",
+    marketAuctionBids: "Pujas actuales",
+    marketPlaceBidButton: "Pujar",
     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)",
+    marketNoItems: "Aún no hay artículos disponibles.",
+    marketYourBid: "Tu puja",
+    marketCreateFormImageLabel: "Subir imagen (jpeg, jpg, png, gif) (tamaño máx.: 500px x 400px)",
+    marketSearchLabel: "Buscar",
+    marketSearchPlaceholder: "Buscar por título o etiquetas",
+    marketMinPriceLabel: "Precio mínimo",
+    marketMaxPriceLabel: "Precio máximo",
+    marketSortLabel: "Ordenar por",
+    marketSortRecent: "Más recientes",
+    marketSortPrice: "Precio",
+    marketSortDeadline: "Fecha límite",
+    marketSearchButton: "Buscar",
+    marketAuctionEndsIn: "Termina",
+    marketAuctionEnded: "Terminó",
+    marketMyBidBadge: "Has pujado",
+    marketNoItemsMatch: "No hay artículos que coincidan con tu búsqueda.",
     //jobs
     jobsTitle: "Ofertas de trabajo",
     jobsDescription: "Descubre y gestiona ofertas de trabajo en tu red.",
@@ -2107,7 +2236,7 @@ module.exports = {
     jobTitlePlaceholder: "Introduce el título del trabajo",
     jobDescriptionPlaceholder: "Describe el puesto",
     jobRequirementsPlaceholder: "Introduce los requisitos",
-    jobLanguagesPlaceholder: "Inglés, Español, Euskera",
+    jobLanguagesPlaceholder: "Inglés, Francés, Español, Euskera...",
     jobTasksPlaceholder: "Lista de tareas",
     jobLocationPresencial: "Presencial",
     jobLocationRemote: "Remoto",
@@ -2131,6 +2260,32 @@ module.exports = {
     jobTimeComplete: "Completo",
     jobsDeleteButton: "ELIMINAR",
     jobsUpdateButton: "ACTUALIZAR",
+    jobsFilterApplied: "CANDIDATURA",
+    jobsAppliedTitle: "Mis candidaturas",
+    jobsAppliedBadge: "Inscrito",
+    jobsFilterFavs: "Favoritos",
+    jobsFavsTitle: "Favoritos",
+    jobsFilterNeeds: "Necesita ayuda",
+    jobsNeedsTitle: "Necesita ayuda",
+    jobsSearchLabel: "Buscar",
+    jobsSearchPlaceholder: "Buscar por título, tags, descripción...",
+    jobsMinSalaryLabel: "Salario mín.",
+    jobsMaxSalaryLabel: "Salario máx.",
+    jobsSortLabel: "Ordenar por",
+    jobsSortRecent: "Más recientes",
+    jobsSortSalary: "Mayor salario",
+    jobsSortSubscribers: "Más candidatos",
+    jobsSearchButton: "Buscar",
+    jobsFavoriteButton: "Favorito",
+    jobsUnfavoriteButton: "Quitar favorito",
+    jobsMessageAuthorButton: "MP",
+    jobsApplicants: "Candidatos",
+    jobsUpdatedAt: "Actualizado",
+    jobSetOpen: "Marcar abierto",
+    jobNewBadge: "NUEVO",
+    jobsTagsLabel: "Tags",
+    jobsTagsPlaceholder: "tag1, tag2, tag3",
+    noJobsMatch: "No hay ofertas que coincidan con tu búsqueda.",
     //projects
     projectsTitle: "Proyectos",
     projectsDescription: "Crea, financia y sigue proyectos impulsados por la comunidad en tu red.",
@@ -2227,6 +2382,8 @@ module.exports = {
     projectBackersNone: "Aún no hay aportaciones.",
     projectNoRemainingBudget: "No queda presupuesto.",
     projectFilterBackers: "MECENAS",
+    projectFilterApplied: "APORTANDO",
+    projectAppliedTitle: "APORTANDO",
     projectBackersLeaderboardTitle: "Mecenas destacados",
     projectNoBackersFound: "No hay mecenas.",
     projectBackerAmount: "Total aportado",
@@ -2235,6 +2392,10 @@ module.exports = {
     projectPledgeAmount: "Cantidad",
     projectSelectMilestoneOrBounty: "Seleccionar Hito o Recompensa",
     projectPledgeButton: "Aportar",
+    //footer
+    footerLicense: "GPLv3",
+    footerPackage: "Paquete",
+    footerVersion: "Versión",
     //modules
     modulesModuleName: "Nombre",
     modulesModuleDescription: "Descripción",
@@ -2312,6 +2473,8 @@ module.exports = {
     modulesProjectsDescription: "Módulo para explorar, financiar y gestionar proyectos.",
     modulesBankingLabel: "Banca",
     modulesBankingDescription: "Módulo para conocer el valor real de ECOIN y distribuir una RBU utilizando la tesorería común.",
+    modulesFavoritesLabel: "Favoritos",
+    modulesFavoritesDescription: "Módulo para gestionar tu contenido favorito."
      
      //END
     }

+ 306 - 143
src/client/assets/translations/oasis_eu.js

@@ -111,6 +111,7 @@ module.exports = {
     marketTitle: "Merkatua",
     opinionsTitle: "Iritziak",
     saveSettings: "Gorde konfigurazioa",
+    apply: "Aplikatu",
     // menu categories
     menuPersonal: "Pertsonala",
     menuContent: "Edukia",
@@ -473,127 +474,172 @@ module.exports = {
     encryptionError: "Errorea testua zifratzean.",
     decryptionError: "Errorea testua deszifratzean.",
     //bookmarking
-    bookmarkTitle: "Markagailuak",
-    bookmarkDescription: "Aurkitu eta kudeatu markagailuak zure sarean.",
-    bookmarkCreateTitle: "Sort markagailua",
-    bookmarkTitleLabel: "Izenburua",
-    bookmarkDescriptionLabel: "Deskribapena",
-    bookmarkCreatedAt: "Noiz",  
-    bookmarkAuthor: "Nork",
-    bookmarkUrlLabel: "Lotura",
-    bookmarkLink: "Lotura",
-    bookmarkCreateButton: "Sortu markagailua",
-    existingbookmarksTitle: "Dauden Markagailuak",
-    nobookmarks: "Markagailurik ez.",
-    newbookmarkSuccess: "Markagailu berria ondo sortu da!",
+    bookmarkTitle: "Laster-markak",
+    bookmarkDescription: "Zure sareko laster-markak aurkitu eta kudeatu.",
+    bookmarkAllSectionTitle: "Laster-markak",
+    bookmarkMineSectionTitle: "Zure laster-markak",
+    bookmarkRecentSectionTitle: "Azken laster-markak",
+    bookmarkTopSectionTitle: "Laster-marka onenak",
+    bookmarkFavoritesSectionTitle: "Gogokoak",
+    bookmarkCreateSectionTitle: "Laster-marka sortu",
+    bookmarkUpdateSectionTitle: "Laster-marka eguneratu",
     bookmarkFilterAll: "GUZTIAK",
-    bookmarkFilterMine: "NEUREAK",
+    bookmarkFilterMine: "NIREAK",
+    bookmarkFilterTop: "TOP",
+    bookmarkFilterFavorites: "GOGOKOAK",
+    bookmarkFilterRecent: "AZKENAK",
+    bookmarkCreateButton: "Laster-marka sortu",
     bookmarkUpdateButton: "Eguneratu",
     bookmarkDeleteButton: "Ezabatu",
-    bookmarkAllSectionTitle: "Markagailuak",
-    bookmarkMineSectionTitle: "Zure markagailuak",
-    bookmarkCreateSectionTitle: "Sortu markagailuak",
-    bookmarkUpdateSectionTitle: "Eguneratu markagailuak",
-    bookmarkTagsLabel: "Etiketak",
-    bookmarkTagsPlaceholder: "Sartu etiketak, erabili komak bereizteko.",
-    bookmarkFilterInternal: "BARNEKOA",
-    bookmarkFilterExternal: "KANPOKOA",
-    bookmarkFilterTop: "GORENAK",
-    bookmarkFilterRecent: "BERRIAK",
-    bookmarkInternalTitle: "Barne-markagailuak", 
-    bookmarkExternalTitle: "Kanpo-markagailuak",
-    bookmarkTopTitle: "Markagailu gorenak",
-    bookmarkRecentTitle: "Markagailu berriak",   
+    bookmarkAddFavoriteButton: "Gogokoetara gehitu",
+    bookmarkRemoveFavoriteButton: "Gogokoetatik kendu",
+    bookmarkUrlLabel: "Esteka",
+    bookmarkUrlPlaceholder: "https://adibidea.com",
+    bookmarkDescriptionLabel: "Deskribapena",
+    bookmarkDescriptionPlaceholder: "Aukerakoa",
+    bookmarkTagsLabel: "Tagak",
+    bookmarkTagsPlaceholder: "Sartu tagak komaz banatuta",
     bookmarkCategoryLabel: "Kategoria",
-    bookmarkCategoryPlaceholder: "Sartu kategoria",
+    bookmarkCategoryPlaceholder: "Aukerakoa",
     bookmarkLastVisitLabel: "Azken bisita",
-    bookmarkDescriptionLabel: "Deskribapena",
-    bookmarkDescriptionText: "Deskribapena",
-    bookmarkLinkLabel: "Lotura",
-    bookmarkCategory: "Kategoria",
-    bookmarkLastVisit: "Azken bisita",
+    bookmarkSearchPlaceholder: "Bilatu URLa, tagak, kategoria, egilea...",
+    bookmarkSortRecent: "Berrienak",
+    bookmarkSortOldest: "Zaharrenak",
+    bookmarkSortTop: "Bozkatuenak",
+    bookmarkSearchButton: "Bilatu",
+    bookmarkUpdatedAt: "Eguneratua",
+    bookmarkNoMatch: "Ez dago zure bilaketarekin bat datorren laster-markarik.",
+    noBookmarks: "Ez dago laster-markarik.",
+    noUrl: "Estekarik ez",
+    noCategory: "Kategoriarik ez",
+    noLastVisit: "Azken bisitarik ez",
     //videos
     videoTitle: "Bideoak",
-    videoFileLabel: "Igo Bideoa (.mp4, .webm, .ogv, .mov)",
-    videoDescription: "Aurkitu eta kudeatu bideoak zure sarean.",
-    videoMineSectionTitle: "Zeure Bideoak",
-    videoCreateSectionTitle: "Igo Bideoa",
-    videoUpdateSectionTitle: "Eguneratu Bideoa",
+    videoDescription: "Arakatu eta kudeatu zure sareko bideo-edukia.",
+    videoPluginTitle: "Izenburua",
+    videoPluginDescription: "Deskribapena",
+    videoMineSectionTitle: "Zure bideoak",
+    videoCreateSectionTitle: "Bideoa igo",
+    videoUpdateSectionTitle: "Bideoa eguneratu",
     videoAllSectionTitle: "Bideoak",
+    videoRecentSectionTitle: "Azken bideoak",
+    videoTopSectionTitle: "Bozkatuenak",
+    videoFavoritesSectionTitle: "Gogokoak",
     videoFilterAll: "GUZTIAK",
-    videoFilterMine: "NEUREAK",
-    videoFilterRecent: "BERRIAK",
-    videoFilterTop: "GORENAK",
-    videoRecentSectionTitle: "Bideo Berriak",
-    videoTopSectionTitle: "Bideo Gorenak",   
-    videoCreateButton: "Igo bideoa",
+    videoFilterMine: "NIREAK",
+    videoFilterRecent: "AZKENAK",
+    videoFilterTop: "TOP",
+    videoFilterFavorites: "GOGOKOAK",
+    videoCreateButton: "Bideoa igo",
     videoUpdateButton: "Eguneratu",
     videoDeleteButton: "Ezabatu",
+    videoAddFavoriteButton: "Gehitu gogokoetara",
+    videoRemoveFavoriteButton: "Kendu gogokoetatik",
+    videoFileLabel: "Aukeratu bideo-fitxategi bat (.mp4, .webm, .ogv, .mov)",
     videoTagsLabel: "Etiketak",
-    videoTagsPlaceholder: "Sartu etiketak, erabili komak bereizteko.",
+    videoTagsPlaceholder: "Idatzi etiketak komaz bereizita",
     videoTitleLabel: "Izenburua",
-    videoTitlePlaceholder: "Hautazkoa",
+    videoTitlePlaceholder: "Aukerakoa",
     videoDescriptionLabel: "Deskribapena",
-    videoDescriptionPlaceholder: "Hautazkoa",
-    noVideos: "Bideorik ez.",
-    videoCreatedAt: "Noiz",
-    videoAuthor: "Nork",
+    videoDescriptionPlaceholder: "Aukerakoa",
+    videoNoFile: "Ez da bideo-fitxategirik eman",
+    noVideos: "Ez dago bideorik erabilgarri.",
+    videoSearchPlaceholder: "Bilatu izenburua, etiketak, egilea...",
+    videoSortRecent: "Berrienak",
+    videoSortOldest: "Zaharrenak",
+    videoSortTop: "Bozkatuenak",
+    videoSearchButton: "Bilatu",
+    videoMessageAuthorButton: "MP",
+    videoUpdatedAt: "Eguneratua",
+    videoNoMatch: "Ez dago zure bilaketarekin bat datorren bideorik.",
     //documents
     documentTitle: "Dokumentuak",
-    documentFileLabel: "Igo Dokumentua (.pdf)",
     documentDescription: "Aurkitu eta kudeatu dokumentuak zure sarean.",
-    documentMineSectionTitle: "Zeure Dokumentuak",
-    documentRecentSectionTitle: "Dokumentu berriak",
-    documentTopSectionTitle: "Dokumentu gorenak",
-    documentCreateSectionTitle: "Igo dokumentua",
-    documentUpdateSectionTitle: "Editatu dokumentua",
     documentAllSectionTitle: "Dokumentuak",
+    documentMineSectionTitle: "Zure dokumentuak",
+    documentRecentSectionTitle: "Azken dokumentuak",
+    documentTopSectionTitle: "Dokumentu top",
+    documentFavoritesSectionTitle: "Gogokoak",
+    documentCreateSectionTitle: "Dokumentua igo",
+    documentUpdateSectionTitle: "Dokumentua editatu",
     documentFilterAll: "GUZTIAK",
     documentFilterMine: "NIREAK",
-    documentFilterRecent: "BERRIAK",
-    documentFilterTop: "GORENAK",
-    documentCreateButton: "Igo Dokumentua",
+    documentFilterRecent: "AZKENAK",
+    documentFilterTop: "TOP",
+    documentFilterFavorites: "GOGOKOAK",
+    documentCreateButton: "Dokumentua igo",
     documentUpdateButton: "Eguneratu",
     documentDeleteButton: "Ezabatu",
+    documentAddFavoriteButton: "Gehitu gogokoetara",
+    documentRemoveFavoriteButton: "Gogokoetatik kendu",
+    documentMessageAuthorButton: "MP",
+    documentFileLabel: "Dokumentua igo (.pdf)",
     documentTagsLabel: "Etiketak",
-    documentTagsPlaceholder: "Sartu etiketak, erabili komak bereizteko.",
+    documentTagsPlaceholder: "Sartu komaz bereizitako etiketak",
     documentTitleLabel: "Izenburua",
-    documentTitlePlaceholder: "Hautazkoa",
+    documentTitlePlaceholder: "Aukerakoa",
     documentDescriptionLabel: "Deskribapena",
-    documentDescriptionPlaceholder: "Hautazkoa",
-    noDocuments: "Dokumenturik ez.",
-    documentCreatedAt: "Noiz",
-    documentAuthor: "Nork",
+    documentDescriptionPlaceholder: "Aukerakoa",
+    documentNoFile: "Fitxategirik ez.",
+    noDocuments: "Ez dago dokumenturik.",
+    documentSearchPlaceholder: "Bilatu izenburua, etiketak, deskribapena, egilea...",
+    documentSortRecent: "Berrienak",
+    documentSortOldest: "Zaharrenak",
+    documentSortTop: "Bozkatuenak",
+    documentSearchButton: "Bilatu",
+    documentNoMatch: "Ez dago bilaketarekin bat datorren dokumenturik.",
+    documentUpdatedAt: "Eguneratua",
     //audios
     audioTitle: "Audioak",
-    audioDescription: "Aurkitu eta kudeatu audio edukiak zure sarean.",
+    audioDescription: "Arakatu eta kudeatu zure sareko audio-edukia.",
     audioPluginTitle: "Izenburua",
     audioPluginDescription: "Deskribapena",
-    audioMineSectionTitle: "Zeure Audioak",
-    audioCreateSectionTitle: "Igo Audioa",
-    audioUpdateSectionTitle: "Eguneratu Audioa",
+    audioMineSectionTitle: "Zure audioak",
+    audioCreateSectionTitle: "Audioa igo",
+    audioUpdateSectionTitle: "Audioa eguneratu",
     audioAllSectionTitle: "Audioak",
-    audioRecentSectionTitle: "Audio Berriak",
-    audioTopSectionTitle: "Audio Gorenak",
+    audioRecentSectionTitle: "Azken audioak",
+    audioTopSectionTitle: "Bozkatuenak",
     audioFilterAll: "GUZTIAK",
-    audioFilterMine: "NEUREAK",
-    audioFilterRecent: "BERRIAK",
-    audioFilterTop: "GORENAK",
-    audioCreateButton: "Igo Audioa",
+    audioFilterMine: "NIREAK",
+    audioFilterRecent: "AZKENAK",
+    audioFilterTop: "TOP",
+    audioCreateButton: "Audioa igo",
     audioUpdateButton: "Eguneratu",
     audioDeleteButton: "Ezabatu",
-    audioFileLabel: "Hautau audio fitxategia (.mp3, .wav, .ogg)",
+    audioAddFavoriteButton: "Gehitu gogokoetara",
+    audioRemoveFavoriteButton: "Kendu gogokoetatik",
+    audioFileLabel: "Aukeratu audio-fitxategi bat (.mp3, .wav, .ogg)",
     audioTagsLabel: "Etiketak",
-    audioTagsPlaceholder: "Sartu etiketak, erabili komak bereizteko.",
+    audioTagsPlaceholder: "Idatzi etiketak komaz bereizita",
     audioTitleLabel: "Izenburua",
-    audioTitlePlaceholder: "Hautazkoa",
+    audioTitlePlaceholder: "Aukerakoa",
     audioDescriptionLabel: "Deskribapena",
-    audioDescriptionPlaceholder: "Hautazkoa",
-    audioCreatedAt: "Noiz",
-    audioAuthor: "Nork",
-    audioNoFile: "Ez duzu audio fitxategirik eman",
-    audioNotSupported: "Zure arakatzaileak ez du audio elementua jasaten.",
-    noAudios: "Audiorik ez.",
+    audioDescriptionPlaceholder: "Aukerakoa",
+    audioNoFile: "Ez da audio-fitxategirik eman",
+    noAudios: "Ez dago audiorik erabilgarri.",
+    audioSearchPlaceholder: "Bilatu izenburua, etiketak, egilea...",
+    audioSortRecent: "Berrienak",
+    audioSortOldest: "Zaharrenak",
+    audioSortTop: "Bozkatuenak",
+    audioSearchButton: "Bilatu",
+    audioMessageAuthorButton: "MP",
+    audioUpdatedAt: "Eguneratua",
+    audioNoMatch: "Ez dago zure bilaketarekin bat datorren audiorik.",
+    audioFavoritesSectionTitle: "Gogokoak",
+    audioFilterFavorites: "GOGOKOAK",
+    //favorites
+    favoritesTitle: "Gogokoak",
+    favoritesDescription: "Zure gogoko guztiak leku berean.",
+    favoritesFilterAll: "GUZTIAK",
+    favoritesFilterRecent: "AZKENAK",
+    favoritesFilterAudios: "AUDIOAK",
+    favoritesFilterBookmarks: "MARKATZAILEAK",
+    favoritesFilterDocuments: "DOKUMENTUAK",
+    favoritesFilterImages: "IRUDIAK",
+    favoritesFilterVideos: "BIDEOAK",
+    favoritesRemoveButton: "Gogokoetatik kendu",
+    favoritesNoItems: "Oraindik ez dago gogokorik.",
     //inhabitants
     yourContacts:       "Zure Kontaktuak",
     allInhabitants:     "Bizilagunak",
@@ -987,6 +1033,7 @@ module.exports = {
     taskPrivateTitle: "Ataza pribatuak",
     notasks: "Atazarik ez.",
     noLocation: "Ez da kokapenik zehaztu",
+    taskSetStatus: "Egoera ezarri",
     //events
     eventTitle: "Ekitaldiak",
     eventDateLabel: "Data",
@@ -1051,6 +1098,10 @@ module.exports = {
     eventNoImage: "Ez da irudirik igo",
     eventAttendConfirmation: "Ekitaldi honetara zoaz",
     eventUnattendConfirmation: "Ez zara ekitaldi honetara joango",
+    eventAttended: "Bertaratzen naiz",
+    eventUnattended: "Ez naiz bertaratzen",
+    eventStatusOpen: "Irekia",
+    eventStatusClosed: "Itxita",
     //tags 
     tagsTitle: "Etiketak",
     tagsDescription: "Aurkitu eta kudeatu taxonomia ereduak zure sarean.",
@@ -1100,7 +1151,25 @@ module.exports = {
     transfersUnconfirmedSectionTitle: "Transferentziak Berretsi Gabe",
     transfersClosedSectionTitle: "Transferentziak Itxita",
     transfersDiscardedSectionTitle: "Transferentziak Baztertuta",
-    transfersAllSectionTitle: "Transferentziak",
+    transfersAllSectionTitle: "Transferentziak",   
+    transfersFilterFavs: "Gustukoak",
+    transfersFavsSectionTitle: "Gustuko transferentziak",
+    transfersSearchLabel: "Bilatu",
+    transfersSearchPlaceholder: "Bilatu kontzeptua, etiketak, erabiltzaileak...",
+    transfersMinAmountLabel: "Gutx. zenbatekoa",
+    transfersMaxAmountLabel: "Geh. zenbatekoa",
+    transfersSortLabel: "Ordenatu",
+    transfersSortRecent: "Berrienak",
+    transfersSortAmount: "Zenbateko handiena",
+    transfersSortDeadline: "Epe hurbilena",
+    transfersSearchButton: "Bilatu",
+    transfersFavoriteButton: "Gustukoa",
+    transfersUnfavoriteButton: "Kendu gustukoa",
+    transfersMessageUserButton: "Mezua",
+    transfersExpiringSoonBadge: "LASTER",
+    transfersExpiredBadge: "IRAUNGITA",
+    transfersUpdatedAt: "Eguneratua",
+    transfersNoMatch: "Ez dago bilaketarekin bat datorren transferentziarik.",
     //votations (voting/polls)
     votationsTitle: "Bozketak",
     votationsDescription: "Aurkitu eta kudeatu bozketak zure sarean.",
@@ -1244,43 +1313,50 @@ module.exports = {
     forumCatPRIVACY: "Pribatutasuna",
     forumCatCYBERWARFARE: "Zibergerra",
     forumCatSURVIVALISM: "Biziraupena",
-    // images
+    //images
     imageTitle: "Irudiak",
+    imageDescription: "Arakatu eta kudeatu zure sareko irudi-edukia.",
     imagePluginTitle: "Izenburua",
     imagePluginDescription: "Deskribapena",
-    imageFileLabel: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
-    imageDescription: "Aurkitu eta kudeatu irudiak zure sarean.",
-    imageMineSectionTitle: "Zure Irudiak",
-    imageCreateSectionTitle: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
-    imageUpdateSectionTitle: "Eguneratu Irudia",
+    imageMineSectionTitle: "Zure irudiak",
+    imageCreateSectionTitle: "Irudia igo",
+    imageUpdateSectionTitle: "Irudia eguneratu",
     imageAllSectionTitle: "Irudiak",
+    imageRecentSectionTitle: "Azken irudiak",
+    imageTopSectionTitle: "Bozkatuenak",
+    imageFavoritesSectionTitle: "Gogokoak",
+    imageGallerySectionTitle: "Galeria",
+    imageMemeSectionTitle: "Memak",
     imageFilterAll: "GUZTIAK",
-    imageFilterMine: "NEUREAK",
-    imageCreateButton: "Igo Irudia",
-    imageEditDescription: "Editatu zure irudiaren xehetasunak.",
-    imageCreateDescription: "Sortu irudi berria.",
-    imageTagsLabel: "Etiketak",
-    imageTagsPlaceholder: "Sartu etiketak, erabili komak bereizteko",
+    imageFilterMine: "NIREAK",
+    imageFilterRecent: "AZKENAK",
+    imageFilterTop: "TOP",
+    imageFilterFavorites: "GOGOKOAK",
+    imageFilterGallery: "GALERIA",
+    imageFilterMeme: "MEMAK",
+    imageCreateButton: "Irudia igo",
     imageUpdateButton: "Eguneratu",
     imageDeleteButton: "Ezabatu",
-    imageCreatedAt: "Noiz",
-    imageAuthor: "Nork",
-    imagePreview: "Irudiaren Aurrebista",
-    noImages: "Irudirik ez.",
-    imageFilterRecent: "BERRIAK",
-    imageFilterPopular: "PIL-PILEAN",
-    imageFilterGallery: "GALERIA",
-    imageFilterTop: "GORENAK",
-    imageFilterMeme: "MEMEAK",
+    imageAddFavoriteButton: "Gehitu gogokoetara",
+    imageRemoveFavoriteButton: "Kendu gogokoetatik",
+    imageFileLabel: "Aukeratu irudi-fitxategi bat (.jpeg, .jpg, .png, .gif)",
+    imageTagsLabel: "Etiketak",
+    imageTagsPlaceholder: "Idatzi etiketak komaz bereizita",
     imageTitleLabel: "Izenburua",
-    imageGallerySectionTitle: "Irudi Galeria",
-    imageMemeSectionTitle: "Memeak",
-    imageTopSectionTitle: "Irudi Gorenak",
-    imageRecentSectionTitle: "Irudi Berriak",
-    imageTitlePlaceholder: "Hautazkoa",
+    imageTitlePlaceholder: "Aukerakoa",
     imageDescriptionLabel: "Deskribapena",
-    imageDescriptionPlaceholder: "Hautazkoa",
-    imageMemeLabel: "Markat MEME bezala",
+    imageDescriptionPlaceholder: "Aukerakoa",
+    imageMemeLabel: "MEME gisa markatu",
+    imageNoFile: "Ez da irudi-fitxategirik eman",
+    noImages: "Ez dago irudirik erabilgarri.",
+    imageSearchPlaceholder: "Bilatu izenburua, etiketak, deskribapena, egilea...",
+    imageSortRecent: "Berrienak",
+    imageSortOldest: "Zaharrenak",
+    imageSortTop: "Bozkatuenak",
+    imageSearchButton: "Bilatu",
+    imageMessageAuthorButton: "Mezua",
+    imageUpdatedAt: "Eguneratua",
+    imageNoMatch: "Ez dago zure bilaketarekin bat datorren irudirik.",
     //feed
     feedTitle:        "Jarioa",
     createFeedTitle:  "Sortu Jarioa",
@@ -1449,7 +1525,7 @@ module.exports = {
     reportsCategoryFeatures: "Gaitasunak",
     reportsCategoryBugs: "Bugak",
     reportsCategoryAbuse: "Gehiegikeria",
-    reportsCategoryContent: "Arazoak edukiarekin",
+    reportsCategoryContent: "Arazoak Edukiarekin",
     reportsUpdateButton: "Eguneratu",
     reportsDeleteButton: "Ezabatu",
     reportsDateLabel: "Data",
@@ -1488,6 +1564,44 @@ module.exports = {
     reportsUnderReviewSectionTitle: "Txostenak Berrikuspenean",
     reportsResolvedSectionTitle: "Zuzendutako Txostenak",
     reportsInvalidSectionTitle: "Txosten Okerrak",
+    reportsTemplateSectionTitle: 'Txosten-eredua',
+    reportsBugTemplateTitle: 'Erreprodukzio xehetasunak (Bug-ak)',
+    reportsFeatureTemplateTitle: 'Xehetasunak (Ezaugarriak)',
+    reportsAbuseTemplateTitle: 'Xehetasunak (Abusua)',
+    reportsContentTemplateTitle: 'Xehetasunak (Arazoak Edukiarekin)',
+    reportsStepsToReproduceLabel: 'Erreproduzitzeko urratsak',
+    reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
+    reportsExpectedBehaviorLabel: 'Espero den emaitza',
+    reportsExpectedBehaviorPlaceholder: 'Zer gertatu beharko litzateke?',
+    reportsActualBehaviorLabel: 'Benetako emaitza',
+    reportsActualBehaviorPlaceholder: 'Zer gertatzen da benetan?',
+    reportsEnvironmentLabel: 'Ingurunea',
+    reportsEnvironmentPlaceholder: 'Bertsioa, gailua, OS, nabigatzailea, ezarpenak, log-ak, etab.',
+    reportsReproduceRateLabel: 'Maiztasuna',
+    reportsReproduceRateAlways: 'Beti',
+    reportsReproduceRateOften: 'Askotan',
+    reportsReproduceRateSometimes: 'Batzuetan',
+    reportsReproduceRateRarely: 'Gutxitan',
+    reportsReproduceRateUnable: 'Ezin da erreproduzitu',
+    reportsReproduceRateUnknown: 'Zehaztu gabe',
+    reportsProblemStatementLabel: 'Arazoa / beharra',
+    reportsProblemStatementPlaceholder: 'Zein arazo konpontzen du ezaugarri honek?',
+    reportsUserStoryLabel: 'User story',
+    reportsUserStoryPlaceholder: '<erabiltzaile> naizenez, <ekintza> nahi dut, <onura> lortzeko.',
+    reportsAcceptanceCriteriaLabel: 'Onarpen-irizpideak',
+    reportsAcceptanceCriteriaPlaceholder: '- Emanda...\n- Noiz...\n- Orduan...',
+    reportsWhatHappenedLabel: 'Zer gertatu da?',
+    reportsWhatHappenedPlaceholder: 'Deskribatu gertakaria testuinguruarekin eta gutxi gorabeherako datekin.',
+    reportsReportedUserLabel: 'Salatutako erabiltzailea / entitatea',
+    reportsReportedUserPlaceholder: '@erabiltzailea, feed, ID, esteka, etab.',
+    reportsEvidenceLinksLabel: 'Frogak / estekak',
+    reportsEvidenceLinksPlaceholder: 'Itsatsi estekak, mezu ID-ak, pantaila-argazkiak (bada), etab.',
+    reportsContentLocationLabel: 'Non dago edukia',
+    reportsContentLocationPlaceholder: 'Esteka, ID, kanala, haria, egilea, etab.',
+    reportsWhyInappropriateLabel: 'Zergatik da desegokia',
+    reportsWhyInappropriatePlaceholder: 'Azaldu arrazoia eta eragina.',
+    reportsRequestedActionLabel: 'Eskatutako ekintza',
+    reportsRequestedActionPlaceholder: 'Kendu, ezkutatu, etiketatu, ohartarazi, etab.',
     //tribes
     tribesTitle: "Tribuak",
     tribeAllSectionTitle: "Tribuak",
@@ -1973,53 +2087,68 @@ module.exports = {
     aiCustomAnswerPlaceholder: "Idatzi zure erantzun pertsonalizatua…",
     statsAIExchanges: "Ereduen trukea",
     //market
-    marketMineSectionTitle: "Zure Elementuak",
-    marketCreateSectionTitle: "Sortu Elementu Berria",
+    marketMineSectionTitle: "Zure artikuluak",
+    marketCreateSectionTitle: "Artikulua sortu",
     marketUpdateSectionTitle: "Eguneratu",
     marketAllSectionTitle: "Merkatua",
-    marketRecentSectionTitle: "Berriak Merkatuan",
+    marketRecentSectionTitle: "Azken merkatua",
     marketTitle: "Merkatua",
-    marketDescription: "Ondasunak eta zerbitzuak salerosteko merkatua zure sarean.",
+    marketDescription: "Zure sarean ondasunak edo zerbitzuak trukatzeko merkatua.",
     marketFilterAll: "GUZTIAK",
-    marketFilterMine: "NEUREAK",
+    marketFilterMine: "NIREAK",
     marketFilterAuctions: "ENKANTEAK",
-    marketFilterItems: "SALEROSKETA",
-    marketFilterNew: "BERRIAK",
-    marketFilterUsed: "ERABILITA",
-    marketFilterBroken: "APURTUTA",
-    marketFilterForSale: "SALTZEN",
+    marketFilterItems: "TRUKEA",
+    marketFilterNew: "BERRIA",
+    marketFilterUsed: "ERABILIA",
+    marketFilterBroken: "HAUTSIA",
+    marketFilterForSale: "SALGAI",
     marketFilterSold: "SALDUTA",
     marketFilterDiscarded: "BAZTERTUTA",
-    marketFilterRecent: "BERRIAK",
-    marketCreateButton: "Sortu Elementua",
+    marketFilterRecent: "AZKENA",
+    marketFilterMyBids: "PUJAK",
+    marketCreateButton: "Artikulua sortu",
     marketItemType: "Mota",
     marketItemTitle: "Izenburua",
     marketItemAvailable: "Epemuga",
     marketItemDescription: "Deskribapena",
-    marketItemDescriptionPlaceholder: "Deskribatu saltzen ari zarena",
+    marketItemDescriptionPlaceholder: "Deskribatu saltzen ari zaren artikulua",
     marketItemStatus: "Egoera",
-    marketItemCondition: "Baldintza",
+    marketItemCondition: "Egoera (kalitatea)",
     marketItemPrice: "Prezioa",
     marketItemTags: "Etiketak",
-    marketItemTagsPlaceholder: "Sartu etiketak, erabili komak bereizteko.",
+    marketItemTagsPlaceholder: "Sartu etiketak komaz bereizita",
     marketItemDeadline: "Epemuga",
     marketItemIncludesShipping: "Bidalketa barne?",
-    marketItemHighestBid: "Eskaintza altuena",
-    marketItemHighestBidder: "Gehien eskaintzen duena",
-    marketItemStock: "Stock",
-    marketOutOfStock: "Stock gabe",
-    marketItemBidTime: "Eskantzaren data",
+    marketItemHighestBid: "Puja altuena",
+    marketItemHighestBidder: "Pujatzaile onena",
+    marketItemStock: "Stocka",
+    marketOutOfStock: "Stockik gabe",
+    marketItemBidTime: "Pujaren data",
     marketActionsUpdate: "Eguneratu",
-    marketUpdateButton: "Eguneratu elementua!",
+    marketUpdateButton: "Artikulua eguneratu!",
     marketActionsDelete: "Ezabatu",
-    marketActionsSold: "Salduta bezala markatu",
+    marketActionsSold: "Salduta gisa markatu",
+    marketActionsChangeStatus: "EGOERA ALDATU",
     marketActionsBuy: "EROSI!",
-    marketAuctionBids: "Uneko Eskaintzak",
-    marketPlaceBidButton: "Eskaini",
+    marketAuctionBids: "Oraingo pujаk",
+    marketPlaceBidButton: "Puja egin",
     marketItemSeller: "Saltzailea",
-    marketNoItems: "Elementurik ez, oraindik.",
-    marketYourBid: "Zeure eskaintza",
-    marketCreateFormImageLabel: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    marketNoItems: "Oraindik ez dago artikulurik eskuragarri.",
+    marketYourBid: "Zure puja",
+    marketCreateFormImageLabel: "Irudia igo (jpeg, jpg, png, gif) (geh. tamaina: 500px x 400px)",
+    marketSearchLabel: "Bilatu",
+    marketSearchPlaceholder: "Izenburuan edo etiketetan bilatu",
+    marketMinPriceLabel: "Gutxieneko prezioa",
+    marketMaxPriceLabel: "Gehieneko prezioa",
+    marketSortLabel: "Ordenatu",
+    marketSortRecent: "Berrienak",
+    marketSortPrice: "Prezioa",
+    marketSortDeadline: "Epemuga",
+    marketSearchButton: "Bilatu",
+    marketAuctionEndsIn: "Amaitzen da",
+    marketAuctionEnded: "Amaituta",
+    marketMyBidBadge: "Puja egin duzu",
+    marketNoItemsMatch: "Ez dago zure bilaketarekin bat datorren artikulurik.",
     //jobs
     jobsTitle: "Lanak",
     jobsDescription: "Aurki eta kudeatu zure sarean lan eskaintzak.",
@@ -2061,7 +2190,7 @@ module.exports = {
     jobTitlePlaceholder: "Idatzi lanaren titulua",
     jobDescriptionPlaceholder: "Deskribatu lana",
     jobRequirementsPlaceholder: "Idatzi eskakizunak",
-    jobLanguagesPlaceholder: "Ingelesa, Espainiera, Euskara",
+    jobLanguagesPlaceholder: "Ingelesa, Frantsesa, Gaztelania, Euskara...",
     jobTasksPlaceholder: "Zeregin zerrenda",
     jobLocationPresencial: "Aurrez aurrekoa",
     jobLocationRemote: "Urrunekoa",
@@ -2083,6 +2212,32 @@ module.exports = {
     jobTimeComplete: "Osotua",
     jobsDeleteButton: "EZABATU",
     jobsUpdateButton: "EGUNERATU",
+    jobsFilterApplied: "ESKATUTA",
+    jobsAppliedTitle: "Nire eskaerak",
+    jobsAppliedBadge: "Izena emanda",
+    jobsFilterFavs: "Gustukoak",
+    jobsFavsTitle: "Gustukoak",
+    jobsFilterNeeds: "Laguntza behar du",
+    jobsNeedsTitle: "Laguntza behar du",
+    jobsSearchLabel: "Bilatu",
+    jobsSearchPlaceholder: "Bilatu izenburua, etiketak, deskribapena...",
+    jobsMinSalaryLabel: "Gutx. soldata",
+    jobsMaxSalaryLabel: "Geh. soldata",
+    jobsSortLabel: "Ordenatu",
+    jobsSortRecent: "Berrienak",
+    jobsSortSalary: "Soldata altuena",
+    jobsSortSubscribers: "Hautagai gehien",
+    jobsSearchButton: "Bilatu",
+    jobsFavoriteButton: "Gustukoa",
+    jobsUnfavoriteButton: "Kendu gustukoa",
+    jobsMessageAuthorButton: "MP",
+    jobsApplicants: "Hautagaiak",
+    jobsUpdatedAt: "Eguneratua",
+    jobSetOpen: "Ireki gisa markatu",
+    jobNewBadge: "BERRIA",
+    jobsTagsLabel: "Etiketak",
+    jobsTagsPlaceholder: "etiketa1, etiketa2, etiketa3",
+    noJobsMatch: "Ez dago bilaketarekin bat datorren lan-eskaintzarik.",
     //projects
     projectsTitle: "Proiektuak",
     projectsDescription: "Sortu, finantzatu eta jarraitu komunitateak gidatutako proiektuak zure sarean.",
@@ -2178,6 +2333,8 @@ module.exports = {
     projectBackersYourPledge: "Zure ekarpena",
     projectBackersNone: "Oraindik ez dago ekarpenik.",
     projectNoRemainingBudget: "Ez da aurrekonturik geratzen.",
+    projectFilterApplied: "ESKATUTA",
+    projectAppliedTitle: "ESKATUTA",
     projectFilterBackers: "BABESLEAK",
     projectBackersLeaderboardTitle: "Babesle nagusiak",
     projectNoBackersFound: "Ez dago babeslerik.",
@@ -2187,6 +2344,10 @@ module.exports = {
     projectPledgeAmount: "Kantitatea",
     projectSelectMilestoneOrBounty: "Hegoa edo Saria hautatu",
     projectPledgeButton: "Ekimen egin",
+    //footer
+    footerLicense: "GPLv3",
+    footerPackage: "Paketea",
+    footerVersion: "Bertsioa",
     //modules
     modulesModuleName: "Izena",
     modulesModuleDescription: "Deskribapena",
@@ -2264,6 +2425,8 @@ module.exports = {
     modulesProjectsDescription: "Proiektuak esploratzeko, finantzatzeko eta kudeatzeko modulu.",
     modulesBankingLabel: "Bankua",
     modulesBankingDescription: "ECOINen benetako balioa zehazteko eta UBI bat altxortegi komuna erabiliz banatzeko modulua.",
+    modulesFavoritesLabel: "Gogokoak",
+    modulesFavoritesDescription: "Zure gogoko edukia kudeatzeko modulua."
 
      //END
   }

+ 267 - 104
src/client/assets/translations/oasis_fr.js

@@ -111,6 +111,7 @@ module.exports = {
     marketTitle: "Marché",
     opinionsTitle: "Avis",
     saveSettings: "Enregistrer la configuration",
+    apply: "Appliquer",
     // menu categories
     menuPersonal: "Personnel",
     menuContent: "Contenu",
@@ -472,97 +473,121 @@ module.exports = {
     encryptionError: "Erreur lors du chiffrement du texte.",
     decryptionError: "Erreur lors du déchiffrement du texte.",
     //bookmarking
-     bookmarkTitle: "Marque-pages",
+    bookmarkTitle: "Marque-pages",
     bookmarkDescription: "Découvrez et gérez des marque-pages dans votre réseau.",
-    bookmarkCreateTitle: "Créer un marque-page",
-    bookmarkTitleLabel: "Titre",
-    bookmarkDescriptionLabel: "Description",
-    bookmarkCreatedAt: "Créé le",  
-    bookmarkAuthor: "Par",
-    bookmarkUrlLabel: "Lien",
-    bookmarkLink: "Lien",
-    bookmarkCreateButton: "Créer un marque-page",
-    existingbookmarksTitle: "Marque-pages existants",
-    nobookmarks: "Aucun marque-page disponible.",
-    newbookmarkSuccess: "Nouveau marque-page créé avec succès !",
-    bookmarkFilterAll: "TOUS",
-    bookmarkFilterMine: "MIENS",
-    bookmarkUpdateButton: "Mettre à jour",
-    bookmarkDeleteButton: "Supprimer",
     bookmarkAllSectionTitle: "Marque-pages",
     bookmarkMineSectionTitle: "Vos marque-pages",
+    bookmarkRecentSectionTitle: "Marque-pages récents",
+    bookmarkTopSectionTitle: "Meilleurs marque-pages",
+    bookmarkFavoritesSectionTitle: "Favoris",
     bookmarkCreateSectionTitle: "Créer un marque-page",
     bookmarkUpdateSectionTitle: "Mettre à jour le marque-page",
-    bookmarkTagsLabel: "Étiquettes",
-    bookmarkTagsPlaceholder: "Saisissez des étiquettes séparées par des virgules",
-    bookmarkFilterInternal: "INTERNE",
-    bookmarkFilterExternal: "EXTERNE",
+    bookmarkFilterAll: "TOUS",
+    bookmarkFilterMine: "MIENS",
     bookmarkFilterTop: "TOP",
+    bookmarkFilterFavorites: "FAVORIS",
     bookmarkFilterRecent: "RÉCENTS",
-    bookmarkInternalTitle: "Marque-pages internes", 
-    bookmarkExternalTitle: "Marque-pages externes",
-    bookmarkTopTitle: "Marque-pages principaux",
-    bookmarkRecentTitle: "Marque-pages récents",   
+    bookmarkCreateButton: "Créer un marque-page",
+    bookmarkUpdateButton: "Mettre à jour",
+    bookmarkDeleteButton: "Supprimer",
+    bookmarkAddFavoriteButton: "Ajouter aux favoris",
+    bookmarkRemoveFavoriteButton: "Retirer des favoris",
+    bookmarkUrlLabel: "Lien",
+    bookmarkUrlPlaceholder: "https://exemple.com",
+    bookmarkDescriptionLabel: "Description",
+    bookmarkDescriptionPlaceholder: "Optionnel",
+    bookmarkTagsLabel: "Tags",
+    bookmarkTagsPlaceholder: "Saisissez des tags séparés par des virgules",
     bookmarkCategoryLabel: "Catégorie",
-    bookmarkCategoryPlaceholder: "Saisissez la catégorie",
+    bookmarkCategoryPlaceholder: "Optionnel",
     bookmarkLastVisitLabel: "Dernière visite",
-    bookmarkDescriptionLabel: "Description",
-    bookmarkDescriptionText: "Description",
-    bookmarkLinkLabel: "Lien",
-    bookmarkCategory: "Catégorie",
-    bookmarkLastVisit: "Dernière visite",
+    bookmarkSearchPlaceholder: "Rechercher URL, tags, catégorie, auteur...",
+    bookmarkSortRecent: "Plus récents",
+    bookmarkSortOldest: "Plus anciens",
+    bookmarkSortTop: "Les plus votés",
+    bookmarkSearchButton: "Rechercher",
+    bookmarkUpdatedAt: "Mis à jour",
+    bookmarkNoMatch: "Aucun marque-page ne correspond à votre recherche.",
+    noBookmarks: "Aucun marque-page disponible.",
+    noUrl: "Aucun lien",
+    noCategory: "Aucune catégorie",
+    noLastVisit: "Aucune dernière visite",
     //videos
     videoTitle: "Vidéos",
-    videoFileLabel: "Téléverser une vidéo (.mp4, .webm, .ogv, .mov)",
-    videoDescription: "Découvrez et gérez des vidéos dans votre réseau.",
+    videoDescription: "Explorez et gérez du contenu vidéo dans votre réseau.",
+    videoPluginTitle: "Titre",
+    videoPluginDescription: "Description",
     videoMineSectionTitle: "Vos vidéos",
     videoCreateSectionTitle: "Téléverser une vidéo",
     videoUpdateSectionTitle: "Mettre à jour la vidéo",
     videoAllSectionTitle: "Vidéos",
+    videoRecentSectionTitle: "Vidéos récentes",
+    videoTopSectionTitle: "Vidéos les plus votées",
+    videoFavoritesSectionTitle: "Favoris",
     videoFilterAll: "TOUS",
-    videoFilterMine: "MIENS",
-    videoFilterRecent: "RÉCENTS",
+    videoFilterMine: "À MOI",
+    videoFilterRecent: "RÉCENTES",
     videoFilterTop: "TOP",
-    videoRecentSectionTitle: "Vidéos récentes",
-    videoTopSectionTitle: "Vidéos principales",   
+    videoFilterFavorites: "FAVORIS",
     videoCreateButton: "Téléverser une vidéo",
     videoUpdateButton: "Mettre à jour",
     videoDeleteButton: "Supprimer",
-    videoTagsLabel: "Étiquettes",
-    videoTagsPlaceholder: "Saisissez des étiquettes séparées par des virgules",
+    videoAddFavoriteButton: "Ajouter aux favoris",
+    videoRemoveFavoriteButton: "Retirer des favoris",
+    videoFileLabel: "Sélectionnez un fichier vidéo (.mp4, .webm, .ogv, .mov)",
+    videoTagsLabel: "Tags",
+    videoTagsPlaceholder: "Saisissez des tags séparés par des virgules",
     videoTitleLabel: "Titre",
-    videoTitlePlaceholder: "Optionnel",
+    videoTitlePlaceholder: "Facultatif",
     videoDescriptionLabel: "Description",
-    videoDescriptionPlaceholder: "Optionnel",
+    videoDescriptionPlaceholder: "Facultatif",
+    videoNoFile: "Aucun fichier vidéo fourni",
     noVideos: "Aucune vidéo disponible.",
-    videoCreatedAt: "Créé le",
-    videoAuthor: "Par",
+    videoSearchPlaceholder: "Rechercher par titre, tags, auteur…",
+    videoSortRecent: "Les plus récentes",
+    videoSortOldest: "Les plus anciennes",
+    videoSortTop: "Les plus votées",
+    videoSearchButton: "Rechercher",
+    videoMessageAuthorButton: "MP",
+    videoUpdatedAt: "Mis à jour",
+    videoNoMatch: "Aucune vidéo ne correspond à votre recherche.",
     //documents
     documentTitle: "Documents",
-    documentFileLabel: "Téléverser un document (.pdf)",
     documentDescription: "Découvrez et gérez des documents dans votre réseau.",
+    documentAllSectionTitle: "Documents",
     documentMineSectionTitle: "Vos documents",
     documentRecentSectionTitle: "Documents récents",
-    documentTopSectionTitle: "Documents principaux",
+    documentTopSectionTitle: "Documents top",
+    documentFavoritesSectionTitle: "Favoris",
     documentCreateSectionTitle: "Téléverser un document",
     documentUpdateSectionTitle: "Modifier le document",
-    documentAllSectionTitle: "Documents",
     documentFilterAll: "TOUS",
     documentFilterMine: "MIENS",
     documentFilterRecent: "RÉCENTS",
     documentFilterTop: "TOP",
-    documentCreateButton: "Téléverser un document",
+    documentFilterFavorites: "FAVORIS",
+    documentCreateButton: "Téléverser",
     documentUpdateButton: "Mettre à jour",
     documentDeleteButton: "Supprimer",
-    documentTagsLabel: "Étiquettes",
-    documentTagsPlaceholder: "Saisissez des étiquettes séparées par des virgules",
+    documentAddFavoriteButton: "Ajouter aux favoris",
+    documentRemoveFavoriteButton: "Retirer des favoris",
+    documentMessageAuthorButton: "PM",
+    documentFileLabel: "Téléverser un document (.pdf)",
+    documentTagsLabel: "Tags",
+    documentTagsPlaceholder: "Saisissez des tags séparés par des virgules",
     documentTitleLabel: "Titre",
     documentTitlePlaceholder: "Optionnel",
     documentDescriptionLabel: "Description",
     documentDescriptionPlaceholder: "Optionnel",
+    documentNoFile: "Aucun fichier.",
     noDocuments: "Aucun document disponible.",
-    documentCreatedAt: "Créé le",
-    documentAuthor: "Par",
+    documentSearchPlaceholder: "Rechercher titre, tags, description, auteur...",
+    documentSortRecent: "Les plus récents",
+    documentSortOldest: "Les plus anciens",
+    documentSortTop: "Les plus votés",
+    documentSearchButton: "Rechercher",
+    documentNoMatch: "Aucun document ne correspond à votre recherche.",
+    documentUpdatedAt: "Mis à jour",
     //audios
     audioTitle: "Audios",
     audioDescription: "Explorez et gérez du contenu audio dans votre réseau.",
@@ -573,26 +598,47 @@ module.exports = {
     audioUpdateSectionTitle: "Mettre à jour l’audio",
     audioAllSectionTitle: "Audios",
     audioRecentSectionTitle: "Audios récents",
-    audioTopSectionTitle: "Audios principaux",
+    audioTopSectionTitle: "Audios les mieux notés",
     audioFilterAll: "TOUS",
-    audioFilterMine: "MIENS",
+    audioFilterMine: "À MOI",
     audioFilterRecent: "RÉCENTS",
     audioFilterTop: "TOP",
     audioCreateButton: "Téléverser un audio",
     audioUpdateButton: "Mettre à jour",
     audioDeleteButton: "Supprimer",
+    audioAddFavoriteButton: "Ajouter aux favoris",
+    audioRemoveFavoriteButton: "Retirer des favoris",
     audioFileLabel: "Sélectionnez un fichier audio (.mp3, .wav, .ogg)",
-    audioTagsLabel: "Étiquettes",
-    audioTagsPlaceholder: "Saisissez des étiquettes séparées par des virgules",
+    audioTagsLabel: "Tags",
+    audioTagsPlaceholder: "Saisissez des tags séparés par des virgules",
     audioTitleLabel: "Titre",
-    audioTitlePlaceholder: "Optionnel",
+    audioTitlePlaceholder: "Facultatif",
     audioDescriptionLabel: "Description",
-    audioDescriptionPlaceholder: "Optionnel",
-    audioCreatedAt: "Créé le",
-    audioAuthor: "Par",
+    audioDescriptionPlaceholder: "Facultatif",
     audioNoFile: "Aucun fichier audio fourni",
-    audioNotSupported: "Votre navigateur ne prend pas en charge l’élément audio.",
     noAudios: "Aucun audio disponible.",
+    audioSearchPlaceholder: "Rechercher par titre, tags, auteur…",
+    audioSortRecent: "Les plus récents",
+    audioSortOldest: "Les plus anciens",
+    audioSortTop: "Les plus votés",
+    audioSearchButton: "Rechercher",
+    audioMessageAuthorButton: "PM",
+    audioUpdatedAt: "Mis à jour",
+    audioNoMatch: "Aucun audio ne correspond à votre recherche.",
+    audioFavoritesSectionTitle: "Favoris",
+    audioFilterFavorites: "FAVORIS",
+    //favorites
+    favoritesTitle: "Favoris",
+    favoritesDescription: "Tous vos favoris au même endroit.",
+    favoritesFilterAll: "TOUS",
+    favoritesFilterRecent: "RÉCENTS",
+    favoritesFilterAudios: "AUDIOS",
+    favoritesFilterBookmarks: "MARQUE-PAGES",
+    favoritesFilterDocuments: "DOCUMENTS",
+    favoritesFilterImages: "IMAGES",
+    favoritesFilterVideos: "VIDÉOS",
+    favoritesRemoveButton: "Retirer des favoris",
+    favoritesNoItems: "Aucun favori pour le moment.",
     //inhabitants
     yourContacts:       "Vos contacts",
     allInhabitants:     "Habitants",
@@ -986,6 +1032,7 @@ module.exports = {
     taskPrivateTitle: "Tâches privées",
     notasks: "Aucune tâche disponible.",
     noLocation: "Aucune localisation spécifiée",
+    taskSetStatus: "Définir l'état",
     //events
     eventTitle: "Événements",
     eventDateLabel: "Date",
@@ -1050,6 +1097,10 @@ module.exports = {
     eventNoImage: "Aucune image téléchargée",
     eventAttendConfirmation: "Vous assistez maintenant à cet événement",
     eventUnattendConfirmation: "Vous ne participez plus à cet événement",
+    eventAttended: "Présent",
+    eventUnattended: "Absent",
+    eventStatusOpen: "Ouvert",
+    eventStatusClosed: "Fermé",
     //tags 
     tagsTitle: "Étiquettes",
     tagsDescription: "Découvrez et explorez les modèles de taxonomie dans votre réseau.",
@@ -1100,6 +1151,24 @@ module.exports = {
     transfersClosedSectionTitle: "Transferts fermés",
     transfersDiscardedSectionTitle: "Transferts rejetés",
     transfersAllSectionTitle: "Transferts",
+    transfersFilterFavs: "Favoris",
+    transfersFavsSectionTitle: "Transferts favoris",
+    transfersSearchLabel: "Recherche",
+    transfersSearchPlaceholder: "Rechercher concept, tags, utilisateurs...",
+    transfersMinAmountLabel: "Montant min.",
+    transfersMaxAmountLabel: "Montant max.",
+    transfersSortLabel: "Trier par",
+    transfersSortRecent: "Plus récents",
+    transfersSortAmount: "Montant le plus élevé",
+    transfersSortDeadline: "Échéance la plus proche",
+    transfersSearchButton: "Rechercher",
+    transfersFavoriteButton: "Favori",
+    transfersUnfavoriteButton: "Retirer favori",
+    transfersMessageUserButton: "Message",
+    transfersExpiringSoonBadge: "BIENTÔT",
+    transfersExpiredBadge: "EXPIRÉ",
+    transfersUpdatedAt: "Mis à jour",
+    transfersNoMatch: "Aucun transfert ne correspond à votre recherche.",
     //votations (voting/polls)
     votationsTitle: "Votations",
     votationsDescription: "Découvrez et gérez les votes dans votre réseau.",
@@ -1243,43 +1312,50 @@ module.exports = {
     forumCatPRIVACY: "Vie privée",
     forumCatCYBERWARFARE: "Cyberguerre",
     forumCatSURVIVALISM: "Survivalisme",
-    // images
+    //images
     imageTitle: "Images",
+    imageDescription: "Explorez et gérez du contenu image dans votre réseau.",
     imagePluginTitle: "Titre",
     imagePluginDescription: "Description",
-    imageFileLabel: "Téléverser une image (jpeg, jpg, png, gif) (taille maximale : 500px x 400px)",
-    imageDescription: "Découvrez et gérez des images dans votre réseau.",
     imageMineSectionTitle: "Vos images",
-    imageCreateSectionTitle: "Téléverser une image (jpeg, jpg, png, gif) (taille maximale : 500px x 400px)",
-    imageUpdateSectionTitle: "Mettre à jour limage",
+    imageCreateSectionTitle: "Téléverser une image",
+    imageUpdateSectionTitle: "Mettre à jour l'image",
     imageAllSectionTitle: "Images",
+    imageRecentSectionTitle: "Images récentes",
+    imageTopSectionTitle: "Top images",
+    imageFavoritesSectionTitle: "Favoris",
+    imageGallerySectionTitle: "Galerie",
+    imageMemeSectionTitle: "Mèmes",
     imageFilterAll: "TOUS",
-    imageFilterMine: "MIENS",
+    imageFilterMine: "À MOI",
+    imageFilterRecent: "RÉCENTES",
+    imageFilterTop: "TOP",
+    imageFilterFavorites: "FAVORIS",
+    imageFilterGallery: "GALERIE",
+    imageFilterMeme: "MÈMES",
     imageCreateButton: "Téléverser une image",
-    imageEditDescription: "Modifiez les détails de votre image.",
-    imageCreateDescription: "Créez une image.",
-    imageTagsLabel: "Étiquettes",
-    imageTagsPlaceholder: "Saisissez des étiquettes séparées par des virgules",
     imageUpdateButton: "Mettre à jour",
     imageDeleteButton: "Supprimer",
-    imageCreatedAt: "Créé le",
-    imageAuthor: "Par",
-    imagePreview: "Aperçu de l’image",
-    noImages: "Aucune image disponible.",
-    imageFilterRecent: "RÉCENTS",
-    imageFilterPopular: "POPULAIRES",
-    imageFilterGallery: "GALERIE",
-    imageFilterTop: "TOP",
-    imageFilterMeme: "MÈMES",
+    imageAddFavoriteButton: "Ajouter aux favoris",
+    imageRemoveFavoriteButton: "Retirer des favoris",
+    imageFileLabel: "Sélectionnez un fichier image (.jpeg, .jpg, .png, .gif)",
+    imageTagsLabel: "Tags",
+    imageTagsPlaceholder: "Saisissez des tags séparés par des virgules",
     imageTitleLabel: "Titre",
-    imageGallerySectionTitle: "Galerie d’images",
-    imageMemeSectionTitle: "Mèmes",
-    imageTopSectionTitle: "Images principales",
-    imageRecentSectionTitle: "Images récentes",
-    imageTitlePlaceholder: "Optionnel",
+    imageTitlePlaceholder: "Facultatif",
     imageDescriptionLabel: "Description",
-    imageDescriptionPlaceholder: "Optionnel",
+    imageDescriptionPlaceholder: "Facultatif",
     imageMemeLabel: "Marquer comme MÈME",
+    imageNoFile: "Aucun fichier image fourni",
+    noImages: "Aucune image disponible.",
+    imageSearchPlaceholder: "Rechercher par titre, tags, description, auteur…",
+    imageSortRecent: "Les plus récentes",
+    imageSortOldest: "Les plus anciennes",
+    imageSortTop: "Les plus votées",
+    imageSearchButton: "Rechercher",
+    imageMessageAuthorButton: "Message",
+    imageUpdatedAt: "Mis à jour",
+    imageNoMatch: "Aucune image ne correspond à votre recherche.",
     //feed
     feedTitle:        "Feed",
     createFeedTitle:  "Créer un feed",
@@ -1495,7 +1571,7 @@ module.exports = {
     reportsCategoryFeatures: "Fonctions",
     reportsCategoryBugs: "Erreurs",
     reportsCategoryAbuse: "Abus",
-    reportsCategoryContent: "Problèmes de contenu",
+    reportsCategoryContent: "Problèmes de Contenu",
     reportsUpdateButton: "Mettre à jour",
     reportsDeleteButton: "Supprimer",
     reportsDateLabel: "Date",
@@ -1534,6 +1610,44 @@ module.exports = {
     reportsUnderReviewSectionTitle: "Rapports en révision",
     reportsResolvedSectionTitle: "Rapports résolus",
     reportsInvalidSectionTitle: "Rapports invalides",
+    reportsTemplateSectionTitle: 'Modèle de signalement',
+    reportsBugTemplateTitle: 'Détails de reproduction (Bugs)',
+    reportsFeatureTemplateTitle: 'Détails (Fonctionnalités)',
+    reportsAbuseTemplateTitle: 'Détails (Abus)',
+    reportsContentTemplateTitle: 'Détails (Problèmes de Contenu)',
+    reportsStepsToReproduceLabel: 'Étapes pour reproduire',
+    reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...',
+    reportsExpectedBehaviorLabel: 'Résultat attendu',
+    reportsExpectedBehaviorPlaceholder: 'Que devrait-il se passer ?',
+    reportsActualBehaviorLabel: 'Résultat obtenu',
+    reportsActualBehaviorPlaceholder: "Que se passe-t-il réellement ?",
+    reportsEnvironmentLabel: 'Environnement',
+    reportsEnvironmentPlaceholder: 'Version, appareil, OS, navigateur, paramètres, logs, etc.',
+    reportsReproduceRateLabel: 'Fréquence',
+    reportsReproduceRateAlways: 'Toujours',
+    reportsReproduceRateOften: 'Souvent',
+    reportsReproduceRateSometimes: 'Parfois',
+    reportsReproduceRateRarely: 'Rarement',
+    reportsReproduceRateUnable: 'Impossible à reproduire',
+    reportsReproduceRateUnknown: 'Non précisé',
+    reportsProblemStatementLabel: 'Problème / besoin',
+    reportsProblemStatementPlaceholder: 'Quel problème cette fonctionnalité résout-elle ?',
+    reportsUserStoryLabel: "User story",
+    reportsUserStoryPlaceholder: "En tant que <utilisateur>, je veux <action>, afin de <bénéfice>.",
+    reportsAcceptanceCriteriaLabel: "Critères d'acceptation",
+    reportsAcceptanceCriteriaPlaceholder: '- Étant donné...\n- Quand...\n- Alors...',
+    reportsWhatHappenedLabel: "Que s'est-il passé ?",
+    reportsWhatHappenedPlaceholder: "Décrivez l'incident avec du contexte et des dates approximatives.",
+    reportsReportedUserLabel: 'Utilisateur / entité signalé(e)',
+    reportsReportedUserPlaceholder: '@utilisateur, feed, ID, lien, etc.',
+    reportsEvidenceLinksLabel: 'Preuves / liens',
+    reportsEvidenceLinksPlaceholder: "Collez des liens, des IDs de messages, des captures (si applicable), etc.",
+    reportsContentLocationLabel: 'Où se trouve le contenu',
+    reportsContentLocationPlaceholder: 'Lien, ID, canal, fil, auteur, etc.',
+    reportsWhyInappropriateLabel: "Pourquoi c'est inapproprié",
+    reportsWhyInappropriatePlaceholder: 'Expliquez la raison et l’impact.',
+    reportsRequestedActionLabel: 'Action demandée',
+    reportsRequestedActionPlaceholder: 'Supprimer, masquer, étiqueter, avertir, etc.',
     //tribes
     tribesTitle: "Tribus",
     tribeAllSectionTitle: "Tribus",
@@ -2025,47 +2139,62 @@ module.exports = {
     marketAllSectionTitle: "Marché",
     marketRecentSectionTitle: "Marché récent",
     marketTitle: "Marché",
-    marketDescription: "Un marché pour échanger des biens ou services dans votre réseau.",
+    marketDescription: "Un marché pour échanger des biens ou des services dans votre réseau.",
     marketFilterAll: "TOUS",
     marketFilterMine: "MIENS",
     marketFilterAuctions: "ENCHÈRES",
     marketFilterItems: "ÉCHANGE",
     marketFilterNew: "NEUF",
-    marketFilterUsed: "UTILISÉ",
+    marketFilterUsed: "D'OCCASION",
     marketFilterBroken: "CASSÉ",
-    marketFilterForSale: "EN VENTE",
+    marketFilterForSale: "À VENDRE",
     marketFilterSold: "VENDU",
-    marketFilterDiscarded: "REJETÉ",
+    marketFilterDiscarded: "JETÉ",
     marketFilterRecent: "RÉCENT",
+    marketFilterMyBids: "ENCHÈRES",
     marketCreateButton: "Créer un article",
     marketItemType: "Type",
     marketItemTitle: "Titre",
-    marketItemAvailable: "Délai",
+    marketItemAvailable: "Date limite",
     marketItemDescription: "Description",
-    marketItemDescriptionPlaceholder: "Décrivez larticle que vous vendez",
-    marketItemStatus: "État",
-    marketItemCondition: "Condition",   
+    marketItemDescriptionPlaceholder: "Décrivez l'article que vous vendez",
+    marketItemStatus: "Statut",
+    marketItemCondition: "État",
     marketItemPrice: "Prix",
-    marketItemTags: "Étiquettes",
-    marketItemTagsPlaceholder: "Saisissez des étiquettes séparées par des virgules",
+    marketItemTags: "Tags",
+    marketItemTagsPlaceholder: "Entrez des tags séparés par des virgules",
     marketItemDeadline: "Date limite",
-    marketItemIncludesShipping: "Inclut l’expédition ?",
-    marketItemHighestBid: "Offre la plus élevée",
+    marketItemIncludesShipping: "Livraison incluse ?",
+    marketItemHighestBid: "Meilleure offre",
     marketItemHighestBidder: "Meilleur enchérisseur",
     marketItemStock: "Stock",
     marketOutOfStock: "Rupture de stock",
-    marketItemBidTime: "Temps de l’offre",
+    marketItemBidTime: "Date de l'enchère",
     marketActionsUpdate: "Mettre à jour",
-    marketUpdateButton: "Mettre à jour larticle !",
+    marketUpdateButton: "Mettre à jour l'article !",
     marketActionsDelete: "Supprimer",
     marketActionsSold: "Marquer comme vendu",
+    marketActionsChangeStatus: "CHANGER LE STATUT",
     marketActionsBuy: "ACHETER !",
-    marketAuctionBids: "Offres actuelles",
-    marketPlaceBidButton: "Faire une offre",
+    marketAuctionBids: "Offres en cours",
+    marketPlaceBidButton: "Enchérir",
     marketItemSeller: "Vendeur",
-    marketNoItems: "Aucun article disponible pour l’instant.",
+    marketNoItems: "Aucun article disponible pour le moment.",
     marketYourBid: "Votre offre",
-    marketCreateFormImageLabel: "Téléverser une image (jpeg, jpg, png, gif) (taille maximale : 500px x 400px)",
+    marketCreateFormImageLabel: "Téléverser une image (jpeg, jpg, png, gif) (taille max : 500px x 400px)",
+    marketSearchLabel: "Rechercher",
+    marketSearchPlaceholder: "Rechercher par titre ou tags",
+    marketMinPriceLabel: "Prix minimum",
+    marketMaxPriceLabel: "Prix maximum",
+    marketSortLabel: "Trier par",
+    marketSortRecent: "Les plus récents",
+    marketSortPrice: "Prix",
+    marketSortDeadline: "Date limite",
+    marketSearchButton: "Rechercher",
+    marketAuctionEndsIn: "Se termine",
+    marketAuctionEnded: "Terminé",
+    marketMyBidBadge: "Vous avez enchéri",
+    marketNoItemsMatch: "Aucun article ne correspond à votre recherche.",
     //jobs
     jobsTitle: "Offres d’emploi",
     jobsDescription: "Découvrez et gérez des offres d’emploi dans votre réseau.",
@@ -2107,7 +2236,7 @@ module.exports = {
     jobTitlePlaceholder: "Saisissez le titre de l’emploi",
     jobDescriptionPlaceholder: "Décrivez le poste",
     jobRequirementsPlaceholder: "Saisissez les exigences",
-    jobLanguagesPlaceholder: "Anglais, Espagnol, Euskera",
+    jobLanguagesPlaceholder: "Anglais, Français, Espagnol, Basque...",
     jobTasksPlaceholder: "Liste des tâches",
     jobLocationPresencial: "Présentiel",
     jobLocationRemote: "À distance",
@@ -2131,6 +2260,32 @@ module.exports = {
     jobTimeComplete: "Complet",
     jobsDeleteButton: "SUPPRIMER",
     jobsUpdateButton: "METTRE À JOUR",
+    jobsFilterApplied: "POSTULÉ",
+    jobsAppliedTitle: "Mes candidatures",
+    jobsAppliedBadge: "Candidaté",
+    jobsFilterFavs: "Favoris",
+    jobsFavsTitle: "Favoris",
+    jobsFilterNeeds: "Besoin d'aide",
+    jobsNeedsTitle: "Besoin d'aide",
+    jobsSearchLabel: "Recherche",
+    jobsSearchPlaceholder: "Rechercher titre, tags, description...",
+    jobsMinSalaryLabel: "Salaire min.",
+    jobsMaxSalaryLabel: "Salaire max.",
+    jobsSortLabel: "Trier par",
+    jobsSortRecent: "Plus récents",
+    jobsSortSalary: "Salaire le plus élevé",
+    jobsSortSubscribers: "Plus de candidats",
+    jobsSearchButton: "Rechercher",
+    jobsFavoriteButton: "Favori",
+    jobsUnfavoriteButton: "Retirer favori",
+    jobsMessageAuthorButton: "MP",
+    jobsApplicants: "Candidats",
+    jobsUpdatedAt: "Mis à jour",
+    jobSetOpen: "Marquer ouvert",
+    jobNewBadge: "NOUVEAU",
+    jobsTagsLabel: "Tags",
+    jobsTagsPlaceholder: "tag1, tag2, tag3",
+    noJobsMatch: "Aucune offre ne correspond à votre recherche.",
     //projects
     projectsTitle: "Projets",
     projectsDescription: "Créez, financez et suivez des projets communautaires dans votre réseau.",
@@ -2226,6 +2381,8 @@ module.exports = {
     projectBackersYourPledge: "Votre engagement",
     projectBackersNone: "Pas encore d'engagement.",
     projectNoRemainingBudget: "Aucun budget restant.",
+    projectFilterApplied: "POSTULÉ",
+    projectAppliedTitle: "POSTULÉ",
     projectFilterBackers: "MÉCÈNES",
     projectBackersLeaderboardTitle: "Mécènes en tête",
     projectNoBackersFound: "Aucun mécène trouvé.",
@@ -2235,6 +2392,10 @@ module.exports = {
     projectPledgeAmount: "Montant",
     projectSelectMilestoneOrBounty: "Sélectionner un jalon ou une récompense",
     projectPledgeButton: "Soutenir",
+    //footer
+    footerLicense: "GPLv3",
+    footerPackage: "Paquet",
+    footerVersion: "Version",
     //modules
     modulesModuleName: "Nom",
     modulesModuleDescription: "Description",
@@ -2312,6 +2473,8 @@ module.exports = {
     modulesProjectsDescription: "Module pour explorer, financer et gérer des projets.",
     modulesBankingLabel: "Banque",
     modulesBankingDescription: "Module pour connaître la valeur réelle de ECOIN et distribuer une RBU en utilisant la trésorerie commune.",
+    modulesFavoritesLabel: "Favoris",
+    modulesFavoritesDescription: "Module pour gérer votre contenu favori."
      
      //END
     }

+ 2 - 1
src/configs/config-manager.js

@@ -43,7 +43,8 @@ if (!fs.existsSync(configFilePath)) {
       "projectsMod": "on",
       "bankingMod": "on",
       "parliamentMod": "on",
-      "courtsMod": "on"
+      "courtsMod": "on",
+      "favoritesMod": "on"
     },
     "wallet": {
       "url": "http://localhost:7474",

+ 7 - 0
src/configs/media-favorites.json

@@ -0,0 +1,7 @@
+{
+  "audios": [],
+  "bookmarks": [],
+  "documents": [],
+  "images": [],
+  "videos": []
+}

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

@@ -37,7 +37,8 @@
     "projectsMod": "on",
     "bankingMod": "on",
     "parliamentMod": "on",
-    "courtsMod": "on"
+    "courtsMod": "on",
+    "favoritesMod": "on"
   },
   "wallet": {
     "url": "http://localhost:7474",

+ 281 - 146
src/models/audios_model.js

@@ -1,185 +1,320 @@
-const pull = require('../server/node_modules/pull-stream')
-const { getConfig } = require('../configs/config-manager.js');
+const pull = require("../server/node_modules/pull-stream");
+const { getConfig } = require("../configs/config-manager.js");
+const categories = require("../backend/opinion_categories");
+
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
-const categories = require('../backend/opinion_categories')
+
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+
+const normalizeTags = (raw) => {
+  if (raw === undefined || raw === null) return undefined;
+  if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
+  return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
+};
+
+const parseBlobId = (blobMarkdown) => {
+  const s = String(blobMarkdown || "");
+  const match = s.match(/\(([^)]+)\)/);
+  return match ? match[1] : s || null;
+};
+
+const voteSum = (opinions = {}) =>
+  Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
 
 module.exports = ({ cooler }) => {
-  let ssb
-  let userId
+  let ssb;
 
   const openSsb = async () => {
-    if (!ssb) {
-      ssb = await cooler.open()
-      userId = ssb.id
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
+
+  const getAllMessages = async (ssbClient) =>
+    new Promise((resolve, reject) => {
+      pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
+    });
+
+  const getMsg = async (ssbClient, key) =>
+    new Promise((resolve, reject) => {
+      ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
+    });
+
+  const buildIndex = (messages) => {
+    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 v = m.value || {};
+      const c = v.content;
+      if (!c) continue;
+
+      if (c.type === "tombstone" && c.target) {
+        tomb.add(c.target);
+        continue;
+      }
+
+      if (c.type !== "audio") continue;
+
+      const ts = v.timestamp || m.timestamp || 0;
+      nodes.set(k, { key: k, ts, c });
+
+      if (c.replaces) {
+        parent.set(k, c.replaces);
+        child.set(c.replaces, k);
+      }
     }
-    return ssb
-  }
+
+    const rootOf = (id) => {
+      let cur = id;
+      while (parent.has(cur)) cur = parent.get(cur);
+      return cur;
+    };
+
+    const tipOf = (id) => {
+      let cur = id;
+      while (child.has(cur)) cur = child.get(cur);
+      return cur;
+    };
+
+    const roots = new Set();
+    for (const id of nodes.keys()) roots.add(rootOf(id));
+
+    const tipByRoot = new Map();
+    for (const r of roots) tipByRoot.set(r, tipOf(r));
+
+    const forward = new Map();
+    for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
+
+    return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
+  };
+
+  const buildAudio = (node, rootId, viewerId) => {
+    const c = node.c || {};
+    const voters = safeArr(c.opinions_inhabitants);
+    return {
+      key: node.key,
+      rootId,
+      url: c.url,
+      createdAt: c.createdAt || new Date(node.ts).toISOString(),
+      updatedAt: c.updatedAt || null,
+      tags: safeArr(c.tags),
+      author: c.author,
+      title: c.title || "",
+      description: c.description || "",
+      opinions: c.opinions || {},
+      opinions_inhabitants: voters,
+      hasVoted: viewerId ? voters.includes(viewerId) : false
+    };
+  };
 
   return {
+    type: "audio",
+
+    async resolveCurrentId(id) {
+      const ssbClient = await openSsb();
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Audio not found");
+      return tip;
+    },
+
+    async resolveRootId(id) {
+      const ssbClient = await openSsb();
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Audio not found");
+
+      let root = tip;
+      while (idx.parent.has(root)) root = idx.parent.get(root);
+      return root;
+    },
+
     async createAudio(blobMarkdown, tagsRaw, title, description) {
-      const ssbClient = await openSsb()
-      const match = blobMarkdown?.match(/\(([^)]+)\)/)
-      const blobId = match ? match[1] : blobMarkdown
-      const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : []
+      const ssbClient = await openSsb();
+      const blobId = parseBlobId(blobMarkdown);
+      const tags = normalizeTags(tagsRaw) || [];
+      const now = new Date().toISOString();
+
       const content = {
-        type: 'audio',
+        type: "audio",
         url: blobId,
-        createdAt: new Date().toISOString(),
-        author: userId,
+        createdAt: now,
+        updatedAt: null,
+        author: ssbClient.id,
         tags,
-        title: title || '',
-        description: description || '',
+        title: title || "",
+        description: description || "",
         opinions: {},
         opinions_inhabitants: []
-      }
+      };
+
       return new Promise((resolve, reject) => {
-        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res))
-      })
+        ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
+      });
     },
 
     async updateAudioById(id, blobMarkdown, tagsRaw, title, description) {
-      const ssbClient = await openSsb()
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(id);
+      const oldMsg = await getMsg(ssbClient, tipId);
+
+      if (!oldMsg || oldMsg.content?.type !== "audio") throw new Error("Audio not found");
+      if (Object.keys(oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit audio after it has received opinions.");
+      if (oldMsg.content.author !== userId) throw new Error("Not the author");
+
+      const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
+      const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
+      const now = new Date().toISOString();
+
+      const updated = {
+        ...oldMsg.content,
+        replaces: tipId,
+        url: blobId || oldMsg.content.url,
+        tags,
+        title: title !== undefined ? title || "" : oldMsg.content.title || "",
+        description: description !== undefined ? description || "" : oldMsg.content.description || "",
+        createdAt: oldMsg.content.createdAt,
+        updatedAt: now
+      };
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, oldMsg) => {
-          if (err || !oldMsg || oldMsg.content?.type !== 'audio') return reject(new Error('Audio not found'))
-          if (Object.keys(oldMsg.content.opinions || {}).length > 0) return reject(new Error('Cannot edit audio after it has received opinions.'))
-          if (oldMsg.content.author !== userId) return reject(new Error('Not the author'))
-          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 || '',
-            updatedAt: new Date().toISOString()
-          }
-          ssbClient.publish(updated, (err3, result) => err3 ? reject(err3) : resolve(result))
-        })
-      })
+        ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
+      });
     },
 
     async deleteAudioById(id) {
-      const ssbClient = await openSsb()
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(id);
+      const msg = await getMsg(ssbClient, tipId);
+
+      if (!msg || msg.content?.type !== "audio") throw new Error("Audio not found");
+      if (msg.content.author !== userId) throw new Error("Not the author");
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'audio') return reject(new Error('Audio 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))
-        })
-      })
+        ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res)));
+      });
     },
 
-    async listAll(filter = 'all') {
-      const ssbClient = await openSsb()
-      const messages = await new Promise((res, rej) => {
-        pull(
-          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 !== 'audio') continue
-        if (tombstoned.has(k)) continue
-        if (c.replaces) replaces.set(c.replaces, k)
-        latest.set(k, {
-          key: k,
-          url: c.url,
-          createdAt: c.createdAt,
-          updatedAt: c.updatedAt || null,
-          tags: c.tags || [],
-          author: c.author,
-          title: c.title || '',
-          description: c.description || '',
-          opinions: c.opinions || {},
-          opinions_inhabitants: c.opinions_inhabitants || []
-        })
+    async listAll(filterOrOpts = "all", maybeOpts = {}) {
+      const ssbClient = await openSsb();
+
+      const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
+      const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
+      const q = String(opts.q || "").trim().toLowerCase();
+      const sort = String(opts.sort || "recent").trim();
+      const viewerId = opts.viewerId || ssbClient.id;
+
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      const items = [];
+      for (const [rootId, tipId] of idx.tipByRoot.entries()) {
+        if (idx.tomb.has(tipId)) continue;
+        const node = idx.nodes.get(tipId);
+        if (!node) continue;
+        items.push(buildAudio(node, rootId, viewerId));
+      }
+
+      let list = items;
+      const now = Date.now();
+
+      if (filter === "mine") list = list.filter((a) => String(a.author) === String(viewerId));
+      else if (filter === "recent") list = list.filter((a) => new Date(a.createdAt).getTime() >= now - 86400000);
+      else if (filter === "top") {
+        list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
       }
-      for (const oldId of replaces.keys()) {
-        latest.delete(oldId)
+
+      if (q) {
+        list = list.filter((a) => {
+          const title = String(a.title || "").toLowerCase();
+          const desc = String(a.description || "").toLowerCase();
+          const tags = safeArr(a.tags).join(" ").toLowerCase();
+          const author = String(a.author || "").toLowerCase();
+          return title.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
+        });
       }
 
-      let audios = Array.from(latest.values())
-
-      if (filter === 'mine') {
-        audios = audios.filter(a => a.author === userId)
-      } else if (filter === 'recent') {
-        const now = Date.now()
-        audios = audios.filter(a => new Date(a.createdAt).getTime() >= (now - 24 * 60 * 60 * 1000))
-      } else if (filter === 'top') {
-        audios = audios.sort((a, b) => {
-          const sumA = Object.values(a.opinions).reduce((sum, v) => sum + v, 0)
-          const sumB = Object.values(b.opinions).reduce((sum, v) => sum + v, 0)
-          return sumB - sumA
-        })
+      if (sort === "top") {
+        list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
+      } else if (sort === "oldest") {
+        list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
       } else {
-        audios = audios.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+        list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
       }
 
-      return audios
+      return list;
     },
 
-    async getAudioById(id) {
-      const ssbClient = await openSsb()
-      return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'audio') return reject(new Error('Audio not found'))
-          resolve({
-            key: id,
-            url: msg.content.url,
-            createdAt: msg.content.createdAt,
-            updatedAt: msg.content.updatedAt || null,
-            tags: msg.content.tags || [],
-            author: msg.content.author,
-            title: msg.content.title || '',
-            description: msg.content.description || '',
-            opinions: msg.content.opinions || {},
-            opinions_inhabitants: msg.content.opinions_inhabitants || []
-          })
-        })
-      })
+    async getAudioById(id, viewerId = null) {
+      const ssbClient = await openSsb();
+      const viewer = viewerId || ssbClient.id;
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Audio not found");
+
+      let root = tip;
+      while (idx.parent.has(root)) root = idx.parent.get(root);
+
+      const node = idx.nodes.get(tip);
+      if (node) return buildAudio(node, root, viewer);
+
+      const msg = await getMsg(ssbClient, tip);
+      if (!msg || msg.content?.type !== "audio") throw new Error("Audio not found");
+      return buildAudio({ key: tip, ts: msg.timestamp || 0, c: msg.content }, root, viewer);
     },
 
     async createOpinion(id, category) {
-      const ssbClient = await openSsb()
-      if (!categories.includes(category)) return reject(new Error('Invalid voting category'))
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+
+      if (!categories.includes(category)) throw new Error("Invalid voting category");
+
+      const tipId = await this.resolveCurrentId(id);
+      const msg = await getMsg(ssbClient, tipId);
+
+      if (!msg || msg.content?.type !== "audio") throw new Error("Audio not found");
+
+      const voters = safeArr(msg.content.opinions_inhabitants);
+      if (voters.includes(userId)) throw new Error("Already voted");
+
+      const now = new Date().toISOString();
+      const updated = {
+        ...msg.content,
+        replaces: tipId,
+        opinions: {
+          ...msg.content.opinions,
+          [category]: (msg.content.opinions?.[category] || 0) + 1
+        },
+        opinions_inhabitants: voters.concat(userId),
+        updatedAt: now
+      };
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'audio') return reject(new Error('Audio not found'))
-          if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'))
-          const updated = {
-            ...msg.content,
-            replaces: id,
-            opinions: {
-              ...msg.content.opinions,
-              [category]: (msg.content.opinions?.[category] || 0) + 1
-            },
-            opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
-            updatedAt: new Date().toISOString()
-          }
-          ssbClient.publish(updated, (err3, result) => err3 ? reject(err3) : resolve(result))
-        })
-      })
+        ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
+      });
     }
-  }
-}
+  };
+};
 

+ 305 - 173
src/models/bookmarking_model.js

@@ -1,200 +1,332 @@
-const pull = require('../server/node_modules/pull-stream')
-const moment = require('../server/node_modules/moment')
-const { getConfig } = require('../configs/config-manager.js');
-const categories = require('../backend/opinion_categories')
+const pull = require("../server/node_modules/pull-stream");
+const moment = require("../server/node_modules/moment");
+const { getConfig } = require("../configs/config-manager.js");
+const categories = require("../backend/opinion_categories");
+
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+const safeText = (v) => String(v || "").trim();
+
+const normalizeTags = (raw) => {
+  if (raw === undefined || raw === null) return [];
+  if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
+  return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
+};
+
+const coerceLastVisit = (lastVisit) => {
+  const now = moment();
+  if (!lastVisit) return now.toISOString();
+  const m = moment(lastVisit, moment.ISO_8601, true);
+  if (!m.isValid()) return now.toISOString();
+  if (m.isAfter(now)) return now.toISOString();
+  return m.toISOString();
+};
+
+const voteSum = (opinions = {}) =>
+  Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
+
 module.exports = ({ cooler }) => {
-  let ssb
-  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
+  let ssb;
+
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
+
+  const getAllMessages = async (ssbClient) =>
+    new Promise((resolve, reject) => {
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
+      );
+    });
+
+  const getMsg = async (ssbClient, key) =>
+    new Promise((resolve, reject) => {
+      ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
+    });
+
+  const buildIndex = (messages) => {
+    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 v = m.value || {};
+      const c = v.content;
+      if (!c) continue;
+
+      if (c.type === "tombstone" && c.target) {
+        tomb.add(c.target);
+        continue;
+      }
+
+      if (c.type !== "bookmark") continue;
+
+      const ts = v.timestamp || m.timestamp || 0;
+      nodes.set(k, { key: k, ts, 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 tipOf = (id) => {
+      let cur = id;
+      while (child.has(cur)) cur = child.get(cur);
+      return cur;
+    };
+
+    const roots = new Set();
+    for (const id of nodes.keys()) roots.add(rootOf(id));
+
+    const tipByRoot = new Map();
+    for (const r of roots) tipByRoot.set(r, tipOf(r));
+
+    const forward = new Map();
+    for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
+
+    return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
+  };
+
+  const buildBookmark = (node, rootId, viewerId) => {
+    const c = node.c || {};
+    const voters = safeArr(c.opinions_inhabitants);
+    return {
+      id: node.key,
+      rootId,
+      url: c.url || "",
+      createdAt: c.createdAt || new Date(node.ts).toISOString(),
+      updatedAt: c.updatedAt || null,
+      lastVisit: c.lastVisit || null,
+      tags: safeArr(c.tags),
+      category: c.category || "",
+      description: c.description || "",
+      opinions: c.opinions || {},
+      opinions_inhabitants: voters,
+      author: c.author,
+      hasVoted: viewerId ? voters.includes(viewerId) : false
+    };
+  };
 
   return {
-    type: 'bookmark',
+    type: "bookmark",
+
+    async resolveCurrentId(id) {
+      const ssbClient = await openSsb();
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Bookmark not found");
+      return tip;
+    },
+
+    async resolveRootId(id) {
+      const ssbClient = await openSsb();
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Bookmark not found");
+
+      let root = tip;
+      while (idx.parent.has(root)) root = idx.parent.get(root);
+      return root;
+    },
 
     async createBookmark(url, tagsRaw, description, category, lastVisit) {
-      const ssbClient = await openSsb()
-      const userId = ssbClient.id
-      let tags = Array.isArray(tagsRaw) ? tagsRaw.filter(t => t) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean)
-      const isInternal = url.includes('127.0.0.1') || url.includes('localhost')
-      if (!tags.includes(isInternal ? 'internal' : 'external')) {
-        tags.push(isInternal ? 'internal' : 'external')
-      }
-      const formattedLastVisit = lastVisit
-        ? moment(lastVisit, moment.ISO_8601, true).toISOString()
-        : moment().toISOString()
+      const ssbClient = await openSsb();
+      const now = new Date().toISOString();
+
+      const u = safeText(url);
+      if (!u) throw new Error("URL is required");
+
       const content = {
-        type: 'bookmark',
-        author: userId,
-        url,
-        tags,
-        description,
-        category,
-        createdAt: new Date().toISOString(),
-        updatedAt: new Date().toISOString(),
-        lastVisit: formattedLastVisit,
+        type: "bookmark",
+        author: ssbClient.id,
+        url: u,
+        tags: normalizeTags(tagsRaw),
+        description: description || "",
+        category: category || "",
+        createdAt: now,
+        updatedAt: now,
+        lastVisit: coerceLastVisit(lastVisit),
         opinions: {},
         opinions_inhabitants: []
-      }
+      };
+
       return new Promise((resolve, reject) => {
-        ssbClient.publish(content, (err, res) => err ? reject(new Error("Error creating bookmark: " + err.message)) : resolve(res))
-      })
+        ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
+      });
     },
 
-    async listAll(author = null, filter = 'all') {
-      const ssbClient = await openSsb()
-      const userId = ssbClient.id
-      const results = await new Promise((res, rej) => {
-        pull(
-          ssbClient.createLogStream({ limit: logLimit }),
-          pull.collect((err, msgs) => err ? rej(err) : res(msgs))
-        )
-      })
-      const tombstoned = new Set(
-        results
-          .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 results) {
-        const k = m.key
-        const c = m.value.content
-        if (!c || c.type !== 'bookmark') continue
-        if (tombstoned.has(k)) continue
-        if (c.replaces) replaces.set(c.replaces, k)
-        latest.set(k, {
-          id: k,
-          url: c.url,
-          description: c.description,
-          category: c.category,
-          createdAt: c.createdAt,
-          lastVisit: c.lastVisit,
-          tags: c.tags || [],
-          opinions: c.opinions || {},
-          opinions_inhabitants: c.opinions_inhabitants || [],
-          author: c.author
-        })
-      }
-      for (const oldId of replaces.keys()) {
-        latest.delete(oldId)
-      }
-      let bookmarks = Array.from(latest.values())
-      if (filter === 'mine' && author === userId) {
-        bookmarks = bookmarks.filter(b => b.author === author)
-      } else if (filter === 'external') {
-        bookmarks = bookmarks.filter(b => b.tags.includes('external'))
-      } else if (filter === 'internal') {
-        bookmarks = bookmarks.filter(b => b.tags.includes('internal'))
-      }
-      return bookmarks
+    async updateBookmarkById(id, updatedData) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+
+      const tipId = await this.resolveCurrentId(id);
+      const oldMsg = await getMsg(ssbClient, tipId);
+
+      if (!oldMsg || oldMsg.content?.type !== "bookmark") throw new Error("Bookmark not found");
+      if (Object.keys(oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit bookmark after it has received opinions.");
+      if (String(oldMsg.content.author) !== String(userId)) throw new Error("Not the author");
+
+      const url = safeText(updatedData.url || oldMsg.content.url);
+      if (!url) throw new Error("URL is required");
+
+      const now = new Date().toISOString();
+
+      const updated = {
+        ...oldMsg.content,
+        replaces: tipId,
+        url,
+        tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags),
+        description: updatedData.description !== undefined ? updatedData.description || "" : oldMsg.content.description || "",
+        category: updatedData.category !== undefined ? updatedData.category || "" : oldMsg.content.category || "",
+        lastVisit: coerceLastVisit(updatedData.lastVisit || oldMsg.content.lastVisit),
+        createdAt: oldMsg.content.createdAt,
+        updatedAt: now
+      };
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
+      });
     },
 
-    async updateBookmarkById(bookmarkId, updatedData) {
-      const ssbClient = await openSsb()
-      const userId = ssbClient.id
-      const old = await new Promise((res, rej) =>
-        ssbClient.get(bookmarkId, (err, msg) =>
-          err || !msg?.content ? rej(err || new Error("Error retrieving old bookmark.")) : res(msg)
-        )
-      )
-      if (Object.keys(old.content.opinions || {}).length > 0) {
-        throw new Error('Cannot edit bookmark after it has received opinions.')
+    async deleteBookmarkById(id) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+
+      const tipId = await this.resolveCurrentId(id);
+      const msg = await getMsg(ssbClient, tipId);
+
+      if (!msg || msg.content?.type !== "bookmark") throw new Error("Bookmark not found");
+      if (String(msg.content.author) !== String(userId)) throw new Error("Not the author");
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
+
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res)));
+      });
+    },
+
+    async listAll(filterOrOpts = "all", maybeOpts = {}) {
+      const ssbClient = await openSsb();
+
+      const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
+      const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
+      const q = safeText(opts.q || "").toLowerCase();
+      const sort = safeText(opts.sort || "recent");
+      const viewerId = opts.viewerId || ssbClient.id;
+
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      const items = [];
+      for (const [rootId, tipId] of idx.tipByRoot.entries()) {
+        if (idx.tomb.has(tipId)) continue;
+        const node = idx.nodes.get(tipId);
+        if (!node) continue;
+        items.push(buildBookmark(node, rootId, viewerId));
       }
-      const tags = updatedData.tags
-        ? updatedData.tags.split(',').map(t => t.trim()).filter(Boolean)
-        : []
-      const isInternal = updatedData.url.includes('127.0.0.1') || updatedData.url.includes('localhost')
-      if (!tags.includes(isInternal ? 'internal' : 'external')) {
-        tags.push(isInternal ? 'internal' : 'external')
+
+      let list = items;
+      const now = Date.now();
+
+      if (filter === "mine") list = list.filter((b) => String(b.author) === String(viewerId));
+      else if (filter === "recent") list = list.filter((b) => new Date(b.createdAt).getTime() >= now - 86400000);
+      else if (filter === "top") {
+        list = list
+          .slice()
+          .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
       }
-      const formattedLastVisit = updatedData.lastVisit
-        ? moment(updatedData.lastVisit, moment.ISO_8601, true).toISOString()
-        : moment().toISOString()
-      const updated = {
-        type: 'bookmark',
-        replaces: bookmarkId,
-        author: old.content.author,
-        url: updatedData.url,
-        tags,
-        description: updatedData.description,
-        category: updatedData.category,
-        createdAt: old.content.createdAt,
-        updatedAt: new Date().toISOString(),
-        lastVisit: formattedLastVisit,
-        opinions: old.content.opinions,
-        opinions_inhabitants: old.content.opinions_inhabitants
+
+      if (q) {
+        list = list.filter((b) => {
+          const url = String(b.url || "").toLowerCase();
+          const cat = String(b.category || "").toLowerCase();
+          const desc = String(b.description || "").toLowerCase();
+          const tags = safeArr(b.tags).join(" ").toLowerCase();
+          const author = String(b.author || "").toLowerCase();
+          return url.includes(q) || cat.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
+        });
       }
-      return new Promise((resolve, reject) => {
-        ssbClient.publish(updated, (err2, res) => err2 ? reject(new Error("Error creating updated bookmark.")) : resolve(res))
-      })
-    },
 
-    async deleteBookmarkById(bookmarkId) {
-      const ssbClient = await openSsb()
-      const userId = ssbClient.id
-      const msg = await new Promise((res, rej) =>
-        ssbClient.get(bookmarkId, (err, m) => err ? rej(new Error("Error retrieving bookmark.")) : res(m))
-      )
-      if (msg.content.author !== userId) throw new Error("Error: You are not the author of this bookmark.")
-      const tombstone = {
-        type: 'tombstone',
-        target: bookmarkId,
-        deletedAt: new Date().toISOString(),
-        author: userId
+      if (sort === "top") {
+        list = list
+          .slice()
+          .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
+      } else if (sort === "oldest") {
+        list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
+      } else {
+        list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
       }
-      return new Promise((resolve, reject) => {
-        ssbClient.publish(tombstone, (err2, res) => {
-          if (err2) return reject(new Error("Error creating tombstone."))
-          resolve(res)
-        })
-      })
+
+      return list;
     },
 
-    async getBookmarkById(bookmarkId) {
-      const ssbClient = await openSsb()
-      return new Promise((resolve, reject) => {
-        ssbClient.get(bookmarkId, (err, msg) => {
-          if (err || !msg || !msg.content) return reject(new Error("Error retrieving bookmark"))
-          const c = msg.content
-          resolve({
-            id: bookmarkId,
-            url: c.url || "Unknown",
-            description: c.description || "No description",
-            category: c.category || "No category",
-            createdAt: c.createdAt || "Unknown",
-            updatedAt: c.updatedAt || "Unknown",
-            lastVisit: c.lastVisit || "Unknown",
-            tags: c.tags || [],
-            opinions: c.opinions || {},
-            opinions_inhabitants: c.opinions_inhabitants || [],
-            author: c.author || "Unknown"
-          })
-        })
-      })
+    async getBookmarkById(id, viewerId = null) {
+      const ssbClient = await openSsb();
+      const viewer = viewerId || ssbClient.id;
+
+      const tipId = await this.resolveCurrentId(id);
+      const rootId = await this.resolveRootId(id);
+
+      const msg = await getMsg(ssbClient, tipId);
+      if (!msg || msg.content?.type !== "bookmark") throw new Error("Bookmark not found");
+
+      return buildBookmark({ key: tipId, ts: msg.timestamp || 0, c: msg.content }, rootId, viewer);
     },
 
-    async createOpinion(bookmarkId, category) {
-      if (!categories.includes(category)) return Promise.reject(new Error('Invalid voting category'))
-      const ssbClient = await openSsb()
-      const userId = ssbClient.id
+    async createOpinion(id, category) {
+      if (!categories.includes(category)) throw new Error("Invalid voting category");
+
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+
+      const tipId = await this.resolveCurrentId(id);
+      const msg = await getMsg(ssbClient, tipId);
+
+      if (!msg || msg.content?.type !== "bookmark") throw new Error("Bookmark not found");
+
+      const voters = safeArr(msg.content.opinions_inhabitants);
+      if (voters.includes(userId)) throw new Error("Already voted");
+
+      const now = new Date().toISOString();
+      const updated = {
+        ...msg.content,
+        replaces: tipId,
+        opinions: {
+          ...msg.content.opinions,
+          [category]: (msg.content.opinions?.[category] || 0) + 1
+        },
+        opinions_inhabitants: voters.concat(userId),
+        updatedAt: now
+      };
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(bookmarkId, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'bookmark') return reject(new Error('Bookmark not found'))
-          if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'))
-          const updated = {
-            ...msg.content,
-            replaces: bookmarkId,
-            opinions: {
-              ...msg.content.opinions,
-              [category]: (msg.content.opinions?.[category] || 0) + 1
-            },
-            opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
-            updatedAt: new Date().toISOString()
-          }
-          ssbClient.publish(updated, (err3, result) => err3 ? reject(err3) : resolve(result))
-        })
-      })
+        ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
+      });
     }
-  }
-}
+  };
+};
 

+ 280 - 124
src/models/documents_model.js

@@ -1,191 +1,347 @@
-const pull = require('../server/node_modules/pull-stream');
-const { getConfig } = require('../configs/config-manager.js');
-const categories = require('../backend/opinion_categories');
+const pull = require("../server/node_modules/pull-stream");
+const { getConfig } = require("../configs/config-manager.js");
+const categories = require("../backend/opinion_categories");
+const mediaFavorites = require("../backend/media-favorites");
+
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
-const extractBlobId = str => {
-  if (!str || typeof str !== 'string') return null;
-  const match = str.match(/\(([^)]+\.sha256)\)/);
-  return match ? match[1] : str.trim();
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+const safeText = (v) => String(v || "").trim();
+
+const parseBlobId = (blobMarkdown) => {
+  if (!blobMarkdown) return null;
+  const s = String(blobMarkdown);
+  const match = s.match(/\(([^)]+)\)/);
+  return match ? match[1] : s.trim();
 };
 
-const parseCSV = str => str ? str.split(',').map(s => s.trim()).filter(Boolean) : [];
+const parseCSV = (str) =>
+  str === undefined || str === null ? undefined : String(str).split(",").map((s) => s.trim()).filter(Boolean);
+
+const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
 
 module.exports = ({ cooler }) => {
   let ssb;
-  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+  let userId;
+
+  const openSsb = async () => {
+    if (!ssb) {
+      ssb = await cooler.open();
+      userId = ssb.id;
+    }
+    return ssb;
+  };
+
+  const getAllMessages = async (ssbClient) =>
+    new Promise((resolve, reject) => {
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
+      );
+    });
+
+  const buildIndex = (messages) => {
+    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 v = m.value || {};
+      const c = v.content;
+      if (!c) continue;
+
+      if (c.type === "tombstone" && c.target) {
+        tomb.add(c.target);
+        continue;
+      }
+
+      if (c.type !== "document") continue;
+
+      const ts = v.timestamp || m.timestamp || 0;
+      nodes.set(k, { key: k, ts, 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 tipOf = (id) => {
+      let cur = id;
+      while (child.has(cur)) cur = child.get(cur);
+      return cur;
+    };
+
+    const roots = new Set();
+    for (const id of nodes.keys()) roots.add(rootOf(id));
+
+    const tipByRoot = new Map();
+    for (const r of roots) tipByRoot.set(r, tipOf(r));
+
+    const forward = new Map();
+    for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
+
+    return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
+  };
+
+  const pickDoc = (node, rootId) => {
+    const c = node.c || {};
+    return {
+      key: node.key,
+      rootId,
+      url: c.url,
+      createdAt: c.createdAt,
+      updatedAt: c.updatedAt || null,
+      tags: safeArr(c.tags),
+      author: c.author,
+      title: c.title || "",
+      description: c.description || "",
+      opinions: c.opinions || {},
+      opinions_inhabitants: safeArr(c.opinions_inhabitants)
+    };
+  };
+
+  const hasBlob = (ssbClient, blobId) =>
+    new Promise((resolve) => {
+      if (!blobId) return resolve(false);
+      ssbClient.blobs.has(blobId, (err, has) => resolve(!err && !!has));
+    });
+
+  const favoritesSetForDocuments = async () => {
+    try {
+      return await mediaFavorites.getFavoriteSet("documents");
+    } catch {
+      return new Set();
+    }
+  };
 
   return {
-    type: 'document',
+    type: "document",
+
+    async resolveCurrentId(id) {
+      const ssbClient = await openSsb();
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Document not found");
+      return tip;
+    },
+
+    async resolveRootId(id) {
+      const ssbClient = await openSsb();
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Document not found");
+
+      let root = tip;
+      while (idx.parent.has(root)) root = idx.parent.get(root);
+      return root;
+    },
 
     async createDocument(blobMarkdown, tagsRaw, title, description) {
       const ssbClient = await openSsb();
-      const userId = ssbClient.id;
-      const blobId = extractBlobId(blobMarkdown);
-      const tags = parseCSV(tagsRaw);
+      const blobId = parseBlobId(blobMarkdown);
+      if (!blobId) throw new Error("Missing document blob");
+
+      const tags = parseCSV(tagsRaw) || [];
+
       const content = {
-        type: 'document',
+        type: "document",
         url: blobId,
         createdAt: new Date().toISOString(),
         author: userId,
         tags,
-        title: title || '',
-        description: description || '',
+        title: title || "",
+        description: description || "",
         opinions: {},
         opinions_inhabitants: []
       };
+
       return new Promise((resolve, reject) => {
-        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res));
+        ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
       });
     },
 
     async updateDocumentById(id, blobMarkdown, tagsRaw, title, description) {
       const ssbClient = await openSsb();
-      const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(id);
+
+      const oldMsg = await new Promise((res, rej) =>
+        ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error("Document not found")) : res(msg)))
+      );
+
+      if (oldMsg.content?.type !== "document") throw new Error("Document not found");
+      if (Object.keys(oldMsg.content.opinions || {}).length > 0) {
+        throw new Error("Cannot edit document after it has received opinions.");
+      }
+      if (String(oldMsg.content.author) !== String(userId)) throw new Error("Not the author");
+
+      const parsedTags = parseCSV(tagsRaw);
+      const tags = parsedTags !== undefined ? parsedTags : safeArr(oldMsg.content.tags);
+
+      const blobId = parseBlobId(blobMarkdown);
+
+      const updatedAt = new Date().toISOString();
+
+      const updated = {
+        ...oldMsg.content,
+        replaces: tipId,
+        url: blobId || oldMsg.content.url,
+        tags,
+        title: title !== undefined ? (title || "") : oldMsg.content.title || "",
+        description: description !== undefined ? (description || "") : oldMsg.content.description || "",
+        updatedAt
+      };
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: updatedAt, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, oldMsg) => {
-          if (err || !oldMsg || oldMsg.content?.type !== 'document') return reject(new Error('Document not found'));
-          if (Object.keys(oldMsg.content.opinions || {}).length > 0) return reject(new Error('Cannot edit document after it has received opinions.'));
-          if (oldMsg.content.author !== userId) return reject(new Error('Not the author'));
-          const tags = parseCSV(tagsRaw);
-          const blobId = extractBlobId(blobMarkdown);
-          const updated = {
-            ...oldMsg.content,
-            replaces: id,
-            url: blobId || oldMsg.content.url,
-            tags,
-            title: title || '',
-            description: description || '',
-            updatedAt: new Date().toISOString()
-          };
-          ssbClient.publish(updated, (err3, result) => err3 ? reject(err3) : resolve(result));
-        });
+        ssbClient.publish(updated, (err2, result) => (err2 ? reject(err2) : resolve(result)));
       });
     },
 
     async deleteDocumentById(id) {
       const ssbClient = await openSsb();
-      const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(id);
+
+      const msg = await new Promise((res, rej) =>
+        ssbClient.get(tipId, (err, m) => (err || !m ? rej(new Error("Document not found")) : res(m)))
+      );
+
+      if (msg.content?.type !== "document") throw new Error("Document not found");
+      if (String(msg.content.author) !== String(userId)) throw new Error("Not the author");
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'document') return reject(new Error('Document 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));
-        });
+        ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res)));
       });
     },
 
-    async listAll(filter = 'all') {
+    async listAll(arg1 = "all") {
       const ssbClient = await openSsb();
-      const userId = ssbClient.id;
-      const messages = await new Promise((res, rej) => {
-        pull(
-          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 opts = typeof arg1 === "object" && arg1 !== null ? arg1 : { filter: arg1 };
+      const filter = safeText(opts.filter || "all");
+      const q = safeText(opts.q || "").toLowerCase();
+      const sort = safeText(opts.sort || "recent");
 
-      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 !== 'document') continue;
-        if (tombstoned.has(k)) continue;
-        if (c.replaces) replaces.set(c.replaces, k);
-        latest.set(k, {
-          key: k,
-          url: c.url,
-          createdAt: c.createdAt,
-          updatedAt: c.updatedAt || null,
-          tags: c.tags || [],
-          author: c.author,
-          title: c.title || '',
-          description: c.description || '',
-          opinions: c.opinions || {},
-          opinions_inhabitants: c.opinions_inhabitants || []
-        });
+      const favorites = await favoritesSetForDocuments();
+
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      const items = [];
+      for (const [rootId, tipId] of idx.tipByRoot.entries()) {
+        if (idx.tomb.has(tipId)) continue;
+        const node = idx.nodes.get(tipId);
+        if (!node) continue;
+        items.push(pickDoc(node, rootId));
       }
 
-      for (const oldId of replaces.keys()) latest.delete(oldId);
+      let out = items;
+      const now = Date.now();
 
-      let documents = Array.from(latest.values());
+      if (filter === "mine") out = out.filter((d) => String(d.author) === String(userId));
+      else if (filter === "recent") out = out.filter((d) => new Date(d.createdAt).getTime() >= now - 86400000);
+      else if (filter === "favorites") out = out.filter((d) => favorites.has(d.rootId || d.key));
 
-      if (filter === 'mine') {
-        documents = documents.filter(d => d.author === userId);
-      } else {
-        documents = documents.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+      if (q) {
+        out = out.filter((d) => {
+          const t = String(d.title || "").toLowerCase();
+          const desc = String(d.description || "").toLowerCase();
+          const u = String(d.url || "").toLowerCase();
+          const a = String(d.author || "").toLowerCase();
+          const tags = safeArr(d.tags).join(" ").toLowerCase();
+          return t.includes(q) || desc.includes(q) || u.includes(q) || a.includes(q) || tags.includes(q);
+        });
       }
 
-      const hasBlob = (blobId) => {
-        return new Promise((resolve) => {
-          ssbClient.blobs.has(blobId, (err, has) => resolve(!err && has));
-        });
-      };
+      const effectiveSort = filter === "top" ? "top" : sort;
 
-      documents = await Promise.all(
-        documents.map(async (doc) => {
-          const ok = await hasBlob(doc.url);
-          return ok ? doc : null;
-        })
-      );
+      if (effectiveSort === "top") {
+        out = out
+          .slice()
+          .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
+      } else if (effectiveSort === "oldest") {
+        out = out.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
+      } else {
+        out = out.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+      }
 
-      return documents.filter(Boolean);
+      const checked = await Promise.all(out.map(async (d) => ((await hasBlob(ssbClient, d.url)) ? d : null)));
+      return checked
+        .filter(Boolean)
+        .map((d) => ({ ...d, isFavorite: favorites.has(d.rootId || d.key) }));
     },
 
     async getDocumentById(id) {
       const ssbClient = await openSsb();
+      const tipId = await this.resolveCurrentId(id);
+      const rootId = await this.resolveRootId(id);
+      const favorites = await favoritesSetForDocuments();
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'document') return reject(new Error('Document not found'));
+        ssbClient.get(tipId, (err, msg) => {
+          if (err || !msg || msg.content?.type !== "document") return reject(new Error("Document not found"));
+          const c = msg.content;
           resolve({
-            key: id,
-            url: msg.content.url,
-            createdAt: msg.content.createdAt,
-            updatedAt: msg.content.updatedAt || null,
-            tags: msg.content.tags || [],
-            author: msg.content.author,
-            title: msg.content.title || '',
-            description: msg.content.description || '',
-            opinions: msg.content.opinions || {},
-            opinions_inhabitants: msg.content.opinions_inhabitants || []
+            key: tipId,
+            rootId,
+            url: c.url,
+            createdAt: c.createdAt,
+            updatedAt: c.updatedAt || null,
+            tags: c.tags || [],
+            author: c.author,
+            title: c.title || "",
+            description: c.description || "",
+            opinions: c.opinions || {},
+            opinions_inhabitants: c.opinions_inhabitants || [],
+            isFavorite: favorites.has(rootId || tipId)
           });
         });
       });
     },
 
     async createOpinion(id, category) {
-      if (!categories.includes(category)) return Promise.reject(new Error('Invalid voting category'));
+      if (!categories.includes(category)) return Promise.reject(new Error("Invalid voting category"));
+
       const ssbClient = await openSsb();
-      const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(id);
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'document') return reject(new Error('Document not found'));
-          if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'));
+        ssbClient.get(tipId, async (err, msg) => {
+          if (err || !msg || msg.content?.type !== "document") return reject(new Error("Document not found"));
+          if (safeArr(msg.content.opinions_inhabitants).includes(userId)) return reject(new Error("Already voted"));
+
+          const now = new Date().toISOString();
+
           const updated = {
             ...msg.content,
-            replaces: id,
-            opinions: {
-              ...msg.content.opinions,
-              [category]: (msg.content.opinions?.[category] || 0) + 1
-            },
-            opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
-            updatedAt: new Date().toISOString()
+            replaces: tipId,
+            opinions: { ...msg.content.opinions, [category]: (msg.content.opinions?.[category] || 0) + 1 },
+            opinions_inhabitants: safeArr(msg.content.opinions_inhabitants).concat(userId),
+            updatedAt: now
           };
-          ssbClient.publish(updated, (err3, result) => err3 ? reject(err3) : resolve(result));
+
+          const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+          await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+
+          ssbClient.publish(updated, (err2, result) => (err2 ? reject(err2) : resolve(result)));
         });
       });
     }

+ 179 - 167
src/models/events_model.js

@@ -10,34 +10,66 @@ module.exports = ({ cooler }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
+  const uniq = (arr) => Array.from(new Set((Array.isArray(arr) ? arr : []).filter(x => typeof x === 'string' && x.trim().length)));
+
+  const normalizePrivacy = (v) => {
+    const s = String(v || 'public').toLowerCase();
+    return s === 'private' ? 'private' : 'public';
+  };
+
+  const normalizePrice = (price) => {
+    let p = typeof price === 'string' ? parseFloat(price.replace(',', '.')) : price;
+    if (isNaN(p) || p < 0) p = 0;
+    return Number(p).toFixed(6);
+  };
+
+  const normalizeDate = (date) => {
+    const m = moment(date);
+    if (!m.isValid()) throw new Error("Invalid date format");
+    return m.toISOString();
+  };
+
+  const deriveStatus = (c) => {
+    const dateM = moment(c.date);
+    let status = String(c.status || 'OPEN').toUpperCase();
+    if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED';
+    if (status !== 'OPEN' && status !== 'CLOSED') status = 'OPEN';
+    return status;
+  };
+
   return {
     type: 'event',
 
     async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic) {
       const ssbClient = await openSsb();
-      const formattedDate = date ? moment(date, moment.ISO_8601, true).toISOString() : moment().toISOString();
-      if (!moment(formattedDate, moment.ISO_8601, true).isValid()) throw new Error("Invalid date format");
-      if (moment(formattedDate).isBefore(moment(), 'minute')) throw new Error("Cannot create an event in the past");
-      if (!Array.isArray(attendees)) attendees = attendees.split(',').map(s => s.trim()).filter(Boolean);
-      attendees.push(userId);
-      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : tagsRaw.split(',').map(s => s.trim()).filter(Boolean);
-      let p = typeof price === 'string' ? parseFloat(price.replace(',', '.')) : price;
-      if (isNaN(p)) p = 0;
+
+      const formattedDate = normalizeDate(date);
+      if (moment(formattedDate).isBefore(moment().startOf('minute'))) throw new Error("Cannot create an event in the past");
+
+      let attendeeList = attendees;
+      if (!Array.isArray(attendeeList)) attendeeList = String(attendeeList || '').split(',').map(s => s.trim()).filter(Boolean);
+      attendeeList = uniq([...attendeeList, userId]);
+
+      const tags = Array.isArray(tagsRaw)
+        ? tagsRaw.filter(Boolean)
+        : String(tagsRaw || '').split(',').map(s => s.trim()).filter(Boolean);
+
       const content = {
         type: 'event',
         title,
         description,
         date: formattedDate,
         location,
-        price: p.toFixed(6),
-        url,
-        attendees,
+        price: normalizePrice(price),
+        url: url || '',
+        attendees: attendeeList,
         tags,
         createdAt: new Date().toISOString(),
         organizer: userId,
         status: 'OPEN',
-        isPublic
+        isPublic: normalizePrivacy(isPublic)
       };
+
       return new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res));
       });
@@ -45,184 +77,164 @@ module.exports = ({ cooler }) => {
 
     async toggleAttendee(eventId) {
       const ssbClient = await openSsb();
+      const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
+      const c = ev.content;
+
+      const status = deriveStatus(c);
+      if (status === 'CLOSED') throw new Error("Cannot attend a closed event");
+
+      let attendees = uniq(c.attendees || []);
+      const idx = attendees.indexOf(userId);
+      if (idx !== -1) attendees.splice(idx, 1); else attendees.push(userId);
+      attendees = uniq(attendees);
+
+      const updated = {
+        ...c,
+        attendees,
+        updatedAt: new Date().toISOString(),
+        replaces: eventId
+      };
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(eventId, async (err, ev) => {
-          if (err || !ev || !ev.content) return reject(new Error("Error retrieving event"));
-          let attendees = Array.isArray(ev.content.attendees) ? [...ev.content.attendees] : [];
-          const idx = attendees.indexOf(userId);
-          if (idx !== -1) attendees.splice(idx, 1); else attendees.push(userId);
-          const tombstone = {
-            type: 'tombstone',
-            target: eventId,
-            deletedAt: new Date().toISOString(),
-            author: userId
-          };
-          const updated = {
-            ...ev.content,
-            attendees,
-            updatedAt: new Date().toISOString(),
-            replaces: eventId
-          };
-          ssbClient.publish(tombstone, err => {
-            if (err) return reject(err);
-            ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
-          });
-        });
+        ssbClient.publish(updated, (err2, res2) => err2 ? reject(err2) : resolve(res2));
       });
     },
 
     async deleteEventById(eventId) {
       const ssbClient = await openSsb();
+      const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
+      if (ev.content.organizer !== userId) throw new Error("Only the organizer can delete this event");
+      const tombstone = { type: 'tombstone', target: eventId, deletedAt: new Date().toISOString(), author: userId };
       return new Promise((resolve, reject) => {
-        ssbClient.get(eventId, (err, ev) => {
-          if (err || !ev || !ev.content) return reject(new Error("Error retrieving event"));
-          if (ev.content.organizer !== userId) return reject(new Error("Only the organizer can delete this event"));
-          const tombstone = {
-            type: 'tombstone',
-            target: eventId,
-            deletedAt: new Date().toISOString(),
-            author: userId
-          };
-          ssbClient.publish(tombstone, (err, res) => err ? reject(err) : resolve(res));
-        });
+        ssbClient.publish(tombstone, (err, res) => err ? reject(err) : resolve(res));
       });
     },
 
     async getEventById(eventId) {
       const ssbClient = await openSsb();
-      return new Promise((resolve, reject) => {
-        ssbClient.get(eventId, async (err, msg) => {
-          if (err || !msg || !msg.content) return reject(new Error("Error retrieving event"));
-          const c = msg.content;
-          const dateM = moment(c.date);
-          let status = c.status || 'OPEN';
-          if (dateM.isValid() && dateM.isBefore(moment()) && status !== 'CLOSED') {
-            const tombstone = {
-              type: 'tombstone',
-              target: eventId,
-              deletedAt: new Date().toISOString(),
-              author: userId
-            };
-            const updated = {
-              ...c,
-              status: 'CLOSED',
-              updatedAt: new Date().toISOString(),
-              replaces: eventId
-            };
-            await ssbClient.publish(tombstone);
-            await ssbClient.publish(updated);
-            status = 'CLOSED';
-          }
-          resolve({
-            id: eventId,
-            title: c.title || '',
-            description: c.description || '',
-            date: c.date || '',
-            location: c.location || '',
-            price: c.price || 0,
-            url: c.url || '',
-            attendees: c.attendees || [],
-            tags: c.tags || [],
-            createdAt: c.createdAt || new Date().toISOString(),
-            updatedAt: c.updatedAt || new Date().toISOString(),
-            organizer: c.organizer || '',
-            status,
-            isPublic: c.isPublic || false
-          });
-        });
-      });
+      const msg = await new Promise((res, rej) => ssbClient.get(eventId, (err, msg) => err || !msg || !msg.content ? rej(new Error("Error retrieving event")) : res(msg)));
+      const c = msg.content;
+
+      const status = deriveStatus(c);
+
+      return {
+        id: eventId,
+        title: c.title || '',
+        description: c.description || '',
+        date: c.date || '',
+        location: c.location || '',
+        price: c.price || 0,
+        url: c.url || '',
+        attendees: Array.isArray(c.attendees) ? c.attendees : [],
+        tags: Array.isArray(c.tags) ? c.tags : [],
+        createdAt: c.createdAt || new Date().toISOString(),
+        updatedAt: c.updatedAt || new Date().toISOString(),
+        organizer: c.organizer || '',
+        status,
+        isPublic: normalizePrivacy(c.isPublic)
+      };
     },
-    
-    
+
     async updateEventById(eventId, updatedData) {
       const ssbClient = await openSsb();
+      const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
+      if (ev.content.organizer !== userId) throw new Error("Only the organizer can update this event");
+
+      const c = ev.content;
+      const status = deriveStatus(c);
+      if (status === 'CLOSED') throw new Error("Cannot edit a closed event");
+
+      const tags = updatedData.tags !== undefined
+        ? (Array.isArray(updatedData.tags)
+            ? updatedData.tags.filter(Boolean)
+            : String(updatedData.tags || '').split(',').map(t => t.trim()).filter(Boolean))
+        : (Array.isArray(c.tags) ? c.tags : []);
+
+      const date = updatedData.date !== undefined && updatedData.date !== ''
+        ? normalizeDate(updatedData.date)
+        : c.date;
+
+      if (moment(date).isBefore(moment().startOf('minute'))) throw new Error("Cannot set an event in the past");
+
+      const updated = {
+        ...c,
+        title: updatedData.title ?? c.title,
+        description: updatedData.description ?? c.description,
+        date,
+        location: updatedData.location ?? c.location,
+        price: updatedData.price !== undefined ? normalizePrice(updatedData.price) : c.price,
+        url: updatedData.url ?? c.url,
+        tags,
+        isPublic: updatedData.isPublic !== undefined ? normalizePrivacy(updatedData.isPublic) : normalizePrivacy(c.isPublic),
+        attendees: uniq(Array.isArray(c.attendees) ? c.attendees : []),
+        updatedAt: new Date().toISOString(),
+        replaces: eventId
+      };
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(eventId, (err, ev) => {
-          if (err || !ev || !ev.content) return reject(new Error("Error retrieving event"));
-          if (ev.content.organizer !== userId) return reject(new Error("Only the organizer can update this event"));
-          const tags = updatedData.tags ? updatedData.tags.split(',').map(t => t.trim()).filter(Boolean) : ev.content.tags;
-          const attendees = updatedData.attendees ? updatedData.attendees.split(',').map(t => t.trim()).filter(Boolean) : ev.content.attendees;
-          const tombstone = {
-            type: 'tombstone',
-            target: eventId,
-            deletedAt: new Date().toISOString(),
-            author: userId
-          };
-          const updated = {
-            ...ev.content,
-            ...updatedData,
-            attendees,
-            tags,
-            updatedAt: new Date().toISOString(),
-            replaces: eventId
-          };
-          ssbClient.publish(tombstone, err => {
-            if (err) return reject(err);
-            ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
-          });
-        });
+        ssbClient.publish(updated, (err2, res2) => err2 ? reject(err2) : resolve(res2));
       });
     },
 
     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 (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,
-              description: c.description,
-              date: c.date,
-              location: c.location,
-              price: c.price,
-              url: c.url,
-              attendees: c.attendees || [],
-              tags: c.tags || [],
-              createdAt: c.createdAt,
-              organizer: c.organizer,
-              status,
-              isPublic: c.isPublic
-            });
-          }
-        }
-        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);
-        })
-       );
-     });
+        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 && r.value.content;
+              if (!c) continue;
+
+              if (c.type === 'tombstone' && c.target) {
+                tombstoned.add(c.target);
+                continue;
+              }
+
+              if (c.type === 'event') {
+                if (c.replaces) replaces.set(c.replaces, k);
+                if (author && c.organizer !== author) continue;
+
+                const status = deriveStatus(c);
+
+                byId.set(k, {
+                  id: k,
+                  title: c.title || '',
+                  description: c.description || '',
+                  date: c.date || '',
+                  location: c.location || '',
+                  price: c.price || 0,
+                  url: c.url || '',
+                  attendees: Array.isArray(c.attendees) ? uniq(c.attendees) : [],
+                  tags: Array.isArray(c.tags) ? c.tags.filter(Boolean) : [],
+                  createdAt: c.createdAt || new Date().toISOString(),
+                  organizer: c.organizer || '',
+                  status,
+                  isPublic: normalizePrivacy(c.isPublic)
+                });
+              }
+            }
+
+            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 => String(e.status).toUpperCase() === 'OPEN');
+            if (filter === 'closed') out = out.filter(e => String(e.status).toUpperCase() === 'CLOSED');
+
+            resolve(out);
+          })
+        );
+      });
     }
-
   };
 };
 

+ 143 - 0
src/models/favorites_model.js

@@ -0,0 +1,143 @@
+const mediaFavorites = require("../backend/media-favorites");
+
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+const safeText = (v) => String(v || "").trim();
+
+const getFn = (obj, names) => {
+  for (const n of names) {
+    if (obj && typeof obj[n] === "function") return obj[n].bind(obj);
+  }
+  return null;
+};
+
+const toTs = (d) => {
+  const t = Date.parse(String(d || ""));
+  return Number.isFinite(t) ? t : 0;
+};
+
+module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel }) => {
+  const kindConfig = {
+    audios: {
+      base: "/audios/",
+      getById: getFn(audiosModel, ["getAudioById", "getById"])
+    },
+    bookmarks: {
+      base: "/bookmarks/",
+      getById: getFn(bookmarksModel, ["getBookmarkById", "getById"])
+    },
+    documents: {
+      base: "/documents/",
+      getById: getFn(documentsModel, ["getDocumentById", "getById"])
+    },
+    images: {
+      base: "/images/",
+      getById: getFn(imagesModel, ["getImageById", "getById"])
+    },
+    videos: {
+      base: "/videos/",
+      getById: getFn(videosModel, ["getVideoById", "getById"])
+    }
+  };
+
+  const kindOrder = ["audios", "bookmarks", "documents", "images", "videos"];
+
+  const hydrateKind = async (kind, ids) => {
+    const cfg = kindConfig[kind];
+    if (!cfg?.getById) return [];
+
+    const out = await Promise.all(
+      safeArr(ids).map(async (favId) => {
+        const id = safeText(favId);
+        if (!id) return null;
+        try {
+          const obj = await cfg.getById(id);
+          const viewId = safeText(obj?.key || obj?.id || id);
+
+          return {
+            kind,
+            favId: id,
+            viewHref: `${cfg.base}${encodeURIComponent(viewId)}`,
+            title: safeText(obj?.title) || safeText(obj?.name) || safeText(obj?.category) || safeText(obj?.url) || "",
+            description: safeText(obj?.description) || "",
+            tags: safeArr(obj?.tags),
+            author: safeText(obj?.author || obj?.organizer || obj?.seller || obj?.from || ""),
+            createdAt: obj?.createdAt || null,
+            updatedAt: obj?.updatedAt || null,
+            url: obj?.url || null,
+            category: obj?.category || null
+          };
+        } catch {
+          return null;
+        }
+      })
+    );
+
+    return out.filter(Boolean);
+  };
+
+  const loadAll = async () => {
+    const sets = await Promise.all(kindOrder.map((k) => mediaFavorites.getFavoriteSet(k)));
+    const idsByKind = {};
+    kindOrder.forEach((k, i) => {
+      idsByKind[k] = Array.from(sets[i] || []);
+    });
+
+    const hydrated = await Promise.all(kindOrder.map((k) => hydrateKind(k, idsByKind[k])));
+    const byKind = {};
+    kindOrder.forEach((k, i) => {
+      byKind[k] = hydrated[i] || [];
+    });
+
+    const flat = kindOrder.flatMap((k) => byKind[k]);
+
+    const counts = {
+      audios: byKind.audios.length,
+      bookmarks: byKind.bookmarks.length,
+      documents: byKind.documents.length,
+      images: byKind.images.length,
+      videos: byKind.videos.length,
+      all: flat.length
+    };
+
+    const recentFlat = flat
+      .slice()
+      .sort((a, b) => (toTs(b.updatedAt) || toTs(b.createdAt)) - (toTs(a.updatedAt) || toTs(a.createdAt)));
+
+    return { byKind, flat, recentFlat, counts };
+  };
+
+  return {
+    async listAll(opts = {}) {
+      const filter = safeText(opts.filter || "all").toLowerCase();
+      const { byKind, recentFlat, counts } = await loadAll();
+
+      if (filter === "recent") {
+        return { items: recentFlat, counts };
+      }
+
+      if (kindOrder.includes(filter)) {
+        const items = byKind[filter] || [];
+        const sorted = items
+          .slice()
+          .sort((a, b) => (toTs(b.updatedAt) || toTs(b.createdAt)) - (toTs(a.updatedAt) || toTs(a.createdAt)));
+        return { items: sorted, counts };
+      }
+
+      const grouped = kindOrder.flatMap((k) =>
+        (byKind[k] || [])
+          .slice()
+          .sort((a, b) => (toTs(b.updatedAt) || toTs(b.createdAt)) - (toTs(a.updatedAt) || toTs(a.createdAt)))
+      );
+
+      return { items: grouped, counts };
+    },
+
+    async removeFavorite(kind, id) {
+      const k = safeText(kind);
+      const favId = safeText(id);
+      if (!k || !favId) return;
+      await mediaFavorites.removeFavorite(k, favId);
+    }
+  };
+};
+

+ 278 - 148
src/models/images_model.js

@@ -1,200 +1,330 @@
-const pull = require('../server/node_modules/pull-stream');
-const { getConfig } = require('../configs/config-manager.js');
-const categories = require('../backend/opinion_categories');
+const pull = require("../server/node_modules/pull-stream");
+const { getConfig } = require("../configs/config-manager.js");
+const categories = require("../backend/opinion_categories");
+
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+
+const normalizeTags = (raw) => {
+  if (raw === undefined || raw === null) return undefined;
+  if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
+  return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
+};
+
+const parseBlobId = (blobMarkdown) => {
+  const s = String(blobMarkdown || "");
+  const match = s.match(/\(([^)]+)\)/);
+  return match ? match[1] : s || null;
+};
+
+const voteSum = (opinions = {}) =>
+  Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
+
 module.exports = ({ cooler }) => {
   let ssb;
-  let userId;
 
   const openSsb = async () => {
-    if (!ssb) {
-      ssb = await cooler.open();
-      userId = ssb.id;
-    }
+    if (!ssb) ssb = await cooler.open();
     return ssb;
   };
 
+  const getAllMessages = async (ssbClient) =>
+    new Promise((resolve, reject) => {
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
+      );
+    });
+
+  const getMsg = async (ssbClient, key) =>
+    new Promise((resolve, reject) => {
+      ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
+    });
+
+  const buildIndex = (messages) => {
+    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 v = m.value || {};
+      const c = v.content;
+      if (!c) continue;
+
+      if (c.type === "tombstone" && c.target) {
+        tomb.add(c.target);
+        continue;
+      }
+
+      if (c.type !== "image") continue;
+
+      const ts = v.timestamp || m.timestamp || 0;
+      nodes.set(k, { key: k, ts, 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 tipOf = (id) => {
+      let cur = id;
+      while (child.has(cur)) cur = child.get(cur);
+      return cur;
+    };
+
+    const roots = new Set();
+    for (const id of nodes.keys()) roots.add(rootOf(id));
+
+    const tipByRoot = new Map();
+    for (const r of roots) tipByRoot.set(r, tipOf(r));
+
+    const forward = new Map();
+    for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
+
+    return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
+  };
+
+  const buildImage = (node, rootId, viewerId) => {
+    const c = node.c || {};
+    const voters = safeArr(c.opinions_inhabitants);
+    return {
+      key: node.key,
+      rootId,
+      url: c.url,
+      createdAt: c.createdAt || new Date(node.ts).toISOString(),
+      updatedAt: c.updatedAt || null,
+      tags: safeArr(c.tags),
+      author: c.author,
+      title: c.title || "",
+      description: c.description || "",
+      meme: !!c.meme,
+      opinions: c.opinions || {},
+      opinions_inhabitants: voters,
+      hasVoted: viewerId ? voters.includes(viewerId) : false
+    };
+  };
+
   return {
-    async createImage(blobMarkdown, tagsRaw, title, description, meme) {
+    type: "image",
+
+    async resolveCurrentId(id) {
       const ssbClient = await openSsb();
-      const match = blobMarkdown?.match(/\(([^)]+)\)/);
-      const blobId = match ? match[1] : blobMarkdown;
-      const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : [];
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Image not found");
+      return tip;
+    },
+
+    async resolveRootId(id) {
+      const ssbClient = await openSsb();
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Image not found");
+
+      let root = tip;
+      while (idx.parent.has(root)) root = idx.parent.get(root);
+      return root;
+    },
+
+    async createImage(blobMarkdown, tagsRaw, title, description, memeBool) {
+      const ssbClient = await openSsb();
+      const blobId = parseBlobId(blobMarkdown);
+      const tags = normalizeTags(tagsRaw) || [];
+      const now = new Date().toISOString();
+
       const content = {
-        type: 'image',
+        type: "image",
         url: blobId,
-        createdAt: new Date().toISOString(),
-        author: userId,
+        createdAt: now,
+        updatedAt: now,
+        author: ssbClient.id,
         tags,
-        title: title || '',
-        description: description || '',
-        meme: !!meme,
+        title: title || "",
+        description: description || "",
+        meme: !!memeBool,
         opinions: {},
         opinions_inhabitants: []
       };
+
       return new Promise((resolve, reject) => {
-        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res));
+        ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
       });
     },
 
-    async updateImageById(id, blobMarkdown, tagsRaw, title, description, meme) {
+    async updateImageById(id, blobMarkdown, tagsRaw, title, description, memeBool) {
       const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(id);
+      const oldMsg = await getMsg(ssbClient, tipId);
+
+      if (!oldMsg || oldMsg.content?.type !== "image") throw new Error("Image not found");
+      if (Object.keys(oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit image after it has received opinions.");
+      if (oldMsg.content.author !== userId) throw new Error("Not the author");
+
+      const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
+      const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
+      const now = new Date().toISOString();
+
+      const updated = {
+        ...oldMsg.content,
+        replaces: tipId,
+        url: blobId || oldMsg.content.url,
+        tags,
+        title: title !== undefined ? title || "" : oldMsg.content.title || "",
+        description: description !== undefined ? description || "" : oldMsg.content.description || "",
+        meme: typeof memeBool === "boolean" ? memeBool : !!oldMsg.content.meme,
+        createdAt: oldMsg.content.createdAt,
+        updatedAt: now
+      };
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, oldMsg) => {
-          if (err || !oldMsg || oldMsg.content?.type !== 'image') return reject(new Error('Image not found'));
-          if (Object.keys(oldMsg.content.opinions || {}).length > 0) return reject(new Error('Cannot edit image after it has received opinions.'));
-          if (oldMsg.content.author !== userId) return reject(new Error('Not the author'));
-          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 ?? 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));
-        });
+        ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
       });
     },
 
     async deleteImageById(id) {
       const ssbClient = await openSsb();
-      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 userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(id);
+      const msg = await getMsg(ssbClient, tipId);
+
+      if (!msg || msg.content?.type !== "image") throw new Error("Image not found");
+      if (msg.content.author !== userId) throw new Error("Not the author");
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
+
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : 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') {
+    async listAll(filterOrOpts = "all", maybeOpts = {}) {
       const ssbClient = await openSsb();
-      const userId = ssbClient.id;
-      const messages = await new Promise((res, rej) => {
-        pull(
-          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 opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
+      const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
+      const q = String(opts.q || "").trim().toLowerCase();
+      const sort = String(opts.sort || "recent").trim();
+      const viewerId = opts.viewerId || ssbClient.id;
 
-      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 (c.replaces) replaces.set(c.replaces, k);
-        if (tombstoned.has(k)) continue;
-        latest.set(k, {
-          key: k,
-          url: c.url,
-          createdAt: c.createdAt,
-          updatedAt: c.updatedAt || null,
-          tags: c.tags || [],
-          author: c.author,
-          title: c.title || '',
-          description: c.description || '',
-          meme: !!c.meme,
-          opinions: c.opinions || {},
-          opinions_inhabitants: c.opinions_inhabitants || []
-        });
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      const items = [];
+      for (const [rootId, tipId] of idx.tipByRoot.entries()) {
+        if (idx.tomb.has(tipId)) continue;
+        const node = idx.nodes.get(tipId);
+        if (!node) continue;
+        items.push(buildImage(node, rootId, viewerId));
+      }
+
+      let list = items;
+      const now = Date.now();
+
+      if (filter === "mine") list = list.filter((im) => String(im.author) === String(viewerId));
+      else if (filter === "recent") list = list.filter((im) => new Date(im.createdAt).getTime() >= now - 86400000);
+      else if (filter === "meme") list = list.filter((im) => im.meme === true);
+      else if (filter === "top") {
+        list = list
+          .slice()
+          .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
       }
 
-      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') {
-        const now = Date.now();
-        images = images.filter(img => new Date(img.createdAt).getTime() >= (now - 24 * 60 * 60 * 1000));
-      } else if (filter === 'meme') {
-        images = images.filter(img => img.meme === true);
-      } else if (filter === 'top') {
-        images = images.sort((a, b) => {
-          const sumA = Object.values(a.opinions).reduce((sum, v) => sum + v, 0);
-          const sumB = Object.values(b.opinions).reduce((sum, v) => sum + v, 0);
-          return sumB - sumA;
+      if (q) {
+        list = list.filter((im) => {
+          const t = String(im.title || "").toLowerCase();
+          const d = String(im.description || "").toLowerCase();
+          const tags = safeArr(im.tags).join(" ").toLowerCase();
+          const a = String(im.author || "").toLowerCase();
+          return t.includes(q) || d.includes(q) || tags.includes(q) || a.includes(q);
         });
+      }
+
+      if (sort === "top") {
+        list = list
+          .slice()
+          .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
+      } else if (sort === "oldest") {
+        list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
       } else {
-        images = images.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+        list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
       }
 
-      return images;
+      return list;
     },
 
-    async getImageById(id) {
+    async getImageById(id, viewerId = null) {
       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'));
-          resolve({
-            key: id,
-            url: msg.content.url,
-            createdAt: msg.content.createdAt,
-            updatedAt: msg.content.updatedAt || null,
-            tags: msg.content.tags || [],
-            author: msg.content.author,
-            title: msg.content.title || '',
-            description: msg.content.description || '',
-            meme: !!msg.content.meme,
-            opinions: msg.content.opinions || {},
-            opinions_inhabitants: msg.content.opinions_inhabitants || []
-          });
-        });
-      });
+      const viewer = viewerId || ssbClient.id;
+
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Image not found");
+
+      let root = tip;
+      while (idx.parent.has(root)) root = idx.parent.get(root);
+
+      const node = idx.nodes.get(tip);
+      if (node) return buildImage(node, root, viewer);
+
+      const msg = await getMsg(ssbClient, tip);
+      if (!msg || msg.content?.type !== "image") throw new Error("Image not found");
+      return buildImage({ key: tip, ts: msg.timestamp || 0, c: msg.content }, root, viewer);
     },
 
     async createOpinion(id, category) {
-      if (!categories.includes(category)) return Promise.reject(new Error('Invalid voting category'));
+      if (!categories.includes(category)) throw new Error("Invalid voting category");
+
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
+
+      const tipId = await this.resolveCurrentId(id);
+      const msg = await getMsg(ssbClient, tipId);
+
+      if (!msg || msg.content?.type !== "image") throw new Error("Image not found");
+
+      const voters = safeArr(msg.content.opinions_inhabitants);
+      if (voters.includes(userId)) throw new Error("Already voted");
+
+      const now = new Date().toISOString();
+      const updated = {
+        ...msg.content,
+        replaces: tipId,
+        opinions: {
+          ...msg.content.opinions,
+          [category]: (msg.content.opinions?.[category] || 0) + 1
+        },
+        opinions_inhabitants: voters.concat(userId),
+        updatedAt: now
+      };
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+
       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.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'));
-          const updated = {
-            ...msg.content,
-            replaces: id,
-            opinions: {
-              ...msg.content.opinions,
-              [category]: (msg.content.opinions?.[category] || 0) + 1
-            },
-            opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
-            updatedAt: new Date().toISOString()
-          };
-          ssbClient.publish(updated, (err3, result) => err3 ? reject(err3) : resolve(result));
-        });
+        ssbClient.publish(updated, (err2, result) => (err2 ? reject(err2) : resolve(result)));
       });
     }
   };

+ 468 - 263
src/models/jobs_model.js

@@ -1,268 +1,473 @@
-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 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 norm = (s) => String(s || "").trim().toLowerCase()
+const toNum = (v) => {
+  const n = parseFloat(String(v ?? "").replace(",", "."))
+  return Number.isFinite(n) ? n : NaN
+}
+const toInt = (v, fallback = 0) => {
+  const n = parseInt(String(v ?? ""), 10)
+  return Number.isFinite(n) ? n : fallback
+}
+
+const normalizeTags = (raw) => {
+  if (raw === undefined || raw === null) return []
+  if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
+  return String(raw).split(",").map(t => t.trim()).filter(Boolean)
+}
+
+const matchSearch = (job, q) => {
+  const qq = norm(q)
+  if (!qq) return true
+  const hay = [
+    job.title,
+    job.description,
+    job.requirements,
+    job.tasks,
+    job.languages,
+    Array.isArray(job.tags) ? job.tags.join(" ") : ""
+  ].map(x => norm(x)).join(" ")
+  return hay.includes(qq)
+}
 
 module.exports = ({ cooler }) => {
-    let ssb;
-    const openSsb = async () => {
-        if (!ssb) ssb = await cooler.open();
-        return ssb;
-    };
-
-    const fields = [
-        'job_type','title','description','requirements','languages',
-        'job_time','tasks','location','vacants','salary','image',
-        'author','createdAt','updatedAt','status','subscribers'
-    ];
-
-    const pickJobFields = (obj = {}) => ({
-        job_type: obj.job_type,
-        title: obj.title,
-        description: obj.description,
-        requirements: obj.requirements,
-        languages: obj.languages,
-        job_time: obj.job_time,
-        tasks: obj.tasks,
-        location: obj.location,
-        vacants: obj.vacants,
-        salary: obj.salary,
-        image: obj.image,
-        author: obj.author,
-        createdAt: obj.createdAt,
-        updatedAt: obj.updatedAt,
-        status: obj.status,
-        subscribers: Array.isArray(obj.subscribers) ? obj.subscribers : []
-    });
+  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 base = pickJobFields(jobData);
-            const content = {
-                type: 'job',
-                ...base,
-                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 current = await this.getJobById(id);
-
-            const onlySubscribersChange = Object.keys(jobData).length > 0 && Object.keys(jobData).every(k => k === 'subscribers');
-            if (!onlySubscribersChange && current.author !== ssbClient.id) throw new Error('Unauthorized');
-
-            let blobId = jobData.image ?? current.image;
-            if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1];
-
-            const patch = {};
-            for (const f of fields) {
-                if (Object.prototype.hasOwnProperty.call(jobData, f) && jobData[f] !== undefined) {
-                    patch[f] = f === 'image' ? blobId : jobData[f];
-                }
-            }
-
-            const next = {
-                ...current,
-                ...patch,
-                image: ('image' in patch ? blobId : current.image),
-                updatedAt: new Date().toISOString()
-            };
-
-            const tomb = {
-                type: 'tombstone',
-                target: id,
-                deletedAt: new Date().toISOString(),
-                author: ssbClient.id
-            };
-
-            const content = {
-                type: 'job',
-                job_type: next.job_type,
-                title: next.title,
-                description: next.description,
-                requirements: next.requirements,
-                languages: next.languages,
-                job_time: next.job_time,
-                tasks: next.tasks,
-                location: next.location,
-                vacants: next.vacants,
-                salary: next.salary,
-                image: next.image,
-                author: current.author,
-                createdAt: current.createdAt,
-                updatedAt: next.updatedAt,
-                status: next.status,
-                subscribers: Array.isArray(next.subscribers) ? next.subscribers : [],
-                replaces: id
-            };
-
-            await new Promise((res, rej) => ssbClient.publish(tomb, e => e ? rej(e) : res()));
-            return new Promise((res, rej) => ssbClient.publish(content, (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');
-            const { id: _dropId, replaces: _dropReplaces, ...safeContent } = msg.content || {};
-            const clean = pickJobFields(safeContent);
-            return { id: key, ...clean };
-        },
-
-        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);
-            const current = Array.isArray(job.subscribers) ? job.subscribers.slice() : [];
-            if (current.includes(userId)) throw new Error('Already subscribed');
-            const next = current.concat(userId);
-            return this.updateJob(latestId, { subscribers: next });
-        },
-
-        async unsubscribeFromJob(id, userId) {
-            const latestId = await this.getJobTipId(id);
-            const job = await this.getJobById(latestId);
-            const current = Array.isArray(job.subscribers) ? job.subscribers.slice() : [];
-            if (!current.includes(userId)) throw new Error('Not subscribed');
-            const next = current.filter(uid => uid !== userId);
-            return this.updateJob(latestId, { subscribers: next });
+  const readAll = async (ssbClient) =>
+    new Promise((resolve, reject) =>
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+      )
+    )
+
+  const buildIndex = (messages) => {
+    const tomb = new Set()
+    const jobNodes = new Map()
+    const parent = new Map()
+    const child = new Map()
+    const jobSubLatest = new Map()
+
+    for (const m of messages) {
+      const key = m.key
+      const v = m.value || {}
+      const c = v.content
+      if (!c) continue
+
+      if (c.type === "tombstone" && c.target) {
+        tomb.add(c.target)
+        continue
+      }
+
+      if (c.type === "job") {
+        jobNodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
+        if (c.replaces) {
+          parent.set(key, c.replaces)
+          child.set(c.replaces, key)
         }
-    };
-};
+        continue
+      }
+
+      if (c.type === "job_sub" && c.jobId) {
+        const author = v.author
+        if (!author) continue
+        const ts = v.timestamp || m.timestamp || 0
+        const jobId = c.jobId
+        const k = `${jobId}::${author}`
+        const prev = jobSubLatest.get(k)
+        if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
+        continue
+      }
+    }
+
+    const rootOf = (id) => {
+      let cur = id
+      while (parent.has(cur)) cur = parent.get(cur)
+      return cur
+    }
+
+    const roots = new Set()
+    for (const id of jobNodes.keys()) roots.add(rootOf(id))
+
+    const tipOf = (id) => {
+      let cur = id
+      while (child.has(cur)) cur = child.get(cur)
+      return cur
+    }
+
+    const tipByRoot = new Map()
+    for (const r of roots) tipByRoot.set(r, tipOf(r))
+
+    const subsByJob = new Map()
+    for (const { jobId, author, value } of jobSubLatest.values()) {
+      if (!subsByJob.has(jobId)) subsByJob.set(jobId, new Set())
+      const set = subsByJob.get(jobId)
+      if (value) set.add(author)
+      else set.delete(author)
+    }
+
+    return { tomb, jobNodes, parent, child, rootOf, tipOf, tipByRoot, subsByJob }
+  }
+
+  const buildJobObject = (node, rootId, subscribers) => {
+    const c = node.c || {}
+    let blobId = c.image || null
+    if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
+
+    const vacants = Math.max(1, toInt(c.vacants, 1))
+    const salaryN = toNum(c.salary)
+    const salary = Number.isFinite(salaryN) ? salaryN.toFixed(6) : "0.000000"
+
+    return {
+      id: node.key,
+      rootId,
+      job_type: c.job_type,
+      title: c.title,
+      description: c.description,
+      requirements: c.requirements,
+      languages: c.languages,
+      job_time: c.job_time,
+      tasks: c.tasks,
+      location: c.location,
+      vacants,
+      salary,
+      image: blobId,
+      author: c.author,
+      createdAt: c.createdAt || new Date(node.ts).toISOString(),
+      updatedAt: c.updatedAt || null,
+      status: c.status || "OPEN",
+      tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
+      subscribers: Array.isArray(subscribers) ? subscribers : []
+    }
+  }
+
+  return {
+    type: "job",
+
+    async createJob(jobData) {
+      const ssbClient = await openSsb()
+
+      const job_type = String(jobData.job_type || "").toLowerCase()
+      if (!["freelancer", "employee"].includes(job_type)) throw new Error("Invalid job type")
+
+      const title = String(jobData.title || "").trim()
+      const description = String(jobData.description || "").trim()
+      if (!title) throw new Error("Invalid title")
+      if (!description) throw new Error("Invalid description")
+
+      const vacants = Math.max(1, toInt(jobData.vacants, 1))
+      const salaryN = toNum(jobData.salary)
+      const salary = Number.isFinite(salaryN) ? salaryN.toFixed(6) : "0.000000"
+
+      const job_time = String(jobData.job_time || "").toLowerCase()
+      if (!["partial", "complete"].includes(job_time)) throw new Error("Invalid job time")
+
+      const location = String(jobData.location || "").toLowerCase()
+      if (!["remote", "presencial"].includes(location)) throw new Error("Invalid location")
+
+      let blobId = jobData.image || null
+      if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
+
+      const tags = normalizeTags(jobData.tags)
+
+      const content = {
+        type: "job",
+        job_type,
+        title,
+        description,
+        requirements: String(jobData.requirements || ""),
+        languages: String(jobData.languages || ""),
+        job_time,
+        tasks: String(jobData.tasks || ""),
+        location,
+        vacants,
+        salary,
+        image: blobId,
+        tags,
+        author: ssbClient.id,
+        createdAt: new Date().toISOString(),
+        updatedAt: new Date().toISOString(),
+        status: "OPEN"
+      }
+
+      return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
+    },
+
+    async resolveCurrentId(jobId) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const { tomb, child } = buildIndex(messages)
+
+      let cur = jobId
+      while (child.has(cur)) cur = child.get(cur)
+      if (tomb.has(cur)) throw new Error("Job not found")
+      return cur
+    },
+
+    async resolveRootId(jobId) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const { tomb, parent, child } = buildIndex(messages)
+
+      let tip = jobId
+      while (child.has(tip)) tip = child.get(tip)
+      if (tomb.has(tip)) throw new Error("Job not found")
+
+      let root = tip
+      while (parent.has(root)) root = parent.get(root)
+      return root
+    },
+
+    async updateJob(id, jobData) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+
+      const tipId = await this.resolveCurrentId(id)
+      const node = idx.jobNodes.get(tipId)
+      if (!node || !node.c) throw new Error("Job not found")
+
+      const existingContent = node.c
+      const author = existingContent.author
+      if (author !== ssbClient.id) throw new Error("Unauthorized")
+
+      const patch = {}
+
+      if (jobData.job_type !== undefined) {
+        const jt = String(jobData.job_type || "").toLowerCase()
+        if (!["freelancer", "employee"].includes(jt)) throw new Error("Invalid job type")
+        patch.job_type = jt
+      }
+
+      if (jobData.title !== undefined) {
+        const t = String(jobData.title || "").trim()
+        if (!t) throw new Error("Invalid title")
+        patch.title = t
+      }
+
+      if (jobData.description !== undefined) {
+        const d = String(jobData.description || "").trim()
+        if (!d) throw new Error("Invalid description")
+        patch.description = d
+      }
+
+      if (jobData.requirements !== undefined) patch.requirements = String(jobData.requirements || "")
+      if (jobData.languages !== undefined) patch.languages = String(jobData.languages || "")
+      if (jobData.tasks !== undefined) patch.tasks = String(jobData.tasks || "")
+
+      if (jobData.job_time !== undefined) {
+        const jt = String(jobData.job_time || "").toLowerCase()
+        if (!["partial", "complete"].includes(jt)) throw new Error("Invalid job time")
+        patch.job_time = jt
+      }
+
+      if (jobData.location !== undefined) {
+        const loc = String(jobData.location || "").toLowerCase()
+        if (!["remote", "presencial"].includes(loc)) throw new Error("Invalid location")
+        patch.location = loc
+      }
+
+      if (jobData.vacants !== undefined) {
+        const v = Math.max(1, toInt(jobData.vacants, 1))
+        patch.vacants = v
+      }
+
+      if (jobData.salary !== undefined) {
+        const s = toNum(jobData.salary)
+        if (!Number.isFinite(s) || s < 0) throw new Error("Invalid salary")
+        patch.salary = s.toFixed(6)
+      }
+
+      if (jobData.tags !== undefined) patch.tags = normalizeTags(jobData.tags)
+
+      if (jobData.image !== undefined) {
+        let blobId = jobData.image
+        if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
+        patch.image = blobId || null
+      }
+
+      if (jobData.status !== undefined) {
+        const s = String(jobData.status || "").toUpperCase()
+        if (!["OPEN", "CLOSED"].includes(s)) throw new Error("Invalid status")
+        patch.status = s
+      }
+
+      const next = {
+        ...existingContent,
+        ...patch,
+        author,
+        createdAt: existingContent.createdAt,
+        updatedAt: new Date().toISOString(),
+        replaces: tipId,
+        type: "job"
+      }
+
+      const tomb = {
+        type: "tombstone",
+        target: tipId,
+        deletedAt: new Date().toISOString(),
+        author: ssbClient.id
+      }
+
+      await new Promise((res, rej) => ssbClient.publish(tomb, (e) => e ? rej(e) : res()))
+      return new Promise((res, rej) => ssbClient.publish(next, (e, m) => e ? rej(e) : res(m)))
+    },
+
+    async updateJobStatus(id, status) {
+      return this.updateJob(id, { status: String(status || "").toUpperCase() })
+    },
+
+    async deleteJob(id) {
+      const ssbClient = await openSsb()
+      const tipId = await this.resolveCurrentId(id)
+      const job = await this.getJobById(tipId)
+      if (!job || job.author !== ssbClient.id) throw new Error("Unauthorized")
+
+      const tomb = {
+        type: "tombstone",
+        target: tipId,
+        deletedAt: new Date().toISOString(),
+        author: ssbClient.id
+      }
+
+      return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r)))
+    },
+
+    async subscribeToJob(id, userId) {
+      const ssbClient = await openSsb()
+      const me = ssbClient.id
+      const uid = userId || me
+
+      const job = await this.getJobById(id)
+      if (!job) throw new Error("Job not found")
+      if (job.author === uid) throw new Error("Cannot subscribe to your own job")
+      if (String(job.status || "").toUpperCase() !== "OPEN") throw new Error("Job is closed")
+
+      const rootId = job.rootId || (await this.resolveRootId(id))
+
+      const msg = {
+        type: "job_sub",
+        jobId: rootId,
+        value: true,
+        createdAt: new Date().toISOString()
+      }
+
+      return new Promise((res, rej) => ssbClient.publish(msg, (e, m) => e ? rej(e) : res(m)))
+    },
+
+    async unsubscribeFromJob(id, userId) {
+      const ssbClient = await openSsb()
+      const me = ssbClient.id
+      const uid = userId || me
+
+      const job = await this.getJobById(id)
+      if (!job) throw new Error("Job not found")
+      if (job.author === uid) throw new Error("Cannot unsubscribe from your own job")
+
+      const rootId = job.rootId || (await this.resolveRootId(id))
+
+      const msg = {
+        type: "job_sub",
+        jobId: rootId,
+        value: false,
+        createdAt: new Date().toISOString()
+      }
+
+      return new Promise((res, rej) => ssbClient.publish(msg, (e, m) => e ? rej(e) : res(m)))
+    },
+
+    async listJobs(filter = "ALL", viewerId = null, query = {}) {
+      const ssbClient = await openSsb()
+      const me = ssbClient.id
+      const viewer = viewerId || me
+
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+
+      const jobs = []
+      for (const [rootId, tipId] of idx.tipByRoot.entries()) {
+        if (idx.tomb.has(tipId)) continue
+        const node = idx.jobNodes.get(tipId)
+        if (!node) continue
+        const subsSet = idx.subsByJob.get(rootId) || new Set()
+        const subs = Array.from(subsSet)
+        jobs.push(buildJobObject(node, rootId, subs))
+      }
+
+      const F = String(filter || "ALL").toUpperCase()
+      let list = jobs
+
+      if (F === "MINE") list = list.filter((j) => j.author === viewer)
+      else if (F === "REMOTE") list = list.filter((j) => String(j.location || "").toUpperCase() === "REMOTE")
+      else if (F === "PRESENCIAL") list = list.filter((j) => String(j.location || "").toUpperCase() === "PRESENCIAL")
+      else if (F === "FREELANCER") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "FREELANCER")
+      else if (F === "EMPLOYEE") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "EMPLOYEE")
+      else if (F === "OPEN") list = list.filter((j) => String(j.status || "").toUpperCase() === "OPEN")
+      else if (F === "CLOSED") list = list.filter((j) => String(j.status || "").toUpperCase() === "CLOSED")
+      else if (F === "RECENT") list = list.filter((j) => moment(j.createdAt).isAfter(moment().subtract(24, "hours")))
+      else if (F === "APPLIED") list = list.filter((j) => Array.isArray(j.subscribers) && j.subscribers.includes(viewer))
+
+      const search = String(query.search || query.q || "").trim()
+      const minSalary = query.minSalary ?? ""
+      const maxSalary = query.maxSalary ?? ""
+      const sort = String(query.sort || "").trim()
+
+      if (search) list = list.filter((j) => matchSearch(j, search))
+
+      const minS = toNum(minSalary)
+      const maxS = toNum(maxSalary)
+
+      if (Number.isFinite(minS)) list = list.filter((j) => toNum(j.salary) >= minS)
+      if (Number.isFinite(maxS)) list = list.filter((j) => toNum(j.salary) <= maxS)
+
+      const byRecent = () => list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+      const bySalary = () => list.sort((a, b) => toNum(b.salary) - toNum(a.salary))
+      const bySubscribers = () => list.sort((a, b) => (b.subscribers || []).length - (a.subscribers || []).length)
+
+      if (F === "TOP") bySalary()
+      else if (sort === "salary") bySalary()
+      else if (sort === "subscribers") bySubscribers()
+      else byRecent()
+
+      return list
+    },
+
+    async getJobById(id, viewerId = null) {
+      const ssbClient = await openSsb()
+      void viewerId
+
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+
+      let tipId = id
+      while (idx.child.has(tipId)) tipId = idx.child.get(tipId)
+      if (idx.tomb.has(tipId)) throw new Error("Job not found")
+
+      let rootId = tipId
+      while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
+
+      const node = idx.jobNodes.get(tipId)
+      if (!node) {
+        const msg = await new Promise((r, j) => ssbClient.get(tipId, (e, m) => e ? j(e) : r(m)))
+        if (!msg || !msg.content) throw new Error("Job not found")
+        const tmpNode = { key: tipId, ts: msg.timestamp || 0, c: msg.content, author: msg.author }
+        const subsSet = idx.subsByJob.get(rootId) || new Set()
+        const subs = Array.from(subsSet)
+        return buildJobObject(tmpNode, rootId, subs)
+      }
+
+      const subsSet = idx.subsByJob.get(rootId) || new Set()
+      const subs = Array.from(subsSet)
+      return buildJobObject(node, rootId, subs)
+    },
+
+    async getJobTipId(id) {
+      return this.resolveCurrentId(id)
+    }
+  }
+}
 

+ 485 - 251
src/models/market_model.js

@@ -1,244 +1,448 @@
-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));
+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", DISCARDED: "DISCARDED" })[s] || (s ? s.replace(/_/g, " ") : s)
+const ORDER = ["FOR_SALE", "OPEN", "RESERVED", "CLOSED", "SOLD", "DISCARDED"]
+const OI = (s) => ORDER.indexOf(N(s))
+
+const parseBidEntry = (raw) => {
+  const s = String(raw || "").trim()
+  if (!s) return null
+
+  if (s.includes("|")) {
+    const parts = s.split("|")
+    if (parts.length < 3) return null
+    const bidder = parts[0] || ""
+    const amount = parseFloat(String(parts[1] || "").replace(",", "."))
+    const time = parts.slice(2).join("|")
+    if (!bidder || !Number.isFinite(amount) || !time) return null
+    return { bidder, amount, time }
+  }
+
+  const first = s.indexOf(":")
+  const second = s.indexOf(":", first + 1)
+  if (first === -1 || second === -1) return null
+
+  const bidder = s.slice(0, first)
+  const amountStr = s.slice(first + 1, second)
+  const time = s.slice(second + 1)
+  const amount = parseFloat(String(amountStr || "").replace(",", "."))
+  if (!bidder || !Number.isFinite(amount) || !time) return null
+  return { bidder, amount, time }
+}
+
+const highestBidAmount = (poll) => {
+  const arr = Array.isArray(poll) ? poll : []
+  let best = 0
+  for (const x of arr) {
+    const b = parseBidEntry(x)
+    if (b && Number.isFinite(b.amount) && b.amount > best) best = b.amount
+  }
+  return best
+}
+
+const hasBidder = (poll, userId) => {
+  const arr = Array.isArray(poll) ? poll : []
+  for (const x of arr) {
+    const b = parseBidEntry(x)
+    if (b && b.bidder === userId) return true
+  }
+  return false
+}
 
 module.exports = ({ cooler }) => {
-  let ssb;
-  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+  let ssb
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open()
+    return ssb
+  }
+
+  const readAll = async (ssbClient) => {
+    return new Promise((resolve, reject) =>
+      pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))))
+    )
+  }
+
+  const resolveGraph = async () => {
+    const ssbClient = await openSsb()
+    const messages = await readAll(ssbClient)
+
+    const tomb = new Set()
+    const fwd = new Map()
+    const parent = new Map()
+
+    for (const m of messages) {
+      const c = m.value && m.value.content
+      if (!c) continue
+      if (c.type === "tombstone" && c.target) {
+        tomb.add(c.target)
+        continue
+      }
+      if (c.type !== "market") continue
+      if (c.replaces) {
+        fwd.set(c.replaces, m.key)
+        parent.set(m.key, c.replaces)
+      }
+    }
+
+    return { tomb, fwd, parent }
+  }
 
   return {
-    type: 'market',
+    type: "market",
 
     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;
+      const ssbClient = await openSsb()
+
+      const formattedDeadline = deadline ? moment(deadline, moment.ISO_8601, true) : null
+      if (!formattedDeadline || !formattedDeadline.isValid()) throw new Error("Invalid deadline")
+      if (formattedDeadline.isBefore(moment(), "minute")) throw new Error("Cannot create an item in the past")
+
+      let blobId = null
       if (image) {
-        const match = image.match(/\(([^)]+)\)/);
-        blobId = match ? match[1] : image;
+        const match = String(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 tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(",").map((t) => t.trim()).filter(Boolean)
+
+      const p = typeof price === "string" ? parseFloat(String(price).replace(",", ".")) : parseFloat(price)
+      if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price")
+
+      const s = parseInt(String(stock || "0"), 10)
+      if (!Number.isFinite(s) || s <= 0) throw new Error("Invalid stock")
+
       const itemContent = {
         type: "market",
         item_type,
         title,
         description,
         image: blobId,
-        price: parseFloat(price).toFixed(6),
+        price: p.toFixed(6),
         tags,
         item_status,
-        status: 'FOR SALE',
-        deadline: formattedDeadline,
-        includesShipping,
-        stock,
+        status: "FOR SALE",
+        deadline: formattedDeadline.toISOString(),
+        includesShipping: !!includesShipping,
+        stock: s,
         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));
-      });
+        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;
+      const { tomb, fwd } = await resolveGraph()
+      let cur = itemId
+      while (fwd.has(cur)) cur = fwd.get(cur)
+      if (tomb.has(cur)) throw new Error("Item not found")
+      return cur
     },
 
     async updateItemById(itemId, updatedData) {
-      const tipId = await this.resolveCurrentId(itemId);
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(itemId)
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+
+      const normalizeTags = (v) => {
+        if (v === undefined) return undefined
+        if (Array.isArray(v)) return v.filter(Boolean)
+        if (typeof v === "string") return v.split(",").map((t) => t.trim()).filter(Boolean)
+        return []
+      }
+
+      const normalized = { ...(updatedData || {}) }
+      const tagsCandidate = normalizeTags(updatedData && updatedData.tags)
+      if (tagsCandidate !== undefined) normalized.tags = tagsCandidate
+
+      if (normalized.price !== undefined && normalized.price !== null && normalized.price !== "") {
+        const p = typeof normalized.price === "string" ? parseFloat(String(normalized.price).replace(",", ".")) : parseFloat(normalized.price)
+        if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price")
+        normalized.price = p.toFixed(6)
+      }
+
+      if (normalized.deadline !== undefined && normalized.deadline !== null && normalized.deadline !== "") {
+        const dl = moment(normalized.deadline, moment.ISO_8601, true)
+        if (!dl.isValid()) throw new Error("Invalid deadline")
+        normalized.deadline = dl.toISOString()
+      }
+
+      if (normalized.stock !== undefined) {
+        const s = parseInt(String(normalized.stock), 10)
+        if (!Number.isFinite(s) || s < 0) throw new Error("Invalid stock")
+        normalized.stock = s
+      }
+
+      if (normalized.includesShipping !== undefined) {
+        normalized.includesShipping = !!normalized.includesShipping
+      }
+
       return new Promise((resolve, reject) => {
         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(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));
-          });
-        });
-      });
+          if (err || !item || !item.content) return reject(new Error("Item not found"))
+          if (item.content.seller !== userId) return reject(new Error("Not the seller"))
+
+          const curStatusNorm = N(item.content.status || "FOR SALE")
+          const curStatus = D(curStatusNorm)
+          if (["SOLD", "DISCARDED"].includes(curStatus)) return reject(new Error("Cannot update this item"))
+
+          const updated = {
+            ...item.content,
+            ...normalized,
+            tags: updatedData && updatedData.tags !== undefined ? normalized.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, (err1) => {
+            if (err1) return reject(err1)
+            ssbClient.publish(updated, (err2, res) => (err2 ? reject(err2) : resolve(res)))
+          })
+        })
+      })
     },
 
     async deleteItemById(itemId) {
-      const tipId = await this.resolveCurrentId(itemId);
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(itemId)
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+
       return new Promise((resolve, reject) => {
         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: tipId, deletedAt: new Date().toISOString(), author: userId };
-          ssbClient.publish(tombstone, (err) => err ? reject(err) : resolve({ message: "Item deleted successfully" }));
-        });
-      });
+          if (err || !item || !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: tipId, deletedAt: new Date().toISOString(), author: userId }
+          ssbClient.publish(tombstone, (err2) => (err2 ? reject(err2) : resolve({ message: "Item deleted successfully" })))
+        })
+      })
     },
 
-    async listAllItems(filter = 'all') {
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
-      const messages = await new Promise((resolve, reject) =>
-        pull(
-          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();
+    async listAllItems(filter = "all") {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const messages = await readAll(ssbClient)
+
+      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 k = m.key
+        const c = m.value && 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 && m.value.timestamp) || m.timestamp || 0, 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();
+      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 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;
+      const items = []
+      const now = moment()
+
+      for (const [rootId, ids] of groups.entries()) {
+        const leaf = Array.from(ids).find((id) => !child.has(id)) || Array.from(ids)[0]
+        if (!leaf) continue
+        if (tomb.has(leaf)) continue
 
-        let best = nodes.get(tip);
-        let bestS = N(best.c.status || 'FOR_SALE');
+        let best = nodes.get(leaf)
+        if (!best) continue
+
+        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 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);
+        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';
+          const dl = moment(c.deadline)
+          if (dl.isValid() && dl.isBefore(now)) {
+            if (status !== "SOLD" && status !== "DISCARDED") {
+              if (String(c.item_type || "").toLowerCase() === "auction") {
+                status = highestBidAmount(c.auctions_poll) > 0 ? "SOLD" : "DISCARDED"
+              } else {
+                status = "DISCARDED"
+              }
+            }
+          }
         }
-        if (status === 'FOR SALE' && (c.stock || 0) === 0) continue;
+
+        if (status === "FOR SALE" && (Number(c.stock) || 0) === 0) continue
 
         items.push({
-          id: tip,
+          id: leaf,
+          rootId,
           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',
+          item_status: c.item_status || "NEW",
           status,
-          createdAt: c.createdAt || best.ts,
+          createdAt: c.createdAt || new Date(best.ts).toISOString(),
           updatedAt: c.updatedAt,
           seller: c.seller,
           includesShipping: !!c.includesShipping,
-          stock: c.stock || 0,
+          stock: Number(c.stock) || 0,
           deadline: c.deadline || null,
-          auctions_poll: c.auctions_poll || []
-        });
+          auctions_poll: Array.isArray(c.auctions_poll) ? c.auctions_poll : []
+        })
       }
 
-      let list = items;
+      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;
+        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 "mybids":
+          list = list.filter((i) => i.item_type === "auction").filter((i) => hasBidder(i.auctions_poll, userId))
+          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));
+      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();
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+
+      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 || 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 k = m.key
+        const c = m.value && 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 && m.value.timestamp) || m.timestamp || 0, 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);
+      let tip = itemId
+      while (child.has(tip)) tip = child.get(tip)
+      if (tomb.has(tip)) return null
 
-      const ids = new Set();
-      let cur = tip;
-      ids.add(cur);
-      while (parent.has(cur)) { cur = parent.get(cur); ids.add(cur); }
+      let rootId = tip
+      while (parent.has(rootId)) rootId = parent.get(rootId)
 
-      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');
+      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) || null
+      if (!best || !best.c) 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 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);
+      const c = best.c
+      let status = D(bestS)
+
+      const now = moment()
       if (c.deadline) {
-        const dl = moment(c.deadline);
-        if (dl.isValid() && dl.isBefore(moment()) && status !== 'SOLD') status = 'DISCARDED';
+        const dl = moment(c.deadline)
+        if (dl.isValid() && dl.isBefore(now)) {
+          if (status !== "SOLD" && status !== "DISCARDED") {
+            if (String(c.item_type || "").toLowerCase() === "auction") {
+              status = highestBidAmount(c.auctions_poll) > 0 ? "SOLD" : "DISCARDED"
+            } else {
+              status = "DISCARDED"
+            }
+          }
+        }
       }
 
       return {
         id: tip,
+        rootId,
         title: c.title,
         description: c.description,
         image: c.image,
@@ -247,146 +451,176 @@ module.exports = ({ cooler }) => {
         item_type: c.item_type,
         item_status: c.item_status,
         status,
-        createdAt: c.createdAt || best.ts,
+        createdAt: c.createdAt || new Date(best.ts).toISOString(),
         updatedAt: c.updatedAt,
         seller: c.seller,
-        includesShipping: c.includesShipping,
-        stock: c.stock,
+        includesShipping: !!c.includesShipping,
+        stock: Number(c.stock) || 0,
         deadline: c.deadline,
-        auctions_poll: c.auctions_poll || []
-      };
+        auctions_poll: Array.isArray(c.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(D(N(item.status)))) continue;
-          let status = item.status;
-          if (item.item_type === 'auction') {
-            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') {
-            status = 'DISCARDED';
-          }
-          await this.updateItemById(item.id, { status });
+      const ssbClient = await openSsb()
+      const myId = ssbClient.id
+      const now = moment()
+      const list = Array.isArray(items) ? items : []
+
+      for (const item of list) {
+        if (!item || !item.deadline) continue
+        if (item.seller !== myId) continue
+        const dl = moment(item.deadline)
+        if (!dl.isValid()) continue
+        if (!dl.isBefore(now)) continue
+
+        const curStatus = D(N(item.status))
+        if (curStatus === "SOLD" || curStatus === "DISCARDED") continue
+
+        let status = curStatus
+        const kind = String(item.item_type || "").toLowerCase()
+
+        if (kind === "auction") {
+          status = highestBidAmount(item.auctions_poll) > 0 ? "SOLD" : "DISCARDED"
+        } else {
+          status = "DISCARDED"
         }
+
+        try {
+          await this.updateItemById(item.id, { status })
+        } catch (_) {}
       }
     },
 
     async setItemAsSold(itemId) {
-      const tipId = await this.resolveCurrentId(itemId);
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(itemId)
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+
       return new Promise((resolve, reject) => {
         ssbClient.get(tipId, (err, item) => {
-          if (err || !item?.content) return reject(new Error("Item not found"));
-          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"));
+          if (err || !item || !item.content) return reject(new Error("Item not found"))
+          if (item.content.seller !== userId) return reject(new Error("Not the seller"))
+
+          const curStatus = String(item.content.status).toUpperCase().replace(/\s+/g, "_")
+          if (["SOLD", "DISCARDED"].includes(curStatus)) return reject(new Error("Already sold/discarded"))
 
           const soldMsg = {
             ...item.content,
             stock: 0,
-            status: 'SOLD',
+            status: "SOLD",
             updatedAt: new Date().toISOString(),
             replaces: tipId
-          };
-          const tomb1 = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
+          }
+
+          const tomb1 = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
 
-          ssbClient.publish(tomb1, err => {
-            if (err) return reject(err);
+          ssbClient.publish(tomb1, (err1) => {
+            if (err1) return reject(err1)
             ssbClient.publish(soldMsg, (err2, soldRes) => {
-              if (err2) return reject(err2);
+              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));
-              });
-            });
-          });
-        });
-      });
+              }
+
+              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();
+      const tipId = await this.resolveCurrentId(itemId)
+      const ssbClient = await openSsb()
+      const me = ssbClient.id
+
       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));
-          });
-        });
-      });
+          if (err || !item || !item.content) return reject(new Error("Item not found"))
+          const c = item.content
+
+          if (String(c.item_type || "").toLowerCase() !== "auction") return reject(new Error("Not an auction"))
+          if (c.seller === userId) return reject(new Error("Cannot bid on your own item"))
+
+          const curStatus = D(N(c.status || "FOR_SALE"))
+          if (curStatus !== "FOR SALE") return reject(new Error("Auction is not active"))
+
+          const dl = c.deadline ? moment(c.deadline) : null
+          if (!dl || !dl.isValid()) return reject(new Error("Invalid deadline"))
+          if (dl.isBefore(moment())) return reject(new Error("Auction closed"))
+
+          const stock = Number(c.stock) || 0
+          if (stock <= 0) return reject(new Error("Out of stock"))
+
+          const basePrice = parseFloat(String(c.price || "0").replace(",", "."))
+          const bid = parseFloat(String(bidAmount || "").replace(",", "."))
+          if (!Number.isFinite(bid) || bid <= 0) return reject(new Error("Invalid bid"))
+
+          const highest = highestBidAmount(c.auctions_poll)
+          const min = Number.isFinite(highest) && highest > 0 ? highest : Number.isFinite(basePrice) ? basePrice : 0
+          if (bid <= min) return reject(new Error("Bid not highest"))
+
+          const bidLine = `${userId}|${bid.toFixed(6)}|${new Date().toISOString()}`
+
+          const updated = {
+            ...c,
+            auctions_poll: [...(Array.isArray(c.auctions_poll) ? c.auctions_poll : []), bidLine],
+            updatedAt: new Date().toISOString(),
+            replaces: tipId
+          }
+
+          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: me }
+
+          ssbClient.publish(tombstone, (err1) => {
+            if (err1) return reject(err1)
+            ssbClient.publish(updated, (err2, res) => (err2 ? reject(err2) : resolve(res)))
+          })
+        })
+      })
     },
 
     async decrementStock(itemId) {
-      const tipId = await this.resolveCurrentId(itemId);
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(itemId)
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
 
       return new Promise((resolve, reject) => {
         ssbClient.get(tipId, (err, item) => {
-          if (err || !item?.content) return reject(new Error("Item not found"));
+          if (err || !item || !item.content) return reject(new Error("Item not found"))
 
-          const curStatus = String(item.content.status).toUpperCase().replace(/\s+/g,'_');
-          if (['SOLD','DISCARDED'].includes(curStatus)) {
-            return resolve({ ok: true, noop: true });
-          }
+          const curStatus = String(item.content.status).toUpperCase().replace(/\s+/g, "_")
+          if (["SOLD", "DISCARDED"].includes(curStatus)) return resolve({ ok: true, noop: true })
 
-          const current = Number(item.content.stock) || 0;
-          if (current <= 0) {
-            return resolve({ ok: true, noop: true });
-          }
+          const current = Number(item.content.stock) || 0
+          if (current <= 0) return resolve({ ok: true, noop: true })
 
-          const newStock = current - 1;
+          const newStock = current - 1
           const updated = {
             ...item.content,
             stock: newStock,
-            status: newStock === 0 ? 'SOLD' : item.content.status,
+            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
-          };
+          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));
-          });
-        });
-      });
+            if (e1) return reject(e1)
+            ssbClient.publish(updated, (e2, res) => (e2 ? reject(e2) : resolve(res)))
+          })
+        })
+      })
     }
-    
-  };
-};
+  }
+}
 

+ 364 - 267
src/models/projects_model.js

@@ -1,7 +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
+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 && getConfig().ssbLogStream.limit) || 1000
 
 module.exports = ({ cooler }) => {
   let ssb
@@ -10,38 +10,68 @@ module.exports = ({ cooler }) => {
     return ssb
   }
 
-  const TYPE = 'project'
-  const clampPercent = n => Math.max(0, Math.min(100, parseInt(n,10) || 0))
+  const TYPE = "project"
+
+  const clampPercent = (n) => {
+    const x = parseInt(n, 10)
+    if (!Number.isFinite(x)) return 0
+    return Math.max(0, Math.min(100, x))
+  }
 
   async function getAllMsgs(ssbClient) {
     return new Promise((r, j) => {
-      pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? j(e) : r(m)))
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((e, m) => (e ? j(e) : r(m)))
+      )
     })
   }
 
+  function extractBlobId(possibleMarkdownImage) {
+    let blobId = possibleMarkdownImage
+    if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1]
+    return blobId
+  }
+
   function normalizeMilestonesFrom(data) {
     if (Array.isArray(data.milestones)) {
-      return data.milestones.map(m => ({
-        title: String(m.title || '').trim(),
-        description: m.description || '',
-        targetPercent: clampPercent(m.targetPercent || 0),
-        dueDate: m.dueDate ? new Date(m.dueDate).toISOString() : null,
-        done: !!m.done
-      })).filter(m => m.title)
+      return data.milestones
+        .map((m) => {
+          return {
+            title: String((m && m.title) || "").trim(),
+            description: (m && m.description) || "",
+            targetPercent: clampPercent(m && m.targetPercent),
+            dueDate: m && m.dueDate ? new Date(m.dueDate).toISOString() : null,
+            done: !!(m && m.done)
+          }
+        })
+        .filter((m) => m.title)
     }
-    const title = String((data['milestones[0][title]'] || data.milestoneTitle || '')).trim()
-    const description = data['milestones[0][description]'] || data.milestoneDescription || ''
-    const tpRaw = (data['milestones[0][targetPercent]'] ?? data.milestoneTargetPercent) ?? 0
+
+    const title = String((data["milestones[0][title]"] || data.milestoneTitle || "")).trim()
+    const description = data["milestones[0][description]"] || data.milestoneDescription || ""
+    const tpRaw = (data["milestones[0][targetPercent]"] != null ? data["milestones[0][targetPercent]"] : data.milestoneTargetPercent) != null
+      ? (data["milestones[0][targetPercent]"] != null ? data["milestones[0][targetPercent]"] : data.milestoneTargetPercent)
+      : 0
     const targetPercent = clampPercent(tpRaw)
-    const dueRaw = data['milestones[0][dueDate]'] || data.milestoneDueDate || null
+    const dueRaw = data["milestones[0][dueDate]"] || data.milestoneDueDate || null
     const dueDate = dueRaw ? new Date(dueRaw).toISOString() : null
     const out = []
     if (title) out.push({ title, description, targetPercent, dueDate, done: false })
     return out
   }
 
-  function autoCompleteMilestoneIfReady(projectLike, milestoneIdx, clampPercentFn) {
-    if (milestoneIdx == null) {
+  function safeMilestoneIndex(project, idx) {
+    const total = Array.isArray(project.milestones) ? project.milestones.length : 0
+    if (idx === null || idx === undefined || idx === "" || isNaN(idx)) return null
+    const n = parseInt(idx, 10)
+    if (!Number.isFinite(n)) return null
+    if (n < 0 || n >= total) return null
+    return n
+  }
+
+  function autoCompleteMilestoneIfReady(projectLike, milestoneIdx) {
+    if (milestoneIdx === null || milestoneIdx === undefined) {
       return { milestones: projectLike.milestones || [], progress: projectLike.progress || 0, changed: false }
     }
     const milestones = Array.isArray(projectLike.milestones) ? projectLike.milestones.slice() : []
@@ -49,17 +79,18 @@ module.exports = ({ cooler }) => {
       return { milestones, progress: projectLike.progress || 0, changed: false }
     }
     const bounties = Array.isArray(projectLike.bounties) ? projectLike.bounties : []
-    const related = bounties.filter(b => b.milestoneIndex === milestoneIdx)
+    const related = bounties.filter((b) => b && b.milestoneIndex === milestoneIdx)
     if (related.length === 0) {
       return { milestones, progress: projectLike.progress || 0, changed: false }
     }
-    const allDone = related.every(b => !!b.done)
+    const allDone = related.every((b) => !!(b && b.done))
     let progress = projectLike.progress || 0
     let changed = false
     if (allDone && !milestones[milestoneIdx].done) {
       milestones[milestoneIdx].done = true
-      const target = clampPercentFn(milestones[milestoneIdx].targetPercent || 0)
-      progress = Math.max(parseInt(progress, 10) || 0, target)
+      const target = clampPercent(milestones[milestoneIdx].targetPercent || 0)
+      const pInt = parseInt(progress, 10)
+      progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target)
       changed = true
     }
     return { milestones, progress, changed }
@@ -68,40 +99,45 @@ module.exports = ({ cooler }) => {
   async function resolveTipId(id) {
     const ssbClient = await openSsb()
     const all = await getAllMsgs(ssbClient)
+
     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 === TYPE && 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('Project not found')
-    return key
+    const forward = new Map()
+
+    for (const m of all) {
+      const c = m && m.value && m.value.content
+      if (!c) continue
+      if (c.type === "tombstone" && c.target) tomb.add(c.target)
+      if (c.type === TYPE && c.replaces) forward.set(c.replaces, m.key)
+    }
+
+    let cur = id
+    while (forward.has(cur)) cur = forward.get(cur)
+    if (tomb.has(cur)) throw new Error("Project not found")
+    return cur
   }
 
   async function getById(id) {
     const ssbClient = await openSsb()
     const tip = await resolveTipId(id)
-    const msg = await new Promise((r, j) => ssbClient.get(tip, (e, m) => e ? j(e) : r(m)))
-    if (!msg) throw new Error('Project not found')
+    const msg = await new Promise((r, j) => ssbClient.get(tip, (e, m) => (e ? j(e) : r(m))))
+    if (!msg || !msg.content) throw new Error("Project not found")
     return { id: tip, ...msg.content }
   }
 
-  function extractBlobId(possibleMarkdownImage) {
-    let blobId = possibleMarkdownImage
-    if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1]
-    return blobId
+  async function publishReplace(ssbClient, currentId, content) {
+    const tomb = { type: "tombstone", target: currentId, deletedAt: new Date().toISOString(), author: ssbClient.id }
+    const updated = { ...content, type: TYPE, replaces: currentId, updatedAt: new Date().toISOString() }
+    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))))
   }
 
-  function safeMilestoneIndex(project, idx) {
-    const total = Array.isArray(project.milestones) ? project.milestones.length : 0
-    if (idx === null || idx === undefined || idx === '' || isNaN(idx)) return null
-    const n = parseInt(idx, 10)
-    if (n < 0 || n >= total) return null
-    return n
+  function isParticipant(project, uid) {
+    if (!project || !uid) return false
+    const backers = Array.isArray(project.backers) ? project.backers : []
+    if (backers.some((b) => b && b.userId === uid)) return true
+    const bounties = Array.isArray(project.bounties) ? project.bounties : []
+    if (bounties.some((b) => b && b.claimedBy === uid)) return true
+    return false
   }
 
   return {
@@ -111,26 +147,36 @@ module.exports = ({ cooler }) => {
       const ssbClient = await openSsb()
       const blobId = extractBlobId(data.image)
       const milestones = normalizeMilestonesFrom(data)
+
+      let goal = parseFloat(data.goal || 0) || 0
+      if (goal < 0) goal = 0
+
+      const deadlineISO = data.deadline ? new Date(data.deadline).toISOString() : null
+
       const content = {
         type: TYPE,
         title: data.title,
         description: data.description,
         image: blobId || null,
-        goal: parseFloat(data.goal || 0) || 0,
+        goal,
         pledged: parseFloat(data.pledged || 0) || 0,
-        deadline: data.deadline || null,
+        deadline: deadlineISO,
         progress: clampPercent(data.progress || 0),
-        status: (data.status || 'ACTIVE').toUpperCase(),
+        status: String(data.status || "ACTIVE").toUpperCase(),
         milestones,
         bounties: Array.isArray(data.bounties)
-          ? data.bounties.map(b => ({
-              title: String(b.title || '').trim(),
-              amount: Math.max(0, parseFloat(b.amount || 0) || 0),
-              description: b.description || '',
-              claimedBy: b.claimedBy || null,
-              done: !!b.done,
-              milestoneIndex: b.milestoneIndex != null ? parseInt(b.milestoneIndex,10) : null
-            }))
+          ? data.bounties
+              .map((b) => {
+                return {
+                  title: String((b && b.title) || "").trim(),
+                  amount: Math.max(0, parseFloat((b && b.amount) || 0) || 0),
+                  description: (b && b.description) || "",
+                  claimedBy: (b && b.claimedBy) || null,
+                  done: !!(b && b.done),
+                  milestoneIndex: b && b.milestoneIndex != null ? parseInt(b.milestoneIndex, 10) : null
+                }
+              })
+              .filter((b) => b.title)
           : [],
         followers: [],
         backers: [],
@@ -138,293 +184,344 @@ module.exports = ({ cooler }) => {
         createdAt: new Date().toISOString(),
         updatedAt: null
       }
-      return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
+
+      return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
     },
 
     async updateProject(id, patch) {
       const ssbClient = await openSsb()
       const current = await getById(id)
+      if (current.author !== ssbClient.id) throw new Error("Unauthorized")
 
-      let blobId = (patch.image === undefined ? current.image : patch.image)
+      let blobId = patch.image === undefined ? current.image : patch.image
       blobId = extractBlobId(blobId)
 
+      let milestones = patch.milestones === undefined ? current.milestones : patch.milestones
+      if (milestones != null) {
+        milestones = Array.isArray(milestones)
+          ? milestones
+              .map((m) => {
+                return {
+                  title: String((m && m.title) || "").trim(),
+                  description: (m && m.description) || "",
+                  targetPercent: clampPercent(m && m.targetPercent),
+                  dueDate: m && m.dueDate ? new Date(m.dueDate).toISOString() : null,
+                  done: !!(m && m.done)
+                }
+              })
+              .filter((m) => m.title)
+          : current.milestones
+      }
+
       let bounties = patch.bounties === undefined ? current.bounties : patch.bounties
-      if (bounties) {
-        bounties = bounties.map(b => ({
-          title: String(b.title || '').trim(),
-          amount: Math.max(0, parseFloat(b.amount || 0) || 0),
-          description: b.description || '',
-          claimedBy: b.claimedBy || null,
-          done: !!b.done,
-          milestoneIndex: b.milestoneIndex != null ? safeMilestoneIndex(current, b.milestoneIndex) : null
-        }))
+      if (bounties != null) {
+        bounties = Array.isArray(bounties)
+          ? bounties
+              .map((b) => {
+                return {
+                  title: String((b && b.title) || "").trim(),
+                  amount: Math.max(0, parseFloat((b && b.amount) || 0) || 0),
+                  description: (b && b.description) || "",
+                  claimedBy: (b && b.claimedBy) || null,
+                  done: !!(b && b.done),
+                  milestoneIndex: b && b.milestoneIndex != null ? safeMilestoneIndex({ milestones: milestones || current.milestones }, b.milestoneIndex) : null
+                }
+              })
+              .filter((b) => b.title)
+          : current.bounties
       }
-      const tomb = { type: 'tombstone', target: current.id, deletedAt: new Date().toISOString(), author: ssbClient.id }
+
+      let deadline = patch.deadline === undefined ? current.deadline : patch.deadline
+      if (deadline != null && deadline !== "") deadline = new Date(deadline).toISOString()
+      else if (deadline === "") deadline = null
+
       const updated = {
-        type: TYPE,
         ...current,
         ...patch,
         image: blobId || null,
+        milestones,
         bounties,
-        updatedAt: new Date().toISOString(),
-        replaces: current.id
+        deadline,
+        progress: patch.progress === undefined ? current.progress : clampPercent(patch.progress),
+        status: patch.status === undefined ? current.status : String(patch.status || "").toUpperCase()
       }
-      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)))
+
+      return publishReplace(ssbClient, current.id, updated)
     },
 
     async deleteProject(id) {
       const ssbClient = await openSsb()
       const tip = await resolveTipId(id)
       const project = await getById(tip)
-      if (project.author !== ssbClient.id) throw new Error('Unauthorized')
-      const tomb = { type: 'tombstone', target: tip, deletedAt: new Date().toISOString(), author: ssbClient.id }
-      return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r)))
+      if (project.author !== ssbClient.id) throw new Error("Unauthorized")
+      const tomb = { type: "tombstone", target: tip, deletedAt: new Date().toISOString(), author: ssbClient.id }
+      return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => (e ? rej(e) : res(r))))
     },
 
     async updateProjectStatus(id, status) {
-      return this.updateProject(id, { status: String(status || '').toUpperCase() })
+      const s = String(status || "").toUpperCase()
+      return this.updateProject(id, { status: s })
     },
 
     async updateProjectProgress(id, progress) {
       const p = clampPercent(progress)
-      return this.updateProject(id, { progress: p, status: p >= 100 ? 'COMPLETED' : undefined })
-    },
-    
-    async getProjectById(id) {
-      const project = await projectsModel.getById(id);
-      project.backers = project.backers || [];
-      const bakers = project.backers.map(b => ({
-        userId: b.userId,
-        amount: b.amount,
-        contributedAt: moment(b.at).format('YYYY/MM/DD')
-      }));
-      return { ...project, bakers };
-    },
-    
-    async updateProjectGoalProgress(projectId, pledgeAmount) {
-     const project = await projectsModel.getById(projectId);
-     project.pledged += pledgeAmount;
-     const goalProgress = (project.pledged / project.goal) * 100;
-     await projectsModel.updateProject(projectId, { pledged: project.pledged, progress: goalProgress });
+      return this.updateProject(id, { progress: p, ...(p >= 100 ? { status: "COMPLETED" } : {}) })
     },
 
-    async followProject(id, userId) {
-      const tip = await this.getProjectTipId(id);
-      const project = await this.getProjectById(tip);
-      const followers = Array.isArray(project.followers) ? project.followers.slice() : [];
-      if (!followers.includes(userId)) followers.push(userId);
-      const activity = {
-        kind: 'follow',
-        amount: null,
-        activityActor: userId
-      };
-      return this.updateProject(tip, { followers, activity });
+    async followProject(id, uid) {
+      const tip = await resolveTipId(id)
+      const ssbClient = await openSsb()
+      const project = await getById(tip)
+      const followers = Array.isArray(project.followers) ? project.followers.slice() : []
+      if (!followers.includes(uid)) followers.push(uid)
+      return publishReplace(ssbClient, project.id, { ...project, followers, activity: { kind: "follow", activityActor: uid, at: new Date().toISOString() } })
     },
 
-    async unfollowProject(id, userId) {
-      const tip = await this.getProjectTipId(id);
-      const project = await this.getProjectById(tip);
-      const followers = (project.followers || []).filter(uid => uid !== userId);
-      const activity = {
-        kind: 'unfollow',
-        amount: null,
-        activityActor: userId
-      };
-      return this.updateProject(tip, { followers, activity });
+    async unfollowProject(id, uid) {
+      const tip = await resolveTipId(id)
+      const ssbClient = await openSsb()
+      const project = await getById(tip)
+      const followers = Array.isArray(project.followers) ? project.followers.filter((x) => x !== uid) : []
+      return publishReplace(ssbClient, project.id, { ...project, followers, activity: { kind: "unfollow", activityActor: uid, at: new Date().toISOString() } })
     },
 
-    async pledgeToProject(id, userId, amount) {
-      const tip = await this.getProjectTipId(id);
-      const project = await this.getProjectById(tip);
-      const amt = Math.max(0, parseFloat(amount || 0) || 0);
-      if (amt <= 0) throw new Error('Invalid amount');
-      const backers = Array.isArray(project.backers) ? project.backers.slice() : [];
-      backers.push({ userId, amount: amt, at: new Date().toISOString() });
-      const pledged = (parseFloat(project.pledged || 0) || 0) + amt;
-      await this.updateProject(tip, { backers, pledged });
+    async pledgeToProject(id, uid, amount) {
+      const tip = await resolveTipId(id)
+      const ssbClient = await openSsb()
+      const project = await getById(tip)
+      const amt = Math.max(0, parseFloat(amount || 0) || 0)
+      if (amt <= 0) throw new Error("Invalid amount")
+      const backers = Array.isArray(project.backers) ? project.backers.slice() : []
+      backers.push({ userId: uid, amount: amt, at: new Date().toISOString(), confirmed: false })
+      const pledged = (parseFloat(project.pledged || 0) || 0) + amt
+      const progress = project.goal ? (pledged / parseFloat(project.goal || 1)) * 100 : project.progress
+      return publishReplace(ssbClient, project.id, { ...project, backers, pledged, progress })
     },
 
     async addBounty(id, bounty) {
-      const tip = await this.getProjectTipId(id);
-      const project = await this.getProjectById(tip);
-      const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : [];
+      const tip = await resolveTipId(id)
+      const ssbClient = await openSsb()
+      const project = await getById(tip)
+      if (project.author !== ssbClient.id) throw new Error("Unauthorized")
+      const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
       const clean = {
-        title: String(bounty.title || '').trim(),
-        amount: Math.max(0, parseFloat(bounty.amount || 0) || 0),
-        description: bounty.description || '',
+        title: String((bounty && bounty.title) || "").trim(),
+        amount: Math.max(0, parseFloat((bounty && bounty.amount) || 0) || 0),
+        description: (bounty && bounty.description) || "",
         claimedBy: null,
         done: false,
-        milestoneIndex: safeMilestoneIndex(project, bounty.milestoneIndex)
-      };
-      bounties.push(clean);
-      return this.updateProject(tip, { bounties });
+        milestoneIndex: safeMilestoneIndex(project, bounty && bounty.milestoneIndex)
+      }
+      if (!clean.title) throw new Error("Bounty title required")
+      bounties.push(clean)
+      return publishReplace(ssbClient, project.id, { ...project, bounties })
     },
 
     async updateBounty(id, index, patch) {
-      const tip = await this.getProjectTipId(id);
-      const project = await this.getProjectById(tip);
-      const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : [];
-      if (!bounties[index]) throw new Error('Bounty not found');
-  
-      if (patch.title !== undefined) bounties[index].title = String(patch.title).trim();
-      if (patch.amount !== undefined) bounties[index].amount = Math.max(0, parseFloat(patch.amount || 0) || 0);
-      if (patch.description !== undefined) bounties[index].description = patch.description || '';
-      if (patch.milestoneIndex !== undefined) {
-        const newIdx = patch.milestoneIndex == null ? null : parseInt(patch.milestoneIndex, 10);
-        bounties[index].milestoneIndex = (newIdx == null) ? null : (isNaN(newIdx) ? null : newIdx);
-      }
-      if (patch.done !== undefined) bounties[index].done = !!patch.done;
+      const tip = await resolveTipId(id)
+      const ssbClient = await openSsb()
+      const project = await getById(tip)
+      if (project.author !== ssbClient.id) throw new Error("Unauthorized")
+      const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
+      if (!bounties[index]) throw new Error("Bounty not found")
+
+      if (patch.title !== undefined) bounties[index].title = String(patch.title || "").trim()
+      if (patch.amount !== undefined) bounties[index].amount = Math.max(0, parseFloat(patch.amount || 0) || 0)
+      if (patch.description !== undefined) bounties[index].description = patch.description || ""
+      if (patch.milestoneIndex !== undefined) bounties[index].milestoneIndex = safeMilestoneIndex(project, patch.milestoneIndex)
+      if (patch.done !== undefined) bounties[index].done = !!patch.done
+
+      return publishReplace(ssbClient, project.id, { ...project, bounties })
+    },
 
-      return this.updateProject(tip, { bounties });
+    async addMilestone(id, milestone) {
+      const tip = await resolveTipId(id)
+      const ssbClient = await openSsb()
+      const project = await getById(tip)
+      if (project.author !== ssbClient.id) throw new Error("Unauthorized")
+      const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
+      const clean = {
+        title: String((milestone && milestone.title) || "").trim(),
+        description: (milestone && milestone.description) || "",
+        targetPercent: clampPercent(milestone && milestone.targetPercent),
+        dueDate: milestone && milestone.dueDate ? new Date(milestone.dueDate).toISOString() : null,
+        done: false
+      }
+      if (!clean.title) throw new Error("Milestone title required")
+      milestones.push(clean)
+      return publishReplace(ssbClient, project.id, { ...project, milestones })
     },
 
     async updateMilestone(id, index, patch) {
-      const tip = await this.getProjectTipId(id)
-      const project = await this.getProjectById(tip)
+      const tip = await resolveTipId(id)
+      const ssbClient = await openSsb()
+      const project = await getById(tip)
+      if (project.author !== ssbClient.id) throw new Error("Unauthorized")
       const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
-      if (!milestones[index]) throw new Error('Milestone not found')
-      if (patch.title !== undefined) milestones[index].title = String(patch.title).trim()
+      if (!milestones[index]) throw new Error("Milestone not found")
+
+      if (patch.title !== undefined) milestones[index].title = String(patch.title || "").trim()
+      if (patch.description !== undefined) milestones[index].description = patch.description || ""
       if (patch.targetPercent !== undefined) milestones[index].targetPercent = clampPercent(patch.targetPercent)
       if (patch.dueDate !== undefined) milestones[index].dueDate = patch.dueDate ? new Date(patch.dueDate).toISOString() : null
+
       let progress = project.progress
       if (patch.done !== undefined) {
         milestones[index].done = !!patch.done
         if (milestones[index].done) {
           const target = clampPercent(milestones[index].targetPercent || 0)
-          progress = Math.max(parseInt(project.progress || 0, 10) || 0, target)
+          const pInt = parseInt(project.progress || 0, 10)
+          progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target)
         }
       }
-      const patchOut = { milestones }
-      if (progress !== project.progress) {
-        patchOut.progress = progress
-        if (progress >= 100) patchOut.status = 'COMPLETED'
-      }
-      return this.updateProject(tip, patchOut)
+
+      const updated = { ...project, milestones, ...(progress !== project.progress ? { progress, ...(progress >= 100 ? { status: "COMPLETED" } : {}) } : {}) }
+      return publishReplace(ssbClient, project.id, updated)
     },
 
-    async claimBounty(id, index, userId) {
-      const tip = await this.getProjectTipId(id)
-      const project = await this.getProjectById(tip)
+    async claimBounty(id, index, uid) {
+      const tip = await resolveTipId(id)
+      const ssbClient = await openSsb()
+      const project = await getById(tip)
       const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
-      if (!bounties[index]) throw new Error('Bounty not found')
-      if (bounties[index].claimedBy) throw new Error('Already claimed')
-      bounties[index].claimedBy = userId
-      return this.updateProject(tip, { bounties })
+      if (!bounties[index]) throw new Error("Bounty not found")
+      if (bounties[index].claimedBy) throw new Error("Already claimed")
+      if (project.author === uid) throw new Error("Authors cannot claim")
+      bounties[index].claimedBy = uid
+      return publishReplace(ssbClient, project.id, { ...project, bounties })
     },
 
-    async completeBounty(id, index, userId) {
-      const tip = await this.getProjectTipId(id)
-      const project = await this.getProjectById(tip)
-      if (project.author !== userId) throw new Error('Unauthorized')
+    async completeBounty(id, index, uid) {
+      const tip = await resolveTipId(id)
+      const ssbClient = await openSsb()
+      const project = await getById(tip)
+      if (project.author !== uid) throw new Error("Unauthorized")
       const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
-      if (!bounties[index]) throw new Error('Bounty not found')
+      if (!bounties[index]) throw new Error("Bounty not found")
       bounties[index].done = true
-      const { milestones, progress, changed } =
-        autoCompleteMilestoneIfReady({ ...project, bounties }, bounties[index].milestoneIndex, clampPercent)
-      const patch = { bounties }
-      if (changed) {
-        patch.milestones = milestones
-        patch.progress = progress
-        if (progress >= 100) patch.status = 'COMPLETED'
-      }
-      return this.updateProject(tip, patch)
-    },
-    
-    async addMilestone(id, milestone) {
-      const tip = await this.getProjectTipId(id)
-      const project = await this.getProjectById(tip)
-      const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
-      const clean = {
-        title: String(milestone.title || '').trim(),
-        description: milestone.description || '',
-        targetPercent: clampPercent(milestone.targetPercent || 0),
-        dueDate: milestone.dueDate ? new Date(milestone.dueDate).toISOString() : null,
-        done: false
+
+      const ac = autoCompleteMilestoneIfReady({ ...project, bounties }, bounties[index].milestoneIndex)
+      const patch = { ...project, bounties }
+      if (ac && ac.changed) {
+        patch.milestones = ac.milestones
+        patch.progress = ac.progress
+        if (ac.progress >= 100) patch.status = "COMPLETED"
       }
-      if (!clean.title) throw new Error('Milestone title required')
-      milestones.push(clean)
-      return this.updateProject(tip, { milestones })
+
+      return publishReplace(ssbClient, project.id, patch)
     },
 
-    async completeMilestone(id, index, userId) {
-      const tip = await this.getProjectTipId(id)
-      const project = await this.getProjectById(tip)
-      if (project.author !== userId) throw new Error('Unauthorized')
+    async completeMilestone(id, index, uid) {
+      const tip = await resolveTipId(id)
+      const ssbClient = await openSsb()
+      const project = await getById(tip)
+      if (project.author !== uid) throw new Error("Unauthorized")
       const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
-      if (!milestones[index]) throw new Error('Milestone not found')
+      if (!milestones[index]) throw new Error("Milestone not found")
       milestones[index].done = true
       const target = clampPercent(milestones[index].targetPercent || 0)
-      const progress = Math.max(parseInt(project.progress || 0, 10) || 0, target)
-      const patch = { milestones, progress }
-      if (progress >= 100) patch.status = 'COMPLETED'
-      return this.updateProject(tip, patch)
+      const pInt = parseInt(project.progress || 0, 10)
+      const progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target)
+      const patch = { ...project, milestones, progress }
+      if (progress >= 100) patch.status = "COMPLETED"
+      return publishReplace(ssbClient, project.id, patch)
     },
 
     async listProjects(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 projects = 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 !== TYPE) return
-              if (c.replaces) { replaces.set(c.replaces, k); referencedAsReplaces.add(c.replaces) }
-              projects.set(k, { key: k, content: c })
-            })
-            const tipProjects = []
-            for (const [id, pr] of projects.entries()) if (!referencedAsReplaces.has(id)) tipProjects.push(pr)
-            const groups = {}
-            for (const pr of tipProjects) {
-              const ancestor = pr.content.replaces || pr.key
-              if (!groups[ancestor]) groups[ancestor] = []
-              groups[ancestor].push(pr)
-            }
-            const liveTipIds = new Set()
-            for (const group of Object.values(groups)) {
-              let best = group[0]
-              for (const pr of group) {
-                const bestTime = new Date(best.content.updatedAt || best.content.createdAt || 0)
-                const prTime = new Date(pr.content.updatedAt || pr.content.createdAt || 0)
-                if (
-                  (best.content.status === 'CANCELLED' && pr.content.status !== 'CANCELLED') ||
-                  (best.content.status === pr.content.status && prTime > bestTime) ||
-                  pr.content.status === 'COMPLETED'
-                ) best = pr
-              }
-              liveTipIds.add(best.key)
-            }
-            let list = Array.from(projects.values())
-              .filter(p => liveTipIds.has(p.key) && !tomb.has(p.key))
-              .map(p => ({ id: p.key, ...p.content }))
-            const F = String(filter || 'ALL').toUpperCase()
-            if (F === 'MINE') list = list.filter(p => p.author === currentUserId)
-            else if (F === 'ACTIVE') list = list.filter(p => (p.status || '').toUpperCase() === 'ACTIVE')
-            else if (F === 'COMPLETED') list = list.filter(p => (p.status || '').toUpperCase() === 'COMPLETED')
-            else if (F === 'PAUSED') list = list.filter(p => (p.status || '').toUpperCase() === 'PAUSED')
-            else if (F === 'CANCELLED') list = list.filter(p => (p.status || '').toUpperCase() === 'CANCELLED')
-            else if (F === 'RECENT') list = list.filter(p => moment(p.createdAt).isAfter(moment().subtract(24, 'hours')))
-            else if (F === 'FOLLOWING') list = list.filter(p => Array.isArray(p.followers) && p.followers.includes(currentUserId))
-            if (F === 'TOP') list.sort((a, b) => (parseFloat(b.pledged||0)/(parseFloat(b.goal||1))) - (parseFloat(a.pledged||0)/(parseFloat(a.goal||1))))
-            else list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
-            res(list)
-          })
-        )
-      })
+      const msgs = await getAllMsgs(ssbClient)
+
+      const tomb = new Set()
+      const nodes = new Map()
+      const parent = new Map()
+      const child = new Map()
+
+      for (const m of msgs) {
+        const k = m && m.key
+        const c = m && m.value && m.value.content
+        if (!c) continue
+        if (c.type === "tombstone" && c.target) {
+          tomb.add(c.target)
+          continue
+        }
+        if (c.type !== TYPE) continue
+        nodes.set(k, { key: k, ts: (m.value && m.value.timestamp) || 0, 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 out = []
+      for (const entry of groups.entries()) {
+        const root = entry[0]
+        const ids = entry[1]
+
+        let tip = Array.from(ids).find((id) => !child.has(id))
+        if (!tip) {
+          const arr = Array.from(ids)
+          tip = arr.reduce((a, b) => (nodes.get(a).ts > nodes.get(b).ts ? a : b))
+        }
+        if (tomb.has(tip)) continue
+        const n = nodes.get(tip)
+        if (!n || !n.c) continue
+
+        const c = n.c
+        const status = String(c.status || "ACTIVE").toUpperCase()
+        const createdAt = c.createdAt || new Date(n.ts).toISOString()
+        const deadline = c.deadline || null
+
+        out.push({
+          id: tip,
+          ...c,
+          status,
+          createdAt,
+          deadline
+        })
+      }
+
+      let list = out
+      const F = String(filter || "ALL").toUpperCase()
+
+      if (F === "MINE") list = list.filter((p) => p && p.author === currentUserId)
+      else if (F === "APPLIED") list = list.filter((p) => p && p.author !== currentUserId && isParticipant(p, currentUserId))
+      else if (F === "ACTIVE") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "ACTIVE")
+      else if (F === "COMPLETED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "COMPLETED")
+      else if (F === "PAUSED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "PAUSED")
+      else if (F === "CANCELLED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "CANCELLED")
+      else if (F === "RECENT") list = list.filter((p) => p && moment(p.createdAt).isAfter(moment().subtract(24, "hours")))
+      else if (F === "FOLLOWING") list = list.filter((p) => Array.isArray(p.followers) && p.followers.includes(currentUserId))
+
+      if (F === "TOP") {
+        list.sort((a, b) => (parseFloat(b.pledged || 0) / (parseFloat(b.goal || 1))) - (parseFloat(a.pledged || 0) / (parseFloat(a.goal || 1))))
+      } else {
+        list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+      }
+
+      return list
     },
 
-    async getProjectById(id) { return getById(id) },
-    async getProjectTipId(id) { return resolveTipId(id) }
+    async getProjectById(id) {
+      return getById(id)
+    },
+
+    async getProjectTipId(id) {
+      return resolveTipId(id)
+    }
   }
 }
 

+ 163 - 39
src/models/reports_model.js

@@ -1,37 +1,81 @@
 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 normU = (v) => String(v || '').trim().toUpperCase();
+const normalizeStatus = (v) => normU(v).replace(/\s+/g, '_').replace(/-+/g, '_');
+const normalizeSeverity = (v) => String(v || '').trim().toLowerCase();
+const ensureArray = (v) => Array.isArray(v) ? v.filter(Boolean) : [];
+
+const trimStr = (v) => String(v || '').trim();
+
+const normalizeTemplate = (category, tpl) => {
+  const cat = normU(category);
+  const t = tpl && typeof tpl === 'object' ? tpl : {};
+
+  const pick = (keys) => {
+    const out = {};
+    for (const k of keys) {
+      const val = trimStr(t[k]);
+      if (val) out[k] = val;
+    }
+    return out;
+  };
+
+  if (cat === 'BUGS') {
+    const out = pick(['stepsToReproduce', 'expectedBehavior', 'actualBehavior', 'environment', 'reproduceRate']);
+    if (out.reproduceRate) out.reproduceRate = normU(out.reproduceRate);
+    return out;
+  }
+
+  if (cat === 'FEATURES') {
+    return pick(['problemStatement', 'userStory', 'acceptanceCriteria']);
+  }
+
+  if (cat === 'ABUSE') {
+    return pick(['whatHappened', 'reportedUser', 'evidenceLinks']);
+  }
+
+  if (cat === 'CONTENT') {
+    return pick(['contentLocation', 'whyInappropriate', 'requestedAction', 'evidenceLinks']);
+  }
+
+  return {};
+};
+
 module.exports = ({ cooler }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
   return {
-    async createReport(title, description, category, image, tagsRaw = [], severity = 'low') {
+    async createReport(title, description, category, image, tagsRaw = [], severity = 'low', template = {}) {
       const ssb = await openSsb();
       const userId = ssb.id;
+
       let blobId = null;
       if (image) {
-        const match = image.match(/\(([^)]+)\)/);
+        const match = String(image).match(/\(([^)]+)\)/);
         blobId = match ? match[1] : image;
       }
+
       const tags = Array.isArray(tagsRaw)
         ? tagsRaw.filter(Boolean)
-        : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
+        : String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean);
 
+      const cat = normU(category);
       const content = {
         type: 'report',
         title,
         description,
-        category,
+        category: cat,
         createdAt: new Date().toISOString(),
         author: userId,
         image: blobId,
         tags,
         confirmations: [],
-        severity,
-        status: 'OPEN'
+        severity: normalizeSeverity(severity) || 'low',
+        status: 'OPEN',
+        template: normalizeTemplate(cat, template)
       };
 
       return new Promise((res, rej) => ssb.publish(content, (err, msg) => err ? rej(err) : res(msg)));
@@ -40,19 +84,43 @@ module.exports = ({ cooler }) => {
     async updateReportById(id, updatedContent) {
       const ssb = await openSsb();
       const userId = ssb.id;
-      const report = await new Promise((res, rej) => ssb.get(id, (err, report) => err ? rej(new Error('Report not found')) : res(report)));
+
+      const report = await new Promise((res, rej) =>
+        ssb.get(id, (err, r) => err ? rej(new Error('Report not found')) : res(r))
+      );
+
       if (report.content.author !== userId) throw new Error('Not the author');
 
-      const tags = updatedContent.tags
-        ? updatedContent.tags.split(',').map(t => t.trim()).filter(Boolean)
-        : report.content.tags;
+      const tags = Object.prototype.hasOwnProperty.call(updatedContent, 'tags')
+        ? String(updatedContent.tags || '').split(',').map(t => t.trim()).filter(Boolean)
+        : ensureArray(report.content.tags);
 
-      let blobId = report.content.image;
+      let blobId = report.content.image || null;
       if (updatedContent.image) {
-        const match = updatedContent.image.match(/\(([^)]+)\)/);
+        const match = String(updatedContent.image).match(/\(([^)]+)\)/);
         blobId = match ? match[1] : updatedContent.image;
       }
 
+      const nextStatus = Object.prototype.hasOwnProperty.call(updatedContent, 'status')
+        ? normalizeStatus(updatedContent.status)
+        : normalizeStatus(report.content.status || 'OPEN');
+
+      const nextSeverity = Object.prototype.hasOwnProperty.call(updatedContent, 'severity')
+        ? (normalizeSeverity(updatedContent.severity) || 'low')
+        : (normalizeSeverity(report.content.severity) || 'low');
+
+      const nextCategory = Object.prototype.hasOwnProperty.call(updatedContent, 'category')
+        ? normU(updatedContent.category)
+        : normU(report.content.category);
+
+      const confirmations = ensureArray(report.content.confirmations);
+
+      const baseTemplate = Object.prototype.hasOwnProperty.call(updatedContent, 'template')
+        ? updatedContent.template
+        : (report.content.template || {});
+
+      const nextTemplate = normalizeTemplate(nextCategory, baseTemplate);
+
       const updated = {
         ...report.content,
         ...updatedContent,
@@ -60,6 +128,11 @@ module.exports = ({ cooler }) => {
         replaces: id,
         image: blobId,
         tags,
+        confirmations,
+        severity: nextSeverity,
+        status: nextStatus,
+        category: nextCategory,
+        template: nextTemplate,
         updatedAt: new Date().toISOString(),
         author: report.content.author
       };
@@ -70,59 +143,110 @@ module.exports = ({ cooler }) => {
     async deleteReportById(id) {
       const ssb = await openSsb();
       const userId = ssb.id;
-      const report = await new Promise((res, rej) => ssb.get(id, (err, report) => err ? rej(new Error('Report not found')) : res(report)));
+
+      const report = await new Promise((res, rej) =>
+        ssb.get(id, (err, r) => err ? rej(new Error('Report not found')) : res(r))
+      );
+
       if (report.content.author !== userId) throw new Error('Not the author');
+
       const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
       return new Promise((res, rej) => ssb.publish(tombstone, (err, result) => err ? rej(err) : res(result)));
     },
 
     async getReportById(id) {
       const ssb = await openSsb();
-      const report = await new Promise((res, rej) => ssb.get(id, (err, report) => err ? rej(new Error('Report not found')) : res(report)));
-      const c = report.content;
-      return { id, ...c };
+
+      const report = await new Promise((res, rej) =>
+        ssb.get(id, (err, r) => err ? rej(new Error('Report not found')) : res(r))
+      );
+
+      const c = report.content || {};
+      const cat = normU(c.category);
+      return {
+        id,
+        ...c,
+        category: cat,
+        status: normalizeStatus(c.status || 'OPEN'),
+        severity: normalizeSeverity(c.severity) || 'low',
+        confirmations: ensureArray(c.confirmations),
+        tags: ensureArray(c.tags),
+        template: normalizeTemplate(cat, c.template || {})
+      };
     },
 
     async confirmReportById(id) {
       const ssb = await openSsb();
       const userId = ssb.id;
-      const report = await new Promise((res, rej) => ssb.get(id, (err, report) => err ? rej(new Error('Report not found')) : res(report)));
-      if (report.content.confirmations.includes(userId)) throw new Error('Already confirmed');
+
+      const report = await new Promise((res, rej) =>
+        ssb.get(id, (err, r) => err ? rej(new Error('Report not found')) : res(r))
+      );
+
+      const confirmations = ensureArray(report.content.confirmations);
+      if (confirmations.includes(userId)) throw new Error('Already confirmed');
+
+      const cat = normU(report.content.category);
       const updated = {
         ...report.content,
+        type: 'report',
         replaces: id,
-        confirmations: [...report.content.confirmations, userId],
-        updatedAt: new Date().toISOString()
+        confirmations: [...confirmations, userId],
+        updatedAt: new Date().toISOString(),
+        status: normalizeStatus(report.content.status || 'OPEN'),
+        category: cat,
+        severity: normalizeSeverity(report.content.severity) || 'low',
+        template: normalizeTemplate(cat, report.content.template || {})
       };
+
       return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
     },
 
     async listAll() {
       const ssb = await openSsb();
+
       return new Promise((resolve, reject) => {
-        pull(ssb.createLogStream({ limit: logLimit }), 
+        pull(
+          ssb.createLogStream({ limit: logLimit }),
           pull.collect((err, results) => {
-          if (err) return reject(err);
-          const tombstoned = new Set();
-          const replaced = new Map();
-          const reports = new Map();
-
-          for (const r of results) {
-            const { key, value: { content: c } } = r;
-            if (!c) continue;
-            if (c.type === 'tombstone') tombstoned.add(c.target);
-            if (c.type === 'report') {
-              if (c.replaces) replaced.set(c.replaces, key);
-              reports.set(key, { id: key, ...c });
+            if (err) return reject(err);
+
+            const tombstoned = new Set();
+            const replaced = new Map();
+            const reports = new Map();
+
+            for (const r of results) {
+              const key = r && r.key;
+              const c = r && r.value && r.value.content ? r.value.content : null;
+              if (!key || !c) continue;
+
+              if (c.type === 'tombstone' && c.target) tombstoned.add(c.target);
+
+              if (c.type === 'report') {
+                if (c.replaces) replaced.set(c.replaces, key);
+
+                const cat = normU(c.category);
+                reports.set(key, {
+                  id: key,
+                  ...c,
+                  category: cat,
+                  status: normalizeStatus(c.status || 'OPEN'),
+                  severity: normalizeSeverity(c.severity) || 'low',
+                  confirmations: ensureArray(c.confirmations),
+                  tags: ensureArray(c.tags),
+                  template: normalizeTemplate(cat, c.template || {})
+                });
+              }
             }
-          }
 
-          tombstoned.forEach(id => reports.delete(id));
-          replaced.forEach((_, oldId) => reports.delete(oldId));
+            tombstoned.forEach(id => reports.delete(id));
+            replaced.forEach((_, oldId) => reports.delete(oldId));
 
-          resolve([...reports.values()]);
-        }));
+            resolve([...reports.values()]);
+          })
+        );
       });
     }
   };
 };
+

+ 79 - 47
src/models/tasks_model.js

@@ -7,15 +7,36 @@ module.exports = ({ cooler }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
+  const uniq = (arr) => Array.from(new Set((Array.isArray(arr) ? arr : []).filter(x => typeof x === 'string' && x.trim().length)));
+
+  const normalizeVisibility = (v) => {
+    const vv = String(v || 'PUBLIC').toUpperCase();
+    return (vv === 'PUBLIC' || vv === 'PRIVATE') ? vv : 'PUBLIC';
+  };
+
+  const normalizeStatus = (v, fallback) => {
+    const vv = String(v || '').toUpperCase();
+    if (vv === 'OPEN' || vv === 'IN-PROGRESS' || vv === 'CLOSED') return vv;
+    return fallback;
+  };
+
   return {
     async createTask(title, description, startTime, endTime, priority, location = '', tagsRaw = [], isPublic) {
       const ssb = await openSsb();
       const userId = ssb.id;
+
       const start = moment(startTime);
       const end = moment(endTime);
       if (!start.isValid() || !end.isValid()) throw new Error('Invalid dates');
-      if (start.isBefore(moment()) || end.isBefore(start)) throw new Error('Invalid time range');
-      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
+
+      const nowFloor = moment().startOf('minute');
+      if (start.isBefore(nowFloor) || end.isBefore(start)) throw new Error('Invalid time range');
+
+      const tags = Array.isArray(tagsRaw)
+        ? tagsRaw.filter(Boolean)
+        : String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean);
+
+      const visibility = normalizeVisibility(isPublic);
 
       const content = {
         type: 'task',
@@ -26,7 +47,7 @@ module.exports = ({ cooler }) => {
         priority,
         location,
         tags,
-        isPublic,
+        isPublic: visibility,
         assignees: [userId],
         createdAt: new Date().toISOString(),
         status: 'OPEN',
@@ -48,18 +69,24 @@ module.exports = ({ cooler }) => {
     async updateTaskById(taskId, updatedData) {
       const ssb = await openSsb();
       const userId = ssb.id;
+
       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');
+
       const keys = Object.keys(updatedData || {}).filter(k => updatedData[k] !== undefined);
       const assigneesOnly = keys.length === 1 && keys[0] === 'assignees';
-      const taskCreator = old.author || c.author;
+
+      const taskCreator = c.author || old.author;
       if (!assigneesOnly && taskCreator !== userId) throw new Error('Not the author');
+
       if (c.status === 'CLOSED') throw new Error('Cannot edit a closed task');
-          const uniq = (arr) => Array.from(new Set((Array.isArray(arr) ? arr : []).filter(x => typeof x === 'string' && x.trim().length)));
-          let nextAssignees = Array.isArray(c.assignees) ? uniq(c.assignees) : [];
+
+      let nextAssignees = Array.isArray(c.assignees) ? uniq(c.assignees) : [];
+
       if (assigneesOnly) {
         const proposed = uniq(updatedData.assignees);
         const oldNoSelf = uniq(nextAssignees.filter(x => x !== userId)).sort();
@@ -72,36 +99,42 @@ module.exports = ({ cooler }) => {
         if (hadSelf === hasSelfNow) throw new Error('Not allowed');
         nextAssignees = proposed;
       }
+
       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');
-      }
+
+      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 = [];
-        }
+        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;
+        newVisibility = normalizeVisibility(updatedData.isPublic);
       }
+
+      let newStatus = c.status;
+      if (updatedData.status !== undefined) {
+        const normalized = normalizeStatus(updatedData.status, null);
+        if (!normalized) throw new Error('Invalid status');
+        newStatus = normalized;
+      }
+
       const updated = {
         ...c,
         title: updatedData.title ?? c.title,
@@ -112,17 +145,19 @@ module.exports = ({ cooler }) => {
         location: updatedData.location ?? c.location,
         tags: newTags,
         isPublic: newVisibility,
-        status: updatedData.status ?? c.status,
+        status: newStatus,
         assignees: assigneesOnly ? nextAssignees : (updatedData.assignees !== undefined ? uniq(updatedData.assignees) : nextAssignees),
         updatedAt: new Date().toISOString(),
         replaces: taskId
       };
+
       return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
     },
 
     async updateTaskStatus(taskId, status) {
-      if (!['OPEN', 'IN-PROGRESS', 'CLOSED'].includes(status)) throw new Error('Invalid status');
-      return this.updateTaskById(taskId, { status });
+      const normalized = String(status || '').toUpperCase();
+      if (!['OPEN', 'IN-PROGRESS', 'CLOSED'].includes(normalized)) throw new Error('Invalid status');
+      return this.updateTaskById(taskId, { status: normalized });
     },
 
     async getTaskById(taskId) {
@@ -141,11 +176,8 @@ module.exports = ({ cooler }) => {
       if (task.status === 'CLOSED') throw new Error('Cannot assign users to a closed task');
       let assignees = Array.isArray(task.assignees) ? [...task.assignees] : [];
       const idx = assignees.indexOf(userId);
-      if (idx !== -1) {
-        assignees.splice(idx, 1);
-      } else {
-        assignees.push(userId);
-      }
+      if (idx !== -1) assignees.splice(idx, 1);
+      else assignees.push(userId);
       return this.updateTaskById(taskId, { assignees });
     },
 
@@ -153,32 +185,32 @@ module.exports = ({ cooler }) => {
       const ssb = await openSsb();
       const now = moment();
       return new Promise((resolve, reject) => {
-        pull(ssb.createLogStream({ limit: logLimit }), 
-        pull.collect((err, results) => {
-          if (err) return reject(err);
-          const tombstoned = new Set();
-          const replaced = new Map();
-          const tasks = new Map();
-
-          for (const r of results) {
-            const { key, value: { content: c } } = r;
-            if (!c) continue;
-            if (c.type === 'tombstone') tombstoned.add(c.target);
-            if (c.type === 'task') {
-              if (c.replaces) replaced.set(c.replaces, key);
-              const status = c.status === 'OPEN' && moment(c.endTime).isBefore(now) ? 'CLOSED' : c.status;
-              tasks.set(key, { id: key, ...c, status });
+        pull(ssb.createLogStream({ limit: logLimit }),
+          pull.collect((err, results) => {
+            if (err) return reject(err);
+            const tombstoned = new Set();
+            const replaced = new Map();
+            const tasks = new Map();
+
+            for (const r of results) {
+              const { key, value: { content: c } } = r;
+              if (!c) continue;
+              if (c.type === 'tombstone') tombstoned.add(c.target);
+              if (c.type === 'task') {
+                if (c.replaces) replaced.set(c.replaces, key);
+                const status = c.status === 'OPEN' && moment(c.endTime).isBefore(now) ? 'CLOSED' : c.status;
+                tasks.set(key, { id: key, ...c, status });
+              }
             }
-          }
 
-          tombstoned.forEach(id => tasks.delete(id));
-          replaced.forEach((_, oldId) => tasks.delete(oldId));
+            tombstoned.forEach(id => tasks.delete(id));
+            replaced.forEach((_, oldId) => tasks.delete(oldId));
 
-          resolve([...tasks.values()]);
-        }));
+            resolve([...tasks.values()]);
+          })
+        );
       });
     }
   };
 };
 
-

+ 285 - 199
src/models/transfers_model.js

@@ -1,269 +1,355 @@
-const pull = require('../server/node_modules/pull-stream');
-const moment = require('../server/node_modules/moment');
-const { getConfig } = require('../configs/config-manager.js');
-const categories = require('../backend/opinion_categories');
-const logLimit = getConfig().ssbLogStream?.limit || 1000;
+const pull = require("../server/node_modules/pull-stream")
+const moment = require("../server/node_modules/moment")
+const { getConfig } = require("../configs/config-manager.js")
+const categories = require("../backend/opinion_categories")
+const logLimit = getConfig().ssbLogStream?.limit || 1000
+
+const isValidId = (to) => /^@[A-Za-z0-9+/]+={0,2}\.ed25519$/.test(String(to || ""))
+
+const parseNum = (v) => {
+  const n = parseFloat(String(v ?? "").replace(",", "."))
+  return Number.isFinite(n) ? n : NaN
+}
+
+const normalizeTags = (raw) => {
+  if (raw === undefined || raw === null) return []
+  if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
+  return String(raw).split(",").map(t => t.trim()).filter(Boolean)
+}
 
 module.exports = ({ cooler }) => {
-  let ssb;
-  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+  let ssb
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
 
   const getAllMessages = async (ssbClient) =>
     new Promise((resolve, reject) => {
       pull(
         ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
-      );
-    });
+      )
+    })
+
+  const getMsg = async (ssbClient, key) =>
+    new Promise((resolve, reject) => {
+      ssbClient.get(key, (err, msg) => err ? reject(err) : resolve(msg))
+    })
+
+  const buildIndex = (messages) => {
+    const tomb = new Set()
+    const nodes = new Map()
+    const parent = new Map()
+    const child = new Map()
 
-  const resolveCurrentId = async (id) => {
-    const ssbClient = await openSsb();
-    const messages = await getAllMessages(ssbClient);
-    const forward = new Map();
     for (const m of messages) {
-      const c = m.value?.content;
-      if (!c) continue;
-      if (c.type === 'transfer' && c.replaces) forward.set(c.replaces, m.key);
+      const k = m.key
+      const v = m.value || {}
+      const c = v.content
+      if (!c) continue
+
+      if (c.type === "tombstone" && c.target) {
+        tomb.add(c.target)
+        continue
+      }
+
+      if (c.type === "transfer") {
+        nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
+        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 tipOf = (id) => {
+      let cur = id
+      while (child.has(cur)) cur = child.get(cur)
+      return cur
     }
-    let cur = id;
-    while (forward.has(cur)) cur = forward.get(cur);
-    return cur;
-  };
 
-  const isValidId = (to) => /^@[A-Za-z0-9+/]+={0,2}\.ed25519$/.test(String(to || ''));
+    const roots = new Set()
+    for (const id of nodes.keys()) roots.add(rootOf(id))
+
+    const tipByRoot = new Map()
+    for (const r of roots) tipByRoot.set(r, tipOf(r))
+
+    return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot }
+  }
+
+  const deriveStatus = (t) => {
+    const status = String(t.status || "").toUpperCase()
+    const from = t.from
+    const to = t.to
+    const required = from === to ? 1 : 2
+    const confirmedCount = Array.isArray(t.confirmedBy) ? t.confirmedBy.length : 0
+
+    const dl = t.deadline ? moment(t.deadline) : null
+    if (status === "UNCONFIRMED" && dl && dl.isValid() && dl.isBefore(moment())) {
+      return confirmedCount >= required ? "CLOSED" : "DISCARDED"
+    }
+    if (status === "CLOSED" || status === "DISCARDED" || status === "UNCONFIRMED") return status
+    return status || "UNCONFIRMED"
+  }
+
+  const buildTransfer = (node) => {
+    const c = node.c || {}
+    return {
+      id: node.key,
+      from: c.from,
+      to: c.to,
+      concept: c.concept,
+      amount: c.amount,
+      createdAt: c.createdAt || new Date(node.ts).toISOString(),
+      updatedAt: c.updatedAt || null,
+      deadline: c.deadline,
+      confirmedBy: Array.isArray(c.confirmedBy) ? c.confirmedBy : [],
+      status: deriveStatus(c),
+      tags: Array.isArray(c.tags) ? c.tags : [],
+      opinions: c.opinions || {},
+      opinions_inhabitants: Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : []
+    }
+  }
 
   return {
-    type: 'transfer',
+    type: "transfer",
+
+    async resolveCurrentId(id) {
+      const ssbClient = await openSsb()
+      const messages = await getAllMessages(ssbClient)
+      const idx = buildIndex(messages)
+
+      let tip = id
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) throw new Error("Not found")
+      return tip
+    },
 
     async createTransfer(to, concept, amount, deadline, tagsRaw = []) {
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
-      if (!isValidId(to)) throw new Error('Invalid recipient ID');
-      const num = typeof amount === 'string' ? parseFloat(amount.replace(',', '.')) : amount;
-      if (isNaN(num) || num <= 0) throw new Error('Amount must be positive');
-      const dl = moment(deadline, moment.ISO_8601, true);
-      if (!dl.isValid() || dl.isBefore(moment())) throw new Error('Deadline must be in the future');
-      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(',').map(t => t.trim()).filter(Boolean);
-      const isSelf = to === userId;
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+
+      if (!isValidId(to)) throw new Error("Invalid recipient ID")
+
+      const num = parseNum(amount)
+      if (!Number.isFinite(num) || num <= 0) throw new Error("Amount must be positive")
+
+      const dl = moment(deadline, moment.ISO_8601, true)
+      if (!dl.isValid() || dl.isBefore(moment())) throw new Error("Deadline must be in the future")
+
+      const tags = normalizeTags(tagsRaw)
+      const isSelf = to === userId
+      const now = new Date().toISOString()
 
       const content = {
-        type: 'transfer',
+        type: "transfer",
         from: userId,
         to,
-        concept,
+        concept: String(concept || ""),
         amount: num.toFixed(6),
-        createdAt: new Date().toISOString(),
+        createdAt: now,
+        updatedAt: now,
         deadline: dl.toISOString(),
-        confirmedBy: isSelf ? [userId, userId] : [userId],
-        status: isSelf ? 'CLOSED' : 'UNCONFIRMED',
+        confirmedBy: [userId],
+        status: isSelf ? "CLOSED" : "UNCONFIRMED",
         tags,
         opinions: {},
         opinions_inhabitants: []
-      };
+      }
 
       return new Promise((resolve, reject) => {
-        ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
-      });
+        ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
+      })
     },
 
     async updateTransferById(id, to, concept, amount, deadline, tagsRaw = []) {
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
-      const tipId = await resolveCurrentId(id);
-
-      const old = await new Promise((res, rej) =>
-        ssbClient.get(tipId, (err, msg) => err || !msg?.content ? rej(err || new Error()) : res(msg))
-      );
-
-      if (old.content.type !== 'transfer') throw new Error('Transfer not found');
-      if (Object.keys(old.content.opinions || {}).length > 0) throw new Error('Cannot edit transfer after it has received opinions.');
-      if (old.content.from !== userId) throw new Error('Not the author');
-      if (old.content.status !== 'UNCONFIRMED') throw new Error('Can only edit unconfirmed');
-      if (!isValidId(to)) throw new Error('Invalid recipient ID');
-
-      const num = typeof amount === 'string' ? parseFloat(amount.replace(',', '.')) : amount;
-      if (isNaN(num) || num <= 0) throw new Error('Amount must be positive');
-      const dl = moment(deadline, moment.ISO_8601, true);
-      if (!dl.isValid() || dl.isBefore(moment())) throw new Error('Deadline must be in the future');
-      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(',').map(t => t.trim()).filter(Boolean);
-      const isSelf = to === userId;
-
-      const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
-      await new Promise((res, rej) => ssbClient.publish(tombstone, err => err ? rej(err) : res()));
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const tipId = await this.resolveCurrentId(id)
+      const old = await getMsg(ssbClient, tipId)
+
+      if (!old?.content || old.content.type !== "transfer") throw new Error("Transfer not found")
+
+      const current = old.content
+      const currentStatus = deriveStatus(current)
+
+      if (Object.keys(current.opinions || {}).length > 0) throw new Error("Cannot edit transfer after it has received opinions.")
+      if (current.from !== userId) throw new Error("Not the author")
+      if (currentStatus !== "UNCONFIRMED") throw new Error("Can only edit unconfirmed")
+
+      const dlOld = current.deadline ? moment(current.deadline) : null
+      if (dlOld && dlOld.isValid() && dlOld.isBefore(moment())) throw new Error("Cannot edit expired")
+
+      if (!isValidId(to)) throw new Error("Invalid recipient ID")
+
+      const num = parseNum(amount)
+      if (!Number.isFinite(num) || num <= 0) throw new Error("Amount must be positive")
+
+      const dl = moment(deadline, moment.ISO_8601, true)
+      if (!dl.isValid() || dl.isBefore(moment())) throw new Error("Deadline must be in the future")
+
+      const tags = normalizeTags(tagsRaw)
+      const isSelf = to === userId
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (err) => err ? rej(err) : res()))
 
       const updated = {
-        type: 'transfer',
+        type: "transfer",
         from: userId,
         to,
-        concept,
+        concept: String(concept || ""),
         amount: num.toFixed(6),
-        createdAt: old.content.createdAt,
+        createdAt: current.createdAt,
         deadline: dl.toISOString(),
-        confirmedBy: isSelf ? [userId, userId] : [userId],
-        status: isSelf ? 'CLOSED' : 'UNCONFIRMED',
+        confirmedBy: [userId],
+        status: isSelf ? "CLOSED" : "UNCONFIRMED",
         tags,
         opinions: {},
         opinions_inhabitants: [],
         updatedAt: new Date().toISOString(),
         replaces: tipId
-      };
+      }
 
       return new Promise((resolve, reject) => {
-        ssbClient.publish(updated, (err, msg) => err ? reject(err) : resolve(msg));
-      });
+        ssbClient.publish(updated, (err, msg) => err ? reject(err) : resolve(msg))
+      })
     },
 
     async confirmTransferById(id) {
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
-      const tipId = await resolveCurrentId(id);
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const tipId = await this.resolveCurrentId(id)
+      const msg = await getMsg(ssbClient, tipId)
 
-      return new Promise((resolve, reject) => {
-        ssbClient.get(tipId, async (err, msg) => {
-          if (err || !msg?.content || msg.content.type !== 'transfer') return reject(new Error('Not found'));
-          const t = msg.content;
-          if (t.status !== 'UNCONFIRMED') return reject(new Error('Not unconfirmed'));
-          if (t.to !== userId) return reject(new Error('Not the recipient'));
-
-          const newConfirmed = [...(t.confirmedBy || []), userId].filter((v, i, a) => a.indexOf(v) === i);
-          const newStatus = newConfirmed.length >= 2 ? 'CLOSED' : 'UNCONFIRMED';
-
-          const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
-          await new Promise((res, rej) => ssbClient.publish(tombstone, e => e ? rej(e) : res()));
-
-          const upd = { ...t, confirmedBy: newConfirmed, status: newStatus, updatedAt: new Date().toISOString(), replaces: tipId };
-          ssbClient.publish(upd, (e2, result) => e2 ? reject(e2) : resolve(result));
-        });
-      });
-    },
+      if (!msg?.content || msg.content.type !== "transfer") throw new Error("Not found")
 
-    async deleteTransferById(id) {
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
-      const tipId = await resolveCurrentId(id);
+      const t = msg.content
+      const status = deriveStatus(t)
+      if (status !== "UNCONFIRMED") throw new Error("Not unconfirmed")
+      if (t.to !== userId) throw new Error("Not the recipient")
+
+      const dl = t.deadline ? moment(t.deadline) : null
+      if (dl && dl.isValid() && dl.isBefore(moment())) throw new Error("Expired")
+
+      const existing = Array.isArray(t.confirmedBy) ? t.confirmedBy : []
+      if (existing.includes(userId)) throw new Error("Already confirmed")
+
+      const required = t.from === t.to ? 1 : 2
+      const newConfirmed = existing.concat(userId).filter((v, i, a) => a.indexOf(v) === i)
+      const newStatus = newConfirmed.length >= required ? "CLOSED" : "UNCONFIRMED"
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => e ? rej(e) : res()))
+
+      const upd = {
+        ...t,
+        confirmedBy: newConfirmed,
+        status: newStatus,
+        updatedAt: new Date().toISOString(),
+        replaces: tipId
+      }
 
       return new Promise((resolve, reject) => {
-        ssbClient.get(tipId, (err, msg) => {
-          if (err || !msg?.content || msg.content.type !== 'transfer') return reject(new Error('Not found'));
-          const t = msg.content;
-          if (t.from !== userId) return reject(new Error('Not the author'));
-          if (t.status !== 'UNCONFIRMED' || (t.confirmedBy || []).length >= 2) return reject(new Error('Not editable'));
-
-          const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
-          ssbClient.publish(tombstone, err2 => err2 ? reject(err2) : resolve());
-        });
-      });
+        ssbClient.publish(upd, (e2, result) => e2 ? reject(e2) : resolve(result))
+      })
     },
 
-    async listAll(filter = 'all') {
-      const ssbClient = await openSsb();
-      const messages = await getAllMessages(ssbClient);
-
-      const tombstoned = new Set();
-      const replaces = new Map();
-      const latest = new Map();
+    async deleteTransferById(id) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const tipId = await this.resolveCurrentId(id)
+      const msg = await getMsg(ssbClient, tipId)
 
-      for (const m of messages) {
-        const c = m.value?.content;
-        const k = m.key;
-        if (!c) continue;
+      if (!msg?.content || msg.content.type !== "transfer") throw new Error("Not found")
 
-        if (c.type === 'tombstone') {
-          const tgt = c.target || c.id;
-          if (tgt) tombstoned.add(tgt);
-          continue;
-        }
+      const t = msg.content
+      const st = deriveStatus(t)
+      const confirmedCount = Array.isArray(t.confirmedBy) ? t.confirmedBy.length : 0
+      const required = t.from === t.to ? 1 : 2
 
-        if (c.type !== 'transfer') continue;
-
-        if (c.replaces) replaces.set(c.replaces, k);
-        latest.set(k, {
-          id: k,
-          from: c.from,
-          to: c.to,
-          concept: c.concept,
-          amount: c.amount,
-          createdAt: c.createdAt,
-          deadline: c.deadline,
-          confirmedBy: c.confirmedBy || [],
-          status: c.status,
-          tags: c.tags || [],
-          opinions: c.opinions || {},
-          opinions_inhabitants: c.opinions_inhabitants || []
-        });
-      }
+      if (t.from !== userId) throw new Error("Not the author")
+      if (st !== "UNCONFIRMED") throw new Error("Not editable")
+      if (confirmedCount >= required) throw new Error("Not editable")
 
-      for (const oldId of replaces.keys()) latest.delete(oldId);
-      for (const delId of tombstoned.values()) latest.delete(delId);
+      const dl = t.deadline ? moment(t.deadline) : null
+      if (dl && dl.isValid() && dl.isBefore(moment())) throw new Error("Cannot delete expired")
 
-      const now = moment();
-      const out = Array.from(latest.values());
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(tombstone, (err) => err ? reject(err) : resolve())
+      })
+    },
 
-      for (const item of out) {
-        const dl = moment(item.deadline);
-        if (item.status === 'UNCONFIRMED' && dl.isValid() && dl.isBefore(now)) {
-          item.status = (item.confirmedBy || []).length >= 2 ? 'CLOSED' : 'DISCARDED';
-        }
+    async listAll(filter = "all") {
+      const ssbClient = await openSsb()
+      const messages = await getAllMessages(ssbClient)
+      const idx = buildIndex(messages)
+
+      const out = []
+      for (const tipId of idx.tipByRoot.values()) {
+        if (idx.tomb.has(tipId)) continue
+        const node = idx.nodes.get(tipId)
+        if (!node) continue
+        out.push(buildTransfer(node))
       }
-
-      return out;
+      return out
     },
 
     async getTransferById(id) {
-      const ssbClient = await openSsb();
-      const tipId = await resolveCurrentId(id);
+      const ssbClient = await openSsb()
+      const messages = await getAllMessages(ssbClient)
+      const idx = buildIndex(messages)
 
-      return new Promise((resolve, reject) => {
-        ssbClient.get(tipId, (err, msg) => {
-          if (err || !msg?.content || msg.content.type !== 'transfer') return reject(new Error('Not found'));
-          const c = msg.content;
-          resolve({
-            id: tipId,
-            from: c.from,
-            to: c.to,
-            concept: c.concept,
-            amount: c.amount,
-            createdAt: c.createdAt,
-            deadline: c.deadline,
-            confirmedBy: c.confirmedBy || [],
-            status: c.status,
-            tags: c.tags || [],
-            opinions: c.opinions || {},
-            opinions_inhabitants: c.opinions_inhabitants || []
-          });
-        });
-      });
+      let tip = id
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) throw new Error("Not found")
+
+      const node = idx.nodes.get(tip)
+      if (node) return buildTransfer(node)
+
+      const msg = await getMsg(ssbClient, tip)
+      if (!msg?.content || msg.content.type !== "transfer") throw new Error("Not found")
+
+      const tmpNode = { key: tip, ts: msg.timestamp || 0, c: msg.content, author: msg.author }
+      return buildTransfer(tmpNode)
     },
 
     async createOpinion(id, category) {
-      if (!categories.includes(category)) throw new Error('Invalid voting category');
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
-      const tipId = await resolveCurrentId(id);
+      if (!categories.includes(category)) throw new Error("Invalid voting category")
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const tipId = await this.resolveCurrentId(id)
+      const msg = await getMsg(ssbClient, tipId)
+
+      if (!msg?.content || msg.content.type !== "transfer") throw new Error("Transfer not found")
+
+      const t = msg.content
+      const voters = Array.isArray(t.opinions_inhabitants) ? t.opinions_inhabitants : []
+      if (voters.includes(userId)) throw new Error("Already voted")
+
+      const updated = {
+        ...t,
+        opinions: {
+          ...(t.opinions || {}),
+          [category]: ((t.opinions || {})[category] || 0) + 1
+        },
+        opinions_inhabitants: voters.concat(userId),
+        updatedAt: new Date().toISOString(),
+        replaces: tipId
+      }
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => e ? rej(e) : res()))
 
       return new Promise((resolve, reject) => {
-        ssbClient.get(tipId, async (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'transfer') return reject(new Error('Transfer not found'));
-          if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'));
-
-          const updated = {
-            ...msg.content,
-            opinions: {
-              ...msg.content.opinions,
-              [category]: (msg.content.opinions?.[category] || 0) + 1
-            },
-            opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
-            updatedAt: new Date().toISOString(),
-            replaces: tipId
-          };
-
-          const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
-          await new Promise((res, rej) => ssbClient.publish(tombstone, e => e ? rej(e) : res()));
-
-          ssbClient.publish(updated, (e2, result) => e2 ? reject(e2) : resolve(result));
-        });
-      });
+        ssbClient.publish(updated, (e2, result) => e2 ? reject(e2) : resolve(result))
+      })
     }
-  };
-};
+  }
+}
 

+ 272 - 132
src/models/videos_model.js

@@ -1,185 +1,325 @@
-const pull = require('../server/node_modules/pull-stream');
-const { getConfig } = require('../configs/config-manager.js');
-const categories = require('../backend/opinion_categories');
+const pull = require("../server/node_modules/pull-stream");
+const { getConfig } = require("../configs/config-manager.js");
+const categories = require("../backend/opinion_categories");
+
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+
+const normalizeTags = (raw) => {
+  if (raw === undefined || raw === null) return undefined;
+  if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
+  return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
+};
+
+const parseBlobId = (blobMarkdown) => {
+  const s = String(blobMarkdown || "");
+  const match = s.match(/\(([^)]+)\)/);
+  return match ? match[1] : s || null;
+};
+
+const voteSum = (opinions = {}) =>
+  Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
+
 module.exports = ({ cooler }) => {
   let ssb;
+
   const openSsb = async () => {
     if (!ssb) ssb = await cooler.open();
     return ssb;
   };
 
+  const getAllMessages = async (ssbClient) =>
+    new Promise((resolve, reject) => {
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
+      );
+    });
+
+  const getMsg = async (ssbClient, key) =>
+    new Promise((resolve, reject) => {
+      ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
+    });
+
+  const buildIndex = (messages) => {
+    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 v = m.value || {};
+      const c = v.content;
+      if (!c) continue;
+
+      if (c.type === "tombstone" && c.target) {
+        tomb.add(c.target);
+        continue;
+      }
+
+      if (c.type !== "video") continue;
+
+      const ts = v.timestamp || m.timestamp || 0;
+      nodes.set(k, { key: k, ts, 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 tipOf = (id) => {
+      let cur = id;
+      while (child.has(cur)) cur = child.get(cur);
+      return cur;
+    };
+
+    const roots = new Set();
+    for (const id of nodes.keys()) roots.add(rootOf(id));
+
+    const tipByRoot = new Map();
+    for (const r of roots) tipByRoot.set(r, tipOf(r));
+
+    const forward = new Map();
+    for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
+
+    return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
+  };
+
+  const buildVideo = (node, rootId, viewerId) => {
+    const c = node.c || {};
+    const voters = safeArr(c.opinions_inhabitants);
+    return {
+      key: node.key,
+      rootId,
+      url: c.url,
+      createdAt: c.createdAt || new Date(node.ts).toISOString(),
+      updatedAt: c.updatedAt || null,
+      tags: safeArr(c.tags),
+      author: c.author,
+      title: c.title || "",
+      description: c.description || "",
+      opinions: c.opinions || {},
+      opinions_inhabitants: voters,
+      hasVoted: viewerId ? voters.includes(viewerId) : false
+    };
+  };
+
   return {
+    type: "video",
+
+    async resolveCurrentId(id) {
+      const ssbClient = await openSsb();
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Video not found");
+      return tip;
+    },
+
+    async resolveRootId(id) {
+      const ssbClient = await openSsb();
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Video not found");
+
+      let root = tip;
+      while (idx.parent.has(root)) root = idx.parent.get(root);
+      return root;
+    },
+
     async createVideo(blobMarkdown, tagsRaw, title, description) {
       const ssbClient = await openSsb();
-      const userId = ssbClient.id;
-      const match = blobMarkdown?.match(/\(([^)]+)\)/);
-      const blobId = match ? match[1] : blobMarkdown;
-      const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : [];
+      const blobId = parseBlobId(blobMarkdown);
+      const tags = normalizeTags(tagsRaw) || [];
+      const now = new Date().toISOString();
+
       const content = {
-        type: 'video',
+        type: "video",
         url: blobId,
-        createdAt: new Date().toISOString(),
-        author: userId,
+        createdAt: now,
+        updatedAt: now,
+        author: ssbClient.id,
         tags,
-        title: title || '',
-        description: description || '',
+        title: title || "",
+        description: description || "",
         opinions: {},
         opinions_inhabitants: []
       };
+
       return new Promise((resolve, reject) => {
-        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res));
+        ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
       });
     },
 
     async updateVideoById(id, blobMarkdown, tagsRaw, title, description) {
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(id);
+      const oldMsg = await getMsg(ssbClient, tipId);
+
+      if (!oldMsg || oldMsg.content?.type !== "video") throw new Error("Video not found");
+      if (Object.keys(oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit video after it has received opinions.");
+      if (oldMsg.content.author !== userId) throw new Error("Not the author");
+
+      const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
+      const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
+      const now = new Date().toISOString();
+
+      const updated = {
+        ...oldMsg.content,
+        replaces: tipId,
+        url: blobId || oldMsg.content.url,
+        tags,
+        title: title !== undefined ? title || "" : oldMsg.content.title || "",
+        description: description !== undefined ? description || "" : oldMsg.content.description || "",
+        createdAt: oldMsg.content.createdAt,
+        updatedAt: now
+      };
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, oldMsg) => {
-          if (err || !oldMsg || oldMsg.content?.type !== 'video') return reject(new Error('Video not found'));
-          if (Object.keys(oldMsg.content.opinions || {}).length > 0) return reject(new Error('Cannot edit video after it has received opinions.'));
-          if (oldMsg.content.author !== userId) return reject(new Error('Not the author'));
-          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 tombstone = {
-            type: 'tombstone',
-            target: id,
-            deletedAt: new Date().toISOString(),
-            author: userId
-          };
-          const updated = {
-            ...oldMsg.content,
-            url: blobId || oldMsg.content.url,
-            tags,
-            title: title || '',
-            description: description || '',
-            updatedAt: new Date().toISOString(),
-            replaces: id
-          };
-          ssbClient.publish(tombstone, err1 => {
-            if (err1) return reject(err1);
-            ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
-          });
-        });
+        ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
       });
     },
 
     async deleteVideoById(id) {
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(id);
+      const msg = await getMsg(ssbClient, tipId);
+
+      if (!msg || msg.content?.type !== "video") throw new Error("Video not found");
+      if (msg.content.author !== userId) throw new Error("Not the author");
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'video') return reject(new Error('Video 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));
-        });
+        ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res)));
       });
     },
 
-    async listAll(filter = 'all') {
+    async listAll(filterOrOpts = "all", maybeOpts = {}) {
       const ssbClient = await openSsb();
-      const userId = ssbClient.id;
-      const messages = await new Promise((res, rej) => {
-        pull(
-          ssbClient.createLogStream({ limit: logLimit }),
-          pull.collect((err, msgs) => err ? rej(err) : res(msgs))
-        );
-      });
-      const tombstoned = new Set();
-      const replaces = new Map();
-      const videos = 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) {
-          tombstoned.add(c.target);
-          continue;
-        }
-        if (c.type !== 'video') continue;
-        if (c.replaces) replaces.set(c.replaces, k);
-        videos.set(k, {
-          key: k,
-          url: c.url,
-          createdAt: c.createdAt,
-          updatedAt: c.updatedAt || null,
-          tags: c.tags || [],
-          author: c.author,
-          title: c.title || '',
-          description: c.description || '',
-          opinions: c.opinions || {},
-          opinions_inhabitants: c.opinions_inhabitants || []
-        });
+
+      const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
+      const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
+      const q = String(opts.q || "").trim().toLowerCase();
+      const sort = String(opts.sort || "recent").trim();
+      const viewerId = opts.viewerId || ssbClient.id;
+
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      const items = [];
+      for (const [rootId, tipId] of idx.tipByRoot.entries()) {
+        if (idx.tomb.has(tipId)) continue;
+        const node = idx.nodes.get(tipId);
+        if (!node) continue;
+        items.push(buildVideo(node, rootId, viewerId));
+      }
+
+      let list = items;
+      const now = Date.now();
+
+      if (filter === "mine") list = list.filter((v) => String(v.author) === String(viewerId));
+      else if (filter === "recent") list = list.filter((v) => new Date(v.createdAt).getTime() >= now - 86400000);
+      else if (filter === "top") {
+        list = list
+          .slice()
+          .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
       }
-      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);
-      } else if (filter === 'recent') {
-        const now = Date.now();
-        out = out.filter(v => new Date(v.createdAt).getTime() >= now - 86400000);
-      } else if (filter === 'top') {
-        out = out.sort((a, b) => {
-          const sum = o => Object.values(o || {}).reduce((s, n) => s + (n || 0), 0);
-          return sum(b.opinions) - sum(a.opinions);
+
+      if (q) {
+        list = list.filter((v) => {
+          const t = String(v.title || "").toLowerCase();
+          const d = String(v.description || "").toLowerCase();
+          const tags = safeArr(v.tags).join(" ").toLowerCase();
+          const a = String(v.author || "").toLowerCase();
+          return t.includes(q) || d.includes(q) || tags.includes(q) || a.includes(q);
         });
+      }
+
+      if (sort === "top") {
+        list = list
+          .slice()
+          .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
+      } else if (sort === "oldest") {
+        list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
       } else {
-        out = out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+        list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
       }
-      return out;
+
+      return list;
     },
 
-    async getVideoById(id) {
+    async getVideoById(id, viewerId = null) {
       const ssbClient = await openSsb();
-      return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'video') return reject(new Error('Video not found'));
-          resolve({
-            key: id,
-            url: msg.content.url,
-            createdAt: msg.content.createdAt,
-            updatedAt: msg.content.updatedAt || null,
-            tags: msg.content.tags || [],
-            author: msg.content.author,
-            title: msg.content.title || '',
-            description: msg.content.description || '',
-            opinions: msg.content.opinions || {},
-            opinions_inhabitants: msg.content.opinions_inhabitants || []
-          });
-        });
-      });
+      const viewer = viewerId || ssbClient.id;
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Video not found");
+
+      let root = tip;
+      while (idx.parent.has(root)) root = idx.parent.get(root);
+
+      const node = idx.nodes.get(tip);
+      if (node) return buildVideo(node, root, viewer);
+
+      const msg = await getMsg(ssbClient, tip);
+      if (!msg || msg.content?.type !== "video") throw new Error("Video not found");
+      return buildVideo({ key: tip, ts: msg.timestamp || 0, c: msg.content }, root, viewer);
     },
 
     async createOpinion(id, category) {
-      if (!categories.includes(category)) return Promise.reject(new Error('Invalid voting category'));
+      if (!categories.includes(category)) throw new Error("Invalid voting category");
+
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
+
+      const tipId = await this.resolveCurrentId(id);
+      const msg = await getMsg(ssbClient, tipId);
+
+      if (!msg || msg.content?.type !== "video") throw new Error("Video not found");
+
+      const voters = safeArr(msg.content.opinions_inhabitants);
+      if (voters.includes(userId)) throw new Error("Already voted");
+
+      const now = new Date().toISOString();
+      const updated = {
+        ...msg.content,
+        replaces: tipId,
+        opinions: {
+          ...msg.content.opinions,
+          [category]: (msg.content.opinions?.[category] || 0) + 1
+        },
+        opinions_inhabitants: voters.concat(userId),
+        updatedAt: now
+      };
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+
       return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'video') return reject(new Error('Video not found'));
-          if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'));
-          const updated = {
-            ...msg.content,
-            replaces: id,
-            opinions: {
-              ...msg.content.opinions,
-              [category]: (msg.content.opinions?.[category] || 0) + 1
-            },
-            opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
-            updatedAt: new Date().toISOString()
-          };
-          ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
-        });
+        ssbClient.publish(updated, (err2, result) => (err2 ? reject(err2) : resolve(result)));
       });
     }
   };

+ 9 - 4
src/models/votes_model.js

@@ -1,7 +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 categories = require('../backend/opinion_categories')
+const categories = require('../backend/opinion_categories');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
@@ -33,17 +33,22 @@ module.exports = ({ cooler }) => {
       const v = m.value;
       const c = v && v.content;
       if (!c) continue;
+
       if (c.type === 'tombstone' && c.target) {
         tombstoned.add(c.target);
         continue;
       }
+
       if (c.type !== TYPE) continue;
+
       const node = {
         key,
         ts: v.timestamp || m.timestamp || 0,
         content: c
       };
+
       votes.set(key, node);
+
       if (c.replaces) {
         replaced.set(c.replaces, key);
         parent.set(key, c.replaces);
@@ -147,7 +152,7 @@ module.exports = ({ cooler }) => {
 
       const tags = Array.isArray(tagsRaw)
         ? tagsRaw.filter(Boolean)
-        : String(tagsRaw).split(',').map(t => t.trim()).filter(Boolean);
+        : String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean);
 
       const content = {
         type: TYPE,
@@ -206,7 +211,7 @@ module.exports = ({ cooler }) => {
       const c = oldMsg.content;
       if (!c || c.type !== TYPE) throw new Error('Invalid type');
       if (c.createdBy !== userId) throw new Error('Not the author');
-      if (Object.keys(c.opinions || {}).length > 0) throw new Error('Cannot edit vote after it has received opinions.')
+      if (Object.keys(c.opinions || {}).length > 0) throw new Error('Cannot edit vote after it has received opinions.');
 
       let newDeadline = c.deadline;
       if (deadline != null && deadline !== '') {
@@ -364,7 +369,7 @@ module.exports = ({ cooler }) => {
     },
 
     async createOpinion(id, category) {
-      if (!categories.includes(category)) throw new Error('Invalid voting category')
+      if (!categories.includes(category)) throw new Error('Invalid voting category');
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       const tipId = await resolveCurrentId(id);

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

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

+ 1 - 1
src/server/package.json

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

+ 386 - 221
src/views/audio_view.js

@@ -1,292 +1,457 @@
-const { form, button, div, h2, p, section, input, label, br, a, audio: audioHyperaxe, span, textarea } = require("../server/node_modules/hyperaxe");
-const { template, i18n } = require('./main_views');
+const {
+  form,
+  button,
+  div,
+  h2,
+  p,
+  section,
+  input,
+  br,
+  a,
+  audio: audioHyperaxe,
+  span,
+  textarea,
+  select,
+  option
+} = 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 opinionCategories = require('../backend/opinion_categories');
+const { config } = require("../server/SSB_server.js");
+const { renderUrl } = require("../backend/renderUrl");
+const opinionCategories = require("../backend/opinion_categories");
+
+const userId = config.keys.id;
+
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+const safeText = (v) => String(v || "").trim();
+
+const buildReturnTo = (filter, params = {}) => {
+  const f = safeText(filter || "all");
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+  const parts = [`filter=${encodeURIComponent(f)}`];
+  if (q) parts.push(`q=${encodeURIComponent(q)}`);
+  if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
+  return `/audios?${parts.join("&")}`;
+};
+
+const renderTags = (tags) => {
+  const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
+  return list.length
+    ? div(
+        { class: "card-tags" },
+        list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
+      )
+    : null;
+};
+
+const renderAudioFavoriteToggle = (audioObj, returnTo = "") =>
+  form(
+    {
+      method: "POST",
+      action: audioObj.isFavorite
+        ? `/audios/favorites/remove/${encodeURIComponent(audioObj.key)}`
+        : `/audios/favorites/add/${encodeURIComponent(audioObj.key)}`
+    },
+    returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
+    button(
+      { type: "submit", class: "filter-btn" },
+      audioObj.isFavorite ? i18n.audioRemoveFavoriteButton : i18n.audioAddFavoriteButton
+    )
+  );
+
+const renderAudioPlayer = (audioObj) =>
+  audioObj?.url
+    ? div(
+        { class: "audio-container", style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
+        audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(audioObj.url)}`, preload: "metadata" })
+      )
+    : p(i18n.audioNoFile);
 
-const userId = config.keys.id
+const renderAudioOwnerActions = (filter, audioObj, params = {}) => {
+  const returnTo = buildReturnTo(filter, params);
+  const isAuthor = String(audioObj.author) === String(userId);
+  const hasOpinions = Object.keys(audioObj.opinions || {}).length > 0;
 
-const getFilteredAudios = (filter, audios, userId) => {
-  const now = Date.now();
-  let filtered =
-    filter === 'mine' ? audios.filter(a => a.author === userId) :
-    filter === 'recent' ? audios.filter(a => new Date(a.createdAt).getTime() >= now - 86400000) :
-    filter === 'top' ? [...audios].sort((a, b) => {
-      const sumA = Object.values(a.opinions || {}).reduce((s, n) => s + (n || 0), 0);
-      const sumB = Object.values(b.opinions || {}).reduce((s, n) => s + (n || 0), 0);
-      return sumB - sumA;
-    }) :
-    audios;
+  if (!isAuthor) return [];
 
-  return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+  const items = [];
+  if (!hasOpinions) {
+    items.push(
+      form(
+        { method: "GET", action: `/audios/edit/${encodeURIComponent(audioObj.key)}` },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
+        button({ class: "update-btn", type: "submit" }, i18n.audioUpdateButton)
+      )
+    );
+  }
+  items.push(
+    form(
+      { method: "POST", action: `/audios/delete/${encodeURIComponent(audioObj.key)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ class: "delete-btn", type: "submit" }, i18n.audioDeleteButton)
+    )
+  );
+
+  return items;
 };
 
-const renderAudioCommentsSection = (audioId, comments = []) => {
-  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) => {
+  const list = safeArr(comments);
+  const commentsCount = list.length;
 
-  return div({ class: 'vote-comments-section' },
-    div({ class: 'comments-count' },
-      span({ class: 'card-label' }, i18n.voteCommentsLabel + ': '),
-      span({ class: 'card-value' }, String(commentsCount))
+  return div(
+    { class: "vote-comments-section" },
+    div(
+      { class: "comments-count" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
+      span({ class: "card-value" }, String(commentsCount))
     ),
-    div({ class: 'comment-form-wrapper' },
-      h2({ class: 'comment-form-title' }, i18n.voteNewCommentLabel),
-      form({
-        method: 'POST',
-        action: `/audios/${encodeURIComponent(audioId)}/comments`,
-        class: 'comment-form'
-      },
+    div(
+      { class: "comment-form-wrapper" },
+      h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
+      form(
+        { method: "POST", action: `/audios/${encodeURIComponent(audioId)}/comments`, class: "comment-form" },
+        returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
         textarea({
-          id: 'comment-text',
-          name: 'text',
+          id: "comment-text",
+          name: "text",
           required: true,
           rows: 4,
-          class: 'comment-textarea',
+          class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         br(),
-        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+        button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
     ),
-    comments && comments.length
-      ? div({ class: 'comments-list' },
-          comments.map(c => {
-            const author = c.value && c.value.author ? c.value.author : '';
-            const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
-            const absDate = ts ? moment(ts).format('YYYY/MM/DD HH:mm:ss') : '';
-            const relDate = ts ? moment(ts).fromNow() : '';
-            const userName = author && author.includes('@') ? author.split('@')[1] : author;
+    list.length
+      ? div(
+          { class: "comments-list" },
+          list.map((c) => {
+            const author = c?.value?.author || "";
+            const ts = c?.value?.timestamp || c?.timestamp;
+            const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
+            const relDate = ts ? moment(ts).fromNow() : "";
+            const userName = author && author.includes("@") ? author.split("@")[1] : author;
+            const content = c?.value?.content || {};
+            const rootId = content.fork || content.root || null;
+            const text = content.text || "";
 
-            return div({ class: 'votations-comment-card' },
-              span({ class: 'created-at' },
+            return div(
+              { class: "votations-comment-card" },
+              span(
+                { class: "created-at" },
                 span(i18n.createdBy),
-                author
-                  ? a(
-                      { href: `/author/${encodeURIComponent(author)}` },
-                      `@${userName}`
-                    )
-                  : span('(unknown)'),
-                absDate ? span(' | ') : '',
-                absDate ? span({ class: 'votations-comment-date' }, absDate) : '',
-                relDate ? span({ class: 'votations-comment-date' }, ' | ', i18n.sendTime) : '',
-                relDate
-                  ? a(
-                      {
-                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
-                      },
-                      relDate
-                    )
-                  : ''
+                author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
+                absDate ? span(" | ") : "",
+                absDate ? span({ class: "votations-comment-date" }, absDate) : "",
+                relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
+                relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
               ),
-              p({
-                class: 'votations-comment-text',
-                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
-              })
+              p({ class: "votations-comment-text" }, ...renderUrl(text))
             );
           })
         )
-      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+      : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
   );
 };
 
-const renderCardField = (labelText, valueText) =>
-  div({ class: "card-field" },
-    span({ class: "card-label" }, labelText),
-    span({ class: "card-value" }, valueText)
-  );
-
-const renderAudioActions = (filter, audio) => {
-  return filter === 'mine' ? div({ class: "audio-actions" },
-    form({ method: "GET", action: `/audios/edit/${encodeURIComponent(audio.key)}` },
-      button({ class: "update-btn", type: "submit" }, i18n.audioUpdateButton)
-    ),
-    form({ method: "POST", action: `/audios/delete/${encodeURIComponent(audio.key)}` },
-      button({ class: "delete-btn", type: "submit" }, i18n.audioDeleteButton)
-    )
-  ) : null;
-};
+const renderAudioList = (audios, filter, params = {}) => {
+  const returnTo = buildReturnTo(filter, params);
 
-const renderAudioList = (filteredAudios, filter) => {
-  return filteredAudios.length > 0
-    ? filteredAudios.map(audio => {
-        const commentCount = typeof audio.commentCount === 'number' ? audio.commentCount : 0;
+  return audios.length
+    ? audios.map((audioObj) => {
+        const commentCount = typeof audioObj.commentCount === "number" ? audioObj.commentCount : 0;
+        const title = safeText(audioObj.title);
+        const ownerActions = renderAudioOwnerActions(filter, audioObj, params);
 
-        return div({ class: "audio-item card" },
-          br,
-          renderAudioActions(filter, audio),
-          form({ method: "GET", action: `/audios/${encodeURIComponent(audio.key)}` },
-            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        return div(
+          { class: "tags-header audio-card" },
+          div(
+            { class: "bookmark-topbar" },
+            div(
+              { class: "bookmark-topbar-left" },
+              form(
+                { method: "GET", action: `/audios/${encodeURIComponent(audioObj.key)}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
+                input({ type: "hidden", name: "filter", value: filter || "all" }),
+                params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
+                params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
+                button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+              ),
+              renderAudioFavoriteToggle(audioObj, returnTo),
+              audioObj.author && String(audioObj.author) !== String(userId)
+                ? form(
+                    { method: "GET", action: "/pm" },
+                    input({ type: "hidden", name: "recipients", value: audioObj.author }),
+                    button({ type: "submit", class: "filter-btn" }, i18n.audioMessageAuthorButton)
+                  )
+                : null
+            ),
+            ownerActions.length ? div({ class: "bookmark-actions" }, ...ownerActions) : null
           ),
-          audio.title?.trim() ? h2(audio.title) : null,
-          audio.url
-            ? div({ class: "audio-container" },
-                audioHyperaxe({
-                  controls: true,
-                  src: `/blob/${encodeURIComponent(audio.url)}`,
-                  type: audio.mimeType,
-                  preload: 'metadata'
-                })
-              )
-            : p(i18n.audioNoFile),
-          p(...renderUrl(audio.description)),
-          audio.tags?.length
-            ? div({ class: "card-tags" },
-                audio.tags.map(tag =>
-                  a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
-                )
-              )
-            : null,
-          div({ class: 'card-comments-summary' },
-            span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
-            span({ class: 'card-value' }, String(commentCount)),
-            br, br,
-            form({ method: 'GET', action: `/audios/${encodeURIComponent(audio.key)}` },
-              button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+          title ? h2(title) : null,
+          renderAudioPlayer(audioObj),
+          safeText(audioObj.description) ? p(...renderUrl(audioObj.description)) : null,
+          renderTags(audioObj.tags),
+          div(
+            { class: "card-comments-summary" },
+            span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
+            span({ class: "card-value" }, String(commentCount)),
+            br(),
+            br(),
+            form(
+              { method: "GET", action: `/audios/${encodeURIComponent(audioObj.key)}` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              input({ type: "hidden", name: "filter", value: filter || "all" }),
+              params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
+              params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
+              button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
             )
           ),
-          br,
-          p({ class: 'card-footer' },
-            span({ class: 'date-link' }, `${moment(audio.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-            a({ href: `/author/${encodeURIComponent(audio.author)}`, class: 'user-link' }, `${audio.author}`)
-          ),
-          div({ class: "voting-buttons" },
-            opinionCategories.map(category =>
-              form({ method: "POST", action: `/audios/opinions/${encodeURIComponent(audio.key)}/${category}` },
-                button({ class: "vote-btn" },
-                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${audio.opinions?.[category] || 0}]`
+          br(),
+          (() => {
+            const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN;
+            const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN;
+            const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
+
+            return p(
+              { class: "card-footer" },
+              span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+              a({ href: `/author/${encodeURIComponent(audioObj.author)}`, class: "user-link" }, `${audioObj.author}`),
+              showUpdated
+                ? span(
+                    { class: "votations-comment-date" },
+                    ` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
+                  )
+                : null
+            );
+          })(),
+          div(
+            { class: "voting-buttons" },
+            opinionCategories.map((category) =>
+              form(
+                { method: "POST", action: `/audios/opinions/${encodeURIComponent(audioObj.key)}/${category}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
+                button(
+                  { class: "vote-btn" },
+                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
+                    audioObj.opinions?.[category] || 0
+                  }]`
                 )
               )
             )
           )
         );
       })
-    : div(i18n.noAudios);
+    : p(params.q ? i18n.audioNoMatch : i18n.noAudios);
 };
 
-const renderAudioForm = (filter, audioId, audioToEdit) => {
-  return div({ class: "div-center audio-form" },
-    form({
-      action: filter === 'edit' ? `/audios/update/${encodeURIComponent(audioId)}` : "/audios/create",
-      method: "POST", enctype: "multipart/form-data"
-    },
-      label(i18n.audioFileLabel), br(),
-      input({ type: "file", name: "audio", required: filter !== "edit" }), br(), br(),
-      label(i18n.audioTagsLabel), br(),
-      input({ type: "text", name: "tags", placeholder: i18n.audioTagsPlaceholder, value: audioToEdit?.tags?.join(', ') || '' }), br(), br(),
-      label(i18n.audioTitleLabel), br(),
-      input({ type: "text", name: "title", placeholder: i18n.audioTitlePlaceholder, value: audioToEdit?.title || '' }), br(), br(),
-      label(i18n.audioDescriptionLabel), br(),
-      textarea({ name: "description", placeholder: i18n.audioDescriptionPlaceholder, rows: "4" }, audioToEdit?.description || ''), br(), br(),
-      button({ type: "submit" }, filter === 'edit' ? i18n.audioUpdateButton : i18n.audioCreateButton)
+const renderAudioForm = (filter, audioId, audioToEdit, params = {}) => {
+  const returnTo = safeText(params.returnTo) || buildReturnTo("all", params);
+  return div(
+    { class: "div-center audio-form" },
+    form(
+      {
+        action: filter === "edit" ? `/audios/update/${encodeURIComponent(audioId)}` : "/audios/create",
+        method: "POST",
+        enctype: "multipart/form-data"
+      },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      span(i18n.audioFileLabel),
+      br(),
+      input({ type: "file", name: "audio", required: filter !== "edit" }),
+      br(),
+      br(),
+      span(i18n.audioTagsLabel),
+      br(),
+      input({
+        type: "text",
+        name: "tags",
+        placeholder: i18n.audioTagsPlaceholder,
+        value: safeArr(audioToEdit?.tags).join(", ")
+      }),
+      br(),
+      br(),
+      span(i18n.audioTitleLabel),
+      br(),
+      input({ type: "text", name: "title", placeholder: i18n.audioTitlePlaceholder, value: audioToEdit?.title || "" }),
+      br(),
+      br(),
+      span(i18n.audioDescriptionLabel),
+      br(),
+      textarea({ name: "description", placeholder: i18n.audioDescriptionPlaceholder, rows: "4" }, audioToEdit?.description || ""),
+      br(),
+      br(),
+      button({ type: "submit" }, filter === "edit" ? i18n.audioUpdateButton : i18n.audioCreateButton)
     )
   );
 };
 
-exports.audioView = async (audios, filter, audioId) => {
-  const title = filter === 'mine' ? i18n.audioMineSectionTitle :
-                filter === 'create' ? i18n.audioCreateSectionTitle :
-                filter === 'edit' ? i18n.audioUpdateSectionTitle :
-                filter === 'recent' ? i18n.audioRecentSectionTitle :
-                filter === 'top' ? i18n.audioTopSectionTitle :
-                i18n.audioAllSectionTitle;
+exports.audioView = async (audios, filter = "all", audioId = null, params = {}) => {
+  const title =
+    filter === "mine"
+      ? i18n.audioMineSectionTitle
+      : filter === "create"
+        ? i18n.audioCreateSectionTitle
+        : filter === "edit"
+          ? i18n.audioUpdateSectionTitle
+          : filter === "recent"
+            ? i18n.audioRecentSectionTitle
+            : filter === "top"
+              ? i18n.audioTopSectionTitle
+              : filter === "favorites"
+                ? i18n.audioFavoritesSectionTitle
+                : i18n.audioAllSectionTitle;
 
-  const filteredAudios = getFilteredAudios(filter, audios, userId);
-  const audioToEdit = audios.find(a => a.key === audioId);
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+
+  const list = safeArr(audios);
+  const audioToEdit = audioId ? list.find((a) => a.key === audioId) : null;
 
   return template(
     title,
     section(
-      div({ class: "tags-header" },
-        h2(title),
-        p(i18n.audioDescription)
-      ),
-      div({ class: "filters" },
-        form({ method: "GET", action: "/audios" },
-          ["all", "mine", "recent", "top"].map(f =>
-            button({
-              type: "submit", name: "filter", value: f,
-              class: filter === f ? "filter-btn active" : "filter-btn"
-            },
-              i18n[`audioFilter${f.charAt(0).toUpperCase() + f.slice(1)}`]
-            )
+      div({ class: "tags-header" }, h2(title), p(i18n.audioDescription)),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
+          button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
+          button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
+          button(
+            { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
+            i18n.audioFilterFavorites
           ),
-          button({ type: "submit", name: "filter", value: "create", class: "create-button" },
-            i18n.audioCreateButton)
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton)
         )
       )
     ),
     section(
-      (filter === 'create' || filter === 'edit')
-        ? renderAudioForm(filter, audioId, audioToEdit)
-        : renderAudioList(filteredAudios, filter)
+      filter === "create" || filter === "edit"
+        ? renderAudioForm(filter, audioId, audioToEdit, { ...params, filter })
+        : section(
+            div(
+              { class: "audios-search" },
+              form(
+                { method: "GET", action: "/audios", class: "filter-box" },
+                input({ type: "hidden", name: "filter", value: filter }),
+                input({
+                  type: "text",
+                  name: "q",
+                  value: q,
+                  placeholder: i18n.audioSearchPlaceholder,
+                  class: "filter-box__input"
+                }),
+                div(
+                  { class: "filter-box__controls" },
+                  select(
+                    { name: "sort", class: "filter-box__select" },
+                    option({ value: "recent", selected: sort === "recent" }, i18n.audioSortRecent),
+                    option({ value: "oldest", selected: sort === "oldest" }, i18n.audioSortOldest),
+                    option({ value: "top", selected: sort === "top" }, i18n.audioSortTop)
+                  ),
+                  button({ type: "submit", class: "filter-box__button" }, i18n.audioSearchButton)
+                )
+              )
+            ),
+            div({ class: "audios-list" }, renderAudioList(list, filter, { q, sort }))
+          )
     )
   );
 };
 
-exports.singleAudioView = async (audio, filter, comments = []) => {
-  const isAuthor = audio.author === userId;
-  const hasOpinions = Object.keys(audio.opinions || {}).length > 0;
+exports.singleAudioView = async (audioObj, filter = "all", comments = [], params = {}) => {
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+  const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
+
+  const title = safeText(audioObj.title);
+  const ownerActions = renderAudioOwnerActions(filter, audioObj, { q, sort });
+
+  const topbarLeft =
+    audioObj.author && String(audioObj.author) !== String(userId)
+      ? form(
+          { method: "GET", action: "/pm" },
+          input({ type: "hidden", name: "recipients", value: audioObj.author }),
+          button({ type: "submit", class: "filter-btn" }, i18n.audioMessageAuthorButton)
+        )
+      : null;
+
+  const topbar = div(
+    { class: "bookmark-topbar" },
+    div({ class: "bookmark-actions" }, renderAudioFavoriteToggle(audioObj, returnTo), ...ownerActions)
+  );
 
   return template(
     i18n.audioTitle,
     section(
-      div({ class: "filters" },
-        form({ method: "GET", action: "/audios" },
-          button({ type: "submit", name: "filter", value: "all", class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.audioFilterAll),
-          button({ type: "submit", name: "filter", value: "mine", class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.audioFilterMine),
-          button({ type: "submit", name: "filter", value: "recent", class: filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.audioFilterRecent),
-          button({ type: "submit", name: "filter", value: "top", class: filter === 'top' ? 'filter-btn active' : 'filter-btn' }, i18n.audioFilterTop),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
+          button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
+          button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
+          button(
+            { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
+            i18n.audioFilterFavorites
+          ),
           button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton)
         )
       ),
-      div({ class: "tags-header" },
-        isAuthor ? div({ class: "audio-actions" },
-          !hasOpinions
-            ? form({ method: "GET", action: `/audios/edit/${encodeURIComponent(audio.key)}` },
-                button({ class: "update-btn", type: "submit" }, i18n.audioUpdateButton)
-              )
-            : null,
-          form({ method: "POST", action: `/audios/delete/${encodeURIComponent(audio.key)}` },
-            button({ class: "delete-btn", type: "submit" }, i18n.audioDeleteButton)
-          )
-        ) : null,
-        form({ method: "GET", action: `/audios/${encodeURIComponent(audio.key)}` },
-          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-        ),
-        h2(audio.title),
-        audio.url
-          ? div({ class: "audio-container" },
-              audioHyperaxe({
-                controls: true,
-                src: `/blob/${encodeURIComponent(audio.url)}`,
-                type: audio.mimeType,
-                preload: 'metadata'
-              })
-            )
-          : p(i18n.audioNoFile),
-        p(...renderUrl(audio.description)),
-        audio.tags?.length
-          ? div({ class: "card-tags" },
-              audio.tags.map(tag =>
-                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+      div(
+        { class: "bookmark-item card" },
+        topbar,
+        title ? h2(title) : null,
+        renderAudioPlayer(audioObj),
+        safeText(audioObj.description) ? p(...renderUrl(audioObj.description)) : null,
+        renderTags(audioObj.tags),
+        br(),
+        (() => {
+          const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN;
+          const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN;
+          const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
+
+          return p(
+            { class: "card-footer" },
+            span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+            a({ href: `/author/${encodeURIComponent(audioObj.author)}`, class: "user-link" }, `${audioObj.author}`),
+            showUpdated
+              ? span(
+                  { class: "votations-comment-date" },
+                  ` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
+                )
+              : null
+          );
+        })(),
+        div(
+          { class: "voting-buttons" },
+          opinionCategories.map((category) =>
+            form(
+              { method: "POST", action: `/audios/opinions/${encodeURIComponent(audioObj.key)}/${category}` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              button(
+                { class: "vote-btn" },
+                `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
+                  audioObj.opinions?.[category] || 0
+                }]`
               )
             )
-          : null,
-        br,
-        p({ class: 'card-footer' },
-          span({ class: 'date-link' }, `${moment(audio.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-          a({ href: `/author/${encodeURIComponent(audio.author)}`, class: 'user-link' }, `${audio.author}`)
-        )
-      ),
-      div({ class: "voting-buttons" },
-        opinionCategories.map(category =>
-          form({ method: "POST", action: `/audios/opinions/${encodeURIComponent(audio.key)}/${category}` },
-            button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${audio.opinions?.[category] || 0}]`)
           )
         )
       ),
-      renderAudioCommentsSection(audio.key, comments)
+      div({ id: "comments" }, renderAudioCommentsSection(audioObj.key, comments, returnTo))
     )
   );
 };

+ 424 - 237
src/views/bookmark_view.js

@@ -1,151 +1,244 @@
-const { form, button, div, h2, p, section, input, label, textarea, br, a, span } = require("../server/node_modules/hyperaxe");
-const { template, i18n } = require('./main_views');
+const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option } =
+  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 opinionCategories = require('../backend/opinion_categories');
-
-const userId = config.keys.id
-
-const renderBookmarkActions = (filter, bookmark) => {
-  return filter === 'mine'
-    ? div({ class: "bookmark-actions" },
-        form({ method: "GET", action: `/bookmarks/edit/${encodeURIComponent(bookmark.id)}` },
-          button({ class: "update-btn", type: "submit" }, i18n.bookmarkUpdateButton)
-        ),
-        form({ method: "POST", action: `/bookmarks/delete/${encodeURIComponent(bookmark.id)}` },
+const { config } = require("../server/SSB_server.js");
+const { renderUrl } = require("../backend/renderUrl");
+const opinionCategories = require("../backend/opinion_categories");
+
+const userId = config.keys.id;
+
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+const safeText = (v) => String(v || "").trim();
+
+const buildReturnTo = (filter, params = {}) => {
+  const f = safeText(filter || "all");
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+  const parts = [`filter=${encodeURIComponent(f)}`];
+  if (q) parts.push(`q=${encodeURIComponent(q)}`);
+  if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
+  return `/bookmarks?${parts.join("&")}`;
+};
+
+const renderPMButton = (recipient, className = "filter-btn") => {
+  const r = safeText(recipient);
+  if (!r) return null;
+  if (String(r) === String(userId)) return null;
+
+  return form(
+    { method: "GET", action: "/pm" },
+    input({ type: "hidden", name: "recipients", value: r }),
+    button({ type: "submit", class: className }, i18n.privateMessage)
+  );
+};
+
+const renderBookmarkActions = (filter, bookmark, params = {}) => {
+  const returnTo = buildReturnTo(filter, params);
+  const isAuthor = String(bookmark.author) === String(userId);
+  const hasOpinions = Object.keys(bookmark.opinions || {}).length > 0;
+
+  return isAuthor
+    ? div(
+        { class: "bookmark-actions" },
+        !hasOpinions
+          ? form(
+              { method: "GET", action: `/bookmarks/edit/${encodeURIComponent(bookmark.id)}` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              button({ class: "update-btn", type: "submit" }, i18n.bookmarkUpdateButton)
+            )
+          : null,
+        form(
+          { method: "POST", action: `/bookmarks/delete/${encodeURIComponent(bookmark.id)}` },
+          input({ type: "hidden", name: "returnTo", value: returnTo }),
           button({ class: "delete-btn", type: "submit" }, i18n.bookmarkDeleteButton)
         )
       )
     : null;
 };
 
-const renderBookmarkCommentsSection = (bookmarkId, comments = []) => {
-  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+const renderBookmarkCommentsSection = (bookmarkId, rootId, comments = [], returnTo = null) => {
+  const list = safeArr(comments);
+  const commentsCount = list.length;
 
-  return div({ class: 'vote-comments-section' },
-    div({ class: 'comments-count' },
-      span({ class: 'card-label' }, i18n.voteCommentsLabel + ': '),
-      span({ class: 'card-value' }, String(commentsCount))
+  return div(
+    { class: "vote-comments-section" },
+    div(
+      { class: "comments-count" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
+      span({ class: "card-value" }, String(commentsCount))
     ),
-    div({ class: 'comment-form-wrapper' },
-      h2({ class: 'comment-form-title' }, i18n.voteNewCommentLabel),
-      form({
-        method: 'POST',
-        action: `/bookmarks/${encodeURIComponent(bookmarkId)}/comments`,
-        class: 'comment-form'
-      },
+    div(
+      { class: "comment-form-wrapper" },
+      h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
+      form(
+        { method: "POST", action: `/bookmarks/${encodeURIComponent(bookmarkId)}/comments`, class: "comment-form" },
+        returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
+        rootId ? input({ type: "hidden", name: "rootId", value: rootId }) : null,
         textarea({
-          id: 'comment-text',
-          name: 'text',
+          id: "comment-text",
+          name: "text",
           required: true,
           rows: 4,
-          class: 'comment-textarea',
+          class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         br(),
-        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+        button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
     ),
-    comments && comments.length
-      ? div({ class: 'comments-list' },
-          comments.map(c => {
-            const author = c.value && c.value.author ? c.value.author : '';
-            const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
-            const absDate = ts ? moment(ts).format('YYYY/MM/DD HH:mm:ss') : '';
-            const relDate = ts ? moment(ts).fromNow() : '';
-            const userName = author && author.includes('@') ? author.split('@')[1] : author;
-
-            return div({ class: 'votations-comment-card' },
-              span({ class: 'created-at' },
+    list.length
+      ? div(
+          { class: "comments-list" },
+          list.map((c) => {
+            const author = c?.value?.author || "";
+            const ts = c?.value?.timestamp || c?.timestamp;
+            const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
+            const relDate = ts ? moment(ts).fromNow() : "";
+            const userName = author && author.includes("@") ? author.split("@")[1] : author;
+
+            const content = c?.value?.content || {};
+            const text = content.text || "";
+            const threadRoot = content.fork || content.root || null;
+
+            return div(
+              { class: "votations-comment-card" },
+              span(
+                { class: "created-at" },
                 span(i18n.createdBy),
-                author
-                  ? a(
-                      { href: `/author/${encodeURIComponent(author)}` },
-                      `@${userName}`
-                    )
-                  : span('(unknown)'),
-                absDate ? span(' | ') : '',
-                absDate ? span({ class: 'votations-comment-date' }, absDate) : '',
-                relDate ? span({ class: 'votations-comment-date' }, ' | ', i18n.sendTime) : '',
-                relDate
-                  ? a(
-                      {
-                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
-                      },
-                      relDate
-                    )
-                  : ''
+                author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
+                absDate ? span(" | ") : "",
+                absDate ? span({ class: "votations-comment-date" }, absDate) : "",
+                relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
+                relDate && threadRoot
+                  ? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate)
+                  : ""
               ),
-              p({
-                class: 'votations-comment-text',
-                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
-              })
+              p({ class: "votations-comment-text" }, ...renderUrl(text))
             );
           })
         )
-      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+      : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
   );
 };
 
 const renderCardField = (labelText, value) =>
-  div({ class: 'card-field' },
-    span({ class: 'card-label' }, labelText),
-    span({ class: 'card-value' }, value)
+  div(
+    { class: "card-field" },
+    span({ class: "card-label" }, labelText),
+    span({ class: "card-value" }, value)
   );
 
-const renderBookmarkList = (filteredBookmarks, filter) => {
-  return filteredBookmarks.length > 0
-    ? filteredBookmarks.map(bookmark => {
-        const commentCount = typeof bookmark.commentCount === 'number' ? bookmark.commentCount : 0;
+const renderFavoriteToggle = (bookmark, returnTo) =>
+  form(
+    {
+      method: "POST",
+      action: bookmark.isFavorite
+        ? `/bookmarks/favorites/remove/${encodeURIComponent(bookmark.id)}`
+        : `/bookmarks/favorites/add/${encodeURIComponent(bookmark.id)}`,
+      class: "bookmark-favorite-form"
+    },
+    returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
+    button(
+      { type: "submit", class: "filter-btn" },
+      bookmark.isFavorite ? i18n.bookmarkRemoveFavoriteButton : i18n.bookmarkAddFavoriteButton
+    )
+  );
 
-        return div({ class: "tags-header" },
-          renderBookmarkActions(filter, bookmark),
-          form({ method: "GET", action: `/bookmarks/${encodeURIComponent(bookmark.id)}` },
-            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-          ),
-          h2(bookmark.category || bookmark.url || ''),
-          renderCardField(i18n.bookmarkUrlLabel + ":", ''), 
-          br,
-          div(bookmark.url
-            ? a({ href: bookmark.url, target: "_blank", class: "bookmark-url" }, bookmark.url)
-            : i18n.noUrl
-          ),
-          renderCardField(i18n.bookmarkLastVisit + ":", bookmark.lastVisit
-            ? moment(bookmark.lastVisit).format('YYYY/MM/DD HH:mm:ss')
-            : i18n.noLastVisit
+const renderTags = (tags) => {
+  const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
+  return list.length
+    ? div(
+        { class: "card-tags" },
+        list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
+      )
+    : null;
+};
+
+const renderBookmarkList = (filteredBookmarks, filter, params = {}) => {
+  const returnTo = buildReturnTo(filter, params);
+
+  return filteredBookmarks.length
+    ? filteredBookmarks.map((bookmark) => {
+        const commentCount = typeof bookmark.commentCount === "number" ? bookmark.commentCount : 0;
+
+        const lastVisit = bookmark.lastVisit ? moment(bookmark.lastVisit) : null;
+        const lastVisitTxt =
+          lastVisit && lastVisit.isValid()
+            ? `${lastVisit.format("YYYY/MM/DD HH:mm:ss")} (${lastVisit.fromNow()})`
+            : i18n.noLastVisit;
+
+        const urlLink = bookmark.url
+          ? a({ href: bookmark.url, target: "_blank", rel: "noreferrer noopener", class: "bookmark-url" }, bookmark.url)
+          : i18n.noUrl;
+
+        return div(
+          { class: "tags-header bookmark-card" },
+          div(
+            { class: "bookmark-topbar" },
+            div(
+              { class: "bookmark-topbar-left" },
+              form(
+                { method: "GET", action: `/bookmarks/${encodeURIComponent(bookmark.id)}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
+                input({ type: "hidden", name: "filter", value: filter || "all" }),
+                params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
+                params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
+                button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+              ),
+              renderPMButton(bookmark.author),
+              renderFavoriteToggle(bookmark, returnTo)
+            ),
+            renderBookmarkActions(filter, bookmark, params)
           ),
-          bookmark.category?.trim()
-            ? renderCardField(i18n.bookmarkCategory + ":", bookmark.category)
-            : null,
-          bookmark.description
-            ? [
-                renderCardField(i18n.bookmarkDescriptionLabel + ":", ''),
-                p(...renderUrl(bookmark.description))
-              ]
-            : null,
-          bookmark.tags?.length
-            ? div({ class: "card-tags" }, bookmark.tags.map(tag =>
-                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
-              ))
-            : null,
-          div({ class: 'card-comments-summary' },
-            span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
-            span({ class: 'card-value' }, String(commentCount)),
-            br, br,
-            form({ method: 'GET', action: `/bookmarks/${encodeURIComponent(bookmark.id)}` },
-              button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+          h2({ class: "bookmark-title" }, bookmark.category || bookmark.url || ""),
+          renderCardField(i18n.bookmarkUrlLabel + ":", urlLink),
+          renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt),
+          renderCardField(i18n.bookmarkCategoryLabel + ":", safeText(bookmark.category) || i18n.noCategory),
+          safeText(bookmark.description) ? p(...renderUrl(bookmark.description)) : null,
+          renderTags(bookmark.tags),
+          div(
+            { class: "card-comments-summary" },
+            span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
+            span({ class: "card-value" }, String(commentCount)),
+            br(),
+            br(),
+            form(
+              { method: "GET", action: `/bookmarks/${encodeURIComponent(bookmark.id)}` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              input({ type: "hidden", name: "filter", value: filter || "all" }),
+              params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
+              params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
+              button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
             )
           ),
-          br,
-          div({ class: 'card-footer' },
-            span({ class: 'date-link' }, `${moment(bookmark.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-            a({ href: `/author/${encodeURIComponent(bookmark.author)}`, class: 'user-link' }, `${bookmark.author}`)
-          ),
-          div({ class: "voting-buttons" },
-            opinionCategories.map(category =>
-              form({ method: "POST", action: `/bookmarks/opinions/${encodeURIComponent(bookmark.id)}/${category}` },
-                button({ class: "vote-btn" },
+          br(),
+          (() => {
+            const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN;
+            const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN;
+            const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
+
+            return p(
+              { class: "card-footer" },
+              span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+              a({ href: `/author/${encodeURIComponent(bookmark.author)}`, class: "user-link" }, `${bookmark.author}`),
+              showUpdated
+                ? span(
+                    { class: "votations-comment-date" },
+                    ` | ${i18n.bookmarkUpdatedAt}: ${moment(bookmark.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
+                  )
+                : null
+            );
+          })(),
+          div(
+            { class: "voting-buttons" },
+            opinionCategories.map((category) =>
+              form(
+                { method: "POST", action: `/bookmarks/opinions/${encodeURIComponent(bookmark.id)}/${category}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
+                button(
+                  { class: "vote-btn" },
                   `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${bookmark.opinions?.[category] || 0}]`
                 )
               )
@@ -153,161 +246,255 @@ const renderBookmarkList = (filteredBookmarks, filter) => {
           )
         );
       })
-    : p(i18n.nobookmarks);
+    : p(params.q ? i18n.bookmarkNoMatch : i18n.noBookmarks);
 };
 
-const renderBookmarkForm = (filter, bookmarkId, bookmarkToEdit, tags) => {
-  return div({ class: "div-center bookmark-form" },
+const renderBookmarkForm = (filter, bookmarkId, bookmarkToEdit, tags, params = {}) => {
+  const returnFilter = filter === "create" ? "all" : params.filter || "all";
+  const returnTo = params.returnTo || buildReturnTo(returnFilter, params);
+
+  const lastVisitValue =
+    bookmarkToEdit?.lastVisit && moment(bookmarkToEdit.lastVisit).isValid()
+      ? moment(bookmarkToEdit.lastVisit).format("YYYY-MM-DDTHH:mm")
+      : "";
+
+  const lastVisitMax = moment().format("YYYY-MM-DDTHH:mm");
+
+  return div(
+    { class: "div-center bookmark-form" },
     form(
-      {
-        action: filter === 'edit'
-          ? `/bookmarks/update/${encodeURIComponent(bookmarkId)}`
-          : "/bookmarks/create",
-        method: "POST"
-      },
-      label(i18n.bookmarkUrlLabel), br,
-      input({ type: "url", name: "url", id: "url", required: true, placeholder: i18n.bookmarkUrlPlaceholder, value: filter === 'edit' ? bookmarkToEdit.url : '' }), br, br,
-      label(i18n.bookmarkDescriptionLabel), br,
-      textarea({ name: "description", id: "description", placeholder: i18n.bookmarkDescriptionPlaceholder, rows: "4" }, filter === 'edit' ? bookmarkToEdit.description : ''), br, br,
-      label(i18n.bookmarkTagsLabel), br,
-      input({ type: "text", name: "tags", id: "tags", placeholder: i18n.bookmarkTagsPlaceholder, value: filter === 'edit' ? tags.join(', ') : '' }), br, br,
-      label(i18n.bookmarkCategoryLabel), br,
-      input({ type: "text", name: "category", id: "category", placeholder: i18n.bookmarkCategoryPlaceholder, value: filter === 'edit' ? bookmarkToEdit.category : '' }), br, br,
-      label(i18n.bookmarkLastVisitLabel), br,
-      input({ type: "datetime-local", name: "lastVisit", value: filter === 'edit' ? moment(bookmarkToEdit.lastVisit).format('YYYY-MM-DDTHH:mm:ss') : '' }), br, br,
-      button({ type: "submit" }, filter === 'edit' ? i18n.bookmarkUpdateButton : i18n.bookmarkCreateButton)
+      { action: filter === "edit" ? `/bookmarks/update/${encodeURIComponent(bookmarkId)}` : "/bookmarks/create", method: "POST" },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      label(i18n.bookmarkUrlLabel),
+      br(),
+      input({
+        type: "url",
+        name: "url",
+        id: "url",
+        required: true,
+        placeholder: i18n.bookmarkUrlPlaceholder,
+        value: filter === "edit" ? bookmarkToEdit.url || "" : ""
+      }),
+      br(),
+      br(),
+      label(i18n.bookmarkDescriptionLabel),
+      br(),
+      textarea(
+        { name: "description", id: "description", placeholder: i18n.bookmarkDescriptionPlaceholder, rows: "4" },
+        filter === "edit" ? bookmarkToEdit.description || "" : ""
+      ),
+      br(),
+      br(),
+      label(i18n.bookmarkTagsLabel),
+      br(),
+      input({
+        type: "text",
+        name: "tags",
+        id: "tags",
+        placeholder: i18n.bookmarkTagsPlaceholder,
+        value: filter === "edit" ? safeArr(tags).join(", ") : ""
+      }),
+      br(),
+      br(),
+      label(i18n.bookmarkCategoryLabel),
+      br(),
+      input({
+        type: "text",
+        name: "category",
+        id: "category",
+        placeholder: i18n.bookmarkCategoryPlaceholder,
+        value: filter === "edit" ? bookmarkToEdit.category || "" : ""
+      }),
+      br(),
+      br(),
+      label(i18n.bookmarkLastVisitLabel),
+      br(),
+      input({
+        type: "datetime-local",
+        name: "lastVisit",
+        max: lastVisitMax,
+        value: filter === "edit" ? lastVisitValue : ""
+      }),
+      br(),
+      br(),
+      button({ type: "submit" }, filter === "edit" ? i18n.bookmarkUpdateButton : i18n.bookmarkCreateButton)
     )
   );
 };
 
-exports.bookmarkView = async (bookmarks, filter, bookmarkId) => {
-  const title = filter === 'mine' ? i18n.bookmarkMineSectionTitle :
-                filter === 'create' ? i18n.bookmarkCreateSectionTitle :
-                filter === 'edit' ? i18n.bookmarkUpdateSectionTitle :
-                filter === 'internal' ? i18n.bookmarkInternalTitle :
-                filter === 'external' ? i18n.bookmarkExternalTitle :
-                filter === 'top' ? i18n.bookmarkTopTitle :
-                filter === 'recent' ? i18n.bookmarkRecentTitle :
-                i18n.bookmarkAllSectionTitle;
-
-  const sectionTitle = title;
-  const now = Date.now();
-
-  let filteredBookmarks = (filter === 'mine')
-    ? bookmarks.filter(bookmark => String(bookmark.author).trim() === String(userId).trim())
-    : (filter === 'internal')
-      ? bookmarks.filter(bookmark => bookmark.tags?.includes('internal'))
-      : (filter === 'external')
-        ? bookmarks.filter(bookmark => bookmark.tags?.includes('external'))
-        : (filter === 'recent')
-          ? bookmarks.filter(bookmark => new Date(bookmark.createdAt).getTime() >= (now - 24 * 60 * 60 * 1000))
-          : bookmarks;
-
-  if (filter === 'top') {
-    filteredBookmarks = [...filteredBookmarks].sort((a, b) => {
-      const sumA = Object.values(a.opinions || {}).reduce((s, n) => s + n, 0);
-      const sumB = Object.values(b.opinions || {}).reduce((s, n) => s + n, 0);
-      return sumB - sumA;
-    });
-  } else {
-    filteredBookmarks = [...filteredBookmarks].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-  }
-
-  const bookmarkToEdit = bookmarks.find(b => b.id === bookmarkId);
+exports.bookmarkView = async (bookmarks, filter = "all", bookmarkId = null, params = {}) => {
+  const title =
+    filter === "mine"
+      ? i18n.bookmarkMineSectionTitle
+      : filter === "create"
+        ? i18n.bookmarkCreateSectionTitle
+        : filter === "edit"
+          ? i18n.bookmarkUpdateSectionTitle
+          : filter === "recent"
+            ? i18n.bookmarkRecentSectionTitle
+            : filter === "top"
+              ? i18n.bookmarkTopSectionTitle
+              : filter === "favorites"
+                ? i18n.bookmarkFavoritesSectionTitle
+                : i18n.bookmarkAllSectionTitle;
+
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+
+  const list = safeArr(bookmarks);
+  const bookmarkToEdit = bookmarkId ? list.find((b) => b.id === bookmarkId) : null;
   const tags = bookmarkToEdit && Array.isArray(bookmarkToEdit.tags) ? bookmarkToEdit.tags : [];
 
   return template(
     title,
     section(
-      div({ class: "tags-header" },
-        h2(sectionTitle),
-        p(i18n.bookmarkDescription)
-      ),
-      div({ class: "filters" },
-        form({ method: "GET", action: "/bookmarks" },
-          button({ type: "submit", name: "filter", value: "all", class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.bookmarkFilterAll),
-          button({ type: "submit", name: "filter", value: "mine", class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.bookmarkFilterMine),
-          button({ type: "submit", name: "filter", value: "internal", class: filter === 'internal' ? 'filter-btn active' : 'filter-btn' }, i18n.bookmarkFilterInternal),
-          button({ type: "submit", name: "filter", value: "external", class: filter === 'external' ? 'filter-btn active' : 'filter-btn' }, i18n.bookmarkFilterExternal),
-          button({ type: "submit", name: "filter", value: "top", class: filter === 'top' ? 'filter-btn active' : 'filter-btn' }, i18n.bookmarkFilterTop),
-          button({ type: "submit", name: "filter", value: "recent", class: filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.bookmarkFilterRecent),
+      div({ class: "tags-header" }, h2(title), p(i18n.bookmarkDescription)),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/bookmarks", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterMine),
+          button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterTop),
+          button({ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterFavorites),
+          button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterRecent),
           button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.bookmarkCreateButton)
         )
       )
     ),
     section(
-      (filter === 'edit' || filter === 'create')
-        ? renderBookmarkForm(filter, bookmarkId, bookmarkToEdit, tags)
-        : div({ class: "bookmark-list" }, renderBookmarkList(filteredBookmarks, filter))
+      filter === "edit" || filter === "create"
+        ? renderBookmarkForm(filter, bookmarkId, bookmarkToEdit || {}, tags, { ...params, filter })
+        : section(
+            div(
+              { class: "bookmarks-search" },
+              form(
+                { method: "GET", action: "/bookmarks", class: "filter-box" },
+                input({ type: "hidden", name: "filter", value: filter }),
+                input({ type: "text", name: "q", value: q, placeholder: i18n.bookmarkSearchPlaceholder, class: "filter-box__input" }),
+                div(
+                  { class: "filter-box__controls" },
+                  select(
+                    { name: "sort", class: "filter-box__select" },
+                    option({ value: "recent", selected: sort === "recent" }, i18n.bookmarkSortRecent),
+                    option({ value: "oldest", selected: sort === "oldest" }, i18n.bookmarkSortOldest),
+                    option({ value: "top", selected: sort === "top" }, i18n.bookmarkSortTop)
+                  ),
+                  button({ type: "submit", class: "filter-box__button" }, i18n.bookmarkSearchButton)
+                )
+              )
+            ),
+            div({ class: "bookmark-list" }, renderBookmarkList(list, filter, { q, sort }))
+          )
     )
   );
 };
 
-exports.singleBookmarkView = async (bookmark, filter, comments = []) => {
-  const isAuthor = bookmark.author === userId; 
+exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], params = {}) => {
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+  const returnTo = params.returnTo || buildReturnTo(filter, { q, sort });
+
+  const isAuthor = String(bookmark.author) === String(userId);
   const hasOpinions = Object.keys(bookmark.opinions || {}).length > 0;
 
-  return template(
-    i18n.bookmarkTitle,
-    section(
-      div({ class: "filters" },
-        form({ method: "GET", action: "/bookmarks" },
-          button({ type: "submit", name: "filter", value: "all", class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.bookmarkFilterAll),
-          button({ type: "submit", name: "filter", value: "mine", class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.bookmarkFilterMine),
-          button({ type: "submit", name: "filter", value: "internal", class: filter === 'internal' ? 'filter-btn active' : 'filter-btn' }, i18n.bookmarkFilterInternal),
-          button({ type: "submit", name: "filter", value: "external", class: filter === 'external' ? 'filter-btn active' : 'filter-btn' }, i18n.bookmarkFilterExternal),
-          button({ type: "submit", name: "filter", value: "top", class: filter === 'top' ? 'filter-btn active' : 'filter-btn' }, i18n.bookmarkFilterTop),
-          button({ type: "submit", name: "filter", value: "recent", class: filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.bookmarkFilterRecent),
-          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.bookmarkCreateButton)
-        )
-      ),
-      div({ class: "bookmark-item card" },
-        br,
-        isAuthor ? div({ class: "bookmark-actions" },
+  const lastVisit = bookmark.lastVisit ? moment(bookmark.lastVisit) : null;
+  const lastVisitTxt =
+    lastVisit && lastVisit.isValid()
+      ? `${lastVisit.format("YYYY/MM/DD HH:mm:ss")} (${lastVisit.fromNow()})`
+      : i18n.noLastVisit;
+
+  const urlLink = bookmark.url
+    ? a({ href: bookmark.url, target: "_blank", rel: "noreferrer noopener", class: "bookmark-url" }, bookmark.url)
+    : i18n.noUrl;
+
+  const pmBtn = renderPMButton(bookmark.author);
+
+  const actions =
+    isAuthor
+      ? div(
+          { class: "bookmark-actions" },
+          renderFavoriteToggle(bookmark, returnTo),
           !hasOpinions
-            ? form({ method: "GET", action: `/bookmarks/edit/${encodeURIComponent(bookmark.id)}` },
+            ? form(
+                { method: "GET", action: `/bookmarks/edit/${encodeURIComponent(bookmark.id)}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
                 button({ class: "update-btn", type: "submit" }, i18n.bookmarkUpdateButton)
               )
             : null,
-          form({ method: "POST", action: `/bookmarks/delete/${encodeURIComponent(bookmark.id)}` },
+          form(
+            { method: "POST", action: `/bookmarks/delete/${encodeURIComponent(bookmark.id)}` },
+            input({ type: "hidden", name: "returnTo", value: returnTo }),
             button({ class: "delete-btn", type: "submit" }, i18n.bookmarkDeleteButton)
           )
-        ) : null,
-        h2(bookmark.category || bookmark.url || ''),
-        renderCardField(i18n.bookmarkUrlLabel + ":", ''), 
-        br,
-        div(bookmark.url
-          ? a({ href: bookmark.url, target: "_blank", class: "bookmark-url" }, bookmark.url)
-          : i18n.noUrl
-        ),
-        renderCardField(i18n.bookmarkLastVisit + ":", bookmark.lastVisit
-          ? moment(bookmark.lastVisit).format('YYYY/MM/DD HH:mm:ss')
-          : i18n.noLastVisit
-        ),
-        renderCardField(i18n.bookmarkCategory + ":", bookmark.category || i18n.noCategory),
-        renderCardField(i18n.bookmarkDescriptionLabel + ":", ''), 
-        p(...renderUrl(bookmark.description)),
-        bookmark.tags && bookmark.tags.length
-          ? div({ class: "card-tags" },
-              bookmark.tags.map(tag =>
-                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+        )
+      : div(
+          { class: "bookmark-actions" },
+          pmBtn,
+          renderFavoriteToggle(bookmark, returnTo)
+        );
+
+  return template(
+    i18n.bookmarkTitle,
+    section(
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/bookmarks", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterMine),
+          button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterTop),
+          button({ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterFavorites),
+          button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterRecent),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.bookmarkCreateButton)
+        )
+      ),
+      div(
+        { class: "bookmark-item card" },
+        actions,
+        h2({ class: "bookmark-title" }, bookmark.category || bookmark.url || ""),
+        renderCardField(i18n.bookmarkUrlLabel + ":", urlLink),
+        renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt),
+        renderCardField(i18n.bookmarkCategoryLabel + ":", safeText(bookmark.category) || i18n.noCategory),
+        safeText(bookmark.description) ? p(...renderUrl(bookmark.description)) : null,
+        renderTags(bookmark.tags),
+        br(),
+        (() => {
+          const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN;
+          const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN;
+          const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
+
+          return p(
+            { class: "card-footer" },
+            span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+            a({ href: `/author/${encodeURIComponent(bookmark.author)}`, class: "user-link" }, `${bookmark.author}`),
+            showUpdated
+              ? span(
+                  { class: "votations-comment-date" },
+                  ` | ${i18n.bookmarkUpdatedAt}: ${moment(bookmark.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
+                )
+              : null
+          );
+        })(),
+        div(
+          { class: "voting-buttons" },
+          opinionCategories.map((category) =>
+            form(
+              { method: "POST", action: `/bookmarks/opinions/${encodeURIComponent(bookmark.id)}/${category}` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              button(
+                { class: "vote-btn" },
+                `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${bookmark.opinions?.[category] || 0}]`
               )
             )
-          : null,
-        br,
-        div({ class: 'card-footer' },
-          span({ class: 'date-link' }, `${moment(bookmark.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-          a({ href: `/author/${encodeURIComponent(bookmark.author)}`, class: 'user-link' }, `${bookmark.author}`)
-        ),
-        div({ class: "voting-buttons" },
-          opinionCategories.map(category =>
-            form({ method: "POST", action: `/bookmarks/opinions/${encodeURIComponent(bookmark.id)}/${category}` },
-              button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${bookmark.opinions?.[category] || 0}]`)
-            )
           )
         )
       ),
-      renderBookmarkCommentsSection(bookmark.id, comments)
+      renderBookmarkCommentsSection(bookmark.id, bookmark.rootId, comments, returnTo)
     )
   );
 };

+ 381 - 208
src/views/document_view.js

@@ -1,156 +1,227 @@
-const { form, button, div, h2, p, section, input, label, br, a, span, textarea } = require("../server/node_modules/hyperaxe");
+const { form, button, div, h2, p, section, input, label, br, a, span, textarea, select, option } =
+  require("../server/node_modules/hyperaxe");
+
 const moment = require("../server/node_modules/moment");
-const { template, i18n } = require('./main_views');
-const { config } = require('../server/SSB_server.js');
-const { renderUrl } = require('../backend/renderUrl');
-const opinionCategories = require('../backend/opinion_categories');
+const { template, i18n } = require("./main_views");
+const { config } = require("../server/SSB_server.js");
+const { renderUrl } = require("../backend/renderUrl");
+const opinionCategories = require("../backend/opinion_categories");
 
 const userId = config.keys.id;
 
-const getFilteredDocuments = (filter, documents, userId) => {
-  const now = Date.now();
-  let filtered =
-    filter === 'mine' ? documents.filter(d => d.author === userId) :
-    filter === 'recent' ? documents.filter(d => new Date(d.createdAt).getTime() >= now - 86400000) :
-    filter === 'top' ? [...documents].sort((a, b) => {
-      const sumA = Object.values(a.opinions || {}).reduce((s, n) => s + (n || 0), 0);
-      const sumB = Object.values(b.opinions || {}).reduce((s, n) => s + (n || 0), 0);
-      return sumB - sumA;
-    }) :
-    documents;
-  if (filter !== 'top') {
-    filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-  }
-  return filtered;
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+const safeText = (v) => String(v || "").trim();
+
+const buildReturnTo = (filter, params = {}) => {
+  const f = safeText(filter || "all");
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+  const parts = [`filter=${encodeURIComponent(f)}`];
+  if (q) parts.push(`q=${encodeURIComponent(q)}`);
+  if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
+  return `/documents?${parts.join("&")}`;
+};
+
+const safeDomId = (prefix, key) => `${prefix}${String(key || "").replace(/[^A-Za-z0-9_-]/g, "_")}`;
+
+const renderTags = (tags) => {
+  const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
+  return list.length
+    ? div(
+        { class: "card-tags" },
+        list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
+      )
+    : null;
+};
+
+const renderFavoriteToggle = (doc, returnTo) =>
+  form(
+    {
+      method: "POST",
+      action: doc.isFavorite
+        ? `/documents/favorites/remove/${encodeURIComponent(doc.key)}`
+        : `/documents/favorites/add/${encodeURIComponent(doc.key)}`
+    },
+    returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
+    button(
+      { type: "submit", class: "filter-btn" },
+      doc.isFavorite ? i18n.documentRemoveFavoriteButton : i18n.documentAddFavoriteButton
+    )
+  );
+
+const renderDocumentActions = (filter, doc, params = {}) => {
+  const returnTo = buildReturnTo(filter, params);
+  const isAuthor = String(doc.author) === String(userId);
+  const hasOpinions = Object.keys(doc.opinions || {}).length > 0;
+
+  return isAuthor
+    ? div(
+        { class: "bookmark-actions" },
+        !hasOpinions
+          ? form(
+              { method: "GET", action: `/documents/edit/${encodeURIComponent(doc.key)}` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              button({ class: "update-btn", type: "submit" }, i18n.documentUpdateButton)
+            )
+          : null,
+        form(
+          { method: "POST", action: `/documents/delete/${encodeURIComponent(doc.key)}` },
+          input({ type: "hidden", name: "returnTo", value: returnTo }),
+          button({ class: "delete-btn", type: "submit" }, i18n.documentDeleteButton)
+        )
+      )
+    : null;
 };
 
-const renderDocumentCommentsSection = (documentId, comments = []) => {
-  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+const renderDocumentCommentsSection = (documentKey, rootId, comments = [], returnTo = null) => {
+  const list = safeArr(comments);
+  const commentsCount = list.length;
 
-  return div({ class: 'vote-comments-section' },
-    div({ class: 'comments-count' },
-      span({ class: 'card-label' }, i18n.voteCommentsLabel + ': '),
-      span({ class: 'card-value' }, String(commentsCount))
+  return div(
+    { class: "vote-comments-section" },
+    div(
+      { class: "comments-count" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
+      span({ class: "card-value" }, String(commentsCount))
     ),
-    div({ class: 'comment-form-wrapper' },
-      h2({ class: 'comment-form-title' }, i18n.voteNewCommentLabel),
-      form({
-        method: 'POST',
-        action: `/documents/${encodeURIComponent(documentId)}/comments`,
-        class: 'comment-form'
-      },
+    div(
+      { class: "comment-form-wrapper" },
+      h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
+      form(
+        { method: "POST", action: `/documents/${encodeURIComponent(documentKey)}/comments`, class: "comment-form" },
+        returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
+        rootId ? input({ type: "hidden", name: "rootId", value: rootId }) : null,
         textarea({
-          id: 'comment-text',
-          name: 'text',
+          id: "comment-text",
+          name: "text",
           required: true,
           rows: 4,
-          class: 'comment-textarea',
+          class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         br(),
-        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+        button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
     ),
-    comments && comments.length
-      ? div({ class: 'comments-list' },
-          comments.map(c => {
-            const author = c.value && c.value.author ? c.value.author : '';
-            const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
-            const absDate = ts ? moment(ts).format('YYYY/MM/DD HH:mm:ss') : '';
-            const relDate = ts ? moment(ts).fromNow() : '';
-            const userName = author && author.includes('@') ? author.split('@')[1] : author;
-
-            return div({ class: 'votations-comment-card' },
-              span({ class: 'created-at' },
+    list.length
+      ? div(
+          { class: "comments-list" },
+          list.map((c) => {
+            const author = c?.value?.author || "";
+            const ts = c?.value?.timestamp || c?.timestamp;
+            const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
+            const relDate = ts ? moment(ts).fromNow() : "";
+            const userName = author && author.includes("@") ? author.split("@")[1] : author;
+
+            const content = c?.value?.content || {};
+            const text = content.text || "";
+            const threadRoot = content.fork || content.root || null;
+
+            return div(
+              { class: "votations-comment-card" },
+              span(
+                { class: "created-at" },
                 span(i18n.createdBy),
-                author
-                  ? a(
-                      { href: `/author/${encodeURIComponent(author)}` },
-                      `@${userName}`
-                    )
-                  : span('(unknown)'),
-                absDate ? span(' | ') : '',
-                absDate ? span({ class: 'votations-comment-date' }, absDate) : '',
-                relDate ? span({ class: 'votations-comment-date' }, ' | ', i18n.sendTime) : '',
-                relDate
-                  ? a(
-                      {
-                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
-                      },
-                      relDate
-                    )
-                  : ''
+                author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
+                absDate ? span(" | ") : "",
+                absDate ? span({ class: "votations-comment-date" }, absDate) : "",
+                relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
+                relDate && threadRoot
+                  ? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate)
+                  : ""
               ),
-              p({
-                class: 'votations-comment-text',
-                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
-              })
+              p({ class: "votations-comment-text" }, ...renderUrl(text))
             );
           })
         )
-      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+      : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
   );
 };
 
-const renderDocumentActions = (filter, doc) => {
-  return filter === 'mine' ? div({ class: "document-actions" },
-    form({ method: "GET", action: `/documents/edit/${encodeURIComponent(doc.key)}` },
-      button({ class: "update-btn", type: "submit" }, i18n.documentUpdateButton)
-    ),
-    form({ method: "POST", action: `/documents/delete/${encodeURIComponent(doc.key)}` },
-      button({ class: "delete-btn", type: "submit" }, i18n.documentDeleteButton)
-    )
-  ) : null;
-};
+const renderDocumentList = (documents, filter, params = {}) => {
+  const returnTo = buildReturnTo(filter, params);
+
+  return documents.length
+    ? documents.map((doc) => {
+        const commentCount = typeof doc.commentCount === "number" ? doc.commentCount : 0;
+        const title = safeText(doc.title);
+        const pdfId = safeDomId("pdf-container-", doc.key);
+
+        const topbarLeft =
+          doc.author && String(doc.author) !== String(userId)
+            ? form(
+                { method: "GET", action: "/pm" },
+                input({ type: "hidden", name: "recipients", value: doc.author }),
+                button({ type: "submit", class: "filter-btn" }, i18n.documentMessageAuthorButton)
+              )
+            : null;
 
-const renderDocumentList = (filteredDocs, filter) => {
-  const seen = new Set();
-  const unique = [];
-  for (const doc of filteredDocs) {
-    if (seen.has(doc.title)) continue;
-    seen.add(doc.title);
-    unique.push(doc);
-  }
-
-  return unique.length > 0
-    ? unique.map(doc => {
-        const commentCount = typeof doc.commentCount === 'number' ? doc.commentCount : 0;
-
-        return div({ class: "tags-header" },
-          renderDocumentActions(filter, doc),
-          form({ method: "GET", action: `/documents/${encodeURIComponent(doc.key)}` },
-            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        return div(
+          { class: "tags-header document-card" },
+          div(
+            { class: "bookmark-topbar" },
+            div(
+              { class: "bookmark-topbar-left" },
+              form(
+                { method: "GET", action: `/documents/${encodeURIComponent(doc.key)}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
+                input({ type: "hidden", name: "filter", value: filter || "all" }),
+                params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
+                params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
+                button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+              ),
+              renderFavoriteToggle(doc, returnTo),
+              topbarLeft
+            ),
+            renderDocumentActions(filter, doc, params)
           ),
-          doc.title?.trim() ? h2(doc.title) : null,
-          div({
-            id: `pdf-container-${doc.key}`,
-            class: 'pdf-viewer-container',
-            'data-pdf-url': `/blob/${encodeURIComponent(doc.url)}`
-          }),
-          doc.description?.trim() ? p(...renderUrl(doc.description)) : null,
-          doc.tags.length
-            ? div({ class: "card-tags" }, doc.tags.map(tag =>
-                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
-              ))
-            : null,
-          div({ class: 'card-comments-summary' },
-            span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
-            span({ class: 'card-value' }, String(commentCount)),
+          title ? h2(title) : null,
+          doc?.url
+            ? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` })
+            : p(i18n.documentNoFile),
+          safeText(doc.description) ? p(...renderUrl(doc.description)) : null,
+          renderTags(doc.tags),
+          div(
+            { class: "card-comments-summary" },
+            span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
+            span({ class: "card-value" }, String(commentCount)),
             br(),
             br(),
-            form({ method: 'GET', action: `/documents/${encodeURIComponent(doc.key)}` },
-              button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+            form(
+              { method: "GET", action: `/documents/${encodeURIComponent(doc.key)}` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              input({ type: "hidden", name: "filter", value: filter || "all" }),
+              params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
+              params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
+              button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
             )
           ),
           br(),
-          p({ class: 'card-footer' },
-            span({ class: 'date-link' }, `${moment(doc.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-            a({ href: `/author/${encodeURIComponent(doc.author)}`, class: 'user-link' }, doc.author)
-          ),
-          div({ class: "voting-buttons" },
-            opinionCategories.map(category =>
-              form({ method: "POST", action: `/documents/opinions/${encodeURIComponent(doc.key)}/${category}` },
-                button({ class: "vote-btn" },
+          (() => {
+            const createdTs = doc.createdAt ? new Date(doc.createdAt).getTime() : NaN;
+            const updatedTs = doc.updatedAt ? new Date(doc.updatedAt).getTime() : NaN;
+            const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
+
+            return p(
+              { class: "card-footer" },
+              span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+              a({ href: `/author/${encodeURIComponent(doc.author)}`, class: "user-link" }, `${doc.author}`),
+              showUpdated
+                ? span(
+                    { class: "votations-comment-date" },
+                    ` | ${i18n.documentUpdatedAt}: ${moment(doc.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
+                  )
+                : null
+            );
+          })(),
+          div(
+            { class: "voting-buttons" },
+            opinionCategories.map((category) =>
+              form(
+                { method: "POST", action: `/documents/opinions/${encodeURIComponent(doc.key)}/${category}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
+                button(
+                  { class: "vote-btn" },
                   `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${doc.opinions?.[category] || 0}]`
                 )
               )
@@ -158,127 +229,229 @@ const renderDocumentList = (filteredDocs, filter) => {
           )
         );
       })
-    : div(i18n.noDocuments);
+    : p(params.q ? i18n.documentNoMatch : i18n.noDocuments);
 };
 
-const renderDocumentForm = (filter, documentId, docToEdit) => {
-  return div({ class: "div-center document-form" },
-    form({
-      action: filter === 'edit' ? `/documents/update/${encodeURIComponent(documentId)}` : "/documents/create",
-      method: "POST", enctype: "multipart/form-data"
-    },
-      label(i18n.documentFileLabel), br(),
-      input({ type: "file", name: "document", accept: "application/pdf", required: filter !== "edit" }), br(), br(),
-      label(i18n.documentTagsLabel), br(),
-      input({ type: "text", name: "tags", placeholder: i18n.documentTagsPlaceholder, value: docToEdit?.tags?.join(', ') || '' }), br(), br(),
-      label(i18n.documentTitleLabel), br(),
-      input({ type: "text", name: "title", placeholder: i18n.documentTitlePlaceholder, value: docToEdit?.title || '' }), br(), br(),
-      label(i18n.documentDescriptionLabel), br(),
-      textarea({ name: "description", placeholder: i18n.documentDescriptionPlaceholder, rows: "4", value: docToEdit?.description || '' }), br(), br(),
-      button({ type: "submit" }, filter === 'edit' ? i18n.documentUpdateButton : i18n.documentCreateButton)
+const renderDocumentForm = (filter, documentId, docToEdit, params = {}) => {
+  const returnFilter = filter === "create" ? "all" : params.filter || "all";
+  const returnTo = safeText(params.returnTo) || buildReturnTo(returnFilter, params);
+  const tagsValue = safeArr(docToEdit?.tags).join(", ");
+
+  return div(
+    { class: "div-center document-form" },
+    form(
+      {
+        action: filter === "edit" ? `/documents/update/${encodeURIComponent(documentId)}` : "/documents/create",
+        method: "POST",
+        enctype: "multipart/form-data"
+      },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      label(i18n.documentFileLabel),
+      br(),
+      input({ type: "file", name: "document", accept: "application/pdf", required: filter !== "edit" }),
+      br(),
+      br(),
+      label(i18n.documentTagsLabel),
+      br(),
+      input({ type: "text", name: "tags", placeholder: i18n.documentTagsPlaceholder, value: tagsValue }),
+      br(),
+      br(),
+      label(i18n.documentTitleLabel),
+      br(),
+      input({ type: "text", name: "title", placeholder: i18n.documentTitlePlaceholder, value: docToEdit?.title || "" }),
+      br(),
+      br(),
+      label(i18n.documentDescriptionLabel),
+      br(),
+      textarea({ name: "description", placeholder: i18n.documentDescriptionPlaceholder, rows: "4" }, docToEdit?.description || ""),
+      br(),
+      br(),
+      button({ type: "submit" }, filter === "edit" ? i18n.documentUpdateButton : i18n.documentCreateButton)
     )
   );
 };
 
-exports.documentView = async (documents, filter, documentId) => {
-  const title = filter === 'mine' ? i18n.documentMineSectionTitle :
-                filter === 'create' ? i18n.documentCreateSectionTitle :
-                filter === 'edit' ? i18n.documentUpdateSectionTitle :
-                filter === 'recent' ? i18n.documentRecentSectionTitle :
-                filter === 'top' ? i18n.documentTopSectionTitle :
-                i18n.documentAllSectionTitle;
+exports.documentView = async (documents, filter = "all", documentId = null, params = {}) => {
+  const title =
+    filter === "mine"
+      ? i18n.documentMineSectionTitle
+      : filter === "create"
+        ? i18n.documentCreateSectionTitle
+        : filter === "edit"
+          ? i18n.documentUpdateSectionTitle
+          : filter === "recent"
+            ? i18n.documentRecentSectionTitle
+            : filter === "top"
+              ? i18n.documentTopSectionTitle
+              : filter === "favorites"
+                ? i18n.documentFavoritesSectionTitle
+                : i18n.documentAllSectionTitle;
 
-  const filteredDocs = getFilteredDocuments(filter, documents, userId);
-  const docToEdit = documents.find(d => d.key === documentId);
-  const isDocView = ['mine', 'create', 'edit', 'all', 'recent', 'top'].includes(filter);
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+
+  const list = safeArr(documents);
+  const docToEdit = documentId ? list.find((d) => d.key === documentId) : null;
 
   const tpl = template(
     title,
     section(
-      div({ class: "tags-header" },
-        h2(title),
-        p(i18n.documentDescription)
-      ),
-      div({ class: "filters" },
-        form({ method: "GET", action: "/documents" },
-          button({ type: "submit", name: "filter", value: "all", class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.documentFilterAll),
-          button({ type: "submit", name: "filter", value: "mine", class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.documentFilterMine),
-          button({ type: "submit", name: "filter", value: "recent", class: filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.documentFilterRecent),
-          button({ type: "submit", name: "filter", value: "top", class: filter === 'top' ? 'filter-btn active' : 'filter-btn' }, i18n.documentFilterTop),
+      div({ class: "tags-header" }, h2(title), p(i18n.documentDescription)),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/documents", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterMine),
+          button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterTop),
+          button(
+            { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
+            i18n.documentFilterFavorites
+          ),
+          button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterRecent),
           button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.documentCreateButton)
         )
       )
     ),
     section(
-      (filter === 'create' || filter === 'edit')
-        ? renderDocumentForm(filter, documentId, docToEdit)
-        : renderDocumentList(filteredDocs, filter)
+      filter === "create" || filter === "edit"
+        ? renderDocumentForm(filter, documentId, docToEdit || {}, { ...params, filter })
+        : section(
+            div(
+              { class: "documents-search" },
+              form(
+                { method: "GET", action: "/documents", class: "filter-box" },
+                input({ type: "hidden", name: "filter", value: filter }),
+                input({ type: "text", name: "q", value: q, placeholder: i18n.documentSearchPlaceholder, class: "filter-box__input" }),
+                div(
+                  { class: "filter-box__controls" },
+                  select(
+                    { name: "sort", class: "filter-box__select" },
+                    option({ value: "recent", selected: sort === "recent" }, i18n.documentSortRecent),
+                    option({ value: "oldest", selected: sort === "oldest" }, i18n.documentSortOldest),
+                    option({ value: "top", selected: sort === "top" }, i18n.documentSortTop)
+                  ),
+                  button({ type: "submit", class: "filter-box__button" }, i18n.documentSearchButton)
+                )
+              )
+            ),
+            div({ class: "documents-list" }, renderDocumentList(list, filter, { q, sort }))
+          )
     )
   );
 
-  return `${tpl}${isDocView
-    ? `<script type="module" src="/js/pdf.min.mjs"></script>
-       <script src="/js/pdf-viewer.js"></script>`
-    : ''}`;
+  return `${tpl}<script type="module" src="/js/pdf.min.mjs"></script><script src="/js/pdf-viewer.js"></script>`;
 };
 
-exports.singleDocumentView = async (doc, filter, comments = []) => {
-  const isAuthor = doc.author === userId;
+exports.singleDocumentView = async (doc, filter = "all", comments = [], params = {}) => {
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+  const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
+
+  const isAuthor = String(doc.author) === String(userId);
   const hasOpinions = Object.keys(doc.opinions || {}).length > 0;
 
+  const title = safeText(doc.title);
+  const pdfId = safeDomId("pdf-container-", doc.key);
+
+  const topbarLeft =
+    doc.author && String(doc.author) !== String(userId)
+      ? div(
+          { class: "bookmark-topbar-left" },
+          form(
+            { method: "GET", action: "/pm" },
+            input({ type: "hidden", name: "recipients", value: doc.author }),
+            button({ type: "submit", class: "filter-btn" }, i18n.documentMessageAuthorButton)
+          )
+        )
+      : null;
+
+  const topbarRight = div(
+    { class: "bookmark-actions" },
+    renderFavoriteToggle(doc, returnTo),
+    isAuthor && !hasOpinions
+      ? form(
+          { method: "GET", action: `/documents/edit/${encodeURIComponent(doc.key)}` },
+          input({ type: "hidden", name: "returnTo", value: returnTo }),
+          button({ class: "update-btn", type: "submit" }, i18n.documentUpdateButton)
+        )
+      : null,
+    isAuthor
+      ? form(
+          { method: "POST", action: `/documents/delete/${encodeURIComponent(doc.key)}` },
+          input({ type: "hidden", name: "returnTo", value: returnTo }),
+          button({ class: "delete-btn", type: "submit" }, i18n.documentDeleteButton)
+        )
+      : null
+  );
+
   const tpl = template(
     i18n.documentTitle,
     section(
-      div({ class: "filters" },
-        form({ method: "GET", action: "/documents" },
-          button({ type: "submit", name: "filter", value: "all", class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.documentFilterAll),
-          button({ type: "submit", name: "filter", value: "mine", class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.documentFilterMine),
-          button({ type: "submit", name: "filter", value: "recent", class: filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.documentFilterRecent),
-          button({ type: "submit", name: "filter", value: "top", class: filter === 'top' ? 'filter-btn active' : 'filter-btn' }, i18n.documentFilterTop),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/documents", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterMine),
+          button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterTop),
+          button(
+            { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
+            i18n.documentFilterFavorites
+          ),
+          button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterRecent),
           button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.documentCreateButton)
         )
       ),
-      div({ class: "tags-header" },
-        isAuthor ? div({ class: "document-actions" },
-          !hasOpinions
-            ? form({ method: "GET", action: `/documents/edit/${encodeURIComponent(doc.key)}` },
-                button({ class: "update-btn", type: "submit" }, i18n.documentUpdateButton)
-              )
-            : null,
-          form({ method: "POST", action: `/documents/delete/${encodeURIComponent(doc.key)}` },
-            button({ class: "delete-btn", type: "submit" }, i18n.documentDeleteButton)
-          )
-        ) : null,
-        h2(doc.title),
-        div({
-          id: `pdf-container-${doc.key}`,
-          class: 'pdf-viewer-container',
-          'data-pdf-url': `/blob/${encodeURIComponent(doc.url)}`
-        }),
-        p(...renderUrl(doc.description)),
-        doc.tags.length
-          ? div({ class: "card-tags" }, doc.tags.map(tag =>
-              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
-            ))
-          : null,
+      div(
+        { class: "bookmark-item card" },
+        div({ class: "bookmark-topbar" }, topbarLeft, topbarRight),
+        title ? h2(title) : null,
+        doc?.url
+          ? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` })
+          : p(i18n.documentNoFile),
+        safeText(doc.description) ? p(...renderUrl(doc.description)) : null,
+        renderTags(doc.tags),
         br(),
-        p({ class: 'card-footer' },
-          span({ class: 'date-link' }, `${moment(doc.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-          a({ href: `/author/${encodeURIComponent(doc.author)}`, class: 'user-link' }, `${doc.author}`)
-        )
-      ),
-      div({ class: "voting-buttons" },
-        opinionCategories.map(category =>
-          form({ method: "POST", action: `/documents/opinions/${encodeURIComponent(doc.key)}/${category}` },
-            button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${doc.opinions?.[category] || 0}]`)
+        (() => {
+          const createdTs = doc.createdAt ? new Date(doc.createdAt).getTime() : NaN;
+          const updatedTs = doc.updatedAt ? new Date(doc.updatedAt).getTime() : NaN;
+          const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
+
+          return p(
+            { class: "card-footer" },
+            span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+            a({ href: `/author/${encodeURIComponent(doc.author)}`, class: "user-link" }, `${doc.author}`),
+            showUpdated
+              ? span(
+                  { class: "votations-comment-date" },
+                  ` | ${i18n.documentUpdatedAt}: ${moment(doc.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
+                )
+              : null
+          );
+        })(),
+        div(
+          { class: "voting-buttons" },
+          opinionCategories.map((category) =>
+            form(
+              { method: "POST", action: `/documents/opinions/${encodeURIComponent(doc.key)}/${category}` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              button(
+                { class: "vote-btn" },
+                `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${doc.opinions?.[category] || 0}]`
+              )
+            )
           )
         )
       ),
-      renderDocumentCommentsSection(doc.key, comments)
+      div({ id: "comments" }, renderDocumentCommentsSection(doc.key, doc.rootId || doc.key, comments, returnTo))
     )
   );
 
-  return `${tpl}<script type="module" src="/js/pdf.min.mjs"></script>
-<script src="/js/pdf-viewer.js"></script>`;
+  return `${tpl}<script type="module" src="/js/pdf.min.mjs"></script><script src="/js/pdf-viewer.js"></script>`;
 };
 

+ 419 - 272
src/views/event_view.js

@@ -1,344 +1,491 @@
 const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option } = require("../server/node_modules/hyperaxe");
-const { template, i18n } = require('./main_views');
+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 { config } = require("../server/SSB_server.js");
+const { renderUrl } = require("../backend/renderUrl");
 
-const userId = config.keys.id
+const userId = config.keys.id;
 
-const renderStyledField = (labelText, valueElement) =>
-  div({ class: 'card-field' },
-    span({ class: 'card-label' }, labelText),
-    span({ class: 'card-value' }, ...renderUrl(valueElement))
+const opt = (value, isSelected, text) =>
+  option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text);
+
+const safeArray = (v) => (Array.isArray(v) ? v : []);
+
+const toValueChildren = (v) => {
+  if (v === undefined || v === null) return [];
+  if (Array.isArray(v)) return v;
+  if (typeof v === "string") return renderUrl(v);
+  if (typeof v === "number" || typeof v === "boolean") return renderUrl(String(v));
+  return [v];
+};
+
+const normalizePrivacy = (v) => {
+  const s = String(v || "public").toLowerCase();
+  return s === "private" ? "private" : "public";
+};
+
+const privacyLabel = (v) => (normalizePrivacy(v) === "private" ? i18n.eventPrivate : i18n.eventPublic);
+
+const safeExternalHref = (url) => {
+  const s = String(url || "").trim();
+  const lower = s.toLowerCase();
+  if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:")) return s;
+  return "";
+};
+
+const renderCardField = (labelText, valueNode) =>
+  div(
+    { class: "card-field" },
+    span({ class: "card-label" }, labelText),
+    span({ class: "card-value" }, ...toValueChildren(valueNode))
+  );
+
+const normalizeEventStatus = (v) => {
+  const up = String(v || "").toUpperCase();
+  if (up === "OPEN" || up === "CLOSED") return up;
+  return up || "OPEN";
+};
+
+const eventStatusLabel = (v) => {
+  const st = normalizeEventStatus(v);
+  if (st === "OPEN") return i18n.eventStatusOpen;
+  if (st === "CLOSED") return i18n.eventStatusClosed;
+  return st;
+};
+
+const attendanceLabel = (isAttending) => (isAttending ? i18n.eventAttended : i18n.eventUnattended);
+
+const renderEventOwnerActions = (e, returnTo) => {
+  const st = normalizeEventStatus(e.status);
+  if (e.organizer !== userId || st !== "OPEN") return [];
+  return [
+    form(
+      { method: "GET", action: `/events/edit/${encodeURIComponent(e.id)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ type: "submit", class: "update-btn" }, i18n.eventUpdateButton)
+    ),
+    form(
+      { method: "POST", action: `/events/delete/${encodeURIComponent(e.id)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ type: "submit", class: "delete-btn" }, i18n.eventDeleteButton)
+    )
+  ];
+};
+
+const renderEventAttendAction = (e, isAttending, returnTo) => {
+  const st = normalizeEventStatus(e.status);
+  if (st !== "OPEN") return null;
+  return form(
+    { method: "POST", action: `/events/attend/${encodeURIComponent(e.id)}` },
+    input({ type: "hidden", name: "returnTo", value: returnTo }),
+    button({ type: "submit", class: "filter-btn" }, attendanceLabel(isAttending))
   );
-  
-const renderEventCommentsSection = (eventId, comments = []) => {
+};
+
+const renderEventTopbar = (e, filter, opts = {}) => {
+  const currentFilter = filter || "all";
+  const isSingle = !!opts.single;
+
+  const returnToList = `/events?filter=${encodeURIComponent(currentFilter)}`;
+  const returnToSelf = `/events/${encodeURIComponent(e.id)}?filter=${encodeURIComponent(currentFilter)}`;
+  const rt = isSingle ? returnToSelf : returnToList;
+
+  const attendees = safeArray(e.attendees);
+  const isAttending = attendees.includes(userId);
+
+  const leftActions = [];
+
+  if (!isSingle) {
+    leftActions.push(
+      form(
+        { method: "GET", action: `/events/${encodeURIComponent(e.id)}` },
+        input({ type: "hidden", name: "filter", value: currentFilter }),
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+      )
+    );
+  }
+
+  if (e.organizer && e.organizer !== userId) {
+    leftActions.push(
+      form(
+        { method: "GET", action: "/pm" },
+        input({ type: "hidden", name: "recipients", value: e.organizer }),
+        button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
+      )
+    );
+  }
+
+  const rightActions = [];
+  const attendNode = renderEventAttendAction(e, isAttending, rt);
+  if (attendNode) rightActions.push(attendNode);
+
+  const ownerActions = renderEventOwnerActions(e, rt);
+  if (ownerActions.length) rightActions.push(...ownerActions);
+
+  const leftNode = leftActions.length ? div({ class: "bookmark-topbar-left event-topbar-left" }, ...leftActions) : null;
+  const rightNode = rightActions.length ? div({ class: "bookmark-actions event-actions" }, ...rightActions) : null;
+
+  const nodes = [];
+  if (leftNode) nodes.push(leftNode);
+  if (rightNode) nodes.push(rightNode);
+
+  return nodes.length ? div({ class: isSingle ? "bookmark-topbar event-topbar-single" : "bookmark-topbar" }, ...nodes) : null;
+};
+
+const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all") => {
   const commentsCount = Array.isArray(comments) ? comments.length : 0;
+  const returnTo = `/events/${encodeURIComponent(eventId)}?filter=${encodeURIComponent(currentFilter || "all")}`;
 
-  return div({ class: 'vote-comments-section' },
-    div({ class: 'comments-count' },
-      span({ class: 'card-label' }, i18n.voteCommentsLabel + ': '),
-      span({ class: 'card-value' }, String(commentsCount))
+  return div(
+    { class: "vote-comments-section" },
+    div(
+      { class: "comments-count" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
+      span({ class: "card-value" }, String(commentsCount))
     ),
-    div({ class: 'comment-form-wrapper' },
-      h2({ class: 'comment-form-title' }, i18n.voteNewCommentLabel),
-      form({
-        method: 'POST',
-        action: `/events/${encodeURIComponent(eventId)}/comments`,
-        class: 'comment-form'
-      },
+    div(
+      { class: "comment-form-wrapper" },
+      h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
+      form(
+        { method: "POST", action: `/events/${encodeURIComponent(eventId)}/comments`, class: "comment-form" },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
         textarea({
-          id: 'comment-text',
-          name: 'text',
+          id: "comment-text",
+          name: "text",
           required: true,
           rows: 4,
-          class: 'comment-textarea',
+          class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         br(),
-        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+        button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
     ),
     comments && comments.length
-      ? div({ class: 'comments-list' },
-          comments.map(c => {
-            const author = c.value && c.value.author ? c.value.author : '';
+      ? div(
+          { class: "comments-list" },
+          comments.map((c) => {
+            const author = c.value && c.value.author ? c.value.author : "";
             const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
-            const absDate = ts ? moment(ts).format('YYYY/MM/DD HH:mm:ss') : '';
-            const relDate = ts ? moment(ts).fromNow() : '';
-            const userName = author && author.includes('@') ? author.split('@')[1] : author;
+            const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
+            const relDate = ts ? moment(ts).fromNow() : "";
+            const userName = author && author.includes("@") ? author.split("@")[1] : author;
 
-            return div({ class: 'votations-comment-card' },
-              span({ class: 'created-at' },
+            const content = c.value && c.value.content ? c.value.content : {};
+            const root = content.fork || content.root || "";
+            const text = content.text || "";
+
+            return div(
+              { class: "votations-comment-card" },
+              span(
+                { class: "created-at" },
                 span(i18n.createdBy),
-                author
-                  ? a(
-                      { href: `/author/${encodeURIComponent(author)}` },
-                      `@${userName}`
-                    )
-                  : span('(unknown)'),
-                absDate ? span(' | ') : '',
-                absDate ? span({ class: 'votations-comment-date' }, absDate) : '',
-                relDate ? span({ class: 'votations-comment-date' }, ' | ', i18n.sendTime) : '',
-                relDate
-                  ? a(
-                      {
-                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
-                      },
-                      relDate
-                    )
-                  : ''
+                author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
+                absDate ? span(" | ") : "",
+                absDate ? span({ class: "votations-comment-date" }, absDate) : "",
+                relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
+                relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
               ),
-              p({
-                class: 'votations-comment-text',
-                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
-              })
+              p({ class: "votations-comment-text" }, ...renderUrl(String(text)))
             );
           })
         )
-      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+      : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
   );
-};  
-
-const renderEventItem = (e, filter, userId) => {
-  const actions = [];
-  if (filter === 'mine' && e.status === 'OPEN') {
-    actions.push(
-      form({ method:"GET", action:`/events/edit/${encodeURIComponent(e.id)}` },
-        button({ type:"submit", class:"update-btn" }, i18n.eventUpdateButton)
-      ),
-      form({ method:"POST", action:`/events/delete/${encodeURIComponent(e.id)}` },
-        button({ type:"submit", class:"delete-btn" }, i18n.eventDeleteButton)
-      )
-    );
-  }
-  if (e.status === 'OPEN') {
-    actions.push(
-      form({ method:"POST", action:`/events/attend/${encodeURIComponent(e.id)}` },
-        button({ type:"submit" },
-          e.attendees.includes(userId)
-            ? i18n.eventUnattendButton
-            : i18n.eventAttendButton
-        )
-      )
-    );
-  }
+};
 
-  const commentCount = typeof e.commentCount === 'number' ? e.commentCount : 0;
+const renderEventItem = (e, filter) => {
+  const currentFilter = filter || "all";
+  const attendees = safeArray(e.attendees);
+  const commentCount = typeof e.commentCount === "number" ? e.commentCount : 0;
+  const urlHref = safeExternalHref(e.url);
 
-  return div({ class:"card card-section event" },
-    actions.length ? div({ class:"event-actions" }, ...actions) : null,
-    form({ method:"GET", action:`/events/${encodeURIComponent(e.id)}` },
-      button({ type:"submit", class:"filter-btn" }, i18n.viewDetails)
-    ),
-    br,
-    renderStyledField(i18n.eventTitleLabel + ':', e.title),
-    renderStyledField(i18n.eventDescriptionLabel + ':'),
+  const topbar = renderEventTopbar(e, currentFilter, { single: false });
+
+  return div(
+    { class: "card card-section event" },
+    topbar ? topbar : null,
+    renderCardField(i18n.eventTitleLabel + ":", e.title),
+    renderCardField(i18n.eventDescriptionLabel + ":", ""),
     p(...renderUrl(e.description)),
-    renderStyledField(i18n.eventDateLabel + ':', moment(e.date).format('YYYY/MM/DD HH:mm:ss')),
-    e.location?.trim() ? renderStyledField(i18n.eventLocationLabel + ':', e.location) : null,
-    renderStyledField(i18n.eventPrivacyLabel + ':', e.isPublic.toUpperCase()),
-    renderStyledField(i18n.eventStatus + ':', e.status),
-    e.url?.trim() ? renderStyledField(i18n.eventUrlLabel + ':', a({ href: e.url }, e.url)) : null,
-    renderStyledField(i18n.eventPriceLabel + ':', parseFloat(e.price || 0).toFixed(6) + ' ECO'),
-    br,
-    div({ class: 'card-field' },
-      span({ class: 'card-label' }, i18n.eventAttendees + ':'),
-      span({ class: 'card-value' },
-        Array.isArray(e.attendees) && e.attendees.length
-          ? e.attendees.filter(Boolean).map((id, i) => [i > 0 ? ', ' : '', a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
+    renderCardField(i18n.eventDateLabel + ":", e.date ? moment(e.date).format("YYYY/MM/DD HH:mm:ss") : ""),
+    e.location && String(e.location).trim() ? renderCardField(i18n.eventLocationLabel + ":", e.location) : null,
+    renderCardField(i18n.eventPrivacyLabel + ":", privacyLabel(e.isPublic)),
+    renderCardField(i18n.eventStatus + ":", eventStatusLabel(e.status)),
+    urlHref ? renderCardField(i18n.eventUrlLabel + ":", a({ href: urlHref, target: "_blank", rel: "noopener noreferrer" }, urlHref)) : null,
+    renderCardField(i18n.eventPriceLabel + ":", parseFloat(e.price || 0).toFixed(6) + " ECO"),
+    br(),
+    div(
+      { class: "card-field" },
+      span({ class: "card-label" }, i18n.eventAttendees + ":"),
+      span(
+        { class: "card-value" },
+        attendees.length
+          ? attendees
+              .filter(Boolean)
+              .map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)])
+              .flat()
           : i18n.noAttendees
       )
     ),
-    br,
+    br(),
     e.tags && e.tags.filter(Boolean).length
-      ? div({ class: 'card-tags' },
-          e.tags.filter(Boolean).map(tag =>
-            a({
-              href:`/search?query=%23${encodeURIComponent(tag)}`,
-              class:"tag-link"
-            }, `#${tag}`)
-          )
+      ? div(
+          { class: "card-tags" },
+          e.tags.filter(Boolean).map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
         )
       : null,
-    div({ class: 'card-comments-summary' },
-      span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
-      span({ class: 'card-value' }, String(commentCount)),
-      br, br,
-      form({ method: 'GET', action: `/events/${encodeURIComponent(e.id)}` },
-        button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+    div(
+      { class: "card-comments-summary" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
+      span({ class: "card-value" }, String(commentCount)),
+      br(),
+      br(),
+      form(
+        { method: "GET", action: `/events/${encodeURIComponent(e.id)}` },
+        input({ type: "hidden", name: "filter", value: currentFilter }),
+        button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
       )
     ),
-    br,
-    p({ class: 'card-footer' },
-      span({ class: 'date-link' }, `${e.createdAt} ${i18n.performed} `),
-      a({ href: `/author/${encodeURIComponent(e.organizer)}`, class: 'user-link' }, `${e.organizer}`)
+    br(),
+    p(
+      { class: "card-footer" },
+      span({ class: "date-link" }, `${moment(e.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+      a({ href: `/author/${encodeURIComponent(e.organizer)}`, class: "user-link" }, `${e.organizer}`)
     )
   );
 };
 
-exports.eventView = async (events, filter, eventId) => {
-  const list = Array.isArray(events) ? events : [events]
+exports.eventView = async (events, filter, eventId, returnTo) => {
+  const list = Array.isArray(events) ? events : [events];
+  const currentFilter = filter || "all";
+
   const title =
-    filter === 'mine'   ? i18n.eventMineSectionTitle :
-    filter === 'create' ? i18n.eventCreateSectionTitle :
-    filter === 'edit'   ? i18n.eventUpdateSectionTitle :
-                          i18n.eventAllSectionTitle
-
-  const eventToEdit = list.find(e => e.id === eventId) || {}
-  const editTags = Array.isArray(eventToEdit.tags)
-    ? eventToEdit.tags.filter(Boolean)
-    : []
-
-  let filtered
-  if (filter === 'all') {
-    filtered = list.filter(e => String(e.isPublic).toLowerCase() === 'public')
-  } else if (filter === 'mine') {
-    filtered = list.filter(e => e.organizer === userId)
-  } else if (filter === 'today') {
-    filtered = list.filter(e => e.isPublic === "public" && moment(e.date).isSame(moment(), 'day'))
-  } else if (filter === 'week') {
-    filtered = list.filter(e => e.isPublic === "public" && moment(e.date).isBetween(moment(), moment().add(7, 'days'), null, '[]'))
-  } else if (filter === 'month') {
-    filtered = list.filter(e => e.isPublic === "public" && moment(e.date).isBetween(moment(), moment().add(1, 'month'), null, '[]'))
-  } else if (filter === 'year') {
-    filtered = list.filter(e => e.isPublic === "public" && moment(e.date).isBetween(moment(), moment().add(1, 'year'), null, '[]'))
-  } else if (filter === 'archived') {
-    filtered = list.filter(e => e.isPublic === "public" && e.status === 'CLOSED')
+    currentFilter === "mine" ? i18n.eventMineSectionTitle :
+    currentFilter === "create" ? i18n.eventCreateSectionTitle :
+    currentFilter === "edit" ? i18n.eventUpdateSectionTitle :
+    i18n.eventAllSectionTitle;
+
+  const eventToEdit = list.find((e) => e.id === eventId) || {};
+  const editTags = Array.isArray(eventToEdit.tags) ? eventToEdit.tags.filter(Boolean) : [];
+
+  const canSee = (e) => {
+    const isPub = normalizePrivacy(e.isPublic) === "public";
+    if (isPub) return true;
+    if (e.organizer === userId) return true;
+    return safeArray(e.attendees).includes(userId);
+  };
+
+  const visible = list.filter(canSee);
+
+  let filtered;
+  if (currentFilter === "all") {
+    filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public");
+  } else if (currentFilter === "mine") {
+    filtered = visible.filter((e) => e.organizer === userId);
+  } else if (currentFilter === "today") {
+    filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isSame(moment(), "day"));
+  } else if (currentFilter === "week") {
+    filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isBetween(moment(), moment().add(7, "days"), null, "[]"));
+  } else if (currentFilter === "month") {
+    filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isBetween(moment(), moment().add(1, "month"), null, "[]"));
+  } else if (currentFilter === "year") {
+    filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isBetween(moment(), moment().add(1, "year"), null, "[]"));
+  } else if (currentFilter === "archived") {
+    filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && normalizeEventStatus(e.status) === "CLOSED");
   } else {
-    filtered = []
+    filtered = [];
   }
 
-  filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+  filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+
+  const minCreate = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm");
+
+  const ret = typeof returnTo === "string" && returnTo.startsWith("/events") ? returnTo : "/events?filter=mine";
+  const editPrivacy = normalizePrivacy(eventToEdit.isPublic);
 
   return template(
     title,
     section(
-      div({ class: "tags-header" },
-        h2(i18n.eventsTitle),
-        p(i18n.eventsDescription)
-      ),
-      div({ class: "filters" },
-        form({ method: "GET", action: "/events" },
-          button({ type:"submit", name:"filter", value:"all", class:filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterAll),
-          button({ type:"submit", name:"filter", value:"mine", class:filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterMine),
-          button({ type:"submit", name:"filter", value:"today", class:filter === 'today' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterToday),
-          button({ type:"submit", name:"filter", value:"week", class:filter === 'week' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterWeek),
-          button({ type:"submit", name:"filter", value:"month", class:filter === 'month' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterMonth),
-          button({ type:"submit", name:"filter", value:"year", class:filter === 'year' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterYear),
-          button({ type:"submit", name:"filter", value:"archived", class:filter === 'archived' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterArchived),
-          button({ type:"submit", name:"filter", value:"create", class:"create-button" }, i18n.eventCreateButton)
+      div({ class: "tags-header" }, h2(i18n.eventsTitle), p(i18n.eventsDescription)),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/events" },
+          button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine),
+          button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday),
+          button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek),
+          button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth),
+          button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear),
+          button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton)
         )
       )
     ),
     section(
-      (filter === 'edit' || filter === 'create') ? (
-        div({ class: "event-form" },
-          form({
-            action: filter === 'edit'
-              ? `/events/update/${encodeURIComponent(eventId)}`
-              : "/events/create",
-            method: "POST"
-          },  
-            label(i18n.eventTitleLabel), br(),
-            input({ type:"text", name:"title", id:"title", required:true,
-              ...(filter==='edit'?{value:eventToEdit.title}:{})
-            }), br(), br(),
-            label(i18n.eventDescriptionLabel), br(),
-            textarea({ name:"description", id:"description", placeholder:i18n.eventDescriptionPlaceholder, rows:"4"}, filter === 'edit' ? eventToEdit.description : ''), br(), br(),
-            label(i18n.eventDateLabel), br(),
-            input({
-              type: "datetime-local",
-              name: "date",
-              id: "date",
-              required: true,
-              min: moment().format("YYYY-MM-DDTHH:mm"),
-              ...(filter === "edit"
-                ? { value: moment(eventToEdit.date).format("YYYY-MM-DDTHH:mm") }
-                : {}
-              )
-            }), br(), br(),
-            label(i18n.eventPrivacyLabel), br(),
-            select({ name:"isPublic", id:"isPublic",
-              ...(filter==='edit'?{value:eventToEdit.isPublic?'public':'private'}:{})
-            },
-              option({ value:'public' },  i18n.eventPublic),
-              option({ value:'private' }, i18n.eventPrivate)
-            ), br(), br(),
-            label(i18n.eventLocationLabel), br(),
-            input({ type:"text", name:"location", id:"location", required:true,
-              ...(filter==='edit'?{value:eventToEdit.location}:{})
-            }), br(), br(),
-            label(i18n.eventUrlLabel), br(),
-            input({ type:"url", name:"url", id:"url", value:eventToEdit.url||"" }), br(), br(),
-            label(i18n.eventPriceLabel), br(),
-            input({
-              type: "number",
-              name: "price",
-              id: "price",
-              min: "0.000000",
-              value: filter==='edit' ? parseFloat(eventToEdit.price||0).toFixed(6) : (0).toFixed(6),
-              step: "0.000000"
-            }), br(), br(),
-            label(i18n.eventTagsLabel), br(),
-            input({ type:"text", name:"tags", id:"tags", value: filter==='edit'? editTags.join(', '):'' }), br(), br(),
-            button({ type:"submit" }, filter==='edit'? i18n.eventUpdateButton : i18n.eventCreateButton)
+      currentFilter === "edit" || currentFilter === "create"
+        ? div(
+            { class: "event-form" },
+            form(
+              {
+                action: currentFilter === "edit" ? `/events/update/${encodeURIComponent(eventId)}` : "/events/create",
+                method: "POST"
+              },
+              input({ type: "hidden", name: "returnTo", value: ret }),
+              label(i18n.eventTitleLabel),
+              br(),
+              input({
+                type: "text",
+                name: "title",
+                id: "title",
+                required: true,
+                value: currentFilter === "edit" ? eventToEdit.title || "" : ""
+              }),
+              br(),
+              br(),
+              label(i18n.eventDescriptionLabel),
+              br(),
+              textarea(
+                { name: "description", id: "description", placeholder: i18n.eventDescriptionPlaceholder, rows: "4" },
+                currentFilter === "edit" ? eventToEdit.description || "" : ""
+              ),
+              br(),
+              br(),
+              label(i18n.eventDateLabel),
+              br(),
+              input({
+                type: "datetime-local",
+                name: "date",
+                id: "date",
+                required: true,
+                min: currentFilter === "create" ? minCreate : undefined,
+                value: currentFilter === "edit" && eventToEdit.date ? moment(eventToEdit.date).format("YYYY-MM-DDTHH:mm") : ""
+              }),
+              br(),
+              br(),
+              label(i18n.eventPrivacyLabel),
+              br(),
+              select(
+                { name: "isPublic", id: "isPublic" },
+                opt("public", editPrivacy !== "private", i18n.eventPublic),
+                opt("private", editPrivacy === "private", i18n.eventPrivate)
+              ),
+              br(),
+              br(),
+              label(i18n.eventLocationLabel),
+              br(),
+              input({
+                type: "text",
+                name: "location",
+                id: "location",
+                required: true,
+                value: currentFilter === "edit" ? eventToEdit.location || "" : ""
+              }),
+              br(),
+              br(),
+              label(i18n.eventUrlLabel),
+              br(),
+              input({ type: "url", name: "url", id: "url", value: currentFilter === "edit" ? eventToEdit.url || "" : "" }),
+              br(),
+              br(),
+              label(i18n.eventPriceLabel),
+              br(),
+              input({
+                type: "number",
+                name: "price",
+                id: "price",
+                min: "0.000000",
+                step: "0.000001",
+                value: currentFilter === "edit" ? parseFloat(eventToEdit.price || 0).toFixed(6) : (0).toFixed(6)
+              }),
+              br(),
+              br(),
+              label(i18n.eventTagsLabel),
+              br(),
+              input({ type: "text", name: "tags", id: "tags", value: currentFilter === "edit" ? editTags.join(", ") : "" }),
+              br(),
+              br(),
+              button({ type: "submit" }, currentFilter === "edit" ? i18n.eventUpdateButton : i18n.eventCreateButton)
+            )
           )
-        )
-      ) : (
-        div({ class:"event-list" },
-          filtered.length > 0
-            ? filtered.map(e => renderEventItem(e, filter, userId))
-            : p(i18n.noevents)
-        )
-      )
+        : div({ class: "event-list" }, filtered.length > 0 ? filtered.map((e) => renderEventItem(e, currentFilter)) : p(i18n.noevents))
     )
-  )
-}
+  );
+};
 
 exports.singleEventView = async (event, filter, comments = []) => {
+  const currentFilter = filter || "all";
+  const commentCount = typeof event.commentCount === "number" ? event.commentCount : 0;
+  const attendees = safeArray(event.attendees);
+  const urlHref = safeExternalHref(event.url);
+
+  const topbar = renderEventTopbar(event, currentFilter, { single: true });
+
   return template(
     event.title,
     section(
-      div({ class: "filters" },
-        form({ method: 'GET', action: '/events' },
-          button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterAll),
-          button({ type: 'submit', name: 'filter', value: 'mine', class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterMine),
-          button({ type: 'submit', name: 'filter', value: 'today', class: filter === 'today' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterToday),
-          button({ type: 'submit', name: 'filter', value: 'week', class: filter === 'week' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterWeek),
-          button({ type: 'submit', name: 'filter', value: 'month', class: filter === 'month' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterMonth),
-          button({ type: 'submit', name: 'filter', value: 'year', class: filter === 'year' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterYear),
-          button({ type: 'submit', name: 'filter', value: 'archived', class: filter === 'archived' ? 'filter-btn active' : 'filter-btn' }, i18n.eventFilterArchived),
-          button({ type: 'submit', name: 'filter', value: 'create', class: "create-button" }, i18n.eventCreateButton)
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/events" },
+          button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine),
+          button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday),
+          button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek),
+          button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth),
+          button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear),
+          button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton)
         )
       ),
-      div({ class: "event-actions" },
-        form({ method: "POST", action: `/events/attend/${encodeURIComponent(event.id)}` },
-          button({ type: "submit" },
-            event.attendees.includes(userId)
-              ? i18n.eventUnattendButton
-              : i18n.eventAttendButton
-          )
-        )
-      ),
-      div({ class: "card card-section event" },
-        form({ method:"GET", action:`/events/${encodeURIComponent(event.id)}` },
-          button({ type:"submit", class:"filter-btn" }, i18n.viewDetails)
-        ),
-        br,
-        renderStyledField(i18n.eventTitleLabel + ':', event.title),
-        renderStyledField(i18n.eventDescriptionLabel + ':'),
+      div(
+        { class: "card card-section event" },
+        topbar ? topbar : null,
+        renderCardField(i18n.eventTitleLabel + ":", event.title),
+        renderCardField(i18n.eventDescriptionLabel + ":", ""),
         p(...renderUrl(event.description)),
-        renderStyledField(i18n.eventDateLabel + ':', moment(event.date).format('YYYY/MM/DD HH:mm:ss')),
-        event.location?.trim() ? renderStyledField(i18n.eventLocationLabel + ':', event.location) : null,
-        renderStyledField(i18n.eventPrivacyLabel + ':', event.isPublic.toUpperCase()),
-        renderStyledField(i18n.eventStatus + ':', event.status),
-        event.url?.trim() ? renderStyledField(i18n.eventUrlLabel + ':', a({ href: event.url }, event.url)) : null,
-        renderStyledField(i18n.eventPriceLabel + ':', parseFloat(event.price || 0).toFixed(6) + ' ECO'),
-        br,
-        div({ class: 'card-field' },
-          span({ class: 'card-label' }, i18n.eventAttendees + ':'),
-          span({ class: 'card-value' },
-            Array.isArray(event.attendees) && event.attendees.length
-              ? event.attendees.filter(Boolean).map((id, i) => [i > 0 ? ', ' : '', a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
+        renderCardField(i18n.eventDateLabel + ":", event.date ? moment(event.date).format("YYYY/MM/DD HH:mm:ss") : ""),
+        event.location && String(event.location).trim() ? renderCardField(i18n.eventLocationLabel + ":", event.location) : null,
+        renderCardField(i18n.eventPrivacyLabel + ":", privacyLabel(event.isPublic)),
+        renderCardField(i18n.eventStatus + ":", eventStatusLabel(event.status)),
+        urlHref ? renderCardField(i18n.eventUrlLabel + ":", a({ href: urlHref, target: "_blank", rel: "noopener noreferrer" }, urlHref)) : null,
+        renderCardField(i18n.eventPriceLabel + ":", parseFloat(event.price || 0).toFixed(6) + " ECO"),
+        br(),
+        div(
+          { class: "card-field" },
+          span({ class: "card-label" }, i18n.eventAttendees + ":"),
+          span(
+            { class: "card-value" },
+            attendees.length
+              ? attendees
+                  .filter(Boolean)
+                  .map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)])
+                  .flat()
               : i18n.noAttendees
           )
         ),
-        br,
-        event.tags && event.tags.length
-          ? div({ class: 'card-tags' },
-              event.tags.filter(Boolean).map(tag =>
-                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
-              )
+        br(),
+        event.tags && event.tags.filter(Boolean).length
+          ? div(
+              { class: "card-tags" },
+              event.tags.filter(Boolean).map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
             )
           : null,
-        br,
-        p({ class: 'card-footer' },
-          span({ class: 'date-link' }, `${moment(event.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-          a({ href: `/author/${encodeURIComponent(event.organizer)}`, class: 'user-link' }, `${event.organizer}`)
+        div(
+          { class: "card-comments-summary" },
+          span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
+          span({ class: "card-value" }, String(commentCount))
+        ),
+        br(),
+        p(
+          { class: "card-footer" },
+          span({ class: "date-link" }, `${moment(event.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+          a({ href: `/author/${encodeURIComponent(event.organizer)}`, class: "user-link" }, `${event.organizer}`)
         )
       ),
-      renderEventCommentsSection(event.id, comments)
+      renderEventCommentsSection(event.id, comments, currentFilter)
     )
   );
 };
+

+ 144 - 0
src/views/favorites_view.js

@@ -0,0 +1,144 @@
+const { form, button, div, h2, p, section, input, a, span, img } = require("../server/node_modules/hyperaxe");
+
+const { template, i18n } = require("./main_views");
+const moment = require("../server/node_modules/moment");
+const { renderUrl } = require("../backend/renderUrl");
+
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+const safeText = (v) => String(v || "").trim();
+
+const buildReturnTo = (filter) => {
+  const f = safeText(filter || "all");
+  return `/favorites?filter=${encodeURIComponent(f)}`;
+};
+
+const renderTags = (tags) => {
+  const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
+  return list.length
+    ? div(
+        { class: "card-tags" },
+        list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
+      )
+    : null;
+};
+
+const renderBookmarkUrl = (item) => {
+  if (item.kind !== "bookmarks") return null;
+  if (!item.url) return null;
+  return p(
+    a(
+      { href: item.url, target: "_blank", rel: "noreferrer noopener", class: "bookmark-url" },
+      item.url
+    )
+  );
+};
+
+const renderImagePreview = (item) => {
+  if (item.kind !== "images") return null;
+  if (!item.url) return null;
+
+  return div(
+    { class: "image-container" },
+    a(
+      { href: item.viewHref },
+      img({
+        src: `/image/256/${encodeURIComponent(item.url)}`,
+        alt: item.title || "",
+        class: "media-preview",
+        loading: "lazy"
+      })
+    )
+  );
+};
+
+const renderFavoriteCard = (item, filter) => {
+  const returnTo = buildReturnTo(filter);
+
+  const titlePrefix = `[${String(item.kind || "").toUpperCase()}]`;
+  const title = safeText(item.title) || safeText(item.name) || safeText(item.category) || safeText(item.url) || "";
+
+  const ts = item.updatedAt || item.createdAt;
+  const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
+
+  return div(
+    { class: "tags-header bookmark-card" },
+    div(
+      { class: "bookmark-topbar" },
+      div(
+        { class: "bookmark-topbar-left" },
+        form(
+          { method: "GET", action: item.viewHref },
+          input({ type: "hidden", name: "returnTo", value: returnTo }),
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        form(
+          {
+            method: "POST",
+            action: `/favorites/remove/${encodeURIComponent(item.kind)}/${encodeURIComponent(item.favId)}`,
+            class: "bookmark-favorite-form"
+          },
+          input({ type: "hidden", name: "returnTo", value: returnTo }),
+          button({ type: "submit", class: "filter-btn" }, i18n.favoritesRemoveButton)
+        )
+      )
+    ),
+    title ? h2(`${titlePrefix} ${title}`) : h2(titlePrefix),
+    renderImagePreview(item),
+    renderBookmarkUrl(item),
+    safeText(item.description) ? p(...renderUrl(item.description)) : null,
+    renderTags(item.tags),
+    p(
+      { class: "card-footer" },
+      absDate ? span({ class: "date-link" }, `${absDate} ${i18n.performed} `) : "",
+      item.author ? a({ href: `/author/${encodeURIComponent(item.author)}`, class: "user-link" }, `${item.author}`) : ""
+    )
+  );
+};
+
+exports.favoritesView = async (items, filter = "all", counts = {}) => {
+  const c = counts || {};
+  const total = typeof c.all === "number" ? c.all : safeArr(items).length;
+
+  return template(
+    i18n.favoritesTitle,
+    section(
+      div({ class: "tags-header" }, h2(i18n.favoritesTitle), p(i18n.favoritesDescription)),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/favorites", class: "ui-toolbar ui-toolbar--filters" },
+          button(
+            { type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" },
+            `${i18n.favoritesFilterAll} (${total})`
+          ),
+          button(
+            { type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" },
+            `${i18n.favoritesFilterRecent} (${total})`
+          ),
+          button(
+            { type: "submit", name: "filter", value: "audios", class: filter === "audios" ? "filter-btn active" : "filter-btn" },
+            `${i18n.favoritesFilterAudios} (${c.audios || 0})`
+          ),
+          button(
+            { type: "submit", name: "filter", value: "bookmarks", class: filter === "bookmarks" ? "filter-btn active" : "filter-btn" },
+            `${i18n.favoritesFilterBookmarks} (${c.bookmarks || 0})`
+          ),
+          button(
+            { type: "submit", name: "filter", value: "documents", class: filter === "documents" ? "filter-btn active" : "filter-btn" },
+            `${i18n.favoritesFilterDocuments} (${c.documents || 0})`
+          ),
+          button(
+            { type: "submit", name: "filter", value: "images", class: filter === "images" ? "filter-btn active" : "filter-btn" },
+            `${i18n.favoritesFilterImages} (${c.images || 0})`
+          ),
+          button(
+            { type: "submit", name: "filter", value: "videos", class: filter === "videos" ? "filter-btn active" : "filter-btn" },
+            `${i18n.favoritesFilterVideos} (${c.videos || 0})`
+          )
+        )
+      ),
+      div({ class: "bookmark-list" }, safeArr(items).length ? safeArr(items).map((it) => renderFavoriteCard(it, filter)) : p(i18n.favoritesNoItems))
+    )
+  );
+};
+

+ 437 - 244
src/views/image_view.js

@@ -1,327 +1,520 @@
-const { form, button, div, h2, p, section, input, label, br, a, img, span, textarea } = require("../server/node_modules/hyperaxe");
+const { form, button, div, h2, p, section, input, label, br, a, img, span, textarea, select, option } =
+  require("../server/node_modules/hyperaxe");
+
 const moment = require("../server/node_modules/moment");
-const { template, i18n } = require('./main_views');
-const { config } = require('../server/SSB_server.js');
-const { renderUrl } = require('../backend/renderUrl');
-const opinionCategories = require('../backend/opinion_categories');
+const { template, i18n } = require("./main_views");
+const { config } = require("../server/SSB_server.js");
+const { renderUrl } = require("../backend/renderUrl");
+const opinionCategories = require("../backend/opinion_categories");
 
 const userId = config.keys.id;
 
-const getFilteredImages = (filter, images, userId) => {
-  const now = Date.now();
-  let filtered =
-    filter === 'mine' ? images.filter(img => img.author === userId) :
-    filter === 'recent' ? images.filter(img => new Date(img.createdAt).getTime() >= now - 86400000) :
-    filter === 'meme' ? images.filter(img => img.meme) :
-    filter === 'top' ? [...images].sort((a, b) => {
-      const sum = o => Object.values(o || {}).reduce((s, n) => s + n, 0);
-      return sum(b.opinions) - sum(a.opinions);
-    }) :
-    images;
-
-  return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+const safeText = (v) => String(v || "").trim();
+
+const buildReturnTo = (filter, params = {}) => {
+  const f = safeText(filter || "all");
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+  const parts = [`filter=${encodeURIComponent(f)}`];
+  if (q) parts.push(`q=${encodeURIComponent(q)}`);
+  if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
+  return `/images?${parts.join("&")}`;
 };
 
-const renderImageActions = (filter, imgObj) => {
-  return filter === 'mine' ? div({ class: "image-actions" },
-    form({ method: "GET", action: `/images/edit/${encodeURIComponent(imgObj.key)}` },
-      button({ class: "update-btn", type: "submit" }, i18n.imageUpdateButton)
-    ),
-    form({ method: "POST", action: `/images/delete/${encodeURIComponent(imgObj.key)}` },
+const renderPMButton = (recipient, className = "filter-btn") => {
+  const r = safeText(recipient);
+  if (!r) return null;
+  if (String(r) === String(userId)) return null;
+
+  return form(
+    { method: "GET", action: "/pm" },
+    input({ type: "hidden", name: "recipients", value: r }),
+    button({ type: "submit", class: className }, i18n.privateMessage)
+  );
+};
+
+const renderTags = (tags) => {
+  const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
+  return list.length
+    ? div(
+        { class: "card-tags" },
+        list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
+      )
+    : null;
+};
+
+const renderImageFavoriteToggle = (imgObj, returnTo = "") =>
+  form(
+    {
+      method: "POST",
+      action: imgObj.isFavorite
+        ? `/images/favorites/remove/${encodeURIComponent(imgObj.key)}`
+        : `/images/favorites/add/${encodeURIComponent(imgObj.key)}`
+    },
+    returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
+    button(
+      { type: "submit", class: "filter-btn" },
+      imgObj.isFavorite ? i18n.imageRemoveFavoriteButton : i18n.imageAddFavoriteButton
+    )
+  );
+
+const renderImageMedia = (imgObj, filter, params = {}) => {
+  const src = imgObj?.url ? `/blob/${encodeURIComponent(imgObj.url)}` : "";
+
+  return imgObj?.url
+    ? div(
+        { class: "image-container", style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
+        a(
+          {
+            href: `/images/${encodeURIComponent(imgObj.key)}?filter=${encodeURIComponent(filter || "all")}${
+              params.q ? `&q=${encodeURIComponent(params.q)}` : ""
+            }${params.sort ? `&sort=${encodeURIComponent(params.sort)}` : ""}`
+          },
+          img({ src, alt: imgObj.title || "", class: "media-preview", loading: "lazy" })
+        )
+      )
+    : p(i18n.imageNoFile);
+};
+
+const renderImageOwnerActions = (filter, imgObj, params = {}) => {
+  const returnTo = buildReturnTo(filter, params);
+  const isAuthor = String(imgObj.author) === String(userId);
+  const hasOpinions = Object.keys(imgObj.opinions || {}).length > 0;
+
+  if (!isAuthor) return [];
+
+  const items = [];
+  if (!hasOpinions) {
+    items.push(
+      form(
+        { method: "GET", action: `/images/edit/${encodeURIComponent(imgObj.key)}` },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
+        button({ class: "update-btn", type: "submit" }, i18n.imageUpdateButton)
+      )
+    );
+  }
+  items.push(
+    form(
+      { method: "POST", action: `/images/delete/${encodeURIComponent(imgObj.key)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
       button({ class: "delete-btn", type: "submit" }, i18n.imageDeleteButton)
     )
-  ) : null;
+  );
+
+  return items;
 };
 
-const renderImageList = (filteredImages, filter) => {
-  return filteredImages.length > 0
-    ? filteredImages.map(imgObj => {
-        const commentCount = typeof imgObj.commentCount === 'number' ? imgObj.commentCount : 0;
-        return div({ class: "tags-header" },
-          renderImageActions(filter, imgObj),
-          form({ method: "GET", action: `/images/${encodeURIComponent(imgObj.key)}` },
-            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-          ),
-          imgObj.title ? h2(imgObj.title) : null,
-          a({ href: `#img-${encodeURIComponent(imgObj.key)}` },
-            img({ src: `/blob/${encodeURIComponent(imgObj.url)}` })
-          ),
-          imgObj.description ? p(...renderUrl(imgObj.description)) : null,
-          imgObj.tags?.length
-            ? div({ class: "card-tags" },
-                imgObj.tags.map(tag =>
-                  a(
-                    {
-                      href: `/search?query=%23${encodeURIComponent(tag)}`,
-                      class: "tag-link"
-                    },
-                    `#${tag}`
-                  )
-                )
-              )
-            : null,
-          div({ class: 'card-comments-summary' },
-            span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
-            span({ class: 'card-value' }, String(commentCount)),
-            br, br,
-            form({ method: 'GET', action: `/images/${encodeURIComponent(imgObj.key)}` },
-              button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
-            )
-          ),
-          br,
-          p({ class: 'card-footer' },
-            span(
-              { class: 'date-link' },
-              `${moment(imgObj.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `
+const renderImageList = (images, filter, params = {}) => {
+  const returnTo = buildReturnTo(filter, params);
+  return images.length
+    ? images.map((imgObj) => {
+        const commentCount = typeof imgObj.commentCount === "number" ? imgObj.commentCount : 0;
+        const title = safeText(imgObj.title);
+        const ownerActions = renderImageOwnerActions(filter, imgObj, params);
+
+        return div(
+          { class: "tags-header image-card" },
+          div(
+            { class: "bookmark-topbar" },
+            div(
+              { class: "bookmark-topbar-left" },
+              form(
+                { method: "GET", action: `/images/${encodeURIComponent(imgObj.key)}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
+                input({ type: "hidden", name: "filter", value: filter || "all" }),
+                params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
+                params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
+                button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+              ),
+              renderImageFavoriteToggle(imgObj, returnTo),
+              renderPMButton(imgObj.author)
             ),
-            a(
-              { href: `/author/${encodeURIComponent(imgObj.author)}`, class: 'user-link' },
-              `${imgObj.author}`
+            ownerActions.length ? div({ class: "bookmark-actions" }, ...ownerActions) : null
+          ),
+          title ? h2(title) : null,
+          renderImageMedia(imgObj, filter, params),
+          safeText(imgObj.description) ? p(...renderUrl(imgObj.description)) : null,
+          renderTags(imgObj.tags),
+          div(
+            { class: "card-comments-summary" },
+            span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
+            span({ class: "card-value" }, String(commentCount)),
+            br(),
+            br(),
+            form(
+              { method: "GET", action: `/images/${encodeURIComponent(imgObj.key)}` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              input({ type: "hidden", name: "filter", value: filter || "all" }),
+              params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
+              params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
+              button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
             )
           ),
-          div({ class: "voting-buttons" },
-            opinionCategories.map(category =>
-              form({ method: "POST", action: `/images/opinions/${encodeURIComponent(imgObj.key)}/${category}` },
+          br(),
+          (() => {
+            const createdTs = imgObj.createdAt ? new Date(imgObj.createdAt).getTime() : NaN;
+            const updatedTs = imgObj.updatedAt ? new Date(imgObj.updatedAt).getTime() : NaN;
+            const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
+
+            return p(
+              { class: "card-footer" },
+              span({ class: "date-link" }, `${moment(imgObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+              a({ href: `/author/${encodeURIComponent(imgObj.author)}`, class: "user-link" }, `${imgObj.author}`),
+              showUpdated
+                ? span(
+                    { class: "votations-comment-date" },
+                    ` | ${i18n.imageUpdatedAt}: ${moment(imgObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
+                  )
+                : null
+            );
+          })(),
+          div(
+            { class: "voting-buttons" },
+            opinionCategories.map((category) =>
+              form(
+                { method: "POST", action: `/images/opinions/${encodeURIComponent(imgObj.key)}/${category}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
                 button(
                   { class: "vote-btn" },
-                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${imgObj.opinions?.[category] || 0}]`
+                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
+                    imgObj.opinions?.[category] || 0
+                  }]`
                 )
               )
             )
           )
         );
       })
-    : div(i18n.noImages);
+    : p(params.q ? i18n.imageNoMatch : i18n.noImages);
 };
 
-const renderImageForm = (filter, imageId, imageToEdit) => {
-  return div({ class: "div-center image-form" },
-    form({
-      action: filter === 'edit'
-        ? `/images/update/${encodeURIComponent(imageId)}`
-        : "/images/create",
-      method: "POST", enctype: "multipart/form-data"
-    },
-      label(i18n.imageFileLabel), br(),
-      input({ type: "file", name: "image", required: filter !== "edit" }), br(), br(),
-      imageToEdit?.url ? img({ src: `/blob/${encodeURIComponent(imageToEdit.url)}`, class: "image-detail" }) : null,
+const renderImageForm = (filter, imageId, imageToEdit, params = {}) => {
+  const returnFilter = filter === "create" ? "all" : params.filter || "all";
+  const returnTo = safeText(params.returnTo) || buildReturnTo(returnFilter, params);
+  const tagsValue = safeArr(imageToEdit?.tags).join(", ");
+
+  return div(
+    { class: "div-center image-form" },
+    form(
+      {
+        action: filter === "edit" ? `/images/update/${encodeURIComponent(imageId)}` : "/images/create",
+        method: "POST",
+        enctype: "multipart/form-data"
+      },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      label(i18n.imageFileLabel),
+      br(),
+      input({ type: "file", name: "image", required: filter !== "edit" }),
+      br(),
+      br(),
+      imageToEdit?.url
+        ? img({ src: `/blob/${encodeURIComponent(imageToEdit.url)}`, class: "media-preview", alt: imageToEdit?.title || "" })
+        : null,
+      br(),
+      label(i18n.imageTagsLabel),
+      br(),
+      input({ type: "text", name: "tags", placeholder: i18n.imageTagsPlaceholder, value: tagsValue }),
+      br(),
+      br(),
+      label(i18n.imageTitleLabel),
+      br(),
+      input({ type: "text", name: "title", placeholder: i18n.imageTitlePlaceholder, value: imageToEdit?.title || "" }),
       br(),
-      label(i18n.imageTagsLabel), br(),
-      input({ type: "text", name: "tags", placeholder: i18n.imageTagsPlaceholder, value: imageToEdit?.tags?.join(',') || '' }), br(), br(),
-      label(i18n.imageTitleLabel), br(),
-      input({ type: "text", name: "title", placeholder: i18n.imageTitlePlaceholder, value: imageToEdit?.title || '' }), br(), br(),
-      label(i18n.imageDescriptionLabel), br(),
-      textarea({ name: "description", placeholder: i18n.imageDescriptionPlaceholder, rows: "4", value: imageToEdit?.description || '' }), br(), br(),
+      br(),
+      label(i18n.imageDescriptionLabel),
+      br(),
+      textarea({ name: "description", placeholder: i18n.imageDescriptionPlaceholder, rows: "4" }, imageToEdit?.description || ""),
+      br(),
+      br(),
+      input({ type: "hidden", name: "meme", value: "0" }),
       label(i18n.imageMemeLabel),
-      input({ type: "checkbox", name: "meme", ...(imageToEdit?.meme ? { checked: true } : {}) }), br(), br(),
-      button({ type: "submit" }, filter === 'edit' ? i18n.imageUpdateButton : i18n.imageCreateButton)
+      br(),
+      input({
+        id: "meme-checkbox",
+        type: "checkbox",
+        name: "meme",
+        value: "1",
+        class: "meme-checkbox",
+        ...(imageToEdit?.meme ? { checked: true } : {})
+      }),
+      br(),
+      br(),
+      button({ type: "submit" }, filter === "edit" ? i18n.imageUpdateButton : i18n.imageCreateButton)
     )
   );
 };
 
-const renderGallery = (sortedImages) => {
-  return div({ class: "gallery" },
-    sortedImages.length
-      ? sortedImages.map(imgObj =>
-          a({ href: `#img-${encodeURIComponent(imgObj.key)}`, class: "gallery-item" },
-            img({ src: `/blob/${encodeURIComponent(imgObj.url)}`, alt: imgObj.title || "", class: "gallery-image" })
-          )
-        )
-      : div(i18n.noImages)
+const renderGallery = (images) => {
+  if (!images.length) return div(i18n.noImages);
+
+  return div(
+    { class: "gallery" },
+    images.map((imgObj) => {
+      const src = imgObj.url ? `/image/256/${encodeURIComponent(imgObj.url)}` : "";
+      return a(
+        { href: `#img-${encodeURIComponent(imgObj.key)}`, class: "gallery-item" },
+        img({ src, alt: imgObj.title || "", class: "gallery-image", loading: "lazy" })
+      );
+    })
   );
 };
 
-const renderLightbox = (sortedImages) => {
-  return sortedImages.map(imgObj =>
-    div(
+const renderLightbox = (images) =>
+  images.map((imgObj) => {
+    const src = imgObj.url ? `/blob/${encodeURIComponent(imgObj.url)}` : "";
+    return div(
       { id: `img-${encodeURIComponent(imgObj.key)}`, class: "lightbox" },
       a({ href: "#", class: "lightbox-close" }, "×"),
-      img({ src: `/blob/${encodeURIComponent(imgObj.url)}`, class: "lightbox-image", alt: imgObj.title || "" })
-    )
-  );
-};
+      img({ src, class: "lightbox-image", alt: imgObj.title || "" })
+    );
+  });
 
-const renderImageCommentsSection = (imageId, comments = []) => {
-  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+const renderImageCommentsSection = (imageKey, comments = [], returnTo = null) => {
+  const list = safeArr(comments);
+  const commentsCount = list.length;
 
-  return div({ class: 'vote-comments-section' },
-    div({ class: 'comments-count' },
-      span({ class: 'card-label' }, i18n.voteCommentsLabel + ': '),
-      span({ class: 'card-value' }, String(commentsCount))
+  return div(
+    { class: "vote-comments-section" },
+    div(
+      { class: "comments-count" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
+      span({ class: "card-value" }, String(commentsCount))
     ),
-    div({ class: 'comment-form-wrapper' },
-      h2({ class: 'comment-form-title' }, i18n.voteNewCommentLabel),
-      form({
-        method: 'POST',
-        action: `/images/${encodeURIComponent(imageId)}/comments`,
-        class: 'comment-form'
-      },
+    div(
+      { class: "comment-form-wrapper" },
+      h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
+      form(
+        { method: "POST", action: `/images/${encodeURIComponent(imageKey)}/comments`, class: "comment-form" },
+        returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
         textarea({
-          id: 'comment-text',
-          name: 'text',
+          id: "comment-text",
+          name: "text",
           required: true,
           rows: 4,
-          class: 'comment-textarea',
+          class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         br(),
-        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+        button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
     ),
-    comments && comments.length
-      ? div({ class: 'comments-list' },
-          comments.map(c => {
-            const author = c.value && c.value.author ? c.value.author : '';
-            const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
-            const absDate = ts ? moment(ts).format('YYYY/MM/DD HH:mm:ss') : '';
-            const relDate = ts ? moment(ts).fromNow() : '';
-            const userName = author && author.includes('@') ? author.split('@')[1] : author;
-
-            return div({ class: 'votations-comment-card' },
-              span({ class: 'created-at' },
+    list.length
+      ? div(
+          { class: "comments-list" },
+          list.map((c) => {
+            const author = c?.value?.author || "";
+            const ts = c?.value?.timestamp || c?.timestamp;
+            const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
+            const relDate = ts ? moment(ts).fromNow() : "";
+            const userName = author && author.includes("@") ? author.split("@")[1] : author;
+
+            const content = c?.value?.content || {};
+            const text = content.text || "";
+            const threadRoot = content.fork || content.root || null;
+
+            return div(
+              { class: "votations-comment-card" },
+              span(
+                { class: "created-at" },
                 span(i18n.createdBy),
-                author
-                  ? a(
-                      { href: `/author/${encodeURIComponent(author)}` },
-                      `@${userName}`
-                    )
-                  : span('(unknown)'),
-                absDate ? span(' | ') : '',
-                absDate ? span({ class: 'votations-comment-date' }, absDate) : '',
-                relDate ? span({ class: 'votations-comment-date' }, ' | ', i18n.sendTime) : '',
-                relDate
-                  ? a(
-                      {
-                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
-                      },
-                      relDate
-                    )
-                  : ''
+                author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
+                absDate ? span(" | ") : "",
+                absDate ? span({ class: "votations-comment-date" }, absDate) : "",
+                relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
+                relDate && threadRoot ? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate) : ""
               ),
-              p({
-                class: 'votations-comment-text',
-                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
-              })
+              p({ class: "votations-comment-text" }, ...renderUrl(text))
             );
           })
         )
-      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+      : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
   );
 };
 
-exports.imageView = async (images, filter, imageId) => {
-  const title = filter === 'mine' ? i18n.imageMineSectionTitle :
-                filter === 'create' ? i18n.imageCreateSectionTitle :
-                filter === 'edit' ? i18n.imageUpdateSectionTitle :
-                filter === 'gallery' ? i18n.imageGallerySectionTitle :
-                filter === 'meme' ? i18n.imageMemeSectionTitle :
-                filter === 'recent' ? i18n.imageRecentSectionTitle :
-                filter === 'top' ? i18n.imageTopSectionTitle :
-                i18n.imageAllSectionTitle;
+exports.imageView = async (images, filter = "all", imageId = null, params = {}) => {
+  const title =
+    filter === "mine"
+      ? i18n.imageMineSectionTitle
+      : filter === "create"
+        ? i18n.imageCreateSectionTitle
+        : filter === "edit"
+          ? i18n.imageUpdateSectionTitle
+          : filter === "gallery"
+            ? i18n.imageGallerySectionTitle
+            : filter === "meme"
+              ? i18n.imageMemeSectionTitle
+              : filter === "recent"
+                ? i18n.imageRecentSectionTitle
+                : filter === "top"
+                  ? i18n.imageTopSectionTitle
+                  : filter === "favorites"
+                    ? i18n.imageFavoritesSectionTitle
+                    : i18n.imageAllSectionTitle;
+
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
 
-  const filteredImages = getFilteredImages(filter, images, userId);
-  const imageToEdit = images.find(img => img.key === imageId);
+  const list = safeArr(images);
+  const imageToEdit = imageId ? list.find((im) => im.key === imageId) : null;
 
   return template(
     title,
     section(
-      div({ class: "tags-header" },
-        h2(i18n.imageCreateSectionTitle),
-        p(i18n.imageDescription)
-      ),
-      div({ class: "filters" },
-        form({ method: "GET", action: "/images" },
-          ["all", "mine", "recent", "top", "gallery", "meme"].map(f =>
-            button({
-              type: "submit", name: "filter", value: f,
-              class: filter === f ? "filter-btn active" : "filter-btn"
-            },
-              i18n[`imageFilter${f.charAt(0).toUpperCase() + f.slice(1)}`]
-            )
+      div({ class: "tags-header" }, h2(title), p(i18n.imageDescription)),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/images", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMine),
+          button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterRecent),
+          button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterTop),
+          button(
+            { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
+            i18n.imageFilterFavorites
+          ),
+          button(
+            { type: "submit", name: "filter", value: "gallery", class: filter === "gallery" ? "filter-btn active" : "filter-btn" },
+            i18n.imageFilterGallery
           ),
-          button({ type: "submit", name: "filter", value: "create", class: "create-button" },
-            i18n.imageCreateButton)
+          button({ type: "submit", name: "filter", value: "meme", class: filter === "meme" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMeme),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.imageCreateButton)
         )
       )
     ),
     section(
-      (filter === 'create' || filter === 'edit')
-        ? renderImageForm(filter, imageId, imageToEdit)
-        : filter === 'gallery'
-          ? renderGallery(filteredImages)
-          : renderImageList(filteredImages, filter)
+      filter === "create" || filter === "edit"
+        ? renderImageForm(filter, imageId, imageToEdit, { ...params, filter })
+        : section(
+            div(
+              { class: "images-search" },
+              form(
+                { method: "GET", action: "/images", class: "filter-box" },
+                input({ type: "hidden", name: "filter", value: filter }),
+                input({
+                  type: "text",
+                  name: "q",
+                  value: q,
+                  placeholder: i18n.imageSearchPlaceholder,
+                  class: "filter-box__input"
+                }),
+                div(
+                  { class: "filter-box__controls" },
+                  select(
+                    { name: "sort", class: "filter-box__select" },
+                    option({ value: "recent", selected: sort === "recent" }, i18n.imageSortRecent),
+                    option({ value: "oldest", selected: sort === "oldest" }, i18n.imageSortOldest),
+                    option({ value: "top", selected: sort === "top" }, i18n.imageSortTop)
+                  ),
+                  button({ type: "submit", class: "filter-box__button" }, i18n.imageSearchButton)
+                )
+              )
+            ),
+            filter === "gallery" ? renderGallery(list) : div({ class: "images-list" }, renderImageList(list, filter, { q, sort }))
+          )
     ),
-    ...renderLightbox(filteredImages)
+    ...(filter === "gallery" ? renderLightbox(list) : [])
   );
 };
 
-exports.singleImageView = async (image, filter, comments = []) => {
-  const isAuthor = image.author === userId;
-  const hasOpinions = Object.keys(image.opinions || {}).length > 0;
+exports.singleImageView = async (imageObj, filter = "all", comments = [], params = {}) => {
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+  const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
+
+  const title = safeText(imageObj.title);
+  const ownerActions = renderImageOwnerActions(filter, imageObj, { q, sort });
+
+  const topbar = div(
+    { class: "bookmark-topbar" },
+    div(
+      { class: "bookmark-topbar-left" },
+      renderImageFavoriteToggle(imageObj, returnTo),
+      renderPMButton(imageObj.author)
+    ),
+    ownerActions.length ? div({ class: "bookmark-actions" }, ...ownerActions) : null
+  );
 
   return template(
     i18n.imageTitle,
     section(
-      div({ class: "filters" },
-        form({ method: "GET", action: "/images" },
-          button({ type: "submit", name: "filter", value: "all", class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.imageFilterAll),
-          button({ type: "submit", name: "filter", value: "mine", class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.imageFilterMine),
-          button({ type: "submit", name: "filter", value: "meme", class: filter === 'meme' ? 'filter-btn active' : 'filter-btn' }, i18n.imageFilterMeme),
-          button({ type: "submit", name: "filter", value: "top", class: filter === 'top' ? 'filter-btn active' : 'filter-btn' }, i18n.imageFilterTop),
-          button({ type: "submit", name: "filter", value: "recent", class: filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.imageFilterRecent),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/images", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMine),
+          button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterRecent),
+          button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterTop),
+          button(
+            { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
+            i18n.imageFilterFavorites
+          ),
+          button(
+            { type: "submit", name: "filter", value: "gallery", class: filter === "gallery" ? "filter-btn active" : "filter-btn" },
+            i18n.imageFilterGallery
+          ),
+          button({ type: "submit", name: "filter", value: "meme", class: filter === "meme" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMeme),
           button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.imageCreateButton)
         )
       ),
-      div({ class: "tags-header" },
-        isAuthor ? div({ class: "image-actions" },
-          !hasOpinions
-            ? form({ method: "GET", action: `/images/edit/${encodeURIComponent(image.key)}` },
-                button({ class: "update-btn", type: "submit" }, i18n.imageUpdateButton)
-              )
-            : null,
-          form({ method: "POST", action: `/images/delete/${encodeURIComponent(image.key)}` },
-            button({ class: "delete-btn", type: "submit" }, i18n.imageDeleteButton)
-          )
-        ) : null,
-        h2(image.title),
-        image.url ? img({ src: `/blob/${encodeURIComponent(image.url)}` }) : null,
-        p(...renderUrl(image.description)),
-        image.tags?.length
-          ? div({ class: "card-tags" },
-              image.tags.map(tag =>
-                a(
-                  {
-                    href: `/search?query=%23${encodeURIComponent(tag)}`,
-                    class: "tag-link"
-                  },
-                  `#${tag}`
+      div(
+        { class: "bookmark-item card" },
+        topbar,
+        title ? h2(title) : null,
+        imageObj?.url
+          ? div(
+              { class: "image-container", style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
+              img({
+                src: `/blob/${encodeURIComponent(imageObj.url)}`,
+                alt: imageObj.title || "",
+                class: "media-preview",
+                loading: "lazy"
+              })
+            )
+          : p(i18n.imageNoFile),
+        safeText(imageObj.description) ? p(...renderUrl(imageObj.description)) : null,
+        renderTags(imageObj.tags),
+        br(),
+        (() => {
+          const createdTs = imageObj.createdAt ? new Date(imageObj.createdAt).getTime() : NaN;
+          const updatedTs = imageObj.updatedAt ? new Date(imageObj.updatedAt).getTime() : NaN;
+          const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
+
+          return p(
+            { class: "card-footer" },
+            span({ class: "date-link" }, `${moment(imageObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+            a({ href: `/author/${encodeURIComponent(imageObj.author)}`, class: "user-link" }, `${imageObj.author}`),
+            showUpdated
+              ? span(
+                  { class: "votations-comment-date" },
+                  ` | ${i18n.imageUpdatedAt}: ${moment(imageObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
                 )
+              : null
+          );
+        })(),
+        div(
+          { class: "voting-buttons" },
+          opinionCategories.map((category) =>
+            form(
+              { method: "POST", action: `/images/opinions/${encodeURIComponent(imageObj.key)}/${category}` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              button(
+                { class: "vote-btn" },
+                `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
+                  imageObj.opinions?.[category] || 0
+                }]`
               )
             )
-          : null,
-        br,
-        p({ class: 'card-footer' },
-          span(
-            { class: 'date-link' },
-            `${moment(image.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `
-          ),
-          a(
-            { href: `/author/${encodeURIComponent(image.author)}`, class: 'user-link' },
-            `${image.author}`
-          )
-        )
-      ),
-      div({ class: "voting-buttons" },
-        opinionCategories.map(category =>
-          form({ method: "POST", action: `/images/opinions/${encodeURIComponent(image.key)}/${category}` },
-            button(
-              { class: "vote-btn" },
-              `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${image.opinions?.[category] || 0}]`
-            )
           )
         )
       ),
-      renderImageCommentsSection(image.key, comments)
+      div({ id: "comments" }, renderImageCommentsSection(imageObj.key, comments, returnTo))
     )
   );
 };

+ 509 - 326
src/views/jobs_view.js

@@ -1,386 +1,569 @@
-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 { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, progress } = 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 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' }
-];
+  { key: "ALL", i18n: "jobsFilterAll", title: "jobsAllTitle" },
+  { key: "MINE", i18n: "jobsFilterMine", title: "jobsMineTitle" },
+  { key: "APPLIED", i18n: "jobsFilterApplied", title: "jobsAppliedTitle" },
+  { 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: "TOP", i18n: "jobsFilterTop", title: "jobsTopTitle" },
+  { key: "CV", i18n: "jobsCV", title: "jobsCVTitle" }
+]
 
 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';
+  if (typeof photoField === "string" && photoField.startsWith("/image/")) return photoField
+  if (typeof photoField === "string" && /^&[A-Za-z0-9+/=]+\.sha256$/.test(photoField)) return `/image/${size}/${encodeURIComponent(photoField)}`
+  return "/assets/images/default-avatar.png"
 }
 
+const safeArr = (v) => (Array.isArray(v) ? v : [])
+const safeText = (v) => String(v || "").trim()
+
+const parseNum = (v) => {
+  const n = parseFloat(String(v ?? "").replace(",", "."))
+  return Number.isFinite(n) ? n : NaN
+}
+
+const fmtSalary = (v) => {
+  const n = parseNum(v)
+  return Number.isFinite(n) ? n.toFixed(6) : String(v ?? "")
+}
+
+const buildReturnTo = (filter, params = {}) => {
+  const f = safeText(filter || "ALL")
+  const q = safeText(params.search || params.q || "")
+  const minSalary = params.minSalary ?? ""
+  const maxSalary = params.maxSalary ?? ""
+  const sort = safeText(params.sort || "")
+  const parts = [`filter=${encodeURIComponent(f)}`]
+  if (q) parts.push(`search=${encodeURIComponent(q)}`)
+  if (String(minSalary) !== "") parts.push(`minSalary=${encodeURIComponent(String(minSalary))}`)
+  if (String(maxSalary) !== "") parts.push(`maxSalary=${encodeURIComponent(String(maxSalary))}`)
+  if (sort) parts.push(`sort=${encodeURIComponent(sort)}`)
+  return `/jobs?${parts.join("&")}`
+}
+
+const renderPmButton = (recipientId) =>
+  recipientId && String(recipientId) !== String(userId)
+    ? form(
+        { method: "GET", action: "/pm" },
+        input({ type: "hidden", name: "recipients", value: recipientId }),
+        button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
+      )
+    : null
+
 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,
+  div(
+    { class: "card-field" },
+    span({ class: "card-label" }, labelText),
+    span({ class: "card-value" }, String(value ?? ""))
+  )
 
-          form({ method: "GET", action: `/jobs/${encodeURIComponent(job.id)}` },
-            button({ type: "submit", class: "filter-btn" }, i18n.viewDetailsButton)
-          ),
+const renderCardFieldRich = (labelText, parts) =>
+  div(
+    { class: "card-field" },
+    span({ class: "card-label" }, labelText),
+    span({ class: "card-value" }, ...(Array.isArray(parts) ? parts : [String(parts ?? "")]))
+  )
+
+const renderTags = (tags = []) => {
+  const arr = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean)
+  return arr.length
+    ? div(
+        { class: "card-tags" },
+        arr.map((tag) => a({ class: "tag-link", href: `/search?query=%23${encodeURIComponent(tag)}` }, `#${tag}`))
+      )
+    : null
+}
+
+const renderApplicantsProgress = (subsCount, vacants) => {
+  const s = Math.max(0, Number(subsCount || 0))
+  const v = Math.max(1, Number(vacants || 1))
+  return div(
+    { class: "confirmations-block" },
+    div(
+      { class: "card-field" },
+      span({ class: "card-label" }, `${i18n.jobsApplicants}: `),
+      span({ class: "card-value" }, `${s}/${v}`)
+    ),
+    progress({ class: "confirmations-progress", value: s, max: v })
+  )
+}
+
+const renderSubscribers = (subs = []) => {
+  const n = safeArr(subs).length
+  return div(
+    { class: "card-field" },
+    span({ class: "card-label" }, `${i18n.jobSubscribers}:`),
+    span({ class: "card-value" }, n > 0 ? String(n) : i18n.noSubscribers.toUpperCase())
+  )
+}
+
+const renderUpdatedLabel = (createdAt, updatedAt) => {
+  const createdTs = createdAt ? new Date(createdAt).getTime() : NaN
+  const updatedTs = updatedAt ? new Date(updatedAt).getTime() : NaN
+  const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs)
+  return showUpdated
+    ? span({ class: "votations-comment-date" }, ` | ${i18n.jobsUpdatedAt}: ${moment(updatedAt).format("YYYY/MM/DD HH:mm:ss")}`)
+    : null
+}
+
+const renderJobOwnerActions = (job, returnTo) => {
+  const isAuthor = String(job.author) === String(userId)
+  if (!isAuthor) return []
+  const isOpen = String(job.status || "").toUpperCase() === "OPEN"
+  return [
+    form(
+      { method: "POST", action: `/jobs/status/${encodeURIComponent(job.id)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ class: "status-btn", type: "submit", name: "status", value: isOpen ? "CLOSED" : "OPEN" }, isOpen ? i18n.jobSetClosed : i18n.jobSetOpen)
+    ),
+    form(
+      { method: "GET", action: `/jobs/edit/${encodeURIComponent(job.id)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ class: "update-btn", type: "submit" }, i18n.jobsUpdateButton)
+    ),
+    form(
+      { method: "POST", action: `/jobs/delete/${encodeURIComponent(job.id)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ class: "delete-btn", type: "submit" }, i18n.jobsDeleteButton)
+    )
+  ]
+}
+
+const renderJobTopbar = (job, filter, params = {}) => {
+  const returnTo = buildReturnTo(filter, params)
+  const isAuthor = String(job.author) === String(userId)
+  const isOpen = String(job.status || "").toUpperCase() === "OPEN"
+  const subs = safeArr(job.subscribers)
+  const isSubscribed = subs.includes(userId)
+  const isSingle = params && params.single === true
+
+  const chips = []
+  if (isSubscribed) chips.push(span({ class: "chip chip-you" }, i18n.jobsAppliedBadge))
+
+  const leftActions = []
+
+  if (!isSingle) {
+    leftActions.push(
+      form(
+        { method: "GET", action: `/jobs/${encodeURIComponent(job.id)}` },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
+        input({ type: "hidden", name: "filter", value: filter || "ALL" }),
+        params.search ? input({ type: "hidden", name: "search", value: params.search }) : null,
+        params.minSalary !== undefined ? input({ type: "hidden", name: "minSalary", value: String(params.minSalary ?? "") }) : null,
+        params.maxSalary !== undefined ? input({ type: "hidden", name: "maxSalary", value: String(params.maxSalary ?? "") }) : null,
+        params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetailsButton)
+      )
+    )
+  }
+
+  leftActions.push(renderPmButton(job.author))
+
+  if (!isAuthor && isOpen) {
+    leftActions.push(
+      isSubscribed
+        ? form(
+            { method: "POST", action: `/jobs/unsubscribe/${encodeURIComponent(job.id)}` },
+            input({ type: "hidden", name: "returnTo", value: returnTo }),
+            button({ type: "submit", class: "filter-btn" }, i18n.jobUnsubscribeButton)
+          )
+        : form(
+            { method: "POST", action: `/jobs/subscribe/${encodeURIComponent(job.id)}` },
+            input({ type: "hidden", name: "returnTo", value: returnTo }),
+            button({ type: "submit", class: "filter-btn" }, i18n.jobSubscribeButton)
+          )
+    )
+  }
+
+  const leftChildren = []
+  if (chips.length) leftChildren.push(div({ class: "transfer-chips" }, ...chips))
+  const leftActionNodes = leftActions.filter(Boolean)
+  if (leftActionNodes.length) leftChildren.push(...leftActionNodes)
+
+  const ownerActions = renderJobOwnerActions(job, returnTo)
+  const leftNode = leftChildren.length ? div({ class: "bookmark-topbar-left transfer-topbar-left" }, ...leftChildren) : null
+  const actionsNode = ownerActions.length ? div({ class: "bookmark-actions transfer-actions" }, ...ownerActions) : null
+
+  const topbarChildren = []
+  if (leftNode) topbarChildren.push(leftNode)
+  if (actionsNode) topbarChildren.push(actionsNode)
+
+  const topbarClass = isSingle ? "bookmark-topbar transfer-topbar-single" : "bookmark-topbar"
+  return topbarChildren.length ? div({ class: topbarClass }, ...topbarChildren) : null
+}
+
+const renderJobList = (jobs, filter, params = {}) => {
+  const returnTo = buildReturnTo(filter, params)
+  const list = safeArr(jobs)
+
+  return list.length
+    ? list.map((job) => {
+        const topbar = renderJobTopbar(job, filter, params)
+        const subs = safeArr(job.subscribers)
+        const tagsNode = renderTags(job.tags)
+        const salaryText = `${fmtSalary(job.salary)} ECO`
+
+        return div(
+          { class: "job-card" },
+          topbar ? topbar : null,
+          safeText(job.title) ? h2(job.title) : null,
+          job.image ? div({ class: "activity-image-preview" }, img({ src: `/blob/${encodeURIComponent(job.image)}` })) : null,
+          tagsNode ? tagsNode : null,
           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 + ':', ''),
+          safeText(job.description) ? renderCardFieldRich(`${i18n.jobDescription}:`, renderUrl(job.description)) : null,
           br(),
-          div({ class: 'card-label' }, h2(`${job.salary} ECO`)),
+          renderApplicantsProgress(subs.length, job.vacants),
+          renderSubscribers(subs),
+          renderCardField(`${i18n.jobStatus}:`, i18n["jobStatus" + String(job.status || "").toUpperCase()] || String(job.status || "").toUpperCase()),
+          renderCardField(`${i18n.jobLanguages}:`, String(job.languages || "").toUpperCase()),
+          renderCardField(`${i18n.jobType}:`, i18n["jobType" + String(job.job_type || "").toUpperCase()] || String(job.job_type || "").toUpperCase()),
+          renderCardField(`${i18n.jobLocation}:`, String(job.location || "").toUpperCase()),
+          renderCardField(`${i18n.jobTime}:`, i18n["jobTime" + String(job.job_time || "").toUpperCase()] || String(job.job_time || "").toUpperCase()),
+          renderCardField(`${i18n.jobVacants}:`, job.vacants),
+          safeText(job.requirements) ? renderCardFieldRich(`${i18n.jobRequirements}:`, renderUrl(job.requirements)) : null,
+          safeText(job.tasks) ? renderCardFieldRich(`${i18n.jobTasks}:`, renderUrl(job.tasks)) : null,
+          renderCardFieldRich(`${i18n.jobSalary}:`, [span({ class: "card-salary" }, salaryText)]),
           br(),
-          div({ class: 'card-comments-summary' },
-            span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
-            span({ class: 'card-value' }, String(job.commentCount || 0)),
-            br(), br(),
-            form({ method: 'GET', action: `/jobs/${encodeURIComponent(job.id)}` },
-              button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+          div(
+            { class: "card-comments-summary" },
+            span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
+            span({ class: "card-value" }, String(job.commentCount || 0)),
+            br(),
+            br(),
+            form(
+              { method: "GET", action: `/jobs/${encodeURIComponent(job.id)}#comments` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              input({ type: "hidden", name: "filter", value: filter || "ALL" }),
+              params.search ? input({ type: "hidden", name: "search", value: params.search }) : null,
+              params.minSalary !== undefined ? input({ type: "hidden", name: "minSalary", value: String(params.minSalary ?? "") }) : null,
+              params.maxSalary !== undefined ? input({ type: "hidden", name: "maxSalary", value: String(params.maxSalary ?? "") }) : null,
+              params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
+              button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
             )
           ),
-          div({ class: 'card-footer' },
-            span({ class: 'date-link' }, `${moment(job.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-            a({ href: `/author/${encodeURIComponent(job.author)}`, class: 'user-link' }, job.author)
+          p(
+            { 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),
+            renderUpdatedLabel(job.createdAt, job.updatedAt)
           )
-        );
+        )
       })
-    : p(i18n.noJobsFound);
+    : p(i18n.noJobsMatch || i18n.noJobsFound)
+}
 
-const renderJobForm = (job = {}, mode = 'create') => {
-  const isEdit = mode === 'edit';
-  return div({ class: "div-center job-form" },
-    form({
+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(),
+      input({ type: "hidden", name: "returnTo", value: "/jobs?filter=MINE" }),
+      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.jobsTagsLabel),
+      br(),
+      input({ type: "text", name: "tags", value: Array.isArray(job.tags) ? job.tags.join(", ") : (job.tags || "") }),
+      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.000001", min: "0", 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' },
+  div(
+    { class: "cv-list" },
+    safeArr(inhabitants).length
+      ? safeArr(inhabitants).map((user) => {
+          const isMe = String(user.id) === String(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)),
+              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
+                { class: "cv-actions" },
+                form({ method: "GET", action: `/inhabitant/${encodeURIComponent(user.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.inhabitantviewDetails)),
+                !isMe ? renderPmButton(user.id) : null
               )
             )
           )
         })
-      : p({ class: 'no-results' }, i18n.noInhabitantsFound)
-  );
+      : p({ class: "no-results" }, i18n.noInhabitantsFound)
+  )
 
-const renderJobCommentsSection = (jobId, comments = []) => {
-  const commentsCount = Array.isArray(comments) ? comments.length : 0;
-
-  return div({ class: 'vote-comments-section' },
-    div({ class: 'comments-count' },
-      span({ class: 'card-label' }, i18n.voteCommentsLabel + ': '),
-      span({ class: 'card-value' }, String(commentsCount))
-    ),
-    div({ class: 'comment-form-wrapper' },
-      h2({ class: 'comment-form-title' }, i18n.voteNewCommentLabel),
-      form({
-        method: 'POST',
-        action: `/jobs/${encodeURIComponent(jobId)}/comments`,
-        class: 'comment-form'
-      },
-        textarea({
-          id: 'comment-text',
-          name: 'text',
-          required: true,
-          rows: 4,
-          class: 'comment-textarea',
-          placeholder: i18n.voteNewCommentPlaceholder
-        }),
-        br(),
-        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
-      )
-    ),
-    comments && comments.length
-      ? div({ class: 'comments-list' },
-          comments.map(c => {
-            const author = c.value && c.value.author ? c.value.author : '';
-            const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
-            const absDate = ts ? moment(ts).format('YYYY/MM/DD HH:mm:ss') : '';
-            const relDate = ts ? moment(ts).fromNow() : '';
-            const userName = author && author.includes('@') ? author.split('@')[1] : author;
-            const rootId = c.value && c.value.content ? (c.value.content.fork || c.value.content.root) : null;
-
-            return div({ class: 'votations-comment-card' },
-              span({ class: 'created-at' },
-                span(i18n.createdBy),
-                author
-                  ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`)
-                  : span('(unknown)'),
-                absDate ? span(' | ') : '',
-                absDate ? span({ class: 'votations-comment-date' }, absDate) : '',
-                relDate ? span({ class: 'votations-comment-date' }, ' | ', i18n.sendTime) : '',
-                relDate && rootId
-                  ? a({
-                      href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}`
-                    }, relDate)
-                  : ''
-              ),
-              p({
-                class: 'votations-comment-text',
-                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
-              })
-            );
-          })
-        )
-      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
-  );
-};
+exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => {
+  const search = safeText(params.search || "")
+  const minSalary = params.minSalary ?? ""
+  const maxSalary = params.maxSalary ?? ""
+  const sort = safeText(params.sort || "recent")
 
-exports.jobsView = async (jobsOrCVs, filter = "ALL", cvQuery = {}) => {
-  const filterObj = FILTERS.find(f => f.key === filter) || FILTERS[0];
-  const sectionTitle = i18n[filterObj.title] || i18n.jobsTitle;
+  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 =>
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/jobs", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "search", value: search }),
+          input({ type: "hidden", name: "minSalary", value: String(minSalary ?? "") }),
+          input({ type: "hidden", name: "maxSalary", value: String(maxSalary ?? "") }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          ...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))
+          ),
+          button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.jobsCreateJob)
         )
-      ),
-      filter === 'CV'
+      )
+    ),
+    section(
+      filter === "CV"
         ? section(
-            form({ method: "GET", action: "/jobs" },
+            form(
+              { method: "GET", action: "/jobs", class: "cv-filter-form" },
               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)
+              input({ type: "text", name: "location", placeholder: i18n.filterLocation, value: params.location || "" }),
+              input({ type: "text", name: "language", placeholder: i18n.filterLanguage, value: params.language || "" }),
+              input({ type: "text", name: "skills", placeholder: i18n.filterSkills, value: params.skills || "" }),
+              button({ type: "submit", class: "filter-btn" }, i18n.applyFilters)
             ),
             br(),
             renderCVList(jobsOrCVs)
           )
-        : filter === 'CREATE' || filter === 'EDIT'
+        : filter === "CREATE" || filter === "EDIT"
           ? (() => {
-              const jobToEdit = filter === 'EDIT' ? jobsOrCVs[0] : {};
-              return renderJobForm(jobToEdit, filter === 'EDIT' ? 'edit' : 'create');
+              const jobToEdit = filter === "EDIT" ? (Array.isArray(jobsOrCVs) ? jobsOrCVs[0] : {}) : {}
+              return renderJobForm(jobToEdit, filter === "EDIT" ? "edit" : "create")
             })()
-          : div({ class: "jobs-list" }, renderJobList(jobsOrCVs, filter))
+          : section(
+              div(
+                { class: "jobs-search" },
+                form(
+                  { method: "GET", action: "/jobs", class: "filter-box" },
+                  input({ type: "hidden", name: "filter", value: filter || "ALL" }),
+                  input({ type: "text", name: "search", value: search, placeholder: i18n.jobsSearchPlaceholder, class: "filter-box__input" }),
+                  div(
+                    { class: "filter-box__controls" },
+                    div(
+                      { class: "transfer-range" },
+                      input({ type: "number", name: "minSalary", step: "0.000001", min: "0", value: String(minSalary ?? ""), placeholder: i18n.jobsMinSalaryLabel, class: "filter-box__number transfer-amount-input" }),
+                      input({ type: "number", name: "maxSalary", step: "0.000001", min: "0", value: String(maxSalary ?? ""), placeholder: i18n.jobsMaxSalaryLabel, class: "filter-box__number transfer-amount-input" })
+                    ),
+                    select(
+                      { name: "sort", class: "filter-box__select" },
+                      option({ value: "recent", selected: sort === "recent" }, i18n.jobsSortRecent),
+                      option({ value: "salary", selected: sort === "salary" }, i18n.jobsSortSalary),
+                      option({ value: "subscribers", selected: sort === "subscribers" }, i18n.jobsSortSubscribers)
+                    ),
+                    button({ type: "submit", class: "filter-box__button" }, i18n.jobsSearchButton)
+                  )
+                )
+              ),
+              br(),
+              div({ class: "jobs-list" }, renderJobList(jobsOrCVs, filter, { ...params, search, minSalary, maxSalary, sort }))
+            )
     )
-  );
-};
+  )
+}
+
+const renderJobCommentsSection = (jobId, returnTo, comments = []) => {
+  const list = safeArr(comments)
+  const commentsCount = list.length
+
+  return div(
+    { class: "vote-comments-section" },
+    div(
+      { class: "comments-count" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
+      span({ class: "card-value" }, String(commentsCount))
+    ),
+    div(
+      { class: "comment-form-wrapper" },
+      h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
+      form(
+        { method: "POST", action: `/jobs/${encodeURIComponent(jobId)}/comments`, class: "comment-form" },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
+        textarea({ id: "comment-text", name: "text", required: true, rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
+        br(),
+        button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
+      )
+    ),
+    list.length
+      ? div(
+          { class: "comments-list" },
+          list.map((c) => {
+            const author = c?.value?.author || ""
+            const ts = c?.value?.timestamp || c?.timestamp
+            const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
+            const relDate = ts ? moment(ts).fromNow() : ""
+            const userName = author && author.includes("@") ? author.split("@")[1] : author
+            const rootId = c?.value?.content ? (c.value.content.fork || c.value.content.root) : null
+            const text = c?.value?.content?.text || ""
+
+            return div(
+              { class: "votations-comment-card" },
+              span(
+                { class: "created-at" },
+                span(i18n.createdBy),
+                author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
+                absDate ? span(" | ") : "",
+                absDate ? span({ class: "votations-comment-date" }, absDate) : "",
+                relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
+                relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
+              ),
+              p({ class: "votations-comment-text" }, ...renderUrl(text))
+            )
+          })
+        )
+      : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
+  )
+}
+
+exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {}) => {
+  const returnTo = safeText(params.returnTo) || buildReturnTo(filter, params)
+  const topbar = renderJobTopbar(job, filter, { ...params, single: true })
+  const subs = safeArr(job.subscribers)
+  const tagsNode = renderTags(job.tags)
+  const salaryText = `${fmtSalary(job.salary)} ECO`
 
-exports.singleJobsView = async (job, filter = "ALL", comments = []) => {
-  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 =>
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/jobs", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "search", value: safeText(params.search || "") }),
+          input({ type: "hidden", name: "minSalary", value: String(params.minSalary ?? "") }),
+          input({ type: "hidden", name: "maxSalary", value: String(params.maxSalary ?? "") }),
+          input({ type: "hidden", name: "sort", value: safeText(params.sort || "recent") }),
+          ...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))
+          ),
+          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)
+      div(
+        { class: "job-card" },
+        topbar ? topbar : null,
+        safeText(job.title) ? h2(job.title) : null,
+        job.image ? div({ class: "activity-image-preview" }, img({ src: `/blob/${encodeURIComponent(job.image)}` })) : null,
+        tagsNode ? tagsNode : null,
+        br(),
+        safeText(job.description) ? renderCardFieldRich(`${i18n.jobDescription}:`, renderUrl(job.description)) : null,
+        br(),
+        renderApplicantsProgress(subs.length, job.vacants),
+        renderSubscribers(subs),
+        renderCardField(`${i18n.jobStatus}:`, i18n["jobStatus" + String(job.status || "").toUpperCase()] || String(job.status || "").toUpperCase()),
+        renderCardField(`${i18n.jobLanguages}:`, String(job.languages || "").toUpperCase()),
+        renderCardField(`${i18n.jobType}:`, i18n["jobType" + String(job.job_type || "").toUpperCase()] || String(job.job_type || "").toUpperCase()),
+        renderCardField(`${i18n.jobLocation}:`, String(job.location || "").toUpperCase()),
+        renderCardField(`${i18n.jobTime}:`, i18n["jobTime" + String(job.job_time || "").toUpperCase()] || String(job.job_time || "").toUpperCase()),
+        renderCardField(`${i18n.jobVacants}:`, job.vacants),
+        safeText(job.requirements) ? renderCardFieldRich(`${i18n.jobRequirements}:`, renderUrl(job.requirements)) : null,
+        safeText(job.tasks) ? renderCardFieldRich(`${i18n.jobTasks}:`, renderUrl(job.tasks)) : null,
+        renderCardFieldRich(`${i18n.jobSalary}:`, [span({ class: "card-salary" }, salaryText)]),
+        br(),
+        p(
+          { 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),
+          renderUpdatedLabel(job.createdAt, job.updatedAt)
         )
       ),
-      renderJobCommentsSection(job.id, comments)
+      div({ id: "comments" }, renderJobCommentsSection(job.id, returnTo, comments))
     )
-  );
-};
+  )
+}
+

+ 67 - 4
src/views/main_views.js

@@ -56,6 +56,53 @@ const nbsp = "\xa0";
 const { getConfig } = require('../configs/config-manager.js');
 
 // menu INIT
+const readPkg = () => {
+  const file = path.resolve(__dirname, "..", "server", "package.json");
+  try {
+    const txt = fs.readFileSync(file, "utf8");
+    const parsed = JSON.parse(txt || "{}");
+    return parsed && typeof parsed === "object" ? parsed : {};
+  } catch (_) {
+    return {};
+  }
+};
+
+const renderFooter = () => {
+  const pkg = readPkg();
+  const year = moment().format("YYYY");
+  const pkgName = pkg?.name || "@krakenslab/oasis";
+  const pkgVersion = pkg?.version || "?";
+
+  return div(
+    { class: "oasis-footer" },
+    div(
+      { class: "oasis-footer-center" },
+      a(
+        { href: "/", class: "oasis-footer-logo-link" },
+        img({
+          class: "oasis-footer-logo",
+          src: "/assets/images/snh-oasis.jpg",
+          alt: "Oasis"
+        })
+      ),
+      a(
+        { href: "https://code.03c8.net/krakenslab/oasis", target: "_blank", rel: "noreferrer noopener", class: "oasis-footer-license-link" },
+      span(pkgName),
+      ),
+      span("["),
+         span({ class: "oasis-footer-version" }, pkgVersion),
+      span("]"),
+      span({ class: "oasis-footer-sep" }, " - "),
+      a(
+        { href: "https://www.gnu.org/licenses/gpl-3.0.html", target: "_blank", rel: "noreferrer noopener", class: "oasis-footer-license-link" },
+        i18n.footerLicense
+      ),
+      span({ class: "oasis-footer-sep" }, " - "),
+      span({ class: "oasis-footer-year" }, year)
+    )
+  );
+};
+
 const navLink = ({ href, emoji, text, current, class: extraClass }) =>
   li(
     a(
@@ -539,6 +586,20 @@ const renderAgendaLink = () => {
     : "";
 };
 
+const renderFavoritesLink = () => {
+  const favoritesMod = getConfig().modules.favoritesMod === "on";
+  return favoritesMod
+    ? [
+        navLink({
+          href: "/favorites",
+          emoji: "ꘝ",
+          text: i18n.favoritesTitle,
+          class: "favorites-link enabled"
+        })
+      ]
+    : "";
+};
+
 const renderAILink = () => {
   const aiMod = getConfig().modules.aiMod === "on";
   return aiMod
@@ -667,6 +728,7 @@ const template = (titlePrefix, ...elements) => {
                   text: i18n.cvTitle
                 }),
                 renderAgendaLink(),
+                renderFavoritesLink(),
                 renderWalletLink(),
                 navLink({
                   href: "/modules",
@@ -799,16 +861,17 @@ const template = (titlePrefix, ...elements) => {
                   emoji: "▤",
                   title: i18n.menuMedia
                 },
+                renderAudiosLink(),
                 renderBookmarksLink(),
+                renderDocsLink(),
                 renderImagesLink(),
-                renderVideosLink(),
-                renderAudiosLink(),
-                renderDocsLink()
+                renderVideosLink()
               )
             )
           )
         )
-      )
+      ),
+    renderFooter()
     )
   );
   return doctypeString + nodes.outerHTML;

Dosya farkı çok büyük olduğundan ihmal edildi
+ 682 - 386
src/views/market_view.js


+ 1 - 0
src/views/modules_view.js

@@ -14,6 +14,7 @@ const modulesView = () => {
     { name: 'courts', label: i18n.modulesCourtsLabel, description: i18n.modulesCourtsDescription },
     { name: 'docs', label: i18n.modulesDocsLabel, description: i18n.modulesDocsDescription },
     { name: 'events', label: i18n.modulesEventsLabel, description: i18n.modulesEventsDescription },
+    { name: 'favorites', label: i18n.modulesFavoritesLabel, description: i18n.modulesFavoritesDescription },
     { name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription },
     { name: 'forum', label: i18n.modulesForumLabel, description: i18n.modulesForumDescription },
     { name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription },

Dosya farkı çok büyük olduğundan ihmal edildi
+ 628 - 495
src/views/projects_view.js


+ 572 - 211
src/views/report_view.js

@@ -1,289 +1,649 @@
 const { div, h2, p, section, button, form, a, textarea, br, input, img, span, label, select, option } = require("../server/node_modules/hyperaxe");
-const { template, i18n } = require('./main_views');
-const { config } = require('../server/SSB_server.js');
-const moment = require('../server/node_modules/moment');
-const { renderUrl } = require('../backend/renderUrl');
+const { template, i18n } = require("./main_views");
+const { config } = require("../server/SSB_server.js");
+const moment = require("../server/node_modules/moment");
+const { renderUrl } = require("../backend/renderUrl");
 
 const userId = config.keys.id;
 
-const renderCardField = (labelText, value) =>
-  div({ class: 'card-field' },
-    span({ class: 'card-label' }, labelText),
-    span({ class: 'card-value' }, ...renderUrl(value))
+const normU = (v) => String(v || "").trim().toUpperCase();
+const normalizeStatus = (v) => normU(v).replace(/\s+/g, "_").replace(/-+/g, "_");
+
+const CATEGORY_BY_FILTER = {
+  features: "FEATURES",
+  bugs: "BUGS",
+  abuse: "ABUSE",
+  content: "CONTENT"
+};
+
+const STATUS_BY_FILTER = {
+  open: "OPEN",
+  under_review: "UNDER_REVIEW",
+  resolved: "RESOLVED",
+  invalid: "INVALID",
+  closed: "CLOSED"
+};
+
+const opt = (value, isSelected, text) =>
+  option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text);
+
+const hasAnyTemplateValue = (t) => {
+  if (!t || typeof t !== "object") return false;
+  return Object.values(t).some((v) => String(v || "").trim());
+};
+
+const renderCardField = (labelText, value = "") =>
+  div(
+    { class: "card-field" },
+    span({ class: "card-label" }, labelText),
+    span({ class: "card-value" }, ...renderUrl(String(value ?? "")))
+  );
+
+const renderStackedTextField = (lbl, val) =>
+  String(val || "").trim()
+    ? div(
+        { class: "card-field card-field-stacked" },
+        span({ class: "card-label" }, lbl),
+        br(),
+        span({ class: "card-value" }, ...renderUrl(String(val)))
+      )
+    : null;
+
+const renderPmButton = (recipientId) =>
+  recipientId && String(recipientId) !== String(userId)
+    ? form(
+        { method: "GET", action: "/pm" },
+        input({ type: "hidden", name: "recipients", value: recipientId }),
+        button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
+      )
+    : null;
+
+const renderReportOwnerActions = (report, currentFilter) => {
+  const st = normalizeStatus(report && report.status ? report.status : "OPEN");
+
+  return div(
+    { class: "bookmark-actions report-actions" },
+    form(
+      { method: "GET", action: `/reports/edit/${encodeURIComponent(report.id)}` },
+      button({ type: "submit", class: "update-btn" }, i18n.reportsUpdateButton)
+    ),
+    form(
+      { method: "POST", action: `/reports/delete/${encodeURIComponent(report.id)}` },
+      button({ type: "submit", class: "delete-btn" }, i18n.reportsDeleteButton)
+    ),
+    form(
+      { method: "POST", action: `/reports/status/${encodeURIComponent(report.id)}`, class: "project-control-form project-control-form--status" },
+      select(
+        { name: "status", class: "project-control-select" },
+        opt("OPEN", st === "OPEN", i18n.reportsStatusOpen),
+        opt("UNDER_REVIEW", st === "UNDER_REVIEW", i18n.reportsStatusUnderReview),
+        opt("RESOLVED", st === "RESOLVED", i18n.reportsStatusResolved),
+        opt("INVALID", st === "INVALID", i18n.reportsStatusInvalid),
+        opt("CLOSED", st === "CLOSED", i18n.reportsStatusClosed || "CLOSED")
+      ),
+      button({ class: "status-btn project-control-btn", type: "submit" }, i18n.reportsSetStatus || i18n.projectSetStatus || "Set status")
+    )
   );
-  
+};
+
+const renderReportTopbar = (report, currentFilter, isSingle) => {
+  const isAuthor = report && String(report.author) === String(userId);
+
+  const leftActions = [];
+
+  if (!isSingle) {
+    leftActions.push(
+      form(
+        { method: "GET", action: `/reports/${encodeURIComponent(report.id)}` },
+        input({ type: "hidden", name: "filter", value: currentFilter }),
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+      )
+    );
+  }
+
+  const pm = renderPmButton(report && report.author);
+  if (pm) leftActions.push(pm);
+
+  const leftNode = leftActions.length ? div({ class: "bookmark-topbar-left report-topbar-left" }, ...leftActions) : null;
+  const rightNode = isAuthor ? renderReportOwnerActions(report, currentFilter) : null;
+
+  const nodes = [];
+  if (leftNode) nodes.push(leftNode);
+  if (rightNode) nodes.push(rightNode);
+
+  return nodes.length ? div({ class: isSingle ? "bookmark-topbar report-topbar-single" : "bookmark-topbar" }, ...nodes) : null;
+};
+
+const renderTemplateDetails = (report) => {
+  const category = normU(report.category);
+  const t = report.template && typeof report.template === "object" ? report.template : {};
+  if (!hasAnyTemplateValue(t)) return null;
+
+  const renderValueField = (lbl, val) =>
+    String(val || "").trim()
+      ? renderCardField(lbl, String(val))
+      : null;
+
+  if (category === "BUGS") {
+    return div(
+      { class: "report-template" },
+      h2({ class: "report-template-title" }, i18n.reportsBugTemplateTitle),
+      renderStackedTextField(i18n.reportsStepsToReproduceLabel + ":", t.stepsToReproduce),
+      renderStackedTextField(i18n.reportsExpectedBehaviorLabel + ":", t.expectedBehavior),
+      renderStackedTextField(i18n.reportsActualBehaviorLabel + ":", t.actualBehavior),
+      renderStackedTextField(i18n.reportsEnvironmentLabel + ":", t.environment),
+      renderValueField(i18n.reportsReproduceRateLabel + ":", t.reproduceRate)
+    );
+  }
+
+  if (category === "FEATURES") {
+    return div(
+      { class: "report-template" },
+      h2({ class: "report-template-title" }, i18n.reportsFeatureTemplateTitle),
+      renderStackedTextField(i18n.reportsProblemStatementLabel + ":", t.problemStatement),
+      renderStackedTextField(i18n.reportsUserStoryLabel + ":", t.userStory),
+      renderStackedTextField(i18n.reportsAcceptanceCriteriaLabel + ":", t.acceptanceCriteria)
+    );
+  }
+
+  if (category === "ABUSE") {
+    return div(
+      { class: "report-template" },
+      h2({ class: "report-template-title" }, i18n.reportsAbuseTemplateTitle),
+      renderStackedTextField(i18n.reportsWhatHappenedLabel + ":", t.whatHappened),
+      renderStackedTextField(i18n.reportsReportedUserLabel + ":", t.reportedUser),
+      renderStackedTextField(i18n.reportsEvidenceLinksLabel + ":", t.evidenceLinks)
+    );
+  }
+
+  if (category === "CONTENT") {
+    return div(
+      { class: "report-template" },
+      h2({ class: "report-template-title" }, i18n.reportsContentTemplateTitle),
+      renderStackedTextField(i18n.reportsContentLocationLabel + ":", t.contentLocation),
+      renderStackedTextField(i18n.reportsWhyInappropriateLabel + ":", t.whyInappropriate),
+      renderStackedTextField(i18n.reportsRequestedActionLabel + ":", t.requestedAction),
+      renderStackedTextField(i18n.reportsEvidenceLinksLabel + ":", t.evidenceLinks)
+    );
+  }
+
+  return null;
+};
+
 const renderReportCommentsSection = (reportId, comments = []) => {
   const commentsCount = Array.isArray(comments) ? comments.length : 0;
 
-  return div({ class: 'vote-comments-section' },
-    div({ class: 'comments-count' },
-      span({ class: 'card-label' }, i18n.voteCommentsLabel + ': '),
-      span({ class: 'card-value' }, String(commentsCount))
+  return div(
+    { class: "vote-comments-section" },
+    div(
+      { class: "comments-count" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
+      span({ class: "card-value" }, String(commentsCount))
     ),
-    div({ class: 'comment-form-wrapper' },
-      h2({ class: 'comment-form-title' }, i18n.voteNewCommentLabel),
-      form({
-        method: 'POST',
-        action: `/reports/${encodeURIComponent(reportId)}/comments`,
-        class: 'comment-form'
-      },
+    div(
+      { class: "comment-form-wrapper" },
+      h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
+      form(
+        {
+          method: "POST",
+          action: `/reports/${encodeURIComponent(reportId)}/comments`,
+          class: "comment-form"
+        },
         textarea({
-          id: 'comment-text',
-          name: 'text',
+          id: "comment-text",
+          name: "text",
           required: true,
           rows: 4,
-          class: 'comment-textarea',
+          class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         br(),
-        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+        button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
     ),
     comments && comments.length
-      ? div({ class: 'comments-list' },
-          comments.map(c => {
-            const author = c.value && c.value.author ? c.value.author : '';
+      ? div(
+          { class: "comments-list" },
+          comments.map((c) => {
+            const author = c.value && c.value.author ? c.value.author : "";
             const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
-            const absDate = ts ? moment(ts).format('YYYY/MM/DD HH:mm:ss') : '';
-            const relDate = ts ? moment(ts).fromNow() : '';
-            const userName = author && author.includes('@') ? author.split('@')[1] : author;
+            const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
+            const relDate = ts ? moment(ts).fromNow() : "";
+            const userName = author && author.includes("@") ? author.split("@")[1] : author;
+
+            const content = c.value && c.value.content ? c.value.content : {};
+            const root = content.fork || content.root || "";
+            const text = content.text || "";
 
-            return div({ class: 'votations-comment-card' },
-              span({ class: 'created-at' },
+            return div(
+              { class: "votations-comment-card" },
+              span(
+                { class: "created-at" },
                 span(i18n.createdBy),
-                author
-                  ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`)
-                  : span('(unknown)'),
-                absDate ? span(' | ') : '',
-                absDate ? span({ class: 'votations-comment-date' }, absDate) : '',
-                relDate ? span({ class: 'votations-comment-date' }, ' | ', i18n.sendTime) : '',
-                relDate
-                  ? a({
-                      href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
-                    }, relDate)
-                  : ''
+                author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
+                absDate ? span(" | ") : "",
+                absDate ? span({ class: "votations-comment-date" }, absDate) : "",
+                relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
+                relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
               ),
-              p({
-                class: 'votations-comment-text',
-                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
-              })
+              p({ class: "votations-comment-text" }, ...renderUrl(text))
             );
           })
         )
-      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+      : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
   );
-};  
+};
 
-const renderReportCard = (report, userId) => {
-  const actions = report.author === userId ? [
-    form({ method: "GET", action: `/reports/edit/${encodeURIComponent(report.id)}` },
-      button({ type: "submit", class: "update-btn" }, i18n.reportsUpdateButton)
-    ),
-    form({ method: "POST", action: `/reports/delete/${encodeURIComponent(report.id)}` },
-      button({ type: "submit", class: "delete-btn" }, i18n.reportsDeleteButton)
-    ),
-    form({ method: "POST", action: `/reports/status/${encodeURIComponent(report.id)}` },
-      button({ type: "submit", name: "status", value: "OPEN" }, i18n.reportsStatusOpen), br(),
-      button({ type: "submit", name: "status", value: "UNDER_REVIEW" }, i18n.reportsStatusUnderReview), br(),
-      button({ type: "submit", name: "status", value: "RESOLVED" }, i18n.reportsStatusResolved), br(),
-      button({ type: "submit", name: "status", value: "INVALID" }, i18n.reportsStatusInvalid)
-    )
-  ] : [];
+const renderTemplateForCategory = (category, templateData = {}) => {
+  const cat = normU(category || "FEATURES");
+  const t = templateData && typeof templateData === "object" ? templateData : {};
+  const tval = (k) => String(t[k] || "");
+  const reproduceRateVal = normU(t.reproduceRate || "");
 
-  const commentCount = typeof report.commentCount === 'number' ? report.commentCount : 0;
+  if (cat === "BUGS") {
+    return div(
+      { class: "report-template-block" },
+      h2({ class: "report-template-title" }, i18n.reportsBugTemplateTitle),
+      label(i18n.reportsStepsToReproduceLabel),
+      br(),
+      textarea({ name: "stepsToReproduce", rows: "4", placeholder: i18n.reportsStepsToReproducePlaceholder }, tval("stepsToReproduce")),
+      br(),
+      br(),
+      label(i18n.reportsExpectedBehaviorLabel),
+      br(),
+      textarea({ name: "expectedBehavior", rows: "3", placeholder: i18n.reportsExpectedBehaviorPlaceholder }, tval("expectedBehavior")),
+      br(),
+      br(),
+      label(i18n.reportsActualBehaviorLabel),
+      br(),
+      textarea({ name: "actualBehavior", rows: "3", placeholder: i18n.reportsActualBehaviorPlaceholder }, tval("actualBehavior")),
+      br(),
+      br(),
+      label(i18n.reportsEnvironmentLabel),
+      br(),
+      textarea({ name: "environment", rows: "3", placeholder: i18n.reportsEnvironmentPlaceholder }, tval("environment")),
+      br(),
+      br(),
+      label(i18n.reportsReproduceRateLabel),
+      br(),
+      select(
+        { name: "reproduceRate" },
+        opt("", !reproduceRateVal, i18n.reportsReproduceRateUnknown),
+        opt("ALWAYS", reproduceRateVal === "ALWAYS", i18n.reportsReproduceRateAlways),
+        opt("OFTEN", reproduceRateVal === "OFTEN", i18n.reportsReproduceRateOften),
+        opt("SOMETIMES", reproduceRateVal === "SOMETIMES", i18n.reportsReproduceRateSometimes),
+        opt("RARELY", reproduceRateVal === "RARELY", i18n.reportsReproduceRateRarely),
+        opt("UNABLE", reproduceRateVal === "UNABLE", i18n.reportsReproduceRateUnable)
+      )
+    );
+  }
 
-  return div({ class: "card card-section report" },
-    actions.length ? div({ class: "report-actions" }, ...actions) : null,
-    form({ method: 'GET', action: `/reports/${encodeURIComponent(report.id)}` },
-      button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails)
-    ),
-    br,
+  if (cat === "ABUSE") {
+    return div(
+      { class: "report-template-block" },
+      h2({ class: "report-template-title" }, i18n.reportsAbuseTemplateTitle),
+      label(i18n.reportsWhatHappenedLabel),
+      br(),
+      textarea({ name: "whatHappened", rows: "4", placeholder: i18n.reportsWhatHappenedPlaceholder }, tval("whatHappened")),
+      br(),
+      br(),
+      label(i18n.reportsReportedUserLabel),
+      br(),
+      textarea({ name: "reportedUser", rows: "2", placeholder: i18n.reportsReportedUserPlaceholder }, tval("reportedUser")),
+      br(),
+      br(),
+      label(i18n.reportsEvidenceLinksLabel),
+      br(),
+      textarea({ name: "evidenceLinks", rows: "3", placeholder: i18n.reportsEvidenceLinksPlaceholder }, tval("evidenceLinks"))
+    );
+  }
+
+  if (cat === "CONTENT") {
+    return div(
+      { class: "report-template-block" },
+      h2({ class: "report-template-title" }, i18n.reportsContentTemplateTitle),
+      label(i18n.reportsContentLocationLabel),
+      br(),
+      textarea({ name: "contentLocation", rows: "3", placeholder: i18n.reportsContentLocationPlaceholder }, tval("contentLocation")),
+      br(),
+      br(),
+      label(i18n.reportsWhyInappropriateLabel),
+      br(),
+      textarea({ name: "whyInappropriate", rows: "4", placeholder: i18n.reportsWhyInappropriatePlaceholder }, tval("whyInappropriate")),
+      br(),
+      br(),
+      label(i18n.reportsRequestedActionLabel),
+      br(),
+      textarea({ name: "requestedAction", rows: "3", placeholder: i18n.reportsRequestedActionPlaceholder }, tval("requestedAction")),
+      br(),
+      br(),
+      label(i18n.reportsEvidenceLinksLabel),
+      br(),
+      textarea({ name: "evidenceLinks", rows: "3", placeholder: i18n.reportsEvidenceLinksPlaceholder }, tval("evidenceLinks"))
+    );
+  }
+
+  return div(
+    { class: "report-template-block" },
+    h2({ class: "report-template-title" }, i18n.reportsFeatureTemplateTitle),
+    label(i18n.reportsProblemStatementLabel),
+    br(),
+    textarea({ name: "problemStatement", rows: "4", placeholder: i18n.reportsProblemStatementPlaceholder }, tval("problemStatement")),
+    br(),
+    br(),
+    label(i18n.reportsUserStoryLabel),
+    br(),
+    textarea({ name: "userStory", rows: "3", placeholder: i18n.reportsUserStoryPlaceholder }, tval("userStory")),
+    br(),
+    br(),
+    label(i18n.reportsAcceptanceCriteriaLabel),
+    br(),
+    textarea({ name: "acceptanceCriteria", rows: "4", placeholder: i18n.reportsAcceptanceCriteriaPlaceholder }, tval("acceptanceCriteria"))
+  );
+};
+
+const renderReportCard = (report, userId, currentFilter = "all") => {
+  const confirmations = Array.isArray(report.confirmations) ? report.confirmations : [];
+  const commentCount = typeof report.commentCount === "number" ? report.commentCount : 0;
+  const severity = normU(report.severity || "low");
+
+  const topbar = renderReportTopbar(report, currentFilter, false);
+  const details = renderTemplateDetails(report);
+
+  return div(
+    { class: "card card-section report" },
+    topbar ? topbar : null,
     renderCardField(i18n.reportsTitleLabel + ":", report.title),
     renderCardField(i18n.reportsStatus + ":", report.status),
-    renderCardField(i18n.reportsSeverity + ":", report.severity.toUpperCase()),
+    renderCardField(i18n.reportsSeverity + ":", severity),
     renderCardField(i18n.reportsCategory + ":", report.category),
-    renderCardField(i18n.reportsDescriptionLabel + ':'),
-    p(...renderUrl(report.description)), 
-    div({ class: 'card-field' },
-      report.image ? div({ class: 'card-field' },
-        img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" })
-      ) : null
-    ),
-    br,
-    renderCardField(i18n.reportsConfirmations + ":", report.confirmations.length),
-    br,
-    form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` },
-      button({ type: "submit" }, i18n.reportsConfirmButton)
-    ),
-    a({ href: "/tasks?filter=create", target: "_blank" },
-      button({ type: "button" }, i18n.reportsCreateTaskButton)
-    ),
-    br(), br(),
+    report.image ? br() : null,
+    report.image ? div({ class: "card-field" }, img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" })) : null,
+    report.image && details ? br() : null,
+    details ? details : null,
+    br(),
+    renderCardField(i18n.reportsConfirmations + ":", confirmations.length),
+    br(),
+    form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` }, button({ type: "submit" }, i18n.reportsConfirmButton)),
+    a({ href: "/tasks?filter=create", target: "_blank" }, button({ type: "button" }, i18n.reportsCreateTaskButton)),
+    br(),
+    br(),
     report.tags && report.tags.length
-      ? div({ class: "card-tags" },
-          report.tags.map(tag =>
-            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
-          )
+      ? div(
+          { class: "card-tags" },
+          report.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
         )
       : null,
-    div({ class: 'card-comments-summary' },
-      span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
-      span({ class: 'card-value' }, String(commentCount)),
-      br(), br(),
-      form({ method: 'GET', action: `/reports/${encodeURIComponent(report.id)}` },
-        button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+    div(
+      { class: "card-comments-summary" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
+      span({ class: "card-value" }, String(commentCount)),
+      br(),
+      br(),
+      form(
+        { method: "GET", action: `/reports/${encodeURIComponent(report.id)}` },
+        input({ type: "hidden", name: "filter", value: currentFilter }),
+        button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
       )
     ),
-    br,
-    p({ class: 'card-footer' },
-      span({ class: 'date-link' }, `${moment(report.createdAt).format('YYYY-MM-DD HH:mm')} ${i18n.performed} `),
+    br(),
+    p(
+      { class: "card-footer" },
+      span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
       a({ class: "user-link", href: `/author/${encodeURIComponent(report.author)}` }, report.author)
     )
   );
 };
 
-exports.reportView = async (reports, filter, reportId) => {
+exports.reportView = async (reports, filter, reportId, createCategory) => {
   const title =
-    filter === 'mine' ? i18n.reportsMineSectionTitle :
-    filter === 'features' ? i18n.reportsFeaturesSectionTitle :
-    filter === 'bugs' ? i18n.reportsBugsSectionTitle :
-    filter === 'abuse' ? i18n.reportsAbuseSectionTitle :
-    filter === 'content' ? i18n.reportsContentSectionTitle :
-    filter === 'confirmed' ? i18n.reportsConfirmedSectionTitle :
-    filter === 'open' ? i18n.reportsOpenSectionTitle :
-    filter === 'under_review' ? i18n.reportsUnderReviewSectionTitle :
-    filter === 'resolved' ? i18n.reportsResolvedSectionTitle :
-    filter === 'invalid' ? i18n.reportsInvalidSectionTitle :
+    filter === "create" ? i18n.reportsCreateButton :
+    filter === "edit" ? i18n.reportsUpdateButton :
+    filter === "mine" ? i18n.reportsMineSectionTitle :
+    filter === "features" ? i18n.reportsFeaturesSectionTitle :
+    filter === "bugs" ? i18n.reportsBugsSectionTitle :
+    filter === "abuse" ? i18n.reportsAbuseSectionTitle :
+    filter === "content" ? i18n.reportsContentSectionTitle :
+    filter === "confirmed" ? i18n.reportsConfirmedSectionTitle :
+    filter === "open" ? i18n.reportsOpenSectionTitle :
+    filter === "under_review" ? i18n.reportsUnderReviewSectionTitle :
+    filter === "resolved" ? i18n.reportsResolvedSectionTitle :
+    filter === "invalid" ? i18n.reportsInvalidSectionTitle :
     i18n.reportsAllSectionTitle;
 
-  let filtered =
-    filter === 'mine' ? reports.filter(r => r.author === userId) : reports;
+  let filtered = Array.isArray(reports) ? reports : [];
+
+  if (filter === "mine") {
+    filtered = filtered.filter((r) => r.author === userId);
+  } else if (filter === "confirmed") {
+    filtered = filtered.filter((r) => Array.isArray(r.confirmations) && r.confirmations.includes(userId));
+  } else if (CATEGORY_BY_FILTER[filter]) {
+    const wanted = CATEGORY_BY_FILTER[filter];
+    filtered = filtered.filter((r) => normU(r.category) === wanted);
+  } else if (STATUS_BY_FILTER[filter]) {
+    const wanted = STATUS_BY_FILTER[filter];
+    filtered = filtered.filter((r) => normalizeStatus(r.status) === wanted);
+  }
 
   filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-  const reportToEdit = filter === 'edit' ? reports.find(r => r.id === reportId) : null;
+
+  const reportToEdit = filter === "edit"
+    ? (Array.isArray(reports) ? reports.find((r) => r.id === reportId) : null)
+    : null;
+
+  const btnClass = (v) => (filter === v ? "filter-btn active" : "filter-btn");
+
+  const selectedCategory = normU(
+    filter === "create"
+      ? (createCategory || "FEATURES")
+      : (reportToEdit?.category || "FEATURES")
+  );
+
+  const selectedTemplate = reportToEdit?.template && typeof reportToEdit.template === "object" ? reportToEdit.template : {};
+  const applyLabel = i18n.apply || "Apply";
+  const sev = String(reportToEdit?.severity || "low");
+  const hiddenDescription = String(reportToEdit?.description || "");
 
   return template(
     title,
     section(
-      div({ class: "tags-header" },
+      div(
+        { class: "tags-header" },
         h2(i18n.reportsTitle),
         p(i18n.reportsDescription)
       ),
-      div({ class: "filters" },
-        form({ method: "GET", action: "/reports" },
-          button({ type: "submit", name: "filter", value: "all", class: "filter-btn" }, i18n.reportsFilterAll),
-          button({ type: "submit", name: "filter", value: "mine", class: "filter-btn" }, i18n.reportsFilterMine),
-          button({ type: "submit", name: "filter", value: "features", class: "filter-btn" }, i18n.reportsFilterFeatures),
-          button({ type: "submit", name: "filter", value: "bugs", class: "filter-btn" }, i18n.reportsFilterBugs),
-          button({ type: "submit", name: "filter", value: "abuse", class: "filter-btn" }, i18n.reportsFilterAbuse),
-          button({ type: "submit", name: "filter", value: "content", class: "filter-btn" }, i18n.reportsFilterContent),
-          button({ type: "submit", name: "filter", value: "confirmed", class: "filter-btn" }, i18n.reportsFilterConfirmed),
-          button({ type: "submit", name: "filter", value: "open", class: "filter-btn" }, i18n.reportsFilterOpen),
-          button({ type: "submit", name: "filter", value: "under_review", class: "filter-btn" }, i18n.reportsFilterUnderReview),
-          button({ type: "submit", name: "filter", value: "resolved", class: "filter-btn" }, i18n.reportsFilterResolved),
-          button({ type: "submit", name: "filter", value: "invalid", class: "filter-btn" }, i18n.reportsFilterInvalid),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/reports" },
+          button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, i18n.reportsFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, i18n.reportsFilterMine),
+          button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, i18n.reportsFilterFeatures),
+          button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, i18n.reportsFilterBugs),
+          button({ type: "submit", name: "filter", value: "abuse", class: btnClass("abuse") }, i18n.reportsFilterAbuse),
+          button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, i18n.reportsFilterContent),
+          button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, i18n.reportsFilterConfirmed),
+          button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, i18n.reportsFilterOpen),
+          button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, i18n.reportsFilterUnderReview),
+          button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, i18n.reportsFilterResolved),
+          button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, i18n.reportsFilterInvalid),
           button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.reportsCreateButton)
         )
       )
     ),
     section(
-      filter === 'edit' || filter === 'create'
-        ? div({ class: "report-form" },
-            form({ action: filter === 'edit' ? `/reports/update/${encodeURIComponent(reportId)}` : "/reports/create", method: "POST", enctype: "multipart/form-data" },
-              label(i18n.reportsTitleLabel), br(),
-              input({ type: "text", name: "title", required: true, value: reportToEdit?.title || '' }), br(), br(),
-
-              label(i18n.reportsDescriptionLabel), br(),
-              textarea({ name: "description", required: true, rows:"4" }, reportToEdit?.description || ''), br(), br(),
-
-              label(i18n.reportsCategory), br(),
-              select({ name: "category", required: true },
-                option({ value: "FEATURES", selected: reportToEdit?.category === 'FEATURES' }, i18n.reportsCategoryFeatures),
-                option({ value: "BUGS", selected: reportToEdit?.category === 'BUGS' }, i18n.reportsCategoryBugs),
-                option({ value: "ABUSE", selected: reportToEdit?.category === 'ABUSE' }, i18n.reportsCategoryAbuse),
-                option({ value: "CONTENT", selected: reportToEdit?.category === 'CONTENT' }, i18n.reportsCategoryContent)
-              ), br(), br(),
-
-              label(i18n.reportsSeverity), br(),
-              select({ name: "severity" },
-                option({ value: "critical", selected: reportToEdit?.severity === 'critical' }, i18n.reportsSeverityCritical),
-                option({ value: "high", selected: reportToEdit?.severity === 'high' }, i18n.reportsSeverityHigh),
-                option({ value: "medium", selected: reportToEdit?.severity === 'medium' }, i18n.reportsSeverityMedium),
-                option({ value: "low", selected: reportToEdit?.severity === 'low' }, i18n.reportsSeverityLow)
-              ), br(), br(),
-
-              label(i18n.reportsUploadFile), br(),
-              input({ type: "file", name: "image" }), br(), br(),
-
-              label("Tags"), br(),
-              input({ type: "text", name: "tags", value: reportToEdit?.tags?.join(', ') || '' }), br(), br(),
-
-              button({ type: "submit" }, filter === 'edit' ? i18n.reportsUpdateButton : i18n.reportsCreateButton)
-            )
+      filter === "edit" || filter === "create"
+        ? div(
+            { class: "report-form" },
+            filter === "create"
+              ? div(
+                  label(i18n.reportsTitleLabel),
+                  br(),
+                  input({ type: "text", name: "title", required: true, value: "", form: "report-create-form" }),
+                  br(),
+                  br(),
+                  form(
+                    { id: "report-category-form", method: "GET", action: "/reports" },
+                    input({ type: "hidden", name: "filter", value: "create" }),
+                    label(i18n.reportsCategory),
+                    br(),
+                    select(
+                      { name: "category" },
+                      opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures),
+                      opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs),
+                      opt("ABUSE", selectedCategory === "ABUSE", i18n.reportsCategoryAbuse),
+                      opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent)
+                    ),
+                    br(),
+                    button({ type: "submit", class: "filter-btn" }, applyLabel)
+                  ),
+                  br(),
+                  h2({ class: "report-category-fixed" }, selectedCategory),
+                  br(),
+                  form(
+                    { id: "report-create-form", action: "/reports/create", method: "POST", enctype: "multipart/form-data" },
+                    input({ type: "hidden", name: "category", value: selectedCategory }),
+                    input({ type: "hidden", name: "description", value: "" }),
+                    label(i18n.reportsSeverity),
+                    br(),
+                    select(
+                      { name: "severity" },
+                      opt("critical", sev === "critical", i18n.reportsSeverityCritical),
+                      opt("high", sev === "high", i18n.reportsSeverityHigh),
+                      opt("medium", sev === "medium", i18n.reportsSeverityMedium),
+                      opt("low", sev === "low", i18n.reportsSeverityLow)
+                    ),
+                    br(),
+                    br(),
+                    h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle),
+                    renderTemplateForCategory(selectedCategory, {}),
+                    label(i18n.reportsUploadFile),
+                    br(),
+                    input({ type: "file", name: "image" }),
+                    br(),
+                    br(),
+                    label("Tags"),
+                    br(),
+                    input({ type: "text", name: "tags", value: "" }),
+                    br(),
+                    br(),
+                    button({ type: "submit" }, i18n.reportsCreateButton)
+                  )
+                )
+              : div(
+                  form(
+                    { id: "report-edit-form", action: `/reports/update/${encodeURIComponent(reportId)}`, method: "POST", enctype: "multipart/form-data" },
+                    label(i18n.reportsTitleLabel),
+                    br(),
+                    input({ type: "text", name: "title", required: true, value: reportToEdit?.title || "" }),
+                    br(),
+                    br(),
+                    input({ type: "hidden", name: "description", value: hiddenDescription }),
+                    label(i18n.reportsCategory),
+                    br(),
+                    select(
+                      { name: "category", required: true },
+                      opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures),
+                      opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs),
+                      opt("ABUSE", selectedCategory === "ABUSE", i18n.reportsCategoryAbuse),
+                      opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent)
+                    ),
+                    br(),
+                    br(),
+                    label(i18n.reportsSeverity),
+                    br(),
+                    select(
+                      { name: "severity" },
+                      opt("critical", sev === "critical", i18n.reportsSeverityCritical),
+                      opt("high", sev === "high", i18n.reportsSeverityHigh),
+                      opt("medium", sev === "medium", i18n.reportsSeverityMedium),
+                      opt("low", sev === "low", i18n.reportsSeverityLow)
+                    ),
+                    br(),
+                    br(),
+                    h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle),
+                    renderTemplateForCategory(selectedCategory, selectedTemplate),
+                    br(),
+                    br(),
+                    label(i18n.reportsUploadFile),
+                    br(),
+                    input({ type: "file", name: "image" }),
+                    br(),
+                    br(),
+                    label("Tags"),
+                    br(),
+                    input({ type: "text", name: "tags", value: reportToEdit?.tags?.join(", ") || "" }),
+                    br(),
+                    br(),
+                    button({ type: "submit" }, i18n.reportsUpdateButton)
+                  )
+                )
           )
-        : div({ class: "report-list" },
-            filtered.length > 0 ? filtered.map(r => renderReportCard(r, userId)) : p(i18n.reportsNoItems)
-       )
-     )
+        : div(
+            { class: "report-list" },
+            filtered.length > 0 ? filtered.map((r) => renderReportCard(r, userId, filter)) : p(i18n.reportsNoItems)
+          )
+    )
   );
 };
 
 exports.singleReportView = async (report, filter, comments = []) => {
+  const btnClass = (v) => (filter === v ? "filter-btn active" : "filter-btn");
+  const confirmations = Array.isArray(report.confirmations) ? report.confirmations : [];
+  const severity = normU(report.severity || "low");
+
+  const topbar = renderReportTopbar(report, filter || "all", true);
+  const details = renderTemplateDetails(report);
+
   return template(
     report.title,
     section(
-      div({ class: "filters" },
-        form({ method: 'GET', action: '/reports' },
-          button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.reportsFilterAll),
-          button({ type: 'submit', name: 'filter', value: 'mine', class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.reportsFilterMine),
-          button({ type: 'submit', name: 'filter', value: 'features', class: filter === 'features' ? 'filter-btn active' : 'filter-btn' }, i18n.reportsFilterFeatures),
-          button({ type: 'submit', name: 'filter', value: 'bugs', class: filter === 'bugs' ? 'filter-btn active' : 'filter-btn' }, i18n.reportsFilterBugs),
-          button({ type: 'submit', name: 'filter', value: 'abuse', class: filter === 'abuse' ? 'filter-btn active' : 'filter-btn' }, i18n.reportsFilterAbuse),
-          button({ type: 'submit', name: 'filter', value: 'content', class: filter === 'content' ? 'filter-btn active' : 'filter-btn' }, i18n.reportsFilterContent),
-          button({ type: 'submit', name: 'filter', value: 'confirmed', class: filter === 'confirmed' ? 'filter-btn active' : 'filter-btn' }, i18n.reportsFilterConfirmed),
-          button({ type: 'submit', name: 'filter', value: 'open', class: filter === 'open' ? 'filter-btn active' : 'filter-btn' }, i18n.reportsFilterOpen),
-          button({ type: 'submit', name: 'filter', value: 'under_review', class: filter === 'under_review' ? 'filter-btn active' : 'filter-btn' }, i18n.reportsFilterUnderReview),
-          button({ type: 'submit', name: 'filter', value: 'resolved', class: filter === 'resolved' ? 'filter-btn active' : 'filter-btn' }, i18n.reportsFilterResolved),
-          button({ type: 'submit', name: 'filter', value: 'invalid', class: filter === 'invalid' ? 'filter-btn active' : 'filter-btn' }, i18n.reportsFilterInvalid),
-          button({ type: 'submit', name: 'filter', value: 'create', class: "create-button" }, i18n.reportsCreateButton)
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/reports" },
+          button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, i18n.reportsFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, i18n.reportsFilterMine),
+          button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, i18n.reportsFilterFeatures),
+          button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, i18n.reportsFilterBugs),
+          button({ type: "submit", name: "filter", value: "abuse", class: btnClass("abuse") }, i18n.reportsFilterAbuse),
+          button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, i18n.reportsFilterContent),
+          button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, i18n.reportsFilterConfirmed),
+          button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, i18n.reportsFilterOpen),
+          button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, i18n.reportsFilterUnderReview),
+          button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, i18n.reportsFilterResolved),
+          button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, i18n.reportsFilterInvalid),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.reportsCreateButton)
         )
       ),
-      div({ class: "card card-section report" },
-        form({ method: 'GET', action: `/reports/${encodeURIComponent(report.id)}` },
-          button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails)
-        ),
-        br,
+      div(
+        { class: "card card-section report" },
+        topbar ? topbar : null,
         renderCardField(i18n.reportsTitleLabel + ":", report.title),
         renderCardField(i18n.reportsStatus + ":", report.status),
-        renderCardField(i18n.reportsSeverity + ":", report.severity.toUpperCase()),
+        renderCardField(i18n.reportsSeverity + ":", severity),
         renderCardField(i18n.reportsCategory + ":", report.category),
-        renderCardField(i18n.reportsDescriptionLabel + ':'),
-        p(...renderUrl(report.description)), 
-        div({ class: 'card-field' },
-          report.image ? div({ class: 'card-field' },
-            img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" })
-          ) : null
-        ),
-        br,
-        renderCardField(i18n.reportsConfirmations + ":", report.confirmations.length),
-        br,
-        form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` },
-          button({ type: "submit" }, i18n.reportsConfirmButton)
-        ),
-        a({ href: "/tasks?filter=create", target: "_blank" },
-          button({ type: "button" }, i18n.reportsCreateTaskButton)
-        ),
-        br(), br(),
+        report.image ? br() : null,
+        report.image ? div({ class: "card-field" }, img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" })) : null,
+        report.image && details ? br() : null,
+        details ? details : null,
+        br(),
+        renderCardField(i18n.reportsConfirmations + ":", confirmations.length),
+        br(),
+        form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` }, button({ type: "submit" }, i18n.reportsConfirmButton)),
+        a({ href: "/tasks?filter=create", target: "_blank" }, button({ type: "button" }, i18n.reportsCreateTaskButton)),
+        br(),
+        br(),
         report.tags && report.tags.length
-          ? div({ class: "card-tags" },
-              report.tags.map(tag =>
-                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
-              )
+          ? div(
+              { class: "card-tags" },
+              report.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
             )
           : null,
-        br,
-        p({ class: 'card-footer' },
-          span({ class: 'date-link' }, `${moment(report.createdAt).format('YYYY-MM-DD HH:mm')} ${i18n.performed} `),
+        br(),
+        p(
+          { class: "card-footer" },
+          span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
           a({ class: "user-link", href: `/author/${encodeURIComponent(report.author)}` }, report.author)
         )
       ),
@@ -291,3 +651,4 @@ exports.singleReportView = async (report, filter, comments = []) => {
     )
   );
 };
+

+ 1 - 0
src/views/settings_view.js

@@ -112,6 +112,7 @@ const settingsView = ({ version, aiPrompt }) => {
             option({ value: "mentions", selected: currentConfig.homePage === "mentions" ? true : undefined }, i18n.mentions),
             option({ value: "inbox", selected: currentConfig.homePage === "inbox" ? true : undefined }, i18n.inbox),
             option({ value: "agenda", selected: currentConfig.homePage === "agenda" ? true : undefined }, i18n.agendaTitle),
+            option({ value: "favorites", selected: currentConfig.homePage === "favorites" ? true : undefined }, i18n.favoritesTitle),
             option({ value: "stats", selected: currentConfig.homePage === "stats" ? true : undefined }, i18n.statsTitle),
             option({ value: "blockexplorer", selected: currentConfig.homePage === "blockexplorer" ? true : undefined }, i18n.blockchain)
           ),

+ 362 - 222
src/views/task_view.js

@@ -1,253 +1,385 @@
 const { div, h2, p, section, button, form, input, select, option, a, br, textarea, label, span } = require("../server/node_modules/hyperaxe");
-const moment = require('../server/node_modules/moment');
-const { template, i18n } = require('./main_views');
-const { config } = require('../server/SSB_server.js');
-const { renderUrl } = require('../backend/renderUrl');
+const moment = require("../server/node_modules/moment");
+const { template, i18n } = require("./main_views");
+const { config } = require("../server/SSB_server.js");
+const { renderUrl } = require("../backend/renderUrl");
 
 const userId = config.keys.id;
 
-const renderStyledField = (labelText, valueElement) =>
-  div({ class: 'card-field' },
-    span({ class: 'card-label' }, labelText),
-    span({ class: 'card-value' }, ...renderUrl(valueElement))
+const opt = (value, isSelected, text) =>
+  option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text);
+
+const safeArray = (v) => Array.isArray(v) ? v : [];
+
+const toValueChildren = (v) => {
+  if (v === undefined || v === null) return [];
+  if (Array.isArray(v)) return v;
+  if (typeof v === "string") return renderUrl(v);
+  if (typeof v === "number" || typeof v === "boolean") return renderUrl(String(v));
+  return [v];
+};
+
+const renderCardField = (labelText, valueNode) =>
+  div(
+    { class: "card-field" },
+    span({ class: "card-label" }, labelText),
+    span({ class: "card-value" }, ...toValueChildren(valueNode))
   );
-  
-const renderTaskCommentsSection = (taskId, comments = []) => {
+
+const normalizeStatus = (v) => {
+  const up = String(v || "").toUpperCase();
+  if (up === "OPEN" || up === "IN-PROGRESS" || up === "CLOSED") return up;
+  return "OPEN";
+};
+
+const statusLabel = (s) => {
+  const up = normalizeStatus(s);
+  if (up === "OPEN") return i18n.taskStatusOpen;
+  if (up === "IN-PROGRESS") return i18n.taskStatusInProgress;
+  return i18n.taskStatusClosed;
+};
+
+const visibilityLabel = (v) => {
+  const vv = String(v || "").toUpperCase();
+  if (vv === "PRIVATE") return i18n.taskPrivate;
+  return i18n.taskPublic;
+};
+
+const renderTaskOwnerActions = (task, returnTo) => {
+  const st = normalizeStatus(task.status || "OPEN");
+  const setStatusLabel = i18n.taskSetStatus;
+
+  return [
+    form(
+      { method: "GET", action: `/tasks/edit/${encodeURIComponent(task.id)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ type: "submit", class: "update-btn" }, i18n.taskUpdateButton)
+    ),
+    form(
+      { method: "POST", action: `/tasks/delete/${encodeURIComponent(task.id)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ type: "submit", class: "delete-btn" }, i18n.taskDeleteButton)
+    ),
+    form(
+      { method: "POST", action: `/tasks/status/${encodeURIComponent(task.id)}`, class: "project-control-form project-control-form--status" },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      select(
+        { name: "status", class: "project-control-select" },
+        option({ value: "OPEN", selected: st === "OPEN" }, i18n.taskStatusOpen),
+        option({ value: "IN-PROGRESS", selected: st === "IN-PROGRESS" }, i18n.taskStatusInProgress),
+        option({ value: "CLOSED", selected: st === "CLOSED" }, i18n.taskStatusClosed)
+      ),
+      button({ class: "status-btn project-control-btn", type: "submit" }, setStatusLabel)
+    )
+  ];
+};
+
+const renderTaskAssignAction = (task, isAssignedToMe, returnTo) => {
+  const st = normalizeStatus(task.status || "OPEN");
+  if (st === "CLOSED") return null;
+  return form(
+    { method: "POST", action: `/tasks/assign/${encodeURIComponent(task.id)}` },
+    input({ type: "hidden", name: "returnTo", value: returnTo }),
+    button({ type: "submit", class: "filter-btn" }, isAssignedToMe ? i18n.taskUnassignButton : i18n.taskAssignButton)
+  );
+};
+
+const renderTaskTopbar = (task, filter, opts = {}) => {
+  const currentFilter = filter || "all";
+  const isSingle = !!opts.single;
+
+  const returnToList = `/tasks?filter=${encodeURIComponent(currentFilter)}`;
+  const returnToSelf = `/tasks/${encodeURIComponent(task.id)}?filter=${encodeURIComponent(currentFilter)}`;
+  const rt = isSingle ? returnToSelf : returnToList;
+
+  const assignees = safeArray(task.assignees);
+  const isAssignedToMe = assignees.includes(userId);
+
+  const leftActions = [];
+  if (!isSingle) {
+    leftActions.push(
+      form(
+        { method: "GET", action: `/tasks/${encodeURIComponent(task.id)}` },
+        input({ type: "hidden", name: "filter", value: currentFilter }),
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+      )
+    );
+  }
+
+  if (task.author && task.author !== userId) {
+    leftActions.push(
+      form(
+        { method: "GET", action: "/pm" },
+        input({ type: "hidden", name: "recipients", value: task.author }),
+        button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
+      )
+    );
+  }
+
+  const ownerActions = task.author === userId ? renderTaskOwnerActions(task, rt) : [];
+  const assignNode = renderTaskAssignAction(task, isAssignedToMe, rt);
+
+  const rightActions = [];
+  if (assignNode) rightActions.push(assignNode);
+  if (ownerActions.length) rightActions.push(...ownerActions);
+
+  const leftNode = leftActions.length ? div({ class: "bookmark-topbar-left task-topbar-left" }, ...leftActions) : null;
+  const rightNode = rightActions.length ? div({ class: "bookmark-actions task-actions" }, ...rightActions) : null;
+
+  const nodes = [];
+  if (leftNode) nodes.push(leftNode);
+  if (rightNode) nodes.push(rightNode);
+
+  return nodes.length ? div({ class: isSingle ? "bookmark-topbar task-topbar-single" : "bookmark-topbar" }, ...nodes) : null;
+};
+
+const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all") => {
   const commentsCount = Array.isArray(comments) ? comments.length : 0;
+  const returnTo = `/tasks/${encodeURIComponent(taskId)}?filter=${encodeURIComponent(currentFilter || "all")}`;
 
-  return div({ class: 'vote-comments-section' },
-    div({ class: 'comments-count' },
-      span({ class: 'card-label' }, i18n.voteCommentsLabel + ': '),
-      span({ class: 'card-value' }, String(commentsCount))
+  return div(
+    { class: "vote-comments-section" },
+    div(
+      { class: "comments-count" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
+      span({ class: "card-value" }, String(commentsCount))
     ),
-    div({ class: 'comment-form-wrapper' },
-      h2({ class: 'comment-form-title' }, i18n.voteNewCommentLabel),
-      form({
-        method: 'POST',
-        action: `/tasks/${encodeURIComponent(taskId)}/comments`,
-        class: 'comment-form'
-      },
+    div(
+      { class: "comment-form-wrapper" },
+      h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
+      form(
+        { method: "POST", action: `/tasks/${encodeURIComponent(taskId)}/comments`, class: "comment-form" },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
         textarea({
-          id: 'comment-text',
-          name: 'text',
+          id: "comment-text",
+          name: "text",
           required: true,
           rows: 4,
-          class: 'comment-textarea',
+          class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         br(),
-        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+        button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
     ),
     comments && comments.length
-      ? div({ class: 'comments-list' },
-          comments.map(c => {
-            const author = c.value && c.value.author ? c.value.author : '';
+      ? div(
+          { class: "comments-list" },
+          comments.map((c) => {
+            const author = c.value && c.value.author ? c.value.author : "";
             const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
-            const absDate = ts ? moment(ts).format('YYYY/MM/DD HH:mm:ss') : '';
-            const relDate = ts ? moment(ts).fromNow() : '';
-            const userName = author && author.includes('@') ? author.split('@')[1] : author;
+            const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
+            const relDate = ts ? moment(ts).fromNow() : "";
+            const userName = author && author.includes("@") ? author.split("@")[1] : author;
+
+            const content = c.value && c.value.content ? c.value.content : {};
+            const root = content.fork || content.root || "";
+            const text = content.text || "";
 
-            return div({ class: 'votations-comment-card' },
-              span({ class: 'created-at' },
+            return div(
+              { class: "votations-comment-card" },
+              span(
+                { class: "created-at" },
                 span(i18n.createdBy),
-                author
-                  ? a(
-                      { href: `/author/${encodeURIComponent(author)}` },
-                      `@${userName}`
-                    )
-                  : span('(unknown)'),
-                absDate ? span(' | ') : '',
-                absDate ? span({ class: 'votations-comment-date' }, absDate) : '',
-                relDate ? span({ class: 'votations-comment-date' }, ' | ', i18n.sendTime) : '',
-                relDate
-                  ? a(
-                      {
-                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
-                      },
-                      relDate
-                    )
-                  : ''
+                author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
+                absDate ? span(" | ") : "",
+                absDate ? span({ class: "votations-comment-date" }, absDate) : "",
+                relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
+                relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
               ),
-              p({
-                class: 'votations-comment-text',
-                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
-              })
+              p({ class: "votations-comment-text" }, ...renderUrl(String(text)))
             );
           })
         )
-      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+      : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
   );
-};  
-
-const renderTaskItem = (task, filter, userId) => {
-  const actions = [];
-  if (filter === 'mine' && task.author === userId) {
-    actions.push(
-      form({ method: 'GET', action: `/tasks/edit/${encodeURIComponent(task.id)}` },
-        button({ type: 'submit', class: 'update-btn' }, i18n.taskUpdateButton)
-      ),
-      form({ method: 'POST', action: `/tasks/delete/${encodeURIComponent(task.id)}` },
-        button({ type: 'submit', class: 'delete-btn' }, i18n.taskDeleteButton)
-      ),
-      form({ method: 'POST', action: `/tasks/status/${encodeURIComponent(task.id)}` },
-        button({ type: 'submit', name: 'status', value: 'OPEN' }, i18n.taskStatusOpen), br(),
-        button({ type: 'submit', name: 'status', value: 'IN-PROGRESS' }, i18n.taskStatusInProgress), br(),
-        button({ type: 'submit', name: 'status', value: 'CLOSED' }, i18n.taskStatusClosed)
-      )
-    );
-  }
-  if (task.status !== 'CLOSED') {
-    actions.push(
-      form({ method: 'POST', action: `/tasks/assign/${encodeURIComponent(task.id)}` },
-        button(
-          { type: 'submit' },
-          task.assignees.includes(userId) ? i18n.taskUnassignButton : i18n.taskAssignButton
-        )
-      )
-    );
-  }
+};
 
-  const commentCount = typeof task.commentCount === 'number' ? task.commentCount : 0;
+const renderTaskItem = (task, filter) => {
+  const currentFilter = filter || "all";
+  const assignees = safeArray(task.assignees);
+  const commentCount = typeof task.commentCount === "number" ? task.commentCount : 0;
 
-  return div({ class: 'card card-section task' },
-    actions.length > 0 ? div({ class: 'task-actions' }, ...actions) : null,
-    form({ method: 'GET', action: `/tasks/${encodeURIComponent(task.id)}` },
-      button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails)
-    ),
-    br,
-    renderStyledField(i18n.taskTitleLabel + ':', task.title),
-    renderStyledField(i18n.taskDescriptionLabel + ':'),
+  const topbar = renderTaskTopbar(task, currentFilter, { single: false });
+
+  return div(
+    { class: "card card-section task" },
+    topbar ? topbar : null,
+    renderCardField(i18n.taskTitleLabel + ":", task.title),
+    renderCardField(i18n.taskDescriptionLabel + ":", ""),
     p(...renderUrl(task.description)),
-    task.location?.trim() ? renderStyledField(i18n.taskLocationLabel + ':', task.location) : null,
-    renderStyledField(i18n.taskStatus + ':', task.status),
-    renderStyledField(i18n.taskPriorityLabel + ':', task.priority),
-    renderStyledField(i18n.taskVisibilityLabel + ':', task.isPublic),
-    renderStyledField(i18n.taskStartTimeLabel + ':', moment(task.startTime).format('YYYY/MM/DD HH:mm:ss')),
-    renderStyledField(i18n.taskEndTimeLabel + ':', moment(task.endTime).format('YYYY/MM/DD HH:mm:ss')),
-    br,
-    div({ class: 'card-field' },
-      span({ class: 'card-label' }, i18n.taskAssignedTo + ':'),
-      span({ class: 'card-value' },
-        Array.isArray(task.assignees) && task.assignees.length
-          ? task.assignees.map((id, i) => [i > 0 ? ', ' : '', a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
+    task.location && String(task.location).trim() ? renderCardField(i18n.taskLocationLabel + ":", task.location) : null,
+    renderCardField(i18n.taskStatus + ":", statusLabel(task.status)),
+    renderCardField(i18n.taskPriorityLabel + ":", task.priority),
+    renderCardField(i18n.taskVisibilityLabel + ":", visibilityLabel(task.isPublic)),
+    renderCardField(i18n.taskStartTimeLabel + ":", task.startTime ? moment(task.startTime).format("YYYY/MM/DD HH:mm:ss") : ""),
+    renderCardField(i18n.taskEndTimeLabel + ":", task.endTime ? moment(task.endTime).format("YYYY/MM/DD HH:mm:ss") : ""),
+    br(),
+    div(
+      { class: "card-field" },
+      span({ class: "card-label" }, i18n.taskAssignedTo + ":"),
+      span(
+        { class: "card-value" },
+        assignees.length
+          ? assignees.map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
           : i18n.noAssignees
       )
     ),
-    br,
+    br(),
     Array.isArray(task.tags) && task.tags.length
-      ? div({ class: 'card-tags' },
-          task.tags.map(tag =>
-            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
-          )
+      ? div(
+          { class: "card-tags" },
+          task.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
         )
       : null,
-    div({ class: 'card-comments-summary' },
-      span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
-      span({ class: 'card-value' }, String(commentCount)),
-      br, br,
-      form({ method: 'GET', action: `/tasks/${encodeURIComponent(task.id)}` },
-        button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+    div(
+      { class: "card-comments-summary" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
+      span({ class: "card-value" }, String(commentCount)),
+      br(),
+      br(),
+      form(
+        { method: "GET", action: `/tasks/${encodeURIComponent(task.id)}` },
+        input({ type: "hidden", name: "filter", value: currentFilter }),
+        button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
       )
     ),
-    br,
-    p({ class: 'card-footer' },
-      span({ class: 'date-link' }, `${moment(task.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-      a({ href: `/author/${encodeURIComponent(task.author)}`, class: 'user-link' }, `${task.author}`)
+    br(),
+    p(
+      { class: "card-footer" },
+      span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+      a({ href: `/author/${encodeURIComponent(task.author)}`, class: "user-link" }, `${task.author}`)
     )
   );
 };
 
-exports.taskView = async (tasks, filter, taskId) => {
+exports.taskView = async (tasks, filter, taskId, returnTo) => {
   const list = Array.isArray(tasks) ? tasks : [tasks];
+  const currentFilter = filter || "all";
+
   const title =
-    filter === 'mine'        ? i18n.taskMineSectionTitle :
-    filter === 'create'      ? i18n.taskCreateSectionTitle :
-    filter === 'edit'        ? i18n.taskUpdateSectionTitle :
-    filter === 'open'        ? i18n.taskOpenTitle :
-    filter === 'in-progress' ? i18n.taskInProgressTitle :
-    filter === 'closed'      ? i18n.taskClosedTitle :
-    filter === 'assigned'    ? i18n.taskAssignedTitle :
-    filter === 'priority-urgent' ? i18n.taskFilterUrgent :
-    filter === 'priority-high'   ? i18n.taskFilterHigh :
-    filter === 'priority-medium' ? i18n.taskFilterMedium :
-    filter === 'priority-low'    ? i18n.taskFilterLow :
-                                  i18n.taskAllSectionTitle;
+    currentFilter === "mine" ? i18n.taskMineSectionTitle :
+    currentFilter === "create" ? i18n.taskCreateSectionTitle :
+    currentFilter === "edit" ? i18n.taskUpdateSectionTitle :
+    currentFilter === "open" ? i18n.taskOpenTitle :
+    currentFilter === "in-progress" ? i18n.taskInProgressTitle :
+    currentFilter === "closed" ? i18n.taskClosedTitle :
+    currentFilter === "assigned" ? i18n.taskAssignedTitle :
+    currentFilter === "priority-urgent" ? i18n.taskFilterUrgent :
+    currentFilter === "priority-high" ? i18n.taskFilterHigh :
+    currentFilter === "priority-medium" ? i18n.taskFilterMedium :
+    currentFilter === "priority-low" ? i18n.taskFilterLow :
+    i18n.taskAllSectionTitle;
+
+  const canSee = (t) => {
+    const vis = String(t.isPublic || "").toUpperCase();
+    if (vis === "PUBLIC") return true;
+    if (t.author === userId) return true;
+    return safeArray(t.assignees).includes(userId);
+  };
+
+  const visible = list.filter(canSee);
 
   let filtered;
-  if (filter === 'mine') filtered = list.filter(t => t.author === userId);
-  else if (filter === 'assigned') filtered = list.filter(t => Array.isArray(t.assignees) && t.assignees.includes(userId) && t.isPublic === 'PUBLIC');
-  else if (filter === 'open') filtered = list.filter(t => t.status === 'OPEN' && t.isPublic === 'PUBLIC');
-  else if (filter === 'in-progress') filtered = list.filter(t => t.status === 'IN-PROGRESS' && t.isPublic === 'PUBLIC');
-  else if (filter === 'closed') filtered = list.filter(t => t.status === 'CLOSED' && t.isPublic === 'PUBLIC');
-  else if (filter === 'priority-urgent') filtered = list.filter(t => t.priority === 'URGENT' && t.isPublic === 'PUBLIC');
-  else if (filter === 'priority-high') filtered = list.filter(t => t.priority === 'HIGH' && t.isPublic === 'PUBLIC');
-  else if (filter === 'priority-medium') filtered = list.filter(t => t.priority === 'MEDIUM' && t.isPublic === 'PUBLIC');
-  else if (filter === 'priority-low') filtered = list.filter(t => t.priority === 'LOW' && t.isPublic === 'PUBLIC');
-  else filtered = list.filter(t => t.isPublic === 'PUBLIC');
+  if (currentFilter === "mine") filtered = visible.filter((t) => t.author === userId);
+  else if (currentFilter === "assigned") filtered = visible.filter((t) => safeArray(t.assignees).includes(userId));
+  else if (currentFilter === "open") filtered = visible.filter((t) => normalizeStatus(t.status) === "OPEN");
+  else if (currentFilter === "in-progress") filtered = visible.filter((t) => normalizeStatus(t.status) === "IN-PROGRESS");
+  else if (currentFilter === "closed") filtered = visible.filter((t) => normalizeStatus(t.status) === "CLOSED");
+  else if (currentFilter === "priority-urgent") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "URGENT");
+  else if (currentFilter === "priority-high") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "HIGH");
+  else if (currentFilter === "priority-medium") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "MEDIUM");
+  else if (currentFilter === "priority-low") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "LOW");
+  else filtered = visible;
 
   filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
 
-  const editTask = list.find(t => t.id === taskId) || {};
+  const editTask = list.find((t) => t.id === taskId) || {};
   const editTags = Array.isArray(editTask.tags) ? editTask.tags : [];
+  const minCreate = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm");
+
+  const ret = typeof returnTo === "string" && returnTo.startsWith("/tasks")
+    ? returnTo
+    : "/tasks?filter=mine";
 
   return template(
     title,
     section(
-      div({ class: 'tags-header' },
+      div(
+        { class: "tags-header" },
         h2(i18n.tasksTitle),
         p(i18n.tasksDescription)
       ),
-      div({ class: 'filters' },
-        form({ method: 'GET', action: '/tasks' },
-          button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterAll),
-          button({ type: 'submit', name: 'filter', value: 'mine', class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterMine),
-          button({ type: 'submit', name: 'filter', value: 'assigned', class: filter === 'assigned' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterAssigned),
-          button({ type: 'submit', name: 'filter', value: 'open', class: filter === 'open' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterOpen),
-          button({ type: 'submit', name: 'filter', value: 'in-progress', class: filter === 'in-progress' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterInProgress),
-          button({ type: 'submit', name: 'filter', value: 'closed', class: filter === 'closed' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterClosed),
-          button({ type: 'submit', name: 'filter', value: 'priority-low', class: filter === 'priority-low' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterLow),
-          button({ type: 'submit', name: 'filter', value: 'priority-medium', class: filter === 'priority-medium' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterMedium),
-          button({ type: 'submit', name: 'filter', value: 'priority-high', class: filter === 'priority-high' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterHigh),
-          button({ type: 'submit', name: 'filter', value: 'priority-urgent', class: filter === 'priority-urgent' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterUrgent),
-          button({ type: 'submit', name: 'filter', value: 'create', class: "create-button" }, i18n.taskCreateButton)
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/tasks" },
+          button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine),
+          button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned),
+          button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen),
+          button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress),
+          button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed),
+          button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow),
+          button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium),
+          button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh),
+          button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton)
         )
       )
     ),
     section(
-      filter === 'edit' || filter === 'create'
-        ? div({ class: 'task-form' },
-            form({ action: filter === 'edit' ? `/tasks/update/${encodeURIComponent(taskId)}` : '/tasks/create', method: 'POST' },
+      currentFilter === "edit" || currentFilter === "create"
+        ? div(
+            { class: "task-form" },
+            form(
+              { action: currentFilter === "edit" ? `/tasks/update/${encodeURIComponent(taskId)}` : "/tasks/create", method: "POST" },
+              input({ type: "hidden", name: "returnTo", value: ret }),
               label(i18n.taskTitleLabel), br(),
-              input({ type: 'text', name: 'title', required: true, value: filter === 'edit' ? editTask.title : '' }), br(), br(),
+              input({ type: "text", name: "title", required: true, value: currentFilter === "edit" ? (editTask.title || "") : "" }), br(), br(),
               label(i18n.taskDescriptionLabel), br(),
-              textarea({ name: 'description', required: true, placeholder: i18n.taskDescriptionPlaceholder, rows:"4"}, filter === 'edit' ? editTask.description : ''), br(), br(),
+              textarea({ name: "description", required: true, placeholder: i18n.taskDescriptionPlaceholder, rows: "4" }, currentFilter === "edit" ? (editTask.description || "") : ""), br(), br(),
               label(i18n.taskStartTimeLabel), br(),
-              input({ type: 'datetime-local', name: 'startTime', required: true, min: moment().format('YYYY-MM-DDTHH:mm'), value: filter === 'edit' ? moment(editTask.startTime).format('YYYY-MM-DDTHH:mm') : '' }), br(), br(),
+              input({
+                type: "datetime-local",
+                name: "startTime",
+                required: true,
+                min: currentFilter === "create" ? minCreate : undefined,
+                value: currentFilter === "edit" && editTask.startTime ? moment(editTask.startTime).format("YYYY-MM-DDTHH:mm") : ""
+              }), br(), br(),
               label(i18n.taskEndTimeLabel), br(),
-              input({ type: 'datetime-local', name: 'endTime', required: true, min: moment().format('YYYY-MM-DDTHH:mm'), value: filter === 'edit' ? moment(editTask.endTime).format('YYYY-MM-DDTHH:mm') : '' }), br(), br(),
+              input({
+                type: "datetime-local",
+                name: "endTime",
+                required: true,
+                min: currentFilter === "create" ? minCreate : undefined,
+                value: currentFilter === "edit" && editTask.endTime ? moment(editTask.endTime).format("YYYY-MM-DDTHH:mm") : ""
+              }), br(), br(),
               label(i18n.taskPriorityLabel), br(),
-              select({ name: 'priority', required: true },
-                option({ value: 'URGENT', selected: editTask.priority === 'URGENT' }, i18n.taskPriorityUrgent),
-                option({ value: 'HIGH', selected: editTask.priority === 'HIGH' }, i18n.taskPriorityHigh),   
-                option({ value: 'MEDIUM', selected: editTask.priority === 'MEDIUM' }, i18n.taskPriorityMedium),
-                option({ value: 'LOW', selected: editTask.priority === 'LOW' }, i18n.taskPriorityLow)
+              select(
+                { name: "priority", required: true },
+                opt("URGENT", String(editTask.priority || "").toUpperCase() === "URGENT", i18n.taskPriorityUrgent),
+                opt("HIGH", String(editTask.priority || "").toUpperCase() === "HIGH", i18n.taskPriorityHigh),
+                opt("MEDIUM", String(editTask.priority || "").toUpperCase() === "MEDIUM", i18n.taskPriorityMedium),
+                opt("LOW", !editTask.priority || String(editTask.priority || "").toUpperCase() === "LOW", i18n.taskPriorityLow)
               ), br(), br(),
               label(i18n.taskLocationLabel), br(),
-              input({ type: 'text', name: 'location', value: editTask.location || '' }), br(), br(),
+              input({ type: "text", name: "location", value: editTask.location || "" }), br(), br(),
               label(i18n.taskTagsLabel), br(),
-              input({ type: 'text', name: 'tags', value: editTags.join(', ') }), br(), br(),
+              input({ type: "text", name: "tags", value: editTags.join(", ") }), br(), br(),
               label(i18n.taskVisibilityLabel), br(),
-              select({ name: 'isPublic', id: 'isPublic' },
-                option({ value: 'PUBLIC', selected: editTask.isPublic === 'PUBLIC' ? 'selected' : undefined }, i18n.taskPublic),
-                option({ value: 'PRIVATE', selected: editTask.isPublic === 'PRIVATE' ? 'selected' : undefined }, i18n.taskPrivate)
-              ), br(), br(),  
-              button({ type: 'submit' }, filter === 'edit' ? i18n.taskUpdateButton : i18n.taskCreateButton)
+              select(
+                { name: "isPublic", id: "isPublic" },
+                opt("PUBLIC", String(editTask.isPublic || "PUBLIC").toUpperCase() === "PUBLIC", i18n.taskPublic),
+                opt("PRIVATE", String(editTask.isPublic || "").toUpperCase() === "PRIVATE", i18n.taskPrivate)
+              ), br(), br(),
+              button({ type: "submit" }, currentFilter === "edit" ? i18n.taskUpdateButton : i18n.taskCreateButton)
             )
           )
-        : div({ class: 'task-list' },
+        : div(
+            { class: "task-list" },
             filtered.length > 0
-              ? filtered.map(t => renderTaskItem(t, filter, userId))
+              ? filtered.map((t) => renderTaskItem(t, currentFilter))
               : p(i18n.notasks)
           )
     )
@@ -255,62 +387,70 @@ exports.taskView = async (tasks, filter, taskId) => {
 };
 
 exports.singleTaskView = async (task, filter, comments = []) => {
+  const currentFilter = filter || "all";
+  const assignees = safeArray(task.assignees);
+  const commentCount = typeof task.commentCount === "number" ? task.commentCount : 0;
+
+  const topbar = renderTaskTopbar(task, currentFilter, { single: true });
+
   return template(
     task.title,
     section(
-      div({ class: "filters" },
-        form({ method: 'GET', action: '/tasks' },
-          button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterAll),
-          button({ type: 'submit', name: 'filter', value: 'mine', class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterMine),
-          button({ type: 'submit', name: 'filter', value: 'assigned', class: filter === 'assigned' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterAssigned),
-          button({ type: 'submit', name: 'filter', value: 'open', class: filter === 'open' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterOpen),
-          button({ type: 'submit', name: 'filter', value: 'in-progress', class: filter === 'in-progress' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterInProgress),
-          button({ type: 'submit', name: 'filter', value: 'closed', class: filter === 'closed' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterClosed),
-          button({ type: 'submit', name: 'filter', value: 'priority-low', class: filter === 'priority-low' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterLow),
-          button({ type: 'submit', name: 'filter', value: 'priority-medium', class: filter === 'priority-medium' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterMedium),
-          button({ type: 'submit', name: 'filter', value: 'priority-high', class: filter === 'priority-high' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterHigh),
-          button({ type: 'submit', name: 'filter', value: 'priority-urgent', class: filter === 'priority-urgent' ? 'filter-btn active' : 'filter-btn' }, i18n.taskFilterUrgent),
-          button({ type: 'submit', name: 'filter', value: 'create', class: "create-button" }, i18n.taskCreateButton)
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/tasks" },
+          button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine),
+          button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned),
+          button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen),
+          button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress),
+          button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed),
+          button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow),
+          button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium),
+          button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh),
+          button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton)
         )
       ),
-      div({ class: 'card card-section task' },
-        renderStyledField(i18n.taskTitleLabel + ':', task.title),
-        renderStyledField(i18n.taskDescriptionLabel + ':'),
+      div(
+        { class: "card card-section task" },
+        topbar ? topbar : null,
+        renderCardField(i18n.taskTitleLabel + ":", task.title),
+        renderCardField(i18n.taskDescriptionLabel + ":", ""),
         p(...renderUrl(task.description)),
-        renderStyledField(i18n.taskStartTimeLabel + ':', moment(task.startTime).format('YYYY/MM/DD HH:mm:ss')),
-        renderStyledField(i18n.taskEndTimeLabel + ':', moment(task.endTime).format('YYYY/MM/DD HH:mm:ss')),
-        renderStyledField(i18n.taskPriorityLabel + ':', task.priority),
-        task.location?.trim() ? renderStyledField(i18n.taskLocationLabel + ':', task.location) : null,
-        renderStyledField(i18n.taskCreatedAt + ':', moment(task.createdAt).format(i18n.dateFormat)),
-        renderStyledField(i18n.taskBy + ':', a({ href: `/author/${encodeURIComponent(task.author)}` }, task.author)),
-        renderStyledField(i18n.taskStatus + ':', task.status),
-        renderStyledField(i18n.taskVisibilityLabel + ':', task.isPublic),
-        div({ class: 'card-field' },
-          span({ class: 'card-label' }, i18n.taskAssignedTo + ':'),
-          span({ class: 'card-value' },
-            Array.isArray(task.assignees) && task.assignees.length
-              ? task.assignees.map((id, i) => [i > 0 ? ', ' : '', a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
+        renderCardField(i18n.taskStartTimeLabel + ":", task.startTime ? moment(task.startTime).format("YYYY/MM/DD HH:mm:ss") : ""),
+        renderCardField(i18n.taskEndTimeLabel + ":", task.endTime ? moment(task.endTime).format("YYYY/MM/DD HH:mm:ss") : ""),
+        renderCardField(i18n.taskPriorityLabel + ":", task.priority),
+        task.location && String(task.location).trim() ? renderCardField(i18n.taskLocationLabel + ":", task.location) : null,
+        renderCardField(i18n.taskCreatedAt + ":", task.createdAt ? moment(task.createdAt).format(i18n.dateFormat) : ""),
+        renderCardField(i18n.taskBy + ":", a({ href: `/author/${encodeURIComponent(task.author)}`, class: "user-link" }, task.author)),
+        renderCardField(i18n.taskStatus + ":", statusLabel(task.status)),
+        renderCardField(i18n.taskVisibilityLabel + ":", visibilityLabel(task.isPublic)),
+        div(
+          { class: "card-field" },
+          span({ class: "card-label" }, i18n.taskAssignedTo + ":"),
+          span(
+            { class: "card-value" },
+            assignees.length
+              ? assignees.map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
               : i18n.noAssignees
           )
         ),
         Array.isArray(task.tags) && task.tags.length
-          ? div({ class: 'card-tags' },
-              task.tags.map(tag =>
-                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
-              )
+          ? div(
+              { class: "card-tags" },
+              task.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
             )
-          : null
-      ),
-      div({ class: "task-actions" },
-        form({ method: "POST", action: `/tasks/assign/${encodeURIComponent(task.id)}` },
-          button({ type: "submit" },
-            task.assignees.includes(userId)
-              ? i18n.taskUnassignButton
-              : i18n.taskAssignButton
-          )
+          : null,
+        div(
+          { class: "card-comments-summary" },
+          span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
+          span({ class: "card-value" }, String(commentCount))
         )
       ),
-      renderTaskCommentsSection(task.id, comments)
+      renderTaskCommentsSection(task.id, comments, currentFilter)
     )
   );
 };
+

+ 399 - 178
src/views/transfer_view.js

@@ -1,80 +1,206 @@
-const { div, h2, p, section, button, form, a, input, img, textarea, br, span, label } = 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 opinionCategories = require('../backend/opinion_categories');
+const { div, h2, p, section, button, form, a, input, br, span, label, select, option, progress } = 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 opinionCategories = require("../backend/opinion_categories")
 
 const userId = config.keys.id
 
-const generateTransferActions = (transfer, userId) => {
-  return (transfer.from === userId && transfer.status === 'UNCONFIRMED')
-    ? div({ class: "transfer-actions" },
-        form({ method: "GET", action: `/transfers/edit/${encodeURIComponent(transfer.id)}` },
-          button({ type: "submit", class: "update-btn" }, i18n.transfersUpdateButton)
-        ),
-        form({ method: "POST", action: `/transfers/delete/${encodeURIComponent(transfer.id)}` },
-          button({ type: "submit", class: "delete-btn" }, i18n.transfersDeleteButton)
-        )
+const safeArr = (v) => (Array.isArray(v) ? v : [])
+const safeText = (v) => String(v || "").trim()
+
+const parseNum = (v) => {
+  const n = parseFloat(String(v ?? "").replace(",", "."))
+  return Number.isFinite(n) ? n : NaN
+}
+
+const fmtAmount = (v) => {
+  const n = parseNum(v)
+  return Number.isFinite(n) ? n.toFixed(6) : String(v ?? "")
+}
+
+const buildReturnTo = (filter, params = {}) => {
+  const f = safeText(filter || "all")
+  const q = safeText(params.q || "")
+  const minAmount = params.minAmount ?? ""
+  const maxAmount = params.maxAmount ?? ""
+  const sort = safeText(params.sort || "")
+  const parts = [`filter=${encodeURIComponent(f)}`]
+  if (q) parts.push(`q=${encodeURIComponent(q)}`)
+  if (String(minAmount) !== "") parts.push(`minAmount=${encodeURIComponent(String(minAmount))}`)
+  if (String(maxAmount) !== "") parts.push(`maxAmount=${encodeURIComponent(String(maxAmount))}`)
+  if (sort) parts.push(`sort=${encodeURIComponent(sort)}`)
+  return `/transfers?${parts.join("&")}`
+}
+
+const statusKey = (s) => {
+  const up = String(s || "").toUpperCase()
+  const pretty = up.charAt(0) + up.slice(1).toLowerCase()
+  return `transfersStatus${pretty}`
+}
+
+const renderTags = (tags = []) => {
+  const arr = safeArr(tags).map(t => String(t || "").trim()).filter(Boolean)
+  return arr.length
+    ? div(
+        { class: "card-tags" },
+        arr.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
       )
-    : null;
-};
-
-const generateTransferCard = (transfer, userId) => {
-  return div({ class: "transfer-item" },
-    div({ class: 'card-section transfer' },
-      generateTransferActions(transfer, userId),
-      form({ method: "GET", action: `/transfers/${encodeURIComponent(transfer.id)}` },
+    : null
+}
+
+const renderCardField = (labelText, valueNode) =>
+  div(
+    { class: "card-field" },
+    span({ class: "card-label" }, labelText),
+    span({ class: "card-value" }, valueNode)
+  )
+
+const renderConfirmationsBar = (confirmedCount, required) => {
+  const req = Math.max(1, Number(required || 2))
+  const cc = Math.max(0, Number(confirmedCount || 0))
+  return div(
+    { class: "confirmations-block" },
+      { class: "card-field" },
+      span({ class: "card-label" }, `${i18n.transfersConfirmations}: `),
+      span({ class: "card-value" }, `${cc}/${req}`),
+    progress({ class: "confirmations-progress", value: cc, max: req })
+  )
+}
+
+const renderOwnerActions = (transfer, returnTo) => {
+  const canEdit = transfer.from === userId && String(transfer.status || "").toUpperCase() === "UNCONFIRMED"
+  if (!canEdit) return []
+  return [
+    form(
+      { method: "GET", action: `/transfers/edit/${encodeURIComponent(transfer.id)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ type: "submit", class: "update-btn" }, i18n.transfersUpdateButton)
+    ),
+    form(
+      { method: "POST", action: `/transfers/delete/${encodeURIComponent(transfer.id)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ type: "submit", class: "delete-btn" }, i18n.transfersDeleteButton)
+    )
+  ]
+}
+
+const renderUpdatedLabel = (createdAt, updatedAt) => {
+  const createdTs = createdAt ? new Date(createdAt).getTime() : NaN
+  const updatedTs = updatedAt ? new Date(updatedAt).getTime() : NaN
+  const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs)
+  return showUpdated
+    ? span({ class: "votations-comment-date" }, ` | ${i18n.transfersUpdatedAt}: ${moment(updatedAt).format("YYYY-MM-DD HH:mm")}`)
+    : null
+}
+
+const renderTransferTopbar = (transfer, filter, params = {}) => {
+  const returnTo = buildReturnTo(filter, params)
+  const dl = transfer.deadline ? moment(transfer.deadline) : null
+  const isExpired = dl && dl.isValid() ? dl.isBefore(moment()) : false
+  const isExpiringSoon = dl && dl.isValid() ? !isExpired && dl.diff(moment(), "hours") <= 24 : false
+  const otherParty = transfer.from === userId ? transfer.to : transfer.from
+  const isSingle = params && params.single === true
+
+  const chips = []
+  if (isExpired) chips.push(span({ class: "chip chip-warn" }, i18n.transfersExpiredBadge))
+  if (isExpiringSoon) chips.push(span({ class: "chip chip-warn" }, i18n.transfersExpiringSoonBadge))
+
+  const leftActions = []
+
+  if (!isSingle) {
+    leftActions.push(
+      form(
+        { method: "GET", action: `/transfers/${encodeURIComponent(transfer.id)}` },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
+        input({ type: "hidden", name: "filter", value: filter || "all" }),
+        params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
+        params.minAmount !== undefined ? input({ type: "hidden", name: "minAmount", value: String(params.minAmount ?? "") }) : null,
+        params.maxAmount !== undefined ? input({ type: "hidden", name: "maxAmount", value: String(params.maxAmount ?? "") }) : null,
+        params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
         button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-      ),
-      br,
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, `${i18n.transfersConcept}:`),
-        span({ class: 'card-value' }, transfer.concept)
-      ),
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, `${i18n.transfersDeadline}:`),
-        span({ class: 'card-value' }, moment(transfer.deadline).format("YYYY-MM-DD HH:mm"))
-      ),
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, `${i18n.transfersStatus}:`),
-        span({ class: 'card-value' }, i18n[`transfersStatus${transfer.status.charAt(0) + transfer.status.slice(1).toLowerCase()}`])
-      ),
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, `${i18n.transfersAmount}:`),
-        span({ class: 'card-value' }, `${transfer.amount} ECO`)
-      ),
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, `${i18n.transfersFrom}:`),
-        span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(transfer.from)}`, target: "_blank" }, transfer.from))
-      ),
-      div({ class: 'card-field' },
-        span({ class: 'card-label' }, `${i18n.transfersTo}:`),
-        span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(transfer.to)}`, target: "_blank" }, transfer.to))
-      ),
-      h2({ class: 'card-field' },
-        span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
-        span({ class: 'card-value' }, `${(transfer.confirmedBy || []).length}/2`)
-      ),
-      (transfer.status === 'UNCONFIRMED' && transfer.to === userId)
-        ? form({ method: "POST", action: `/transfers/confirm/${encodeURIComponent(transfer.id)}` },
-            button({ type: "submit" }, i18n.transfersConfirmButton), br(), br()
-          )
-        : null,
-      transfer.tags && transfer.tags.length
-        ? div({ class: 'card-tags' },
-            transfer.tags.map(tag =>
-              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
-            )
+      )
+    )
+  }
+
+  if (otherParty && String(otherParty) !== String(userId)) {
+    leftActions.push(
+      form(
+        { method: "GET", action: "/pm" },
+        input({ type: "hidden", name: "recipients", value: otherParty }),
+        button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
+      )
+    )
+  }
+
+  const leftChildren = []
+  if (chips.length) leftChildren.push(div({ class: "transfer-chips" }, ...chips))
+  leftChildren.push(...leftActions.filter(Boolean))
+
+  const ownerActions = renderOwnerActions(transfer, returnTo)
+  const actionsNode = ownerActions.length ? div({ class: "bookmark-actions transfer-actions" }, ...ownerActions) : null
+
+  const leftClass = leftChildren.length ? "bookmark-topbar-left transfer-topbar-left" : ""
+  const leftNode = leftChildren.length ? div({ class: leftClass }, ...leftChildren) : null
+
+  const topbarChildren = []
+  if (leftNode) topbarChildren.push(leftNode)
+  if (actionsNode) topbarChildren.push(actionsNode)
+
+  const topbarClass = isSingle ? "bookmark-topbar transfer-topbar-single" : "bookmark-topbar"
+  return topbarChildren.length ? div({ class: topbarClass }, ...topbarChildren) : null
+}
+
+const generateTransferCard = (transfer, filter, params = {}) => {
+  const returnTo = buildReturnTo(filter, params)
+  const confirmedBy = safeArr(transfer.confirmedBy)
+  const required = transfer.from === transfer.to ? 1 : 2
+  const confirmedCount = confirmedBy.length
+  const isUnconfirmed = String(transfer.status || "").toUpperCase() === "UNCONFIRMED"
+  const dl = transfer.deadline ? moment(transfer.deadline) : null
+  const isExpired = dl && dl.isValid() ? dl.isBefore(moment()) : false
+  const showConfirm = isUnconfirmed && transfer.to === userId && !confirmedBy.includes(userId) && !isExpired
+
+  const topbar = renderTransferTopbar(transfer, filter, params)
+  const tagsNode = renderTags(transfer.tags)
+
+  return div(
+    { class: "transfer-item" },
+    div(
+      { class: "card-section transfer" },
+      topbar ? topbar : null,
+      renderCardField(`${i18n.transfersConcept}:`, transfer.concept || ""),
+      renderCardField(`${i18n.transfersDeadline}:`, dl && dl.isValid() ? dl.format("YYYY-MM-DD HH:mm") : ""),
+      renderCardField(`${i18n.transfersStatus}:`, i18n[statusKey(transfer.status)] || String(transfer.status || "")),
+      renderCardField(`${i18n.transfersAmount}:`, `${fmtAmount(transfer.amount)} ECO`),
+      renderCardField(`${i18n.transfersFrom}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.from)}` }, transfer.from)),
+      renderCardField(`${i18n.transfersTo}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.to)}` }, transfer.to)),
+      br(),
+      renderConfirmationsBar(confirmedCount, required),
+      br(),
+      showConfirm
+        ? form(
+            { method: "POST", action: `/transfers/confirm/${encodeURIComponent(transfer.id)}` },
+            input({ type: "hidden", name: "returnTo", value: returnTo }),
+            button({ type: "submit" }, i18n.transfersConfirmButton),
+            br(),
+            br()
           )
         : null,
-      br,
-      p({ class: 'card-footer' },
-        span({ class: 'date-link' }, `${transfer.createdAt} ${i18n.performed} `),
-        a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: 'user-link' }, `${transfer.from}`)
+      tagsNode ? tagsNode : null,
+      tagsNode ? br() : null,
+      p(
+        { class: "card-footer" },
+        span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
+        a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: "user-link" }, `${transfer.from}`),
+        renderUpdatedLabel(transfer.createdAt, transfer.updatedAt)
       ),
-      div({ class: "voting-buttons" },
+      div(
+        { class: "voting-buttons transfer-voting-buttons" },
         opinionCategories.map(category =>
-          form({ method: "POST", action: `/transfers/opinions/${encodeURIComponent(transfer.id)}/${category}` },
+          form(
+            { method: "POST", action: `/transfers/opinions/${encodeURIComponent(transfer.id)}/${category}` },
+            input({ type: "hidden", name: "returnTo", value: returnTo }),
             button(
               { class: "vote-btn" },
               `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${transfer.opinions?.[category] || 0}]`
@@ -83,144 +209,239 @@ const generateTransferCard = (transfer, userId) => {
         )
       )
     )
-  );
-};
+  )
+}
+
+exports.transferView = async (transfers, filter, transferId, params = {}) => {
+  const normalizedFilter = filter === "favs" ? "all" : (filter || "all")
 
-exports.transferView = async (transfers, filter, transferId) => {
   const title =
-    filter === 'mine'        ? i18n.transfersMineSectionTitle :
-    filter === 'pending'     ? i18n.transfersPendingSectionTitle :
-    filter === 'top'         ? i18n.transfersTopSectionTitle :
-    filter === 'unconfirmed' ? i18n.transfersUnconfirmedSectionTitle :
-    filter === 'closed'      ? i18n.transfersClosedSectionTitle :
-    filter === 'discarded'   ? i18n.transfersDiscardedSectionTitle :
-                               i18n.transfersAllSectionTitle;
+    normalizedFilter === "mine"        ? i18n.transfersMineSectionTitle :
+    normalizedFilter === "pending"     ? i18n.transfersPendingSectionTitle :
+    normalizedFilter === "top"         ? i18n.transfersTopSectionTitle :
+    normalizedFilter === "unconfirmed" ? i18n.transfersUnconfirmedSectionTitle :
+    normalizedFilter === "closed"      ? i18n.transfersClosedSectionTitle :
+    normalizedFilter === "discarded"   ? i18n.transfersDiscardedSectionTitle :
+    normalizedFilter === "create"      ? i18n.transfersCreateSectionTitle :
+    normalizedFilter === "edit"        ? i18n.transfersUpdateSectionTitle :
+                                        i18n.transfersAllSectionTitle
+
+  const q = safeText(params.q || "")
+  const minAmountRaw = params.minAmount ?? ""
+  const maxAmountRaw = params.maxAmount ?? ""
+  const minAmount = parseNum(minAmountRaw)
+  const maxAmount = parseNum(maxAmountRaw)
+  const sort = safeText(params.sort || "recent")
+
+  const list = safeArr(transfers)
 
   let filtered =
-    filter === 'mine'        ? transfers.filter(t => t.from === userId || t.to === userId) :
-    filter === 'pending'     ? transfers.filter(t => t.status === 'UNCONFIRMED' && t.to === userId) :
-    filter === 'top'         ? transfers.filter(t => t.status === 'CLOSED').sort((a, b) => b.amount - a.amount) :
-    filter === 'unconfirmed' ? transfers.filter(t => t.status === 'UNCONFIRMED') :
-    filter === 'closed'      ? transfers.filter(t => t.status === 'CLOSED') :
-    filter === 'discarded'   ? transfers.filter(t => t.status === 'DISCARDED') :
-                               transfers;
-
-  if (filter !== 'top') {
-    filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+    normalizedFilter === "mine"        ? list.filter(t => t.from === userId || t.to === userId) :
+    normalizedFilter === "pending"     ? list.filter(t => String(t.status || "").toUpperCase() === "UNCONFIRMED" && t.to === userId && !safeArr(t.confirmedBy).includes(userId)) :
+    normalizedFilter === "top"         ? list.filter(t => String(t.status || "").toUpperCase() === "CLOSED") :
+    normalizedFilter === "unconfirmed" ? list.filter(t => String(t.status || "").toUpperCase() === "UNCONFIRMED") :
+    normalizedFilter === "closed"      ? list.filter(t => String(t.status || "").toUpperCase() === "CLOSED") :
+    normalizedFilter === "discarded"   ? list.filter(t => String(t.status || "").toUpperCase() === "DISCARDED") :
+    normalizedFilter === "market"      ? list :
+                                        list
+
+  if (q) {
+    const qq = q.toLowerCase()
+    filtered = filtered.filter(t => {
+      const concept = String(t.concept || "").toLowerCase()
+      const tags = safeArr(t.tags).join(" ").toLowerCase()
+      const from = String(t.from || "").toLowerCase()
+      const to = String(t.to || "").toLowerCase()
+      return concept.includes(qq) || tags.includes(qq) || from.includes(qq) || to.includes(qq)
+    })
   }
 
-  const isForm = filter === 'create' || filter === 'edit';
-  const transferToEdit = filter === 'edit' ? transfers.find(t => t.id === transferId) || {} : {};
+  if (Number.isFinite(minAmount)) filtered = filtered.filter(t => parseNum(t.amount) >= minAmount)
+  if (Number.isFinite(maxAmount)) filtered = filtered.filter(t => parseNum(t.amount) <= maxAmount)
+
+  if (normalizedFilter === "top" || sort === "amount") {
+    filtered = filtered.sort((a, b) => parseNum(b.amount) - parseNum(a.amount))
+  } else if (sort === "deadline") {
+    filtered = filtered.sort((a, b) => new Date(a.deadline || 0) - new Date(b.deadline || 0))
+  } else {
+    filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+  }
+
+  const isForm = normalizedFilter === "create" || normalizedFilter === "edit"
+  const transferToEdit = normalizedFilter === "edit" ? (list.find(t => t.id === transferId) || {}) : {}
+  const returnToForForm = buildReturnTo("all", {})
 
   return template(
     title,
     section(
-      div({ class: "tags-header" }, h2(i18n.transfersTitle), p(i18n.transfersDescription)),
-      div({ class: "filters" },
-        form({ method: "GET", action: "/transfers" },
-          button({ type: "submit", name: "filter", value: "all", class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.transfersFilterAll),
-          button({ type: "submit", name: "filter", value: "mine", class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.transfersFilterMine),
-          button({ type: "submit", name: "filter", value: "pending", class: filter === 'pending' ? 'filter-btn active' : 'filter-btn' }, i18n.transfersFilterPending),
-          button({ type: "submit", name: "filter", value: "unconfirmed", class: filter === 'unconfirmed' ? 'filter-btn active' : 'filter-btn' }, i18n.transfersFilterUnconfirmed),
-          button({ type: "submit", name: "filter", value: "closed", class: filter === 'closed' ? 'filter-btn active' : 'filter-btn' }, i18n.transfersFilterClosed),
-          button({ type: "submit", name: "filter", value: "discarded", class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' }, i18n.transfersFilterDiscarded),
-          button({ type: "submit", name: "filter", value: "top", class: filter === 'top' ? 'filter-btn active' : 'filter-btn' }, i18n.transfersFilterTop),
+      div({ class: "tags-header" }, h2(title), p(i18n.transfersDescription)),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/transfers", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "minAmount", value: String(minAmountRaw ?? "") }),
+          input({ type: "hidden", name: "maxAmount", value: String(maxAmountRaw ?? "") }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: normalizedFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: normalizedFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMine),
+          button({ type: "submit", name: "filter", value: "market", class: normalizedFilter === "market" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMarket),
+          button({ type: "submit", name: "filter", value: "pending", class: normalizedFilter === "pending" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterPending),
+          button({ type: "submit", name: "filter", value: "unconfirmed", class: normalizedFilter === "unconfirmed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUnconfirmed),
+          button({ type: "submit", name: "filter", value: "closed", class: normalizedFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterClosed),
+          button({ type: "submit", name: "filter", value: "discarded", class: normalizedFilter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterDiscarded),
+          button({ type: "submit", name: "filter", value: "top", class: normalizedFilter === "top" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTop),
           button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.transfersCreateButton)
         )
       )
     ),
     section(
       isForm
-        ? div({ class: "transfer-form" },
-            form({ action: filter === 'edit' ? `/transfers/update/${encodeURIComponent(transferId)}` : "/transfers/create", method: "POST" },
-              label(i18n.transfersToUser), br(),
-              input({ type: "text", name: "to", required: true, pattern: "^@[A-Za-z0-9+/]+={0,2}\\.ed25519$", title: i18n.transfersToUserValidation, value: transferToEdit.to || "" }), br(), br(),
-              label(i18n.transfersConcept), br(),
-              input({ type: "text", name: "concept", required: true, value: transferToEdit.concept || "" }), br(), br(),
-              label(i18n.transfersAmount), br(),
-              input({ type: "number", name: "amount", step: "0.000001", required: true, min: "0.000001", value: transferToEdit.amount || "" }), br(), br(),
-              label(i18n.transfersDeadline), br(),
-              input({ type: "datetime-local", name: "deadline", required: true, min: moment().format("YYYY-MM-DDTHH:mm"), value: transferToEdit.deadline ? moment(transferToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "" }), br(), br(),
-              label(i18n.transfersTags), br(),
-              input({ type: "text", name: "tags", value: (transferToEdit.tags || []).join(", ") }), br(), br(),
-              button({ type: "submit" }, filter === 'edit' ? i18n.transfersUpdateButton : i18n.transfersCreateButton)
+        ? div(
+            { class: "transfer-form" },
+            form(
+              { action: normalizedFilter === "edit" ? `/transfers/update/${encodeURIComponent(transferId)}` : "/transfers/create", method: "POST" },
+              input({ type: "hidden", name: "returnTo", value: returnToForForm }),
+              label(i18n.transfersToUser),
+              br(),
+              input({ type: "text", name: "to", required: true, pattern: "^@[A-Za-z0-9+/]+={0,2}\\.ed25519$", title: i18n.transfersToUserValidation, value: transferToEdit.to || "" }),
+              br(),
+              br(),
+              label(i18n.transfersConcept),
+              br(),
+              input({ type: "text", name: "concept", required: true, value: transferToEdit.concept || "" }),
+              br(),
+              br(),
+              label(i18n.transfersAmount),
+              br(),
+              input({ type: "number", name: "amount", step: "0.000001", required: true, min: "0.000001", value: transferToEdit.amount || "" }),
+              br(),
+              br(),
+              label(i18n.transfersDeadline),
+              br(),
+              input({ type: "datetime-local", name: "deadline", required: true, min: moment().format("YYYY-MM-DDTHH:mm"), value: transferToEdit.deadline ? moment(transferToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "" }),
+              br(),
+              br(),
+              label(i18n.transfersTags),
+              br(),
+              input({ type: "text", name: "tags", value: safeArr(transferToEdit.tags).join(", ") }),
+              br(),
+              br(),
+              button({ type: "submit" }, normalizedFilter === "edit" ? i18n.transfersUpdateButton : i18n.transfersCreateButton)
             )
           )
-        : div({ class: "transfer-list" },
-            filtered.length > 0
-              ? filtered.map(t => generateTransferCard(t, userId))
-              : p(i18n.transfersNoItems)
+        : section(
+            div(
+              { class: "transfers-search" },
+              form(
+                { method: "GET", action: "/transfers", class: "filter-box" },
+                input({ type: "hidden", name: "filter", value: normalizedFilter || "all" }),
+                input({ type: "text", name: "q", value: q, placeholder: i18n.transfersSearchPlaceholder, class: "filter-box__input" }),
+                div(
+                  { class: "filter-box__controls" },
+                  div(
+                    { class: "transfer-range" },
+                    input({ type: "number", name: "minAmount", step: "0.000001", min: "0", value: String(minAmountRaw ?? ""), placeholder: i18n.transfersMinAmountLabel, class: "filter-box__number transfer-amount-input" }),
+                    input({ type: "number", name: "maxAmount", step: "0.000001", min: "0", value: String(maxAmountRaw ?? ""), placeholder: i18n.transfersMaxAmountLabel, class: "filter-box__number transfer-amount-input" })
+                  ),
+                  select(
+                    { name: "sort", class: "filter-box__select" },
+                    option({ value: "recent", selected: sort === "recent" }, i18n.transfersSortRecent),
+                    option({ value: "amount", selected: sort === "amount" }, i18n.transfersSortAmount),
+                    option({ value: "deadline", selected: sort === "deadline" }, i18n.transfersSortDeadline)
+                  ),
+                  button({ type: "submit", class: "filter-box__button" }, i18n.transfersSearchButton)
+                )
+              )
+            ),
+            br(),
+            div(
+              { class: "transfer-list" },
+              filtered.length
+                ? filtered.map(t => generateTransferCard(t, normalizedFilter, { q, minAmount: minAmountRaw, maxAmount: maxAmountRaw, sort }))
+                : p(q || String(minAmountRaw) || String(maxAmountRaw) ? i18n.transfersNoMatch : i18n.transfersNoItems)
+            )
           )
     )
-  );
-};
+  )
+}
+
+exports.singleTransferView = async (transfer, filter, params = {}) => {
+  const normalizedFilter = filter === "favs" ? "all" : (filter || "all")
+  const q = safeText(params.q || "")
+  const sort = safeText(params.sort || "recent")
+  const returnTo = safeText(params.returnTo) || buildReturnTo(normalizedFilter, { ...params, q, sort })
+
+  const confirmedBy = safeArr(transfer.confirmedBy)
+  const required = transfer.from === transfer.to ? 1 : 2
+  const confirmedCount = confirmedBy.length
+  const isUnconfirmed = String(transfer.status || "").toUpperCase() === "UNCONFIRMED"
+  const dl = transfer.deadline ? moment(transfer.deadline) : null
+  const isExpired = dl && dl.isValid() ? dl.isBefore(moment()) : false
+  const showConfirm = isUnconfirmed && transfer.to === userId && !confirmedBy.includes(userId) && !isExpired
+
+  const topbar = renderTransferTopbar(transfer, normalizedFilter, { ...params, q, sort, single: true })
+  const tagsNode = renderTags(transfer.tags)
 
-exports.singleTransferView = async (transfer, filter) => {
   return template(
     transfer.concept,
     section(
-      div({ class: "filters" },
-        form({ method: 'GET', action: '/transfers' },
-          button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.transfersFilterAll),
-          button({ type: 'submit', name: 'filter', value: 'mine', class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.transfersFilterMine),
-          button({ type: 'submit', name: 'filter', value: 'pending', class: filter === 'pending' ? 'filter-btn active' : 'filter-btn' }, i18n.transfersFilterPending),
-          button({ type: 'submit', name: 'filter', value: 'unconfirmed', class: filter === 'unconfirmed' ? 'filter-btn active' : 'filter-btn' }, i18n.transfersFilterUnconfirmed),
-          button({ type: 'submit', name: 'filter', value: 'closed', class: filter === 'closed' ? 'filter-btn active' : 'filter-btn' }, i18n.transfersFilterClosed),
-          button({ type: 'submit', name: 'filter', value: 'discarded', class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' }, i18n.transfersFilterDiscarded),
-          button({ type: 'submit', name: 'filter', value: 'create', class: "create-button" }, i18n.transfersCreateButton)
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/transfers", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "minAmount", value: String(params.minAmount ?? "") }),
+          input({ type: "hidden", name: "maxAmount", value: String(params.maxAmount ?? "") }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: normalizedFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: normalizedFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMine),
+          button({ type: "submit", name: "filter", value: "market", class: normalizedFilter === "market" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMarket),
+          button({ type: "submit", name: "filter", value: "pending", class: normalizedFilter === "pending" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterPending),
+          button({ type: "submit", name: "filter", value: "unconfirmed", class: normalizedFilter === "unconfirmed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUnconfirmed),
+          button({ type: "submit", name: "filter", value: "closed", class: normalizedFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterClosed),
+          button({ type: "submit", name: "filter", value: "discarded", class: normalizedFilter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterDiscarded),
+          button({ type: "submit", name: "filter", value: "top", class: normalizedFilter === "top" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTop),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.transfersCreateButton)
         )
       ),
-      div({ class: "transfer-item" },
-        div({ class: 'card-section transfer' },
-          generateTransferActions(transfer, userId),
-          div({ class: 'card-field' },
-            span({ class: 'card-label' }, `${i18n.transfersConcept}:`),
-            span({ class: 'card-value' }, transfer.concept)
-          ),
-          div({ class: 'card-field' },
-            span({ class: 'card-label' }, `${i18n.transfersDeadline}:`),
-            span({ class: 'card-value' }, moment(transfer.deadline).format("YYYY-MM-DD HH:mm"))
-          ),
-          div({ class: 'card-field' },
-            span({ class: 'card-label' }, `${i18n.transfersStatus}:`),
-            span({ class: 'card-value' }, i18n[`transfersStatus${transfer.status.charAt(0) + transfer.status.slice(1).toLowerCase()}`])
-          ),
-          div({ class: 'card-field' },
-            span({ class: 'card-label' }, `${i18n.transfersAmount}:`),
-            span({ class: 'card-value' }, `${transfer.amount} ECO`)
-          ),
-          div({ class: 'card-field' },
-            span({ class: 'card-label' }, `${i18n.transfersFrom}:`),
-            span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(transfer.from)}`, target: "_blank" }, transfer.from))
-          ),
-          div({ class: 'card-field' },
-            span({ class: 'card-label' }, `${i18n.transfersTo}:`),
-            span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(transfer.to)}`, target: "_blank" }, transfer.to))
-          ),
-          h2({ class: 'card-field' },
-            span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
-            span({ class: 'card-value' }, `${(transfer.confirmedBy || []).length}/2`)
-          ),
-          (transfer.status === 'UNCONFIRMED' && transfer.to === userId)
-            ? form({ method: "POST", action: `/transfers/confirm/${encodeURIComponent(transfer.id)}` },
-                button({ type: "submit" }, i18n.transfersConfirmButton), br(), br()
-              )
-            : null,
-          transfer.tags && transfer.tags.length
-            ? div({ class: 'card-tags' },
-                transfer.tags.map(tag =>
-                  a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
-                )
+      div(
+        { class: "transfer-item" },
+        div(
+          { class: "card-section transfer" },
+          topbar ? topbar : null,
+          renderCardField(`${i18n.transfersConcept}:`, transfer.concept || ""),
+          renderCardField(`${i18n.transfersDeadline}:`, dl && dl.isValid() ? dl.format("YYYY-MM-DD HH:mm") : ""),
+          renderCardField(`${i18n.transfersStatus}:`, i18n[statusKey(transfer.status)] || String(transfer.status || "")),
+          renderCardField(`${i18n.transfersAmount}:`, `${fmtAmount(transfer.amount)} ECO`),
+          renderCardField(`${i18n.transfersFrom}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.from)}` }, transfer.from)),
+          renderCardField(`${i18n.transfersTo}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.to)}` }, transfer.to)),
+          br(),
+          renderConfirmationsBar(confirmedCount, required),
+          br(),
+          showConfirm
+            ? form(
+                { method: "POST", action: `/transfers/confirm/${encodeURIComponent(transfer.id)}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
+                button({ type: "submit" }, i18n.transfersConfirmButton),
+                br(),
+                br()
               )
             : null,
-          br,
-          p({ class: 'card-footer' },
-            span({ class: 'date-link' }, `${transfer.createdAt} ${i18n.performed} `),
-            a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: 'user-link' }, `${transfer.from}`)
+          tagsNode ? tagsNode : null,
+          tagsNode ? br() : null,
+          p(
+            { class: "card-footer" },
+            span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
+            a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: "user-link" }, `${transfer.from}`),
+            renderUpdatedLabel(transfer.createdAt, transfer.updatedAt)
           ),
-          div({ class: "voting-buttons" },
+          div(
+            { class: "voting-buttons transfer-voting-buttons" },
             opinionCategories.map(category =>
-              form({ method: "POST", action: `/transfers/opinions/${encodeURIComponent(transfer.id)}/${category}` },
+              form(
+                { method: "POST", action: `/transfers/opinions/${encodeURIComponent(transfer.id)}/${category}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
                 button(
                   { class: "vote-btn" },
                   `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${transfer.opinions?.[category] || 0}]`
@@ -231,6 +452,6 @@ exports.singleTransferView = async (transfer, filter) => {
         )
       )
     )
-  );
-};
+  )
+}
 

+ 391 - 229
src/views/video_view.js

@@ -1,302 +1,464 @@
-const { form, button, div, h2, p, section, input, label, br, a, video: videoHyperaxe, span, textarea } = require("../server/node_modules/hyperaxe");
+const {
+  form,
+  button,
+  div,
+  h2,
+  p,
+  section,
+  input,
+  br,
+  a,
+  video: videoHyperaxe,
+  span,
+  textarea,
+  select,
+  option
+} = require("../server/node_modules/hyperaxe");
+
 const moment = require("../server/node_modules/moment");
-const { template, i18n } = require('./main_views');
-const { config } = require('../server/SSB_server.js');
-const { renderUrl } = require('../backend/renderUrl');
-const opinionCategories = require('../backend/opinion_categories');
+const { template, i18n } = require("./main_views");
+const { config } = require("../server/SSB_server.js");
+const { renderUrl } = require("../backend/renderUrl");
+const opinionCategories = require("../backend/opinion_categories");
 
 const userId = config.keys.id;
 
-const getFilteredVideos = (filter, videos, userId) => {
-  const now = Date.now();
-  let filtered =
-    filter === 'mine' ? videos.filter(v => v.author === userId) :
-    filter === 'recent' ? videos.filter(v => new Date(v.createdAt).getTime() >= now - 86400000) :
-    filter === 'top' ? [...videos].sort((a, b) => {
-      const sumA = Object.values(a.opinions || {}).reduce((s, n) => s + (n || 0), 0);
-      const sumB = Object.values(b.opinions || {}).reduce((s, n) => s + (n || 0), 0);
-      return sumB - sumA;
-    }) :
-    videos;
-
-  return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+const safeText = (v) => String(v || "").trim();
+
+const buildReturnTo = (filter, params = {}) => {
+  const f = safeText(filter || "all");
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+  const parts = [`filter=${encodeURIComponent(f)}`];
+  if (q) parts.push(`q=${encodeURIComponent(q)}`);
+  if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
+  return `/videos?${parts.join("&")}`;
+};
+
+const renderPMButton = (recipient, className = "filter-btn") => {
+  const r = safeText(recipient);
+  if (!r) return null;
+  if (String(r) === String(userId)) return null;
+
+  return form(
+    { method: "GET", action: "/pm" },
+    input({ type: "hidden", name: "recipients", value: r }),
+    button({ type: "submit", class: className }, i18n.privateMessage)
+  );
+};
+
+const renderTags = (tags) => {
+  const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
+  return list.length
+    ? div(
+        { class: "card-tags" },
+        list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
+      )
+    : null;
 };
 
-const renderVideoCommentsSection = (videoId, comments = []) => {
-  const commentsCount = Array.isArray(comments) ? comments.length : 0;
+const renderVideoFavoriteToggle = (videoObj, returnTo = "") =>
+  form(
+    {
+      method: "POST",
+      action: videoObj.isFavorite
+        ? `/videos/favorites/remove/${encodeURIComponent(videoObj.key)}`
+        : `/videos/favorites/add/${encodeURIComponent(videoObj.key)}`
+    },
+    returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
+    button(
+      { type: "submit", class: "filter-btn" },
+      videoObj.isFavorite ? i18n.videoRemoveFavoriteButton : i18n.videoAddFavoriteButton
+    )
+  );
+
+const renderVideoPlayer = (videoObj) =>
+  videoObj?.url
+    ? div(
+        { class: "video-container", style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
+        videoHyperaxe({
+          controls: true,
+          src: `/blob/${encodeURIComponent(videoObj.url)}`,
+          preload: "metadata"
+        })
+      )
+    : p(i18n.videoNoFile);
+
+const renderVideoOwnerActions = (filter, videoObj, params = {}) => {
+  const returnTo = buildReturnTo(filter, params);
+  const isAuthor = String(videoObj.author) === String(userId);
+  const hasOpinions = Object.keys(videoObj.opinions || {}).length > 0;
+
+  if (!isAuthor) return [];
 
-  return div({ class: 'vote-comments-section' },
-    div({ class: 'comments-count' },
-      span({ class: 'card-label' }, i18n.voteCommentsLabel + ': '),
-      span({ class: 'card-value' }, String(commentsCount))
+  const items = [];
+  if (!hasOpinions) {
+    items.push(
+      form(
+        { method: "GET", action: `/videos/edit/${encodeURIComponent(videoObj.key)}` },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
+        button({ class: "update-btn", type: "submit" }, i18n.videoUpdateButton)
+      )
+    );
+  }
+  items.push(
+    form(
+      { method: "POST", action: `/videos/delete/${encodeURIComponent(videoObj.key)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ class: "delete-btn", type: "submit" }, i18n.videoDeleteButton)
+    )
+  );
+
+  return items;
+};
+
+const renderVideoCommentsSection = (videoId, comments = [], returnTo = null) => {
+  const list = safeArr(comments);
+  const commentsCount = list.length;
+
+  return div(
+    { class: "vote-comments-section" },
+    div(
+      { class: "comments-count" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
+      span({ class: "card-value" }, String(commentsCount))
     ),
-    div({ class: 'comment-form-wrapper' },
-      h2({ class: 'comment-form-title' }, i18n.voteNewCommentLabel),
-      form({
-        method: 'POST',
-        action: `/videos/${encodeURIComponent(videoId)}/comments`,
-        class: 'comment-form'
-      },
+    div(
+      { class: "comment-form-wrapper" },
+      h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
+      form(
+        { method: "POST", action: `/videos/${encodeURIComponent(videoId)}/comments`, class: "comment-form" },
+        returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
         textarea({
-          id: 'comment-text',
-          name: 'text',
+          id: "comment-text",
+          name: "text",
           required: true,
           rows: 4,
-          class: 'comment-textarea',
+          class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         br(),
-        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+        button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
     ),
-    comments && comments.length
-      ? div({ class: 'comments-list' },
-          comments.map(c => {
-            const author = c.value && c.value.author ? c.value.author : '';
-            const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
-            const absDate = ts ? moment(ts).format('YYYY/MM/DD HH:mm:ss') : '';
-            const relDate = ts ? moment(ts).fromNow() : '';
-            const userName = author && author.includes('@') ? author.split('@')[1] : author;
-
-            return div({ class: 'votations-comment-card' },
-              span({ class: 'created-at' },
+    list.length
+      ? div(
+          { class: "comments-list" },
+          list.map((c) => {
+            const author = c?.value?.author || "";
+            const ts = c?.value?.timestamp || c?.timestamp;
+            const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
+            const relDate = ts ? moment(ts).fromNow() : "";
+            const userName = author && author.includes("@") ? author.split("@")[1] : author;
+            const content = c?.value?.content || {};
+            const rootId = content.fork || content.root || null;
+            const text = content.text || "";
+
+            return div(
+              { class: "votations-comment-card" },
+              span(
+                { class: "created-at" },
                 span(i18n.createdBy),
-                author
-                  ? a(
-                      { href: `/author/${encodeURIComponent(author)}` },
-                      `@${userName}`
-                    )
-                  : span('(unknown)'),
-                absDate ? span(' | ') : '',
-                absDate ? span({ class: 'votations-comment-date' }, absDate) : '',
-                relDate ? span({ class: 'votations-comment-date' }, ' | ', i18n.sendTime) : '',
-                relDate
-                  ? a(
-                      {
-                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
-                      },
-                      relDate
-                    )
-                  : ''
+                author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
+                absDate ? span(" | ") : "",
+                absDate ? span({ class: "votations-comment-date" }, absDate) : "",
+                relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
+                relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
               ),
-              p({
-                class: 'votations-comment-text',
-                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
-              })
+              p({ class: "votations-comment-text" }, ...renderUrl(text))
             );
           })
         )
-      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+      : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
   );
 };
 
-const renderVideoActions = (filter, video) => {
-  return filter === 'mine' ? div({ class: "video-actions" },
-    form({ method: "GET", action: `/videos/edit/${encodeURIComponent(video.key)}` },
-      button({ class: "update-btn", type: "submit" }, i18n.videoUpdateButton)
-    ),
-    form({ method: "POST", action: `/videos/delete/${encodeURIComponent(video.key)}` },
-      button({ class: "delete-btn", type: "submit" }, i18n.videoDeleteButton)
-    )
-  ) : null;
-};
+const renderVideoList = (videos, filter, params = {}) => {
+  const returnTo = buildReturnTo(filter, params);
 
-const renderVideoList = (filteredVideos, filter) => {
-  return filteredVideos.length > 0
-    ? filteredVideos.map(video => {
-        const commentCount = typeof video.commentCount === 'number' ? video.commentCount : 0;
+  return videos.length
+    ? videos.map((videoObj) => {
+        const commentCount = typeof videoObj.commentCount === "number" ? videoObj.commentCount : 0;
+        const title = safeText(videoObj.title);
+        const ownerActions = renderVideoOwnerActions(filter, videoObj, params);
 
-        return div({ class: "tags-header" },
-          renderVideoActions(filter, video),
-          form({ method: "GET", action: `/videos/${encodeURIComponent(video.key)}` },
-            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        return div(
+          { class: "tags-header video-card" },
+          div(
+            { class: "bookmark-topbar" },
+            div(
+              { class: "bookmark-topbar-left" },
+              form(
+                { method: "GET", action: `/videos/${encodeURIComponent(videoObj.key)}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
+                input({ type: "hidden", name: "filter", value: filter || "all" }),
+                params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
+                params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
+                button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+              ),
+              renderVideoFavoriteToggle(videoObj, returnTo),
+              renderPMButton(videoObj.author)
+            ),
+            ownerActions.length ? div({ class: "bookmark-actions" }, ...ownerActions) : null
           ),
-          video.title?.trim() ? h2(video.title) : null,
-          video.url
-            ? div({ class: "video-container" },
-                videoHyperaxe({
-                  controls: true,
-                  src: `/blob/${encodeURIComponent(video.url)}`,
-                  type: video.mimeType,
-                  preload: 'metadata',
-                  width: '640',
-                  height: '360'
-                })
-              )
-            : p(i18n.videoNoFile),
-          video.description?.trim() ? p(...renderUrl(video.description)) : null,
-          video.tags?.length
-            ? div({ class: "card-tags" },
-                video.tags.map(tag =>
-                  a(
-                    {
-                      href: `/search?query=%23${encodeURIComponent(tag)}`,
-                      class: "tag-link"
-                    },
-                    `#${tag}`
-                  )
-                )
-              )
-            : null,
-          div({ class: 'card-comments-summary' },
-            span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
-            span({ class: 'card-value' }, String(commentCount)),
-            br, br,
-            form({ method: 'GET', action: `/videos/${encodeURIComponent(video.key)}` },
-              button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+          title ? h2(title) : null,
+          renderVideoPlayer(videoObj),
+          safeText(videoObj.description) ? p(...renderUrl(videoObj.description)) : null,
+          renderTags(videoObj.tags),
+          div(
+            { class: "card-comments-summary" },
+            span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
+            span({ class: "card-value" }, String(commentCount)),
+            br(),
+            br(),
+            form(
+              { method: "GET", action: `/videos/${encodeURIComponent(videoObj.key)}` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              input({ type: "hidden", name: "filter", value: filter || "all" }),
+              params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
+              params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
+              button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
             )
           ),
-          br,
-          p({ class: 'card-footer' },
-            span({ class: 'date-link' }, `${moment(video.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-            a({ href: `/author/${encodeURIComponent(video.author)}`, class: 'user-link' }, `${video.author}`)
-          ),
-          div({ class: "voting-buttons" },
-            opinionCategories.map(category =>
-              form({ method: "POST", action: `/videos/opinions/${encodeURIComponent(video.key)}/${category}` },
+          br(),
+          (() => {
+            const createdTs = videoObj.createdAt ? new Date(videoObj.createdAt).getTime() : NaN;
+            const updatedTs = videoObj.updatedAt ? new Date(videoObj.updatedAt).getTime() : NaN;
+            const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
+
+            return p(
+              { class: "card-footer" },
+              span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+              a({ href: `/author/${encodeURIComponent(videoObj.author)}`, class: "user-link" }, `${videoObj.author}`),
+              showUpdated
+                ? span(
+                    { class: "votations-comment-date" },
+                    ` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
+                  )
+                : null
+            );
+          })(),
+          div(
+            { class: "voting-buttons" },
+            opinionCategories.map((category) =>
+              form(
+                { method: "POST", action: `/videos/opinions/${encodeURIComponent(videoObj.key)}/${category}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
                 button(
                   { class: "vote-btn" },
-                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${video.opinions?.[category] || 0}]`
+                  `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
+                    videoObj.opinions?.[category] || 0
+                  }]`
                 )
               )
             )
           )
         );
       })
-    : div(i18n.noVideos);
+    : p(params.q ? i18n.videoNoMatch : i18n.noVideos);
 };
 
-const renderVideoForm = (filter, videoId, videoToEdit) => {
-  return div({ class: "div-center video-form" },
-    form({
-      action: filter === 'edit' ? `/videos/update/${encodeURIComponent(videoId)}` : "/videos/create",
-      method: "POST", enctype: "multipart/form-data"
-    },
-      label(i18n.videoFileLabel), br(),
-      input({ type: "file", name: "video", required: filter !== "edit" }), br(), br(),
-      label(i18n.videoTagsLabel), br(),
-      input({ type: "text", name: "tags", placeholder: i18n.videoTagsPlaceholder, value: videoToEdit?.tags?.join(', ') || '' }), br(), br(),
-      label(i18n.videoTitleLabel), br(),
-      input({ type: "text", name: "title", placeholder: i18n.videoTitlePlaceholder, value: videoToEdit?.title || '' }), br(), br(),
-      label(i18n.videoDescriptionLabel), br(),
-      textarea({ name: "description", placeholder: i18n.videoDescriptionPlaceholder, rows: "4", value: videoToEdit?.description || '' }), br(), br(),
-      button({ type: "submit" }, filter === 'edit' ? i18n.videoUpdateButton : i18n.videoCreateButton)
+const renderVideoForm = (filter, videoId, videoToEdit, params = {}) => {
+  const returnTo = safeText(params.returnTo) || buildReturnTo("all", params);
+
+  return div(
+    { class: "div-center video-form" },
+    form(
+      {
+        action: filter === "edit" ? `/videos/update/${encodeURIComponent(videoId)}` : "/videos/create",
+        method: "POST",
+        enctype: "multipart/form-data"
+      },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      span(i18n.videoFileLabel),
+      br(),
+      input({ type: "file", name: "video", required: filter !== "edit" }),
+      br(),
+      br(),
+      span(i18n.videoTagsLabel),
+      br(),
+      input({
+        type: "text",
+        name: "tags",
+        placeholder: i18n.videoTagsPlaceholder,
+        value: safeArr(videoToEdit?.tags).join(", ")
+      }),
+      br(),
+      br(),
+      span(i18n.videoTitleLabel),
+      br(),
+      input({ type: "text", name: "title", placeholder: i18n.videoTitlePlaceholder, value: videoToEdit?.title || "" }),
+      br(),
+      br(),
+      span(i18n.videoDescriptionLabel),
+      br(),
+      textarea({ name: "description", placeholder: i18n.videoDescriptionPlaceholder, rows: "4" }, videoToEdit?.description || ""),
+      br(),
+      br(),
+      button({ type: "submit" }, filter === "edit" ? i18n.videoUpdateButton : i18n.videoCreateButton)
     )
   );
 };
 
-exports.videoView = async (videos, filter, videoId) => {
-  const title = filter === 'mine' ? i18n.videoMineSectionTitle :
-                filter === 'create' ? i18n.videoCreateSectionTitle :
-                filter === 'edit' ? i18n.videoUpdateSectionTitle :
-                filter === 'recent' ? i18n.videoRecentSectionTitle :
-                filter === 'top' ? i18n.videoTopSectionTitle :
-                i18n.videoAllSectionTitle;
+exports.videoView = async (videos, filter = "all", videoId = null, params = {}) => {
+  const title =
+    filter === "mine"
+      ? i18n.videoMineSectionTitle
+      : filter === "create"
+        ? i18n.videoCreateSectionTitle
+        : filter === "edit"
+          ? i18n.videoUpdateSectionTitle
+          : filter === "recent"
+            ? i18n.videoRecentSectionTitle
+            : filter === "top"
+              ? i18n.videoTopSectionTitle
+              : filter === "favorites"
+                ? i18n.videoFavoritesSectionTitle
+                : i18n.videoAllSectionTitle;
+
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
 
-  const filteredVideos = getFilteredVideos(filter, videos, userId);
-  const videoToEdit = videos.find(v => v.key === videoId);
+  const list = safeArr(videos);
+  const videoToEdit = videoId ? list.find((v) => v.key === videoId) : null;
 
   return template(
     title,
     section(
-      div({ class: "tags-header" },
-        h2(title),
-        p(i18n.videoDescription)
-      ),
-      div({ class: "filters" },
-        form({ method: "GET", action: "/videos" },
-          ["all", "mine", "recent", "top"].map(f =>
-            button({
-              type: "submit", name: "filter", value: f,
-              class: filter === f ? "filter-btn active" : "filter-btn"
-            },
-              i18n[`videoFilter${f.charAt(0).toUpperCase() + f.slice(1)}`]
-            )
+      div({ class: "tags-header" }, h2(title), p(i18n.videoDescription)),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterMine),
+          button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterRecent),
+          button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterTop),
+          button(
+            { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
+            i18n.videoFilterFavorites
           ),
-          button({ type: "submit", name: "filter", value: "create", class: "create-button" },
-            i18n.videoCreateButton)
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.videoCreateButton)
         )
       )
     ),
     section(
-      (filter === 'create' || filter === 'edit')
-        ? renderVideoForm(filter, videoId, videoToEdit)
-        : renderVideoList(filteredVideos, filter)
+      filter === "create" || filter === "edit"
+        ? renderVideoForm(filter, videoId, videoToEdit, { ...params, filter })
+        : section(
+            div(
+              { class: "videos-search" },
+              form(
+                { method: "GET", action: "/videos", class: "filter-box" },
+                input({ type: "hidden", name: "filter", value: filter }),
+                input({
+                  type: "text",
+                  name: "q",
+                  value: q,
+                  placeholder: i18n.videoSearchPlaceholder,
+                  class: "filter-box__input"
+                }),
+                div(
+                  { class: "filter-box__controls" },
+                  select(
+                    { name: "sort", class: "filter-box__select" },
+                    option({ value: "recent", selected: sort === "recent" }, i18n.videoSortRecent),
+                    option({ value: "oldest", selected: sort === "oldest" }, i18n.videoSortOldest),
+                    option({ value: "top", selected: sort === "top" }, i18n.videoSortTop)
+                  ),
+                  button({ type: "submit", class: "filter-box__button" }, i18n.videoSearchButton)
+                )
+              )
+            ),
+            div({ class: "videos-list" }, renderVideoList(list, filter, { q, sort }))
+          )
     )
   );
 };
 
-exports.singleVideoView = async (video, filter, comments = []) => {
-  const isAuthor = video.author === userId;
-  const hasOpinions = Object.keys(video.opinions || {}).length > 0;
+exports.singleVideoView = async (videoObj, filter = "all", comments = [], params = {}) => {
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+  const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
+
+  const title = safeText(videoObj.title);
+  const ownerActions = renderVideoOwnerActions(filter, videoObj, { q, sort });
+
+  const topbar = div(
+    { class: "bookmark-topbar" },
+    div(
+      { class: "bookmark-topbar-left" },
+      renderVideoFavoriteToggle(videoObj, returnTo),
+      renderPMButton(videoObj.author)
+    ),
+    ownerActions.length ? div({ class: "bookmark-actions" }, ...ownerActions) : null
+  );
 
   return template(
     i18n.videoTitle,
     section(
-      div({ class: "filters" },
-        form({ method: "GET", action: "/videos" },
-          button({ type: "submit", name: "filter", value: "all", class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.videoFilterAll),
-          button({ type: "submit", name: "filter", value: "mine", class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.videoFilterMine),
-          button({ type: "submit", name: "filter", value: "recent", class: filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.videoFilterRecent),
-          button({ type: "submit", name: "filter", value: "top", class: filter === 'top' ? 'filter-btn active' : 'filter-btn' }, i18n.videoFilterTop),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterMine),
+          button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterRecent),
+          button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterTop),
+          button(
+            { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
+            i18n.videoFilterFavorites
+          ),
           button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.videoCreateButton)
         )
       ),
-      div({ class: "tags-header" },
-        isAuthor ? div({ class: "video-actions" },
-          !hasOpinions
-            ? form({ method: "GET", action: `/videos/edit/${encodeURIComponent(video.key)}` },
-                button({ class: "update-btn", type: "submit" }, i18n.videoUpdateButton)
-              )
-            : null,
-          form({ method: "POST", action: `/videos/delete/${encodeURIComponent(video.key)}` },
-            button({ class: "delete-btn", type: "submit" }, i18n.videoDeleteButton)
-          )
-        ) : null,
-        h2(video.title),
-        video.url
-          ? div({ class: "video-container" },
-              videoHyperaxe({
-                controls: true,
-                src: `/blob/${encodeURIComponent(video.url)}`,
-                type: video.mimeType,
-                preload: 'metadata',
-                width: '640',
-                height: '360'
-              })
-            )
-          : p(i18n.videoNoFile),
-        p(...renderUrl(video.description)),
-        video.tags?.length
-          ? div({ class: "card-tags" },
-              video.tags.map(tag =>
-                a(
-                  {
-                    href: `/search?query=%23${encodeURIComponent(tag)}`,
-                    class: "tag-link"
-                  },
-                  `#${tag}`
+      div(
+        { class: "bookmark-item card" },
+        topbar,
+        title ? h2(title) : null,
+        renderVideoPlayer(videoObj),
+        safeText(videoObj.description) ? p(...renderUrl(videoObj.description)) : null,
+        renderTags(videoObj.tags),
+        br(),
+        (() => {
+          const createdTs = videoObj.createdAt ? new Date(videoObj.createdAt).getTime() : NaN;
+          const updatedTs = videoObj.updatedAt ? new Date(videoObj.updatedAt).getTime() : NaN;
+          const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
+
+          return p(
+            { class: "card-footer" },
+            span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+            a({ href: `/author/${encodeURIComponent(videoObj.author)}`, class: "user-link" }, `${videoObj.author}`),
+            showUpdated
+              ? span(
+                  { class: "votations-comment-date" },
+                  ` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
                 )
+              : null
+          );
+        })(),
+        div(
+          { class: "voting-buttons" },
+          opinionCategories.map((category) =>
+            form(
+              { method: "POST", action: `/videos/opinions/${encodeURIComponent(videoObj.key)}/${category}` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              button(
+                { class: "vote-btn" },
+                `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
+                  videoObj.opinions?.[category] || 0
+                }]`
               )
             )
-          : null,
-        br,
-        p({ class: 'card-footer' },
-          span({ class: 'date-link' }, `${moment(video.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-          a({ href: `/author/${encodeURIComponent(video.author)}`, class: 'user-link' }, `${video.author}`)
-        )
-      ),
-      div({ class: "voting-buttons" },
-        opinionCategories.map(category =>
-          form({ method: "POST", action: `/videos/opinions/${encodeURIComponent(video.key)}/${category}` },
-            button(
-              { class: "vote-btn" },
-              `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${video.opinions?.[category] || 0}]`
-            )
           )
         )
       ),
-      renderVideoCommentsSection(video.key, comments)
+      div({ id: "comments" }, renderVideoCommentsSection(videoObj.key, comments, returnTo))
     )
   );
 };

+ 310 - 167
src/views/vote_view.js

@@ -1,249 +1,392 @@
 const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, label, span } = 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 opinionCategories = require('../backend/opinion_categories');
+const { template, i18n } = require("./main_views");
+const moment = require("../server/node_modules/moment");
+const { config } = require("../server/SSB_server.js");
+const opinionCategories = require("../backend/opinion_categories");
+const { renderUrl } = require("../backend/renderUrl");
 
 const userId = config.keys.id;
 
-const voteLabel = opt =>
-  i18n['vote' + opt.split('_').map(w => w.charAt(0) + w.slice(1).toLowerCase()).join('')] || opt;
+const safeArray = (v) => Array.isArray(v) ? v : [];
 
-const renderStyledField = (labelText, valueElement) =>
-  div({ class: 'card-field' },
-    span({ class: 'card-label' }, labelText),
-    span({ class: 'card-value' }, valueElement)
+const voteLabel = (opt) =>
+  i18n["vote" + opt.split("_").map(w => w.charAt(0) + w.slice(1).toLowerCase()).join("")] || opt;
+
+const toValueChildren = (v) => {
+  if (v === undefined || v === null) return [];
+  if (Array.isArray(v)) return v;
+  if (typeof v === "string") return renderUrl(v);
+  if (typeof v === "number" || typeof v === "boolean") return renderUrl(String(v));
+  return [v];
+};
+
+const renderCardField = (labelText, valueNode) =>
+  div(
+    { class: "card-field" },
+    span({ class: "card-label" }, labelText),
+    span({ class: "card-value" }, ...toValueChildren(valueNode))
   );
 
-const renderVoteCard = (v, voteOptions, firstRow, secondRow, userId, filter) => {
-  const baseCounts = voteOptions.reduce((acc, opt) => { acc[opt] = v.votes?.[opt] || 0; return acc }, {});
-  const maxOpt = voteOptions.filter(opt => opt !== 'FOLLOW_MAJORITY')
-    .reduce((top, opt) => baseCounts[opt] > baseCounts[top] ? opt : top, 'NOT_INTERESTED');
-  const result = v.totalVotes === 0 ? 'NOT_INTERESTED' : maxOpt;
-  const finalCounts = { ...baseCounts };
-  if (baseCounts.FOLLOW_MAJORITY > 0 && result !== 'FOLLOW_MAJORITY') {
-    finalCounts[result] += baseCounts.FOLLOW_MAJORITY;
+const normalizeStatus = (v) => {
+  const up = String(v || "").toUpperCase();
+  if (up === "OPEN" || up === "CLOSED") return up;
+  return up || "OPEN";
+};
+
+const statusLabel = (s) => {
+  const up = normalizeStatus(s);
+  if (up === "OPEN") return i18n.voteStatusOpen || i18n.voteFilterOpen || "OPEN";
+  if (up === "CLOSED") return i18n.voteStatusClosed || i18n.voteFilterClosed || "CLOSED";
+  return up;
+};
+
+const renderVoteOwnerActions = (v, returnTo, mode) => {
+  const showUpdateButton = mode === "mine" && !Object.keys(v.opinions || {}).length;
+  const showDeleteButton = mode === "mine";
+
+  const actions = [];
+  if (showUpdateButton) {
+    actions.push(
+      form(
+        { method: "GET", action: `/votes/edit/${encodeURIComponent(v.id)}` },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
+        button({ class: "update-btn", type: "submit" }, i18n.voteUpdateButton)
+      )
+    );
+  }
+  if (showDeleteButton) {
+    actions.push(
+      form(
+        { method: "POST", action: `/votes/delete/${encodeURIComponent(v.id)}` },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
+        button({ class: "delete-btn", type: "submit" }, i18n.voteDeleteButton)
+      )
+    );
   }
+  return actions;
+};
 
-  const showUpdateButton = filter === 'mine' && !Object.values(v.opinions || {}).length;
-  const showDeleteButton = filter === 'mine';
+const renderVotePMActions = (v) => {
+  if (!v.createdBy || v.createdBy === userId) return [];
+  return [
+    form(
+      { method: "GET", action: "/pm" },
+      input({ type: "hidden", name: "recipients", value: v.createdBy }),
+      button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
+    )
+  ];
+};
 
-  const commentCount = typeof v.commentCount === 'number' ? v.commentCount : 0;
-  const showCommentsSummaryInCard = filter !== 'detail';
+const renderVoteTopbar = (v, activeFilter, opts = {}) => {
+  const isSingle = !!opts.single;
+  const currentFilter = activeFilter || "all";
 
-  return div({ class: 'card card-section vote' },
-    filter === 'mine' ? div({ class: 'vote-actions' },
-      showUpdateButton
-        ? form({ method: 'GET', action: `/votes/edit/${encodeURIComponent(v.id)}` },
-            button({ class: "update-btn", type: "submit" }, i18n.voteUpdateButton)
-          )
-        : null,
-      showDeleteButton
-        ? form({ method: 'POST', action: `/votes/delete/${encodeURIComponent(v.id)}` },
-            button({ class: "delete-btn", type: "submit" }, i18n.voteDeleteButton)
-          )
-        : null
-    ) : null,
-    form({ method: 'GET', action: `/votes/${encodeURIComponent(v.id)}` },
-      button({ class: 'filter-btn', type: 'submit' }, i18n.viewDetails)
+  const returnToList = `/votes?filter=${encodeURIComponent(currentFilter)}`;
+  const returnToSelf = `/votes/${encodeURIComponent(v.id)}?filter=${encodeURIComponent(currentFilter)}`;
+  const rt = isSingle ? returnToSelf : returnToList;
+
+  const leftActions = [];
+
+  if (!isSingle) {
+    leftActions.push(
+      form(
+        { method: "GET", action: `/votes/${encodeURIComponent(v.id)}` },
+        input({ type: "hidden", name: "filter", value: currentFilter }),
+        button({ class: "filter-btn", type: "submit" }, i18n.viewDetails)
+      )
+    );
+  }
+
+  leftActions.push(...renderVotePMActions(v));
+
+  const ownerActions = renderVoteOwnerActions(v, rt, opts.mode || "");
+  const rightActions = [];
+  if (ownerActions.length) rightActions.push(...ownerActions);
+
+  const leftNode = leftActions.length ? div({ class: "bookmark-topbar-left" }, ...leftActions) : null;
+  const rightNode = rightActions.length ? div({ class: "bookmark-actions vote-actions" }, ...rightActions) : null;
+
+  const nodes = [];
+  if (leftNode) nodes.push(leftNode);
+  if (rightNode) nodes.push(rightNode);
+
+  return nodes.length ? div({ class: isSingle ? "bookmark-topbar vote-topbar-single" : "bookmark-topbar" }, ...nodes) : null;
+};
+
+const renderVoteButtons = (v, voteOptions, firstRow, secondRow, returnTo) => {
+  if (normalizeStatus(v.status) !== "OPEN") return null;
+
+  return div(
+    { class: "vote-buttons-block" },
+    div(
+      { class: "vote-buttons-row" },
+      ...firstRow.map((opt) =>
+        form(
+          { method: "POST", action: `/votes/vote/${encodeURIComponent(v.id)}` },
+          input({ type: "hidden", name: "returnTo", value: returnTo }),
+          button({ type: "submit", name: "choice", value: opt }, voteLabel(opt))
+        )
+      )
     ),
-    br,
-    renderStyledField(i18n.voteQuestionLabel + ':', v.question),
-    renderStyledField(i18n.voteDeadline + ':', moment(v.deadline).format('YYYY/MM/DD HH:mm:ss')),
-    renderStyledField(i18n.voteStatus + ':', v.status),
-    br,
-    v.status === 'OPEN'
-      ? div({ class: 'vote-buttons-block' },
-          div({ class: 'vote-buttons-row' },
-            ...firstRow.map(opt => form({ method: 'POST', action: `/votes/vote/${encodeURIComponent(v.id)}` },
-              button({ type: 'submit', name: 'choice', value: opt }, voteLabel(opt))
-            ))
-          ),
-          div({ class: 'vote-buttons-row' },
-            ...secondRow.map(opt => form({ method: 'POST', action: `/votes/vote/${encodeURIComponent(v.id)}` },
-              button({ type: 'submit', name: 'choice', value: opt }, voteLabel(opt))
-            ))
-          )
+    div(
+      { class: "vote-buttons-row" },
+      ...secondRow.map((opt) =>
+        form(
+          { method: "POST", action: `/votes/vote/${encodeURIComponent(v.id)}` },
+          input({ type: "hidden", name: "returnTo", value: returnTo }),
+          button({ type: "submit", name: "choice", value: opt }, voteLabel(opt))
         )
-      : null,
-    renderStyledField(i18n.voteTotalVotes + ':', v.totalVotes),
-    br,
-    div({ class: 'vote-table' },
+      )
+    )
+  );
+};
+
+const renderOpinionsBar = (v, returnTo) =>
+  div(
+    { class: "voting-buttons" },
+    opinionCategories.map((category) =>
+      form(
+        { method: "POST", action: `/votes/opinions/${encodeURIComponent(v.id)}/${category}` },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
+        button(
+          { class: "vote-btn", type: "submit" },
+          `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${(v.opinions && v.opinions[category]) ? v.opinions[category] : 0}]`
+        )
+      )
+    )
+  );
+
+const renderVoteCard = (v, voteOptions, firstRow, secondRow, mode, activeFilter) => {
+  const baseCounts = voteOptions.reduce((acc, opt) => {
+    acc[opt] = (v.votes && v.votes[opt]) ? v.votes[opt] : 0;
+    return acc;
+  }, {});
+
+  const maxOpt = voteOptions
+    .filter((opt) => opt !== "FOLLOW_MAJORITY")
+    .reduce((top, opt) => baseCounts[opt] > baseCounts[top] ? opt : top, "NOT_INTERESTED");
+
+  const totalVotesNum = typeof v.totalVotes === "number" ? v.totalVotes : parseInt(String(v.totalVotes || "0"), 10) || 0;
+  const result = totalVotesNum === 0 ? "NOT_INTERESTED" : maxOpt;
+
+  const commentCount = typeof v.commentCount === "number" ? v.commentCount : 0;
+  const showCommentsSummaryInCard = mode !== "detail";
+
+  const listReturnTo = `/votes?filter=${encodeURIComponent(activeFilter || "all")}`;
+  const detailReturnTo = `/votes/${encodeURIComponent(v.id)}?filter=${encodeURIComponent(activeFilter || "all")}`;
+  const returnTo = mode === "detail" ? detailReturnTo : listReturnTo;
+
+  const topbar = renderVoteTopbar(v, activeFilter, { single: mode === "detail", mode });
+
+  return div(
+    { class: "card card-section vote" },
+    topbar ? topbar : null,
+    renderCardField(i18n.voteQuestionLabel + ":", v.question),
+    renderCardField(i18n.voteDeadline + ":", v.deadline ? moment(v.deadline).format("YYYY/MM/DD HH:mm:ss") : ""),
+    renderCardField(i18n.voteStatus + ":", statusLabel(v.status)),
+    br(),
+    renderVoteButtons(v, voteOptions, firstRow, secondRow, returnTo),
+    renderCardField(i18n.voteTotalVotes + ":", totalVotesNum),
+    br(),
+    div(
+      { class: "vote-table" },
       table(
-        tr(...voteOptions.map(opt => th(voteLabel(opt)))),
-        tr(...voteOptions.map(opt => td(baseCounts[opt])))
+        tr(...voteOptions.map((opt) => th(voteLabel(opt)))),
+        tr(...voteOptions.map((opt) => td(baseCounts[opt])))
       )
     ),
-    renderStyledField(
-      i18n.voteBreakdown + ':',
-      span({}, [
-        voteLabel(result), ' = ', baseCounts[result],
-        ' + ', voteLabel('FOLLOW_MAJORITY'), ': ', baseCounts.FOLLOW_MAJORITY
-      ])
+    renderCardField(
+      i18n.voteBreakdown + ":",
+      span(
+        voteLabel(result), " = ", String(baseCounts[result] || 0),
+        " + ", voteLabel("FOLLOW_MAJORITY"), ": ", String(baseCounts.FOLLOW_MAJORITY || 0)
+      )
     ),
-    br,
-    div({ class: 'vote-buttons-row' }, h2(voteLabel(result))),
+    br(),
+    div({ class: "vote-buttons-row" }, h2(voteLabel(result))),
     v.tags && v.tags.filter(Boolean).length
-      ? div({ class: 'card-tags' },
-          v.tags.filter(Boolean).map(tag =>
-            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+      ? div(
+          { class: "card-tags" },
+          v.tags.filter(Boolean).map((tag) =>
+            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
           )
         )
       : null,
     showCommentsSummaryInCard
-      ? div({ class: 'card-comments-summary' },
-          span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'),
-          span({ class: 'card-value' }, String(commentCount)),
-          br,br,
-          form({ method: 'GET', action: `/votes/${encodeURIComponent(v.id)}` },
-            button({ type: 'submit', class: 'filter-btn' }, i18n.voteCommentsForumButton)
+      ? div(
+          { class: "card-comments-summary" },
+          span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
+          span({ class: "card-value" }, String(commentCount)),
+          br(),
+          br(),
+          form(
+            { method: "GET", action: `/votes/${encodeURIComponent(v.id)}` },
+            input({ type: "hidden", name: "filter", value: activeFilter || "all" }),
+            button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
           )
         )
       : null,
-    br,
-    p({ class: 'card-footer' },
-      span({ class: 'date-link' }, `${moment(v.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-      a({ href: `/author/${encodeURIComponent(v.createdBy)}`, class: 'user-link' }, `${v.createdBy}`)
+    br(),
+    p(
+      { class: "card-footer" },
+      span({ class: "date-link" }, `${moment(v.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+      a({ href: `/author/${encodeURIComponent(v.createdBy)}`, class: "user-link" }, `${v.createdBy}`)
     ),
-    div({ class: 'voting-buttons' },
-      opinionCategories.map(category =>
-        form({ method: 'POST', action: `/votes/opinions/${encodeURIComponent(v.id)}/${category}` },
-          button({ class: 'vote-btn' }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${v.opinions?.[category] || 0}]`)
-        )
-      )
-    )
+    renderOpinionsBar(v, returnTo)
   );
 };
 
-const renderCommentsSection = (voteId, comments) => {
+const renderCommentsSection = (voteId, comments, activeFilter) => {
   const commentsCount = Array.isArray(comments) ? comments.length : 0;
+  const returnTo = `/votes/${encodeURIComponent(voteId)}?filter=${encodeURIComponent(activeFilter || "all")}`;
 
-  return div({ class: 'vote-comments-section' },
-    div({ class: 'comments-count' },
-      span({ class: 'card-label' }, i18n.voteCommentsLabel + ': '),
-      span({ class: 'card-value' }, String(commentsCount))
+  return div(
+    { class: "vote-comments-section" },
+    div(
+      { class: "comments-count" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
+      span({ class: "card-value" }, String(commentsCount))
     ),
-    div({ class: 'comment-form-wrapper' },
-      h2({ class: 'comment-form-title' }, i18n.voteNewCommentLabel),
-      form({ method: 'POST', action: `/votes/${encodeURIComponent(voteId)}/comments`, class: 'comment-form' },
+    div(
+      { class: "comment-form-wrapper" },
+      h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
+      form(
+        { method: "POST", action: `/votes/${encodeURIComponent(voteId)}/comments`, class: "comment-form" },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
         textarea({
-          id: 'comment-text',
-          name: 'text',
+          id: "comment-text",
+          name: "text",
           required: true,
           rows: 4,
-          class: 'comment-textarea',
+          class: "comment-textarea",
           placeholder: i18n.voteNewCommentPlaceholder
         }),
         br(),
-        button({ type: 'submit', class: 'comment-submit-btn' }, i18n.voteNewCommentButton)
+        button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
       )
     ),
     comments && comments.length
-      ? div({ class: 'comments-list' },
-          comments.map(c => {
-            const author = c.value && c.value.author ? c.value.author : '';
+      ? div(
+          { class: "comments-list" },
+          comments.map((c) => {
+            const author = c.value && c.value.author ? c.value.author : "";
             const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
-            const absDate = ts ? moment(ts).format('YYYY/MM/DD HH:mm:ss') : '';
-            const relDate = ts ? moment(ts).fromNow() : '';
-            const userName = author.split('@')[1]; 
-            return div({ class: 'votations-comment-card' },
-             span({ class: 'created-at' },
+            const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
+            const relDate = ts ? moment(ts).fromNow() : "";
+            const userName = author && author.includes("@") ? author.split("@")[1] : author;
+
+            const content = c.value && c.value.content ? c.value.content : {};
+            const root = content.fork || content.root || "";
+            const text = content.text || "";
+
+            return div(
+              { class: "votations-comment-card" },
+              span(
+                { class: "created-at" },
                 span(i18n.createdBy),
-                author
-                  ? a(
-                      { href: `/author/${encodeURIComponent(author)}` },
-                      `@${userName}`
-                    )
-                  : span('(unknown)'),
-                absDate ? span(' | ') : '',
-                absDate ? span({ class: 'votations-comment-date' }, absDate) : '',
-                relDate ? span({ class: 'votations-comment-date' }, ' | ', i18n.sendTime) : '',
-                relDate
-                  ? a(
-                      { 
-                        href: `/thread/${encodeURIComponent(c.value.content.fork || c.value.content.root)}#${encodeURIComponent(c.key)}`
-                      },
-                      relDate
-                    )
-                  : ''
+                author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
+                absDate ? span(" | ") : "",
+                absDate ? span({ class: "votations-comment-date" }, absDate) : "",
+                relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
+                relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
               ),
-              p({
-                class: 'votations-comment-text',
-                innerHTML: (c.value && c.value.content && c.value.content.text) || ''
-              })
+              p({ class: "votations-comment-text" }, ...renderUrl(String(text)))
             );
           })
         )
-      : p({ class: 'votations-no-comments' }, i18n.voteNoCommentsYet)
+      : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
   );
 };
 
-exports.voteView = async (votes, filter, voteId, comments = []) => {
+exports.voteView = async (votes, mode, voteId, comments = [], activeFilterParam) => {
   const list = Array.isArray(votes) ? votes : [votes];
+
+  const standardFilters = ["all", "mine", "open", "closed"];
+  const activeFilter = standardFilters.includes(activeFilterParam)
+    ? activeFilterParam
+    : (standardFilters.includes(mode) ? mode : "all");
+
   const title =
-    filter === 'mine'   ? i18n.voteMineSectionTitle :
-    filter === 'create' ? i18n.voteCreateSectionTitle :
-    filter === 'edit'   ? i18n.voteUpdateSectionTitle :
-    filter === 'open'   ? i18n.voteOpenTitle :
-    filter === 'closed' ? i18n.voteClosedTitle :
-    filter === 'detail' ? (i18n.voteDetailSectionTitle || i18n.voteAllSectionTitle) :
-                           i18n.voteAllSectionTitle;
-
-  const voteToEdit = list.find(v => v.id === voteId) || {};
+    mode === "mine" ? i18n.voteMineSectionTitle :
+    mode === "create" ? i18n.voteCreateSectionTitle :
+    mode === "edit" ? i18n.voteUpdateSectionTitle :
+    mode === "open" ? i18n.voteOpenTitle :
+    mode === "closed" ? i18n.voteClosedTitle :
+    mode === "detail" ? (i18n.voteDetailSectionTitle || i18n.voteAllSectionTitle) :
+    i18n.voteAllSectionTitle;
+
+  const voteToEdit = list.find((v) => v.id === voteId) || {};
   const editTags = Array.isArray(voteToEdit.tags) ? voteToEdit.tags.filter(Boolean) : [];
 
   let filtered =
-    filter === 'mine'   ? list.filter(v => v.createdBy === userId && v.status !== 'tombstone') : 
-    filter === 'open'   ? list.filter(v => v.status === 'OPEN' && v.status !== 'tombstone') :
-    filter === 'closed' ? list.filter(v => v.status === 'CLOSED' && v.status !== 'tombstone') :
-                         list.filter(v => v.status !== 'tombstone');
+    mode === "mine" ? list.filter((v) => v.createdBy === userId) :
+    mode === "open" ? list.filter((v) => normalizeStatus(v.status) === "OPEN") :
+    mode === "closed" ? list.filter((v) => normalizeStatus(v.status) === "CLOSED") :
+    list;
+
   filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
 
-  const voteOptions = ['ABSTENTION', 'YES', 'NO', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'];
-  const firstRow = ['ABSTENTION', 'YES', 'NO'];
-  const secondRow = ['CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'];
+  const voteOptions = ["ABSTENTION", "YES", "NO", "CONFUSED", "FOLLOW_MAJORITY", "NOT_INTERESTED"];
+  const firstRow = ["ABSTENTION", "YES", "NO"];
+  const secondRow = ["CONFUSED", "FOLLOW_MAJORITY", "NOT_INTERESTED"];
 
-  const header = div({ class: 'tags-header' },
+  const header = div(
+    { class: "tags-header" },
     h2(i18n.votationsTitle),
     p(i18n.votationsDescription)
   );
 
+  const listReturnTo = standardFilters.includes(activeFilter) ? `/votes?filter=${encodeURIComponent(activeFilter)}` : "/votes";
+
+  const deadlineMin = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm");
+  const deadlineValue = voteToEdit.deadline ? moment(voteToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "";
+
   return template(
     title,
     section(
       header,
-      div({ class: 'filters' },
-        form({ method: 'GET', action: '/votes' },
-          button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.voteFilterAll),
-          button({ type: 'submit', name: 'filter', value: 'mine', class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.voteFilterMine),
-          button({ type: 'submit', name: 'filter', value: 'open', class: filter === 'open' ? 'filter-btn active' : 'filter-btn' }, i18n.voteFilterOpen),
-          button({ type: 'submit', name: 'filter', value: 'closed', class: filter === 'closed' ? 'filter-btn active' : 'filter-btn' }, i18n.voteFilterClosed),
-          button({ type: 'submit', name: 'filter', value: 'create', class: "create-button" }, i18n.voteCreateButton)
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/votes" },
+          button({ type: "submit", name: "filter", value: "all", class: mode === "all" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: mode === "mine" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterMine),
+          button({ type: "submit", name: "filter", value: "open", class: mode === "open" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterOpen),
+          button({ type: "submit", name: "filter", value: "closed", class: mode === "closed" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterClosed),
+          button({ type: "submit", name: "filter", value: "create", class: mode === "create" ? "create-button active" : "create-button" }, i18n.voteCreateButton)
         )
       )
     ),
     section(
-      (filter === 'edit' || filter === 'create')
-        ? div({ class: 'vote-form' },
-            form({ action: filter === 'edit' ? `/votes/update/${encodeURIComponent(voteId)}` : '/votes/create', method: 'POST' },
+      (mode === "edit" || mode === "create")
+        ? div(
+            { class: "vote-form" },
+            form(
+              { action: mode === "edit" ? `/votes/update/${encodeURIComponent(voteId)}` : "/votes/create", method: "POST" },
+              input({ type: "hidden", name: "returnTo", value: listReturnTo }),
               h2(i18n.voteQuestionLabel),
-              input({ type: 'text', name: 'question', id: 'question', required: true, value: voteToEdit.question || '' }), br(), br(),
+              input({ type: "text", name: "question", id: "question", required: true, value: voteToEdit.question || "" }), br(), br(),
               label(i18n.voteDeadlineLabel), br(),
-              input({ type: 'datetime-local', name: 'deadline', id: 'deadline', required: true,
-                min: moment().format('YYYY-MM-DDTHH:mm'),
-                value: voteToEdit.deadline ? moment(voteToEdit.deadline).format('YYYY-MM-DDTHH:mm') : ''
+              input({
+                type: "datetime-local",
+                name: "deadline",
+                id: "deadline",
+                required: true,
+                min: mode === "create" ? deadlineMin : undefined,
+                value: deadlineValue
               }), br(), br(),
               label(i18n.voteTagsLabel), br(),
-              input({ type: 'text', name: 'tags', id: 'tags', value: editTags.join(', ') }), br(), br(),
-              button({ type: 'submit' }, filter === 'edit' ? i18n.voteUpdateButton : i18n.voteCreateButton)
+              input({ type: "text", name: "tags", id: "tags", value: editTags.join(", ") }), br(), br(),
+              button({ type: "submit" }, mode === "edit" ? i18n.voteUpdateButton : i18n.voteCreateButton)
             )
           )
-        : div({ class: 'vote-list' },
+        : div(
+            { class: "vote-list" },
             filtered.length > 0
-              ? filtered.map(v => renderVoteCard(v, voteOptions, firstRow, secondRow, userId, filter))
+              ? filtered.map((v) => renderVoteCard(v, voteOptions, firstRow, secondRow, mode, activeFilter))
               : p(i18n.novotes)
           ),
-      (filter === 'detail' && voteId) ? renderCommentsSection(voteId, comments) : null
+      (mode === "detail" && voteId) ? renderCommentsSection(voteId, comments, activeFilter) : null
     )
   );
 };