Browse Source

Oasis release 0.3.5

psy 2 days ago
parent
commit
283147eff0
100 changed files with 19423 additions and 6598 deletions
  1. 143 46
      README.md
  2. 41 2
      docs/CHANGELOG.md
  3. 17 0
      docs/MANIFESTO
  4. 205 0
      docs/PUB/deploy.md
  5. 0 2
      docs/devs/CONTRIBUTORS
  6. 0 1
      docs/devs/MAINTAINERS
  7. 16 0
      docs/devs/install.md
  8. 7 3
      docs/install/install.md
  9. 1 1
      docs/security.md
  10. 4 0
      install.sh
  11. 10 30
      oasis.sh
  12. 66 4
      scripts/patch-node-modules.js
  13. 0 10
      scripts/release.sh
  14. 1397 446
      src/backend/backend.js
  15. 41 0
      src/backend/blobHandler.js
  16. 19 0
      src/backend/renderTextWithStyles.js
  17. 0 158
      src/backend/supports.js
  18. 24 19
      src/backend/updater.js
  19. BIN
      src/client/assets/images/default-avatar.png
  20. BIN
      src/client/assets/images/default-market.png
  21. BIN
      src/client/assets/images/default-tribe.png
  22. 1136 74
      src/client/assets/styles/style.css
  23. 136 114
      src/client/assets/themes/Clear-SNH.css
  24. 0 5
      src/client/assets/themes/Dark-SNH.css
  25. 57 6
      src/client/assets/themes/Matrix-SNH.css
  26. 96 8
      src/client/assets/themes/Purple-SNH.css
  27. 1 1
      src/client/assets/translations/i18n.js
  28. 1162 69
      src/client/assets/translations/oasis_en.js
  29. 1276 178
      src/client/assets/translations/oasis_es.js
  30. 1406 0
      src/client/assets/translations/oasis_eu.js
  31. 0 262
      src/client/assets/translations/oasis_fr.js
  32. 54 73
      src/client/gui.js
  33. 10 7
      src/client/middleware.js
  34. 76 0
      src/client/public/js/pdf-viewer.js
  35. 22 0
      src/client/public/js/pdf.min.mjs
  36. 22 0
      src/client/public/js/pdf.worker.min.mjs
  37. 37 9
      src/configs/config-manager.js
  38. 21 4
      src/configs/oasis-config.json
  39. 33 27
      src/configs/server-config.json
  40. 64 0
      src/models/activity_model.js
  41. 97 0
      src/models/agenda_model.js
  42. 181 0
      src/models/audios_model.js
  43. 196 0
      src/models/bookmarking_model.js
  44. 51 0
      src/models/cipher_model.js
  45. 148 0
      src/models/cv_model.js
  46. 179 0
      src/models/documents_model.js
  47. 274 0
      src/models/events_model.js
  48. 52 0
      src/models/exportmode_model.js
  49. 131 0
      src/models/feed_model.js
  50. 190 0
      src/models/images_model.js
  51. 288 0
      src/models/inhabitants_model.js
  52. 85 0
      src/models/legacy_model.js
  53. 467 679
      src/models/main_models.js
  54. 288 0
      src/models/market_model.js
  55. 143 0
      src/models/opinions_model.js
  56. 15 0
      src/models/panicmode_model.js
  57. 134 0
      src/models/pixelia_model.js
  58. 71 0
      src/models/privatemessages_model.js
  59. 215 0
      src/models/reports_model.js
  60. 117 0
      src/models/search_model.js
  61. 116 0
      src/models/stats_model.js
  62. 52 0
      src/models/tags_model.js
  63. 224 0
      src/models/tasks_model.js
  64. 208 0
      src/models/transfers_model.js
  65. 141 0
      src/models/trending_model.js
  66. 420 0
      src/models/tribes_model.js
  67. 195 0
      src/models/videos_model.js
  68. 294 0
      src/models/votes_model.js
  69. 51 0
      src/models/wallet_model.js
  70. 83 212
      src/server/SSB_server.js
  71. 1224 2839
      src/server/package-lock.json
  72. 16 16
      src/server/package.json
  73. 22 0
      src/server/ssb_config.js
  74. 66 0
      src/server/ssb_metadata.js
  75. 633 0
      src/views/activity_view.js
  76. 187 0
      src/views/agenda_view.js
  77. 198 0
      src/views/audio_view.js
  78. 190 0
      src/views/bookmark_view.js
  79. 105 0
      src/views/cipher_view.js
  80. 229 0
      src/views/cv_view.js
  81. 192 0
      src/views/document_view.js
  82. 241 0
      src/views/event_view.js
  83. 150 0
      src/views/feed_view.js
  84. 212 0
      src/views/image_view.js
  85. 82 0
      src/views/indexing_view.js
  86. 197 0
      src/views/inhabitants_view.js
  87. 83 0
      src/views/invites_view.js
  88. 65 0
      src/views/legacy_view.js
  89. 838 1293
      src/views/main_views.js
  90. 339 0
      src/views/market_view.js
  91. 106 0
      src/views/modules_view.js
  92. 299 0
      src/views/opinions_view.js
  93. 57 0
      src/views/peers_view.js
  94. 95 0
      src/views/pixelia_view.js
  95. 36 0
      src/views/pm_view.js
  96. 206 0
      src/views/report_view.js
  97. 384 0
      src/views/search_view.js
  98. 144 0
      src/views/settings_view.js
  99. 121 0
      src/views/stats_view.js
  100. 0 0
      src/views/tags_view.js

+ 143 - 46
README.md

@@ -1,14 +1,133 @@
 # Oasis
 
-Oasis is a **free, open-source, encrypted, peer-to-peer, distributed & federated**... project networking application 
+Oasis is a **free, open-source, encrypted, peer-to-peer, distributed (not decentralized!) & federated**... project networking application 
 that helps you follow interesting content and discover new ones.
 
-Check ['Overview`](https://wiki.solarnethub.com/socialnet/overview) for more info.
-
-  ![SNH](https://solarnethub.com/git/snh-oasis-logo.jpg "SolarNET.HuB")
+  ![SNH](https://solarnethub.com/git/snh-oasis-logo3.jpg "SolarNET.HuB")
 
 Oasis redefines what it means to be connected in the modern world, giving people 
-the ability to control their online presence and interactions without the need for centralized institutions. 
+the ability to control their online presence and interactions without the need for centralized institutions.
+
+----------
+
+## Frontend:
+
+Main features of the Oasis interface are:
+
+ +  Data manipulation is not permitted due to the use of BLOCKCHAIN technology.
+ +  No browser JavaScript. Just pure HTML+CSS. A really secure frontend!.
+ +  Use your favorite web browser to read and write messages to the people you care about.
+ +  Strong cryptography in every single point of the network.
+ +  You are the center of your own distributed network. Online or offline, it works anywhere that you are.
+ +  Initial identities are randomnly generated (no username or password required).
+ +  No personal profile generated (no questions about gender, age, location, etc …).
+ +  Automatic exif stripping (such as GPS coordinates) on images for better privacy.
+ +  No email or associated mobile phone required.
+ +  Automatic updates with new functionalities.
+ 
+   ![SNH](https://solarnethub.com/git/snh-oasis-settings.png "SolarNET.HuB")
+      
+But it has others features that are also really interesting, for example:
+
+ +  Support for multiple languages.
+ 
+   ![SNH](https://solarnethub.com/git/snh-oasis-languages.png "SolarNET.HuB")
+
+ +  Modularity to set your own environment.
+ 
+   ![SNH](https://solarnethub.com/git/snh-oasis-modules.png "SolarNET.HuB")
+    
+ +  Support for multiple themes.
+ 
+ 
+   ![SNH](https://solarnethub.com/git/snh-clear-theme.png "SolarNET.HuB")
+   ![SNH](https://solarnethub.com/git/snh-purple-theme.png "SolarNET.HuB")
+   ![SNH](https://solarnethub.com/git/snh-matrix-theme.png "SolarNET.HuB")
+ 
+And much more, that we invite you to discover by yourself ;-)
+
+----------
+
+## Modules:
+
+Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
+
+ + Agenda: Module to manage all your assigned items.
+ + Audios: Module to discover and manage audios.
+ + Bookmarks: Module to discover and manage bookmarks.	
+ + Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).	
+ + Documents: Module to discover and manage documents.	
+ + Events: Module to discover and manage events.	
+ + Feed: Module to discover and share short-texts (feeds).	
+ + Governance: Module to discover and manage votes.	
+ + Images: Module to discover and manage images.	
+ + Invites: Module to manage and apply invite codes.	
+ + Legacy: Module to manage your secret (private key) quickly and securely.	
+ + Latest: Module to receive the most recent posts and discussions.
+ + Market: Module to exchange goods or services.
+ + Multiverse: Module to receive content from other federated peers.	
+ + Opinions: Module to discover and vote on opinions.	
+ + Pixelia: Module to draw on a collaborative grid.	
+ + Popular: Module to receive posts that are trending, most viewed, or most commented on.	
+ + Reports: Module to manage and track reports related to issues, bugs, abuses, and content warnings.	
+ + Summaries: Module to receive summaries of long discussions or posts.	
+ + Tags: Module to discover and explore taxonomy patterns (tags).	
+ + Tasks: Module to discover and manage tasks.	
+ + Threads: Module to receive conversations grouped by topic or question.	
+ + Transfers: Module to discover and manage smart-contracts (transfers).	
+ + Trending: Module to explore the most popular content.	
+ + Tribes: Module to explore or create tribes (groups).	
+ + Videos: Module to discover and manage videos.	
+ + Wallet: Module to manage your digital assets (ECOin).	
+ + Topics: Module to receive discussion categories based on shared interests.
+
+Both the codebase and the inhabitants can generate new modules.
+
+----------
+
+## ECOin (crypto-economy)
+
+Oasis contains its own cryptocurrency. With it, you can exchange items and services in the marketplace. 
+
+  ![SNH](https://solarnethub.com/git/oasis-tomatoes-example.png "SolarNET.HuB")
+
+You can also receive a -Universal Basic Income- if you contribute to the Tribes and their coordinated actions.
+
+ + https://ecoin.03c8.net
+
+----------
+
+## L.A.R.P.
+
+Oasis contains a L.A.R.P. (real action role-playing) structured around 1+8 main houses. 
+
+  ![SNH](https://solarnethub.com/git/oasis-larp-schema.jpg "SolarNET.HuB")
+
+The main objective is to empower the inhabitants to organize around specific proposals and generate federated governments with specific characteristics.
+
+ + https://wiki.solarnethub.com/socialnet/roleplaying#how_to_play
+ 
+Check "The Houses" to review which one fit better with your ambitions:
+
+ + https://wiki.solarnethub.com/socialnet/roleplaying#the_houses
+
+----------
+  
+## Invite codes (for PUBs and TRIBES):
+
+Oasis is a TRUSTNET. This means you need an invitation code to enter the PUBs (managed by inhabitants or hacklabs).
+
+Similarly, TRIBES (groups in Oasis) require an entry code.
+
+  ![SNH](https://solarnethub.com/git/snh-oasis-invites.png "SolarNET.HuB")
+  
+While you can use it and connect to any nodes you want, it's a good idea to get an entry code to connect with the community.
+
+So you'll need to know someone, or participate in a collective action that distributes invitation codes, to see everything.
+
+ + https://wiki.solarnethub.com/socialnet/snh#finding_inhabitants
+  
+----------
 
 ## Architecture:
 
@@ -28,6 +147,8 @@ Even though Alice and Dan lack a direct connection, they can still exchange feed
 This is because gossip creates “transitive” connections between computers. Dan's messages travel through Carla and the PUB 
 to reach Alice, and visa-versa. 
 
+----------
+
 ## Backend:
 
 Oasis is based on a mesh network and self-hosted social media ecosystem called Secure Scuttlebutt (SSB). 
@@ -40,48 +161,15 @@ of any kind. Like a crypto transaction, SSB posts are censorship-resistant and a
 In SSB each user hosts their own content and the content of the peers they follow, which provides fault tolerance and 
 eventual consistency. 
 
-## Frontend:
-
-Main features of the Oasis interface are:
-
- +  No browser JavaScript. Just pure HTML+CSS. A really secure frontend!.
- +  Use your favorite web browser to read and write messages to the people you care about.
- +  Strong cryptography in every single point of the network.
- +  You are the center of your own distributed network. Online or offline, it works anywhere that you are.
- +  Initial identities are randomnly generated (no username or password required).
- +  No personal profile generated (no questions about gender, age, location, etc …).
- +  Automatic exif stripping (such as GPS coordinates) on images for better privacy.
- +  No email or associated mobile phone required.
- +  Support for multiple languages.
- +  Automatic updates with new functionalities.
- 
-   ![SNH](https://solarnethub.com/git/snh-oasis-settings.png "SolarNET.HuB")
-      
-But it has others features that are also really interesting, for example:
-
- +  Modularity to set your own environment.
- 
-   ![SNH](https://solarnethub.com/git/snh-oasis-modules.png "SolarNET.HuB")
-     
- +  A wallet to manage your ECOIn assets directly on the network.
- 
-   ![SNH](https://solarnethub.com/git/snh-oasis-ecoin.png "SolarNET.HuB")
-    
- +  A client side robust encryption (aes-256-cbc) to encrypt/decrypt your messages, even on the semantic layer.
-
-   ![SNH](https://solarnethub.com/git/snh-oasis-cipher.png "SolarNET.HuB")
-   
-And much more, that we invite you to discover by yourself.
-
 ----------
 
 ## Installing:
 
-Follow ['INSTALL.md'](docs/install.md) to build and install it on your device.
+Follow ['INSTALL.md'](docs/install/install.md) to build and install it on your device.
 
 ----------
 
-## Setup:
+## Setup & Deploy:
 
 Visit ['Settings'](https://wiki.solarnethub.com/socialnet/snh#settings_minimal) to learn how to choose your language, set a theme & configure your avatar.
 
@@ -99,7 +187,7 @@ This allows you to communicate and access content from outside the [project netw
 
 ----------
 
-## SNH-Hub:
+## SNH-Hub (for HackLabs):
 
 The public content of the ['PUB: "La Plaza"'](https://wiki.solarnethub.com/socialnet/snh-pub) can be visited from outside the [project network](https://wiki.solarnethub.com/socialnet/overview), through the [World Wide Web](https://en.wikipedia.org/wiki/World_Wide_Web) (aka [Clearnet](https://en.wikipedia.org/wiki/Clearnet_(networking))).
 
@@ -117,17 +205,25 @@ See stats: https://laplaza.solarnethub.com/
 
 ----------
 
-## Development:
+## Roadmap:
 
-Oasis is completely coded in: node.js (v22.13.1), HTML5 + CSS.
+Review ['Roadmap'](https://wiki.solarnethub.com/project/roadmap#the_project_network) to know about some required functionalities that can be implemented.
 
-Check ['Call 4 Hackers'](https://wiki.solarnethub.com/community/hackers) for contributing with developments.
+----------
+
+## Translations:
+
+Oasis supports multiple languages. One way to contribute is to translate the interface into your language so other people in your region can use it more intuitively.
+
+ + https://wiki.solarnethub.com/socialnet/snh#choose_language
 
 ----------
 
-## Roadmap:
+## Development:
 
-Review ['Roadmap'](https://wiki.solarnethub.com/project/roadmap#the_project_network) to know about some required functionalities that can be implemented.
+Oasis is completely coded in: node.js, HTML5 + CSS.
+
+Check ['Call 4 Hackers'](https://wiki.solarnethub.com/community/hackers) for contributing with developments.
 
 ----------
 
@@ -145,3 +241,4 @@ Review ['Roadmap'](https://wiki.solarnethub.com/project/roadmap#the_project_netw
  + ECOin: https://wiki.solarnethub.com/ecoin/overview
  + Role-playing (L.A.R.P): https://wiki.solarnethub.com/socialnet/roleplaying
  + Warehouse: https://wiki.solarnethub.com/stock/submit_request
+ + THS: https://thehackerstyle.com

+ 41 - 2
docs/CHANGELOG.md

@@ -13,11 +13,50 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
-## v0.3.5 - 2025-02-15
+## v0.3.5 - 2025-06-21 (summer solstic)
 
 ### Changed
 
-- Refactoring tasks + Skin selector + Language selector
+- Hardcore "hacking" and refactoring for: models + backend + middleware + views.
+
+### Added
+
+- Some "core" modules:
+
+  + Agenda: Module to manage all your assigned items.
+  + Audios: Module to discover and manage audios.
+  + Bookmarks: Module to discover and manage bookmarks.
+  + Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).
+  + Documents: Module to discover and manage documents.
+  + Events: Module to discover and manage events.
+  + Feed: Module to discover and share short-texts (feeds).
+  + Governance: Module to discover and manage votes.
+  + Images: Module to discover and manage images.
+  + Invites: Module to manage and apply invite codes.
+  + Legacy: Module to manage your secret (private key) quickly and securely.
+  + Latest: Module to receive the most recent posts and discussions.
+  + Market: Module to exchange goods or services.
+  + Multiverse: Module to receive content from other federated peers.
+  + Opinions: Module to discover and vote on opinions.
+  + Pixelia: Module to draw on a collaborative grid.
+  + Popular: Module to receive posts that are trending, most viewed, or most commented on.
+  + Reports: Module to manage and track reports related to issues, bugs, abuses, and content warnings.
+  + Summaries: Module to receive summaries of long discussions or posts.
+  + Tags: Module to discover and explore taxonomy patterns (tags).
+  + Tasks: Module to discover and manage tasks.
+  + Threads: Module to receive conversations grouped by topic or question.
+  + Transfers: Module to discover and manage smart-contracts (transfers).
+  + Trending: Module to explore the most popular content.
+  + Tribes: Module to explore or create tribes (groups).
+  + Videos: Module to discover and manage videos.
+  + Wallet: Module to manage your digital assets (ECOin).
+  + Topics: Module to receive discussion categories based on shared interests.
+
+- New languages: Spanish, Euskara and French.
+
+- New themes: SNH-Clear, SNH-Purple and SNH-Matrix.
+
+- L.A.R.P (Live Action Role-PLaying) structure.
 
 ## v0.3.0 - 2024-12-15
 

File diff suppressed because it is too large
+ 17 - 0
docs/MANIFESTO


+ 205 - 0
docs/PUB/deploy.md

@@ -0,0 +1,205 @@
+# Oasis PUB Deployment Guide
+
+This guide will walk you through the process of deploying an **Oasis PUB** on your server. 
+
+---
+
+A PUB server needs a static, publicly-reachable IP address.
+
+By default it uses port 8008, so make sure to expose that port (or whatever port you configure) to the internet.
+
+## 1) Install NodeJS (LTS v18.20.8 for SSB-Server compatibility)
+
+   sudo apt-get install git curl
+   curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
+   source ~/.bashrc
+   nvm install 18
+   nvm use 18
+   nvm alias default 18
+
+## 2) Create a `~/.ssb/config` file and the `oasis-pub-server.sh` launch script
+
+Before running the server, create a config file that enables needed plugins and network options.
+
+   nano ~/.ssb/config
+
+Paste this:
+
+{
+  "logging": {
+    "level": "notice"
+  },
+  "caps": {
+    "shs": "+u5/ShHkb5g8jIWmybt/8ulGbZ2jFfzp8ggMwmKcRF0="
+  },
+  "pub": true,
+  "local": false,
+  "friends": {
+    "dunbar": 150,
+    "hops": 3
+  },
+  "gossip": {
+    "connections": 50,
+    "seed": true,
+    "seeds": [
+      "solarnethub.com:8008~shs:HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4="
+    ],
+    "global": true
+  },
+  "connections": {
+    "incoming": {
+      "net": [
+        { 
+          "port": 8008,
+          "scope": "public",
+          "transform": "shs",
+          "external": "{your-hostname}"
+        },
+        { 
+          "port": 8008,
+          "host": "localhost",
+          "scope": "device",
+          "transform": "shs"
+        }
+      ],
+      "unix": [
+        { 
+          "scope": ["device", "local", "private"],
+          "transform": "noauth"
+        }
+      ]
+    },
+    "outgoing": {
+      "net": [
+        { 
+          "transform": "shs"
+        }
+      ]
+    }
+  },
+  "replicationScheduler": {
+    "autostart": true,
+    "partialReplication": null
+  },
+  "autofollow": {
+    "enabled": true,
+    "suggestions": [
+      "@HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519"
+    ]
+  }
+}
+
+Be sure to replace {your-hostname} with your server’s domain or IP.
+
+## 3) Install ssb-server and plugins locally
+
+   npm -g install ssb-server ssb-master ssb-gossip ssb-ebt ssb-friends ssb-blobs ssb-conn ssb-logging ssb-replication-scheduler
+   npm audit fix
+   
+## 4) Create the launch script and some patches
+
+Save the following script at: ~/oasis-pub/patch-ssb-ref.js 
+   
+   const fs = require('fs');
+   const path = require('path');
+
+   const ssbRefPath = path.resolve(__dirname, 'node_modules/ssb-ref/index.js'); // Adjust as needed
+
+   if (fs.existsSync(ssbRefPath)) {
+     const data = fs.readFileSync(ssbRefPath, 'utf8');
+     const patchedData = data.replace(
+       'exports.parseAddress = deprecate(\'ssb-ref.parseAddress\', parseAddress)',
+       'exports.parseAddress = parseAddress'
+     );
+
+     if (data !== patchedData) {
+       fs.writeFileSync(ssbRefPath, patchedData);
+       console.log('[OASIS] [PATCH] Patched ssb-ref to remove deprecated usage of parseAddress');
+     }
+   }
+
+And make it executable:
+
+   chmod +x ~/oasis-pub/patch-ssb-ref.js 
+
+Finally, save the following script at: ~/oasis-pub/oasis-pub-server.sh.
+    
+  #!/bin/bash
+  export NODE_OPTIONS="--no-deprecation"
+
+  cd ~/oasis-pub
+  node patch-ssb-ref.js
+  ssb-server start
+   
+And make it executable:
+
+   chmod +x ~/oasis-pub/oasis-pub-server.sh
+
+## 5) Run the server script
+
+Use a session-manager such as screen or tmux to create a detachable session. Start the session and run the script:
+
+   sh ~/oasis-pub/oasis-pub-server.sh
+
+Then, detach the session.
+
+## 6) Create the Pub's profile
+
+It's a good idea to give your PUB a name, by publishing one on its feed. 
+
+To do this, first get the PUB's ID, with: 
+
+   cd ~/oasis-pub 
+   ssb-server whoami
+   
+   {
+     "id": "@HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519"
+   }
+
+Then, publish a name with the following command:
+
+   ssb-server publish --type about --about {pub-id} --name {name}
+
+## 7) Create Invites
+
+For a last step, you should create invite codes, which you can send to other inhabitants to let them join the PUB. 
+
+The command to create an invite code is:
+
+   cd ~/oasis-pub 
+   ssb-server invite.create 1
+
+This may now be given out to friends, to command your PUB to follow them. 
+
+If you want to let a single code be used more than once, you can provide a number larger than 1.
+
+   cd ~/oasis-pub 
+   ssb-server invite.create 500
+
+## 8) Announce your PUB
+
+To announce your PUB, publish this message:
+
+   cd ~/oasis-pub 
+   ssb-server publish --type pub --address.key {pub-id} --address.host {name} --address.port {number}
+   
+For example, to announce `solarnethub.com` PUB: "La Plaza":
+
+   ssb-server publish --type pub --address.key @HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519 --address.host solarnethub.com --address.port 8008
+    
+## 9) Following another PUB
+
+To follow another PUB's feed, publish this other message:
+
+   cd ~/oasis-pub 
+   ssb-server publish --type contact --contact {pub-id-to-follow} --following
+    
+For example, to follow `solarnethub.com` PUB: "La Plaza":
+
+   cd ~/oasis-pub 
+   ssb-server publish --type contact --contact "@HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519" --following
+
+## 10) Join the Oasis PUB Network
+
+To help your PUB discover other Oasis-connected peers and speed up replication, we’ve included the Oasis seed PUB at `solarnethub.com` in your config file.
+

+ 0 - 2
docs/devs/CONTRIBUTORS

@@ -1,2 +0,0 @@
-psy <epsylon@riseup.net>
-Christian Bundy <christianbundy@fraction.io>

+ 0 - 1
docs/devs/MAINTAINERS

@@ -1 +0,0 @@
-psy <epsylon@riseup.net>

+ 16 - 0
docs/devs/install.md

@@ -0,0 +1,16 @@
+# Developer Install
+
+To deploy the development environment:
+
+```shell
+git clone https://code.03c8.net/KrakensLab/oasis
+cd oasis
+bash install.sh
+cd src/server
+npm run dev
+```
+
+Once Oasis is started in dev mode, visit [http://localhost:3000](http://localhost:3000). 
+
+While the server processes are running, they will restart theirselves automatically every time you save changes in any file into `/src`. Page autoreload feature is not available even for the development environment because we avoid using JavaScript in the browser, so your browser will remain untouched. Reload the page manually to display the changes.
+

+ 7 - 3
docs/install/install.md

@@ -8,13 +8,15 @@ You can either use the automated installation script or manually download the so
 
 ## 1) Automated Installation (Recommended)
 
-To install **Oasis** with a single command, run: 'sh install.sh'
+To install **Oasis** with a single command, run: 
+
+    sh install.sh
 
 ---
 
 ## 2) Manual Installation 
 
-On a GNU/Linux based system, execute the following steps (from a shell):
+Try to execute the following steps (from a shell):
 
     sudo apt-get install git curl
     curl -sL http://deb.nodesource.com/setup_22.x | sudo bash -
@@ -27,4 +29,6 @@ On a GNU/Linux based system, execute the following steps (from a shell):
 
 ## 3) Run Oasis
 
-To run **Oasis** just launch: 'sh oasis.sh'
+To run **Oasis** just launch: 
+
+    sh oasis.sh

+ 1 - 1
docs/security.md

@@ -28,4 +28,4 @@ Only the latest release is supported.
 
 ## Reporting a Vulnerability
 
-Send an email to solarnethub@riseup.net to report any security problems. Please do not use the public issue tracker.
+Send an email to epsylon@riseup.net to report any security problems. Please do not use the public issue tracker.

+ 4 - 0
install.sh

@@ -4,7 +4,11 @@ cd src/server
 printf "==========================\n"
 printf "|| OASIS Installer v0.1 ||\n"
 printf "==========================\n"
+sudo apt-get install git curl
+curl -sL http://deb.nodesource.com/setup_22.x | sudo bash -
+sudo apt-get install -y nodejs
 npm install .
+npm audit fix
 printf "==========================\n"
 printf "\nOASIS has been correctly deployed! ;)\n\n"
 printf "Run: 'sh oasis.sh' to start ...\n\n"

+ 10 - 30
oasis.sh

@@ -1,32 +1,12 @@
-#!/bin/bash
+#!/bin/sh
 
 CURRENT_DIR=$(pwd)
-
-cd "$CURRENT_DIR/src/server" || { echo "Directory not found: $CURRENT_DIR/src/server"; exit 1; }
-node SSB_server.js start &
-
-check_server_ready() {
-  local host="127.0.0.1"
-  local port="8008"
-
-  node -e "
-  const net = require('net');
-  const client = new net.Socket();
-  client.setTimeout(5000);  // Set a timeout of 5 seconds
-  client.connect($port, '$host', function() {
-    client.end();  // Successfully connected, close the socket
-    process.exit(0);  // Exit with a success code
-  });
-  client.on('error', function(err) {
-    process.exit(1);  // Exit with error code if connection fails
-  });
-  " 
-}
-
-until check_server_ready; do
-  sleep 1
-done
-
-cd "$CURRENT_DIR/src/backend" || { echo "Directory not found: $CURRENT_DIR/src/backend"; exit 1; }
-node backend.js
-
+MODE=$1
+
+if [ "$MODE" = "server" ]; then
+  cd "$CURRENT_DIR/src/server" || { echo "Directory not found: $CURRENT_DIR/src/server"; exit 1; }
+  exec node SSB_server.js start
+else
+  cd "$CURRENT_DIR/src/backend" || { echo "Directory not found: $CURRENT_DIR/src/backend"; exit 1; }
+  exec node backend.js
+fi

+ 66 - 4
scripts/patch-node-modules.js

@@ -1,12 +1,74 @@
 const fs = require('fs');
 const path = require('path');
 
-const ssbRefPath = path.resolve(__dirname, '../src/server/node_modules/ssb-ref/index.js');
+const log = (msg) => console.log(`[OASIS] [PATCH] ${msg}`);
 
+// === Patch ssb-ref ===
+const ssbRefPath = path.resolve(__dirname, '../src/server/node_modules/ssb-ref/index.js');
 if (fs.existsSync(ssbRefPath)) {
   const data = fs.readFileSync(ssbRefPath, 'utf8');
-  const patchedData = data.replace('exports.parseAddress = deprecate(\'ssb-ref.parseAddress\', parseAddress)', 'exports.parseAddress = parseAddress');
+  const patched = data.replace(
+    /exports\.parseAddress\s*=\s*deprecate\([^)]*\)/,
+    'exports.parseAddress = parseAddress'
+  );
+  if (patched !== data) {
+    fs.writeFileSync(ssbRefPath, patched);
+    log('Patched ssb-ref to remove deprecated usage of parseAddress');
+  } else {
+    log('ssb-ref patch skipped: target line not found');
+  }
+}
+
+// === Patch ssb-blobs ===
+const ssbBlobsPath = path.resolve(__dirname, '../src/server/node_modules/ssb-blobs/inject.js');
+if (fs.existsSync(ssbBlobsPath)) {
+  let data = fs.readFileSync(ssbBlobsPath, 'utf8');
+
+  const marker = 'want: function (id, cb)';
+  const startIndex = data.indexOf(marker);
+  if (startIndex !== -1) {
+    const endIndex = data.indexOf('},', startIndex); // end of function block
+    if (endIndex !== -1) {
+      const before = data.slice(0, startIndex);
+      const after = data.slice(endIndex + 2);
+
+      const replacement = `
+  want: function (id, cb) {
+    id = toBlobId(id);
+    if (!isBlobId(id)) return cb(new Error('invalid id:' + id));
+
+    if (blobStore.isEmptyHash(id)) return cb(null, true);
+
+    if (wantCallbacks[id]) {
+      if (!Array.isArray(wantCallbacks[id])) wantCallbacks[id] = [];
+      wantCallbacks[id].push(cb);
+    } else {
+      wantCallbacks[id] = [cb];
+      blobStore.size(id, function (err, size) {
+        if (err) return cb(err);
+        if (size != null) {
+          while (wantCallbacks[id].length) {
+            const fn = wantCallbacks[id].shift();
+            if (typeof fn === 'function') fn(null, true);
+          }
+          delete wantCallbacks[id];
+        }
+      });
+    }
+
+    const peerId = findPeerWithBlob(id);
+    if (peerId) get(peerId, id);
+
+    if (wantCallbacks[id]) registerWant(id);
+  },`;
 
-  fs.writeFileSync(ssbRefPath, patchedData);
-  console.log('[OASIS] [PATCH] Patched ssb-ref to remove deprecated usage of parseAddress');
+      const finalData = before + replacement + after;
+      fs.writeFileSync(ssbBlobsPath, finalData);
+      log('Patched ssb-blobs to fix wantCallbacks handling');
+    } else {
+      log('ssb-blobs patch skipped: end of want function not found');
+    }
+  } else {
+    log('ssb-blobs patch skipped: want function not found');
+  }
 }

+ 0 - 10
scripts/release.sh

@@ -1,10 +0,0 @@
-#!/usr/bin/sh
-
-git push -f origin master:release-$(jq -r .version < package.json)
-
-until git push origin master; do
-  sleep 120;
-done
-
-npm publish
-

File diff suppressed because it is too large
+ 1397 - 446
src/backend/backend.js


+ 41 - 0
src/backend/blobHandler.js

@@ -0,0 +1,41 @@
+const pull = require('../server/node_modules/pull-stream');
+const FileType = require("../server/node_modules/file-type");
+const promisesFs = require('fs').promises;
+const ssb = require("../client/gui");
+const config = require("../server/SSB_server").config; 
+const cooler = ssb({ offline: config.offline });
+
+const handleBlobUpload = async function (ctx, fileFieldName) {
+  if (!ctx.request.files || !ctx.request.files[fileFieldName]) {
+    return null;
+  }
+
+  const blobUpload = ctx.request.files[fileFieldName];
+  if (!blobUpload) return null;
+
+  const data = await promisesFs.readFile(blobUpload.filepath);
+  if (data.length === 0) return null;
+
+  const ssbClient = await cooler.open();
+
+  const blob = { name: blobUpload.name };
+  blob.id = await new Promise((resolve, reject) => {
+    pull(pull.values([data]), ssbClient.blobs.add((err, ref) => err ? reject(err) : resolve(ref)));
+  });
+
+  try {
+    const fileType = await FileType.fromBuffer(data);
+    blob.mime = fileType.mime;
+  } catch {
+    blob.mime = "application/octet-stream";
+  }
+
+  if (blob.mime.startsWith("image/")) return `\n![image:${blob.name}](${blob.id})`;
+  if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
+  if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`;
+  if (blob.mime === "application/pdf") return `[pdf:${blob.name}](${blob.id})`;
+
+  return `\n[${blob.name}](${blob.id})`;
+};
+
+module.exports = { handleBlobUpload };

+ 19 - 0
src/backend/renderTextWithStyles.js

@@ -0,0 +1,19 @@
+function renderTextWithStyles(text) {
+  if (!text) return ''
+  return String(text)
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
+      `<a href="/author/${encodeURIComponent('@' + id)}" class="styled-link" target="_blank">@${id}</a>`
+    )
+    .replace(/#(\w+)/g, (_, tag) =>
+      `<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${tag}</a>`
+    )
+    .replace(/(https?:\/\/[^\s]+)/g, url =>
+      `<a href="${url}" target="_blank" class="styled-link">${url}</a>`
+    )
+}
+
+module.exports = { renderTextWithStyles }
+

+ 0 - 158
src/backend/supports.js

@@ -1,158 +0,0 @@
-#!/usr/bin/env node
-
-const { readFileSync } = require("fs");
-const { join } =require("path");
-
-const path = require("path");
-const homedir = require('os').homedir();
-const supportingPath = path.join(homedir, ".ssb/flume/contacts2.json");
-const {
-  a,
-  br,
-  li,
-} = require("../server/node_modules/hyperaxe");
-
-const envPaths = require("../server/node_modules/env-paths");
-const {cli} = require("../client/oasis_client");
-const ssb = require("../client/gui");
-
-const defaultConfig = {};
-const defaultConfigFile = join(
-  envPaths.default("oasis", { suffix: "" }).config,
-  "/default.json"
-);
-
-const config = cli(defaultConfig, defaultConfigFile);
-if (config.debug) {
-  process.env.DEBUG = "oasis,oasis:*";
-}
-const cooler = ssb({ offline: config.offline });
-
-const models = require("../models/main_models");
-
-const { about } = models({
-  cooler,
-  isPublic: config.public,
-});
-
-async function getNameByIdSupported(supported){
-  const name_supported = await about.name(supported);
-  return name_supported
-}
-
-async function getNameByIdBlocked(blocked){
-  name_blocked = await about.name(blocked);
-  return name_blocked
-}
-
-async function getNameByIdRecommended(recommended){
-  name_recommended = await about.name(recommended);
-  return name_recommended
-}
-
-  try{
-      var supporting = JSON.parse(readFileSync(supportingPath, {encoding:'utf8', flag:'r'})).value;
-    }catch{
-      var supporting = undefined;
-    }
-    if (supporting == undefined) {
-        var supportingValue = "false";
-    }else{
-        var keys = Object.keys(supporting);
-        if (keys[0] === undefined){
-          var supportingValue = "false";
-        }else{
-          var supportingValue = "true";
-        }
-    }
-
-    if (supportingValue === "true") {
-      var arr = [];
-      var keys = Object.keys(supporting);
-        var data = Object.entries(supporting[keys[0]]);
-        Object.entries(data).forEach(([key, value]) => {
-         if (value[1]===1){
-          var supported = (value[0])
-           if (!arr.includes(supported)) {
-              getNameSupported(supported);
-              async function getNameSupported(supported){
-                 const name_supported = await getNameByIdSupported(supported);
-              arr.push(
-               li(
-                 name_supported,br,
-                 a(
-                  { href: `/author/${encodeURIComponent(supported)}` }, 
-                  supported
-                 )
-               ), br
-              );
-             }
-           }
-         }
-      });
-    }else{
-      var arr = [];
-    }
-    var supports = arr;
-
-    if (supportingValue === "true") {
-      var arr2 = [];
-      var keys = Object.keys(supporting);
-      var data = Object.entries(supporting[keys[0]]);
-       Object.entries(data).forEach(([key, value]) => {
-         if (value[1]===-1){
-          var blocked = (value[0])
-           if (!arr2.includes(blocked)) {
-              getNameBlocked(blocked);
-              async function getNameBlocked(blocked){
-                 name_blocked = await getNameByIdBlocked(blocked);
-              arr2.push(
-               li(
-                 name_blocked,br,
-                 a( 
-                  { href: `/author/${encodeURIComponent(blocked)}` },
-                  blocked
-                 )
-               ), br
-              );
-             }
-           }
-         }
-      });
-    }else{
-      var arr2 = [];
-    }
-    var blocks = arr2;
-
-    if (supportingValue === "true") {
-      var arr3 = [];
-      var keys = Object.keys(supporting);
-      var data = Object.entries(supporting[keys[0]]);
-       Object.entries(data).forEach(([key, value]) => {
-         if (value[1]===-2){
-          var recommended = (value[0])
-           if (!arr3.includes(recommended)) {
-              getNameRecommended(recommended);
-              async function getNameRecommended(recommended){
-                 name_recommended = await getNameByIdRecommended(recommended);
-              arr3.push(
-               li(
-                 name_recommended,br,
-                 a( 
-                  { href: `/author/${encodeURIComponent(recommended)}` },
-                  recommended
-                 )
-               ), br
-              );
-             }
-           }
-         }
-      });
-    }else{
-      var arr3 = [];
-    }
-    var recommends = arr3;
-
-module.exports.supporting = supports;
-module.exports.blocking = blocks;
-module.exports.recommending = recommends

+ 24 - 19
src/backend/updater.js

@@ -1,14 +1,16 @@
 const fetch = require('../server/node_modules/node-fetch');
-const { existsSync, readFileSync } = require('fs');
+const { existsSync, readFileSync, writeFileSync, unlinkSync } = require('fs');
 const { join } = require('path');
 
 const localpackage = join(__dirname, '../server/package.json');
 const remoteUrl = 'https://code.03c8.net/KrakensLab/oasis/raw/master/src/server/package.json'; // Official SNH-Oasis
 const remoteUrl2 = 'https://raw.githubusercontent.com/epsylon/oasis/refs/heads/main/src/server/package.json'; // Mirror SNH-Oasis
 
+let printed = false;
+
 async function extractVersionFromText(text) {
   try {
-    const versionMatch = text.match(/"version":\s*"([^"]+)"/); 
+    const versionMatch = text.match(/"version":\s*"([^"]+)"/);
     if (versionMatch) {
       return versionMatch[1];
     } else {
@@ -26,12 +28,16 @@ async function diffVersion(body, callback) {
     const remoteVersion = remoteData.version;
 
     const localData = JSON.parse(readFileSync(localpackage, 'utf8'));
-    const localVersion = localData.version; 
+    const localVersion = localData.version;
+
+    const updateFlagPath = join(__dirname, "../server/.update_required");
 
     if (remoteVersion !== localVersion) {
-      callback("required"); 
+      writeFileSync(updateFlagPath, JSON.stringify({ required: true }));
+      callback("required");
     } else {
-      callback("");  // No update required
+      if (existsSync(updateFlagPath)) unlinkSync(updateFlagPath);
+      callback("");  // no updates required
     }
   } catch (error) {
     console.error("Error comparing versions:", error.message);
@@ -43,8 +49,8 @@ async function checkMirror(callback) {
   try {
     const response = await fetch(remoteUrl2, {
       method: 'GET',
-      headers: { 
-        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 
+      headers: {
+        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
         'Accept': 'application/json, text/plain, */*',
         'Accept-Language': 'en-US,en;q=0.9',
         'Accept-Encoding': 'gzip, deflate, br',
@@ -61,7 +67,7 @@ async function checkMirror(callback) {
     const data = await response.text();
     callback(null, data);
   } catch (error) {
-    console.error("Error fetching from mirror URL:", error.message);
+    console.error("\noasis@version: no updates requested.\n");
     callback(error);
   }
 }
@@ -71,8 +77,8 @@ exports.getRemoteVersion = async () => {
     try {
       const response = await fetch(remoteUrl, {
         method: 'GET',
-        headers: { 
-          'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 
+        headers: {
+          'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
           'Accept': 'application/json, text/plain, */*',
           'Accept-Language': 'en-US,en;q=0.9',
           'Accept-Encoding': 'gzip, deflate, br',
@@ -87,25 +93,24 @@ exports.getRemoteVersion = async () => {
       }
       const data = await response.text();
       diffVersion(data, (status) => {
-        if (status === "required") {
-          global.ck = "required";
+        if (status === "required" && !printed) {
+          printed = true; 
           console.log("\noasis@version: new code updates are available:\n\n1) Run Oasis and go to 'Settings' tab\n2) Click at 'Get updates' button to download latest code\n3) Restart Oasis when finished\n");
-        } else {
-          console.log("\noasis@version: no updates required.\n");
+        } else if (status === "") {
+          console.log("\noasis@version: no updates requested.\n");
         }
       });
     } catch (error) {
-      console.error("Error fetching from official URL:", error.message);
       checkMirror((err, data) => {
         if (err) {
-          console.error("Error fetching from mirror URL:", err.message);
+          console.error("\noasis@version: no updates requested.\n");
         } else {
           diffVersion(data, (status) => {
-            if (status === "required") {
-              global.ck = "required";
+            if (status === "required" && !printed) {
+              printed = true; 
               console.log("\noasis@version: new code updates are available:\n\n1) Run Oasis and go to 'Settings' tab\n2) Click at 'Get updates' button to download latest code\n3) Restart Oasis when finished\n");
             } else {
-              console.log("\noasis@version: no updates required.\n");
+              console.log("\noasis@version: no updates requested.\n");
             }
           });
         }

BIN
src/client/assets/images/default-avatar.png


BIN
src/client/assets/images/default-market.png


BIN
src/client/assets/images/default-tribe.png


File diff suppressed because it is too large
+ 1136 - 74
src/client/assets/styles/style.css


+ 136 - 114
src/client/assets/themes/Clear-SNH.css

@@ -1,192 +1,214 @@
 body {
-  background-color: #F9F9F9;
-  color: #2C2C2C;
-  font-family: 'Roboto', sans-serif;
-}
-
-header, footer {
-  background-color: #FFFFFF;
-  border-bottom: 1px solid #E0E0E0;
-}
-
-.sidebar-left, .sidebar-right {
-  background-color: #F4F4F4;
-  border: 1px solid #E0E0E0;
+  background-color: #F9F9F9 !important;
+  color: #2C2C2C !important;
+  font-family: 'Roboto', sans-serif !important;
 }
 
 .main-column {
-  background-color: #FFFFFF;
-  border: 1px solid #E0E0E0;
+  background-color: #FFFFFF !important;
+  border: 1px solid #E0E0E0 !important;
 }
 
 button, input[type="submit"], input[type="button"] {
-  background-color: #FF6F00;
-  color: #FFFFFF;
-  border: none;
-  border-radius: 6px;
-  padding: 10px 20px;
-  cursor: pointer;
-  font-weight: 600;
+  background-color: #FF6F00 !important;
+  color: #FFFFFF !important;
+  border: none !important;
+  border-radius: 6px !important;
+  padding: 10px 20px !important;
+  cursor: pointer !important;
+  font-weight: 600 !important;
 }
 
 button:hover, input[type="submit"]:hover, input[type="button"]:hover {
-  background-color: #FF8F00;
+  background-color: #FF8F00 !important;
 }
 
 input, textarea, select {
-  background-color: #FFFFFF;
-  color: #2C2C2C;
-  border: 1px solid #E0E0E0;
-  border-radius: 4px;
-  padding: 8px;
-  font-size: 16px;
+  background-color: #FFFFFF !important;
+  color: #2C2C2C !important;
+  border: 1px solid #E0E0E0 !important;
+  border-radius: 4px !important;
+  padding: 8px !important;
+  font-size: 16px !important;
 }
 
 a {
-  color: #007BFF;
-  text-decoration: none;
+  color: #007BFF !important;
+  text-decoration: none !important;
 }
 
 a:hover {
-  text-decoration: underline;
+  text-decoration: underline !important;
 }
 
 table {
-  background-color: #FFFFFF;
-  color: #2C2C2C;
-  width: 100%;
-  border-collapse: collapse;
+  background-color: #FFFFFF !important;
+  color: #2C2C2C !important;
+  width: 100% !important;
+  border-collapse: collapse !important;
 }
 
 table th {
-  background-color: #F8F8F8;
-  padding: 12px 15px;
-  text-align: left;
-  font-weight: 600;
+  background-color: #F8F8F8 !important;
+  padding: 12px 15px !important;
+  text-align: left !important;
+  font-weight: 600 !important;
 }
 
 table tr:nth-child(even) {
-  background-color: #FAFAFA;
+  background-color: #FAFAFA !important;
 }
 
 table td {
-  padding: 12px 15px;
-}
-
-nav ul {
-  background-color: #FFFFFF;
-  border-bottom: 1px solid #E0E0E0;
-  padding: 0;
-  margin: 0;
-  list-style-type: none;
-}
-
-nav ul li {
-  display: inline-block;
-  margin-right: 10px;
-}
-
-nav ul li a {
-  color: #2C2C2C;
-  padding: 12px 15px;
-  font-size: 16px;
-  display: inline-block;
-  transition: background-color 0.3s;
-}
-
-nav ul li a:hover {
-  background-color: #F4F4F4;
-  border-radius: 4px;
+  padding: 12px 15px !important;
 }
 
 .profile {
-  background-color: #FFFFFF;
-  padding: 20px;
-  border-radius: 8px;
-  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+  background-color: #FFFFFF !important;
+  padding: 20px !important;
+  border-radius: 8px !important;
+  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
 }
 
 .profile .name {
-  color: #FF6F00;
-  font-size: 20px;
-  font-weight: 700;
+  color: #FF6F00 !important;
+  font-size: 20px !important;
+  font-weight: 700 !important;
 }
 
 .avatar {
-  border: 3px solid #FF6F00;
-  border-radius: 50%;
-  width: 60px;
-  height: 60px;
+  border: 3px solid #FF6F00 !important;
+  border-radius: 50% !important;
+  width: 60px !important;
+  height: 60px !important;
 }
 
 article, section {
-  background-color: #FFFFFF;
-  color: #2C2C2C;
-  padding: 20px;
-  border-radius: 8px;
-  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
+  background-color: #FFFFFF !important;
+  color: #2C2C2C !important;
+  padding: 20px !important;
+  border-radius: 8px !important;
+  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05) !important;
 }
 
 .post-preview img {
-  border-radius: 8px;
-  max-width: 100%;
-  height: auto;
+  border-radius: 8px !important;
+  max-width: 100% !important;
+  height: auto !important;
 }
 
 .post-preview .image-container {
-  max-width: 100%;
-  overflow: hidden;
-  display: block;
-  margin: 0 auto;
+  max-width: 100% !important;
+  overflow: hidden !important;
+  display: block !important;
+  margin: 0 auto !important;
 }
 
 div {
-  background-color: #FFFFFF;
-  border: 1px solid #E0E0E0;
+  background-color: #FFFFFF !important;
+  border: 1px solid #E0E0E0 !important;
 }
 
 div .header-content {
-  width: 100%;
+  width: 100% !important;
 }
 
 ::-webkit-scrollbar {
-  width: 8px;
+  width: 8px !important;
 }
 
 ::-webkit-scrollbar-thumb {
-  background-color: #B0B0B0;
-  border-radius: 8px;
+  background-color: #B0B0B0 !important;
+  border-radius: 8px !important;
 }
 
 ::-webkit-scrollbar-track {
-  background-color: #F9F9F9;
+  background-color: #F9F9F9 !important;
 }
 
 .action-container {
-  background-color: #FFFFFF;
-  border: 1px solid #E0E0E0;
-  padding: 20px;
-  border-radius: 8px;
-  color: #2C2C2C;
-  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
+  background-color: #FFFFFF !important;
+  border: 1px solid #E0E0E0 !important;
+  padding: 20px !important;
+  border-radius: 8px !important;
+  color: #2C2C2C !important;
+  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05) !important;
 }
 
 footer {
-  background-color: #FFFFFF;
-  border-top: 1px solid #E0E0E0;
-  padding: 15px 0;
+  background-color: #FFFFFF !important;
+  border-top: 1px solid #E0E0E0 !important;
+  padding: 15px 0 !important;
 }
 
 footer a {
-  background-color: #007BFF;
-  color: #FFFFFF;
-  padding: 10px 20px;
-  border-radius: 6px;
-  text-decoration: none;
-  font-weight: 600;
+  background-color: #007BFF !important;
+  color: #FFFFFF !important;
+  padding: 10px 20px !important;
+  border-radius: 6px !important;
+  text-decoration: none !important;
+  font-weight: 600 !important;
 }
 
 footer a:hover {
-  background-color: #0056b3;
+  background-color: #0056b3 !important;
 }
 
+.sidebar-left nav ul,
+.sidebar-right nav ul {
+  display: flex !important;
+  flex-direction: column !important;
+}
+
+.sidebar-left nav ul,
+.sidebar-right nav ul {
+  display: flex;
+  flex-direction: column;
+  margin: 0;
+}
+
+.sidebar-left nav ul li,
+.sidebar-right nav ul li {
+  width: 100%;
+  display: block;
+}
+
+.sidebar-left nav ul li a,
+.sidebar-right nav ul li a,
+.header nav ul li a {
+  display: block;
+  width: 100%;
+  padding: 12px 16px;
+  font-size: 15px;
+  font-weight: 500;
+  border-radius: 6px;
+  background-color: #ffffff !important;
+  color: #2C2C2C !important;
+  border: 1px solid #D0D0D0 !important;
+  text-align: left;
+  box-sizing: border-box;
+}
+
+.sidebar-left nav ul li a:hover,
+.sidebar-right nav ul li a:hover,
+.header nav ul li a:hover {
+  background-color: #f0f0f0 !important;
+}
+
+.filter-btn,
+.create-button,
+.edit-btn,
+.delete-btn,
+.join-btn,
+.leave-btn,
+.buy-btn {
+  background-color: #FF6F00 !important;
+  color: #FFFFFF !important;
+  border: none !important;
+}
+
+.filter-btn:hover,
+.create-button:hover {
+  background-color: #FF8F00 !important;
+  color: #FFFFFF !important;
+}

+ 0 - 5
src/client/assets/themes/Dark-SNH.css

@@ -54,10 +54,6 @@ table tr:nth-child(even) {
   background-color: #2A2A2A;
 }
 
-nav ul {
-  background-color: #1F1F1F;
-}
-
 nav ul li a:hover {
   color: #FFDD44;
   text-decoration: underline;
@@ -129,7 +125,6 @@ div .header-content {
 
 footer {
   background-color: #1F1F1F;
-  border-top: 1px solid #333;
   padding: 10px 0;
 }
 

+ 57 - 6
src/client/assets/themes/Matrix-SNH.css

@@ -1,9 +1,7 @@
 body {
-  background-color: #000000;
-  color: #00FF00;
+  background-color: #000000 !important;
+  color: #00FF00 !important;
   font-family: 'Courier New', monospace;
-  line-height: 1.5;
-  font-size: 16px;
 }
 
 header, footer {
@@ -11,7 +9,6 @@ header, footer {
   color: #00FF00;
   padding: 20px;
   text-align: center;
-  border-bottom: 2px solid #00FF00;
   font-size: 18px;
 }
 
@@ -92,7 +89,6 @@ nav ul {
   background-color: #000000;
   padding: 0;
   margin: 0;
-  border-bottom: 2px solid #00FF00;
 }
 
 nav ul li {
@@ -190,3 +186,58 @@ footer a {
   text-decoration: none;
 }
 
+.top-bar-left,
+.top-bar-mid,
+.top-bar-right {
+  background-color: #000000 !important;
+  border: 2px solid #00FF00 !important;
+  padding: 12px 16px;
+  box-shadow: 0 0 12px #00FF00;
+  border-radius: 8px;
+  display: flex;
+  gap: 12px;
+}
+
+.sidebar-left,
+.sidebar-right {
+  background-color: #000000 !important;
+  border: 2px solid #00FF00 !important;
+  box-shadow: 0 0 15px #00FF00;
+  padding: 16px;
+}
+
+.sidebar-left nav ul,
+.sidebar-right nav ul {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin: 0;
+  padding: 0;
+}
+
+.sidebar-left nav ul li,
+.sidebar-right nav ul li {
+  width: 100%;
+}
+
+.sidebar-left nav ul li a,
+.sidebar-right nav ul li a,
+.header nav ul li a {
+  background-color: #000000 !important;
+  color: #00FF00 !important;
+  border: 1px solid #00FF00 !important;
+  font-weight: bold;
+  border-radius: 6px;
+  padding: 10px 14px;
+  display: flex;
+  justify-content: flex-start;
+  box-shadow: 0 0 6px #00FF00;
+  transition: background-color 0.3s ease, color 0.3s ease;
+}
+
+.sidebar-left nav ul li a:hover,
+.sidebar-right nav ul li a:hover,
+.header nav ul li a:hover {
+  background-color: #00FF00 !important;
+  color: #000000 !important;
+}

+ 96 - 8
src/client/assets/themes/Purple-SNH.css

@@ -4,14 +4,6 @@ body {
   font-family: 'Arial', sans-serif;
 }
 
-header, footer {
-  background-color: #7A1F9C;
-  color: #E5E5E5;
-  padding: 20px;
-  text-align: center;
-  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
-}
-
 footer {
   border-top: 2px solid #9B1C96;
 }
@@ -188,3 +180,99 @@ footer a:hover {
   background-color: #6A0066;
 }
 
+
+.sidebar-left nav ul li a,
+.sidebar-right nav ul li a,
+.header nav ul li a {
+  background-color: #682B94 !important;
+  color: #FFE082 !important;
+  border: 1px solid #B86ADE !important;
+  font-weight: 600;
+  border-radius: 6px;
+  padding: 12px 16px;
+  display: flex;
+  justify-content: flex-start;
+  box-sizing: border-box;
+  transition: background-color 0.2s ease, border-color 0.2s ease;
+}
+
+.sidebar-left nav ul li a:hover,
+.sidebar-right nav ul li a:hover,
+.header nav ul li a:hover {
+  background-color: #682B94 !important;
+  border-color: #FFD54F !important;
+  color: #FFFFFF !important;
+}
+
+body {
+  background-color: #2D0B47 !important;
+}
+
+.main-column,
+article,
+section,
+.action-container,
+.profile,
+.post-preview {
+  background-color: #3C1360 !important;
+  border-color: #B86ADE !important;
+  color: #FFEEDB !important;
+}
+
+input,
+textarea,
+select {
+  background-color: #4B1A72 !important;
+  color: #FFFFFF !important;
+  border-color: #BB5EFF !important;
+}
+
+button,
+input[type="submit"],
+input[type="button"] {
+  background-color: #A34AD8 !important;
+  color: #FFFFFF !important;
+  border-color: #751E9F !important;
+}
+
+header {
+  background-color: #5A1A85 !important;
+  border-bottom: 1px solid #B86ADE !important;
+  box-shadow: none !important;
+}
+
+.header {
+  background-color: #5A1A85 !important;
+}
+
+.top-bar-left,
+.top-bar-mid,
+.top-bar-right {
+  background-color: #39006D !important;
+  border: 1px solid #9B1C96 !important;
+  padding: 15px;
+  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
+  display: flex;
+  gap: 12px;
+  border-radius: 10px;
+}
+
+.sidebar-left,
+.sidebar-right {
+  padding: 16px;
+  box-sizing: border-box;
+}
+
+.sidebar-left nav ul,
+.sidebar-right nav ul {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin: 0;
+  padding: 0;
+}
+
+.sidebar-left nav ul li,
+.sidebar-right nav ul li {
+  width: 100%;
+}

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

@@ -1,6 +1,6 @@
 const path = require('path');
 let i18n = {};
-const languages = ['en', 'es', 'fr']; // Add more language codes if needed
+const languages = ['en', 'es', 'eu']; // Add more language codes
 
 languages.forEach(language => {
   try {

File diff suppressed because it is too large
+ 1162 - 69
src/client/assets/translations/oasis_en.js


File diff suppressed because it is too large
+ 1276 - 178
src/client/assets/translations/oasis_es.js


File diff suppressed because it is too large
+ 1406 - 0
src/client/assets/translations/oasis_eu.js


+ 0 - 262
src/client/assets/translations/oasis_fr.js

@@ -1,262 +0,0 @@
-const { a, em, strong } = require('../../../server/node_modules/hyperaxe');
-module.exports = {
-  fr: {
-    languageName: "Français",
-  extended: "Multivers",
-  extendedDescription: [
-    "Lorsque vous soutenez quelqu'un, vous pouvez télécharger les publications des habitants qu'ils soutiennent, et ces publications apparaissent ici, triées par récence.",
-  ],
-  popular: "Points forts",
-  popularDescription: [
-    "Publications des habitants de votre réseau, ",
-    strong("triées par spreads"),
-    ". Sélectionnez la période de temps pour obtenir une liste.",
-  ],
-    day: "Jour",
-    week: "Semaine",
-    month: "Mois",
-    year: "Année",
-    latest: "Derniers",
-    latestDescription: [
-    strong("Publications"),
-    " de vous et des habitants que vous soutenez, triées par les plus récentes.",
-    ],
-    topics: "Sujets",
-    topicsDescription: [
-    strong("Sujets"),
-    " de vous et des habitants que vous soutenez, triés par les plus récents. Sélectionnez l'horodatage d'une publication pour voir le reste du fil.",
-    ],
-    summaries: "Résumés",
-    summariesDescription: [
-    strong("Sujets et quelques commentaires"),
-    " de vous et des habitants que vous soutenez, triés par les plus récents. Sélectionnez l'horodatage d'une publication pour voir le reste du fil.",
-    ],
-    threads: "Fils",
-    threadsDescription: [
-    strong("Publications avec commentaires"),
-    " des habitants que vous soutenez et de votre multivers, triées par les plus récentes. Sélectionnez l'horodatage d'une publication pour voir le reste du fil.",
-    ],
-    profile: "Avatar",
-    inhabitants: "Habitants",
-    manualMode: "Mode Manuel",
-    mentions: "Mentions",
-    mentionsDescription: [
-    strong("Publications qui vous @mentionnent"),
-    ", triées par les plus récentes.",
-    ],
-    private: "Boîte de réception",
-    peers: "Nœuds",
-    privateDescription: [
-    "Le dernier commentaire de ",
-    strong("fils privés qui vous incluent"),
-    ", triés par les plus récents. Les publications privées sont chiffrées pour votre clé publique et ont un maximum de 7 destinataires. Les destinataires ne peuvent pas être ajoutés après le début du fil. Sélectionnez l'horodatage pour voir le fil complet.",
-    ],
-    search: "Rechercher",
-    imageSearch: "Recherche d'images",
-    searchPlaceholder: "Recherchez des habitants, des #hashtags et des mots clés ...",
-    settings: "Paramètres",
-    continueReading: "continuer la lecture",
-    moreComments: "plus de commentaires",
-    readThread: "lire le reste du fil",
-    // modules
-    modules: "Modules",
-    modulesViewTitle: "Modules",
-    modulesViewDescription: "Configurez votre environnement en activant ou désactivant des modules.",
-    inbox: "Boîte de réception",
-    multiverse: "Multivers",
-    popularLabel: "⌘ En vedette",
-    topicsLabel: "ϟ Sujets",
-    latestLabel: "☄ Derniers",
-    summariesLabel: "※ Résumés",
-    threadsLabel: "♺ Fils",
-    multiverseLabel: "∞ Multivers",
-    inboxLabel: "☂ Boîte de réception",
-    invitesLabel: "ꔹ Invitations",
-    walletLabel: "❄ Portefeuille",
-    legacyLabel: "ꖤ Clés",
-    cipherLabel: "ꗄ Crypte",
-    saveSettings: "Enregistrer les paramètres",
-    // post actions
-    comment: "Commenter",
-    subtopic: "Sous-sujet",
-    json: "JSON",
-    // relationships
-    unfollow: "Ne plus soutenir",
-    follow: "Soutenir",
-    block: "Bloquer",
-    unblock: "Débloquer",
-    newerPosts: "Publications plus récentes",
-    olderPosts: "Publications plus anciennes",
-    feedRangeEmpty: "La plage donnée est vide pour ce flux. Essayez de voir le ",
-    seeFullFeed: "flux complet",
-    feedEmpty: "Le client local d'Oasis n'a pas encore accédé aux publications.",
-    beginningOfFeed: "Ceci est le début du flux",
-    noNewerPosts: "Aucune publication plus récente reçue.",
-    relationshipNotFollowing: "Ne vous soutient pas",
-    relationshipTheyFollow: "Ils soutiennent",
-    relationshipMutuals: "Soutien mutuel",
-    relationshipFollowing: "Vous soutenez",
-    relationshipYou: "Vous",
-    relationshipBlocking: "Vous bloquez",
-    relationshipNone: "Vous ne soutenez pas",
-    relationshipConflict: "En conflit",
-    relationshipBlockingPost: "Publication bloquée",
-    // spreads view
-    viewLikes: "Voir les soutiens",
-    spreadedDescription: "Liste des publications soutenues par l'habitant.",
-    likedBy: " -> Soutiens",
-    // composer
-    attachFiles: "Joindre des fichiers",
-    mentionsMatching: "Mentions correspondantes",
-    preview: "Aperçu",
-    publish: "Publier",
-    contentWarningPlaceholder: "Ajoutez un sujet à la publication (optionnel)",
-    privateWarningPlaceholder: "Ajoutez des habitants pour envoyer une publication privée (ex: @bob @alice) (optionnel)",
-    publishWarningPlaceholder: "...",
-    publishCustomDescription: [
-    "RAPPEL : En raison de la technologie blockchain, une fois publié, il ne peut être ni modifié ni supprimé.",
-    ],
-    commentWarning: [
-    "RAPPEL : En raison de la technologie blockchain, une fois publié, il ne peut être ni modifié ni supprimé.",
-    ],
-    commentPublic: "public",
-    commentPrivate: "privé",
-    // settings
-    updateit: "Obtenir les mises à jour",
-    info: "Informations",
-    settingsIntro: ({ version }) => [
-      `Oasis: [${version}]`,
-    ],
-    timeAgo: "",
-    sendTime: "environ ",
-    theme: "Thème",
-    legacy: "Clés",
-    legacyTitle: "Clés",
-    legacyDescription: "Gérez votre secret (clé privée) rapidement et en toute sécurité.",
-    legacyExportButton: "Exporter",
-    legacyImportButton: "Importer",
-    exportTitle: "Exporter les données",
-    exportDescription: "Définissez un mot de passe (minimum 32 caractères) pour chiffrer votre clé",
-    importTitle: "Importer des données",
-    importDescription: "Importez votre secret chiffré (clé privée) pour activer votre avatar",
-    importAttach: "Joindre un fichier chiffré (.enc)",
-    passwordLengthInfo: "Le mot de passe doit contenir au moins 32 caractères.",
-    passwordImport: "Saisissez votre mot de passe pour déchiffrer les données qui seront enregistrées dans le répertoire de votre système (nom : secret)",
-    exportPasswordPlaceholder: "Utilisez des minuscules, majuscules, chiffres et symboles",
-    fileInfo: "Votre clé secrète chiffrée sera enregistrée dans le répertoire de votre système (nom : oasis.enc)",
-    themeIntro: "Choisissez un thème.",
-    setTheme: "Définir le thème",
-    language: "Langue",
-    languageDescription: "Si vous souhaitez utiliser une autre langue, sélectionnez-la ici.",
-    setLanguage: "Définir la langue",
-    status: "Statut",
-    peerConnections: "Nœuds",
-    peerConnectionsIntro: "Gérez toutes vos connexions avec d'autres nœuds.",
-    online: "En ligne",
-    supported: "Soutenu",
-    recommended: "Recommandé",
-    blocked: "Bloqué",
-    noConnections: "Aucun habitant connecté.",
-    noSupportedConnections: "Aucun habitant soutenu.",
-    noBlockedConnections: "Aucun habitant bloqué.",
-    noRecommendedConnections: "Aucun habitant recommandé.",
-    startNetworking: "Commencer à se connecter",
-    stopNetworking: "Arrêter de se connecter",
-    restartNetworking: "Redémarrer la connexion",
-    sync: "Synchroniser le réseau",
-    indexes: "Index",
-    indexesDescription: "Reconstruire vos index est sûr et peut résoudre certains types d'erreurs.",
-    invites: "Invitations",
-    invitesDescription: "Utilisez les codes d'invitation du PUB ici.",
-    acceptInvite: "Accepter l'invitation",
-    acceptedInvites: "Réseaux fédérés",
-    noInvites: "Aucune invitation acceptée",
-    // misc
-    oasisDescription: "Réseau du Projet SNH",
-    submit: "Envoyer",
-    subjectLabel: "Sujet",
-    editProfile: "Modifier l'avatar",
-    editProfileDescription: "",
-    profileName: "Nom de l'avatar (texte brut)",
-    profileImage: "Image de l'avatar",
-    profileDescription: "Description de l'avatar (Markdown)",
-    hashtagDescription: "Publications des habitants de votre réseau mentionnant ce #hashtag, triées par les plus récentes.",
-    rebuildName: "Reconstruire la base de données",
-    wallet: "Portefeuille",
-    walletAddress: "Adresse",
-    walletAmount: "Montant",
-    walletAddressLine: ({ address }) => `Adresse : ${address}`,
-    walletAmountLine: ({ amount }) => `Montant : ${amount} ECO`,
-    walletBack: "Retour",
-    walletBalanceTitle: "Solde",
-    walletReceiveTitle: "Recevoir",
-    walletHistoryTitle: "Historique",
-    walletWalletSendTitle: "Envoyer",
-    walletBalanceLine: ({ balance }) => `${balance} ECO`,
-    walletCnfrs: "Cnfrs",
-    walletConfirm: "Confirmer",
-    walletDescription: "Gérez vos actifs numériques, y compris l'envoi et la réception d'ECOin, consultez votre solde et votre historique de transactions.",
-    walletDate: "Date",
-    walletFee: "Frais (Plus les frais sont élevés, plus votre transaction sera traitée rapidement)",
-    walletFeeLine: ({ fee }) => `Frais : ECO ${fee}`,
-    walletHistory: "Historique",
-    walletReceive: "Recevoir",
-    walletReset: "Réinitialiser",
-    walletSend: "Envoyer",
-    walletStatus: "Statut",
-    walletDisconnected: "Portefeuille ECOin déconnecté. Vérifiez la configuration de votre portefeuille ou l'état de la connexion.",
-    walletSentToLine: ({ destination, amount }) => `Envoyé ECO ${amount} à ${destination}`,
-    walletSettingsTitle: "Portefeuille",
-    walletSettingsDescription: "Intégrez Oasis à votre portefeuille ECOin.",
-    walletStatusMessages: {
-        invalid_amount: "Montant invalide",
-        invalid_dest: "Adresse de destination invalide",
-        invalid_fee: "Frais invalides",
-        validation_errors: "Erreurs de validation",
-        send_tx_success: "Transaction réussie",
-    },
-    walletTitle: "Portefeuille",
-    walletTotalCostLine: ({ totalCost }) => `Coût total : ECO ${totalCost}`,
-    walletTransactionId: "ID de transaction",
-    walletTxId: "ID Tx",
-    walletType: "Type",
-    walletUser: "Nom d'utilisateur",
-    walletPass: "Mot de passe",
-    walletConfiguration: "Configurer le portefeuille",
-    //cipher
-    cipher: "Chiffrement",
-    randomPassword: "Mot de passe aléatoire",
-    password: "Mot de passe",
-    text: "Texte",
-    encryptedText: "Texte chiffré",
-    iv: "Vecteur d'initialisation (IV)",
-    encryptTitle: "Chiffrez votre texte",
-    encryptDescription: "Entrez le texte que vous souhaitez chiffrer et fournissez un mot de passe.",
-    encryptButton: "Chiffrer",
-    decryptTitle: "Déchiffrez votre texte",
-    decryptDescription: "Entrez le texte chiffré et fournissez le même mot de passe utilisé pour le chiffrer.",
-    decryptButton: "Déchiffrer",
-    passwordLengthError: "Le mot de passe doit contenir au moins 32 caractères.",
-    missingFieldsError: "Texte, mot de passe ou IV non fournis.",
-    encryptionError: "Erreur lors du chiffrement du texte.",
-    decryptionError: "Erreur lors du déchiffrement du texte.",
-    //cipher
-    cipherTitle: "Chiffrement",
-    cipherDescription: "Chiffrez et déchiffrez du contenu de manière symétrique (en utilisant un mot de passe partagé).",
-    randomPassword: "Mot de passe aléatoire",
-    cipherEncryptTitle: "Chiffrer un texte",
-    cipherEncryptDescription: "Définissez un mot de passe (minimum 32 caractères) pour chiffrer votre texte",
-    cipherTextLabel: "Texte à chiffrer",
-    cipherTextPlaceholder: "Entrez le texte à chiffrer...",
-    cipherPasswordLabel: "Mot de passe",
-    cipherPasswordPlaceholder: "Entrez un mot de passe...",
-    cipherEncryptButton: "Chiffrer",
-    cipherDecryptTitle: "Déchiffrer un texte",
-    cipherDecryptDescription: "Entrez le texte chiffré, le mot de passe et l'IV pour le déchiffrer.",
-    cipherEncryptedTextLabel: "Texte chiffré",
-    cipherEncryptedTextPlaceholder: "Entrez le texte chiffré...",
-    cipherIvLabel: "IV",
-    cipherIvPlaceholder: "Entrez le vecteur d'initialisation...",
-    cipherDecryptButton: "Déchiffrer"
-    }
-};

+ 54 - 73
src/client/gui.js

@@ -1,13 +1,19 @@
-"use strict";
-
 const path = require('path');
 const fs = require('fs');
 const os = require('os');
-const ssbClient = require(path.join(__dirname, '../server/node_modules/ssb-client'));
-const ssbConfig = require(path.join(__dirname, '../server/node_modules/ssb-config'));
-const ssbKeys = require(path.join(__dirname, '../server/node_modules/ssb-keys'));
 const debug = require('../server/node_modules/debug')('oasis');
 const lodash = require('../server/node_modules/lodash');
+const ssbClient = require('../server/node_modules/ssb-client');
+const ssbConfig = require('../server/node_modules/ssb-config');
+const ssbKeys = require('../server/node_modules/ssb-keys');
+const { printMetadata } = require('../server/ssb_metadata');
+const updateFlagPath = path.join(__dirname, "../server/.update_required");
+
+let internalSSB = null;
+try {
+  const { server } = require('../server/SSB_server');
+  internalSSB = server;
+} catch {}
 
 if (process.env.OASIS_TEST) {
   ssbConfig.path = fs.mkdtempSync(path.join(os.tmpdir(), "oasis-"));
@@ -18,60 +24,33 @@ const socketPath = path.join(ssbConfig.path, "socket");
 const publicInteger = ssbConfig.keys.public.replace(".ed25519", "");
 const remote = `unix:${socketPath}~noauth:${publicInteger}`;
 
-const log = (formatter, ...args) => {
-  const isDebugEnabled = debug.enabled;
-  debug.enabled = true;
-  debug(formatter, ...args);
-  debug.enabled = isDebugEnabled;
-};
-
 const connect = (options) =>
   new Promise((resolve, reject) => {
-    const onSuccess = (ssb) => {
-      resolve(ssb);
-    };
     ssbClient(process.env.OASIS_TEST ? ssbConfig.keys : null, options)
-      .then(onSuccess)
+      .then(resolve)
       .catch(reject);
   });
 
 let closing = false;
-let serverHandle;
 let clientHandle;
 
-const attemptConnection = () =>
-  new Promise((resolve, reject) => {
-    const originalConnect = process.env.OASIS_TEST
-      ? new Promise((resolve, reject) =>
-          reject({
-            message: "could not connect to sbot",
-          })
-        )
-      : connect({ remote });
-    originalConnect
-      .then((ssb) => {
-        resolve(ssb);
-      })
-      .catch((e) => {
-        if (closing) return;
-        debug("Unix socket failed");
-        if (e.message !== "could not connect to sbot") {
-          throw e;
+const attemptConnectionWithBackoff = (attempt = 1) => {
+  const maxAttempts = 5;
+  const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
+
+  return new Promise((resolve, reject) => {
+    connect({ remote })
+      .then(resolve)
+      .catch((error) => {
+        if (attempt >= maxAttempts) {
+          return reject(new Error("Failed to connect after multiple attempts"));
         }
-        connect()
-          .then((ssb) => {
-            resolve(ssb);
-          })
-          .catch((e) => {
-            if (closing) return;
-            debug("TCP socket failed");
-            if (e.message !== "could not connect to sbot") {
-              throw e;
-            }
-            reject(new Error("Both connection options failed"));
-          });
+        setTimeout(() => {
+          attemptConnectionWithBackoff(attempt + 1).then(resolve).catch(reject);
+        }, delay);
       });
   });
+};
 
 let pendingConnection = null;
 
@@ -79,11 +58,12 @@ const ensureConnection = (customConfig) => {
   if (pendingConnection === null) {
     pendingConnection = new Promise((resolve) => {
       setTimeout(() => {
-      attemptConnection()
-        .then((ssb) => {
-          resolve(ssb);
-        })
-        });
+        attemptConnectionWithBackoff()
+          .then(resolve)
+          .catch(() => {
+            resolve(null);
+          });
+      });
     });
 
     const cancel = () => (pendingConnection = null);
@@ -94,40 +74,44 @@ const ensureConnection = (customConfig) => {
 };
 
 module.exports = ({ offline }) => {
-  if (offline) {
-    log("Offline mode activated - not connecting to scuttlebutt peers or pubs");
-  }
-
   const customConfig = JSON.parse(JSON.stringify(ssbConfig));
-
   if (offline === true) {
     lodash.set(customConfig, "conn.autostart", false);
   }
-
   lodash.set(
     customConfig,
     "conn.hops",
-    lodash.get(ssbConfig, "conn.hops", lodash.get(ssbConfig.friends.hops, 0))
+    lodash.get(ssbConfig, "conn.hops", lodash.get(ssbConfig.friends, "hops", 0))
   );
 
   const cooler = {
     open() {
       return new Promise((resolve, reject) => {
+        if (internalSSB) {
+          const { printMetadata, colors } = require('../server/ssb_metadata');
+          printMetadata('OASIS GUI running at: http://localhost:3000', colors.yellow);
+          return resolve(internalSSB);
+        }
+
         if (clientHandle && clientHandle.closed === false) {
-          resolve(clientHandle);
-        } else {
-          ensureConnection(customConfig).then((ssb) => {
-            clientHandle = ssb;
-            if (closing) {
-              cooler.close();
-              reject(new Error("Closing Oasis"));
-            } else {
-              resolve(ssb);
-            }
-          });
+          return resolve(clientHandle);
         }
+
+        ensureConnection(customConfig).then((ssb) => {
+          if (!ssb) return reject(new Error("No SSB server available"));
+          clientHandle = ssb;
+          if (closing) {
+            cooler.close();
+            reject(new Error("Closing Oasis"));
+          } else {
+            const { printMetadata, colors } = require('../server/ssb_metadata');
+            printMetadata('OASIS GUI running at: http://localhost:3000', colors.yellow);
+            resolve(ssb);
+          }
+        }).catch(reject);
       });
     },
+
     close() {
       closing = true;
       if (clientHandle && clientHandle.closed === false) {
@@ -136,8 +120,5 @@ module.exports = ({ offline }) => {
     },
   };
 
-  cooler.open();
-
   return cooler;
 };
-

+ 10 - 7
src/client/middleware.js

@@ -13,29 +13,23 @@ module.exports = ({ host, port, middleware, allowHost }) => {
 
   const isValidRequest = (request) => {
     if (validHosts.includes(request.hostname) !== true) {
-      console.log(`Invalid HTTP hostname: ${request.hostname}`);
       return false;
     }
-
     if (request.method !== "GET") {
       if (request.header.referer == null) {
-        console.log("No referer");
         return false;
       }
 
       try {
         const refererUrl = new URL(request.header.referer);
         if (validHosts.includes(refererUrl.hostname) !== true) {
-          console.log(`Invalid referer hostname: ${refererUrl.hostname}`);
           return false;
         }
 
         if (refererUrl.pathname.startsWith("/blob/")) {
-          console.log(`Invalid referer path: ${refererUrl.pathname}`);
           return false;
         }
       } catch (e) {
-        console.log(`Invalid referer URL: ${request.header.referer}`);
         return false;
       }
     }
@@ -55,17 +49,22 @@ module.exports = ({ host, port, middleware, allowHost }) => {
   });
 
   app.use(mount("/assets", assets));
+  
+  // pdf viewer
+  app.use(mount("/js", koaStatic(path.join(__dirname, 'public/js'))));
+  app.use(koaStatic(path.join(__dirname, 'public')));
 
   app.use(async (ctx, next) => {
   
     //console.log("Requesting:", ctx.path); // uncomment to check for HTTP requests
     
     const csp = [
-      "default-src 'none'",
+      "default-src 'self' blob:", 
       "img-src 'self'",
       "form-action 'self'",
       "media-src 'self'",
       "style-src 'self'",
+      "script-src 'self' http://localhost:3000/js",  // pdfviewer
     ].join("; ");
 
     ctx.set("Content-Security-Policy", csp);
@@ -90,6 +89,10 @@ module.exports = ({ host, port, middleware, allowHost }) => {
 
     await next();
   });
+  
+  // pdf viewer
+  const pdfjsPath = path.join(__dirname, '../server/node_modules/pdfjs-dist/build/pdf.min.js');
+  app.use(koaStatic(pdfjsPath));
 
   middleware.forEach((m) => app.use(m));
 

+ 76 - 0
src/client/public/js/pdf-viewer.js

@@ -0,0 +1,76 @@
+document.addEventListener('DOMContentLoaded', () => {
+  if (typeof pdfjsLib === 'undefined') return;
+  pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf.worker.min.mjs';
+
+  document.querySelectorAll('.pdf-viewer-container').forEach(async container => {
+    const pdfUrl = container.getAttribute('data-pdf-url');
+    if (!pdfUrl) return;
+
+    const pdf = await pdfjsLib.getDocument(pdfUrl).promise;
+    let currentPage = 1;
+    let scale = 1.5;
+    let rotation = 0;
+
+    const canvas = document.createElement('canvas');
+    const ctx = canvas.getContext('2d');
+    container.innerHTML = '';
+    container.appendChild(canvas);
+
+    const controls = document.createElement('div');
+    controls.className = 'pdf-controls';
+    controls.innerHTML = `
+      <button id="prev">⬅️</button>
+      <button id="next">➡️</button>
+      <button id="zoomIn">🔍+</button>
+      <button id="zoomOut">🔍−</button>
+      <button id="rotate">↻</button>
+      <button id="download">⬇️</button>
+      <button id="fullscreen">🔲</button>
+      <button id="metadata">ℹ️</button>
+    `;
+
+    container.appendChild(controls);
+
+    const renderPage = async (num) => {
+      const page = await pdf.getPage(num);
+      const viewport = page.getViewport({ scale, rotation });
+      canvas.width = viewport.width;
+      canvas.height = viewport.height;
+      ctx.clearRect(0, 0, canvas.width, canvas.height);
+      await page.render({ canvasContext: ctx, viewport }).promise;
+    };
+
+    const goToPage = (delta) => {
+      const newPage = currentPage + delta;
+      if (newPage >= 1 && newPage <= pdf.numPages) {
+        currentPage = newPage;
+        renderPage(currentPage);
+      }
+    };
+
+    renderPage(currentPage);
+
+    controls.querySelector('#prev').onclick = () => goToPage(-1);
+    controls.querySelector('#next').onclick = () => goToPage(1);
+    controls.querySelector('#zoomIn').onclick = () => { scale += 0.2; renderPage(currentPage); };
+    controls.querySelector('#zoomOut').onclick = () => { scale = Math.max(0.5, scale - 0.2); renderPage(currentPage); };
+    controls.querySelector('#rotate').onclick = () => { rotation = (rotation + 90) % 360; renderPage(currentPage); };
+    controls.querySelector('#download').onclick = () => {
+      const a = document.createElement('a');
+      a.href = pdfUrl;
+      a.download = 'document.pdf';
+      a.click();
+    };
+    controls.querySelector('#fullscreen').onclick = () => {
+      if (canvas.requestFullscreen) canvas.requestFullscreen();
+      else if (canvas.webkitRequestFullscreen) canvas.webkitRequestFullscreen();
+      else if (canvas.mozRequestFullScreen) canvas.mozRequestFullScreen();
+      else if (canvas.msRequestFullscreen) canvas.msRequestFullscreen();
+    };
+    controls.querySelector('#metadata').onclick = async () => {
+      const info = await pdf.getMetadata();
+      alert(`Title: ${info.info.Title || 'N/A'}\nAuthor: ${info.info.Author || 'N/A'}\nPDF Producer: ${info.info.Producer || 'N/A'}`);
+    };
+  });
+});
+

File diff suppressed because it is too large
+ 22 - 0
src/client/public/js/pdf.min.mjs


File diff suppressed because it is too large
+ 22 - 0
src/client/public/js/pdf.worker.min.mjs


+ 37 - 9
src/configs/config-manager.js

@@ -5,15 +5,44 @@ const configFilePath = path.join(__dirname, 'oasis-config.json');
 
 if (!fs.existsSync(configFilePath)) {
   const defaultConfig = {
-    modules: {
-      invitesMod: 'on',
-      walletMod: 'on',
+    "themes": {
+      "current": "Dark-SNH"
     },
-    wallet: {
-      url: 'http://localhost:7474',
-      user: 'ecoinrpc',
-      pass: 'ecoinrpc',
-      fee: 0.01,
+    "modules": {
+      "popularMod": "on",
+      "topicsMod": "on",
+      "summariesMod": "on",
+      "latestMod": "on",
+      "threadsMod": "on",
+      "multiverseMod": "on",
+      "invitesMod": "on",
+      "walletMod": "on",
+      "legacyMod": "on",
+      "cipherMod": "on",
+      "bookmarksMod": "on",
+      "videosMod": "on",
+      "docsMod": "on",
+      "audiosMod": "on",
+      "tagsMod": "on",
+      "imagesMod": "on",
+      "trendingMod": "on",
+      "eventsMod": "on",
+      "tasksMod": "on",
+      "marketMod": "on",
+      "tribesMod": "on",
+      "governanceMod": "on",
+      "reportsMod": "on",
+      "opinionsMod": "on",
+      "transfersMod": "on",
+      "feedMod": "on",
+      "pixeliaMod": "on",
+      "agendaMod": "on"
+    },
+    "wallet": {
+      "url": "http://localhost:7474",
+      "user": "ecoinrpc",
+      "pass": "",
+      "fee": "1"
     }
   };
   fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
@@ -32,4 +61,3 @@ module.exports = {
   getConfig,
   saveConfig,
 };
-

+ 21 - 4
src/configs/oasis-config.json

@@ -9,16 +9,33 @@
     "latestMod": "on",
     "threadsMod": "on",
     "multiverseMod": "on",
-    "inboxMod": "on",
     "invitesMod": "on",
     "walletMod": "on",
     "legacyMod": "on",
-    "cipherMod": "on"
+    "cipherMod": "on",
+    "bookmarksMod": "on",
+    "videosMod": "on",
+    "docsMod": "on",
+    "audiosMod": "on",
+    "tagsMod": "on",
+    "imagesMod": "on",
+    "trendingMod": "on",
+    "eventsMod": "on",
+    "tasksMod": "on",
+    "marketMod": "on",
+    "governanceMod": "on",
+    "tribesMod": "on",
+    "reportsMod": "on",
+    "opinionsMod": "on",
+    "transfersMod": "on",
+    "feedMod": "on",
+    "pixeliaMod": "on",
+    "agendaMod": "on"
   },
   "wallet": {
     "url": "http://localhost:7474",
     "user": "ecoinrpc",
-    "pass": "ecoinrpc",
-    "fee": "0.01"
+    "pass": "",
+    "fee": "1"
   }
 }

+ 33 - 27
src/configs/server-config.json

@@ -1,14 +1,23 @@
 {
-  "replicationScheduler": {
-    "autostart": true,
-    "partialReplication": null
+  "logging": {
+    "level": "notice"
   },
-  "pub": true,
+  "caps": {
+    "shs": "+u5/ShHkb5g8jIWmybt/8ulGbZ2jFfzp8ggMwmKcRF0="
+  },
+  "pub": false,
   "local": true,
   "friends": {
     "dunbar": 300,
     "hops": 2
   },
+  "autofollow": {
+    "legacy": false,
+    "enabled": false,
+    "feeds": [
+      "@HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519"
+    ]
+  },
   "gossip": {
     "connections": 20,
     "local": true,
@@ -16,57 +25,54 @@
     "seed": true,
     "global": true
   },
+  "replicationScheduler": {
+    "autostart": true,
+    "partialReplication": null
+  },
   "connections": {
+    "seeds": [
+      "solarnethub.com:8008~shs:HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519"
+    ],
     "incoming": {
       "net": [
-        {
-          "scope": "public",
-          "transform": "shs",
-          "port": 8008
-        },
         {
           "scope": "device",
           "transform": "shs",
           "port": 8008
         }
       ],
-      "tunnel": [
+      "unix": [
         {
-          "scope": "public",
-          "portal": "@1wOEiCjJJ0nEs1OABUIV20valZ1LHUsfHJY/ivBoM8Y=.ed25519",
-          "transform": "shs"
+          "scope": ["device", "local", "private"],
+          "transform": "noauth"
         }
-      ],
-      "onion": [
+      ]
+    },
+    "outgoing": {
+      "net": [
         {
-          "scope": "public",
           "transform": "shs"
         }
       ],
-      "ws": [
+      "tunnel": [
         {
           "scope": "public",
-          "transform": "shs"
-        }
-      ]
-    },
-    "outgoing": {
-      "net": [
-        {
+          "portal": "@HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519",
           "transform": "shs"
         }
       ],
-      "ws": [
+      "onion": [
         {
+          "scope": "public",
           "transform": "shs"
         }
       ],
-      "tunnel": [
+      "ws": [
         {
+          "scope": "public",
           "transform": "shs"
         }
       ]
     }
   }
 }
-

+ 64 - 0
src/models/activity_model.js

@@ -0,0 +1,64 @@
+const pull = require('../server/node_modules/pull-stream')
+
+module.exports = ({ cooler }) => {
+  let ssb
+
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open()
+    return ssb
+  }
+
+  return {
+    async listFeed(filter = 'all') {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+
+      const results = await new Promise((resolve, reject) => {
+        pull(
+          ssbClient.createLogStream({ reverse: true, limit: 1000 }),
+          pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+        )
+      })
+
+      const tombstoned = new Set()
+      const replaces = new Map()
+      const latest = new Map()
+
+      for (const msg of results) {
+        const k = msg.key
+        const c = msg.value?.content
+        const author = msg.value?.author
+        if (!c?.type) continue
+        if (c.type === 'tombstone' && c.target) {
+          tombstoned.add(c.target)
+          continue
+        }
+        if (c.replaces) replaces.set(c.replaces, k)
+        latest.set(k, {
+          id: k,
+          author,
+          ts: msg.value.timestamp,
+          type: c.type,
+          content: c
+        })
+      }
+
+      for (const oldId of replaces.keys()) {
+        latest.delete(oldId)
+      }
+
+      for (const t of tombstoned) {
+        latest.delete(t)
+      }
+
+      let actions = Array.from(latest.values())
+
+      if (filter === 'mine') {
+        actions = actions.filter(a => a.author === userId)
+      }
+
+      return actions
+    }
+  }
+}
+

+ 97 - 0
src/models/agenda_model.js

@@ -0,0 +1,97 @@
+const pull = require('../server/node_modules/pull-stream');
+const moment = require('../server/node_modules/moment');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
+
+  const fetchItems = (targetType, filterFn) =>
+    new Promise((resolve, reject) => {
+      openSsb()
+        .then((ssbClient) => {
+          const userId = ssbClient.id;
+          pull(
+            ssbClient.createLogStream(),
+            pull.collect((err, msgs) => {
+              if (err) return reject(err);
+
+              const tombstoned = new Set();
+              const replacesMap = new Map();
+              const latestMap = new Map();
+
+              for (const msg of msgs) {
+                const c = msg.value?.content;
+                const k = msg.key;
+                if (!c) continue;
+
+                if (c.type === 'tombstone' && c.target) {
+                  tombstoned.add(c.target);
+                } else if (c.type === targetType) {
+                  if (c.replaces) replacesMap.set(c.replaces, k);
+                  latestMap.set(k, { key: k, value: msg.value });
+                }
+              }
+
+              for (const [oldId, newId] of replacesMap.entries()) {
+                latestMap.delete(oldId);
+              }
+
+              const results = Array.from(latestMap.values()).filter(
+                (msg) => !tombstoned.has(msg.key) && filterFn(msg.value.content, userId)
+              );
+
+              resolve(results.map(item => ({ ...item.value.content, id: item.key })));
+            })
+          );
+        })
+        .catch(reject);
+    });
+
+  return {
+    async listAgenda(filter = 'all') {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+
+      const [tasks, events, transfers, tribes, marketItems, reports] = await Promise.all([
+        fetchItems('task', (c, id) => Array.isArray(c.assignees) && c.assignees.includes(id)),
+        fetchItems('event', (c, id) => Array.isArray(c.attendees) && c.attendees.includes(id)),
+        fetchItems('transfer', (c, id) => c.from === id || c.to === id),
+        fetchItems('tribe', (c, id) => Array.isArray(c.members) && c.members.includes(id)),
+        fetchItems('market', (c, id) => c.seller === id || (Array.isArray(c.auctions_poll) && c.auctions_poll.some(b => b.split(':')[0] === id))),
+        fetchItems('report', (c, id) => c.author === id || (Array.isArray(c.confirmations) && c.confirmations.includes(id)))
+      ]);
+
+      let combined = [
+        ...tasks,
+        ...events,
+        ...transfers,
+        ...tribes.map(t => ({ ...t, type: 'tribe', title: t.title })),
+        ...marketItems.map(m => ({ ...m, type: 'market' })),
+        ...reports.map(r => ({ ...r, type: 'report' }))
+      ];
+
+      if (filter === 'tasks') combined = tasks;
+      else if (filter === 'events') combined = events;
+      else if (filter === 'transfers') combined = transfers;
+      else if (filter === 'tribes') combined = tribes.map(t => ({ ...t, type: 'tribe', title: t.name }));
+      else if (filter === 'market') combined = marketItems;
+      else if (filter === 'reports') combined = reports;
+      else if (filter === 'open') combined = combined.filter(i => i.status === 'OPEN');
+      else if (filter === 'closed') combined = combined.filter(i => i.status === 'CLOSED');
+
+      combined = Array.from(new Map(combined.map(i => [i.id, i])).values());
+
+      combined.sort((a, b) => {
+        const dateA = a.startTime || a.date || a.deadline || a.createdAt;
+        const dateB = b.startTime || b.date || b.deadline || b.createdAt;
+        return new Date(dateA) - new Date(dateB);
+      });
+
+      return combined;
+    }
+  };
+};

+ 181 - 0
src/models/audios_model.js

@@ -0,0 +1,181 @@
+const pull = require('../server/node_modules/pull-stream')
+
+module.exports = ({ cooler }) => {
+  let ssb
+  let userId
+
+  const openSsb = async () => {
+    if (!ssb) {
+      ssb = await cooler.open()
+      userId = ssb.id
+    }
+    return ssb
+  }
+
+  return {
+    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 content = {
+        type: 'audio',
+        url: blobId,
+        createdAt: new Date().toISOString(),
+        author: userId,
+        tags,
+        title: title || '',
+        description: description || '',
+        opinions: {},
+        opinions_inhabitants: []
+      }
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res))
+      })
+    },
+
+    async updateAudioById(id, blobMarkdown, tagsRaw, title, description) {
+      const ssbClient = await openSsb()
+      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))
+        })
+      })
+    },
+
+    async deleteAudioById(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'))
+          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))
+        })
+      })
+    },
+
+    async listAll(filter = 'all') {
+      const ssbClient = await openSsb()
+      const messages = await new Promise((res, rej) => {
+        pull(
+          ssbClient.createLogStream(),
+          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 || []
+        })
+      }
+      for (const oldId of replaces.keys()) {
+        latest.delete(oldId)
+      }
+
+      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
+        })
+      } else {
+        audios = audios.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+      }
+
+      return audios
+    },
+
+    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 createOpinion(id, category) {
+      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'))
+          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))
+        })
+      })
+    }
+  }
+}
+

+ 196 - 0
src/models/bookmarking_model.js

@@ -0,0 +1,196 @@
+const pull = require('../server/node_modules/pull-stream')
+const moment = require('../server/node_modules/moment')
+
+module.exports = ({ cooler }) => {
+  let ssb
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
+
+  return {
+    type: 'bookmark',
+
+    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 content = {
+        type: 'bookmark',
+        author: userId,
+        url,
+        tags,
+        description,
+        category,
+        createdAt: new Date().toISOString(),
+        updatedAt: new Date().toISOString(),
+        lastVisit: formattedLastVisit,
+        opinions: {},
+        opinions_inhabitants: []
+      }
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, res) => err ? reject(new Error("Error creating bookmark: " + err.message)) : 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(),
+          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(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.')
+      }
+      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')
+      }
+      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
+      }
+      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
+      }
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(tombstone, (err2, res) => {
+          if (err2) return reject(new Error("Error creating tombstone."))
+          resolve(res)
+        })
+      })
+    },
+
+    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 createOpinion(bookmarkId, category) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      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))
+        })
+      })
+    }
+  }
+}
+

+ 51 - 0
src/models/cipher_model.js

@@ -0,0 +1,51 @@
+const crypto = require('crypto');
+
+function generateKeyFromPassword(password, salt) {
+  return crypto.scryptSync(password, salt, 32);
+}
+
+function encryptText(text, password) {
+  const salt = crypto.randomBytes(16);
+  const iv = crypto.randomBytes(12);
+  const key = generateKeyFromPassword(password, salt);
+  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
+  let encryptedText = cipher.update(text, 'utf-8', 'hex');
+  encryptedText += cipher.final('hex');
+  const authTag = cipher.getAuthTag().toString('hex');
+  const ivHex = iv.toString('hex');
+  const saltHex = salt.toString('hex');
+  return { encryptedText, iv: ivHex, salt: saltHex, authTag };
+}
+
+function decryptText(encryptedText, password, ivHex, saltHex, authTagHex) {
+  const salt = Buffer.from(saltHex, 'hex');
+  const iv = Buffer.from(ivHex, 'hex');
+  const authTag = Buffer.from(authTagHex, 'hex');
+  const key = generateKeyFromPassword(password, salt);
+  const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
+  decipher.setAuthTag(authTag);
+  let decryptedText = decipher.update(encryptedText, 'hex', 'utf-8');
+  decryptedText += decipher.final('utf-8');
+  return decryptedText;
+}
+
+function extractComponents(encryptedText) {
+  const iv = encryptedText.slice(0, 24);
+  const salt = encryptedText.slice(24, 56);
+  const authTag = encryptedText.slice(56, 88);
+  const encrypted = encryptedText.slice(88);
+  return { iv, salt, authTag, encrypted };
+}
+
+module.exports = {
+  encryptData: (text, password) => {
+      const { encryptedText, iv, salt, authTag } = encryptText(text, password);
+      return { encryptedText: iv + salt + authTag + encryptedText, iv, salt, authTag };
+  },
+  decryptData: (encryptedText, password) => {
+      const { iv, salt, authTag, encrypted } = extractComponents(encryptedText);
+      const decryptedText = decryptText(encrypted, password, iv, salt, authTag);
+      return decryptedText;
+  },
+  extractComponents
+};

+ 148 - 0
src/models/cv_model.js

@@ -0,0 +1,148 @@
+const pull = require('../server/node_modules/pull-stream');
+
+const extractBlobId = str => {
+  if (!str || typeof str !== 'string') return null;
+  const match = str.match(/\(([^)]+\.sha256)\)/);
+  return match ? match[1] : str.trim();
+};
+
+const parseCSV = str => str ? str.split(',').map(s => s.trim()).filter(Boolean) : [];
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+
+  return {
+    type: 'curriculum',
+
+    async createCV(data, photoBlobId) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const content = {
+        type: 'curriculum',
+        author: userId,
+        name: data.name,
+        description: data.description,
+        photo: extractBlobId(photoBlobId) || null,
+        contact: userId,
+        personalSkills: parseCSV(data.personalSkills),
+        personalExperiences: data.personalExperiences || '',
+        oasisExperiences: data.oasisExperiences || '',
+        oasisSkills: parseCSV(data.oasisSkills),
+        educationExperiences: data.educationExperiences || '',
+        educationalSkills: parseCSV(data.educationalSkills),
+        languages: data.languages || '',
+        professionalExperiences: data.professionalExperiences || '',
+        professionalSkills: parseCSV(data.professionalSkills),
+        location: data.location || 'UNKNOWN',
+        status: data.status || 'LOOKING FOR WORK',
+        preferences: data.preferences || 'REMOTE WORKING',
+        createdAt: new Date().toISOString()
+      };
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
+      });
+    },
+
+    async updateCV(id, data, photoBlobId) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const old = await new Promise((res, rej) =>
+        ssbClient.get(id, (err, msg) =>
+          err || !msg?.content ? rej(err || new Error('CV not found')) : res(msg)
+        )
+      );
+      if (old.content.author !== userId) throw new Error('Not the author');
+
+      const tombstone = {
+        type: 'tombstone',
+        id,
+        deletedAt: new Date().toISOString()
+      };
+
+      await new Promise((res, rej) =>
+        ssbClient.publish(tombstone, err => err ? rej(err) : res())
+      );
+
+      const content = {
+        type: 'curriculum',
+        author: userId,
+        name: data.name,
+        description: data.description,
+        photo: extractBlobId(photoBlobId) || null,
+        contact: userId,
+        personalSkills: parseCSV(data.personalSkills),
+        personalExperiences: data.personalExperiences || '',
+        oasisExperiences: data.oasisExperiences || '',
+        oasisSkills: parseCSV(data.oasisSkills),
+        educationExperiences: data.educationExperiences || '',
+        educationalSkills: parseCSV(data.educationalSkills),
+        languages: data.languages || '',
+        professionalExperiences: data.professionalExperiences || '',
+        professionalSkills: parseCSV(data.professionalSkills),
+        location: data.location || 'UNKNOWN',
+        status: data.status || 'LOOKING FOR WORK',
+        preferences: data.preferences || 'REMOTE WORKING',
+        createdAt: old.content.createdAt,
+        updatedAt: new Date().toISOString()
+      };
+
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
+      });
+    },
+
+    async deleteCVById(id) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const msg = await new Promise((res, rej) =>
+        ssbClient.get(id, (err, msg) =>
+          err || !msg?.content ? rej(new Error('CV not found')) : res(msg)
+        )
+      );
+      if (msg.content.author !== userId) throw new Error('Not the author');
+      const tombstone = {
+        type: 'tombstone',
+        id,
+        deletedAt: new Date().toISOString()
+      };
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(tombstone, (err, result) => err ? reject(err) : resolve(result));
+      });
+    },
+
+    async getCVByUserId(targetUserId) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const authorId = targetUserId || userId;
+      return new Promise((resolve, reject) => {
+        pull(
+          ssbClient.createLogStream(),
+          pull.collect((err, msgs) => {
+            if (err) return reject(err);
+
+            const tombstoned = new Set(
+              msgs
+                .filter(m => m.value?.content?.type === 'tombstone' && m.value?.content?.id)
+                .map(m => m.value.content.id)
+            );
+
+            const cvMsgs = msgs
+              .filter(m =>
+                m.value?.content?.type === 'curriculum' &&
+                m.value?.content?.author === authorId &&
+                !tombstoned.has(m.key)
+              )
+              .sort((a, b) => b.value.timestamp - a.value.timestamp);
+
+            if (!cvMsgs.length) return resolve(null);
+
+            const latest = cvMsgs[0];
+            resolve({ id: latest.key, ...latest.value.content });
+          })
+        );
+      });
+    }
+  };
+};
+

+ 179 - 0
src/models/documents_model.js

@@ -0,0 +1,179 @@
+const pull = require('../server/node_modules/pull-stream')
+
+const extractBlobId = str => {
+  if (!str || typeof str !== 'string') return null
+  const match = str.match(/\(([^)]+\.sha256)\)/)
+  return match ? match[1] : str.trim()
+}
+
+const parseCSV = str => str ? str.split(',').map(s => s.trim()).filter(Boolean) : []
+
+module.exports = ({ cooler }) => {
+  let ssb
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
+
+  return {
+    type: 'document',
+
+    async createDocument(blobMarkdown, tagsRaw, title, description) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const blobId = extractBlobId(blobMarkdown)
+      const tags = parseCSV(tagsRaw)
+      const content = {
+        type: 'document',
+        url: blobId,
+        createdAt: new Date().toISOString(),
+        author: userId,
+        tags,
+        title: title || '',
+        description: description || '',
+        opinions: {},
+        opinions_inhabitants: []
+      }
+      return new Promise((resolve, reject) => {
+        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
+      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))
+        })
+      })
+    },
+
+    async deleteDocumentById(id) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.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.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))
+        })
+      })
+    },
+
+    async listAll(filter = 'all') {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const messages = await new Promise((res, rej) => {
+        pull(
+          ssbClient.createLogStream(),
+          pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+        )
+      })
+
+      const tombstoned = new Set(
+        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 !== '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 || []
+        })
+      }
+
+      for (const oldId of replaces.keys()) {
+        latest.delete(oldId)
+      }
+
+      let documents = Array.from(latest.values())
+
+      if (filter === 'mine') {
+        documents = documents.filter(d => d.author === userId)
+      } else {
+        documents = documents.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+      }
+
+      return documents
+    },
+
+    async getDocumentById(id) {
+      const ssbClient = await openSsb()
+      return new Promise((resolve, reject) => {
+        ssbClient.get(id, (err, msg) => {
+          if (err || !msg || msg.content?.type !== 'document') return reject(new Error('Document 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 createOpinion(id, category) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.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'))
+          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))
+        })
+      })
+    }
+  }
+}
+

+ 274 - 0
src/models/events_model.js

@@ -0,0 +1,274 @@
+const pull = require('../server/node_modules/pull-stream');
+const moment = require('../server/node_modules/moment');
+const { config } = require('../server/SSB_server.js');
+
+const userId = config.keys.id;
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+
+  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 content = {
+        type: 'event',
+        title,
+        description,
+        date: formattedDate,
+        location,
+        price: p.toFixed(6),
+        url,
+        attendees,
+        tags,
+        createdAt: new Date().toISOString(),
+        organizer: userId,
+        status: 'OPEN',
+        opinions: {},
+        opinions_inhabitants: [],
+        isPublic
+      };
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res));
+      });
+    },
+
+    async toggleAttendee(eventId) {
+      const ssbClient = await openSsb();
+      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));
+          });
+        });
+      });
+    },
+
+    async deleteEventById(eventId) {
+      const ssbClient = await openSsb();
+      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));
+        });
+      });
+    },
+
+    async listAll(author = null, filter = 'all') {
+      const ssbClient = await openSsb();
+      return new Promise((resolve, reject) => {
+        pull(
+          ssbClient.createLogStream(),
+          pull.collect(async (err, results) => {
+            if (err) return reject(new Error("Error listing events: " + err.message));
+            const tombstoned = new Set();
+            const replaces = new Map();
+            const byId = new Map();
+            for (const r of results) {
+              const k = r.key;
+              const c = r.value.content;
+              if (!c) continue;
+              if (c.type === 'tombstone' && c.target) {
+                tombstoned.add(c.target);
+                continue;
+              }
+              if (c.type === 'event') {
+                if (tombstoned.has(k)) continue;
+                if (c.replaces) replaces.set(c.replaces, k);
+                if (author && c.organizer !== author) continue;
+                let status = c.status || 'OPEN';
+                const dateM = moment(c.date);
+                if (dateM.isValid() && dateM.isBefore(moment()) && status !== 'CLOSED') {
+                  const tombstone = {
+                    type: 'tombstone',
+                    target: k,
+                    deletedAt: new Date().toISOString(),
+                    author: c.organizer
+                  };
+                  const updated = {
+                    ...c,
+                    status: 'CLOSED',
+                    updatedAt: new Date().toISOString(),
+                    replaces: k
+                  };
+                  await new Promise((res, rej) => ssbClient.publish(tombstone, err => err ? rej(err) : res()));
+                  await new Promise((res, rej) => ssbClient.publish(updated, err => err ? rej(err) : res()));
+                  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,
+                  opinions: c.opinions || {},
+                  opinions_inhabitants: c.opinions_inhabitants || [],
+                  isPublic: c.isPublic
+                });
+              }
+            }
+            for (const replaced of replaces.keys()) {
+              byId.delete(replaced);
+            }
+            let out = Array.from(byId.values());
+            if (filter === 'mine') out = out.filter(e => e.organizer === userId);
+            if (['features', 'bugs', 'abuse', 'content'].includes(filter)) out = out.filter(e => e.category === filter);
+            if (filter === 'confirmed') out = out.filter(e => e.confirmations?.length >= 3);
+            if (['open', 'resolved', 'invalid', 'underreview'].includes(filter)) out = out.filter(e => e.status.toLowerCase() === filter);
+            resolve(out);
+          })
+        );
+      });
+    },
+
+    async updateEventById(eventId, updatedData) {
+      const ssbClient = await openSsb();
+      return new Promise((resolve, reject) => {
+        ssbClient.get(eventId, (err, ev) => {
+          if (err || !ev || !ev.content) return reject(new Error("Error retrieving event"));
+          if (Object.keys(ev.content.opinions || {}).length > 0) return reject(new Error('Cannot edit event after it has received opinions'));
+          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));
+          });
+        });
+      });
+    },
+
+    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(),
+            opinions: c.opinions || {},
+            opinions_inhabitants: c.opinions_inhabitants || [],
+            organizer: c.organizer || '',
+            status,
+            isPublic: c.isPublic || 'private'
+          });
+        });
+      });
+    },
+
+    async createOpinion(id, category) {
+      const ssbClient = await openSsb();
+      return new Promise((resolve, reject) => {
+        ssbClient.get(id, (err, msg) => {
+          if (err || !msg || msg.content?.type !== 'event') return reject(new Error('Event not found'));
+          if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'));
+          const tombstone = {
+            type: 'tombstone',
+            target: id,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          };
+          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: id
+          };
+          ssbClient.publish(tombstone, err => {
+            if (err) return reject(err);
+            ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
+          });
+        });
+      });
+    }
+  };
+};
+

+ 52 - 0
src/models/exportmode_model.js

@@ -0,0 +1,52 @@
+const os = require('os');
+const fs = require('fs');
+const path = require('path');
+const archiver = require('../server/node_modules/archiver');
+
+module.exports = {
+  exportSSB: async (outputPath) => {
+    try {
+      const homeDir = os.homedir();
+      const ssbPath = path.join(homeDir, '.ssb');
+      const output = fs.createWriteStream(outputPath);
+      const archive = archiver('zip', {
+        zlib: { level: 9 }
+      });
+      archive.pipe(output);
+
+      const addDirectoryToArchive = (dirPath, archive) => {
+        const files = fs.readdirSync(dirPath);
+        let hasFiles = false;
+
+        files.forEach((file) => {
+          const filePath = path.join(dirPath, file);
+          const stat = fs.statSync(filePath);
+
+          if (file === 'secret') {
+            return;
+          }
+
+          if (stat.isDirectory()) {
+            addDirectoryToArchive(filePath, archive);
+            archive.directory(filePath, path.relative(ssbPath, filePath));
+            hasFiles = true;
+          } else {
+            archive.file(filePath, { name: path.relative(ssbPath, filePath) });
+            hasFiles = true;
+          }
+        });
+
+        if (!hasFiles) {
+          archive.directory(dirPath, path.relative(ssbPath, dirPath));
+        }
+      };
+
+      addDirectoryToArchive(ssbPath, archive);
+      await archive.finalize();
+
+      return outputPath;
+    } catch (error) {
+      throw new Error("Error exporting data: " + error.message);
+    }
+  }
+};

+ 131 - 0
src/models/feed_model.js

@@ -0,0 +1,131 @@
+const pull = require('../server/node_modules/pull-stream');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+
+  const createFeed = async (text) => {
+    const ssbClient = await openSsb();
+    const userId = ssbClient.id;
+    if (typeof text !== 'string' || text.length > 280) throw new Error("Text too long");
+    const content = {
+      type: 'feed',
+      text,
+      author: userId,
+      createdAt: new Date().toISOString(),
+      opinions: {},
+      opinions_inhabitants: [],
+      refeeds: 0,
+      refeeds_inhabitants: []
+    };
+    return new Promise((resolve, reject) => {
+      ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
+    });
+  };
+
+  const createRefeed = async (contentId) => {
+    const ssbClient = await openSsb();
+    const userId = ssbClient.id;
+    const msg = await new Promise((resolve, reject) => {
+      ssbClient.get(contentId, (err, value) => {
+        if (err) return reject(err);
+        resolve(value);
+      });
+    });
+    if (!msg || !msg.content || msg.content.type !== 'feed') throw new Error("Invalid feed");
+    if (msg.content.refeeds_inhabitants?.includes(userId)) throw new Error("Already refeeded");
+    const tombstone = { type: 'tombstone', target: contentId, deletedAt: new Date().toISOString() };
+    const updated = {
+      ...msg.content,
+      refeeds: (msg.content.refeeds || 0) + 1,
+      refeeds_inhabitants: [...(msg.content.refeeds_inhabitants || []), userId],
+      updatedAt: new Date().toISOString(),
+      replaces: contentId
+    };
+    await new Promise((res, rej) => ssbClient.publish(tombstone, err => err ? rej(err) : res()));
+    return new Promise((resolve, reject) => {
+      ssbClient.publish(updated, (err2, msg) => err2 ? reject(err2) : resolve(msg));
+    });
+  };
+
+  const addOpinion = async (contentId, category) => {
+    const ssbClient = await openSsb();
+    const userId = ssbClient.id;
+    const msg = await ssbClient.get(contentId);
+    if (!msg || !msg.content || msg.content.type !== 'feed') throw new Error("Invalid feed");
+    if (msg.content.opinions_inhabitants?.includes(userId)) throw new Error("Already voted");
+    const tombstone = { type: 'tombstone', target: contentId, deletedAt: new Date().toISOString() };
+    const updated = {
+      ...msg.content,
+      opinions: {
+        ...msg.content.opinions,
+        [category]: (msg.content.opinions?.[category] || 0) + 1
+      },
+      opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
+      updatedAt: new Date().toISOString(),
+      replaces: contentId
+    };
+    await new Promise((res, rej) => ssbClient.publish(tombstone, err => err ? rej(err) : res()));
+    return new Promise((resolve, reject) => {
+      ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
+    });
+  };
+
+  const listFeeds = async (filter = 'ALL') => {
+    const ssbClient = await openSsb();
+    const userId = ssbClient.id;
+    const now = Date.now();
+    const messages = await new Promise((res, rej) => {
+      pull(
+        ssbClient.createLogStream(),
+        pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+      );
+    });
+
+    const tombstoned = new Set();
+    const replaces = new Map();
+    const byId = new Map();
+
+    for (const msg of messages) {
+      const c = msg.value?.content;
+      const k = msg.key;
+      if (!c) continue;
+      if (c.type === 'tombstone' && c.target) {
+        tombstoned.add(c.target);
+        continue;
+      }
+      if (c.type === 'feed') {
+        if (tombstoned.has(k)) continue;
+        if (c.replaces) replaces.set(c.replaces, k);
+        byId.set(k, msg);
+      }
+    }
+
+    for (const replaced of replaces.keys()) {
+      byId.delete(replaced);
+    }
+
+    let feeds = Array.from(byId.values());
+
+    if (filter === 'MINE') {
+      feeds = feeds.filter(m => m.value.content.author === userId);
+    } else if (filter === 'TODAY') {
+      feeds = feeds.filter(m => now - m.value.timestamp < 86400000);
+    } else if (filter === 'TOP') {
+      feeds = feeds.sort((a, b) => {
+        const aVotes = Object.values(a.value.content.opinions || {}).reduce((sum, x) => sum + x, 0);
+        const bVotes = Object.values(b.value.content.opinions || {}).reduce((sum, x) => sum + x, 0);
+        return bVotes - aVotes;
+      });
+    }
+
+    return feeds;
+  };
+
+  return {
+    createFeed,
+    createRefeed,
+    addOpinion,
+    listFeeds
+  };
+};

+ 190 - 0
src/models/images_model.js

@@ -0,0 +1,190 @@
+const pull = require('../server/node_modules/pull-stream')
+
+module.exports = ({ cooler }) => {
+  let ssb
+  let userId
+
+  const openSsb = async () => {
+    if (!ssb) {
+      ssb = await cooler.open()
+      userId = ssb.id
+    }
+    return ssb
+  }
+
+  return {
+    async createImage(blobMarkdown, tagsRaw, title, description, meme) {
+      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 content = {
+        type: 'image',
+        url: blobId,
+        createdAt: new Date().toISOString(),
+        author: userId,
+        tags,
+        title: title || '',
+        description: description || '',
+        meme: !!meme,
+        opinions: {},
+        opinions_inhabitants: []
+      }
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res))
+      })
+    },
+
+    async updateImageById(id, blobMarkdown, tagsRaw, title, description, meme) {
+      const ssbClient = await openSsb()
+      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 (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 || '',
+            meme: !!meme,
+            updatedAt: new Date().toISOString()
+          }
+          ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result))
+        })
+      })
+    },
+
+    async deleteImageById(id) {
+      const ssbClient = await openSsb()
+      return new Promise((resolve, reject) => {
+        ssbClient.get(id, (err, msg) => {
+          if (err || !msg || msg.content?.type !== 'image') return reject(new Error('Image not found'))
+          if (msg.content.author !== userId) return reject(new Error('Not the author'))
+          const tombstone = {
+            type: 'tombstone',
+            target: id,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          }
+          ssbClient.publish(tombstone, (err2, res) => err2 ? reject(err2) : resolve(res))
+        })
+      })
+    },
+
+    async listAll(filter = 'all') {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const messages = await new Promise((res, rej) => {
+        pull(
+          ssbClient.createLogStream(),
+          pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+        )
+      })
+
+      const tombstoned = new Set(
+        messages
+          .filter(m => m.value.content?.type === 'tombstone')
+          .map(m => m.value.content.target)
+      )
+
+      const replaces = new Map()
+      const latest = new Map()
+
+      for (const m of messages) {
+        const k = m.key
+        const c = m.value?.content
+        if (!c || c.type !== 'image') continue
+        if (tombstoned.has(k)) continue
+        if (c.replaces) replaces.set(c.replaces, k)
+        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 || []
+        })
+      }
+
+      for (const oldId of replaces.keys()) {
+        latest.delete(oldId)
+      }
+
+      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
+        })
+      } else {
+        images = images.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+      }
+
+      return images
+    },
+
+    async getImageById(id) {
+      const ssbClient = await openSsb()
+      return new Promise((resolve, reject) => {
+        ssbClient.get(id, (err, msg) => {
+          if (err || !msg || msg.content?.type !== 'image') return reject(new Error('Image not found'))
+          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 || []
+          })
+        })
+      })
+    },
+
+    async createOpinion(id, category) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      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))
+        })
+      })
+    }
+  }
+}
+

+ 288 - 0
src/models/inhabitants_model.js

@@ -0,0 +1,288 @@
+const pull = require('../server/node_modules/pull-stream');
+const ssbClientGUI = require("../client/gui");
+const coolerInstance = ssbClientGUI({ offline: require('../server/ssb_config').offline });
+const models = require("../models/main_models");
+const { about, friend } = models({
+  cooler: coolerInstance,
+  isPublic: require('../server/ssb_config').public,
+});
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+
+  return {
+    async listInhabitants(options = {}) {
+      const { filter = 'all', search = '', location = '', language = '', skills = '' } = options;
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const timeoutPromise = (timeout) => new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout));
+      const fetchUserImage = (feedId) => {
+        return Promise.race([
+          about.image(feedId),
+          timeoutPromise(5000) 
+        ]).catch(() => '/assets/images/default-avatar.png'); 
+      };
+
+      if (filter === 'GALLERY') {
+        const feedIds = await new Promise((res, rej) => {
+          pull(
+            ssbClient.createLogStream(),
+            pull.filter(msg => {
+              const c = msg.value?.content;
+              const a = msg.value?.author;
+              return c &&
+                c.type === 'about' &&
+                c.type !== 'tombstone' &&
+                typeof c.name === 'string' &&
+                typeof c.about === 'string' &&
+                c.about === a;
+            }),
+            pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+          );
+        });
+
+        const uniqueFeedIds = Array.from(new Set(feedIds.map(r => r.value.author).filter(Boolean)));
+
+        const users = await Promise.all(
+          uniqueFeedIds.map(async (feedId) => {
+            const name = await about.name(feedId);
+            const description = await about.description(feedId);
+            const image = await fetchUserImage(feedId); 
+            const photo =
+              typeof image === 'string'
+                ? `/image/256/${encodeURIComponent(image)}`
+                : '/assets/images/default-avatar.png';
+            return { id: feedId, name, description, photo };
+          })
+        );
+
+        return users;
+      }
+
+      if (filter === 'all') {
+        const feedIds = await new Promise((res, rej) => {
+          pull(
+            ssbClient.createLogStream(),
+            pull.filter(msg => {
+              const c = msg.value?.content;
+              const a = msg.value?.author;
+              return c &&
+                c.type === 'about' &&
+                c.type !== 'tombstone' &&
+                typeof c.name === 'string' &&
+                typeof c.about === 'string' &&
+                c.about === a;
+            }),
+            pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+          );
+        });
+
+        const uniqueFeedIds = Array.from(new Set(feedIds.map(r => r.value.author).filter(Boolean)));
+
+        const users = await Promise.all(
+          uniqueFeedIds.map(async (feedId) => {
+            const name = await about.name(feedId);
+            const description = await about.description(feedId);
+            const image = await fetchUserImage(feedId);
+            const photo =
+              typeof image === 'string'
+                ? `/image/256/${encodeURIComponent(image)}`
+                : '/assets/images/default-avatar.png';
+
+            return { id: feedId, name, description, photo };
+          })
+        );
+
+        const deduplicated = Array.from(new Map(users.filter(u => u && u.id).map(u => [u.id, u])).values());
+        let filtered = deduplicated;
+
+        if (search) {
+          const q = search.toLowerCase();
+          filtered = filtered.filter(u =>
+            u.name?.toLowerCase().includes(q) ||
+            u.description?.toLowerCase().includes(q) ||
+            u.id?.toLowerCase().includes(q)
+          );
+        }
+
+        return filtered;
+      }
+
+      if (filter === 'contacts') {
+        const all = await this.listInhabitants({ filter: 'all' });
+        const result = [];
+        for (const user of all) {
+          const rel = await friend.getRelationship(user.id);
+          if (rel.following) result.push(user);
+        }
+        return Array.from(new Map(result.map(u => [u.id, u])).values());
+      }
+
+      if (filter === 'blocked') {
+        const all = await this.listInhabitants({ filter: 'all' });
+        const result = [];
+        for (const user of all) {
+          const rel = await friend.getRelationship(user.id);
+          if (rel.blocking) result.push({ ...user, isBlocked: true });
+        }
+        return Array.from(new Map(result.map(u => [u.id, u])).values());
+      }
+
+      if (filter === 'SUGGESTED') {
+        const all = await this.listInhabitants({ filter: 'all' });
+        const result = [];
+        for (const user of all) {
+          if (user.id === userId) continue;
+          const rel = await friend.getRelationship(user.id);
+          if (!rel.following && !rel.blocking && rel.followsMe) {
+            const cv = await this.getCVByUserId(user.id);
+            if (cv) result.push({ ...this._normalizeCurriculum(cv), mutualCount: 1 });
+          }
+        }
+        return Array.from(new Map(result.map(u => [u.id, u])).values())
+          .sort((a, b) => (b.mutualCount || 0) - (a.mutualCount || 0));
+      }
+
+      if (filter === 'CVs' || filter === 'MATCHSKILLS') {
+        const records = await new Promise((res, rej) => {
+          pull(
+            ssbClient.createLogStream(),
+            pull.filter(msg =>
+              msg.value.content?.type === 'curriculum' &&
+              msg.value.content?.type !== 'tombstone'
+            ),
+            pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+          );
+        });
+
+        let cvs = records.map(r => this._normalizeCurriculum(r.value.content));
+        cvs = Array.from(new Map(cvs.map(u => [u.id, u])).values());
+
+        if (filter === 'CVs') {
+          if (search) {
+            const q = search.toLowerCase();
+            cvs = cvs.filter(u =>
+              u.name.toLowerCase().includes(q) ||
+              u.description.toLowerCase().includes(q) ||
+              u.skills.some(s => s.toLowerCase().includes(q))
+            );
+          }
+          if (location) {
+            cvs = cvs.filter(u => u.location?.toLowerCase() === location.toLowerCase());
+          }
+          if (language) {
+            cvs = cvs.filter(u => u.languages.map(l => l.toLowerCase()).includes(language.toLowerCase()));
+          }
+          if (skills) {
+            const skillList = skills.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
+            cvs = cvs.filter(u => skillList.every(s => u.skills.map(k => k.toLowerCase()).includes(s)));
+          }
+          return cvs;
+        }
+
+        if (filter === 'MATCHSKILLS') {
+          const cv = await this.getCVByUserId();
+          const userSkills = cv
+            ? [
+                ...cv.personalSkills,
+                ...cv.oasisSkills,
+                ...cv.educationalSkills,
+                ...cv.professionalSkills
+              ].map(s => s.toLowerCase())
+            : [];
+          if (!userSkills.length) return [];
+          const matches = cvs.map(c => {
+            if (c.id === userId) return null;
+            const common = c.skills.map(s => s.toLowerCase()).filter(s => userSkills.includes(s));
+            if (!common.length) return null;
+            return { ...c, commonSkills: common };
+          }).filter(Boolean);
+          return matches.sort((a, b) => b.commonSkills.length - a.commonSkills.length);
+        }
+      }
+
+      return [];
+    },
+
+    _normalizeCurriculum(c) {
+      const photo =
+        typeof c.photo === 'string'
+          ? `/image/256/${encodeURIComponent(c.photo)}`
+          : '/assets/images/default-avatar.png';
+
+      return {
+        id: c.author,
+        name: c.name,
+        description: c.description,
+        photo,
+        skills: [
+          ...c.personalSkills,
+          ...c.oasisSkills,
+          ...c.educationalSkills,
+          ...c.professionalSkills
+        ],
+        location: c.location,
+        languages: typeof c.languages === 'string'
+          ? c.languages.split(',').map(x => x.trim())
+          : Array.isArray(c.languages) ? c.languages : [],
+        createdAt: c.createdAt
+      };
+    },
+
+    async getCVByUserId(id) {
+      const ssbClient = await openSsb();
+      const targetId = id || ssbClient.id;
+      const records = await new Promise((res, rej) => {
+        pull(
+          ssbClient.createUserStream({ id: targetId }),
+          pull.filter(msg =>
+            msg.value.content?.type === 'curriculum' &&
+            msg.value.content?.type !== 'tombstone'
+          ),
+          pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+        );
+      });
+      return records.length ? records[records.length - 1].value.content : null;
+    },
+
+    async _getLatestAboutById(id) {
+      const ssbClient = await openSsb();
+      const records = await new Promise((res, rej) => {
+        pull(
+          ssbClient.createUserStream({ id }),
+          pull.filter(msg =>
+            msg.value.content?.type === 'about' &&
+            msg.value.content?.type !== 'tombstone'
+          ),
+          pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+        );
+      });
+      if (!records.length) return null;
+      const latest = records.sort((a, b) => b.value.timestamp - a.value.timestamp)[0];
+      return latest.value.content;
+    },
+
+    async getFeedByUserId(id) {
+      const ssbClient = await openSsb();
+      const targetId = id || ssbClient.id;
+      const records = await new Promise((res, rej) => {
+        pull(
+          ssbClient.createUserStream({ id: targetId }),
+          pull.filter(msg =>
+            msg.value &&
+            msg.value.content &&
+            typeof msg.value.content.text === 'string' &&
+            msg.value.content?.type !== 'tombstone'
+          ),
+          pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+        );
+      });
+      return records
+        .filter(m => typeof m.value.content.text === 'string')
+        .sort((a, b) => b.value.timestamp - a.value.timestamp)
+        .slice(0, 10);
+    }
+  };
+};
+

+ 85 - 0
src/models/legacy_model.js

@@ -0,0 +1,85 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const os = require('os');
+
+function encryptFile(filePath, password) {
+  if (typeof password === 'object' && password.password) {
+    password = password.password;
+  }
+  const key = Buffer.from(password, 'utf-8');
+  const iv = crypto.randomBytes(16);
+  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
+  const homeDir = os.homedir();
+  const encryptedFilePath = path.join(homeDir, 'oasis.enc');
+  const output = fs.createWriteStream(encryptedFilePath);
+  const input = fs.createReadStream(filePath);
+  input.pipe(cipher).pipe(output);
+  return new Promise((resolve, reject) => {
+    output.on('finish', () => {
+      resolve(encryptedFilePath);
+    });
+    output.on('error', (err) => {
+      reject(err);
+    });
+  });
+}
+
+function decryptFile(filePath, password) {
+  if (typeof password === 'object' && password.password) {
+    password = password.password;
+  } 
+  const key = Buffer.from(password, 'utf-8');
+  const iv = crypto.randomBytes(16);
+  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); 
+  const homeDir = os.homedir();
+  const decryptedFilePath = path.join(homeDir, 'secret');
+  const output = fs.createWriteStream(decryptedFilePath);
+  const input = fs.createReadStream(filePath);
+  input.pipe(decipher).pipe(output);
+  return new Promise((resolve, reject) => {
+    output.on('finish', () => {
+      resolve(decryptedFilePath);
+    });
+    output.on('error', (err) => {
+      console.error('Error deciphering data:', err);
+      reject(err);
+    });
+  });
+}
+
+module.exports = {
+  exportData: async (password) => {
+    try {
+      const homeDir = os.homedir();
+      const secretFilePath = path.join(homeDir, '.ssb', 'secret');
+      
+      if (!fs.existsSync(secretFilePath)) {
+        throw new Error(".ssb/secret file doesn't exist");
+      }
+      const encryptedFilePath = await encryptFile(secretFilePath, password);   
+      fs.unlinkSync(secretFilePath);
+      return encryptedFilePath;
+    } catch (error) {
+      throw new Error("Error exporting data: " + error.message);
+    }
+  },
+  importData: async ({ filePath, password }) => {
+    try {
+      if (!fs.existsSync(filePath)) {
+        throw new Error('Encrypted file not found.');
+      }
+      const decryptedFilePath = await decryptFile(filePath, password);
+
+      if (!fs.existsSync(decryptedFilePath)) {
+        throw new Error("Decryption failed.");
+      }
+
+      fs.unlinkSync(filePath);
+      return decryptedFilePath;
+
+    } catch (error) {
+      throw new Error("Error importing data: " + error.message);
+    }
+  }
+};

File diff suppressed because it is too large
+ 467 - 679
src/models/main_models.js


+ 288 - 0
src/models/market_model.js

@@ -0,0 +1,288 @@
+const pull = require('../server/node_modules/pull-stream');
+const moment = require('../server/node_modules/moment');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+
+  return {
+    type: 'market',
+
+    async createItem(item_type, title, description, image, price, tagsRaw = [], item_status, deadline, includesShipping = false) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const formattedDeadline = deadline ? moment(deadline, moment.ISO_8601, true).toISOString() : null;
+      let blobId = null;
+      if (image) {
+        const match = image.match(/\(([^)]+)\)/);
+        blobId = match ? match[1] : image;
+      }
+      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
+      const itemContent = {
+        type: "market",
+        item_type,
+        title,
+        description,
+        image: blobId,
+        price: parseFloat(price).toFixed(6),
+        tags,
+        item_status,
+        status: 'FOR SALE',
+        deadline: formattedDeadline,
+        includesShipping,
+        createdAt: new Date().toISOString(),
+        updatedAt: new Date().toISOString(),
+        seller: userId,
+        auctions_poll: []
+      };
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(itemContent, (err, res) => err ? reject(err) : resolve(res));
+      });
+    },
+
+    async updateItemById(itemId, updatedData) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      return new Promise((resolve, reject) => {
+        ssbClient.get(itemId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Item not found"));
+          if (item.content.seller !== userId) return reject(new Error("Not the seller"));
+          if (['SOLD', 'DISCARDED'].includes(item.content.status)) return reject(new Error("Cannot update this item"));
+          const updated = {
+            ...item.content,
+            ...updatedData,
+            tags: updatedData.tags || item.content.tags,
+            updatedAt: new Date().toISOString(),
+            replaces: itemId
+          };
+          const tombstone = {
+            type: 'tombstone',
+            target: itemId,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          };
+          ssbClient.publish(tombstone, (err) => {
+            if (err) return reject(err);
+            ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
+          });
+        });
+      });
+    },
+
+    async deleteItemById(itemId) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      return new Promise((resolve, reject) => {
+        ssbClient.get(itemId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Item not found"));
+          if (item.content.seller !== userId) return reject(new Error("Not the seller"));
+          const tombstone = {
+            type: 'tombstone',
+            target: itemId,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          };
+          ssbClient.publish(tombstone, (err) => err ? reject(err) : resolve({ message: "Item deleted successfully" }));
+        });
+      });
+    },
+
+    async listAllItems(filter = 'all') {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+	  return new Promise((resolve, reject) => {
+	    pull(
+	      ssbClient.createLogStream(),
+	      pull.collect(async (err, results) => {
+		if (err) return reject(new Error("Error listing items: " + err.message));
+		const tombstoned = new Set();
+		const replaces = new Map();
+		const itemsById = new Map();
+
+		for (const msg of results) {
+		  const c = msg.value?.content;
+		  const k = msg.key;
+		  if (!c) continue;
+		  if (c.type === 'tombstone' && c.target) {
+		    tombstoned.add(c.target);
+		    continue;
+		  }
+		  if (c.type === 'market') {
+		    if (tombstoned.has(k)) continue;
+		    if (c.replaces) replaces.set(c.replaces, k);
+		    itemsById.set(k, { id: k, ...c });
+		  }
+		}
+
+		for (const replacedId of replaces.keys()) {
+		  itemsById.delete(replacedId);
+		}
+
+		let filteredItems = Array.from(itemsById.values());
+		await this.checkAuctionItemsStatus(filteredItems);
+
+		switch (filter) {
+		  case 'mine':
+		    filteredItems = filteredItems.filter(e => e.seller === userId);
+		    break;
+		  case 'exchange':
+		    filteredItems = filteredItems.filter(e => e.item_type === 'exchange' && e.status === 'FOR SALE');
+		    break;
+		  case 'auctions':
+		    filteredItems = filteredItems.filter(e => e.item_type === 'auction' && e.status === 'FOR SALE');
+		    break;
+		  case 'new':
+		    filteredItems = filteredItems.filter(e => e.item_status === 'NEW' && e.status === 'FOR SALE');
+		    break;
+		  case 'used':
+		    filteredItems = filteredItems.filter(e => e.item_status === 'USED' && e.status === 'FOR SALE');
+		    break;
+		  case 'broken':
+		    filteredItems = filteredItems.filter(e => e.item_status === 'BROKEN' && e.status === 'FOR SALE');
+		    break;
+		  case 'for sale':
+		    filteredItems = filteredItems.filter(e => e.status === 'FOR SALE');
+		    break;
+		  case 'sold':
+		    filteredItems = filteredItems.filter(e => e.status === 'SOLD');
+		    break;
+		  case 'discarded':
+		    filteredItems = filteredItems.filter(e => e.status === 'DISCARDED');
+		    break;
+		  default:
+		    break;
+		}
+
+		filteredItems = filteredItems.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+		resolve(filteredItems);
+	      })
+	    );
+	  });
+	},
+
+    async checkAuctionItemsStatus(items) {
+      const now = new Date().toISOString();
+      for (let item of items) {
+        if ((item.item_type === 'auction' || item.item_type === 'exchange') && item.deadline && now > item.deadline) {
+          if (['SOLD', 'DISCARDED'].includes(item.status)) continue;
+          let status = item.status;
+          if (item.item_type === 'auction') {
+            const highestBid = item.auctions_poll.reduce((prev, curr) => {
+              const [_, bidAmount] = curr.split(':');
+              return parseFloat(bidAmount) > prev ? parseFloat(bidAmount) : prev;
+            }, 0);
+            status = highestBid > 0 ? 'SOLD' : 'DISCARDED';
+          } else if (item.item_type === 'exchange') {
+            status = 'DISCARDED';
+          }
+          await this.updateItemById(item.id, { status });
+        }
+      }
+    },
+
+    async setItemAsSold(itemId) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      return new Promise((resolve, reject) => {
+        ssbClient.get(itemId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Item not found"));
+          if (['SOLD', 'DISCARDED'].includes(item.content.status)) return reject(new Error("Already sold/discarded"));
+          const updated = {
+            ...item.content,
+            status: 'SOLD',
+            updatedAt: new Date().toISOString(),
+            replaces: itemId
+          };
+          const tombstone = {
+            type: 'tombstone',
+            target: itemId,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          };
+          ssbClient.publish(tombstone, (err) => {
+            if (err) return reject(err);
+            ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
+          });
+        });
+      });
+    },
+
+    async getItemById(itemId) {
+      const ssbClient = await openSsb();
+      return new Promise((resolve, reject) => {
+        ssbClient.get(itemId, async (err, item) => {
+          if (err || !item?.content) return reject(new Error("Item not found"));
+          const c = item.content;
+          let status = c.status || 'FOR SALE';
+          if (c.deadline) {
+            const deadlineMoment = moment(c.deadline);
+            if (deadlineMoment.isValid() && deadlineMoment.isBefore(moment()) && status !== 'SOLD') {
+              status = 'DISCARDED';
+              const tombstone = {
+                type: 'tombstone',
+                target: itemId,
+                deletedAt: new Date().toISOString(),
+                author: c.seller
+              };
+              const updated = { ...c, status, updatedAt: new Date().toISOString() };
+              await ssbClient.publish(tombstone);
+              await ssbClient.publish(updated);
+            }
+          }
+          resolve({
+            id: itemId,
+            title: c.title,
+            description: c.description,
+            price: c.price,
+            status,
+            item_status: c.item_status,
+            seller: c.seller,
+            createdAt: c.createdAt,
+            updatedAt: c.updatedAt,
+            image: c.image || null,
+            tags: c.tags || [],
+            includesShipping: c.includesShipping,
+            deadline: c.deadline,
+            auctions_poll: c.auctions_poll || [],
+            item_type: c.item_type
+          });
+        });
+      });
+    },
+
+    async addBidToAuction(itemId, userId, bidAmount) {
+      const ssbClient = await openSsb();
+      return new Promise((resolve, reject) => {
+        ssbClient.get(itemId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Item not found"));
+          if (item.content.item_type !== 'auction') return reject(new Error("Not an auction"));
+          if (item.content.seller === userId) return reject(new Error("Cannot bid on your own item"));
+          if (parseFloat(bidAmount) <= parseFloat(item.content.price)) return reject(new Error("Bid too low"));
+          const highestBid = item.content.auctions_poll.reduce((prev, curr) => {
+            const [_, bid] = curr.split(':');
+            return Math.max(prev, parseFloat(bid));
+          }, 0);
+          if (parseFloat(bidAmount) <= highestBid) return reject(new Error("Bid not highest"));
+          const bid = `${userId}:${bidAmount}:${new Date().toISOString()}`;
+          const updated = {
+            ...item.content,
+            auctions_poll: [...(item.content.auctions_poll || []), bid],
+            updatedAt: new Date().toISOString(),
+            replaces: itemId
+          };
+          const tombstone = {
+            type: 'tombstone',
+            target: itemId,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          };
+          ssbClient.publish(tombstone, (err) => {
+            if (err) return reject(err);
+            ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
+          });
+        });
+      });
+    }
+  };
+};
+

+ 143 - 0
src/models/opinions_model.js

@@ -0,0 +1,143 @@
+const pull = require('../server/node_modules/pull-stream');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
+
+  const categories = [
+    "interesting", "necessary", "funny", "disgusting", "sensible",
+    "propaganda", "adultOnly", "boring", "confusing", "inspiring", "spam"
+  ];
+
+  const validTypes = [
+    'event', 'bookmark', 'task', 'votes', 'report', 'transfer',
+    'feed', 'image', 'audio', 'video', 'document'
+  ];
+
+  const getPreview = (c) => {
+    if (c.type === 'bookmark' && c.bookmark) return `🔖 ${c.bookmark}`;
+    if (c.type === 'event' && c.text) return c.text.slice(0, 200);
+    if (c.type === 'task' && c.description) return c.description;
+    return c.text || c.description || c.title || '';
+  };
+
+  const createVote = async (contentId, category) => {
+    const ssbClient = await openSsb();
+    const userId = ssbClient.id;
+    if (!categories.includes(category)) throw new Error("Invalid voting category.");
+
+    const msg = await new Promise((resolve, reject) =>
+      ssbClient.get(contentId, (err, value) => err ? reject(err) : resolve(value))
+    );
+
+    if (!msg || !msg.content) throw new Error("Opinion not found.");
+    const type = msg.content.type;
+    if (!validTypes.includes(type)) throw new Error("Invalid content type for voting.");
+    if (msg.content.opinions_inhabitants?.includes(userId)) throw new Error("Already voted.");
+
+    const tombstone = {
+      type: 'tombstone',
+      target: contentId,
+      deletedAt: new Date().toISOString()
+    };
+
+    const updated = {
+      ...msg.content,
+      opinions: {
+        ...msg.content.opinions,
+        [category]: (msg.content.opinions?.[category] || 0) + 1
+      },
+      opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
+      updatedAt: new Date().toISOString(),
+      replaces: contentId
+    };
+
+    await new Promise((resolve, reject) =>
+      ssbClient.publish(tombstone, (err) => err ? reject(err) : resolve())
+    );
+
+    return new Promise((resolve, reject) =>
+      ssbClient.publish(updated, (err, result) => err ? reject(err) : resolve(result))
+    );
+  };
+
+  const listOpinions = async (filter = 'ALL', category = '') => {
+    const ssbClient = await openSsb();
+    const userId = ssbClient.id;
+
+    const messages = await new Promise((res, rej) => {
+      pull(
+        ssbClient.createLogStream(),
+        pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+      );
+    });
+
+    const tombstoned = new Set();
+    const replaces = new Map();
+    const byId = new Map();
+
+    for (const msg of messages) {
+      const key = msg.key;
+      const c = msg.value?.content;
+      if (!c) continue;
+      if (c.type === 'tombstone' && c.target) {
+        tombstoned.add(c.target);
+        continue;
+      }
+      if (c.opinions && !tombstoned.has(key)) {
+        if (c.replaces) replaces.set(c.replaces, key);
+        byId.set(key, {
+          key,
+          value: {
+            ...msg.value,
+            preview: getPreview(c)
+          }
+        });
+      }
+    }
+
+    for (const replacedId of replaces.keys()) {
+      byId.delete(replacedId);
+    }
+
+    let filtered = Array.from(byId.values());
+
+    if (filter === 'MINE') {
+      filtered = filtered.filter(m => m.value.author === userId);
+    } else if (filter === 'RECENT') {
+      const now = Date.now();
+      filtered = filtered.filter(m => now - m.value.timestamp < 24 * 60 * 60 * 1000);
+    } else if (filter === 'TOP') {
+      filtered = filtered.sort((a, b) => {
+        const sum = v => Object.values(v.content.opinions || {}).reduce((acc, x) => acc + x, 0);
+        return sum(b.value) - sum(a.value);
+      });
+    } else if (categories.includes(filter)) {
+      filtered = filtered
+        .filter(m => m.value.content.opinions?.[filter])
+        .sort((a, b) =>
+          (b.value.content.opinions[filter] || 0) - (a.value.content.opinions[filter] || 0)
+        );
+    }
+
+    return filtered;
+  };
+
+  const getMessageById = async (id) => {
+    const ssbClient = await openSsb();
+    return new Promise((resolve, reject) =>
+      ssbClient.get(id, (err, msg) => err ? reject(new Error("Error fetching opinion: " + err)) : (!msg?.content ? reject(new Error("Opinion not found")) : resolve(msg)))
+    );
+  };
+
+  return {
+    createVote,
+    listOpinions,
+    getMessageById,
+    categories
+  };
+};
+

+ 15 - 0
src/models/panicmode_model.js

@@ -0,0 +1,15 @@
+const os = require('os');
+const fs = require('fs');
+const path = require('path');
+
+module.exports = {
+  removeSSB: async () => {
+    try {
+      const homeDir = os.homedir();
+      const ssbPath = path.join(homeDir, '.ssb');
+      await fs.promises.rm(ssbPath, { recursive: true, force: true });
+    } catch (error) {
+      throw new Error("Error deleting data: " + error.message);
+    }
+  }
+};

+ 134 - 0
src/models/pixelia_model.js

@@ -0,0 +1,134 @@
+const pull = require('../server/node_modules/pull-stream');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
+
+  const getPixelByCoordinate = async (coordinateKey) => {
+    const ssbClient = await openSsb();
+    const messages = await new Promise((res, rej) => {
+      pull(
+        ssbClient.createLogStream(),
+        pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+      );
+    });
+
+    const tombstoned = new Set(
+      messages
+        .filter(m => m.value?.content?.type === 'tombstone' && m.value?.content?.target)
+        .map(m => m.value.content.target)
+    );
+
+    const replaces = new Map();
+    const byId = new Map();
+
+    for (const m of messages) {
+      const c = m.value?.content;
+      const k = m.key;
+      if (!c || c.type !== 'pixelia' || c.coordinateKey !== coordinateKey) continue;
+      if (tombstoned.has(k)) continue;
+      if (c.replaces) replaces.set(c.replaces, k);
+      byId.set(k, m);
+    }
+
+    for (const r of replaces.keys()) {
+      byId.delete(r);
+    }
+
+    return [...byId.values()][0] || null;
+  };
+
+  const paintPixel = async (x, y, color) => {
+    if (x < 1 || x > 50 || y < 1 || y > 200) {
+      throw new Error('Coordinates out of bounds. Please use x (1-50) and y (1-200)');
+    }
+
+    const ssbClient = await openSsb();
+    const userId = ssbClient.id;
+    const coordinateKey = `${x}:${y}`;
+    const existingPixel = await getPixelByCoordinate(coordinateKey);
+
+    if (existingPixel) {
+      const tombstone = {
+        type: 'tombstone',
+        target: existingPixel.key,
+        deletedAt: new Date().toISOString()
+      };
+      await new Promise((resolve, reject) =>
+        ssbClient.publish(tombstone, err => err ? reject(err) : resolve())
+      );
+    }
+
+    const contributors = existingPixel?.value?.content?.contributors_inhabitants || [];
+    const contributors_inhabitants = contributors.includes(userId)
+      ? contributors
+      : [...contributors, userId];
+
+    const content = {
+      type: 'pixelia',
+      x: x - 1,
+      y: y - 1,
+      color,
+      author: userId,
+      contributors_inhabitants,
+      timestamp: Date.now(),
+      coordinateKey,
+      replaces: existingPixel?.key || null
+    };
+
+    await new Promise((resolve, reject) => {
+      ssbClient.publish(content, (err) => err ? reject(err) : resolve());
+    });
+  };
+
+  const listPixels = async () => {
+    const ssbClient = await openSsb();
+    const messages = await new Promise((res, rej) => {
+      pull(
+        ssbClient.createLogStream(),
+        pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+      );
+    });
+
+    const tombstoned = new Set();
+    const replaces = new Map();
+    const byKey = new Map();
+
+    for (const m of messages) {
+      const c = m.value?.content;
+      const k = m.key;
+      if (!c) continue;
+      if (c.type === 'tombstone' && c.target) {
+        tombstoned.add(c.target);
+        continue;
+      }
+      if (c.type === 'pixelia') {
+        if (tombstoned.has(k)) continue;
+        if (c.replaces) replaces.set(c.replaces, k);
+        byKey.set(k, m);
+      }
+    }
+
+    for (const replaced of replaces.keys()) {
+      byKey.delete(replaced);
+    }
+
+    return Array.from(byKey.values()).map(m => ({
+      x: m.value.content.x + 1,
+      y: m.value.content.y + 1,
+      color: m.value.content.color,
+      author: m.value.content.author,
+      contributors_inhabitants: m.value.content.contributors_inhabitants || [],
+      timestamp: m.value.timestamp
+    }));
+  };
+
+  return {
+    paintPixel,
+    listPixels
+  };
+};

+ 71 - 0
src/models/privatemessages_model.js

@@ -0,0 +1,71 @@
+const pull = require('../server/node_modules/pull-stream');
+const util = require('../server/node_modules/util');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  let userId;
+  const openSsb = async () => {
+    if (!ssb) {
+      ssb = await cooler.open();
+      userId = ssb.id;
+    }
+    return ssb;
+  };
+
+  return {
+    type: 'post',
+
+    async sendMessage(recipients = [], subject = '', text = '') {
+      const ssbClient = await openSsb();
+      const content = {
+        type: 'post',
+        from: userId,
+        to: recipients,
+        subject,
+        text,
+        sentAt: new Date().toISOString(),
+        private: true
+      };
+      const publishAsync = util.promisify(ssbClient.private.publish);
+      return publishAsync(content, recipients);
+    },
+
+    async deleteMessageById(messageId) {
+      const ssbClient = await openSsb();
+      const rawMsg = await new Promise((resolve, reject) =>
+        ssbClient.get(messageId, (err, m) =>
+          err ? reject(new Error("Error retrieving message.")) : resolve(m)
+        )
+      );
+      let decrypted;
+      try {
+        decrypted = ssbClient.private.unbox({
+          key: messageId,
+          value: rawMsg,
+          timestamp: rawMsg.timestamp || Date.now()
+        });
+      } catch {
+        throw new Error("Malformed message.");
+      }
+
+      const content = decrypted?.value?.content;
+      const author = decrypted?.value?.author;
+      const recps = content?.to;
+
+      if (!content || !author || !Array.isArray(recps)) throw new Error("Malformed message.");
+      if (content.type === 'tombstone') throw new Error("Message already deleted.");
+      if (author !== userId) throw new Error("You are not the author of this message.");
+
+      const tombstone = {
+        type: 'tombstone',
+        target: messageId,
+        deletedAt: new Date().toISOString(),
+        private: true
+      };
+
+      const publishAsync = util.promisify(ssbClient.private.publish);
+      return publishAsync(tombstone, recps);
+    }
+  };
+};
+

+ 215 - 0
src/models/reports_model.js

@@ -0,0 +1,215 @@
+const pull = require('../server/node_modules/pull-stream')
+const moment = require('../server/node_modules/moment')
+
+module.exports = ({ cooler }) => {
+  let ssb
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
+
+  return {
+    type: 'report',
+
+    async createReport(title, description, category, image, tagsRaw = [], severity = 'low', isAnonymous = false) {
+      const ssb = await openSsb()
+      const userId = ssb.id
+      let blobId = null
+      if (image) {
+        const match = image.match(/\(([^)]+)\)/)
+        blobId = match ? match[1] : image
+      }
+      const tags = Array.isArray(tagsRaw)
+        ? tagsRaw.filter(Boolean)
+        : tagsRaw.split(',').map(t => t.trim()).filter(Boolean)
+      const content = {
+        type: 'report',
+        title,
+        description,
+        category,
+        createdAt: new Date().toISOString(),
+        author: userId,
+        image: blobId,
+        tags,
+        opinions: {},
+        opinions_inhabitants: [],
+        confirmations: [],
+        severity,
+        status: 'OPEN',
+        isAnonymous
+      }
+      return new Promise((resolve, reject) => {
+        ssb.publish(content, (err, res) => err ? reject(err) : resolve(res))
+      })
+    },
+
+    async updateReportById(id, updatedContent) {
+      const ssb = await openSsb()
+      const userId = ssb.id
+      return new Promise((resolve, reject) => {
+        ssb.get(id, (err, report) => {
+          if (err || !report || !report.content) return reject(new Error('Report not found'))
+          if (report.content.author !== userId) return reject(new Error('Not the author'))
+          if (Object.keys(report.content.opinions || {}).length > 0) return reject(new Error('Cannot edit report after it has received opinions.'))
+          const tags = updatedContent.tags
+            ? updatedContent.tags.split(',').map(t => t.trim()).filter(Boolean)
+            : report.content.tags
+          let blobId = null
+          if (updatedContent.image) {
+            const match = updatedContent.image.match(/\(([^)]+)\)/)
+            blobId = match ? match[1] : updatedContent.image
+          }
+          const updated = {
+            ...report.content,
+            ...updatedContent,
+            type: 'report',
+            replaces: id,
+            image: blobId || report.content.image,
+            tags,
+            updatedAt: new Date().toISOString(),
+            author: report.content.author
+          }
+          ssb.publish(updated, (e, r) => e ? reject(e) : resolve(r))
+        })
+      })
+    },
+
+    async deleteReportById(id) {
+      const ssb = await openSsb()
+      const userId = ssb.id
+      return new Promise((resolve, reject) => {
+        ssb.get(id, (err, report) => {
+          if (err || !report || !report.content) return reject(new Error('Report not found'))
+          if (report.content.author !== userId) return reject(new Error('Not the author'))
+          const tombstone = {
+            type: 'tombstone',
+            target: id,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          }
+          ssb.publish(tombstone, (err, res) => err ? reject(err) : resolve(res))
+        })
+      })
+    },
+
+    async listAll(filter = 'all') {
+      const ssb = await openSsb()
+      const userId = ssb.id
+      return new Promise((resolve, reject) => {
+        pull(
+          ssb.createLogStream(),
+          pull.collect((err, results) => {
+            if (err) return reject(err)
+            const tombstonedIds = new Set(
+              results
+                .filter(msg => msg.value.content?.type === 'tombstone')
+                .map(msg => msg.value.content.target)
+            )
+            const replaces = new Map()
+            const latest = new Map()
+            for (const msg of results) {
+              const k = msg.key
+              const c = msg.value?.content
+              if (!c || c.type !== 'report') continue
+              if (tombstonedIds.has(k)) continue
+              if (c.replaces) replaces.set(c.replaces, k)
+              latest.set(k, {
+                id: k,
+                title: c.title,
+                description: c.description,
+                category: c.category,
+                createdAt: c.createdAt,
+                author: c.author,
+                image: c.image || null,
+                tags: c.tags || [],
+                opinions: c.opinions || {},
+                opinions_inhabitants: c.opinions_inhabitants || [],
+                confirmations: c.confirmations || [],
+                severity: c.severity || 'LOW',
+                status: c.status || 'OPEN',
+                isAnonymous: c.isAnonymous || false
+              })
+            }
+            for (const oldId of replaces.keys()) {
+              latest.delete(oldId)
+            }
+            let reports = Array.from(latest.values())
+            if (filter === 'mine') reports = reports.filter(r => r.author === userId)
+            if (['features', 'bugs', 'abuse', 'content'].includes(filter)) {
+              reports = reports.filter(r => r.category.toLowerCase() === filter)
+            }
+            if (filter === 'confirmed') reports = reports.filter(r => r.confirmations.length >= 3)
+            if (['open', 'resolved', 'invalid', 'underreview'].includes(filter)) {
+              reports = reports.filter(r => r.status.toLowerCase() === filter)
+            }
+            resolve(reports)
+          })
+        )
+      })
+    },
+
+    async getReportById(id) {
+      const ssb = await openSsb()
+      return new Promise((resolve, reject) => {
+        ssb.get(id, (err, report) => {
+          if (err || !report || !report.content) return reject(new Error('Report not found'))
+          const c = report.content
+          resolve({
+            id,
+            title: c.title,
+            description: c.description,
+            category: c.category,
+            createdAt: c.createdAt,
+            author: c.author,
+            image: c.image || null,
+            tags: c.tags || [],
+            opinions: c.opinions || {},
+            opinions_inhabitants: c.opinions_inhabitants || [],
+            confirmations: c.confirmations || [],
+            severity: c.severity || 'LOW',
+            status: c.status || 'OPEN',
+            isAnonymous: c.isAnonymous || false
+          })
+        })
+      })
+    },
+
+    async confirmReportById(id) {
+      const ssb = await openSsb()
+      const userId = ssb.id
+      return new Promise((resolve, reject) => {
+        ssb.get(id, (err, report) => {
+          if (err || !report || !report.content) return reject(new Error('Report not found'))
+          if ((report.content.confirmations || []).includes(userId)) return reject(new Error('Already confirmed'))
+          const updated = {
+            ...report.content,
+            replaces: id,
+            confirmations: [...(report.content.confirmations || []), userId],
+            updatedAt: new Date().toISOString()
+          }
+          ssb.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result))
+        })
+      })
+    },
+
+    async createOpinion(id, category) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      return new Promise((resolve, reject) => {
+        ssbClient.get(id, (err, msg) => {
+          if (err || !msg || msg.content?.type !== 'report') return reject(new Error('Report 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))
+        })
+      })
+    }
+  }
+}
+

File diff suppressed because it is too large
+ 117 - 0
src/models/search_model.js


+ 116 - 0
src/models/stats_model.js

@@ -0,0 +1,116 @@
+const pull = require('../server/node_modules/pull-stream');
+const os = require('os');
+const fs = require('fs');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
+
+  const types = [
+    'bookmark', 'event', 'task', 'votes', 'report', 'feed',
+    'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe', 'market'
+  ];
+
+  const getFolderSize = (folderPath) => {
+    const files = fs.readdirSync(folderPath);
+    let totalSize = 0;
+    for (const file of files) {
+      const filePath = `${folderPath}/${file}`;
+      const stats = fs.statSync(filePath);
+      totalSize += stats.isDirectory() ? getFolderSize(filePath) : stats.size;
+    }
+    return totalSize;
+  };
+
+  const formatSize = (sizeInBytes) => {
+    if (sizeInBytes < 1024) return `${sizeInBytes} B`;
+    const kb = 1024, mb = kb * 1024, gb = mb * 1024, tb = gb * 1024;
+    if (sizeInBytes < mb) return `${(sizeInBytes / kb).toFixed(2)} KB`;
+    if (sizeInBytes < gb) return `${(sizeInBytes / mb).toFixed(2)} MB`;
+    if (sizeInBytes < tb) return `${(sizeInBytes / gb).toFixed(2)} GB`;
+    return `${(sizeInBytes / tb).toFixed(2)} TB`;
+  };
+
+  const getStats = async (filter = 'ALL') => {
+    const ssbClient = await openSsb();
+    const userId = ssbClient.id;
+
+    const messages = await new Promise((res, rej) => {
+      pull(
+        ssbClient.createLogStream(),
+        pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+      );
+    });
+
+    const allMsgs = messages.filter(m => m.value?.content);
+    const tombstoned = new Set(allMsgs.filter(m => m.value.content.type === 'tombstone' && m.value.content.target).map(m => m.value.content.target));
+    const replacesMap = new Map();
+    const userMsgs = filter === 'MINE' ? allMsgs.filter(m => m.value.author === userId) : allMsgs;
+
+    const latestByType = {};
+    const opinions = {};
+    const content = {};
+
+    for (const t of types) {
+      latestByType[t] = new Map();
+      opinions[t] = 0;
+      content[t] = 0;
+    }
+
+    for (const m of userMsgs) {
+      const k = m.key;
+      const c = m.value.content;
+      const t = c.type;
+      if (!types.includes(t)) continue;
+      if (tombstoned.has(k)) continue;
+      if (c.replaces) replacesMap.set(c.replaces, k);
+      latestByType[t].set(k, { msg: m, content: c });
+    }
+
+    for (const replacedId of replacesMap.keys()) {
+      for (const t of types) {
+        latestByType[t].delete(replacedId);
+      }
+    }
+
+    for (const t of types) {
+      const values = Array.from(latestByType[t].values());
+      content[t] = values.length;
+      opinions[t] = values.filter(e => (e.content.opinions_inhabitants || []).length > 0).length;
+    }
+
+    const tribeContents = Array.from(latestByType['tribe'].values()).map(e => e.content);
+    const memberTribes = tribeContents
+      .filter(c => Array.isArray(c.members) && c.members.includes(userId))
+      .map(c => c.name || c.title || c.id);
+
+    const inhabitants = new Set(allMsgs.map(m => m.value.author)).size;
+
+    const secretStat = fs.statSync(`${os.homedir()}/.ssb/secret`);
+    const createdAt = secretStat.birthtime.toLocaleString();
+
+    const folderSize = getFolderSize(`${os.homedir()}/.ssb`);
+    const flumeSize = getFolderSize(`${os.homedir()}/.ssb/flume`);
+    const blobsSize = getFolderSize(`${os.homedir()}/.ssb/blobs`);
+
+    return {
+      id: userId,
+      createdAt,
+      inhabitants,
+      content,
+      opinions,
+      memberTribes,
+      userTombstoneCount: userMsgs.filter(m => m.value.content.type === 'tombstone').length,
+      networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
+      folderSize: formatSize(folderSize),
+      statsBlockchainSize: formatSize(flumeSize),
+      statsBlobsSize: formatSize(blobsSize)
+    };
+  };
+
+  return { getStats };
+};
+

+ 52 - 0
src/models/tags_model.js

@@ -0,0 +1,52 @@
+const pull = require('../server/node_modules/pull-stream');
+const moment = require('../server/node_modules/moment');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
+
+  return {
+    async listTags(filter = 'all') {
+      const ssbClient = await openSsb();
+
+      return new Promise((resolve, reject) => {
+        pull(
+          ssbClient.createLogStream(),
+          pull.filter(msg => {
+            const c = msg.value.content;
+            return c && Array.isArray(c.tags) && c.tags.length && c.type !== 'tombstone';
+          }),
+          pull.collect((err, results) => {
+            if (err) return reject(new Error(`Error retrieving tags: ${err.message}`));
+            const counts = {};
+
+            results.forEach(record => {
+              const c = record.value.content;
+              c.tags.filter(Boolean).forEach(tag => {
+                counts[tag] = (counts[tag] || 0) + 1;
+              });
+            });
+
+            let tags = Object.entries(counts).map(([name, count]) => ({ name, count }));
+
+            if (filter === 'top') {
+              tags.sort((a, b) => b.count - a.count);
+            } else if (filter === 'cloud') {
+              const max = Math.max(...tags.map(t => t.count), 1);
+              tags = tags.map(t => ({ ...t, weight: t.count / max }));
+            } else {
+              tags.sort((a, b) => a.name.localeCompare(b.name));
+            }
+
+            const deduplicatedTags = Array.from(new Map(tags.map(tag => [tag.name, tag])).values());
+
+            resolve(deduplicatedTags);
+          })
+        );
+      });
+    }
+  };
+};

+ 224 - 0
src/models/tasks_model.js

@@ -0,0 +1,224 @@
+const pull = require('../server/node_modules/pull-stream');
+const moment = require('../server/node_modules/moment');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+
+  return {
+    type: 'task',
+
+    async createTask(title, description, startTime, endTime, priority, location = '', tagsRaw = [], isPublic) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const start = moment(startTime);
+      const end = moment(endTime);
+      if (!start.isValid()) throw new Error('Invalid starting date');
+      if (!end.isValid()) throw new Error('Invalid ending date');
+      if (start.isBefore(moment())) throw new Error('Start time is in the past');
+      if (end.isBefore(start)) throw new Error('End time is before start time');
+      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
+      const content = {
+        type: 'task',
+        title,
+        description,
+        startTime: start.toISOString(),
+        endTime: end.toISOString(),
+        priority,
+        location,
+        tags,
+        isPublic,
+        assignees: [userId],
+        createdAt: new Date().toISOString(),
+        status: 'OPEN',
+        author: userId,
+        opinions: {},
+        opinions_inhabitants: []
+      };
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res));
+      });
+    },
+
+    async toggleAssignee(taskId) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const task = await this.getTaskById(taskId);
+      let assignees = Array.isArray(task.assignees) ? [...task.assignees] : [];
+      if (task.status === 'CLOSED') throw new Error('Cannot assign users to a closed task');
+      const idx = assignees.indexOf(userId);
+      if (idx !== -1) {
+        assignees.splice(idx, 1);
+      } else {
+        assignees.push(userId);
+      }
+      return this.updateTaskById(taskId, { assignees });
+    },
+
+    async updateTaskStatus(taskId, status) {
+      if (!['OPEN', 'IN-PROGRESS', 'CLOSED'].includes(status)) throw new Error('Invalid status');
+      return this.updateTaskById(taskId, { status });
+    },
+
+    async deleteTaskById(taskId) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      return new Promise((resolve, reject) => {
+        ssbClient.get(taskId, (err, task) => {
+          if (err || !task || !task.content) return reject(new Error('Task not found'));
+          if (task.content.author !== userId) return reject(new Error('Only the author can delete the task'));
+          const tombstone = {
+            type: 'tombstone',
+            target: taskId,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          };
+          ssbClient.publish(tombstone, (pubErr, res) => pubErr ? reject(pubErr) : resolve(res));
+        });
+      });
+    },
+
+  async updateTaskById(taskId, updatedData) {
+    const ssbClient = await openSsb();
+    const userId = ssbClient.id;
+
+    return new Promise((resolve, reject) => {
+      ssbClient.get(taskId, (err, task) => {
+        if (err || !task || !task.content) return reject(new Error('Task not found'));
+        if (Object.keys(task.content.opinions || {}).length > 0) return reject(new Error('Cannot edit task after it has received opinions.'));
+        if (task.content.status === 'CLOSED') return reject(new Error('Cannot edit a closed task.'));
+        if (updatedData.tags) {
+          updatedData.tags = Array.isArray(updatedData.tags) ? updatedData.tags : updatedData.tags.split(',').map(tag => tag.trim());
+        }
+
+        const tombstone = {
+          type: 'tombstone',
+          target: taskId,
+          deletedAt: new Date().toISOString(),
+          author: userId
+        };
+
+        const updated = {
+          ...task.content,
+          ...updatedData,
+          updatedAt: new Date().toISOString(),
+          replaces: taskId
+        };
+
+        ssbClient.publish(tombstone, (err) => {
+          if (err) return reject(err);
+          ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
+        });
+      });
+    });
+  },
+
+    async getTaskById(taskId) {
+      const ssbClient = await openSsb();
+      return new Promise((resolve, reject) => {
+        ssbClient.get(taskId, (err, task) => {
+          if (err || !task || !task.content || task.content.type === 'tombstone') return reject(new Error('Task not found'));
+          const c = task.content;
+          resolve({
+            id: taskId,
+            title: c.title,
+            description: c.description,
+            startTime: c.startTime,
+            endTime: c.endTime,
+            priority: c.priority,
+            location: c.location,
+            tags: Array.isArray(c.tags) ? c.tags : [],
+            isPublic: c.isPublic,
+            assignees: Array.isArray(c.assignees) ? c.assignees : [],
+            createdAt: c.createdAt,
+            status: c.status || 'OPEN',
+            author: c.author,
+            opinions: c.opinions || {},
+            opinions_inhabitants: c.opinions_inhabitants || []
+          });
+        });
+      });
+    },
+
+    async listAll(filter = 'all') {
+      const ssbClient = await openSsb();
+      return new Promise((resolve, reject) => {
+        pull(
+          ssbClient.createLogStream(),
+          pull.collect((err, results) => {
+            if (err) return reject(err);
+            const tombstoned = new Set();
+            const replaces = new Map();
+            const byId = new Map();
+            for (const msg of results) {
+              const k = msg.key;
+              const c = msg.value.content;
+              if (!c) continue;
+              if (c.type === 'tombstone' && c.target) {
+                tombstoned.add(c.target);
+                continue;
+              }
+              if (c.type === 'task') {
+                if (tombstoned.has(k)) continue;
+                if (c.replaces) replaces.set(c.replaces, k);
+                byId.set(k, {
+                  id: k,
+                  title: c.title,
+                  description: c.description,
+                  startTime: c.startTime,
+                  endTime: c.endTime,
+                  priority: c.priority,
+                  location: c.location,
+                  tags: Array.isArray(c.tags) ? c.tags : [],
+                  isPublic: c.isPublic,
+                  assignees: Array.isArray(c.assignees) ? c.assignees : [],
+                  createdAt: c.createdAt,
+                  status: c.status || 'OPEN',
+                  author: c.author,
+                  opinions: c.opinions || {},
+                  opinions_inhabitants: c.opinions_inhabitants || []
+                });
+              }
+            }
+            for (const replaced of replaces.keys()) {
+              byId.delete(replaced);
+            }
+            resolve(Array.from(byId.values()));
+          })
+        );
+      });
+    },
+
+    async createOpinion(id, category) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      return new Promise((resolve, reject) => {
+        ssbClient.get(id, (err, msg) => {
+          if (err || !msg || msg.content?.type !== 'task') return reject(new Error('Task not found'));
+          if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'));
+          const tombstone = {
+            type: 'tombstone',
+            target: id,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          };
+          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: id
+          };
+          ssbClient.publish(tombstone, (err) => {
+            if (err) return reject(err);
+            ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
+          });
+        });
+      });
+    }
+  };
+};
+

+ 208 - 0
src/models/transfers_model.js

@@ -0,0 +1,208 @@
+const pull = require('../server/node_modules/pull-stream');
+const moment = require('../server/node_modules/moment');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+
+  return {
+    type: 'transfer',
+
+    async createTransfer(to, concept, amount, deadline, tagsRaw = []) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      if (!/^@[A-Za-z0-9+\/]+= {0,2}\.ed25519$/.test(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) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
+      const isSelf = to === userId;
+      const content = {
+        type: 'transfer',
+        from: userId,
+        to,
+        concept,
+        amount: num.toFixed(6),
+        createdAt: new Date().toISOString(),
+        deadline: dl.toISOString(),
+        confirmedBy: isSelf ? [userId, userId] : [userId],
+        status: isSelf ? 'CLOSED' : 'UNCONFIRMED',
+        tags,
+        opinions: {},
+        opinions_inhabitants: []
+      };
+      return new Promise((resolve, reject) => {
+        ssb.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
+      });
+    },
+
+    async updateTransferById(id, to, concept, amount, deadline, tagsRaw = []) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      const old = await new Promise((res, rej) => ssb.get(id, (err, msg) => err || !msg?.content ? rej(err || new Error()) : res(msg)));
+      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');
+
+      const tomb = { type: 'tombstone', id, deletedAt: new Date().toISOString() };
+      await new Promise((res, rej) => ssb.publish(tomb, err => err ? rej(err) : res()));
+
+      if (!/^@[A-Za-z0-9+\/]+= {0,2}\.ed25519$/.test(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) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
+      const isSelf = to === userId;
+      const updated = {
+        type: 'transfer',
+        from: userId,
+        to,
+        concept,
+        amount: num.toFixed(6),
+        createdAt: old.content.createdAt,
+        deadline: dl.toISOString(),
+        confirmedBy: isSelf ? [userId, userId] : [userId],
+        status: isSelf ? 'CLOSED' : 'UNCONFIRMED',
+        tags,
+        opinions: {},
+        opinions_inhabitants: [],
+        updatedAt: new Date().toISOString(),
+        replaces: id
+      };
+      return new Promise((resolve, reject) => {
+        ssb.publish(updated, (err, msg) => err ? reject(err) : resolve(msg));
+      });
+    },
+
+    async confirmTransferById(id) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      return new Promise((resolve, reject) => {
+        ssb.get(id, async (err, msg) => {
+          if (err || !msg?.content) 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 upd = { ...t, confirmedBy: newConfirmed, status: newStatus, updatedAt: new Date().toISOString(), replaces: id };
+          const tombstone = { type: 'tombstone', id, deletedAt: new Date().toISOString() };
+          await new Promise((res, rej) => ssb.publish(tombstone, (err) => err ? rej(err) : res()));
+          ssb.publish(upd, (err, result) => err ? reject(err) : resolve(result));
+        });
+      });
+    },
+
+    async deleteTransferById(id) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      return new Promise((resolve, reject) => {
+        ssb.get(id, (err, msg) => {
+          if (err || !msg?.content) 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 tomb = { type: 'tombstone', id, deletedAt: new Date().toISOString() };
+          ssb.publish(tomb, err => err ? reject(err) : resolve());
+        });
+      });
+    },
+
+	async listAll(filter = 'all') {
+	  const ssb = await openSsb();
+	  return new Promise((resolve, reject) => {
+	    pull(
+	      ssb.createLogStream(),
+	      pull.collect(async (err, results) => {
+		if (err) return reject(err);
+		const tombstoned = new Set();
+		const replaces = new Map();
+		const transfersById = new Map();
+		const now = moment();
+
+		for (const r of results) {
+		  const c = r.value?.content;
+		  const k = r.key;
+		  if (!c) continue;
+		  if (c.type === 'tombstone' && c.id) {
+		    tombstoned.add(c.id);
+		    continue;
+		  }
+		  if (c.type === 'transfer') {
+		    if (tombstoned.has(k)) continue;
+		    if (c.replaces) replaces.set(c.replaces, k);
+		    transfersById.set(k, { id: k, ...c });
+		  }
+		}
+
+		for (const replacedId of replaces.keys()) {
+		  transfersById.delete(replacedId);
+		}
+
+		const deduped = Array.from(transfersById.values());
+
+		for (const item of deduped) {
+		  const dl = moment(item.deadline);
+		  if (item.status === 'UNCONFIRMED' && dl.isBefore(now)) {
+		    item.status = (item.confirmedBy || []).length >= 2 ? 'CLOSED' : 'DISCARDED';
+		  }
+		}
+
+		resolve(deduped);
+	      })
+	    );
+	  });
+	},
+
+    async getTransferById(id) {
+      const ssb = await openSsb();
+      return new Promise((resolve, reject) => {
+        ssb.get(id, (err, msg) => {
+          if (err || !msg?.content || msg.content.type === 'tombstone') return reject(new Error('Not found'));
+          const c = msg.content;
+          resolve({
+            id,
+            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 || []
+          });
+        });
+      });
+    },
+
+    async createOpinion(id, category) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      return new Promise((resolve, reject) => {
+        ssb.get(id, (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: id
+          };
+          ssb.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
+        });
+      });
+    }
+  };
+};
+

+ 141 - 0
src/models/trending_model.js

@@ -0,0 +1,141 @@
+const pull = require('../server/node_modules/pull-stream');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
+
+  const types = [
+    'bookmark', 'event', 'task', 'votes', 'report', 'feed',
+    'image', 'audio', 'video', 'document', 'transfer'
+  ];
+
+  const categories = [
+    'interesting', 'necessary', 'funny', 'disgusting', 'sensible',
+    'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'
+  ];
+
+  const listTrending = async (filter = 'ALL') => {
+    const ssbClient = await openSsb();
+    const userId = ssbClient.id;
+
+    const messages = await new Promise((res, rej) => {
+      pull(
+        ssbClient.createLogStream(),
+        pull.collect((err, xs) => err ? rej(err) : res(xs))
+      );
+    });
+
+    const tombstoned = new Set();
+    const replaces = new Map();
+    const itemsById = 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.opinions) {
+        if (tombstoned.has(k)) continue;
+        if (c.replaces) replaces.set(c.replaces, k);
+        itemsById.set(k, m);
+      }
+    }
+
+    for (const replacedId of replaces.keys()) {
+      itemsById.delete(replacedId);
+    }
+
+    let items = Array.from(itemsById.values());
+
+    if (filter === 'MINE') {
+      items = items.filter(m => m.value.author === userId);
+    } else if (filter === 'RECENT') {
+      const now = Date.now();
+      items = items.filter(m => now - m.value.timestamp < 24 * 60 * 60 * 1000);
+    }
+
+    if (types.includes(filter)) {
+      items = items.filter(m => m.value.content.type === filter);
+    }
+
+    if (filter !== 'ALL') {
+      items = items.filter(m => (m.value.content.opinions_inhabitants || []).length > 0);
+    }
+
+    if (filter === 'TOP') {
+      items.sort((a, b) => {
+        const aLen = (a.value.content.opinions_inhabitants || []).length;
+        const bLen = (b.value.content.opinions_inhabitants || []).length;
+        if (bLen !== aLen) return bLen - aLen;
+        return b.value.timestamp - a.value.timestamp;
+      });
+    } else {
+      items.sort((a, b) => {
+        const aLen = (a.value.content.opinions_inhabitants || []).length;
+        const bLen = (b.value.content.opinions_inhabitants || []).length;
+        return bLen - aLen;
+      });
+    }
+
+    return { filtered: items };
+  };
+
+  const getMessageById = async id => {
+    const ssbClient = await openSsb();
+    return new Promise((res, rej) => {
+      ssbClient.get(id, (err, msg) => {
+        if (err) rej(err);
+        else res(msg);
+      });
+    });
+  };
+
+  const createVote = async (contentId, category) => {
+    const ssbClient = await openSsb();
+    const userId = ssbClient.id;
+
+    if (!categories.includes(category)) throw new Error('Invalid voting category');
+
+    const msg = await getMessageById(contentId);
+    if (!msg || !msg.content) throw new Error('Content not found');
+
+    const type = msg.content.type;
+    if (!types.includes(type)) throw new Error('Invalid content type for voting');
+
+    if (msg.content.opinions_inhabitants?.includes(userId)) throw new Error('Already voted');
+
+    const tombstone = {
+      type: 'tombstone',
+      target: contentId,
+      deletedAt: new Date().toISOString()
+    };
+
+    const updated = {
+      ...msg.content,
+      opinions: {
+        ...msg.content.opinions,
+        [category]: (msg.content.opinions?.[category] || 0) + 1
+      },
+      opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
+      updatedAt: new Date().toISOString(),
+      replaces: contentId
+    };
+
+    await new Promise((res, rej) => {
+      ssbClient.publish(tombstone, (err) => err ? rej(err) : res());
+    });
+
+    return new Promise((res, rej) => {
+      ssbClient.publish(updated, (err, result) => err ? rej(err) : res(result));
+    });
+  };
+
+  return { listTrending, getMessageById, createVote, types, categories };
+};
+

+ 420 - 0
src/models/tribes_model.js

@@ -0,0 +1,420 @@
+const pull = require('../server/node_modules/pull-stream');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
+
+  return {
+    type: 'tribe',
+
+    async createTribe(title, description, image, location, tagsRaw = [], isLARP = false, isAnonymous = true, inviteMode = 'strict') {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      let blobId = null;
+      if (image) {
+        const match = image.match(/\(([^)]+)\)/);
+        blobId = match ? match[1] : image;
+      }
+      const tags = Array.isArray(tagsRaw)
+        ? tagsRaw.filter(Boolean)
+        : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
+      const content = {
+        type: 'tribe',
+        title,
+        description,
+        image: blobId,
+        location,
+        tags,
+        isLARP: Boolean(isLARP),
+        isAnonymous: Boolean(isAnonymous),
+        members: [userId],
+        invites: [],
+        inviteMode,
+        createdAt: new Date().toISOString(),
+        updatedAt: new Date().toISOString(),
+        author: userId,
+        feed: [],
+      };
+      return new Promise((res, rej) => ssb.publish(content, (e, r) => e ? rej(e) : res(r)));
+    },
+
+    async generateInvite(tribeId) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+
+      const tribe = await this.getTribeById(tribeId);
+      if (tribe.inviteMode === 'strict' && tribe.author !== userId) {
+        throw new Error('Only the author can generate invites in strict mode');
+      }
+      if (tribe.inviteMode === 'open' && !tribe.members.includes(userId)) {
+        throw new Error('Only tribe members can generate invites in open mode');
+      }
+
+      const code = Math.random().toString(36).substring(2, 10);
+      const invites = Array.isArray(tribe.invites) ? [...tribe.invites, code] : [code];
+
+      await this.updateTribeInvites(tribeId, invites);
+      return code;
+    },
+
+    async updateTribeInvites(tribeId, invites) {
+      const ssb = await openSsb();
+      const tribe = await this.getTribeById(tribeId);
+      const updatedTribe = {
+        type: 'tribe',
+        replaces: tribeId,
+        title: tribe.title,
+        description: tribe.description,
+        image: tribe.image,
+        location: tribe.location,
+        tags: tribe.tags,
+        isLARP: tribe.isLARP,
+        isAnonymous: tribe.isAnonymous,
+        members: tribe.members,
+        invites: invites,
+        inviteMode: tribe.inviteMode,
+        createdAt: tribe.createdAt,
+        updatedAt: new Date().toISOString(),
+        author: tribe.author,
+        feed: tribe.feed
+      };
+      return this.publishUpdatedTribe(tribeId, updatedTribe);
+    },
+
+    async leaveTribe(tribeId) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      const tribe = await this.getTribeById(tribeId);
+      if (!tribe) throw new Error('Tribe not found');
+      const members = Array.isArray(tribe.members) ? [...tribe.members] : [];
+      const idx = members.indexOf(userId);
+      if (idx === -1) throw new Error('Inhabitant is not a member of the tribe');
+      members.splice(idx, 1);
+      const updatedTribe = {
+        type: 'tribe',
+        replaces: tribeId, 
+        title: tribe.title,
+        description: tribe.description,
+        image: tribe.image,
+        location: tribe.location,
+        tags: tribe.tags,
+        isLARP: tribe.isLARP,
+        isAnonymous: tribe.isAnonymous,
+        members: members,
+        invites: tribe.invites,
+        inviteMode: tribe.inviteMode,
+        createdAt: tribe.createdAt,
+        updatedAt: new Date().toISOString(),
+        author: tribe.author,
+        feed: tribe.feed
+      };
+      return new Promise((resolve, reject) => {
+        ssb.publish(updatedTribe, (err, result) => err ? reject(err) : resolve(result));
+      });
+    },
+
+    async joinByInvite(code) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      const tribes = await this.listAll();
+      const latestTribe = tribes.find(tribe => tribe.invites && tribe.invites.includes(code));
+      if (!latestTribe) {
+        return new Promise((_, rej) => rej(new Error('Invalid or expired invite code.')));
+      }
+      const tribe = latestTribe;
+      if (!tribe.invites.includes(code)) {
+        return new Promise((_, rej) => rej(new Error('Invalid or expired invite code.')));
+      }
+      const members = Array.isArray(tribe.members) ? [...tribe.members] : [];
+      if (!members.includes(userId)) members.push(userId);
+      const updatedInvites = tribe.invites.filter(c => c !== code);
+      const updatedTribe = {
+        type: 'tribe',
+        replaces: tribe.id, 
+        title: tribe.title,
+        description: tribe.description,
+        image: tribe.image,
+        location: tribe.location,
+        tags: tribe.tags,
+        isLARP: tribe.isLARP,
+        isAnonymous: tribe.isAnonymous,
+        members: members,
+        invites: updatedInvites,
+        inviteMode: tribe.inviteMode,
+        createdAt: tribe.createdAt,
+        updatedAt: new Date().toISOString(),
+        author: tribe.author,
+        feed: tribe.feed
+      };
+      await this.publishUpdatedTribe(tribe.id, updatedTribe);
+      return new Promise((res) => res(tribe.id));
+    },
+
+    async deleteTribeById(tribeId) {
+       await this.publishTombstone(tribeId);
+    },
+
+    async updateTribeMembers(tribeId, members) {
+      const ssb = await openSsb();
+      const tribe = await this.getTribeById(tribeId);
+
+      const updatedTribe = {
+        type: 'tribe',
+        replaces: tribeId,
+        title: tribe.title,
+        description: tribe.description,
+        image: tribe.image,
+        location: tribe.location,
+        tags: tribe.tags,
+        isLARP: tribe.isLARP,
+        isAnonymous: tribe.isAnonymous,
+        members: members,
+        invites: tribe.invites,
+        inviteMode: tribe.inviteMode,
+        createdAt: tribe.createdAt,
+        updatedAt: new Date().toISOString(),
+        author: tribe.author,
+        feed: tribe.feed
+      };
+      return this.publishUpdatedTribe(tribeId, updatedTribe);
+    },
+
+    async updateTribeFeed(tribeId, newFeed) {
+      const ssb = await openSsb();
+      const tribe = await this.getTribeById(tribeId);
+
+      const updatedTribe = {
+        type: 'tribe',
+        replaces: tribeId,
+        title: tribe.title,
+        description: tribe.description,
+        image: tribe.image,
+        location: tribe.location,
+        tags: tribe.tags,
+        isLARP: tribe.isLARP,
+        isAnonymous: tribe.isAnonymous,
+        members: tribe.members,
+        invites: tribe.invites,
+        inviteMode: tribe.inviteMode,
+        createdAt: tribe.createdAt,
+        updatedAt: new Date().toISOString(),
+        author: tribe.author,
+        feed: newFeed
+      };
+      return this.publishUpdatedTribe(tribeId, updatedTribe);
+    },
+
+    async publishUpdatedTribe(tribeId, updatedTribe) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      await this.publishTombstone(tribeId);
+      const updatedTribeData = {
+        type: 'tribe',
+        title: updatedTribe.title,
+        description: updatedTribe.description,
+        image: updatedTribe.image,
+        location: updatedTribe.location,
+        tags: updatedTribe.tags,
+        isLARP: updatedTribe.isLARP,
+        isAnonymous: updatedTribe.isAnonymous,
+        members: updatedTribe.members,
+        invites: updatedTribe.invites,
+        inviteMode: updatedTribe.inviteMode,
+        createdAt: updatedTribe.createdAt,
+        updatedAt: new Date().toISOString(),
+        author: updatedTribe.author,
+        feed: updatedTribe.feed
+      };
+      return new Promise((resolve, reject) => {
+         ssb.publish(updatedTribeData, (err, result) => err ? reject(err) : resolve(result));
+      });
+    },
+
+    async getTribeById(tribeId) {
+      const ssb = await openSsb();
+      return new Promise((res, rej) => pull(
+        ssb.createLogStream(),
+        pull.collect((err, msgs) => {
+          if (err) return rej(err);
+          const tombstoned = new Set();
+          const replaces = new Map();
+          const tribes = new Map();
+          for (const msg of msgs) {
+            const k = msg.key;
+            const c = msg.value?.content;
+            if (!c) continue;
+            if (c.type === 'tombstone' && c.target) tombstoned.add(c.target);
+            if (c.type === 'tribe') {
+              if (tombstoned.has(k)) continue;
+              if (c.replaces) replaces.set(c.replaces, k);
+              tribes.set(k, { id: k, content: c });
+            }
+          }
+          let latestId = tribeId;
+          while (replaces.has(latestId)) latestId = replaces.get(latestId);
+          const tribe = tribes.get(latestId);
+          if (!tribe) return rej(new Error('Tribe not found'));
+          res({
+            id: tribe.id,
+            title: tribe.content.title,
+            description: tribe.content.description,
+            image: tribe.content.image || null,
+            location: tribe.content.location,
+            tags: Array.isArray(tribe.content.tags) ? tribe.content.tags : [],
+            isLARP: tribe.content.isLARP,
+            isAnonymous: tribe.content.isAnonymous,
+            members: Array.isArray(tribe.content.members) ? tribe.content.members : [],
+            invites: Array.isArray(tribe.content.invites) ? tribe.content.invites : [],
+            inviteMode: tribe.content.inviteMode || 'strict',
+            createdAt: tribe.content.createdAt,
+            updatedAt: tribe.content.updatedAt,
+            author: tribe.content.author,
+            feed: Array.isArray(tribe.content.feed) ? tribe.content.feed : []
+          });
+        })
+      ));
+    },
+
+    async listAll() {
+      const ssb = await openSsb();
+      return new Promise((res, rej) => pull(
+        ssb.createLogStream(),
+        pull.collect((err, msgs) => {
+          if (err) return rej(err);
+          const tombstoned = new Set();
+          const replaces = new Map();
+          const tribes = new Map();
+          for (const msg of msgs) {
+            const k = msg.key;
+            const c = msg.value?.content;
+            if (!c) continue;
+            if (c.type === 'tombstone' && c.target) tombstoned.add(c.target);
+            if (c.type === 'tribe') {
+              if (tombstoned.has(k)) continue;
+              if (c.replaces) replaces.set(c.replaces, k);
+              tribes.set(k, { id: k, content: c });
+            }
+          }
+          const seen = new Set();
+          const finalTribes = [];
+
+          for (const [originalId, { content }] of tribes.entries()) {
+            let latestId = originalId;
+            let latestVersion = content;
+            while (replaces.has(latestId)) {
+              latestId = replaces.get(latestId);
+              const nextTribe = tribes.get(latestId);
+              if (nextTribe && nextTribe.content.updatedAt > latestVersion.updatedAt) {
+                latestVersion = nextTribe.content;
+              }
+            }
+            if (!seen.has(latestId) && !tombstoned.has(latestId)) {
+              seen.add(latestId);
+              finalTribes.push({
+                id: latestId,
+                type: latestVersion.type,
+                title: latestVersion.title,
+                description: latestVersion.description,
+                image: latestVersion.image || null,
+                location: latestVersion.location,
+                tags: Array.isArray(latestVersion.tags) ? latestVersion.tags : [],
+                isLARP: latestVersion.isLARP || false,
+                isAnonymous: latestVersion.isAnonymous === false ? false : true,
+                members: Array.isArray(latestVersion.members) ? latestVersion.members : [],
+                invites: Array.isArray(latestVersion.invites) ? latestVersion.invites : [],
+                inviteMode: latestVersion.inviteMode || 'strict',
+                createdAt: latestVersion.createdAt,
+                updatedAt: latestVersion.updatedAt,
+                author: latestVersion.author,
+                feed: Array.isArray(latestVersion.feed) ? latestVersion.feed : []
+              });
+            }
+          }
+          res(finalTribes);
+        })
+      ));
+    },
+    
+    async updateTribeById(tribeId, updatedContent) {
+      const ssb = await openSsb();
+      const tribe = await this.getTribeById(tribeId);
+      if (!tribe) throw new Error('Tribe not found');
+      const updatedTribe = {
+        type: 'tribe',
+        ...tribe,
+        ...updatedContent,
+        updatedAt: new Date().toISOString()
+      };
+      return this.publishUpdatedTribe(tribeId, updatedTribe);
+    },
+
+    async publishTombstone(tribeId) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+
+      const tombstone = {
+        type: 'tombstone',
+        target: tribeId,
+        deletedAt: new Date().toISOString(),
+        author: userId
+      };
+
+      await new Promise((resolve, reject) => {
+      ssb.publish(tombstone, (err) => {
+        if (err) return reject(err);
+        resolve();
+        });
+      });
+      const tribe = await this.getTribeById(tribeId); 
+      const tombstonedTribe = {
+        type: 'tombstone',
+        title: tribe.title,
+        description: tribe.description,
+        image: tribe.image,
+        location: tribe.location,
+        tags: tribe.tags,
+        isLARP: tribe.isLARP,
+        isAnonymous: tribe.isAnonymous,
+        members: [],
+        invites: [],
+        inviteMode: tribe.inviteMode,
+        createdAt: tribe.createdAt,
+        updatedAt: new Date().toISOString(),
+        author: tribe.author,
+        feed: tribe.feed
+      };
+      return new Promise((resolve, reject) => {
+        ssb.publish(tombstonedTribe, (err, result) => err ? reject(err) : resolve(result));
+      });
+    },
+
+    async refeed(tribeId, messageId) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      const tribe = await this.getTribeById(tribeId);
+      if (!tribe.isAnonymous && !tribe.members.includes(userId)) throw new Error('Not a member');
+      const feed = tribe.feed.map(item => {
+        item.refeeds_inhabitants = item.refeeds_inhabitants || [];
+        if (item.id === messageId && !item.refeeds_inhabitants.includes(userId)) {
+          item.refeeds = (item.refeeds || 0) + 1;
+          item.refeeds_inhabitants.push(userId);
+        }
+        return item;
+      });
+      await this.updateTribeFeed(tribeId, feed);
+    },
+
+    async postMessage(tribeId, message) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      const tribe = await this.getTribeById(tribeId);
+      if (!tribe.isAnonymous && !tribe.members.includes(userId)) throw new Error('Not a member');
+      const now = Date.now();
+      const feedItem = { type: 'feed', id: now.toString(), date: now, author: userId, message, refeeds: 0, refeeds_inhabitants: [] };
+      const feed = [...tribe.feed, feedItem];
+      await this.updateTribeFeed(tribeId, feed);
+      return feedItem;
+    }
+  };
+};
+

+ 195 - 0
src/models/videos_model.js

@@ -0,0 +1,195 @@
+const pull = require('../server/node_modules/pull-stream');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
+
+  return {
+    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 content = {
+        type: 'video',
+        url: blobId,
+        createdAt: new Date().toISOString(),
+        author: userId,
+        tags,
+        title: title || '',
+        description: description || '',
+        opinions: {},
+        opinions_inhabitants: []
+      };
+      return new Promise((resolve, reject) => {
+        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;
+      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, err => {
+            if (err) return reject(err);
+            ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
+          });
+        });
+      });
+    },
+
+    async deleteVideoById(id) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      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));
+        });
+      });
+    },
+
+    async listAll(filter = 'all') {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const messages = await new Promise((res, rej) => {
+        pull(
+          ssbClient.createLogStream(),
+          pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+        );
+      });
+      const tombstoned = new Set();
+      const replaces = new Map();
+      const 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 (tombstoned.has(k)) continue;
+        if (c.replaces) replaces.set(c.replaces, k);
+        videos.set(k, {
+          key: k,
+          url: c.url,
+          createdAt: c.createdAt,
+          updatedAt: c.updatedAt || null,
+          tags: c.tags || [],
+          author: c.author,
+          title: c.title || '',
+          description: c.description || '',
+          opinions: c.opinions || {},
+          opinions_inhabitants: c.opinions_inhabitants || []
+        });
+      }
+      for (const replaced of replaces.keys()) {
+        videos.delete(replaced);
+      }
+      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 sumA = Object.values(a.opinions).reduce((s, v) => s + v, 0);
+          const sumB = Object.values(b.opinions).reduce((s, v) => s + v, 0);
+          return sumB - sumA;
+        });
+      } else {
+        out = out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+      }
+      return out;
+    },
+
+    async getVideoById(id) {
+      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 || []
+          });
+        });
+      });
+    },
+
+    async createOpinion(id, category) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      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 tombstone = {
+            type: 'tombstone',
+            target: id,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          };
+          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: id
+          };
+          ssbClient.publish(tombstone, err => {
+            if (err) return reject(err);
+            ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
+          });
+        });
+      });
+    }
+  };
+};
+

+ 294 - 0
src/models/votes_model.js

@@ -0,0 +1,294 @@
+const pull = require('../server/node_modules/pull-stream');
+const moment = require('../server/node_modules/moment');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+
+  return {
+    async createVote(question, deadline, options = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'], tagsRaw = []) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      const parsedDeadline = moment(deadline, moment.ISO_8601, true);
+      if (!parsedDeadline.isValid()) throw new Error('Invalid deadline');
+      if (parsedDeadline.isBefore(moment())) throw new Error('Deadline must be in the future');
+      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
+      const content = {
+        type: 'votes',
+        question,
+        options,
+        deadline: parsedDeadline.toISOString(),
+        createdBy: userId,
+        status: 'OPEN',
+        votes: options.reduce((acc, opt) => { acc[opt] = 0; return acc; }, {}),
+        totalVotes: 0,
+        voters: [],
+        tags,
+        opinions: {},
+        opinions_inhabitants: [],
+        createdAt: new Date().toISOString()
+      };
+      return new Promise((resolve, reject) => {
+        ssb.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
+      });
+    },
+
+    async updateVoteById(id, updatedData) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+        const vote = await new Promise((resolve, reject) => {
+          ssb.get(id, (err, vote) => {
+          if (err || !vote?.content) return reject(new Error('Vote not found'));
+        resolve(vote);
+      });
+    });
+
+    if (vote.content.createdBy !== userId) {
+      throw new Error('Not the author');
+    }
+    if (vote.content.totalVotes > 0) {
+      throw new Error('Already voted');
+    }
+    const deadline = moment(vote.content.deadline);
+    if (!deadline.isValid() || deadline.isBefore(moment())) {
+      throw new Error('Deadline passed');
+    }
+    let tags = [];
+    if (updatedData.tags) {
+      if (Array.isArray(updatedData.tags)) {
+        tags = updatedData.tags.filter(Boolean); 
+      } else {
+        tags = updatedData.tags.split(',').map(t => t.trim()).filter(Boolean);
+      }
+    } else {
+      tags = vote.content.tags || [];
+    }
+
+    const tombstone = {
+      type: 'tombstone',
+      target: id,
+      deletedAt: new Date().toISOString(),
+      author: userId
+    };
+
+    const updated = {
+      ...vote.content,
+      ...updatedData,
+      tags,
+      updatedAt: new Date().toISOString(),
+      replaces: id
+    };
+    await new Promise((resolve, reject) => {
+      ssb.publish(tombstone, (err, res) => {
+        if (err) return reject(err);
+        resolve(res);
+      });
+    });
+    const result = await new Promise((resolve, reject) => {
+      ssb.publish(updated, (err, res) => {
+        if (err) return reject(err);
+        resolve(res);
+      });
+    });
+      return result;
+    },
+
+    async deleteVoteById(id) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      return new Promise((resolve, reject) => {
+        ssb.get(id, (err, vote) => {
+          if (err || !vote?.content) return reject(new Error('Vote not found'));
+          if (vote.content.createdBy !== userId) return reject(new Error('Not the author'));
+          const tombstone = {
+            type: 'tombstone',
+            target: id,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          };
+          ssb.publish(tombstone, (err, res) => err ? reject(err) : resolve(res));
+        });
+      });
+    },
+
+    async voteOnVote(id, choice) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      return new Promise((resolve, reject) => {
+        ssb.get(id, (err, vote) => {
+          if (err || !vote?.content) return reject(new Error('Vote not found'));
+          const validChoices = vote.content.options || [];
+          if (!validChoices.includes(choice)) return reject(new Error('Invalid choice'));
+          const { voters = [], votes = {}, totalVotes = 0 } = vote.content;
+          if (voters.includes(userId)) return reject(new Error('Already voted'));
+          votes[choice] = (votes[choice] || 0) + 1;
+          voters.push(userId);
+          const tombstone = {
+            type: 'tombstone',
+            target: id,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          };
+          const updated = {
+            ...vote.content,
+            votes,
+            voters,
+            totalVotes: totalVotes + 1,
+            updatedAt: new Date().toISOString(),
+            replaces: id
+          };
+          ssb.publish(tombstone, err => {
+            if (err) return reject(err);
+            ssb.publish(updated, (err2, res2) => err2 ? reject(err2) : resolve(res2));
+          });
+        });
+      });
+    },
+
+    async getVoteById(id) {
+      const ssb = await openSsb();
+      return new Promise((resolve, reject) => {
+        ssb.get(id, async (err, vote) => {
+          if (err || !vote?.content) return reject(new Error('Vote not found'));
+          const c = vote.content;
+          const deadlineMoment = moment(c.deadline);
+          let status = c.status || 'OPEN';
+          if (deadlineMoment.isValid() && deadlineMoment.isBefore(moment()) && status !== 'CLOSED') {
+            const tombstone = {
+              type: 'tombstone',
+              target: id,
+              deletedAt: new Date().toISOString(),
+              author: c.createdBy
+            };
+            const updated = {
+              ...c,
+              status: 'CLOSED',
+              updatedAt: new Date().toISOString(),
+              replaces: id
+            };
+            await ssb.publish(tombstone);
+            await ssb.publish(updated);
+            status = 'CLOSED';
+          }
+          resolve({
+            id,
+            question: c.question,
+            options: c.options,
+            votes: c.votes,
+            totalVotes: c.totalVotes,
+            status,
+            deadline: c.deadline,
+            createdBy: c.createdBy,
+            createdAt: c.createdAt,
+            tags: c.tags || [],
+            opinions: c.opinions || {},
+            opinions_inhabitants: c.opinions_inhabitants || []
+          });
+        });
+      });
+    },
+
+    async listAll(filter = 'all') {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      return new Promise((resolve, reject) => {
+        pull(
+          ssb.createLogStream(),
+          pull.collect(async (err, results) => {
+            if (err) return reject(err);
+            const tombstoned = new Set();
+            const replaces = new Map();
+            const byId = new Map();
+            const now = moment();
+            for (const r of results) {
+              const k = r.key;
+              const c = r.value.content;
+              if (!c) continue;
+              if (c.type === 'tombstone' && c.target) {
+                tombstoned.add(c.target);
+                continue;
+              }
+              if (c.type === 'votes') {
+                if (tombstoned.has(k)) continue;
+                if (c.replaces) replaces.set(c.replaces, k);
+                let status = c.status || 'OPEN';
+                const deadline = moment(c.deadline);
+                if (deadline.isValid() && deadline.isBefore(now) && status !== 'CLOSED') {
+                  const tomb = {
+                    type: 'tombstone',
+                    target: k,
+                    deletedAt: new Date().toISOString(),
+                    author: c.createdBy
+                  };
+                  const updated = {
+                    ...c,
+                    status: 'CLOSED',
+                    updatedAt: new Date().toISOString(),
+                    replaces: k
+                  };
+                  await ssb.publish(tomb);
+                  await ssb.publish(updated);
+                  status = 'CLOSED';
+                }
+                byId.set(k, {
+                  id: k,
+                  question: c.question,
+                  options: c.options,
+                  votes: c.votes,
+                  totalVotes: c.totalVotes,
+                  status,
+                  deadline: c.deadline,
+                  createdBy: c.createdBy,
+                  createdAt: c.createdAt,
+                  tags: c.tags || [],
+                  opinions: c.opinions || {},
+                  opinions_inhabitants: c.opinions_inhabitants || []
+                });
+              }
+            }
+            for (const replaced of replaces.keys()) {
+              byId.delete(replaced);
+            }
+            const out = Array.from(byId.values());
+            if (filter === 'mine') return resolve(out.filter(v => v.createdBy === userId));
+            if (filter === 'open') return resolve(out.filter(v => v.status === 'OPEN'));
+            if (filter === 'closed') return resolve(out.filter(v => v.status === 'CLOSED'));
+            resolve(out);
+          })
+        );
+      });
+    },
+
+    async createOpinion(id, category) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      return new Promise((resolve, reject) => {
+        ssb.get(id, (err, msg) => {
+          if (err || !msg || msg.content?.type !== 'votes') return reject(new Error('Vote not found'));
+          if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'));
+          const tombstone = {
+            type: 'tombstone',
+            target: id,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          };
+          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: id
+          };
+          ssb.publish(tombstone, err => {
+            if (err) return reject(err);
+            ssb.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
+          });
+        });
+      });
+    }
+  };
+};
+

+ 51 - 0
src/models/wallet_model.js

@@ -0,0 +1,51 @@
+const {
+  RequestManager,
+  HTTPTransport,
+  Client } = require("../server/node_modules/@open-rpc/client-js");
+
+module.exports = {
+    client: async (url, user, pass) => {
+      const transport = new HTTPTransport(url, {
+        headers: {
+          'Authorization': 'Basic ' + btoa(`${user}:${pass}`)
+        }
+      });
+      return new Client(new RequestManager([transport]));
+    },
+    execute: async (url, user, pass, method, params = []) => {
+      try {
+        const clientrpc = await module.exports.client(url, user, pass);
+        return await clientrpc.request({ method, params });
+      } catch (error) {
+        throw new Error(
+          "ECOin wallet disconnected. " +
+          "Check your wallet settings or connection status."
+        );
+      }
+    },
+    getBalance: async (url, user, pass) => {
+      return await module.exports.execute(url, user, pass, "getbalance");
+    },
+    getAddress: async (url, user, pass) => {
+      const addresses = await module.exports.execute(url, user, pass, "getaddressesbyaccount", ['']);
+      return addresses[0]  // TODO: Handle multiple addresses
+    },
+    listTransactions: async (url, user, pass) => {
+      return await module.exports.execute(url, user, pass, "listtransactions", ["", 1000000, 0]);
+    },
+    sendToAddress: async (url, user, pass, address, amount) => {
+      return await module.exports.execute(url, user, pass, "sendtoaddress", [address, amount]);
+    },
+    validateSend: async (url, user, pass, address, amount, fee) => {
+      let isValid = false
+      const errors = [];
+      const addressValid = await module.exports.execute(url, user, pass, "validateaddress", [address]);
+      const amountValid = amount > 0;
+      const feeValid = fee > 0;
+      if (!addressValid.isvalid) { errors.push("invalid_dest") }
+      if (!amountValid) { errors.push("invalid_amount") }
+      if (!feeValid) { errors.push("invalid_fee") }
+      if (errors.length == 0) { isValid = true }
+      return { isValid, errors }
+    }
+}

+ 83 - 212
src/server/SSB_server.js

@@ -1,231 +1,102 @@
 #!/usr/bin/env node
-
 const moduleAlias = require('module-alias');
 moduleAlias.addAlias('punycode', 'punycode/');
 
-var fs = require('fs')
-var path = require('path')
-const SecretStack = require('secret-stack')
-var caps = require('ssb-caps')
-var SSB = require('ssb-db')
-var Client       = require('ssb-client')
-var cmdAliases   = require('../client/cli-cmd-aliases')
-var packageJson  = require('./package.json')
-var Config       = require('ssb-config/inject')
-var minimist     = require('minimist')
-var muxrpcli     = require('muxrpcli')
-
-const configPath = path.resolve(__dirname, '../configs', 'server-config.json');
-const configData = JSON.parse(fs.readFileSync(configPath, 'utf8'));
-
-let argv = process.argv.slice(2);
-const i = argv.indexOf('--');
-const conf = argv.slice(i + 1);
-argv = ~i ? argv.slice(0, i) : argv;
-
-let config = Config('ssb', minimist(conf));
-config = {...config, ...configData};
+const fs = require('fs');
+const path = require('path');
+const SecretStack = require('secret-stack');
+const caps = require('ssb-caps');
+const SSB = require('ssb-db');
+const config = require('./ssb_config');
+const { printMetadata } = require('./ssb_metadata');
+
+require('ssb-plugins').loadUserPlugins(SecretStack({ caps }), config);
+
+const Server = SecretStack({ caps })
+  .use(SSB)
+  .use(require('ssb-master'))
+  .use(require('ssb-gossip'))
+  .use(require('ssb-ebt'))
+  .use(require('ssb-friends'))
+  .use(require('ssb-blobs'))
+  .use(require('ssb-lan'))
+  .use(require('ssb-meme'))
+  .use(require('ssb-plugins'))
+  .use(require('ssb-conn'))
+  .use(require('ssb-box'))
+  .use(require('ssb-search'))
+  .use(require('ssb-private'))
+  .use(require('ssb-friend-pub'))
+  .use(require('ssb-invite-client'))
+  .use(require('ssb-logging'))
+  .use(require('ssb-replication-scheduler'))
+  .use(require('ssb-partial-replication'))
+  .use(require('ssb-about'))
+  .use(require('ssb-onion'))
+  .use(require('ssb-unix-socket'))
+  .use(require('ssb-no-auth'))
+  .use(require('ssb-backlinks'))
+  .use(require('ssb-links'))
+  .use(require('ssb-tangle'))
+  .use(require('ssb-query'));
+  
+if (config.autofollow?.enabled !== false) {
+  Server.use(require('ssb-autofollow'));
+}
 
 const manifestFile = path.join(config.path, 'manifest.json');
-
-console.log("=========================");
-console.log("Package:", packageJson.name, "[Version: " + packageJson.version + "]");
-//console.log("Server path:", config.path);
-//console.log("Loading configuration from:", configPath);
-console.log("Logging Level:", config.logging.level);
-console.log("Public Key ID: [", config.keys.public,"]");
-console.log("=========================");
-const modules = [
-  'ssb-master', 'ssb-gossip', 'ssb-ebt', 'ssb-friends', 'ssb-blobs', 'ssb-lan', 'ssb-meme',
-  'ssb-ooo', 'ssb-plugins', 'ssb-conn', 'ssb-box', 'ssb-search', 'ssb-friend-pub', 'ssb-invite-client',
-  'ssb-logging', 'ssb-replication-scheduler', 'ssb-partial-replication', 'ssb-about', 'ssb-onion',
-  'ssb-unix-socket', 'ssb-no-auth', 'ssb-backlinks', 'ssb-links'
-];
-console.log("Modules loaded: [", modules.length, "] ->", modules.join(', '));
-console.log("=========================");
-
-function showProgress(progress) {
-  function bar(r) {
-    let s = '\r', M = 50;
-    for (let i = 0; i < M; i++) {
-      s += i < M * r ? '*' : '.';
-    }
-    return s;
-  }
-
-  function round(n, p) {
-    return Math.round(n * p) / p;
-  }
-
-  function percent(n) {
-    return (round(n, 1000) * 100).toString().substring(0, 4) + '%';
-  }
-
-  function rate(prog) {
-    if (prog.target == prog.current) return 1;
-    return (prog.current - prog.start) / (prog.target - prog.start);
-  }
-
-  let prog = -1;
-  const int = setInterval(function () {
-    const p = progress();
-    let r = 1, c = 0;
-    const tasks = [];
-    for (let k in p) {
-      const _r = rate(p[k]);
-      if (_r < 1)
-        tasks.push(k + ':' + percent(_r));
-      r = Math.min(_r, r);
-      c++;
-    }
-    if (r != prog) {
-      prog = r;
-      const msg = tasks.join(', ');
-      process.stdout.write('\r' + bar(prog) + ' (' + msg + ')\x1b[K\r');
-    }
-  }, 333);
-  int.unref && int.unref();
-}
+let server;
+const argv = process.argv.slice(2);
 
 if (argv[0] === 'start') {
-  const tribes = require('ssb-tribes');
-  const conn = require('ssb-conn');
-  const legacy_conn = require('ssb-legacy-conn');
-  const db2 = require('ssb-db2');
-  const replication_scheduler = require('ssb-replication-scheduler');
-  const friends = require('ssb-friends');
-  const ebt = require('ssb-ebt');
-  const box = require('ssb-box');
-  const threads = require('ssb-threads');
-  const invite = require('ssb-invite');
-  const conn_db = require('ssb-conn-db');
-  const search2 = require('ssb-search2');
-  const friend_pub = require('ssb-friend-pub');
-  const invite_client = require('ssb-invite-client');
-  const tunnel = require('ssb-tunnel');
-  const conn_query = require('ssb-conn-query');
-  const conn_hub = require('ssb-conn-hub');
-  const conn_staging = require('ssb-conn-staging');
-  const device_address = require('ssb-device-address');
-  const gossip = require('ssb-gossip');
-  const master = require('ssb-master');
-  const logging = require('ssb-logging');
-  const partial_replication = require('ssb-partial-replication');
-  const about = require('ssb-about');
-  const onion = require('ssb-onion');
-  const unix = require('ssb-unix-socket');
-  const auth = require('ssb-no-auth');
-  const backlinks = require('ssb-backlinks');
-  const links = require('ssb-links');
-
-  function createSsbServer() {
-    return SecretStack({ caps }).use(SSB, gossip, tribes, conn, db2, master, ebt, box, threads, invite, conn_db, search2, friend_pub, invite_client, tunnel, config, conn_query, conn_hub, conn_staging, device_address, friends, logging, replication_scheduler, partial_replication, about, onion, unix, auth, backlinks, links);
-  }
-
-  const Server = createSsbServer()
-    .use(require('ssb-master'))
-    .use(require('ssb-gossip'))
-    .use(require('ssb-ebt'))
-    .use(require('ssb-friends'))
-    .use(require('ssb-blobs'))
-    .use(require('ssb-lan'))
-    .use(require('ssb-meme'))
-    .use(require('ssb-ooo'))
-    .use(require('ssb-plugins'))
-    .use(require('ssb-conn'))
-    .use(require('ssb-box'))
-    .use(require('ssb-search'))
-    .use(require('ssb-friend-pub'))
-    .use(require('ssb-invite-client'))
-    .use(require('ssb-logging'))
-    .use(require('ssb-replication-scheduler'))
-    .use(require('ssb-partial-replication'))
-    .use(require('ssb-about'))
-    .use(require('ssb-onion'))
-    .use(require('ssb-unix-socket'))
-    .use(require('ssb-no-auth'))
-    .use(require('ssb-backlinks'))
-    .use(require("ssb-tangle"))
-    .use(require('ssb-links'))
-    .use(require('ssb-query'));
-
-  require('ssb-plugins').loadUserPlugins(Server, config);
-
-  const server = Server(config);
-
+  server = Server(config);
   fs.writeFileSync(manifestFile, JSON.stringify(server.getManifest(), null, 2));
 
-  if (process.stdout.isTTY && (config.logging.level !== 'info')) {
-    showProgress(server.progress);
-  }
-
-  let manifest;
-  try {
-    manifest = JSON.parse(fs.readFileSync(manifestFile));
-  } catch (err) {
-    throw explain(err, 'no manifest file - should be generated first time server is run');
+  const { cmdAliases } = require('../client/cli-cmd-aliases');
+  const manifest = server.getManifest();
+  for (const k in cmdAliases) {
+    server[k] = server[cmdAliases[k]];
+    manifest[k] = manifest[cmdAliases[k]];
   }
 
-  const opts = {
-    manifest: manifest,
-    port: config.port,
-    host: 'localhost',
-    caps: config.caps,
-    key: config.key || config.keys.id
+  manifest.config = 'sync';
+  server.config = cb => {
+    console.log(JSON.stringify(config, null, 2));
+    cb();
   };
 
-const levelup = require('../server/node_modules/levelup');
-const originalLevelUp = levelup.LevelUp;
-require('../server/node_modules/levelup').LevelUp = function (...args) {
-  const levelupInstance = new originalLevelUp(...args);
-  levelupInstance.on('error', function (err) {
-    if (err && err.message && err.message.includes('LOCK')) {
-      return;
-    }
-    this.emit('error', err);
-  });
-  return levelupInstance;
-};
-process.on('uncaughtException', function (err) {
-  if (err && err.message && err.message.includes('LOCK')) {
-    return;
+  if (process.stdout.isTTY && config.logging?.level !== 'info') {
+    const showProgress = () => {
+      let prog = -1;
+      const bar = r => '\r' + '*'.repeat(Math.floor(r * 50)) + '.'.repeat(50 - Math.floor(r * 50));
+      const percent = r => (Math.round(r * 10000) / 100).toFixed(2) + '%';
+      const rate = prog => prog.target === prog.current ? 1 : (prog.current - prog.start) / (prog.target - prog.start);
+      const interval = setInterval(() => {
+        const p = server.progress();
+        let r = 1;
+        const tasks = [];
+        for (const k in p) {
+          const pr = rate(p[k]);
+          if (pr < 1) tasks.push(`${k}:${percent(pr)}`);
+          r = Math.min(r, pr);
+        }
+        if (r !== prog) {
+          prog = r;
+          process.stdout.write(bar(r) + ` (${tasks.join(', ')})\x1b[K\r`);
+        }
+      }, 333);
+      interval.unref?.();
+    };
+    showProgress();
   }
-  throw err;
-});
 
-Client(config.keys, opts, function (err, rpc) {
-  if (err) {
-    process.exit(1);
-  }
-  for (let k in cmdAliases) {
-    rpc[k] = rpc[cmdAliases[k]];
-    manifest[k] = manifest[cmdAliases[k]];
-  }
+  const { printMetadata, colors } = require('./ssb_metadata');
+  printMetadata('OASIS Server Only', colors.cyan);
 
-    manifest.config = 'sync';
-    rpc.config = function (cb) {
-      console.log(JSON.stringify(config, null, 2));
-      cb();
-    };   
-    function validateParams(argv, manifest, rpc, verbose) {
-        if (!Array.isArray(argv)) {
-        return false;
-    }   
-    if (typeof manifest !== 'object' || manifest === null) {
-        return false;
-    }
-    if (typeof rpc !== 'object' || rpc === null) {
-        return false;
-    }
-    if (typeof verbose !== 'boolean') {
-        if (verbose === 'true') {
-            verbose = true;
-        } else if (verbose === 'false') {
-            verbose = false;
-        }
-    }
-    return true;
-    }
-})
 }
 
+module.exports = {
+  config,
+  server: server || Server(config),
+  open: async () => server || Server(config)
+};

File diff suppressed because it is too large
+ 1224 - 2839
src/server/package-lock.json


+ 16 - 16
src/server/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.3.4",
-  "description": "SNH-Oasis Project Networking GUI Utopia",
+  "version": "0.3.5",
+  "description": "Oasis Social Networking Project Utopia",
   "repository": {
     "type": "git",
     "url": "git+ssh://git@code.03c8.net/krakenlabs/oasis.git"
@@ -13,17 +13,15 @@
     "oasis": "npm run start"
   },
   "scripts": {
-    "dev": "nodemon --inspect src/index.js --debug --no-open",
+    "start": "npm run start:ssb && sleep 10 && npm run start:backend",
+    "start:backend": "node ../backend/backend.js",
+    "start:ssb": "node SSB_server.js start &",
     "fix": "common-good fix",
-    "postinstall": "patch-package",
+    "postinstall": "node ../../scripts/patch-node-modules.js",
     "prestart": "",
-    "start": "npm run start-server && npm run start-client",
-    "start-server": "node src/server.js start &",
-    "start-client": "node src/index.js",
     "test": "tap --timeout 240 && common-good test",
     "preversion": "npm test",
-    "version": "mv docs/CHANGELOG.md ./ && mv CHANGELOG.md docs/ && git add docs/CHANGELOG.md",
-    "postinstall": "node ../../scripts/patch-node-modules.js"
+    "version": "mv docs/CHANGELOG.md ./ && mv CHANGELOG.md docs/ && git add docs/CHANGELOG.md"
   },
   "dependencies": {
     "@koa/router": "^13.1.0",
@@ -32,6 +30,7 @@
     "archiver": "^7.0.1",
     "await-exec": "^0.1.2",
     "axios": "^1.7.9",
+    "base64-url": "^2.3.3",
     "broadcast-stream": "^0.2.1",
     "caller-path": "^4.0.0",
     "crypto": "^1.0.1",
@@ -39,6 +38,7 @@
     "env-paths": "^2.2.1",
     "epidemic-broadcast-trees": "^9.0.4",
     "file-type": "^16.5.4",
+    "gpt-3-encoder": "^1.1.4",
     "has-network": "0.0.1",
     "highlight.js": "11.0.0",
     "hyperaxe": "^2.0.1",
@@ -55,6 +55,7 @@
     "minimist": "^1.2.8",
     "mkdirp": "^3.0.1",
     "module-alias": "^2.2.3",
+    "moment": "^2.30.1",
     "multiblob": "^1.13.0",
     "multiserver": "^3.3.1",
     "multiserver-address": "^1.0.1",
@@ -66,6 +67,7 @@
     "open": "^8.4.2",
     "packet-stream": "^2.0.6",
     "packet-stream-codec": "^1.2.0",
+    "pdfjs-dist": "^5.2.133",
     "piexifjs": "^1.0.4",
     "pretty-ms": "^7.0.1",
     "pull-abortable": "^4.1.1",
@@ -81,8 +83,8 @@
     "remark-html": "^16.0.1",
     "require-style": "^1.1.0",
     "secret-stack": "^6.3.1",
-    "sqlite3": "^5.1.7",
     "ssb-about": "^2.0.1",
+    "ssb-autofollow": "^1.1.0",
     "ssb-backlinks": "^2.1.1",
     "ssb-blobs": "^2.0.1",
     "ssb-box": "^1.0.1",
@@ -94,8 +96,7 @@
     "ssb-conn-hub": "^1.2.0",
     "ssb-conn-query": "^1.2.2",
     "ssb-conn-staging": "^1.0.0",
-    "ssb-db": "^20.3.0",
-    "ssb-db2": "^8.1.0",
+    "ssb-db": "^20.4.1",
     "ssb-device-address": "^1.1.6",
     "ssb-ebt": "^9.0.0",
     "ssb-friend-pub": "^1.0.7",
@@ -116,25 +117,24 @@
     "ssb-msgs": "^5.2.0",
     "ssb-no-auth": "^1.0.0",
     "ssb-onion": "^1.0.0",
-    "ssb-ooo": "^1.3.3",
     "ssb-partial-replication": "^3.0.1",
     "ssb-plugins": "^1.0.2",
-    "ssb-private1": "^1.0.1",
+    "ssb-private": "^1.1.0",
     "ssb-query": "^2.4.5",
     "ssb-ref": "^2.16.0",
     "ssb-replication-scheduler": "^3.0.0",
     "ssb-room": "^0.0.10",
     "ssb-search": "^1.3.0",
-    "ssb-search2": "^2.1.3",
     "ssb-server": "file:packages/ssb-server",
     "ssb-tangle": "^1.0.1",
     "ssb-thread-schema": "^1.1.1",
     "ssb-threads": "^10.0.4",
-    "ssb-tribes": "^4.0.1",
     "ssb-tunnel": "^2.0.0",
     "ssb-unix-socket": "^1.0.0",
     "ssb-ws": "^6.2.3",
+    "tokenizers-linux-x64-gnu": "^0.13.4-rc1",
     "unzipper": "^0.12.3",
+    "util": "^0.12.5",
     "yargs": "^17.7.2"
   },
   "overrides": {

+ 22 - 0
src/server/ssb_config.js

@@ -0,0 +1,22 @@
+const fs = require('fs');
+const path = require('path');
+const Config = require('ssb-config/inject');
+const minimist = require('minimist');
+
+const configPath = path.resolve(__dirname, '../configs', 'server-config.json');
+const configData = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+
+const argv = process.argv.slice(2);
+const i = argv.indexOf('--');
+const conf = argv.slice(i + 1);
+const cliArgs = ~i ? argv.slice(0, i) : argv;
+
+let config = Config('ssb', minimist(conf));
+config = { ...config, ...configData };
+
+// Set blob size limit to 50MB
+const megabyte = Math.pow(2, 20);
+config.blobs = config.blobs || {};
+config.blobs.max = 50 * megabyte;
+
+module.exports = config;

+ 66 - 0
src/server/ssb_metadata.js

@@ -0,0 +1,66 @@
+const fs = require('fs');
+const path = require('path');
+const pkg = require('./package.json');
+const config = require('./ssb_config');
+const updater = require('../backend/updater.js');
+
+let printed = false;
+let checkedForUpdate = false; 
+
+function getModules() {
+  const nodeModulesPath = path.resolve(__dirname, 'node_modules');
+  try {
+    return fs.readdirSync(nodeModulesPath)
+      .filter(m => fs.existsSync(path.join(nodeModulesPath, m, 'package.json')));
+  } catch {
+    return [];
+  }
+}
+
+const colors = {
+  blue: '\x1b[38;5;33m',
+  yellow: '\x1b[38;5;226m',
+  orange: '\x1b[38;5;214m',
+  cyan: '\x1b[36m',
+  reset: '\x1b[0m'
+};
+
+async function checkForUpdate() {
+  if (checkedForUpdate) return; 
+  checkedForUpdate = true; 
+
+  const updateFlagPath = path.join(__dirname, '../server/.update_required');
+  if (fs.existsSync(updateFlagPath)) {
+    fs.unlinkSync(updateFlagPath);
+  }
+  await updater.getRemoteVersion();
+}
+
+async function printMetadata(mode, modeColor = colors.cyan) {
+  if (printed) return;
+  printed = true;
+
+  const modules = getModules();
+  const version = pkg.version;
+  const name = pkg.name;
+  const logLevel = config.logging?.level || 'info';
+  const publicKey = config.keys?.public || '';
+
+  console.log("=========================");
+  console.log(`Mode: ${modeColor}${mode}${colors.reset}`);
+  console.log("=========================");
+  console.log(`Package: ${colors.blue}${name} ${colors.yellow}[Version: ${version}]${colors.reset}`);
+  console.log("Logging Level:", logLevel);
+  console.log(`Oasis ID: [ ${colors.orange}@${publicKey}${colors.reset} ]`);
+  console.log("=========================");
+  console.log("Modules loaded: [", modules.length, "]");
+  console.log("=========================");
+
+  // Check for updates
+  await checkForUpdate();
+}
+
+module.exports = {
+  printMetadata,
+  colors
+};

+ 633 - 0
src/views/activity_view.js

@@ -0,0 +1,633 @@
+const { div, h2, p, section, button, form, a, input, img, textarea, br, span, video: videoHyperaxe, audio: audioHyperaxe, table, tr, td, th } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+const moment = require("../server/node_modules/moment");
+
+function capitalize(str) {
+  return typeof str === 'string' && str.length ? str[0].toUpperCase() + str.slice(1) : '';
+}
+
+function renderActionCards(actions) {
+  const validActions = actions
+    .filter(action => {
+      const content = action.value?.content || action.content;
+      if (!content || typeof content !== 'object') return false;
+      if (content.type === 'tombstone') return false;
+      if (content.type === 'post' && content.private === true) return false;
+      if (content.type === 'tribe' && content.isAnonymous === true) return false;
+      return true;
+    })
+    .sort((a, b) => b.ts - a.ts);
+
+  if (!validActions.length) {
+    return div({ class: "no-actions" }, p(i18n.noActions)); 
+  }
+
+  return validActions.map(action => {
+    const date = action.ts ? new Date(action.ts).toLocaleString() : "";
+    const userLink = action.author
+      ? a({ href: `/author/${encodeURIComponent(action.author)}` }, action.author)
+      : 'unknown';
+    const type = action.type || 'unknown';
+    const typeLabel = i18n[`type${capitalize(type)}`] || type;
+    const content = action.content || {};
+    const cardBody = [];
+
+   if (type === 'votes') {
+    const { question, deadline, status, votes, totalVotes } = content;
+    const votesList = votes && typeof votes === 'object'
+      ? Object.entries(votes).map(([option, count]) => ({ option, count }))
+      : [];
+    cardBody.push(
+        h2({ class: 'type-label' }, `[${typeLabel}]`),
+        form({ method: "GET", action: `/votes/${encodeURIComponent(action.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        h2(`${question}`),
+        p(`${i18n.deadline}: ${deadline ? new Date(deadline).toLocaleString() : ''}`),
+        h2(`${i18n.voteTotalVotes}: ${totalVotes}`),
+        table(
+            tr(...votesList.map(({ option }) => th(i18n[option] || option))),
+            tr(...votesList.map(({ count }) => td(count)))
+        )
+    );
+   }
+
+    if (type === 'transfer') {
+      const { from, to, concept, amount, deadline, status, tags, confirmedBy } = content;
+      const validTags = Array.isArray(tags) ? tags : [];
+      cardBody.push(
+         h2({ class: 'type-label' }, `[${typeLabel}]`),
+         form({ method: "GET", action: `/transfers/${encodeURIComponent(action.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+         ),
+         h2(i18n.concept + ": " + concept),
+         p(i18n.from + ": ", a({ href: `/author/${encodeURIComponent(from)}`, target: "_blank" }, from)),
+         p(i18n.to + ": ", a({ href: `/author/${encodeURIComponent(to)}`, target: "_blank" }, to)),
+         h2(i18n.amount + ": " + amount),
+         p(i18n.deadline + ": " + (deadline ? new Date(deadline).toLocaleString() : "")),
+         p(i18n.status + ": " + status),
+         p(`${i18n.transfersConfirmations}: ${confirmedBy.length}/2`),
+         validTags.length
+            ? div(validTags.map(tag =>
+               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+             ))
+           : ""
+      );
+    }
+    
+    if (type === 'pixelia') {
+      const { author } = content;
+      cardBody.push(
+         h2({ class: 'type-label' }, `[${typeLabel}]`),
+         form({ method: "GET", action: `/pixelia` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+         ),
+         p(`${i18n.activityPixelia} ${i18n.pixeliaBy}: `, a({ href: `/author/${encodeURIComponent(author)}` }, author)),
+      );
+    }
+
+    if (type === 'tribe') {
+      const { title, description, image, location, tags, isLARP, isAnonymous, members, createdAt, author } = content;
+      const validTags = Array.isArray(tags) ? tags : [];
+      cardBody.push(
+        h2({ class: 'type-label' }, `[${typeLabel}]`),
+        form({ method: "GET", action: `/tribe/${encodeURIComponent(action.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        h2(`${title}`),
+        p(`${description || ''}`),
+	image 
+	  ? img({ src: `/blob/${encodeURIComponent(image)}`, class: 'feed-image' }) 
+	  : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }),
+        p(`${i18n.location || 'Location'}: ${location || ''}`),
+        typeof isLARP === 'boolean' ? p(`LARP: ${isLARP ? 'Yes' : 'No'}`) : "",
+        typeof isAnonymous === 'boolean' ? p(`Anonymous: ${isAnonymous ? 'Yes' : 'No'}`) : "",
+        Array.isArray(members) ? h2(`${i18n.tribeMembersCount || 'Members'}: ${members.length}`) : "",
+        createdAt ? p(`${i18n.createdAt}: ${new Date(createdAt).toLocaleString()}`) : "",
+        author ? p(`${i18n.author}: `, a({ href: `/author/${encodeURIComponent(author)}` }, author)) : "",
+        validTags.length
+            ? div(validTags.map(tag =>
+                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+              ))
+            : ""
+      );
+    }
+
+    if (type === 'curriculum') {
+      const { author, name, description, photo, personalSkills, personalExperiences, oasisExperiences, oasisSkills, educationExperiences, educationalSkills, languages, professionalExperiences, professionalSkills, location, status,  preferences, createdAt} = content;
+      cardBody.push(
+        h2({ class: 'type-label' }, `[${typeLabel}]`),
+        form({ method: "GET", action: `/inhabitant/${encodeURIComponent(action.author)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        h2(`${name}`),
+        description ? p(`${i18n.description}: ${description}`) : "",
+        photo ? img({ src: `/blob/${encodeURIComponent(photo)}`, class: 'feed-image' }) : "",
+        location ? p(`${i18n.cvLocationLabel || 'Location'}: ${location}`) : "",
+        languages ? p(`${i18n.cvLanguagesLabel || 'Languages'}: ${languages}`) : "",
+        createdAt ? p(`${i18n.cvCreatedAt}: ${new Date(createdAt).toLocaleString()}`) : "".
+        br,
+        personalExperiences ? p(`${i18n.cvPersonalExperiencesLabel || 'Personal Experiences'}: ${personalExperiences}`) : "",
+        personalSkills && personalSkills.length
+            ? div(personalSkills.map(skill =>
+                a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`)
+            )) : "",
+        oasisExperiences ? p(`${i18n.cvOasisExperiencesLabel || 'Oasis Experiences'}: ${oasisExperiences}`) : "",
+        oasisSkills && oasisSkills.length
+            ? div(oasisSkills.map(skill =>
+                a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`)
+            )) : "",
+        educationExperiences ? p(`${i18n.cvEducationExperiencesLabel || 'Education Experiences'}: ${educationExperiences}`) : "",
+        educationalSkills && educationalSkills.length
+            ? div(educationalSkills.map(skill =>
+                a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`)
+            )) : "",
+        professionalExperiences ? p(`${i18n.cvProfessionalExperiencesLabel || 'Professional Experiences'}: ${professionalExperiences}`) : "",
+        professionalSkills && professionalSkills.length
+            ? div(professionalSkills.map(skill =>
+                a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`)
+            )) : "",
+        status ? h2(`${i18n.cvStatusLabel}: ${status}`) : "",
+        preferences ? p(`${i18n.cvPreferencesLabel || 'Preferences'}: ${preferences}`) : "",
+        h2(`${i18n.activityContact}: `, a({ href: `/author/${encodeURIComponent(action.author)}` }, action.author))
+      );
+    }
+
+    if (type === 'image') {
+      const { url, title, description, tags, meme } = content;
+      const validTags = Array.isArray(tags) ? tags : [];
+        cardBody.push(
+        h2({ class: 'type-label' }, `[${typeLabel}]`),
+        form({ method: "GET", action: `/images/${encodeURIComponent(action.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        title ? h2(title) : "",
+        description ? p(description) : "",
+        meme ? h2(`${i18n.trendingCategory}: ${i18n.meme}`) : "",
+        img({ src: `/blob/${encodeURIComponent(url)}`, class: 'feed-image' }),
+        br,
+        validTags.length
+           ? div(validTags.map(tag =>
+               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+             ))
+           : ""
+      );
+    }
+
+    if (content.type === 'audio') {
+      const { url, mimeType, title, description, tags } = content;
+      const validTags = Array.isArray(tags) ? tags : [];
+      cardBody.push(
+        h2({ class: 'type-label' }, `[${typeLabel}]`),
+        form({ method: "GET", action: `/audios/${encodeURIComponent(action.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        title?.trim() ? h2(title) : "",
+        description?.trim() ? p(description) : "",
+        url
+          ? div({ class: "audio-container" },
+              audioHyperaxe({
+                controls: true,
+                src: `/blob/${encodeURIComponent(url)}`,
+                type: mimeType
+              })
+            )
+          : p(i18n.audioNoFile),
+        validTags.length
+           ? div(validTags.map(tag =>
+               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+             ))
+           : ""
+      );
+    }
+
+    if (content.type === 'video') {
+      const { url, mimeType, title, description, tags } = content;
+      const validTags = Array.isArray(tags) ? tags : [];
+      cardBody.push(
+        h2({ class: 'type-label' }, `[${typeLabel}]`),
+        form({ method: "GET", action: `/videos/${encodeURIComponent(action.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        title?.trim() ? h2(title) : "",
+        description?.trim() ? p(description) : "",
+        url
+          ? div({ class: "video-container" },
+              videoHyperaxe({
+                controls: true,
+                src: `/blob/${encodeURIComponent(url)}`,
+                type: mimeType,
+                preload: 'metadata',
+                width: '640',
+                height: '360'
+              })
+            )
+          : p(i18n.videoNoFile),
+        validTags.length
+           ? div(validTags.map(tag =>
+               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+             ))
+           : ""
+      );
+    }
+
+    if (content.type === 'document') {
+       const { url, title, description, tags, key } = content;
+       const validTags = Array.isArray(tags) ? tags : [];
+       cardBody.push(
+        h2({ class: 'type-label' }, `[${typeLabel}]`),
+        form({ method: "GET", action: `/documents/${encodeURIComponent(action.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        title?.trim() ? h2(title) : "",
+        description?.trim() ? p(description) : "",
+        div({
+          id: `pdf-container-${key || url}`,
+          class: 'pdf-viewer-container',
+          'data-pdf-url': `/blob/${encodeURIComponent(url)}`
+        }),
+        tags.length
+          ? div(validTags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+            ))
+          : null
+      );
+    }
+
+    if (type === 'bookmark') {
+      const { author, url, tags, description, category, lastVisit } = content;
+      const validTags = Array.isArray(tags) ? tags : [];
+      cardBody.push(
+        h2({ class: 'type-label' }, `[${typeLabel}]`),
+        form({ method: "GET", action: `/bookmarks/${encodeURIComponent(action.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        description ? p(`${description}`) : "",  
+        h2(url ? p(a({ href: url, target: '_blank', class: "bookmark-url" }, url)) : ""),
+        category ? p(`${i18n.bookmarkCategory}: ${category}`) : "",
+        lastVisit ? p(`${i18n.bookmarkLastVisit}: ${new Date(lastVisit).toLocaleString()}`) : "",
+        validTags.length
+            ? div(validTags.map(tag =>
+                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+              ))
+            : ""
+        );
+    }
+
+    if (type === 'event') {
+        const { title, description, date, location, price, url: eventUrl, attendees, tags, organizer, status, isPublic } = content;
+        const validTags = Array.isArray(tags) ? tags : [];
+        cardBody.push(
+          h2({ class: 'type-label' }, `[${typeLabel}]`),
+          form({ method: "GET", action: `/events/${encodeURIComponent(action.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ),
+            p(`${i18n.title}: ${title}`),
+            description ? p(`${i18n.description}: ${description}`) : "",
+            date ? p(`${i18n.date}: ${new Date(date).toLocaleString()}`) : "",
+            location ? p(`${i18n.location || 'Location'}: ${location}`) : "",
+            status ? p(`${i18n.status}: ${status}`) : "",
+            typeof isPublic === 'boolean' ? p(`${i18n.isPublic || 'Public'}: ${isPublic ? 'Yes' : 'No'}`) : "",
+            price ? p(`${i18n.price || 'Price'}: ${price} ECO`) : "",
+            eventUrl ? p(`${i18n.trendingUrl}: `, a({ href: eventUrl, target: '_blank' }, eventUrl)) : "",
+            organizer ? p(`${i18n.organizer || 'Organizer'}: `, a({ href: `/author/${encodeURIComponent(organizer)}` }, organizer)) : "",
+            Array.isArray(attendees) ? p(`${i18n.attendees}: ${attendees.length}`) : "",
+            validTags.length
+            ? div(validTags.map(tag =>
+               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+             ))
+            : "",
+        );
+    }
+
+    if (type === 'task') {
+        const { title, description, startTime, endTime, priority, location, tags, isPublic, assignees, status, author } = content;
+        const validTags = Array.isArray(tags) ? tags : [];
+        cardBody.push(
+          h2({ class: 'type-label' }, `[${typeLabel}]`),
+          form({ method: "GET", action: `/tasks/${encodeURIComponent(action.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ),
+            p(`${i18n.title}: ${title}`),
+            description ? p(`${i18n.description}: ${description}`) : "",
+            startTime ? p(`${i18n.startTime || 'Start'}: ${new Date(startTime).toLocaleString()}`) : "",
+            endTime ? p(`${i18n.endTime || 'End'}: ${new Date(endTime).toLocaleString()}`) : "",
+            priority ? p(`${i18n.priority || 'Priority'}: ${priority}`) : "",
+            location ? p(`${i18n.location || 'Location'}: ${location}`) : "",
+            validTags.length
+             ? div(validTags.map(tag =>
+               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+             ))
+            : "",
+            typeof isPublic === 'boolean' ? p(`${i18n.isPublic || 'Public'}: ${isPublic ? 'Yes' : 'No'}`) : "",
+            Array.isArray(assignees) ? p(`${i18n.taskAssignees || 'Assignees'}: ${assignees.length}`) : "",
+            status ? p(`${i18n.status}: ${status}`) : "",
+            author ? p(`${i18n.author || 'Author'}: `, a({ href: `/author/${encodeURIComponent(author)}` }, author)) : ""
+        );
+    }
+    
+    if (type === 'feed') {
+      const { text, author, createdAt, opinions, opinions_inhabitants, refeeds, refeeds_inhabitants } = content;
+      cardBody.push(
+        h2({ class: 'type-label' }, `[${typeLabel}]`),
+        h2(text),
+        p(i18n.author + ": ", a({ href: `/author/${encodeURIComponent(author)}`, target: "_blank" }, author)),
+        p(i18n.createdAt + ": " + new Date(createdAt).toLocaleString()),
+        h2(i18n.tribeFeedRefeeds + ": " + refeeds)
+      );
+    }
+    
+    if (type === 'post') {
+      const { contentWarning, text } = content;
+      cardBody.push(
+        h2({ class: 'type-label' }, `[${typeLabel}]`),
+        form({ method: "GET", action: `/thread/${encodeURIComponent(action.id)}#${encodeURIComponent(action.id)}` },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        contentWarning ? h2(contentWarning) : '',
+        p({ innerHTML: text })
+      );
+   }
+   
+   if (type === 'vote') {
+     const { vote } = content;
+     cardBody.push(
+       h2({ class: 'type-label' }, `[${typeLabel}]`),
+       p(
+         a({ href: `/author/${encodeURIComponent(action.author)}` }, action.author),
+         ` ${i18n.activitySpread} `,
+         a({ href: `/thread/${encodeURIComponent(vote.link)}#${encodeURIComponent(vote.link)}` }, vote.link)
+       )
+     );
+   }
+   
+   if (type === 'about') {
+     const { about, name, description } = content;
+     cardBody.push(
+       h2({ class: 'type-label' }, `[${typeLabel}]`),
+        form({ method: "GET", action: `/author/${encodeURIComponent(action.author)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+         ),
+         h2(a({ href: `/author/${encodeURIComponent(about)}` },`@`,name)),
+         p(description)
+     );
+   }  
+   
+    if (type === 'contact') {
+      const { contact } = content;
+      cardBody.push(
+        h2({ class: 'type-label' }, `[${typeLabel}]`),
+        form({ method: "GET", action: `/inhabitants` },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+      p(
+      `${i18n.activitySupport}: `,
+      a({ href: `/author/${encodeURIComponent(action.author)}` }, action.author),
+      span({ class: 'action-meta' }, " <-> "),
+      a({ href: `/author/${encodeURIComponent(contact)}` }, contact)
+      )
+      );
+    }
+    
+    if (type === 'pub') {
+      const { address } = content;
+      const { host, key } = address;
+      cardBody.push(
+        h2({ class: 'type-label' }, `[${typeLabel}]`),
+          form({ method: "GET", action: `/invites` },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+	p(
+	  ` ${i18n.activityJoin}: `,
+	  a({ href: `/author/${encodeURIComponent(action.author)}` }, action.author),
+          span({ class: 'action-meta' }, " -> "),
+	  a({ href: `/author/${encodeURIComponent(key)}` }, key),
+	  ` (`,
+	  host,
+	  `)`
+	)
+      );
+    }
+    
+    if (type === 'market') {
+      const { item_type, title, description, price, tags, status, item_status, deadline, includesShipping, seller, image, auctions_poll } = content;
+      const validTags = Array.isArray(tags) ? tags : [];
+      cardBody.push(
+        h2({ class: 'type-label' }, `[${typeLabel}]`),
+        form({ method: "GET", action: `/market/${encodeURIComponent(action.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        p(i18n.marketItemTitle + ": " + title),
+        p(i18n.marketItemDescription + ": " + description),
+        image
+            ? img({ src: `/blob/${encodeURIComponent(image)}` })
+            : img({ src: '/assets/images/default-market.png', alt: title }),
+        p(i18n.marketItemType + ": " + item_type),
+        p(i18n.marketItemCondition + ": " + item_status),
+        p(i18n.marketItemIncludesShipping + ": " + (includesShipping ? i18n.YESLabel : i18n.NOLabel)),
+        p(i18n.deadline + ": " + (deadline ? new Date(deadline).toLocaleString() : "")),
+        p(`${i18n.marketItemSeller}: `, a({ href: `/author/${encodeURIComponent(seller)}` }, seller)),
+        validTags.length
+           ? div(validTags.map(tag =>
+               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+             ))
+           : "",
+        h2(i18n.marketItemStatus + ": " + status),
+        div({ class: "market-card price" },
+          p(`${i18n.marketItemPrice}: ${price} ECO`)
+        ),
+    );
+    if (item_type === 'auction') {
+        if (status !== 'SOLD' && status !== 'DISCARDED') {
+            cardBody.push(
+                div({ class: "auction-info" },
+                    auctions_poll && auctions_poll.length > 0
+                        ? [
+                            p({ class: "auction-bid-text" }, i18n.marketAuctionBids),
+                            table({ class: 'auction-bid-table' },
+                                tr(
+                                    th(i18n.marketAuctionBidTime),
+                                    th(i18n.marketAuctionUser),
+                                    th(i18n.marketAuctionBidAmount)
+                                ),
+                                auctions_poll.map(bid => {
+                                    const [userId, bidAmount, bidTime] = bid.split(':');
+                                    return tr(
+                                        td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
+                                        td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
+                                        td(`${parseFloat(bidAmount).toFixed(6)} ECO`)
+                                    );
+                                })
+                            )
+                        ]
+                        : p(i18n.marketNoBids),
+                    form({ method: "POST", action: `/market/bid/${encodeURIComponent(action.id)}` },
+                        input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
+                        br(),
+                        button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
+                    )
+                )
+            );
+        }
+    }
+    if (item_type === 'exchange') {
+        if (status !== 'SOLD' && status !== 'DISCARDED') {
+            cardBody.push(
+                form({ method: "POST", action: `/market/buy/${encodeURIComponent(action.id)}` },
+                    button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
+                )
+            );
+        }
+    }
+  }
+
+    if (type === 'report') {
+        const { title, description, category, createdAt, author, image, tags, confirmations, severity, status, isAnonymous } = content;
+        const validTags = Array.isArray(tags) ? tags : [];
+        cardBody.push(
+          h2({ class: 'type-label' }, `[${typeLabel}]`),
+          form({ method: "GET", action: `/reports/${encodeURIComponent(action.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ),
+            p(`${i18n.title}: ${title}`),
+            description ? p(`${i18n.description}: ${description}`) : "",
+            category ? p(`${i18n.category}: ${category}`) : "",
+            severity ? p(`${i18n.severity || 'Severity'}: ${severity}`) : "",
+            status ? p(`${i18n.status}: ${status}`) : "",
+            image ? img({ src: `/blob/${encodeURIComponent(image)}`, class: 'feed-image' }) : "",
+            createdAt ? p(`${i18n.reportsCreatedAt}: ${new Date(createdAt).toLocaleString()}`) : "",    
+	    p(`${i18n.author || 'Author'}: `, 
+		  typeof isAnonymous === 'boolean' 
+		    ? (isAnonymous 
+			? i18n.reportsAnonymousAuthor || 'Anonymous' 
+			: a({ href: `/author/${encodeURIComponent(author)}`, target: '_blank' }, author)) 
+		    : author ? a({ href: `/author/${encodeURIComponent(author)}`, target: '_blank' }, author) : ""
+		),
+            Array.isArray(confirmations) ? h2(`${i18n.confirmations || 'Confirmations'}: ${confirmations.length}`) : "",
+            validTags.length
+             ? div(validTags.map(tag =>
+               a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+             ))
+             : ""
+        );
+    }
+
+    return div({ class: 'action-card' },
+      p({ class: 'action-meta' }, `${date} ${i18n.performed} `, userLink),
+      ...cardBody
+    );
+  });
+}
+
+exports.activityView = (actions, filter, userId) => {
+  const title = filter === 'mine' ? i18n.yourActivity : i18n.globalActivity;
+  const desc = i18n.activityDesc;
+
+  const activityTypes = [
+    { type: 'recent', label: i18n.typeRecent },
+    { type: 'all', label: i18n.allButton },
+    { type: 'mine', label: i18n.mineButton },
+    { type: 'votes', label: i18n.typeVotes },
+    { type: 'event', label: i18n.typeEvent },
+    { type: 'task', label: i18n.typeTask },
+    { type: 'report', label: i18n.typeReport },
+    { type: 'tribe', label: i18n.typeTribe },
+    { type: 'about', label: i18n.typeAbout },
+    { type: 'curriculum', label: i18n.typeCurriculum },
+    { type: 'market', label: i18n.typeMarket },
+    { type: 'transfer', label: i18n.typeTransfer },
+    { type: 'feed', label: i18n.typeFeed },
+    { type: 'post', label: i18n.typePost },
+    { type: 'pixelia', label: i18n.typePixelia },
+    { type: 'bookmark', label: i18n.typeBookmark },
+    { type: 'image', label: i18n.typeImage },
+    { type: 'video', label: i18n.typeVideo },
+    { type: 'audio', label: i18n.typeAudio },
+    { type: 'document', label: i18n.typeDocument }
+  ];
+  let filteredActions;
+  if (filter === 'mine') {
+    filteredActions = actions.filter(action => actions.author === userId && action.type !== 'tombstone');
+  } else if (filter === 'recent') {
+    const now = Date.now();
+    filteredActions = actions.filter(action => 
+      action.type !== 'tombstone' && action.ts && now - action.ts < 24 * 60 * 60 * 1000 
+    );
+  } else {
+    filteredActions = actions.filter(action => (action.type === filter || filter === 'all') && action.type !== 'tombstone');
+  }
+
+  let html = template(
+    title,
+    section(
+      div({ class: 'tags-header' },
+        h2(i18n.activityList),
+        p(desc)
+        ),
+        form({ method: 'GET', action: '/activity' },
+          div({ class: 'mode-buttons', style: 'display:grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 24px;' },
+            div({
+              style: 'display: flex; flex-direction: column; gap: 8px;'
+            },
+              activityTypes.slice(0, 3).map(({ type, label }) =>
+                form({ method: 'GET', action: '/activity' },
+                  input({ type: 'hidden', name: 'filter', value: type }),
+                  button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
+                )
+              )
+            ),
+            div({
+              style: 'display: flex; flex-direction: column; gap: 8px;'
+            },
+              activityTypes.slice(3, 7).map(({ type, label }) =>
+                form({ method: 'GET', action: '/activity' },
+                  input({ type: 'hidden', name: 'filter', value: type }),
+                  button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
+                )
+              )
+            ),
+            div({
+              style: 'display: flex; flex-direction: column; gap: 8px;'
+            },
+              activityTypes.slice(7, 11).map(({ type, label }) =>
+                form({ method: 'GET', action: '/activity' },
+                  input({ type: 'hidden', name: 'filter', value: type }),
+                  button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
+                )
+              )
+            ),
+            div({
+              style: 'display: flex; flex-direction: column; gap: 8px;'
+            },
+              activityTypes.slice(11, 15).map(({ type, label }) =>
+                form({ method: 'GET', action: '/activity' },
+                  input({ type: 'hidden', name: 'filter', value: type }),
+                  button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
+                )
+              )
+            ),
+            div({
+              style: 'display: flex; flex-direction: column; gap: 8px;'
+            },
+              activityTypes.slice(15, 20).map(({ type, label }) =>
+                form({ method: 'GET', action: '/activity' },
+                  input({ type: 'hidden', name: 'filter', value: type }),
+                  button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
+                )
+              )
+            )
+          )
+        ),
+      section({ class: 'feed-container' }, renderActionCards(filteredActions))
+    )
+  );
+  const hasDocument = actions.some(a => a && a.type === 'document');
+  if (hasDocument) {
+    html += `
+      <script type="module" src="/js/pdf.min.mjs"></script>
+      <script src="/js/pdf-viewer.js"></script>
+    `;
+  }
+  return html;
+};
+

+ 187 - 0
src/views/agenda_view.js

@@ -0,0 +1,187 @@
+const { div, h2, p, section, button, form, img, textarea, a, br, h1 } = 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');
+
+userId = config.keys.id;
+
+exports.agendaView = async (items, filter) => {
+  const now = Date.now();
+  const counts = {
+    all: items.length,
+    open: items.filter(i => i.status === 'OPEN').length,
+    closed: items.filter(i => i.status === 'CLOSED').length,
+    tasks: items.filter(i => i.type === 'task').length,
+    events: items.filter(i => i.type === 'event').length,
+    transfers: items.filter(i => i.type === 'transfer').length,
+    tribes: items.filter(i => i.type === 'tribe').length,
+    market: items.filter(i => i.type === 'market').length,
+    reports: items.filter(i => i.type === 'report').length
+  };
+
+  const filtered =
+    filter === 'open' ? items.filter(i => i.status === 'OPEN') :
+    filter === 'closed' ? items.filter(i => i.status === 'CLOSED') :
+    filter === 'tasks' ? items.filter(i => i.type === 'task') :
+    filter === 'events' ? items.filter(i => i.type === 'event') :
+    filter === 'transfers' ? items.filter(i => i.type === 'transfer') :
+    filter === 'tribes' ? items.filter(i => i.type === 'tribe') :
+    filter === 'market' ? items.filter(i => i.type === 'market') :
+    filter === 'reports' ? items.filter(i => i.type === 'report') :
+    items;
+
+  const fmt = d => moment(d).format('YYYY/MM/DD HH:mm:ss');
+
+  return template(
+    i18n.agendaTitle,
+    section(
+      div({ class: 'tags-header' },
+        h2(i18n.agendaTitle),
+        p(i18n.agendaDescription)
+      ),
+      div({ class: 'filters' },
+        form({ method: 'GET', action: '/agenda' },
+          button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterAll} (${counts.all})`),
+          button({ type: 'submit', name: 'filter', value: 'open', class: filter === 'open' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterOpen} (${counts.open})`),
+          button({ type: 'submit', name: 'filter', value: 'closed', class: filter === 'closed' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterClosed} (${counts.closed})`),
+          button({ type: 'submit', name: 'filter', value: 'events', class: filter === 'events' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterEvents} (${counts.events})`),
+          button({ type: 'submit', name: 'filter', value: 'tasks', class: filter === 'tasks' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterTasks} (${counts.tasks})`),
+          button({ type: 'submit', name: 'filter', value: 'reports', class: filter === 'reports' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterReports} (${counts.reports})`),
+          button({ type: 'submit', name: 'filter', value: 'tribes', class: filter === 'tribes' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterTribes} (${counts.tribes})`),
+          button({ type: 'submit', name: 'filter', value: 'market', class: filter === 'market' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterMarket} (${counts.market})`), 
+          button({ type: 'submit', name: 'filter', value: 'transfers', class: filter === 'transfers' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterTransfers} (${counts.transfers})`)
+        )
+      ),
+      div({ class: 'agenda-list' },
+        filtered.length
+          ? filtered.map(item => {
+              const author = item.seller || item.organizer || item.from || item.author;
+              const commonFields = [
+                p(`${i18n.agendaAuthor}: `, a({ href: `/author/${encodeURIComponent(author)}` }, author)),
+                p(`${i18n.agendaCreatedAt}: ${fmt(item.createdAt)}`)
+              ];
+              let details = [];
+              let actionButton = null;
+		if (item.type === 'market') {
+		  commonFields.push(p(`${i18n.marketItemType}: ${item.item_type}`));
+		  commonFields.push(p(`${i18n.marketItemTitle}: ${item.title}`));
+		  commonFields.push(p(`${i18n.marketItemDescription}: ${item.description}`));
+		  commonFields.push(p(`${i18n.marketItemPrice}: ${item.price} ECO`));
+		  commonFields.push(p(`${i18n.marketItemIncludesShipping}: ${item.includesShipping ? i18n.agendaYes : i18n.agendaNo}`));
+		  commonFields.push(p(`${i18n.marketItemSeller}: `, a({ href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)));
+		  commonFields.push(p(`${i18n.marketItemAvailable}: ${moment(item.createdAt).format('YYYY-MM-DD HH:mm')}`));
+		  commonFields.push(
+		    item.image
+		      ? img({ src: `/blob/${encodeURIComponent(item.image)}`, class: 'market-image' })
+		      : p(i18n.marketNoImage)
+		  );
+		  commonFields.push(
+		    item.tags && item.tags.length
+		      ? div(
+			  item.tags.map(tag =>
+			    a(
+			      {
+				href: `/search?query=%23${encodeURIComponent(tag)}`,
+				class: 'tag-link',
+				style: 'margin-right:0.8em;',
+			      },
+			      `#${tag}`
+			    )
+			  )
+			)
+		      : null
+		  );
+
+		  if (item.item_type === 'auction') {
+		    details.push(p(`${i18n.marketItemAvailable}: ${moment(item.deadline).format('YYYY-MM-DD HH:mm')}`));
+		    const bids = item.auctions_poll.map(bid => parseFloat(bid.split(':')[1]));
+		    const maxBid = bids.length ? Math.max(...bids) : 0;
+		    details.push(p(`${i18n.marketItemHighestBid}: ${maxBid} ECO`));
+		  }
+
+		  details.push(p(`${i18n.marketItemStatus}: ${item.status}`));
+		}
+              if (item.type === 'tribe') {
+                commonFields.push(p(`${i18n.agendaDescriptionLabel}: ${item.description || i18n.noDescription}`));
+                details = [
+                  p(`${i18n.agendaMembersCount}: ${item.members.length || 0}`),
+                  p(`${i18n.agendaLocationLabel}: ${item.location || i18n.noLocation}`),
+                  p(`${i18n.agendaLARPLabel}: ${item.isLARP ? i18n.agendaYes : i18n.agendaNo}`), 
+                  p(`${i18n.agendaAnonymousLabel}: ${item.isAnonymous ? i18n.agendaYes : i18n.agendaNo}`), 
+                  p(`${i18n.agendaInviteModeLabel}: ${item.inviteMode || i18n.noInviteMode}`)
+                ];
+
+                const membersList = item.members.map(member => 
+                  p(a({ href: `/author/${encodeURIComponent(member)}` }, member))
+                );
+
+                details.push(
+                  div({ class: 'members-list' }, `${i18n.agendaMembersLabel}:`, membersList)
+                );
+              }
+
+              if (item.type === 'report') {
+                details = [
+                  p(`${i18n.agendareportCategory}: ${item.category || i18n.noCategory}`),
+                  p(`${i18n.agendareportSeverity}: ${item.severity || i18n.noSeverity}`),
+                  p(`${i18n.agendareportStatus}: ${item.status || i18n.noStatus}`),
+                  p(`${i18n.agendareportDescription}: ${item.description || i18n.noDescription}`)
+                ];
+              }
+
+              if (item.type === 'event') {
+                details = [
+                  p(`${i18n.eventDescriptionLabel}: ${item.description}`),
+                  p(`${i18n.eventLocationLabel}: ${item.location}`),
+                  p(`${i18n.eventDateLabel}: ${fmt(item.date)}`),
+                  p(`${i18n.eventPriceLabel}: ${item.price} ECO`),
+                  p(`${i18n.eventUrlLabel}: ${item.url || i18n.noUrl}`)
+                ];
+                actionButton = form({ method: 'POST', action: `/events/attend/${encodeURIComponent(item.id)}` },
+                              button({ type: 'submit', class: 'assign-btn' },
+                                     `${i18n.eventAttendButton}`)
+                );
+              }
+
+              if (item.type === 'task') {
+                details = [
+                  p(`${i18n.taskDescriptionLabel}: ${item.description}`),
+                  p(`${i18n.taskPriorityLabel}: ${item.priority}`),
+                  p(`${i18n.taskLocationLabel}: ${item.location}`)
+                ];
+                const assigned = Array.isArray(item.assignees) && item.assignees.includes(userId);
+                actionButton = form({ method: 'POST', action: `/tasks/assign/${encodeURIComponent(item.id)}` },
+                              button({ type: 'submit', class: 'assign-btn' },
+                                     assigned ? i18n.taskUnassignButton : i18n.taskAssignButton));
+              }
+
+              if (item.type === 'transfer') {
+                details = [
+                  p(`${i18n.agendaTransferConcept}: ${item.concept}`),
+                  p(`${i18n.agendaTransferAmount}: ${item.amount}`),
+                  p(`${i18n.agendaTransferDeadline}: ${fmt(item.deadline)}`)
+                ];
+              }
+
+              return div({ class: 'agenda-item' },
+                h2(`[${item.type.toUpperCase()}] ${item.title || item.name || item.concept}`),
+                ...commonFields,
+                ...details,
+                actionButton, br()
+              );
+            })
+          : p(i18n.agendaNoItems)
+      )
+    )
+  );
+};
+

+ 198 - 0
src/views/audio_view.js

@@ -0,0 +1,198 @@
+const { form, button, div, h2, p, section, input, label, br, a, audio: audioHyperaxe } = 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 userId = config.keys.id
+
+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;
+
+  return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+};
+
+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 = (filteredAudios, filter) => {
+  return filteredAudios.length > 0
+    ? filteredAudios.map(audio =>
+        div({ class: "audio-item" },
+          renderAudioActions(filter, audio),
+          form({ method: "GET", action: `/audios/${encodeURIComponent(audio.key)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)),
+          br,
+          audio.url
+            ? div({ class: "audio-container" },
+                audioHyperaxe({
+                  controls: true,
+                  src: `/blob/${encodeURIComponent(audio.url)}`,
+                  type: audio.mimeType,
+                  preload: 'metadata',
+                  width: '640',
+                  height: '360'
+                })
+              )
+            : p(i18n.audioNoFile),
+          p(`${i18n.audioCreatedAt}: ${moment(audio.createdAt).format('YYYY/MM/DD HH:mm:ss')}`),
+          p(`${i18n.audioAuthor}: `, a({ href: `/author/${encodeURIComponent(audio.author)}` }, audio.author)),
+          audio.title?.trim() ? h2(audio.title) : null,
+          audio.description?.trim() ? p(audio.description) : null,
+          audio.tags?.length
+            ? div(audio.tags.map(tag =>
+                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+              ))
+            : null,
+          div({ class: "voting-buttons" },
+            ['interesting','necessary','funny','disgusting','sensible',
+             'propaganda','adultOnly','boring','confusing','inspiring','spam']
+              .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)}`]} [${audio.opinions?.[category] || 0}]`
+                  )
+                )
+              )
+          )
+        )
+      )
+    : div(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(),
+      input({ type: "text", name: "description", placeholder: i18n.audioDescriptionPlaceholder, value: 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;
+
+  const filteredAudios = getFilteredAudios(filter, audios, userId);
+
+  const audioToEdit = audios.find(a => a.key === audioId);
+
+  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)}`]
+            )
+          ),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" },
+            i18n.audioCreateButton)
+        )
+      )
+    ),
+    section(
+      (filter === 'create' || filter === 'edit')
+        ? renderAudioForm(filter, audioId, audioToEdit)
+        : renderAudioList(filteredAudios, filter)
+    )
+  );
+};
+
+exports.singleAudioView = async (audio, filter) => {
+  const isAuthor = audio.author === userId; 
+  const hasOpinions = Object.keys(audio.opinions || {}).length > 0; 
+
+  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),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton)
+        )
+      ),
+      div({ class: "tags-header" },
+        h2(audio.title),
+        p(audio.description),
+        audio.url
+          ? div({ class: "audio-container" },
+              audioHyperaxe({
+                controls: true,
+                src: `/blob/${encodeURIComponent(audio.url)}`,
+                type: audio.mimeType,
+                preload: 'metadata',
+                width: '640',
+                height: '360'
+              })
+            )
+          : p(i18n.audioNoFile),
+        p(`${i18n.audioCreatedAt}: ${moment(audio.createdAt).format('YYYY/MM/DD HH:mm:ss')}`),
+        p(`${i18n.audioAuthor}: `, a({ href: `/author/${encodeURIComponent(audio.author)}` }, audio.author)),
+        audio.tags?.length
+          ? div(audio.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+            ))
+          : null
+      ),
+      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,
+      div({ class: "voting-buttons" },
+        ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'].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)}`]} [${audio.opinions?.[category] || 0}]`)
+          )
+        )
+      )
+    )
+  );
+};

+ 190 - 0
src/views/bookmark_view.js

@@ -0,0 +1,190 @@
+const { form, button, div, h2, p, section, input, label, textarea, br, a, ul, li } = 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 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)}` },
+      button({ class: "delete-btn", type: "submit" }, i18n.bookmarkDeleteButton)
+    )
+  ) : null;
+};
+
+const renderBookmarkList = (filteredBookmarks, filter) => {
+  return filteredBookmarks.length > 0
+    ? filteredBookmarks.map(bookmark =>
+        div({ class: "bookmark-item" },
+          renderBookmarkActions(filter, bookmark),
+          form({ method: "GET", action: `/bookmarks/${encodeURIComponent(bookmark.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ),br,
+          h2(bookmark.title),
+          p(bookmark.description),
+          label(bookmark.url ? a({ href: bookmark.url, target: "_blank", class: "bookmark-url" }, bookmark.url) : null), br,
+          p(`${i18n.bookmarkCreatedAt}: ${moment(bookmark.createdAt).format('YYYY/MM/DD HH:mm:ss')}`),
+          p(`${i18n.bookmarkAuthor}: `, a({ href: `/author/${encodeURIComponent(bookmark.author)}` }, bookmark.author)),
+          bookmark.category?.trim() ? p(`${i18n.bookmarkCategory}: ${bookmark.category}`) : null,
+          p(`${i18n.bookmarkLastVisit}: ${moment(bookmark.lastVisit).format('YYYY/MM/DD HH:mm:ss') || i18n.noLastVisit}`),
+          bookmark.tags?.length
+            ? div(bookmark.tags.map(tag =>
+                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+              ))
+            : null,
+          div({ class: "voting-buttons" },
+            ['interesting','necessary','funny','disgusting','sensible','propaganda','adultOnly','boring','confusing','inspiring','spam'].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)}`]} [${bookmark.opinions?.[category] || 0}]`)
+              )
+            )
+          )
+        )
+      )
+    : i18n.nobookmarks;
+};
+
+const renderBookmarkForm = (filter, bookmarkId, bookmarkToEdit, tags) => {
+  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),
+      textarea({ name: "description", id: "description", placeholder: i18n.bookmarkDescriptionPlaceholder, value: filter === 'edit' ? bookmarkToEdit.description : '' }), br, br,
+      label(i18n.bookmarkTagsLabel),
+      input({ type: "text", name: "tags", id: "tags", placeholder: i18n.bookmarkTagsPlaceholder, value: filter === 'edit' ? tags.join(', ') : '' }), br, br,
+      label(i18n.bookmarkCategoryLabel),
+      input({ type: "text", name: "category", id: "category", placeholder: i18n.bookmarkCategoryPlaceholder, value: filter === 'edit' ? bookmarkToEdit.category : '' }), br, br,
+      label(i18n.bookmarkLastVisitLabel),
+      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)
+    )
+  );
+};
+
+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);
+  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),
+          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))
+    )
+  );
+};
+
+exports.singleBookmarkView = async (bookmark, filter) => {
+  const isAuthor = bookmark.author === 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: "tags-header" },
+        p(bookmark.description),
+        label(bookmark.url ? a({ href: bookmark.url, target: "_blank", class: "bookmark-url" }, bookmark.url) : null), br,
+        p(`${i18n.bookmarkCreatedAt}: ${moment(bookmark.createdAt).format('YYYY/MM/DD HH:mm:ss')}`),
+        p(`${i18n.bookmarkAuthor}: `, a({ href: `/author/${encodeURIComponent(bookmark.author)}` }, bookmark.author)),
+        p(`${i18n.bookmarkCategory}: ${bookmark.category || i18n.noCategory}`),
+        p(`${i18n.bookmarkLastVisitLabel}: ${moment(bookmark.lastVisit).format('YYYY/MM/DD HH:mm:ss') || i18n.noLastVisit}`),
+        bookmark.tags && bookmark.tags.length
+          ? div(
+              bookmark.tags.map(tag =>
+                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+              )
+            )
+          : null
+      ),
+      isAuthor ? div({ class: "bookmark-actions" },
+        !hasOpinions
+          ? form({ method: "GET", action: `/bookmarks/edit/${encodeURIComponent(bookmark.id)}` },
+              button({ class: "update-btn", type: "submit" }, i18n.bookmarkUpdateButton)
+            )
+          : null,
+        form({ method: "POST", action: `/bookmarks/delete/${encodeURIComponent(bookmark.id)}` },
+          button({ class: "delete-btn", type: "submit" }, i18n.bookmarkDeleteButton)
+        )
+      ) : null,
+      div({ class: "voting-buttons" },
+        ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'].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)}`]} [${bookmark.opinions?.[category] || 0}]`)
+          )
+        )
+      )
+    )
+  );
+};

+ 105 - 0
src/views/cipher_view.js

@@ -0,0 +1,105 @@
+const { form, button, div, h2, p, section, textarea, label, input, br } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+const crypto = require('crypto');
+
+function generateRandomPassword(length = 32) {
+  return crypto.randomBytes(length).toString('hex').slice(0, length);
+}
+
+const cipherView = async (encryptedText = "", decryptedText = "", iv = "", password = "") => {
+  const randomPassword = generateRandomPassword();
+
+  const header = div({ class: "tags-header" },
+    h2(i18n.cipherTitle),
+    p(i18n.cipherDescription)
+  );
+
+  const encryptForm = form(
+    { action: "/cipher/encrypt", method: "POST", id: "encryptForm" },
+    textarea({
+      name: "text",
+      id: "text",
+      required: true,
+      placeholder: i18n.cipherTextPlaceholder,
+      rows: 4
+    }),
+    br,
+    label(i18n.cipherPasswordLabel),
+    br,
+    input({
+      type: "password",
+      name: "password",
+      id: "password",
+      required: true,
+      placeholder: i18n.cipherPasswordPlaceholder,
+      minlength: 32
+    }),
+    br,
+    button({ type: "submit" }, i18n.cipherEncryptButton)
+  );
+
+  const decryptForm = form(
+    { action: "/cipher/decrypt", method: "POST", id: "decryptForm" },
+    textarea({
+      name: "encryptedText",
+      id: "encryptedText",
+      required: true,
+      placeholder: i18n.cipherEncryptedTextPlaceholder,
+      rows: 4,
+      value: encryptedText
+    }),
+    br,
+    label(i18n.cipherPasswordLabel),
+    br,
+    input({
+      type: "password",
+      name: "password",
+      id: "password",
+      required: true,
+      placeholder: i18n.cipherPasswordPlaceholder,
+      minlength: 32
+    }),
+    br,
+    button({ type: "submit" }, i18n.cipherDecryptButton)
+  );
+
+  const encryptResult = encryptedText 
+    ? div({ class: "cipher-result visible encrypted-result" }, 
+        label(i18n.cipherEncryptedMessageLabel),
+        br,br,
+        div({ class: "cipher-text" }, encryptedText),
+        label(i18n.cipherPasswordUsedLabel),
+        br,br,
+        div({ class: "cipher-text" }, password) 
+      )
+    : null;
+
+  const decryptResult = decryptedText 
+    ? div({ class: "cipher-result visible decrypted-result" }, 
+        label(i18n.cipherDecryptedMessageLabel),
+        br,br,
+        div({ class: "cipher-text" }, decryptedText) 
+      )
+    : null;
+
+  return template(
+    i18n.cipherTitle,
+    section(
+      header,
+      div({ id: "randomPassword" },
+        h2({ class: "generated-password" }, randomPassword)
+      ),
+      div({ class: "div-center" },
+        encryptForm,
+        br,
+        encryptResult, 
+        decryptForm,
+        br,
+        decryptResult 
+      )
+    )
+  );
+};
+
+exports.cipherView = cipherView;
+

+ 229 - 0
src/views/cv_view.js

@@ -0,0 +1,229 @@
+const { form, button, div, h2, p, section, textarea, label, input, br, img, a, select, option } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+
+const generateCVBox = (label, content, className) => {
+  return div({ class: `cv-box ${className}` }, 
+    h2(label),
+    content
+  );
+};
+
+const generateTags = (tags) => {
+  return tags && tags.length
+    ? div(
+        tags.map(tag =>
+          a({
+            href: `/search?query=%23${encodeURIComponent(tag)}`,
+            class: "tag-link",
+            style: "margin-right:0.8em;margin-bottom:0.5em;"
+          }, `#${tag}`)
+        )
+      )
+    : null;
+};
+
+exports.createCVView = async (cv = {}, editMode = false) => {
+  const title = editMode ? i18n.cvEditSectionTitle : i18n.cvCreateSectionTitle;
+
+  return template(
+    title,
+    section(
+      div({ class: "tags-header" },
+        h2(title),
+        p(i18n.cvDescription)
+      ),
+      div({ class: "cv-form" },
+        form({
+          method: "POST",
+          action: editMode ? `/cv/update/${encodeURIComponent(cv.id)}` : "/cv/upload",
+          enctype: "multipart/form-data"
+        },
+
+          generateCVBox(i18n.cvPersonal, [
+            label(i18n.cvNameLabel), br(),
+            input({ type: "text", name: "name", required: true, value: cv.name || "" }), br(),
+            label(i18n.cvDescriptionLabel), br(),
+            textarea({ name: "description", required: true }, cv.description || ""), br(),
+            label(i18n.cvLanguagesLabel), br(),
+            input({ type: "text", name: "languages", value: cv.languages || "" }), br(),
+            label(i18n.cvPhotoLabel), br(),
+            input({ type: "file", name: "image" }), br(), br(),
+            label(i18n.cvPersonalExperiencesLabel), br(),
+            textarea({ name: "personalExperiences", rows: 4 }, cv.personalExperiences || ""), br(),
+            label(i18n.cvPersonalSkillsLabel), br(),
+            input({ type: "text", name: "personalSkills", required: true, value: (cv.personalSkills || []).join(", ") }), br()
+          ], "personal"),
+
+          generateCVBox(i18n.cvOasis, [
+            label(i18n.cvOasisExperiencesLabel), br(),
+            textarea({ name: "oasisExperiences", rows: 4 }, cv.oasisExperiences || ""), br(),
+            label(i18n.cvOasisSkillsLabel), br(),
+            input({ type: "text", name: "oasisSkills", value: (cv.oasisSkills || []).join(", ") }), br()
+          ], "oasis"),
+
+          generateCVBox(i18n.cvEducational, [
+            label(i18n.cvEducationExperiencesLabel), br(),
+            textarea({ name: "educationExperiences", rows: 4 }, cv.educationExperiences || ""), br(),
+            label(i18n.cvEducationalSkillsLabel), br(),
+            input({ type: "text", name: "educationalSkills", value: (cv.educationalSkills || []).join(", ") }), br()
+          ], "education"),
+
+          generateCVBox(i18n.cvProfessional, [
+            label(i18n.cvProfessionalExperiencesLabel), br(),
+            textarea({ name: "professionalExperiences", rows: 4 }, cv.professionalExperiences || ""), br(),
+            label(i18n.cvProfessionalSkillsLabel), br(),
+            input({ type: "text", name: "professionalSkills", value: (cv.professionalSkills || []).join(", ") }), br()
+          ], "professional"),
+
+          generateCVBox(i18n.cvAvailability, [
+            label(i18n.cvLocationLabel), br(),
+            input({ type: "text", name: "location", required: true, value: cv.location || "UNKNOWN" }), br(),
+            label(i18n.cvStatusLabel), br(),
+            select({ name: "status", required: true },
+              option({ value: "AVAILABLE", selected: cv.status === "AVAILABLE FOR COLLABORATION" }, "AVAILABLE FOR COLLABORATION"),
+              option({ value: "UNAVAILABLE", selected: cv.status === "NOT CURRENTLY AVAILABLE" }, "NOT CURRENTLY AVAILABLE"),
+              option({ value: "LOOKING FOR WORK", selected: !cv.status || cv.status === "LOOKING FOR WORK" }, "LOOKING FOR WORK")
+            ), br(), br(),
+            label(i18n.cvPreferencesLabel), br(),
+            select({ name: "preferences", required: true },
+              option({ value: "IN PERSON", selected: cv.preferences === "IN-PERSON ONLY" }, "IN-PERSON ONLY"),
+              option({ value: "REMOTE WORKING", selected: !cv.preferences || cv.preferences === "REMOTE WORKING" }, "REMOTE-WORKING")
+            ), br()
+          ], "availability"),
+
+          button({ type: "submit" }, editMode ? i18n.cvUpdateButton : i18n.cvCreateButton)
+        )
+      )
+    )
+  )
+};
+
+exports.cvView = async (cv) => {
+  const title = i18n.cvTitle;
+
+  if (!cv) {
+    return template(
+      title,
+      section(
+        div({ class: "tags-header" },
+          h2(title),
+          p(i18n.cvDescription)
+        ),
+        div({ class: "no-cv" },
+          p(i18n.cvNoCV),
+          form({ method: "GET", action: "/cv/create" },
+            button({ type: "submit" }, i18n.cvCreateButton)
+          )
+        )
+      )
+    )
+  }
+
+  const hasPersonal = cv.contact || cv.name || cv.description || cv.photo || typeof cv.oasisContributor === "boolean" || (cv.personalSkills && cv.personalSkills.length);
+  const hasPersonalExp = cv.personalExperiences;
+  const hasOasis = cv.oasisExperiences || (cv.oasisSkills && cv.oasisSkills.length);
+  const hasEducational = cv.educationExperiences || cv.languages || (cv.educationalSkills && cv.educationalSkills.length);
+  const hasProfessional = cv.professionalExperiences || (cv.professionalSkills && cv.professionalSkills.length);
+  const hasAvailability = cv.location || cv.status || cv.preferences;
+
+  return template(
+    title,
+    section(
+      div({ class: "tags-header" },
+        h2(title),
+        p(i18n.cvDescription)
+      ),
+      div({ class: "cv-section" },
+        div({ class: "cv-item" }, ...[
+          div({ class: "cv-actions" },
+            form({ method: "GET", action: `/cv/edit/${encodeURIComponent(cv.id)}` },
+              button({ type: "submit" }, i18n.cvEditButton)
+            ),
+            form({ method: "POST", action: `/cv/delete/${encodeURIComponent(cv.id)}` },
+              button({ type: "submit" }, i18n.cvDeleteButton)
+            )
+          ),
+          div({ class: "cv-meta" },
+            p(`${i18n.cvCreatedAt}: ${new Date(cv.createdAt).toLocaleString()}`),
+            cv.updatedAt ? p(`${i18n.cvUpdatedAt}: ${new Date(cv.updatedAt).toLocaleString()}`) : null
+          ),
+          hasPersonal ? div({ class: "cv-box personal" }, ...[
+            cv.photo
+              ? img({
+                  src: `/blob/${encodeURIComponent(cv.photo)}`,
+                  class: "cv-photo"
+                })
+              : null,
+            cv.contact ? p("User ID: ", a({ href: `/author/${encodeURIComponent(cv.contact)}` }, cv.contact)) : null,
+            cv.name ? p(`${i18n.cvNameLabel}: ${cv.name}`) : null,
+            cv.description ? p(`${i18n.cvDescriptionLabel}: ${cv.description}`) : null,
+            cv.languages ? p(`${i18n.cvLanguagesLabel}: ${cv.languages}`) : null,
+            (cv.personalSkills && cv.personalSkills.length)
+              ? div(
+                  cv.personalSkills.map(tag =>
+                    a({
+                      href: `/search?query=%23${encodeURIComponent(tag)}`,
+                      class: "tag-link",
+                      style: "margin-right:0.8em;margin-bottom:0.5em;"
+                    }, `#${tag}`)
+                  )
+                )
+              : null
+          ]) : null,
+          hasOasis ? div({ class: "cv-box oasis" }, ...[
+            h2(i18n.cvOasisContributorView),
+            p(`${cv.oasisExperiences}`),
+            (cv.oasisSkills && cv.oasisSkills.length)
+              ? div(
+                  cv.oasisSkills.map(tag =>
+                    a({
+                      href: `/search?query=%23${encodeURIComponent(tag)}`,
+                      class: "tag-link",
+                      style: "margin-right:0.8em;margin-bottom:0.5em;"
+                    }, `#${tag}`)
+                  )
+                )
+              : null
+          ]) : null,
+          hasEducational ? div({ class: "cv-box education" }, ...[
+            h2(i18n.cvEducationalView),
+            cv.educationExperiences ? p(`${cv.educationExperiences}`) : null,
+            (cv.educationalSkills && cv.educationalSkills.length)
+              ? div(
+                  cv.educationalSkills.map(tag =>
+                    a({
+                      href: `/search?query=%23${encodeURIComponent(tag)}`,
+                      class: "tag-link",
+                      style: "margin-right:0.8em;margin-bottom:0.5em;"
+                    }, `#${tag}`)
+                  )
+                )
+              : null
+          ]) : null,
+          hasProfessional ? div({ class: "cv-box professional" }, ...[
+            h2(i18n.cvProfessionalView),
+            cv.professionalExperiences ? p(`${cv.professionalExperiences}`) : null,
+            (cv.professionalSkills && cv.professionalSkills.length)
+              ? div(
+                  cv.professionalSkills.map(tag =>
+                    a({
+                      href: `/search?query=%23${encodeURIComponent(tag)}`,
+                      class: "tag-link",
+                      style: "margin-right:0.8em;margin-bottom:0.5em;"
+                    }, `#${tag}`)
+                  )
+                )
+              : null
+          ]) : null,
+          hasAvailability ? div({ class: "cv-box availability" }, ...[
+            h2(i18n.cvAvailabilityView),
+            cv.location ? p(`${i18n.cvLocationLabel}: ${cv.location}`) : null,
+            cv.status ? p(`${i18n.cvStatusLabel}: ${cv.status}`) : null,
+            cv.preferences ? p(`${i18n.cvPreferencesLabel}: ${cv.preferences}`) : null
+          ]) : null
+        ])
+      )
+    )
+  );
+};
+

+ 192 - 0
src/views/document_view.js

@@ -0,0 +1,192 @@
+const { form, button, div, h2, p, section, input, label, br, a } = 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 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;
+
+  return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+};
+
+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 = (filteredDocs, filter) => {
+  return filteredDocs.length > 0
+    ? filteredDocs.map(doc =>
+        div({ class: "document-item" },
+          renderDocumentActions(filter, doc),
+          form({ method: "GET", action: `/documents/${encodeURIComponent(doc.key)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)),
+          br,
+          div({
+            id: `pdf-container-${doc.key}`,
+            class: 'pdf-viewer-container',
+            'data-pdf-url': `/blob/${encodeURIComponent(doc.url)}`
+          }),
+          p(`${i18n.documentCreatedAt}: ${moment(doc.createdAt).format('YYYY/MM/DD HH:mm:ss')}`),
+          p(`${i18n.documentAuthor}: `, a({ href: `/author/${encodeURIComponent(doc.author)}` }, doc.author)),
+          doc.title?.trim() ? h2(doc.title) : null,
+          doc.description?.trim() ? p(doc.description) : null,
+          doc.tags.length
+            ? div(doc.tags.map(tag =>
+                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+              ))
+            : null,
+          div({ class: "voting-buttons" },
+            ['interesting','necessary','funny','disgusting','sensible',
+             'propaganda','adultOnly','boring','confusing','inspiring','spam']
+              .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)}`]} [${doc.opinions?.[category] || 0}]`
+                  )
+                )
+              )
+          )
+        )
+      )
+    : div(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(),
+      input({ type: "text", name: "description", placeholder: i18n.documentDescriptionPlaceholder, value: 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;
+
+  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 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),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.documentCreateButton)
+        )
+      )
+    ),
+    section(
+      (filter === 'create' || filter === 'edit')
+        ? renderDocumentForm(filter, documentId, docToEdit)
+        : renderDocumentList(filteredDocs, filter)
+    )
+  );
+
+  return `${tpl}
+    ${isDocView
+      ? `<script type="module" src="/js/pdf.min.mjs"></script>
+         <script src="/js/pdf-viewer.js"></script>`
+      : ''}`;
+};
+
+exports.singleDocumentView = async (doc, filter) => {
+  const isAuthor = doc.author === userId;
+  const hasOpinions = Object.keys(doc.opinions || {}).length > 0;
+
+  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),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.documentCreateButton)
+        )
+      ),
+      div({ class: "tags-header" },
+        h2(doc.title),
+        p(doc.description),
+        div({
+          id: `pdf-container-${doc.key}`,
+          class: 'pdf-viewer-container',
+          'data-pdf-url': `/blob/${encodeURIComponent(doc.url)}`
+        }),
+        p(`${i18n.documentCreatedAt}: ${moment(doc.createdAt).format('YYYY/MM/DD HH:mm:ss')}`),
+        p(`${i18n.documentAuthor}: `, a({ href: `/author/${encodeURIComponent(doc.author)}` }, doc.author)),
+        doc.tags?.length
+          ? div(doc.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+            ))
+          : null
+      ),
+      isAuthor ? div({ class: "document-actions" },
+        !hasOpinions
+          ? form({ method: "GET", action: `/documents/edit/${encodeURIComponent(doc.key)}` },
+              button({ class: "update-btn", type: "submit" }, i18n.documentUpdateButton)
+            )
+          : null,
+        form({ method: "POST", action: `/documents/delete/${encodeURIComponent(doc.key)}` },
+          button({ class: "delete-btn", type: "submit" }, i18n.documentDeleteButton)
+        )
+      ) : null,
+      div({ class: "voting-buttons" },
+        ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'].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)}`]} [${doc.opinions?.[category] || 0}]`)
+          )
+        )
+      )
+    )
+  );
+
+  return `${tpl}
+    ${filter === 'mine' || filter === 'edit' || filter === 'top' || filter === 'recent' || filter === 'all'
+      ? `<script type="module" src="/js/pdf.min.mjs"></script>
+         <script src="/js/pdf-viewer.js"></script>`
+      : ''}`;
+};

+ 241 - 0
src/views/event_view.js

@@ -0,0 +1,241 @@
+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 moment = require("../server/node_modules/moment");
+const { config } = require('../server/SSB_server.js');
+
+const userId = config.keys.id
+
+exports.eventView = async (events, filter, eventId) => {
+  const list = Array.isArray(events) ? events : [events]
+  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 => e.isPublic === "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')
+  } else {
+    filtered = []
+  }
+
+  filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+
+  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)
+        )
+      )
+    ),
+    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}, 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)
+          )
+        )
+      ) : (
+        div({ class:"event-list" },
+          filtered.length > 0
+            ? filtered.map(e => {
+                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)
+                    )
+                  )
+                  actions.push(
+                    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
+                      )
+                    )
+                  )
+                }
+                return div({ class:"event-item" },
+                  actions.length ? div({ class:"event-actions" }, ...actions) : null,
+                  form({ method:"GET", action:`/events/${encodeURIComponent(e.id)}` },
+                    button({ type:"submit", class:"filter-btn" }, i18n.viewDetails)
+                  ),
+                  h2(e.title),
+                  p(`${i18n.eventDescription}: ${e.description}`),
+                  p(`${i18n.eventDate}: ${moment(e.date).format('YYYY/MM/DD HH:mm:ss')}`),
+                  p(`${i18n.eventPrivacyLabel}: ${e.isPublic}`),
+                  e.location?.trim() ? p(`${i18n.eventLocation}: ${e.location}`) : null,
+                  e.url?.trim() ? p(`${i18n.eventUrlLabel}: `, a({ href: e.url }, e.url)) : null,
+                  p(`${i18n.eventPriceLabel}: ${parseFloat(e.price || 0).toFixed(6)} ECO`),
+                  p(`${i18n.eventAttendees}: `,
+                    Array.isArray(e.attendees) && e.attendees.length
+                      ? e.attendees.filter(Boolean).map((id, i) => [i > 0 ? ', ' : '', a({ href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
+                      : i18n.noAttendees
+                  ),
+                  p(`${i18n.eventCreatedAt}: ${moment(e.createdAt).format('YYYY/MM/DD HH:mm:ss')}`),
+                  p(`${i18n.eventBy}: `, a({ href: `/author/${encodeURIComponent(e.organizer)}` }, e.organizer)),
+                  p(`${i18n.eventStatus}: ${e.status}`),
+                  e.tags && e.tags.filter(Boolean).length
+                    ? div(
+                        e.tags.filter(Boolean).map(tag =>
+                          a({
+                            href:`/search?query=%23${encodeURIComponent(tag)}`,
+                            class:"tag-link",
+                            style:"margin-right:0.8em;margin-bottom:0.5em;"
+                          }, `#${tag}`)
+                        )
+                      )
+                    : null,
+                  div({ class: "voting-buttons" },
+                    ['interesting','necessary','funny','disgusting','sensible','propaganda','adultOnly','boring','confusing','inspiring','spam'].map(category =>
+                      form({ method:"POST", action:`/events/opinions/${encodeURIComponent(e.id)}/${category}` },
+                        button({ class:"vote-btn" },
+                          `${i18n[`vote${category.charAt(0).toUpperCase()+category.slice(1)}`]} [${e.opinions?.[category]||0}]`
+                        )
+                      )
+                    )
+                  )
+                )
+              })
+            : p(i18n.noevents)
+        )
+      )
+    )
+  )
+}
+
+exports.singleEventView = async (event, filter) => {
+  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: "tags-header" },
+        h2(event.title),
+        p(event.description),
+        p(`${i18n.eventDate}: ${moment(event.date).format('YYYY/MM/DD HH:mm:ss')}`),
+        p(`${i18n.eventLocation}: ${event.location}`),
+        p(`${i18n.eventUrlLabel}: `, a({ href: event.url }, event.url)),
+        p(`${i18n.eventPriceLabel}: ${parseFloat(event.price || 0).toFixed(6)} ECO`),
+        p(`${i18n.eventAttendees}: `,
+          Array.isArray(event.attendees) && event.attendees.length
+            ? event.attendees.map((id, i) => [i > 0 ? ', ' : '', a({ href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
+            : i18n.noAttendees
+        ),
+        p(`${i18n.eventCreatedAt}: ${moment(event.createdAt).format('YYYY/MM/DD HH:mm:ss')}`),
+        p(`${i18n.eventBy}: `, a({ href: `/author/${encodeURIComponent(event.organizer)}` }, event.organizer)),
+        p(`${i18n.eventStatus}: ${event.status}`),
+        event.tags && event.tags.length
+          ? div(
+              event.tags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
+            )
+          : null
+      ),
+      div({ class: "event-actions" },
+        form({ method: "POST", action: `/events/attend/${encodeURIComponent(event.id)}` },
+          button({ type: "submit" },
+            event.attendees.includes(userId)
+              ? i18n.eventUnattendButton
+              : i18n.eventAttendButton
+          )
+        )
+      )
+    )
+  );
+};

+ 150 - 0
src/views/feed_view.js

@@ -0,0 +1,150 @@
+const { div, h2, p, section, button, form, a, span, textarea, br, input, h1 } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+const { config } = require('../server/SSB_server.js');
+
+const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
+
+const generateFilterButtons = (filters, currentFilter, action) => {
+  return filters.map(mode =>
+    form({ method: 'GET', action },
+      input({ type: 'hidden', name: 'filter', value: mode }),
+      button({ type: 'submit', class: currentFilter === mode.toLowerCase() ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
+    )
+  );
+};
+
+const renderFeedCard = (feed, alreadyRefeeded, alreadyVoted) => {
+  const content = feed.value.content;
+  const totalVotes = Object.entries(content.opinions || {});
+  const totalCount = totalVotes.reduce((sum, [, count]) => sum + count, 0);
+  const createdAt = feed.value.timestamp ? new Date(feed.value.timestamp).toLocaleString() : '';
+
+  return div({ class: 'feed-card' },
+    div({ class: 'feed-row' },
+      div({ class: 'refeed-column' },
+        h1(`${content.refeeds || 0}`),
+        !alreadyRefeeded
+          ? form({ method: 'POST', action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
+              button({ class: 'refeed-btn' }, i18n.refeedButton)
+            )
+          : p(i18n.alreadyRefeeded)
+      ),
+      div({ class: 'feed-main' },
+        div({ class: 'feed-text', innerHTML: renderTextWithStyles(content.text) }),
+        p(`${i18n.author}: `, a({ href: `/author/${encodeURIComponent(feed.value.author)}` }, feed.value.author)),
+        p(`${i18n.createdAtLabel}: ${createdAt}`),
+        h2(`${i18n.totalOpinions}: ${totalCount}`)
+      )
+    ),
+    div({ class: 'votes-wrapper' },
+      totalVotes.length > 0
+        ? div({ class: 'votes' },
+            totalVotes.map(([category, count]) =>
+              span({ class: 'vote-category' }, `${category}: ${count}`)
+            )
+          )
+        : null,
+      !alreadyVoted
+        ? div({ class: 'voting-buttons' },
+            ['interesting','necessary','funny','disgusting','sensible','propaganda','adultOnly','boring','confusing','inspiring','spam'].map(cat =>
+              form({ method: 'POST', action: `/feed/opinions/${encodeURIComponent(feed.key)}/${cat}` },
+                button({ class: 'vote-btn' }, `${i18n['vote'+cat.charAt(0).toUpperCase()+cat.slice(1)] || cat} [${content.opinions?.[cat]||0}]`)
+              )
+            )
+          )
+        : p(i18n.alreadyVoted)
+    )
+  );
+};
+
+exports.feedView = (feeds, filter) => {
+  const title = 
+    filter === 'MINE'   ? i18n.MINEButton :
+    filter === 'TODAY'  ? i18n.TODAYButton :
+    filter === 'TOP'    ? i18n.TOPButton :
+    filter === 'CREATE' ? i18n.createFeedTitle :
+    filter === 'tag'    ? i18n.filteredByTag :
+                          i18n.feedTitle;
+
+  if (filter !== 'TOP') {
+    feeds = feeds.sort((a, b) => b.value.timestamp - a.value.timestamp);
+  } else {
+    feeds = feeds.sort((a, b) => {
+      const aRefeeds = a.value.content.refeeds || 0;
+      const bRefeeds = b.value.content.refeeds || 0;
+      return bRefeeds - aRefeeds;
+    });
+  }
+
+  const header = div({ class: 'tags-header' },
+    h2(title),
+    p(i18n.FeedshareYourOpinions)
+  );
+
+  return template(
+    title,
+    section(
+      header,
+      div({ class: 'mode-buttons', style: 'display:flex; gap:8px; margin-bottom:24px;' },
+        generateFilterButtons(['ALL', 'MINE', 'TODAY', 'TOP'], filter, '/feed'),
+        form({ method: 'GET', action: '/feed/create' },
+          button({
+            type: 'submit',
+            class: 'create-button filter-btn'
+          }, i18n.createFeedTitle || "Create Feed")
+        )
+      ),
+      section(
+        filter === 'CREATE'
+          ? form({ method: 'POST', action: '/feed/create' },
+              textarea({
+                name: 'text',
+                placeholder: i18n.feedPlaceholder,
+                maxlength: 280,
+                rows: 5,
+                cols: 50
+              }),
+              br(),
+              button({ type: 'submit' }, i18n.createFeedButton)
+            )
+          : feeds && feeds.length > 0
+            ? div({ class: 'feed-container' },
+                feeds.map(feed => {
+                  const content = feed.value.content;
+                  const alreadyRefeeded = content.refeeds_inhabitants?.includes(config.keys.id);
+                  const alreadyVoted = content.opinions_inhabitants?.includes(config.keys.id);
+                  return renderFeedCard(feed, alreadyRefeeded, alreadyVoted);
+                })
+              )
+            : div({ class: 'no-results' }, p(i18n.noFeedsFound))
+      )
+    )
+  );
+};
+
+exports.feedCreateView = () => {
+  return template(
+    i18n.createFeedTitle,
+    section(
+      div({ class: 'tags-header' },
+        h2(i18n.createFeedTitle),
+        p(i18n.FeedshareYourOpinions)
+      ),
+      div({ class: 'mode-buttons', style: 'display:flex; gap:8px; margin-bottom:24px;' },
+        generateFilterButtons(['ALL', 'MINE', 'TODAY', 'TOP'], 'CREATE', '/feed')
+      ),
+      form({ method: 'POST', action: '/feed/create' },
+        textarea({
+          name: 'text',
+          maxlength: '280',
+          rows: 5,
+          cols: 50,
+          placeholder: i18n.feedPlaceholder
+        }),
+        br(),
+        button({ type: 'submit', class: 'create-button' }, i18n.createFeedButton || 'Send Feed!')
+      )
+    )
+  );
+};
+

+ 212 - 0
src/views/image_view.js

@@ -0,0 +1,212 @@
+const { form, button, div, h2, p, section, input, label, br, a, img } = 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 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 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)}` },
+      button({ class: "delete-btn", type: "submit" }, i18n.imageDeleteButton)
+    )
+  ) : null;
+};
+
+const renderImageList = (filteredImages, filter) => {
+  return filteredImages.length > 0
+    ? filteredImages.map(imgObj =>
+        div({ class: "image-item" },
+          renderImageActions(filter, imgObj),
+          form({ method: "GET", action: `/images/${encodeURIComponent(imgObj.key)}` },
+	    button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+	  ),  br,
+          a({ href: `#img-${encodeURIComponent(imgObj.key)}` }, img({ src: `/blob/${encodeURIComponent(imgObj.url)}` })),
+          br(),
+          p(`${i18n.imageCreatedAt}: ${moment(imgObj.createdAt).format('YYYY/MM/DD HH:mm:ss')}`),
+          p(`${i18n.imageAuthor}: `, a({ href: `/author/${encodeURIComponent(imgObj.author)}` }, imgObj.author)),
+          imgObj.title ? h2(imgObj.title) : null,
+          imgObj.description ? p(imgObj.description) : null,
+          imgObj.tags?.length
+            ? div(imgObj.tags.map(tag =>
+                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+              ))
+            : null,
+          div({ class: "voting-buttons" },
+            ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam']
+              .map(category =>
+                form({ method: "POST", action: `/images/opinions/${encodeURIComponent(imgObj.key)}/${category}` },
+                  button({ class: "vote-btn" },
+                    `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${imgObj.opinions?.[category] || 0}]`
+                  )
+                )
+              )
+          )
+        )
+      )
+    : div(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,
+      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(),
+      input({ type: "text", name: "description", placeholder: i18n.imageDescriptionPlaceholder, value: imageToEdit?.description || '' }), br(), br(),
+      label(i18n.imageMemeLabel),
+      input({ type: "checkbox", name: "meme", ...(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 renderLightbox = (sortedImages) => {
+  return sortedImages.map(imgObj =>
+    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 || "" })
+    )
+  );
+};
+
+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;
+
+  const filteredImages = getFilteredImages(filter, images, userId);
+
+  const imageToEdit = images.find(img => img.key === imageId);
+
+  return template(
+    title,
+    section(
+      div({ class: "tags-header" },
+        h2(title),
+        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)}`]
+            )
+          ),
+          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)
+    ),
+    ...renderLightbox(filteredImages)
+  );
+};
+
+exports.singleImageView = async (image, filter) => {
+  const isAuthor = image.author === userId;
+  const hasOpinions = Object.keys(image.opinions || {}).length > 0; 
+
+  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),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.imageCreateButton)
+        )
+      ),
+      div({ class: "tags-header" },
+        h2(image.title),
+        p(image.description),
+        p(`${i18n.imageCreatedAt}: ${moment(image.createdAt).format('YYYY/MM/DD HH:mm:ss')}`),
+        p(`${i18n.imageAuthor}: `, a({ href: `/author/${encodeURIComponent(image.author)}` }, image.author)),
+        image.url ? img({ src: `/blob/${encodeURIComponent(image.url)}` }) : null,br,
+        image.tags?.length
+          ? div(
+              image.tags.map(tag =>
+                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+              )
+            )
+          : null
+      ),
+      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,
+      div({ class: "voting-buttons" },
+        ['interesting', 'necessary', 'funny', 'disgusting', 'sensible', 'propaganda', 'adultOnly', 'boring', 'confusing', 'inspiring', 'spam'].map(category =>
+          form({ method: "POST", action: `/images/opinions/${encodeURIComponent(image.key)}/${category}` },
+            button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${image.opinions?.[category] || 0}]`)
+          )
+        )
+      )
+    )
+  );
+};

+ 82 - 0
src/views/indexing_view.js

@@ -0,0 +1,82 @@
+const { html, head, title, link, meta, style, body, main, p, progress, svg, rect, circle, path, g } = require("../server/node_modules/hyperaxe");
+const { i18n } = require('./main_views');
+
+const mysticalMessages = [
+  "Water flows for those who wait.",
+  "Where silence burns, the voice of the Oasis grows.",
+  "Each grain of sand holds the eternal.",
+  "There is no map to the true refuge—only the path.",
+  "Under the still sun, patience blooms."
+];
+
+const indexingView = ({ percent }) => {
+  const message = `Oasis has only processed ${percent}% of the messages and needs to catch up. This page will refresh every 10 seconds.`;
+
+  const currentMessage = percent >= 100 ? "The oasis welcomes you with light." : mysticalMessages[Math.floor(percent / 20) % mysticalMessages.length];
+
+  const nodes = html(
+    { lang: "en" },
+    head(
+      title("Oasis"),
+      link({ rel: "icon", type: "image/svg+xml", href: "/assets/favicon.svg" }),
+      meta({ charset: "utf-8" }),
+      meta({ name: "description", content: i18n.oasisDescription }),
+      meta({ name: "viewport", content: toAttributes({ width: "device-width", "initial-scale": 1 }) }),
+      meta({ "http-equiv": "refresh", content: 10 }),
+      style(`
+        body { background: #f4e9dc; color: #3b2f2f; font-family: serif; text-align: center; padding: 2em; }
+        .mystical { font-style: italic; margin-top: 1em; font-size: 1.2em; transition: opacity 1s ease-in-out; }
+        .desert-svg { display: block; margin: 2em auto; max-width: 100%; height: auto; }
+        .star {
+          animation: blink 2s infinite ease-in-out;
+        }
+        .star:nth-child(odd) {
+          animation-delay: 1s;
+        }
+        @keyframes blink {
+          0%, 100% { opacity: 1; }
+          50% { opacity: 0.2; }
+        }
+      `)
+    ),
+    body(
+      main(
+        { id: "content" },
+        p(message),
+        progress({ value: percent, max: 100 }),
+        p({ class: "mystical" }, currentMessage),
+        svg(
+          {
+            xmlns: "http://www.w3.org/2000/svg",
+            viewBox: "0 0 400 100",
+            class: "desert-svg"
+          },
+          rect({ x: 0, y: 0, width: 400, height: 100, fill: percent >= 100 ? "#fceabb" : "#0b1a2a" }),
+          percent < 100 ?
+            Array.from({ length: 20 }, (_, i) => {
+              const cx = Math.random() * 400;
+              const cy = Math.random() * 40;
+              const r = Math.random() * 1.5 + 0.5;
+              return circle({ cx, cy, r, fill: "white", class: "star" });
+            }) :
+            circle({ cx: 350, cy: 30, r: 20, fill: "#fff6b0" }),
+          path({
+            d: "M0,80 Q100,70 200,80 T400,80 L400,100 L0,100 Z",
+            fill: "#d2b48c"
+          }),
+          g(
+            { transform: `translate(${(400 - 32) * (percent / 100)}, 60)` },
+            rect({ x: 0, y: 0, width: 8, height: 8, fill: "#e0cfa9" }),
+            rect({ x: 10, y: 0, width: 6, height: 6, fill: "#e0cfa9" }),
+            rect({ x: 20, y: 0, width: 8, height: 8, fill: "#e0cfa9" })
+          )
+        )
+      )
+    )
+  );
+
+  const result = doctypeString + nodes.outerHTML;
+  return result;
+};
+
+exports.indexingView = indexingView;

+ 197 - 0
src/views/inhabitants_view.js

@@ -0,0 +1,197 @@
+const { div, h2, p, section, button, form, img, a, textarea, input, br } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+
+const generateFilterButtons = (filters, currentFilter) => {
+  return filters.map(mode => 
+    form({ method: 'GET', action: '/inhabitants' },
+      input({ type: 'hidden', name: 'filter', value: mode }),
+      button({
+        type: 'submit',
+        class: currentFilter === mode ? 'filter-btn active' : 'filter-btn'
+      }, i18n[mode + 'Button'] || i18n[mode + 'SectionTitle'] || mode)
+    )
+  );
+};
+
+const renderInhabitantCard = (user, filter) => {
+  return div({ class: 'inhabitant-card' },
+    img({
+      class: 'inhabitant-photo',
+      src: user.photo
+    }),
+    div({ class: 'inhabitant-details' },
+      h2(user.name),
+      user.description ? p(user.description) : null,
+      filter === 'MATCHSKILLS' && user.commonSkills?.length
+        ? p(`${i18n.commonSkills}: ${user.commonSkills.join(', ')}`) : null,
+      filter === 'SUGGESTED' && user.mutualCount
+        ? p(`${i18n.mutualFollowers}: ${user.mutualCount}`) : null,
+      filter === 'blocked' && user.isBlocked
+        ? p(i18n.blockedLabel) : null,
+      p(`${i18n.oasisId}: `, a({ href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
+      ['CVs', 'MATCHSKILLS', 'SUGGESTED'].includes(filter)
+        ? a({ href: `/inhabitant/${encodeURIComponent(user.id)}`, class: 'view-profile-btn' }, i18n.inhabitantviewDetails)
+        : null
+    )
+  );
+};
+
+const renderGalleryInhabitants = (inhabitants) => {
+  return div({ class: "gallery", style: 'display:grid; grid-template-columns: repeat(3, 1fr); gap:16px;' },
+    inhabitants.length
+      ? inhabitants.map(u => {
+          const photo = u.photo || '/assets/images/default-avatar.png'; 
+          return a({ href: `#inhabitant-${encodeURIComponent(u.id)}`, class: "gallery-item" },
+            img({
+              src: photo,
+              alt: u.name || "Anonymous",
+              class: "gallery-image"
+            })
+          );
+        })
+      : p(i18n.noInhabitantsFound)
+  );
+};
+
+const renderLightbox = (inhabitants) => {
+  return inhabitants.map(u => {
+    const photoUrl = u.photo;
+    return div(
+      { id: `inhabitant-${encodeURIComponent(u.id)}`, class: "lightbox" },
+      a({ href: "#", class: "lightbox-close" }, "×"),
+      img({ 
+        src: photoUrl, 
+        class: "lightbox-image", 
+        alt: u.name || "Anonymous" 
+      })
+    );
+  });
+};
+
+exports.inhabitantsView = (inhabitants, filter, query) => {
+  const title = filter === 'contacts'    ? i18n.yourContacts              :
+                filter === 'CVs'         ? i18n.allCVs                    :
+                filter === 'MATCHSKILLS' ? i18n.matchSkills               :
+                filter === 'SUGGESTED'   ? i18n.suggestedSectionTitle     :
+                filter === 'blocked'     ? i18n.blockedSectionTitle       :
+                filter === 'GALLERY'     ? i18n.gallerySectionTitle       :
+                                            i18n.allInhabitants;
+
+  const showCVFilters = filter === 'CVs' || filter === 'MATCHSKILLS';
+  const filters = ['all', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
+
+  return template(
+    title,
+    section(
+      div({ class: 'tags-header' },
+        h2(title),
+        p(i18n.discoverPeople)
+      ),
+      div({ class: 'filters' },
+        form({ method: 'GET', action: '/inhabitants' },
+          input({ type: 'hidden', name: 'filter', value: filter }),
+          input({
+            type: 'text',
+            name: 'search',
+            placeholder: i18n.searchInhabitantsPlaceholder,
+            value: query.search || ''
+          }),
+          showCVFilters
+            ? [
+                input({
+                  type: 'text',
+                  name: 'location',
+                  placeholder: i18n.filterLocation,
+                  value: query.location || ''
+                }),
+                input({
+                  type: 'text',
+                  name: 'language',
+                  placeholder: i18n.filterLanguage,
+                  value: query.language || ''
+                }),
+                input({
+                  type: 'text',
+                  name: 'skills',
+                  placeholder: i18n.filterSkills,
+                  value: query.skills || ''
+                })
+              ]
+            : null,
+          br(),
+          button({ type: 'submit' }, i18n.applyFilters)
+        )
+      ),
+      div({ class: 'inhabitant-action', style: 'margin-top: 1em;' },
+        generateFilterButtons(filters, filter)
+      ),
+      
+      filter === 'GALLERY' 
+        ? renderGalleryInhabitants(inhabitants)
+        : div({ class: 'inhabitants-list' },
+            inhabitants && inhabitants.length > 0
+              ? inhabitants.map(user => renderInhabitantCard(user, filter))
+              : p({ class: 'no-results' }, i18n.noInhabitantsFound)
+          ),
+      ...renderLightbox(inhabitants)
+    )
+  );
+};
+
+exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }) => {
+  const profile = cv || about || {};
+  const id = cv?.author || about?.about || 'unknown';
+  const name = cv?.name || about?.name || 'Unnamed';
+  const description = cv?.description || about?.description || '';
+  const image = cv?.photo ? `/image/256/${encodeURIComponent(cv.photo)}` : '/assets/images/snh-oasis.jpg';
+  const location = cv?.location || '';
+  const languages = typeof cv?.languages === 'string'
+    ? cv.languages.split(',').map(x => x.trim()).filter(Boolean)
+    : Array.isArray(cv?.languages) ? cv.languages : [];
+  const skills = [
+    ...(cv?.personalSkills || []),
+    ...(cv?.oasisSkills || []),
+    ...(cv?.educationalSkills || []),
+    ...(cv?.professionalSkills || [])
+  ];
+  const status = cv?.status || '';
+  const preferences = cv?.preferences || '';
+  const createdAt = cv?.createdAt ? new Date(cv.createdAt).toLocaleString() : '';
+  const title = i18n.inhabitantProfileTitle || i18n.inhabitantviewDetails;
+
+  const header = div({ class: 'tags-header' },
+    h2(title),
+    p(i18n.discoverPeople)
+  );
+
+  return template(
+    name,
+    section(
+      header,
+      div({ class: 'mode-buttons', style: 'display:flex; gap:8px; margin-top:16px;' },
+        generateFilterButtons(['all', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
+      ),
+      div({ class: 'inhabitant-card', style: 'margin-top:32px;' },
+        img({ class: 'inhabitant-photo', src: image, alt: name }),
+        div({ class: 'inhabitant-details' },
+          h2(name),
+          p(a({ href: `/author/${encodeURIComponent(id)}` }, id)),
+          description ? p(description) : null,
+          location ? p(`${i18n.locationLabel}: ${location}`) : null,
+          languages.length ? p(`${i18n.languagesLabel}: ${languages.join(', ')}`) : null,
+          skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null,
+          status ? p(`${i18n.statusLabel || 'Status'}: ${status}`) : null,
+          preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${preferences}`) : null,
+          createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null
+        )
+      ),
+      feed && feed.length
+        ? section({ class: 'profile-feed' },
+            h2(i18n.latestMessages || 'Latest Messages'),
+            feed.map(m => div({ class: 'post' }, p(m.value.content.text || '')))
+          )
+        : null
+    )
+  );
+};
+

+ 83 - 0
src/views/invites_view.js

@@ -0,0 +1,83 @@
+const { form, button, div, h2, p, section, ul, li, a, br, hr, input } = require("../server/node_modules/hyperaxe");
+const path = require("path");
+const fs = require('fs');
+const { template, i18n } = require('./main_views');
+
+const homedir = require('os').homedir();
+const gossipPath = path.join(homedir, ".ssb/gossip.json");
+
+const invitesView = ({ invitesEnabled }) => {
+  let pubs = [];
+  let pubsValue = "false";
+
+  try {
+    pubs = fs.readFileSync(gossipPath, "utf8");
+  } catch {
+    pubs = undefined;
+  }
+
+  if (pubs) {
+    try {
+      pubs = JSON.parse(pubs);
+      if (Array.isArray(pubs) && pubs.length > 0) pubsValue = "true";
+      else pubsValue = "false";
+    } catch {
+      pubsValue = "false";
+    }
+  }
+
+  const pubItems = pubsValue === "true"
+    ? pubs.map(pubItem =>
+        li(
+          p(`PUB: ${pubItem.host}`),
+          p(`${i18n.inhabitants}: ${pubItem.announcers}`),
+          a({ href: `/author/${encodeURIComponent(pubItem.key)}` }, pubItem.key),
+          br,
+          br
+        )
+      )
+    : [];
+
+  const title = i18n.invites;
+  const description = i18n.invitesDescription;
+
+  return template(
+    title,
+    section(
+      div({ class: 'tags-header' },
+        h2(title),
+        p(description)
+      )
+    ),
+    section(
+      div({ class: 'invites-tribes' },
+        h2(i18n.invitesTribesTitle),
+        form(
+          { action: '/tribes/join-code', method: 'post' },
+          input({ name: 'inviteCode', type: 'text', placeholder: i18n.invitesTribeInviteCodePlaceholder, autofocus: true, required: true }),
+          br(),
+          button({ type: 'submit' }, i18n.invitesTribeJoinButton)
+        )
+      )
+    ),
+    section(
+      div({ class: 'pubs-section' },
+        h2(i18n.invitesPubsTitle),
+        form(
+          { action: '/settings/invite/accept', method: 'post' },
+          input({ name: 'invite', type: 'text', placeholder: i18n.invitesPubInviteCodePlaceholder, autofocus: true, required: true }),
+          br(),
+          button({ type: 'submit' }, i18n.invitesAcceptInvite)
+        ),
+        br(),
+        hr(),
+        h2(`${i18n.invitesAcceptedInvites} (${pubItems.length})`),
+        pubItems.length
+          ? ul(pubItems)
+          : p({ class: 'empty' }, i18n.invitesNoInvites)
+      )
+    )
+  );
+};
+
+exports.invitesView = invitesView;

+ 65 - 0
src/views/legacy_view.js

@@ -0,0 +1,65 @@
+const { form, button, div, h2, p, section, input, label, br } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+
+const crypto = require('crypto');
+
+function generateRandomPassword(length = 32) {
+  return crypto.randomBytes(length).toString('hex').slice(0, length); 
+}
+
+const legacyView = async () => {
+  const randomPassword = generateRandomPassword();
+
+  const header = div({ class: 'tags-header' },
+    h2(i18n.legacyTitle),
+    p(i18n.legacyDescription)
+  );
+
+  return template(
+    i18n.legacyTitle,
+    section(
+      header,
+      p({ id: "randomPassword" }, h2({ class: "generated-password" }, randomPassword)),
+      div({ class: "div-center legacy-section" },
+        p(i18n.exportDescription),
+        form(
+          {
+            action: "/legacy/export",
+            method: "POST",
+            id: "exportForm"
+          },
+          label(i18n.exportPasswordLabel),
+          input({
+            type: "password",
+            name: "password",
+            id: "password",
+            required: true,
+            placeholder: i18n.exportPasswordPlaceholder,
+            minlength: 32
+          }),
+          p({ class: "file-info" }, i18n.fileInfo),
+          button({ type: "submit" }, i18n.legacyExportButton)
+        ),
+        br,
+        p(i18n.importDescription),
+        form(
+          { action: "/legacy/import", method: "POST", enctype: "multipart/form-data" },
+          input({ type: "file", name: "uploadedFile", required: true }),
+          br,
+          p(i18n.passwordImport),
+          input({
+            type: "password",
+            name: "importPassword",
+            required: true,
+            placeholder: i18n.importPasswordPlaceholder,
+            minlength: 32
+          }),
+          br,
+          button({ type: "submit" }, i18n.legacyImportButton)
+        )
+      )
+    )
+  );
+};
+
+exports.legacyView = legacyView;

File diff suppressed because it is too large
+ 838 - 1293
src/views/main_views.js


+ 339 - 0
src/views/market_view.js

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

+ 106 - 0
src/views/modules_view.js

@@ -0,0 +1,106 @@
+const { form, button, div, h2, p, section, table, tr, td, input, a, br, ul, li, span, strong } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+const { getConfig } = require('../configs/config-manager.js');
+
+const modulesView = () => {
+  const config = getConfig().modules;
+  const modules = [
+    { name: 'agenda', label: i18n.modulesAgendaLabel, description: i18n.modulesAgendaDescription },
+    { name: 'audios', label: i18n.modulesAudiosLabel, description: i18n.modulesAudiosDescription },
+    { name: 'bookmarks', label: i18n.modulesBookmarksLabel, description: i18n.modulesBookmarksDescription },
+    { name: 'cipher', label: i18n.modulesCipherLabel, description: i18n.modulesCipherDescription },
+    { name: 'docs', label: i18n.modulesDocsLabel, description: i18n.modulesDocsDescription },
+    { name: 'events', label: i18n.modulesEventsLabel, description: i18n.modulesEventsDescription },
+    { name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription },
+    { name: 'governance', label: i18n.modulesGovernanceLabel, description: i18n.modulesGovernanceDescription },
+    { name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription },
+    { name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
+    { name: 'legacy', label: i18n.modulesLegacyLabel, description: i18n.modulesLegacyDescription },
+    { name: 'latest', label: i18n.modulesLatestLabel, description: i18n.modulesLatestDescription },
+    { name: 'market', label: i18n.modulesMarketLabel, description: i18n.modulesMarketDescription },
+    { name: 'multiverse', label: i18n.modulesMultiverseLabel, description: i18n.modulesMultiverseDescription },
+    { name: 'opinions', label: i18n.modulesOpinionsLabel, description: i18n.modulesOpinionsDescription },
+    { name: 'pixelia', label: i18n.modulesPixeliaLabel, description: i18n.modulesPixeliaDescription },
+    { name: 'popular', label: i18n.modulesPopularLabel, description: i18n.modulesPopularDescription },
+    { name: 'reports', label: i18n.modulesReportsLabel, description: i18n.modulesReportsDescription },
+    { name: 'summaries', label: i18n.modulesSummariesLabel, description: i18n.modulesSummariesDescription },
+    { name: 'tags', label: i18n.modulesTagsLabel, description: i18n.modulesTagsDescription },
+    { name: 'tasks', label: i18n.modulesTasksLabel, description: i18n.modulesTasksDescription },
+    { name: 'threads', label: i18n.modulesThreadsLabel, description: i18n.modulesThreadsDescription },
+    { name: 'transfers', label: i18n.modulesTransfersLabel, description: i18n.modulesTransfersDescription },
+    { name: 'trending', label: i18n.modulesTrendingLabel, description: i18n.modulesTrendingDescription },
+    { name: 'tribes', label: i18n.modulesTribesLabel, description: i18n.modulesTribesDescription },
+    { name: 'videos', label: i18n.modulesVideosLabel, description: i18n.modulesVideosDescription },
+    { name: 'wallet', label: i18n.modulesWalletLabel, description: i18n.modulesWalletDescription },
+    { name: 'topics', label: i18n.modulesTopicsLabel, description: i18n.modulesTopicsDescription }
+  ];
+
+  const moduleStates = modules.reduce((acc, mod) => {
+    acc[`${mod.name}Mod`] = config[`${mod.name}Mod`] === 'on' ? 'on' : 'off';
+    return acc;
+  }, {});
+
+  const activeModulesCount = modules.filter(mod => moduleStates[`${mod.name}Mod`] === 'on').length;
+  const totalModulesCount = modules.length;
+  const disabledModulesCount = totalModulesCount - activeModulesCount;
+
+  const header = div({ class: 'tags-header' },
+    h2(i18n.modulesViewTitle),
+    p(i18n.modulesViewDescription),
+    div({ style: 'margin-bottom:16px;' },
+      `${i18n.modulesTotalModulesLabel}: `,
+      strong({ style: 'color:#888;' }, totalModulesCount),
+      ul({ style: 'list-style-type:none; padding:0; margin:0;' },
+        li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
+          `${i18n.modulesEnabledModulesLabel}: `,
+          span({ style: 'color:#888;' }, activeModulesCount)
+        ),
+        li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
+          `${i18n.modulesDisabledModulesLabel}: `,
+          span({ style: 'color:#888;' }, disabledModulesCount)
+        )
+      )
+    )
+  );
+
+  return template(
+    i18n.modules,
+    section(header),
+    section(
+      form(
+        { action: "/save-modules", method: "post" },
+        table(
+          { class: "module-table" },
+          tr(
+            td(i18n.modulesModuleName),
+            td(i18n.modulesModuleDescription),
+            td({ style: 'text-align: center;' }, i18n.modulesModuleStatus)
+          ),
+          modules.map(mod => 
+            tr(
+              td(a({ href: `/${mod.name}` }, mod.label)),
+              td(p(mod.description)),
+              td(
+                input({
+                  type: "checkbox",
+                  id: `${mod.name}Mod`,
+                  name: `${mod.name}Form`,
+                  class: "input-checkbox",
+                  value: 'on',
+                  checked: moduleStates[`${mod.name}Mod`] === 'on' ? true : undefined
+                })
+              )
+            )
+          )
+        ),
+        div(
+          { class: "save-button-container", style: "margin-top: 20px; text-align: center;" },
+          button({ type: "submit", class: "submit-button" }, i18n.saveSettings)
+        )
+      )
+    )
+  );
+};
+
+exports.modulesView = modulesView;
+

+ 299 - 0
src/views/opinions_view.js

@@ -0,0 +1,299 @@
+const { div, h2, p, section, button, form, a, img, video: videoHyperaxe, audio: audioHyperaxe, input, table, tr, th, td, br } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+const { config } = require('../server/SSB_server.js');
+
+const generateFilterButtons = (filters, currentFilter) => {
+  return filters.map(mode =>
+    form({ method: 'GET', action: '/opinions' },
+      input({ type: 'hidden', name: 'filter', value: mode }),
+      button({ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
+    )
+  );
+};
+
+const renderContentHtml = (content, key) => {
+  switch (content.type) {
+    case 'event':
+      return div({ class: 'opinion-event' },
+        form({ method: "GET", action: `/events/${encodeURIComponent(key)}` },
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        h2(content.title),
+        content.description ? p(`${i18n.description}: ${content.description}`) : "",
+        content.date ? p(`${i18n.date}: ${new Date(content.date).toLocaleString()}`) : "",
+        content.location ? p(`${i18n.location}: ${content.location}`) : "",
+        typeof content.isPublic === 'boolean' ? p(`${i18n.isPublic || 'Public'}: ${content.isPublic ? 'Yes' : 'No'}`) : "",
+        content.status ? p(`${i18n.status}: ${content.status}`) : "",
+        content.price ? p(`${i18n.trendingPrice}: ${content.price} ECO`) : "",
+        content.url ? p(`${i18n.trendingUrl}: `, a({ href: content.url, target: '_blank' }, content.url)) : "",
+        content.organizer ? p(`${i18n.organizer || 'Organizer'}: `, a({ href: `/author/${encodeURIComponent(content.organizer)}` }, content.organizer)) : "",
+        Array.isArray(content.attendees) ? p(`${i18n.attendees}: ${content.attendees.length}`) : "",
+        br,
+        content.tags?.length
+          ? div(content.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+            ))
+          : null
+      );
+    case 'bookmark':
+      return div({ class: 'opinion-bookmark' },
+        form({ method: "GET", action: `/bookmarks/${encodeURIComponent(key)}` },
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),br,
+        content.description ? p(content.description) : "",
+        h2(content.url ? p(a({ href: content.url, target: '_blank', class: "bookmark-url" }, content.url)) : ""),
+        content.category ? p(`${i18n.category}: ${content.category}`) : "",
+        content.lastVisit ? p(`${i18n.bookmarkLastVisit}: ${new Date(content.lastVisit).toLocaleString()}`) : "",
+        br,
+        content.tags?.length
+          ? div(content.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+            ))
+          : null
+      );
+    case 'task':
+      return div({ class: 'opinion-task' },
+        form({ method: "GET", action: `/tasks/${encodeURIComponent(key)}` },
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        h2(content.title),
+        content.description ? p(`${i18n.description}: ${content.description}`) : "",
+        content.startTime ? p(`${i18n.trendingStart}: ${new Date(content.startTime).toLocaleString()}`) : "",
+        content.endTime ? p(`${i18n.trendingEnd}: ${new Date(content.endTime).toLocaleString()}`) : "",
+        content.priority ? p(`${i18n.trendingPriority}: ${content.priority}`) : "",
+        content.location ? p(`${i18n.trendingLocation}: ${content.location}`) : "",
+        br,
+        content.tags?.length
+          ? div(content.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+            ))
+          : null,
+        typeof content.isPublic === 'boolean' ? p(`${i18n.trendingIsPublic || 'Public'}: ${content.isPublic ? 'Yes' : 'No'}`) : "",
+        Array.isArray(content.assignees) ? p(`${i18n.trendingAssignees || 'Assignees'}: ${content.assignees.length}`) : "",
+        content.status ? p(`${i18n.trendingStatus}: ${content.status}`) : "",
+        content.author ? p(`${i18n.trendingAuthor || 'Author'}: `, a({ href: `/author/${encodeURIComponent(content.author)}` }, content.author)) : ""
+      );
+    case 'image':
+      return div({ class: 'opinion-image' },
+        form({ method: "GET", action: `/images/${encodeURIComponent(key)}` },
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        content.title ? h2(content.title) : "",
+        content.description ? p(content.description) : "",
+        content.meme ? h2(`${i18n.category}: ${i18n.meme}`) : "",
+        img({ src: `/blob/${encodeURIComponent(content.url)}`, class: 'feed-image' }),
+        br,
+        content.tags?.length
+          ? div(content.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+            ))
+          : null
+      );
+    case 'video':
+      return div({ class: 'opinion-video' },
+        form({ method: "GET", action: `/videos/${encodeURIComponent(key)}` },
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        content.title ? h2(content.title) : "",
+        content.description ? p(content.description) : "",
+        videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType || 'video/mp4', width: '640', height: '360' }),
+        br,
+        content.tags?.length
+          ? div(content.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+            ))
+          : null
+      );
+    case 'audio':
+      return div({ class: 'opinion-audio' },
+        form({ method: "GET", action: `/audios/${encodeURIComponent(key)}` },
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        content.title ? h2(content.title) : "",
+        content.description ? p(content.description) : "",
+        audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType, preload: 'metadata' }),
+        br,
+        content.tags?.length
+          ? div(content.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+            ))
+          : null
+      );
+    case 'document':
+      return div({ class: 'opinion-document' },
+        form({ method: "GET", action: `/documents/${encodeURIComponent(key)}` },
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        br,
+        content.title ? h2(content.title) : "",
+        content.description ? p(content.description) : "",
+        div({ class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(content.url)}` }),
+        br,
+        content.tags?.length
+          ? div(content.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+            ))
+          : null
+      );
+    case 'feed':
+      return div({ class: 'opinion-feed' },
+        h2(content.text),
+        p(`${i18n.author}: `, a({ href: `/author/${encodeURIComponent(content.author)}`, target: "_blank" }, content.author)),
+        p(`${i18n.createdAt}: ${new Date(content.createdAt).toLocaleString()}`),
+        h2(`${i18n.tribeFeedRefeeds}: ${content.refeeds}`)
+      );
+    case 'votes':
+      const votesList = content.votes && typeof content.votes === 'object'
+        ? Object.entries(content.votes).map(([option, count]) => ({ option, count }))
+        : [];
+      return div({ class: 'opinion-votes' },
+        form({ method: "GET", action: `/votes/${encodeURIComponent(key)}` },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        h2(content.question),
+        p(`${i18n.deadline}: ${content.deadline ? new Date(content.deadline).toLocaleString() : ''}`),
+        h2(`${i18n.voteTotalVotes}: ${content.totalVotes}`),
+        table(
+          tr(...votesList.map(({ option }) => th(i18n[option] || option))),
+          tr(...votesList.map(({ count }) => td(count)))
+        )
+      );
+  case 'report':
+    return div({ class: 'opinion-report' },
+      form({ method: "GET", action: `/reports/${encodeURIComponent(key)}` },
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+      ),
+      br,
+      p(`${i18n.title}: ${content.title}`),
+      content.description ? p(`${i18n.description}: ${content.description}`) : "",
+      content.category ? p(`${i18n.category}: ${content.category}`) : "",
+      content.severity ? p(`${i18n.severity || 'Severity'}: ${content.severity}`) : "",
+      content.status ? p(`${i18n.status}: ${content.status}`) : "",
+      content.image ? img({ src: `/blob/${encodeURIComponent(content.image)}`, class: 'feed-image' }) : "",
+      content.createdAt ? p(`${i18n.date}: ${new Date(content.createdAt).toLocaleString()}`) : "",
+      typeof content.isAnonymous === 'boolean' 
+        ? p(`${i18n.author || 'Author'}: `, content.isAnonymous 
+        ? i18n.reportsAnonymousAuthor || 'Anonymous' 
+        : a({ href: `/author/${encodeURIComponent(content.author)}`, target: '_blank' }, content.author))
+        : content.author ? p(`${i18n.author || 'Author'}: `, a({ href: `/author/${encodeURIComponent(content.author)}`, target: '_blank' }, content.author)) : "",
+      Array.isArray(content.confirmations) ? p(`${i18n.confirmations || 'Confirmations'}: ${content.confirmations.length}`) : "",
+      br,
+      content.tags?.length
+        ? div(content.tags.map(tag =>
+           a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+          ))
+        : null
+    );
+    case 'transfer':
+      return div({ class: 'opinion-transfer' },
+        form({ method: "GET", action: `/transfers/${encodeURIComponent(key)}` },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        h2(`${i18n.concept}: ${content.concept}`),
+        p(`${i18n.from}: `, a({ href: `/author/${encodeURIComponent(content.from)}`, target: "_blank" }, content.from)),
+        p(`${i18n.to}: `, a({ href: `/author/${encodeURIComponent(content.to)}`, target: "_blank" }, content.to)),
+        h2(`${i18n.amount}: ${content.amount}`),
+        p(`${i18n.deadline}: ${content.deadline ? new Date(content.deadline).toLocaleString() : ""}`),
+        p(`${i18n.status}: ${content.status}`),
+        p(`${i18n.transfersConfirmations}: ${content.confirmedBy.length}/2`),
+        br,
+        content.tags?.length
+          ? div(content.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+            ))
+          : null
+      );
+    default:
+      return div({ class: 'styled-text', innerHTML: content.text || content.description || content.title || '[no content]' });
+  }
+};
+
+exports.opinionsView = (items, filter) => {
+  const title = i18n.opinionsTitle;
+  const baseFilters = ['RECENT', 'ALL', 'MINE', 'TOP'];
+  const categoryFilters = [
+    ['interesting', 'necessary', 'funny', 'disgusting'],
+    ['sensible', 'propaganda', 'adultOnly', 'boring'],
+    ['confusing', 'inspiring', 'spam']
+  ];
+
+  if (filter !== 'TOP') {
+    items = [...items].sort((a, b) => b.value.timestamp - a.value.timestamp);
+  }
+
+  const hasDocuments = items.some(item => item.value.content?.type === 'document');
+
+  const header = div({ class: 'tags-header' },
+    h2(title),
+    p(i18n.shareYourOpinions)
+  );
+
+  const html = template(
+    title,
+    section(
+      header,
+      div({ class: 'mode-buttons', style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px;' },
+        div({ style: 'display:flex;flex-direction:column;gap:8px;' },
+          baseFilters.map(mode =>
+            form({ method: 'GET', action: '/opinions' },
+              input({ type: 'hidden', name: 'filter', value: mode }),
+              button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
+            )
+          )
+        ),
+        ...categoryFilters.map(row =>
+          div({ style: 'display:flex;flex-direction:column;gap:8px;' },
+            row.map(mode =>
+              form({ method: 'GET', action: '/opinions' },
+                input({ type: 'hidden', name: 'filter', value: mode }),
+                button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
+              )
+            )
+          )
+        )
+      ),
+      section(
+        items.length > 0
+          ? div({ class: 'opinions-container' },
+              items.map(item => {
+                const c = item.value.content;
+                const voteEntries = Object.entries(c.opinions || {});
+                const total = voteEntries.reduce((sum, [, v]) => sum + v, 0);
+                const voted = c.opinions_inhabitants?.includes(config.keys.id);
+                const created = new Date(item.value.timestamp).toLocaleString();
+                const key = item.key;
+                const contentHtml = renderContentHtml(c, key);
+
+                return div({ class: 'opinion-card' },
+                  contentHtml,
+                  p(`${i18n.author}: `, a({ href: `/author/${encodeURIComponent(item.value.author)}` }, item.value.author)),
+                  p(`${i18n.createdAtLabel || i18n.opinionsCreatedAt}: ${created}`),
+                  h2(`${i18n.totalOpinions || i18n.opinionsTotalCount}: ${total}`),
+		!voted
+		  ? div({ class: 'voting-buttons' },
+		      ['interesting','necessary','funny','disgusting','sensible','propaganda','adultOnly','boring','confusing','inspiring','spam'].map(cat => 
+			form({
+			  method: 'POST', 
+			  action: `/opinions/${encodeURIComponent(item.key)}/${cat}`
+			},
+			  button({ class: 'vote-btn' }, 
+			    `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)]} [${c.opinions?.[cat] || 0}]`
+			  )
+			)
+		      )
+		    )
+		  : p(i18n.alreadyVoted)
+                );
+              })
+            )
+          : div({ class: 'no-results' }, p(i18n.noOpinionsFound))
+      )
+    )
+  );
+
+  return `${html}${hasDocuments
+    ? `<script type="module" src="/js/pdf.min.mjs"></script>
+       <script src="/js/pdf-viewer.js"></script>`
+    : ''}`;
+};
+

+ 57 - 0
src/views/peers_view.js

@@ -0,0 +1,57 @@
+const { form, button, div, h2, p, section, ul, li, a, br } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+
+const peersView = async ({ peers }) => {
+  const startButton = form(
+    { action: "/settings/conn/start", method: "post" },
+    button({ type: "submit" }, i18n.startNetworking)
+  );
+
+  const restartButton = form(
+    { action: "/settings/conn/restart", method: "post" },
+    button({ type: "submit" }, i18n.restartNetworking)
+  );
+
+  const stopButton = form(
+    { action: "/settings/conn/stop", method: "post" },
+    button({ type: "submit" }, i18n.stopNetworking)
+  );
+
+  const syncButton = form(
+    { action: "/settings/conn/sync", method: "post" },
+    button({ type: "submit" }, i18n.sync)
+  );
+
+  const connButtons = [startButton, restartButton, stopButton, syncButton];
+
+  const connectedPeers = (peers || []).filter(([, data]) => data.state === "connected");
+
+  const peerList = connectedPeers.map(([, data]) =>
+    li(
+      data.name, br,
+      a({ href: `/author/${encodeURIComponent(data.key)}` }, data.key), br, br
+    )
+  );
+
+  return template(
+    i18n.peers,
+    section(
+      div({ class: 'tags-header' },
+        h2(i18n.peers),
+        p(i18n.peerConnectionsIntro)
+      ),
+      div({ class: "conn-actions" },
+        ...connButtons
+      ),
+      div({ class: "peers-list" },
+        h2(`${i18n.online} (${connectedPeers.length})`),
+        connectedPeers.length > 0
+          ? ul(peerList)
+          : p(i18n.noConnections),
+        p(i18n.connectionActionIntro)
+      )
+    )
+  );
+};
+
+exports.peersView = peersView;

+ 95 - 0
src/views/pixelia_view.js

@@ -0,0 +1,95 @@
+const { div, h2, p, section, form, input, label, select, option, button, table, tr, td, hr, ul, li, a, br } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+
+exports.pixeliaView = (pixelArt, errorMessage) => {
+  const title = i18n.pixeliaTitle;
+  const description = i18n.pixeliaDescription;
+  const gridWidth = 50; 
+  const gridHeight = 200; 
+
+  const grid = table(
+    { class: "pixelia-grid" },
+    ...Array.from({ length: gridHeight }, (_, rowIndex) =>
+      tr(
+        ...Array.from({ length: gridWidth }, (_, colIndex) => {
+          const pixel = pixelArt.find(p => p.x === colIndex + 1 && p.y === rowIndex + 1);
+          const colorClass = pixel ? `pixel-color-${pixel.color.slice(1)}` : 'pixel-empty';
+          const cellId = `cell-${rowIndex + 1}-${colIndex + 1}`;
+          return td(
+            {
+              id: cellId,
+              title: pixel ? `By: ${pixel.author}` : "",
+              class: `pixel-cell ${colorClass}`
+            },
+            ""
+          );
+        })
+      )
+    )
+  );
+
+  const contributors = pixelArt.length > 0 ? [...new Set(pixelArt.flatMap(p => p.contributors_inhabitants || []))] : [];
+
+  return template(
+    title,
+    section(
+      div({ class: "tags-header" },
+        h2(title),
+        p(description)
+      )
+    ),
+    section(
+      div({ class: "pixelia-form-wrap" },
+        form({ method: "POST", action: "/pixelia/paint"}, [
+          label({ for: "x" }, "X (1-50):"),
+          input({ type: "number", id: "x", name: "x", min: 1, max: gridWidth, required: true }),
+          br,br,
+          label({ for: "y" }, "Y (1-200):"),
+          input({ type: "number", id: "y", name: "y", min: 1, max: gridHeight, required: true }),
+          br,br,
+          label({ for: "color" }, i18n.colorLabel),
+          select({ id: "color", name: "color", required: true },
+            option({ value: "#000000", style: "background-color:#000000;" }, "Black"),
+            option({ value: "#ffffff", style: "background-color:#ffffff;" }, "White"),
+            option({ value: "#17f018", style: "background-color:#17f018;" }, "Green"),
+            option({ value: "#ffbb00", style: "background-color:#ffbb00;" }, "Yellow"),
+            option({ value: "#ff0000", style: "background-color:#ff0000;" }, "Red"),
+            option({ value: "#0000ff", style: "background-color:#0000ff;" }, "Blue"),
+            option({ value: "#ffff00", style: "background-color:#ffff00;" }, "Lime"),
+            option({ value: "#00ff00", style: "background-color:#00ff00;" }, "Spring Green"),
+            option({ value: "#00ffff", style: "background-color:#00ffff;" }, "Aqua"),
+            option({ value: "#ff00ff", style: "background-color:#ff00ff;" }, "Fuchsia"),
+            option({ value: "#a52a2a", style: "background-color:#a52a2a;" }, "Brown"),
+            option({ value: "#800080", style: "background-color:#800080;" }, "Purple"),
+            option({ value: "#808000", style: "background-color:#808000;" }, "Olive"),
+            option({ value: "#00bfff", style: "background-color:#00bfff;" }, "Deep Sky Blue"),
+            option({ value: "#d3d3d3", style: "background-color:#d3d3d3;" }, "Light Grey"),
+            option({ value: "#ff6347", style: "background-color:#ff6347;" }, "Tomato")
+          ),
+          br,br,
+          button({ type: "submit" }, i18n.paintButton)
+        ])
+      ),
+      errorMessage ? div({ class: "error-message" }, errorMessage) : null,
+      div({ class: "total-pixels" },
+        h2(`${i18n.totalPixels}: ${pixelArt.length}`)
+      )
+    ),
+    hr(),
+    section(
+      div({ class: "main_content" },
+        div({ class: "pixelia-grid-wrap" }, grid),
+        pixelArt.length > 0 ? 
+        div({ class: "contributors" },
+          h2(i18n.contributorsTitle),
+          ul(
+            ...contributors.map(author =>
+              li(a({ href: `/author/${encodeURIComponent(author)}` }, author))
+            )
+          )
+        ) : null 
+      )
+    )
+  );
+};
+

+ 36 - 0
src/views/pm_view.js

@@ -0,0 +1,36 @@
+const { div, h2, p, section, button, form, input, textarea, br, label } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+
+exports.pmView = async () => {
+  const title = i18n.pmSendTitle;
+  const description = i18n.pmDescription;
+
+  return template(
+    title,
+    section(
+      div({ class: "tags-header" },
+        h2(title),
+        p(description)
+      ),
+      section(
+        div({ class: "pm-form" },
+          form({ method: "POST", action: "/pm" },
+            label({ for: "recipients" }, i18n.pmRecipients),
+            br(),
+            input({ type: "text", name: "recipients", placeholder: i18n.pmRecipientsHint, required: true }),
+            br(),
+            label({ for: "subject" }, i18n.pmSubject),
+            br(),
+            input({ type: "text", name: "subject", placeholder: i18n.pmSubjectHint }),
+            br(),
+            label({ for: "text" }, i18n.pmText),
+            br(),
+            textarea({ name: "text", rows: "6", cols: "50" }),
+            br(), br(),
+            button({ type: "submit" }, i18n.pmSend)
+          )
+        )
+      )
+    )
+  );
+};

+ 206 - 0
src/views/report_view.js

@@ -0,0 +1,206 @@
+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 userId = config.keys.id;
+
+const generateReportActions = (report, userId) => {
+  return report.author === userId
+    ? div({ class: "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)}` },
+          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)
+        )
+      )
+    : null;
+};
+
+const generateReportCard = (report, userId) => {
+  return div({ class: "report-item" },
+    generateReportActions(report, userId),
+    h2(report.title),
+    form({ method: 'GET', action: `/reports/${encodeURIComponent(report.id)}` },
+      button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails)
+    ),
+    p(`${i18n.reportsCategory}: ${report.category}`),
+    p(`${i18n.reportsSeverity}: ${report.severity}`),
+    p(`${i18n.reportsStatus}: ${report.status}`),
+    p(`${i18n.reportsDescriptionLabel}: ${report.description}`),
+    report.image ? img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" }) : p(i18n.reportsNoFile),
+    p(`${i18n.reportsCreatedAt}: ${moment(report.createdAt).format('YYYY-MM-DD HH:mm')}`),
+    p(`${i18n.reportsCreatedBy}: `,
+      report.isAnonymous
+        ? span({ class: "anonymous-label" }, i18n.reportsAnonymousAuthor)
+        : a({ href: `/author/${encodeURIComponent(report.author)}` }, report.author)
+    ),
+    p(`${i18n.reportsConfirmations}: ${report.confirmations.length}`),
+    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(
+          report.tags.map(tag =>
+            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right:0.8em;" }, `#${tag}`)
+          )
+        ) : null,
+    div({ class: "voting-buttons" },
+      ["interesting", "necessary", "funny", "disgusting", "sensible", "propaganda", "adultOnly", "boring", "confusing", "inspiring", "spam"].map(category =>
+        form({ method: "POST", action: `/reports/opinions/${encodeURIComponent(report.id)}/${category}` },
+          button({ class: "vote-btn" }, `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`]} [${report.opinions?.[category] || 0}]`)
+        )
+      )
+    )
+  );
+};
+
+exports.reportView = async (reports, filter, reportId) => {
+  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 :
+    i18n.reportsAllSectionTitle;
+
+  let filtered =
+    filter === 'mine' ? reports.filter(r => r.author === userId) : reports;
+
+  filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+  const reportToEdit = filter === 'edit' ? reports.find(r => r.id === reportId) : null;
+
+  return template(
+    title,
+    section(
+      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),
+          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 }, 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: "low", selected: reportToEdit?.severity === 'low' }, i18n.reportsSeverityLow),
+                option({ value: "medium", selected: reportToEdit?.severity === 'medium' }, i18n.reportsSeverityMedium),
+                option({ value: "high", selected: reportToEdit?.severity === 'high' }, i18n.reportsSeverityHigh),
+                option({ value: "critical", selected: reportToEdit?.severity === 'critical' }, i18n.reportsSeverityCritical)
+              ), 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(),
+
+              label(i18n.reportsAnonymityOption),
+              input({ type: "checkbox", name: "isAnonymous", checked: reportToEdit?.isAnonymous || false }), br(), br(),
+
+              button({ type: "submit" }, filter === 'edit' ? i18n.reportsUpdateButton : i18n.reportsCreateButton)
+            )
+          )
+        : div({ class: "report-list" },
+            filtered.length > 0 ? filtered.map(r => generateReportCard(r, userId)) : p(i18n.reportsNoItems)
+       )
+     )
+  );
+};
+
+exports.singleReportView = async (report, filter) => {
+  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: "tags-header" },
+        h2(report.title),
+        p(report.description),
+        p(`${i18n.reportsCategory}: ${report.category}`),
+        p(`${i18n.reportsSeverity}: ${report.severity}`),
+        p(`${i18n.reportsStatus}: ${report.status}`),
+        report.image ? img({ src: `/blob/${encodeURIComponent(report.image)}`, class: "report-image" }) : p(i18n.reportsNoFile),
+        p(`${i18n.reportsCreatedAt}: ${moment(report.createdAt).format('YYYY-MM-DD HH:mm')}`),
+        p(`${i18n.reportsCreatedBy}: `,
+          report.isAnonymous
+            ? span({ class: "anonymous-label" }, i18n.reportsAnonymousAuthor)
+            : a({ href: `/author/${encodeURIComponent(report.author)}` }, report.author)
+        ),
+        p(`${i18n.reportsConfirmations}: ${report.confirmations.length}`),
+        report.tags && report.tags.length
+          ? div(
+              report.tags.map(tag =>
+                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right:0.8em;" }, `#${tag}`)
+              )
+            )
+          : null
+      ),
+      div({ class: "report-actions" },
+        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))
+      )
+    )
+  );
+};

+ 384 - 0
src/views/search_view.js

@@ -0,0 +1,384 @@
+const { form, button, div, h2, p, section, input, select, option, img, audio: audioHyperaxe, video: videoHyperaxe, table, hr, hd, br, td, tr, a } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+const moment = require("../server/node_modules/moment");
+const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
+
+const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = [], hashtag = null, results = {}, resultCount = "10" }) => {
+  const searchInput = input({
+    name: "query",
+    required: false,
+    type: "search",
+    value: query,
+    placeholder: i18n.searchPlaceholder
+  });
+  searchInput.setAttribute("minlength", 3);
+
+  const contentTypes = [
+    "post", "about", "curriculum", "tribe", "market", "transfer", "feed", "votes", 
+    "report", "task", "event", "bookmark", "image", "audio", "video", "document", "all"
+  ];
+
+  const filterSelect = select(
+    { 
+      id: "search-type", 
+      name: "type", 
+      class: "input-select", 
+      style: "position:relative; z-index:10;" 
+    },
+    contentTypes.map(type =>
+      option({
+        value: type === 'all' ? "" : type, 
+        selected: (types.length === 0 && type === 'all') || types.includes(type)
+      }, i18n[type + "Label"] || type.toUpperCase())
+    )
+  );
+
+  const resultsPerPageSelect = select(
+    { 
+      id: "results-per-page", 
+      name: "resultsPerPage", 
+      class: "input-select", 
+      style: "position:relative; z-index:10;margin-left:10px;" 
+    },
+    option({ value: "100", selected: resultCount === "100" }, "100"),
+    option({ value: "50", selected: resultCount === "50" }, "50"),
+    option({ value: "10", selected: resultCount === "10" }, "10"),
+    option({ value: "all", selected: resultCount === "all" }, i18n.allTypesLabel)
+  );
+  
+let hasDocument = false; 
+
+const renderContentHtml = (content) => {
+  switch (content.type) {
+    case 'post':
+      return div({ class: 'search-post' },
+        content.contentWarning ? p(i18n.contentWarning + `: ${content.contentWarning}`) : null,
+        content.text ? p({ innerHTML: content.text }) : null,
+        content.tags && content.tags.length
+           ? div(content.tags.map(tag =>
+           a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+         ))
+       : null
+    );
+    case 'about':
+      return div({ class: 'search-about' },
+        content.name ? h2('@', content.name) : null,
+        content.description ? p(content.description) : null,
+        content.image ? img({ src: `/image/64/${encodeURIComponent(content.image)}` }) : null
+    );
+    case 'feed':
+      return div({ class: 'search-feed' },
+        content.text ? h2(content.text) : null,
+        div(
+          h2(`${i18n.tribeFeedRefeeds}: ${content.refeeds}`)
+        )
+    );
+    case 'event':
+      return div({ class: 'search-event' },
+        content.title ? h2(content.title) : null,
+        content.description ? p(i18n.searchDescription + `: ${content.description}`) : null,
+        content.date ? p(`${i18n.eventDate}: ${new Date(content.date).toLocaleString()}`) : null,
+        content.location ? p(`${i18n.eventLocation}: ${content.location}`) : null,
+        content.price ? p(`${i18n.eventPrice}: ${content.price} ECO`) : null,
+        content.eventUrl ? p(`${i18n.eventUrlLabel}: `, a({ href: content.eventUrl, target: '_blank' }, content.eventUrl)) : null,
+        content.organizer ? p(`${i18n.eventOrganizer}: `, a({ href: `/author/${encodeURIComponent(content.organizer)}` }, content.organizer)) : null,
+        content.tags && content.tags.length
+          ? div(content.tags.map(tag =>
+            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+          ))
+        : null
+      );
+    case 'votes':
+      return div({ class: 'search-vote' },
+        content.question ? h2(content.question) : null,
+        content.status ? p(`${i18n.voteStatus}: ${content.status}`) : null,
+        content.totalVotes ? p(`${i18n.voteTotalVotes}: ${content.totalVotes}`) : null,
+        content.votes && content.votes.YES ? p(`${i18n.voteYes}: ${content.votes.YES}`) : null,
+        content.votes && content.votes.NO ? p(`${i18n.voteNo}: ${content.votes.NO}`) : null,
+        content.votes && content.votes.ABSTENTION ? p(`${i18n.voteAbstention}: ${content.votes.ABSTENTION}`) : null,
+        content.votes && content.votes.FOLLOW_MAJORITY ? p(`${i18n.voteFollowMajority}: ${content.votes.FOLLOW_MAJORITY}`) : null,
+        content.tags && content.tags.length
+          ? div(content.tags.map(tag =>
+            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+          ))
+        : null
+      );
+    case 'tribe':
+      return div({ class: 'search-tribe' },
+        h2(content.title),
+        content.description ? p(i18n.tribeDescriptionLabel + ': ' + content.description) : null,
+        content.image ? img({ src: `/blob/${encodeURIComponent(content.image)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }),
+        p(`${i18n.location || 'Location'}: ${content.location || ''}`),
+        typeof content.isLARP === 'boolean' ? p(`${i18n.isLARPLabel || 'LARP'}: ${content.isLARP ? 'Yes' : 'No'}`) : null,
+        typeof content.isAnonymous === 'boolean' ? p(`${i18n.isAnonymousLabel || 'Anonymous'}: ${content.isAnonymous ? 'Yes' : 'No'}`) : null,
+        Array.isArray(content.members) ? p(`${i18n.tribeMembersCount || 'Members'}: ${content.members.length}`) : null,
+        content.tags && content.tags.length
+          ? div(content.tags.map(tag =>
+            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+          ))
+        : null
+      );
+    case 'audio':
+      return content.url ? div({ class: 'search-audio' },
+        content.title ? h2(content.title) : null,
+        content.description ? p(i18n.searchDescription + `: ${content.description}`) : null,
+        audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType, preload: 'metadata' }),
+        content.tags && content.tags.length
+          ? div(content.tags.map(tag =>
+            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+          ))
+        : null
+      ) : null;
+    case 'image':
+      return content.url ? div({ class: 'search-image' },
+        content.title ? h2(content.title) : null,
+        content.description ? p(i18n.searchDescription + `: ${content.description}`) : null,
+        content.meme ? h2(`${i18n.trendingCategory}: ${i18n.meme}`) : null,
+        img({ src: `/blob/${encodeURIComponent(content.url)}` }),
+        br(),
+        content.tags && content.tags.length
+          ? div(content.tags.map(tag =>
+            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+          ))
+        : null
+      ) : null;
+    case 'video':
+      return content.url ? div({ class: 'search-video' },
+        content.title ? h2(content.title) : null,
+        content.description ? p(i18n.searchDescription + `: ${content.description}`) : null,
+        videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType || 'video/mp4', width: '640', height: '360' }),
+        content.tags && content.tags.length
+          ? div(content.tags.map(tag =>
+            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+          ))
+        : null
+      ) : null;
+    case 'document':
+      return div({ class: 'search-document' },
+        content.title ? h2(content.title) : null,
+        content.description ? p(i18n.searchDescription + `: ${content.description}`) : null,
+        div({
+          id: `pdf-container-${content.key || content.url}`,
+          class: 'pdf-viewer-container',
+         'data-pdf-url': `/blob/${encodeURIComponent(content.url)}`
+        }),
+       content.tags && content.tags.length
+          ? div(content.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+            ))
+          : null
+     );
+    case 'market':
+      return div({ class: 'search-market' },
+        content.item_type ? h2(`${i18n.marketItemType}: ${content.item_type}`) : null,
+        content.title ? h2(content.title) : null,
+        content.description ? p(`${i18n.searchDescription}: ${content.description}`) : null,
+        content.price ? p(`${i18n.searchPriceLabel}: ${content.price || 'N/A'}`) : null,
+        content.status ? p(`${i18n.marketItemCondition}: ${content.status}`) : null,
+        content.item_status ? p(`${i18n.marketItemCondition}: ${content.item_status}`) : null,
+        content.deadline ? p(`${i18n.marketItemDeadline}: ${new Date(content.deadline).toLocaleString()}`) : null,
+        typeof content.includesShipping === 'boolean' ? p(`${i18n.marketItemIncludesShipping}: ${content.includesShipping ? i18n.YESLabel : i18n.NOLabel}`) : null,
+        content.seller ? p(`${i18n.marketItemSeller}: `, a({ href: `/author/${encodeURIComponent(content.seller)}` }, content.seller)) : null,
+        content.image ? img({ src: `/blob/${encodeURIComponent(content.image)}`, class: 'market-image' }) : null,
+        content.auctions_poll && content.auctions_poll.length > 0
+          ? div({ class: 'auction-info' },
+              p(i18n.marketAuctionBids),
+              table({ class: 'auction-bid-table' },
+                tr(
+                  th(i18n.marketAuctionBidTime),
+                  th(i18n.marketAuctionUser),
+                  th(i18n.marketAuctionBidAmount)
+                ),
+                content.auctions_poll.map(bid => {
+                  const [userId, bidAmount, bidTime] = bid.split(':');
+                  return tr(
+                    td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
+                    td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
+                    td(`${parseFloat(bidAmount).toFixed(6)} ECO`)
+                  );
+                })
+              )
+          )
+          : null,
+        content.tags && content.tags.length
+          ? div(content.tags.map(tag =>
+            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+          ))
+        : null
+      );
+    case 'bookmark':
+      return div({ class: 'search-bookmark' },
+        content.description ? p(content.description) : null,
+        h2(content.url ? a({ href: content.url, target: '_blank' }, content.url) : null),
+        content.category ? p(`${i18n.bookmarkCategory}: ${content.category}`) : null,
+        content.lastVisit ? p(`${i18n.bookmarkLastVisit}: ${new Date(content.lastVisit).toLocaleString()}`) : null,
+        content.tags && content.tags.length
+          ? div(content.tags.map(tag =>
+            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+          ))
+        : null
+      );
+    case 'task':
+      return div({ class: 'search-task' },
+       content.title ? h2(content.title) : null,
+       content.description ? p(i18n.searchDescription + `: ${content.description}`) : null,
+       content.startTime ? p(`${i18n.taskStartTimeLabel}: ${new Date(content.startTime).toLocaleString()}`) : null,
+       content.endTime ? p(`${i18n.taskEndTimeLabel}: ${new Date(content.endTime).toLocaleString()}`) : null,
+       content.priority ? p(`${i18n.searchPriorityLabel}: ${content.priority}`) : null,
+       content.location ? p(`${i18n.searchLocationLabel}: ${content.location}`) : null,
+       typeof content.isPublic === 'boolean' ? p(`${i18n.searchIsPublicLabel}: ${content.isPublic ? i18n.YESLabel : i18n.NOLabel}`) : null,
+       Array.isArray(content.assignees)
+          ? p(`${i18n.taskAssignees}: ${content.assignees.length}`)
+          : null,
+       content.status ? p(`${i18n.searchStatusLabel}: ${content.status}`) : null,
+       content.author ? p(`${i18n.author}: `, a({ href: `/author/${encodeURIComponent(content.author)}` }, content.author)) : null,
+       content.tags && content.tags.length
+         ? div(content.tags.map(tag =>
+            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+           ))
+         : null
+     );
+   case 'report':
+      return div({ class: 'search-report' },
+      content.title ? h2(content.title) : null,
+      content.description ? p(i18n.searchDescription + `: ${content.description}`) : null,
+      content.category ? p(`${i18n.searchCategoryLabel}: ${content.category}`) : null,
+      content.severity ? p(`${i18n.reportsSeverity}: ${content.severity}`) : null,
+      content.status ? p(`${i18n.searchStatusLabel}: ${content.status}`) : null,
+      content.image ? img({ src: `/blob/${encodeURIComponent(content.image)}` }) : null,
+      typeof content.confirmations === 'number' ? p(`${i18n.reportsConfirmations}: ${content.confirmations}`) : null,
+      br,
+      content.tags && content.tags.length
+        ? div(content.tags.map(tag =>
+            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+          ))
+        : null
+    );
+    case 'transfer':
+      return div({ class: 'search-transfer' },
+        p(`${i18n.transfersFrom}: `, a({ href: `/author/${encodeURIComponent(content.from)}` }, content.from)),
+        p(`${i18n.transfersTo}: `, a({ href: `/author/${encodeURIComponent(content.to)}` }, content.to)),
+        p(`${i18n.transfersAmount}: ${content.amount}`),
+        h2(`${i18n.transfersConcept}: ${content.concept}`),
+        p(`${i18n.transfersStatus}: ${content.status}`),
+        content.confirmedBy && content.confirmedBy.length
+          ? p(`${i18n.transfersConfirmations}: ${content.confirmedBy.length}`)
+          : null,
+        content.tags && content.tags.length
+          ? div(content.tags.map(tag =>
+            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+          ))
+        : null
+      );
+    case 'curriculum':
+      return div({ class: 'search-curriculum' },
+        content.name ? h2(content.name) : null,
+        content.description ? p(content.description) : null,
+        content.photo ? img({ src: `/blob/${encodeURIComponent(content.photo)}`, class: 'curriculum-photo' }) : null,
+        content.location ? p(`${i18n.cvLocationLabel}: ${content.location}`) : null,
+        content.status ? p(`${i18n.cvStatusLabel}: ${content.status}`) : null,
+        content.preferences ? p(`${i18n.cvPreferencesLabel}: ${content.preferences}`) : null,
+        Array.isArray(content.personalSkills) && content.personalSkills.length
+          ? div(content.personalSkills.map(skill =>
+              a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: 'tag-link' }, `#${skill}`)
+            )) : null,
+        Array.isArray(content.personalExperiences) && content.personalExperiences.length
+          ? div(content.personalExperiences.map(exp => p(exp))) : null,
+        Array.isArray(content.oasisExperiences) && content.oasisExperiences.length
+          ? div(content.oasisExperiences.map(exp => p(exp))) : null,
+        Array.isArray(content.oasisSkills) && content.oasisSkills.length
+          ? div(content.oasisSkills.map(skill => p(skill))) : null,
+        Array.isArray(content.educationExperiences) && content.educationExperiences.length
+          ? div(content.educationExperiences.map(exp => p(exp))) : null,
+        Array.isArray(content.educationalSkills) && content.educationalSkills.length
+          ? div(content.educationalSkills.map(skill =>
+              a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: 'tag-link' }, `#${skill}`)
+            )) : null,
+        Array.isArray(content.languages) && content.languages.length
+          ? div(content.languages.map(lang => p(lang))) : null,
+        Array.isArray(content.professionalExperiences) && content.professionalExperiences.length
+          ? div(content.professionalExperiences.map(exp => p(exp))) : null,
+       Array.isArray(content.professionalSkills) && content.professionalSkills.length
+          ? div(content.professionalSkills.map(skill => p(skill))) : null
+      );
+    default:
+      return div({ class: 'styled-text', innerHTML: renderTextWithStyles(content.text || content.description || content.title || '[no content]') });
+  }
+};
+
+const resultSection = Object.entries(results).length > 0
+  ? Object.entries(results).map(([key, msgs]) =>
+    div(
+      { class: "search-result-group" },
+      h2(i18n[key + "Label"] || key),
+      ...msgs.map((msg) => {
+        const content = msg.value.content || {};
+        const created = new Date(msg.timestamp).toLocaleString();
+        if (content.type === 'document') hasDocument = true;
+        const contentHtml = renderContentHtml(content);
+        let author;
+        let authorUrl = '#';
+
+        if (content.type === 'market') {
+          author = content.seller || i18n.anonymous || "Anonymous";
+          authorUrl = `/author/${encodeURIComponent(content.seller)}`;
+        } else if (content.type === 'event') {
+          author = content.organizer || i18n.anonymous || "Anonymous";
+          authorUrl = `/author/${encodeURIComponent(content.organizer)}`;
+        } else if (content.type === 'transfer') {
+          author = content.from || i18n.anonymous || "Anonymous";
+          authorUrl = `/author/${encodeURIComponent(content.from)}`;
+        } else if (content.type === 'post' || content.type === 'about') {
+          author = null;
+        } else if (content.type === 'report' && content.isAnonymous) {
+          author = null;
+        } else {
+          author = content.author || i18n.anonymous || "Anonymous";
+          authorUrl = `/author/${encodeURIComponent(content.author || 'anonymous')}`;
+        }
+
+        return div({ class: 'result-item' }, [
+          contentHtml,
+          author
+            ? p(`${i18n.author}: `, a({ href: authorUrl }, author))
+            : null,
+          p(`${i18n.createdAtLabel || i18n.searchCreatedAt}: ${created}`)
+        ]);
+      })
+    )
+  )
+  : div({ class: 'no-results' }, p(i18n.noResultsFound));
+
+  let html = template(
+    hashtag ? `#${hashtag}` : i18n.search,
+    section(
+      div({ class: "tags-header" },
+        h2(hashtag ? `#${hashtag}` : i18n.search),
+        p(hashtag ? i18n.hashtagDescription : i18n.searchDescriptionLabel)
+      ),
+      form(
+        { action: "/search", method: "POST", class: "search-form" },
+        div({ class: "search-bar" },
+          filterSelect,
+          resultsPerPageSelect,
+          searchInput,
+          br(), br(),
+          button({ type: "submit" }, i18n.searchSubmit)
+        )
+      )
+    ),
+    section(resultSection)
+  );
+
+  if (hasDocument) {
+    html += `
+      <script type="module" src="/js/pdf.min.mjs"></script>
+      <script src="/js/pdf-viewer.js"></script>
+    `;
+  }
+
+  return html;
+};
+
+exports.searchView = searchView;
+

+ 144 - 0
src/views/settings_view.js

@@ -0,0 +1,144 @@
+const { form, button, div, h2, p, section, select, option, input, br, a, label } = require("../server/node_modules/hyperaxe");
+const fs = require('fs');
+const path = require('path');
+const { getConfig } = require('../configs/config-manager.js');
+const { template, selectedLanguage, i18n, setLanguage } = require('./main_views');
+const i18nBase = require("../client/assets/translations/i18n");
+
+const snhUrl = "https://wiki.solarnethub.com/socialnet/overview";
+
+const themeFilePath = path.join(__dirname, '../configs/oasis-config.json');
+const getThemeConfig = () => {
+  try {
+    const configData = fs.readFileSync(themeFilePath);
+    return JSON.parse(configData);
+  } catch (error) {
+    console.error('Error reading config file:', error);
+    return {};
+  }
+};
+
+const settingsView = ({ version }) => {
+  const currentThemeConfig = getThemeConfig();
+  const theme = currentThemeConfig.themes?.current || "Dark-SNH";
+  const currentConfig = getConfig();
+  const walletUrl = currentConfig.wallet.url;
+  const walletUser = currentConfig.wallet.user;
+  const walletFee = currentConfig.wallet.feee;
+
+  const themeElements = [
+    option({ value: "Dark-SNH", selected: theme === "Dark-SNH" ? true : undefined }, "Dark-SNH"),
+    option({ value: "Clear-SNH", selected: theme === "Clear-SNH" ? true : undefined }, "Clear-SNH"),
+    option({ value: "Purple-SNH", selected: theme === "Purple-SNH" ? true : undefined }, "Purple-SNH"),
+    option({ value: "Matrix-SNH", selected: theme === "Matrix-SNH" ? true : undefined }, "Matrix-SNH")
+  ];
+
+  const languageOption = (longName, shortName) => {
+    return shortName === selectedLanguage
+      ? option({ value: shortName, selected: true }, longName)
+      : option({ value: shortName }, longName);
+  };
+
+  const rebuildButton = form(
+    { action: "/settings/rebuild", method: "post" },
+    button({ type: "submit" }, i18n.rebuildName)
+  );
+
+  const updateFlagPath = path.join(__dirname, '../server/.update_required');
+  let updateButton = null;
+  if (fs.existsSync(updateFlagPath)) {
+    updateButton = form(
+      { action: "/update", method: "post" },
+      button({ type: "submit" }, i18n.updateit)
+    );
+  }
+
+  return template(
+    i18n.settings,
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.settings),
+        p(a({ href: snhUrl, target: "_blank" }, i18n.settingsIntro({ version }))),
+        updateButton
+      )
+    ),
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.theme),
+        p(i18n.themeIntro),
+        form(
+          { action: "/settings/theme", method: "post" },
+          select({ name: "theme" }, ...themeElements),
+          br(),
+          br(),
+          button({ type: "submit" }, i18n.setTheme)
+        )
+      )
+    ),
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.language),
+        p(i18n.languageDescription),
+        form(
+          { action: "/language", method: "post" },
+          select({ name: "language" }, [
+            languageOption("English", "en"),
+            languageOption("Castellano", "es"),
+            languageOption("Euskara", "eu")
+          ]),
+          br(),
+          br(),
+          button({ type: "submit" }, i18n.setLanguage)
+        )
+      )
+    ),
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.wallet),
+        p(i18n.walletSettingsDescription),
+        form(
+          { action: "/settings/wallet", method: "POST" },
+          label({ for: "wallet_url" }, i18n.walletAddress), br(),
+          input({ type: "text", id: "wallet_url", name: "wallet_url", placeholder: walletUrl, value: walletUrl }), br(),
+          label({ for: "wallet_user" }, i18n.walletUser), br(),
+          input({ type: "text", id: "wallet_user", name: "wallet_user", placeholder: walletUser, value: walletUser }), br(),
+          label({ for: "wallet_pass" }, i18n.walletPass), br(),
+          input({ type: "password", id: "wallet_pass", name: "wallet_pass" }), br(),
+          label({ for: "wallet_fee" }, i18n.walletFee), br(),
+          input({ type: "text", id: "wallet_fee", name: "wallet_fee", placeholder: walletFee, value: walletFee }), br(),
+          button({ type: "submit" }, i18n.walletConfiguration)
+        )
+      )
+    ),
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.indexes),
+        p(i18n.indexesDescription),
+        rebuildButton
+      )
+    ),
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.exportDataTitle),
+        p(i18n.exportDataDescription),
+        form(
+          { action: "/export/create", method: "POST", id: "exportForm" },
+          button({ type: "submit" }, i18n.exportDataButton)
+        )
+      )
+    ),
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.panicMode),
+        p(i18n.removeDataDescription),
+        form(
+          { action: "/panic/remove", method: "POST", id: "removeForm" },
+          button({ type: "submit" }, i18n.removePanicButton)
+        )
+      )
+    )
+  );
+};
+
+exports.settingsView = settingsView;
+

+ 121 - 0
src/views/stats_view.js

@@ -0,0 +1,121 @@
+const { div, h2, p, section, button, form, input, ul, li, a, h3, span, strong } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+
+exports.statsView = (stats, filter) => {
+  const title = i18n.statsTitle;
+  const description = i18n.statsDescription;
+  const modes = ['ALL', 'MINE', 'TOMBSTONE'];
+  const types = [
+    'bookmark', 'event', 'task', 'votes', 'report', 'feed',
+    'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe', 'market'
+  ];
+  const totalContent = types.reduce((sum, t) => sum + (stats.content[t] || 0), 0);
+  const totalOpinions = types.reduce((sum, t) => sum + (stats.opinions[t] || 0), 0);
+  const blockStyle = 'padding:16px;border:1px solid #ddd;border-radius:8px;margin-bottom:24px;';
+
+  return template(
+    title,
+    section(
+      div({ class: 'tags-header' },
+        h2(title),
+        p(description)
+      ),
+      div({ class: 'mode-buttons', style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px;' },
+        modes.map(m =>
+          form({ method: 'GET', action: '/stats' },
+            input({ type: 'hidden', name: 'filter', value: m }),
+            button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, i18n[m + 'Button'])
+          )
+        )
+      ),
+      section(
+	div({ style: 'background-color:#f8f9fa; padding:24px; border-radius:8px; border:1px solid #e0e0e0; box-shadow:0 2px 8px rgba(0,0,0,0.1);' },
+	    h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, `${i18n.statsOasisID}: `, a({ href: `/author/${encodeURIComponent(stats.id)}`, style: 'color:#007bff; text-decoration:none;' }, stats.id)),
+	    h3({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsCreatedAt}: `, span({ style: 'color:#888;' }, stats.createdAt)),
+	  div({ style: 'margin-bottom:16px;' },
+	  ul({ style: 'list-style-type:none; padding:0; margin:0;' },
+	    li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
+	      `${i18n.statsBlobsSize}: `,
+	      span({ style: 'color:#888;' }, stats.statsBlobsSize)
+	    ),
+	    li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
+	      `${i18n.statsBlockchainSize}: `,
+	      span({ style: 'color:#888;' }, stats.statsBlockchainSize)
+	    ),
+	    li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
+	      strong(`${i18n.statsSize}: `,
+	      span({ style: 'color:#888;' },
+	      span({ style: 'color:#555;' }, stats.folderSize) 
+	      )
+	      )
+	    )
+	   )
+	  )
+	),
+        filter === 'ALL'
+          ? div({ class: 'stats-container' }, [
+            div({ style: blockStyle },
+              h2(`${i18n.statsTotalInhabitants}: ${stats.inhabitants}`)
+            ),
+            div({ style: blockStyle },
+              h2(`${i18n.statsDiscoveredTribes}: ${stats.content.tribe}`)
+            ),
+            div({ style: blockStyle },
+              h2(`${i18n.statsDiscoveredMarket}: ${stats.content.market}`)
+            ),
+            div({ style: blockStyle },
+              h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`),
+              ul(types.map(t =>
+                stats.opinions[t] > 0
+                  ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${stats.opinions[t]}`)
+                  : null
+              ).filter(Boolean))
+            ),
+            div({ style: blockStyle },
+              h2(`${i18n.statsNetworkContent}: ${totalContent}`),
+              ul(types.map(t =>
+                stats.content[t] > 0
+                  ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${stats.content[t]}`)
+                  : null
+              ).filter(Boolean))
+            )
+          ])
+          : filter === 'MINE'
+            ? div({ class: 'stats-container' }, [
+              div({ style: blockStyle },
+                h2(`${i18n.statsDiscoveredTribes}: ${stats.memberTribes.length}`),
+                ul(stats.memberTribes.map(name => li(name)))
+              ),
+              div({ style: blockStyle },
+                h2(`${i18n.statsYourMarket}: ${stats.content.market}`)
+              ),
+              div({ style: blockStyle },
+                h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
+                ul(types.map(t =>
+                  stats.opinions[t] > 0
+                    ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${stats.opinions[t]}`)
+                    : null
+                ).filter(Boolean))
+              ),
+              div({ style: blockStyle },
+                h2(`${i18n.statsYourContent}: ${totalContent}`),
+                ul(types.map(t =>
+                  stats.content[t] > 0
+                    ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${stats.content[t]}`)
+                    : null
+                ).filter(Boolean))
+              )
+            ])
+            : div({ class: 'stats-container' }, [
+              div({ style: blockStyle },
+                h2(`${i18n.TOMBSTONEButton}: ${stats.userTombstoneCount}`),
+                ul(
+                  li(`${i18n.statsNetwork}: ${stats.networkTombstoneCount}`),
+                  li(`${i18n.statsYou}: ${stats.userTombstoneCount}`)
+                )
+              )
+            ])
+      )
+    )
+  );
+};

+ 0 - 0
src/views/tags_view.js


Some files were not shown because too many files changed in this diff