Quellcode durchsuchen

Oasis release 0.7.0

psy vor 3 Tagen
Ursprung
Commit
642c4ec326
81 geänderte Dateien mit 11963 neuen und 318 gelöschten Zeilen
  1. 7 1
      README.md
  2. 13 0
      docs/CHANGELOG.md
  3. 2 2
      docs/PUB/deploy.md
  4. 443 43
      src/backend/backend.js
  5. 3 0
      src/backend/media-favorites.js
  6. 7 0
      src/client/assets/styles/mobile.css
  7. 142 1
      src/client/assets/styles/style.css
  8. 256 1
      src/client/assets/translations/oasis_ar.js
  9. 255 1
      src/client/assets/translations/oasis_de.js
  10. 270 1
      src/client/assets/translations/oasis_en.js
  11. 257 1
      src/client/assets/translations/oasis_es.js
  12. 256 1
      src/client/assets/translations/oasis_eu.js
  13. 257 1
      src/client/assets/translations/oasis_fr.js
  14. 256 1
      src/client/assets/translations/oasis_hi.js
  15. 256 1
      src/client/assets/translations/oasis_it.js
  16. 256 1
      src/client/assets/translations/oasis_pt.js
  17. 256 1
      src/client/assets/translations/oasis_ru.js
  18. 256 1
      src/client/assets/translations/oasis_zh.js
  19. 4 0
      src/client/middleware.js
  20. 4 1
      src/configs/config-manager.js
  21. 4 1
      src/configs/media-favorites.json
  22. 7 2
      src/configs/oasis-config.json
  23. 285 0
      src/games/8ball/index.html
  24. 21 0
      src/games/8ball/thumbnail.svg
  25. 209 0
      src/games/arkanoid/index.html
  26. 36 0
      src/games/arkanoid/thumbnail.svg
  27. 272 0
      src/games/artillery/index.html
  28. 12 0
      src/games/artillery/thumbnail.svg
  29. 250 0
      src/games/asteroids/index.html
  30. 25 0
      src/games/asteroids/thumbnail.svg
  31. 693 0
      src/games/audiopendulum/index.html
  32. 31 0
      src/games/audiopendulum/thumbnail.svg
  33. 236 0
      src/games/cocoland/index.html
  34. 26 0
      src/games/cocoland/thumbnail.svg
  35. 323 0
      src/games/cocoman/index.html
  36. 22 0
      src/games/cocoman/thumbnail.svg
  37. 793 0
      src/games/ecoinflow/index.html
  38. 113 0
      src/games/ecoinflow/thumbnail.svg
  39. 238 0
      src/games/flipflop/index.html
  40. 44 0
      src/games/flipflop/thumbnail.svg
  41. 222 0
      src/games/labyrinth/index.html
  42. 16 0
      src/games/labyrinth/thumbnail.svg
  43. 192 0
      src/games/pingpong/index.html
  44. 11 0
      src/games/pingpong/thumbnail.svg
  45. 234 0
      src/games/spaceinvaders/index.html
  46. 26 0
      src/games/spaceinvaders/thumbnail.svg
  47. 242 0
      src/games/tetris/index.html
  48. 22 0
      src/games/tetris/thumbnail.svg
  49. 182 0
      src/games/tiktaktoe/index.html
  50. 27 0
      src/games/tiktaktoe/thumbnail.svg
  51. 17 3
      src/models/activity_model.js
  52. 10 3
      src/models/agenda_model.js
  53. 351 103
      src/models/banking_model.js
  54. 519 0
      src/models/calendars_model.js
  55. 507 0
      src/models/chats_model.js
  56. 17 2
      src/models/favorites_model.js
  57. 67 0
      src/models/games_model.js
  58. 454 0
      src/models/pads_model.js
  59. 27 3
      src/models/search_model.js
  60. 4 1
      src/models/stats_model.js
  61. 16 1
      src/models/tags_model.js
  62. 1 1
      src/server/package-lock.json
  63. 2 2
      src/server/package.json
  64. 6 0
      src/server/ssb_metadata.js
  65. 117 58
      src/views/activity_view.js
  66. 12 1
      src/views/agenda_view.js
  67. 52 32
      src/views/banking_views.js
  68. 13 5
      src/views/blockchain_view.js
  69. 381 0
      src/views/calendars_view.js
  70. 347 0
      src/views/chats_view.js
  71. 12 0
      src/views/favorites_view.js
  72. 149 0
      src/views/games_view.js
  73. 16 2
      src/views/inhabitants_view.js
  74. 65 1
      src/views/main_views.js
  75. 7 3
      src/views/modules_view.js
  76. 348 0
      src/views/pads_view.js
  77. 31 4
      src/views/search_view.js
  78. 9 23
      src/views/settings_view.js
  79. 4 2
      src/views/shops_view.js
  80. 23 1
      src/views/stats_view.js
  81. 109 5
      src/views/tribes_view.js

+ 7 - 1
README.md

@@ -55,6 +55,8 @@ And some other nice modules, like for example:
  
 And much more, that we invite you to discover by yourself ;-)
 
+   ![SNH](https://solarnethub.com/git/snh-games.jpeg "SolarNET.HuB")
+   
 ----------
 
 ## Modules:
@@ -67,13 +69,16 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Banking: Module to determine the real value of ECOIN and distribute a UBI using the common treasury.
  + BlockExplorer: Module to navigate the blockchain.
  + Bookmarks: Module to discover and manage bookmarks.	
+ + Calendars: Module to discover and manage calendars.
+ + Chats: Module to discover and manage encrypted chats.
  + Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).	
  + Courts: Module to resolve conflicts and emit veredicts.	
  + Documents: Module to discover and manage documents.	
  + Events: Module to discover and manage events.
  + Favorites: Module to manage your favorite content.
  + Feed: Module to discover and share short-texts (feeds).
- + Forums: Module to discover and manage forums.	
+ + Forums: Module to discover and manage forums.
+ + Games: Module to play and share your scores in various mini-games.	
  + Governance: Module to discover and manage votes.	
  + Images: Module to discover and manage images.
  + Invites: Module to manage and apply invite codes.
@@ -84,6 +89,7 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Market: Module to exchange goods or services.
  + Multiverse: Module to receive content from other federated peers.
  + Opinions: Module to discover and vote on opinions.	
+ + Pads: Module to manage collaborative encrypted text editors.
  + Parliament: Module to elect governments and vote on laws.	
  + Pixelia: Module to draw on a collaborative grid.	
  + Projects: Module to explore, crowd-funding and manage projects.

+ 13 - 0
docs/CHANGELOG.md

@@ -13,6 +13,19 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.7.0 - 2026-04-12
+
+### Added
+
+- Pads module: create, manage and discover collaborative encrypted text editors (Pads plugin).
+- Chats module: create, manage and discover encrypted chats (Chats plugin).
+- Games module: play and share your scores in various mini-games (Games plugin).
+- Calendars module: create, manage and discover calendars (Calendars plugin).
+
+### Changed
+
+- Connectivity mode notice: offline/online (Core plugin).
+
 ## v0.6.9 - 2026-04-04
 
 ### Added

+ 2 - 2
docs/PUB/deploy.md

@@ -21,6 +21,7 @@ By default it uses port 8008, so make sure to expose that port (or whatever port
 
 Before running the server, create a config file that enables needed plugins and network options.
 
+   mkdir -p ~/.ssb
    nano ~/.ssb/config
 
 Paste this:
@@ -96,7 +97,6 @@ Be sure to replace {your-hostname} with your server’s domain or IP.
 
    npm -g install ssb-server
 
-   mkdir -p ~/.ssb
    cd ~/.ssb
    npm init -y
 
@@ -147,7 +147,7 @@ And make it executable:
 
 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
+   ~/oasis-pub/oasis-pub-server.sh
 
 Then, detach the session.
 

+ 443 - 43
src/backend/backend.js

@@ -256,7 +256,9 @@ const ssbConfig = require('../server/ssb_config');
 const tribeCrypto = require('../models/tribe_crypto')(ssbConfig.path);
 const reportsModel = require('../models/reports_model')({ cooler, isPublic: config.public });
 const transfersModel = require('../models/transfers_model')({ cooler, isPublic: config.public });
-const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public });
+const padsModel = require('../models/pads_model')({ cooler, cipherModel });
+const calendarsModel = require('../models/calendars_model')({ cooler, pmModel });
+const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel });
 const cvModel = require('../models/cv_model')({ cooler, isPublic: config.public });
 const inhabitantsModel = require('../models/inhabitants_model')({ cooler, isPublic: config.public });
 const feedModel = require('../models/feed_model')({ cooler, isPublic: config.public });
@@ -269,7 +271,7 @@ const trendingModel = require('../models/trending_model')({ cooler, isPublic: co
 const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public });
 const tribesModel = require('../models/tribes_model')({ cooler, isPublic: config.public, tribeCrypto });
 const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel });
-const searchModel = require('../models/search_model')({ cooler, isPublic: config.public });
+const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel });
 const activityModel = require('../models/activity_model')({ cooler, isPublic: config.public });
 const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: config.public });
 const marketModel = require('../models/market_model')({ cooler, isPublic: config.public });
@@ -277,10 +279,12 @@ const forumModel = require('../models/forum_model')({ cooler, isPublic: config.p
 const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public });
 const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public });
 const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public });
+const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto });
 const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
 const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public });
+const gamesModel = require('../models/games_model')({ cooler });
 const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public });
-const favoritesModel = require("../models/favorites_model")({services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel });
+const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel });
 const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } });
 const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel } });
 tribesModel.processIncomingKeys().catch(err => {
@@ -311,9 +315,12 @@ const mediaResolvers = {
   documents: id => documentsModel.resolveRootId(id),
   bookmarks: id => bookmarksModel.resolveRootId(id),
   shops: id => shopsModel.resolveRootId(id),
-  maps: id => mapsModel.resolveRootId(id)
+  chats: id => chatsModel.resolveRootId(id),
+  maps: id => mapsModel.resolveRootId(id),
+  pads: id => padsModel.resolveRootId(id),
+  calendars: id => calendarsModel.resolveRootId(id)
 };
-const mediaModCheck = { images: 'imagesMod', audios: 'audiosMod', videos: 'videosMod', documents: 'documentsMod', bookmarks: 'bookmarksMod', market: 'marketMod', jobs: 'jobsMod', projects: 'projectsMod', shops: 'shopsMod', maps: 'mapsMod' };
+const mediaModCheck = { images: 'imagesMod', audios: 'audiosMod', videos: 'videosMod', documents: 'documentsMod', bookmarks: 'bookmarksMod', market: 'marketMod', jobs: 'jobsMod', projects: 'projectsMod', shops: 'shopsMod', chats: 'chatsMod', maps: 'mapsMod', pads: 'padsMod', calendars: 'calendarsMod' };
 const favAction = async (ctx, kind, action) => {
   if (!checkMod(ctx, mediaModCheck[kind])) { ctx.redirect('/modules'); return; }
   try {
@@ -643,6 +650,7 @@ const { activityView } = require("../views/activity_view");
 const { cvView, createCVView } = require("../views/cv_view");
 const { indexingView } = require("../views/indexing_view");
 const { pixeliaView } = require("../views/pixelia_view");
+const { gamesView } = require("../views/games_view");
 const { statsView } = require("../views/stats_view");
 const { tribesView, tribeView, renderInvitePage } = require("../views/tribes_view");
 const { agendaView } = require("../views/agenda_view");
@@ -677,6 +685,9 @@ const { forumView, singleForumView } = require("../views/forum_view");
 const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view");
 const { jobsView, singleJobsView, renderJobForm } = require("../views/jobs_view");
 const { shopsView, singleShopView, singleProductView, editProductView } = require("../views/shops_view");
+const { chatsView, singleChatView } = require("../views/chats_view");
+const { padsView, singlePadView } = require("../views/pads_view");
+const { calendarsView, singleCalendarView } = require("../views/calendars_view");
 const { projectsView, singleProjectView } = require("../views/projects_view")
 const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views")
 const { favoritesView } = require("../views/favorites_view");
@@ -764,7 +775,7 @@ router
     ctx.body = await popularView({ messages, prefix: nav(div({ class: "filters" }, ul(['day','week','month','year'].map(p => li(form({ method: "GET", action: `/public/popular/${p}` }, button({ type: "submit", class: "filter-btn" }, t[p]))))))) });
   }) 
   .get("/modules", async (ctx) => {
-    const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts'];
+    const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts'];
     const cfg = getConfig().modules;
     ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {}));
   })
@@ -776,6 +787,23 @@ router
     let chatHistory = []; try { chatHistory = JSON.parse(fs.readFileSync(historyPath, 'utf-8')); } catch {}
     ctx.body = aiView(chatHistory, getConfig().ai?.prompt?.trim() || '');
   })
+  .get('/games', async (ctx) => {
+    if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
+    const filter = qf(ctx, 'all');
+    const hall = await gamesModel.getHallOfFame();
+    ctx.body = gamesView(filter, hall);
+  })
+  .get('/games/:name', async (ctx) => {
+    if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
+    const { gameShellView } = require('../views/games_view');
+    ctx.body = gameShellView(ctx.params.name);
+  })
+  .post('/games/submit-score', koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
+    const { game, score } = ctx.request.body;
+    try { await gamesModel.submitScore(game, score); } catch (_) {}
+    ctx.redirect('/games?filter=scoring');
+  })
   .get('/pixelia', async (ctx) => {
     if (!checkMod(ctx, 'pixeliaMod')) { ctx.redirect('/modules'); return; }
     const pixelArt = await pixeliaModel.listPixels();
@@ -855,7 +883,7 @@ router
     const pull = require('../server/node_modules/pull-stream'), ssb = await require('../client/gui')({ offline: require('../server/ssb_config').offline }).open();
     const latestFromStream = await new Promise(res => pull(ssb.createUserStream({ id: feedId, reverse: true }), pull.filter(m => m?.value?.content?.type !== 'tombstone'), pull.take(1), pull.collect((err, arr) => res(!err && arr?.[0] ? normTs(arr[0].value?.timestamp || arr[0].timestamp) : 0))));
     const days = latestFromStream ? (Date.now() - latestFromStream) / 86400000 : Infinity;
-    ctx.body = await authorView({ feedId, messages: sanitizedMsgs, firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastActivityBucket: days < 14 ? 'green' : days < 182.5 ? 'orange' : 'red' });
+    ctx.body = await authorView({ feedId, messages: sanitizedMsgs, firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, lastActivityBucket: days < 14 ? 'green' : days < 182.5 ? 'orange' : 'red' });
   })
   .get("/search", async (ctx) => {
     const query = ctx.query.query || '';
@@ -1065,9 +1093,9 @@ router
         inhabitants.map(async (u) => {
         try {
           const bank = await bankingModel.getBankingData(u.id);
-          return { id: u.id, karmaScore: bank?.karmaScore || 0, estimatedUBI: bank?.estimatedUBI || 0 };
+          return { id: u.id, karmaScore: bank?.karmaScore || 0, estimatedUBI: bank?.estimatedUBI || 0, lastClaimedDate: bank?.lastClaimedDate || null, totalClaimed: bank?.totalClaimed || 0 };
         } catch {
-          return { id: u.id, karmaScore: 0, estimatedUBI: 0 };
+          return { id: u.id, karmaScore: 0, estimatedUBI: 0, lastClaimedDate: null, totalClaimed: 0 };
         }
         })
       )
@@ -1084,7 +1112,7 @@ router
       })
     );
     const addrMap = new Map(addresses.map(x => [x.id, x.address]));
-    const karmaMap = new Map(karmaList.map(x => [x.id, { karmaScore: x.karmaScore, estimatedUBI: x.estimatedUBI }]));
+    const karmaMap = new Map(karmaList.map(x => [x.id, { karmaScore: x.karmaScore, estimatedUBI: x.estimatedUBI, lastClaimedDate: x.lastClaimedDate, totalClaimed: x.totalClaimed }]));
     const activityMap = new Map(activityList.map(x => [x.id, x.lastActivityBucket]));
     let enriched = inhabitants.map(u => {
       const kd = karmaMap.get(u.id) || {};
@@ -1093,6 +1121,8 @@ router
         ecoAddress: addrMap.get(u.id) || null,
         karmaScore: kd.karmaScore ?? (typeof u.karmaScore === 'number' ? u.karmaScore : 0),
         estimatedUBI: kd.estimatedUBI || 0,
+        lastClaimedDate: kd.lastClaimedDate || null,
+        totalClaimed: kd.totalClaimed || 0,
         lastActivityBucket: activityMap.get(u.id)
       };
     });
@@ -1121,7 +1151,9 @@ router
     const currentUserId = getViewerId();
     const karmaScore = bank && typeof bank.karmaScore === 'number' ? bank.karmaScore : 0;
     const estimatedUBI = bank?.estimatedUBI || 0;
-    ctx.body = await inhabitantsProfileView({ about, cv, feed, photo, karmaScore, estimatedUBI, lastActivityBucket: bucketInfo.bucket, viewedId: id }, currentUserId);
+    const lastClaimedDate = bank?.lastClaimedDate || null;
+    const totalClaimed = bank?.totalClaimed || 0;
+    ctx.body = await inhabitantsProfileView({ about, cv, feed, photo, karmaScore, estimatedUBI, lastClaimedDate, totalClaimed, lastActivityBucket: bucketInfo.bucket, viewedId: id }, currentUserId);
   })
   .get('/parliament', async (ctx) => {
     if (!checkMod(ctx, 'parliamentMod')) return ctx.redirect('/modules');
@@ -1268,7 +1300,20 @@ router
         const stItems = await listByTribeAllChain(st.id, null).catch(() => []);
         subContent.push(...stItems.map(item => ({ ...item, tribeName: st.title })));
       }
-      const combined = [...allContent, ...subContent];
+      const [allPadsRaw, allChatsRaw, allCalsRaw, allMapsRaw] = await Promise.all([
+        padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
+        chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
+        calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
+        mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => [])
+      ]);
+      const toStandalone = (type, url) => (item) => ({ contentType: type, id: item.rootId || item.key, title: item.title || '', author: item.author, createdAt: item.createdAt, directUrl: url(item) });
+      const standaloneItems = [
+        ...allPadsRaw.filter(p => p.tribeId === tribe.id).map(toStandalone('pad', p => `/pads/${encodeURIComponent(p.rootId)}`)),
+        ...allChatsRaw.filter(c => c.tribeId === tribe.id).map(toStandalone('chat', c => `/chats/${encodeURIComponent(c.rootId || c.key)}`)),
+        ...allCalsRaw.filter(c => c.tribeId === tribe.id).map(toStandalone('calendar', c => `/calendars/${encodeURIComponent(c.rootId)}`)),
+        ...allMapsRaw.filter(m => m.tribeId === tribe.id).map(toStandalone('map', m => `/maps/${encodeURIComponent(m.key || m.id)}`))
+      ];
+      const combined = [...allContent, ...subContent, ...standaloneItems];
       const allInhabitants = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true });
       const allMembers = [...new Set([...tribe.members, ...subTribes.flatMap(st => st.members || [])])];
       const memberMap = new Map(allInhabitants.filter(u => allMembers.includes(u.id)).map(u => [u.id, u]));
@@ -1303,6 +1348,15 @@ router
     } else if (section === 'maps') {
       const allMaps = await mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []);
       sectionData = allMaps.filter(m => m.tribeId === tribe.id);
+    } else if (section === 'pads') {
+      const allPads = await padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []);
+      sectionData = allPads.filter(p => p.tribeId === tribe.id);
+    } else if (section === 'chats') {
+      const allChats = await chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []);
+      sectionData = allChats.filter(c => c.tribeId === tribe.id);
+    } else if (section === 'calendars') {
+      const allCals = await calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []);
+      sectionData = allCals.filter(c => c.tribeId === tribe.id);
     } else if (section === 'search') {
       const sq = (ctx.query.q || '').trim().toLowerCase();
       let results = [];
@@ -1359,6 +1413,17 @@ router
     try { await bankingModel.ensureSelfAddressPublished(); } catch (_) {}
     try { await bankingModel.getUserEngagementScore(userId); } catch (_) {}
     const allActions = await activityModel.listFeed('all');
+    for (const action of allActions) {
+      if (action.type === 'pad') {
+        const c = action.value?.content || action.content || {};
+        const rootId = action.id || action.key || '';
+        const decrypted = padsModel.decryptContent(c, rootId);
+        if (decrypted.title) {
+          if (action.value?.content) { action.value.content.title = decrypted.title; action.value.content.deadline = decrypted.deadline; }
+          else if (action.content) { action.content.title = decrypted.title; action.content.deadline = decrypted.deadline; }
+        }
+      }
+    }
     ctx.body = activityView(allActions, filter, userId, q);
   })
   .get("/profile", async (ctx) => {
@@ -1376,7 +1441,7 @@ router
       lastActivityTs = await new Promise(res => pull(ssb.createUserStream({ id: myFeedId, reverse: true }), pull.filter(m => m?.value?.content?.type !== "tombstone"), pull.take(1), pull.collect((err, arr) => res(!err && arr?.[0] ? normTs(arr[0].value?.timestamp || arr[0].timestamp) : 0))));
     }
     const days = lastActivityTs ? (Date.now() - lastActivityTs) / 86400000 : Infinity;
-    ctx.body = await authorView({ feedId: myFeedId, messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastActivityBucket: days < 14 ? "green" : days < 182.5 ? "orange" : "red" });
+    ctx.body = await authorView({ feedId: myFeedId, messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, lastActivityBucket: days < 14 ? "green" : days < 182.5 ? "orange" : "red" });
   })
   .get("/profile/edit", async (ctx) => {
     const myFeedId = await meta.myFeedId();
@@ -1764,6 +1829,104 @@ router
     const [products, comments, mapData] = await Promise.all([shopsModel.listProducts(shop.rootId || shop.key), getVoteComments(shop.key), resolveMapUrl(shop.mapUrl)]);
     ctx.body = await singleShopView({ ...shop, isFavorite: fav.has(String(shop.rootId || shop.key)), commentCount: comments.length }, filter, products, comments, { q, sort, returnTo: safeReturnTo(ctx, `/shops?filter=${encodeURIComponent(filter)}`, ['/shops']), mapData });
   })
+  .get("/chats", async (ctx) => {
+    if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    const { filter = 'all', q = '', tribeId = '' } = ctx.query;
+    const viewerId = getViewerId();
+    if (filter === 'create') {
+      ctx.body = await chatsView([], 'create', null, { q, ...(tribeId ? { tribeId } : {}) });
+      return;
+    }
+    const modelFilter = filter === "favorites" ? "all" : filter;
+    const items = await chatsModel.listAll({ filter: modelFilter, q, viewerId });
+    const fav = await mediaFavorites.getFavoriteSet('chats');
+    const enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
+    const finalList = filter === "favorites" ? enriched.filter(x => x.isFavorite) : enriched;
+    ctx.body = await chatsView(finalList, filter, null, { q });
+  })
+  .get("/chats/edit/:id", async (ctx) => {
+    if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    const chat = await chatsModel.getChatById(ctx.params.id);
+    if (!chat) { ctx.redirect('/chats'); return; }
+    ctx.body = await chatsView([], 'edit', chat, { returnTo: ctx.query.returnTo || '' });
+  })
+  .get("/chats/:chatId", async (ctx) => {
+    if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    const { filter = 'all', q = '' } = ctx.query;
+    const chat = await chatsModel.getChatById(ctx.params.chatId);
+    if (!chat) { ctx.redirect('/chats'); return; }
+    const fav = await mediaFavorites.getFavoriteSet('chats');
+    const messages = await chatsModel.listMessages(chat.rootId || chat.key);
+    ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)) }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']) });
+  })
+  .get("/pads", async (ctx) => {
+    if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    const filter = String(ctx.query.filter || "all").toLowerCase();
+    const uid = getViewerId();
+    if (filter === "edit") {
+      const id = ctx.query.id;
+      if (!id) { ctx.redirect('/pads'); return; }
+      const pad = await padsModel.getPadById(id);
+      if (!pad || pad.author !== uid) { ctx.redirect('/pads'); return; }
+      ctx.body = await padsView([], "edit", pad, {});
+      return;
+    }
+    const q = String(ctx.query.q || "").trim();
+    const tribeId = ctx.query.tribeId || "";
+    const pads = await padsModel.listAll({ filter, viewerId: uid });
+    const fav = await mediaFavorites.getFavoriteSet('pads');
+    const enriched = pads.map(p => ({ ...p, isFavorite: fav.has(String(p.rootId)) }));
+    ctx.body = await padsView(enriched, filter, null, { q, ...(tribeId ? { tribeId } : {}) });
+  })
+  .get("/pads/:padId", async (ctx) => {
+    if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    const pad = await padsModel.getPadById(ctx.params.padId);
+    if (!pad) { ctx.redirect('/pads'); return; }
+    const fav = await mediaFavorites.getFavoriteSet('pads');
+    const entries = await padsModel.getEntries(pad.rootId);
+    const inviteCode = ctx.query.inviteCode || null;
+    const versionKey = ctx.query.version || null;
+    const selectedVersion = versionKey
+      ? (entries.find(e => e.key === versionKey) || entries[parseInt(versionKey)] || null)
+      : null;
+    const baseUrl = `${ctx.protocol}://${ctx.host}`;
+    ctx.body = await singlePadView({ ...pad, isFavorite: fav.has(String(pad.rootId)) }, entries, { baseUrl, inviteCode, selectedVersion });
+  })
+  .get("/calendars", async (ctx) => {
+    if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const filter = String(ctx.query.filter || "all").toLowerCase();
+    const uid = getViewerId();
+    if (filter === "edit") {
+      const id = ctx.query.id;
+      if (!id) { ctx.redirect('/calendars'); return; }
+      const cal = await calendarsModel.getCalendarById(id);
+      if (!cal || cal.author !== uid) { ctx.redirect('/calendars'); return; }
+      ctx.body = await calendarsView([], "edit", cal, {});
+      return;
+    }
+    const q = String(ctx.query.q || "").trim();
+    const tribeId = ctx.query.tribeId || "";
+    const modelFilter = filter === "favorites" ? "all" : filter;
+    const calendars = await calendarsModel.listAll({ filter: modelFilter, viewerId: uid });
+    const fav = await mediaFavorites.getFavoriteSet('calendars');
+    const enriched = calendars.map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) }));
+    const finalList = filter === "favorites" ? enriched.filter(c => c.isFavorite) : enriched;
+    ctx.body = await calendarsView(finalList, filter, null, { q, ...(tribeId ? { tribeId } : {}) });
+  })
+  .get("/calendars/:calId", async (ctx) => {
+    if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const cal = await calendarsModel.getCalendarById(ctx.params.calId);
+    if (!cal) { ctx.redirect('/calendars'); return; }
+    const dates = await calendarsModel.getDatesForCalendar(cal.rootId);
+    const notesByDate = {};
+    for (const d of dates) {
+      notesByDate[d.key] = await calendarsModel.getNotesForDate(cal.rootId, d.key);
+    }
+    const fav = await mediaFavorites.getFavoriteSet('calendars');
+    const month = String(ctx.query.month || "").trim() || null;
+    const day = String(ctx.query.day || "").trim() || null;
+    ctx.body = await singleCalendarView({ ...cal, isFavorite: fav.has(String(cal.rootId)) }, dates, notesByDate, { month, day });
+  })
   .get("/projects", async (ctx) => {
     if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
     const filter = String(ctx.query.filter || "ALL").toUpperCase()
@@ -1799,12 +1962,16 @@ router
     const q = (query.q || '').trim();
     const msg = (query.msg || '').trim();
     await bankingModel.ensureSelfAddressPublished();
-    if (filter === 'overview') {
+    if (filter === 'overview' && bankingModel.isPubNode()) {
       try { await bankingModel.executeEpoch({}); } catch (_) {}
+      try { await bankingModel.publishPubBalance(); } catch (_) {}
+      try { await bankingModel.processPendingClaims(); } catch (_) {}
     }
     const data = await bankingModel.listBanking(filter, userId);
+    data.isPub = bankingModel.isPubNode();
+    data.alreadyClaimed = data.summary?.alreadyClaimed || false;
     if (filter === 'overview') {
-      const pending = (data.allocations || []).find(a => a.to === userId && a.status === "UNCONFIRMED");
+      const pending = (data.allocations || []).find(a => a.to === userId && (a.status === "UNCLAIMED" || a.status === "UNCONFIRMED"));
       data.pendingUBI = pending || null;
     }
     if (filter === 'addresses' && q) {
@@ -1824,7 +1991,7 @@ router
       totalSupply: 25500000,
       isSynced: isSynced
     };
-    ctx.body = renderBankingView(data, filter, userId);
+    ctx.body = renderBankingView(data, filter, userId, data.isPub);
   })
   .get("/banking/allocation/:id", async (ctx) => {
     const userId = getViewerId();
@@ -3118,6 +3285,236 @@ router
     ctx.redirect(safeReturnTo(ctx, `/shops/product/${encodeURIComponent(ctx.params.productId)}`, ['/shops']));
   })
   .post("/shops/:shopId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'shops', 'shopId'))
+  .post("/chats/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
+    if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    const b = ctx.request.body;
+    const tribeId = b.tribeId || null;
+    const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null;
+    await chatsModel.createChat(stripDangerousTags(b.title), stripDangerousTags(b.description), imageBlob, b.category, b.status, b.tags, tribeId);
+    ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=chats` : safeReturnTo(ctx, '/chats?filter=mine', ['/chats']));
+  })
+  .post("/chats/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
+    if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    const b = ctx.request.body;
+    const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : undefined;
+    const patch = { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), category: b.category, status: b.status, tags: b.tags };
+    if (imageBlob !== undefined) patch.image = imageBlob;
+    await chatsModel.updateChatById(ctx.params.id, patch);
+    ctx.redirect(safeReturnTo(ctx, '/chats?filter=mine', ['/chats']));
+  })
+  .post("/chats/delete/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    await chatsModel.deleteChatById(ctx.params.id);
+    ctx.redirect(safeReturnTo(ctx, '/chats?filter=mine', ['/chats']));
+  })
+  .post("/chats/close/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    await chatsModel.closeChatById(ctx.params.id);
+    ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.id)}`, ['/chats']));
+  })
+  .post("/chats/generate-invite", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    const chatId = ctx.request.body.chatId;
+    const code = await chatsModel.generateInvite(chatId);
+    ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(chatId)}?inviteCode=${encodeURIComponent(code)}`, ['/chats']));
+  })
+  .post("/chats/join-code", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    const code = String(ctx.request.body.code || '').trim();
+    try {
+      const chatKey = await chatsModel.joinByInvite(code);
+      ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(chatKey)}`, ['/chats']));
+    } catch (_) {
+      ctx.redirect(safeReturnTo(ctx, '/chats', ['/chats']));
+    }
+  })
+  .post("/chats/join/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    try {
+      await chatsModel.joinChat(ctx.params.id);
+    } catch (_) {}
+    ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.id)}`, ['/chats']));
+  })
+  .post("/chats/leave/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    try {
+      await chatsModel.leaveChat(ctx.params.id);
+    } catch (_) {}
+    ctx.redirect(safeReturnTo(ctx, '/chats?filter=all', ['/chats']));
+  })
+  .post("/chats/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'chats', 'add'))
+  .post("/chats/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'chats', 'remove'))
+  .post("/chats/:chatId/message", koaBody({ multipart: true }), async (ctx) => {
+    if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    const text = stripDangerousTags(String(ctx.request.body.text || '').trim());
+    const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null;
+    if (!text && !imageBlob) { ctx.redirect(`/chats/${encodeURIComponent(ctx.params.chatId)}`); return; }
+    try {
+      await chatsModel.sendMessage(ctx.params.chatId, text, imageBlob);
+    } catch (_) {}
+    ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.chatId)}`, ['/chats']));
+  })
+  .post("/pads/create", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    const b = ctx.request.body || {};
+    const tribeId = b.tribeId || null;
+    const msg = await padsModel.createPad(
+      stripDangerousTags(b.title || ""),
+      b.status || "OPEN",
+      b.deadline || "",
+      b.tags || "",
+      tribeId
+    );
+    ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=pads` : `/pads/${encodeURIComponent(msg.key)}`);
+  })
+  .post("/pads/update/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    const b = ctx.request.body || {};
+    await padsModel.updatePadById(ctx.params.id, {
+      title: stripDangerousTags(b.title || ""),
+      status: b.status || "OPEN",
+      deadline: b.deadline || "",
+      tags: b.tags || ""
+    });
+    ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post("/pads/close/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    try { await padsModel.closePadById(ctx.params.id); } catch (_) {}
+    ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post("/pads/delete/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    await padsModel.deletePadById(ctx.params.id);
+    ctx.redirect('/pads');
+  })
+  .post("/pads/generate-invite/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    const code = await padsModel.generateInvite(ctx.params.id);
+    ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}?inviteCode=${encodeURIComponent(code)}`);
+  })
+  .post("/pads/join-code", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    const code = String((ctx.request.body || {}).code || "").trim();
+    try {
+      const padId = await padsModel.joinByInvite(code);
+      ctx.redirect(`/pads/${encodeURIComponent(padId)}`);
+    } catch (_) {
+      ctx.redirect('/pads');
+    }
+  })
+  .post("/pads/join/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
+    await padsModel.addMemberToPad(ctx.params.id, uid);
+    ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post("/pads/entry/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    const b = ctx.request.body || {};
+    const text = stripDangerousTags(String(b.text || "").trim());
+    if (text) await padsModel.addEntry(ctx.params.id, text);
+    ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post("/pads/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'pads', 'add'))
+  .post("/pads/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'pads', 'remove'))
+  .post("/calendars/create", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const b = ctx.request.body || {};
+    const tribeId = b.tribeId || null;
+    const intervalWeekly  = [].concat(b.intervalWeekly).includes("1");
+    const intervalMonthly = [].concat(b.intervalMonthly).includes("1");
+    const intervalYearly  = [].concat(b.intervalYearly).includes("1");
+    try {
+      const msg = await calendarsModel.createCalendar({
+        title: stripDangerousTags(b.title || ""),
+        status: b.status || "OPEN",
+        deadline: b.deadline || "",
+        tags: b.tags || "",
+        firstDate: b.firstDate || "",
+        firstDateLabel: stripDangerousTags(b.firstDateLabel || ""),
+        firstNote: stripDangerousTags(b.firstNote || ""),
+        intervalWeekly, intervalMonthly, intervalYearly,
+        tribeId
+      });
+      ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=calendars` : `/calendars/${encodeURIComponent(msg.key)}`);
+    } catch (e) {
+      console.error("[calendars/create]", e && e.message ? e.message : e)
+      ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=calendars` : '/calendars');
+    }
+  })
+  .post("/calendars/update/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const b = ctx.request.body || {};
+    try {
+      await calendarsModel.updateCalendarById(ctx.params.id, {
+        title: stripDangerousTags(b.title || ""),
+        status: b.status || "OPEN",
+        deadline: b.deadline || "",
+        tags: b.tags || ""
+      });
+    } catch (_) {}
+    ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post("/calendars/delete/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    try { await calendarsModel.deleteCalendarById(ctx.params.id); } catch (_) {}
+    ctx.redirect('/calendars');
+  })
+  .post("/calendars/join/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    try { await calendarsModel.joinCalendar(ctx.params.id); } catch (_) {}
+    ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post("/calendars/leave/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    try { await calendarsModel.leaveCalendar(ctx.params.id); } catch (_) {}
+    ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post("/calendars/add-date/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const b = ctx.request.body || {};
+    const intervalWeekly  = [].concat(b.intervalWeekly).includes("1");
+    const intervalMonthly = [].concat(b.intervalMonthly).includes("1");
+    const intervalYearly  = [].concat(b.intervalYearly).includes("1");
+    try {
+      const dateMsgs = await calendarsModel.addDate(ctx.params.id, b.date || "", stripDangerousTags(b.label || ""), intervalWeekly, intervalMonthly, intervalYearly, b.intervalDeadline || "");
+      const noteText = stripDangerousTags(String(b.text || "").trim());
+      if (noteText && Array.isArray(dateMsgs)) {
+        for (const msg of dateMsgs) {
+          if (msg && msg.key) {
+            try { await calendarsModel.addNote(ctx.params.id, msg.key, noteText); } catch (_) {}
+          }
+        }
+      }
+    } catch (e) {
+      console.error("[calendars/add-date]", e && e.message ? e.message : e)
+    }
+    ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post("/calendars/add-note/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const b = ctx.request.body || {};
+    const text = stripDangerousTags(String(b.text || "").trim());
+    if (text) {
+      try { await calendarsModel.addNote(ctx.params.id, b.dateId || "", text); } catch (_) {}
+    }
+    ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post("/calendars/delete-note/:noteId", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const calendarId = (ctx.request.body || {}).calendarId || "";
+    try { await calendarsModel.deleteNote(ctx.params.noteId); } catch (_) {}
+    ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars');
+  })
+  .post("/calendars/delete-date/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const calendarId = (ctx.request.body || {}).calendarId || "";
+    try { await calendarsModel.deleteDate(ctx.params.id, calendarId); } catch (_) {}
+    ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars');
+  })
+  .post("/calendars/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'calendars', 'add'))
+  .post("/calendars/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'calendars', 'remove'))
   .post("/projects/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
     const b = ctx.request.body || {}, image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : null;
@@ -3242,39 +3639,35 @@ router
     ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
   })
   .post("/projects/:projectId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'projects', 'projectId'))
+  .post("/banking/claim-ubi", koaBody(), async (ctx) => {
+    const userId = getViewerId();
+    try {
+      await bankingModel.claimUBI(userId);
+      ctx.redirect("/banking?filter=overview&msg=claimed_pending");
+    } catch (e) {
+      ctx.redirect(`/banking?filter=overview&msg=${encodeURIComponent(e.message || "error")}`);
+    }
+  })
   .post("/banking/claim/:id", koaBody(), async (ctx) => {
+    const { i18n: _i18n } = require("../views/main_views");
     const userId = getViewerId(), allocation = await bankingModel.getAllocationById(ctx.params.id);
-    if (!allocation) { ctx.body = { error: i18n.errorNoAllocation }; return; }
-    if (allocation.to !== userId || allocation.status !== "UNCONFIRMED") { ctx.body = { error: i18n.errorInvalidClaim }; return; }
+    if (!allocation) { ctx.body = { error: _i18n.errorNoAllocation }; return; }
+    if (allocation.to !== userId || (allocation.status !== "UNCLAIMED" && allocation.status !== "UNCONFIRMED")) { ctx.body = { error: _i18n.errorInvalidClaim }; return; }
+    if (!bankingModel.isPubNode()) {
+      ctx.redirect("/banking?filter=overview&msg=claimed_pending");
+      return;
+    }
     const { txid } = await bankingModel.claimAllocation({ transferId: ctx.params.id, claimerId: userId });
     await bankingModel.publishBankClaim({ amount: allocation.amount, epochId: allocation.concept, allocationId: allocation.id, txid });
-    try {
-      const ssbClient = await cooler.open();
-      const now = new Date().toISOString();
-      await new Promise((resolve, reject) => ssbClient.publish({
-        type: "transfer",
-        from: "PUB",
-        to: userId,
-        concept: `UBI ${allocation.concept || ""}`.trim(),
-        amount: Number(allocation.amount || 0).toFixed(6),
-        createdAt: now,
-        updatedAt: now,
-        deadline: new Date(Date.now() + 14 * 86400000).toISOString(),
-        confirmedBy: [userId],
-        status: "CLOSED",
-        tags: ["UBI"],
-        opinions: {},
-        opinions_inhabitants: [],
-        txid
-      }, (err, msg) => err ? reject(err) : resolve(msg)));
-    } catch (_) {}
     ctx.redirect(`/banking?claimed=${encodeURIComponent(txid)}`);
   })
   .post("/banking/simulate", koaBody(), async (ctx) => {
+    if (!bankingModel.isPubNode()) { ctx.status = 403; ctx.body = { error: require("../views/main_views").i18n.bankPubOnly }; return; }
     const { epochId, rules } = ctx.request.body || {};
     ctx.body = await bankingModel.computeEpoch({ epochId, rules });
   })
   .post("/banking/run", koaBody(), async (ctx) => {
+    if (!bankingModel.isPubNode()) { ctx.status = 403; ctx.body = { error: require("../views/main_views").i18n.bankPubOnly }; return; }
     const { epochId, rules } = ctx.request.body || {};
     ctx.body = await bankingModel.executeEpoch({ epochId, rules });
   })
@@ -3391,11 +3784,11 @@ router
   })
   .post("/settings/rebuild", async ctx => { meta.rebuild(); ctx.redirect("/settings"); })
   .post("/modules/preset", koaBody(), async (ctx) => {
-    const ALL_MODULES = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts'];
+    const ALL_MODULES = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts'];
     const PRESETS = {
-      minimal: ['feed', 'forum', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'],
-      social: ['agenda', 'audios', 'bookmarks', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'images', 'invites', 'legacy', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
-      economy: ['agenda', 'audios', 'bookmarks', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'images', 'invites', 'legacy', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'],
+      minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'],
+      social: ['agenda', 'audios', 'bookmarks', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
+      economy: ['agenda', 'audios', 'bookmarks', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'],
       full: ALL_MODULES
     };
     const preset = String(ctx.request.body.preset || '');
@@ -3407,7 +3800,7 @@ router
     ctx.redirect('/modules');
   })
   .post("/save-modules", koaBody(), async (ctx) => {
-    const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts'];
+    const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts'];
     const cfg = getConfig();
     modules.forEach(mod => cfg.modules[`${mod}Mod`] = ctx.request.body[`${mod}Form`] === 'on' ? 'on' : 'off');
     saveConfig(cfg);
@@ -3427,6 +3820,12 @@ router
     fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
     ctx.redirect("/settings");
   })
+  .post("/settings/pub-id", koaBody(), async (ctx) => {
+    const b = ctx.request.body, cfg = getConfig();
+    cfg.pubId = String(b.pub_id || "").trim();
+    saveConfig(cfg);
+    ctx.redirect("/settings");
+  })
   .post('/transfers/create', koaBody(), async ctx => {
     if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
     const b = ctx.request.body;
@@ -3524,6 +3923,7 @@ const middleware = [
           sharedState.setCarbonHcH(hcH);
         } catch (_) {}
         try { await refreshInboxCount(); } catch (_) {}
+        try { await calendarsModel.checkDueReminders(); } catch (_) {}
       }
     }
     await next();

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

@@ -6,9 +6,12 @@ const FILE = path.join(__dirname, "../configs/media-favorites.json");
 const DEFAULT = {
   audios: [],
   bookmarks: [],
+  calendars: [],
+  chats: [],
   documents: [],
   images: [],
   maps: [],
+  pads: [],
   shops: [],
   videos: []
 };

+ 7 - 0
src/client/assets/styles/mobile.css

@@ -496,3 +496,10 @@ h3 { font-size: 1em !important; }
 [style*="width:50%"] {
   width: 100% !important;
 }
+.inhabitant-karma-ubi .ubi-line{display:block;font-size:0.9em}
+.pad-editor-container{flex-direction:column !important}
+.pad-editor-area,.pad-members-list{width:100% !important;min-width:unset !important}
+.chat-main-split{flex-direction:column !important}
+.chat-messages-column,.chat-participants-column{width:100% !important;min-width:unset !important;flex:unset !important}
+.games-grid{grid-template-columns:1fr !important}
+.game-card{width:100% !important}

+ 142 - 1
src/client/assets/styles/style.css

@@ -3654,7 +3654,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 }
 
 .tribe-card-members {
-  border: 1px solid #555;
+  border: none;
   border-radius: 4px;
   padding: 8px;
   margin: 6px 0;
@@ -3688,6 +3688,14 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   background: #ffa300;
   color: #000;
 }
+.tribe-action-btn.danger-btn { border-color: #d9534f; color: #d9534f; }
+.tribe-action-btn.danger-btn:hover { background: #d9534f; color: #000; }
+
+.calendar-interval-block { margin: 8px 0 16px; }
+.calendar-interval-label { display: block; margin-bottom: 6px; }
+.calendar-interval-until { margin-top: 10px; }
+.calendar-interval-row { display: flex; gap: 16px; flex-wrap: wrap; border: 0px; }
+.calendar-interval-option { display: flex; align-items: center; gap: 4px; cursor: pointer; white-space: nowrap; }
 
 .tribe-thumb-grid {
   display: grid;
@@ -4876,3 +4884,136 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .shop-product-actions:empty {
   display: none;
 }
+.ubi-available{color:#4CAF50;font-weight:bold}
+.ubi-unavailable{color:#F44336;font-weight:bold}
+.banking-ubi .card-field{margin:4px 0}
+.pad-status-open{font-weight:bold}
+.pad-status-invite{color:#FFA500;font-weight:bold}
+.pad-status-closed{font-weight:bold}
+.pad-deadline{color:#FFA500;font-size:0.9rem}
+.pad-editor-container{display:flex;gap:16px;margin-top:12px}
+.pad-editor-area{flex:2;display:flex;flex-direction:column;gap:8px}
+.pad-editor-area textarea{width:100%;min-height:200px;background:#1a1a1a;color:#eee;border:1px solid #444;border-radius:4px;padding:10px;font-family:monospace;font-size:0.95rem;resize:vertical;box-sizing:border-box}
+.pad-members-list{flex:1;min-width:160px;background:#1a1a1a;border:1px solid #333;border-radius:4px;padding:10px}
+.pad-members-list h4{margin:0 0 8px 0;color:#aaa;font-size:0.85rem;text-transform:uppercase}
+.pad-member-item{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:0.85rem}
+.pad-color-indicator{width:10px;height:10px;border-radius:50%;flex-shrink:0}
+.pad-entries{margin-top:16px;display:flex;flex-direction:column;gap:8px}
+.pad-entry{padding:8px 12px;border-left:4px solid #444;background:#1a1a1a;border-radius:0 4px 4px 0}
+.pad-entry-header{font-size:0.8rem;color:#888;margin-bottom:4px}
+.pad-entry-text{white-space:pre-wrap;word-break:break-word;font-family:monospace;font-size:0.9rem}
+.pad-invite-section{margin-top:12px;padding:12px;background:#1a1a1a;border:1px solid #333;border-radius:4px}
+.pad-invite-section input[type="text"]{width:100%;background:#111;color:#eee;border:1px solid #555;border-radius:4px;padding:8px;box-sizing:border-box;margin-bottom:8px}
+.chat-main-split{display:flex;gap:16px;width:100%}
+.chat-messages-column{flex:2;min-width:0;display:flex;flex-direction:column;gap:8px}
+.chat-participants-column{flex:1;min-width:140px;background:#1a1a1a;border:1px solid #333;border-radius:4px;padding:12px}
+.chat-participants-column h2{margin:0 0 8px 0;color:#aaa;font-size:0.85rem;text-transform:uppercase;font-weight:normal}
+.chat-participants-list{list-style:none;padding:0;margin:0}
+.chat-participants-list li{padding:4px 0;font-size:0.85rem}
+.chat-message-form{background:#1a1a1a;border:1px solid #333;border-radius:4px;padding:10px}
+.chat-message-form textarea{width:100%;box-sizing:border-box;background:#111;color:#eee;border:1px solid #555;border-radius:4px;padding:8px;resize:vertical;font-family:inherit}
+.chat-messages-list{display:flex;flex-direction:column;gap:4px}
+.chat-message{padding:6px 10px;border-radius:4px;background:#1a1a1a;word-break:break-word;border:none}
+.chat-message-author{border-left-color:#FFA500;background:#1f1800}
+.chat-message-self{border-left-color:#888;background:#181818}
+.chat-message-meta{font-size:0.8rem;color:#888;border:none}
+.chat-message-date{color:#666}
+.chat-message-sender a{color:#aaa}
+.chat-message-text{display:block;margin-top:2px;font-size:0.9rem;white-space:pre-wrap;word-break:break-word}
+.calendar-note-card{position:relative;padding:10px 80px 10px 10px;min-height:56px}
+.calendar-note-delete{position:absolute;top:8px;right:8px;margin:0}
+.calendar-note-delete button{padding:4px 10px;font-size:0.8rem}
+.chat-no-messages{color:#666;font-style:italic;padding:12px 0}
+.chat-join-section{margin-top:12px;padding:12px;background:#1a1a1a;border:1px solid #333;border-radius:4px}
+.chat-invite-form input[type="text"]{width:100%;background:#111;color:#eee;border:1px solid #555;border-radius:4px;padding:8px;box-sizing:border-box;margin-bottom:8px}
+.games-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:20px;margin-top:16px}
+.game-card{border:1px solid #333;border-radius:8px;overflow:hidden;display:flex;flex-direction:column;background:#111}
+.game-card-media{width:100%;max-height:180px;overflow:hidden;display:flex;align-items:center;justify-content:center;background:#000}
+.game-card-media img{width:100%;max-height:180px;object-fit:cover}
+.game-card-body{padding:14px;display:flex;flex-direction:column;gap:8px;flex:1}
+.game-card-title{margin:0;font-size:1.1rem;color:#FFA500}
+.game-card-desc{margin:0;font-size:0.9rem;color:#ccc;flex:1}
+.game-card-actions{display:flex;justify-content:center;margin-top:8px}
+.hall-of-fame-table{width:100%;border-collapse:collapse;font-size:0.9rem}
+.hall-of-fame-table th{color:#FFA500;text-align:left;padding:4px 8px;border-bottom:1px solid #444}
+.hall-of-fame-table td{padding:4px 8px;color:#ccc;border-bottom:1px solid #222}
+.hall-of-fame-table tr:hover td{background:#1a1a1a}
+
+.activity-filter-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:16px;margin-bottom:24px}
+.activity-filter-col{display:flex;flex-direction:column;gap:8px}
+
+.visit-btn-centered{display:flex;justify-content:center;margin-top:10px}
+
+.pad-editor-white{width:100%;box-sizing:border-box;background:#fff;color:#111;border:1px solid #aaa;border-radius:4px;padding:10px;resize:vertical;font-family:inherit;font-size:0.95rem}
+.pad-readonly-text{background:#fff;color:#111;border:1px solid #aaa;border-radius:4px;padding:10px;white-space:pre-wrap;word-break:break-word;font-size:0.95rem}
+.pad-version-list{margin-top:12px}
+.pad-version-item{display:flex;flex-direction:column;gap:4px;padding:8px;border-bottom:1px solid #333;font-size:0.85rem}
+.pad-version-date{color:#888}
+.pad-version-author a{color:#FFA500}
+.pad-version-link{color:#aaa;text-decoration:underline;cursor:pointer}
+.pad-author-color-0{background-color:#e74c3c}
+.pad-author-color-1{background-color:#3498db}
+.pad-author-color-2{background-color:#2ecc71}
+.pad-author-color-3{background-color:#f39c12}
+.pad-author-color-4{background-color:#9b59b6}
+.pad-author-color-5{background-color:#1abc9c}
+.pad-author-color-6{background-color:#e67e22}
+.pad-author-color-7{background-color:#e91e63}
+.pad-author-color-8{background-color:#00bcd4}
+.pad-author-color-9{background-color:#8bc34a}
+.pad-author-color-none{background-color:#888}
+.pad-entry-author{display:block;font-size:.75rem;margin-bottom:4px;opacity:.75}
+.pad-author-swatch{display:inline-block;width:12px;height:12px;border-radius:2px;margin-right:6px;vertical-align:middle;border:1px solid #000}
+.pad-readonly-colored{background:#fff;color:#111;padding:12px;border-radius:4px;min-height:120px;white-space:pre-wrap;word-break:break-word;font-family:inherit;font-size:.95rem;border:1px solid #aaa;margin-bottom:10px}
+.pad-author-span{padding:1px 2px;border-radius:2px}
+.pad-members-section{margin-top:12px;border:none}
+.pad-members-below-tags{margin-top:12px;border:none}
+.pad-member-card{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:0.85rem}
+.pad-members-grid{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}
+.pad-main-layout{display:flex;gap:20px}
+.pad-main-left{flex:2;min-width:0}
+.pad-main-right{flex:1;min-width:180px}
+
+.chat-full-width{flex:1;min-width:0}
+.chat-participants-section{margin-top:12px;padding:12px;background:#1a1a1a;border:none;border-radius:4px}
+.chat-participants-below{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px}
+.chat-participants-below a{font-size:0.85rem;color:#aaa}
+.chat-message-image-wrap{margin:4px 0}
+.chat-message-image{max-width:100%;max-height:300px;border-radius:4px}
+
+.games-single-col{display:flex;flex-direction:column;gap:16px;margin-top:16px}
+.game-row{display:flex;gap:16px;border:1px solid #333;border-radius:8px;overflow:hidden;background:#111;padding:12px;align-items:flex-start}
+.game-row-media{flex-shrink:0;width:120px}
+.game-row-media img{width:100%;border-radius:4px}
+.game-row-body{flex:1;display:flex;flex-direction:column;gap:6px}
+.game-top-score{font-size:0.85rem;color:#aaa}
+.score-first{color:#2ecc71;font-weight:bold}
+.games-scoring-list{display:flex;flex-direction:column;gap:24px;margin-top:16px}
+.game-scoring-section{border:1px solid #333;border-radius:8px;padding:16px;background:#111}
+.game-scoring-header{display:flex;gap:16px;align-items:flex-start;margin-bottom:12px}
+.game-scoring-thumb{width:80px;border-radius:4px;flex-shrink:0}
+.game-scoring-info{flex:1}
+.game-shell-section{padding:0!important;margin:0}
+.game-iframe{width:100%;height:82vh;border:none;background:#000;display:block}
+.game-iframe-ecoinflow{height:95vh}
+.game-iframe-audiopendulum{height:95vh}
+.game-iframe-flipflop{height:720px}
+.game-iframe-tiktaktoe{height:580px}
+.game-desc-yellow{color:yellow}
+.game-new-record-label{color:#FFA500;font-weight:bold}
+.game-row-actions{display:flex;align-items:center;text-align:center;flex-shrink:0;border:0px;}
+.calendar-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin:12px 0}
+.calendar-day-header{font-weight:bold;padding:8px;text-align:center;color:#FFA500}
+.calendar-day{padding:10px 4px;text-align:center;background:#222;border-radius:4px;min-height:36px}
+.calendar-day-empty{background:#111;opacity:.4}
+.calendar-day-marked{background:#FFA500;color:#000;font-weight:bold}
+.calendar-day-marked a{color:#000;text-decoration:none;display:block}
+.calendar-nav{display:flex;justify-content:space-between;align-items:center;margin:8px 0}
+.calendar-date-list{margin-top:16px}
+.calendar-date-item{background:#333;border-radius:6px;margin-bottom:14px;position:relative;min-height:64px}
+.calendar-date-delete{position:absolute;top:12px;right:12px;margin:0}
+.calendar-date-delete button{padding:4px 10px;font-size:0.8rem;top:12px;margin-top: 25px;margin-right: 35px;}
+.calendar-date-item-header{color:#FFA500;font-weight:bold;margin-bottom:6px}
+.calendar-participants-count{color:#FFA500;font-weight:bold}
+.calendar-day-notes{margin-top:16px}
+.pad-viewer-back{margin-bottom:12px}

+ 256 - 1
src/client/assets/translations/oasis_ar.js

@@ -639,6 +639,9 @@ module.exports = {
     favoritesFilterDocuments: "المستندات",
     favoritesFilterImages: "الصور",
     favoritesFilterMaps: "خرائط",
+    favoritesFilterPads: "اللوحات",
+    favoritesFilterChats: "الدردشات",
+    favoritesFilterCalendars: "التقويمات",
     favoritesFilterVideos: "الفيديوهات",
     favoritesRemoveButton: "إزالة من المفضلة",
     favoritesNoItems: "لا توجد مفضلات بعد.",
@@ -1726,6 +1729,16 @@ module.exports = {
     tribeSectionVideos: "الفيديوهات",
     tribeSectionDocuments: "المستندات",
     tribeSectionBookmarks: "العلامات المرجعية",
+    tribeSectionMaps: "MAPS",
+    tribeSectionPads: "PADS",
+    tribeSectionChats: "CHATS",
+    tribeSectionCalendars: "CALENDARS",
+    tribePadCreate: "Create Pad",
+    tribeChatCreate: "Create Chat",
+    tribeCalendarCreate: "Create Calendar",
+    tribePadsEmpty: "No pads, yet.",
+    tribeChatsEmpty: "No chats, yet.",
+    tribeCalendarsEmpty: "No calendars, yet.",
     tribeInhabitantsEmpty: "لا يوجد سكان في هذه القبيلة بعد.",
     tribeEventCreate: "إنشاء حدث",
     tribeEventsEmpty: "لا توجد أحداث بعد.",
@@ -1891,6 +1904,7 @@ module.exports = {
     agendaFilterTransfers: "التحويلات",
     agendaFilterJobs: "الوظائف",
     agendaFilterProjects: "المشاريع",
+    agendaFilterCalendars: "التقويمات",
     agendaNoItems: "لم يتم العثور على إسنادات.",
     agendaAuthor: "بواسطة",
     agendaDiscardButton: "تجاهل",
@@ -2087,6 +2101,12 @@ module.exports = {
     bankViewTx: 'عرض المعاملة',
     bankClaimNow: 'طالب الآن',
     bankClaimUBI: 'طالب بـ UBI!',
+    bankClaimAndPay: 'Claim & Pay',
+    bankClaimedPending: 'Claim pending...',
+    bankStatusUnclaimed: 'Unclaimed',
+    bankStatusClaimed: 'Claimed',
+    bankStatusExpired: 'Expired',
+    bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'لا توجد تخصيصات UBI معلقة لهذه الحقبة.',
     bankPubBalance: 'رصيد PUB',
     bankEpoch: 'الحقبة',
@@ -2137,6 +2157,24 @@ module.exports = {
     bankRemoveMyAddress: 'إزالة عنواني',
     bankNotRemovableOasis: 'لا يمكن إزالة العناوين محليًا',
     bankingFutureUBI: "UBI",
+    pubIdTitle: "PUB Wallet",
+    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdLabel: "PUB ID",
+    pubIdSave: "Save configuration",
+    pubIdPlaceholder: "@PUB_ID.ed25519",
+    bankUbiAvailability: "توفر الدخل الأساسي",
+    bankUbiAvailableOk: "متاح",
+    bankUbiAvailableNo: "لا توجد أموال!",
+    bankAlreadyClaimedThisMonth: "تم المطالبة بالفعل هذا الشهر",
+    bankUbiThisMonth: "الدخل الأساسي (هذا الشهر)",
+    bankUbiLastClaimed: "الدخل الأساسي (آخر مطالبة)",
+    bankUbiNeverClaimed: "لم يتم المطالبة قط",
+    bankUbiTotalClaimed: "الدخل الأساسي (إجمالي المطالبات)",
+    bankUbiPub: "PUB",
+    bankUbiInhabitant: "ساكن",
+    bankUbiClaimedAmount: "المطالب به (ECO)",
+    typeBankUbiResult: "البنك - الدخل الأساسي",
+    bankNoPubConfigured: "لم يتم تكوين PUB. قم بتعيين معرف PUB في الإعدادات.",
     shopsTitle: "متاجر",
     shopDescription: "اكتشف وأدر المتاجر في الشبكة.",
     shopTitle: "متجر",
@@ -2786,6 +2824,223 @@ module.exports = {
     typeMap: "خرائط",
     typeMapMarker: "علامة خريطة",
     modulesMapLabel: "خرائط",
-    modulesMapDescription: "وحدة لإدارة ومشاركة الخرائط غير المتصلة."
+    modulesMapDescription: "وحدة لإدارة ومشاركة الخرائط غير المتصلة.",
+    padsTitle: "الوسادات",
+    padTitle: "وسادة",
+    modulesPadsLabel: "الوسادات",
+    modulesPadsDescription: "وحدة لإدارة محررات النصوص التعاونية.",
+    padFilterAll: "الكل",
+    padFilterMine: "خاصتي",
+    padFilterRecent: "الأخيرة",
+    padFilterOpen: "مفتوح",
+    padFilterClosed: "مغلق",
+    padCreate: "إنشاء وسادة",
+    padUpdate: "تحديث الوسادة",
+    padDelete: "حذف الوسادة",
+    padTitleLabel: "العنوان",
+    padTitlePlaceholder: "أدخل عنوان الوسادة...",
+    padStatusLabel: "الحالة",
+    padStatusOpen: "مفتوح",
+    padStatusInviteOnly: "بدعوة فقط",
+    padStatusClosed: "مغلق",
+    padDeadlineLabel: "الموعد النهائي",
+    padTagsLabel: "الوسوم",
+    padTagsPlaceholder: "وسم1، وسم2، ...",
+    padMembersLabel: "الأعضاء",
+    padVisitPad: "زيارة الوسادة",
+    padShareUrl: "مشاركة الرابط",
+    padCreated: "تاريخ الإنشاء",
+    padAuthor: "المؤلف",
+    padGenerateCode: "إنشاء رمز",
+    padInviteCodeLabel: "رمز الدعوة",
+    padInviteCodePlaceholder: "أدخل رمز الدعوة...",
+    padValidateInvite: "تحقق",
+    padStartEditing: "ابدأ التحرير!",
+    padEditorPlaceholder: "ابدأ الكتابة...",
+    padSubmitEntry: "إرسال",
+    padNoEntries: "لا توجد مدخلات بعد.",
+    padAllSectionTitle: "جميع الوسادات",
+    padMineSectionTitle: "وساداتي",
+    padRecentSectionTitle: "الوسادات الأخيرة",
+    padOpenSectionTitle: "الوسادات المفتوحة",
+    padClosedSectionTitle: "الوسادات المغلقة",
+    padCreateSectionTitle: "إنشاء وسادة جديدة",
+    padUpdateSectionTitle: "تحديث الوسادة",
+    padInviteGenerated: "تم إنشاء رمز الدعوة",
+    typePad: "وسادة",
+    padNew: "جديد",
+    padAddFavorite: "إضافة إلى المفضلة",
+    padRemoveFavorite: "إزالة من المفضلة",
+    padClose: "إغلاق اللوحة",
+    padBackToEditor: "العودة إلى المحرر",
+    padSearchPlaceholder: "البحث عن الوسادات...",
+
+    modulesChatsLabel: "المحادثات",
+    modulesChatsDescription: "وحدة لاكتشاف وإدارة المحادثات المشفرة.",
+    typeChat: "محادثة",
+    typeChatMessage: "رسالة محادثة",
+    chatLabel: "المحادثات",
+    chatMessageLabel: "رسائل المحادثة",
+    chatsTitle: "المحادثات",
+    chatMineSectionTitle: "Your Chats",
+    chatRecentTitle: "Recent Chats",
+    chatFavoritesTitle: "المفضلة",
+    chatOpenTitle: "Open Chats",
+    chatClosedTitle: "Closed Chats",
+    chatDescription: "الوصف",
+    chatCategory: "الفئة",
+    chatStatus: "الحالة",
+    chatFilterAll: "الكل",
+    chatFilterMine: "لي",
+    chatFilterRecent: "الأخيرة",
+    chatFilterFavorites: "المفضلة",
+    chatFilterOpen: "مفتوح",
+    chatFilterClosed: "مغلق",
+    chatCreate: "إنشاء محادثة",
+    chatUpdate: "تحديث المحادثة",
+    chatDelete: "حذف المحادثة",
+    chatClose: "إغلاق المحادثة",
+    chatVisitChat: "زيارة المحادثة",
+    chatUntitled: "محادثة بدون عنوان",
+    chatNoItems: "لا توجد محادثات.",
+    chatParticipants: "المشاركون",
+    chatStartChatting: "ابدأ المحادثة!",
+    chatGenerateCode: "إنشاء رمز",
+    chatShareUrl: "مشاركة الرابط",
+    chatCreatedAt: "تم الإنشاء",
+    chatSearchPlaceholder: "بحث في المحادثات...",
+    chatStatusOpen: "مفتوح",
+    chatStatusInviteOnly: "بالدعوة فقط",
+    chatStatusClosed: "مغلق",
+    chatSendMessage: "إرسال",
+    chatMessagePlaceholder: "اكتب رسالتك...",
+    chatNoMessages: "لا توجد رسائل بعد.",
+    chatLeave: "مغادرة المحادثة",
+    chatInviteCodeLabel: "أدخل رمز الدعوة",
+    chatJoinByInvite: "انضم",
+    chatTitlePlaceholder: "عنوان المحادثة",
+    chatDescriptionPlaceholder: "وصف المحادثة",
+    chatTagsPlaceholder: "وسم1، وسم2",
+    chatImageLabel: "اختر ملف صورة (.jpeg, .jpg, .png, .gif)",
+    chatInviteCode: "رمز الدعوة",
+    chatAuthor: "المؤلف",
+    chatCreated: "تم الإنشاء",
+    chatAddFavorite: "إضافة للمفضلة",
+    chatRemoveFavorite: "إزالة من المفضلة",
+    chatPM: "رسالة",
+    chatStatusLabel: "الحالة",
+    chatCategoryLabel: "الفئة",
+    chatParticipantsLabel: "المشاركون",
+    gamesTitle: "الألعاب",
+    gamesDescription: "اكتشف وألعب بعض الألعاب.",
+    gamesFilterAll: "الكل",
+    gamesPlayButton: "العب!",
+    gamesBackToGames: "العودة إلى الألعاب",
+    modulesGamesLabel: "الألعاب",
+    modulesGamesDescription: "وحدة لاكتشاف وتشغيل بعض الألعاب.",
+    gamesCocolandTitle: "Cocoland",
+    gamesCocolandDesc: "جوزة هند بعيون تقفز فوق أشجار النخيل وتجمع ECOins.",
+    gamesTheFlowTitle: "ECOinflow",
+    gamesTheFlowDesc: "اربط PUBs بالسكان عبر المدققين والمتاجر والمجمّعات. انجُ من تهديد CBDC!",
+    gamesSpaceInvadersTitle: "Space Invaders",
+    gamesSpaceInvadersDesc: "أوقف الغزو الفضائي! أسقط موجات الغزاة قبل أن يصلوا إلى الأرض.",
+    gamesArkanoidTitle: "Arkanoid",
+    gamesArkanoidDesc: "اكسر جميع الطوب بمضربك وكرتك. تحدي أركيد كلاسيكي.",
+    gamesPingPongTitle: "PingPong",
+    gamesPingPongDesc: "بينج بونج كلاسيكي ضد ذكاء اصطناعي. أول من يصل إلى 5 نقاط يفوز.",
+    gamesOutrunTitle: "Outrun",
+    gamesOutrunDesc: "سباق ضد الوقت! تجنب السيارات وصل إلى خط النهاية قبل انتهاء الوقت.",
+    gamesAsteroidsTitle: "Asteroids",
+    gamesAsteroidsDesc: "قد مركبتك عبر حقل الكويكبات. دمرها قبل أن تصطدم بك.",
+    gamesTikTakToeTitle: "TikTakToe",
+    gamesTikTakToeDesc: "حجر، ورقة أو مقص ضد ذكاء اصطناعي. الأفضل في ثلاث جولات يفوز.",
+    gamesFlipFlopTitle: "FlipFlop",
+    gamesFlipFlopDesc: "اقلب عملة معدنية وراهن على الوجه أو الظهر. كم أنت محظوظ?",
+    games8BallTitle: "8Ball Pool",
+    games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.",
+    gamesArtilleryTitle: "Artillery",
+    gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.",
+    gamesLabyrinthTitle: "Labyrinth",
+    gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.",
+    gamesCocomanTitle: "Cocoman",
+    gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.",
+    gamesTetrisTitle: "Tetris",
+    gamesAudioPendulumTitle: "Audio Pendulum",
+    gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.",
+    gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?",
+    gamesQuakeTitle: "Quake Arena",
+    gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.",
+    gamesFilterScoring: "SCORING",
+    gamesHallOfFame: "Hall of Fame",
+    gamesHallPlayer: "Player",
+    gamesHallScore: "Score",
+    gamesNoScores: "No scores yet.",
+    calendarsTitle: "التقاويم",
+    calendarTitle: "التقويم",
+    modulesCalendarsLabel: "التقاويم",
+    modulesCalendarsDescription: "وحدة لاكتشاف وإدارة التقاويم.",
+    typeCalendar: "تقويم",
+    calendarFilterAll: "الكل",
+    calendarFilterMine: "تقاويمي",
+    calendarFilterOpen: "مفتوح",
+    calendarFilterClosed: "مغلق",
+    calendarFilterRecent: "الأحدث",
+    calendarFilterFavorites: "المفضلة",
+    calendarCreate: "إنشاء تقويم",
+    calendarUpdate: "تحديث",
+    calendarDelete: "حذف",
+    calendarTitleLabel: "العنوان",
+    calendarTitlePlaceholder: "عنوان التقويم...",
+    calendarStatusLabel: "الحالة",
+    calendarStatusOpen: "مفتوح",
+    calendarStatusClosed: "مغلق",
+    calendarDeadlineLabel: "Deadline",
+    calendarTagsLabel: "الوسوم",
+    calendarTagsPlaceholder: "وسم1، وسم2...",
+    calendarParticipantsLabel: "المشاركون",
+    calendarParticipantsCount: "المشاركون",
+    calendarVisitCalendar: "زيارة التقويم",
+    calendarCreated: "تم الإنشاء",
+    calendarAuthor: "المؤلف",
+    calendarJoin: "انضمام",
+    calendarJoined: "منضم",
+    calendarAddDate: "إضافة تاريخ",
+    calendarAddNote: "إضافة ملاحظة",
+    calendarDateLabel: "التاريخ",
+    calendarDatePlaceholder: "وصف هذا التاريخ...",
+    calendarNoteLabel: "ملاحظة",
+    calendarNotePlaceholder: "إضافة ملاحظة...",
+    calendarFirstDateLabel: "التاريخ",
+    calendarFirstNoteLabel: "ملاحظات",
+    calendarIntervalLabel: "Interval",
+    calendarIntervalWeekly: "Weekly",
+    calendarIntervalMonthly: "Monthly",
+    calendarIntervalYearly: "Yearly",
+    calendarFormDescription: "الوصف",
+    calendarNoDates: "لا توجد تواريخ.",
+    calendarNoNotes: "لا توجد ملاحظات.",
+    calendarsNoItems: "لا توجد تقاويم.",
+    calendarsDescription: "اكتشف وأدر التقويمات في شبكتك.",
+    calendarMonthPrev: "← السابق",
+    calendarMonthNext: "التالي →",
+    calendarMonthLabel: "التواريخ",
+    calendarsShareUrl: "رابط المشاركة",
+    calendarAllSectionTitle: "جميع التقاويم",
+    calendarRecentSectionTitle: "التقويمات الأحدث",
+    calendarFavoritesSectionTitle: "المفضلة",
+    calendarMineSectionTitle: "تقويماتك",
+    calendarOpenSectionTitle: "التقاويم المفتوحة",
+    calendarClosedSectionTitle: "التقاويم المغلقة",
+    calendarCreateSectionTitle: "إنشاء تقويم جديد",
+    calendarUpdateSectionTitle: "تحديث التقويم",
+    calendarAddFavorite: "إضافة إلى المفضلة",
+  calendarDeleteNote: "Delete",
+    calendarRemoveFavorite: "إزالة من المفضلة",
+    calendarSearchPlaceholder: "البحث عن تقاويم...",
+    calendarAddEntry: "إضافة إدخال",
+    calendarLeave: "مغادرة التقويم",
+    statsCalendar: "التقاويم",
+    statsCalendarDate: "تواريخ التقويم",
+    statsCalendarNote: "ملاحظات التقويم"
     }
 };

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

@@ -635,6 +635,9 @@ module.exports = {
     favoritesFilterDocuments: "DOKUMENTE",
     favoritesFilterImages: "BILDER",
     favoritesFilterMaps: "KARTEN",
+    favoritesFilterPads: "PADS",
+    favoritesFilterChats: "CHATS",
+    favoritesFilterCalendars: "KALENDER",
     favoritesFilterVideos: "VIDEOS",
     favoritesRemoveButton: "Aus Favoriten entfernen",
     favoritesNoItems: "Noch keine Favoriten.",
@@ -1419,6 +1422,9 @@ module.exports = {
     typeKarmaScore:       "KARMA",
     typeParliament:       "PARLAMENT",
     typeSpread:           "VERBREITUNGEN",
+    typeChat:             "CHAT",
+    typeChatMessage:      "CHATNACHRICHT",
+    typeGameScore:        "SPIELERGEBNIS",
     typeParliamentCandidature: "Parlament · Kandidatur",
     typeParliamentTerm:   "Parlament · Amtszeit",
     typeParliamentProposal:"Parlament · Vorschlag",
@@ -1722,6 +1728,16 @@ module.exports = {
     tribeSectionVideos: "VIDEOS",
     tribeSectionDocuments: "DOKUMENTE",
     tribeSectionBookmarks: "LESEZEICHEN",
+    tribeSectionMaps: "MAPS",
+    tribeSectionPads: "PADS",
+    tribeSectionChats: "CHATS",
+    tribeSectionCalendars: "CALENDARS",
+    tribePadCreate: "Create Pad",
+    tribeChatCreate: "Create Chat",
+    tribeCalendarCreate: "Create Calendar",
+    tribePadsEmpty: "No pads, yet.",
+    tribeChatsEmpty: "No chats, yet.",
+    tribeCalendarsEmpty: "No calendars, yet.",
     tribeInhabitantsEmpty: "Noch keine Bewohner in diesem Stamm.",
     tribeEventCreate: "Termin erstellen",
     tribeEventsEmpty: "Noch keine Termine.",
@@ -1887,6 +1903,7 @@ module.exports = {
     agendaFilterTransfers: "ÜBERWEISUNGEN",
     agendaFilterJobs: "STELLEN",
     agendaFilterProjects: "PROJEKTE",
+    agendaFilterCalendars: "KALENDER",
     agendaNoItems: "Keine Zuweisungen gefunden.",
     agendaAuthor: "Von",
     agendaDiscardButton: "Verwerfen",
@@ -2083,6 +2100,12 @@ module.exports = {
     bankViewTx: 'Tx anzeigen',
     bankClaimNow: 'Jetzt beanspruchen',
     bankClaimUBI: 'UBI beanspruchen!',
+    bankClaimAndPay: 'Claim & Pay',
+    bankClaimedPending: 'Claim pending...',
+    bankStatusUnclaimed: 'Unclaimed',
+    bankStatusClaimed: 'Claimed',
+    bankStatusExpired: 'Expired',
+    bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'Keine ausstehenden UBI-Zuweisungen für diese Epoche.',
     bankPubBalance: 'PUB-Kontostand',
     bankEpoch: 'Epoche',
@@ -2133,6 +2156,24 @@ module.exports = {
     bankRemoveMyAddress: 'Meine Adresse entfernen',
     bankNotRemovableOasis: 'Adressen können nicht lokal entfernt werden',
     bankingFutureUBI: "UBI",
+    pubIdTitle: "PUB Wallet",
+    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdLabel: "PUB ID",
+    pubIdSave: "Save configuration",
+    pubIdPlaceholder: "@PUB_ID.ed25519",
+    bankUbiAvailability: "UBI-Verfügbarkeit",
+    bankUbiAvailableOk: "OK",
+    bankUbiAvailableNo: "KEIN GUTHABEN!",
+    bankAlreadyClaimedThisMonth: "Diesen Monat bereits beansprucht",
+    bankUbiThisMonth: "UBI (diesen Monat)",
+    bankUbiLastClaimed: "UBI (zuletzt beansprucht)",
+    bankUbiNeverClaimed: "Nie beansprucht",
+    bankUbiTotalClaimed: "UBI (gesamt beansprucht)",
+    bankUbiPub: "PUB",
+    bankUbiInhabitant: "BEWOHNER",
+    bankUbiClaimedAmount: "BEANSPRUCHT (ECO)",
+    typeBankUbiResult: "BANKING - UBI",
+    bankNoPubConfigured: "Kein PUB konfiguriert. Lege deine PUB-ID in den Einstellungen fest.",
     shopsTitle: "Shops",
     shopDescription: "Shops im Netzwerk entdecken und verwalten.",
     shopTitle: "Shop",
@@ -2783,6 +2824,219 @@ module.exports = {
     typeMap: "KARTEN",
     typeMapMarker: "KARTENMARKIERUNG",
     modulesMapLabel: "Karten",
-    modulesMapDescription: "Modul zum Verwalten und Teilen von Offline-Karten."
+    modulesMapDescription: "Modul zum Verwalten und Teilen von Offline-Karten.",
+    padsTitle: "Pads",
+    padTitle: "Pad",
+    modulesPadsLabel: "Pads",
+    modulesPadsDescription: "Modul zur Verwaltung kollaborativer Texteditoren.",
+    padFilterAll: "ALLE",
+    padFilterMine: "MEINE",
+    padFilterRecent: "AKTUELL",
+    padFilterOpen: "OFFEN",
+    padFilterClosed: "GESCHLOSSEN",
+    padCreate: "Pad Erstellen",
+    padUpdate: "Pad Aktualisieren",
+    padDelete: "Pad Löschen",
+    padTitleLabel: "Titel",
+    padTitlePlaceholder: "Pad-Titel eingeben...",
+    padStatusLabel: "Status",
+    padStatusOpen: "OFFEN",
+    padStatusInviteOnly: "NUR AUF EINLADUNG",
+    padStatusClosed: "GESCHLOSSEN",
+    padDeadlineLabel: "Frist",
+    padTagsLabel: "Tags",
+    padTagsPlaceholder: "tag1, tag2, ...",
+    padMembersLabel: "Mitglieder",
+    padVisitPad: "Pad Besuchen",
+    padShareUrl: "URL Teilen",
+    padCreated: "Erstellt",
+    padAuthor: "Autor",
+    padGenerateCode: "Code Generieren",
+    padInviteCodeLabel: "Einladungscode",
+    padInviteCodePlaceholder: "Einladungscode eingeben...",
+    padValidateInvite: "Validieren",
+    padStartEditing: "BEARBEITUNG STARTEN!",
+    padEditorPlaceholder: "Schreiben beginnen...",
+    padSubmitEntry: "Senden",
+    padNoEntries: "Noch keine Einträge.",
+    padAllSectionTitle: "Alle Pads",
+    padMineSectionTitle: "Meine Pads",
+    padRecentSectionTitle: "Aktuelle Pads",
+    padOpenSectionTitle: "Offene Pads",
+    padClosedSectionTitle: "Geschlossene Pads",
+    padCreateSectionTitle: "Neues Pad Erstellen",
+    padUpdateSectionTitle: "Pad Aktualisieren",
+    padInviteGenerated: "Einladungscode Generiert",
+    typePad: "PAD",
+    padNew: "NEU",
+    padAddFavorite: "Zu Favoriten Hinzufügen",
+    padRemoveFavorite: "Aus Favoriten Entfernen",
+    padClose: "Pad schließen",
+    padBackToEditor: "Zurück zum Editor",
+    padSearchPlaceholder: "Pads suchen...",
+    gamesTitle: "Spiele",
+    gamesDescription: "Entdecke und spiele einige Spiele.",
+    gamesFilterAll: "ALLE",
+    gamesPlayButton: "SPIELEN!",
+    gamesBackToGames: "Zurück zu Spielen",
+    modulesGamesLabel: "Spiele",
+    modulesGamesDescription: "Modul zum Entdecken und Spielen von Spielen.",
+    gamesCocolandTitle: "Cocoland",
+    gamesCocolandDesc: "Eine Kokosnuss mit Augen, die über Palmen springt und ECOins sammelt.",
+    gamesTheFlowTitle: "ECOinflow",
+    gamesTheFlowDesc: "Verbinde PUBs mit Bewohnern über Validatoren, Shops und Akkumulatoren. Überstehe die CBDC-Bedrohung!",
+    gamesSpaceInvadersTitle: "Space Invaders",
+    gamesSpaceInvadersDesc: "Stoppt die Alien-Invasion! Schießt Wellen von Eindringlingen ab.",
+    gamesArkanoidTitle: "Arkanoid",
+    gamesArkanoidDesc: "Brecht alle Steine mit eurem Schläger und Ball. Ein klassisches Arcade-Spiel.",
+    gamesPingPongTitle: "PingPong",
+    gamesPingPongDesc: "Klassisches Ping-Pong gegen eine KI. Wer zuerst 5 Punkte hat, gewinnt.",
+    gamesOutrunTitle: "Outrun",
+    gamesOutrunDesc: "Rennen gegen die Zeit! Weiche dem Verkehr aus und erreiche das Ziel rechtzeitig.",
+    gamesAsteroidsTitle: "Asteroids",
+    gamesAsteroidsDesc: "Steuere dein Raumschiff durch ein tödliches Asteroidenfeld. Schieße sie ab, bevor sie dich treffen.",
+    gamesTikTakToeTitle: "TikTakToe",
+    gamesTikTakToeDesc: "Schere, Stein, Papier gegen eine KI. Das Beste aus drei Runden gewinnt.",
+    gamesFlipFlopTitle: "FlipFlop",
+    gamesFlipFlopDesc: "Werfe eine Münze und wette auf Kopf oder Zahl. Wie viel Glück hast du?",
+    games8BallTitle: "8Ball Pool",
+    games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.",
+    gamesArtilleryTitle: "Artillery",
+    gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.",
+    gamesLabyrinthTitle: "Labyrinth",
+    gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.",
+    gamesCocomanTitle: "Cocoman",
+    gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.",
+    gamesTetrisTitle: "Tetris",
+    gamesAudioPendulumTitle: "Audio Pendulum",
+    gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.",
+    gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?",
+    gamesQuakeTitle: "Quake Arena",
+    gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.",
+    gamesFilterScoring: "SCORING",
+    gamesHallOfFame: "Hall of Fame",
+    gamesHallPlayer: "Player",
+    gamesHallScore: "Score",
+    gamesNoScores: "Noch keine Ergebnisse.",
+    modulesChatsLabel: "Chats",
+    modulesChatsDescription: "Modul zum Entdecken und Verwalten verschlüsselter Chats.",
+    chatMessageLabel: "CHATNACHRICHTEN",
+    chatsTitle: "Chats",
+    chatMineSectionTitle: "Meine Chats",
+    chatRecentTitle: "Aktuelle Chats",
+    chatFavoritesTitle: "Favoriten",
+    chatOpenTitle: "Offene Chats",
+    chatClosedTitle: "Geschlossene Chats",
+    chatDescription: "Beschreibung",
+    chatCategory: "Kategorie",
+    chatStatus: "STATUS",
+    chatFilterAll: "ALLE",
+    chatFilterMine: "MEINE",
+    chatFilterRecent: "AKTUELL",
+    chatFilterFavorites: "FAVORITEN",
+    chatFilterOpen: "OFFEN",
+    chatFilterClosed: "GESCHLOSSEN",
+    chatCreate: "Chat Erstellen",
+    chatUpdate: "Chat Aktualisieren",
+    chatDelete: "Chat Löschen",
+    chatClose: "Chat Schließen",
+    chatVisitChat: "CHAT BESUCHEN",
+    chatUntitled: "Unbenannter Chat",
+    chatNoItems: "Keine Chats gefunden.",
+    chatParticipants: "Teilnehmer",
+    chatStartChatting: "CHAT STARTEN!",
+    chatGenerateCode: "Code Generieren",
+    chatShareUrl: "URL Teilen",
+    chatCreatedAt: "ERSTELLT",
+    chatSearchPlaceholder: "Chats suchen...",
+    chatStatusOpen: "OFFEN",
+    chatStatusInviteOnly: "NUR AUF EINLADUNG",
+    chatStatusClosed: "GESCHLOSSEN",
+    chatSendMessage: "Senden",
+    chatMessagePlaceholder: "Nachricht eingeben...",
+    chatNoMessages: "Noch keine Nachrichten.",
+    chatLeave: "Chat Verlassen",
+    chatInviteCodeLabel: "Einladungscode eingeben",
+    chatJoinByInvite: "Beitreten",
+    chatTitlePlaceholder: "Chat-Titel",
+    chatDescriptionPlaceholder: "Chat-Beschreibung",
+    chatTagsPlaceholder: "tag1, tag2, tag3",
+    chatImageLabel: "Bilddatei auswählen (.jpeg, .jpg, .png, .gif)",
+    chatInviteCode: "Einladungscode",
+    chatAuthor: "Autor",
+    chatCreated: "Erstellt",
+    chatAddFavorite: "Zu Favoriten Hinzufügen",
+    chatRemoveFavorite: "Aus Favoriten Entfernen",
+    chatPM: "PM",
+    chatStatusLabel: "Status",
+    chatCategoryLabel: "Kategorie",
+    chatParticipantsLabel: "Teilnehmer",
+    calendarsTitle: "Kalender",
+    calendarTitle: "Kalender",
+    modulesCalendarsLabel: "Kalender",
+    modulesCalendarsDescription: "Modul zum Entdecken und Verwalten von Kalendern.",
+    typeCalendar: "KALENDER",
+    calendarFilterAll: "ALLE",
+    calendarFilterMine: "MEINE",
+    calendarFilterOpen: "OFFEN",
+    calendarFilterClosed: "GESCHLOSSEN",
+    calendarFilterRecent: "KÜRZLICH",
+    calendarFilterFavorites: "FAVORITEN",
+    calendarCreate: "Kalender erstellen",
+    calendarUpdate: "Aktualisieren",
+    calendarDelete: "Löschen",
+    calendarTitleLabel: "Titel",
+    calendarTitlePlaceholder: "Kalendertitel...",
+    calendarStatusLabel: "Status",
+    calendarStatusOpen: "OFFEN",
+    calendarStatusClosed: "GESCHLOSSEN",
+    calendarDeadlineLabel: "Deadline",
+    calendarTagsLabel: "Tags",
+    calendarTagsPlaceholder: "tag1, tag2...",
+    calendarParticipantsLabel: "Teilnehmer",
+    calendarParticipantsCount: "Teilnehmer",
+    calendarVisitCalendar: "Kalender besuchen",
+    calendarCreated: "Erstellt",
+    calendarAuthor: "Autor",
+    calendarJoin: "Beitreten",
+    calendarJoined: "Beigetreten",
+    calendarAddDate: "Datum hinzufügen",
+    calendarAddNote: "Notiz hinzufügen",
+    calendarDateLabel: "Datum",
+    calendarDatePlaceholder: "Dieses Datum beschreiben...",
+    calendarNoteLabel: "Notiz",
+    calendarNotePlaceholder: "Notiz hinzufügen...",
+    calendarFirstDateLabel: "Datum",
+    calendarFirstNoteLabel: "Notizen",
+    calendarIntervalLabel: "Interval",
+    calendarIntervalWeekly: "Weekly",
+    calendarIntervalMonthly: "Monthly",
+    calendarIntervalYearly: "Yearly",
+    calendarFormDescription: "Beschreibung",
+    calendarNoDates: "Noch keine Daten hinzugefügt.",
+    calendarNoNotes: "Keine Notizen.",
+    calendarsNoItems: "Keine Kalender gefunden.",
+    calendarsDescription: "Entdecke und verwalte Kalender in deinem Netzwerk.",
+    calendarMonthPrev: "← Zurück",
+    calendarMonthNext: "Weiter →",
+    calendarMonthLabel: "Daten",
+    calendarsShareUrl: "Teilen-URL",
+    calendarAllSectionTitle: "Alle Kalender",
+    calendarRecentSectionTitle: "Kürzliche Kalender",
+    calendarFavoritesSectionTitle: "Favoriten",
+    calendarMineSectionTitle: "Deine Kalender",
+    calendarOpenSectionTitle: "Offene Kalender",
+    calendarClosedSectionTitle: "Geschlossene Kalender",
+    calendarCreateSectionTitle: "Neuen Kalender erstellen",
+    calendarUpdateSectionTitle: "Kalender aktualisieren",
+    calendarAddFavorite: "Zu Favoriten hinzufügen",
+  calendarDeleteNote: "Delete",
+    calendarRemoveFavorite: "Aus Favoriten entfernen",
+    calendarSearchPlaceholder: "Kalender suchen...",
+    calendarAddEntry: "Eintrag hinzufügen",
+    calendarLeave: "Kalender verlassen",
+    statsCalendar: "Kalender",
+    statsCalendarDate: "Kalenderdaten",
+    statsCalendarNote: "Kalendernotizen"
     }
 }

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

@@ -639,6 +639,9 @@ module.exports = {
     favoritesFilterDocuments: "DOCUMENTS",
     favoritesFilterImages: "IMAGES",
     favoritesFilterMaps: "MAPS",
+    favoritesFilterPads: "PADS",
+    favoritesFilterChats: "CHATS",
+    favoritesFilterCalendars: "CALENDARS",
     favoritesFilterVideos: "VIDEOS",
     favoritesRemoveButton: "Remove from favorites",
     favoritesNoItems: "No favorites yet.",
@@ -1731,6 +1734,16 @@ module.exports = {
     tribeSectionVideos: "VIDEOS",
     tribeSectionDocuments: "DOCUMENTS",
     tribeSectionBookmarks: "BOOKMARKS",
+    tribeSectionMaps: "MAPS",
+    tribeSectionPads: "PADS",
+    tribeSectionChats: "CHATS",
+    tribeSectionCalendars: "CALENDARS",
+    tribePadCreate: "Create Pad",
+    tribeChatCreate: "Create Chat",
+    tribeCalendarCreate: "Create Calendar",
+    tribePadsEmpty: "No pads, yet.",
+    tribeChatsEmpty: "No chats, yet.",
+    tribeCalendarsEmpty: "No calendars, yet.",
     tribeInhabitantsEmpty: "No inhabitants in this tribe, yet.",
     tribeEventCreate: "Create Event",
     tribeEventsEmpty: "No events, yet.",
@@ -1896,6 +1909,7 @@ module.exports = {
     agendaFilterTransfers: "TRANSFERS",
     agendaFilterJobs: "JOBS",
     agendaFilterProjects: "PROJECTS",
+    agendaFilterCalendars: "CALENDARS",
     agendaNoItems: "No assignments found.",
     agendaAuthor: "By",
     agendaDiscardButton: "Discard",
@@ -2092,6 +2106,12 @@ module.exports = {
     bankViewTx: 'View Tx',
     bankClaimNow: 'Claim now',
     bankClaimUBI: 'Claim UBI!',
+    bankClaimAndPay: 'Claim & Pay',
+    bankClaimedPending: 'Claim pending...',
+    bankStatusUnclaimed: 'Unclaimed',
+    bankStatusClaimed: 'Claimed',
+    bankStatusExpired: 'Expired',
+    bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'No pending UBI allocations for this epoch.',
     bankPubBalance: 'PUB Balance',
     bankEpoch: 'Epoch',
@@ -2142,6 +2162,24 @@ module.exports = {
     bankRemoveMyAddress: 'Remove my address',
     bankNotRemovableOasis: 'Addresses cannot be removed locally',
     bankingFutureUBI: "UBI",
+    pubIdTitle: "PUB Wallet",
+    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdLabel: "PUB ID",
+    pubIdSave: "Save configuration",
+    pubIdPlaceholder: "@PUB_ID.ed25519",
+    bankUbiAvailability: "UBI Availability",
+    bankUbiAvailableOk: "OK",
+    bankUbiAvailableNo: "NO FUNDS!",
+    bankAlreadyClaimedThisMonth: "Already claimed this month",
+    bankUbiThisMonth: "UBI (this month)",
+    bankUbiLastClaimed: "UBI (last claimed)",
+    bankUbiNeverClaimed: "Never claimed",
+    bankUbiTotalClaimed: "UBI (total claimed)",
+    bankUbiPub: "PUB",
+    bankUbiInhabitant: "INHABITANT",
+    bankUbiClaimedAmount: "CLAIMED (ECO)",
+    typeBankUbiResult: "BANKING - UBI",
+    bankNoPubConfigured: "No PUB configured. Set your PUB ID in Settings.",
     shopsTitle: "Shops",
     shopDescription: "Discover and manage shops in the network.",
     shopTitle: "Shop",
@@ -2791,7 +2829,238 @@ module.exports = {
     typeMap: "MAPS",
     typeMapMarker: "MAP MARKER",
     modulesMapLabel: "Maps",
-    modulesMapDescription: "Module to manage and share offline maps."
+    modulesMapDescription: "Module to manage and share offline maps.",
+    padsTitle: "Pads",
+    padTitle: "Pad",
+    modulesPadsLabel: "Pads",
+    modulesPadsDescription: "Module to manage collaborative text editors.",
+    padFilterAll: "ALL",
+    padFilterMine: "MINE",
+    padFilterRecent: "RECENT",
+    padFilterOpen: "OPEN",
+    padFilterClosed: "CLOSED",
+    padCreate: "Create Pad",
+    padUpdate: "Update Pad",
+    padDelete: "Delete Pad",
+    padTitleLabel: "Title",
+    padTitlePlaceholder: "Enter pad title...",
+    padStatusLabel: "Status",
+    padStatusOpen: "OPEN",
+    padStatusInviteOnly: "INVITE-ONLY",
+    padStatusClosed: "CLOSED",
+    padDeadlineLabel: "Deadline",
+    padTagsLabel: "Tags",
+    padTagsPlaceholder: "tag1, tag2, ...",
+    padMembersLabel: "Members",
+    padVisitPad: "Visit Pad",
+    padShareUrl: "Share URL",
+    padCreated: "Created",
+    padAuthor: "Author",
+    padGenerateCode: "Generate Code",
+    padInviteCodeLabel: "Invite Code",
+    padInviteCodePlaceholder: "Enter invite code...",
+    padValidateInvite: "Validate",
+    padStartEditing: "START EDITING!",
+    padEditorPlaceholder: "Start writing...",
+    padSubmitEntry: "Submit",
+    padNoEntries: "No entries yet.",
+    padAllSectionTitle: "Pads",
+    padMineSectionTitle: "Your Pads",
+    padsNoItems: "No pads found.",
+    padsDescription: "Manage collaborative encrypted text editors in your network.",
+    padVersionHistory: "Version History",
+    padVersionView: "View",
+    padRecentSectionTitle: "Recent Pads",
+    padOpenSectionTitle: "Open Pads",
+    padClosedSectionTitle: "Closed Pads",
+    padCreateSectionTitle: "Create New Pad",
+    padUpdateSectionTitle: "Update Pad",
+    padInviteGenerated: "Invite Code Generated",
+    typePad: "PAD",
+    padNew: "NEW",
+    padAddFavorite: "Add to Favorites",
+    padRemoveFavorite: "Remove from Favorites",
+    padClose: "Close Pad",
+    padBackToEditor: "Back to editor",
+    padSearchPlaceholder: "Search pads...",
+
+    calendarsTitle: "Calendars",
+    calendarTitle: "Calendar",
+    modulesCalendarsLabel: "Calendars",
+    modulesCalendarsDescription: "Module to discover and manage calendars.",
+    typeCalendar: "CALENDAR",
+    calendarFilterAll: "ALL",
+    calendarFilterMine: "MINE",
+    calendarFilterOpen: "OPEN",
+    calendarFilterClosed: "CLOSED",
+    calendarFilterRecent: "RECENT",
+    calendarFilterFavorites: "FAVORITES",
+    calendarCreate: "Create Calendar",
+    calendarUpdate: "Update",
+    calendarDelete: "Delete",
+    calendarTitleLabel: "Title",
+    calendarTitlePlaceholder: "Calendar title...",
+    calendarStatusLabel: "Status",
+    calendarStatusOpen: "OPEN",
+    calendarStatusClosed: "CLOSED",
+    calendarDeadlineLabel: "Deadline",
+    calendarTagsLabel: "Tags",
+    calendarTagsPlaceholder: "tag1, tag2...",
+    calendarParticipantsLabel: "Participants",
+    calendarParticipantsCount: "Participants",
+    calendarVisitCalendar: "Visit Calendar",
+    calendarCreated: "Created",
+    calendarAuthor: "Author",
+    calendarJoin: "Join Calendar",
+    calendarJoined: "Joined",
+    calendarAddDate: "Add Date",
+    calendarAddNote: "Add Note",
+    calendarDateLabel: "Date",
+    calendarDatePlaceholder: "Describe this date...",
+    calendarNoteLabel: "Note",
+    calendarNotePlaceholder: "Add a note...",
+    calendarFirstDateLabel: "Date",
+    calendarFirstNoteLabel: "Notes",
+    calendarIntervalLabel: "Interval",
+    calendarIntervalWeekly: "Weekly",
+    calendarIntervalMonthly: "Monthly",
+    calendarIntervalYearly: "Yearly",
+    calendarFormDescription: "Description",
+    calendarNoDates: "No dates added yet.",
+    calendarNoNotes: "No notes.",
+    calendarsNoItems: "No calendars found.",
+    calendarsDescription: "Discover and manage calendars in your network.",
+    calendarMonthPrev: "\u2190 Prev",
+    calendarMonthNext: "Next \u2192",
+    calendarMonthLabel: "Dates",
+    calendarsShareUrl: "Share URL",
+    calendarAllSectionTitle: "All Calendars",
+    calendarRecentSectionTitle: "Recent Calendars",
+    calendarFavoritesSectionTitle: "Favorites",
+    calendarMineSectionTitle: "Your Calendars",
+    calendarOpenSectionTitle: "Open Calendars",
+    calendarClosedSectionTitle: "Closed Calendars",
+    calendarCreateSectionTitle: "Create New Calendar",
+    calendarUpdateSectionTitle: "Update Calendar",
+    calendarAddFavorite: "Add to Favorites",
+  calendarDeleteNote: "Delete",
+    calendarRemoveFavorite: "Remove from Favorites",
+    calendarSearchPlaceholder: "Search calendars...",
+    calendarAddEntry: "Add Entry",
+    calendarLeave: "Leave Calendar",
+    statsCalendar: "Calendars",
+    statsCalendarDate: "Calendar Dates",
+    statsCalendarNote: "Calendar Notes",
+
+    modulesChatsLabel: "Chats",
+    modulesChatsDescription: "Module to discover and manage encrypted chats.",
+    typeChat: "CHAT",
+    typeChatMessage: "CHAT MESSAGE",
+    chatLabel: "CHATS",
+    chatMessageLabel: "CHAT MESSAGES",
+    chatsTitle: "Chats",
+    chatMineSectionTitle: "Your Chats",
+    chatRecentTitle: "Recent Chats",
+    chatFavoritesTitle: "Favorites",
+    chatOpenTitle: "Open Chats",
+    chatClosedTitle: "Closed Chats",
+    chatDescription: "Description",
+    chatCategory: "Category",
+    chatStatus: "STATUS",
+    chatFilterAll: "ALL",
+    chatFilterMine: "MINE",
+    chatFilterRecent: "RECENT",
+    chatFilterFavorites: "FAVORITES",
+    chatFilterOpen: "OPEN",
+    chatFilterClosed: "CLOSED",
+    chatCreate: "Create Chat",
+    chatUpdate: "Update Chat",
+    chatDelete: "Delete Chat",
+    chatClose: "Close Chat",
+    chatVisitChat: "VISIT CHAT",
+    chatUntitled: "Untitled Chat",
+    chatNoItems: "No chats found.",
+    chatParticipants: "Participants",
+    chatStartChatting: "START CHATTING!",
+    chatGenerateCode: "Generate Code",
+    chatShareUrl: "Share URL",
+    chatCreatedAt: "CREATED",
+    chatSearchPlaceholder: "Search chats...",
+    chatStatusOpen: "OPEN",
+    chatStatusInviteOnly: "INVITE-ONLY",
+    chatStatusClosed: "CLOSED",
+    chatSendMessage: "Send",
+    chatMessagePlaceholder: "Type your message...",
+    chatNoMessages: "No messages yet.",
+    chatLeave: "Leave Chat",
+    chatInviteCodeLabel: "Enter invite code",
+    chatJoinByInvite: "Join",
+    chatTitlePlaceholder: "Chat title",
+    chatDescriptionPlaceholder: "Chat description",
+    chatTagsPlaceholder: "tag1, tag2, tag3",
+    chatImageLabel: "Select an image file (.jpeg, .jpg, .png, .gif)",
+    chatInviteCode: "Invite Code",
+    chatAuthor: "Author",
+    chatCreated: "Created",
+    chatAddFavorite: "Add to Favorites",
+    chatRemoveFavorite: "Remove from Favorites",
+    chatPM: "PM",
+    chatStatusLabel: "Status",
+    chatCategoryLabel: "Category",
+    chatParticipantsLabel: "Participants",
+    gamesTitle: "Games",
+    gamesDescription: "Discover and play some mini-games in your network.",
+    gamesFilterAll: "ALL",
+    gamesPlayButton: "PLAY!",
+    gamesBackToGames: "Back to Games",
+    modulesGamesLabel: "Games",
+    modulesGamesDescription: "Module to discover and play some games.",
+    gamesCocolandTitle: "Cocoland",
+    gamesCocolandDesc: "A coconut with eyes jumping over palm trees and collecting ECOins. How far can you go?",
+    gamesTheFlowTitle: "ECOinflow",
+    gamesTheFlowDesc: "Connect PUBs to habitants through validators, shops and accumulators. Survive the CBDC threat!",
+    gamesSpaceInvadersTitle: "Space Invaders",
+    gamesSpaceInvadersDesc: "Stop the alien invasion! Shoot down waves of invaders before they reach the ground.",
+    gamesArkanoidTitle: "Arkanoid",
+    gamesArkanoidDesc: "Break all the bricks with your paddle and ball. A classic arcade challenge.",
+    gamesPingPongTitle: "PingPong",
+    gamesPingPongDesc: "Classic ping-pong against an AI opponent. First to 5 points wins.",
+    gamesOutrunTitle: "Outrun",
+    gamesOutrunDesc: "Race against time! Dodge traffic and reach the finish line before the clock runs out.",
+    gamesAsteroidsTitle: "Asteroids",
+    gamesAsteroidsDesc: "Pilot your ship through a deadly asteroid field. Shoot them down before they hit you.",
+    gamesTikTakToeTitle: "TikTakToe",
+    gamesTikTakToeDesc: "Rock, Paper, Scissors against an AI. Best of three rounds wins.",
+    gamesFlipFlopTitle: "FlipFlop",
+    gamesFlipFlopDesc: "Flip a coin and bet on heads or tails. How lucky are you?",
+    games8BallTitle: "8Ball Pool",
+    games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.",
+    gamesArtilleryTitle: "Artillery",
+    gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.",
+    gamesLabyrinthTitle: "Labyrinth",
+    gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.",
+    gamesCocomanTitle: "Cocoman",
+    gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Ghosts move in real time — keep moving!",
+    gamesTetrisTitle: "Tetris",
+    gamesAudioPendulumTitle: "Audio Pendulum",
+    gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.",
+    gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?",
+    gamesQuakeTitle: "Quake Arena",
+    gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.",
+    gamesFilterScoring: "SCORING",
+    gamesHallOfFame: "Hall of Fame",
+    gamesHallPlayer: "Player",
+    gamesHallScore: "Score",
+    gamesNoScores: "No scores yet.",
+    gamesHallDate: "Date",
+    typeGameScore: "GAME SCORE",
+    gamesNewRecord: "New Record",
+    gameScoreLabel: "GAME SCORES",
+    statsChat: "Chats",
+    statsChatMessage: "Chat messages",
+    statsPad: "Pads",
+    statsPadEntry: "Pad entries",
+    statsGameScore: "Game scores"
 
     }
 };

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

@@ -631,6 +631,9 @@ module.exports = {
     favoritesFilterDocuments: "DOCUMENTOS",
     favoritesFilterImages: "IMÁGENES",
     favoritesFilterMaps: "MAPAS",
+    favoritesFilterPads: "PADS",
+    favoritesFilterChats: "CHATS",
+    favoritesFilterCalendars: "CALENDARIOS",
     favoritesFilterVideos: "VÍDEOS",
     favoritesRemoveButton: "Quitar de favoritos",
     favoritesNoItems: "Todavía no hay favoritos.",
@@ -1721,6 +1724,16 @@ module.exports = {
     tribeSectionVideos: "VÍDEOS",
     tribeSectionDocuments: "DOCUMENTOS",
     tribeSectionBookmarks: "MARCADORES",
+    tribeSectionMaps: "MAPS",
+    tribeSectionPads: "PADS",
+    tribeSectionChats: "CHATS",
+    tribeSectionCalendars: "CALENDARS",
+    tribePadCreate: "Create Pad",
+    tribeChatCreate: "Create Chat",
+    tribeCalendarCreate: "Create Calendar",
+    tribePadsEmpty: "No pads, yet.",
+    tribeChatsEmpty: "No chats, yet.",
+    tribeCalendarsEmpty: "No calendars, yet.",
     tribeInhabitantsEmpty: "No hay habitantes en esta tribu, aún.",
     tribeEventCreate: "Crear Evento",
     tribeEventsEmpty: "No hay eventos, aún.",
@@ -1886,6 +1899,7 @@ module.exports = {
     agendaFilterTransfers: "TRANSFERENCIAS",
     agendaFilterJobs: "TRABAJOS",
     agendaFilterProjects: "PROYECTOS",
+    agendaFilterCalendars: "CALENDARIOS",
     agendaNoItems: "No se encontraron asignaciones.",
     agendaDiscardButton: "Descartar",
     agendaRestoreButton: "Restaurar",
@@ -2090,6 +2104,12 @@ module.exports = {
     bankViewTx: 'Ver Tx',
     bankClaimNow: 'Reclamar',
     bankClaimUBI: '¡Reclamar RBU!',
+    bankClaimAndPay: 'Claim & Pay',
+    bankClaimedPending: 'Claim pending...',
+    bankStatusUnclaimed: 'Unclaimed',
+    bankStatusClaimed: 'Claimed',
+    bankStatusExpired: 'Expired',
+    bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'No hay asignaciones RBU pendientes para esta época.',
     bankPubBalance: 'Saldo del PUB',
     bankEpoch: 'Época',
@@ -2140,6 +2160,24 @@ module.exports = {
     bankRemoveMyAddress: 'Eliminar mi dirección',
     bankNotRemovableOasis: 'Las direcciones no se pueden eliminar localmente',
     bankingFutureUBI: "UBI",
+    pubIdTitle: "PUB Wallet",
+    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdLabel: "PUB ID",
+    pubIdSave: "Save configuration",
+    pubIdPlaceholder: "@PUB_ID.ed25519",
+    bankUbiAvailability: "Disponibilidad UBI",
+    bankUbiAvailableOk: "OK",
+    bankUbiAvailableNo: "SIN FONDOS!",
+    bankAlreadyClaimedThisMonth: "Ya reclamado este mes",
+    bankUbiThisMonth: "UBI (este mes)",
+    bankUbiLastClaimed: "UBI (última reclamada)",
+    bankUbiNeverClaimed: "Nunca reclamado",
+    bankUbiTotalClaimed: "UBI (total reclamado)",
+    bankUbiPub: "PUB",
+    bankUbiInhabitant: "HABITANTE",
+    bankUbiClaimedAmount: "RECLAMADO (ECO)",
+    typeBankUbiResult: "BANCARIO - UBI",
+    bankNoPubConfigured: "Sin PUB configurado. Establece tu ID de PUB en Ajustes.",
     shopsTitle: "Tiendas",
     shopDescription: "Descubre y gestiona tiendas en la red.",
     shopTitle: "Tienda",
@@ -2795,6 +2833,224 @@ module.exports = {
     typeMap: "MAPAS",
     typeMapMarker: "MARCADOR DE MAPA",
     modulesMapLabel: "Mapas",
-    modulesMapDescription: "Módulo para gestionar y compartir mapas offline."
+    modulesMapDescription: "Módulo para gestionar y compartir mapas offline.",
+    padsTitle: "Pads",
+    padTitle: "Pad",
+    modulesPadsLabel: "Pads",
+    modulesPadsDescription: "Módulo para gestionar editores de texto colaborativos.",
+    padFilterAll: "TODOS",
+    padFilterMine: "MÍO",
+    padFilterRecent: "RECIENTE",
+    padFilterOpen: "ABIERTO",
+    padFilterClosed: "CERRADO",
+    padCreate: "Crear Pad",
+    padUpdate: "Actualizar Pad",
+    padDelete: "Eliminar Pad",
+    padTitleLabel: "Título",
+    padTitlePlaceholder: "Escribe el título del pad...",
+    padStatusLabel: "Estado",
+    padStatusOpen: "ABIERTO",
+    padStatusInviteOnly: "SOLO INVITADOS",
+    padStatusClosed: "CERRADO",
+    padDeadlineLabel: "Fecha límite",
+    padTagsLabel: "Etiquetas",
+    padTagsPlaceholder: "etiqueta1, etiqueta2, ...",
+    padMembersLabel: "Miembros",
+    padVisitPad: "Visitar Pad",
+    padShareUrl: "Compartir URL",
+    padCreated: "Creado",
+    padAuthor: "Autor",
+    padGenerateCode: "Generar Código",
+    padInviteCodeLabel: "Código de Invitación",
+    padInviteCodePlaceholder: "Introduce el código de invitación...",
+    padValidateInvite: "Validar",
+    padStartEditing: "¡EMPEZAR A EDITAR!",
+    padEditorPlaceholder: "Empieza a escribir...",
+    padSubmitEntry: "Enviar",
+    padNoEntries: "Sin entradas aún.",
+    padAllSectionTitle: "Todos los Pads",
+    padMineSectionTitle: "Mis Pads",
+    padRecentSectionTitle: "Pads Recientes",
+    padOpenSectionTitle: "Pads Abiertos",
+    padClosedSectionTitle: "Pads Cerrados",
+    padCreateSectionTitle: "Crear Nuevo Pad",
+    padUpdateSectionTitle: "Actualizar Pad",
+    padInviteGenerated: "Código de Invitación Generado",
+    typePad: "PAD",
+    padNew: "NUEVO",
+    padAddFavorite: "Añadir a Favoritos",
+    padRemoveFavorite: "Quitar de Favoritos",
+    padClose: "Cerrar Pad",
+    padBackToEditor: "Volver al editor",
+    padSearchPlaceholder: "Buscar pads...",
+
+    calendarsTitle: "Calendarios",
+    calendarTitle: "Calendario",
+    modulesCalendarsLabel: "Calendarios",
+    modulesCalendarsDescription: "Módulo para descubrir y gestionar calendarios.",
+    typeCalendar: "CALENDARIO",
+    calendarFilterAll: "TODOS",
+    calendarFilterMine: "MÍOs",
+    calendarFilterOpen: "ABIERTOS",
+    calendarFilterClosed: "CERRADOS",
+    calendarFilterRecent: "RECIENTES",
+    calendarFilterFavorites: "FAVORITOS",
+    calendarCreate: "Crear Calendario",
+    calendarUpdate: "Actualizar",
+    calendarDelete: "Eliminar",
+    calendarTitleLabel: "Título",
+    calendarTitlePlaceholder: "Título del calendario...",
+    calendarStatusLabel: "Estado",
+    calendarStatusOpen: "ABIERTO",
+    calendarStatusClosed: "CERRADO",
+    calendarDeadlineLabel: "Deadline",
+    calendarTagsLabel: "Etiquetas",
+    calendarTagsPlaceholder: "etiqueta1, etiqueta2...",
+    calendarParticipantsLabel: "Participantes",
+    calendarParticipantsCount: "Participantes",
+    calendarVisitCalendar: "Ver Calendario",
+    calendarCreated: "Creado",
+    calendarAuthor: "Autor",
+    calendarJoin: "Unirse al Calendario",
+    calendarJoined: "Unido",
+    calendarAddDate: "Añadir Fecha",
+    calendarAddNote: "Añadir Nota",
+    calendarDateLabel: "Fecha",
+    calendarDatePlaceholder: "Describe esta fecha...",
+    calendarNoteLabel: "Nota",
+    calendarNotePlaceholder: "Añadir una nota...",
+    calendarFirstDateLabel: "Fecha",
+    calendarFirstNoteLabel: "Notas",
+    calendarIntervalLabel: "Interval",
+    calendarIntervalWeekly: "Weekly",
+    calendarIntervalMonthly: "Monthly",
+    calendarIntervalYearly: "Yearly",
+    calendarFormDescription: "Descripción",
+    calendarNoDates: "No hay fechas añadidas.",
+    calendarNoNotes: "Sin notas.",
+    calendarsNoItems: "No se encontraron calendarios.",
+    calendarsDescription: "Descubre y gestiona calendarios en tu red.",
+    calendarMonthPrev: "\u2190 Anterior",
+    calendarMonthNext: "Siguiente \u2192",
+    calendarMonthLabel: "Fechas",
+    calendarsShareUrl: "URL para compartir",
+    calendarAllSectionTitle: "Todos los Calendarios",
+    calendarRecentSectionTitle: "Calendarios Recientes",
+    calendarFavoritesSectionTitle: "Favoritos",
+    calendarMineSectionTitle: "Tus Calendarios",
+    calendarOpenSectionTitle: "Calendarios Abiertos",
+    calendarClosedSectionTitle: "Calendarios Cerrados",
+    calendarCreateSectionTitle: "Crear Nuevo Calendario",
+    calendarUpdateSectionTitle: "Actualizar Calendario",
+    calendarAddFavorite: "Añadir a Favoritos",
+  calendarDeleteNote: "Delete",
+    calendarRemoveFavorite: "Quitar de Favoritos",
+    calendarSearchPlaceholder: "Buscar calendarios...",
+    calendarAddEntry: "Añadir Entrada",
+    calendarLeave: "Salir del Calendario",
+    statsCalendar: "Calendarios",
+    statsCalendarDate: "Fechas de Calendario",
+    statsCalendarNote: "Notas de Calendario",
+
+    modulesChatsLabel: "Chats",
+    modulesChatsDescription: "Módulo para descubrir y gestionar chats cifrados.",
+    typeChat: "CHAT",
+    typeChatMessage: "MENSAJE DE CHAT",
+    chatLabel: "CHATS",
+    chatMessageLabel: "MENSAJES DE CHAT",
+    chatsTitle: "Chats",
+    chatMineSectionTitle: "Your Chats",
+    chatRecentTitle: "Recent Chats",
+    chatFavoritesTitle: "Favoritos",
+    chatOpenTitle: "Open Chats",
+    chatClosedTitle: "Closed Chats",
+    chatDescription: "Descripción",
+    chatCategory: "Categoría",
+    chatStatus: "ESTADO",
+    chatFilterAll: "TODOS",
+    chatFilterMine: "MÍO",
+    chatFilterRecent: "RECIENTE",
+    chatFilterFavorites: "FAVORITOS",
+    chatFilterOpen: "ABIERTO",
+    chatFilterClosed: "CERRADO",
+    chatCreate: "Crear Chat",
+    chatUpdate: "Actualizar Chat",
+    chatDelete: "Eliminar Chat",
+    chatClose: "Cerrar Chat",
+    chatVisitChat: "VER CHAT",
+    chatUntitled: "Chat sin título",
+    chatNoItems: "No se encontraron chats.",
+    chatParticipants: "Participantes",
+    chatStartChatting: "¡EMPEZAR A CHATEAR!",
+    chatGenerateCode: "Generar Código",
+    chatShareUrl: "Compartir URL",
+    chatCreatedAt: "CREADO",
+    chatSearchPlaceholder: "Buscar chats...",
+    chatStatusOpen: "ABIERTO",
+    chatStatusInviteOnly: "SOLO INVITADOS",
+    chatStatusClosed: "CERRADO",
+    chatSendMessage: "Enviar",
+    chatMessagePlaceholder: "Escribe tu mensaje...",
+    chatNoMessages: "Sin mensajes aún.",
+    chatLeave: "Salir del Chat",
+    chatInviteCodeLabel: "Introduce el código de invitación",
+    chatJoinByInvite: "Unirse",
+    chatTitlePlaceholder: "Título del chat",
+    chatDescriptionPlaceholder: "Descripción del chat",
+    chatTagsPlaceholder: "etiqueta1, etiqueta2",
+    chatImageLabel: "Selecciona un archivo de imagen (.jpeg, .jpg, .png, .gif)",
+    chatInviteCode: "Código de Invitación",
+    chatAuthor: "Autor",
+    chatCreated: "Creado",
+    chatAddFavorite: "Añadir a Favoritos",
+    chatRemoveFavorite: "Quitar de Favoritos",
+    chatPM: "MP",
+    chatStatusLabel: "Estado",
+    chatCategoryLabel: "Categoría",
+    chatParticipantsLabel: "Participantes",
+    gamesTitle: "Juegos",
+    gamesDescription: "Descubre y juega algunos juegos.",
+    gamesFilterAll: "TODOS",
+    gamesPlayButton: "¡JUGAR!",
+    gamesBackToGames: "Volver a Juegos",
+    modulesGamesLabel: "Juegos",
+    modulesGamesDescription: "Módulo para descubrir y jugar algunos juegos.",
+    gamesCocolandTitle: "Cocoland",
+    gamesCocolandDesc: "Un coco con ojos saltando palmeras y coleccionando ECOins. ¿Hasta dónde puedes llegar?",
+    gamesTheFlowTitle: "ECOinflow",
+    gamesTheFlowDesc: "Conecta PUBs a habitantes mediante validadores, tiendas y acumuladores. ¡Sobrevive a la amenaza CBDC!",
+    gamesSpaceInvadersTitle: "Space Invaders",
+    gamesSpaceInvadersDesc: "¡Detén la invasión alienígena! Destruye oleadas de invasores antes de que lleguen al suelo.",
+    gamesArkanoidTitle: "Arkanoid",
+    gamesArkanoidDesc: "Rompe todos los ladrillos con tu paleta y pelota. Un clásico desafío arcade.",
+    gamesPingPongTitle: "PingPong",
+    gamesPingPongDesc: "Ping-pong clásico contra una IA. El primero en llegar a 5 puntos gana.",
+    gamesOutrunTitle: "Outrun",
+    gamesOutrunDesc: "¡Corre contra el tiempo! Esquiva el tráfico y llega a la meta antes de que se acabe el tiempo.",
+    gamesAsteroidsTitle: "Asteroids",
+    gamesAsteroidsDesc: "Pilota tu nave por un campo de asteroides mortal. Dispárales antes de que te alcancen.",
+    gamesTikTakToeTitle: "TikTakToe",
+    gamesTikTakToeDesc: "Piedra, papel o tijera contra una IA. Gana el mejor de tres rondas.",
+    gamesFlipFlopTitle: "FlipFlop",
+    gamesFlipFlopDesc: "Lanza una moneda y apuesta cara o cruz. ¿Cuánta suerte tienes?",
+    games8BallTitle: "8Ball Pool",
+    games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.",
+    gamesArtilleryTitle: "Artillery",
+    gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.",
+    gamesLabyrinthTitle: "Labyrinth",
+    gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.",
+    gamesCocomanTitle: "Cocoman",
+    gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.",
+    gamesTetrisTitle: "Tetris",
+    gamesAudioPendulumTitle: "Audio Pendulum",
+    gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.",
+    gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?",
+    gamesQuakeTitle: "Quake Arena",
+    gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.",
+    gamesFilterScoring: "SCORING",
+    gamesHallOfFame: "Hall of Fame",
+    gamesHallPlayer: "Player",
+    gamesHallScore: "Score",
+    gamesNoScores: "No scores yet."
     }
 };

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

@@ -633,6 +633,9 @@ module.exports = {
     favoritesFilterDocuments: "DOKUMENTUAK",
     favoritesFilterImages: "IRUDIAK",
     favoritesFilterMaps: "MAPAK",
+    favoritesFilterPads: "PADS",
+    favoritesFilterChats: "TXATAK",
+    favoritesFilterCalendars: "EGUTEGIAK",
     favoritesFilterVideos: "BIDEOAK",
     favoritesRemoveButton: "Gogokoetatik kendu",
     favoritesNoItems: "Oraindik ez dago gogokorik.",
@@ -1688,6 +1691,16 @@ module.exports = {
     tribeSectionVideos: "BIDEOAK",
     tribeSectionDocuments: "DOKUMENTUAK",
     tribeSectionBookmarks: "LASTER-MARKAK",
+    tribeSectionMaps: "MAPS",
+    tribeSectionPads: "PADS",
+    tribeSectionChats: "CHATS",
+    tribeSectionCalendars: "CALENDARS",
+    tribePadCreate: "Create Pad",
+    tribeChatCreate: "Create Chat",
+    tribeCalendarCreate: "Create Calendar",
+    tribePadsEmpty: "No pads, yet.",
+    tribeChatsEmpty: "No chats, yet.",
+    tribeCalendarsEmpty: "No calendars, yet.",
     tribeInhabitantsEmpty: "Tribu honetan biztanlerik ez, oraindik.",
     tribeEventCreate: "Sortu Ekitaldia",
     tribeEventsEmpty: "Ekitaldirik ez, oraindik.",
@@ -1853,6 +1866,7 @@ module.exports = {
     agendaFilterTransfers: "TRANSFERENTZIAK",
     agendaFilterJobs: "LANPOSTUAK",
     agendaFilterProjects: "PROIEKTUAK",
+    agendaFilterCalendars: "EGUTEGIAK",
     agendaInviteModeLabel: "Egoera",
     agendaNoItems: "Esleipenik ez.",
     agendaDiscardButton: "Baztertu",
@@ -2057,6 +2071,12 @@ module.exports = {
     bankViewTx: 'Tx ikusi',
     bankClaimNow: 'Esleitu',
     bankClaimUBI: 'RBU eskatu!',
+    bankClaimAndPay: 'Claim & Pay',
+    bankClaimedPending: 'Claim pending...',
+    bankStatusUnclaimed: 'Unclaimed',
+    bankStatusClaimed: 'Claimed',
+    bankStatusExpired: 'Expired',
+    bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'Ez dago RBU esleipen zain garai honetan.',
     bankPubBalance: 'PUB saldoa',
     bankEpoch: 'Epea',
@@ -2108,6 +2128,24 @@ module.exports = {
     bankNotRemovableOasis: 'Oasis helbideak ezin dira lokalki kendu',
     bankingUserEngagementScore: "KARMA puntuazioa",
     bankingFutureUBI: "UBI",
+    pubIdTitle: "PUB Wallet",
+    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdLabel: "PUB ID",
+    pubIdSave: "Save configuration",
+    pubIdPlaceholder: "@PUB_ID.ed25519",
+    bankUbiAvailability: "UBI Erabilgarritasuna",
+    bankUbiAvailableOk: "OK",
+    bankUbiAvailableNo: "FUNTSIK EZ!",
+    bankAlreadyClaimedThisMonth: "Hilabete honetan jada aldarrikatu da",
+    bankUbiThisMonth: "UBI (hilabete honetan)",
+    bankUbiLastClaimed: "UBI (azken aldarrikapena)",
+    bankUbiNeverClaimed: "Inoiz aldarrikatu ez",
+    bankUbiTotalClaimed: "UBI (guztira aldarrikatua)",
+    bankUbiPub: "PUB",
+    bankUbiInhabitant: "BIZTANLEA",
+    bankUbiClaimedAmount: "ALDARRIKATUA (ECO)",
+    typeBankUbiResult: "BANKUA - UBI",
+    bankNoPubConfigured: "PUBik ez dago konfiguratuta. Ezarri zure PUB IDa Ezarpenetan.",
     shopsTitle: "Dendak",
     shopDescription: "Sareko dendak aurkitu eta kudeatu.",
     shopTitle: "Denda",
@@ -2756,6 +2794,223 @@ module.exports = {
     typeMap: "MAPAK",
     typeMapMarker: "MAPA MARKATZAILEA",
     modulesMapLabel: "Mapak",
-    modulesMapDescription: "Lineaz kanpoko mapak kudeatzeko eta partekatzeko modulua."
+    modulesMapDescription: "Lineaz kanpoko mapak kudeatzeko eta partekatzeko modulua.",
+    padsTitle: "Padak",
+    padTitle: "Pad",
+    modulesPadsLabel: "Padak",
+    modulesPadsDescription: "Testu editoreak lankidetzan kudeatzeko modulua.",
+    padFilterAll: "GUZTIAK",
+    padFilterMine: "NIREA",
+    padFilterRecent: "BERRIA",
+    padFilterOpen: "IREKIA",
+    padFilterClosed: "ITXIA",
+    padCreate: "Pad Sortu",
+    padUpdate: "Pad Eguneratu",
+    padDelete: "Pad Ezabatu",
+    padTitleLabel: "Izenburua",
+    padTitlePlaceholder: "Idatzi pad-aren izenburua...",
+    padStatusLabel: "Egoera",
+    padStatusOpen: "IREKIA",
+    padStatusInviteOnly: "GONBIDATUAK SOILIK",
+    padStatusClosed: "ITXIA",
+    padDeadlineLabel: "Epemuga",
+    padTagsLabel: "Etiketak",
+    padTagsPlaceholder: "etiketa1, etiketa2, ...",
+    padMembersLabel: "Kideak",
+    padVisitPad: "Pad Bisitatu",
+    padShareUrl: "URL Partekatu",
+    padCreated: "Sortua",
+    padAuthor: "Egilea",
+    padGenerateCode: "Kodea Sortu",
+    padInviteCodeLabel: "Gonbidapen Kodea",
+    padInviteCodePlaceholder: "Sartu gonbidapen kodea...",
+    padValidateInvite: "Balioztatu",
+    padStartEditing: "EDITZEN HASI!",
+    padEditorPlaceholder: "Idazten hasi...",
+    padSubmitEntry: "Bidali",
+    padNoEntries: "Oraindik sarrerarik ez.",
+    padAllSectionTitle: "Pad Guztiak",
+    padMineSectionTitle: "Nire Padak",
+    padRecentSectionTitle: "Azken Padak",
+    padOpenSectionTitle: "Pad Irekiak",
+    padClosedSectionTitle: "Pad Itxiak",
+    padCreateSectionTitle: "Pad Berria Sortu",
+    padUpdateSectionTitle: "Pad Eguneratu",
+    padInviteGenerated: "Gonbidapen Kodea Sortua",
+    typePad: "PAD",
+    padNew: "BERRIA",
+    padAddFavorite: "Gogokoen Gehitu",
+    padRemoveFavorite: "Gogokoetatik Kendu",
+    padClose: "Itxi Pad",
+    padBackToEditor: "Editorera itzuli",
+    padSearchPlaceholder: "Bilatu padak...",
+
+    modulesChatsLabel: "Txatak",
+    modulesChatsDescription: "Txat zifratu publikoak aurkitu eta kudeatzeko modulua.",
+    typeChat: "TXATA",
+    typeChatMessage: "TXAT MEZUA",
+    chatLabel: "TXATAK",
+    chatMessageLabel: "TXAT MEZUAK",
+    chatsTitle: "Txatak",
+    chatMineSectionTitle: "Your Chats",
+    chatRecentTitle: "Recent Chats",
+    chatFavoritesTitle: "Gogokoak",
+    chatOpenTitle: "Open Chats",
+    chatClosedTitle: "Closed Chats",
+    chatDescription: "Deskribapena",
+    chatCategory: "Kategoria",
+    chatStatus: "EGOERA",
+    chatFilterAll: "GUZTIAK",
+    chatFilterMine: "NIREA",
+    chatFilterRecent: "BERRIA",
+    chatFilterFavorites: "GOGOKOAK",
+    chatFilterOpen: "IREKIA",
+    chatFilterClosed: "ITXIA",
+    chatCreate: "Txata Sortu",
+    chatUpdate: "Txata Eguneratu",
+    chatDelete: "Txata Ezabatu",
+    chatClose: "Txata Itxi",
+    chatVisitChat: "TXATA IKUSI",
+    chatUntitled: "Izenburugabeko Txata",
+    chatNoItems: "Ez da txatarik aurkitu.",
+    chatParticipants: "Parte-hartzaileak",
+    chatStartChatting: "TXATEATZEN HASI!",
+    chatGenerateCode: "Kodea Sortu",
+    chatShareUrl: "URLa Partekatu",
+    chatCreatedAt: "SORTUA",
+    chatSearchPlaceholder: "Txatak bilatu...",
+    chatStatusOpen: "IREKIA",
+    chatStatusInviteOnly: "GONBIDAPENEZ BAKARRIK",
+    chatStatusClosed: "ITXIA",
+    chatSendMessage: "Bidali",
+    chatMessagePlaceholder: "Idatzi zure mezua...",
+    chatNoMessages: "Oraindik mezurik ez.",
+    chatLeave: "Txata Utzi",
+    chatInviteCodeLabel: "Gonbidapen kodea sartu",
+    chatJoinByInvite: "Sartu",
+    chatTitlePlaceholder: "Txataren izenburua",
+    chatDescriptionPlaceholder: "Txataren deskribapena",
+    chatTagsPlaceholder: "etiketa1, etiketa2",
+    chatImageLabel: "Aukeratu irudi-fitxategi bat (.jpeg, .jpg, .png, .gif)",
+    chatInviteCode: "Gonbidapen Kodea",
+    chatAuthor: "Egilea",
+    chatCreated: "Sortua",
+    chatAddFavorite: "Gogokoen artean gehitu",
+    chatRemoveFavorite: "Gogokoenetatik kendu",
+    chatPM: "MP",
+    chatStatusLabel: "Egoera",
+    chatCategoryLabel: "Kategoria",
+    chatParticipantsLabel: "Parte-hartzaileak",
+    gamesTitle: "Jokoak",
+    gamesDescription: "Aurkitu eta jokatu joko batzuk.",
+    gamesFilterAll: "GUZTIAK",
+    gamesPlayButton: "JOLASTU!",
+    gamesBackToGames: "Jokoetara itzuli",
+    modulesGamesLabel: "Jokoak",
+    modulesGamesDescription: "Jokoak aurkitzeko eta jolasteko modulua.",
+    gamesCocolandTitle: "Cocoland",
+    gamesCocolandDesc: "Begiak dituen koko bat palmondoen gainetik jauzika ECOins biltzen.",
+    gamesTheFlowTitle: "ECOinflow",
+    gamesTheFlowDesc: "Lotu PUBak biztanleekin baliozkotzaileen, denden eta metatzaileen bidez. Iraun CBDC mehatxuari!",
+    gamesSpaceInvadersTitle: "Space Invaders",
+    gamesSpaceInvadersDesc: "Gelditu inbasio extraterrestrea! Inbasore olatuak bota.",
+    gamesArkanoidTitle: "Arkanoid",
+    gamesArkanoidDesc: "Hautsi adreilu guztiak zure pala eta pilotarekin. Arcade klasiko bat.",
+    gamesPingPongTitle: "PingPong",
+    gamesPingPongDesc: "Ping-pong klasikoa IA baten aurka. Lehenak 5 puntu lortzen duena irabazten du.",
+    gamesOutrunTitle: "Outrun",
+    gamesOutrunDesc: "Denboraren aurka lasterketa! Saihestu trafikoa eta iritsi helmugara garaiz.",
+    gamesAsteroidsTitle: "Asteroids",
+    gamesAsteroidsDesc: "Gidatu zure ontzia asteroide eremuaren bidez. Tiro egin haiek jo aurretik.",
+    gamesTikTakToeTitle: "TikTakToe",
+    gamesTikTakToeDesc: "Harria, papera edo artaziak IA baten aurka. Hiru txandako onena irabazten du.",
+    gamesFlipFlopTitle: "FlipFlop",
+    gamesFlipFlopDesc: "Bota txanpon bat eta apostatu aurpegian edo buztanean. Zenbat zorte duzu?",
+    games8BallTitle: "8Ball Pool",
+    games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.",
+    gamesArtilleryTitle: "Artillery",
+    gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.",
+    gamesLabyrinthTitle: "Labyrinth",
+    gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.",
+    gamesCocomanTitle: "Cocoman",
+    gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.",
+    gamesTetrisTitle: "Tetris",
+    gamesAudioPendulumTitle: "Audio Pendulum",
+    gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.",
+    gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?",
+    gamesQuakeTitle: "Quake Arena",
+    gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.",
+    gamesFilterScoring: "SCORING",
+    gamesHallOfFame: "Hall of Fame",
+    gamesHallPlayer: "Player",
+    gamesHallScore: "Score",
+    gamesNoScores: "No scores yet.",
+    calendarsTitle: "Egutegia",
+    calendarTitle: "Egutegia",
+    modulesCalendarsLabel: "Egutegiak",
+    modulesCalendarsDescription: "Egutegiak aurkitu eta kudeatzeko modulua.",
+    typeCalendar: "EGUTEGIA",
+    calendarFilterAll: "GUZTIAK",
+    calendarFilterMine: "NIREAK",
+    calendarFilterOpen: "IREKIA",
+    calendarFilterClosed: "ITXIA",
+    calendarFilterRecent: "BERRIAK",
+    calendarFilterFavorites: "GOGOKOAK",
+    calendarCreate: "Egutegia sortu",
+    calendarUpdate: "Eguneratu",
+    calendarDelete: "Ezabatu",
+    calendarTitleLabel: "Izenburua",
+    calendarTitlePlaceholder: "Egutegiko izenburua...",
+    calendarStatusLabel: "Egoera",
+    calendarStatusOpen: "IREKIA",
+    calendarStatusClosed: "ITXIA",
+    calendarDeadlineLabel: "Deadline",
+    calendarTagsLabel: "Etiketak",
+    calendarTagsPlaceholder: "etiketa1, etiketa2...",
+    calendarParticipantsLabel: "Parte-hartzaileak",
+    calendarParticipantsCount: "Parte-hartzaileak",
+    calendarVisitCalendar: "Egutegia bisitatu",
+    calendarCreated: "Sortua",
+    calendarAuthor: "Egilea",
+    calendarJoin: "Batu",
+    calendarJoined: "Batuta",
+    calendarAddDate: "Data gehitu",
+    calendarAddNote: "Oharra gehitu",
+    calendarDateLabel: "Data",
+    calendarDatePlaceholder: "Data hau deskribatu...",
+    calendarNoteLabel: "Oharra",
+    calendarNotePlaceholder: "Oharra gehitu...",
+    calendarFirstDateLabel: "Data",
+    calendarFirstNoteLabel: "Oharrak",
+    calendarIntervalLabel: "Interval",
+    calendarIntervalWeekly: "Weekly",
+    calendarIntervalMonthly: "Monthly",
+    calendarIntervalYearly: "Yearly",
+    calendarFormDescription: "Deskribapena",
+    calendarNoDates: "Ez dago datarik.",
+    calendarNoNotes: "Ez dago oharrik.",
+    calendarsNoItems: "Ez da egutegiak aurkitu.",
+    calendarsDescription: "Aurkitu eta kudeatu zure sareko egutegiak.",
+    calendarMonthPrev: "← Aurrekoa",
+    calendarMonthNext: "Hurrengoa →",
+    calendarMonthLabel: "Datak",
+    calendarsShareUrl: "Partekatzeko URLa",
+    calendarAllSectionTitle: "Egutegi guztiak",
+    calendarRecentSectionTitle: "Egutegi Berriak",
+    calendarFavoritesSectionTitle: "Gogokoak",
+    calendarMineSectionTitle: "Zure Egutegiak",
+    calendarOpenSectionTitle: "Egutegi irekiak",
+    calendarClosedSectionTitle: "Egutegi itxiak",
+    calendarCreateSectionTitle: "Egutegi berria sortu",
+    calendarUpdateSectionTitle: "Egutegia eguneratu",
+    calendarAddFavorite: "Gogokoen gehitu",
+  calendarDeleteNote: "Delete",
+    calendarRemoveFavorite: "Gogokoetatik kendu",
+    calendarSearchPlaceholder: "Egutegiak bilatu...",
+    calendarAddEntry: "Gehitu Sarrera",
+    calendarLeave: "Irten Egutegitik",
+    statsCalendar: "Egutegiak",
+    statsCalendarDate: "Egutegi datak",
+    statsCalendarNote: "Egutegi oharrak"
   }
 };

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

@@ -631,6 +631,9 @@ module.exports = {
     favoritesFilterDocuments: "DOCUMENTS",
     favoritesFilterImages: "IMAGES",
     favoritesFilterMaps: "CARTES",
+    favoritesFilterPads: "PADS",
+    favoritesFilterChats: "CHATS",
+    favoritesFilterCalendars: "CALENDRIERS",
     favoritesFilterVideos: "VIDÉOS",
     favoritesRemoveButton: "Retirer des favoris",
     favoritesNoItems: "Aucun favori pour le moment.",
@@ -1713,6 +1716,16 @@ module.exports = {
     tribeSectionVideos: "VIDÉOS",
     tribeSectionDocuments: "DOCUMENTS",
     tribeSectionBookmarks: "SIGNETS",
+    tribeSectionMaps: "MAPS",
+    tribeSectionPads: "PADS",
+    tribeSectionChats: "CHATS",
+    tribeSectionCalendars: "CALENDARS",
+    tribePadCreate: "Create Pad",
+    tribeChatCreate: "Create Chat",
+    tribeCalendarCreate: "Create Calendar",
+    tribePadsEmpty: "No pads, yet.",
+    tribeChatsEmpty: "No chats, yet.",
+    tribeCalendarsEmpty: "No calendars, yet.",
     tribeInhabitantsEmpty: "Aucun habitant dans cette tribu pour l'instant.",
     tribeEventCreate: "Créer un événement",
     tribeEventsEmpty: "Aucun événement pour l'instant.",
@@ -1878,6 +1891,7 @@ module.exports = {
     agendaFilterTransfers: "TRANSFERTS",
     agendaFilterJobs: "EMPLOIS",
     agendaFilterProjects: "PROJETS",
+    agendaFilterCalendars: "CALENDRIERS",
     agendaNoItems: "Aucune affectation trouvée.",
     agendaDiscardButton: "Écarter",
     agendaRestoreButton: "Restaurer",
@@ -2082,6 +2096,12 @@ module.exports = {
     bankViewTx: 'Voir la Tx',
     bankClaimNow: 'Réclamer',
     bankClaimUBI: 'Réclamer RBU !',
+    bankClaimAndPay: 'Claim & Pay',
+    bankClaimedPending: 'Claim pending...',
+    bankStatusUnclaimed: 'Unclaimed',
+    bankStatusClaimed: 'Claimed',
+    bankStatusExpired: 'Expired',
+    bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'Aucune allocation RBU en attente pour cette époque.',
     bankPubBalance: 'Solde du PUB',
     bankEpoch: 'Époque',
@@ -2133,6 +2153,24 @@ module.exports = {
     bankNotRemovableOasis: 'Les adresses ne peuvent pas être supprimées localement',
     bankingUserEngagementScore: "Score KARMA",
     bankingFutureUBI: "UBI",
+    pubIdTitle: "PUB Wallet",
+    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdLabel: "PUB ID",
+    pubIdSave: "Save configuration",
+    pubIdPlaceholder: "@PUB_ID.ed25519",
+    bankUbiAvailability: "Disponibilité RBU",
+    bankUbiAvailableOk: "OK",
+    bankUbiAvailableNo: "PAS DE FONDS!",
+    bankAlreadyClaimedThisMonth: "Déjà réclamé ce mois-ci",
+    bankUbiThisMonth: "RBU (ce mois)",
+    bankUbiLastClaimed: "RBU (dernière réclamation)",
+    bankUbiNeverClaimed: "Jamais réclamé",
+    bankUbiTotalClaimed: "RBU (total réclamé)",
+    bankUbiPub: "PUB",
+    bankUbiInhabitant: "HABITANT",
+    bankUbiClaimedAmount: "RÉCLAMÉ (ECO)",
+    typeBankUbiResult: "BANCAIRE - RBU",
+    bankNoPubConfigured: "Aucun PUB configuré. Définissez votre ID PUB dans les Paramètres.",
     shopsTitle: "Boutiques",
     shopDescription: "Découvrez et gérez les boutiques du réseau.",
     shopTitle: "Boutique",
@@ -2784,6 +2822,224 @@ module.exports = {
     typeMap: "CARTES",
     typeMapMarker: "MARQUEUR DE CARTE",
     modulesMapLabel: "Cartes",
-    modulesMapDescription: "Module pour gérer et partager des cartes hors ligne."
+    modulesMapDescription: "Module pour gérer et partager des cartes hors ligne.",
+    padsTitle: "Pads",
+    padTitle: "Pad",
+    modulesPadsLabel: "Pads",
+    modulesPadsDescription: "Module pour gérer les éditeurs de texte collaboratifs.",
+    padFilterAll: "TOUS",
+    padFilterMine: "MES PADS",
+    padFilterRecent: "RÉCENT",
+    padFilterOpen: "OUVERT",
+    padFilterClosed: "FERMÉ",
+    padCreate: "Créer Pad",
+    padUpdate: "Modifier Pad",
+    padDelete: "Supprimer Pad",
+    padTitleLabel: "Titre",
+    padTitlePlaceholder: "Entrez le titre du pad...",
+    padStatusLabel: "Statut",
+    padStatusOpen: "OUVERT",
+    padStatusInviteOnly: "SUR INVITATION",
+    padStatusClosed: "FERMÉ",
+    padDeadlineLabel: "Date limite",
+    padTagsLabel: "Tags",
+    padTagsPlaceholder: "tag1, tag2, ...",
+    padMembersLabel: "Membres",
+    padVisitPad: "Visiter le Pad",
+    padShareUrl: "Partager URL",
+    padCreated: "Créé",
+    padAuthor: "Auteur",
+    padGenerateCode: "Générer un Code",
+    padInviteCodeLabel: "Code d'Invitation",
+    padInviteCodePlaceholder: "Entrez le code d'invitation...",
+    padValidateInvite: "Valider",
+    padStartEditing: "COMMENCER À ÉDITER !",
+    padEditorPlaceholder: "Commencez à écrire...",
+    padSubmitEntry: "Envoyer",
+    padNoEntries: "Aucune entrée pour l'instant.",
+    padAllSectionTitle: "Tous les Pads",
+    padMineSectionTitle: "Mes Pads",
+    padRecentSectionTitle: "Pads Récents",
+    padOpenSectionTitle: "Pads Ouverts",
+    padClosedSectionTitle: "Pads Fermés",
+    padCreateSectionTitle: "Créer un Nouveau Pad",
+    padUpdateSectionTitle: "Modifier le Pad",
+    padInviteGenerated: "Code d'Invitation Généré",
+    typePad: "PAD",
+    padNew: "NOUVEAU",
+    padAddFavorite: "Ajouter aux Favoris",
+    padRemoveFavorite: "Retirer des Favoris",
+    padClose: "Fermer le pad",
+    padBackToEditor: "Retour à l'éditeur",
+    padSearchPlaceholder: "Rechercher des pads...",
+
+    modulesChatsLabel: "Chats",
+    modulesChatsDescription: "Module pour découvrir et gérer les chats chiffrés.",
+    typeChat: "CHAT",
+    typeChatMessage: "MESSAGE CHAT",
+    chatLabel: "CHATS",
+    chatMessageLabel: "MESSAGES CHAT",
+    chatsTitle: "Chats",
+    chatMineSectionTitle: "Your Chats",
+    chatRecentTitle: "Recent Chats",
+    chatFavoritesTitle: "Favoris",
+    chatOpenTitle: "Open Chats",
+    chatClosedTitle: "Closed Chats",
+    chatDescription: "Description",
+    chatCategory: "Catégorie",
+    chatStatus: "STATUT",
+    chatFilterAll: "TOUS",
+    chatFilterMine: "MIEN",
+    chatFilterRecent: "RÉCENT",
+    chatFilterFavorites: "FAVORIS",
+    chatFilterOpen: "OUVERT",
+    chatFilterClosed: "FERMÉ",
+    chatCreate: "Créer Chat",
+    chatUpdate: "Modifier Chat",
+    chatDelete: "Supprimer Chat",
+    chatClose: "Fermer Chat",
+    chatVisitChat: "VOIR CHAT",
+    chatUntitled: "Chat sans titre",
+    chatNoItems: "Aucun chat trouvé.",
+    chatParticipants: "Participants",
+    chatStartChatting: "COMMENCER À CHATTER!",
+    chatGenerateCode: "Générer Code",
+    chatShareUrl: "Partager URL",
+    chatCreatedAt: "CRÉÉ",
+    chatSearchPlaceholder: "Rechercher chats...",
+    chatStatusOpen: "OUVERT",
+    chatStatusInviteOnly: "SUR INVITATION",
+    chatStatusClosed: "FERMÉ",
+    chatSendMessage: "Envoyer",
+    chatMessagePlaceholder: "Tapez votre message...",
+    chatNoMessages: "Pas encore de messages.",
+    chatLeave: "Quitter Chat",
+    chatInviteCodeLabel: "Entrez le code d'invitation",
+    chatJoinByInvite: "Rejoindre",
+    chatTitlePlaceholder: "Titre du chat",
+    chatDescriptionPlaceholder: "Description du chat",
+    chatTagsPlaceholder: "tag1, tag2, tag3",
+    chatImageLabel: "Sélectionnez un fichier image (.jpeg, .jpg, .png, .gif)",
+    chatInviteCode: "Code d'Invitation",
+    chatAuthor: "Auteur",
+    chatCreated: "Créé",
+    chatAddFavorite: "Ajouter aux Favoris",
+    chatRemoveFavorite: "Retirer des Favoris",
+    chatPM: "MP",
+    chatStatusLabel: "Statut",
+    chatCategoryLabel: "Catégorie",
+    chatParticipantsLabel: "Participants",
+    gamesTitle: "Jeux",
+    gamesDescription: "Découvrez et jouez à des jeux.",
+    gamesFilterAll: "TOUS",
+    gamesPlayButton: "JOUER!",
+    gamesBackToGames: "Retour aux Jeux",
+    modulesGamesLabel: "Jeux",
+    modulesGamesDescription: "Module pour découvrir et jouer à des jeux.",
+    gamesCocolandTitle: "Cocoland",
+    gamesCocolandDesc: "Une noix de coco avec des yeux qui saute par-dessus des palmiers en collectant des ECOins.",
+    gamesTheFlowTitle: "ECOinflow",
+    gamesTheFlowDesc: "Connectez les PUBs aux habitants via validateurs, boutiques et accumulateurs. Résistez à la menace CBDC !",
+    gamesSpaceInvadersTitle: "Space Invaders",
+    gamesSpaceInvadersDesc: "Arrêtez l'invasion extraterrestre! Abattez les vagues d'envahisseurs.",
+    gamesArkanoidTitle: "Arkanoid",
+    gamesArkanoidDesc: "Cassez toutes les briques avec votre raquette et votre balle. Un défi arcade classique.",
+    gamesPingPongTitle: "PingPong",
+    gamesPingPongDesc: "Ping-pong classique contre une IA. Le premier à 5 points gagne.",
+    gamesOutrunTitle: "Outrun",
+    gamesOutrunDesc: "Course contre la montre! Évitez les obstacles et atteignez l'arrivée à temps.",
+    gamesAsteroidsTitle: "Asteroids",
+    gamesAsteroidsDesc: "Pilotez votre vaisseau dans un champ d'astéroïdes. Détruisez-les avant qu'ils ne vous touchent.",
+    gamesTikTakToeTitle: "TikTakToe",
+    gamesTikTakToeDesc: "Pierre, papier, ciseaux contre une IA. Le meilleur des trois manches gagne.",
+    gamesFlipFlopTitle: "FlipFlop",
+    gamesFlipFlopDesc: "Lancez une pièce et pariez sur pile ou face. Quelle est votre chance?",
+    games8BallTitle: "8Ball Pool",
+    games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.",
+    gamesArtilleryTitle: "Artillery",
+    gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.",
+    gamesLabyrinthTitle: "Labyrinth",
+    gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.",
+    gamesCocomanTitle: "Cocoman",
+    gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.",
+    gamesTetrisTitle: "Tetris",
+    gamesAudioPendulumTitle: "Audio Pendulum",
+    gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.",
+    gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?",
+    gamesQuakeTitle: "Quake Arena",
+    gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.",
+    gamesFilterScoring: "SCORING",
+    gamesHallOfFame: "Hall of Fame",
+    gamesHallPlayer: "Player",
+    gamesHallScore: "Score",
+    gamesNoScores: "No scores yet.",
+    calendarsTitle: "Calendriers",
+    calendarTitle: "Calendrier",
+    modulesCalendarsLabel: "Calendriers",
+    modulesCalendarsDescription: "Module pour découvrir et gérer les calendriers.",
+    typeCalendar: "CALENDRIER",
+    calendarFilterAll: "TOUS",
+    calendarFilterMine: "LES MIENS",
+    calendarFilterOpen: "OUVERT",
+    calendarFilterClosed: "FERMÉ",
+    calendarFilterRecent: "RÉCENTS",
+    calendarFilterFavorites: "FAVORIS",
+    calendarCreate: "Créer un calendrier",
+    calendarUpdate: "Mettre à jour",
+    calendarDelete: "Supprimer",
+    calendarTitleLabel: "Titre",
+    calendarTitlePlaceholder: "Titre du calendrier...",
+    calendarStatusLabel: "Statut",
+    calendarStatusOpen: "OUVERT",
+    calendarStatusClosed: "FERMÉ",
+    calendarDeadlineLabel: "Deadline",
+    calendarTagsLabel: "Étiquettes",
+    calendarTagsPlaceholder: "tag1, tag2...",
+    calendarParticipantsLabel: "Participants",
+    calendarParticipantsCount: "Participants",
+    calendarVisitCalendar: "Visiter le calendrier",
+    calendarCreated: "Créé",
+    calendarAuthor: "Auteur",
+    calendarJoin: "Rejoindre",
+    calendarJoined: "Rejoint",
+    calendarAddDate: "Ajouter une date",
+    calendarAddNote: "Ajouter une note",
+    calendarDateLabel: "Date",
+    calendarDatePlaceholder: "Décrivez cette date...",
+    calendarNoteLabel: "Note",
+    calendarNotePlaceholder: "Ajouter une note...",
+    calendarFirstDateLabel: "Date",
+    calendarFirstNoteLabel: "Notes",
+    calendarIntervalLabel: "Interval",
+    calendarIntervalWeekly: "Weekly",
+    calendarIntervalMonthly: "Monthly",
+    calendarIntervalYearly: "Yearly",
+    calendarFormDescription: "Description",
+    calendarNoDates: "Aucune date ajoutée.",
+    calendarNoNotes: "Aucune note.",
+    calendarsNoItems: "Aucun calendrier trouvé.",
+    calendarsDescription: "Découvrez et gérez les calendriers de votre réseau.",
+    calendarMonthPrev: "← Précédent",
+    calendarMonthNext: "Suivant →",
+    calendarMonthLabel: "Dates",
+    calendarsShareUrl: "URL de partage",
+    calendarAllSectionTitle: "Tous les calendriers",
+    calendarRecentSectionTitle: "Calendriers Récents",
+    calendarFavoritesSectionTitle: "Favoris",
+    calendarMineSectionTitle: "Vos Calendriers",
+    calendarOpenSectionTitle: "Calendriers ouverts",
+    calendarClosedSectionTitle: "Calendriers fermés",
+    calendarCreateSectionTitle: "Créer un calendrier",
+    calendarUpdateSectionTitle: "Mettre à jour le calendrier",
+    calendarAddFavorite: "Ajouter aux favoris",
+  calendarDeleteNote: "Delete",
+    calendarRemoveFavorite: "Retirer des favoris",
+    calendarSearchPlaceholder: "Rechercher des calendriers...",
+    calendarAddEntry: "Ajouter une entrée",
+    calendarLeave: "Quitter le calendrier",
+    statsCalendar: "Calendriers",
+    statsCalendarDate: "Dates de calendrier",
+    statsCalendarNote: "Notes de calendrier"
     }
 };
+// calendar keys added

+ 256 - 1
src/client/assets/translations/oasis_hi.js

@@ -639,6 +639,9 @@ module.exports = {
     favoritesFilterDocuments: "दस्तावेज़",
     favoritesFilterImages: "छवियाँ",
     favoritesFilterMaps: "मानचित्र",
+    favoritesFilterPads: "पैड",
+    favoritesFilterChats: "चैट्स",
+    favoritesFilterCalendars: "कैलेंडर",
     favoritesFilterVideos: "वीडियो",
     favoritesRemoveButton: "पसंदीदा से हटाएँ",
     favoritesNoItems: "अभी तक कोई पसंदीदा नहीं।",
@@ -1726,6 +1729,16 @@ module.exports = {
     tribeSectionVideos: "वीडियो",
     tribeSectionDocuments: "दस्तावेज़",
     tribeSectionBookmarks: "बुकमार्क",
+    tribeSectionMaps: "MAPS",
+    tribeSectionPads: "PADS",
+    tribeSectionChats: "CHATS",
+    tribeSectionCalendars: "CALENDARS",
+    tribePadCreate: "Create Pad",
+    tribeChatCreate: "Create Chat",
+    tribeCalendarCreate: "Create Calendar",
+    tribePadsEmpty: "No pads, yet.",
+    tribeChatsEmpty: "No chats, yet.",
+    tribeCalendarsEmpty: "No calendars, yet.",
     tribeInhabitantsEmpty: "इस जनजाति में अभी तक कोई निवासी नहीं।",
     tribeEventCreate: "कार्यक्रम बनाएँ",
     tribeEventsEmpty: "अभी तक कोई कार्यक्रम नहीं।",
@@ -1891,6 +1904,7 @@ module.exports = {
     agendaFilterTransfers: "स्थानांतरण",
     agendaFilterJobs: "नौकरियाँ",
     agendaFilterProjects: "परियोजनाएँ",
+    agendaFilterCalendars: "कैलेंडर",
     agendaNoItems: "कोई सौंपे गए आइटम नहीं मिले।",
     agendaAuthor: "द्वारा",
     agendaDiscardButton: "निरस्त करें",
@@ -2087,6 +2101,12 @@ module.exports = {
     bankViewTx: 'Tx देखें',
     bankClaimNow: 'अभी दावा करें',
     bankClaimUBI: 'UBI का दावा करें!',
+    bankClaimAndPay: 'Claim & Pay',
+    bankClaimedPending: 'Claim pending...',
+    bankStatusUnclaimed: 'Unclaimed',
+    bankStatusClaimed: 'Claimed',
+    bankStatusExpired: 'Expired',
+    bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'इस अवधि के लिए कोई लंबित UBI आवंटन नहीं।',
     bankPubBalance: 'PUB शेष',
     bankEpoch: 'युग',
@@ -2137,6 +2157,24 @@ module.exports = {
     bankRemoveMyAddress: 'मेरा पता हटाएँ',
     bankNotRemovableOasis: 'पते स्थानीय रूप से नहीं हटाए जा सकते',
     bankingFutureUBI: "UBI",
+    pubIdTitle: "PUB Wallet",
+    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdLabel: "PUB ID",
+    pubIdSave: "Save configuration",
+    pubIdPlaceholder: "@PUB_ID.ed25519",
+    bankUbiAvailability: "UBI उपलब्धता",
+    bankUbiAvailableOk: "OK",
+    bankUbiAvailableNo: "कोई धन नहीं!",
+    bankAlreadyClaimedThisMonth: "इस महीने पहले ही दावा किया गया",
+    bankUbiThisMonth: "UBI (इस महीने)",
+    bankUbiLastClaimed: "UBI (अंतिम दावा)",
+    bankUbiNeverClaimed: "कभी दावा नहीं किया",
+    bankUbiTotalClaimed: "UBI (कुल दावा)",
+    bankUbiPub: "PUB",
+    bankUbiInhabitant: "निवासी",
+    bankUbiClaimedAmount: "दावा (ECO)",
+    typeBankUbiResult: "बैंकिंग - UBI",
+    bankNoPubConfigured: "कोई PUB कॉन्फ़िगर नहीं है। सेटिंग में अपना PUB ID सेट करें।",
     shopsTitle: "दुकानें",
     shopDescription: "नेटवर्क में दुकानें खोजें और प्रबंधित करें।",
     shopTitle: "दुकान",
@@ -2786,6 +2824,223 @@ module.exports = {
     typeMap: "मानचित्र",
     typeMapMarker: "मानचित्र मार्कर",
     modulesMapLabel: "मानचित्र",
-    modulesMapDescription: "ऑफ़लाइन मानचित्रों को प्रबंधित और साझा करने का मॉड्यूल।"
+    modulesMapDescription: "ऑफ़लाइन मानचित्रों को प्रबंधित और साझा करने का मॉड्यूल।",
+    padsTitle: "पैड्स",
+    padTitle: "पैड",
+    modulesPadsLabel: "पैड्स",
+    modulesPadsDescription: "सहयोगी पाठ संपादकों को प्रबंधित करने का मॉड्यूल।",
+    padFilterAll: "सभी",
+    padFilterMine: "मेरे",
+    padFilterRecent: "हालिया",
+    padFilterOpen: "खुला",
+    padFilterClosed: "बंद",
+    padCreate: "पैड बनाएं",
+    padUpdate: "पैड अपडेट करें",
+    padDelete: "पैड हटाएं",
+    padTitleLabel: "शीर्षक",
+    padTitlePlaceholder: "पैड का शीर्षक दर्ज करें...",
+    padStatusLabel: "स्थिति",
+    padStatusOpen: "खुला",
+    padStatusInviteOnly: "केवल आमंत्रण",
+    padStatusClosed: "बंद",
+    padDeadlineLabel: "समय सीमा",
+    padTagsLabel: "टैग",
+    padTagsPlaceholder: "टैग1, टैग2, ...",
+    padMembersLabel: "सदस्य",
+    padVisitPad: "पैड देखें",
+    padShareUrl: "URL साझा करें",
+    padCreated: "बनाया गया",
+    padAuthor: "लेखक",
+    padGenerateCode: "कोड बनाएं",
+    padInviteCodeLabel: "आमंत्रण कोड",
+    padInviteCodePlaceholder: "आमंत्रण कोड दर्ज करें...",
+    padValidateInvite: "सत्यापित करें",
+    padStartEditing: "संपादन शुरू करें!",
+    padEditorPlaceholder: "लिखना शुरू करें...",
+    padSubmitEntry: "सबमिट करें",
+    padNoEntries: "अभी कोई प्रविष्टि नहीं।",
+    padAllSectionTitle: "सभी पैड्स",
+    padMineSectionTitle: "मेरे पैड्स",
+    padRecentSectionTitle: "हालिया पैड्स",
+    padOpenSectionTitle: "खुले पैड्स",
+    padClosedSectionTitle: "बंद पैड्स",
+    padCreateSectionTitle: "नया पैड बनाएं",
+    padUpdateSectionTitle: "पैड अपडेट करें",
+    padInviteGenerated: "आमंत्रण कोड बनाया गया",
+    typePad: "पैड",
+    padNew: "नया",
+    padAddFavorite: "पसंदीदा में जोड़ें",
+    padRemoveFavorite: "पसंदीदा से हटाएं",
+    padClose: "पैड बंद करें",
+    padBackToEditor: "संपादक पर वापस",
+    padSearchPlaceholder: "पैड खोजें...",
+
+    modulesChatsLabel: "चैट्स",
+    modulesChatsDescription: "एन्क्रिप्टेड चैट खोजने और प्रबंधित करने का मॉड्यूल।",
+    typeChat: "चैट",
+    typeChatMessage: "चैट संदेश",
+    chatLabel: "चैट्स",
+    chatMessageLabel: "चैट संदेश",
+    chatsTitle: "चैट्स",
+    chatMineSectionTitle: "Your Chats",
+    chatRecentTitle: "Recent Chats",
+    chatFavoritesTitle: "पसंदीदा",
+    chatOpenTitle: "Open Chats",
+    chatClosedTitle: "Closed Chats",
+    chatDescription: "विवरण",
+    chatCategory: "श्रेणी",
+    chatStatus: "स्थिति",
+    chatFilterAll: "सभी",
+    chatFilterMine: "मेरे",
+    chatFilterRecent: "हाल के",
+    chatFilterFavorites: "पसंदीदा",
+    chatFilterOpen: "खुले",
+    chatFilterClosed: "बंद",
+    chatCreate: "चैट बनाएं",
+    chatUpdate: "चैट अपडेट करें",
+    chatDelete: "चैट हटाएं",
+    chatClose: "चैट बंद करें",
+    chatVisitChat: "चैट देखें",
+    chatUntitled: "बिना शीर्षक चैट",
+    chatNoItems: "कोई चैट नहीं मिली।",
+    chatParticipants: "प्रतिभागी",
+    chatStartChatting: "चैट शुरू करें!",
+    chatGenerateCode: "कोड बनाएं",
+    chatShareUrl: "URL शेयर करें",
+    chatCreatedAt: "बनाया गया",
+    chatSearchPlaceholder: "चैट खोजें...",
+    chatStatusOpen: "खुला",
+    chatStatusInviteOnly: "केवल आमंत्रण",
+    chatStatusClosed: "बंद",
+    chatSendMessage: "भेजें",
+    chatMessagePlaceholder: "संदेश लिखें...",
+    chatNoMessages: "अभी तक कोई संदेश नहीं।",
+    chatLeave: "चैट छोड़ें",
+    chatInviteCodeLabel: "आमंत्रण कोड दर्ज करें",
+    chatJoinByInvite: "शामिल हों",
+    chatTitlePlaceholder: "चैट का शीर्षक",
+    chatDescriptionPlaceholder: "चैट का विवरण",
+    chatTagsPlaceholder: "टैग1, टैग2",
+    chatImageLabel: "छवि फ़ाइल चुनें (.jpeg, .jpg, .png, .gif)",
+    chatInviteCode: "आमंत्रण कोड",
+    chatAuthor: "लेखक",
+    chatCreated: "बनाया",
+    chatAddFavorite: "पसंदीदा में जोड़ें",
+    chatRemoveFavorite: "पसंदीदा से हटाएं",
+    chatPM: "PM",
+    chatStatusLabel: "स्थिति",
+    chatCategoryLabel: "श्रेणी",
+    chatParticipantsLabel: "प्रतिभागी",
+    gamesTitle: "खेल",
+    gamesDescription: "कुछ खेल खोजें और खेलें।",
+    gamesFilterAll: "सभी",
+    gamesPlayButton: "खेलें!",
+    gamesBackToGames: "खेलों पर वापस जाएं",
+    modulesGamesLabel: "खेल",
+    modulesGamesDescription: "कुछ खेल खोजने और खेलने का मॉड्यूल।",
+    gamesCocolandTitle: "Cocoland",
+    gamesCocolandDesc: "आंखों वाला एक नारियल ताड़ के पेड़ों के ऊपर कूदता है और ECOins इकट्ठा करता है।",
+    gamesTheFlowTitle: "ECOinflow",
+    gamesTheFlowDesc: "PUBs को validators, shops और accumulators के ज़रिए habitants से जोड़ें। CBDC के खतरे से बचें!",
+    gamesSpaceInvadersTitle: "Space Invaders",
+    gamesSpaceInvadersDesc: "एलियन आक्रमण रोकें! आक्रमणकारियों की लहरों को मार गिराएं।",
+    gamesArkanoidTitle: "Arkanoid",
+    gamesArkanoidDesc: "अपने पैडल और गेंद से सभी ईंटें तोड़ें। एक क्लासिक आर्केड चुनौती।",
+    gamesPingPongTitle: "PingPong",
+    gamesPingPongDesc: "AI के खिलाफ क्लासिक पिंग-पोंग। 5 पॉइंट पहले पाने वाला जीतता है।",
+    gamesOutrunTitle: "Outrun",
+    gamesOutrunDesc: "समय के खिलाफ दौड़! यातायात से बचें और समय से पहले मंजिल पर पहुंचें।",
+    gamesAsteroidsTitle: "Asteroids",
+    gamesAsteroidsDesc: "क्षुद्रग्रह क्षेत्र में अपना यान उड़ाएं। उन्हें आने से पहले नष्ट करें।",
+    gamesTikTakToeTitle: "TikTakToe",
+    gamesTikTakToeDesc: "AI के खिलाफ पत्थर, कागज या कैंची। तीन में से दो जीतने वाला विजेता।",
+    gamesFlipFlopTitle: "FlipFlop",
+    gamesFlipFlopDesc: "एक सिक्का उछालें और हेड या टेल पर दांव लगाएं। आप कितने भाग्यशाली हैं?",
+    games8BallTitle: "8Ball Pool",
+    games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.",
+    gamesArtilleryTitle: "Artillery",
+    gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.",
+    gamesLabyrinthTitle: "Labyrinth",
+    gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.",
+    gamesCocomanTitle: "Cocoman",
+    gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.",
+    gamesTetrisTitle: "Tetris",
+    gamesAudioPendulumTitle: "Audio Pendulum",
+    gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.",
+    gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?",
+    gamesQuakeTitle: "Quake Arena",
+    gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.",
+    gamesFilterScoring: "SCORING",
+    gamesHallOfFame: "Hall of Fame",
+    gamesHallPlayer: "Player",
+    gamesHallScore: "Score",
+    gamesNoScores: "No scores yet.",
+    calendarsTitle: "कैलेंडर",
+    calendarTitle: "कैलेंडर",
+    modulesCalendarsLabel: "कैलेंडर",
+    modulesCalendarsDescription: "कैलेंडर खोजने और प्रबंधित करने का मॉड्यूल।",
+    typeCalendar: "कैलेंडर",
+    calendarFilterAll: "सभी",
+    calendarFilterMine: "मेरे",
+    calendarFilterOpen: "खुला",
+    calendarFilterClosed: "बंद",
+    calendarFilterRecent: "हाल के",
+    calendarFilterFavorites: "पसंदीदा",
+    calendarCreate: "कैलेंडर बनाएं",
+    calendarUpdate: "अपडेट करें",
+    calendarDelete: "हटाएं",
+    calendarTitleLabel: "शीर्षक",
+    calendarTitlePlaceholder: "कैलेंडर शीर्षक...",
+    calendarStatusLabel: "स्थिति",
+    calendarStatusOpen: "खुला",
+    calendarStatusClosed: "बंद",
+    calendarDeadlineLabel: "Deadline",
+    calendarTagsLabel: "टैग",
+    calendarTagsPlaceholder: "टैग1, टैग2...",
+    calendarParticipantsLabel: "प्रतिभागी",
+    calendarParticipantsCount: "प्रतिभागी",
+    calendarVisitCalendar: "कैलेंडर देखें",
+    calendarCreated: "बनाया गया",
+    calendarAuthor: "लेखक",
+    calendarJoin: "जुड़ें",
+    calendarJoined: "जुड़े हुए",
+    calendarAddDate: "तारीख जोड़ें",
+    calendarAddNote: "नोट जोड़ें",
+    calendarDateLabel: "तारीख",
+    calendarDatePlaceholder: "इस तारीख का वर्णन करें...",
+    calendarNoteLabel: "नोट",
+    calendarNotePlaceholder: "नोट जोड़ें...",
+    calendarFirstDateLabel: "तारीख",
+    calendarFirstNoteLabel: "नोट्स",
+    calendarIntervalLabel: "Interval",
+    calendarIntervalWeekly: "Weekly",
+    calendarIntervalMonthly: "Monthly",
+    calendarIntervalYearly: "Yearly",
+    calendarFormDescription: "विवरण",
+    calendarNoDates: "कोई तारीख नहीं जोड़ी गई।",
+    calendarNoNotes: "कोई नोट नहीं।",
+    calendarsNoItems: "कोई कैलेंडर नहीं मिला।",
+    calendarsDescription: "अपने नेटवर्क में कैलेंडर खोजें और प्रबंधित करें।",
+    calendarMonthPrev: "← पिछला",
+    calendarMonthNext: "अगला →",
+    calendarMonthLabel: "तारीखें",
+    calendarsShareUrl: "शेयर URL",
+    calendarAllSectionTitle: "सभी कैलेंडर",
+    calendarRecentSectionTitle: "हाल के कैलेंडर",
+    calendarFavoritesSectionTitle: "पसंदीदा",
+    calendarMineSectionTitle: "आपके कैलेंडर",
+    calendarOpenSectionTitle: "खुले कैलेंडर",
+    calendarClosedSectionTitle: "बंद कैलेंडर",
+    calendarCreateSectionTitle: "नया कैलेंडर बनाएं",
+    calendarUpdateSectionTitle: "कैलेंडर अपडेट करें",
+    calendarAddFavorite: "पसंदीदा में जोड़ें",
+  calendarDeleteNote: "Delete",
+    calendarRemoveFavorite: "पसंदीदा से हटाएं",
+    calendarSearchPlaceholder: "कैलेंडर खोजें...",
+    calendarAddEntry: "प्रविष्टि जोड़ें",
+    calendarLeave: "कैलेंडर छोड़ें",
+    statsCalendar: "कैलेंडर",
+    statsCalendarDate: "कैलेंडर तारीखें",
+    statsCalendarNote: "कैलेंडर नोट"
     }
 };

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

@@ -639,6 +639,9 @@ module.exports = {
     favoritesFilterDocuments: "DOCUMENTI",
     favoritesFilterImages: "IMMAGINI",
     favoritesFilterMaps: "MAPPE",
+    favoritesFilterPads: "PADS",
+    favoritesFilterChats: "CHAT",
+    favoritesFilterCalendars: "CALENDARI",
     favoritesFilterVideos: "VIDEO",
     favoritesRemoveButton: "Rimuovi dai preferiti",
     favoritesNoItems: "Nessun preferito ancora.",
@@ -1726,6 +1729,16 @@ module.exports = {
     tribeSectionVideos: "VIDEO",
     tribeSectionDocuments: "DOCUMENTI",
     tribeSectionBookmarks: "SEGNALIBRI",
+    tribeSectionMaps: "MAPS",
+    tribeSectionPads: "PADS",
+    tribeSectionChats: "CHATS",
+    tribeSectionCalendars: "CALENDARS",
+    tribePadCreate: "Create Pad",
+    tribeChatCreate: "Create Chat",
+    tribeCalendarCreate: "Create Calendar",
+    tribePadsEmpty: "No pads, yet.",
+    tribeChatsEmpty: "No chats, yet.",
+    tribeCalendarsEmpty: "No calendars, yet.",
     tribeInhabitantsEmpty: "Nessun abitante in questa tribù.",
     tribeEventCreate: "Crea evento",
     tribeEventsEmpty: "Nessun evento.",
@@ -1891,6 +1904,7 @@ module.exports = {
     agendaFilterTransfers: "TRASFERIMENTI",
     agendaFilterJobs: "LAVORI",
     agendaFilterProjects: "PROGETTI",
+    agendaFilterCalendars: "CALENDARI",
     agendaNoItems: "Nessun incarico trovato.",
     agendaAuthor: "Di",
     agendaDiscardButton: "Scarta",
@@ -2087,6 +2101,12 @@ module.exports = {
     bankViewTx: 'Vedi Tx',
     bankClaimNow: 'Riscuoti ora',
     bankClaimUBI: 'Richiedi RBU!',
+    bankClaimAndPay: 'Claim & Pay',
+    bankClaimedPending: 'Claim pending...',
+    bankStatusUnclaimed: 'Unclaimed',
+    bankStatusClaimed: 'Claimed',
+    bankStatusExpired: 'Expired',
+    bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'Nessuna assegnazione RBU in sospeso per questa epoca.',
     bankPubBalance: 'Saldo PUB',
     bankEpoch: 'Epoca',
@@ -2137,6 +2157,24 @@ module.exports = {
     bankRemoveMyAddress: 'Rimuovi il mio indirizzo',
     bankNotRemovableOasis: 'Gli indirizzi non possono essere rimossi localmente',
     bankingFutureUBI: "UBI",
+    pubIdTitle: "PUB Wallet",
+    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdLabel: "PUB ID",
+    pubIdSave: "Save configuration",
+    pubIdPlaceholder: "@PUB_ID.ed25519",
+    bankUbiAvailability: "Disponibilità RBU",
+    bankUbiAvailableOk: "OK",
+    bankUbiAvailableNo: "NESSUN FONDO!",
+    bankAlreadyClaimedThisMonth: "Già richiesto questo mese",
+    bankUbiThisMonth: "RBU (questo mese)",
+    bankUbiLastClaimed: "RBU (ultima richiesta)",
+    bankUbiNeverClaimed: "Mai richiesto",
+    bankUbiTotalClaimed: "RBU (totale richiesto)",
+    bankUbiPub: "PUB",
+    bankUbiInhabitant: "ABITANTE",
+    bankUbiClaimedAmount: "RICHIESTO (ECO)",
+    typeBankUbiResult: "BANCARIO - RBU",
+    bankNoPubConfigured: "Nessun PUB configurato. Imposta il tuo ID PUB nelle Impostazioni.",
     shopsTitle: "Negozi",
     shopDescription: "Scopri e gestisci negozi nella rete.",
     shopTitle: "Negozio",
@@ -2787,6 +2825,223 @@ module.exports = {
     typeMap: "MAPPE",
     typeMapMarker: "MARCATORE MAPPA",
     modulesMapLabel: "Mappe",
-    modulesMapDescription: "Modulo per gestire e condividere mappe offline."
+    modulesMapDescription: "Modulo per gestire e condividere mappe offline.",
+    padsTitle: "Pad",
+    padTitle: "Pad",
+    modulesPadsLabel: "Pad",
+    modulesPadsDescription: "Modulo per gestire editor di testo collaborativi.",
+    padFilterAll: "TUTTI",
+    padFilterMine: "MIO",
+    padFilterRecent: "RECENTE",
+    padFilterOpen: "APERTO",
+    padFilterClosed: "CHIUSO",
+    padCreate: "Crea Pad",
+    padUpdate: "Aggiorna Pad",
+    padDelete: "Elimina Pad",
+    padTitleLabel: "Titolo",
+    padTitlePlaceholder: "Inserisci il titolo del pad...",
+    padStatusLabel: "Stato",
+    padStatusOpen: "APERTO",
+    padStatusInviteOnly: "SOLO SU INVITO",
+    padStatusClosed: "CHIUSO",
+    padDeadlineLabel: "Scadenza",
+    padTagsLabel: "Tag",
+    padTagsPlaceholder: "tag1, tag2, ...",
+    padMembersLabel: "Membri",
+    padVisitPad: "Visita Pad",
+    padShareUrl: "Condividi URL",
+    padCreated: "Creato",
+    padAuthor: "Autore",
+    padGenerateCode: "Genera Codice",
+    padInviteCodeLabel: "Codice Invito",
+    padInviteCodePlaceholder: "Inserisci il codice invito...",
+    padValidateInvite: "Valida",
+    padStartEditing: "INIZIA A MODIFICARE!",
+    padEditorPlaceholder: "Inizia a scrivere...",
+    padSubmitEntry: "Invia",
+    padNoEntries: "Nessuna voce ancora.",
+    padAllSectionTitle: "Tutti i Pad",
+    padMineSectionTitle: "I Miei Pad",
+    padRecentSectionTitle: "Pad Recenti",
+    padOpenSectionTitle: "Pad Aperti",
+    padClosedSectionTitle: "Pad Chiusi",
+    padCreateSectionTitle: "Crea Nuovo Pad",
+    padUpdateSectionTitle: "Aggiorna Pad",
+    padInviteGenerated: "Codice Invito Generato",
+    typePad: "PAD",
+    padNew: "NUOVO",
+    padAddFavorite: "Aggiungi ai Preferiti",
+    padRemoveFavorite: "Rimuovi dai Preferiti",
+    padClose: "Chiudi Pad",
+    padBackToEditor: "Torna all'editor",
+    padSearchPlaceholder: "Cerca pad...",
+
+    modulesChatsLabel: "Chat",
+    modulesChatsDescription: "Modulo per scoprire e gestire chat cifrate.",
+    typeChat: "CHAT",
+    typeChatMessage: "MESSAGGIO CHAT",
+    chatLabel: "CHAT",
+    chatMessageLabel: "MESSAGGI CHAT",
+    chatsTitle: "Chat",
+    chatMineSectionTitle: "Your Chats",
+    chatRecentTitle: "Recent Chats",
+    chatFavoritesTitle: "Preferiti",
+    chatOpenTitle: "Open Chats",
+    chatClosedTitle: "Closed Chats",
+    chatDescription: "Descrizione",
+    chatCategory: "Categoria",
+    chatStatus: "STATO",
+    chatFilterAll: "TUTTI",
+    chatFilterMine: "MIO",
+    chatFilterRecent: "RECENTE",
+    chatFilterFavorites: "PREFERITI",
+    chatFilterOpen: "APERTO",
+    chatFilterClosed: "CHIUSO",
+    chatCreate: "Crea Chat",
+    chatUpdate: "Aggiorna Chat",
+    chatDelete: "Elimina Chat",
+    chatClose: "Chiudi Chat",
+    chatVisitChat: "VISITA CHAT",
+    chatUntitled: "Chat senza titolo",
+    chatNoItems: "Nessuna chat trovata.",
+    chatParticipants: "Partecipanti",
+    chatStartChatting: "INIZIA A CHATTARE!",
+    chatGenerateCode: "Genera Codice",
+    chatShareUrl: "Condividi URL",
+    chatCreatedAt: "CREATO",
+    chatSearchPlaceholder: "Cerca chat...",
+    chatStatusOpen: "APERTO",
+    chatStatusInviteOnly: "SOLO SU INVITO",
+    chatStatusClosed: "CHIUSO",
+    chatSendMessage: "Invia",
+    chatMessagePlaceholder: "Scrivi il tuo messaggio...",
+    chatNoMessages: "Ancora nessun messaggio.",
+    chatLeave: "Abbandona Chat",
+    chatInviteCodeLabel: "Inserisci codice invito",
+    chatJoinByInvite: "Unisciti",
+    chatTitlePlaceholder: "Titolo chat",
+    chatDescriptionPlaceholder: "Descrizione chat",
+    chatTagsPlaceholder: "tag1, tag2, tag3",
+    chatImageLabel: "Seleziona un file immagine (.jpeg, .jpg, .png, .gif)",
+    chatInviteCode: "Codice Invito",
+    chatAuthor: "Autore",
+    chatCreated: "Creato",
+    chatAddFavorite: "Aggiungi ai Preferiti",
+    chatRemoveFavorite: "Rimuovi dai Preferiti",
+    chatPM: "MP",
+    chatStatusLabel: "Stato",
+    chatCategoryLabel: "Categoria",
+    chatParticipantsLabel: "Partecipanti",
+    gamesTitle: "Giochi",
+    gamesDescription: "Scopri e gioca ad alcuni giochi.",
+    gamesFilterAll: "TUTTI",
+    gamesPlayButton: "GIOCA!",
+    gamesBackToGames: "Torna ai Giochi",
+    modulesGamesLabel: "Giochi",
+    modulesGamesDescription: "Modulo per scoprire e giocare ad alcuni giochi.",
+    gamesCocolandTitle: "Cocoland",
+    gamesCocolandDesc: "Una noce di cocco con occhi che salta palme e raccoglie ECOins.",
+    gamesTheFlowTitle: "ECOinflow",
+    gamesTheFlowDesc: "Collega i PUB agli abitanti tramite validatori, negozi e accumulatori. Sopravvivi alla minaccia CBDC!",
+    gamesSpaceInvadersTitle: "Space Invaders",
+    gamesSpaceInvadersDesc: "Fermate l'invasione aliena! Abbattete le ondate di invasori.",
+    gamesArkanoidTitle: "Arkanoid",
+    gamesArkanoidDesc: "Rompi tutti i mattoni con la tua racchetta e la pallina. Una sfida arcade classica.",
+    gamesPingPongTitle: "PingPong",
+    gamesPingPongDesc: "Ping-pong classico contro un'IA. Il primo a 5 punti vince.",
+    gamesOutrunTitle: "Outrun",
+    gamesOutrunDesc: "Corsa contro il tempo! Evita il traffico e raggiungi il traguardo prima che scada.",
+    gamesAsteroidsTitle: "Asteroids",
+    gamesAsteroidsDesc: "Pilota la tua navicella in un campo di asteroidi. Sparali prima che ti colpiscano.",
+    gamesTikTakToeTitle: "TikTakToe",
+    gamesTikTakToeDesc: "Carta, forbici, sasso contro una IA. Il migliore dei tre round vince.",
+    gamesFlipFlopTitle: "FlipFlop",
+    gamesFlipFlopDesc: "Lancia una moneta e scommetti su testa o croce. Quanta fortuna hai?",
+    games8BallTitle: "8Ball Pool",
+    games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.",
+    gamesArtilleryTitle: "Artillery",
+    gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.",
+    gamesLabyrinthTitle: "Labyrinth",
+    gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.",
+    gamesCocomanTitle: "Cocoman",
+    gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.",
+    gamesTetrisTitle: "Tetris",
+    gamesAudioPendulumTitle: "Audio Pendulum",
+    gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.",
+    gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?",
+    gamesQuakeTitle: "Quake Arena",
+    gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.",
+    gamesFilterScoring: "SCORING",
+    gamesHallOfFame: "Hall of Fame",
+    gamesHallPlayer: "Player",
+    gamesHallScore: "Score",
+    gamesNoScores: "No scores yet.",
+    calendarsTitle: "Calendari",
+    calendarTitle: "Calendario",
+    modulesCalendarsLabel: "Calendari",
+    modulesCalendarsDescription: "Modulo per scoprire e gestire i calendari.",
+    typeCalendar: "CALENDARIO",
+    calendarFilterAll: "TUTTI",
+    calendarFilterMine: "I MIEI",
+    calendarFilterOpen: "APERTO",
+    calendarFilterClosed: "CHIUSO",
+    calendarFilterRecent: "RECENTI",
+    calendarFilterFavorites: "PREFERITI",
+    calendarCreate: "Crea calendario",
+    calendarUpdate: "Aggiorna",
+    calendarDelete: "Elimina",
+    calendarTitleLabel: "Titolo",
+    calendarTitlePlaceholder: "Titolo del calendario...",
+    calendarStatusLabel: "Stato",
+    calendarStatusOpen: "APERTO",
+    calendarStatusClosed: "CHIUSO",
+    calendarDeadlineLabel: "Deadline",
+    calendarTagsLabel: "Tag",
+    calendarTagsPlaceholder: "tag1, tag2...",
+    calendarParticipantsLabel: "Partecipanti",
+    calendarParticipantsCount: "Partecipanti",
+    calendarVisitCalendar: "Visita il calendario",
+    calendarCreated: "Creato",
+    calendarAuthor: "Autore",
+    calendarJoin: "Partecipa",
+    calendarJoined: "Iscritto",
+    calendarAddDate: "Aggiungi data",
+    calendarAddNote: "Aggiungi nota",
+    calendarDateLabel: "Data",
+    calendarDatePlaceholder: "Descrivi questa data...",
+    calendarNoteLabel: "Nota",
+    calendarNotePlaceholder: "Aggiungi una nota...",
+    calendarFirstDateLabel: "Data",
+    calendarFirstNoteLabel: "Note",
+    calendarIntervalLabel: "Interval",
+    calendarIntervalWeekly: "Weekly",
+    calendarIntervalMonthly: "Monthly",
+    calendarIntervalYearly: "Yearly",
+    calendarFormDescription: "Descrizione",
+    calendarNoDates: "Nessuna data aggiunta.",
+    calendarNoNotes: "Nessuna nota.",
+    calendarsNoItems: "Nessun calendario trovato.",
+    calendarsDescription: "Scopri e gestisci i calendari nella tua rete.",
+    calendarMonthPrev: "← Precedente",
+    calendarMonthNext: "Successivo →",
+    calendarMonthLabel: "Date",
+    calendarsShareUrl: "URL di condivisione",
+    calendarAllSectionTitle: "Tutti i calendari",
+    calendarRecentSectionTitle: "Calendari Recenti",
+    calendarFavoritesSectionTitle: "Preferiti",
+    calendarMineSectionTitle: "I Tuoi Calendari",
+    calendarOpenSectionTitle: "Calendari aperti",
+    calendarClosedSectionTitle: "Calendari chiusi",
+    calendarCreateSectionTitle: "Crea nuovo calendario",
+    calendarUpdateSectionTitle: "Aggiorna calendario",
+    calendarAddFavorite: "Aggiungi ai preferiti",
+  calendarDeleteNote: "Delete",
+    calendarRemoveFavorite: "Rimuovi dai preferiti",
+    calendarSearchPlaceholder: "Cerca calendari...",
+    calendarAddEntry: "Aggiungi Voce",
+    calendarLeave: "Lascia Calendario",
+    statsCalendar: "Calendari",
+    statsCalendarDate: "Date calendario",
+    statsCalendarNote: "Note calendario"
     }
 };

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

@@ -639,6 +639,9 @@ module.exports = {
     favoritesFilterDocuments: "DOCUMENTOS",
     favoritesFilterImages: "IMAGENS",
     favoritesFilterMaps: "MAPAS",
+    favoritesFilterPads: "PADS",
+    favoritesFilterChats: "CHATS",
+    favoritesFilterCalendars: "CALENDÁRIOS",
     favoritesFilterVideos: "VÍDEOS",
     favoritesRemoveButton: "Remover dos favoritos",
     favoritesNoItems: "Ainda sem favoritos.",
@@ -1726,6 +1729,16 @@ module.exports = {
     tribeSectionVideos: "VÍDEOS",
     tribeSectionDocuments: "DOCUMENTOS",
     tribeSectionBookmarks: "MARCADORES",
+    tribeSectionMaps: "MAPS",
+    tribeSectionPads: "PADS",
+    tribeSectionChats: "CHATS",
+    tribeSectionCalendars: "CALENDARS",
+    tribePadCreate: "Create Pad",
+    tribeChatCreate: "Create Chat",
+    tribeCalendarCreate: "Create Calendar",
+    tribePadsEmpty: "No pads, yet.",
+    tribeChatsEmpty: "No chats, yet.",
+    tribeCalendarsEmpty: "No calendars, yet.",
     tribeInhabitantsEmpty: "Ainda sem habitantes nesta tribo.",
     tribeEventCreate: "Criar evento",
     tribeEventsEmpty: "Ainda sem eventos.",
@@ -1891,6 +1904,7 @@ module.exports = {
     agendaFilterTransfers: "TRANSFERÊNCIAS",
     agendaFilterJobs: "EMPREGOS",
     agendaFilterProjects: "PROJETOS",
+    agendaFilterCalendars: "CALENDÁRIOS",
     agendaNoItems: "Sem atribuições encontradas.",
     agendaAuthor: "Por",
     agendaDiscardButton: "Descartar",
@@ -2087,6 +2101,12 @@ module.exports = {
     bankViewTx: 'View Tx',
     bankClaimNow: 'Claim now',
     bankClaimUBI: 'Reivindicar RBU!',
+    bankClaimAndPay: 'Claim & Pay',
+    bankClaimedPending: 'Claim pending...',
+    bankStatusUnclaimed: 'Unclaimed',
+    bankStatusClaimed: 'Claimed',
+    bankStatusExpired: 'Expired',
+    bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'Nenhuma alocação RBU pendente para esta época.',
     bankPubBalance: 'PUB Balance',
     bankEpoch: 'Epoch',
@@ -2137,6 +2157,24 @@ module.exports = {
     bankRemoveMyAddress: 'Remove my address',
     bankNotRemovableOasis: 'Addresses cannot be removed locally',
     bankingFutureUBI: "UBI",
+    pubIdTitle: "PUB Wallet",
+    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdLabel: "PUB ID",
+    pubIdSave: "Save configuration",
+    pubIdPlaceholder: "@PUB_ID.ed25519",
+    bankUbiAvailability: "Disponibilidade RBU",
+    bankUbiAvailableOk: "OK",
+    bankUbiAvailableNo: "SEM FUNDOS!",
+    bankAlreadyClaimedThisMonth: "Já reclamado este mês",
+    bankUbiThisMonth: "RBU (este mês)",
+    bankUbiLastClaimed: "RBU (última reclamada)",
+    bankUbiNeverClaimed: "Nunca reclamado",
+    bankUbiTotalClaimed: "RBU (total reclamado)",
+    bankUbiPub: "PUB",
+    bankUbiInhabitant: "HABITANTE",
+    bankUbiClaimedAmount: "RECLAMADO (ECO)",
+    typeBankUbiResult: "BANCÁRIO - RBU",
+    bankNoPubConfigured: "Nenhum PUB configurado. Define o teu ID PUB nas Definições.",
     shopsTitle: "Lojas",
     shopDescription: "Descubra e gerencie lojas na rede.",
     shopTitle: "Loja",
@@ -2787,6 +2825,223 @@ module.exports = {
     typeMap: "MAPAS",
     typeMapMarker: "MARCADOR DE MAPA",
     modulesMapLabel: "Mapas",
-    modulesMapDescription: "Módulo para gerenciar e compartilhar mapas offline."
+    modulesMapDescription: "Módulo para gerenciar e compartilhar mapas offline.",
+    padsTitle: "Pads",
+    padTitle: "Pad",
+    modulesPadsLabel: "Pads",
+    modulesPadsDescription: "Módulo para gerir editores de texto colaborativos.",
+    padFilterAll: "TODOS",
+    padFilterMine: "MEU",
+    padFilterRecent: "RECENTE",
+    padFilterOpen: "ABERTO",
+    padFilterClosed: "FECHADO",
+    padCreate: "Criar Pad",
+    padUpdate: "Atualizar Pad",
+    padDelete: "Eliminar Pad",
+    padTitleLabel: "Título",
+    padTitlePlaceholder: "Escreva o título do pad...",
+    padStatusLabel: "Estado",
+    padStatusOpen: "ABERTO",
+    padStatusInviteOnly: "APENAS POR CONVITE",
+    padStatusClosed: "FECHADO",
+    padDeadlineLabel: "Prazo",
+    padTagsLabel: "Tags",
+    padTagsPlaceholder: "tag1, tag2, ...",
+    padMembersLabel: "Membros",
+    padVisitPad: "Visitar Pad",
+    padShareUrl: "Partilhar URL",
+    padCreated: "Criado",
+    padAuthor: "Autor",
+    padGenerateCode: "Gerar Código",
+    padInviteCodeLabel: "Código de Convite",
+    padInviteCodePlaceholder: "Introduza o código de convite...",
+    padValidateInvite: "Validar",
+    padStartEditing: "COMEÇAR A EDITAR!",
+    padEditorPlaceholder: "Comece a escrever...",
+    padSubmitEntry: "Enviar",
+    padNoEntries: "Sem entradas ainda.",
+    padAllSectionTitle: "Todos os Pads",
+    padMineSectionTitle: "Os Meus Pads",
+    padRecentSectionTitle: "Pads Recentes",
+    padOpenSectionTitle: "Pads Abertos",
+    padClosedSectionTitle: "Pads Fechados",
+    padCreateSectionTitle: "Criar Novo Pad",
+    padUpdateSectionTitle: "Atualizar Pad",
+    padInviteGenerated: "Código de Convite Gerado",
+    typePad: "PAD",
+    padNew: "NOVO",
+    padAddFavorite: "Adicionar aos Favoritos",
+    padRemoveFavorite: "Remover dos Favoritos",
+    padClose: "Fechar Pad",
+    padBackToEditor: "Voltar ao editor",
+    padSearchPlaceholder: "Pesquisar pads...",
+
+    modulesChatsLabel: "Chats",
+    modulesChatsDescription: "Módulo para descobrir e gerir chats cifrados.",
+    typeChat: "CHAT",
+    typeChatMessage: "MENSAGEM CHAT",
+    chatLabel: "CHATS",
+    chatMessageLabel: "MENSAGENS CHAT",
+    chatsTitle: "Chats",
+    chatMineSectionTitle: "Your Chats",
+    chatRecentTitle: "Recent Chats",
+    chatFavoritesTitle: "Favoritos",
+    chatOpenTitle: "Open Chats",
+    chatClosedTitle: "Closed Chats",
+    chatDescription: "Descrição",
+    chatCategory: "Categoria",
+    chatStatus: "ESTADO",
+    chatFilterAll: "TODOS",
+    chatFilterMine: "MEU",
+    chatFilterRecent: "RECENTE",
+    chatFilterFavorites: "FAVORITOS",
+    chatFilterOpen: "ABERTO",
+    chatFilterClosed: "FECHADO",
+    chatCreate: "Criar Chat",
+    chatUpdate: "Atualizar Chat",
+    chatDelete: "Eliminar Chat",
+    chatClose: "Fechar Chat",
+    chatVisitChat: "VER CHAT",
+    chatUntitled: "Chat sem título",
+    chatNoItems: "Nenhum chat encontrado.",
+    chatParticipants: "Participantes",
+    chatStartChatting: "COMEÇAR A CONVERSAR!",
+    chatGenerateCode: "Gerar Código",
+    chatShareUrl: "Partilhar URL",
+    chatCreatedAt: "CRIADO",
+    chatSearchPlaceholder: "Pesquisar chats...",
+    chatStatusOpen: "ABERTO",
+    chatStatusInviteOnly: "SÓ POR CONVITE",
+    chatStatusClosed: "FECHADO",
+    chatSendMessage: "Enviar",
+    chatMessagePlaceholder: "Escreva a sua mensagem...",
+    chatNoMessages: "Sem mensagens ainda.",
+    chatLeave: "Sair do Chat",
+    chatInviteCodeLabel: "Introduza o código de convite",
+    chatJoinByInvite: "Aderir",
+    chatTitlePlaceholder: "Título do chat",
+    chatDescriptionPlaceholder: "Descrição do chat",
+    chatTagsPlaceholder: "tag1, tag2, tag3",
+    chatImageLabel: "Seleciona um ficheiro de imagem (.jpeg, .jpg, .png, .gif)",
+    chatInviteCode: "Código de Convite",
+    chatAuthor: "Autor",
+    chatCreated: "Criado",
+    chatAddFavorite: "Adicionar aos Favoritos",
+    chatRemoveFavorite: "Remover dos Favoritos",
+    chatPM: "MP",
+    chatStatusLabel: "Estado",
+    chatCategoryLabel: "Categoria",
+    chatParticipantsLabel: "Participantes",
+    gamesTitle: "Jogos",
+    gamesDescription: "Descubra e jogue alguns jogos.",
+    gamesFilterAll: "TODOS",
+    gamesPlayButton: "JOGAR!",
+    gamesBackToGames: "Voltar aos Jogos",
+    modulesGamesLabel: "Jogos",
+    modulesGamesDescription: "Módulo para descobrir e jogar alguns jogos.",
+    gamesCocolandTitle: "Cocoland",
+    gamesCocolandDesc: "Um coco com olhos a saltar palmeiras e a colecionar ECOins.",
+    gamesTheFlowTitle: "ECOinflow",
+    gamesTheFlowDesc: "Liga PUBs a habitantes através de validadores, lojas e acumuladores. Sobrevive à ameaça CBDC!",
+    gamesSpaceInvadersTitle: "Space Invaders",
+    gamesSpaceInvadersDesc: "Pare a invasão alienígena! Destrua ondas de invasores.",
+    gamesArkanoidTitle: "Arkanoid",
+    gamesArkanoidDesc: "Quebre todos os tijolos com sua raquete e bola. Um clássico desafio arcade.",
+    gamesPingPongTitle: "PingPong",
+    gamesPingPongDesc: "Ping-pong clássico contra uma IA. O primeiro a 5 pontos ganha.",
+    gamesOutrunTitle: "Outrun",
+    gamesOutrunDesc: "Corrida contra o tempo! Desvie do tráfego e chegue à meta antes do tempo acabar.",
+    gamesAsteroidsTitle: "Asteroids",
+    gamesAsteroidsDesc: "Pilote sua nave por um campo de asteroides mortais. Destrua-os antes que te atinjam.",
+    gamesTikTakToeTitle: "TikTakToe",
+    gamesTikTakToeDesc: "Pedra, papel ou tesoura contra uma IA. Melhor de três rodadas vence.",
+    gamesFlipFlopTitle: "FlipFlop",
+    gamesFlipFlopDesc: "Lance uma moeda e aposte em cara ou coroa. Qual é a sua sorte?",
+    games8BallTitle: "8Ball Pool",
+    games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.",
+    gamesArtilleryTitle: "Artillery",
+    gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.",
+    gamesLabyrinthTitle: "Labyrinth",
+    gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.",
+    gamesCocomanTitle: "Cocoman",
+    gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.",
+    gamesTetrisTitle: "Tetris",
+    gamesAudioPendulumTitle: "Audio Pendulum",
+    gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.",
+    gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?",
+    gamesQuakeTitle: "Quake Arena",
+    gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.",
+    gamesFilterScoring: "SCORING",
+    gamesHallOfFame: "Hall of Fame",
+    gamesHallPlayer: "Player",
+    gamesHallScore: "Score",
+    gamesNoScores: "No scores yet.",
+    calendarsTitle: "Calendários",
+    calendarTitle: "Calendário",
+    modulesCalendarsLabel: "Calendários",
+    modulesCalendarsDescription: "Módulo para descobrir e gerir calendários.",
+    typeCalendar: "CALENDÁRIO",
+    calendarFilterAll: "TODOS",
+    calendarFilterMine: "OS MEUS",
+    calendarFilterOpen: "ABERTO",
+    calendarFilterClosed: "FECHADO",
+    calendarFilterRecent: "RECENTES",
+    calendarFilterFavorites: "FAVORITOS",
+    calendarCreate: "Criar calendário",
+    calendarUpdate: "Atualizar",
+    calendarDelete: "Eliminar",
+    calendarTitleLabel: "Título",
+    calendarTitlePlaceholder: "Título do calendário...",
+    calendarStatusLabel: "Estado",
+    calendarStatusOpen: "ABERTO",
+    calendarStatusClosed: "FECHADO",
+    calendarDeadlineLabel: "Deadline",
+    calendarTagsLabel: "Etiquetas",
+    calendarTagsPlaceholder: "etiqueta1, etiqueta2...",
+    calendarParticipantsLabel: "Participantes",
+    calendarParticipantsCount: "Participantes",
+    calendarVisitCalendar: "Visitar calendário",
+    calendarCreated: "Criado",
+    calendarAuthor: "Autor",
+    calendarJoin: "Participar",
+    calendarJoined: "Inscrito",
+    calendarAddDate: "Adicionar data",
+    calendarAddNote: "Adicionar nota",
+    calendarDateLabel: "Data",
+    calendarDatePlaceholder: "Descrever esta data...",
+    calendarNoteLabel: "Nota",
+    calendarNotePlaceholder: "Adicionar uma nota...",
+    calendarFirstDateLabel: "Data",
+    calendarFirstNoteLabel: "Notas",
+    calendarIntervalLabel: "Interval",
+    calendarIntervalWeekly: "Weekly",
+    calendarIntervalMonthly: "Monthly",
+    calendarIntervalYearly: "Yearly",
+    calendarFormDescription: "Descrição",
+    calendarNoDates: "Nenhuma data adicionada.",
+    calendarNoNotes: "Nenhuma nota.",
+    calendarsNoItems: "Nenhum calendário encontrado.",
+    calendarsDescription: "Descubra e gerencie calendários na sua rede.",
+    calendarMonthPrev: "← Anterior",
+    calendarMonthNext: "Seguinte →",
+    calendarMonthLabel: "Datas",
+    calendarsShareUrl: "URL de partilha",
+    calendarAllSectionTitle: "Todos os calendários",
+    calendarRecentSectionTitle: "Calendários Recentes",
+    calendarFavoritesSectionTitle: "Favoritos",
+    calendarMineSectionTitle: "Seus Calendários",
+    calendarOpenSectionTitle: "Calendários abertos",
+    calendarClosedSectionTitle: "Calendários fechados",
+    calendarCreateSectionTitle: "Criar novo calendário",
+    calendarUpdateSectionTitle: "Atualizar calendário",
+    calendarAddFavorite: "Adicionar aos favoritos",
+  calendarDeleteNote: "Delete",
+    calendarRemoveFavorite: "Remover dos favoritos",
+    calendarSearchPlaceholder: "Pesquisar calendários...",
+    calendarAddEntry: "Adicionar Entrada",
+    calendarLeave: "Sair do Calendário",
+    statsCalendar: "Calendários",
+    statsCalendarDate: "Datas de calendário",
+    statsCalendarNote: "Notas de calendário"
     }
 };

+ 256 - 1
src/client/assets/translations/oasis_ru.js

@@ -639,6 +639,9 @@ module.exports = {
     favoritesFilterDocuments: "ДОКУМЕНТЫ",
     favoritesFilterImages: "ИЗОБРАЖЕНИЯ",
     favoritesFilterMaps: "КАРТЫ",
+    favoritesFilterPads: "БЛОКНОТЫ",
+    favoritesFilterChats: "ЧАТЫ",
+    favoritesFilterCalendars: "КАЛЕНДАРИ",
     favoritesFilterVideos: "ВИДЕО",
     favoritesRemoveButton: "Убрать из избранного",
     favoritesNoItems: "Пока нет избранного.",
@@ -1714,6 +1717,16 @@ module.exports = {
     tribeSectionVideos: "ВИДЕО",
     tribeSectionDocuments: "ДОКУМЕНТЫ",
     tribeSectionBookmarks: "ЗАКЛАДКИ",
+    tribeSectionMaps: "MAPS",
+    tribeSectionPads: "PADS",
+    tribeSectionChats: "CHATS",
+    tribeSectionCalendars: "CALENDARS",
+    tribePadCreate: "Create Pad",
+    tribeChatCreate: "Create Chat",
+    tribeCalendarCreate: "Create Calendar",
+    tribePadsEmpty: "No pads, yet.",
+    tribeChatsEmpty: "No chats, yet.",
+    tribeCalendarsEmpty: "No calendars, yet.",
     tribeInhabitantsEmpty: "В этом племени пока нет жителей.",
     tribeEventCreate: "Создать событие",
     tribeEventsEmpty: "Событий пока нет.",
@@ -1879,6 +1892,7 @@ module.exports = {
     agendaFilterTransfers: "ПЕРЕВОДЫ",
     agendaFilterJobs: "ВАКАНСИИ",
     agendaFilterProjects: "ПРОЕКТЫ",
+    agendaFilterCalendars: "КАЛЕНДАРИ",
     agendaNoItems: "Назначений не найдено.",
     agendaAuthor: "Автор",
     agendaDiscardButton: "Отклонить",
@@ -2053,6 +2067,12 @@ module.exports = {
     bankViewTx: "Просмотр транзакции",
     bankClaimNow: "Получить сейчас",
     bankClaimUBI: "Получить UBI!",
+    bankClaimAndPay: 'Claim & Pay',
+    bankClaimedPending: 'Claim pending...',
+    bankStatusUnclaimed: 'Unclaimed',
+    bankStatusClaimed: 'Claimed',
+    bankStatusExpired: 'Expired',
+    bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: "Нет ожидающих распределений UBI для этой эпохи.",
     bankPubBalance: "Баланс PUB",
     bankEpoch: "Эпоха",
@@ -2101,6 +2121,24 @@ module.exports = {
     bankRemoveMyAddress: "Удалить мой адрес",
     bankNotRemovableOasis: "Адреса нельзя удалить локально",
     bankingFutureUBI: "UBI",
+    pubIdTitle: "PUB Wallet",
+    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdLabel: "PUB ID",
+    pubIdSave: "Save configuration",
+    pubIdPlaceholder: "@PUB_ID.ed25519",
+    bankUbiAvailability: "Доступность БОД",
+    bankUbiAvailableOk: "OK",
+    bankUbiAvailableNo: "НЕТ СРЕДСТВ!",
+    bankAlreadyClaimedThisMonth: "Уже получено в этом месяце",
+    bankUbiThisMonth: "БОД (этот месяц)",
+    bankUbiLastClaimed: "БОД (последнее получение)",
+    bankUbiNeverClaimed: "Никогда не получено",
+    bankUbiTotalClaimed: "БОД (всего получено)",
+    bankUbiPub: "PUB",
+    bankUbiInhabitant: "ЖИТЕЛЬ",
+    bankUbiClaimedAmount: "ПОЛУЧЕНО (ECO)",
+    typeBankUbiResult: "БАНКИНГ - БОД",
+    bankNoPubConfigured: "PUB не настроен. Укажите ID PUB в Настройках.",
     shopsTitle: "Магазины",
     shopDescription: "Откройте и управляйте магазинами в сети.",
     shopTitle: "Магазин",
@@ -2749,6 +2787,223 @@ module.exports = {
     typeMap: "КАРТЫ",
     typeMapMarker: "МАРКЕР КАРТЫ",
     modulesMapLabel: "Карты",
-    modulesMapDescription: "Модуль для управления и обмена офлайн-картами."
+    modulesMapDescription: "Модуль для управления и обмена офлайн-картами.",
+    padsTitle: "Пады",
+    padTitle: "Пад",
+    modulesPadsLabel: "Пады",
+    modulesPadsDescription: "Модуль для управления совместными текстовыми редакторами.",
+    padFilterAll: "ВСЕ",
+    padFilterMine: "МОИ",
+    padFilterRecent: "НЕДАВНИЕ",
+    padFilterOpen: "ОТКРЫТЫЕ",
+    padFilterClosed: "ЗАКРЫТЫЕ",
+    padCreate: "Создать Пад",
+    padUpdate: "Обновить Пад",
+    padDelete: "Удалить Пад",
+    padTitleLabel: "Заголовок",
+    padTitlePlaceholder: "Введите заголовок пада...",
+    padStatusLabel: "Статус",
+    padStatusOpen: "ОТКРЫТ",
+    padStatusInviteOnly: "ТОЛЬКО ПО ПРИГЛАШЕНИЮ",
+    padStatusClosed: "ЗАКРЫТ",
+    padDeadlineLabel: "Срок",
+    padTagsLabel: "Теги",
+    padTagsPlaceholder: "тег1, тег2, ...",
+    padMembersLabel: "Участники",
+    padVisitPad: "Посетить Пад",
+    padShareUrl: "Поделиться URL",
+    padCreated: "Создано",
+    padAuthor: "Автор",
+    padGenerateCode: "Сгенерировать Код",
+    padInviteCodeLabel: "Код Приглашения",
+    padInviteCodePlaceholder: "Введите код приглашения...",
+    padValidateInvite: "Проверить",
+    padStartEditing: "НАЧАТЬ РЕДАКТИРОВАНИЕ!",
+    padEditorPlaceholder: "Начните писать...",
+    padSubmitEntry: "Отправить",
+    padNoEntries: "Записей пока нет.",
+    padAllSectionTitle: "Все Пады",
+    padMineSectionTitle: "Мои Пады",
+    padRecentSectionTitle: "Недавние Пады",
+    padOpenSectionTitle: "Открытые Пады",
+    padClosedSectionTitle: "Закрытые Пады",
+    padCreateSectionTitle: "Создать Новый Пад",
+    padUpdateSectionTitle: "Обновить Пад",
+    padInviteGenerated: "Код Приглашения Сгенерирован",
+    typePad: "ПАД",
+    padNew: "НОВЫЙ",
+    padAddFavorite: "Добавить в Избранное",
+    padRemoveFavorite: "Удалить из Избранного",
+    padClose: "Закрыть Pad",
+    padBackToEditor: "Назад к редактору",
+    padSearchPlaceholder: "Поиск падов...",
+
+    modulesChatsLabel: "Чаты",
+    modulesChatsDescription: "Модуль для обнаружения и управления зашифрованными чатами.",
+    typeChat: "ЧАТ",
+    typeChatMessage: "СООБЩЕНИЕ ЧАТА",
+    chatLabel: "ЧАТЫ",
+    chatMessageLabel: "СООБЩЕНИЯ ЧАТА",
+    chatsTitle: "Чаты",
+    chatMineSectionTitle: "Your Chats",
+    chatRecentTitle: "Recent Chats",
+    chatFavoritesTitle: "Избранное",
+    chatOpenTitle: "Open Chats",
+    chatClosedTitle: "Closed Chats",
+    chatDescription: "Описание",
+    chatCategory: "Категория",
+    chatStatus: "СТАТУС",
+    chatFilterAll: "ВСЕ",
+    chatFilterMine: "МОИ",
+    chatFilterRecent: "НЕДАВНИЕ",
+    chatFilterFavorites: "ИЗБРАННОЕ",
+    chatFilterOpen: "ОТКРЫТЫЕ",
+    chatFilterClosed: "ЗАКРЫТЫЕ",
+    chatCreate: "Создать Чат",
+    chatUpdate: "Обновить Чат",
+    chatDelete: "Удалить Чат",
+    chatClose: "Закрыть Чат",
+    chatVisitChat: "ПЕРЕЙТИ В ЧАТ",
+    chatUntitled: "Чат без названия",
+    chatNoItems: "Чаты не найдены.",
+    chatParticipants: "Участники",
+    chatStartChatting: "НАЧАТЬ ОБЩЕНИЕ!",
+    chatGenerateCode: "Создать Код",
+    chatShareUrl: "Поделиться URL",
+    chatCreatedAt: "СОЗДАН",
+    chatSearchPlaceholder: "Поиск чатов...",
+    chatStatusOpen: "ОТКРЫТЫЙ",
+    chatStatusInviteOnly: "ТОЛЬКО ПО ПРИГЛАШЕНИЮ",
+    chatStatusClosed: "ЗАКРЫТЫЙ",
+    chatSendMessage: "Отправить",
+    chatMessagePlaceholder: "Введите сообщение...",
+    chatNoMessages: "Сообщений пока нет.",
+    chatLeave: "Покинуть Чат",
+    chatInviteCodeLabel: "Введите код приглашения",
+    chatJoinByInvite: "Войти",
+    chatTitlePlaceholder: "Название чата",
+    chatDescriptionPlaceholder: "Описание чата",
+    chatTagsPlaceholder: "тег1, тег2, тег3",
+    chatImageLabel: "Выбрать файл изображения (.jpeg, .jpg, .png, .gif)",
+    chatInviteCode: "Код Приглашения",
+    chatAuthor: "Автор",
+    chatCreated: "Создан",
+    chatAddFavorite: "Добавить в Избранное",
+    chatRemoveFavorite: "Убрать из Избранного",
+    chatPM: "ЛС",
+    chatStatusLabel: "Статус",
+    chatCategoryLabel: "Категория",
+    chatParticipantsLabel: "Участники",
+    gamesTitle: "Игры",
+    gamesDescription: "Откройте и играйте в игры.",
+    gamesFilterAll: "ВСЕ",
+    gamesPlayButton: "ИГРАТЬ!",
+    gamesBackToGames: "Назад к играм",
+    modulesGamesLabel: "Игры",
+    modulesGamesDescription: "Модуль для открытия и игры в игры.",
+    gamesCocolandTitle: "Cocoland",
+    gamesCocolandDesc: "Кокос с глазами прыгает через пальмы и собирает ECOins.",
+    gamesTheFlowTitle: "ECOinflow",
+    gamesTheFlowDesc: "Соединяй PUB'ы с жителями через валидаторы, магазины и аккумуляторы. Противостой угрозе CBDC!",
+    gamesSpaceInvadersTitle: "Space Invaders",
+    gamesSpaceInvadersDesc: "Остановите вторжение пришельцев! Уничтожайте волны захватчиков.",
+    gamesArkanoidTitle: "Arkanoid",
+    gamesArkanoidDesc: "Разбейте все кирпичи своей ракеткой и мячом. Классическая аркада.",
+    gamesPingPongTitle: "PingPong",
+    gamesPingPongDesc: "Классический пинг-понг против ИИ. Первый до 5 очков побеждает.",
+    gamesOutrunTitle: "Outrun",
+    gamesOutrunDesc: "Гонка со временем! Уворачивайтесь от машин и доберитесь до финиша вовремя.",
+    gamesAsteroidsTitle: "Asteroids",
+    gamesAsteroidsDesc: "Пилотируйте корабль через астероидное поле. Уничтожайте их до столкновения.",
+    gamesTikTakToeTitle: "TikTakToe",
+    gamesTikTakToeDesc: "Камень, ножницы, бумага против ИИ. Лучший из трёх раундов побеждает.",
+    gamesFlipFlopTitle: "FlipFlop",
+    gamesFlipFlopDesc: "Подбросьте монету и угадайте орёл или решку. Насколько вы удачливы?",
+    games8BallTitle: "8Ball Pool",
+    games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.",
+    gamesArtilleryTitle: "Artillery",
+    gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.",
+    gamesLabyrinthTitle: "Labyrinth",
+    gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.",
+    gamesCocomanTitle: "Cocoman",
+    gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.",
+    gamesTetrisTitle: "Tetris",
+    gamesAudioPendulumTitle: "Audio Pendulum",
+    gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.",
+    gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?",
+    gamesQuakeTitle: "Quake Arena",
+    gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.",
+    gamesFilterScoring: "SCORING",
+    gamesHallOfFame: "Hall of Fame",
+    gamesHallPlayer: "Player",
+    gamesHallScore: "Score",
+    gamesNoScores: "No scores yet.",
+    calendarsTitle: "Календари",
+    calendarTitle: "Календарь",
+    modulesCalendarsLabel: "Календари",
+    modulesCalendarsDescription: "Модуль для просмотра и управления календарями.",
+    typeCalendar: "КАЛЕНДАРЬ",
+    calendarFilterAll: "ВСЕ",
+    calendarFilterMine: "МОИ",
+    calendarFilterOpen: "ОТКРЫТЫЕ",
+    calendarFilterClosed: "ЗАКРЫТЫЕ",
+    calendarFilterRecent: "НЕДАВНИЕ",
+    calendarFilterFavorites: "ИЗБРАННОЕ",
+    calendarCreate: "Создать календарь",
+    calendarUpdate: "Обновить",
+    calendarDelete: "Удалить",
+    calendarTitleLabel: "Название",
+    calendarTitlePlaceholder: "Название календаря...",
+    calendarStatusLabel: "Статус",
+    calendarStatusOpen: "ОТКРЫТЫЙ",
+    calendarStatusClosed: "ЗАКРЫТЫЙ",
+    calendarDeadlineLabel: "Deadline",
+    calendarTagsLabel: "Теги",
+    calendarTagsPlaceholder: "тег1, тег2...",
+    calendarParticipantsLabel: "Участники",
+    calendarParticipantsCount: "Участники",
+    calendarVisitCalendar: "Открыть календарь",
+    calendarCreated: "Создан",
+    calendarAuthor: "Автор",
+    calendarJoin: "Присоединиться",
+    calendarJoined: "Участник",
+    calendarAddDate: "Добавить дату",
+    calendarAddNote: "Добавить заметку",
+    calendarDateLabel: "Дата",
+    calendarDatePlaceholder: "Опишите эту дату...",
+    calendarNoteLabel: "Заметка",
+    calendarNotePlaceholder: "Добавить заметку...",
+    calendarFirstDateLabel: "Дата",
+    calendarFirstNoteLabel: "Заметки",
+    calendarIntervalLabel: "Interval",
+    calendarIntervalWeekly: "Weekly",
+    calendarIntervalMonthly: "Monthly",
+    calendarIntervalYearly: "Yearly",
+    calendarFormDescription: "Описание",
+    calendarNoDates: "Даты не добавлены.",
+    calendarNoNotes: "Заметок нет.",
+    calendarsNoItems: "Календари не найдены.",
+    calendarsDescription: "Находите и управляйте календарями в вашей сети.",
+    calendarMonthPrev: "← Назад",
+    calendarMonthNext: "Вперёд →",
+    calendarMonthLabel: "Даты",
+    calendarsShareUrl: "Поделиться URL",
+    calendarAllSectionTitle: "Все календари",
+    calendarRecentSectionTitle: "Недавние календари",
+    calendarFavoritesSectionTitle: "Избранное",
+    calendarMineSectionTitle: "Ваши Календари",
+    calendarOpenSectionTitle: "Открытые календари",
+    calendarClosedSectionTitle: "Закрытые календари",
+    calendarCreateSectionTitle: "Создать новый календарь",
+    calendarUpdateSectionTitle: "Обновить календарь",
+    calendarAddFavorite: "Добавить в избранное",
+  calendarDeleteNote: "Delete",
+    calendarRemoveFavorite: "Убрать из избранного",
+    calendarSearchPlaceholder: "Поиск календарей...",
+    calendarAddEntry: "Добавить запись",
+    calendarLeave: "Покинуть календарь",
+    statsCalendar: "Календари",
+    statsCalendarDate: "Даты календаря",
+    statsCalendarNote: "Заметки календаря"
     }
 };

+ 256 - 1
src/client/assets/translations/oasis_zh.js

@@ -640,6 +640,9 @@ module.exports = {
     favoritesFilterDocuments: "文档",
     favoritesFilterImages: "图片",
     favoritesFilterMaps: "地图",
+    favoritesFilterPads: "记事本",
+    favoritesFilterChats: "聊天",
+    favoritesFilterCalendars: "日历",
     favoritesFilterVideos: "视频",
     favoritesRemoveButton: "取消收藏",
     favoritesNoItems: "还没有收藏。",
@@ -1727,6 +1730,16 @@ module.exports = {
     tribeSectionVideos: "视频",
     tribeSectionDocuments: "文档",
     tribeSectionBookmarks: "书签",
+    tribeSectionMaps: "MAPS",
+    tribeSectionPads: "PADS",
+    tribeSectionChats: "CHATS",
+    tribeSectionCalendars: "CALENDARS",
+    tribePadCreate: "Create Pad",
+    tribeChatCreate: "Create Chat",
+    tribeCalendarCreate: "Create Calendar",
+    tribePadsEmpty: "No pads, yet.",
+    tribeChatsEmpty: "No chats, yet.",
+    tribeCalendarsEmpty: "No calendars, yet.",
     tribeInhabitantsEmpty: "此部落中还没有居民。",
     tribeEventCreate: "创建活动",
     tribeEventsEmpty: "还没有活动。",
@@ -1892,6 +1905,7 @@ module.exports = {
     agendaFilterTransfers: "转账",
     agendaFilterJobs: "工作",
     agendaFilterProjects: "项目",
+    agendaFilterCalendars: "日历",
     agendaNoItems: "未找到分配项目。",
     agendaAuthor: "作者",
     agendaDiscardButton: "丢弃",
@@ -2088,6 +2102,12 @@ module.exports = {
     bankViewTx: '查看交易',
     bankClaimNow: '立即领取',
     bankClaimUBI: '领取 UBI!',
+    bankClaimAndPay: 'Claim & Pay',
+    bankClaimedPending: 'Claim pending...',
+    bankStatusUnclaimed: 'Unclaimed',
+    bankStatusClaimed: 'Claimed',
+    bankStatusExpired: 'Expired',
+    bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: '本期无待领取的 UBI 分配。',
     bankPubBalance: 'PUB 余额',
     bankEpoch: '纪元',
@@ -2138,6 +2158,24 @@ module.exports = {
     bankRemoveMyAddress: '移除我的地址',
     bankNotRemovableOasis: '无法在本地移除地址',
     bankingFutureUBI: "UBI",
+    pubIdTitle: "PUB Wallet",
+    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdLabel: "PUB ID",
+    pubIdSave: "Save configuration",
+    pubIdPlaceholder: "@PUB_ID.ed25519",
+    bankUbiAvailability: "UBI 可用性",
+    bankUbiAvailableOk: "可用",
+    bankUbiAvailableNo: "资金不足!",
+    bankAlreadyClaimedThisMonth: "本月已领取",
+    bankUbiThisMonth: "UBI(本月)",
+    bankUbiLastClaimed: "UBI(上次领取)",
+    bankUbiNeverClaimed: "从未领取",
+    bankUbiTotalClaimed: "UBI(累计领取)",
+    bankUbiPub: "PUB",
+    bankUbiInhabitant: "居民",
+    bankUbiClaimedAmount: "已领取(ECO)",
+    typeBankUbiResult: "银行 - UBI",
+    bankNoPubConfigured: "未配置 PUB。请在设置中设置您的 PUB ID。",
     shopsTitle: "商店",
     shopDescription: "发现和管理网络中的商店。",
     shopTitle: "商店",
@@ -2787,6 +2825,223 @@ module.exports = {
     typeMap: "地图",
     typeMapMarker: "地图标记",
     modulesMapLabel: "地图",
-    modulesMapDescription: "管理和分享离线地图的模块。"
+    modulesMapDescription: "管理和分享离线地图的模块。",
+    padsTitle: "协作板",
+    padTitle: "协作板",
+    modulesPadsLabel: "协作板",
+    modulesPadsDescription: "管理协作文本编辑器的模块。",
+    padFilterAll: "全部",
+    padFilterMine: "我的",
+    padFilterRecent: "最近",
+    padFilterOpen: "开放",
+    padFilterClosed: "关闭",
+    padCreate: "创建协作板",
+    padUpdate: "更新协作板",
+    padDelete: "删除协作板",
+    padTitleLabel: "标题",
+    padTitlePlaceholder: "输入协作板标题...",
+    padStatusLabel: "状态",
+    padStatusOpen: "开放",
+    padStatusInviteOnly: "仅限受邀者",
+    padStatusClosed: "关闭",
+    padDeadlineLabel: "截止日期",
+    padTagsLabel: "标签",
+    padTagsPlaceholder: "标签1, 标签2, ...",
+    padMembersLabel: "成员",
+    padVisitPad: "访问协作板",
+    padShareUrl: "分享链接",
+    padCreated: "创建时间",
+    padAuthor: "作者",
+    padGenerateCode: "生成邀请码",
+    padInviteCodeLabel: "邀请码",
+    padInviteCodePlaceholder: "输入邀请码...",
+    padValidateInvite: "验证",
+    padStartEditing: "开始编辑!",
+    padEditorPlaceholder: "开始写作...",
+    padSubmitEntry: "提交",
+    padNoEntries: "暂无内容。",
+    padAllSectionTitle: "全部协作板",
+    padMineSectionTitle: "我的协作板",
+    padRecentSectionTitle: "最近协作板",
+    padOpenSectionTitle: "开放协作板",
+    padClosedSectionTitle: "关闭协作板",
+    padCreateSectionTitle: "创建新协作板",
+    padUpdateSectionTitle: "更新协作板",
+    padInviteGenerated: "邀请码已生成",
+    typePad: "协作板",
+    padNew: "新建",
+    padAddFavorite: "添加到收藏",
+    padRemoveFavorite: "从收藏中移除",
+    padClose: "关闭记事本",
+    padBackToEditor: "返回编辑器",
+    padSearchPlaceholder: "搜索共享文本...",
+
+    modulesChatsLabel: "聊天",
+    modulesChatsDescription: "发现和管理加密聊天室的模块。",
+    typeChat: "聊天",
+    typeChatMessage: "聊天消息",
+    chatLabel: "聊天",
+    chatMessageLabel: "聊天消息",
+    chatsTitle: "聊天",
+    chatMineSectionTitle: "Your Chats",
+    chatRecentTitle: "Recent Chats",
+    chatFavoritesTitle: "收藏",
+    chatOpenTitle: "Open Chats",
+    chatClosedTitle: "Closed Chats",
+    chatDescription: "描述",
+    chatCategory: "类别",
+    chatStatus: "状态",
+    chatFilterAll: "全部",
+    chatFilterMine: "我的",
+    chatFilterRecent: "最近",
+    chatFilterFavorites: "收藏",
+    chatFilterOpen: "开放",
+    chatFilterClosed: "已关闭",
+    chatCreate: "创建聊天",
+    chatUpdate: "更新聊天",
+    chatDelete: "删除聊天",
+    chatClose: "关闭聊天",
+    chatVisitChat: "访问聊天",
+    chatUntitled: "无标题聊天",
+    chatNoItems: "未找到聊天。",
+    chatParticipants: "参与者",
+    chatStartChatting: "开始聊天!",
+    chatGenerateCode: "生成代码",
+    chatShareUrl: "分享链接",
+    chatCreatedAt: "创建时间",
+    chatSearchPlaceholder: "搜索聊天...",
+    chatStatusOpen: "开放",
+    chatStatusInviteOnly: "仅限邀请",
+    chatStatusClosed: "已关闭",
+    chatSendMessage: "发送",
+    chatMessagePlaceholder: "输入您的消息...",
+    chatNoMessages: "暂无消息。",
+    chatLeave: "离开聊天",
+    chatInviteCodeLabel: "输入邀请码",
+    chatJoinByInvite: "加入",
+    chatTitlePlaceholder: "聊天标题",
+    chatDescriptionPlaceholder: "聊天描述",
+    chatTagsPlaceholder: "标签1, 标签2",
+    chatImageLabel: "选择图片文件 (.jpeg, .jpg, .png, .gif)",
+    chatInviteCode: "邀请码",
+    chatAuthor: "作者",
+    chatCreated: "创建",
+    chatAddFavorite: "添加到收藏",
+    chatRemoveFavorite: "从收藏中移除",
+    chatPM: "私信",
+    chatStatusLabel: "状态",
+    chatCategoryLabel: "类别",
+    chatParticipantsLabel: "参与者",
+    gamesTitle: "游戏",
+    gamesDescription: "发现并玩一些游戏。",
+    gamesFilterAll: "全部",
+    gamesPlayButton: "开始游戏!",
+    gamesBackToGames: "返回游戏",
+    modulesGamesLabel: "游戏",
+    modulesGamesDescription: "用于发现和玩游戏的模块。",
+    gamesCocolandTitle: "Cocoland",
+    gamesCocolandDesc: "一颗有眼睛的椰子跳过棕榈树,收集ECOins。",
+    gamesTheFlowTitle: "ECOinflow",
+    gamesTheFlowDesc: "通过验证者、商店和累积器将PUB连接到居民。抵御CBDC威胁!",
+    gamesSpaceInvadersTitle: "Space Invaders",
+    gamesSpaceInvadersDesc: "阻止外星人入侵!在入侵者抵达地面之前击落它们。",
+    gamesArkanoidTitle: "Arkanoid",
+    gamesArkanoidDesc: "用你的球拍和球打破所有砖块。经典街机挑战。",
+    gamesPingPongTitle: "PingPong",
+    gamesPingPongDesc: "与AI对战的经典乒乓球。先得5分者获胜。",
+    gamesOutrunTitle: "Outrun",
+    gamesOutrunDesc: "与时间赛跑!躲避交通障碍,在时间用完前到达终点。",
+    gamesAsteroidsTitle: "Asteroids",
+    gamesAsteroidsDesc: "驾驶飞船穿越小行星带。在它们撞上你之前将其摧毁。",
+    gamesTikTakToeTitle: "TikTakToe",
+    gamesTikTakToeDesc: "与AI对战石头、剪刀、布。三局两胜获胜。",
+    gamesFlipFlopTitle: "FlipFlop",
+    gamesFlipFlopDesc: "抛硬币,猜正面还是反面。你有多幸运?",
+    games8BallTitle: "8Ball Pool",
+    games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.",
+    gamesArtilleryTitle: "Artillery",
+    gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.",
+    gamesLabyrinthTitle: "Labyrinth",
+    gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.",
+    gamesCocomanTitle: "Cocoman",
+    gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.",
+    gamesTetrisTitle: "Tetris",
+    gamesAudioPendulumTitle: "Audio Pendulum",
+    gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.",
+    gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?",
+    gamesQuakeTitle: "Quake Arena",
+    gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.",
+    gamesFilterScoring: "SCORING",
+    gamesHallOfFame: "Hall of Fame",
+    gamesHallPlayer: "Player",
+    gamesHallScore: "Score",
+    gamesNoScores: "No scores yet.",
+    calendarsTitle: "日历",
+    calendarTitle: "日历",
+    modulesCalendarsLabel: "日历",
+    modulesCalendarsDescription: "用于发现和管理日历的模块。",
+    typeCalendar: "日历",
+    calendarFilterAll: "全部",
+    calendarFilterMine: "我的",
+    calendarFilterOpen: "开放",
+    calendarFilterClosed: "关闭",
+    calendarFilterRecent: "最近",
+    calendarFilterFavorites: "收藏",
+    calendarCreate: "创建日历",
+    calendarUpdate: "更新",
+    calendarDelete: "删除",
+    calendarTitleLabel: "标题",
+    calendarTitlePlaceholder: "日历标题...",
+    calendarStatusLabel: "状态",
+    calendarStatusOpen: "开放",
+    calendarStatusClosed: "关闭",
+    calendarDeadlineLabel: "Deadline",
+    calendarTagsLabel: "标签",
+    calendarTagsPlaceholder: "标签1, 标签2...",
+    calendarParticipantsLabel: "参与者",
+    calendarParticipantsCount: "参与者",
+    calendarVisitCalendar: "访问日历",
+    calendarCreated: "已创建",
+    calendarAuthor: "作者",
+    calendarJoin: "加入",
+    calendarJoined: "已加入",
+    calendarAddDate: "添加日期",
+    calendarAddNote: "添加笔记",
+    calendarDateLabel: "日期",
+    calendarDatePlaceholder: "描述这个日期...",
+    calendarNoteLabel: "笔记",
+    calendarNotePlaceholder: "添加笔记...",
+    calendarFirstDateLabel: "日期",
+    calendarFirstNoteLabel: "备注",
+    calendarIntervalLabel: "Interval",
+    calendarIntervalWeekly: "Weekly",
+    calendarIntervalMonthly: "Monthly",
+    calendarIntervalYearly: "Yearly",
+    calendarFormDescription: "描述",
+    calendarNoDates: "尚未添加日期。",
+    calendarNoNotes: "暂无笔记。",
+    calendarsNoItems: "未找到日历。",
+    calendarsDescription: "在您的网络中发现和管理日历。",
+    calendarMonthPrev: "← 上一月",
+    calendarMonthNext: "下一月 →",
+    calendarMonthLabel: "日期",
+    calendarsShareUrl: "分享链接",
+    calendarAllSectionTitle: "所有日历",
+    calendarRecentSectionTitle: "最近的日历",
+    calendarFavoritesSectionTitle: "收藏",
+    calendarMineSectionTitle: "你的日历",
+    calendarOpenSectionTitle: "开放日历",
+    calendarClosedSectionTitle: "关闭日历",
+    calendarCreateSectionTitle: "创建新日历",
+    calendarUpdateSectionTitle: "更新日历",
+    calendarAddFavorite: "添加到收藏",
+  calendarDeleteNote: "Delete",
+    calendarRemoveFavorite: "从收藏移除",
+    calendarSearchPlaceholder: "搜索日历...",
+    calendarAddEntry: "添加条目",
+    calendarLeave: "离开日历",
+    statsCalendar: "日历",
+    statsCalendarDate: "日历日期",
+    statsCalendarNote: "日历笔记"
     }
 };

+ 4 - 0
src/client/middleware.js

@@ -60,6 +60,10 @@ module.exports = ({ host, port, middleware, allowHost }) => {
   app.use(mount("/mapcache", mapcache));
 
 
+  const gamesStatic = new Koa();
+  gamesStatic.use(koaStatic(join(__dirname, "..", "games")));
+  app.use(mount("/game-assets", gamesStatic));
+
   app.use(mount("/js", koaStatic(path.join(__dirname, 'public/js'))));
   app.use(koaStatic(path.join(__dirname, 'public')));
 

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

@@ -33,6 +33,8 @@ if (!fs.existsSync(configFilePath)) {
       "tribesMod": "on",
       "reportsMod": "on",
       "opinionsMod": "on",
+      "padsMod": "on",
+      "calendarsMod": "on",
       "transfersMod": "on",
       "feedMod": "on",
       "pixeliaMod": "on",
@@ -66,7 +68,8 @@ if (!fs.existsSync(configFilePath)) {
       "limit": 2000
     },
     "homePage": "activity",
-    "language": "en"
+    "language": "en",
+    "pubId": ""
   };
   fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
 }

+ 4 - 1
src/configs/media-favorites.json

@@ -3,5 +3,8 @@
   "bookmarks": [],
   "documents": [],
   "images": [],
+  "maps": [],
+  "pads": [],
+  "shops": [],
   "videos": []
-}
+}

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

@@ -27,12 +27,15 @@
     "tribesMod": "on",
     "reportsMod": "on",
     "opinionsMod": "on",
+    "padsMod": "on",
+    "calendarsMod": "on",
     "transfersMod": "on",
     "feedMod": "on",
     "pixeliaMod": "on",
     "agendaMod": "on",
     "aiMod": "on",
     "forumMod": "on",
+    "gamesMod": "on",
     "jobsMod": "on",
     "shopsMod": "on",
     "projectsMod": "on",
@@ -40,7 +43,8 @@
     "parliamentMod": "on",
     "courtsMod": "on",
     "favoritesMod": "on",
-    "mapsMod": "on"
+    "mapsMod": "on",
+    "chatsMod": "on"
   },
   "wallet": {
     "url": "http://localhost:7474",
@@ -60,5 +64,6 @@
     "limit": 2000
   },
   "homePage": "activity",
-  "language": "en"
+  "language": "en",
+  "pubId": ""
 }

+ 285 - 0
src/games/8ball/index.html

@@ -0,0 +1,285 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>8Ball Pool</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#topbar a:hover { text-decoration: underline; }
+#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
+canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; cursor: crosshair; }
+#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 24px; }
+#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 6px; }
+#scoreSubmit { text-align: center; margin: 6px; }
+#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">8BALL POOL</span>
+</div>
+<div id="ui">
+  <span>SCORE: <b id="score">0</b></span>
+  <span>SHOTS: <b id="shots">0</b></span>
+  <span>BEST: <b id="best">-</b></span>
+</div>
+<canvas id="c" width="600" height="340"></canvas>
+<div id="msg">Click to aim &amp; shoot. Hold = more power.</div>
+<div id="controls">Click on table to aim cue ball &nbsp;|&nbsp; Hold longer = more power &nbsp;|&nbsp; SPACE — new game</div>
+<div id="scoreSubmit" style="display:none">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="8ball">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit">Submit Score to Hall of Fame</button>
+  </form>
+</div>
+<script>
+document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+const W = canvas.width, H = canvas.height;
+
+const TABLE_X = 20, TABLE_Y = 20, TABLE_W = 560, TABLE_H = 280;
+const POCKET_R = 20, BALL_R = 10;
+const FRICTION = 0.975;
+const POCKETS = [
+  [TABLE_X, TABLE_Y], [TABLE_X + TABLE_W/2, TABLE_Y - 6], [TABLE_X + TABLE_W, TABLE_Y],
+  [TABLE_X, TABLE_Y + TABLE_H], [TABLE_X + TABLE_W/2, TABLE_Y + TABLE_H + 6], [TABLE_X + TABLE_W, TABLE_Y + TABLE_H]
+];
+const BALL_COLORS = ['#fff','#ffd700','#1e90ff','#dc143c','#800080','#ff8c00','#006400','#8b0000','#000','#ffd700','#1e90ff','#dc143c','#800080','#ff8c00','#006400','#8b0000'];
+const BALL_STRIPE = [false,false,false,false,false,false,false,false,false,true,true,true,true,true,true,true];
+
+let score = 0, shots = 0, best = parseInt(localStorage.getItem('8ball_best') || '-1');
+let balls = [], state = 'idle', mouseDown = false, mousePos = {x:0,y:0}, powerStart = 0, power = 0;
+let foul = false, gameOver = false;
+
+function initBalls() {
+  balls = [];
+  const cx = TABLE_X + TABLE_W * 0.72, cy = TABLE_Y + TABLE_H / 2;
+  const sp = BALL_R * 2.05;
+  const rack = [
+    [0,0],[1,-0.5],[1,0.5],[2,-1],[2,0],[2,1],[3,-1.5],[3,-0.5],[3,0.5],[3,1.5],
+    [4,-2],[4,-1],[4,0],[4,1],[4,2]
+  ];
+  for (let i = 0; i < 15; i++) {
+    const [row, col] = rack[i];
+    balls.push({ x: cx + row * sp * 0.866, y: cy + col * sp, vx: 0, vy: 0, potted: false, idx: i + 1 });
+  }
+  balls.push({ x: TABLE_X + TABLE_W * 0.25, y: cy, vx: 0, vy: 0, potted: false, idx: 0 });
+}
+
+function resetGame() {
+  score = 0; shots = 0; foul = false; gameOver = false;
+  state = 'aiming';
+  document.getElementById('score').textContent = '0';
+  document.getElementById('shots').textContent = '0';
+  document.getElementById('msg').textContent = 'Click to aim & shoot. Hold = more power.';
+  document.getElementById('scoreSubmit').style.display = 'none';
+  initBalls();
+}
+
+const cue = () => balls.find(b => b.idx === 0);
+
+function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
+
+function processPhysics() {
+  let moving = true;
+  let steps = 0;
+  while (moving && steps < 800) {
+    steps++;
+    moving = false;
+    for (const b of balls) {
+      if (b.potted) continue;
+      b.x += b.vx; b.y += b.vy;
+      b.vx *= FRICTION; b.vy *= FRICTION;
+      if (Math.abs(b.vx) < 0.05) b.vx = 0;
+      if (Math.abs(b.vy) < 0.05) b.vy = 0;
+      if (Math.abs(b.vx) > 0.05 || Math.abs(b.vy) > 0.05) moving = true;
+      const lx = TABLE_X + BALL_R, rx = TABLE_X + TABLE_W - BALL_R;
+      const ty = TABLE_Y + BALL_R, by = TABLE_Y + TABLE_H - BALL_R;
+      if (b.x < lx) { b.x = lx; b.vx = Math.abs(b.vx) * 0.8; }
+      if (b.x > rx) { b.x = rx; b.vx = -Math.abs(b.vx) * 0.8; }
+      if (b.y < ty) { b.y = ty; b.vy = Math.abs(b.vy) * 0.8; }
+      if (b.y > by) { b.y = by; b.vy = -Math.abs(b.vy) * 0.8; }
+      for (const p of POCKETS) {
+        if (Math.hypot(b.x - p[0], b.y - p[1]) < POCKET_R) {
+          b.potted = true; b.vx = 0; b.vy = 0;
+          if (b.idx !== 0) {
+            if (b.idx === 8) { foul = true; }
+            else { score++; document.getElementById('score').textContent = score; }
+          } else {
+            b.x = TABLE_X + TABLE_W * 0.25; b.y = TABLE_Y + TABLE_H / 2; b.potted = false;
+          }
+          break;
+        }
+      }
+    }
+    const active = balls.filter(b => !b.potted);
+    for (let i = 0; i < active.length; i++) {
+      for (let j = i + 1; j < active.length; j++) {
+        const a = active[i], b2 = active[j];
+        const dx = b2.x - a.x, dy = b2.y - a.y;
+        const d = Math.hypot(dx, dy);
+        if (d < BALL_R * 2 && d > 0.001) {
+          const nx = dx / d, ny = dy / d;
+          const rel = (a.vx - b2.vx) * nx + (a.vy - b2.vy) * ny;
+          if (rel > 0) {
+            a.vx -= rel * nx; a.vy -= rel * ny;
+            b2.vx += rel * nx; b2.vy += rel * ny;
+          }
+          const overlap = BALL_R * 2 - d;
+          a.x -= overlap / 2 * nx; a.y -= overlap / 2 * ny;
+          b2.x += overlap / 2 * nx; b2.y += overlap / 2 * ny;
+        }
+      }
+    }
+  }
+}
+
+function shoot(targetX, targetY, pw) {
+  const c = cue();
+  if (!c) return;
+  shots++;
+  document.getElementById('shots').textContent = shots;
+  const angle = Math.atan2(targetY - c.y, targetX - c.x);
+  const spd = pw * 0.28;
+  c.vx = Math.cos(angle) * spd;
+  c.vy = Math.sin(angle) * spd;
+  processPhysics();
+  const remaining = balls.filter(b => !b.potted && b.idx !== 0 && b.idx !== 8);
+  if (remaining.length === 0) {
+    const eight = balls.find(b => b.idx === 8);
+    if (!eight || eight.potted) {
+      endGame();
+    }
+  } else if (foul) {
+    endGame();
+  }
+}
+
+function endGame() {
+  gameOver = true;
+  state = 'over';
+  const finalScore = foul ? Math.floor(score / 2) : score;
+  document.getElementById('score').textContent = finalScore;
+  if (best < 0 || finalScore > best) {
+    best = finalScore;
+    localStorage.setItem('8ball_best', best);
+    document.getElementById('best').textContent = best;
+  }
+  document.getElementById('msg').textContent = foul ? `FOUL! 8-ball potted early. Score: ${finalScore}. SPACE = new game` : `Game over! Potted ${finalScore} balls in ${shots} shots. SPACE = new game`;
+  document.getElementById('scoreInput').value = finalScore;
+  document.getElementById('scoreSubmit').style.display = 'block';
+}
+
+function getCanvasPos(e) {
+  const r = canvas.getBoundingClientRect();
+  const touch = e.touches ? e.touches[0] : e;
+  return { x: (touch.clientX - r.left) * canvas.width / r.width, y: (touch.clientY - r.top) * canvas.height / r.height };
+}
+
+canvas.addEventListener('mousedown', e => {
+  if (state !== 'aiming') return;
+  mouseDown = true; mousePos = getCanvasPos(e); powerStart = Date.now(); power = 0;
+});
+canvas.addEventListener('mousemove', e => { mousePos = getCanvasPos(e); });
+canvas.addEventListener('mouseup', e => {
+  if (!mouseDown || state !== 'aiming') return;
+  mouseDown = false;
+  power = Math.min((Date.now() - powerStart) / 1000 * 80, 100);
+  shoot(mousePos.x, mousePos.y, power);
+});
+canvas.addEventListener('touchstart', e => { e.preventDefault(); if (state !== 'aiming') return; mouseDown = true; mousePos = getCanvasPos(e); powerStart = Date.now(); }, { passive: false });
+canvas.addEventListener('touchmove', e => { e.preventDefault(); mousePos = getCanvasPos(e); }, { passive: false });
+canvas.addEventListener('touchend', e => {
+  e.preventDefault();
+  if (!mouseDown || state !== 'aiming') return;
+  mouseDown = false;
+  power = Math.min((Date.now() - powerStart) / 1000 * 80, 100);
+  shoot(mousePos.x, mousePos.y, power);
+}, { passive: false });
+document.addEventListener('keydown', e => {
+  if (e.code === 'Space') { e.preventDefault(); resetGame(); }
+});
+
+function drawTable() {
+  ctx.fillStyle = '#2d5a1b';
+  ctx.fillRect(0, 0, W, H);
+  ctx.fillStyle = '#1a6b30';
+  ctx.fillRect(TABLE_X, TABLE_Y, TABLE_W, TABLE_H);
+  ctx.fillStyle = '#8B4513';
+  ctx.fillRect(TABLE_X - 14, TABLE_Y - 14, TABLE_W + 28, 14);
+  ctx.fillRect(TABLE_X - 14, TABLE_Y + TABLE_H, TABLE_W + 28, 14);
+  ctx.fillRect(TABLE_X - 14, TABLE_Y - 14, 14, TABLE_H + 28);
+  ctx.fillRect(TABLE_X + TABLE_W, TABLE_Y - 14, 14, TABLE_H + 28);
+  for (const p of POCKETS) {
+    ctx.fillStyle = '#000';
+    ctx.beginPath(); ctx.arc(p[0], p[1], POCKET_R, 0, Math.PI * 2); ctx.fill();
+  }
+}
+
+function drawBall(b) {
+  if (b.potted) return;
+  const stripe = BALL_STRIPE[b.idx];
+  ctx.fillStyle = BALL_COLORS[b.idx];
+  ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.fill();
+  if (stripe) {
+    ctx.save();
+    ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.clip();
+    ctx.fillStyle = '#fff';
+    ctx.fillRect(b.x - BALL_R, b.y - BALL_R * 0.4, BALL_R * 2, BALL_R * 0.8);
+    ctx.restore();
+  }
+  if (b.idx > 0) {
+    ctx.fillStyle = '#fff';
+    ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R * 0.38, 0, Math.PI * 2); ctx.fill();
+    ctx.fillStyle = '#222';
+    ctx.font = `bold ${BALL_R * 0.6}px monospace`;
+    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+    ctx.fillText(b.idx, b.x, b.y + 0.5);
+  }
+  ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 1;
+  ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.stroke();
+}
+
+function drawAim() {
+  const c = cue();
+  if (!c) return;
+  const angle = Math.atan2(mousePos.y - c.y, mousePos.x - c.x);
+  const pw = mouseDown ? Math.min((Date.now() - powerStart) / 1000 * 80, 100) : 30;
+  ctx.strokeStyle = mouseDown ? `rgba(255,${Math.round(165*(1-pw/100))},0,0.7)` : 'rgba(255,255,255,0.4)';
+  ctx.lineWidth = mouseDown ? 2 : 1;
+  ctx.setLineDash([6, 4]);
+  ctx.beginPath();
+  ctx.moveTo(c.x, c.y);
+  ctx.lineTo(c.x + Math.cos(angle) * 120, c.y + Math.sin(angle) * 120);
+  ctx.stroke();
+  ctx.setLineDash([]);
+  if (mouseDown) {
+    ctx.fillStyle = '#FFA500';
+    ctx.fillRect(TABLE_X, TABLE_Y + TABLE_H + 6, pw / 100 * TABLE_W, 6);
+    ctx.strokeStyle = '#555'; ctx.lineWidth = 1;
+    ctx.strokeRect(TABLE_X, TABLE_Y + TABLE_H + 6, TABLE_W, 6);
+  }
+}
+
+if (best >= 0) document.getElementById('best').textContent = best;
+resetGame();
+
+function loop() {
+  ctx.clearRect(0, 0, W, H);
+  drawTable();
+  balls.forEach(drawBall);
+  if (state === 'aiming') drawAim();
+  requestAnimationFrame(loop);
+}
+loop();
+</script>
+</body>
+</html>

+ 21 - 0
src/games/8ball/thumbnail.svg

@@ -0,0 +1,21 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
+  <rect width="120" height="80" fill="#0a5e2a"/>
+  <rect x="6" y="6" width="108" height="68" fill="#0a6e2e" rx="3"/>
+  <rect x="8" y="8" width="104" height="64" fill="#0d8040" rx="2"/>
+  <circle cx="20" cy="20" r="5" fill="#fff"/>
+  <circle cx="40" cy="15" r="5" fill="#ff0"/>
+  <circle cx="40" cy="25" r="5" fill="#00f"/>
+  <circle cx="55" cy="20" r="5" fill="#f00"/>
+  <circle cx="55" cy="10" r="5" fill="#800080"/>
+  <circle cx="55" cy="30" r="5" fill="#f90"/>
+  <circle cx="70" cy="15" r="5" fill="#0a0"/>
+  <circle cx="70" cy="25" r="5" fill="#700"/>
+  <circle cx="85" cy="20" r="5" fill="#000"/>
+  <text x="85" y="24" font-size="6" fill="#fff" text-anchor="middle" font-weight="bold">8</text>
+  <circle cx="6" cy="6" r="4" fill="#000"/>
+  <circle cx="114" cy="6" r="4" fill="#000"/>
+  <circle cx="6" cy="74" r="4" fill="#000"/>
+  <circle cx="114" cy="74" r="4" fill="#000"/>
+  <circle cx="60" cy="6" r="4" fill="#000"/>
+  <circle cx="60" cy="74" r="4" fill="#000"/>
+</svg>

+ 209 - 0
src/games/arkanoid/index.html

@@ -0,0 +1,209 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Arkanoid</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#topbar a:hover { text-decoration: underline; }
+#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
+canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; cursor: none; }
+#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
+#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">ARKANOID</span>
+</div>
+<div id="ui">
+  <span>SCORE: <b id="score">0</b></span>
+  <span>LIVES: <b id="lives">3</b></span>
+  <span>LEVEL: <b id="level">1</b></span>
+</div>
+<canvas id="c" width="560" height="420"></canvas>
+<div id="msg">Press SPACE or CLICK to start</div>
+<div id="controls">&#8592;&#8594; or MOUSE — Move paddle &nbsp;|&nbsp; SPACE / CLICK — Launch ball</div>
+<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="arkanoid">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
+  </form>
+</div>
+<script>
+document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+const W = canvas.width, H = canvas.height;
+
+const PADDLE_W = 80, PADDLE_H = 12, PADDLE_Y = H - 30;
+const BALL_R = 8;
+const BRICK_COLS = 10, BRICK_ROWS = 5;
+const BRICK_W = 48, BRICK_H = 16, BRICK_GAP = 4;
+const BRICK_OFF_X = (W - (BRICK_COLS * (BRICK_W + BRICK_GAP) - BRICK_GAP)) / 2;
+const BRICK_OFF_Y = 40;
+const BRICK_COLORS = ['#e74c3c', '#e67e22', '#FFA500', '#2ecc71', '#3498db'];
+
+let state = 'idle';
+let score = 0, lives = 3, level = 1;
+let paddle, ball, bricks, attached;
+
+function initGame() {
+  paddle = { x: W / 2 - PADDLE_W / 2, y: PADDLE_Y, w: PADDLE_W, h: PADDLE_H };
+  resetBall();
+  initBricks();
+}
+
+function resetBall() {
+  ball = { x: paddle.x + paddle.w / 2, y: PADDLE_Y - BALL_R - 1, vx: 3 + level * 0.3, vy: -(4 + level * 0.3) };
+  attached = true;
+}
+
+function initBricks() {
+  bricks = [];
+  for (let r = 0; r < BRICK_ROWS; r++) {
+    for (let c = 0; c < BRICK_COLS; c++) {
+      bricks.push({
+        x: BRICK_OFF_X + c * (BRICK_W + BRICK_GAP),
+        y: BRICK_OFF_Y + r * (BRICK_H + BRICK_GAP),
+        w: BRICK_W, h: BRICK_H,
+        alive: true,
+        color: BRICK_COLORS[r % BRICK_COLORS.length],
+        points: (BRICK_ROWS - r) * 10
+      });
+    }
+  }
+}
+
+function launch() {
+  if (state === 'idle' || state === 'over' || state === 'win') {
+    if (state === 'over') { score = 0; lives = 3; level = 1; document.getElementById('score').textContent = '0'; document.getElementById('lives').textContent = '3'; document.getElementById('level').textContent = '1'; }
+    if (state === 'win') { level++; document.getElementById('level').textContent = level; }
+    initGame();
+    state = 'play';
+    attached = false;
+    document.getElementById('msg').textContent = '';
+    return;
+  }
+  if (attached) {
+    attached = false;
+  }
+}
+
+const keys = {};
+document.addEventListener('keydown', e => {
+  keys[e.code] = true;
+  if (e.code === 'Space') { e.preventDefault(); launch(); }
+});
+document.addEventListener('keyup', e => { keys[e.code] = false; });
+canvas.addEventListener('click', launch);
+canvas.addEventListener('mousemove', e => {
+  const rect = canvas.getBoundingClientRect();
+  const mx = (e.clientX - rect.left) * canvas.width / rect.width;
+  paddle.x = Math.max(0, Math.min(W - paddle.w, mx - paddle.w / 2));
+  if (attached) ball.x = paddle.x + paddle.w / 2;
+});
+
+function reflect(ball, bx, by, bw, bh) {
+  const overlapLeft = ball.x + BALL_R - bx;
+  const overlapRight = bx + bw - (ball.x - BALL_R);
+  const overlapTop = ball.y + BALL_R - by;
+  const overlapBottom = by + bh - (ball.y - BALL_R);
+  const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
+  if (minOverlap === overlapTop || minOverlap === overlapBottom) ball.vy *= -1;
+  else ball.vx *= -1;
+}
+
+function loop() {
+  ctx.clearRect(0, 0, W, H);
+
+  if (state === 'play') {
+    if (keys['ArrowLeft']) paddle.x = Math.max(0, paddle.x - 6);
+    if (keys['ArrowRight']) paddle.x = Math.min(W - paddle.w, paddle.x + 6);
+
+    if (attached) {
+      ball.x = paddle.x + paddle.w / 2;
+    } else {
+      ball.x += ball.vx;
+      ball.y += ball.vy;
+
+      if (ball.x - BALL_R < 0) { ball.x = BALL_R; ball.vx = Math.abs(ball.vx); }
+      if (ball.x + BALL_R > W) { ball.x = W - BALL_R; ball.vx = -Math.abs(ball.vx); }
+      if (ball.y - BALL_R < 0) { ball.y = BALL_R; ball.vy = Math.abs(ball.vy); }
+
+      if (ball.y + BALL_R >= paddle.y && ball.y + BALL_R <= paddle.y + paddle.h + Math.abs(ball.vy) &&
+          ball.x >= paddle.x - BALL_R && ball.x <= paddle.x + paddle.w + BALL_R) {
+        const rel = (ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2);
+        const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
+        ball.vx = rel * speed * 1.1;
+        ball.vy = -Math.abs(ball.vy);
+        ball.y = paddle.y - BALL_R - 1;
+      }
+
+      if (ball.y - BALL_R > H) {
+        lives--;
+        document.getElementById('lives').textContent = lives;
+        if (lives <= 0) { state = 'over'; document.getElementById('msg').textContent = 'GAME OVER — Press SPACE'; document.getElementById('scoreInput').value = score; document.getElementById('scoreSubmit').style.display = 'block'; }
+        else { resetBall(); attached = true; }
+      }
+
+      for (const b of bricks) {
+        if (!b.alive) continue;
+        if (ball.x + BALL_R > b.x && ball.x - BALL_R < b.x + b.w && ball.y + BALL_R > b.y && ball.y - BALL_R < b.y + b.h) {
+          b.alive = false;
+          score += b.points;
+          document.getElementById('score').textContent = score;
+          reflect(ball, b.x, b.y, b.w, b.h);
+          break;
+        }
+      }
+
+      if (bricks.every(b => !b.alive)) {
+        state = 'win';
+        document.getElementById('msg').textContent = 'LEVEL CLEAR! — Press SPACE for next level';
+      }
+    }
+  }
+
+  bricks.forEach(b => {
+    if (!b.alive) return;
+    ctx.fillStyle = b.color;
+    ctx.beginPath();
+    ctx.roundRect(b.x, b.y, b.w, b.h, 3);
+    ctx.fill();
+    ctx.strokeStyle = 'rgba(255,255,255,0.15)';
+    ctx.lineWidth = 1;
+    ctx.stroke();
+  });
+
+  ctx.fillStyle = '#FFA500';
+  ctx.beginPath();
+  ctx.roundRect(paddle.x, paddle.y, paddle.w, paddle.h, 4);
+  ctx.fill();
+  ctx.fillStyle = '#FFD700';
+  ctx.fillRect(paddle.x + 8, paddle.y + 3, paddle.w - 16, 3);
+
+  ctx.fillStyle = '#fff';
+  ctx.beginPath();
+  ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
+  ctx.fill();
+  ctx.fillStyle = 'rgba(255,255,255,0.3)';
+  ctx.beginPath();
+  ctx.arc(ball.x - 3, ball.y - 3, BALL_R * 0.4, 0, Math.PI * 2);
+  ctx.fill();
+
+  requestAnimationFrame(loop);
+}
+
+initGame();
+loop();
+</script>
+</body>
+</html>

+ 36 - 0
src/games/arkanoid/thumbnail.svg

@@ -0,0 +1,36 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
+  <rect width="400" height="220" fill="#000"/>
+  <g>
+    <rect x="20" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
+    <rect x="82" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
+    <rect x="144" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
+    <rect x="206" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
+    <rect x="268" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
+    <rect x="330" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
+  </g>
+  <g>
+    <rect x="20" y="46" width="54" height="18" rx="3" fill="#3498db"/>
+    <rect x="82" y="46" width="54" height="18" rx="3" fill="#3498db"/>
+    <rect x="144" y="46" width="54" height="18" rx="3" fill="#3498db"/>
+    <rect x="206" y="46" width="54" height="18" rx="3" fill="#3498db"/>
+    <rect x="268" y="46" width="54" height="18" rx="3" fill="#3498db"/>
+    <rect x="330" y="46" width="54" height="18" rx="3" fill="#3498db"/>
+  </g>
+  <g>
+    <rect x="20" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
+    <rect x="82" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
+    <rect x="144" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
+    <rect x="206" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
+    <rect x="268" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
+    <rect x="330" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
+  </g>
+  <g>
+    <rect x="20" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
+    <rect x="82" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
+    <rect x="144" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
+    <rect x="330" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
+  </g>
+  <circle cx="220" cy="155" r="9" fill="#fff"/>
+  <rect x="155" y="190" width="90" height="14" rx="5" fill="#FFA500"/>
+  <text x="200" y="215" font-family="monospace" font-size="12" fill="#FFA500" text-anchor="middle">SCORE: 0</text>
+</svg>

+ 272 - 0
src/games/artillery/index.html

@@ -0,0 +1,272 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Artillery</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#ui { display: flex; gap: 16px; padding: 8px; font-size: 14px; color: #FFA500; flex-wrap: wrap; justify-content: center; }
+canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
+#controls-panel { display: flex; gap: 16px; align-items: center; padding: 8px; flex-wrap: wrap; justify-content: center; }
+#controls-panel label { color: #aaa; font-size: 13px; }
+#controls-panel input[type=range] { width: 120px; }
+#controls-panel span { color: #FFA500; font-size: 14px; min-width: 40px; }
+#fire-btn { background: #3a0000; border: 1px solid #f44; color: #f44; padding: 6px 20px; cursor: pointer; font-family: monospace; font-size: 15px; font-weight: bold; }
+#fire-btn:hover { background: #5a0000; }
+#msg { font-size: 15px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
+#scoreSubmit { text-align: center; margin: 6px; }
+#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">ARTILLERY</span>
+</div>
+<div id="ui">
+  <span>ROUND: <b id="roundEl">1/5</b></span>
+  <span>SHOTS: <b id="shotsEl">0</b></span>
+  <span>SCORE: <b id="scoreEl">0</b></span>
+  <span>WIND: <b id="windEl">0</b></span>
+  <span>BEST: <b id="bestEl">-</b></span>
+</div>
+<canvas id="c" width="800" height="400"></canvas>
+<div id="controls-panel">
+  <label>Angle: <input type="range" id="angleInput" min="1" max="89" value="45"> <span id="angleVal">45</span>°</label>
+  <label>Power: <input type="range" id="powerInput" min="1" max="100" value="60"> <span id="powerVal">60</span></label>
+  <button id="fire-btn">FIRE!</button>
+</div>
+<div id="msg">Adjust angle and power, then fire!</div>
+<div id="scoreSubmit" style="display:none">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="artillery">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit">Submit Score to Hall of Fame</button>
+  </form>
+</div>
+<script>
+document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+const W = canvas.width, H = canvas.height;
+
+const TOTAL_ROUNDS = 5;
+let round = 1, shots = 0, score = 0, roundShots = 0;
+let terrain = [], cannonY = 0, targetX = 0, targetY = 0, wind = 0;
+let lastPath = [], lastHit = null, hitFlag = false;
+let gameOver = false;
+let best = parseInt(localStorage.getItem('artillery_best') || '-1');
+
+function seededRand(seed) {
+  let s = seed;
+  return function() { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
+}
+
+function generateTerrain(seed) {
+  const rng = seededRand(seed);
+  const pts = new Array(80).fill(0);
+  pts[0] = 150 + rng() * 80;
+  pts[79] = 150 + rng() * 80;
+  function subdivide(arr, lo, hi, rough) {
+    if (hi - lo <= 1) return;
+    const mid = Math.floor((lo + hi) / 2);
+    arr[mid] = (arr[lo] + arr[hi]) / 2 + (rng() - 0.5) * rough;
+    subdivide(arr, lo, mid, rough * 0.6);
+    subdivide(arr, mid, hi, rough * 0.6);
+  }
+  subdivide(pts, 0, 79, 100);
+  for (let i = 0; i < 80; i++) pts[i] = Math.max(60, Math.min(H - 30, pts[i]));
+  return pts;
+}
+
+function terrainHeightAt(x) {
+  const idx = Math.floor(x / (W / 79));
+  const clamped = Math.max(0, Math.min(78, idx));
+  return terrain[clamped];
+}
+
+function startRound() {
+  const seed = Date.now() + round * 1337;
+  terrain = generateTerrain(seed);
+  const rng = seededRand(seed + 42);
+  targetX = Math.floor(W * 0.6 + rng() * W * 0.3);
+  targetY = terrainHeightAt(targetX) - 1;
+  wind = (rng() - 0.5) * 16;
+  roundShots = 0;
+  lastPath = []; lastHit = null; hitFlag = false;
+  document.getElementById('windEl').textContent = wind.toFixed(1);
+  document.getElementById('roundEl').textContent = `${round}/${TOTAL_ROUNDS}`;
+  document.getElementById('msg').textContent = `Round ${round} — Adjust angle and power, then fire!`;
+}
+
+function calcTrajectory(angleDeg, pw) {
+  const angle = angleDeg * Math.PI / 180;
+  let vx = Math.cos(angle) * pw * 0.45;
+  let vy = -Math.sin(angle) * pw * 0.45;
+  const gravity = 0.18;
+  const windA = wind * 0.012;
+  const cannonX = 30;
+  const startY = terrainHeightAt(cannonX) - 6;
+  let x = cannonX, y = startY;
+  const path = [[x, y]];
+  let hitX = null;
+  for (let i = 0; i < 500; i++) {
+    vx += windA; vy += gravity;
+    x += vx; y += vy;
+    if (x < 0 || x > W) break;
+    if (y > H) break;
+    const th = terrainHeightAt(x);
+    if (y >= th) { hitX = x; break; }
+    path.push([x, y]);
+  }
+  return { path, hitX };
+}
+
+function fire() {
+  if (gameOver) return;
+  lastPath = [];
+  lastHit = null;
+  hitFlag = false;
+  const angle = parseInt(document.getElementById('angleInput').value);
+  const pw = parseInt(document.getElementById('powerInput').value);
+  const { path, hitX } = calcTrajectory(angle, pw);
+  lastPath = path;
+  lastHit = hitX;
+  shots++; roundShots++;
+  document.getElementById('shotsEl').textContent = shots;
+  if (hitX !== null && Math.abs(hitX - targetX) < 28) {
+    hitFlag = true;
+    const roundScore = Math.max(10, 100 - (roundShots - 1) * 15) + (roundShots === 1 ? 50 : 0);
+    score += roundScore;
+    document.getElementById('scoreEl').textContent = score;
+    document.getElementById('msg').textContent = `HIT! +${roundScore} pts. ${round < TOTAL_ROUNDS ? 'Next round...' : 'Game over!'}`;
+    setTimeout(() => {
+      lastPath = []; lastHit = null; hitFlag = false;
+      if (round < TOTAL_ROUNDS) { round++; startRound(); } else { endGame(); }
+    }, 1400);
+  } else {
+    const dist = hitX ? Math.abs(Math.round(hitX - targetX)) : '?';
+    document.getElementById('msg').textContent = `Miss! Distance: ${dist}px from target.`;
+  }
+}
+
+function endGame() {
+  gameOver = true;
+  if (best < 0 || score > best) {
+    best = score;
+    localStorage.setItem('artillery_best', best);
+    document.getElementById('bestEl').textContent = best;
+  }
+  document.getElementById('msg').textContent = `All rounds done! Final score: ${score}. SPACE = new game`;
+  document.getElementById('scoreInput').value = score;
+  document.getElementById('scoreSubmit').style.display = 'block';
+}
+
+function newGame() {
+  round = 1; shots = 0; score = 0; roundShots = 0; gameOver = false;
+  document.getElementById('scoreEl').textContent = '0';
+  document.getElementById('shotsEl').textContent = '0';
+  document.getElementById('scoreSubmit').style.display = 'none';
+  startRound();
+}
+
+document.getElementById('angleInput').addEventListener('input', function() { document.getElementById('angleVal').textContent = this.value; });
+document.getElementById('powerInput').addEventListener('input', function() { document.getElementById('powerVal').textContent = this.value; });
+document.getElementById('fire-btn').addEventListener('click', fire);
+document.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); if (gameOver) newGame(); else fire(); } });
+
+if (best >= 0) document.getElementById('bestEl').textContent = best;
+
+function drawTerrain() {
+  ctx.fillStyle = '#4a7c3f';
+  ctx.beginPath();
+  ctx.moveTo(0, H);
+  for (let i = 0; i < 80; i++) {
+    const x = i * (W / 79);
+    ctx.lineTo(x, terrain[i]);
+  }
+  ctx.lineTo(W, H); ctx.closePath(); ctx.fill();
+  ctx.strokeStyle = '#2d5a25'; ctx.lineWidth = 1;
+  ctx.beginPath();
+  for (let i = 0; i < 80; i++) ctx.lineTo(i * (W / 79), terrain[i]);
+  ctx.stroke();
+}
+
+function drawSky() {
+  const grad = ctx.createLinearGradient(0, 0, 0, H * 0.7);
+  grad.addColorStop(0, '#000020');
+  grad.addColorStop(1, '#1a3a6a');
+  ctx.fillStyle = grad;
+  ctx.fillRect(0, 0, W, H);
+}
+
+function drawCannon() {
+  const cx = 30, cy = terrainHeightAt(30);
+  const angle = parseInt(document.getElementById('angleInput').value) * Math.PI / 180;
+  ctx.fillStyle = '#888';
+  ctx.fillRect(cx - 10, cy - 8, 20, 10);
+  ctx.strokeStyle = '#aaa'; ctx.lineWidth = 4; ctx.lineCap = 'round';
+  ctx.beginPath();
+  ctx.moveTo(cx, cy - 3);
+  ctx.lineTo(cx + Math.cos(angle) * 28, cy - 3 - Math.sin(angle) * 28);
+  ctx.stroke();
+  ctx.fillStyle = '#666';
+  ctx.beginPath(); ctx.arc(cx, cy + 2, 8, 0, Math.PI * 2); ctx.fill();
+}
+
+function drawTarget() {
+  const x = targetX, y = targetY;
+  ctx.strokeStyle = '#f44'; ctx.lineWidth = 2;
+  ctx.beginPath(); ctx.moveTo(x - 14, y); ctx.lineTo(x + 14, y); ctx.stroke();
+  ctx.beginPath(); ctx.moveTo(x, y - 14); ctx.lineTo(x, y + 14); ctx.stroke();
+  ctx.fillStyle = '#f44';
+  ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
+  ctx.strokeStyle = '#f44'; ctx.lineWidth = 1;
+  ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 22, y - 16); ctx.lineTo(x + 22, y - 5); ctx.stroke();
+  ctx.fillStyle = '#f44';
+  ctx.beginPath(); ctx.moveTo(x + 22, y - 16); ctx.lineTo(x + 34, y - 10); ctx.lineTo(x + 22, y - 5); ctx.fill();
+}
+
+function drawTrajectory() {
+  if (lastPath.length < 2) return;
+  ctx.strokeStyle = hitFlag ? 'rgba(100,255,100,0.6)' : 'rgba(255,200,0,0.5)';
+  ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]);
+  ctx.beginPath();
+  lastPath.forEach((pt, i) => i === 0 ? ctx.moveTo(pt[0], pt[1]) : ctx.lineTo(pt[0], pt[1]));
+  ctx.stroke(); ctx.setLineDash([]);
+  if (lastHit !== null) {
+    const hy = terrainHeightAt(lastHit);
+    ctx.fillStyle = hitFlag ? '#0f0' : '#f80';
+    ctx.beginPath(); ctx.arc(lastHit, hy, 8, 0, Math.PI * 2); ctx.fill();
+  }
+}
+
+function drawWind() {
+  const arrow = wind >= 0 ? '→' : '←';
+  const strength = Math.abs(wind).toFixed(1);
+  ctx.fillStyle = '#87CEEB'; ctx.font = '13px monospace';
+  ctx.fillText(`WIND ${arrow} ${strength}`, W - 130, 22);
+}
+
+newGame();
+
+function loop() {
+  drawSky();
+  if (terrain.length) {
+    drawTerrain();
+    drawTrajectory();
+    drawCannon();
+    drawTarget();
+    drawWind();
+  }
+  requestAnimationFrame(loop);
+}
+loop();
+</script>
+</body>
+</html>

+ 12 - 0
src/games/artillery/thumbnail.svg

@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
+  <rect width="120" height="80" fill="#87CEEB"/>
+  <polygon points="0,80 0,55 20,50 35,45 50,48 65,40 80,50 95,42 110,52 120,48 120,80" fill="#4a7c3f"/>
+  <rect x="8" y="56" width="20" height="8" fill="#555" rx="2"/>
+  <rect x="20" y="50" width="14" height="5" fill="#666" rx="2" transform="rotate(-30 27 52)"/>
+  <circle cx="30" cy="45" r="3" fill="#f44" opacity="0.8"/>
+  <circle cx="50" cy="35" r="2.5" fill="#f44" opacity="0.6"/>
+  <circle cx="70" cy="28" r="2" fill="#f44" opacity="0.4"/>
+  <rect x="95" y="38" width="8" height="12" fill="#f00" rx="1"/>
+  <polygon points="99,38 95,32 103,32" fill="#f00"/>
+  <text x="60" y="20" font-size="10" fill="#fff" text-anchor="middle" font-weight="bold" opacity="0.8">~</text>
+</svg>

+ 250 - 0
src/games/asteroids/index.html

@@ -0,0 +1,250 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Asteroids</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#topbar a:hover { text-decoration: underline; }
+#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
+canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
+#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
+#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">ASTEROIDS</span>
+</div>
+<div id="ui">
+  <span>SCORE: <b id="score">0</b></span>
+  <span>LIVES: <b id="lives">3</b></span>
+  <span>LEVEL: <b id="level">1</b></span>
+</div>
+<canvas id="c" width="600" height="420"></canvas>
+<div id="msg">Press SPACE to start</div>
+<div id="controls">&#8592;&#8594; Rotate &nbsp;|&nbsp; &#8593; Thrust &nbsp;|&nbsp; SPACE Shoot</div>
+<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="asteroids">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
+  </form>
+</div>
+<script>
+document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+const W = canvas.width, H = canvas.height;
+
+let state = 'idle';
+let score = 0, lives = 3, level = 1;
+let ship, bullets, asteroids, invincible, frames;
+
+const SHIP_SIZE = 14;
+const BULLET_SPEED = 9;
+const TURN_SPEED = 0.065;
+const THRUST = 0.18;
+const FRICTION = 0.985;
+
+const stars = Array.from({ length: 80 }, () => ({
+  x: Math.random() * W, y: Math.random() * H, r: Math.random() * 1.5 + 0.5, b: Math.random() * 0.7 + 0.3
+}));
+
+function initGame() {
+  ship = { x: W / 2, y: H / 2, vx: 0, vy: 0, angle: -Math.PI / 2, cooldown: 0 };
+  bullets = [];
+  invincible = 120;
+  frames = 0;
+  spawnAsteroids();
+}
+
+function randAsteroid(size, x, y) {
+  const angle = Math.random() * Math.PI * 2;
+  const speed = (0.6 + Math.random() * 0.8) * (level * 0.15 + 1);
+  const pts = 7 + Math.floor(Math.random() * 5);
+  const verts = Array.from({ length: pts }, (_, i) => {
+    const a = (i / pts) * Math.PI * 2;
+    const r = size * (0.75 + Math.random() * 0.5);
+    return { x: Math.cos(a) * r, y: Math.sin(a) * r };
+  });
+  return {
+    x: x ?? (Math.random() < 0.5 ? Math.random() * 100 : W - Math.random() * 100),
+    y: y ?? (Math.random() < 0.5 ? Math.random() * 100 : H - Math.random() * 100),
+    vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
+    size, verts, rot: 0, rotSpeed: (Math.random() - 0.5) * 0.04
+  };
+}
+
+function spawnAsteroids() {
+  asteroids = [];
+  for (let i = 0; i < 3 + level; i++) asteroids.push(randAsteroid(38));
+}
+
+const keys = {};
+document.addEventListener('keydown', e => {
+  keys[e.code] = true;
+  if (e.code === 'Space') {
+    e.preventDefault();
+    if (state === 'idle' || state === 'over') {
+      score = 0; lives = 3; level = 1;
+      document.getElementById('score').textContent = '0';
+      document.getElementById('lives').textContent = '3';
+      document.getElementById('level').textContent = '1';
+      initGame(); state = 'play';
+      document.getElementById('msg').textContent = '';
+    }
+  }
+});
+document.addEventListener('keyup', e => { keys[e.code] = false; });
+
+function wrap(obj) {
+  if (obj.x < -50) obj.x = W + 50;
+  if (obj.x > W + 50) obj.x = -50;
+  if (obj.y < -50) obj.y = H + 50;
+  if (obj.y > H + 50) obj.y = -50;
+}
+
+function drawShip(s, alpha) {
+  ctx.save();
+  ctx.translate(s.x, s.y);
+  ctx.rotate(s.angle);
+  ctx.strokeStyle = `rgba(255,165,0,${alpha})`;
+  ctx.lineWidth = 2;
+  ctx.beginPath();
+  ctx.moveTo(SHIP_SIZE, 0);
+  ctx.lineTo(-SHIP_SIZE * 0.7, -SHIP_SIZE * 0.6);
+  ctx.lineTo(-SHIP_SIZE * 0.4, 0);
+  ctx.lineTo(-SHIP_SIZE * 0.7, SHIP_SIZE * 0.6);
+  ctx.closePath();
+  ctx.stroke();
+  if (keys['ArrowUp'] && state === 'play' && frames % 4 < 2) {
+    ctx.fillStyle = `rgba(255,100,0,${alpha})`;
+    ctx.beginPath();
+    ctx.moveTo(-SHIP_SIZE * 0.4, 0);
+    ctx.lineTo(-SHIP_SIZE * 0.7, -SHIP_SIZE * 0.35);
+    ctx.lineTo(-SHIP_SIZE * 1.3, 0);
+    ctx.lineTo(-SHIP_SIZE * 0.7, SHIP_SIZE * 0.35);
+    ctx.fill();
+  }
+  ctx.restore();
+}
+
+function drawAsteroid(a) {
+  ctx.save();
+  ctx.translate(a.x, a.y);
+  ctx.rotate(a.rot);
+  ctx.strokeStyle = '#aaa';
+  ctx.lineWidth = 1.5;
+  ctx.beginPath();
+  ctx.moveTo(a.verts[0].x, a.verts[0].y);
+  for (let i = 1; i < a.verts.length; i++) ctx.lineTo(a.verts[i].x, a.verts[i].y);
+  ctx.closePath();
+  ctx.stroke();
+  ctx.restore();
+}
+
+function loop() {
+  ctx.clearRect(0, 0, W, H);
+
+  stars.forEach(s => {
+    ctx.fillStyle = `rgba(255,255,255,${s.b})`;
+    ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
+  });
+
+  if (state === 'play') {
+    if (keys['ArrowLeft']) ship.angle -= TURN_SPEED;
+    if (keys['ArrowRight']) ship.angle += TURN_SPEED;
+    if (keys['ArrowUp']) {
+      ship.vx += Math.cos(ship.angle) * THRUST;
+      ship.vy += Math.sin(ship.angle) * THRUST;
+    }
+    ship.vx *= FRICTION; ship.vy *= FRICTION;
+    ship.x += ship.vx; ship.y += ship.vy;
+    wrap(ship);
+
+    if (ship.cooldown > 0) ship.cooldown--;
+    if (keys['Space'] && ship.cooldown === 0) {
+      bullets.push({ x: ship.x + Math.cos(ship.angle) * SHIP_SIZE, y: ship.y + Math.sin(ship.angle) * SHIP_SIZE, vx: Math.cos(ship.angle) * BULLET_SPEED + ship.vx, vy: Math.sin(ship.angle) * BULLET_SPEED + ship.vy, life: 55 });
+      ship.cooldown = 12;
+    }
+
+    for (let i = bullets.length - 1; i >= 0; i--) {
+      const b = bullets[i];
+      b.x += b.vx; b.y += b.vy; b.life--;
+      wrap(b);
+      if (b.life <= 0) { bullets.splice(i, 1); continue; }
+      let hit = false;
+      for (let j = asteroids.length - 1; j >= 0; j--) {
+        const a = asteroids[j];
+        const dx = b.x - a.x, dy = b.y - a.y;
+        if (Math.sqrt(dx * dx + dy * dy) < a.size * 0.85) {
+          const pts = a.size > 25 ? 20 : a.size > 14 ? 50 : 100;
+          score += pts;
+          document.getElementById('score').textContent = score;
+          bullets.splice(i, 1);
+          asteroids.splice(j, 1);
+          if (a.size > 25) { asteroids.push(randAsteroid(18, a.x + 10, a.y)); asteroids.push(randAsteroid(18, a.x - 10, a.y)); }
+          else if (a.size > 14) { asteroids.push(randAsteroid(10, a.x, a.y + 10)); asteroids.push(randAsteroid(10, a.x, a.y - 10)); }
+          hit = true; break;
+        }
+      }
+      if (hit) continue;
+    }
+
+    for (let j = asteroids.length - 1; j >= 0; j--) {
+      const a = asteroids[j];
+      a.x += a.vx; a.y += a.vy; a.rot += a.rotSpeed;
+      wrap(a);
+      if (invincible <= 0) {
+        const dx = ship.x - a.x, dy = ship.y - a.y;
+        if (Math.sqrt(dx * dx + dy * dy) < a.size * 0.75 + SHIP_SIZE * 0.6) {
+          lives--;
+          document.getElementById('lives').textContent = lives;
+          if (lives <= 0) { state = 'over'; document.getElementById('msg').textContent = `GAME OVER! Score: ${score} — SPACE to retry`; document.getElementById('scoreInput').value = score; document.getElementById('scoreSubmit').style.display = 'block'; }
+          else { ship.x = W / 2; ship.y = H / 2; ship.vx = 0; ship.vy = 0; invincible = 120; }
+        }
+      }
+    }
+
+    if (invincible > 0) invincible--;
+
+    if (asteroids.length === 0) {
+      level++;
+      document.getElementById('level').textContent = level;
+      spawnAsteroids();
+    }
+
+    frames++;
+  }
+
+  asteroids.forEach(drawAsteroid);
+  bullets.forEach(b => {
+    ctx.fillStyle = '#fff';
+    ctx.beginPath(); ctx.arc(b.x, b.y, 2.5, 0, Math.PI * 2); ctx.fill();
+  });
+
+  const alpha = invincible > 0 && frames % 6 < 3 ? 0.3 : 1;
+  drawShip(ship, alpha);
+
+  for (let i = 0; i < lives; i++) {
+    ctx.save(); ctx.translate(16 + i * 22, H - 18); ctx.rotate(-Math.PI / 2);
+    ctx.strokeStyle = '#FFA500'; ctx.lineWidth = 1.5;
+    ctx.beginPath(); ctx.moveTo(9, 0); ctx.lineTo(-6, -5); ctx.lineTo(-3, 0); ctx.lineTo(-6, 5); ctx.closePath(); ctx.stroke();
+    ctx.restore();
+  }
+
+  requestAnimationFrame(loop);
+}
+
+initGame();
+loop();
+</script>
+</body>
+</html>

+ 25 - 0
src/games/asteroids/thumbnail.svg

@@ -0,0 +1,25 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
+  <rect width="400" height="220" fill="#000"/>
+  <circle cx="30" cy="20" r="1.5" fill="#fff" opacity="0.8"/>
+  <circle cx="80" cy="45" r="1" fill="#fff" opacity="0.6"/>
+  <circle cx="120" cy="10" r="2" fill="#fff" opacity="0.9"/>
+  <circle cx="200" cy="30" r="1" fill="#fff" opacity="0.7"/>
+  <circle cx="320" cy="15" r="1.5" fill="#fff" opacity="0.8"/>
+  <circle cx="370" cy="50" r="1" fill="#fff" opacity="0.6"/>
+  <circle cx="50" cy="180" r="1" fill="#fff" opacity="0.5"/>
+  <circle cx="350" cy="190" r="1.5" fill="#fff" opacity="0.7"/>
+  <circle cx="280" cy="8" r="1" fill="#fff" opacity="0.8"/>
+  <polygon points="85,60 110,45 140,55 155,80 140,110 110,120 75,105 65,80" fill="#888" stroke="#aaa" stroke-width="2"/>
+  <polygon points="240,30 265,22 290,35 300,60 285,85 255,90 230,75 225,50" fill="#777" stroke="#999" stroke-width="2"/>
+  <polygon points="310,130 330,118 355,128 365,152 350,172 325,178 305,165 298,142" fill="#999" stroke="#bbb" stroke-width="2"/>
+  <polygon points="40,130 58,120 75,132 80,155 65,170 43,172 28,160 25,140" fill="#666" stroke="#888" stroke-width="2"/>
+  <polygon points="200,105 196,95 200,92 204,95 200,108" fill="#FFA500" stroke="#FFA500" stroke-width="1"/>
+  <polygon points="196,108 192,118 200,112 208,118 204,108" fill="#FFA500"/>
+  <circle cx="200" cy="96" r="4" fill="#fff" stroke="#FFA500" stroke-width="1.5"/>
+  <rect x="192" y="107" width="4" height="10" fill="#ff6600" opacity="0.8"/>
+  <rect x="204" y="107" width="4" height="10" fill="#ff6600" opacity="0.8"/>
+  <rect x="197" y="115" width="6" height="14" fill="#ff3300" opacity="0.6"/>
+  <line x1="200" y1="80" x2="200" y2="50" stroke="#fff" stroke-width="2" opacity="0.8"/>
+  <circle cx="200" cy="46" r="3" fill="#fff" opacity="0.9"/>
+  <text x="200" y="215" font-family="monospace" font-size="12" fill="#FFA500" text-anchor="middle">SCORE: 0</text>
+</svg>

+ 693 - 0
src/games/audiopendulum/index.html

@@ -0,0 +1,693 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>Musical Double Pendulum</title>
+  <style>
+    :root {
+      --bg: #0a0a0a;
+      --panel: #1a1a1a;
+      --border: #333;
+      --accent: #4a9eff;
+      --axis: #2b6fff;
+      --grid: #121212;
+      --text: #ffffff;
+      --muted: #888;
+      --trace1: #ff6b4a;
+      --trace2: #4aff6b;
+      --warning: #ff6b4a;
+      --success: #4aff6b;
+      --audio: #ff9500;
+    }
+    * { box-sizing: border-box; }
+    body { margin: 0; padding: 14px; font-family: Consolas, monospace; background: var(--bg); color: var(--text); }
+    .container { display: flex; gap: 14px; align-items: flex-start; }
+    .simulation-panel {
+      background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
+      padding: 14px; flex: 1; min-width: 0;
+    }
+    .controls-panel {
+      background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
+      padding: 14px; width: 340px; flex-shrink: 0;
+    }
+    h3 { margin: 0 0 8px 0; color: var(--accent); font-size: 13px; letter-spacing: 2px; text-transform: uppercase; }
+    .audio-section { border: 2px solid var(--audio); border-radius: 8px; padding: 10px; margin: 8px 0; background: #1a0f00; }
+    .audio-section h3 { color: var(--audio); margin-bottom: 8px; }
+    .status { background:#000; color:var(--accent); padding:7px 10px; border-radius:6px; margin-bottom:7px; font-size:11px; }
+    .audio-status { background:#1a0f00; color:var(--audio); border: 1px solid var(--audio); }
+    .toolbar { display:flex; gap:8px; margin-bottom:8px; flex-wrap: wrap; }
+    button {
+      padding: 6px 10px; border: 1px solid var(--accent); background: var(--panel); color: var(--accent);
+      border-radius: 6px; cursor: pointer; transition: all .2s; font-family: inherit; font-size: 12px;
+    }
+    button:hover { background: var(--accent); color: #000; }
+    button.audio-btn { border-color: var(--audio); color: var(--audio); }
+    button.audio-btn:hover { background: var(--audio); color: #000; }
+    button:disabled { opacity: 0.5; cursor: not-allowed; }
+    canvas { background:#000; border:1px solid #444; display:block; width:100%; cursor:grab; }
+    canvas:active { cursor:grabbing; }
+    .geometry-info { background:#0a0a2a; color:#99aaff; padding:8px 10px; border-radius:6px; margin-top:8px; font-size:11px; }
+    .energy-info { background:#0a2a0a; color:#99ff99; padding:8px 10px; border-radius:6px; margin-top:5px; font-size:11px; }
+    .control-group { margin-bottom: 10px; padding-bottom:10px; border-bottom:1px solid #333; }
+    .control-group:last-child { border-bottom: none; }
+    label { display:block; margin-bottom:4px; font-size:11px; color:#ccc; text-transform:uppercase; letter-spacing:1px; }
+    input[type="range"] { width:100%; margin-bottom:4px; accent-color: var(--accent); }
+    .audio-control input[type="range"] { accent-color: var(--audio); }
+    .value-display { background:#000; color:var(--accent); padding:2px 6px; border-radius:3px; font-size:11px; float:right; }
+    .audio-value { color: var(--audio); }
+    .state-tag { display: none; font-size: 11px; }
+    .state-tag.visible { display: inline; }
+    #topbar { width:100%; padding:8px 16px; display:flex; align-items:center; gap:16px; background:#111; border-bottom:1px solid #333; margin-bottom:10px; }
+    #topbar a { color:#FFA500; text-decoration:none; font-size:14px; }
+    #topbar a:hover { text-decoration:underline; }
+  </style>
+</head>
+<body>
+  <div id="topbar">
+    <a href="/games" target="_top">&#8592; Back to Games</a>
+    <span style="color:#FFA500;font-weight:bold">AUDIO PENDULUM</span>
+  </div>
+  <div class="container">
+    <div class="simulation-panel" id="simPanel">
+      <div class="status" id="status">Status: Stopped | Energy: 0.00 J | Time: 0.00 s</div>
+      <div class="status audio-status" id="audioStatus">Tonal: Disabled | Percussion: Disabled | ♪: -- Hz | 🥁: 0 hits</div>
+      <div class="toolbar">
+        <button id="startBtn">&#9654; Start</button>
+        <button id="stopBtn">&#9209; Stop</button>
+        <button id="resetBtn">&#8635; Reset</button>
+        <button id="clearBtn">Clear Traces</button>
+        <button id="audioBtn" class="audio-btn">&#128266; Enable Audio</button>
+      </div>
+      <canvas id="pendulumCanvas" width="680" height="540"></canvas>
+      <div class="geometry-info" id="geometryInfo">
+        <strong>Physical State:</strong><br />
+        <span id="geoTheta">Initial position: vertical up (12 o'clock)</span><br />
+        <span id="geoOmega">State: Unstable equilibrium</span><br />
+        <span id="geoStateNearEq" class="state-tag" style="color:var(--success)">&#10003; NEAR EQUILIBRIUM</span>
+        <span id="geoStateMoving" class="state-tag visible">In motion</span>
+        <span id="geoStateRest" class="state-tag" style="color:var(--success)"><br />&#10003; AT REST</span>
+        <span id="geoStateTonal" class="state-tag" style="color:var(--audio)"><br />&#127925; TONAL AUDIO ACTIVE</span>
+        <span id="geoStatePerc" class="state-tag" style="color:var(--audio)"><br />&#129345; PERCUSSION ACTIVE</span>
+      </div>
+      <div class="energy-info" id="energyInfo">
+        <strong>Energy Conservation:</strong><br />
+        <span id="energyLine1">Initial: -- J | Current: -- J</span><br />
+        <span id="energyLine2">Energy drift: 0.000% | Status: CORRECT</span>
+        <span id="energySingularity" class="state-tag" style="color:var(--warning)"><br />Singularities: 0</span>
+      </div>
+    </div>
+
+    <div class="controls-panel">
+      <div class="control-group">
+        <label>Pendulum 1 Length (m) <span class="value-display" id="l1Display">1.00</span></label>
+        <input type="range" id="l1" min="0.2" max="3.0" step="0.05" value="1.0" />
+        <label>Pendulum 2 Length (m) <span class="value-display" id="l2Display">1.00</span></label>
+        <input type="range" id="l2" min="0.2" max="3.0" step="0.05" value="1.0" />
+      </div>
+      <div class="control-group">
+        <label>Pendulum 1 Mass (kg) <span class="value-display" id="m1Display">1.00</span></label>
+        <input type="range" id="m1" min="0.2" max="5.0" step="0.1" value="1.0" />
+        <label>Pendulum 2 Mass (kg) <span class="value-display" id="m2Display">1.00</span></label>
+        <input type="range" id="m2" min="0.2" max="5.0" step="0.1" value="1.0" />
+      </div>
+      <div class="control-group">
+        <label>Gravity (m/s²) <span class="value-display" id="gDisplay">9.81</span></label>
+        <input type="range" id="g" min="0.1" max="20.0" step="0.1" value="9.81" />
+        <label>Friction / Damping <span class="value-display" id="dampingDisplay">0.10</span></label>
+        <input type="range" id="damping" min="0" max="2.0" step="0.01" value="0.1" />
+      </div>
+      <div class="control-group">
+        <label>Angular Velocity 1 (rad/s) <span class="value-display" id="w1Display">0.00</span></label>
+        <input type="range" id="w1" min="-6.0" max="6.0" step="0.1" value="0.0" />
+        <label>Angular Velocity 2 (rad/s) <span class="value-display" id="w2Display">0.00</span></label>
+        <input type="range" id="w2" min="-6.0" max="6.0" step="0.1" value="0.0" />
+      </div>
+
+      <div class="audio-section">
+        <div class="control-group audio-control">
+          <label>Master Volume <span class="value-display audio-value" id="volumeDisplay">0.30</span></label>
+          <input type="range" id="volume" min="0" max="1.0" step="0.05" value="0.3" />
+          <label>Base Freq - Pendulum 1 (Hz) <span class="value-display audio-value" id="freq1Display">440</span></label>
+          <input type="range" id="freq1" min="200" max="1000" step="10" value="440" />
+          <label>Base Freq - Pendulum 2 (Hz) <span class="value-display audio-value" id="freq2Display">550</span></label>
+          <input type="range" id="freq2" min="200" max="1000" step="10" value="550" />
+          <label>Sensitivity <span class="value-display audio-value" id="sensitivityDisplay">2.0</span></label>
+          <input type="range" id="sensitivity" min="0.5" max="5.0" step="0.1" value="2.0" />
+        </div>
+        <div class="control-group audio-control">
+          <label>Percussion Volume <span class="value-display audio-value" id="percVolumeDisplay">0.00</span></label>
+          <input type="range" id="percVolume" min="0" max="1.0" step="0.05" value="0" />
+          <label>Peak Threshold <span class="value-display audio-value" id="thresholdDisplay">1.0</span></label>
+          <input type="range" id="threshold" min="0.2" max="3.0" step="0.1" value="1.0" />
+          <label>Cooldown (ms) <span class="value-display audio-value" id="cooldownDisplay">100</span></label>
+          <input type="range" id="cooldown" min="50" max="500" step="10" value="100" />
+        </div>
+      </div>
+
+      <div class="control-group">
+        <label>Integration &#916;t (s) <span class="value-display" id="dtDisplay">0.005</span></label>
+        <input type="range" id="dt" min="0.001" max="0.020" step="0.001" value="0.005" />
+      </div>
+    </div>
+  </div>
+
+  <script>
+    class MusicalDoublePendulum {
+      constructor(canvasId, panelId) {
+        this.canvas = document.getElementById(canvasId);
+        this.ctx = this.canvas.getContext('2d');
+        this.panel = document.getElementById(panelId);
+        this.width = this.canvas.width;
+        this.height = this.canvas.height;
+        this.originX = this.width / 2;
+        this.originY = this.height / 2;
+        this.l1 = 1.0; this.l2 = 1.0;
+        this.m1 = 1.0; this.m2 = 1.0;
+        this.g = 9.81;
+        this.damping = 0.1;
+        this.theta1 = Math.PI;
+        this.theta2 = 0.0;
+        this.omega1 = 0.0; this.omega2 = 0.0;
+        this.running = false;
+        this.time = 0;
+        this.dt = 0.005;
+        this.scale = 100;
+        this.trace1 = []; this.trace2 = [];
+        this.maxTraceLength = 3000;
+        this.dragging = false;
+        this.dragTarget = null;
+        this.initialEnergy = 0;
+        this.currentEnergy = 0;
+        this.maxEnergyDrift = 0;
+        this.energyHistory = [];
+        this.singularityCount = 0;
+        this.audioContext = null;
+        this.oscillator1 = null; this.oscillator2 = null;
+        this.gainNode1 = null; this.gainNode2 = null;
+        this.masterGain = null;
+        this.audioEnabled = false;
+        this.percussionEnabled = false;
+        this.kickBuffer = null; this.snareBuffer = null;
+        this.lastOmega1 = 0; this.lastOmega2 = 0;
+        this.omega1Trend = 0; this.omega2Trend = 0;
+        this.lastTrigger1 = 0; this.lastTrigger2 = 0;
+        this.triggerCooldown = 0.1;
+        this.baseFreq1 = 440; this.baseFreq2 = 550;
+        this.volume = 0.3;
+        this.sensitivity = 2.0;
+        this.percussionVolume = 0;
+        this.peakThreshold = 1.0;
+        this.currentFreq1 = 0; this.currentFreq2 = 0;
+        this.kickCount = 0; this.snareCount = 0;
+        this.setupEventListeners();
+        this.updateControls();
+        this.initialEnergy = this.computeEnergy();
+        requestAnimationFrame(() => { this.resizeCanvas(); });
+      }
+
+      createPercussionSounds() {
+        if (!this.audioContext) return;
+        const sr = this.audioContext.sampleRate;
+        const kickBuf = this.audioContext.createBuffer(1, 0.5 * sr, sr);
+        const kd = kickBuf.getChannelData(0);
+        for (let i = 0; i < kd.length; i++) {
+          const t = i / sr;
+          const env = Math.exp(-t * 15);
+          kd[i] = Math.sin(2 * Math.PI * 60 * (1 - t * 0.8) * t) * env + (Math.random() - 0.5) * 0.1 * env;
+        }
+        this.kickBuffer = kickBuf;
+        const snareBuf = this.audioContext.createBuffer(1, 0.2 * sr, sr);
+        const sd = snareBuf.getChannelData(0);
+        for (let i = 0; i < sd.length; i++) {
+          const t = i / sr;
+          const env = Math.exp(-t * 30);
+          sd[i] = (Math.sin(2 * Math.PI * 200 * t) * 0.3 + (Math.random() - 0.5) * 0.7) * env;
+        }
+        this.snareBuffer = snareBuf;
+      }
+
+      playPercussion(type) {
+        if (!this.percussionEnabled || !this.audioContext) return;
+        const buf = type === 'kick' ? this.kickBuffer : this.snareBuffer;
+        if (!buf) return;
+        const now = this.audioContext.currentTime;
+        const src = this.audioContext.createBufferSource();
+        const gain = this.audioContext.createGain();
+        src.buffer = buf;
+        gain.gain.setValueAtTime(this.percussionVolume * (type === 'kick' ? 0.8 : 0.6), now);
+        src.connect(gain);
+        gain.connect(this.audioContext.destination);
+        src.start(now);
+        if (type === 'kick') this.kickCount++; else this.snareCount++;
+        setTimeout(() => { try { src.disconnect(); gain.disconnect(); } catch (e) {} }, 600);
+      }
+
+      detectPeaks() {
+        if (!this.percussionEnabled) return;
+        const now = this.audioContext ? this.audioContext.currentTime : this.time;
+        const d1 = this.omega1 - this.lastOmega1;
+        const d2 = this.omega2 - this.lastOmega2;
+        const t1 = d1 > 0.01 ? 1 : (d1 < -0.01 ? -1 : 0);
+        const t2 = d2 > 0.01 ? 1 : (d2 < -0.01 ? -1 : 0);
+        if (this.omega1Trend === 1 && t1 === -1 && Math.abs(this.omega1) > this.peakThreshold && now - this.lastTrigger1 > this.triggerCooldown) {
+          this.playPercussion('kick');
+          this.lastTrigger1 = now;
+          this.flashPercStatus('kick');
+        }
+        if (this.omega2Trend === 1 && t2 === -1 && Math.abs(this.omega2) > this.peakThreshold && now - this.lastTrigger2 > this.triggerCooldown) {
+          this.playPercussion('snare');
+          this.lastTrigger2 = now;
+          this.flashPercStatus('snare');
+        }
+        this.lastOmega1 = this.omega1; this.lastOmega2 = this.omega2;
+        this.omega1Trend = t1; this.omega2Trend = t2;
+      }
+
+      flashPercStatus(type) {
+        const el = document.getElementById(type === 'kick' ? 'kick-status' : 'snare-status');
+        if (!el) return;
+        const orig = el.textContent;
+        el.textContent = type === 'kick' ? '\u{1F941} Kick: HIT!' : '\u{1F514} Snare: HIT!';
+        el.style.color = type === 'kick' ? '#ff6b4a' : '#4aff6b';
+        setTimeout(() => { el.textContent = orig; el.style.color = ''; }, 150);
+      }
+
+      async initAudio() {
+        try {
+          this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
+          this.masterGain = this.audioContext.createGain();
+          this.masterGain.connect(this.audioContext.destination);
+          this.masterGain.gain.setValueAtTime(this.volume, this.audioContext.currentTime);
+          this.oscillator1 = this.audioContext.createOscillator();
+          this.gainNode1 = this.audioContext.createGain();
+          this.oscillator1.connect(this.gainNode1);
+          this.gainNode1.connect(this.masterGain);
+          this.oscillator2 = this.audioContext.createOscillator();
+          this.gainNode2 = this.audioContext.createGain();
+          this.oscillator2.connect(this.gainNode2);
+          this.gainNode2.connect(this.masterGain);
+          this.oscillator1.type = 'sine';
+          this.oscillator2.type = 'triangle';
+          this.oscillator1.frequency.setValueAtTime(this.baseFreq1, this.audioContext.currentTime);
+          this.oscillator2.frequency.setValueAtTime(this.baseFreq2, this.audioContext.currentTime);
+          this.gainNode1.gain.setValueAtTime(0, this.audioContext.currentTime);
+          this.gainNode2.gain.setValueAtTime(0, this.audioContext.currentTime);
+          this.oscillator1.start();
+          this.oscillator2.start();
+          this.audioEnabled = true;
+          return true;
+        } catch (e) {
+          return false;
+        }
+      }
+
+      updateAudio() {
+        if (!this.audioEnabled || !this.audioContext) return;
+        const now = this.audioContext.currentTime;
+        this.currentFreq1 = Math.max(100, Math.min(2000, this.baseFreq1 * (1 + Math.abs(this.omega1) * this.sensitivity * 0.1)));
+        this.currentFreq2 = Math.max(100, Math.min(2000, this.baseFreq2 * (1 + Math.abs(this.omega2) * this.sensitivity * 0.1)));
+        this.oscillator1.frequency.setTargetAtTime(this.currentFreq1, now, 0.01);
+        this.oscillator2.frequency.setTargetAtTime(this.currentFreq2, now, 0.01);
+        this.gainNode1.gain.setTargetAtTime(Math.min(0.5, Math.abs(this.omega1) * 0.1) * this.volume, now, 0.01);
+        this.gainNode2.gain.setTargetAtTime(Math.min(0.5, Math.abs(this.omega2) * 0.1) * this.volume, now, 0.01);
+      }
+
+      stopAudio() {
+        if (this.audioContext && this.audioEnabled) {
+          try { this.oscillator1.stop(); this.oscillator2.stop(); this.audioContext.close(); } catch (e) {}
+        }
+        this.audioEnabled = false;
+        this.audioContext = null;
+      }
+
+      setupEventListeners() {
+        const controls = ['l1','l2','m1','m2','g','damping','w1','w2','dt','volume','freq1','freq2','sensitivity','percVolume','threshold','cooldown'];
+        controls.forEach(id => {
+          document.getElementById(id).addEventListener('input', (e) => {
+            const v = parseFloat(e.target.value);
+            if (id === 'w1') this.omega1 = v;
+            else if (id === 'w2') this.omega2 = v;
+            else if (id === 'dt') this.dt = v;
+            else if (id === 'volume') this.volume = v;
+            else if (id === 'freq1') this.baseFreq1 = v;
+            else if (id === 'freq2') this.baseFreq2 = v;
+            else if (id === 'sensitivity') this.sensitivity = v;
+            else if (id === 'percVolume') {
+              this.percussionVolume = v;
+              if (v > 0 && !this.percussionEnabled) {
+                if (!this.audioContext) this.initAudio().then(ok => { if (ok) { this.createPercussionSounds(); this.percussionEnabled = true; } });
+                else { this.createPercussionSounds(); this.percussionEnabled = true; }
+              } else if (v === 0) {
+                this.percussionEnabled = false;
+              }
+            }
+            else if (id === 'threshold') this.peakThreshold = v;
+            else if (id === 'cooldown') this.triggerCooldown = v / 1000;
+            else this[id] = v;
+            this.updateControls();
+            if (id === 'l1' || id === 'l2') this.updateScale();
+            if (this.audioEnabled && this.masterGain && (id === 'volume' || id === 'freq1' || id === 'freq2' || id === 'sensitivity')) {
+              this.masterGain.gain.setTargetAtTime(this.volume, this.audioContext.currentTime, 0.1);
+            }
+            if (!this.running) { this.initialEnergy = this.computeEnergy(); this.maxEnergyDrift = 0; this.energyHistory = []; }
+            if (!this.running) this.draw();
+          });
+        });
+
+        document.getElementById('startBtn').addEventListener('click', () => this.start());
+        document.getElementById('stopBtn').addEventListener('click', () => this.stop());
+        document.getElementById('resetBtn').addEventListener('click', () => this.reset());
+        document.getElementById('clearBtn').addEventListener('click', () => this.clearTraces());
+        document.getElementById('audioBtn').addEventListener('click', async () => {
+          const btn = document.getElementById('audioBtn');
+          if (!this.audioEnabled) {
+            if (await this.initAudio()) {
+              btn.textContent = 'Disable Audio';
+              btn.style.background = '#ff9500';
+              btn.style.color = '#000';
+            }
+          } else {
+            this.stopAudio();
+            btn.textContent = '\u{1F50A} Enable Audio';
+            btn.style.background = '';
+            btn.style.color = '#ff9500';
+          }
+        });
+
+        this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
+        this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
+        this.canvas.addEventListener('mouseup', () => this.onMouseUp());
+        this.canvas.addEventListener('mouseleave', () => this.onMouseUp());
+        window.addEventListener('resize', () => this.resizeCanvas());
+      }
+
+      updateControls() {
+        const set = (id, v, d=2) => { const el = document.getElementById(id); if (el) el.textContent = v.toFixed(d); };
+        set('l1Display', this.l1); set('l2Display', this.l2);
+        set('m1Display', this.m1); set('m2Display', this.m2);
+        set('gDisplay', this.g); set('dampingDisplay', this.damping);
+        set('w1Display', this.omega1); set('w2Display', this.omega2);
+        set('dtDisplay', this.dt, 3);
+        set('volumeDisplay', this.volume);
+        const f1el = document.getElementById('freq1Display'); if (f1el) f1el.textContent = this.baseFreq1.toFixed(0);
+        const f2el = document.getElementById('freq2Display'); if (f2el) f2el.textContent = this.baseFreq2.toFixed(0);
+        set('sensitivityDisplay', this.sensitivity, 1);
+        set('percVolumeDisplay', this.percussionVolume);
+        set('thresholdDisplay', this.peakThreshold, 1);
+        const cdel = document.getElementById('cooldownDisplay'); if (cdel) cdel.textContent = (this.triggerCooldown * 1000).toFixed(0);
+      }
+
+      resizeCanvas() {
+        const w = this.canvas.clientWidth;
+        if (w < 10) return;
+        this.canvas.width = w;
+        this.canvas.height = Math.round(w * (540 / 680));
+        this.updateCanvasMetrics();
+      }
+
+      getMousePos(e) {
+        const r = this.canvas.getBoundingClientRect();
+        return { x: e.clientX - r.left, y: e.clientY - r.top };
+      }
+
+      onMouseDown(e) {
+        if (this.running) return;
+        const m = this.getMousePos(e);
+        const p1 = this.getPendulumPosition(1);
+        const p2 = this.getPendulumPosition(2);
+        if (Math.hypot(m.x - p1.x, m.y - p1.y) < 20) { this.dragging = true; this.dragTarget = 1; }
+        else if (Math.hypot(m.x - p2.x, m.y - p2.y) < 20) { this.dragging = true; this.dragTarget = 2; }
+      }
+
+      onMouseMove(e) {
+        if (!this.dragging || this.running) return;
+        const m = this.getMousePos(e);
+        if (this.dragTarget === 1) {
+          this.theta1 = Math.atan2(m.x - this.originX, m.y - this.originY);
+        } else {
+          const p1 = this.getPendulumPosition(1);
+          this.theta2 = Math.atan2(m.x - p1.x, m.y - p1.y) - this.theta1;
+        }
+        this.initialEnergy = this.computeEnergy();
+        this.maxEnergyDrift = 0;
+        this.energyHistory = [];
+        this.draw();
+      }
+
+      onMouseUp() { this.dragging = false; this.dragTarget = null; }
+
+      updateCanvasMetrics() {
+        this.width = this.canvas.width; this.height = this.canvas.height;
+        this.originX = this.width / 2; this.originY = this.height / 2;
+        this.updateScale();
+        if (!this.running) this.draw();
+      }
+
+      updateScale() {
+        const half = 0.5 * Math.min(this.width, this.height) * 0.9;
+        const len = this.l1 + this.l2;
+        this.scale = len > 0 ? half / len : 100;
+      }
+
+      getPendulumPosition(n) {
+        if (n === 1) return {
+          x: this.originX + this.l1 * this.scale * Math.sin(this.theta1),
+          y: this.originY + this.l1 * this.scale * Math.cos(this.theta1)
+        };
+        const p1 = this.getPendulumPosition(1);
+        return {
+          x: p1.x + this.l2 * this.scale * Math.sin(this.theta1 + this.theta2),
+          y: p1.y + this.l2 * this.scale * Math.cos(this.theta1 + this.theta2)
+        };
+      }
+
+      accelerations() {
+        const { m1, m2, l1, l2, g } = this;
+        const th1 = this.theta1, th2 = this.theta2, w1 = this.omega1, w2 = this.omega2;
+        const s12 = Math.sin(th1 - th2), c12 = Math.cos(th1 - th2);
+        const den = 2*m1 + m2 - m2 * Math.cos(2*th1 - 2*th2);
+        if (Math.abs(den) < 1e-8) {
+          this.singularityCount++;
+          return { a1: -0.1*w1 - 0.5*th1, a2: -0.1*w2 - 0.5*th2 };
+        }
+        let a1 = (-g*(2*m1+m2)*Math.sin(th1) - m2*g*Math.sin(th1-2*th2) - 2*s12*m2*(w2*w2*l2 + w1*w1*l1*c12)) / (l1*den);
+        let a2 = (2*s12*(w1*w1*l1*(m1+m2) + g*(m1+m2)*Math.cos(th1) + w2*w2*l2*m2*c12)) / (l2*den);
+        if (this.damping > 0) {
+          const vx1 = l1*w1*Math.cos(th1), vy1 = -l1*w1*Math.sin(th1);
+          const vx2 = vx1 + l2*(w1+w2)*Math.cos(th1+th2), vy2 = vy1 - l2*(w1+w2)*Math.sin(th1+th2);
+          const dr = this.damping * 0.05;
+          const v1sq = vx1*vx1+vy1*vy1, v2sq = vx2*vx2+vy2*vy2;
+          if (v1sq > 1e-6) a1 += -dr * v1sq * Math.sign(w1) / l1;
+          if (v2sq > 1e-6) a2 += -dr * v2sq * Math.sign(w2) / l2;
+          const jf = this.damping * 0.3;
+          a1 -= jf*(w1 + 0.5*th1); a2 -= jf*(w2 + 0.5*th2);
+          if (this.damping > 1.0) { const hd = (this.damping-1.0)*2.0; a1 -= hd*w1; a2 -= hd*w2; }
+        }
+        return { a1, a2 };
+      }
+
+      rk4Step(dt) {
+        const deriv = (th1, th2, w1, w2) => {
+          const [st1, st2, sw1, sw2] = [this.theta1, this.theta2, this.omega1, this.omega2];
+          this.theta1=th1; this.theta2=th2; this.omega1=w1; this.omega2=w2;
+          const {a1,a2} = this.accelerations();
+          this.theta1=st1; this.theta2=st2; this.omega1=sw1; this.omega2=sw2;
+          return {dth1:w1, dth2:w2, dw1:a1, dw2:a2};
+        };
+        const s = {th1:this.theta1, th2:this.theta2, w1:this.omega1, w2:this.omega2};
+        const k1=deriv(s.th1,s.th2,s.w1,s.w2);
+        const k2=deriv(s.th1+.5*dt*k1.dth1, s.th2+.5*dt*k1.dth2, s.w1+.5*dt*k1.dw1, s.w2+.5*dt*k1.dw2);
+        const k3=deriv(s.th1+.5*dt*k2.dth1, s.th2+.5*dt*k2.dth2, s.w1+.5*dt*k2.dw1, s.w2+.5*dt*k2.dw2);
+        const k4=deriv(s.th1+dt*k3.dth1, s.th2+dt*k3.dth2, s.w1+dt*k3.dw1, s.w2+dt*k3.dw2);
+        this.theta1 += (dt/6)*(k1.dth1+2*k2.dth1+2*k3.dth1+k4.dth1);
+        this.theta2 += (dt/6)*(k1.dth2+2*k2.dth2+2*k3.dth2+k4.dth2);
+        this.omega1 += (dt/6)*(k1.dw1+2*k2.dw1+2*k3.dw1+k4.dw1);
+        this.omega2 += (dt/6)*(k1.dw2+2*k2.dw2+2*k3.dw2+k4.dw2);
+      }
+
+      update() {
+        if (!this.running) return;
+        this.rk4Step(this.dt);
+        this.updateAudio();
+        this.detectPeaks();
+        const p1 = this.getPendulumPosition(1), p2 = this.getPendulumPosition(2);
+        this.trace1.push({x:p1.x, y:p1.y}); this.trace2.push({x:p2.x, y:p2.y});
+        if (this.trace1.length > this.maxTraceLength) this.trace1.shift();
+        if (this.trace2.length > this.maxTraceLength) this.trace2.shift();
+        this.currentEnergy = this.computeEnergy();
+        this.energyHistory.push(this.currentEnergy);
+        if (this.energyHistory.length > 1000) this.energyHistory.shift();
+        this.maxEnergyDrift = Math.max(this.maxEnergyDrift, Math.abs(this.currentEnergy - this.initialEnergy));
+        this.time += this.dt;
+        this.updateStatus();
+      }
+
+      computeEnergy() {
+        const vx1 = this.l1*this.omega1*Math.cos(this.theta1);
+        const vy1 = -this.l1*this.omega1*Math.sin(this.theta1);
+        const vx2 = vx1 + this.l2*(this.omega1+this.omega2)*Math.cos(this.theta1+this.theta2);
+        const vy2 = vy1 - this.l2*(this.omega1+this.omega2)*Math.sin(this.theta1+this.theta2);
+        const T = 0.5*this.m1*(vx1*vx1+vy1*vy1) + 0.5*this.m2*(vx2*vx2+vy2*vy2);
+        const V = this.m1*this.g*this.l1*(1-Math.cos(this.theta1)) +
+                  this.m2*this.g*(this.l1*(1-Math.cos(this.theta1)) + this.l2*(1-Math.cos(this.theta1+this.theta2)));
+        return T + V;
+      }
+
+      setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; }
+      setVisible(id, v) { const el = document.getElementById(id); if (el) el.classList.toggle('visible', v); }
+
+      updateStatus() {
+        const state = this.running ? 'Running' : 'Stopped';
+        this.setText('status', 'Status: ' + state + ' | Energy: ' + this.currentEnergy.toFixed(3) + ' J | Time: ' + this.time.toFixed(2) + ' s');
+        const f1 = this.audioEnabled ? this.currentFreq1.toFixed(0) : '--';
+        const f2 = this.audioEnabled ? this.currentFreq2.toFixed(0) : '--';
+        this.setText('audioStatus',
+          'Tonal: ' + (this.audioEnabled ? 'Active' : 'Disabled') +
+          ' | Percussion: ' + (this.percussionEnabled ? 'Active' : 'Disabled') +
+          ' | \u266a: ' + f1 + '/' + f2 + ' Hz | \u{1F941}: ' + (this.kickCount + this.snareCount) + ' hits');
+        const driftPct = this.initialEnergy !== 0 ? (this.maxEnergyDrift / Math.abs(this.initialEnergy)) * 100 : 0;
+        let energyStatus = 'CORRECT';
+        if (driftPct > 1.0) energyStatus = 'HIGH DRIFT';
+        else if (driftPct > 0.1) energyStatus = 'MINOR DRIFT';
+        this.setText('energyLine1', 'Initial: ' + this.initialEnergy.toFixed(4) + ' J | Current: ' + this.currentEnergy.toFixed(4) + ' J');
+        this.setText('energyLine2', 'Energy drift: ' + driftPct.toFixed(3) + '% | Status: ' + energyStatus);
+        this.setVisible('energySingularity', this.singularityCount > 0);
+        if (this.singularityCount > 0) this.setText('energySingularity', 'Singularities: ' + this.singularityCount);
+
+        this.setText('geoTheta', '\u03b8\u2081=' + this.theta1.toFixed(3) + ' rad, \u03b8\u2082=' + this.theta2.toFixed(3) + ' rad');
+        this.setText('geoOmega', '\u03c9\u2081=' + this.omega1.toFixed(3) + ' rad/s, \u03c9\u2082=' + this.omega2.toFixed(3) + ' rad/s');
+        const vel = Math.sqrt(this.omega1*this.omega1 + this.omega2*this.omega2);
+        const nearEq = Math.abs(this.theta1) < 0.1 && Math.abs(this.theta2) < 0.1 && vel < 0.1;
+        this.setVisible('geoStateNearEq', nearEq);
+        this.setVisible('geoStateMoving', !nearEq);
+        this.setVisible('geoStateRest', this.damping > 1.0 && vel < 0.01);
+        this.setVisible('geoStateTonal', this.audioEnabled && vel > 0.1);
+        this.setVisible('geoStatePerc', this.percussionEnabled && vel > 0.1);
+      }
+
+      draw() {
+        this.ctx.clearRect(0, 0, this.width, this.height);
+        this.drawGridAndAxes();
+        this.drawTraces();
+        this.drawPendulum();
+        this.drawInfo();
+      }
+
+      drawGridAndAxes() {
+        this.ctx.strokeStyle = '#121212'; this.ctx.lineWidth = 1;
+        for (let x = 0; x <= this.width; x += 40) { this.ctx.beginPath(); this.ctx.moveTo(x,0); this.ctx.lineTo(x,this.height); this.ctx.stroke(); }
+        for (let y = 0; y <= this.height; y += 40) { this.ctx.beginPath(); this.ctx.moveTo(0,y); this.ctx.lineTo(this.width,y); this.ctx.stroke(); }
+        this.ctx.strokeStyle = '#2b6fff'; this.ctx.lineWidth = 2;
+        this.ctx.beginPath(); this.ctx.moveTo(this.originX,0); this.ctx.lineTo(this.originX,this.height); this.ctx.stroke();
+        this.ctx.beginPath(); this.ctx.moveTo(0,this.originY); this.ctx.lineTo(this.width,this.originY); this.ctx.stroke();
+        this.ctx.fillStyle = '#4a9eff'; this.ctx.beginPath(); this.ctx.arc(this.originX,this.originY,5,0,Math.PI*2); this.ctx.fill();
+        this.ctx.fillStyle = '#fff'; this.ctx.beginPath(); this.ctx.arc(this.originX,this.originY,2,0,Math.PI*2); this.ctx.fill();
+      }
+
+      drawTraces() {
+        const drawTrace = (trace, color, lw, alpha) => {
+          if (trace.length < 2) return;
+          this.ctx.strokeStyle = color; this.ctx.lineWidth = lw; this.ctx.globalAlpha = alpha;
+          this.ctx.beginPath(); this.ctx.moveTo(trace[0].x, trace[0].y);
+          for (let i = 1; i < trace.length; i++) this.ctx.lineTo(trace[i].x, trace[i].y);
+          this.ctx.stroke();
+        };
+        drawTrace(this.trace1, '#ff6b4a', 1.5, 0.7);
+        drawTrace(this.trace2, '#4aff6b', 2.5, 0.9);
+        this.ctx.globalAlpha = 1.0;
+      }
+
+      drawPendulum() {
+        const p1 = this.getPendulumPosition(1), p2 = this.getPendulumPosition(2);
+        this.ctx.strokeStyle = '#fff'; this.ctx.lineWidth = 4;
+        this.ctx.beginPath(); this.ctx.moveTo(this.originX,this.originY); this.ctx.lineTo(p1.x,p1.y); this.ctx.stroke();
+        this.ctx.beginPath(); this.ctx.moveTo(p1.x,p1.y); this.ctx.lineTo(p2.x,p2.y); this.ctx.stroke();
+        const r1 = Math.max(6, 4+this.m1*4), r2 = Math.max(6, 4+this.m2*4);
+        const drawBob = (px, py, r, color, omega) => {
+          if (this.audioEnabled && this.running) {
+            const intensity = Math.min(1, Math.abs(omega) * 0.2);
+            if (intensity > 0.1) { this.ctx.shadowBlur = 15*intensity; this.ctx.shadowColor = color; }
+          }
+          this.ctx.fillStyle = color; this.ctx.beginPath(); this.ctx.arc(px,py,r,0,Math.PI*2); this.ctx.fill();
+          this.ctx.strokeStyle = '#fff'; this.ctx.lineWidth = 2; this.ctx.beginPath(); this.ctx.arc(px,py,r,0,Math.PI*2); this.ctx.stroke();
+          this.ctx.shadowBlur = 0;
+        };
+        drawBob(p1.x, p1.y, r1, '#ff6b4a', this.omega1);
+        drawBob(p2.x, p2.y, r2, '#4aff6b', this.omega2);
+      }
+
+      drawInfo() {
+        this.ctx.fillStyle = '#4a9eff'; this.ctx.font = '13px Consolas';
+        this.ctx.fillText('\u03b8\u2081: ' + this.theta1.toFixed(3) + ' rad', 10, 20);
+        this.ctx.fillText('\u03b8\u2082: ' + this.theta2.toFixed(3) + ' rad', 10, 36);
+        this.ctx.fillText('\u03c9\u2081: ' + this.omega1.toFixed(3) + ' rad/s', 10, 52);
+        this.ctx.fillText('\u03c9\u2082: ' + this.omega2.toFixed(3) + ' rad/s', 10, 68);
+        let y = 84;
+        if (this.audioEnabled && this.running) {
+          this.ctx.fillStyle = '#ff9500';
+          this.ctx.fillText('\u266a1: ' + this.currentFreq1.toFixed(0) + ' Hz', 10, y);
+          this.ctx.fillText('\u266a2: ' + this.currentFreq2.toFixed(0) + ' Hz', 10, y+16);
+          y += 32;
+        }
+        if (this.percussionEnabled && this.running) {
+          this.ctx.fillStyle = '#ff6b4a';
+          this.ctx.fillText('\u{1F941}: ' + this.kickCount + ' hits', 10, y);
+          this.ctx.fillStyle = '#4aff6b';
+          this.ctx.fillText('\u{1F514}: ' + this.snareCount + ' hits', 10, y+16);
+          y += 32;
+        }
+        if (this.damping > 0) {
+          this.ctx.fillStyle = this.damping > 1.0 ? '#ff6b4a' : '#ffaa4a';
+          this.ctx.fillText('Friction: ' + this.damping.toFixed(2), 10, y);
+        }
+        this.ctx.fillStyle = '#888'; this.ctx.font = '11px Consolas';
+        this.ctx.fillText('Scale: ' + this.scale.toFixed(1) + ' px/m', 10, this.height-10);
+      }
+
+      start() {
+        if (!this.running) {
+          this.running = true;
+          this.initialEnergy = this.computeEnergy();
+          this.maxEnergyDrift = 0;
+          this.animate();
+        }
+      }
+
+      stop() {
+        this.running = false;
+        if (this.audioEnabled) {
+          this.gainNode1.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
+          this.gainNode2.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
+        }
+      }
+
+      reset() {
+        this.stop();
+        this.theta1 = Math.PI; this.theta2 = 0.0;
+        this.omega1 = 0; this.omega2 = 0;
+        this.time = 0;
+        this.clearTraces();
+        this.initialEnergy = this.computeEnergy();
+        this.currentEnergy = this.initialEnergy;
+        this.maxEnergyDrift = 0; this.energyHistory = []; this.singularityCount = 0;
+        this.kickCount = 0; this.snareCount = 0;
+        this.lastTrigger1 = 0; this.lastTrigger2 = 0;
+        this.lastOmega1 = 0; this.lastOmega2 = 0;
+        this.omega1Trend = 0; this.omega2Trend = 0;
+        this.resizeCanvas();
+      }
+
+      clearTraces() { this.trace1 = []; this.trace2 = []; if (!this.running) this.draw(); }
+
+      animate() {
+        if (!this.running) return;
+        this.update(); this.draw();
+        requestAnimationFrame(() => this.animate());
+      }
+    }
+
+    const pendulum = new MusicalDoublePendulum('pendulumCanvas', 'simPanel');
+  </script>
+</body>
+</html>

+ 31 - 0
src/games/audiopendulum/thumbnail.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
+  <rect width="400" height="220" fill="#000"/>
+  <rect x="10" y="10" width="380" height="200" rx="6" fill="#0a0a0a" stroke="#1a1a1a" stroke-width="1"/>
+
+  <line x1="200" y1="30" x2="200" y2="220" stroke="#2b6fff" stroke-width="1" opacity="0.4"/>
+  <line x1="10" y1="110" x2="390" y2="110" stroke="#2b6fff" stroke-width="1" opacity="0.4"/>
+
+  <circle cx="200" cy="50" r="5" fill="#4a9eff"/>
+  <circle cx="200" cy="50" r="2" fill="#fff"/>
+
+  <line x1="200" y1="50" x2="148" y2="128" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
+  <line x1="148" y1="128" x2="220" y2="185" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
+
+  <circle cx="148" cy="128" r="10" fill="#ff6b4a"/>
+  <circle cx="148" cy="128" r="10" fill="none" stroke="#fff" stroke-width="1.5"/>
+
+  <circle cx="220" cy="185" r="12" fill="#4aff6b"/>
+  <circle cx="220" cy="185" r="12" fill="none" stroke="#fff" stroke-width="1.5"/>
+
+  <path d="M 200 50 Q 175 90 148 128 Q 130 155 120 170 Q 110 185 130 192 Q 155 200 175 185 Q 200 168 220 152 Q 245 135 255 118 Q 265 100 248 88 Q 228 76 210 88 Q 192 100 178 115" stroke="#ff6b4a" stroke-width="1.2" fill="none" opacity="0.6"/>
+  <path d="M 148 128 Q 170 148 195 162 Q 218 175 232 180 Q 248 184 255 175 Q 265 163 258 148 Q 250 132 235 125 Q 218 117 205 122 Q 190 128 182 138 Q 172 150 168 162" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.75"/>
+
+  <path d="M 270 85 Q 278 78 278 85 Q 278 92 270 92" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.9"/>
+  <path d="M 278 80 Q 290 72 290 85 Q 290 98 278 90" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.7"/>
+  <path d="M 290 75 Q 305 65 305 85 Q 305 105 290 95" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.5"/>
+
+  <path d="M 285 128 Q 293 121 293 128 Q 293 135 285 135" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.9"/>
+  <path d="M 293 123 Q 305 115 305 128 Q 305 141 293 133" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.7"/>
+
+  <text x="200" y="213" font-family="'Courier New', monospace" font-size="13" fill="#ff9500" text-anchor="middle" font-weight="bold" letter-spacing="3">AUDIOPENDULUM</text>
+</svg>

+ 236 - 0
src/games/cocoland/index.html

@@ -0,0 +1,236 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Cocoland</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#topbar a:hover { text-decoration: underline; }
+#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
+canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
+#msg { font-size: 18px; color: #FFA500; margin: 8px; text-align: center; min-height: 28px; }
+#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">COCOLAND</span>
+</div>
+<div id="ui">
+  <span>SCORE: <b id="score">0</b></span>
+  <span>BEST: <b id="best">0</b></span>
+</div>
+<canvas id="c" width="600" height="250"></canvas>
+<div id="msg">Press SPACE or TAP to start</div>
+<div id="controls">SPACE / TAP — Jump &nbsp;|&nbsp; Double jump allowed</div>
+<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="cocoland">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
+  </form>
+</div>
+<script>
+document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+const scoreEl = document.getElementById('score');
+const bestEl = document.getElementById('best');
+const msgEl = document.getElementById('msg');
+
+const W = canvas.width, H = canvas.height;
+const GROUND = H - 40;
+const GRAVITY = 0.6;
+const JUMP = -13;
+
+let state = 'idle';
+let score = 0, best = 0, frames = 0;
+let coco, obstacles, coins, speed;
+
+function reset() {
+  coco = { x: 80, y: GROUND - 44, vy: 0, r: 22, jumps: 0 };
+  obstacles = [];
+  coins = [];
+  speed = 2;
+  score = 0;
+  frames = 0;
+  scoreEl.textContent = '0';
+}
+
+function jump() {
+  if (state === 'idle' || state === 'over') {
+    reset();
+    state = 'play';
+    msgEl.textContent = '';
+    return;
+  }
+  if (coco.jumps < 2) {
+    coco.vy = JUMP;
+    coco.jumps++;
+  }
+}
+
+document.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); jump(); } });
+canvas.addEventListener('click', jump);
+
+function spawnObstacle() {
+  const h = 30 + Math.random() * 40;
+  obstacles.push({ x: W, y: GROUND - h, w: 22, h });
+}
+
+function spawnCoin() {
+  const y = GROUND - 60 - Math.random() * 80;
+  coins.push({ x: W, y, r: 9, collected: false });
+}
+
+function drawCoco() {
+  const x = coco.x, y = coco.y;
+  ctx.fillStyle = '#8B4513';
+  ctx.beginPath();
+  ctx.arc(x, y + coco.r, coco.r, 0, Math.PI * 2);
+  ctx.fill();
+  ctx.fillStyle = '#fff';
+  ctx.beginPath(); ctx.arc(x - 8, y + coco.r - 6, 7, 0, Math.PI * 2); ctx.fill();
+  ctx.beginPath(); ctx.arc(x + 8, y + coco.r - 6, 7, 0, Math.PI * 2); ctx.fill();
+  ctx.fillStyle = '#222';
+  ctx.beginPath(); ctx.arc(x - 7, y + coco.r - 6, 4, 0, Math.PI * 2); ctx.fill();
+  ctx.beginPath(); ctx.arc(x + 9, y + coco.r - 6, 4, 0, Math.PI * 2); ctx.fill();
+  ctx.strokeStyle = '#5a2000';
+  ctx.lineWidth = 2;
+  ctx.beginPath();
+  ctx.arc(x, y + coco.r + 7, 8, 0.1 * Math.PI, 0.9 * Math.PI);
+  ctx.stroke();
+  ctx.fillStyle = '#3a8000';
+  ctx.beginPath();
+  ctx.moveTo(x, y);
+  ctx.quadraticCurveTo(x - 14, y - 14, x - 8, y - 24);
+  ctx.quadraticCurveTo(x, y - 10, x, y);
+  ctx.fill();
+  ctx.beginPath();
+  ctx.moveTo(x, y);
+  ctx.quadraticCurveTo(x + 14, y - 14, x + 8, y - 24);
+  ctx.quadraticCurveTo(x, y - 10, x, y);
+  ctx.fill();
+}
+
+function drawPalmTree(px, groundY) {
+  ctx.strokeStyle = '#4a7a00';
+  ctx.lineWidth = 7;
+  ctx.beginPath();
+  ctx.moveTo(px, groundY);
+  ctx.quadraticCurveTo(px + 6, groundY - 60, px - 4, groundY - 110);
+  ctx.stroke();
+  ctx.fillStyle = '#3a8000';
+  for (let a = -0.6; a <= 0.7; a += 0.3) {
+    ctx.beginPath();
+    ctx.ellipse(px - 4 + Math.cos(a) * 32, groundY - 110 + Math.sin(a) * 12 - 10, 30, 10, a, 0, Math.PI * 2);
+    ctx.fill();
+  }
+  ctx.fillStyle = '#8B4513';
+  for (let i = 0; i < 3; i++) {
+    ctx.beginPath();
+    ctx.arc(px - 4 + (i - 1) * 9, groundY - 115, 6, 0, Math.PI * 2);
+    ctx.fill();
+  }
+}
+
+function drawCoin(c) {
+  if (c.collected) return;
+  ctx.fillStyle = '#FFA500';
+  ctx.beginPath();
+  ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
+  ctx.fill();
+  ctx.fillStyle = '#fff';
+  ctx.font = 'bold 10px monospace';
+  ctx.textAlign = 'center';
+  ctx.textBaseline = 'middle';
+  ctx.fillText('E', c.x, c.y);
+}
+
+function collides(ax, ay, aw, ah, bx, by, bw, bh) {
+  return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
+}
+
+let bgOffset = 0;
+let palmTimer = 0, coinTimer = 0;
+
+function loop() {
+  ctx.clearRect(0, 0, W, H);
+
+  ctx.fillStyle = '#1a3a00';
+  ctx.fillRect(0, GROUND, W, H - GROUND);
+
+  if (state === 'play') {
+    bgOffset = (bgOffset + speed * 0.5) % W;
+    palmTimer += speed;
+    coinTimer += speed;
+    if (palmTimer > 350 + Math.random() * 150) {
+      spawnObstacle();
+      palmTimer = 0;
+    }
+    if (coinTimer > 150 + Math.random() * 80) {
+      spawnCoin();
+      coinTimer = 0;
+    }
+  }
+
+  for (let i = obstacles.length - 1; i >= 0; i--) {
+    const o = obstacles[i];
+    if (state === 'play') o.x -= speed;
+    if (o.x + o.w < 0) { obstacles.splice(i, 1); continue; }
+    drawPalmTree(o.x + 11, GROUND);
+    if (state === 'play' && collides(coco.x - coco.r + 4, coco.y, coco.r * 2 - 8, coco.r * 2, o.x, o.y, o.w, o.h)) {
+      state = 'over';
+      if (score > best) { best = score; bestEl.textContent = best; }
+      msgEl.textContent = 'GAME OVER — Press SPACE to retry';
+      document.getElementById('scoreInput').value = score;
+      document.getElementById('scoreSubmit').style.display = 'block';
+    }
+  }
+
+  for (let i = coins.length - 1; i >= 0; i--) {
+    const c = coins[i];
+    if (state === 'play') c.x -= speed;
+    if (c.x + c.r < 0) { coins.splice(i, 1); continue; }
+    drawCoin(c);
+    if (!c.collected && state === 'play') {
+      const dx = coco.x - c.x, dy = (coco.y + coco.r) - c.y;
+      if (Math.sqrt(dx * dx + dy * dy) < coco.r + c.r) {
+        c.collected = true;
+        score += 10;
+        scoreEl.textContent = score;
+      }
+    }
+  }
+
+  if (state === 'play') {
+    coco.vy += GRAVITY;
+    coco.y += coco.vy;
+    if (coco.y >= GROUND - coco.r * 2) {
+      coco.y = GROUND - coco.r * 2;
+      coco.vy = 0;
+      coco.jumps = 0;
+    }
+    frames++;
+    if (frames % 6 === 0) {
+      score++;
+      scoreEl.textContent = score;
+    }
+    if (frames % 60 === 0 && speed < 10) speed += 0.1;
+  }
+
+  drawCoco();
+  requestAnimationFrame(loop);
+}
+
+reset();
+loop();
+</script>
+</body>
+</html>

+ 26 - 0
src/games/cocoland/thumbnail.svg

@@ -0,0 +1,26 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
+  <rect width="400" height="220" fill="#000"/>
+  <rect x="0" y="180" width="400" height="40" fill="#1a3a00"/>
+  <line x1="300" y1="180" x2="280" y2="50" stroke="#4a7a00" stroke-width="8" stroke-linecap="round"/>
+  <ellipse cx="265" cy="55" rx="50" ry="30" fill="#2d6a00"/>
+  <ellipse cx="285" cy="45" rx="45" ry="25" fill="#3a8000"/>
+  <ellipse cx="275" cy="35" rx="40" ry="20" fill="#4a9400"/>
+  <circle cx="268" cy="62" r="8" fill="#8B4513"/>
+  <circle cx="282" cy="65" r="7" fill="#8B4513"/>
+  <circle cx="275" cy="58" r="7" fill="#8B4513"/>
+  <line x1="100" y1="180" x2="90" y2="90" stroke="#4a7a00" stroke-width="6" stroke-linecap="round"/>
+  <ellipse cx="80" cy="95" rx="35" ry="22" fill="#2d6a00"/>
+  <ellipse cx="95" cy="87" rx="30" ry="18" fill="#3a8000"/>
+  <circle cx="88" cy="100" r="6" fill="#8B4513"/>
+  <circle cx="100" cy="102" r="5" fill="#8B4513"/>
+  <circle cx="58" cy="150" r="22" fill="#8B4513"/>
+  <circle cx="48" cy="143" r="7" fill="#fff"/>
+  <circle cx="68" cy="143" r="7" fill="#fff"/>
+  <circle cx="50" cy="143" r="4" fill="#222"/>
+  <circle cx="70" cy="143" r="4" fill="#222"/>
+  <path d="M48 158 Q58 164 68 158" stroke="#5a2000" stroke-width="2" fill="none" stroke-linecap="round"/>
+  <polygon points="150,165 158,145 166,165" fill="#FFA500"/>
+  <polygon points="190,170 198,150 206,170" fill="#FFA500"/>
+  <polygon points="230,162 238,142 246,162" fill="#FFA500"/>
+  <text x="200" y="210" font-family="monospace" font-size="13" fill="#FFA500" text-anchor="middle" opacity="0.7">SCORE: 0</text>
+</svg>

+ 323 - 0
src/games/cocoman/index.html

@@ -0,0 +1,323 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Cocoman</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
+canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
+#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
+#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 6px; }
+#dpad-row { display: flex; gap: 4px; }
+#dpad button { width: 44px; height: 44px; background: #222; border: 1px solid #555; color: #FFA500; font-size: 18px; cursor: pointer; }
+#dpad button:active { background: #444; }
+#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
+#scoreSubmit { text-align: center; margin: 6px; }
+#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">COCOMAN</span>
+</div>
+<div id="ui">
+  <span>SCORE: <b id="scoreEl">0</b></span>
+  <span>LIVES: <b id="livesEl">3</b></span>
+  <span>BEST: <b id="bestEl">-</b></span>
+</div>
+<canvas id="c"></canvas>
+<div id="msg">Press SPACE or tap arrow to start</div>
+<div id="dpad">
+  <div id="dpad-row"><button id="btn-up">&#8593;</button></div>
+  <div id="dpad-row"><button id="btn-left">&#8592;</button><button id="btn-down">&#8595;</button><button id="btn-right">&#8594;</button></div>
+</div>
+<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
+<div id="scoreSubmit" style="display:none">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="cocoman">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit">Submit Score to Hall of Fame</button>
+  </form>
+</div>
+<script>
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+const CELL = 20;
+
+const MAP = [
+  '############################',
+  '#............##............#',
+  '#.####.#####.##.#####.####.#',
+  '#o#  #.#   #.##.#   #.#  #o#',
+  '#.####.#####.##.#####.####.#',
+  '#..........................#',
+  '#.####.##.########.##.####.#',
+  '#.####.##.########.##.####.#',
+  '#......##....##....##......#',
+  '######.##### ## #####.######',
+  '     #.##### ## #####.#     ',
+  '     #.##    G    ##.#      ',
+  '     #.## ###=### ##.#      ',
+  '######.## #     # ##.######',
+  '      .   # GGG #   .      ',
+  '######.## #     # ##.######',
+  '     #.## ####### ##.#     ',
+  '     #.##         ##.#     ',
+  '     #.## ######## ##.#    ',
+  '######.## ######## ##.######',
+  '#............##............#',
+  '#.####.#####.##.#####.####.#',
+  '#.####.#####.##.#####.####.#',
+  '#o..##.......  .......##..o#',
+  '###.##.##.########.##.##.###',
+  '###.##.##.########.##.##.###',
+  '#......##....##....##......#',
+  '#.##########.##.##########.#',
+  '#.##########.##.##########.#',
+  '#..........................#',
+  '############################'
+];
+
+const COLS = MAP[0].length, ROWS = MAP.length;
+canvas.width = COLS * CELL;
+canvas.height = ROWS * CELL;
+
+const GHOST_COLORS = ['#f00', '#f9a', '#0ff', '#fa0'];
+
+let score = 0, lives = 3, gameState = 'idle', comboGhost = 0;
+let best = parseInt(localStorage.getItem('cocoman_best') || '-1');
+let dots = [], powers = [], player, ghosts, nextDir = [1, 0], frightTimer = 0;
+let tickInterval = null;
+
+function initLevel() {
+  dots = []; powers = [];
+  for (let r = 0; r < ROWS; r++) {
+    for (let c = 0; c < COLS; c++) {
+      if (MAP[r][c] === '.') dots.push([c, r]);
+      if (MAP[r][c] === 'o') powers.push([c, r]);
+    }
+  }
+  player = { x: 14, y: 23, dir: [1, 0] };
+  nextDir = [1, 0];
+  ghosts = [
+    { x: 13, y: 14, dir: [0, -1], color: GHOST_COLORS[0] },
+    { x: 14, y: 14, dir: [0, 1],  color: GHOST_COLORS[1] },
+    { x: 13, y: 15, dir: [1, 0],  color: GHOST_COLORS[2] },
+    { x: 14, y: 15, dir: [-1, 0], color: GHOST_COLORS[3] }
+  ];
+  frightTimer = 0; comboGhost = 0;
+}
+
+function wrapX(x) {
+  if (x < 0) return COLS - 1;
+  if (x >= COLS) return 0;
+  return x;
+}
+
+function cellAt(x, y) {
+  if (y < 0 || y >= ROWS) return '#';
+  const row = MAP[Math.floor(y)];
+  const ch = row[Math.floor(x)];
+  return ch === undefined ? ' ' : ch;
+}
+
+function canMovePlayer(x, y, dx, dy) {
+  const nx = wrapX(x + dx), ny = y + dy;
+  const ch = cellAt(nx, ny);
+  return ch !== '#' && ch !== '=';
+}
+
+function canGhostMove(x, y, dx, dy) {
+  const nx = wrapX(x + dx), ny = y + dy;
+  return cellAt(nx, ny) !== '#';
+}
+
+function moveGhost(g) {
+  const dirs = [[0,-1],[0,1],[-1,0],[1,0]].filter(([dx,dy]) => {
+    if (!canGhostMove(g.x, g.y, dx, dy)) return false;
+    if (dx === -g.dir[0] && dy === -g.dir[1]) return false;
+    return true;
+  });
+  if (!dirs.length) return;
+  if (frightTimer > 0) {
+    g.dir = dirs[Math.floor(Math.random() * dirs.length)];
+  } else {
+    dirs.sort((a, b) =>
+      Math.hypot(g.x+a[0]-player.x, g.y+a[1]-player.y) -
+      Math.hypot(g.x+b[0]-player.x, g.y+b[1]-player.y)
+    );
+    g.dir = dirs[0];
+  }
+  g.x = wrapX(g.x + g.dir[0]);
+  g.y += g.dir[1];
+}
+
+function tick() {
+  if (gameState !== 'play') return;
+
+  if (canMovePlayer(player.x, player.y, ...nextDir)) player.dir = [...nextDir];
+  if (canMovePlayer(player.x, player.y, ...player.dir)) {
+    player.x = wrapX(player.x + player.dir[0]);
+    player.y += player.dir[1];
+  }
+
+  const di = dots.findIndex(d => d[0] === player.x && d[1] === player.y);
+  if (di >= 0) { dots.splice(di, 1); score += 10; document.getElementById('scoreEl').textContent = score; }
+  const pi = powers.findIndex(p => p[0] === player.x && p[1] === player.y);
+  if (pi >= 0) { powers.splice(pi, 1); score += 50; frightTimer = 22; comboGhost = 0; document.getElementById('scoreEl').textContent = score; }
+
+  if (frightTimer > 0) frightTimer--;
+
+  ghosts.forEach(moveGhost);
+
+  for (const g of ghosts) {
+    if (g.x === player.x && g.y === player.y) {
+      if (frightTimer > 0) {
+        comboGhost++;
+        score += 200 * Math.pow(2, comboGhost - 1);
+        document.getElementById('scoreEl').textContent = score;
+        g.x = 13; g.y = 14; g.dir = [0, -1];
+      } else {
+        loseLife();
+        return;
+      }
+    }
+  }
+
+  if (!dots.length && !powers.length) winGame();
+}
+
+function loseLife() {
+  lives--;
+  document.getElementById('livesEl').textContent = lives;
+  if (lives <= 0) {
+    endGame();
+  } else {
+    player = { x: 14, y: 23, dir: [1, 0] };
+    nextDir = [1, 0]; frightTimer = 0;
+    document.getElementById('msg').textContent = 'Caught! Keep going...';
+  }
+}
+
+function winGame() {
+  clearInterval(tickInterval); tickInterval = null;
+  gameState = 'over';
+  score += lives * 100;
+  document.getElementById('scoreEl').textContent = score;
+  document.getElementById('msg').textContent = `You won! Score: ${score}. SPACE = new game`;
+  saveAndShow();
+}
+
+function endGame() {
+  clearInterval(tickInterval); tickInterval = null;
+  gameState = 'over';
+  document.getElementById('msg').textContent = `GAME OVER! Score: ${score}. SPACE = new game`;
+  saveAndShow();
+}
+
+function saveAndShow() {
+  if (best < 0 || score > best) {
+    best = score;
+    localStorage.setItem('cocoman_best', best);
+    document.getElementById('bestEl').textContent = best;
+  }
+  document.getElementById('scoreInput').value = score;
+  document.getElementById('scoreSubmit').style.display = 'block';
+}
+
+function startGame() {
+  score = 0; lives = 3; gameState = 'play';
+  document.getElementById('scoreEl').textContent = '0';
+  document.getElementById('livesEl').textContent = '3';
+  document.getElementById('msg').textContent = '';
+  document.getElementById('scoreSubmit').style.display = 'none';
+  initLevel();
+  if (tickInterval) clearInterval(tickInterval);
+  tickInterval = setInterval(tick, 155);
+}
+
+document.addEventListener('keydown', e => {
+  const map = { ArrowUp: [0,-1], ArrowDown: [0,1], ArrowLeft: [-1,0], ArrowRight: [1,0], KeyW: [0,-1], KeyS: [0,1], KeyA: [-1,0], KeyD: [1,0] };
+  if (e.code === 'Space') { e.preventDefault(); startGame(); return; }
+  if (map[e.code]) { e.preventDefault(); if (gameState === 'idle') startGame(); nextDir = map[e.code]; }
+});
+
+function dpadInput(dx, dy) { if (gameState === 'idle') startGame(); nextDir = [dx, dy]; }
+document.getElementById('btn-up').addEventListener('click', () => dpadInput(0, -1));
+document.getElementById('btn-down').addEventListener('click', () => dpadInput(0, 1));
+document.getElementById('btn-left').addEventListener('click', () => dpadInput(-1, 0));
+document.getElementById('btn-right').addEventListener('click', () => dpadInput(1, 0));
+
+if (best >= 0) document.getElementById('bestEl').textContent = best;
+initLevel();
+
+let animFrame = 0;
+function draw() {
+  ctx.fillStyle = '#000';
+  ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+  for (let r = 0; r < ROWS; r++) {
+    for (let c = 0; c < COLS; c++) {
+      if (MAP[r][c] === '#') {
+        ctx.fillStyle = '#00c';
+        ctx.fillRect(c * CELL, r * CELL, CELL, CELL);
+        ctx.strokeStyle = '#44f';
+        ctx.strokeRect(c * CELL + 1, r * CELL + 1, CELL - 2, CELL - 2);
+      }
+    }
+  }
+
+  ctx.fillStyle = '#fff';
+  for (const [dc, dr] of dots) {
+    ctx.beginPath(); ctx.arc(dc * CELL + CELL/2, dr * CELL + CELL/2, 2.5, 0, Math.PI * 2); ctx.fill();
+  }
+  if (Math.floor(animFrame / 15) % 2) {
+    ctx.fillStyle = '#FFA500';
+    for (const [dc, dr] of powers) {
+      ctx.beginPath(); ctx.arc(dc * CELL + CELL/2, dr * CELL + CELL/2, 5, 0, Math.PI * 2); ctx.fill();
+    }
+  }
+
+  for (const g of ghosts) {
+    ctx.fillStyle = frightTimer > 0 ? '#006' : g.color;
+    const gx = g.x * CELL + CELL/2, gy = g.y * CELL + CELL/2;
+    ctx.beginPath();
+    ctx.arc(gx, gy - 2, CELL/2 - 2, Math.PI, 0);
+    ctx.lineTo(gx + CELL/2 - 2, gy + CELL/2 - 2);
+    for (let w = 0; w < 4; w++) {
+      ctx.lineTo(gx + CELL/2 - 2 - w * (CELL - 4) / 4, gy + CELL/2 - 2 - (w % 2 ? 3 : 0));
+    }
+    ctx.lineTo(gx - CELL/2 + 2, gy + CELL/2 - 2); ctx.closePath(); ctx.fill();
+    ctx.fillStyle = '#fff';
+    ctx.beginPath(); ctx.arc(gx - 3, gy - 3, 3, 0, Math.PI * 2); ctx.fill();
+    ctx.beginPath(); ctx.arc(gx + 3, gy - 3, 3, 0, Math.PI * 2); ctx.fill();
+    if (frightTimer <= 0) {
+      ctx.fillStyle = '#00c';
+      ctx.beginPath(); ctx.arc(gx - 3, gy - 3, 1.5, 0, Math.PI * 2); ctx.fill();
+      ctx.beginPath(); ctx.arc(gx + 3, gy - 3, 1.5, 0, Math.PI * 2); ctx.fill();
+    }
+  }
+
+  const mouth = Math.abs(Math.sin(animFrame * 0.15)) * 0.4;
+  const pa = player.dir[0] !== 0 ? Math.atan2(player.dir[1], player.dir[0]) : -Math.PI / 2 * Math.sign(player.dir[1] || 1);
+  ctx.fillStyle = '#ff0';
+  ctx.beginPath();
+  ctx.moveTo(player.x * CELL + CELL/2, player.y * CELL + CELL/2);
+  ctx.arc(player.x * CELL + CELL/2, player.y * CELL + CELL/2, CELL/2 - 1, pa + mouth, pa + Math.PI * 2 - mouth);
+  ctx.closePath(); ctx.fill();
+
+  animFrame++;
+  requestAnimationFrame(draw);
+}
+
+draw();
+</script>
+</body>
+</html>

+ 22 - 0
src/games/cocoman/thumbnail.svg

@@ -0,0 +1,22 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
+  <rect width="120" height="80" fill="#000"/>
+  <g fill="#00f" stroke="#00f" stroke-width="1">
+    <rect x="5" y="5" width="110" height="8"/>
+    <rect x="5" y="67" width="110" height="8"/>
+    <rect x="5" y="5" width="8" height="70"/>
+    <rect x="107" y="5" width="8" height="70"/>
+    <rect x="30" y="5" width="8" height="35"/>
+    <rect x="60" y="5" width="8" height="35"/>
+    <rect x="90" y="5" width="8" height="35"/>
+    <rect x="30" y="45" width="8" height="30"/>
+    <rect x="60" y="45" width="8" height="30"/>
+    <rect x="90" y="45" width="8" height="30"/>
+  </g>
+  <circle cx="20" cy="42" r="8" fill="#ff0"/>
+  <path d="M20,42 L28,38 L28,46 Z" fill="#000"/>
+  <circle cx="55" cy="25" r="4" fill="#f00" opacity="0.9"/>
+  <circle cx="80" cy="55" r="4" fill="#f88" opacity="0.9"/>
+  <circle cx="19" cy="25" r="2" fill="#fff"/>
+  <circle cx="45" cy="55" r="2" fill="#fff"/>
+  <circle cx="75" cy="35" r="2" fill="#fff"/>
+</svg>

Datei-Diff unterdrückt, da er zu groß ist
+ 793 - 0
src/games/ecoinflow/index.html


+ 113 - 0
src/games/ecoinflow/thumbnail.svg

@@ -0,0 +1,113 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
+  <rect width="400" height="220" fill="#000"/>
+  <rect x="10" y="10" width="380" height="200" rx="4" fill="#111" stroke="#222" stroke-width="1"/>
+
+  <!-- Grid cells -->
+  <rect x="22" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="82" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="142" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="202" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="262" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="322" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+
+  <rect x="22" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="82" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="142" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="202" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="262" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="322" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+
+  <rect x="22" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="82" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="142" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="202" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="262" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+  <rect x="322" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
+
+  <!-- Pipes (orange paths between nodes) -->
+  <path d="M48 74 L48 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
+  <path d="M48 134 L48 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
+  <path d="M74 48 L82 48" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
+  <path d="M134 48 L142 48" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
+  <path d="M108 74 L108 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
+  <path d="M108 134 L108 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
+  <path d="M194 108 L202 108" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
+  <path d="M254 108 L262 108" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
+  <path d="M228 74 L228 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
+  <path d="M288 74 L288 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
+  <path d="M288 134 L288 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
+  <path d="M314 168 L322 168" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
+  <path d="M168 134 L168 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
+
+  <!-- PUB node (orange square) row1 col1 -->
+  <rect x="34" y="34" width="36" height="36" rx="2" fill="#FFA500" opacity="0.92"/>
+  <line x1="52" y1="24" x2="52" y2="34" stroke="#FFA500" stroke-width="2"/>
+  <circle cx="52" cy="21" r="3" fill="#FFA500"/>
+  <text x="52" y="57" font-family="monospace" font-size="9" fill="#000" text-anchor="middle" font-weight="bold">PUB</text>
+
+  <!-- habitante node (green circle) row1 col2 -->
+  <circle cx="108" cy="48" r="18" fill="#8BC34A" opacity="0.9"/>
+  <text x="108" y="52" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
+
+  <!-- validador (diamond) row1 col3 -->
+  <polygon points="168,30 186,48 168,66 150,48" fill="rgba(200,218,230,0.55)" stroke="#ccc" stroke-width="1"/>
+  <text x="168" y="52" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">VAL</text>
+
+  <!-- tienda (hexagon) row1 col4 -->
+  <polygon points="228,30 241,38 241,58 228,66 215,58 215,38" fill="#4CAF50" opacity="0.9"/>
+  <text x="228" y="52" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
+
+  <!-- acumulador (triangle) row1 col5 -->
+  <polygon points="288,30 310,66 266,66" fill="rgba(255,82,82,0.25)" stroke="#FF5252" stroke-width="1"/>
+  <text x="288" y="58" font-family="monospace" font-size="7" fill="#eee" text-anchor="middle" font-weight="bold">ACU</text>
+
+  <!-- CBDC (grey square) row1 col6 -->
+  <rect x="334" y="34" width="36" height="36" rx="2" fill="#607D8B" opacity="0.9"/>
+  <text x="352" y="57" font-family="monospace" font-size="8" fill="#cfd8dc" text-anchor="middle" font-weight="bold">CBD</text>
+
+  <!-- row2 nodes -->
+  <!-- PUB node row2 col1 -->
+  <rect x="34" y="94" width="36" height="36" rx="2" fill="#FFA500" opacity="0.7"/>
+  <text x="52" y="117" font-family="monospace" font-size="9" fill="#000" text-anchor="middle" font-weight="bold">PUB</text>
+
+  <!-- hab row2 col2 -->
+  <circle cx="108" cy="108" r="18" fill="#8BC34A" opacity="0.7"/>
+  <text x="108" y="112" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
+
+  <!-- hab row2 col4 -->
+  <circle cx="228" cy="108" r="18" fill="#8BC34A" opacity="0.85"/>
+  <text x="228" y="112" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
+
+  <!-- tienda row2 col5 -->
+  <polygon points="288,90 301,98 301,118 288,126 275,118 275,98" fill="#4CAF50" opacity="0.75"/>
+  <text x="288" y="112" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
+
+  <!-- checkpoint row3 col2 -->
+  <rect x="90" y="154" width="36" height="36" rx="2" fill="none" stroke="#FFFF55" stroke-width="3"/>
+  <text x="108" y="177" font-family="monospace" font-size="7" fill="#FFFF55" text-anchor="middle" font-weight="bold">CHK</text>
+
+  <!-- hab row3 col3 -->
+  <circle cx="168" cy="168" r="18" fill="#8BC34A" opacity="0.8"/>
+  <text x="168" y="172" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
+
+  <!-- CBDC row3 col4 -->
+  <rect x="214" y="154" width="36" height="36" rx="2" fill="#607D8B" opacity="0.8"/>
+  <text x="232" y="177" font-family="monospace" font-size="8" fill="#cfd8dc" text-anchor="middle" font-weight="bold">CBD</text>
+
+  <!-- tienda row3 col6 -->
+  <polygon points="348,154 361,162 361,182 348,190 335,182 335,162" fill="#4CAF50" opacity="0.8"/>
+  <text x="348" y="176" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
+
+  <!-- Flow particles on some pipes -->
+  <circle cx="48" cy="78" r="3" fill="#FFA500" opacity="0.95">
+    <animate attributeName="cy" values="74;82" dur="0.9s" repeatCount="indefinite"/>
+    <animate attributeName="opacity" values="0;1;1;0" dur="0.9s" repeatCount="indefinite"/>
+  </circle>
+  <circle cx="91" cy="48" r="3" fill="#FFA500" opacity="0.95">
+    <animate attributeName="cx" values="74;82" dur="0.9s" repeatCount="indefinite"/>
+    <animate attributeName="opacity" values="0;1;1;0" dur="0.9s" repeatCount="indefinite"/>
+  </circle>
+
+  <!-- Title -->
+  <text x="200" y="212" font-family="'Courier New', monospace" font-size="14" fill="#FFA500" text-anchor="middle" font-weight="bold" letter-spacing="3">ECOINFLOW</text>
+</svg>

Datei-Diff unterdrückt, da er zu groß ist
+ 238 - 0
src/games/flipflop/index.html


Datei-Diff unterdrückt, da er zu groß ist
+ 44 - 0
src/games/flipflop/thumbnail.svg


+ 222 - 0
src/games/labyrinth/index.html

@@ -0,0 +1,222 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Labyrinth</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
+canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; border: 1px solid #333; }
+#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
+#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 6px; }
+#dpad-row { display: flex; gap: 4px; }
+#dpad button { width: 44px; height: 44px; background: #222; border: 1px solid #555; color: #FFA500; font-size: 18px; cursor: pointer; }
+#dpad button:active { background: #444; }
+#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
+#scoreSubmit { text-align: center; margin: 6px; }
+#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">LABYRINTH</span>
+</div>
+<div id="ui">
+  <span>LEVEL: <b id="levelEl">1</b></span>
+  <span>MOVES: <b id="movesEl">150</b></span>
+  <span>SCORE: <b id="scoreEl">0</b></span>
+  <span>BEST: <b id="bestEl">-</b></span>
+</div>
+<canvas id="c"></canvas>
+<div id="msg">Press SPACE or tap to start</div>
+<div id="dpad">
+  <div id="dpad-row"><button id="btn-up">&#8593;</button></div>
+  <div id="dpad-row"><button id="btn-left">&#8592;</button><button id="btn-down">&#8595;</button><button id="btn-right">&#8594;</button></div>
+</div>
+<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
+<div id="scoreSubmit" style="display:none">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="labyrinth">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit">Submit Score to Hall of Fame</button>
+  </form>
+</div>
+<script>
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+
+const BASE_SIZE = 10;
+let CELL = 36;
+
+let level = 1, moves = 0, maxMoves = 150, score = 0, total = 0, gameState = 'idle';
+let cols, rows, grid, px, py;
+let best = parseInt(localStorage.getItem('labyrinth_best') || '-1');
+
+function seededRand(seed) {
+  let s = seed;
+  return () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
+}
+
+function generateMaze(c, r, seed) {
+  const rng = seededRand(seed);
+  const cells = Array.from({length: r}, () => Array.from({length: c}, () => ({ n: true, s: true, e: true, w: true, visited: false })));
+  function shuffle(arr) {
+    for (let i = arr.length - 1; i > 0; i--) {
+      const j = Math.floor(rng() * (i + 1));
+      [arr[i], arr[j]] = [arr[j], arr[i]];
+    }
+    return arr;
+  }
+  function carve(x, y) {
+    cells[y][x].visited = true;
+    const dirs = shuffle([['n', 0, -1], ['s', 0, 1], ['e', 1, 0], ['w', -1, 0]]);
+    for (const [d, dx, dy] of dirs) {
+      const nx = x + dx, ny = y + dy;
+      if (nx >= 0 && nx < c && ny >= 0 && ny < r && !cells[ny][nx].visited) {
+        const opp = { n: 's', s: 'n', e: 'w', w: 'e' };
+        cells[y][x][d] = false;
+        cells[ny][nx][opp[d]] = false;
+        carve(nx, ny);
+      }
+    }
+  }
+  carve(0, 0);
+  return cells;
+}
+
+function getAvailableSize() {
+  const usedH = (document.getElementById('topbar').offsetHeight || 40)
+    + (document.getElementById('ui').offsetHeight || 30)
+    + (document.getElementById('msg').offsetHeight || 22)
+    + (document.getElementById('dpad').offsetHeight || 100)
+    + (document.getElementById('controls').offsetHeight || 16)
+    + 24;
+  return { w: window.innerWidth - 8, h: Math.max(200, window.innerHeight - usedH) };
+}
+
+function startLevel() {
+  cols = BASE_SIZE + (level - 1) * 2;
+  rows = Math.floor(cols * 0.7);
+  maxMoves = cols * rows * 2;
+  moves = 0;
+  grid = generateMaze(cols, rows, level * 7919 + 42);
+  px = Math.floor(cols / 2); py = Math.floor(rows / 2);
+  const avail = getAvailableSize();
+  CELL = Math.max(16, Math.floor(Math.min(avail.w / cols, avail.h / rows)));
+  canvas.width = cols * CELL;
+  canvas.height = rows * CELL;
+  document.getElementById('levelEl').textContent = level;
+  document.getElementById('movesEl').textContent = maxMoves;
+  document.getElementById('msg').textContent = `Level ${level} — Reach the exit!`;
+  gameState = 'play';
+}
+
+function tryMove(dx, dy) {
+  if (gameState !== 'play') return;
+  const cell = grid[py][px];
+  const dirs = { '0,-1': 'n', '0,1': 's', '1,0': 'e', '-1,0': 'w' };
+  const key = `${dx},${dy}`;
+  const wall = dirs[key];
+  if (!wall || cell[wall]) return;
+  px += dx; py += dy;
+  moves++;
+  document.getElementById('movesEl').textContent = maxMoves - moves;
+  if (px === cols - 1 && py === rows - 1) {
+    const remaining = maxMoves - moves;
+    const levelScore = remaining + level * 50;
+    total += levelScore;
+    document.getElementById('scoreEl').textContent = total;
+    document.getElementById('msg').textContent = `EXIT! +${levelScore} pts. Next level...`;
+    gameState = 'transit';
+    setTimeout(() => { level++; startLevel(); }, 1000);
+  } else if (moves >= maxMoves) {
+    endGame();
+  }
+}
+
+function endGame() {
+  gameState = 'over';
+  if (best < 0 || total > best) {
+    best = total;
+    localStorage.setItem('labyrinth_best', best);
+    document.getElementById('bestEl').textContent = best;
+  }
+  document.getElementById('msg').textContent = `Out of moves! Score: ${total}. SPACE = new game`;
+  document.getElementById('scoreInput').value = total;
+  document.getElementById('scoreSubmit').style.display = 'block';
+}
+
+function newGame() {
+  level = 1; total = 0; score = 0;
+  document.getElementById('scoreEl').textContent = '0';
+  document.getElementById('scoreSubmit').style.display = 'none';
+  startLevel();
+}
+
+document.addEventListener('keydown', e => {
+  if (e.code === 'Space') { e.preventDefault(); newGame(); return; }
+  const map = { ArrowUp: [0,-1], ArrowDown: [0,1], ArrowLeft: [-1,0], ArrowRight: [1,0], KeyW: [0,-1], KeyS: [0,1], KeyA: [-1,0], KeyD: [1,0] };
+  if (map[e.code]) { e.preventDefault(); if (gameState === 'idle') newGame(); else tryMove(...map[e.code]); }
+});
+
+canvas.addEventListener('click', () => { if (gameState === 'idle') newGame(); });
+window.addEventListener('resize', () => { if (gameState !== 'idle') startLevel(); });
+
+const dpadStart = () => { if (gameState === 'idle') newGame(); };
+document.getElementById('btn-up').addEventListener('click', () => { dpadStart(); tryMove(0, -1); });
+document.getElementById('btn-down').addEventListener('click', () => { dpadStart(); tryMove(0, 1); });
+document.getElementById('btn-left').addEventListener('click', () => { dpadStart(); tryMove(-1, 0); });
+document.getElementById('btn-right').addEventListener('click', () => { dpadStart(); tryMove(1, 0); });
+
+if (best >= 0) document.getElementById('bestEl').textContent = best;
+
+const VIEW = 6;
+
+function draw() {
+  const W = canvas.width, H = canvas.height;
+  ctx.fillStyle = '#0a0a0a';
+  ctx.fillRect(0, 0, W, H);
+  if (gameState === 'idle') { requestAnimationFrame(draw); return; }
+
+  const viewCols = Math.min(cols, Math.floor(W / CELL));
+  const viewRows = Math.min(rows, Math.floor(H / CELL));
+  const offX = Math.max(0, Math.min(px - Math.floor(viewCols / 2), cols - viewCols));
+  const offY = Math.max(0, Math.min(py - Math.floor(viewRows / 2), rows - viewRows));
+
+  for (let r = offY; r < Math.min(offY + viewRows + 1, rows); r++) {
+    for (let c = offX; c < Math.min(offX + viewCols + 1, cols); c++) {
+      const x = (c - offX) * CELL, y = (r - offY) * CELL;
+      const cell = grid[r][c];
+      ctx.strokeStyle = '#FFA500'; ctx.lineWidth = 2;
+      if (cell.n) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + CELL, y); ctx.stroke(); }
+      if (cell.s) { ctx.beginPath(); ctx.moveTo(x, y + CELL); ctx.lineTo(x + CELL, y + CELL); ctx.stroke(); }
+      if (cell.w) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + CELL); ctx.stroke(); }
+      if (cell.e) { ctx.beginPath(); ctx.moveTo(x + CELL, y); ctx.lineTo(x + CELL, y + CELL); ctx.stroke(); }
+    }
+  }
+
+  const ex = (cols - 1 - offX) * CELL + CELL / 2, ey = (rows - 1 - offY) * CELL + CELL / 2;
+  if (cols - 1 >= offX && cols - 1 < offX + viewCols && rows - 1 >= offY && rows - 1 < offY + viewRows) {
+    ctx.fillStyle = '#f44';
+    ctx.beginPath(); ctx.arc(ex, ey, 7, 0, Math.PI * 2); ctx.fill();
+    ctx.fillStyle = '#fff'; ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+    ctx.fillText('EXIT', ex, ey);
+  }
+
+  const ppx = (px - offX) * CELL + CELL / 2, ppy = (py - offY) * CELL + CELL / 2;
+  ctx.fillStyle = '#00ff88';
+  ctx.beginPath(); ctx.arc(ppx, ppy, 7, 0, Math.PI * 2); ctx.fill();
+
+  requestAnimationFrame(draw);
+}
+
+draw();
+</script>
+</body>
+</html>

+ 16 - 0
src/games/labyrinth/thumbnail.svg

@@ -0,0 +1,16 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
+  <rect width="120" height="80" fill="#111"/>
+  <g stroke="#FFA500" stroke-width="2" fill="none">
+    <rect x="5" y="5" width="110" height="70"/>
+    <line x1="25" y1="5" x2="25" y2="45"/>
+    <line x1="45" y1="35" x2="45" y2="75"/>
+    <line x1="65" y1="5" x2="65" y2="45"/>
+    <line x1="85" y1="35" x2="85" y2="75"/>
+    <line x1="5" y1="25" x2="45" y2="25"/>
+    <line x1="65" y1="25" x2="105" y2="25"/>
+    <line x1="25" y1="55" x2="65" y2="55"/>
+    <line x1="85" y1="55" x2="105" y2="55"/>
+  </g>
+  <circle cx="15" cy="15" r="4" fill="#00ff88"/>
+  <circle cx="105" cy="65" r="4" fill="#ff4444"/>
+</svg>

+ 192 - 0
src/games/pingpong/index.html

@@ -0,0 +1,192 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>PingPong</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#topbar a:hover { text-decoration: underline; }
+canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
+#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
+#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">PINGPONG</span>
+</div>
+<canvas id="c" width="640" height="400"></canvas>
+<div id="msg">Press SPACE to start</div>
+<div id="controls">&#8593;&#8595; or W/S — Move paddle &nbsp;|&nbsp; First to 5 wins</div>
+<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="pingpong">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
+  </form>
+</div>
+<script>
+document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+const W = canvas.width, H = canvas.height;
+
+const PADDLE_W = 12, PADDLE_H = 70, PADDLE_OFF = 18;
+const BALL_R = 8;
+const WIN_SCORE = 5;
+
+let state = 'idle';
+let playerScore = 0, aiScore = 0;
+let player, ai, ball;
+let frames = 0;
+
+function initGame() {
+  player = { x: PADDLE_OFF, y: H / 2 - PADDLE_H / 2, w: PADDLE_W, h: PADDLE_H, speed: 5 };
+  ai = { x: W - PADDLE_OFF - PADDLE_W, y: H / 2 - PADDLE_H / 2, w: PADDLE_W, h: PADDLE_H, speed: 3.8 };
+  resetBall(1);
+  frames = 0;
+}
+
+function resetBall(dir) {
+  const angle = (Math.random() * 0.5 - 0.25) * Math.PI;
+  const speed = 5;
+  ball = {
+    x: W / 2, y: H / 2,
+    vx: dir * speed * Math.cos(angle),
+    vy: speed * Math.sin(angle),
+    r: BALL_R
+  };
+}
+
+const keys = {};
+document.addEventListener('keydown', e => {
+  keys[e.code] = true;
+  if (e.code === 'Space') {
+    e.preventDefault();
+    if (state === 'idle' || state === 'over') {
+      playerScore = 0; aiScore = 0;
+      initGame();
+      state = 'play';
+      document.getElementById('msg').textContent = '';
+    }
+  }
+});
+document.addEventListener('keyup', e => { keys[e.code] = false; });
+
+function paddleCollide(ball, pad) {
+  return ball.x - ball.r < pad.x + pad.w &&
+         ball.x + ball.r > pad.x &&
+         ball.y + ball.r > pad.y &&
+         ball.y - ball.r < pad.y + pad.h;
+}
+
+function loop() {
+  ctx.clearRect(0, 0, W, H);
+
+  ctx.strokeStyle = '#222';
+  ctx.lineWidth = 2;
+  ctx.setLineDash([12, 10]);
+  ctx.beginPath();
+  ctx.moveTo(W / 2, 0);
+  ctx.lineTo(W / 2, H);
+  ctx.stroke();
+  ctx.setLineDash([]);
+
+  if (state === 'play') {
+    if ((keys['ArrowUp'] || keys['KeyW']) && player.y > 0) player.y -= player.speed;
+    if ((keys['ArrowDown'] || keys['KeyS']) && player.y + player.h < H) player.y += player.speed;
+
+    const aiCenter = ai.y + ai.h / 2;
+    const aiSpeed = ai.speed + Math.min(frames * 0.001, 1);
+    if (aiCenter < ball.y - 4) ai.y = Math.min(H - ai.h, ai.y + aiSpeed);
+    else if (aiCenter > ball.y + 4) ai.y = Math.max(0, ai.y - aiSpeed);
+
+    ball.x += ball.vx;
+    ball.y += ball.vy;
+
+    if (ball.y - ball.r < 0) { ball.y = ball.r; ball.vy = Math.abs(ball.vy); }
+    if (ball.y + ball.r > H) { ball.y = H - ball.r; ball.vy = -Math.abs(ball.vy); }
+
+    if (paddleCollide(ball, player)) {
+      ball.x = player.x + player.w + ball.r;
+      const rel = (ball.y - (player.y + player.h / 2)) / (player.h / 2);
+      const speed = Math.min(Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy) + 0.3, 14);
+      const angle = rel * 0.75;
+      ball.vx = speed * Math.cos(angle);
+      ball.vy = speed * Math.sin(angle);
+    }
+
+    if (paddleCollide(ball, ai)) {
+      ball.x = ai.x - ball.r;
+      const rel = (ball.y - (ai.y + ai.h / 2)) / (ai.h / 2);
+      const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
+      const angle = rel * 0.75;
+      ball.vx = -speed * Math.cos(angle);
+      ball.vy = speed * Math.sin(angle);
+    }
+
+    if (ball.x - ball.r < 0) {
+      aiScore++;
+      if (aiScore >= WIN_SCORE) { state = 'over'; document.getElementById('msg').textContent = 'AI WINS — Press SPACE to retry'; }
+      else resetBall(1);
+    }
+    if (ball.x + ball.r > W) {
+      playerScore++;
+      if (playerScore >= WIN_SCORE) { state = 'over'; document.getElementById('msg').textContent = 'YOU WIN! — Press SPACE to play again'; document.getElementById('scoreInput').value = playerScore * 100; document.getElementById('scoreSubmit').style.display = 'block'; }
+      else resetBall(-1);
+    }
+
+    frames++;
+  }
+
+  ctx.fillStyle = '#FFA500';
+  ctx.beginPath(); ctx.roundRect(player.x, player.y, player.w, player.h, 4); ctx.fill();
+
+  ctx.fillStyle = '#4CAF50';
+  ctx.beginPath(); ctx.roundRect(ai.x, ai.y, ai.w, ai.h, 4); ctx.fill();
+
+  if (state !== 'idle') {
+    ctx.fillStyle = '#fff';
+    ctx.shadowBlur = 8;
+    ctx.shadowColor = '#fff';
+    ctx.beginPath();
+    ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
+    ctx.fill();
+    ctx.shadowBlur = 0;
+  }
+
+  ctx.font = 'bold 48px monospace';
+  ctx.textAlign = 'center';
+  ctx.fillStyle = '#FFA500';
+  ctx.fillText(playerScore, W / 4, 60);
+  ctx.fillStyle = '#4CAF50';
+  ctx.fillText(aiScore, W * 3 / 4, 60);
+
+  ctx.font = '13px monospace';
+  ctx.fillStyle = '#555';
+  ctx.fillText('YOU', W / 4, 78);
+  ctx.fillText('AI', W * 3 / 4, 78);
+
+  if (state === 'idle') {
+    ctx.font = '22px monospace';
+    ctx.fillStyle = '#FFA500';
+    ctx.fillText('PINGPONG', W / 2, H / 2 - 10);
+    ctx.font = '16px monospace';
+    ctx.fillStyle = '#888';
+    ctx.fillText('Press SPACE to start', W / 2, H / 2 + 20);
+  }
+
+  requestAnimationFrame(loop);
+}
+
+initGame();
+loop();
+</script>
+</body>
+</html>

+ 11 - 0
src/games/pingpong/thumbnail.svg

@@ -0,0 +1,11 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
+  <rect width="400" height="220" fill="#000"/>
+  <line x1="200" y1="0" x2="200" y2="220" stroke="#333" stroke-width="2" stroke-dasharray="10,10"/>
+  <rect x="12" y="70" width="14" height="70" rx="4" fill="#FFA500"/>
+  <rect x="374" y="80" width="14" height="60" rx="4" fill="#4CAF50"/>
+  <circle cx="260" cy="125" r="10" fill="#fff"/>
+  <text x="150" y="210" font-family="monospace" font-size="20" fill="#FFA500" text-anchor="middle" font-weight="bold">3</text>
+  <text x="250" y="210" font-family="monospace" font-size="20" fill="#4CAF50" text-anchor="middle" font-weight="bold">2</text>
+  <text x="50" y="210" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">YOU</text>
+  <text x="355" y="210" font-family="monospace" font-size="11" fill="#4CAF50" text-anchor="middle">AI</text>
+</svg>

+ 234 - 0
src/games/spaceinvaders/index.html

@@ -0,0 +1,234 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Space Invaders</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#topbar a:hover { text-decoration: underline; }
+#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #4CAF50; }
+canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
+#msg { font-size: 18px; color: #4CAF50; margin: 6px; text-align: center; min-height: 28px; }
+#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">SPACE INVADERS</span>
+</div>
+<div id="ui">
+  <span>SCORE: <b id="score">0</b></span>
+  <span>LIVES: <b id="lives">3</b></span>
+  <span>WAVE: <b id="wave">1</b></span>
+</div>
+<canvas id="c" width="600" height="380"></canvas>
+<div id="msg">Press SPACE to start</div>
+<div id="controls">&#8592;&#8594; Move &nbsp;|&nbsp; SPACE — Shoot</div>
+<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="spaceinvaders">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
+  </form>
+</div>
+<script>
+document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+const W = canvas.width, H = canvas.height;
+
+let state = 'idle';
+let score = 0, lives = 3, wave = 1;
+let player, bullets, aliens, alienBullets;
+let alienDir = 1, alienMoveTimer = 0, alienDropped = false;
+let alienShootTimer = 0;
+let frames = 0;
+
+const ALIEN_COLS = 10, ALIEN_ROWS = 4;
+const PLAYER_W = 36, PLAYER_H = 18;
+const BULLET_SPEED = 9;
+const ALIEN_BULLET_SPEED = 3;
+
+function initGame() {
+  player = { x: W / 2 - PLAYER_W / 2, y: H - 40, w: PLAYER_W, h: PLAYER_H, speed: 5, shooting: false, cooldown: 0 };
+  bullets = [];
+  alienBullets = [];
+  alienDir = 1;
+  alienMoveTimer = 0;
+  alienDropped = false;
+  alienShootTimer = 0;
+  frames = 0;
+  spawnAliens();
+}
+
+function spawnAliens() {
+  aliens = [];
+  for (let r = 0; r < ALIEN_ROWS; r++) {
+    for (let c = 0; c < ALIEN_COLS; c++) {
+      aliens.push({ x: 40 + c * 52, y: 40 + r * 44, w: 32, h: 22, alive: true, type: r < 2 ? 0 : 1 });
+    }
+  }
+}
+
+const keys = {};
+document.addEventListener('keydown', e => {
+  keys[e.code] = true;
+  if (e.code === 'Space') e.preventDefault();
+  if (e.code === 'Space' && (state === 'idle' || state === 'over')) {
+    score = 0; lives = 3; wave = 1;
+    document.getElementById('score').textContent = '0';
+    document.getElementById('lives').textContent = '3';
+    document.getElementById('wave').textContent = '1';
+    initGame();
+    state = 'play';
+    document.getElementById('msg').textContent = '';
+  }
+});
+document.addEventListener('keyup', e => { keys[e.code] = false; });
+
+function drawPlayer(p) {
+  ctx.fillStyle = '#FFA500';
+  ctx.fillRect(p.x + p.w / 2 - 4, p.y - 8, 8, 8);
+  ctx.fillRect(p.x, p.y, p.w, p.h);
+  ctx.fillStyle = '#FF6600';
+  ctx.fillRect(p.x + 4, p.y + 4, 6, p.h - 4);
+  ctx.fillRect(p.x + p.w - 10, p.y + 4, 6, p.h - 4);
+}
+
+function drawAlien(a) {
+  if (!a.alive) return;
+  ctx.fillStyle = a.type === 0 ? '#4CAF50' : '#FFA500';
+  const f = Math.floor(frames / 20) % 2;
+  const x = a.x, y = a.y;
+  if (a.type === 0) {
+    ctx.fillRect(x + 4, y, 8, 6); ctx.fillRect(x + 20, y, 8, 6);
+    ctx.fillRect(x, y + 6, 32, 10);
+    ctx.fillRect(f === 0 ? x - 4 : x, y + 16, 8, 6);
+    ctx.fillRect(f === 0 ? x + 28 : x + 24, y + 16, 8, 6);
+    ctx.fillStyle = '#000';
+    ctx.fillRect(x + 8, y + 8, 4, 4); ctx.fillRect(x + 20, y + 8, 4, 4);
+  } else {
+    ctx.fillRect(x + 8, y, 16, 6);
+    ctx.fillRect(x, y + 6, 32, 10);
+    ctx.fillRect(f === 0 ? x + 4 : x, y + 16, 8, 6);
+    ctx.fillRect(f === 0 ? x + 20 : x + 24, y + 16, 8, 6);
+    ctx.fillStyle = '#000';
+    ctx.fillRect(x + 6, y + 8, 6, 4); ctx.fillRect(x + 20, y + 8, 6, 4);
+  }
+}
+
+function alienSpeed() { return 0.07 + wave * 0.06 + (1 - aliens.filter(a => a.alive).length / (ALIEN_COLS * ALIEN_ROWS)) * 0.2; }
+
+function loop() {
+  ctx.clearRect(0, 0, W, H);
+
+  ctx.fillStyle = '#003300';
+  ctx.fillRect(0, H - 2, W, 2);
+
+  if (state === 'play') {
+    if (keys['ArrowLeft'] && player.x > 0) player.x -= player.speed;
+    if (keys['ArrowRight'] && player.x + player.w < W) player.x += player.speed;
+
+    if (player.cooldown > 0) player.cooldown--;
+    if (keys['Space'] && player.cooldown === 0) {
+      bullets.push({ x: player.x + player.w / 2 - 2, y: player.y, w: 4, h: 10 });
+      player.cooldown = 12;
+    }
+
+    for (let i = bullets.length - 1; i >= 0; i--) {
+      bullets[i].y -= BULLET_SPEED;
+      if (bullets[i].y < 0) { bullets.splice(i, 1); continue; }
+      let hit = false;
+      for (const a of aliens) {
+        if (!a.alive) continue;
+        if (bullets[i] && bullets[i].x < a.x + a.w && bullets[i].x + bullets[i].w > a.x && bullets[i].y < a.y + a.h && bullets[i].y + bullets[i].h > a.y) {
+          a.alive = false;
+          score += a.type === 0 ? 20 : 10;
+          document.getElementById('score').textContent = score;
+          bullets.splice(i, 1);
+          hit = true;
+          break;
+        }
+      }
+    }
+
+    alienMoveTimer += alienSpeed();
+    if (alienMoveTimer >= 1) {
+      alienMoveTimer = 0;
+      let atEdge = false;
+      for (const a of aliens) {
+        if (!a.alive) continue;
+        if ((alienDir > 0 && a.x + a.w + 8 > W) || (alienDir < 0 && a.x - 8 < 0)) { atEdge = true; break; }
+      }
+      if (atEdge) {
+        alienDir *= -1;
+        for (const a of aliens) { if (a.alive) a.y += 12; }
+      } else {
+        for (const a of aliens) { if (a.alive) a.x += alienDir * 6; }
+      }
+    }
+
+    for (const a of aliens) {
+      if (a.alive && a.y + a.h >= player.y) {
+        state = 'over';
+        document.getElementById('msg').textContent = 'GAME OVER — Press SPACE';
+        document.getElementById('scoreInput').value = score;
+        document.getElementById('scoreSubmit').style.display = 'block';
+      }
+    }
+
+    alienShootTimer++;
+    if (alienShootTimer > Math.max(60, 130 - wave * 8)) {
+      alienShootTimer = 0;
+      const alive = aliens.filter(a => a.alive);
+      if (alive.length && alienBullets.length < 3) {
+        const shooter = alive[Math.floor(Math.random() * alive.length)];
+        alienBullets.push({ x: shooter.x + shooter.w / 2 - 2, y: shooter.y + shooter.h, w: 4, h: 10 });
+      }
+    }
+
+    for (let i = alienBullets.length - 1; i >= 0; i--) {
+      alienBullets[i].y += ALIEN_BULLET_SPEED;
+      if (alienBullets[i].y > H) { alienBullets.splice(i, 1); continue; }
+      const b = alienBullets[i];
+      if (b && b.x < player.x + player.w && b.x + b.w > player.x && b.y < player.y + player.h && b.y + b.h > player.y) {
+        alienBullets.splice(i, 1);
+        lives--;
+        document.getElementById('lives').textContent = lives;
+        if (lives <= 0) { state = 'over'; document.getElementById('msg').textContent = 'GAME OVER — Press SPACE'; document.getElementById('scoreInput').value = score; document.getElementById('scoreSubmit').style.display = 'block'; }
+      }
+    }
+
+    if (aliens.every(a => !a.alive)) {
+      wave++;
+      document.getElementById('wave').textContent = wave;
+      spawnAliens();
+    }
+
+    frames++;
+  }
+
+  aliens.forEach(drawAlien);
+  bullets.forEach(b => { ctx.fillStyle = '#fff'; ctx.fillRect(b.x, b.y, b.w, b.h); });
+  alienBullets.forEach(b => { ctx.fillStyle = '#f44'; ctx.fillRect(b.x, b.y, b.w, b.h); });
+  drawPlayer(player);
+
+  for (let i = 0; i < lives; i++) {
+    ctx.fillStyle = '#FFA500';
+    ctx.fillRect(10 + i * 20, H - 16, 14, 8);
+  }
+
+  requestAnimationFrame(loop);
+}
+
+initGame();
+loop();
+</script>
+</body>
+</html>

+ 26 - 0
src/games/spaceinvaders/thumbnail.svg

@@ -0,0 +1,26 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
+  <rect width="400" height="220" fill="#000"/>
+  <g fill="#4CAF50">
+    <rect x="30" y="30" width="8" height="8"/><rect x="46" y="30" width="8" height="8"/><rect x="38" y="38" width="16" height="8"/><rect x="26" y="46" width="32" height="8"/><rect x="22" y="54" width="8" height="8"/><rect x="54" y="54" width="8" height="8"/>
+    <rect x="90" y="30" width="8" height="8"/><rect x="106" y="30" width="8" height="8"/><rect x="98" y="38" width="16" height="8"/><rect x="86" y="46" width="32" height="8"/><rect x="82" y="54" width="8" height="8"/><rect x="114" y="54" width="8" height="8"/>
+    <rect x="150" y="30" width="8" height="8"/><rect x="166" y="30" width="8" height="8"/><rect x="158" y="38" width="16" height="8"/><rect x="146" y="46" width="32" height="8"/><rect x="142" y="54" width="8" height="8"/><rect x="174" y="54" width="8" height="8"/>
+    <rect x="210" y="30" width="8" height="8"/><rect x="226" y="30" width="8" height="8"/><rect x="218" y="38" width="16" height="8"/><rect x="206" y="46" width="32" height="8"/><rect x="202" y="54" width="8" height="8"/><rect x="234" y="54" width="8" height="8"/>
+    <rect x="270" y="30" width="8" height="8"/><rect x="286" y="30" width="8" height="8"/><rect x="278" y="38" width="16" height="8"/><rect x="266" y="46" width="32" height="8"/><rect x="262" y="54" width="8" height="8"/><rect x="294" y="54" width="8" height="8"/>
+    <rect x="330" y="30" width="8" height="8"/><rect x="346" y="30" width="8" height="8"/><rect x="338" y="38" width="16" height="8"/><rect x="326" y="46" width="32" height="8"/><rect x="322" y="54" width="8" height="8"/><rect x="354" y="54" width="8" height="8"/>
+  </g>
+  <g fill="#FFA500">
+    <rect x="30" y="100" width="8" height="8"/><rect x="46" y="100" width="8" height="8"/><rect x="38" y="92" width="16" height="8"/><rect x="34" y="108" width="24" height="8"/>
+    <rect x="90" y="100" width="8" height="8"/><rect x="106" y="100" width="8" height="8"/><rect x="98" y="92" width="16" height="8"/><rect x="94" y="108" width="24" height="8"/>
+    <rect x="150" y="100" width="8" height="8"/><rect x="166" y="100" width="8" height="8"/><rect x="158" y="92" width="16" height="8"/><rect x="154" y="108" width="24" height="8"/>
+    <rect x="210" y="100" width="8" height="8"/><rect x="226" y="100" width="8" height="8"/><rect x="218" y="92" width="16" height="8"/><rect x="214" y="108" width="24" height="8"/>
+    <rect x="270" y="100" width="8" height="8"/><rect x="286" y="100" width="8" height="8"/><rect x="278" y="92" width="16" height="8"/><rect x="274" y="108" width="24" height="8"/>
+  </g>
+  <g fill="#FFA500">
+    <rect x="190" y="180" width="20" height="8"/>
+    <rect x="184" y="172" width="32" height="8"/>
+    <rect x="178" y="164" width="44" height="8"/>
+    <rect x="196" y="156" width="8" height="8"/>
+  </g>
+  <rect x="196" y="144" width="4" height="12" fill="#fff"/>
+  <text x="200" y="215" font-family="monospace" font-size="12" fill="#4CAF50" text-anchor="middle">SCORE: 0</text>
+</svg>

+ 242 - 0
src/games/tetris/index.html

@@ -0,0 +1,242 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Tetris</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#game-wrap { display: flex; flex: 1; align-self: stretch; min-height: 0; gap: 16px; align-items: stretch; padding: 8px; }
+canvas { display: block; border: 1px solid #333; }
+#c { flex: 1; min-width: 0; }
+#sidebar { width: 120px; color: #ccc; font-size: 13px; }
+#sidebar h3 { color: #FFA500; margin-bottom: 6px; }
+#sidebar p { margin-bottom: 4px; }
+#sidebar b { color: #FFA500; }
+#next-canvas { border: 1px solid #333; margin: 8px 0; }
+#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
+#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
+#touch-row { display: flex; gap: 6px; justify-content: center; margin: 4px; }
+#touch-row button { width: 50px; height: 40px; background: #222; border: 1px solid #555; color: #FFA500; font-size: 16px; cursor: pointer; }
+#scoreSubmit { text-align: center; margin: 6px; }
+#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">TETRIS</span>
+</div>
+<div id="game-wrap">
+  <canvas id="c" width="200" height="400"></canvas>
+  <div id="sidebar">
+    <h3>NEXT</h3>
+    <canvas id="next-canvas" width="100" height="80"></canvas>
+    <p>SCORE<br><b id="scoreEl">0</b></p>
+    <p>LINES<br><b id="linesEl">0</b></p>
+    <p>LEVEL<br><b id="levelEl">1</b></p>
+    <p>BEST<br><b id="bestEl">-</b></p>
+    <p style="margin-top:10px;color:#888;font-size:11px">&#8592;&#8594; Move<br>&#8593; Rotate<br>&#8595; Soft drop<br>SPACE Hard drop</p>
+  </div>
+</div>
+<div id="msg">Press SPACE to start</div>
+<div id="touch-row">
+  <button id="tleft">&#8592;</button>
+  <button id="trot">&#8635;</button>
+  <button id="tright">&#8594;</button>
+  <button id="tdrop">&#8595;&#8595;</button>
+</div>
+<div id="controls">Arrow keys &nbsp;|&nbsp; SPACE — hard drop / new game</div>
+<div id="scoreSubmit" style="display:none">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="tetris">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit">Submit Score to Hall of Fame</button>
+  </form>
+</div>
+<script>
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+const nextCanvas = document.getElementById('next-canvas');
+const nctx = nextCanvas.getContext('2d');
+const COLS = 10, ROWS = 20, CELL = 20;
+
+const PIECES = [
+  { shape: [[1,1,1,1]], color: '#00f0f0' },
+  { shape: [[1,1],[1,1]], color: '#ffd700' },
+  { shape: [[0,1,1],[1,1,0]], color: '#0f0' },
+  { shape: [[1,1,0],[0,1,1]], color: '#f00' },
+  { shape: [[1,0,0],[1,1,1]], color: '#00f' },
+  { shape: [[0,0,1],[1,1,1]], color: '#fa0' },
+  { shape: [[0,1,0],[1,1,1]], color: '#a0f' }
+];
+
+let board, score, lines, level, piece, next, gameState, dropTimer, dropInterval;
+let best = parseInt(localStorage.getItem('tetris_best') || '-1');
+
+function rotate(shape) {
+  return shape[0].map((_, i) => shape.map(row => row[i]).reverse());
+}
+
+function newPiece(template) {
+  const t = template || PIECES[Math.floor(Math.random() * PIECES.length)];
+  return { shape: t.shape.map(r => [...r]), color: t.color, x: Math.floor(COLS / 2) - Math.floor(t.shape[0].length / 2), y: 0 };
+}
+
+function fits(shape, x, y) {
+  for (let r = 0; r < shape.length; r++)
+    for (let c = 0; c < shape[r].length; c++)
+      if (shape[r][c]) {
+        const nx = x + c, ny = y + r;
+        if (nx < 0 || nx >= COLS || ny >= ROWS) return false;
+        if (ny >= 0 && board[ny][nx]) return false;
+      }
+  return true;
+}
+
+function lock() {
+  for (let r = 0; r < piece.shape.length; r++)
+    for (let c = 0; c < piece.shape[r].length; c++)
+      if (piece.shape[r][c] && piece.y + r >= 0) board[piece.y + r][piece.x + c] = piece.color;
+  let cleared = 0;
+  for (let r = ROWS - 1; r >= 0; r--) {
+    if (board[r].every(c => c)) {
+      board.splice(r, 1);
+      board.unshift(new Array(COLS).fill(0));
+      cleared++; r++;
+    }
+  }
+  if (cleared) {
+    const pts = [0, 100, 300, 500, 800][cleared] * level;
+    score += pts; lines += cleared;
+    level = Math.floor(lines / 10) + 1;
+    dropInterval = Math.max(100, 800 - (level - 1) * 70);
+    document.getElementById('scoreEl').textContent = score;
+    document.getElementById('linesEl').textContent = lines;
+    document.getElementById('levelEl').textContent = level;
+  }
+  piece = newPiece(next);
+  next = PIECES[Math.floor(Math.random() * PIECES.length)];
+  if (!fits(piece.shape, piece.x, piece.y)) { endGame(); return; }
+}
+
+function drop() {
+  if (!fits(piece.shape, piece.x, piece.y + 1)) { lock(); } else { piece.y++; }
+}
+
+function hardDrop() {
+  while (fits(piece.shape, piece.x, piece.y + 1)) { piece.y++; score += 2; }
+  lock();
+  document.getElementById('scoreEl').textContent = score;
+}
+
+function startGame() {
+  board = Array.from({length: ROWS}, () => new Array(COLS).fill(0));
+  score = 0; lines = 0; level = 1; dropInterval = 800; dropTimer = 0;
+  gameState = 'play';
+  next = PIECES[Math.floor(Math.random() * PIECES.length)];
+  piece = newPiece(PIECES[Math.floor(Math.random() * PIECES.length)]);
+  document.getElementById('scoreEl').textContent = '0';
+  document.getElementById('linesEl').textContent = '0';
+  document.getElementById('levelEl').textContent = '1';
+  document.getElementById('msg').textContent = '';
+  document.getElementById('scoreSubmit').style.display = 'none';
+}
+
+function endGame() {
+  gameState = 'over';
+  if (best < 0 || score > best) {
+    best = score;
+    localStorage.setItem('tetris_best', best);
+    document.getElementById('bestEl').textContent = best;
+  }
+  document.getElementById('msg').textContent = `GAME OVER! Score: ${score}. SPACE = new game`;
+  document.getElementById('scoreInput').value = score;
+  document.getElementById('scoreSubmit').style.display = 'block';
+}
+
+document.addEventListener('keydown', e => {
+  if (e.code === 'Space') { e.preventDefault(); if (gameState !== 'play') startGame(); else hardDrop(); return; }
+  if (gameState !== 'play') return;
+  if (e.code === 'ArrowLeft') { e.preventDefault(); if (fits(piece.shape, piece.x - 1, piece.y)) piece.x--; }
+  if (e.code === 'ArrowRight') { e.preventDefault(); if (fits(piece.shape, piece.x + 1, piece.y)) piece.x++; }
+  if (e.code === 'ArrowDown') { e.preventDefault(); drop(); }
+  if (e.code === 'ArrowUp' || e.code === 'KeyX') {
+    e.preventDefault();
+    const rot = rotate(piece.shape);
+    if (fits(rot, piece.x, piece.y)) piece.shape = rot;
+    else if (fits(rot, piece.x - 1, piece.y)) { piece.x--; piece.shape = rot; }
+    else if (fits(rot, piece.x + 1, piece.y)) { piece.x++; piece.shape = rot; }
+  }
+});
+
+document.getElementById('tleft').addEventListener('click', () => { if (gameState === 'play' && fits(piece.shape, piece.x - 1, piece.y)) piece.x--; });
+document.getElementById('tright').addEventListener('click', () => { if (gameState === 'play' && fits(piece.shape, piece.x + 1, piece.y)) piece.x++; });
+document.getElementById('trot').addEventListener('click', () => { if (gameState !== 'play') return; const rot = rotate(piece.shape); if (fits(rot, piece.x, piece.y)) piece.shape = rot; });
+document.getElementById('tdrop').addEventListener('click', () => { if (gameState === 'play') hardDrop(); });
+
+if (best >= 0) document.getElementById('bestEl').textContent = best;
+
+function drawCell(c, x, y, size, context) {
+  context = context || ctx;
+  context.fillStyle = c;
+  context.fillRect(x + 1, y + 1, size - 2, size - 2);
+  context.fillStyle = 'rgba(255,255,255,0.15)';
+  context.fillRect(x + 1, y + 1, size - 2, 4);
+  context.fillStyle = 'rgba(0,0,0,0.2)';
+  context.fillRect(x + 1, y + size - 5, size - 2, 4);
+}
+
+function getGhostY() {
+  let gy = piece.y;
+  while (fits(piece.shape, piece.x, gy + 1)) gy++;
+  return gy;
+}
+
+let last = 0;
+function loop(ts) {
+  const dt = ts - last; last = ts;
+  if (gameState === 'play') {
+    dropTimer += dt;
+    if (dropTimer >= dropInterval) { dropTimer = 0; drop(); }
+  }
+
+  ctx.fillStyle = '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height);
+  ctx.strokeStyle = '#111'; ctx.lineWidth = 0.5;
+  for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { ctx.strokeRect(c * CELL, r * CELL, CELL, CELL); }
+
+  if (board) {
+    for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) if (board[r][c]) drawCell(board[r][c], c * CELL, r * CELL, CELL);
+  }
+
+  if (piece && gameState === 'play') {
+    const gy = getGhostY();
+    piece.shape.forEach((row, r) => row.forEach((v, c) => {
+      if (v) {
+        ctx.fillStyle = 'rgba(255,255,255,0.1)';
+        ctx.fillRect((piece.x + c) * CELL + 1, (gy + r) * CELL + 1, CELL - 2, CELL - 2);
+        drawCell(piece.color, (piece.x + c) * CELL, (piece.y + r) * CELL, CELL);
+      }
+    }));
+  }
+
+  nctx.fillStyle = '#0a0a0a'; nctx.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
+  if (next) {
+    const nCell = 18;
+    const ox = Math.floor((nextCanvas.width - next.shape[0].length * nCell) / 2);
+    const oy = Math.floor((nextCanvas.height - next.shape.length * nCell) / 2);
+    next.shape.forEach((row, r) => row.forEach((v, c) => { if (v) drawCell(next.color, ox + c * nCell, oy + r * nCell, nCell, nctx); }));
+  }
+
+  requestAnimationFrame(loop);
+}
+
+gameState = 'idle';
+requestAnimationFrame(loop);
+</script>
+</body>
+</html>

+ 22 - 0
src/games/tetris/thumbnail.svg

@@ -0,0 +1,22 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
+  <rect width="120" height="80" fill="#111"/>
+  <rect x="30" y="5" width="60" height="70" fill="#000" stroke="#333" stroke-width="1"/>
+  <rect x="30" y="55" width="10" height="10" fill="#00f0f0"/>
+  <rect x="40" y="55" width="10" height="10" fill="#00f0f0"/>
+  <rect x="50" y="55" width="10" height="10" fill="#00f0f0"/>
+  <rect x="60" y="55" width="10" height="10" fill="#00f0f0"/>
+  <rect x="30" y="65" width="10" height="10" fill="#ff0"/>
+  <rect x="40" y="65" width="10" height="10" fill="#ff0"/>
+  <rect x="50" y="65" width="10" height="10" fill="#0f0"/>
+  <rect x="60" y="65" width="10" height="10" fill="#0f0"/>
+  <rect x="70" y="65" width="10" height="10" fill="#f00"/>
+  <rect x="80" y="65" width="10" height="10" fill="#f00"/>
+  <rect x="60" y="45" width="10" height="10" fill="#f0a"/>
+  <rect x="60" y="35" width="10" height="10" fill="#f0a"/>
+  <rect x="70" y="35" width="10" height="10" fill="#f0a"/>
+  <rect x="80" y="35" width="10" height="10" fill="#f0a"/>
+  <rect x="40" y="20" width="10" height="10" fill="#fa0"/>
+  <rect x="50" y="20" width="10" height="10" fill="#fa0"/>
+  <rect x="50" y="10" width="10" height="10" fill="#fa0"/>
+  <rect x="60" y="10" width="10" height="10" fill="#fa0"/>
+</svg>

+ 182 - 0
src/games/tiktaktoe/index.html

@@ -0,0 +1,182 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>TikTakToe — Rock Paper Scissors</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; min-height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#topbar a:hover { text-decoration: underline; }
+h1 { color: #FFA500; font-size: 1.4rem; margin: 18px 0 6px; }
+#score-bar { display: flex; gap: 32px; font-size: 15px; color: #aaa; margin-bottom: 14px; }
+#score-bar span b { color: #FFA500; }
+#arena { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; max-width: 480px; padding: 0 12px; }
+#choices { display: flex; gap: 16px; }
+.choice-btn { font-size: 3rem; background: #111; border: 2px solid #333; border-radius: 12px; cursor: pointer; padding: 14px 20px; transition: border-color 0.15s, background 0.15s; line-height: 1; }
+.choice-btn:hover { border-color: #FFA500; background: #1a1a00; }
+.choice-btn.selected { border-color: #FFA500; background: #2a1a00; }
+#battle { display: flex; align-items: center; gap: 24px; font-size: 2.2rem; min-height: 72px; }
+#vs { font-size: 1rem; color: #555; }
+#result { font-size: 1.3rem; min-height: 32px; text-align: center; }
+.win { color: #4CAF50; } .lose { color: #e74c3c; } .draw { color: #FFA500; }
+#rounds { display: flex; gap: 8px; margin-top: 4px; }
+.round-dot { width: 14px; height: 14px; border-radius: 50%; background: #222; border: 2px solid #444; }
+.round-dot.win { background: #4CAF50; border-color: #4CAF50; }
+.round-dot.lose { background: #e74c3c; border-color: #e74c3c; }
+.round-dot.draw { background: #FFA500; border-color: #FFA500; }
+#play-btn { margin-top: 10px; padding: 10px 32px; font-family: monospace; font-size: 1rem; background: #FFA500; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
+#play-btn:hover { background: #ffb700; }
+#play-btn:disabled { background: #333; color: #666; cursor: default; }
+#final { font-size: 1.4rem; min-height: 36px; text-align: center; margin-top: 8px; }
+#hint { color: #555; font-size: 13px; margin-top: 4px; }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">ROCK PAPER SCISSORS</span>
+</div>
+<h1>Rock &middot; Paper &middot; Scissors</h1>
+<div id="score-bar">
+  <span>YOU: <b id="playerWins">0</b></span>
+  <span>AI: <b id="aiWins">0</b></span>
+  <span>DRAWS: <b id="draws">0</b></span>
+</div>
+<div id="arena">
+  <div id="choices">
+    <button class="choice-btn" data-choice="rock" title="Rock">&#9994;</button>
+    <button class="choice-btn" data-choice="paper" title="Paper">&#9995;</button>
+    <button class="choice-btn" data-choice="scissors" title="Scissors">✌️</button>
+  </div>
+  <div id="battle">
+    <span id="playerEmoji">?</span>
+    <span id="vs">VS</span>
+    <span id="aiEmoji">?</span>
+  </div>
+  <div id="result">&nbsp;</div>
+  <div id="rounds"></div>
+  <button id="play-btn" disabled>Choose a move!</button>
+  <div id="final">&nbsp;</div>
+  <div id="hint">Best of 5 rounds</div>
+  <div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
+    <form method="POST" action="/games/submit-score" target="_top">
+      <input type="hidden" name="game" value="tiktaktoe">
+      <input type="hidden" id="scoreInput" name="score" value="0">
+      <button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
+    </form>
+  </div>
+</div>
+<script>
+const EMOJI = { rock: '\u270A', paper: '\u270B', scissors: '✌️' };
+const BEATS = { rock: 'scissors', scissors: 'paper', paper: 'rock' };
+const NAMES = { rock: 'Rock', paper: 'Paper', scissors: 'Scissors' };
+
+let playerWins = 0, aiWins = 0, draws = 0;
+let roundHistory = [];
+let chosen = null;
+let gameOver = false;
+let streak = 0, bestStreak = 0;
+const MAX_ROUNDS = 5;
+
+const playerWinsEl = document.getElementById('playerWins');
+const aiWinsEl = document.getElementById('aiWins');
+const drawsEl = document.getElementById('draws');
+const playerEmojiEl = document.getElementById('playerEmoji');
+const aiEmojiEl = document.getElementById('aiEmoji');
+const resultEl = document.getElementById('result');
+const roundsEl = document.getElementById('rounds');
+const playBtn = document.getElementById('play-btn');
+const finalEl = document.getElementById('final');
+
+function updateRounds() {
+  while (roundsEl.firstChild) roundsEl.removeChild(roundsEl.firstChild);
+  for (let i = 0; i < MAX_ROUNDS; i++) {
+    const dot = document.createElement('div');
+    dot.className = 'round-dot' + (roundHistory[i] ? ' ' + roundHistory[i] : '');
+    roundsEl.appendChild(dot);
+  }
+}
+
+document.querySelectorAll('.choice-btn').forEach(btn => {
+  btn.addEventListener('click', () => {
+    if (gameOver) return;
+    document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
+    btn.classList.add('selected');
+    chosen = btn.dataset.choice;
+    playBtn.disabled = false;
+    playBtn.textContent = 'PLAY!';
+  });
+});
+
+function onPlay() {
+  if (!chosen || gameOver) return;
+  const choices = ['rock', 'paper', 'scissors'];
+  const aiChoice = choices[Math.floor(Math.random() * 3)];
+
+  playerEmojiEl.textContent = EMOJI[chosen];
+  aiEmojiEl.textContent = EMOJI[aiChoice];
+
+  let outcome, outcomeClass;
+  if (chosen === aiChoice) {
+    outcome = 'DRAW!'; outcomeClass = 'draw';
+    draws++;
+    roundHistory.push('draw');
+    drawsEl.textContent = draws;
+  } else if (BEATS[chosen] === aiChoice) {
+    outcome = NAMES[chosen] + ' beats ' + NAMES[aiChoice] + ' \u2014 YOU WIN!'; outcomeClass = 'win';
+    playerWins++; streak++;
+    if (streak > bestStreak) bestStreak = streak;
+    roundHistory.push('win');
+    playerWinsEl.textContent = playerWins;
+  } else {
+    outcome = NAMES[aiChoice] + ' beats ' + NAMES[chosen] + ' \u2014 AI WINS!'; outcomeClass = 'lose';
+    aiWins++; streak = 0;
+    roundHistory.push('lose');
+    aiWinsEl.textContent = aiWins;
+  }
+
+  resultEl.className = outcomeClass;
+  resultEl.textContent = outcome;
+  updateRounds();
+
+  const total = playerWins + aiWins + draws;
+  if (playerWins >= 3 || aiWins >= 3 || total >= MAX_ROUNDS) {
+    gameOver = true;
+    if (playerWins > aiWins) { finalEl.className = 'win'; finalEl.textContent = 'YOU WIN THE MATCH! Best streak: ' + bestStreak; document.getElementById('scoreInput').value = bestStreak; document.getElementById('scoreSubmit').style.display = 'block'; }
+    else if (aiWins > playerWins) { finalEl.className = 'lose'; finalEl.textContent = 'AI WINS THE MATCH!'; }
+    else { finalEl.className = 'draw'; finalEl.textContent = "IT'S A TIE!"; }
+    playBtn.textContent = 'New Game';
+    playBtn.disabled = false;
+    playBtn.onclick = resetGame;
+  } else {
+    chosen = null;
+    document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
+    playBtn.disabled = true;
+    playBtn.textContent = 'Choose a move!';
+  }
+}
+
+playBtn.addEventListener('click', onPlay);
+
+function resetGame() {
+  playerWins = 0; aiWins = 0; draws = 0; roundHistory = []; chosen = null; gameOver = false; streak = 0; bestStreak = 0;
+  playerWinsEl.textContent = '0'; aiWinsEl.textContent = '0'; drawsEl.textContent = '0';
+  playerEmojiEl.textContent = '?'; aiEmojiEl.textContent = '?';
+  resultEl.textContent = '\u00a0'; resultEl.className = '';
+  finalEl.textContent = '\u00a0'; finalEl.className = '';
+  document.getElementById('scoreSubmit').style.display = 'none';
+  document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
+  playBtn.disabled = true;
+  playBtn.textContent = 'Choose a move!';
+  playBtn.onclick = onPlay;
+  updateRounds();
+}
+
+updateRounds();
+</script>
+</body>
+</html>

+ 27 - 0
src/games/tiktaktoe/thumbnail.svg

@@ -0,0 +1,27 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
+  <rect width="400" height="220" fill="#000"/>
+  <rect x="120" y="20" width="160" height="180" rx="8" fill="#111"/>
+  <line x1="174" y1="28" x2="174" y2="192" stroke="#444" stroke-width="3"/>
+  <line x1="226" y1="28" x2="226" y2="192" stroke="#444" stroke-width="3"/>
+  <line x1="128" y1="80" x2="272" y2="80" stroke="#444" stroke-width="3"/>
+  <line x1="128" y1="132" x2="272" y2="132" stroke="#444" stroke-width="3"/>
+  <g stroke="#FFA500" stroke-width="4" fill="none" stroke-linecap="round">
+    <line x1="135" y1="35" x2="165" y2="68"/>
+    <line x1="165" y1="35" x2="135" y2="68"/>
+  </g>
+  <circle cx="200" cy="52" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
+  <g stroke="#FFA500" stroke-width="4" fill="none" stroke-linecap="round">
+    <line x1="238" y1="35" x2="268" y2="68"/>
+    <line x1="268" y1="35" x2="238" y2="68"/>
+  </g>
+  <circle cx="151" cy="106" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
+  <g stroke="#FFA500" stroke-width="4" fill="none" stroke-linecap="round">
+    <line x1="187" y1="88" x2="217" y2="122"/>
+    <line x1="217" y1="88" x2="187" y2="122"/>
+  </g>
+  <circle cx="253" cy="106" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
+  <circle cx="151" cy="158" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
+  <circle cx="200" cy="158" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
+  <line x1="120" y1="158" x2="280" y2="158" stroke="#FFA500" stroke-width="5" opacity="0.6"/>
+  <text x="200" y="215" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">Rock · Paper · Scissors</text>
+</svg>

+ 17 - 3
src/models/activity_model.js

@@ -23,6 +23,8 @@ function inferType(c = {}) {
   if (c.type === 'courts_mediators') return 'courtsMediators';
   if (c.type === 'map') return 'map';
   if (c.type === 'mapMarker') return 'mapMarker';
+  if (c.type === 'chat') return 'chat';
+  if (c.type === 'chatMessage') return 'chatMessage';
   if (c.type === 'vote' && c.vote && typeof c.vote.link === 'string') {
     const br = Array.isArray(c.branch) ? c.branch : [];
     if (br.includes(c.vote.link) && Number(c.vote.value) === 1) return 'spread';
@@ -492,12 +494,20 @@ module.exports = ({ cooler }) => {
       deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
 
       const tribeInternalTypes = new Set(['tribeLeave', 'tribeFeedPost', 'tribeFeedRefeed', 'tribe-content']);
+      const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent']);
       const isAllowedTribeActivity = (a) => !tribeInternalTypes.has(a.type);
+      const isVisible = (a) => {
+        if (hiddenTypes.has(a.type)) return false;
+        if (a.type === 'pad' && (a.content || {}).status !== 'OPEN') return false;
+        if (a.type === 'chat' && (a.content || {}).status !== 'OPEN') return false;
+        if (a.type === 'calendar' && (a.content || {}).status !== 'OPEN') return false;
+        return true;
+      };
 
       let out;
-      if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a));
-      else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a)) }
-      else if (filter === 'all') out = deduped.filter(isAllowedTribeActivity);
+      if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a));
+      else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) }
+      else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
       else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim');
       else if (filter === 'karma') out = deduped.filter(a => a.type === 'karmaScore');
       else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
@@ -513,6 +523,10 @@ module.exports = ({ cooler }) => {
         });
       else if (filter === 'task')
         out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
+      else if (filter === 'gameScore') out = deduped.filter(a => a.type === 'gameScore');
+      else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
+      else if (filter === 'chat') out = deduped.filter(a => a.type === 'chat' && (a.content || {}).status === 'OPEN');
+      else if (filter === 'calendar') out = deduped.filter(a => a.type === 'calendar' && (a.content || {}).status === 'OPEN');
       else out = deduped.filter(a => a.type === filter);
 
       out.sort((a, b) => (b.ts || 0) - (a.ts || 0));

+ 10 - 3
src/models/agenda_model.js

@@ -140,7 +140,7 @@ module.exports = ({ cooler }) => {
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
 
-      const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll] = await Promise.all([
+      const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll] = await Promise.all([
         fetchItems('task'),
         fetchItems('event'),
         fetchItems('transfer'),
@@ -148,7 +148,8 @@ module.exports = ({ cooler }) => {
         fetchItems('market'),
         fetchItems('report'),
         fetchItems('job'),
-        fetchItems('project')
+        fetchItems('project'),
+        fetchItems('calendar')
       ]);
 
       const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
@@ -161,6 +162,9 @@ module.exports = ({ cooler }) => {
       const reports = reportsAll.filter(c => c.author === userId || (Array.isArray(c.confirmations) && c.confirmations.includes(userId))).map(r => ({ ...r, type: 'report' }));
       const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
       const projects = projectsAll.map(p => ({ ...p, type: 'project' }));
+      const calendars = calendarsAll
+        .filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId)))
+        .map(c => ({ ...c, type: 'calendar' }));
 
       let combined = [
         ...tasks,
@@ -170,7 +174,8 @@ module.exports = ({ cooler }) => {
         ...marketItems,
         ...reports,
         ...jobs,
-        ...projects
+        ...projects,
+        ...calendars
       ];
 
       let filtered;
@@ -188,6 +193,7 @@ module.exports = ({ cooler }) => {
         else if (filter === 'closed') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'CLOSED');
         else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job');
         else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project');
+        else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar');
       }
 
       filtered.sort((a, b) => {
@@ -213,6 +219,7 @@ module.exports = ({ cooler }) => {
           reports: mainItems.filter(i => i.type === 'report').length,
           jobs: mainItems.filter(i => i.type === 'job').length,
           projects: mainItems.filter(i => i.type === 'project').length,
+          calendars: mainItems.filter(i => i.type === 'calendar').length,
           discarded: discarded.length
         }
       };

+ 351 - 103
src/models/banking_model.js

@@ -7,14 +7,16 @@ const { config } = require("../server/SSB_server.js");
 
 const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
 
+const MAX_PENDING_EPOCHS = 12;
+
 const DEFAULT_RULES = {
-  epochKind: "WEEKLY",
+  epochKind: "MONTHLY",
   alpha: 0.2,
   reserveMin: 500,
   capPerEpoch: 2000,
   caps: { M_max: 3, T_max: 1.5, P_max: 2, cap_user_epoch: 50, w_min: 0.2, w_max: 6 },
   coeffs: { a1: 0.6, a2: 0.4, a3: 0.3, a4: 0.5, b1: 0.5, b2: 1.0 },
-  graceDays: 14
+  graceDays: 30
 };
 
 const STORAGE_DIR = path.join(__dirname, "..", "configs");
@@ -31,13 +33,9 @@ function ensureStoreFiles() {
 
 function epochIdNow() {
   const d = new Date();
-  const tmp = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
-  const dayNum = tmp.getUTCDay() || 7;
-  tmp.setUTCDate(tmp.getUTCDate() + 4 - dayNum);
-  const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
-  const weekNo = Math.ceil((((tmp - yearStart) / 86400000) + 1) / 7);
-  const yyyy = tmp.getUTCFullYear();
-  return `${yyyy}-${String(weekNo).padStart(2, "0")}`;
+  const yyyy = d.getUTCFullYear();
+  const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
+  return `${yyyy}-${mm}`;
 }
 
 async function getAnyWalletAddress() {
@@ -108,7 +106,7 @@ function writeJson(p, v) {
 async function rpcCall(method, params, kind = "user") {
   const cfg = getWalletCfg(kind);
   if (!cfg?.url) {
-    return null; 
+    return null;
   }
   const headers = {
     "Content-Type": "application/json",
@@ -132,9 +130,9 @@ async function rpcCall(method, params, kind = "user") {
     }
     const data = await res.json();
     if (data.error) {
-      return null; 
+      return null;
     }
-    return data.result; 
+    return data.result;
   } catch (err) {
     return null;
   }
@@ -176,6 +174,15 @@ function getWalletCfg(kind) {
   return cfg.wallet || null;
 }
 
+function isPubNode() {
+  const cfg = getWalletCfg("pub");
+  return !!(cfg && cfg.url);
+}
+
+function getConfiguredPubId() {
+  return (getConfig() || {}).pubId || "";
+}
+
 function resolveUserId(maybeId) {
   const s = String(maybeId || "").trim();
   if (s) return s;
@@ -216,15 +223,19 @@ module.exports = ({ services } = {}) => {
     return ssbInstance;
   }
 
-  async function getWalletFromSSB(userId) {
+  async function scanLogStream() {
     const ssb = await openSsb();
-    if (!ssb) return null;
-    const msgs = await new Promise((resolve, reject) =>
+    if (!ssb) return [];
+    return new Promise((resolve, reject) =>
       pull(
         ssb.createLogStream({ limit: getLogLimit() }),
         pull.collect((err, arr) => err ? reject(err) : resolve(arr))
       )
     );
+  }
+
+  async function getWalletFromSSB(userId) {
+    const msgs = await scanLogStream();
     for (let i = msgs.length - 1; i >= 0; i--) {
       const v = msgs[i].value || {};
       const c = v.content || {};
@@ -236,15 +247,8 @@ module.exports = ({ services } = {}) => {
   }
 
   async function scanAllWalletsSSB() {
-    const ssb = await openSsb();
-    if (!ssb) return {};
     const latest = {};
-    const msgs = await new Promise((resolve, reject) =>
-      pull(
-        ssb.createLogStream({ limit: getLogLimit() }),
-        pull.collect((err, arr) => err ? reject(err) : resolve(arr))
-      )
-    );
+    const msgs = await scanLogStream();
     for (let i = msgs.length - 1; i >= 0; i--) {
       const v = msgs[i].value || {};
       const c = v.content || {};
@@ -338,6 +342,7 @@ module.exports = ({ services } = {}) => {
     if (c.vote) return "vote";
     if (c.votes) return "votes";
     if (c.address && c.coin === "ECO" && c.type === "wallet") return "bankWallet";
+    if (c.type === "ubiClaimResult" && c.txid && c.epochId) return "ubiClaimResult";
     if (typeof c.amount !== "undefined" && c.epochId && c.allocationId) return "bankClaim";
     if (typeof c.item_type !== "undefined" && typeof c.status !== "undefined") return "market";
     if (typeof c.goal !== "undefined" && typeof c.progress !== "undefined") return "project";
@@ -428,12 +433,7 @@ module.exports = ({ services } = {}) => {
       FEED_SRC = "none";
       return [];
     }
-    const msgs = await new Promise((resolve, reject) =>
-      pull(
-        ssb.createLogStream({ limit: getLogLimit() }),
-        pull.collect((err, arr) => err ? reject(err) : resolve(arr))
-      )
-    );
+    const msgs = await scanLogStream();
     FEED_SRC = "ssb.createLogStream";
     return msgs.map(m => {
       const v = m.value || {};
@@ -483,57 +483,60 @@ async function fetchUserActions(userId) {
   });
 }
 
-// karma scoring table
 function scoreFromActions(actions) {
   let score = 0;
+  const nowMs = Date.now();
   for (const action of actions) {
     const t = normalizeType(action);
     const c = action.content || {};
     const rawType = String(c.type || "").toLowerCase();
-    if (t === "post") score += 10;
-    else if (t === "comment") score += 5;
-    else if (t === "like") score += 2;
-    else if (t === "image") score += 8;
-    else if (t === "video") score += 12;
-    else if (t === "audio") score += 8;
-    else if (t === "document") score += 6;
-    else if (t === "bookmark") score += 2;
-    else if (t === "feed") score += 6;
-    else if (t === "forum") score += c.root ? 5 : 10;
-    else if (t === "vote") score += 3 + calculateOpinionScore(c);
-    else if (t === "votes") score += Math.min(10, Number(c.totalVotes || 0));
-    else if (t === "market") score += scoreMarket(c);
-    else if (t === "project") score += scoreProject(c);
-    else if (t === "tribe") score += 6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0);
-    else if (t === "event") score += 4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0);
-    else if (t === "task") score += 3 + priorityBump(c.priority);
-    else if (t === "report") score += 4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity);
-    else if (t === "curriculum") score += 5;
-    else if (t === "aiexchange") score += Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0;
-    else if (t === "job") score += 4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0);
-    else if (t === "bankclaim") score += Math.min(20, Math.log(1 + Math.max(0, Number(c.amount) || 0)) * 5);
-    else if (t === "bankwallet") score += 2;
-    else if (t === "transfer") score += 1;
-    else if (t === "about") score += 1;
-    else if (t === "contact") score += 1;
-    else if (t === "pub") score += 1;
-    else if (t === "parliamentcandidature" || rawType === "parliamentcandidature") score += 12;
-    else if (t === "parliamentterm" || rawType === "parliamentterm") score += 25;
-    else if (t === "parliamentproposal" || rawType === "parliamentproposal") score += 8;
-    else if (t === "parliamentlaw" || rawType === "parliamentlaw") score += 16;
-    else if (t === "parliamentrevocation" || rawType === "parliamentrevocation") score += 10;
-    else if (t === "courts_case" || t === "courtscase" || rawType === "courts_case") score += 4;
-    else if (t === "courts_evidence" || t === "courtsevidence" || rawType === "courts_evidence") score += 3;
-    else if (t === "courts_answer" || t === "courtsanswer" || rawType === "courts_answer") score += 4;
-    else if (t === "courts_verdict" || t === "courtsverdict" || rawType === "courts_verdict") score += 10;
-    else if (t === "courts_settlement" || t === "courtssettlement" || rawType === "courts_settlement") score += 8;
-    else if (t === "courts_nomination" || t === "courtsnomination" || rawType === "courts_nomination") score += 6;
-    else if (t === "courts_nom_vote" || t === "courtsnomvote" || rawType === "courts_nom_vote") score += 3;
-    else if (t === "courts_public_pref" || t === "courtspublicpref" || rawType === "courts_public_pref") score += 1;
-    else if (t === "courts_mediators" || t === "courtsmediators" || rawType === "courts_mediators") score += 6;
-    else if (t === "courts_open_support" || t === "courtsopensupport" || rawType === "courts_open_support") score += 2;
-    else if (t === "courts_verdict_vote" || t === "courtsverdictvote" || rawType === "courts_verdict_vote") score += 3;
-    else if (t === "courts_judge_assign" || t === "courtsjudgeassign" || rawType === "courts_judge_assign") score += 5;
+    const ts = action.value?.timestamp;
+    const ageDays = ts ? (nowMs - ts) / 86400000 : Infinity;
+    const decay = ageDays <= 30 ? 1.0 : ageDays <= 90 ? 0.5 : 0.25;
+    if (t === "post") score += 10 * decay;
+    else if (t === "comment") score += 5 * decay;
+    else if (t === "like") score += 2 * decay;
+    else if (t === "image") score += 8 * decay;
+    else if (t === "video") score += 12 * decay;
+    else if (t === "audio") score += 8 * decay;
+    else if (t === "document") score += 6 * decay;
+    else if (t === "bookmark") score += 2 * decay;
+    else if (t === "feed") score += 6 * decay;
+    else if (t === "forum") score += (c.root ? 5 : 10) * decay;
+    else if (t === "vote") score += (3 + calculateOpinionScore(c)) * decay;
+    else if (t === "votes") score += Math.min(10, Number(c.totalVotes || 0)) * decay;
+    else if (t === "market") score += scoreMarket(c) * decay;
+    else if (t === "project") score += scoreProject(c) * decay;
+    else if (t === "tribe") score += (6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0)) * decay;
+    else if (t === "event") score += (4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0)) * decay;
+    else if (t === "task") score += (3 + priorityBump(c.priority)) * decay;
+    else if (t === "report") score += (4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity)) * decay;
+    else if (t === "curriculum") score += 5 * decay;
+    else if (t === "aiexchange") score += (Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0) * decay;
+    else if (t === "job") score += (4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0)) * decay;
+    else if (t === "bankclaim") score += Math.min(20, Math.log(1 + Math.max(0, Number(c.amount) || 0)) * 5) * decay;
+    else if (t === "bankwallet") score += 2 * decay;
+    else if (t === "transfer") score += 1 * decay;
+    else if (t === "about") score += 1 * decay;
+    else if (t === "contact") score += 1 * decay;
+    else if (t === "pub") score += 1 * decay;
+    else if (t === "parliamentcandidature" || rawType === "parliamentcandidature") score += 12 * decay;
+    else if (t === "parliamentterm" || rawType === "parliamentterm") score += 25 * decay;
+    else if (t === "parliamentproposal" || rawType === "parliamentproposal") score += 8 * decay;
+    else if (t === "parliamentlaw" || rawType === "parliamentlaw") score += 16 * decay;
+    else if (t === "parliamentrevocation" || rawType === "parliamentrevocation") score += 10 * decay;
+    else if (t === "courts_case" || t === "courtscase" || rawType === "courts_case") score += 4 * decay;
+    else if (t === "courts_evidence" || t === "courtsevidence" || rawType === "courts_evidence") score += 3 * decay;
+    else if (t === "courts_answer" || t === "courtsanswer" || rawType === "courts_answer") score += 4 * decay;
+    else if (t === "courts_verdict" || t === "courtsverdict" || rawType === "courts_verdict") score += 10 * decay;
+    else if (t === "courts_settlement" || t === "courtssettlement" || rawType === "courts_settlement") score += 8 * decay;
+    else if (t === "courts_nomination" || t === "courtsnomination" || rawType === "courts_nomination") score += 6 * decay;
+    else if (t === "courts_nom_vote" || t === "courtsnomvote" || rawType === "courts_nom_vote") score += 3 * decay;
+    else if (t === "courts_public_pref" || t === "courtspublicpref" || rawType === "courts_public_pref") score += 1 * decay;
+    else if (t === "courts_mediators" || t === "courtsmediators" || rawType === "courts_mediators") score += 6 * decay;
+    else if (t === "courts_open_support" || t === "courtsopensupport" || rawType === "courts_open_support") score += 2 * decay;
+    else if (t === "courts_verdict_vote" || t === "courtsverdictvote" || rawType === "courts_verdict_vote") score += 3 * decay;
+    else if (t === "courts_judge_assign" || t === "courtsjudgeassign" || rawType === "courts_judge_assign") score += 5 * decay;
   }
   return Math.max(0, Math.round(score));
 }
@@ -550,7 +553,7 @@ async function getUserEngagementScore(userId) {
   const isSelf = idsEqual(uid, ssb.id);
   const hasSSB = !!(ssb && ssb.publish);
 
-  const changed = (prev === null) || (karmaScore !== prev); 
+  const changed = (prev === null) || (karmaScore !== prev);
   const nowMs = Date.now();
   const lastMs = lastPublishedTimestamp ? new Date(lastPublishedTimestamp).getTime() : 0;
   const cooldownOk = (nowMs - lastMs) >= 24 * 60 * 60 * 1000;
@@ -613,7 +616,7 @@ async function getLastPublishedTimestamp(userId) {
     );
   });
 }
- 
+
   function computePoolVars(pubBal, rules) {
     const alphaCap = (rules.alpha || DEFAULT_RULES.alpha) * pubBal;
     const available = Math.max(0, pubBal - (rules.reserveMin || DEFAULT_RULES.reserveMin));
@@ -628,18 +631,21 @@ async function getLastPublishedTimestamp(userId) {
     const addresses = await listAddressesMerged();
     const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address));
     const capUser = (rules.caps && rules.caps.cap_user_epoch) || DEFAULT_RULES.caps.cap_user_epoch;
+    const wMin = (rules.caps && rules.caps.w_min) || DEFAULT_RULES.caps.w_min;
+    const wMax = (rules.caps && rules.caps.w_max) || DEFAULT_RULES.caps.w_max;
     const weights = [];
     for (const entry of eligible) {
       const score = await getUserEngagementScore(entry.id);
-      weights.push({ user: entry.id, w: 1 + score / 100 });
+      weights.push({ user: entry.id, w: clamp(1 + score / 100, wMin, wMax) });
     }
     if (!weights.length && userId) {
       const score = await getUserEngagementScore(userId);
-      weights.push({ user: userId, w: 1 + score / 100 });
+      weights.push({ user: userId, w: clamp(1 + score / 100, wMin, wMax) });
     }
     const W = weights.reduce((acc, x) => acc + x.w, 0) || 1;
+    const floorUbi = 1;
     const allocations = weights.map(({ user, w }) => {
-      const amount = Math.min(pv.pool * w / W, capUser);
+      const amount = Math.max(floorUbi, Math.min(pv.pool * w / W, capUser));
       return {
         id: `alloc:${epochId}:${user}`,
         epoch: epochId,
@@ -655,24 +661,27 @@ async function getLastPublishedTimestamp(userId) {
 
   async function executeEpoch({ epochId, rules = DEFAULT_RULES } = {}) {
     const eid = epochId || epochIdNow();
+    await expireOldAllocations();
     const existing = await epochsRepo.get(eid);
     if (existing) return { epoch: existing, allocations: await transfersRepo.listByTag(`epoch:${eid}`) };
     const { epoch, allocations } = await computeEpoch({ epochId: eid, userId: config.keys.id, rules });
     await epochsRepo.save(epoch);
     for (const a of allocations) {
       if (a.amount <= 0) continue;
-      await transfersRepo.create({
+      const record = {
         id: a.id,
-        from: "PUB",
+        from: config.keys.id,
         to: a.user,
         amount: a.amount,
-        concept: `UBI ${epochId}`,
-        status: "UNCONFIRMED",
+        concept: `UBI ${eid}`,
+        status: "UNCLAIMED",
         createdAt: new Date().toISOString(),
         deadline: new Date(Date.now() + (rules.graceDays || DEFAULT_RULES.graceDays) * 86400000).toISOString(),
-        tags: ["UBI", `epoch:${epochId}`],
+        tags: ["UBI", `epoch:${eid}`],
         opinions: {}
-      });
+      };
+      await transfersRepo.create(record);
+      try { await publishUbiAllocation(record); } catch (_) {}
     }
     return { epoch, allocations };
   }
@@ -683,18 +692,44 @@ async function getLastPublishedTimestamp(userId) {
     return new Promise((resolve, reject) => ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res)));
   }
 
-  async function claimAllocation({ transferId, claimerId }) {
+  async function claimAllocation({ transferId, claimerId, forcePub = false }) {
     const allocation = await transfersRepo.findById(transferId);
-    if (!allocation || allocation.status !== "UNCONFIRMED") throw new Error("Invalid allocation or already confirmed.");
-    if (allocation.to !== claimerId) throw new Error("This allocation is not for you.");
-    const claimerAddress = await getUserAddress(claimerId);
-    if (!claimerAddress || !isValidEcoinAddress(claimerAddress)) throw new Error("No valid ECOin address registered.");
-    const txid = await rpcCall("sendtoaddress", [claimerAddress, allocation.amount, `UBI ${allocation.concept || "claim"}`], "pub");
+    if (!allocation || (allocation.status !== "UNCLAIMED" && allocation.status !== "UNCONFIRMED")) throw new Error("Invalid allocation or already claimed.");
+    if (claimerId && allocation.to !== claimerId) throw new Error("This allocation is not for you.");
+    const addr = await getUserAddress(allocation.to);
+    if (!addr || !isValidEcoinAddress(addr)) throw new Error("No valid ECOin address registered.");
+    const txid = await rpcCall("sendtoaddress", [addr, allocation.amount, `UBI ${allocation.concept || "claim"}`], "pub");
     if (!txid) throw new Error("RPC sendtoaddress failed. Check PUB wallet configuration.");
     await transfersRepo.markClosed(transferId, txid);
     return { txid };
   }
 
+  async function claimUBI(userId) {
+    const uid = resolveUserId(userId);
+    const epochId = epochIdNow();
+    const pubId = getConfiguredPubId();
+    if (!pubId) throw new Error("no_pub_configured");
+    const alreadyClaimed = await hasClaimedThisMonth(uid);
+    if (alreadyClaimed) throw new Error("already_claimed");
+    const pubBalance = await getPubBalanceFromSSB();
+    if (pubBalance <= 0) throw new Error("no_funds");
+    const pv = computePoolVars(pubBalance, DEFAULT_RULES);
+    const addresses = await listAddressesMerged();
+    const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address));
+    const karmaScore = await getUserEngagementScore(uid);
+    const wMin = DEFAULT_RULES.caps.w_min;
+    const wMax = DEFAULT_RULES.caps.w_max;
+    const capUser = DEFAULT_RULES.caps.cap_user_epoch;
+    const userW = clamp(1 + karmaScore / 100, wMin, wMax);
+    const totalW = eligible.reduce((acc, a) => acc + clamp(1 + 0 / 100, wMin, wMax), 0) || 1;
+    const amount = Number(Math.max(1, Math.min(pv.pool * userW / totalW, capUser)).toFixed(6));
+    const ssb = await openSsb();
+    if (!ssb || !ssb.publish) throw new Error("ssb_unavailable");
+    const content = { type: "ubiClaim", pubId, amount, epochId, claimedAt: new Date().toISOString() };
+    await new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res)));
+    return { status: "claimed_pending", amount, epochId };
+  }
+
   async function updateAllocationStatus(allocationId, status, txid) {
     if (status === "CLOSED") {
       await transfersRepo.markClosed(allocationId, txid);
@@ -710,17 +745,101 @@ async function getLastPublishedTimestamp(userId) {
     }
   }
 
+  async function getPubBalanceFromSSB() {
+    const pubId = getConfiguredPubId();
+    if (!pubId) return 0;
+    const msgs = await scanLogStream();
+    for (let i = msgs.length - 1; i >= 0; i--) {
+      const v = msgs[i].value || {};
+      const c = v.content || {};
+      if (v.author === pubId && c && c.type === "pubBalance" && c.coin === "ECO") {
+        return Number(c.balance) || 0;
+      }
+    }
+    return 0;
+  }
+
+  async function publishPubBalance() {
+    if (!isPubNode()) return;
+    const balance = await safeGetBalance("pub");
+    const ssb = await openSsb();
+    if (!ssb || !ssb.publish) return;
+    const content = { type: "pubBalance", balance, coin: "ECO", timestamp: Date.now() };
+    await new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res)));
+    return balance;
+  }
+
+  async function hasClaimedThisMonth(userId) {
+    const epochId = epochIdNow();
+    const msgs = await scanLogStream();
+    for (const m of msgs) {
+      const v = m.value || {};
+      const c = v.content || {};
+      if (c.type === "ubiClaimResult" && c.userId === userId && c.epochId === epochId) return true;
+      if (c.type === "ubiClaim" && v.author === userId && c.epochId === epochId) return true;
+    }
+    return false;
+  }
+
+  async function getUbiClaimHistory(userId) {
+    const msgs = await scanLogStream();
+    let lastClaimedDate = null;
+    let totalClaimed = 0;
+    let claimCount = 0;
+    for (const m of msgs) {
+      const v = m.value || {};
+      const c = v.content || {};
+      if (c.type === "ubiClaimResult" && c.userId === userId) {
+        totalClaimed += Number(c.amount) || 0;
+        claimCount += 1;
+        const d = c.processedAt || null;
+        if (d && (!lastClaimedDate || d > lastClaimedDate)) lastClaimedDate = d;
+      }
+    }
+    return { lastClaimedDate, totalClaimed: Number(totalClaimed.toFixed(6)), claimCount };
+  }
+
+  async function getUbiAllocationsFromSSB() {
+    const pubId = getConfiguredPubId();
+    if (!pubId) return [];
+    const msgs = await scanLogStream();
+    const out = [];
+    for (const m of msgs) {
+      const v = m.value || {};
+      const c = v.content || {};
+      if (v.author === pubId && c && c.type === "ubiAllocation") {
+        out.push({
+          id: c.allocationId,
+          from: pubId,
+          to: c.to,
+          amount: c.amount,
+          concept: c.concept,
+          epochId: c.epochId,
+          status: c.status || "UNCLAIMED",
+          createdAt: c.createdAt || new Date().toISOString()
+        });
+      }
+    }
+    return out;
+  }
+
   async function listBanking(filter = "overview", userId) {
     const uid = resolveUserId(userId);
     const epochId = epochIdNow();
-    const pubBalance = await safeGetBalance("pub");
+    let pubBalance, allocations;
+    if (isPubNode()) {
+      pubBalance = await safeGetBalance("pub");
+      const all = await transfersRepo.listByTag("UBI");
+      allocations = all.map(t => ({
+        id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status,
+        createdAt: t.createdAt || t.deadline || new Date().toISOString(), txid: t.txid
+      }));
+    } else {
+      pubBalance = await getPubBalanceFromSSB();
+      allocations = await getUbiAllocationsFromSSB();
+    }
     const userBalance = await safeGetBalance("user");
     const epochs = await epochsRepo.list();
-    const all = await transfersRepo.listByTag("UBI");
-    const allocations = all.map(t => ({
-      id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status,
-      createdAt: t.createdAt || t.deadline || new Date().toISOString(), txid: t.txid
-    }));
     let computed = null;
     try { computed = await computeEpoch({ epochId, userId: uid, rules: DEFAULT_RULES }); } catch {}
     const pv = computePoolVars(pubBalance, DEFAULT_RULES);
@@ -729,6 +848,8 @@ async function getLastPublishedTimestamp(userId) {
     const poolForEpoch = computed?.epoch?.pool || pv.pool || 0;
     const futureUBI = Number(((engagementScore / 100) * poolForEpoch).toFixed(6));
     const addresses = await listAddressesMerged();
+    const alreadyClaimed = await hasClaimedThisMonth(uid);
+    const pubId = getConfiguredPubId();
     const summary = {
       userBalance,
       pubBalance,
@@ -736,7 +857,10 @@ async function getLastPublishedTimestamp(userId) {
       pool: poolForEpoch,
       weightsSum: computed?.epoch?.weightsSum || 0,
       userEngagementScore: engagementScore,
-      futureUBI
+      futureUBI,
+      ubiAvailability: pubBalance > 0 ? "OK" : "NO_FUNDS",
+      alreadyClaimed,
+      pubId
     };
     const exchange = await calculateEcoinValue();
     return { summary, allocations, epochs, rules: DEFAULT_RULES, addresses, exchange };
@@ -763,7 +887,7 @@ async function getLastPublishedTimestamp(userId) {
       id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status, createdAt: t.createdAt || new Date().toISOString(), txid: t.txid
     }));
   }
-  
+
   async function calculateEcoinValue() {
     let isSynced = false;
     let circulatingSupply = 0;
@@ -825,16 +949,16 @@ async function getLastPublishedTimestamp(userId) {
       const result = await rpcCall("getinfo", []);
       return result?.moneysupply || 0;
     } catch (error) {
-      return 0; 
+      return 0;
     }
   }
-  
+
   async function getBankingData(userId) {
     const ecoValue = await calculateEcoinValue();
     const karmaScore = await getUserEngagementScore(userId);
     let estimatedUBI = 0;
     try {
-      const pubBal = await safeGetBalance("pub");
+      const pubBal = isPubNode() ? await safeGetBalance("pub") : await getPubBalanceFromSSB();
       const pv = computePoolVars(pubBal, DEFAULT_RULES);
       const pool = pv.pool || 0;
       const addresses = await listAddressesMerged();
@@ -844,19 +968,144 @@ async function getLastPublishedTimestamp(userId) {
       const cap = DEFAULT_RULES.caps?.cap_user_epoch ?? 50;
       estimatedUBI = Math.min(pool * (userW / Math.max(1, totalW)), cap);
     } catch (_) {}
+    const claimHistory = await getUbiClaimHistory(userId).catch(() => ({ lastClaimedDate: null, totalClaimed: 0 }));
     return {
       ecoValue,
       karmaScore,
       estimatedUBI,
+      lastClaimedDate: claimHistory.lastClaimedDate,
+      totalClaimed: claimHistory.totalClaimed
     };
   }
 
+  async function expireOldAllocations() {
+    const cutoffMs = MAX_PENDING_EPOCHS * 30 * 86400000;
+    const now = Date.now();
+    const allocs = await transfersRepo.listAll();
+    for (const a of allocs) {
+      if ((a.status === "UNCLAIMED" || a.status === "UNCONFIRMED") &&
+          (now - new Date(a.createdAt).getTime()) > cutoffMs) {
+        await updateAllocationStatus(a.id, "EXPIRED");
+      }
+    }
+  }
+
+  async function publishUbiAllocation(allocation) {
+    const ssb = await openSsb();
+    if (!ssb) return;
+    const epochTag = (allocation.tags || []).find(t => t.startsWith("epoch:"));
+    const content = {
+      type: "ubiAllocation",
+      allocationId: allocation.id,
+      to: allocation.to,
+      amount: allocation.amount,
+      concept: allocation.concept,
+      epochId: epochTag ? epochTag.slice(6) : "",
+      status: "UNCLAIMED",
+      createdAt: allocation.createdAt
+    };
+    return new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res)));
+  }
+
+  async function publishUbiClaim(allocationId, epochId) {
+    const ssb = await openSsb();
+    if (!ssb) return;
+    const content = { type: "ubiClaim", allocationId, epochId, claimedAt: new Date().toISOString() };
+    return new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res)));
+  }
+
+  async function publishUbiClaimResult(allocationId, epochId, txid, userId, amount) {
+    const ssb = await openSsb();
+    if (!ssb) return;
+    const content = { type: "ubiClaimResult", allocationId, epochId, txid, userId, amount, processedAt: new Date().toISOString() };
+    return new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res)));
+  }
+
+  async function processPendingClaims() {
+    if (!isPubNode()) return;
+    const ssb = await openSsb();
+    if (!ssb) return;
+    const claims = [];
+    const results = [];
+    await new Promise((resolve, reject) => {
+      pull(ssb.messagesByType({ type: "ubiClaim", reverse: false }),
+        pull.drain(msg => {
+          if (msg.value?.content?.type === "ubiClaim") {
+            claims.push({ ...msg.value.content, _author: msg.value.author });
+          }
+        },
+          err => err ? reject(err) : resolve()));
+    });
+    await new Promise((resolve, reject) => {
+      pull(ssb.messagesByType({ type: "ubiClaimResult", reverse: false }),
+        pull.drain(msg => { if (msg.value?.content?.type === "ubiClaimResult") results.push(msg.value.content); },
+          err => err ? reject(err) : resolve()));
+    });
+    const processedEpochUser = new Set(results.map(r => `${r.epochId}:${r.userId}`));
+    const epochId = epochIdNow();
+    for (const claim of claims) {
+      const claimantId = claim._author;
+      if (!claimantId) continue;
+      const claimEpoch = claim.epochId || epochId;
+      if (processedEpochUser.has(`${claimEpoch}:${claimantId}`)) continue;
+      try {
+        const addr = await getUserAddress(claimantId);
+        if (!addr || !isValidEcoinAddress(addr)) continue;
+        const pubBal = await safeGetBalance("pub");
+        if (pubBal <= 0) continue;
+        const pv = computePoolVars(pubBal, DEFAULT_RULES);
+        const addresses = await listAddressesMerged();
+        const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address));
+        const karmaScore = await getUserEngagementScore(claimantId);
+        const wMin = DEFAULT_RULES.caps.w_min;
+        const wMax = DEFAULT_RULES.caps.w_max;
+        const capUser = DEFAULT_RULES.caps.cap_user_epoch;
+        const userW = clamp(1 + karmaScore / 100, wMin, wMax);
+        const totalW = eligible.reduce((acc) => acc + clamp(1, wMin, wMax), 0) || 1;
+        const amount = Number(Math.max(1, Math.min(pv.pool * userW / totalW, capUser)).toFixed(6));
+        const txid = await rpcCall("sendtoaddress", [addr, amount, `UBI ${claimEpoch}`], "pub");
+        if (!txid) continue;
+        await publishUbiClaimResult(claim.allocationId || `claim:${claimEpoch}:${claimantId}`, claimEpoch, txid, claimantId, amount);
+        await publishBankClaim({ amount, epochId: claimEpoch, allocationId: claim.allocationId || `claim:${claimEpoch}:${claimantId}`, txid });
+        const now = new Date().toISOString();
+        await new Promise((resolve, reject) => ssb.publish({
+          type: "transfer",
+          from: config.keys.id,
+          to: claimantId,
+          concept: `UBI ${claimEpoch}`,
+          amount: String(amount),
+          createdAt: now,
+          updatedAt: now,
+          deadline: new Date(Date.now() + 30 * 86400000).toISOString(),
+          confirmedBy: [claimantId],
+          status: "CLOSED",
+          tags: ["UBI"],
+          opinions: {},
+          opinions_inhabitants: [],
+          txid
+        }, (err, msg) => err ? reject(err) : resolve(msg)));
+      } catch (_) {}
+    }
+  }
+
   return {
     DEFAULT_RULES,
+    isPubNode,
+    getConfiguredPubId,
     computeEpoch,
     executeEpoch,
     getUserEngagementScore,
     publishBankClaim,
+    publishUbiAllocation,
+    publishUbiClaim,
+    publishUbiClaimResult,
+    publishPubBalance,
+    getPubBalanceFromSSB,
+    hasClaimedThisMonth,
+    getUbiClaimHistory,
+    claimUBI,
+    processPendingClaims,
+    expireOldAllocations,
     claimAllocation,
     listBanking,
     getAllocationById,
@@ -872,4 +1121,3 @@ async function getLastPublishedTimestamp(userId) {
     getBankingData
   };
 };
-

+ 519 - 0
src/models/calendars_model.js

@@ -0,0 +1,519 @@
+const pull = require("../server/node_modules/pull-stream")
+const { getConfig } = require("../configs/config-manager.js")
+const logLimit = getConfig().ssbLogStream?.limit || 1000
+
+const safeText = (v) => String(v || "").trim()
+const normalizeTags = (raw) => {
+  if (!raw) return []
+  if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
+  return String(raw).split(",").map(t => t.trim()).filter(Boolean)
+}
+const hasAnyInterval = (w, m, y) => !!(w || m || y)
+const expandRecurrence = (firstDate, deadline, weekly, monthly, yearly) => {
+  const start = new Date(firstDate)
+  const out = [start]
+  if (!deadline || !hasAnyInterval(weekly, monthly, yearly)) return out
+  const end = new Date(deadline).getTime()
+  const seen = new Set([start.getTime()])
+  const walk = (mutate) => {
+    const n = new Date(start)
+    mutate(n)
+    while (n.getTime() <= end) {
+      const t = n.getTime()
+      if (!seen.has(t)) { seen.add(t); out.push(new Date(n)) }
+      mutate(n)
+    }
+  }
+  if (weekly)  walk((d) => d.setDate(d.getDate() + 7))
+  if (monthly) walk((d) => d.setMonth(d.getMonth() + 1))
+  if (yearly)  walk((d) => d.setFullYear(d.getFullYear() + 1))
+  return out.sort((a, b) => a.getTime() - b.getTime())
+}
+
+module.exports = ({ cooler, pmModel }) => {
+  let ssb
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
+
+  const readAll = async (ssbClient) =>
+    new Promise((resolve, reject) =>
+      pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
+    )
+
+  const buildIndex = (messages) => {
+    const tomb = new Set()
+    const nodes = new Map()
+    const parent = new Map()
+    const child = new Map()
+
+    for (const m of messages) {
+      const k = m.key
+      const v = m.value || {}
+      const c = v.content
+      if (!c) continue
+      if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
+      if (c.type === "calendar") {
+        nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
+        if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
+      }
+    }
+
+    const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
+    const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
+
+    const roots = new Set()
+    for (const id of nodes.keys()) roots.add(rootOf(id))
+    const tipByRoot = new Map()
+    for (const r of roots) tipByRoot.set(r, tipOf(r))
+
+    return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot }
+  }
+
+  const buildCalendar = (node, rootId) => {
+    const c = node.c || {}
+    if (c.type !== "calendar") return null
+    return {
+      key: node.key,
+      rootId,
+      title: safeText(c.title),
+      status: c.status || "OPEN",
+      deadline: c.deadline || "",
+      tags: Array.isArray(c.tags) ? c.tags : [],
+      author: c.author || node.author,
+      participants: Array.isArray(c.participants) ? c.participants : [],
+      createdAt: c.createdAt || new Date(node.ts).toISOString(),
+      updatedAt: c.updatedAt || null,
+      tribeId: c.tribeId || null
+    }
+  }
+
+  const isClosed = (calendar) => {
+    if (calendar.status === "CLOSED") return true
+    if (!calendar.deadline) return false
+    return new Date(calendar.deadline).getTime() <= Date.now()
+  }
+
+  return {
+    type: "calendar",
+
+    async resolveRootId(id) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      let tip = id
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) throw new Error("Not found")
+      let root = tip
+      while (idx.parent.has(root)) root = idx.parent.get(root)
+      return root
+    },
+
+    async resolveCurrentId(id) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      let tip = id
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) throw new Error("Not found")
+      return tip
+    },
+
+    async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, tribeId }) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const now = new Date().toISOString()
+      const validStatus = ["OPEN", "CLOSED"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
+
+      if (deadline && new Date(deadline).getTime() <= Date.now()) throw new Error("Deadline must be in the future")
+      if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future")
+
+      const content = {
+        type: "calendar",
+        title: safeText(title),
+        status: validStatus,
+        deadline: deadline || "",
+        tags: normalizeTags(tags),
+        author: userId,
+        participants: [userId],
+        createdAt: now,
+        updatedAt: now,
+        ...(tribeId ? { tribeId } : {})
+      }
+
+      const calMsg = await new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
+      })
+
+      const calendarId = calMsg.key
+      const dates = expandRecurrence(firstDate, deadline, intervalWeekly, intervalMonthly, intervalYearly)
+
+      const allDateMsgs = []
+      for (const d of dates) {
+        const dateMsg = await new Promise((resolve, reject) => {
+          ssbClient.publish({
+            type: "calendarDate",
+            calendarId,
+            date: d.toISOString(),
+            label: safeText(firstDateLabel),
+            author: userId,
+            createdAt: new Date().toISOString()
+          }, (err, msg) => err ? reject(err) : resolve(msg))
+        })
+        allDateMsgs.push(dateMsg)
+      }
+
+      if (firstNote && safeText(firstNote) && allDateMsgs.length > 0) {
+        for (const dateMsg of allDateMsgs) {
+          await new Promise((resolve, reject) => {
+            ssbClient.publish({
+              type: "calendarNote",
+              calendarId,
+              dateId: dateMsg.key,
+              text: safeText(firstNote),
+              author: userId,
+              createdAt: new Date().toISOString()
+            }, (err, msg) => err ? reject(err) : resolve(msg))
+          })
+        }
+      }
+
+      return calMsg
+    },
+
+    async updateCalendarById(id, data) {
+      const tipId = await this.resolveCurrentId(id)
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+
+      return new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Calendar not found"))
+          if (item.content.author !== userId) return reject(new Error("Not the author"))
+          const c = item.content
+          const updated = {
+            ...c,
+            title: data.title !== undefined ? safeText(data.title) : c.title,
+            status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : c.status) : c.status,
+            deadline: data.deadline !== undefined ? data.deadline : c.deadline,
+            tags: data.tags !== undefined ? normalizeTags(data.tags) : c.tags,
+            updatedAt: new Date().toISOString(),
+            replaces: tipId
+          }
+          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+          ssbClient.publish(tombstone, (e1) => {
+            if (e1) return reject(e1)
+            ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
+          })
+        })
+      })
+    },
+
+    async deleteCalendarById(id) {
+      const tipId = await this.resolveCurrentId(id)
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      return new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Calendar not found"))
+          if (item.content.author !== userId) return reject(new Error("Not the author"))
+          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+          ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
+        })
+      })
+    },
+
+    async joinCalendar(calendarId) {
+      const tipId = await this.resolveCurrentId(calendarId)
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+
+      return new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Calendar not found"))
+          const c = item.content
+          const participants = Array.isArray(c.participants) ? c.participants : []
+          if (participants.includes(userId)) return resolve()
+          const updated = { ...c, participants: [...participants, userId], updatedAt: new Date().toISOString(), replaces: tipId }
+          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+          ssbClient.publish(tombstone, (e1) => {
+            if (e1) return reject(e1)
+            ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
+          })
+        })
+      })
+    },
+
+    async leaveCalendar(calendarId) {
+      const tipId = await this.resolveCurrentId(calendarId)
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      return new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Calendar not found"))
+          const c = item.content
+          if (c.author === userId) return reject(new Error("Author cannot leave"))
+          const participants = Array.isArray(c.participants) ? c.participants : []
+          if (!participants.includes(userId)) return resolve()
+          const updated = { ...c, participants: participants.filter(p => p !== userId), updatedAt: new Date().toISOString(), replaces: tipId }
+          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+          ssbClient.publish(tombstone, (e1) => {
+            if (e1) return reject(e1)
+            ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
+          })
+        })
+      })
+    },
+
+    async getCalendarById(id) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      let tip = id
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) return null
+      const node = idx.nodes.get(tip)
+      if (!node || node.c.type !== "calendar") return null
+      let root = tip
+      while (idx.parent.has(root)) root = idx.parent.get(root)
+      const cal = buildCalendar(node, root)
+      if (!cal) return null
+      cal.isClosed = isClosed(cal)
+      return cal
+    },
+
+    async listAll({ filter = "all", viewerId } = {}) {
+      const ssbClient = await openSsb()
+      const uid = viewerId || ssbClient.id
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      const items = []
+      for (const [rootId, tipId] of idx.tipByRoot.entries()) {
+        if (idx.tomb.has(tipId)) continue
+        const node = idx.nodes.get(tipId)
+        if (!node || node.c.type !== "calendar") continue
+        const cal = buildCalendar(node, rootId)
+        if (!cal) continue
+        cal.isClosed = isClosed(cal)
+        items.push(cal)
+      }
+      let list = items
+      if (filter === "mine") list = list.filter(c => c.author === uid)
+      else if (filter === "recent") {
+        const now = Date.now()
+        list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000)
+      }
+      else if (filter === "open") list = list.filter(c => !c.isClosed)
+      else if (filter === "closed") list = list.filter(c => c.isClosed)
+      return list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+    },
+
+    async addDate(calendarId, date, label, intervalWeekly, intervalMonthly, intervalYearly, intervalDeadline) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const rootId = await this.resolveRootId(calendarId)
+      const cal = await this.getCalendarById(rootId)
+      if (!cal) throw new Error("Calendar not found")
+      if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar")
+      if (!date || new Date(date).getTime() <= Date.now()) throw new Error("Date must be in the future")
+
+      const deadlineForExpansion = (intervalDeadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)) ? intervalDeadline : cal.deadline
+      const dates = expandRecurrence(date, deadlineForExpansion, intervalWeekly, intervalMonthly, intervalYearly)
+      const allMsgs = []
+      for (const d of dates) {
+        const msg = await new Promise((resolve, reject) => {
+          ssbClient.publish({
+            type: "calendarDate",
+            calendarId: rootId,
+            date: d.toISOString(),
+            label: safeText(label),
+            author: userId,
+            createdAt: new Date().toISOString()
+          }, (err, m) => err ? reject(err) : resolve(m))
+        })
+        allMsgs.push(msg)
+      }
+      return allMsgs
+    },
+
+    async getDatesForCalendar(calendarId) {
+      const rootId = await this.resolveRootId(calendarId)
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const tombstoned = new Set()
+      for (const m of messages) {
+        const c = (m.value || {}).content
+        if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
+      }
+      const dates = []
+      for (const m of messages) {
+        if (tombstoned.has(m.key)) continue
+        const v = m.value || {}
+        const c = v.content
+        if (!c || c.type !== "calendarDate") continue
+        if (c.calendarId !== rootId) continue
+        dates.push({
+          key: m.key,
+          calendarId: c.calendarId,
+          date: c.date,
+          label: c.label || "",
+          author: c.author || v.author,
+          createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
+        })
+      }
+      dates.sort((a, b) => new Date(a.date) - new Date(b.date))
+      return dates
+    },
+
+    async deleteDate(dateId, calendarId) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const rootId = await this.resolveRootId(calendarId)
+      const cal = await this.getCalendarById(rootId)
+      if (!cal) throw new Error("Calendar not found")
+      const messages = await readAll(ssbClient)
+      const tombstoned = new Set()
+      for (const m of messages) {
+        const c = (m.value || {}).content
+        if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
+      }
+      let dateAuthor = null
+      for (const m of messages) {
+        if (m.key !== dateId) continue
+        const c = (m.value || {}).content
+        if (!c || c.type !== "calendarDate") continue
+        if (tombstoned.has(m.key)) break
+        dateAuthor = c.author || (m.value || {}).author
+        break
+      }
+      if (!dateAuthor) throw new Error("Date not found")
+      if (dateAuthor !== userId && cal.author !== userId) throw new Error("Not authorized")
+      for (const m of messages) {
+        const c = (m.value || {}).content
+        if (!c || c.type !== "calendarNote") continue
+        if (tombstoned.has(m.key)) continue
+        if (c.dateId !== dateId) continue
+        await new Promise((resolve, reject) => {
+          ssbClient.publish({ type: "tombstone", target: m.key, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve())
+        })
+      }
+      return new Promise((resolve, reject) => {
+        ssbClient.publish({ type: "tombstone", target: dateId, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve())
+      })
+    },
+
+    async addNote(calendarId, dateId, text) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const rootId = await this.resolveRootId(calendarId)
+      const cal = await this.getCalendarById(rootId)
+      if (!cal) throw new Error("Calendar not found")
+      if (!cal.participants.includes(userId)) throw new Error("Only participants can add notes")
+      return new Promise((resolve, reject) => {
+        ssbClient.publish({
+          type: "calendarNote",
+          calendarId: rootId,
+          dateId,
+          text: safeText(text),
+          author: userId,
+          createdAt: new Date().toISOString()
+        }, (err, msg) => err ? reject(err) : resolve(msg))
+      })
+    },
+
+    async deleteNote(noteId) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      return new Promise((resolve, reject) => {
+        ssbClient.get(noteId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Note not found"))
+          if (item.content.author !== userId) return reject(new Error("Not the author"))
+          ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg))
+        })
+      })
+    },
+
+    async getNotesForDate(calendarId, dateId) {
+      const rootId = await this.resolveRootId(calendarId)
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const tombstoned = new Set()
+      for (const m of messages) {
+        const c = (m.value || {}).content
+        if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
+      }
+      const notes = []
+      for (const m of messages) {
+        const v = m.value || {}
+        const c = v.content
+        if (!c || c.type !== "calendarNote") continue
+        if (tombstoned.has(m.key)) continue
+        if (c.calendarId !== rootId || c.dateId !== dateId) continue
+        notes.push({
+          key: m.key,
+          calendarId: c.calendarId,
+          dateId: c.dateId,
+          text: c.text || "",
+          author: c.author || v.author,
+          createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
+        })
+      }
+      notes.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
+      return notes
+    },
+
+    async checkDueReminders() {
+      if (!pmModel) return
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const now = Date.now()
+
+      const sentMarkers = new Set()
+      for (const m of messages) {
+        const c = (m.value || {}).content
+        if (!c || c.type !== "calendarReminderSent") continue
+        sentMarkers.add(`${c.calendarId}::${c.dateId}`)
+      }
+
+      const dueDates = []
+      for (const m of messages) {
+        const v = m.value || {}
+        const c = v.content
+        if (!c || c.type !== "calendarDate") continue
+        if (new Date(c.date).getTime() > now) continue
+        if (sentMarkers.has(`${c.calendarId}::${m.key}`)) continue
+        dueDates.push({ key: m.key, calendarId: c.calendarId, date: c.date, label: c.label || "" })
+      }
+
+      for (const dd of dueDates) {
+        try {
+          const cal = await this.getCalendarById(dd.calendarId)
+          if (!cal) continue
+          const participants = cal.participants.filter(p => typeof p === "string" && p.length > 0)
+          if (participants.length === 0) continue
+          const notesForDay = await this.getNotesForDate(dd.calendarId, dd.key)
+          const notesBlock = notesForDay.length > 0
+            ? notesForDay.map(n => `  - ${n.text}`).join("\n\n")
+            : "  (no notes)"
+          const subject = `Calendar Reminder: ${cal.title}`
+          const text =
+            `Reminder from: ${cal.author}\n` +
+            `Title: ${cal.title}\n` +
+            `Date: ${dd.label || dd.date}\n\n` +
+            `Notes for this day:\n\n${notesBlock}\n\n` +
+            `Visit Calendar: /calendars/${cal.rootId}`
+          const chunkSize = 6
+          for (let i = 0; i < participants.length; i += chunkSize) {
+            await pmModel.sendMessage(participants.slice(i, i + chunkSize), subject, text)
+          }
+          await new Promise((resolve, reject) => {
+            ssbClient.publish({
+              type: "calendarReminderSent",
+              calendarId: dd.calendarId,
+              dateId: dd.key,
+              sentAt: new Date().toISOString()
+            }, (err) => err ? reject(err) : resolve())
+          })
+        } catch (_) {}
+      }
+    }
+  }
+}

+ 507 - 0
src/models/chats_model.js

@@ -0,0 +1,507 @@
+const pull = require("../server/node_modules/pull-stream")
+const crypto = require("crypto")
+const { getConfig } = require("../configs/config-manager.js")
+const logLimit = getConfig().ssbLogStream?.limit || 1000
+
+const safeArr = (v) => (Array.isArray(v) ? v : [])
+const safeText = (v) => String(v || "").trim()
+const normalizeTags = (raw) => {
+  if (raw === undefined || raw === null) return []
+  if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
+  return String(raw).split(",").map(t => t.trim()).filter(Boolean)
+}
+
+const INVITE_CODE_BYTES = 16
+const VALID_STATUS = ["OPEN", "INVITE-ONLY", "CLOSED"]
+
+module.exports = ({ cooler, tribeCrypto }) => {
+  let ssb
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
+
+  const readAll = async (ssbClient) =>
+    new Promise((resolve, reject) =>
+      pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
+    )
+
+  const buildIndex = (messages) => {
+    const tomb = new Set()
+    const nodes = new Map()
+    const parent = new Map()
+    const child = new Map()
+    const msgNodes = new Map()
+
+    for (const m of messages) {
+      const k = m.key
+      const v = m.value || {}
+      const c = v.content
+      if (!c) continue
+      if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
+      if (c.type === "chat") {
+        nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
+        if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
+      } else if (c.type === "chatMessage") {
+        msgNodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
+      }
+    }
+
+    const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
+    const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
+
+    const roots = new Set()
+    for (const id of nodes.keys()) roots.add(rootOf(id))
+    const tipByRoot = new Map()
+    for (const r of roots) tipByRoot.set(r, tipOf(r))
+
+    return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, msgNodes }
+  }
+
+  const resolveKeyChainSets = (chatRootId) => {
+    if (!tribeCrypto) return []
+    const keys = tribeCrypto.getKeys(chatRootId)
+    return keys.map(k => [k])
+  }
+
+  const buildChat = (node, rootId) => {
+    const rawC = node.c || {}
+    if (rawC.type !== "chat") return null
+
+    let c = rawC
+    if (tribeCrypto && c.encryptedPayload) {
+      const keyChainSets = resolveKeyChainSets(rootId)
+      c = tribeCrypto.decryptContent(c, keyChainSets)
+    }
+
+    return {
+      key: node.key,
+      rootId,
+      title: c.title || "",
+      description: c.description || "",
+      image: c.image || null,
+      category: c.category || "",
+      status: c.status || "OPEN",
+      tags: safeArr(c.tags),
+      members: safeArr(c.members),
+      invites: safeArr(c.invites),
+      author: c.author || node.author,
+      createdAt: c.createdAt || new Date(node.ts).toISOString(),
+      updatedAt: c.updatedAt || null,
+      encrypted: !!c.encrypted,
+      tribeId: c.tribeId || null
+    }
+  }
+
+  const buildMessage = (node, chatRootId) => {
+    const c = node.c || {}
+    if (c.type !== "chatMessage") return null
+
+    let text = c.text || ""
+    if (tribeCrypto && c.encryptedText) {
+      const keys = tribeCrypto.getKeys(chatRootId)
+      for (const keyHex of keys) {
+        try {
+          text = tribeCrypto.decryptWithKey(c.encryptedText, keyHex)
+          break
+        } catch (_) {}
+      }
+    }
+
+    return {
+      key: node.key,
+      chatId: c.chatId || "",
+      text,
+      image: c.image || null,
+      author: c.author || node.author,
+      createdAt: c.createdAt || new Date(node.ts).toISOString()
+    }
+  }
+
+  const publishTombstone = async (ssbClient, tipId) =>
+    new Promise((resolve, reject) => {
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
+      ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
+    })
+
+  return {
+    type: "chat",
+
+    async resolveRootId(id) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      let tip = id
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) throw new Error("Not found")
+      let root = tip
+      while (idx.parent.has(root)) root = idx.parent.get(root)
+      return root
+    },
+
+    async resolveCurrentId(id) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      let tip = id
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) throw new Error("Not found")
+      return tip
+    },
+
+    async createChat(title, description, image, category, status, tagsRaw, tribeId) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const blobId = image ? String(image).trim() || null : null
+      const tags = normalizeTags(tagsRaw)
+      const st = VALID_STATUS.includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
+      const now = new Date().toISOString()
+
+      let content = {
+        type: "chat",
+        title: safeText(title),
+        description: safeText(description),
+        image: blobId,
+        category: safeText(category),
+        status: st,
+        tags,
+        members: [userId],
+        invites: [],
+        author: userId,
+        createdAt: now,
+        updatedAt: now,
+        ...(tribeId ? { tribeId } : {})
+      }
+
+      if (tribeCrypto) {
+        const chatKey = tribeCrypto.generateTribeKey()
+        const result = await new Promise((resolve, reject) => {
+          const plainContent = Object.assign({}, content)
+          ssbClient.publish(plainContent, (err, msg) => err ? reject(err) : resolve(msg))
+        })
+        tribeCrypto.setKey(result.key, chatKey, 1)
+        return result
+      }
+
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
+      })
+    },
+
+    async updateChatById(id, data) {
+      const tipId = await this.resolveCurrentId(id)
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+
+      return new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Chat not found"))
+          const c = item.content
+
+          const rawAuthor = c.author || (c.encryptedPayload ? null : undefined)
+          if (rawAuthor && rawAuthor !== userId) return reject(new Error("Not the author"))
+
+          const rootId = tipId
+          const messages = []
+          const node = { key: tipId, c, author: item.author, ts: item.timestamp || 0 }
+          const chat = buildChat(node, rootId)
+          if (!chat) return reject(new Error("Invalid chat"))
+
+          const updated = {
+            type: "chat",
+            replaces: tipId,
+            title: data.title !== undefined ? safeText(data.title) : chat.title,
+            description: data.description !== undefined ? safeText(data.description) : chat.description,
+            image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : chat.image) : chat.image,
+            category: data.category !== undefined ? safeText(data.category) : chat.category,
+            status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status,
+            tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags,
+            members: chat.members,
+            invites: chat.invites,
+            author: chat.author,
+            createdAt: chat.createdAt,
+            updatedAt: new Date().toISOString()
+          }
+
+          ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e1) => {
+            if (e1) return reject(e1)
+            ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
+          })
+        })
+      })
+    },
+
+    async deleteChatById(id) {
+      const tipId = await this.resolveCurrentId(id)
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+
+      return new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Chat not found"))
+          if (item.content.author && item.content.author !== userId) return reject(new Error("Not the author"))
+          ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve())
+        })
+      })
+    },
+
+    async closeChatById(id) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      let tip = id
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) throw new Error("Not found")
+      let root = tip
+      while (idx.parent.has(root)) root = idx.parent.get(root)
+
+      const node = idx.nodes.get(tip)
+      if (!node) throw new Error("Not found")
+      const chat = buildChat(node, root)
+      if (!chat) throw new Error("Invalid chat")
+      if (chat.author !== userId) throw new Error("Not the author")
+
+      const updated = {
+        type: "chat",
+        replaces: tip,
+        title: chat.title,
+        description: chat.description,
+        image: chat.image,
+        category: chat.category,
+        status: "CLOSED",
+        tags: chat.tags,
+        members: chat.members,
+        invites: chat.invites,
+        author: chat.author,
+        createdAt: chat.createdAt,
+        updatedAt: new Date().toISOString()
+      }
+
+      await publishTombstone(ssbClient, tip)
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))
+      })
+    },
+
+    async getChatById(id) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+
+      let tip = id
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) return null
+
+      const node = idx.nodes.get(tip)
+      if (!node || node.c.type !== "chat") return null
+
+      let root = tip
+      while (idx.parent.has(root)) root = idx.parent.get(root)
+
+      const chat = buildChat(node, root)
+      if (!chat) return null
+      return chat
+    },
+
+    async listAll({ filter = "all", q = "", sort = "recent", viewerId } = {}) {
+      const ssbClient = await openSsb()
+      const uid = viewerId || ssbClient.id
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      const now = Date.now()
+
+      const items = []
+      for (const [rootId, tipId] of idx.tipByRoot.entries()) {
+        if (idx.tomb.has(tipId)) continue
+        const node = idx.nodes.get(tipId)
+        if (!node || node.c.type !== "chat") continue
+        const chat = buildChat(node, rootId)
+        if (!chat) continue
+        items.push(chat)
+      }
+
+      let list = items
+
+      if (filter === "mine") list = list.filter(c => c.author === uid)
+      else if (filter === "recent") list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000)
+      else if (filter === "open") list = list.filter(c => c.status === "OPEN" || c.status === "INVITE-ONLY")
+      else if (filter === "closed") list = list.filter(c => c.status === "CLOSED")
+
+      if (q) {
+        const qq = q.toLowerCase()
+        list = list.filter(c => {
+          const t = String(c.title || "").toLowerCase()
+          const d = String(c.description || "").toLowerCase()
+          const cat = String(c.category || "").toLowerCase()
+          const tags = safeArr(c.tags).join(" ").toLowerCase()
+          return t.includes(qq) || d.includes(qq) || cat.includes(qq) || tags.includes(qq)
+        })
+      }
+
+      list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+      return list
+    },
+
+    async generateInvite(chatId) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const chat = await this.getChatById(chatId)
+      if (!chat) throw new Error("Chat not found")
+      if (chat.author !== userId) throw new Error("Only the author can generate invites")
+
+      const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
+      let invite = code
+
+      if (tribeCrypto) {
+        const chatKey = tribeCrypto.getKey(chat.rootId)
+        if (chatKey) {
+          const ek = tribeCrypto.encryptForInvite(chatKey, code)
+          invite = { code, ek, gen: tribeCrypto.getGen(chat.rootId) }
+        }
+      }
+
+      const invites = [...chat.invites, invite]
+      await this.updateChatById(chatId, { invites, members: chat.members, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags })
+      return code
+    },
+
+    async joinByInvite(code) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+
+      let matchedChat = null
+      let matchedInvite = null
+
+      for (const [rootId, tipId] of idx.tipByRoot.entries()) {
+        if (idx.tomb.has(tipId)) continue
+        const node = idx.nodes.get(tipId)
+        if (!node || node.c.type !== "chat") continue
+        const chat = buildChat(node, rootId)
+        if (!chat || !chat.invites.length) continue
+
+        for (const inv of chat.invites) {
+          if (typeof inv === "string" && inv === code) {
+            matchedChat = chat; matchedInvite = inv; break
+          }
+          if (typeof inv === "object" && inv.code === code) {
+            matchedChat = chat; matchedInvite = inv; break
+          }
+        }
+        if (matchedChat) break
+      }
+
+      if (!matchedChat) throw new Error("Invalid or expired invite code")
+      if (matchedChat.members.includes(userId)) throw new Error("Already a participant")
+
+      if (tribeCrypto && typeof matchedInvite === "object" && matchedInvite.ek) {
+        const chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code)
+        tribeCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1)
+      }
+
+      const members = [...matchedChat.members, userId]
+      const invites = matchedChat.invites.filter(inv => {
+        if (typeof inv === "string") return inv !== code
+        return inv.code !== code
+      })
+
+      await this.updateChatById(matchedChat.key, { members, invites, status: matchedChat.status, title: matchedChat.title, description: matchedChat.description, image: matchedChat.image, category: matchedChat.category, tags: matchedChat.tags })
+      return matchedChat.key
+    },
+
+    async joinChat(chatId) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const chat = await this.getChatById(chatId)
+      if (!chat) throw new Error("Chat not found")
+      if (chat.status === "CLOSED") throw new Error("Chat is closed")
+      if (chat.members.includes(userId)) return chat.key
+
+      const members = [...chat.members, userId]
+
+      if (tribeCrypto) {
+        const chatKey = tribeCrypto.getKey(chat.rootId)
+        if (chatKey && ssbClient.keys) {
+          try {
+            tribeCrypto.boxKeyForMember(chatKey, userId, ssbClient.keys)
+          } catch (_) {}
+        }
+      }
+
+      await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags })
+      return chat.key
+    },
+
+    async leaveChat(chatId) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const chat = await this.getChatById(chatId)
+      if (!chat) throw new Error("Chat not found")
+      if (chat.author === userId) throw new Error("Author cannot leave their own chat")
+      const members = chat.members.filter(m => m !== userId)
+      await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags })
+    },
+
+    async sendMessage(chatId, text, image = null) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const chat = await this.getChatById(chatId)
+      if (!chat) throw new Error("Chat not found")
+      if (chat.status === "CLOSED") throw new Error("Chat is closed")
+      if (!chat.members.includes(userId)) throw new Error("Not a participant")
+
+      const messages = await readAll(ssbClient)
+      const oneHourAgo = Date.now() - 60 * 60 * 1000
+      const recentCount = messages.filter(m => {
+        const c = m.value?.content
+        return c?.type === "chatMessage" && c?.chatId === chat.rootId && m.value?.author === userId && (m.value?.timestamp || 0) >= oneHourAgo
+      }).length
+      if (recentCount >= 3) throw new Error("Rate limit: max 3 messages per hour")
+
+      const now = new Date().toISOString()
+      let content = {
+        type: "chatMessage",
+        chatId: chat.rootId,
+        author: userId,
+        createdAt: now
+      }
+      if (image) content.image = image
+
+      if (tribeCrypto) {
+        const chatKey = tribeCrypto.getKey(chat.rootId)
+        if (chatKey) {
+          content.encryptedText = tribeCrypto.encryptWithKey(safeText(text), chatKey)
+        } else {
+          content.text = safeText(text)
+        }
+      } else {
+        content.text = safeText(text)
+      }
+
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
+      })
+    },
+
+    async listMessages(chatRootId) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+
+      const result = []
+      for (const [k, node] of idx.msgNodes.entries()) {
+        if (node.c.chatId !== chatRootId) continue
+        const msg = buildMessage(node, chatRootId)
+        if (msg) result.push(msg)
+      }
+
+      result.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
+      return result
+    },
+
+    async getParticipants(chatRootId) {
+      const chat = await this.getChatById(chatRootId)
+      if (!chat) return []
+      return chat.members
+    }
+  }
+}

+ 17 - 2
src/models/favorites_model.js

@@ -15,7 +15,7 @@ const toTs = (d) => {
   return Number.isFinite(t) ? t : 0;
 };
 
-module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel }) => {
+module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel }) => {
   const kindConfig = {
     audios: {
       base: "/audios/",
@@ -40,10 +40,22 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
     videos: {
       base: "/videos/",
       getById: getFn(videosModel, ["getVideoById", "getById"])
+    },
+    pads: {
+      base: "/pads/",
+      getById: getFn(padsModel, ["getPadById", "getById"])
+    },
+    chats: {
+      base: "/chats/",
+      getById: getFn(chatsModel, ["getChatById", "getById"])
+    },
+    calendars: {
+      base: "/calendars/",
+      getById: getFn(calendarsModel, ["getCalendarById", "getById"])
     }
   };
 
-  const kindOrder = ["audios", "bookmarks", "documents", "images", "maps", "videos"];
+  const kindOrder = ["audios", "bookmarks", "calendars", "chats", "documents", "images", "maps", "pads", "videos"];
 
   const hydrateKind = async (kind, ids) => {
     const cfg = kindConfig[kind];
@@ -97,9 +109,12 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
     const counts = {
       audios: byKind.audios.length,
       bookmarks: byKind.bookmarks.length,
+      calendars: byKind.calendars.length,
+      chats: byKind.chats.length,
       documents: byKind.documents.length,
       images: byKind.images.length,
       maps: byKind.maps.length,
+      pads: byKind.pads.length,
       videos: byKind.videos.length,
       all: flat.length
     };

+ 67 - 0
src/models/games_model.js

@@ -0,0 +1,67 @@
+const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 5000;
+
+const VALID_GAMES = new Set([
+  'cocoland', 'ecoinflow', 'spaceinvaders', 'arkanoid', 'pingpong',
+  'asteroids', 'tiktaktoe', 'flipflop',
+  '8ball', 'artillery', 'labyrinth', 'cocoman', 'tetris'
+]);
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
+
+  async function readAll(ssbClient) {
+    return new Promise((resolve, reject) => {
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((err, results) => (err ? reject(err) : resolve(results)))
+      );
+    });
+  }
+
+  return {
+    async submitScore(game, score) {
+      if (!VALID_GAMES.has(game)) throw new Error('invalid game');
+      const n = Number(score);
+      if (!Number.isFinite(n) || n < 0 || n > 9999999) throw new Error('invalid score');
+      const ssbClient = await openSsb();
+      return new Promise((resolve, reject) => {
+        ssbClient.publish({ type: 'gameScore', game, score: Math.round(n) }, (err, msg) => {
+          if (err) reject(err); else resolve(msg);
+        });
+      });
+    },
+
+    async getHallOfFame() {
+      const ssbClient = await openSsb();
+      const messages = await readAll(ssbClient);
+      const best = {};
+      for (const m of messages) {
+        const c = m.value && m.value.content;
+        if (!c || c.type !== 'gameScore') continue;
+        if (!VALID_GAMES.has(c.game)) continue;
+        const author = m.value.author;
+        const score = Number(c.score);
+        if (!Number.isFinite(score) || score < 0) continue;
+        const key = `${c.game}:${author}`;
+        if (!best[key] || score > best[key].score) {
+          best[key] = { author, score, game: c.game, ts: m.value.timestamp || 0 };
+        }
+      }
+      const hall = {};
+      for (const game of VALID_GAMES) hall[game] = [];
+      for (const entry of Object.values(best)) {
+        if (hall[entry.game]) hall[entry.game].push(entry);
+      }
+      for (const game of VALID_GAMES) {
+        hall[game] = hall[game].sort((a, b) => b.score - a.score).slice(0, 10);
+      }
+      return hall;
+    }
+  };
+};

+ 454 - 0
src/models/pads_model.js

@@ -0,0 +1,454 @@
+const pull = require("../server/node_modules/pull-stream")
+const crypto = require("crypto")
+const fs = require("fs")
+const path = require("path")
+const { getConfig } = require("../configs/config-manager.js")
+const logLimit = getConfig().ssbLogStream?.limit || 1000
+
+const safeText = (v) => String(v || "").trim()
+const normalizeTags = (raw) => {
+  if (!raw) return []
+  if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
+  return String(raw).split(",").map(t => t.trim()).filter(Boolean)
+}
+const INVITE_SALT = "SolarNET.HuB-pads"
+const INVITE_BYTES = 16
+const MEMBER_COLORS = ["#e74c3c","#3498db","#2ecc71","#f39c12","#9b59b6","#1abc9c","#e67e22","#e91e63","#00bcd4","#8bc34a"]
+
+module.exports = ({ cooler, cipherModel }) => {
+  let ssb
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
+
+  let keyringPath = null
+  const getKeyring = () => {
+    if (!keyringPath) {
+      const ssbConfig = require("../server/node_modules/ssb-config/inject")()
+      keyringPath = path.join(ssbConfig.path, "pad-keys.json")
+    }
+    try { return JSON.parse(fs.readFileSync(keyringPath, "utf8")) } catch (e) { return {} }
+  }
+  const saveKeyring = (kr) => fs.writeFileSync(keyringPath, JSON.stringify(kr, null, 2), "utf8")
+  const getPadKey = (rootId) => { const kr = getKeyring(); return kr[rootId] || null }
+  const setPadKey = (rootId, keyHex) => { const kr = getKeyring(); kr[rootId] = keyHex; saveKeyring(kr) }
+
+  const encryptField = (text, keyHex) => {
+    const key = Buffer.from(keyHex, "hex")
+    const iv = crypto.randomBytes(12)
+    const cipher = crypto.createCipheriv("aes-256-gcm", key, iv)
+    const enc = Buffer.concat([cipher.update(text, "utf8"), cipher.final()])
+    const authTag = cipher.getAuthTag()
+    return iv.toString("hex") + authTag.toString("hex") + enc.toString("hex")
+  }
+
+  const decryptField = (encrypted, keyHex) => {
+    try {
+      const key = Buffer.from(keyHex, "hex")
+      const iv = Buffer.from(encrypted.slice(0, 24), "hex")
+      const authTag = Buffer.from(encrypted.slice(24, 56), "hex")
+      const ciphertext = Buffer.from(encrypted.slice(56), "hex")
+      const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv)
+      decipher.setAuthTag(authTag)
+      return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8")
+    } catch (_) { return "" }
+  }
+
+  const encryptForInvite = (padKeyHex, code) => {
+    const derived = crypto.scryptSync(code, INVITE_SALT, 32)
+    return encryptField(padKeyHex, derived.toString("hex"))
+  }
+
+  const decryptFromInvite = (encryptedKey, code) => {
+    const derived = crypto.scryptSync(code, INVITE_SALT, 32)
+    return decryptField(encryptedKey, derived.toString("hex"))
+  }
+
+  const readAll = async (ssbClient) =>
+    new Promise((resolve, reject) =>
+      pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
+    )
+
+  const buildIndex = (messages) => {
+    const tomb = new Set()
+    const nodes = new Map()
+    const parent = new Map()
+    const child = new Map()
+
+    for (const m of messages) {
+      const k = m.key
+      const v = m.value || {}
+      const c = v.content
+      if (!c) continue
+      if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
+      if (c.type === "pad") {
+        nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
+        if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
+      }
+    }
+
+    const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
+    const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
+
+    const roots = new Set()
+    for (const id of nodes.keys()) roots.add(rootOf(id))
+    const tipByRoot = new Map()
+    for (const r of roots) tipByRoot.set(r, tipOf(r))
+
+    return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot }
+  }
+
+  const decryptPadFields = (c, rootId) => {
+    const keyHex = getPadKey(rootId)
+    if (!keyHex) return { title: "", deadline: "", tags: [] }
+    const title = c.title ? decryptField(c.title, keyHex) : ""
+    const deadline = c.deadline ? decryptField(c.deadline, keyHex) : ""
+    const tagsRaw = c.tags ? decryptField(c.tags, keyHex) : ""
+    const tags = normalizeTags(tagsRaw)
+    return { title, deadline, tags }
+  }
+
+  const buildPad = (node, rootId) => {
+    const c = node.c || {}
+    if (c.type !== "pad") return null
+    const { title, deadline, tags } = decryptPadFields(c, rootId)
+    return {
+      key: node.key,
+      rootId,
+      title,
+      status: c.status || "OPEN",
+      deadline,
+      tags,
+      author: c.author || node.author,
+      members: Array.isArray(c.members) ? c.members : [],
+      invites: Array.isArray(c.invites) ? c.invites : [],
+      createdAt: c.createdAt || new Date(node.ts).toISOString(),
+      updatedAt: c.updatedAt || null,
+      tribeId: c.tribeId || null
+    }
+  }
+
+  const isClosed = (pad) => {
+    if (pad.status === "CLOSED") return true
+    if (!pad.deadline) return false
+    return new Date(pad.deadline).getTime() <= Date.now()
+  }
+
+  return {
+    type: "pad",
+
+    decryptContent(content, rootId) {
+      return decryptPadFields(content, rootId)
+    },
+
+    async resolveRootId(id) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      let tip = id
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) throw new Error("Not found")
+      let root = tip
+      while (idx.parent.has(root)) root = idx.parent.get(root)
+      return root
+    },
+
+    async resolveCurrentId(id) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      let tip = id
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) throw new Error("Not found")
+      return tip
+    },
+
+    async createPad(title, status, deadline, tagsRaw, tribeId) {
+      const ssbClient = await openSsb()
+      const now = new Date().toISOString()
+      const validStatus = ["OPEN", "INVITE-ONLY"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
+      const keyHex = crypto.randomBytes(32).toString("hex")
+
+      const encrypt = (text) => {
+        const key = Buffer.from(keyHex, "hex")
+        const iv = crypto.randomBytes(12)
+        const cipher = crypto.createCipheriv("aes-256-gcm", key, iv)
+        const enc = Buffer.concat([cipher.update(text, "utf8"), cipher.final()])
+        const authTag = cipher.getAuthTag()
+        return iv.toString("hex") + authTag.toString("hex") + enc.toString("hex")
+      }
+
+      const content = {
+        type: "pad",
+        title: encrypt(safeText(title)),
+        status: validStatus,
+        deadline: deadline ? encrypt(String(deadline)) : "",
+        tags: encrypt(normalizeTags(tagsRaw).join(",")),
+        author: ssbClient.id,
+        members: [ssbClient.id],
+        invites: [],
+        createdAt: now,
+        updatedAt: now,
+        encrypted: true,
+        ...(tribeId ? { tribeId } : {})
+      }
+
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, msg) => {
+          if (err) return reject(err)
+          setPadKey(msg.key, keyHex)
+          resolve(msg)
+        })
+      })
+    },
+
+    async updatePadById(id, data) {
+      const tipId = await this.resolveCurrentId(id)
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const rootId = await this.resolveRootId(id)
+      const keyHex = getPadKey(rootId)
+
+      return new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Pad not found"))
+          if (item.content.author !== userId) return reject(new Error("Not the author"))
+          const c = item.content
+          const enc = (text) => keyHex ? encryptField(text, keyHex) : text
+          const updated = {
+            ...c,
+            title: data.title !== undefined ? enc(safeText(data.title)) : c.title,
+            status: data.status !== undefined ? (["OPEN","INVITE-ONLY"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : c.status) : c.status,
+            deadline: data.deadline !== undefined ? enc(String(data.deadline)) : c.deadline,
+            tags: data.tags !== undefined ? enc(normalizeTags(data.tags).join(",")) : c.tags,
+            updatedAt: new Date().toISOString(),
+            replaces: tipId
+          }
+          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+          ssbClient.publish(tombstone, (e1) => {
+            if (e1) return reject(e1)
+            ssbClient.publish(updated, (e2, res) => {
+              if (e2) return reject(e2)
+              if (keyHex) setPadKey(res.key, keyHex)
+              resolve(res)
+            })
+          })
+        })
+      })
+    },
+
+    async closePadById(id) {
+      const tipId = await this.resolveCurrentId(id)
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const rootId = await this.resolveRootId(id)
+      const keyHex = getPadKey(rootId)
+      return new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Pad not found"))
+          if (item.content.author !== userId) return reject(new Error("Not the author"))
+          const c = item.content
+          const updated = {
+            ...c,
+            status: "CLOSED",
+            updatedAt: new Date().toISOString(),
+            replaces: tipId
+          }
+          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+          ssbClient.publish(tombstone, (e1) => {
+            if (e1) return reject(e1)
+            ssbClient.publish(updated, (e2, res) => {
+              if (e2) return reject(e2)
+              if (keyHex) setPadKey(res.key, keyHex)
+              resolve(res)
+            })
+          })
+        })
+      })
+    },
+
+    async addMemberToPad(padId, feedId) {
+      const tipId = await this.resolveCurrentId(padId)
+      const ssbClient = await openSsb()
+      const rootId = await this.resolveRootId(padId)
+
+      return new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Pad not found"))
+          const c = item.content
+          const members = Array.isArray(c.members) ? c.members : []
+          if (members.includes(feedId)) return resolve()
+          const updated = { ...c, members: [...members, feedId], updatedAt: new Date().toISOString(), replaces: tipId }
+          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
+          ssbClient.publish(tombstone, (e1) => {
+            if (e1) return reject(e1)
+            ssbClient.publish(updated, (e2, res) => {
+              if (e2) return reject(e2)
+              const keyHex = getPadKey(rootId)
+              if (keyHex) setPadKey(res.key, keyHex)
+              resolve(res)
+            })
+          })
+        })
+      })
+    },
+
+    async deletePadById(id) {
+      const tipId = await this.resolveCurrentId(id)
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      return new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Pad not found"))
+          if (item.content.author !== userId) return reject(new Error("Not the author"))
+          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+          ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
+        })
+      })
+    },
+
+    async getPadById(id) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      let tip = id
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) return null
+      const node = idx.nodes.get(tip)
+      if (!node || node.c.type !== "pad") return null
+      let root = tip
+      while (idx.parent.has(root)) root = idx.parent.get(root)
+      const pad = buildPad(node, root)
+      if (!pad) return null
+      pad.isClosed = isClosed(pad)
+      return pad
+    },
+
+    async listAll({ filter = "all", viewerId } = {}) {
+      const ssbClient = await openSsb()
+      const uid = viewerId || ssbClient.id
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      const items = []
+      for (const [rootId, tipId] of idx.tipByRoot.entries()) {
+        if (idx.tomb.has(tipId)) continue
+        const node = idx.nodes.get(tipId)
+        if (!node || node.c.type !== "pad") continue
+        const pad = buildPad(node, rootId)
+        if (!pad) continue
+        pad.isClosed = isClosed(pad)
+        items.push(pad)
+      }
+      const now = Date.now()
+      let list = items
+      if (filter === "mine") list = list.filter(p => p.author === uid)
+      else if (filter === "recent") list = list.filter(p => new Date(p.createdAt).getTime() >= now - 86400000)
+      else if (filter === "open") list = list.filter(p => !p.isClosed)
+      else if (filter === "closed") list = list.filter(p => p.isClosed)
+      return list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+    },
+
+    async generateInvite(padId) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const pad = await this.getPadById(padId)
+      if (!pad) throw new Error("Pad not found")
+      if (pad.author !== userId) throw new Error("Only the author can generate invites")
+      const rootId = await this.resolveRootId(padId)
+      const keyHex = getPadKey(rootId)
+      const code = crypto.randomBytes(INVITE_BYTES).toString("hex")
+      let invite = code
+      if (keyHex) {
+        const ek = encryptForInvite(keyHex, code)
+        invite = { code, ek }
+      }
+      const invites = [...pad.invites, invite]
+      await this.updatePadById(padId, { invites })
+      return code
+    },
+
+    async joinByInvite(code) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const pads = await this.listAll()
+      let matchedPad = null
+      let matchedInvite = null
+      for (const p of pads) {
+        for (const inv of p.invites) {
+          if (typeof inv === "string" && inv === code) { matchedPad = p; matchedInvite = inv; break }
+          if (typeof inv === "object" && inv.code === code) { matchedPad = p; matchedInvite = inv; break }
+        }
+        if (matchedPad) break
+      }
+      if (!matchedPad) throw new Error("Invalid or expired invite code")
+      if (matchedPad.members.includes(userId)) throw new Error("Already a member")
+      if (typeof matchedInvite === "object" && matchedInvite.ek) {
+        const padKey = decryptFromInvite(matchedInvite.ek, code)
+        const rootId = await this.resolveRootId(matchedPad.rootId)
+        setPadKey(rootId, padKey)
+      }
+      await this.addMemberToPad(matchedPad.rootId, userId)
+      const invites = matchedPad.invites.filter(inv => {
+        if (typeof inv === "string") return inv !== code
+        return inv.code !== code
+      })
+      const tipId = await this.resolveCurrentId(matchedPad.rootId)
+      const ssbC = await openSsb()
+      return new Promise((resolve, reject) => {
+        ssbC.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Pad not found after join"))
+          const updated = { ...item.content, invites, updatedAt: new Date().toISOString(), replaces: tipId }
+          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+          ssbC.publish(tombstone, (e1) => {
+            if (e1) return reject(e1)
+            ssbC.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(matchedPad.rootId))
+          })
+        })
+      })
+    },
+
+    async addEntry(padId, text) {
+      const ssbClient = await openSsb()
+      const rootId = await this.resolveRootId(padId)
+      const keyHex = getPadKey(rootId)
+      const now = new Date().toISOString()
+      const encText = keyHex ? encryptField(safeText(text), keyHex) : safeText(text)
+      const content = {
+        type: "padEntry",
+        padId: rootId,
+        text: encText,
+        author: ssbClient.id,
+        createdAt: now,
+        encrypted: !!keyHex
+      }
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
+      })
+    },
+
+    async getEntries(padRootId) {
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const keyHex = getPadKey(padRootId)
+      const entries = []
+      for (const m of messages) {
+        const v = m.value || {}
+        const c = v.content
+        if (!c || c.type !== "padEntry") continue
+        if (c.padId !== padRootId) continue
+        const text = (keyHex && c.encrypted) ? decryptField(c.text, keyHex) : (c.text || "")
+        entries.push({
+          key: m.key,
+          author: c.author || v.author,
+          text,
+          createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
+        })
+      }
+      entries.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
+      return entries
+    },
+
+    getMemberColor(members, feedId) {
+      const idx = members.indexOf(feedId)
+      return idx >= 0 ? MEMBER_COLORS[idx % MEMBER_COLORS.length] : "#888"
+    }
+  }
+}

+ 27 - 3
src/models/search_model.js

@@ -3,7 +3,7 @@ const moment = require('../server/node_modules/moment');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, padsModel }) => {
   let ssb;
   const openSsb = async () => {
     if (!ssb) ssb = await cooler.open();
@@ -14,7 +14,7 @@ module.exports = ({ cooler }) => {
     'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
     'votes', 'report', 'task', 'event', 'bookmark', 'document',
     'image', 'audio', 'video', 'market', 'bankWallet', 'bankClaim',
-    'project', 'job', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct'
+    'project', 'job', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad'
   ];
 
   const getRelevantFields = (type, content) => {
@@ -73,6 +73,12 @@ module.exports = ({ cooler }) => {
         return [content?.title, content?.shortDescription, content?.description, content?.location, ...(content?.tags || []), content?.visibility, content?.url];
       case 'shopProduct':
         return [content?.title, content?.description, content?.price, ...(content?.tags || []), content?.shopId];
+      case 'chat':
+        return [content?.title, content?.description, content?.category, ...(content?.tags || []), content?.status, content?.author];
+      case 'pad':
+        return [content?.title, content?.status, content?.deadline, ...(content?.tags || []), content?.author];
+      case 'gameScore':
+        return [content?.game, content?.player];
       default:
         return [];
     }
@@ -220,6 +226,10 @@ module.exports = ({ cooler }) => {
       return ['shopProduct', author, norm(c.title), norm(c.shopId)].join('|');
     }
 
+    if (t === 'pad') {
+      return ['pad', author, norm(c.title), norm(c.deadline)].join('|');
+    }
+
     return `${t}:${msg.key}`;
   };
 
@@ -264,6 +274,19 @@ module.exports = ({ cooler }) => {
       latestByKey.delete(oldId);
     }
 
+    if (padsModel) {
+      for (const msg of latestByKey.values()) {
+        const c = msg?.value?.content;
+        if (c?.type === 'pad') {
+          const rootId = c.replaces ? msg.key : msg.key;
+          const decrypted = padsModel.decryptContent(c, rootId);
+          c.title = decrypted.title || c.title;
+          c.deadline = decrypted.deadline || c.deadline;
+          c.tags = decrypted.tags.length ? decrypted.tags : c.tags;
+        }
+      }
+    }
+
     let filtered = Array.from(latestByKey.values()).filter(msg => {
       const c = msg?.value?.content;
       const t = c?.type;
@@ -276,7 +299,8 @@ module.exports = ({ cooler }) => {
       const fields = getRelevantFields(t, c);
       if (queryLower.startsWith('#') && queryLower.length > 1) {
         const tag = queryLower.substring(1);
-        return (c?.tags || []).some(x => String(x).toLowerCase() === tag);
+        const tagArr = Array.isArray(c?.tags) ? c.tags : (typeof c?.tags === 'string' ? c.tags.split(',').map(s => s.trim()).filter(Boolean) : []);
+        return tagArr.some(x => String(x).toLowerCase() === tag);
       }
       return fields.filter(Boolean).map(String).some(field => field.toLowerCase().includes(queryLower));
     });

+ 4 - 1
src/models/stats_model.js

@@ -36,7 +36,8 @@ module.exports = ({ cooler }) => {
   const types = [
     'bookmark','event','task','votes','report','feed','project',
     'image','audio','video','document','transfer','post','tribe',
-    'market','forum','job','aiExchange','map','shop','shopProduct',
+    'market','forum','job','aiExchange','map','shop','shopProduct','chat','chatMessage',
+    'pad','padEntry','gameScore','calendar','calendarDate','calendarNote',
     'parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw',
     'courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote'
   ];
@@ -136,6 +137,8 @@ module.exports = ({ cooler }) => {
     if (c.type === 'map') return 'map';
     if (c.type === 'shop') return 'shop';
     if (c.type === 'shopProduct') return 'shopProduct';
+    if (c.type === 'chat') return 'chat';
+    if (c.type === 'chatMessage') return 'chatMessage';
     return '';
   };
 

+ 16 - 1
src/models/tags_model.js

@@ -2,7 +2,7 @@ const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, padsModel }) => {
   let ssb;
   const openSsb = async () => {
     if (!ssb) ssb = await cooler.open();
@@ -152,6 +152,21 @@ module.exports = ({ cooler }) => {
         }
       }
 
+      if (padsModel) {
+        const viewerId = '';
+        const pads = await padsModel.listAll({ filter: 'all', viewerId }).catch(() => []);
+        for (const pad of pads) {
+          if (!Array.isArray(pad.tags)) continue;
+          const uniquePadTags = new Set(pad.tags.map(tagKey).filter(Boolean));
+          for (const k of uniquePadTags) {
+            const display = normalizeTag(pad.tags.find(t => tagKey(t) === k) || k) || k;
+            const prev = counts.get(k);
+            if (!prev) counts.set(k, { name: display, count: 1 });
+            else counts.set(k, { name: prev.name || display, count: prev.count + 1 });
+          }
+        }
+      }
+
       let tags = Array.from(counts.values());
 
       if (filter === 'top') {

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

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

+ 2 - 2
src/server/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.6.9",
-  "description": "Oasis Social Networking Project Utopia",
+  "version": "0.7.0",
+  "description": "Oasis - Social Networking Utopia",
   "repository": {
     "type": "git",
     "url": "git+ssh://git@code.03c8.net/krakenlabs/oasis.git"

+ 6 - 0
src/server/ssb_metadata.js

@@ -1,4 +1,5 @@
 const fs = require('fs');
+const os = require('os');
 const path = require('path');
 const pkg = require('./package.json');
 const config = require('./ssb_config');
@@ -52,6 +53,11 @@ async function printMetadata(mode, modeColor = colors.cyan) {
   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} ]`);
+  const ifaces = os.networkInterfaces();
+  const isOnline = Object.values(ifaces).some(list =>
+    list && list.some(i => !i.internal && i.family === 'IPv4')
+  );
+  console.log(`- Mode: ${isOnline ? 'online' : 'offline'}`);
   console.log("");
   console.log("=========================");
   console.log("Modules loaded: [", modules.length, "]");

+ 117 - 58
src/views/activity_view.js

@@ -339,6 +339,10 @@ function renderActionCards(actions, userId, allActions) {
       headerText = `[TRIBE · REFEED]`;
     } else if (type === 'shopProduct') {
       headerText = `[SHOP · PRODUCT]`;
+    } else if (type === 'chat') {
+      headerText = `[CHAT \u00b7 NEW]`;
+    } else if (type === 'pad') {
+      headerText = `[PAD · ${String(i18n.padNew || 'NEW').toUpperCase()}]`;
     } else {
       const typeLabel = i18n[`type${capitalize(type)}`] || type;
       headerText = `[${String(typeLabel).toUpperCase()}]`;
@@ -437,6 +441,32 @@ function renderActionCards(actions, userId, allActions) {
       );
     }
 
+    if (type === 'ubiclaimresult') {
+      const { txid, userId: inhabitantId, amount, epochId } = content;
+      const pubAuthor = action.author || action.value?.author || '';
+      const amt = Number(amount || 0);
+      cardBody.push(
+        div({ class: 'card-section banking-ubi' },
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankUbiPub + ':'),
+            span({ class: 'card-value' }, pubAuthor)
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankUbiInhabitant + ':'),
+            span({ class: 'card-value' }, inhabitantId || '')
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankUbiClaimedAmount + ':'),
+            span({ class: 'card-value' }, `${amt.toFixed(6)} ECO`)
+          ),
+          txid ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankTx + ':'),
+            a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${txid}`, target: '_blank' }, txid)
+          ) : ""
+        )
+      );
+    }
+
     if (type === 'pixelia') {
       const { author } = content;
       cardBody.push(
@@ -1083,6 +1113,43 @@ function renderActionCards(actions, userId, allActions) {
       );
     }
 
+    if (type === 'chat') {
+      const { title, description, image, category, status } = content;
+      const chatKey = action.id || action.key || '';
+      const displayDesc = description ? (description.length > 140 ? description.slice(0, 140) + "\u2026" : description) : "";
+      const chatImageNode = renderMediaBlob(image);
+      cardBody.push(
+        div({ class: 'card-section chat' },
+          div({ class: 'card-field' }, span({ class: 'card-label' }, 'Chat:'), span({ class: 'card-value' }, chatKey ? a({ href: `/chats/${encodeURIComponent(chatKey)}`, class: 'user-link' }, title || chatKey) : (title || ''))),
+          displayDesc ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatDescription || 'Description') + ':'), span({ class: 'card-value' }, displayDesc)) : '',
+          category ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatCategoryLabel || 'Category') + ':'), span({ class: 'card-value' }, category)) : '',
+          status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'Status') + ':'), span({ class: 'card-value' }, status)) : ''
+        )
+      );
+    }
+
+    if (type === 'pad') {
+      const padKey = action.id || action.key || '';
+      const padTitle = content.title || action.title || '';
+      cardBody.push(
+        div({ class: 'card-section' },
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padTitle || 'Pad') + ':'), span({ class: 'card-value' }, padKey ? a({ href: `/pads/${encodeURIComponent(padKey)}`, class: 'user-link' }, padTitle || padKey) : '')),
+          content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padDeadlineLabel || 'Deadline') + ':'), span({ class: 'card-value' }, content.deadline)) : ''
+        )
+      );
+    }
+
+    if (type === 'calendar') {
+      const calKey = action.id || action.key || '';
+      const calTitle = content.title || action.title || '';
+      cardBody.push(
+        div({ class: 'card-section' },
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.calendarTitle || 'Calendar') + ':'), span({ class: 'card-value' }, calKey ? a({ href: `/calendars/${encodeURIComponent(calKey)}`, class: 'user-link' }, calTitle || calKey) : '')),
+          content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.calendarStatusLabel || 'Status') + ':'), span({ class: 'card-value' }, content.status)) : ''
+        )
+      );
+    }
+
     if (type === 'report') {
       const { title, confirmations, severity, status } = content;
       cardBody.push(
@@ -1224,6 +1291,22 @@ function renderActionCards(actions, userId, allActions) {
       );
     }
 
+    if (type === 'gameScore') {
+      const { game, score } = content;
+      cardBody.push(
+        div({ class: 'card-section ai-exchange' },
+          game ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, (i18n.gamesTitle || 'Game') + ':'),
+            span({ class: 'card-value' }, String(game).toUpperCase())
+          ) : null,
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, (i18n.gamesHallScore || 'Score') + ':'),
+            span({ class: 'card-value' }, String(score || 0))
+          )
+        )
+      );
+    }
+
     if (type === 'job') {
       const { title, job_type, tasks, location, vacants, salary, status, subscribers } = content;
       cardBody.push(
@@ -1511,6 +1594,9 @@ function getViewDetailsAction(type, action) {
     case 'market':     return `/market/${id}`;
     case 'shop':       return `/shops/${id}`;
     case 'shopProduct': return `/shops/product/${id}`;
+    case 'chat':       return `/chats/${id}`;
+    case 'pad':        return `/pads/${id}`;
+    case 'calendar':   return `/calendars/${id}`;
     case 'job':        return `/jobs/${id}`;
     case 'project':    return `/projects/${id}`;
     case 'report':     return `/reports/${id}`;
@@ -1528,33 +1614,37 @@ exports.activityView = (actions, filter, userId, q = '') => {
     { type: 'recent',    label: i18n.typeRecent },
     { type: 'all',       label: i18n.allButton },
     { type: 'mine',      label: i18n.mineButton },
-    { type: 'banking',   label: i18n.typeBanking },
-    { type: 'market',    label: i18n.typeMarket },
-    { type: 'project',   label: i18n.typeProject },
-    { type: 'job',       label: i18n.typeJob },
-    { type: 'curriculum',label: i18n.typeCurriculum },
-    { type: 'shop',      label: i18n.typeShop },
-    { type: 'transfer',  label: i18n.typeTransfer },
+    { type: 'report',    label: i18n.typeReport },
+    { type: 'karmaScore',label: i18n.typeKarmaScore },
     { type: 'about',     label: i18n.typeAbout },
     { type: 'tribe',     label: i18n.typeTribe },
     { type: 'parliament',label: i18n.typeParliament },
     { type: 'courts',    label: i18n.typeCourts },
-    { type: 'karmaScore',label: i18n.typeKarmaScore },
     { type: 'votes',     label: i18n.typeVotes },
+    { type: 'calendar',  label: i18n.typeCalendar || 'Calendar' },
     { type: 'event',     label: i18n.typeEvent },
     { type: 'task',      label: i18n.typeTask },
-    { type: 'report',    label: i18n.typeReport },
     { type: 'feed',      label: i18n.typeFeed },
-    { type: 'aiExchange',label: i18n.typeAiExchange },
     { type: 'post',      label: i18n.typePost },
-    { type: 'spread',    label: i18n.typeSpread || 'SPREAD' },
-    { type: 'pixelia',   label: i18n.typePixelia },
+    { type: 'spread',    label: i18n.typeSpread },
+    { type: 'chat',      label: i18n.typeChat },
+    { type: 'pad',       label: i18n.typePad },
     { type: 'forum',     label: i18n.typeForum },
     { type: 'map',       label: i18n.typeMap },
+    { type: 'banking',   label: i18n.typeBanking },
+    { type: 'market',    label: i18n.typeMarket },
+    { type: 'shop',      label: i18n.typeShop },
+    { type: 'project',   label: i18n.typeProject },
+    { type: 'job',       label: i18n.typeJob },
+    { type: 'curriculum',label: i18n.typeCurriculum },
+    { type: 'transfer',  label: i18n.typeTransfer },
+    { type: 'aiExchange',label: i18n.typeAiExchange },
+    { type: 'gameScore', label: i18n.typeGameScore },
+    { type: 'pixelia',   label: i18n.typePixelia },
     { type: 'audio',     label: i18n.typeAudio },
     { type: 'bookmark',  label: i18n.typeBookmark },
-    { type: 'document',  label: i18n.typeDocument },
     { type: 'image',     label: i18n.typeImage },
+    { type: 'document',  label: i18n.typeDocument },
     { type: 'video',     label: i18n.typeVideo }
   ];
 
@@ -1565,7 +1655,7 @@ exports.activityView = (actions, filter, userId, q = '') => {
     const now = Date.now();
     filteredActions = actions.filter(action => action.type !== 'tombstone' && action.ts && now - action.ts < 24 * 60 * 60 * 1000);
   } else if (filter === 'banking') {
-    filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'bankWallet' || action.type === 'bankClaim'));
+    filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'bankWallet' || action.type === 'bankClaim' || action.type === 'ubiclaimresult'));
   } else if (filter === 'tribe') {
     filteredActions = actions.filter(action =>
       action.type !== 'tombstone' &&
@@ -1583,6 +1673,8 @@ exports.activityView = (actions, filter, userId, q = '') => {
     filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'task' || action.type === 'taskAssignment'));
   } else if (filter === 'spread') {
     filteredActions = actions.filter(action => action.type === 'spread');
+  } else if (filter === 'gameScore') {
+    filteredActions = actions.filter(action => action.type === 'gameScore');
   } else {
     filteredActions = actions.filter(action => (action.type === filter || filter === 'all' || (filter === 'shop' && action.type === 'shopProduct')) && action.type !== 'tombstone');
   }
@@ -1614,50 +1706,17 @@ exports.activityView = (actions, filter, userId, q = '') => {
         h2(i18n.activityList),
         p(desc)
       ),
-      form({ method: 'GET', action: '/activity' },
-        div({ class: 'mode-buttons', style: 'display:grid; grid-template-columns: repeat(6, 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, 10).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(10, 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, 19).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(19, 26).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(26).map(({ type, label }) =>
+      div({ class: 'activity-filter-grid' },
+        ...[
+          activityTypes.slice(0, 4),
+          activityTypes.slice(4, 12),
+          activityTypes.slice(12, 19),
+          activityTypes.slice(19, 26),
+          activityTypes.slice(26, 29),
+          activityTypes.slice(29)
+        ].map(col =>
+          div({ class: 'activity-filter-col' },
+            col.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)

+ 12 - 1
src/views/agenda_view.js

@@ -24,6 +24,7 @@ function getViewDetailsAction(item) {
     case 'report': return `/reports/${encodeURIComponent(item.id)}`;
     case 'job': return `/jobs/${encodeURIComponent(item.id)}`;
     case 'project': return `/projects/${encodeURIComponent(item.id)}`;
+    case 'calendar': return `/calendars/${encodeURIComponent(item.id)}`;
     default: return `/messages/${encodeURIComponent(item.id)}`;
   }
 }
@@ -141,6 +142,14 @@ const renderAgendaItem = (item, userId, filter) => {
     ];
   }
 
+  if (item.type === 'calendar') {
+    details = [
+      renderCardField((i18n.calendarStatusLabel || 'Status') + ':', item.isClosed ? (i18n.calendarStatusClosed || 'CLOSED') : (i18n.calendarStatusOpen || 'OPEN')),
+      renderCardField((i18n.calendarDeadlineLabel || 'Deadline') + ':', item.deadline ? moment(item.deadline).format('YYYY/MM/DD HH:mm') : ''),
+      renderCardField((i18n.calendarParticipantsLabel || 'Participants') + ':', Array.isArray(item.participants) ? item.participants.length : 0)
+    ];
+  }
+
   if (item.type === 'job') {
     const subs = Array.isArray(item.subscribers)
       ? item.subscribers
@@ -192,7 +201,7 @@ const renderAgendaItem = (item, userId, filter) => {
 
 exports.agendaView = async (data, filter) => {
   const { items = [], counts: _c = {} } = data || {};
-  const counts = { all: 0, open: 0, closed: 0, events: 0, tasks: 0, reports: 0, tribes: 0, jobs: 0, market: 0, projects: 0, transfers: 0, discarded: 0, ..._c };
+  const counts = { all: 0, open: 0, closed: 0, events: 0, tasks: 0, reports: 0, tribes: 0, jobs: 0, market: 0, projects: 0, transfers: 0, calendars: 0, discarded: 0, ..._c };
   return template(
     i18n.agendaTitle,
     section(
@@ -222,6 +231,8 @@ exports.agendaView = async (data, filter) => {
             `${i18n.agendaFilterMarket} (${counts.market})`),
           button({ type: 'submit', name: 'filter', value: 'projects', class: filter === 'projects' ? 'filter-btn active' : 'filter-btn' },
             `${i18n.agendaFilterProjects} (${counts.projects})`),
+          button({ type: 'submit', name: 'filter', value: 'calendars', class: filter === 'calendars' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterCalendars || 'CALENDARS'} (${counts.calendars})`),
           button({ type: 'submit', name: 'filter', value: 'transfers', class: filter === 'transfers' ? 'filter-btn active' : 'filter-btn' },
             `${i18n.agendaFilterTransfers} (${counts.transfers})`),
           button({ type: 'submit', name: 'filter', value: 'discarded', class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' },

+ 52 - 32
src/views/banking_views.js

@@ -8,6 +8,8 @@ const FILTER_LABELS = {
   mine: i18n.mine,
   pending: i18n.pending,
   closed: i18n.closed,
+  claimed: i18n.bankStatusClaimed,
+  expired: i18n.bankStatusExpired,
   epochs: i18n.bankEpochs,
   rules: i18n.bankRules,
   addresses: i18n.bankAddresses
@@ -25,14 +27,14 @@ const generateFilterButtons = (filters, currentFilter, action) =>
 
 const kvRow = (label, value) =>
   tr(td({ class: "card-label" }, label), td({ class: "card-value" }, value));
-  
+
 const fmtIndex = (value) => {
     return value ? value.toFixed(6) : "0.000000";
 };
 
 const pct = (value) => {
     if (value === undefined || value === null) return "0.000001%";
-    const formattedValue = (value).toFixed(6); 
+    const formattedValue = (value).toFixed(6);
     const sign = value >= 0 ? "+" : "";
     return `${sign}${formattedValue}%`;
 };
@@ -50,7 +52,7 @@ const renderExchange = (ex) => {
     div({ class: "bank-summary" },
       table({ class: "bank-info-table" },
         tbody(
-          kvRow(i18n.bankingSyncStatus, 
+          kvRow(i18n.bankingSyncStatus,
             span({ class: syncStatusClass }, syncStatus)
           ),
           kvRow(i18n.bankExchangeCurrentValue, `${fmtIndex(ex.ecoValue)} ECO`),
@@ -71,45 +73,59 @@ const renderOverviewSummaryTable = (s, rules) => {
   const w = 1 + score / 100;
   const cap = rules?.caps?.cap_user_epoch ?? 50;
   const future = Math.min(pool * (w / W), cap);
+  const availClass = s.ubiAvailability === "OK" ? "ubi-available" : "ubi-unavailable";
+  const availLabel = s.ubiAvailability === "OK" ? i18n.bankUbiAvailableOk : i18n.bankUbiAvailableNo;
   return div({ class: "bank-summary" },
     table({ class: "bank-info-table" },
       tbody(
         kvRow(i18n.bankUserBalance, `${Number(s.userBalance || 0).toFixed(6)} ECO`),
         kvRow(i18n.bankPubBalance, `${Number(s.pubBalance || 0).toFixed(6)} ECO`),
+        kvRow(i18n.bankUbiAvailability, span({ class: availClass }, availLabel)),
+        s.pubId ? kvRow(i18n.pubIdLabel, a({ href: `/author/${encodeURIComponent(s.pubId)}`, class: "user-link" }, s.pubId)) : null,
         kvRow(i18n.bankEpoch, String(s.epochId || "-")),
         kvRow(i18n.bankPool, `${pool.toFixed(6)} ECO`),
         kvRow(i18n.bankWeightsSum, String(W.toFixed(6))),
         kvRow(i18n.bankingUserEngagementScore, String(score)),
-        kvRow(i18n.bankingFutureUBI, `${future.toFixed(6)} ECO`)
+        kvRow(i18n.bankUbiThisMonth, `${future.toFixed(6)} ECO`)
       )
     )
   );
 };
 
-const renderClaimUBIBlock = (pendingAllocation) => {
+const renderClaimUBIBlock = (pendingAllocation, isPub, alreadyClaimed, pubId) => {
+  if (alreadyClaimed) {
+    return div({ class: "bank-claim-ubi" }, p(i18n.bankAlreadyClaimedThisMonth));
+  }
+  if (!pubId && !isPub) {
+    return div({ class: "bank-claim-ubi" }, p(i18n.bankNoPubConfigured));
+  }
+  if (!pendingAllocation && !isPub) {
+    return div({ class: "bank-claim-ubi" },
+      div({ class: "bank-claim-card" },
+        form({ method: "POST", action: "/banking/claim-ubi" },
+          button({ type: "submit", class: "create-button bank-claim-btn" }, i18n.bankClaimUBI)
+        )
+      )
+    );
+  }
   if (!pendingAllocation) return div({ class: "bank-claim-ubi" }, p(i18n.bankNoPendingUBI));
   return div({ class: "bank-claim-ubi" },
     div({ class: "bank-claim-card" },
-      p(`${i18n.bankingFutureUBI}: `, span({ class: "accent" }, `${Number(pendingAllocation.amount || 0).toFixed(6)} ECO`)),
+      p(`${i18n.bankUbiThisMonth}: `, span({ class: "accent" }, `${Number(pendingAllocation.amount || 0).toFixed(6)} ECO`)),
       p(`${i18n.bankEpoch}: `, span(pendingAllocation.concept || "")),
       form({ method: "POST", action: `/banking/claim/${encodeURIComponent(pendingAllocation.id)}` },
-        button({ type: "submit", class: "create-button bank-claim-btn" }, i18n.bankClaimUBI)
+        button({ type: "submit", class: "create-button bank-claim-btn" }, isPub ? i18n.bankClaimAndPay : i18n.bankClaimUBI)
       )
     )
   );
 };
 
-function calculateFutureUBI(userEngagementScore, poolAmount) {
-  const maxScore = 100;
-  const scorePercentage = userEngagementScore / maxScore;
-  const estimatedUBI = poolAmount * scorePercentage;
-  return estimatedUBI;
-}
-
 const filterAllocations = (allocs, filter, userId) => {
-  if (filter === "mine") return allocs.filter(a => a.to === userId && a.status === "UNCONFIRMED");
-  if (filter === "pending") return allocs.filter(a => a.status === "UNCONFIRMED");
+  if (filter === "mine") return allocs.filter(a => a.to === userId && (a.status === "UNCLAIMED" || a.status === "UNCONFIRMED"));
+  if (filter === "pending") return allocs.filter(a => a.status === "UNCLAIMED" || a.status === "UNCONFIRMED");
   if (filter === "closed") return allocs.filter(a => a.status === "CLOSED");
+  if (filter === "claimed") return allocs.filter(a => a.status === "CLAIMED");
+  if (filter === "expired") return allocs.filter(a => a.status === "EXPIRED");
   return allocs;
 };
 
@@ -139,7 +155,7 @@ const allocationsTable = (rows = [], userId) =>
               td(String(Number(r.amount || 0).toFixed(6))),
               td(r.status),
               td(
-                r.status === "UNCONFIRMED" && r.to === userId
+                (r.status === "UNCLAIMED" || r.status === "UNCONFIRMED") && r.to === userId
                   ? form({ method: "POST", action: `/banking/claim/${encodeURIComponent(r.id)}` },
                       button({ type: "submit", class: "filter-btn" }, i18n.bankClaimNow)
                     )
@@ -187,11 +203,15 @@ const flashText = (key) => {
   if (key === "invalid") return i18n.bankAddressInvalid;
   if (key === "deleted") return i18n.bankAddressDeleted;
   if (key === "not_found") return i18n.bankAddressNotFound;
+  if (key === "claimed_pending") return i18n.bankClaimedPending;
+  if (key === "already_claimed") return i18n.bankAlreadyClaimedThisMonth;
+  if (key === "no_pub_configured") return i18n.bankNoPubConfigured;
+  if (key === "no_funds") return i18n.bankUbiAvailableNo;
   return "";
 };
 
 const flashBanner = (msgKey) =>
-  !msgKey ? null : div({ class: "flash-banner" }, p(flashText(msgKey)));
+  !msgKey ? null : div({ class: "flash-banner" }, p(flashText(msgKey) || msgKey));
 
 const addressesToolbar = (rows = [], search = "") =>
   div({ class: "addr-toolbar" },
@@ -264,15 +284,15 @@ const renderAddresses = (data, userId) => {
                     td(a({ href: `/author/${encodeURIComponent(r.id)}`, class: "user-link" }, r.id)),
                     td(r.address),
                     td(r.source === "local" ? i18n.bankLocal : i18n.bankFromOasis),
-			td(
-			  div({ class: "row-actions" },
-				form({ method: "POST", action: "/banking/addresses/delete", class: "addr-del" },
-				  input({ type: "hidden", name: "userId", value: r.id }),
-				  input({ type: "hidden", name: "source", value: r.source || "local" }),
-				  button({ type: "submit", class: "delete-btn", onclick: `return confirm(${JSON.stringify(i18n.bankAddressDeleteConfirm)})` }, i18n.bankAddressDelete)
-				)
-			  )
+		td(
+		  div({ class: "row-actions" },
+			form({ method: "POST", action: "/banking/addresses/delete", class: "addr-del" },
+			  input({ type: "hidden", name: "userId", value: r.id }),
+			  input({ type: "hidden", name: "source", value: r.source || "local" }),
+			  button({ type: "submit", class: "delete-btn", onclick: `return confirm(${JSON.stringify(i18n.bankAddressDeleteConfirm)})` }, i18n.bankAddressDelete)
 			)
+		  )
+		)
                   )
                 )
               )
@@ -282,16 +302,17 @@ const renderAddresses = (data, userId) => {
   );
 };
 
-const renderBankingView = (data, filter, userId) =>
+const renderBankingView = (data, filter, userId, isPub) =>
   template(
     i18n.banking,
     section(
       div({ class: "tags-header" }, h2(i18n.banking), p(i18n.bankingDescription)),
-      generateFilterButtons(["overview","exchange","mine","pending","closed","epochs","rules","addresses"], filter, "/banking"),
+      data.flash ? div({ class: "flash-banner" }, p(flashText(data.flash) || data.flash)) : null,
+      generateFilterButtons(["overview","exchange","mine","pending","closed","claimed","expired","epochs","rules","addresses"], filter, "/banking"),
       filter === "overview"
         ? div(
             renderOverviewSummaryTable(data.summary || {}, data.rules),
-            renderClaimUBIBlock(data.pendingUBI || null),
+            renderClaimUBIBlock(data.pendingUBI || null, isPub, data.alreadyClaimed, (data.summary || {}).pubId),
             allocationsTable((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), userId)
           )
         : filter === "exchange"
@@ -307,8 +328,8 @@ const renderBankingView = (data, filter, userId) =>
             userId
           )
     )
-  )
-  
+  );
+
 const renderSingleAllocationView = (alloc, userId) => {
   if (!alloc) return template(i18n.banking, section(div(p(i18n.bankNoAllocations))));
   return template(
@@ -362,4 +383,3 @@ const renderEpochView = (epoch, allocations) => {
 };
 
 module.exports = { renderBankingView, renderSingleAllocationView, renderEpochView };
-

+ 13 - 5
src/views/blockchain_view.js

@@ -12,14 +12,16 @@ const FILTER_LABELS = {
   transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, tribe: i18n.typeTribe,
   project: i18n.typeProject, banking: i18n.typeBanking, bankWallet: i18n.typeBankWallet, bankClaim: i18n.typeBankClaim,
   aiExchange: i18n.typeAiExchange, parliament: i18n.typeParliament, courts: i18n.typeCourts,
-  map: i18n.typeMap, shop: i18n.typeShop, shopProduct: i18n.typeShopProduct || 'Shop Product'
+  map: i18n.typeMap, shop: i18n.typeShop, shopProduct: i18n.typeShopProduct || 'Shop Product',
+  pad: i18n.typePad || 'PAD', chat: i18n.typeChat || 'CHAT', gameScore: i18n.typeGameScore || 'GAME SCORE',
+  calendar: i18n.typeCalendar || 'CALENDAR'
 };
 
 const BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone'];
-const CAT_BLOCK1  = ['votes', 'event', 'task', 'report', 'parliament', 'courts'];
+const CAT_BLOCK1  = ['votes', 'event', 'task', 'report', 'calendar', 'parliament', 'courts'];
 const CAT_BLOCK2  = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange'];
-const CAT_BLOCK3  = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia', 'shop'];
-const CAT_BLOCK4  = ['forum', 'bookmark', 'image', 'video', 'audio', 'document', 'map'];
+const CAT_BLOCK3  = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia', 'shop', 'gameScore'];
+const CAT_BLOCK4  = ['forum', 'pad', 'chat', 'bookmark', 'image', 'video', 'audio', 'document', 'map'];
 
 const SEARCH_FIELDS = ['author','id','from','to'];
 
@@ -103,6 +105,7 @@ const getViewDetailsAction = (type, block) => {
     case 'job': return `/jobs/${encodeURIComponent(block.id)}`;
     case 'project': return `/projects/${encodeURIComponent(block.id)}`;
     case 'report': return `/reports/${encodeURIComponent(block.id)}`;
+    case 'calendar': return `/calendars/${encodeURIComponent(block.id)}`;
     case 'bankWallet': return `/wallet`;
     case 'bankClaim': return `/banking${block.content?.epochId ? `/epoch/${encodeURIComponent(block.content.epochId)}` : ''}`;
     case 'parliamentTerm': return `/parliament`;
@@ -123,6 +126,9 @@ const getViewDetailsAction = (type, block) => {
     case 'mapMarker': return block.content?.mapId ? `/maps/${encodeURIComponent(block.content.mapId)}` : `/maps`;
     case 'shop': return `/shops/${encodeURIComponent(block.id)}`;
     case 'shopProduct': return `/shops/product/${encodeURIComponent(block.id)}`;
+    case 'pad': return `/pads/${encodeURIComponent(block.id)}`;
+    case 'chat': return `/chats/${encodeURIComponent(block.id)}`;
+    case 'gameScore': return `/games?filter=scoring`;
     default: return null;
   }
 };
@@ -139,7 +145,9 @@ const TYPE_COLORS = {
   courtsCase:'#c0392b', courtsEvidence:'#c0392b', courtsAnswer:'#c0392b',
   courtsVerdict:'#c0392b', courtsSettlement:'#c0392b', courtsNomination:'#c0392b',
   map:'#27ae60', mapMarker:'#27ae60',
-  shop:'#e67e22', shopProduct:'#e67e22'
+  shop:'#e67e22', shopProduct:'#e67e22',
+  pad:'#2ecc71', chat:'#3498db', gameScore:'#f39c12',
+  calendar:'#e74c3c'
 };
 
 const renderBlockDiagram = (blocks, qs) => {

+ 381 - 0
src/views/calendars_view.js

@@ -0,0 +1,381 @@
+const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td, 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 renderNoteText = (text) => {
+  if (!text) return []
+  const urlRegex = /(https?:\/\/[^\s]+)/g
+  const result = []
+  let last = 0
+  text.replace(urlRegex, (match, _g, offset) => {
+    if (offset > last) result.push(text.slice(last, offset))
+    result.push(a({ href: match, target: "_blank" }, match))
+    last = offset + match.length
+  })
+  if (last < text.length) result.push(text.slice(last))
+  return result
+}
+
+const renderCalendarFavoriteToggle = (cal, returnTo) =>
+  form(
+    { method: "POST", action: cal.isFavorite ? `/calendars/favorites/remove/${encodeURIComponent(cal.rootId)}` : `/calendars/favorites/add/${encodeURIComponent(cal.rootId)}` },
+    returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
+    button({ type: "submit", class: "tribe-action-btn" }, cal.isFavorite ? (i18n.calendarRemoveFavorite || "Remove Favorite") : (i18n.calendarAddFavorite || "Add Favorite"))
+  )
+
+const renderModeButtons = (currentFilter) =>
+  div({ class: "tribe-mode-buttons" },
+    ["all", "mine", "recent", "favorites", "open", "closed"].map(f =>
+      form({ method: "GET", action: "/calendars" },
+        input({ type: "hidden", name: "filter", value: f }),
+        button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" },
+          i18n[`calendarFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase())
+      )
+    ),
+    form({ method: "GET", action: "/calendars" },
+      input({ type: "hidden", name: "filter", value: "create" }),
+      button({ type: "submit", class: "create-button" }, i18n.calendarCreate || "Create Calendar")
+    )
+  )
+
+const renderStatus = (cal) => {
+  if (cal.isClosed) return span({ class: "pad-status-closed" }, i18n.calendarStatusClosed || "CLOSED")
+  return span({ class: "pad-status-open" }, i18n.calendarStatusOpen || "OPEN")
+}
+
+const renderCalendarCard = (cal) => {
+  const href = `/calendars/${encodeURIComponent(cal.rootId)}`
+  return div({ class: "tribe-card" },
+    div({ class: "tribe-card-body" },
+      div({ class: "tribe-card-title" },
+        a({ href }, cal.title || "\u2014")
+      ),
+      table({ class: "tribe-info-table" },
+        tr(td({ class: "tribe-info-label" }, i18n.calendarStatusLabel || "Status"), td({ class: "tribe-info-value" }, renderStatus(cal))),
+        cal.deadline ? tr(td({ class: "tribe-info-label" }, i18n.calendarDeadlineLabel || "Deadline"), td({ class: "tribe-info-value" }, moment(cal.deadline).format("YYYY-MM-DD HH:mm"))) : null,
+      ),
+      div({ class: "tribe-card-members" },
+        span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${cal.participants.length}`)
+      ),
+      div({ class: "visit-btn-centered" },
+        a({ href, class: "filter-btn" }, i18n.calendarVisitCalendar || "Visit Calendar")
+      )
+    )
+  )
+}
+
+const renderIntervalBlock = () =>
+  div({ class: "calendar-interval-block" },
+    span({ class: "calendar-interval-label" }, i18n.calendarIntervalLabel || "Interval"),
+    div({ class: "calendar-interval-row" },
+      input({ type: "hidden", name: "intervalWeekly", value: "0" }),
+      label({ class: "calendar-interval-option" },
+        input({ type: "checkbox", name: "intervalWeekly", value: "1" }),
+        " ", i18n.calendarIntervalWeekly || "Weekly"
+      ),
+      input({ type: "hidden", name: "intervalMonthly", value: "0" }),
+      label({ class: "calendar-interval-option" },
+        input({ type: "checkbox", name: "intervalMonthly", value: "1" }),
+        " ", i18n.calendarIntervalMonthly || "Monthly"
+      ),
+      input({ type: "hidden", name: "intervalYearly", value: "0" }),
+      label({ class: "calendar-interval-option" },
+        input({ type: "checkbox", name: "intervalYearly", value: "1" }),
+        " ", i18n.calendarIntervalYearly || "Yearly"
+      )
+    ),
+    span({ class: "calendar-interval-label calendar-interval-until" }, i18n.calendarIntervalUntil || "Until"),
+    input({ type: "datetime-local", name: "intervalDeadline" }),
+    br()
+  )
+
+const renderCreateForm = (calendarToEdit, params) => {
+  const isEdit = !!calendarToEdit
+  const tribeId = (params && params.tribeId) || ""
+  const now = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm")
+  const action = isEdit ? `/calendars/update/${encodeURIComponent(calendarToEdit.rootId)}` : "/calendars/create"
+  const sectionTitle = isEdit ? (i18n.calendarUpdateSectionTitle || "Update Calendar") : (i18n.calendarCreateSectionTitle || "Create New Calendar")
+  return div({ class: "div-center audio-form" },
+    h2(sectionTitle),
+    form({ method: "POST", action },
+      tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
+      span(i18n.calendarTitleLabel || "Title"), br(),
+      input({ type: "text", name: "title", required: true, placeholder: i18n.calendarTitlePlaceholder || "Calendar title...", value: calendarToEdit ? calendarToEdit.title : "" }),
+      br(), br(),
+      span(i18n.calendarStatusLabel || "Status"), br(),
+      select({ name: "status", required: true },
+        option({ value: "OPEN", ...((!calendarToEdit || calendarToEdit.status === "OPEN") ? { selected: true } : {}) }, i18n.calendarStatusOpen || "OPEN"),
+        option({ value: "CLOSED", ...((calendarToEdit && calendarToEdit.status === "CLOSED") ? { selected: true } : {}) }, i18n.calendarStatusClosed || "CLOSED")
+      ),
+      br(), br(),
+      span(i18n.calendarDeadlineLabel || "Deadline"), br(),
+      input({ type: "datetime-local", name: "deadline", required: true, min: now, value: calendarToEdit && calendarToEdit.deadline ? moment(calendarToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "" }),
+      br(), br(),
+      span(i18n.calendarTagsLabel || "Tags"), br(),
+      input({ type: "text", name: "tags", placeholder: i18n.calendarTagsPlaceholder || "tag1, tag2...", value: calendarToEdit && Array.isArray(calendarToEdit.tags) ? calendarToEdit.tags.join(", ") : "" }),
+      br(), br(),
+      !isEdit
+        ? [
+            span(i18n.calendarFirstDateLabel || "Date"), br(),
+            input({ type: "datetime-local", name: "firstDate", required: true, min: now }),
+            br(), br(),
+            span(i18n.calendarFormDescription || "Description"), br(),
+            input({ type: "text", name: "firstDateLabel", placeholder: i18n.calendarDatePlaceholder || "Describe this date..." }),
+            br(), br(),
+            renderIntervalBlock(),
+            span(i18n.calendarFirstNoteLabel || "Notes"), br(),
+            textarea({ name: "firstNote", rows: "3", placeholder: i18n.calendarNotePlaceholder || "Add a note..." }),
+            br(), br()
+          ]
+        : null,
+      button({ type: "submit", class: "create-button" }, isEdit ? (i18n.calendarUpdate || "Update") : (i18n.calendarCreate || "Create Calendar"))
+    )
+  )
+}
+
+const renderMonthGrid = (year, month, datesMap, calendarId) => {
+  const DAY_NAMES = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]
+  const firstDay = new Date(year, month, 1)
+  const daysInMonth = new Date(year, month + 1, 0).getDate()
+  const startPad = (firstDay.getDay() + 6) % 7
+  const monthStr = `${year}-${String(month + 1).padStart(2, "0")}`
+
+  const headerCells = DAY_NAMES.map(d => div({ class: "calendar-day-header" }, d))
+  const cells = []
+
+  for (let i = 0; i < startPad; i++) cells.push(div({ class: "calendar-day calendar-day-empty" }, " "))
+
+  for (let day = 1; day <= daysInMonth; day++) {
+    const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
+    const marked = datesMap && datesMap[dateStr] && datesMap[dateStr].length > 0
+    if (marked) {
+      cells.push(
+        div({ class: "calendar-day calendar-day-marked" },
+          a({ href: `/calendars/${encodeURIComponent(calendarId)}?month=${monthStr}&day=${dateStr}` }, String(day))
+        )
+      )
+    } else {
+      cells.push(div({ class: "calendar-day" }, String(day)))
+    }
+  }
+
+  return div({ class: "calendar-grid" }, ...headerCells, ...cells)
+}
+
+exports.calendarsView = async (calendars, filter, calendarToEdit, params) => {
+  const q = (params && params.q) || ""
+  const showForm = filter === "create" || filter === "edit" || !!calendarToEdit
+  const headerMap = {
+    all: i18n.calendarAllSectionTitle || "Calendars",
+    mine: i18n.calendarMineSectionTitle || "Your Calendars",
+    recent: i18n.calendarRecentSectionTitle || "Recent Calendars",
+    favorites: i18n.calendarFavoritesSectionTitle || "Favorites",
+    open: i18n.calendarOpenSectionTitle || "Open Calendars",
+    closed: i18n.calendarClosedSectionTitle || "Closed Calendars"
+  }
+  const headerText = headerMap[filter] || headerMap.all
+
+  return template(
+    i18n.calendarsTitle || "Calendars",
+    section(
+      div({ class: "tags-header" },
+        h2(headerText),
+        p(i18n.calendarsDescription || "Discover and manage calendars.")
+      ),
+      renderModeButtons(filter),
+      q
+        ? div({ class: "filters" },
+            form({ method: "GET", action: "/calendars" },
+              input({ type: "text", name: "q", value: q, placeholder: i18n.calendarSearchPlaceholder || "Search calendars..." }),
+              button({ type: "submit", class: "filter-btn" }, i18n.searchButton || "Search")
+            )
+          )
+        : null
+    ),
+    section(
+      showForm
+        ? renderCreateForm(calendarToEdit, params)
+        : (calendars.length > 0
+            ? div({ class: "tribe-grid" }, ...calendars.map(c => renderCalendarCard(c)))
+            : p({ class: "no-content" }, i18n.calendarsNoItems || "No calendars found."))
+    )
+  )
+}
+
+exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
+  const { month: monthStr, day: selectedDay } = params || {}
+  const isAuthor = calendar.author === userId
+  const isParticipant = calendar.participants.includes(userId)
+  const calClosed = calendar.isClosed
+  const shareUrl = `/calendars/${encodeURIComponent(calendar.rootId)}`
+
+  const now = moment()
+  const currentMonth = monthStr ? moment(monthStr, "YYYY-MM") : now.clone().startOf("month")
+  const prevMonth = currentMonth.clone().subtract(1, "month").format("YYYY-MM")
+  const nextMonth = currentMonth.clone().add(1, "month").format("YYYY-MM")
+  const year = currentMonth.year()
+  const month = currentMonth.month()
+
+  const datesMap = {}
+  for (const d of dates) {
+    const dayKey = moment(d.date).format("YYYY-MM-DD")
+    if (!datesMap[dayKey]) datesMap[dayKey] = []
+    datesMap[dayKey].push(d)
+  }
+
+  const tags = Array.isArray(calendar.tags) && calendar.tags.length > 0
+    ? div({ class: "tribe-side-tags" }, ...calendar.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t} `)))
+    : null
+
+  const calSide = div({ class: "tribe-side" },
+    h2(null, calendar.title || "\u2014"),
+    div({ class: "shop-share" },
+      span({ class: "tribe-info-label" }, i18n.calendarsShareUrl || "Share URL"),
+      input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" })
+    ),
+    div({ class: "tribe-card-members" },
+      span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${calendar.participants.length}`)
+    ),
+    table({ class: "tribe-info-table" },
+      tr(td({ class: "tribe-info-label" }, i18n.calendarCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.createdAt).format("YYYY-MM-DD"))),
+      tr(td({ class: "tribe-info-value", colspan: "4" }, a({ href: `/author/${encodeURIComponent(calendar.author)}`, class: "user-link" }, calendar.author))),
+      tr(td({ class: "tribe-info-label" }, i18n.calendarStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(calendar))),
+      calendar.deadline ? tr(td({ class: "tribe-info-label" }, i18n.calendarDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.deadline).format("YYYY-MM-DD HH:mm"))) : null
+    ),
+    div({ class: "tribe-side-actions" },
+      renderCalendarFavoriteToggle(calendar, shareUrl),
+      isAuthor
+        ? form({ method: "GET", action: "/calendars" },
+            input({ type: "hidden", name: "filter", value: "edit" }),
+            input({ type: "hidden", name: "id", value: calendar.rootId }),
+            button({ type: "submit", class: "tribe-action-btn" }, i18n.calendarUpdate || "Update")
+          )
+        : null,
+      isAuthor
+        ? form({ method: "POST", action: `/calendars/delete/${encodeURIComponent(calendar.rootId)}` },
+            button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDelete || "Delete")
+          )
+        : null,
+      !isAuthor
+        ? a({ href: `/pm?to=${encodeURIComponent(calendar.author)}`, class: "tribe-action-btn" }, "PM")
+        : null,
+      !isAuthor && !isParticipant
+        ? form({ method: "POST", action: `/calendars/join/${encodeURIComponent(calendar.rootId)}` },
+            button({ type: "submit", class: "create-button" }, i18n.calendarJoin || "Join Calendar")
+          )
+        : null,
+      !isAuthor && isParticipant
+        ? form({ method: "POST", action: `/calendars/leave/${encodeURIComponent(calendar.rootId)}` },
+            button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarLeave || "Leave Calendar")
+          )
+        : null
+    ),
+    tags
+  )
+
+  const minDate = now.add(1, "minute").format("YYYY-MM-DDTHH:mm")
+  const canAddDate = !calClosed && (calendar.status === "OPEN" || isAuthor)
+
+  const unifiedForm = canAddDate
+    ? div({ class: "div-center audio-form" },
+        h4(i18n.calendarAddEntry || "Add Entry"),
+        form({ method: "POST", action: `/calendars/add-date/${encodeURIComponent(calendar.rootId)}` },
+          span(i18n.calendarDateLabel || "Date"), br(),
+          input({ type: "datetime-local", name: "date", required: true, min: minDate }),
+          br(), br(),
+          span(i18n.calendarFormDescription || "Description"), br(),
+          input({ type: "text", name: "label", placeholder: i18n.calendarDatePlaceholder || "Describe this date..." }),
+          br(), br(),
+          renderIntervalBlock(),
+          br(),
+          isParticipant
+            ? [
+                span(i18n.calendarNoteLabel || "Note (optional)"), br(),
+                textarea({ name: "text", rows: "3", placeholder: i18n.calendarNotePlaceholder || "Add a note..." }),
+                br(), br()
+              ]
+            : null,
+          button({ type: "submit", class: "create-button" }, i18n.calendarAddEntry || "Add Entry")
+        )
+      )
+    : null
+
+  const monthLabel = currentMonth.format("MMMM YYYY")
+  const calNav = div({ class: "calendar-nav" },
+    a({ href: `${shareUrl}?month=${prevMonth}`, class: "filter-btn" }, i18n.calendarMonthPrev || "\u2190 Prev"),
+    span({ class: "tribe-info-label" }, monthLabel),
+    a({ href: `${shareUrl}?month=${nextMonth}`, class: "filter-btn" }, i18n.calendarMonthNext || "Next \u2192")
+  )
+
+  const grid = renderMonthGrid(year, month, datesMap, calendar.rootId)
+
+  const dayEntries = selectedDay
+    ? dates.filter(d => moment(d.date).format("YYYY-MM-DD") === selectedDay)
+    : []
+
+  const dayNotesSection = selectedDay
+    ? div({ class: "calendar-day-notes" },
+        h4(`${selectedDay}${dayEntries.length > 0 && dayEntries[0].label ? " \u2014 " + dayEntries[0].label : ""}`),
+        dayEntries.length === 0
+          ? p({ class: "no-content" }, i18n.calendarNoDates || "No dates added yet.")
+          : div(null, ...dayEntries.map(d => {
+              const notes = (notesByDate && notesByDate[d.key]) ? notesByDate[d.key] : []
+              return div({ class: "calendar-date-item" },
+                (isAuthor || String(d.author) === String(userId))
+                  ? form({ method: "POST", action: `/calendars/delete-date/${encodeURIComponent(d.key)}`, class: "calendar-date-delete" },
+                      input({ type: "hidden", name: "calendarId", value: calendar.rootId }),
+                      button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDeleteDate || "Delete Date")
+                    )
+                  : null,
+                div({ class: "calendar-date-item-header" },
+                  `${moment(d.date).format("YYYY-MM-DD HH:mm")}${d.label ? " \u2014 " + d.label : ""}`
+                ),
+                notes.length === 0
+                  ? p({ class: "no-content" }, i18n.calendarNoNotes || "No notes.")
+                  : div(null, ...notes.map(n => {
+                      const isSelf = String(n.author) === String(userId)
+                      const dateStr = moment(n.createdAt).format("YYYY/MM/DD HH:mm")
+                      const shortId = n.author ? "@" + n.author.slice(1, 9) + "\u2026" : "?"
+                      return div({ class: (isSelf ? "chat-message chat-message-self" : "chat-message") + " calendar-note-card" },
+                        isSelf
+                          ? form({ method: "POST", action: `/calendars/delete-note/${encodeURIComponent(n.key)}`, class: "calendar-note-delete" },
+                              input({ type: "hidden", name: "calendarId", value: calendar.rootId }),
+                              button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDeleteNote || "Delete")
+                            )
+                          : null,
+                        div({ class: "chat-message-meta" },
+                          span({ class: "chat-message-sender" },
+                            a({ href: `/author/${encodeURIComponent(n.author)}`, class: "user-link" }, shortId)
+                          ),
+                          span({ class: "chat-message-date" }, ` [ ${dateStr} ]`)
+                        ),
+                        span({ class: "chat-message-text" }, ...renderNoteText(n.text || ""))
+                      )
+                    }))
+              )
+            }))
+      )
+    : null
+
+  const calMain = div({ class: "tribe-main" },
+    calNav,
+    grid,
+    dayNotesSection,
+    unifiedForm
+  )
+
+  return template(
+    calendar.title || i18n.calendarsTitle || "Calendar",
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.calendarsTitle || "Calendars"),
+        p(i18n.calendarsDescription || "Discover and manage calendars.")
+      ),
+      renderModeButtons("all")
+    ),
+    section(div({ class: "tribe-details" }, calSide, calMain))
+  )
+}

+ 347 - 0
src/views/chats_view.js

@@ -0,0 +1,347 @@
+const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, td, 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 { renderUrl } = require("../backend/renderUrl")
+
+const userId = config.keys.id
+const safeArr = (v) => (Array.isArray(v) ? v : [])
+const safeText = (v) => String(v || "").trim()
+
+const CAT_BLOCK1 = ["GENERAL", "OASIS", "L.A.R.P.", "POLITICS", "TECH"]
+const CAT_BLOCK2 = ["SCIENCE", "MUSIC", "ART", "GAMING", "BOOKS", "FILMS"]
+const CAT_BLOCK3 = ["PHILOSOPHY", "SOCIETY", "PRIVACY", "CYBERWARFARE", "SURVIVALISM"]
+const ALL_CATS = [...CAT_BLOCK1, ...CAT_BLOCK2, ...CAT_BLOCK3]
+
+const catKey = (c) => "forumCat" + String(c || "").replace(/\./g, "").replace(/[\s-]/g, "").toUpperCase()
+const catLabel = (c) => i18n[catKey(c)] || c
+
+const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => {
+  if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
+  const s = String(value).trim()
+  if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
+  if (s.startsWith("&")) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs })
+  const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
+  if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, ...attrs })
+  return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
+}
+
+const buildReturnTo = (filter, params = {}) => {
+  const f = safeText(filter || "all")
+  const q = safeText(params.q || "")
+  const parts = [`filter=${encodeURIComponent(f)}`]
+  if (q) parts.push(`q=${encodeURIComponent(q)}`)
+  return `/chats?${parts.join("&")}`
+}
+
+const renderModeButtons = (currentFilter) =>
+  div({ class: "tribe-mode-buttons" },
+    ["all", "mine", "recent", "favorites", "open", "closed"].map(f =>
+      form({ method: "GET", action: "/chats" },
+        input({ type: "hidden", name: "filter", value: f }),
+        button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" }, i18n[`chatFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase())
+      )
+    ),
+    form({ method: "GET", action: "/chats" },
+      input({ type: "hidden", name: "filter", value: "create" }),
+      button({ type: "submit", class: "create-button" }, i18n.chatCreate)
+    )
+  )
+
+const renderChatCard = (chat, filter, params = {}) => {
+  const statusLabel = chat.status === "CLOSED" ? i18n.chatStatusClosed :
+    chat.status === "INVITE-ONLY" ? i18n.chatStatusInviteOnly : i18n.chatStatusOpen
+
+  return div({ class: "tribe-card" },
+    div({ class: "tribe-card-image-wrapper" },
+      a({ href: `/chats/${encodeURIComponent(chat.key)}` },
+        renderMediaBlob(chat.image, "/assets/images/default-avatar.png", { class: "tribe-card-hero-image" })
+      )
+    ),
+    div({ class: "tribe-card-body" },
+      h2({ class: "tribe-card-title" },
+        a({ href: `/chats/${encodeURIComponent(chat.key)}` }, "\uD83D\uDD12 " + (chat.title || i18n.chatUntitled))
+      ),
+      chat.description ? p({ class: "tribe-card-description" }, chat.description) : null,
+      br(),
+      table({ class: "tribe-info-table" },
+        tr(
+          td({ class: "tribe-info-label" }, i18n.chatStatus),
+          td({ class: "tribe-info-value", colspan: "3" }, statusLabel)
+        )
+      ),
+      div({ class: "tribe-card-members" },
+        span({ class: "tribe-members-count" }, `${i18n.chatParticipants}: ${safeArr(chat.members).length}`)
+      ),
+      div({ class: "visit-btn-centered" },
+        a({ href: `/chats/${encodeURIComponent(chat.key)}`, class: "filter-btn" }, i18n.chatVisitChat)
+      )
+    )
+  )
+}
+
+const renderChatForm = (filter, chat = {}, params = {}) => {
+  const isEdit = filter === "edit"
+  const returnTo = safeText(params.returnTo) || buildReturnTo("all")
+  const tribeId = safeText(params.tribeId || "")
+  return div({ class: "div-center audio-form" },
+    h2(isEdit ? i18n.chatUpdate : i18n.chatCreate),
+    form({ action: isEdit ? `/chats/update/${encodeURIComponent(chat.key || "")}` : "/chats/create", method: "POST", enctype: "multipart/form-data" },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
+      span(i18n.title || "Title"), br(),
+      input({ type: "text", name: "title", required: true, placeholder: i18n.chatTitlePlaceholder, value: chat.title || "" }), br(), br(),
+      span(i18n.chatDescription), br(),
+      textarea({ name: "description", rows: 4, placeholder: i18n.chatDescriptionPlaceholder }, chat.description || ""), br(), br(),
+      span(i18n.chatImageLabel || "Select an image file (.jpeg, .jpg, .png, .gif)"), br(),
+      input({ type: "file", name: "image", accept: "image/*" }), br(), br(),
+      span(i18n.chatCategory), br(),
+      select({ name: "category" },
+        option({ value: "" }, "\u2014"),
+        ALL_CATS.map(cat =>
+          option({ value: cat, ...(chat.category === cat ? { selected: true } : {}) }, catLabel(cat))
+        )
+      ), br(), br(),
+      span(i18n.chatStatusLabel || "Status"), br(),
+      select({ name: "status" },
+        option({ value: "OPEN", ...((!chat.status || chat.status === "OPEN") ? { selected: true } : {}) }, i18n.chatStatusOpen),
+        option({ value: "INVITE-ONLY", ...(chat.status === "INVITE-ONLY" ? { selected: true } : {}) }, i18n.chatStatusInviteOnly)
+      ), br(), br(),
+      span(i18n.shopTags || "Tags"), br(),
+      input({ type: "text", name: "tags", placeholder: i18n.chatTagsPlaceholder, value: safeArr(chat.tags).join(", ") }), br(), br(),
+      button({ type: "submit" }, isEdit ? i18n.chatUpdate : i18n.chatCreate)
+    )
+  )
+}
+
+const renderMessageText = (text) => {
+  if (!text) return span({ class: "chat-message-text" }, "")
+  const lines = String(text).split("\n")
+  const nodes = []
+  lines.forEach((line, idx) => {
+    const rendered = renderUrl(line)
+    nodes.push(...rendered)
+    if (idx < lines.length - 1) nodes.push(br())
+  })
+  return span({ class: "chat-message-text" }, ...nodes)
+}
+
+const renderMessage = (msg, chatAuthor) => {
+  const isAuthor = String(msg.author) === String(chatAuthor)
+  const isSelf = String(msg.author) === String(userId)
+  const dateStr = moment(msg.createdAt).format("YYYY/MM/DD HH:mm")
+  const shortId = msg.author ? "@" + msg.author.slice(1, 9) + "\u2026" : "?"
+  const authorLink = msg.author
+    ? a({ href: `/author/${encodeURIComponent(msg.author)}`, class: "user-link" }, shortId)
+    : span("?")
+
+  const imageNode = msg.image ? renderMediaBlob(msg.image, null, { class: "chat-message-image" }) : null
+
+  return div({ class: isSelf ? "chat-message chat-message-self" : isAuthor ? "chat-message chat-message-author" : "chat-message" },
+    div({ class: "chat-message-meta" },
+      span({ class: "chat-message-sender" }, authorLink),
+      span({ class: "chat-message-date" }, ` [ ${dateStr} ]`)
+    ),
+    imageNode ? div({ class: "chat-message-image-wrap" }, imageNode) : null,
+    renderMessageText(msg.text || "")
+  )
+}
+
+
+exports.chatsView = async (chats, filter, chatToEdit = null, params = {}) => {
+  const q = safeText(params.q || "")
+  const list = safeArr(chats)
+
+  const isForm = filter === "create" || filter === "edit"
+
+  const chatHeaderMap = {
+    all: i18n.chatsTitle,
+    mine: i18n.chatMineSectionTitle || "Your Chats",
+    recent: i18n.chatRecentTitle || "Recent Chats",
+    favorites: i18n.chatFavoritesTitle || "Favorites",
+    open: i18n.chatOpenTitle || "Open Chats",
+    closed: i18n.chatClosedTitle || "Closed Chats"
+  }
+  const headerText = chatHeaderMap[filter] || i18n.chatsTitle
+
+  return template(
+    i18n.chatsTitle,
+    section(
+      div({ class: "tags-header" },
+        h2(headerText),
+        p(i18n.modulesChatsDescription)
+      )
+    ),
+    section(renderModeButtons(filter)),
+    !isForm
+      ? section(
+          div({ class: "filters" },
+            form({ method: "GET", action: "/chats" },
+              input({ type: "hidden", name: "filter", value: filter }),
+              input({ type: "text", name: "q", placeholder: i18n.chatSearchPlaceholder, value: q }),
+              br(),
+              button({ type: "submit" }, i18n.search),
+              br()
+            )
+          )
+        )
+      : null,
+    section(
+      isForm
+        ? renderChatForm(filter, filter === "edit" ? (chatToEdit || {}) : {}, params)
+        : div({ class: "tribe-grid" },
+            list.length
+              ? list.map(chat => renderChatCard(chat, filter, { q }))
+              : p(i18n.chatNoItems)
+          )
+    )
+  )
+}
+
+exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
+  const q = safeText(params.q || "")
+  const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q })
+  const isAuthor = String(chat.author) === String(userId)
+  const isMember = safeArr(chat.members).includes(userId)
+  const fullShareUrl = `/chats/${encodeURIComponent(chat.key)}`
+
+  const statusLabel = chat.status === "CLOSED" ? i18n.chatStatusClosed :
+    chat.status === "INVITE-ONLY" ? i18n.chatStatusInviteOnly : i18n.chatStatusOpen
+
+  const chatSide = div({ class: "tribe-side" },
+    h2("\uD83D\uDD12 " + (chat.title || i18n.chatUntitled)),
+    renderMediaBlob(chat.image, "/assets/images/default-avatar.png", { class: "tribe-detail-image" }),
+    div({ class: "shop-share" },
+      span({ class: "tribe-info-label" }, `${i18n.chatShareUrl}: `),
+      input({ type: "text", value: fullShareUrl, readonly: true, class: "shop-share-input" })
+    ),
+    div({ class: "tribe-card-members" },
+      span({ class: "tribe-members-count" }, `${i18n.chatParticipants}: ${safeArr(chat.members).length}`)
+    ),
+    table({ class: "tribe-info-table" },
+      tr(
+        td({ class: "tribe-info-label" }, i18n.chatCreatedAt),
+        td({ class: "tribe-info-value", colspan: "3" }, moment(chat.createdAt).format("YYYY/MM/DD HH:mm"))
+      ),
+      tr(
+        td({ class: "tribe-info-value", colspan: "4" },
+          a({ href: `/author/${encodeURIComponent(chat.author)}`, class: "user-link" }, chat.author)
+        )
+      ),
+      tr(
+        td({ class: "tribe-info-label" }, i18n.chatStatus),
+        td({ class: "tribe-info-value", colspan: "3" }, statusLabel)
+      ),
+      chat.category ? tr(
+        td({ class: "tribe-info-label" }, i18n.chatCategoryLabel),
+        td({ class: "tribe-info-value", colspan: "3" }, catLabel(chat.category))
+      ) : null
+    ),
+    div({ class: "tribe-side-actions" },
+      isAuthor
+        ? form({ method: "POST", action: `/chats/generate-invite` },
+            input({ type: "hidden", name: "chatId", value: chat.key }),
+            input({ type: "hidden", name: "returnTo", value: returnTo }),
+            button({ type: "submit", class: "tribe-action-btn" }, i18n.chatGenerateCode)
+          )
+        : null,
+      form(
+        { method: "POST", action: chat.isFavorite ? `/chats/favorites/remove/${encodeURIComponent(chat.key)}` : `/chats/favorites/add/${encodeURIComponent(chat.key)}` },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
+        button({ type: "submit", class: "tribe-action-btn" }, chat.isFavorite ? i18n.chatRemoveFavorite : i18n.chatAddFavorite)
+      ),
+      chat.author && String(chat.author) !== String(userId)
+        ? form({ method: "GET", action: "/pm" },
+            input({ type: "hidden", name: "recipients", value: chat.author }),
+            button({ type: "submit", class: "tribe-action-btn" }, i18n.chatPM || i18n.privateMessage)
+          )
+        : null,
+      isAuthor
+        ? form({ method: "GET", action: `/chats/edit/${encodeURIComponent(chat.key)}` },
+            button({ type: "submit", class: "tribe-action-btn" }, i18n.chatUpdate)
+          )
+        : null,
+      isAuthor && chat.status !== "CLOSED"
+        ? form({ method: "POST", action: `/chats/close/${encodeURIComponent(chat.key)}` },
+            input({ type: "hidden", name: "returnTo", value: returnTo }),
+            button({ type: "submit", class: "tribe-action-btn" }, i18n.chatClose)
+          )
+        : null,
+      isAuthor
+        ? form({ method: "POST", action: `/chats/delete/${encodeURIComponent(chat.key)}` },
+            button({ type: "submit", class: "tribe-action-btn" }, i18n.chatDelete)
+          )
+        : null,
+      !isAuthor && isMember
+        ? form({ method: "POST", action: `/chats/leave/${encodeURIComponent(chat.key)}` },
+            input({ type: "hidden", name: "returnTo", value: returnTo }),
+            button({ type: "submit", class: "tribe-action-btn" }, i18n.chatLeave)
+          )
+        : null
+    ),
+    !isMember && chat.status !== "CLOSED"
+      ? div({ class: "chat-join-section" },
+          chat.status === "INVITE-ONLY"
+            ? div({ class: "chat-invite-form" },
+                form({ method: "POST", action: "/chats/join-code" },
+                  input({ type: "hidden", name: "returnTo", value: returnTo }),
+                  label(i18n.chatInviteCodeLabel), br(),
+                  input({ type: "text", name: "code", required: true, placeholder: i18n.chatInviteCode }), br(), br(),
+                  button({ type: "submit", class: "filter-btn" }, i18n.chatJoinByInvite)
+                )
+              )
+            : null,
+          chat.status === "OPEN"
+            ? form({ method: "POST", action: `/chats/join/${encodeURIComponent(chat.key)}` },
+                input({ type: "hidden", name: "returnTo", value: returnTo }),
+                button({ type: "submit", class: "create-button" }, i18n.chatStartChatting)
+              )
+            : null
+        )
+      : null,
+    safeArr(chat.tags).length
+      ? div({ class: "tribe-side-tags" },
+          safeArr(chat.tags).map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
+        )
+      : null
+  )
+
+  const msgList = safeArr(messages)
+  const canWrite = isMember && chat.status !== "CLOSED"
+
+  const chatMain = div({ class: "tribe-main chat-full-width" },
+    canWrite
+      ? div({ class: "chat-message-form" },
+          form({ method: "POST", action: `/chats/${encodeURIComponent(chat.key)}/message`, enctype: "multipart/form-data" },
+            input({ type: "hidden", name: "returnTo", value: `/chats/${encodeURIComponent(chat.key)}` }),
+            textarea({ name: "text", rows: 3, placeholder: i18n.chatMessagePlaceholder }), br(),
+            span(i18n.chatImageLabel || "Select an image file (.jpeg, .jpg, .png, .gif)"), br(),
+            input({ type: "file", name: "image", accept: "image/*" }), br(), br(),
+            button({ type: "submit", class: "filter-btn" }, i18n.chatSendMessage)
+          )
+        )
+      : null,
+    div({ class: "chat-messages-list" },
+      msgList.length
+        ? msgList.map(msg => renderMessage(msg, chat.author))
+        : p({ class: "chat-no-messages" }, i18n.chatNoMessages)
+    )
+  )
+
+  return template(
+    chat.title || i18n.chatUntitled,
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.chatsTitle),
+        p(i18n.modulesChatsDescription)
+      ),
+      renderModeButtons(filter || "all")
+    ),
+    section(
+      div({ class: "tribe-details" },
+        chatSide,
+        chatMain
+      )
+    )
+  )
+}

+ 12 - 0
src/views/favorites_view.js

@@ -135,6 +135,18 @@ exports.favoritesView = async (items, filter = "all", counts = {}) => {
             { type: "submit", name: "filter", value: "maps", class: filter === "maps" ? "filter-btn active" : "filter-btn" },
             `${i18n.favoritesFilterMaps} (${c.maps || 0})`
           ),
+          button(
+            { type: "submit", name: "filter", value: "pads", class: filter === "pads" ? "filter-btn active" : "filter-btn" },
+            `${i18n.favoritesFilterPads || "PADS"} (${c.pads || 0})`
+          ),
+          button(
+            { type: "submit", name: "filter", value: "chats", class: filter === "chats" ? "filter-btn active" : "filter-btn" },
+            `${i18n.favoritesFilterChats || "CHATS"} (${c.chats || 0})`
+          ),
+          button(
+            { type: "submit", name: "filter", value: "calendars", class: filter === "calendars" ? "filter-btn active" : "filter-btn" },
+            `${i18n.favoritesFilterCalendars || "CALENDARS"} (${c.calendars || 0})`
+          ),
           button(
             { type: "submit", name: "filter", value: "videos", class: filter === "videos" ? "filter-btn active" : "filter-btn" },
             `${i18n.favoritesFilterVideos} (${c.videos || 0})`

+ 149 - 0
src/views/games_view.js

@@ -0,0 +1,149 @@
+const { div, h2, h3, p, section, form, input, button, a, img, table, tr, td, th, span, iframe } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+const moment = require("../server/node_modules/moment");
+
+const getGames = () => [
+  { id: 'cocoland', title: () => i18n.gamesCocolandTitle, desc: () => i18n.gamesCocolandDesc },
+  { id: 'ecoinflow', title: () => i18n.gamesTheFlowTitle, desc: () => i18n.gamesTheFlowDesc },
+  { id: 'audiopendulum', title: () => i18n.gamesAudioPendulumTitle, desc: () => i18n.gamesAudioPendulumDesc },
+  { id: 'spaceinvaders', title: () => i18n.gamesSpaceInvadersTitle, desc: () => i18n.gamesSpaceInvadersDesc },
+  { id: 'arkanoid', title: () => i18n.gamesArkanoidTitle, desc: () => i18n.gamesArkanoidDesc },
+  { id: 'pingpong', title: () => i18n.gamesPingPongTitle, desc: () => i18n.gamesPingPongDesc },
+  { id: 'asteroids', title: () => i18n.gamesAsteroidsTitle, desc: () => i18n.gamesAsteroidsDesc },
+  { id: 'tiktaktoe', title: () => i18n.gamesTikTakToeTitle, desc: () => i18n.gamesTikTakToeDesc },
+  { id: 'flipflop', title: () => i18n.gamesFlipFlopTitle, desc: () => i18n.gamesFlipFlopDesc },
+  { id: '8ball', title: () => i18n.games8BallTitle, desc: () => i18n.games8BallDesc },
+  { id: 'artillery', title: () => i18n.gamesArtilleryTitle, desc: () => i18n.gamesArtilleryDesc },
+  { id: 'labyrinth', title: () => i18n.gamesLabyrinthTitle, desc: () => i18n.gamesLabyrinthDesc },
+  { id: 'cocoman', title: () => i18n.gamesCocomanTitle, desc: () => i18n.gamesCocomanDesc },
+  { id: 'tetris', title: () => i18n.gamesTetrisTitle, desc: () => i18n.gamesTetrisDesc }
+];
+
+const shortId = (feedId) => feedId ? '@' + feedId.slice(1, 9) + '...' : '?';
+
+const renderHallOfFame = (hall) => {
+  const games = getGames();
+  const gamesWithScores = games.filter(g => hall[g.id] && hall[g.id].length > 0);
+  if (gamesWithScores.length === 0) {
+    return p({ class: 'no-content' }, i18n.gamesNoScores || 'No scores yet.');
+  }
+  return div({ class: 'games-scoring-list' },
+    gamesWithScores.map(game =>
+      div({ class: 'game-scoring-section' },
+        div({ class: 'game-scoring-header' },
+          img({ src: `/game-assets/${game.id}/thumbnail.svg`, alt: game.title(), class: 'game-scoring-thumb', loading: 'lazy' }),
+          div({ class: 'game-scoring-info' },
+            h3({ class: 'game-card-title' }, game.title()),
+            p({ class: 'game-card-desc game-desc-yellow' }, game.desc())
+          )
+        ),
+        table({ class: 'hall-of-fame-table' },
+          tr(th('#'), th(i18n.gamesHallPlayer), th(i18n.gamesHallScore), th(i18n.gamesHallDate || 'Date')),
+          ...hall[game.id].map((entry, idx) =>
+            tr(
+              td(String(idx + 1)),
+              td(a({ href: `/author/${encodeURIComponent(entry.author)}`, class: 'user-link' }, entry.author)),
+              td({ class: idx === 0 ? 'score-first' : '' }, String(entry.score)),
+              td(entry.ts ? moment(entry.ts).format('YYYY-MM-DD') : '\u2014')
+            )
+          )
+        )
+      )
+    )
+  );
+};
+
+const VALID_GAME_IDS = new Set(['cocoland','ecoinflow','audiopendulum','spaceinvaders','arkanoid','pingpong','asteroids','tiktaktoe','flipflop','8ball','artillery','labyrinth','cocoman','tetris']);
+
+exports.gameShellView = (name) => {
+  if (!VALID_GAME_IDS.has(name)) {
+    return template(i18n.gamesTitle, section(p(i18n.notFound || 'Not found')));
+  }
+  const game = getGames().find(g => g.id === name);
+  const filterBar = div({ class: 'filter-group' },
+    form({ method: 'GET', action: '/games' },
+      input({ type: 'hidden', name: 'filter', value: 'all' }),
+      button({ type: 'submit', class: 'filter-btn' }, i18n.gamesFilterAll)
+    ),
+    form({ method: 'GET', action: '/games' },
+      input({ type: 'hidden', name: 'filter', value: 'scoring' }),
+      button({ type: 'submit', class: 'filter-btn' }, i18n.gamesFilterScoring)
+    )
+  );
+  return template(
+    game ? game.title() : name,
+    section(
+      div({ class: 'tags-header' },
+        h2(i18n.gamesTitle),
+        p(i18n.gamesDescription || 'Discover and play some mini-games in your network.')
+      ),
+      filterBar
+    ),
+    section({ class: 'game-shell-section' },
+      iframe({
+        src: `/game-assets/${name}/index.html`,
+        class: `game-iframe game-iframe-${name}`,
+        scrolling: 'no',
+        allowfullscreen: true
+      })
+    )
+  );
+};
+
+exports.gamesView = (filter = 'all', hall = null) => {
+  const games = getGames();
+
+  const filterBar = div({ class: 'filter-group' },
+    form({ method: 'GET', action: '/games' },
+      input({ type: 'hidden', name: 'filter', value: 'all' }),
+      button({ type: 'submit', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' },
+        i18n.gamesFilterAll
+      )
+    ),
+    form({ method: 'GET', action: '/games' },
+      input({ type: 'hidden', name: 'filter', value: 'scoring' }),
+      button({ type: 'submit', class: filter === 'scoring' ? 'filter-btn active' : 'filter-btn' },
+        i18n.gamesFilterScoring
+      )
+    )
+  );
+
+  const content = filter === 'scoring' && hall
+    ? renderHallOfFame(hall)
+    : div({ class: 'games-single-col' },
+        games.map(game => {
+          const topScore = hall && hall[game.id] && hall[game.id].length > 0 ? hall[game.id][0] : null;
+          return div({ class: 'game-row' },
+            div({ class: 'game-row-media' },
+              img({ src: `/game-assets/${game.id}/thumbnail.svg`, alt: game.title(), loading: 'lazy' })
+            ),
+            div({ class: 'game-row-body' },
+              h2({ class: 'game-card-title' }, game.title()),
+              p({ class: 'game-card-desc game-desc-yellow' }, game.desc()),
+              topScore
+                ? p({ class: 'game-top-score' },
+                    a({ href: `/author/${encodeURIComponent(topScore.author)}`, class: 'user-link' }, topScore.author),
+                    span({ class: 'game-new-record-label' }, ' - ' + (i18n.gamesNewRecord || 'New Record') + ': '),
+                    String(topScore.score)
+                  )
+                : null
+            ),
+            div({ class: 'game-row-actions' },
+              a({ href: `/games/${game.id}`, class: 'filter-btn' }, i18n.gamesPlayButton)
+            )
+          );
+        })
+      );
+
+  return template(
+    i18n.gamesTitle,
+    section(
+      div({ class: 'tags-header' },
+        h2(i18n.gamesTitle),
+        p(filter === 'scoring' ? i18n.gamesHallOfFame : (i18n.gamesDescription || 'Discover and play some mini-games in your network.'))
+      ),
+      filterBar
+    ),
+    section(content)
+  );
+};

+ 16 - 2
src/views/inhabitants_view.js

@@ -91,7 +91,13 @@ const renderInhabitantCard = (user, filter, currentUserId) => {
       ...lastActivityBadge(user, isMe),
       div({ class: 'inhabitant-karma-ubi' },
         span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(typeof user.karmaScore === 'number' ? user.karmaScore : 0))),
-        span({ class: 'ubi-line' }, `${i18n.bankingFutureUBI}: `, strong(`${Number(user.estimatedUBI || 0).toFixed(6)} ECO`))
+        span({ class: 'ubi-line' }, `${i18n.bankUbiThisMonth}: `, strong(`${Number(user.estimatedUBI || 0).toFixed(6)} ECO`)),
+        span({ class: 'ubi-line' }, `${i18n.bankUbiLastClaimed}: `,
+          user.lastClaimedDate
+            ? a({ href: '/transfers?filter=ubi', class: 'user-link' }, new Date(user.lastClaimedDate).toLocaleDateString())
+            : strong(i18n.bankUbiNeverClaimed)
+        ),
+        span({ class: 'ubi-line' }, `${i18n.bankUbiTotalClaimed}: `, strong(`${Number(user.totalClaimed || 0).toFixed(6)} ECO`))
       )
     ),
     div({ class: 'inhabitant-details' },
@@ -267,6 +273,8 @@ exports.inhabitantsProfileView = (payload, currentUserId) => {
   const title = i18n.inhabitantProfileTitle || i18n.inhabitantviewDetails;
   const karmaScore = typeof safe.karmaScore === 'number' ? safe.karmaScore : 0;
   const estimatedUBI = Number(safe.estimatedUBI || 0);
+  const lastClaimedDate = safe.lastClaimedDate || null;
+  const totalClaimed = Number(safe.totalClaimed || 0);
 
   const providedBucket = typeof safe.lastActivityBucket === 'string' ? safe.lastActivityBucket : null;
   const dotClass = providedBucket === 'green' ? 'green' : providedBucket === 'orange' ? 'orange' : 'red';
@@ -298,7 +306,13 @@ exports.inhabitantsProfileView = (payload, currentUserId) => {
           ...lastActivityBadge({ lastActivityBucket: dotClass, deviceSource: safe.deviceSource }, isMe),
           div({ class: 'inhabitant-karma-ubi' },
             span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(karmaScore))),
-            span({ class: 'ubi-line' }, `${i18n.bankingFutureUBI}: `, strong(`${estimatedUBI.toFixed(6)} ECO`))
+            span({ class: 'ubi-line' }, `${i18n.bankUbiThisMonth}: `, strong(`${estimatedUBI.toFixed(6)} ECO`)),
+            span({ class: 'ubi-line' }, `${i18n.bankUbiLastClaimed}: `,
+              lastClaimedDate
+                ? a({ href: '/transfers?filter=ubi', class: 'user-link' }, new Date(lastClaimedDate).toLocaleDateString())
+                : strong(i18n.bankUbiNeverClaimed)
+            ),
+            span({ class: 'ubi-line' }, `${i18n.bankUbiTotalClaimed}: `, strong(`${totalClaimed.toFixed(6)} ECO`))
           ),
           (!isMe && (id || viewedId))
             ? form(

+ 65 - 1
src/views/main_views.js

@@ -341,6 +341,21 @@ const renderMapsLink = () => {
   return "";
 };
 
+const renderChatsLink = () => {
+  const chatsMod = getConfig().modules.chatsMod === "on";
+  if (chatsMod) {
+    return [
+      navLink({
+        href: "/chats",
+        emoji: "ꖒ",
+        text: i18n.chatsTitle,
+        class: "chats-link enabled"
+      })
+    ];
+  }
+  return "";
+};
+
 const renderVideosLink = () => {
   const videosMod = getConfig().modules.videosMod === "on";
   if (videosMod) {
@@ -571,6 +586,20 @@ const renderOpinionsLink = () => {
     : "";
 };
 
+const renderPadsLink = () => {
+  const padsMod = getConfig().modules.padsMod === "on";
+  return padsMod
+    ? [
+        navLink({
+          href: "/pads",
+          emoji: "ꔗ",
+          text: i18n.padsTitle,
+          class: "pads-link enabled"
+        })
+      ]
+    : "";
+};
+
 const renderTransfersLink = () => {
   const transfersMod = getConfig().modules.transfersMod === "on";
   return transfersMod
@@ -611,6 +640,13 @@ const renderPixeliaLink = () => {
     : "";
 };
 
+const renderGamesLink = () => {
+  const gamesMod = getConfig().modules.gamesMod === "on";
+  return gamesMod
+    ? [navLink({ href: "/games", emoji: "ꕇ", text: i18n.gamesTitle, class: "games-link enabled" })]
+    : "";
+};
+
 const renderForumLink = () => {
   const forumMod = getConfig().modules.forumMod === "on";
   return forumMod
@@ -681,6 +717,20 @@ const renderEventsLink = () => {
     : "";
 };
 
+const renderCalendarsLink = () => {
+  const calendarsMod = getConfig().modules.calendarsMod === "on";
+  return calendarsMod
+    ? [
+        navLink({
+          href: "/calendars",
+          emoji: "\uA5AF",
+          text: i18n.calendarsTitle || "Calendars",
+          class: "calendars-link enabled"
+        })
+      ]
+    : "";
+};
+
 const renderTasksLink = () => {
   const tasksMod = getConfig().modules.tasksMod === "on";
   return tasksMod
@@ -852,6 +902,7 @@ const template = (titlePrefix, ...elements) => {
                 },
                 renderVotationsLink(),
                 renderEventsLink(),
+                renderCalendarsLink(),
                 renderTasksLink(),
                 renderReportsLink()
               ),
@@ -896,8 +947,10 @@ const template = (titlePrefix, ...elements) => {
                 }),
                 renderTrendingLink(),
                 renderOpinionsLink(),
+                renderPadsLink(),
                 renderForumLink(),
                 renderMapsLink(),
+                renderChatsLink(),
                 renderInvitesLink(),
                 navLink({
                   href: "/peers",
@@ -912,6 +965,7 @@ const template = (titlePrefix, ...elements) => {
                   title: i18n.menuCreative
                 },
                 renderFeedLink(),
+                renderGamesLink(),
                 renderPixeliaLink()
               ),
               navGroup(
@@ -1559,6 +1613,8 @@ exports.authorView = ({
   ecoAddress,
   karmaScore = 0,
   estimatedUBI = 0,
+  lastClaimedDate = null,
+  totalClaimed = 0,
   lastActivityBucket
 }) => {
   const linkUrl = `/author/${encodeURIComponent(feedId)}`;
@@ -1610,7 +1666,13 @@ exports.authorView = ({
         ...lastActivityBadge({ lastActivityBucket: bucket }, true),
         div({ class: "inhabitant-karma-ubi" },
           span({ class: "karma-line" }, `${i18n.bankingUserEngagementScore}: `, strong(karmaScore !== undefined ? karmaScore : 0)),
-          span({ class: "ubi-line" }, `${i18n.bankingFutureUBI}: `, strong(`${Number(estimatedUBI || 0).toFixed(6)} ECO`))
+          span({ class: "ubi-line" }, `${i18n.bankUbiThisMonth}: `, strong(`${Number(estimatedUBI || 0).toFixed(6)} ECO`)),
+          span({ class: "ubi-line" }, `${i18n.bankUbiLastClaimed}: `,
+            lastClaimedDate
+              ? a({ href: "/transfers?filter=ubi", class: "user-link" }, new Date(lastClaimedDate).toLocaleDateString())
+              : strong(i18n.bankUbiNeverClaimed)
+          ),
+          span({ class: "ubi-line" }, `${i18n.bankUbiTotalClaimed}: `, strong(`${Number(totalClaimed || 0).toFixed(6)} ECO`))
         ),
         div({ class: "eco-wallet" },
           p(`${i18n.statsEcoWalletLabel || 'ECOin Wallet'}: `,
@@ -2077,6 +2139,8 @@ exports.privateView = async (messagesInput, filter) => {
       .replace(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="job-link" href="${hrefFor.job(id)}">${match}</a>`)
       .replace(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="project-link" href="${hrefFor.project(id)}">${match}</a>`)
       .replace(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="market-link" href="${hrefFor.market(id)}">${match}</a>`)
+      .replace(/\/calendars\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="calendar-link" href="/calendars/${encodeURIComponent(id)}">${match}</a>`)
+      .replace(/(https?:\/\/[^\s<"]+)/g, (match) => `<a href="${match}" target="_blank" rel="noopener noreferrer">${match}</a>`)
   }
 
   const threads = {}

+ 7 - 3
src/views/modules_view.js

@@ -10,6 +10,8 @@ const modulesView = () => {
     { name: 'audios', label: i18n.modulesAudiosLabel, description: i18n.modulesAudiosDescription },
     { name: 'banking', label: i18n.modulesBankingLabel, description: i18n.modulesBankingDescription },
     { name: 'bookmarks', label: i18n.modulesBookmarksLabel, description: i18n.modulesBookmarksDescription },
+    { name: 'calendars', label: i18n.modulesCalendarsLabel, description: i18n.modulesCalendarsDescription },
+    { name: 'chats', label: i18n.modulesChatsLabel, description: i18n.modulesChatsDescription },
     { name: 'cipher', label: i18n.modulesCipherLabel, description: i18n.modulesCipherDescription },
     { name: 'courts', label: i18n.modulesCourtsLabel, description: i18n.modulesCourtsDescription },
     { name: 'docs', label: i18n.modulesDocsLabel, description: i18n.modulesDocsDescription },
@@ -17,6 +19,7 @@ const modulesView = () => {
     { name: 'favorites', label: i18n.modulesFavoritesLabel, description: i18n.modulesFavoritesDescription },
     { name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription },
     { name: 'forum', label: i18n.modulesForumLabel, description: i18n.modulesForumDescription },
+    { name: 'games', label: i18n.modulesGamesLabel, description: i18n.modulesGamesDescription },
     { name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription },
     { name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
     { name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
@@ -26,6 +29,7 @@ const modulesView = () => {
     { 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: 'pads', label: i18n.modulesPadsLabel, description: i18n.modulesPadsDescription },
     { name: 'parliament', label: i18n.modulesParliamentLabel, description: i18n.modulesParliamentDescription },
     { name: 'pixelia', label: i18n.modulesPixeliaLabel, description: i18n.modulesPixeliaDescription },
     { name: 'projects', label: i18n.modulesProjectsLabel, description: i18n.modulesProjectsDescription },
@@ -74,9 +78,9 @@ const modulesView = () => {
   );
 
   const PRESETS = {
-    minimal: ['feed', 'forum', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'],
-    social: ['agenda', 'audios', 'bookmarks', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'images', 'invites', 'legacy', 'maps', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
-    economy: ['agenda', 'audios', 'bookmarks', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'images', 'invites', 'legacy', 'maps', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'],
+    minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'],
+    social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
+    economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'],
     full: modules.map(m => m.name)
   };
 

+ 348 - 0
src/views/pads_view.js

@@ -0,0 +1,348 @@
+const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, 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
+
+const PAD_COLOR_CLASSES = ["pad-author-color-0","pad-author-color-1","pad-author-color-2","pad-author-color-3","pad-author-color-4","pad-author-color-5","pad-author-color-6","pad-author-color-7","pad-author-color-8","pad-author-color-9"]
+const memberColorClass = (members, feedId) => {
+  const idx = members.indexOf(feedId)
+  return idx >= 0 ? PAD_COLOR_CLASSES[idx % PAD_COLOR_CLASSES.length] : "pad-author-color-none"
+}
+
+const sliceChunksByOffset = (chunks, from, to) => {
+  const out = []
+  let pos = 0
+  for (const c of chunks) {
+    const cStart = pos
+    const cEnd = pos + c.text.length
+    if (cEnd <= from) { pos = cEnd; continue }
+    if (cStart >= to) break
+    const sliceStart = Math.max(0, from - cStart)
+    const sliceEnd = Math.min(c.text.length, to - cStart)
+    if (sliceEnd > sliceStart) out.push({ text: c.text.slice(sliceStart, sliceEnd), author: c.author })
+    pos = cEnd
+  }
+  return out
+}
+
+const mergeAdjacent = (chunks) => {
+  const out = []
+  for (const c of chunks) {
+    if (!c.text) continue
+    if (out.length > 0 && out[out.length - 1].author === c.author) {
+      out[out.length - 1].text += c.text
+    } else {
+      out.push({ ...c })
+    }
+  }
+  return out
+}
+
+const computeAttributedChunks = (entries) => {
+  if (!entries || entries.length === 0) return []
+  let chunks = [{ text: entries[0].text || "", author: entries[0].author }]
+  for (let i = 1; i < entries.length; i++) {
+    const prev = entries[i - 1].text || ""
+    const curr = entries[i].text || ""
+    const author = entries[i].author
+    let start = 0
+    const maxStart = Math.min(prev.length, curr.length)
+    while (start < maxStart && prev.charCodeAt(start) === curr.charCodeAt(start)) start++
+    let endPrev = prev.length
+    let endCurr = curr.length
+    while (endPrev > start && endCurr > start && prev.charCodeAt(endPrev - 1) === curr.charCodeAt(endCurr - 1)) {
+      endPrev--
+      endCurr--
+    }
+    const inserted = curr.slice(start, endCurr)
+    const headChunks = sliceChunksByOffset(chunks, 0, start)
+    const tailChunks = sliceChunksByOffset(chunks, endPrev, prev.length)
+    const middle = inserted ? [{ text: inserted, author }] : []
+    chunks = mergeAdjacent([...headChunks, ...middle, ...tailChunks])
+  }
+  return chunks
+}
+
+const renderStatus = (status, isClosed) => {
+  if (isClosed) return span({ class: "pad-status-closed" }, i18n.padStatusClosed || "CLOSED")
+  if (status === "INVITE-ONLY") return span({ class: "pad-status-invite" }, i18n.padStatusInviteOnly || "INVITE-ONLY")
+  return span({ class: "pad-status-open" }, i18n.padStatusOpen || "OPEN")
+}
+
+const renderModeButtons = (currentFilter) =>
+  div({ class: "tribe-mode-buttons" },
+    ["all", "mine", "recent", "open", "closed"].map(f =>
+      form({ method: "GET", action: "/pads" },
+        input({ type: "hidden", name: "filter", value: f }),
+        button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" },
+          i18n[`padFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase())
+      )
+    ),
+    form({ method: "GET", action: "/pads" },
+      input({ type: "hidden", name: "filter", value: "create" }),
+      button({ type: "submit", class: "create-button" }, i18n.padCreate || "Create Pad")
+    )
+  )
+
+
+const renderPadCard = (pad, filter) => {
+  const returnTo = `/pads?filter=${encodeURIComponent(filter || "all")}`
+  return div({ class: "tribe-card" },
+    div({ class: "tribe-card-body" },
+      h2({ class: "tribe-card-title" },
+        span(null, "\uD83D\uDD12 "),
+        a({ href: `/pads/${encodeURIComponent(pad.rootId)}` }, pad.title || "\u2014")
+      ),
+      table({ class: "tribe-info-table" },
+        tr(td(i18n.padStatusLabel || "Status"), td(renderStatus(pad.status, pad.isClosed))),
+        tr(td(i18n.padDeadlineLabel || "Deadline"), td(pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
+      ),
+      div({ class: "tribe-card-members" },
+        span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
+      ),
+      div({ class: "visit-btn-centered" },
+        a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" }, i18n.padVisitPad || "Visit Pad")
+      )
+    )
+  )
+}
+
+const renderCreateForm = (padToEdit, params) => {
+  const tribeId = (params && params.tribeId) || ""
+  return div({ class: "div-center audio-form" },
+    h2(padToEdit ? (i18n.padUpdateSectionTitle || "Update Pad") : (i18n.padCreateSectionTitle || "Create New Pad")),
+    form({
+      method: "POST",
+      action: padToEdit ? `/pads/update/${encodeURIComponent(padToEdit.rootId)}` : "/pads/create"
+    },
+      tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
+      span(i18n.padTitleLabel || "Title"), require("../server/node_modules/hyperaxe").br(),
+      input({ type: "text", name: "title", value: padToEdit ? padToEdit.title : "", placeholder: i18n.padTitlePlaceholder || "Enter pad title...", required: true }),
+      require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
+      span(i18n.padStatusLabel || "Status"), require("../server/node_modules/hyperaxe").br(),
+      select({ name: "status" },
+        ["OPEN", "INVITE-ONLY"].map(s =>
+          option({ value: s, ...(padToEdit && padToEdit.status === s ? { selected: true } : {}) }, s)
+        )
+      ),
+      require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
+      span(i18n.padDeadlineLabel || "Deadline"), require("../server/node_modules/hyperaxe").br(),
+      input({
+        type: "datetime-local",
+        name: "deadline",
+        value: padToEdit && padToEdit.deadline ? moment(padToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "",
+        min: moment().format("YYYY-MM-DDTHH:mm")
+      }),
+      require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
+      span(i18n.padTagsLabel || "Tags"), require("../server/node_modules/hyperaxe").br(),
+      input({ type: "text", name: "tags", value: padToEdit ? padToEdit.tags.join(", ") : "", placeholder: i18n.padTagsPlaceholder || "tag1, tag2, ..." }),
+      require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
+      button({ type: "submit", class: "create-button" }, padToEdit ? (i18n.padUpdate || "Update Pad") : (i18n.padCreate || "Create Pad"))
+    )
+  )
+}
+
+exports.padsView = async (pads, filter, padToEdit, params) => {
+  const q = String((params && params.q) || "").trim()
+  const isForm = filter === "create" || filter === "edit"
+  const headerMap = {
+    all: i18n.padAllSectionTitle || "Pads",
+    mine: i18n.padMineSectionTitle || "Your Pads",
+    recent: i18n.padRecentSectionTitle || "Recent Pads",
+    open: i18n.padOpenSectionTitle || "Open Pads",
+    closed: i18n.padClosedSectionTitle || "Closed Pads"
+  }
+  const headerText = headerMap[filter] || headerMap.all
+
+  const filteredPads = q
+    ? pads.filter(pd => String(pd.title || "").toLowerCase().includes(q.toLowerCase()))
+    : pads
+
+  const body = div({ class: "main-column" },
+    div({ class: "tags-header" },
+      h2(headerText),
+      p(i18n.padsDescription || "Manage collaborative encrypted text editors in your network.")
+    ),
+    renderModeButtons(filter),
+    !isForm
+      ? div({ class: "filters" },
+          form({ method: "GET", action: "/pads" },
+            input({ type: "hidden", name: "filter", value: filter }),
+            input({ type: "text", name: "q", placeholder: i18n.padSearchPlaceholder || "Search pads...", value: q }),
+            br(),
+            button({ type: "submit" }, i18n.search),
+            br()
+          )
+        )
+      : null,
+    isForm
+      ? renderCreateForm(padToEdit, params)
+      : div(
+          filteredPads.length === 0
+            ? p({ class: "no-content" }, i18n.padsNoItems || "No pads found.")
+            : div({ class: "tribe-grid" }, ...filteredPads.map(pd => renderPadCard(pd, filter)))
+        )
+  )
+
+  return template(i18n.padsTitle || "Pads", body)
+}
+
+exports.singlePadView = async (pad, entries, params) => {
+  const isAuthor = String(pad.author) === String(userId)
+  const isMember = pad.members.includes(userId)
+  const padClosed = pad.isClosed
+  const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
+
+  const shareUrl = `/pads/${encodeURIComponent(pad.rootId)}`
+
+  const tags = Array.isArray(pad.tags) && pad.tags.length > 0
+    ? div({ class: "tribe-side-tags" }, ...pad.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t}`)))
+    : null
+
+  const padSide = div({ class: "tribe-side" },
+    h2(null,
+      span(null, "\uD83D\uDD12 "),
+      pad.title || "\u2014"
+    ),
+    div({ class: "shop-share" },
+      span({ class: "tribe-info-label" }, i18n.padShareUrl || "Share URL"),
+      input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" })
+    ),
+    div({ class: "tribe-card-members" },
+      span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
+    ),
+    table({ class: "tribe-info-table" },
+      tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))),
+      tr(td({ class: "tribe-info-value", colspan: "4" }, a({ href: `/author/${encodeURIComponent(pad.author)}`, class: "user-link" }, pad.author))),
+      tr(td({ class: "tribe-info-label" }, i18n.padStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(pad.status, padClosed))),
+      tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
+    ),
+    div({ class: "tribe-side-actions" },
+      isAuthor
+        ? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
+            button({ type: "submit", class: "tribe-action-btn" }, i18n.padGenerateCode || "Generate Code")
+          )
+        : null,
+      form(
+        { method: "POST", action: pad.isFavorite ? `/pads/favorites/remove/${encodeURIComponent(pad.key)}` : `/pads/favorites/add/${encodeURIComponent(pad.key)}` },
+        returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
+        button({ type: "submit", class: "tribe-action-btn" }, pad.isFavorite ? (i18n.padRemoveFavorite || "Remove Favorite") : (i18n.padAddFavorite || "Add Favorite"))
+      ),
+      !isAuthor
+        ? a({ href: `/pm?to=${encodeURIComponent(pad.author)}`, class: "tribe-action-btn" }, "PM")
+        : null,
+      isAuthor
+        ? form({ method: "GET", action: "/pads" },
+            input({ type: "hidden", name: "filter", value: "edit" }),
+            input({ type: "hidden", name: "id", value: pad.rootId }),
+            button({ type: "submit", class: "tribe-action-btn" }, i18n.padUpdate || "Update")
+          )
+        : null,
+      isAuthor && pad.status !== "CLOSED" && !padClosed
+        ? form({ method: "POST", action: `/pads/close/${encodeURIComponent(pad.rootId)}` },
+            button({ type: "submit", class: "tribe-action-btn" }, i18n.padClose || "Close Pad")
+          )
+        : null,
+      isAuthor
+        ? form({ method: "POST", action: `/pads/delete/${encodeURIComponent(pad.rootId)}` },
+            button({ type: "submit", class: "tribe-action-btn" }, i18n.padDelete || "Delete")
+          )
+        : null
+    ),
+    !isAuthor && pad.status === "INVITE-ONLY" && !isMember
+      ? div({ class: "pad-invite-section" },
+          form({ method: "POST", action: "/pads/join-code" },
+            label(i18n.padInviteCodeLabel || "Invite Code"),
+            input({ type: "text", name: "code", placeholder: i18n.padInviteCodePlaceholder || "Enter invite code..." }),
+            button({ type: "submit", class: "filter-btn" }, i18n.padValidateInvite || "Validate")
+          )
+        )
+      : null,
+    (!isAuthor && (pad.status === "OPEN" || isMember) && !padClosed)
+      ? form({ method: "POST", action: `/pads/join/${encodeURIComponent(pad.rootId)}` },
+          button({ type: "submit", class: "create-button" }, i18n.padStartEditing || "START EDITING!")
+        )
+      : null,
+    tags,
+    params.inviteCode
+      ? div({ class: "pad-invite-section" },
+          p(i18n.padInviteGenerated || "Invite Code Generated"),
+          input({ type: "text", readonly: true, value: params.inviteCode })
+        )
+      : null
+  )
+
+  let canonicalEntries = entries
+  if (params.selectedVersion) {
+    const idx = entries.findIndex(e => e.key === params.selectedVersion.key)
+    if (idx >= 0) canonicalEntries = entries.slice(0, idx + 1)
+  }
+  const chunks = computeAttributedChunks(canonicalEntries)
+  const lastEntry = canonicalEntries.length > 0 ? canonicalEntries[canonicalEntries.length - 1] : null
+  const currentText = lastEntry ? lastEntry.text : ""
+
+  const coloredView = chunks.length > 0
+    ? div({ class: "pad-readonly-colored" },
+        ...chunks.map(c =>
+          span({ class: "pad-author-span " + memberColorClass(pad.members, c.author) }, c.text)
+        )
+      )
+    : p(i18n.padNoEntries || "No entries yet.")
+
+  const editorArea = isMember && !padClosed && !params.selectedVersion
+    ? div({ class: "pad-editor-area" },
+        coloredView,
+        form({ method: "POST", action: `/pads/entry/${encodeURIComponent(pad.rootId)}` },
+          textarea({ name: "text", rows: "12", class: "pad-editor-white", placeholder: i18n.padEditorPlaceholder || "Start writing..." }, currentText),
+          button({ type: "submit", class: "create-button" }, i18n.padSubmitEntry || "Submit")
+        )
+      )
+    : div({ class: "pad-editor-area" },
+        params.selectedVersion
+          ? div({ class: "pad-viewer-back" },
+              a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" },
+                "\u2190 " + (i18n.padBackToEditor || "Back to editor"))
+            )
+          : null,
+        coloredView
+      )
+
+  const versionList = entries.length > 0
+    ? div({ class: "pad-version-list" },
+        h4(i18n.padVersionHistory || "Version History"),
+        ...entries.slice().reverse().map((e, idx) =>
+          div({ class: "pad-version-item" },
+            span({ class: "pad-version-date" }, moment(e.createdAt).format("YYYY-MM-DD HH:mm")),
+            span({ class: "pad-version-author" },
+              span({ class: "pad-author-swatch " + memberColorClass(pad.members, e.author) }),
+              a({ href: `/author/${encodeURIComponent(e.author)}`, class: "user-link" }, "@" + e.author.slice(1, 9) + "\u2026")
+            ),
+            a({ href: `/pads/${encodeURIComponent(pad.rootId)}?version=${encodeURIComponent(e.key || idx)}`, class: "pad-version-link" }, i18n.padVersionView || "View")
+          )
+        )
+      )
+    : null
+
+  const padMain = div({ class: "tribe-main" },
+    div({ class: "pad-main-layout" },
+      div({ class: "pad-main-left" },
+        div({ class: "pad-editor-container" }, editorArea)
+      ),
+      versionList ? div({ class: "pad-main-right" }, versionList) : null
+    )
+  )
+
+  return template(
+    pad.title || i18n.padsTitle || "Pad",
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.padsTitle || "Pads"),
+        p(i18n.padsDescription || "Manage collaborative encrypted text editors in your network.")
+      ),
+      renderModeButtons("all")
+    ),
+    section(div({ class: "tribe-details" }, padSide, padMain))
+  )
+}

+ 31 - 4
src/views/search_view.js

@@ -31,7 +31,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
   const contentTypes = [
     "post", "about", "curriculum", "tribe", "market", "transfer", "feed", "votes",
     "report", "task", "event", "bookmark", "image", "audio", "video", "document",
-    "bankWallet", "bankClaim", "project", "job", "forum", "vote", "contact", "pub", "map", "shop", "shopProduct", "all"
+    "bankWallet", "bankClaim", "project", "job", "forum", "vote", "contact", "pub", "map", "shop", "shopProduct", "chat", "pad", "all"
   ];
 
   const filterSelect = select(
@@ -89,6 +89,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
       case 'map': return `/maps/${encodeURIComponent(contentId)}`;
       case 'shop': return `/shops/${encodeURIComponent(contentId)}`;
       case 'shopProduct': return `/shops/product/${encodeURIComponent(contentId)}`;
+      case 'chat': return `/chats/${encodeURIComponent(contentId)}`;
+      case 'pad': return `/pads/${encodeURIComponent(contentId)}`;
+      case 'gameScore': return content && content.game ? `/games/${encodeURIComponent(content.game)}` : '/games';
       default: return '#';
     }
   };
@@ -474,6 +477,30 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
           content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.searchPriceLabel || 'PRICE') + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null,
           content.stock !== undefined ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock)) : null
         );
+      case 'chat':
+        return div({ class: 'search-chat' },
+          content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatsTitle || 'Chat') + ':'), span({ class: 'card-value' }, content.title)) : null,
+          content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatDescription || 'Description') + ':'), span({ class: 'card-value' }, content.description)) : null,
+          content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatCategoryLabel || 'Category') + ':'), span({ class: 'card-value' }, content.category)) : null,
+          content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'STATUS') + ':'), span({ class: 'card-value' }, content.status)) : null
+        );
+      case 'pad':
+        return div({ class: 'search-pad' },
+          content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padTitle || 'Pad') + ':'), span({ class: 'card-value' }, content.title)) : null,
+          content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padStatusLabel || 'Status') + ':'), span({ class: 'card-value' }, content.status)) : null,
+          content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padDeadlineLabel || 'Deadline') + ':'), span({ class: 'card-value' }, content.deadline)) : null
+        );
+      case 'gameScore':
+        return div({ class: 'search-game' },
+          content.game ? div({ class: 'game-row' },
+            img({ src: `/game-assets/${content.game}/thumbnail.svg`, alt: content.game, class: 'game-scoring-thumb', loading: 'lazy' }),
+            div({ class: 'game-row-body' },
+              h2({ class: 'game-card-title' }, content.game.charAt(0).toUpperCase() + content.game.slice(1)),
+              content.score !== undefined ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.gamesHallScore || 'Score') + ':'), span({ class: 'card-value' }, String(content.score))) : null,
+              a({ href: `/games/${encodeURIComponent(content.game)}`, class: 'filter-btn' }, i18n.gamesPlayButton || 'PLAY!')
+            )
+          ) : null
+        );
       case 'map':
         return div({ class: 'search-map' },
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
@@ -521,9 +548,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
           } else if (content.type === 'votes') {
             author = content.createdBy || i18n.anonymous || "Anonymous";
             authorUrl = `/author/${encodeURIComponent(content.createdBy || 'Anonymous')}`;
-          } else if (content.type === 'shop' || content.type === 'shopProduct') {
-            author = content.author || msg.value.author || i18n.anonymous || "Anonymous";
-            authorUrl = `/author/${encodeURIComponent(content.author || msg.value.author || 'Anonymous')}`;
+          } else if (content.type === 'shop' || content.type === 'shopProduct' || content.type === 'chat' || content.type === 'gameScore') {
+            author = content.author || content.player || msg.value.author || i18n.anonymous || "Anonymous";
+            authorUrl = `/author/${encodeURIComponent(content.author || content.player || msg.value.author || 'Anonymous')}`;
           } else {
             author = content.author;
             authorUrl = `/author/${encodeURIComponent(content.author || 'Anonymous')}`;

+ 9 - 23
src/views/settings_view.js

@@ -28,6 +28,7 @@ const settingsView = ({ version, aiPrompt }) => {
   const pubWalletUrl = currentConfig.walletPub.url || '';
   const pubWalletUser = currentConfig.walletPub.user || '';
   const pubWalletPass = currentConfig.walletPub.pass || '';
+  const pubId = currentConfig.pubId || '';
 
   const themeElements = [
     option({ value: "Dark-SNH", selected: theme === "Dark-SNH" ? true : undefined }, "Dark-SNH"),
@@ -170,33 +171,18 @@ const settingsView = ({ version, aiPrompt }) => {
     ),
     section(
       div({ class: "tags-header" },
-        h2(i18n.pubWallet),
-        p(i18n.pubWalletDescription),
+        h2(i18n.pubIdTitle || "PUB Wallet"),
+        p(i18n.pubIdDescription || "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI)."),
         form(
-          { action: "/settings/pub-wallet", method: "POST" },
-          label({ for: "pub_wallet_url" }, i18n.walletAddress), br(),
+          { action: "/settings/pub-id", method: "POST" },
           input({
             type: "text",
-            id: "pub_wallet_url",
-            name: "wallet_url",
-            placeholder: pubWalletUrl,
-            value: pubWalletUrl
+            id: "pub_id",
+            name: "pub_id",
+            value: pubId,
+            placeholder: i18n.pubIdPlaceholder || "@example.ed25519"
           }), br(),
-          label({ for: "pub_wallet_user" }, i18n.walletUser), br(),
-          input({
-            type: "text",
-            id: "pub_wallet_user",
-            name: "wallet_user",
-            placeholder: pubWalletUser,
-            value: pubWalletUser
-          }), br(),
-          label({ for: "pub_wallet_pass" }, i18n.walletPass), br(),
-          input({
-            type: "password",
-            id: "pub_wallet_pass",
-            name: "wallet_pass"
-          }), br(),
-          button({ type: "submit" }, i18n.pubWalletConfiguration)
+          button({ type: "submit" }, i18n.pubIdSave || "Save configuration")
         )
       )
     ),

+ 4 - 2
src/views/shops_view.js

@@ -285,7 +285,7 @@ exports.shopsView = async (shops, filter, shopToEdit = null, params = {}) => {
     title,
     section(header),
     section(renderModeButtons(filter)),
-    section(searchBar),
+    !isForm ? section(searchBar) : null,
     section(
       isForm
         ? renderShopForm(filter, filter === "edit" ? (shopToEdit || {}) : {}, params)
@@ -318,6 +318,9 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para
       span({ class: "tribe-info-label" }, `${i18n.shopShareUrl}: `),
       input({ type: "text", value: fullShareUrl, readonly: true, class: "shop-share-input" })
     ),
+    div({ class: "tribe-card-members" },
+      span({ class: "tribe-members-count" }, `${i18n.shopProducts}: ${shop.productCount || 0}`)
+    ),
     table({ class: "tribe-info-table" },
       tr(
         td({ class: "tribe-info-label" }, i18n.shopCreatedAt || "CREATED"),
@@ -339,7 +342,6 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para
         td({ class: "tribe-info-value", colspan: "3" }, ...renderUrl(shop.url))
       ) : null
     ),
-    h2({ class: "tribe-members-count" }, `${i18n.shopProducts}: ${shop.productCount || 0}`),
     shop.description ? p({ class: "tribe-side-description" }, ...renderUrl(shop.description)) : null,
     renderMapEmbed(params.mapData, shop.mapUrl),
     div({ class: "tribe-side-actions" },

+ 23 - 1
src/views/stats_view.js

@@ -2,6 +2,11 @@ const { div, h2, p, section, button, form, input, ul, li, a, h3, span, strong, t
 const { template, i18n } = require('./main_views');
 
 Object.assign(i18n, {
+  statsChat: "Chats",
+  statsChatMessage: "Chat messages",
+  statsPad: "Pads",
+  statsPadEntry: "Pad entries",
+  statsGameScore: "Game scores",
   statsParliamentCandidature: "Parliament candidatures",
   statsParliamentTerm: "Parliament terms",
   statsParliamentProposal: "Parliament proposals",
@@ -29,6 +34,7 @@ exports.statsView = (stats, filter) => {
     'bookmark', 'event', 'task', 'votes', 'report', 'feed', 'project',
     'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe',
     'market', 'forum', 'job', 'aiExchange', 'map', 'shop', 'shopProduct',
+    'chat', 'chatMessage', 'pad', 'padEntry', 'gameScore', 'calendar', 'calendarDate', 'calendarNote',
     'parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw',
     'courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote'
   ];
@@ -54,6 +60,14 @@ exports.statsView = (stats, filter) => {
     map: i18n.statsMap,
     shop: i18n.statsShop,
     shopProduct: i18n.statsShopProduct,
+    chat: i18n.statsChat,
+    chatMessage: i18n.statsChatMessage,
+    pad: i18n.statsPad,
+    padEntry: i18n.statsPadEntry,
+    gameScore: i18n.statsGameScore,
+    calendar: i18n.statsCalendar,
+    calendarDate: i18n.statsCalendarDate,
+    calendarNote: i18n.statsCalendarNote,
     parliamentCandidature: i18n.statsParliamentCandidature,
     parliamentTerm: i18n.statsParliamentTerm,
     parliamentProposal: i18n.statsParliamentProposal,
@@ -266,12 +280,20 @@ exports.statsView = (stats, filter) => {
               div({ style: blockStyle },
                 h2(`${i18n.statsNetworkContent}: ${totalContent}`),
                 ul(
-                  types.filter(t => t !== 'karmaScore' && t !== 'shopProduct').map(t => {
+                  types.filter(t => t !== 'karmaScore' && t !== 'shopProduct' && t !== 'padEntry' && t !== 'chatMessage').map(t => {
                     if (C(stats, t) <= 0) return null;
                     if (t === 'shop') return li(
                       span(`${labels[t]}: ${C(stats, t)}`),
                       ul([li(`${labels.shopProduct}: ${C(stats, 'shopProduct')}`)])
                     );
+                    if (t === 'pad') return li(
+                      span(`${labels[t]}: ${C(stats, t)}`),
+                      ul([li(`${labels.padEntry}: ${C(stats, 'padEntry')}`)])
+                    );
+                    if (t === 'chat') return li(
+                      span(`${labels[t]}: ${C(stats, t)}`),
+                      ul([li(`${labels.chatMessage}: ${C(stats, 'chatMessage')}`)])
+                    );
                     if (t !== 'tribe') return li(`${labels[t]}: ${C(stats, t)}`);
                     return li(
                       span(`${labels[t]}: ${C(stats, t)}`),

+ 109 - 5
src/views/tribes_view.js

@@ -388,6 +388,7 @@ const renderSectionNav = (tribe, section) => {
     { items: [{ key: 'votations', label: i18n.tribeSectionVotations }, { key: 'events', label: i18n.tribeSectionEvents }, { key: 'tasks', label: i18n.tribeSectionTasks }] },
     { items: [{ key: 'feed', label: i18n.tribeSectionFeed }, { key: 'forum', label: i18n.tribeSectionForum }] },
     { items: [{ key: 'images', label: i18n.tribeSectionImages || 'IMAGES' }, { key: 'audios', label: i18n.tribeSectionAudios || 'AUDIOS' }, { key: 'videos', label: i18n.tribeSectionVideos || 'VIDEOS' }, { key: 'documents', label: i18n.tribeSectionDocuments || 'DOCUMENTS' }, { key: 'bookmarks', label: i18n.tribeSectionBookmarks || 'BOOKMARKS' }, { key: 'maps', label: i18n.tribeSectionMaps || 'MAPS' }] },
+    { items: [{ key: 'pads', label: i18n.tribeSectionPads || 'PADS' }, { key: 'chats', label: i18n.tribeSectionChats || 'CHATS' }, { key: 'calendars', label: i18n.tribeSectionCalendars || 'CALENDARS' }] },
     { items: [{ key: 'search', label: i18n.tribeSectionSearch }] },
   ];
   return div({ class: 'tribe-section-nav', style: 'border: none;' },
@@ -462,7 +463,7 @@ const contentTypeVerb = (ct) => {
 };
 
 const contentTypeName = (ct) => {
-  const map = { event: i18n.tribeSectionEvents, task: i18n.tribeSectionTasks, votation: i18n.tribeSectionVotations, forum: i18n.tribeSectionForum, 'forum-reply': i18n.tribeSectionForum, media: i18n.tribeSectionMedia, feed: i18n.tribeSectionFeed };
+  const map = { event: i18n.tribeSectionEvents, task: i18n.tribeSectionTasks, votation: i18n.tribeSectionVotations, forum: i18n.tribeSectionForum, 'forum-reply': i18n.tribeSectionForum, media: i18n.tribeSectionMedia, feed: i18n.tribeSectionFeed, pad: i18n.tribeSectionPads || 'PADS', chat: i18n.tribeSectionChats || 'CHATS', calendar: i18n.tribeSectionCalendars || 'CALENDARS', map: i18n.tribeSectionMaps || 'MAPS' };
   return map[ct] || ct;
 };
 
@@ -516,10 +517,12 @@ const renderTribeActivitySection = (tribe, sectionData) => {
       return div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
         div({ class: 'card-header' },
           h2({ class: 'card-label' }, headerText),
-          form({ method: 'GET', action: tribeUrl },
-            input({ type: 'hidden', name: 'section', value: targetSection }),
-            button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details')
-          )
+          item.directUrl
+            ? a({ href: item.directUrl, class: 'filter-btn' }, i18n.viewDetails || 'View Details')
+            : form({ method: 'GET', action: tribeUrl },
+                input({ type: 'hidden', name: 'section', value: targetSection }),
+                button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details')
+              )
         ),
         div({ class: 'tribe-card-body' },
           item.title ? div({ class: 'card-field' },
@@ -1277,6 +1280,104 @@ const renderTribeMapsSection = (tribe, maps) => {
   );
 };
 
+const renderTribePadsSection = (tribe, pads) => {
+  const items = Array.isArray(pads) ? pads : [];
+  const createBtn = form({ method: 'GET', action: '/pads' },
+    input({ type: 'hidden', name: 'filter', value: 'create' }),
+    input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
+    button({ type: 'submit', class: 'create-button' }, i18n.tribePadCreate || 'Create Pad'));
+  if (items.length === 0) return div({ class: 'tribe-content-list' }, createBtn, p(i18n.tribePadsEmpty || 'No pads, yet.'));
+  return div({ class: 'tribe-content-list' },
+    div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionPads || 'PADS'), createBtn),
+    items.map(m =>
+      div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
+        div({ class: 'card-header' },
+          h2({ class: 'card-label' }, `[${(i18n.typePad || 'PAD').toUpperCase()}]`),
+          form({ method: 'GET', action: `/pads/${encodeURIComponent(m.rootId)}` },
+            button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details'))
+        ),
+        div({ class: 'tribe-card-body' },
+          m.title ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
+            span({ class: 'card-value' }, a({ href: `/pads/${encodeURIComponent(m.rootId)}` }, m.title))
+          ) : null
+        ),
+        p({ class: 'card-footer' },
+          span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
+          a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
+        )
+      )
+    )
+  );
+};
+
+const renderTribeChatsSection = (tribe, chats) => {
+  const items = Array.isArray(chats) ? chats : [];
+  const createBtn = form({ method: 'GET', action: '/chats' },
+    input({ type: 'hidden', name: 'filter', value: 'create' }),
+    input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
+    button({ type: 'submit', class: 'create-button' }, i18n.tribeChatCreate || 'Create Chat'));
+  if (items.length === 0) return div({ class: 'tribe-content-list' }, createBtn, p(i18n.tribeChatsEmpty || 'No chats, yet.'));
+  return div({ class: 'tribe-content-list' },
+    div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionChats || 'CHATS'), createBtn),
+    items.map(m =>
+      div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
+        div({ class: 'card-header' },
+          h2({ class: 'card-label' }, `[${(i18n.typeChat || 'CHAT').toUpperCase()}]`),
+          form({ method: 'GET', action: `/chats/${encodeURIComponent(m.key)}` },
+            button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details'))
+        ),
+        div({ class: 'tribe-card-body' },
+          m.title ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
+            span({ class: 'card-value' }, a({ href: `/chats/${encodeURIComponent(m.key)}` }, m.title))
+          ) : null,
+          m.description ? p(m.description.substring(0, 200)) : null
+        ),
+        p({ class: 'card-footer' },
+          span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
+          a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
+        )
+      )
+    )
+  );
+};
+
+const renderTribeCalendarsSection = (tribe, calendars) => {
+  const items = Array.isArray(calendars) ? calendars : [];
+  const createBtn = form({ method: 'GET', action: '/calendars' },
+    input({ type: 'hidden', name: 'filter', value: 'create' }),
+    input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
+    button({ type: 'submit', class: 'create-button' }, i18n.tribeCalendarCreate || 'Create Calendar'));
+  if (items.length === 0) return div({ class: 'tribe-content-list' }, createBtn, p(i18n.tribeCalendarsEmpty || 'No calendars, yet.'));
+  return div({ class: 'tribe-content-list' },
+    div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionCalendars || 'CALENDARS'), createBtn),
+    items.map(m =>
+      div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
+        div({ class: 'card-header' },
+          h2({ class: 'card-label' }, `[${(i18n.typeCalendar || 'CALENDAR').toUpperCase()}]`),
+          form({ method: 'GET', action: `/calendars/${encodeURIComponent(m.rootId)}` },
+            button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details'))
+        ),
+        div({ class: 'tribe-card-body' },
+          m.title ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
+            span({ class: 'card-value' }, a({ href: `/calendars/${encodeURIComponent(m.rootId)}` }, m.title))
+          ) : null,
+          m.deadline ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, (i18n.calendarDeadlineLabel || 'Deadline') + ':'),
+            span({ class: 'card-value' }, new Date(m.deadline).toLocaleDateString())
+          ) : null
+        ),
+        p({ class: 'card-footer' },
+          span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
+          a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
+        )
+      )
+    )
+  );
+};
+
 exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
   if (!tribe) {
     return div({ class: 'error' }, i18n.tribeNotFound);
@@ -1317,6 +1418,9 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
     case 'documents': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'document'); break;
     case 'bookmarks': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'bookmark'); break;
     case 'maps': sectionContent = renderTribeMapsSection(tribe, sectionData); break;
+    case 'pads': sectionContent = renderTribePadsSection(tribe, sectionData); break;
+    case 'chats': sectionContent = renderTribeChatsSection(tribe, sectionData); break;
+    case 'calendars': sectionContent = renderTribeCalendarsSection(tribe, sectionData); break;
     case 'activity':
     default: sectionContent = renderTribeActivitySection(tribe, sectionData); break;
   }