浏览代码

Oasis release 0.5.2

psy 1 天之前
父节点
当前提交
090ce67fd9

+ 6 - 0
README.md

@@ -110,6 +110,12 @@ Our AI is trained with content from the OASIS network and its purpose is to take
 
 
 ----------
 ----------
 
 
+## Parliament (politics)
+
+Oasis contains its own Parliament (Government system).
+
+  ![SNH](https://solarnethub.com/git/oasis-parliament.png "SolarNET.HuB")
+  
 ## ECOin (crypto-economy)
 ## ECOin (crypto-economy)
 
 
 Oasis contains its own cryptocurrency. With it, you can exchange items and services in the marketplace. 
 Oasis contains its own cryptocurrency. With it, you can exchange items and services in the marketplace. 

+ 10 - 0
docs/CHANGELOG.md

@@ -13,6 +13,16 @@ All notable changes to this project will be documented in this file.
 ### Security
 ### Security
 -->
 -->
 
 
+## v0.5.2 - 2025-10-22
+
+### Added
+
+ + Government system (Parliament plugin).
+ 
+### Fixed
+
+ + Forum category translations (Forum plugin).
+
 ## v0.5.1 - 2025-09-26
 ## v0.5.1 - 2025-09-26
 
 
 ### Added
 ### Added

+ 179 - 4
src/backend/backend.js

@@ -77,6 +77,66 @@ function readWalletMap() {
   return {};
   return {};
 }
 }
 
 
+//parliament
+async function buildState(filter) {
+  const f = (filter || 'government').toLowerCase();
+  const [govCard, candidatures, proposals, canPropose, laws, historical] = await Promise.all([
+    parliamentModel.getGovernmentCard(),
+    parliamentModel.listCandidatures('OPEN'),
+    parliamentModel.listProposalsCurrent(),
+    parliamentModel.canPropose(),
+    parliamentModel.listLaws(),
+    parliamentModel.listHistorical()
+  ]);
+  return { filter: f, governmentCard: govCard, candidatures, proposals, canPropose, laws, historical };
+}
+
+function pickLeader(cands = []) {
+  if (!cands.length) return null;
+  return [...cands].sort((a, b) => {
+    const va = Number(a.votes || 0), vb = Number(b.votes || 0);
+    if (vb !== va) return vb - va;
+    const ka = Number(a.karma || 0), kb = Number(b.karma || 0);
+    if (kb !== ka) return kb - ka;
+    const sa = Number(a.profileSince || 0), sb = Number(b.profileSince || 0);
+    if (sa !== sb) return sa - sb;
+    const ca = new Date(a.createdAt).getTime(), cb = new Date(b.createdAt).getTime();
+    if (ca !== cb) return ca - cb;
+    return String(a.targetId).localeCompare(String(b.targetId));
+  })[0];
+}
+
+async function buildLeaderMeta(leader) {
+  if (!leader) return null;
+  if (leader.targetType === 'inhabitant') {
+    let name = null;
+    let image = null;
+    let description = null;
+    try { if (about && typeof about.name === 'function') name = await about.name(leader.targetId); } catch {}
+    try { if (about && typeof about.image === 'function') image = await about.image(leader.targetId); } catch {}
+    try { if (about && typeof about.description === 'function') description = await about.description(leader.targetId); } catch {}
+    const imgId = typeof image === 'string' ? image : (image && (image.link || image.url)) || null;
+    const avatarUrl = imgId ? `/image/256/${encodeURIComponent(imgId)}` : '/assets/images/default-avatar.png';
+    return {
+      isTribe: false,
+      name: name || leader.targetId,
+      avatarUrl,
+      bio: typeof description === 'string' ? description : ''
+    };
+  } else {
+    let tribe = null;
+    try { tribe = await tribesModel.getTribeById(leader.targetId); } catch {}
+    const imgId = tribe && tribe.image ? tribe.image : null;
+    const avatarUrl = imgId ? `/image/256/${encodeURIComponent(imgId)}` : '/assets/images/default-tribe.png';
+    return {
+      isTribe: true,
+      name: leader.targetTitle || (tribe && (tribe.title || tribe.name)) || leader.targetId,
+      avatarUrl,
+      bio: (tribe && tribe.description) || ''
+    };
+  }
+}
+
 //custom styles
 //custom styles
 const customStyleFile = path.join(
 const customStyleFile = path.join(
   envPaths("oasis", { suffix: "" }).config,
   envPaths("oasis", { suffix: "" }).config,
@@ -240,6 +300,8 @@ const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic
 const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public });
 const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public });
 const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
 const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
 const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public })
 const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public })
+const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel }
+});
 
 
 // starting warmup
 // starting warmup
 about._startNameWarmup();
 about._startNameWarmup();
@@ -483,6 +545,7 @@ const { renderBlockchainView, renderSingleBlockView } = require("../views/blockc
 const { jobsView, singleJobsView, renderJobForm } = require("../views/jobs_view");
 const { jobsView, singleJobsView, renderJobForm } = require("../views/jobs_view");
 const { projectsView, singleProjectView } = require("../views/projects_view")
 const { projectsView, singleProjectView } = require("../views/projects_view")
 const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views")
 const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views")
+const { parliamentView } = require("../views/parliament_view");
 
 
 let sharp;
 let sharp;
 
 
@@ -608,8 +671,8 @@ router
     const modules = [
     const modules = [
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 
-    'events', 'tasks', 'market', 'tribes', 'governance', 'reports', 'opinions', 'transfers', 
-    'feed', 'pixelia', 'agenda', 'ai', 'forum', 'jobs', 'projects', 'banking'
+    'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'transfers', 
+    'feed', 'pixelia', 'agenda', 'ai', 'forum', 'jobs', 'projects', 'banking', 'parliament'
     ];
     ];
     const moduleStates = modules.reduce((acc, mod) => {
     const moduleStates = modules.reduce((acc, mod) => {
       acc[`${mod}Mod`] = configMods[`${mod}Mod`];
       acc[`${mod}Mod`] = configMods[`${mod}Mod`];
@@ -973,6 +1036,76 @@ router
     const currentUserId = SSBconfig.config.keys.id;
     const currentUserId = SSBconfig.config.keys.id;
     ctx.body = await inhabitantsProfileView({ about, cv, feed }, currentUserId);
     ctx.body = await inhabitantsProfileView({ about, cv, feed }, currentUserId);
   })
   })
+ .get('/parliament', async (ctx) => {
+    const mod = ctx.cookies.get('parliamentMod') || 'on';
+    if (mod !== 'on') { ctx.redirect('/modules'); return }
+    const filter = (ctx.query.filter || 'government').toLowerCase();
+    let governmentCard = await parliamentModel.getGovernmentCard();
+    if (!governmentCard || !governmentCard.end || moment().isAfter(moment(governmentCard.end))) {
+      await parliamentModel.resolveElection();
+      governmentCard = await parliamentModel.getGovernmentCard();
+    }
+    const [
+      candidatures, proposals, futureLaws, canPropose, laws,
+      historical, leaders, revocations, futureRevocations, revocationsEnactedCount,
+      inhabitantsAll
+      ] = await Promise.all([
+      parliamentModel.listCandidatures('OPEN'),
+      parliamentModel.listProposalsCurrent(),
+      parliamentModel.listFutureLawsCurrent(),
+      parliamentModel.canPropose(),
+      parliamentModel.listLaws(),
+      parliamentModel.listHistorical(),
+      parliamentModel.listLeaders(),
+      parliamentModel.listRevocationsCurrent(),
+      parliamentModel.listFutureRevocationsCurrent(),
+      parliamentModel.countRevocationsEnacted(),
+      inhabitantsModel.listInhabitants({ filter: 'all' })
+    ]); 
+    const inhabitantsTotal = Array.isArray(inhabitantsAll) ? inhabitantsAll.length : 0;
+    const leader = pickLeader(candidatures || []);
+    const leaderMeta = leader ? await parliamentModel.getActorMeta({ targetType: leader.targetType || leader.powerType || 'inhabitant', targetId: leader.targetId || leader.powerId }) : null;
+    const powerMeta = (governmentCard && (governmentCard.powerType === 'tribe' || governmentCard.powerType === 'inhabitant'))
+      ? await parliamentModel.getActorMeta({ targetType: governmentCard.powerType, targetId: governmentCard.powerId })
+      : null;
+    const historicalMetas = {};
+    for (const g of (historical || []).slice(0, 12)) {
+      if (g.powerType === 'tribe' || g.powerType === 'inhabitant') {
+        const k = `${g.powerType}:${g.powerId}`;
+        if (!historicalMetas[k]) {
+          historicalMetas[k] = await parliamentModel.getActorMeta({ targetType: g.powerType, targetId: g.powerId });
+        }
+      }
+    }
+    const leadersMetas = {};
+    for (const r of (leaders || []).slice(0, 20)) {
+      if (r.powerType === 'tribe' || r.powerType === 'inhabitant') {
+        const k = `${r.powerType}:${r.powerId}`;
+        if (!leadersMetas[k]) {
+          leadersMetas[k] = await parliamentModel.getActorMeta({ targetType: r.powerType, targetId: r.powerId });
+        }
+      }
+    }
+    const govWithPopulation = governmentCard ? { ...governmentCard, inhabitantsTotal } : { inhabitantsTotal };
+    ctx.body = await parliamentView({
+      filter,
+      governmentCard: govWithPopulation,
+      candidatures,
+      proposals,
+      futureLaws,
+      canPropose,
+      laws,
+      historical,
+      leaders,
+      leaderMeta,
+      powerMeta,
+      historicalMetas,
+      leadersMetas,
+      revocations,
+      futureRevocations,
+      revocationsEnactedCount
+    });
+  })
   .get('/tribes', async ctx => {
   .get('/tribes', async ctx => {
     const filter = ctx.query.filter || 'all';
     const filter = ctx.query.filter || 'all';
     const search = ctx.query.search || ''; 
     const search = ctx.query.search || ''; 
@@ -2563,6 +2696,48 @@ router
     await votesModel.createOpinion(voteId, category);
     await votesModel.createOpinion(voteId, category);
     ctx.redirect('/votes');
     ctx.redirect('/votes');
   })
   })
+  .post('/parliament/candidatures/propose', koaBody(), async (ctx) => {
+    const { candidateId = '', method = '' } = ctx.request.body || {};
+    const id = String(candidateId || '').trim();
+    const m = String(method || '').trim().toUpperCase();
+    const ALLOWED = new Set(['DEMOCRACY','MAJORITY','MINORITY','DICTATORSHIP','KARMATOCRACY']);
+    if (!id) ctx.throw(400, 'Candidate is required.');
+    if (!ALLOWED.has(m)) ctx.throw(400, 'Invalid method.');
+    await parliamentModel.proposeCandidature({ candidateId: id, method: m }).catch(e => ctx.throw(400, String((e && e.message) || e)));
+    ctx.redirect('/parliament?filter=candidatures');
+  })
+  .post('/parliament/candidatures/:id/vote', koaBody(), async (ctx) => {
+    await parliamentModel.voteCandidature(ctx.params.id).catch(e => ctx.throw(400, String((e && e.message) || e)));
+    ctx.redirect('/parliament?filter=candidatures');
+  })
+  .post('/parliament/proposals/create', koaBody(), async (ctx) => {
+    const { title = '', description = '' } = ctx.request.body || {};
+    const t = String(title || '').trim();
+    const d = String(description || '').trim();
+    if (!t) ctx.throw(400, 'Title is required.');
+    if (d.length > 1000) ctx.throw(400, 'Description must be ≤ 1000 chars.');
+    await parliamentModel.createProposal({ title: t, description: d }).catch(e => ctx.throw(400, String((e && e.message) || e)));
+    ctx.redirect('/parliament?filter=proposals');
+  })
+  .post('/parliament/proposals/close/:id', koaBody(), async (ctx) => {
+    await parliamentModel.closeProposal(ctx.params.id).catch(e => ctx.throw(400, String((e && e.message) || e)));
+    ctx.redirect('/parliament?filter=proposals');
+  })
+  .post('/parliament/resolve', koaBody(), async (ctx) => {
+    await parliamentModel.resolveElection().catch(e => ctx.throw(400, String((e && e.message) || e)));
+    ctx.redirect('/parliament?filter=government');
+  })
+  .post('/parliament/revocations/create', koaBody(), async (ctx) => {
+    const body = ctx.request.body || {};
+    const rawLawId =
+      Array.isArray(body.lawId) ? body.lawId[0] :
+      (body.lawId ?? body['lawId[]'] ?? body.law_id ?? '');
+    const lawId = String(rawLawId || '').trim();
+    if (!lawId) ctx.throw(400, 'Law required');
+    const { title, reasons } = body;
+    await parliamentModel.createRevocation({ lawId, title, reasons });
+    ctx.redirect('/parliament?filter=revocations');
+  })
   .post('/market/create', koaBody({ multipart: true }), async ctx => {
   .post('/market/create', koaBody({ multipart: true }), async ctx => {
     const { item_type, title, description, price, tags, item_status, deadline, includesShipping, stock } = ctx.request.body;
     const { item_type, title, description, price, tags, item_status, deadline, includesShipping, stock } = ctx.request.body;
     const image = await handleBlobUpload(ctx, 'image');
     const image = await handleBlobUpload(ctx, 'image');
@@ -3146,8 +3321,8 @@ router
     const modules = [
     const modules = [
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet',
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet',
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending',
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending',
-    'events', 'tasks', 'market', 'tribes', 'governance', 'reports', 'opinions', 'transfers',
-    'feed', 'pixelia', 'agenda', 'ai', 'forum', 'jobs', 'projects', 'banking'
+    'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'transfers',
+    'feed', 'pixelia', 'agenda', 'ai', 'forum', 'jobs', 'projects', 'banking', 'parliament'
     ];
     ];
     const currentConfig = getConfig();
     const currentConfig = getConfig();
     modules.forEach(mod => {
     modules.forEach(mod => {

二进制
src/client/assets/images/anarchy.png


二进制
src/client/assets/images/democracy.png


二进制
src/client/assets/images/dictatorship.png


二进制
src/client/assets/images/karmatocracy.png


二进制
src/client/assets/images/majority.png


二进制
src/client/assets/images/minority.png


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

@@ -2310,4 +2310,72 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .error-title{margin:0 0 6px 0;font-weight:600}
 .error-title{margin:0 0 6px 0;font-weight:600}
 .error-pre{margin:0;white-space:pre-wrap;font-family:monospace}
 .error-pre{margin:0;white-space:pre-wrap;font-family:monospace}
 
 
+/* parliament */
+.cycle-info {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: .75rem;
+  margin-bottom: 1rem;
+}
+.kpi {
+  border: 1px solid rgba(0,0,0,.08);
+  border-radius: 8px;
+  padding: .75rem;
+}
+.kpi__label {
+  display: block;
+  letter-spacing: .06em;
+  text-transform: uppercase;
+  color: var(--parl-accent, #ff9f1a);
+  opacity: 1;
+}
+.kpi__value {
+  font-weight: 600;
+}
+.method-badge,
+.method-hero {
+  display: inline-flex;
+  align-items: center;
+  gap: .5rem;
+}
+.method-cell {
+  text-align: center;
+}
+.leader-cell {
+  text-align: center;
+}
+.mt-2 { margin-top: .5rem; }
 
 
+.parliament-actor-table {
+  table-layout: auto;
+  width: 100%;
+}
+.parliament-actor-table th,
+.parliament-actor-table td {
+  vertical-align: top;
+}
+
+.parliament-actor-col {
+  white-space: nowrap;
+  width: 1%;
+  text-align: left;
+}
+.parliament-actor-col .user-link {
+  display: inline-block;
+}
+
+.parliament-description-col {
+  white-space: normal;
+  text-align: left;
+}
+
+.parliament-members-row td {
+  padding-top: .5rem;
+}
+.parliament-members-list {
+  margin: .25rem 0 0;
+  padding-left: 1.25rem;
+}
+.parliament-members-list li {
+  margin: .125rem 0;
+}

+ 178 - 22
src/client/assets/translations/oasis_en.js

@@ -356,7 +356,7 @@ module.exports = {
     postLabel: "POSTS",
     postLabel: "POSTS",
     aboutLabel: "INHABITANTS",
     aboutLabel: "INHABITANTS",
     feedLabel: "FEEDS",
     feedLabel: "FEEDS",
-    votesLabel: "GOVERNANCE",
+    votesLabel: "VOTATIONS",
     reportLabel: "REPORTS",
     reportLabel: "REPORTS",
     imageLabel: "IMAGES",
     imageLabel: "IMAGES",
     videoLabel: "VIDEOS",
     videoLabel: "VIDEOS",
@@ -586,7 +586,7 @@ module.exports = {
     audioNoFile: "No audio file provided",
     audioNoFile: "No audio file provided",
     audioNotSupported: "Your browser does not support the audio element.",
     audioNotSupported: "Your browser does not support the audio element.",
     noAudios: "No audios available.",
     noAudios: "No audios available.",
-    // inhabitants
+    //inhabitants
     yourContacts: "Your Contacts",
     yourContacts: "Your Contacts",
     allInhabitants: "Inhabitants",
     allInhabitants: "Inhabitants",
     allCVs: "All CVs",
     allCVs: "All CVs",
@@ -622,6 +622,144 @@ module.exports = {
     oasisId: "ID",
     oasisId: "ID",
     noInhabitantsFound: "No inhabitants found, yet.",
     noInhabitantsFound: "No inhabitants found, yet.",
     inhabitantActivityLevel: "Activity Level",
     inhabitantActivityLevel: "Activity Level",
+    //parliament
+    parliamentTitle: "Parliament",
+    parliamentDescription: "Explore forms of government and collective management laws.",
+    parliamentFilterGovernment: "GOVERMENT",
+    parliamentFilterCandidatures: "CANDIDATURES",
+    parliamentFilterProposals: "PROPOSALS",
+    parliamentFilterLaws: "LAWS",
+    parliamentFilterHistorical: "HISTORICAL",
+    parliamentFilterLeaders: "LEADERS",
+    parliamentFilterRules: "RULES",
+    parliamentGovernmentCard: "Current Government",
+    parliamentGovMethod: "GOVERMENT METHOD",
+    parliamentActorInPowerInhabitant: 'INHABITANT RULING',
+    parliamentActorInPowerTribe: 'TRIBE RULING',
+    parliamentHistoricalGovernmentsTitle: 'GOVERMENTS',
+    parliamentHistoricalElectionsTitle: 'ELECTION CYCLES',
+    parliamentHistoricalLawsTitle: 'HISTORICAL',
+    parliamentHistoricalLeadersTitle: 'LEADERS',
+    parliamentThCycles: 'CYCLES',
+    parliamentThTimesInPower: 'RULING',
+    parliamentThTotalCandidatures: 'CANDIDATURES',
+    parliamentThProposed: 'LAWS PROPOSED',
+    parliamentThApproved: 'LAWS APPROVED',
+    parliamentThDeclined: 'LAWS DECLINED',
+    parliamentThDiscarded: 'LAWS DISCARDED',
+    parliamentVotesReceived: "VOTES RECIEVED",
+    parliamentMembers: "MEMBERS",
+    parliamentLegSince: "CYCLE SINCE",
+    parliamentLegEnd: "CYCLE END",
+    parliamentPoliciesProposal: "LAWS PROPOSAL",
+    parliamentPoliciesApproved: "LAWS APPROVED",
+    parliamentPoliciesDeclined: "LAWS DECLINED",
+    parliamentPoliciesDiscarded: "LAWS DISCARDED",
+    parliamentEfficiency: "% EFFICIENCY",
+    parliamentFilterRevocations: 'REVOCATIONS',
+    parliamentRevocationFormTitle: 'Revocate Law',
+    parliamentRevocationLaw: 'Law',
+    parliamentRevocationTitle: 'Title',
+    parliamentRevocationReasons: 'Reasons',
+    parliamentRevocationPublish: 'Publish Revocation',
+    parliamentCurrentRevocationsTitle: 'Current Revocations',
+    parliamentFutureRevocationsTitle: 'Future Revocations',
+    parliamentPoliciesRevocated: 'LAWS REVOCATED',
+    parliamentPoliciesTitle: 'HISTORICAL',
+    parliamentLawsTitle: 'LAWS APPROVED',
+    parliamentRulesRevocations: 'Any approved law can be revocate using current goverment method ruling.',
+    parliamentNoStableGov: "No goverment choosen, yet.",
+    parliamentNoGovernments: "There are not governments, yet.",
+    parliamentCandidatureFormTitle: "Propose Candidature",
+    parliamentCandidatureId: "Candidature",
+    parliamentCandidatureIdPh: "Oasis ID (@...) or Tribe name",
+    parliamentCandidatureSlogan: "Slogan (max 140 chars)",
+    parliamentCandidatureSloganPh: "A short motto",
+    parliamentCandidatureMethod: "Method",
+    parliamentCandidatureProposeBtn: "Publish Candidature",
+    parliamentThType: "Type",
+    parliamentThId: "ID",
+    parliamentThDate: "Proposal date",
+    parliamentThSlogan: "Slogan",
+    parliamentThMethod: "Method",
+    parliamentThKarma: "Karma",
+    parliamentThSince: "Profile since",
+    parliamentThVotes: "Votes recieved",
+    parliamentThVoteAction: "Vote",
+    parliamentTypeUser: "Inhabitant",
+    parliamentTypeTribe: "Tribe",
+    parliamentVoteBtn: "Vote",
+    parliamentProposalFormTitle: "Propose Law",
+    parliamentProposalTitle: "Title",
+    parliamentProposalDescription: "Description (≤1000)",
+    parliamentProposalPublish: "Publish Proposal",
+    parliamentOpenVote: "Open vote",
+    parliamentFinalize: "Finalize",
+    parliamentDeadline: "Deadline",
+    parliamentStatus: "Status",
+    parliamentNoProposals: "There are not law proposals, yet.",
+    parliamentNoLaws: "There are not laws approved, yet.",
+    parliamentLawMethod: "Method",
+    parliamentLawProposer: "Proposed by",
+    parliamentLawVotes: "YES/Total",
+    parliamentLawEnacted: "Enacted at",
+    parliamentMethodDEMOCRACY: "Democracy",
+    parliamentMethodMAJORITY: "Majority (80%)",
+    parliamentMethodMINORITY: "Minority (20%)",
+    parliamentMethodDICTATORSHIP: "Dictatorship",
+    parliamentMethodKARMATOCRACY: "Karmatocracy",
+    parliamentMethodANARCHY: "Anarchy",
+    parliamentThId: "ID",
+    parliamentThProposalDate: "Proposal date",
+    parliamentThMethod: "Method",
+    parliamentThKarma: "Karma",
+    parliamentThSupports: "Supports",
+    parliamentThVote: "Vote",
+    parliamentCurrentProposalsTitle: "Current Proposals",
+    parliamentVotesSlashTotal: "Votes/Total",
+    parliamentVotesNeeded: "Votes Needed",
+    parliamentFutureLawsTitle: "Future Laws",
+    parliamentNoFutureLaws: "No future laws, yet.",
+    parliamentVoteAction: "Vote",
+    parliamentLeadersTitle: "Leaders",
+    parliamentThLeader: "AVATAR",
+    parliamentPopulation: "Population",
+    parliamentThType: "Type",
+    parliamentThInPower: "In power",
+    parliamentThPresented: "CANDIDATURES",
+    parliamentNoLeaders: "No leaders, yet.",
+    typeParliament: "PARLIAMENT",
+    typeParliamentCandidature: "Parliament · Candidature",
+    typeParliamentTerm: "Parliament · Term",
+    typeParliamentProposal: "Parliament · Proposal",
+    typeParliamentLaw: "Parliament · New Law",
+    parliamentLawQuestion: "Question",
+    parliamentStatus: "Status",
+    parliamentCandidaturesListTitle: "List of Candidatures",
+    parliamentElectionsEnd: "Election end",
+    parliamentTimeRemaining: "Time remaining",
+    parliamentCurrentLeader: "Winning candidature",
+    parliamentNoLeader: "No winning candidature, yet.",
+    parliamentElectionsStatusTitle: "Next Government",
+    parliamentElectionsStart: "Election start",
+    parliamentProposalDeadlineLabel: "Deadline",
+    parliamentProposalTimeLeft: "Time left",
+    parliamentProposalOnTrack: "On track to pass",
+    parliamentProposalOffTrack: "Not enough support yet",
+    parliamentRulesTitle: "How Parliament works",
+    parliamentRulesIntro: "Election resolve every 2 months; candidatures are continuous and reset when a government is chosen.",
+    parliamentRulesCandidates: "Any inhabitant may propose themself, another inhabitant, or any tribe. Each inhabitant can propose up to 3 candidatures per cycle; duplicates in the same cycle are rejected.",
+    parliamentRulesElection: "The winner is the candidature with the most votes at resolution time.",
+    parliamentRulesTies: "Tie-break order: highest inhabitant karma; if tied, oldest profile; if still tied, earliest proposal; then lexicographic by ID.",
+    parliamentRulesFallback: "If no one votes, the latest proposed candidature wins. If there are no candidatures, a random tribe is selected.",
+    parliamentRulesTerm: "The Government tab shows the current government and stats.",
+    parliamentRulesMethods: "Forms: Anarchy (simple majority), Democracy (50%+1), Majority (80%), Minority (20%), Karmatocracy (highest-karma proposals), Dictatorship (instant approval).",
+    parliamentRulesAnarchy: "Anarchy is the default mode: if no candidature is elected at resolution, Anarchy is proclaimed. Under Anarchy, any inhabitant can propose laws.",
+    parliamentRulesProposals: "If you are the ruling inhabitant or a member of the ruling tribe, you can publish law proposals. Non-dictatorship methods create a public vote.",
+    parliamentRulesLimit: "Each inhabitant may publish at most 3 law proposals per cycle.",
+    parliamentRulesLaws: "When a proposal meets its threshold, it becomes a Law and appears in the Laws tab with its enactment date.",
+    parliamentRulesHistorical: "In the Historical tab you can see every government cycle that has occurred and data about its management.",
+    parliamentRulesLeaders: "In the Leaders tab you can see a ranking of inhabitants/tribes that have governed (or stood as candidates), ordered by efficiency.",
     //trending
     //trending
     trendingTitle: "Trending",
     trendingTitle: "Trending",
     exploreTrending: "Explore the most popular content in your network.",
     exploreTrending: "Explore the most popular content in your network.",
@@ -633,7 +771,7 @@ module.exports = {
     transferButton: "TRANSFERS",
     transferButton: "TRANSFERS",
     eventButton: "EVENTS",
     eventButton: "EVENTS",
     taskButton: "TASKS",
     taskButton: "TASKS",
-    votesButton: "GOVERNANCE",
+    votesButton: "VOTATIONS",
     reportButton: "REPORTS",
     reportButton: "REPORTS",
     feedButton: "FEED",
     feedButton: "FEED",
     marketButton: "MARKET",
     marketButton: "MARKET",
@@ -837,15 +975,15 @@ module.exports = {
     transfersClosedSectionTitle: "Closed Transfers",
     transfersClosedSectionTitle: "Closed Transfers",
     transfersDiscardedSectionTitle: "Discarded Transfers",
     transfersDiscardedSectionTitle: "Discarded Transfers",
     transfersAllSectionTitle: "Transfers",
     transfersAllSectionTitle: "Transfers",
-    //governance (voting/polls)
-    governanceTitle: "Governance",
-    governanceDescription: "Discover and manage votations in your network.",
+    //votations (voting/polls)
+    votationsTitle: "Votations",
+    votationsDescription: "Discover and manage votations in your network.",
     voteMineSectionTitle: "Your Votations",
     voteMineSectionTitle: "Your Votations",
     voteCreateSectionTitle: "Create Votation",
     voteCreateSectionTitle: "Create Votation",
     voteUpdateSectionTitle: "Update",
     voteUpdateSectionTitle: "Update",
     voteOpenTitle: "Open Votations",
     voteOpenTitle: "Open Votations",
     voteClosedTitle: "Closed Votations",
     voteClosedTitle: "Closed Votations",
-    voteAllSectionTitle: "Governance",
+    voteAllSectionTitle: "Votations",
     voteCreateButton: "Create Votation",
     voteCreateButton: "Create Votation",
     voteUpdateButton: "Update",
     voteUpdateButton: "Update",
     voteDeleteButton: "Delete",
     voteDeleteButton: "Delete",
@@ -931,7 +1069,7 @@ module.exports = {
     blogImage: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
     blogImage: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
     blogPublish: "Preview",
     blogPublish: "Preview",
     noPopularMessages: "No popular messages published, yet",
     noPopularMessages: "No popular messages published, yet",
-    // forum
+    //forum
     forumTitle: "Forums",
     forumTitle: "Forums",
     forumCategoryLabel: "Category",
     forumCategoryLabel: "Category",
     forumTitleLabel: "Title",
     forumTitleLabel: "Title",
@@ -956,6 +1094,22 @@ module.exports = {
     forumVisitForum: "Visit Forum",
     forumVisitForum: "Visit Forum",
     noForums: "No forums found.",
     noForums: "No forums found.",
     forumVisitButton: "Visit forum",
     forumVisitButton: "Visit forum",
+    forumCatGENERAL: "General",
+    forumCatOASIS: "Oasis",
+    forumCatLARP: "L.A.R.P.",
+    forumCatPOLITICS: "Politics",
+    forumCatTECH: "Tech",
+    forumCatSCIENCE: "Science",
+    forumCatMUSIC: "Music",
+    forumCatART: "Art",
+    forumCatGAMING: "Gaming",
+    forumCatBOOKS: "Books",
+    forumCatFILMS: "Films",
+    forumCatPHILOSOPHY: "Philosophy",
+    forumCatSOCIETY: "Society",
+    forumCatPRIVACY: "Privacy",
+    forumCatCYBERWARFARE: "Cyberwarfare",
+    forumCatSURVIVALISM: "Survivalism",
     //images
     //images
     imageTitle: "Images",
     imageTitle: "Images",
     imagePluginTitle: "Title",
     imagePluginTitle: "Title",
@@ -1047,25 +1201,25 @@ module.exports = {
     typeRecent:           "RECENT",
     typeRecent:           "RECENT",
     errorActivity:        "Error retrieving activity",
     errorActivity:        "Error retrieving activity",
     typePost:             "POST",
     typePost:             "POST",
-    typeTribe:            "TRIBE",
-    typeAbout:            "INHABITANT",
+    typeTribe:            "TRIBES",
+    typeAbout:            "INHABITANTS",
     typeCurriculum:       "CV",
     typeCurriculum:       "CV",
-    typeImage:            "IMAGE",
-    typeBookmark:         "BOOKMARK",
-    typeDocument:         "DOCUMENT",
-    typeVotes:            "GOVERNANCE",
-    typeAudio:            "AUDIO",
+    typeImage:            "IMAGES",
+    typeBookmark:         "BOOKMARKS",
+    typeDocument:         "DOCUMENTS",
+    typeVotes:            "VOTATIONS",
+    typeAudio:            "AUDIOS",
     typeMarket:           "MARKET",
     typeMarket:           "MARKET",
-    typeJob:              "JOB",
-    typeProject:          "PROJECT",
-    typeVideo:            "VIDEO",
+    typeJob:              "JOBS",
+    typeProject:          "PROJECTS",
+    typeVideo:            "VIDEOS",
     typeVote:             "SPREAD",
     typeVote:             "SPREAD",
-    typeEvent:            "EVENT",
+    typeEvent:            "EVENTS",
     typeTransfer:         "TRANSFER",
     typeTransfer:         "TRANSFER",
     typeTask:             "TASKS",
     typeTask:             "TASKS",
     typePixelia: 	  "PIXELIA",
     typePixelia: 	  "PIXELIA",
     typeForum: 	          "FORUM",
     typeForum: 	          "FORUM",
-    typeReport:           "REPORT",
+    typeReport:           "REPORTS",
     typeFeed:             "FEED",
     typeFeed:             "FEED",
     typeContact:          "CONTACT",
     typeContact:          "CONTACT",
     typePub:              "PUB",
     typePub:              "PUB",
@@ -1841,8 +1995,8 @@ module.exports = {
     modulesMarketDescription: "Module to exchange goods or services.",
     modulesMarketDescription: "Module to exchange goods or services.",
     modulesTribesLabel: "Tribes",
     modulesTribesLabel: "Tribes",
     modulesTribesDescription: "Module to explore or create tribes (groups).",
     modulesTribesDescription: "Module to explore or create tribes (groups).",
-    modulesGovernanceLabel: "Governance",
-    modulesGovernanceDescription: "Module to discover and manage votes.",
+    modulesVotationsLabel: "Votations",
+    modulesVotationsDescription: "Module to discover and manage votations.",
     modulesReportsLabel: "Reports",
     modulesReportsLabel: "Reports",
     modulesReportsDescription: "Module to manage and track reports related to issues, bugs, abuses, and content warnings.",
     modulesReportsDescription: "Module to manage and track reports related to issues, bugs, abuses, and content warnings.",
     modulesOpinionsLabel: "Opinions",
     modulesOpinionsLabel: "Opinions",
@@ -1851,6 +2005,8 @@ module.exports = {
     modulesTransfersDescription: "Module to discover and manage smart-contracts (transfers).",
     modulesTransfersDescription: "Module to discover and manage smart-contracts (transfers).",
     modulesFeedLabel: "Feed",
     modulesFeedLabel: "Feed",
     modulesFeedDescription: "Module to discover and share short-texts (feeds).",
     modulesFeedDescription: "Module to discover and share short-texts (feeds).",
+    modulesParliamentLabel: "Parliament",
+    modulesParliamentDescription: "Module to elect governments and vote on laws.",
     modulesPixeliaLabel: "Pixelia",
     modulesPixeliaLabel: "Pixelia",
     modulesPixeliaDescription: "Module to draw on a collaborative grid.",
     modulesPixeliaDescription: "Module to draw on a collaborative grid.",
     modulesAgendaLabel: "Agenda",
     modulesAgendaLabel: "Agenda",

+ 195 - 39
src/client/assets/translations/oasis_es.js

@@ -69,7 +69,7 @@ module.exports = {
     readThread: "leer el resto del hilo",    
     readThread: "leer el resto del hilo",    
     // pixelia
     // pixelia
     pixeliaTitle: 'Pixelia',
     pixeliaTitle: 'Pixelia',
-    pixeliaDescription: 'Dibuja pixeles en un grid y colabor-ART con otros en tu red.',
+    pixeliaDescription: 'Dibuja pixeles en un grid y colabor-ART con habitantes en tu red.',
     coordLabel: 'Coordenadas (e.g., A3)',
     coordLabel: 'Coordenadas (e.g., A3)',
     coordPlaceholder: 'Introduce coordenada',
     coordPlaceholder: 'Introduce coordenada',
     contributorsTitle: "Contribuciones",
     contributorsTitle: "Contribuciones",
@@ -305,7 +305,7 @@ module.exports = {
     searchStatusLabel:"Estado",
     searchStatusLabel:"Estado",
     statusLabel:"Estado",
     statusLabel:"Estado",
     totalVotesLabel:"Votos Totales",
     totalVotesLabel:"Votos Totales",
-    votesLabel:"Votos",
+    votesLabel:"Votaciones",
     noResultsFound:"No se han encontrado resultados.",
     noResultsFound:"No se han encontrado resultados.",
     author:"Autoría",
     author:"Autoría",
     createdAtLabel:"Creado el",
     createdAtLabel:"Creado el",
@@ -582,7 +582,7 @@ module.exports = {
     audioNoFile: "No se proporcionó ningún archivo de audio",
     audioNoFile: "No se proporcionó ningún archivo de audio",
     audioNotSupported: "Tu navegador no soporta el elemento de audio.",
     audioNotSupported: "Tu navegador no soporta el elemento de audio.",
     noAudios: "No hay audios disponibles.",
     noAudios: "No hay audios disponibles.",
-    // inhabitants
+    //inhabitants
     yourContacts:       "Tus Contactos",
     yourContacts:       "Tus Contactos",
     allInhabitants:     "Habitantes",
     allInhabitants:     "Habitantes",
     allCVs:             "Todos los CVs",
     allCVs:             "Todos los CVs",
@@ -618,11 +618,149 @@ module.exports = {
     oasisId: "ID",
     oasisId: "ID",
     noInhabitantsFound:    "No se encontraron habitantes, aún.",
     noInhabitantsFound:    "No se encontraron habitantes, aún.",
     inhabitantActivityLevel: "Nivel Actividad",
     inhabitantActivityLevel: "Nivel Actividad",
+    //parliament
+    parliamentTitle: "Parlamento",
+    parliamentDescription: "Explora formas de gobierno y políticas de gestión colectiva.",
+    parliamentFilterGovernment: "GOBIERNO",
+    parliamentFilterCandidatures: "CANDIDATURAS",
+    parliamentFilterProposals: "PROPUESTAS",
+    parliamentFilterLaws: "LEYES",
+    parliamentFilterHistorical: "HISTÓRICO",
+    parliamentFilterLeaders: "LÍDERES",
+    parliamentFilterRules: "REGLAS",
+    parliamentGovernmentCard: "Gobierno actual",
+    parliamentGovMethod: "MÉTODO DE GOBIERNO",
+    parliamentActorInPowerInhabitant: 'HABITANTE EN EL PODER',
+    parliamentActorInPowerTribe: 'TRIBU EN EL PODER',
+    parliamentHistoricalGovernmentsTitle: 'GOBIERNOS',
+    parliamentHistoricalElectionsTitle: 'CICLOS ELECTORALES',
+    parliamentHistoricalLawsTitle: 'HISTÓRICO',
+    parliamentHistoricalLeadersTitle: 'LÍDERES',
+    parliamentThCycles: 'CICLOS',
+    parliamentThTimesInPower: 'MANDATOS',
+    parliamentThTotalCandidatures: 'CANDIDATURAS',
+    parliamentThProposed: 'LEYES PROPUESTAS',
+    parliamentThApproved: 'LEYES APROBADAS',
+    parliamentThDeclined: 'LEYES RECHAZADAS',
+    parliamentThDiscarded: 'LEYES DESCARTADAS',
+    parliamentVotesReceived: "VOTOS RECIBIDOS",
+    parliamentMembers: "MIEMBROS",
+    parliamentLegSince: "INICIO DEL CICLO",
+    parliamentLegEnd: "FIN DEL CICLO",
+    parliamentPoliciesProposal: "PROPUESTAS DE LEYES",
+    parliamentPoliciesApproved: "LEYES APROBADAS",
+    parliamentPoliciesDeclined: "LEYES RECHAZADAS",
+    parliamentPoliciesDiscarded: "LEYES DESCARTADAS",
+    parliamentEfficiency: "% EFICIENCIA",
+    parliamentFilterRevocations: 'REVOCACIONES',
+    parliamentRevocationFormTitle: 'Revocar Ley',
+    parliamentRevocationLaw: 'Ley',
+    parliamentRevocationTitle: 'Título',
+    parliamentRevocationReasons: 'Motivos',
+    parliamentRevocationPublish: 'Publicar Revocación',
+    parliamentCurrentRevocationsTitle: 'Revocaciones Actuales',
+    parliamentFutureRevocationsTitle: 'Revocaciones Futuras',
+    parliamentPoliciesRevocated: 'LEYES REVOCADAS',
+    parliamentPoliciesTitle: 'HISTÓRICO',
+    parliamentLawsTitle: 'LEYES APROBADAS',
+    parliamentRulesRevocations: 'Cualquier ley aprobada puede ser revocada utilizando el método de gobierno vigente.',
+    parliamentNoStableGov: "Aún no hay un gobierno elegido.",
+    parliamentNoGovernments: "Todavía no hay gobiernos en el histórico.",
+    parliamentCandidatureFormTitle: "Proponer Candidatura",
+    parliamentCandidatureId: "Candidatura",
+    parliamentCandidatureIdPh: "Oasis ID (@...) o nombre de la tribu",
+    parliamentCandidatureSlogan: "Lema (máx. 140 car.)",
+    parliamentCandidatureSloganPh: "Un lema corto",
+    parliamentCandidatureMethod: "Método",
+    parliamentCandidatureProposeBtn: "Publicar Candidatura",
+    parliamentThType: "Tipo",
+    parliamentThId: "ID",
+    parliamentThDate: "Fecha de propuesta",
+    parliamentThSlogan: "Lema",
+    parliamentThMethod: "Método",
+    parliamentThKarma: "Karma",
+    parliamentThSince: "Perfil desde",
+    parliamentThVotes: "Votos recibidos",
+    parliamentThVoteAction: "Votar",
+    parliamentTypeUser: "Habitante",
+    parliamentTypeTribe: "Tribu",
+    parliamentVoteBtn: "Votar",
+    parliamentProposalFormTitle: "Proponer Ley",
+    parliamentProposalTitle: "Título",
+    parliamentProposalDescription: "Descripción (≤1000)",
+    parliamentProposalPublish: "Publicar Propuesta",
+    parliamentOpenVote: "Votación abierta",
+    parliamentFinalize: "Finalizar",
+    parliamentDeadline: "Fecha límite",
+    parliamentStatus: "Estado",
+    parliamentNoProposals: "Aún no hay propuestas de ley.",
+    parliamentNoLaws: "Aún no hay leyes aprobadas.",
+    parliamentLawMethod: "Método",
+    parliamentLawProposer: "Propuesta por",
+    parliamentLawVotes: "SÍ/Total",
+    parliamentLawEnacted: "Promulgada el",
+    parliamentMethodDEMOCRACY: "Democracia",
+    parliamentMethodMAJORITY: "Mayoría (80%)",
+    parliamentMethodMINORITY: "Minoría (20%)",
+    parliamentMethodDICTATORSHIP: "Dictadura",
+    parliamentMethodKARMATOCRACY: "Karmatocracia",
+    parliamentMethodANARCHY: "Anarquía",
+    parliamentThId: "ID",
+    parliamentThProposalDate: "Fecha de propuesta",
+    parliamentThMethod: "Método",
+    parliamentThKarma: "Karma",
+    parliamentThSupports: "Apoyos",
+    parliamentThVote: "Votar",
+    parliamentCurrentProposalsTitle: "Propuestas actuales",
+    parliamentVotesSlashTotal: "Votos/Total",
+    parliamentVotesNeeded: "Votos Necesarios",
+    parliamentFutureLawsTitle: "Futuras leyes",
+    parliamentNoFutureLaws: "Aún no hay futuras leyes.",
+    parliamentVoteAction: "Votar",
+    parliamentLeadersTitle: "Líderes",
+    parliamentThLeader: "AVATAR",
+    parliamentPopulation: "Población",
+    parliamentThType: "Tipo",
+    parliamentThInPower: "Gobiernos",
+    parliamentThPresented: "CANDIDATURAS",
+    parliamentNoLeaders: "Aún no hay líderes.",
+    typeParliament: "PARLAMENTO",
+    typeParliamentCandidature: "Parlamento · Candidatura",
+    typeParliamentTerm: "Parlamento · Ciclo",
+    typeParliamentProposal: "Parlamento · Propuesta",
+    typeParliamentLaw: "Parlamento · Nueva Ley",
+    parliamentLawQuestion: "Pregunta",
+    parliamentStatus: "Estado",
+    parliamentCandidaturesListTitle: "Lista de Candidaturas",
+    parliamentElectionsEnd: "Fin de las elecciones",
+    parliamentTimeRemaining: "Tiempo restante",
+    parliamentCurrentLeader: "Candidatura ganadora",
+    parliamentNoLeader: "Aún no hay candidatura ganadora.",
+    parliamentElectionsStatusTitle: "Próximo Gobierno",
+    parliamentElectionsStart: "Inicio de las Elecciones",
+    parliamentProposalDeadlineLabel: "Fecha límite",
+    parliamentProposalTimeLeft: "Tiempo restante",
+    parliamentProposalOnTrack: "Con apoyo suficiente",
+    parliamentProposalOffTrack: "Apoyo insuficiente",
+    parliamentRulesTitle: "Cómo funciona el Parlamento",
+    parliamentRulesIntro: "Las elecciones se resuelven cada 2 meses; las candidaturas son continuas y se reinician cuando se elige un gobierno.",
+    parliamentRulesCandidates: "Cualquier habitante puede proponerse a sí mismo, a otro habitante o a cualquier tribu. Cada habitante puede proponer hasta 3 candidaturas por ciclo; se rechazan duplicados en el mismo ciclo.",
+    parliamentRulesElection: "Gana la candidatura con más votos en el momento de la resolución.",
+    parliamentRulesTies: "Desempate: mayor karma del habitante; si persiste, perfil más antiguo; si persiste, propuesta más temprana; después, orden lexicográfico por ID.",
+    parliamentRulesFallback: "Si nadie vota, gana la última candidatura propuesta. Si no hay candidaturas, se selecciona una tribu al azar.",
+    parliamentRulesTerm: "La pestaña Gobierno muestra el gobierno actual y sus estadísticas.",
+    parliamentRulesMethods: "Formas: Anarquía (mayoría simple), Democracia (50%+1), Mayoría (80%), Minoría (20%), Karmatocracia (propuestas con mayor karma), Dictadura (aprobación instantánea).",
+    parliamentRulesAnarchy: "Anarquía es el modo por defecto: si al resolver no se elige ninguna candidatura, se proclama la Anarquía. En Anarquía, cualquier habitante puede proponer leyes.",
+    parliamentRulesProposals: "Si eres el habitante gobernante o miembro de la tribu gobernante, puedes publicar propuestas de ley. Los métodos no dictatoriales crean una votación pública.",
+    parliamentRulesLimit: "Cada habitante puede publicar como máximo 3 propuestas de ley por ciclo.",
+    parliamentRulesLaws: "Cuando una propuesta alcanza su umbral, se convierte en Ley y aparece en la pestaña Leyes con su fecha de entrada en vigor.",
+    parliamentRulesHistorical: "En Histórico se puede ver cada ciclo de gobierno que ha habido y datos sobre su gestión.",
+    parliamentRulesLeaders: "En Líderes se puede ver un ranking de habitantes/tribus que han gobernado (o se han presentado), ordenados por eficacia.",
     //trending
     //trending
     trendingTitle: "Tendencias",
     trendingTitle: "Tendencias",
     exploreTrending: "Explora el contenido más popular en tu red.",
     exploreTrending: "Explora el contenido más popular en tu red.",
     ALLButton: "TODOS",
     ALLButton: "TODOS",
-    MINEButton: "MIAS",
+    MINEButton: "MÍAS",
     RECENTButton: "RECIENTES",
     RECENTButton: "RECIENTES",
     TOPButton: "TOP",
     TOPButton: "TOP",
     bookmarkButton: "MARCADORES",
     bookmarkButton: "MARCADORES",
@@ -696,7 +834,7 @@ module.exports = {
     taskUpdateButton: "Actualizar",
     taskUpdateButton: "Actualizar",
     taskDeleteButton: "Eliminar",
     taskDeleteButton: "Eliminar",
     taskFilterAll: "TODOS",
     taskFilterAll: "TODOS",
-    taskFilterMine: "MIAS",
+    taskFilterMine: "MÍAS",
     taskFilterOpen: "ABIERTAS",
     taskFilterOpen: "ABIERTAS",
     taskFilterInProgress: "EN-PROCESO",
     taskFilterInProgress: "EN-PROCESO",
     taskFilterClosed: "CERRADAS",
     taskFilterClosed: "CERRADAS",
@@ -801,7 +939,7 @@ module.exports = {
     transfersFrom: "De",
     transfersFrom: "De",
     transfersTo: "A",
     transfersTo: "A",
     transfersFilterAll: "TODOS",
     transfersFilterAll: "TODOS",
-    transfersFilterMine: "MIAS",
+    transfersFilterMine: "MÍAS",
     transfersFilterMarket: "MERCADO",
     transfersFilterMarket: "MERCADO",
     transfersFilterTop: "TOP",
     transfersFilterTop: "TOP",
     transfersFilterPending: "PENDIENTES",
     transfersFilterPending: "PENDIENTES",
@@ -833,9 +971,9 @@ module.exports = {
     transfersClosedSectionTitle: "Transferencias Cerradas",
     transfersClosedSectionTitle: "Transferencias Cerradas",
     transfersDiscardedSectionTitle: "Transferencias Descartadas",
     transfersDiscardedSectionTitle: "Transferencias Descartadas",
     transfersAllSectionTitle: "Transferencias",
     transfersAllSectionTitle: "Transferencias",
-    //governance (voting/polls)
-    governanceTitle: "Gobierno",
-    governanceDescription: "Descubre y gestiona votaciones en tu red.",
+    //votations (voting/polls)
+    votationsTitle: "Votaciones",
+    votationsDescription: "Descubre y gestiona votaciones en tu red.",
     voteMineSectionTitle: "Tus Votaciones",
     voteMineSectionTitle: "Tus Votaciones",
     voteCreateSectionTitle: "Crear Votación",
     voteCreateSectionTitle: "Crear Votación",
     voteUpdateSectionTitle: "Actualizar",
     voteUpdateSectionTitle: "Actualizar",
@@ -852,7 +990,7 @@ module.exports = {
     voteFollowMajority: "SEGUIR MAYORÍA",
     voteFollowMajority: "SEGUIR MAYORÍA",
     voteNotInterested: "SIN INTERÉS",
     voteNotInterested: "SIN INTERÉS",
     voteFilterAll: "TODOS",
     voteFilterAll: "TODOS",
-    voteFilterMine: "MIAS",
+    voteFilterMine: "MÍAS",
     voteFilterOpen: "ABIERTAS",
     voteFilterOpen: "ABIERTAS",
     voteFilterClosed: "CERRADAS",
     voteFilterClosed: "CERRADAS",
     voteQuestionLabel: "Pregunta",
     voteQuestionLabel: "Pregunta",
@@ -934,9 +1072,9 @@ module.exports = {
     forumTitlePlaceholder: "Título del foro...",
     forumTitlePlaceholder: "Título del foro...",
     forumCreateButton: "Crear foro",
     forumCreateButton: "Crear foro",
     forumCreateSectionTitle: "Crear foro",
     forumCreateSectionTitle: "Crear foro",
-    forumDescription: "Habla abiertamente con otras habitantes de tu red.",
+    forumDescription: "Habla abiertamente con habitantes de tu red.",
     forumFilterAll: "TODOS",
     forumFilterAll: "TODOS",
-    forumFilterMine: "MIAS",
+    forumFilterMine: "MÍAS",
     forumFilterRecent: "RECIENTES",
     forumFilterRecent: "RECIENTES",
     forumFilterTop: "TOP",
     forumFilterTop: "TOP",
     forumMineSectionTitle: "Tus Foros",
     forumMineSectionTitle: "Tus Foros",
@@ -952,6 +1090,22 @@ module.exports = {
     forumVisitForum: "Visitar Foro",
     forumVisitForum: "Visitar Foro",
     noForums: "No hay foros disponibles, aún.",
     noForums: "No hay foros disponibles, aún.",
     forumVisitButton: "Visitar foro", 
     forumVisitButton: "Visitar foro", 
+    forumCatGENERAL: "General",
+    forumCatOASIS: "Oasis",
+    forumCatLARP: "L.A.R.P.",
+    forumCatPOLITICS: "Política",
+    forumCatTECH: "Tecnología",
+    forumCatSCIENCE: "Ciencia",
+    forumCatMUSIC: "Música",
+    forumCatART: "Arte",
+    forumCatGAMING: "Videojuegos",
+    forumCatBOOKS: "Libros",
+    forumCatFILMS: "Cine",
+    forumCatPHILOSOPHY: "Filosofía",
+    forumCatSOCIETY: "Sociedad",
+    forumCatPRIVACY: "Privacidad",
+    forumCatCYBERWARFARE: "Ciberguerra",
+    forumCatSURVIVALISM: "Supervivencia",
     // images
     // images
     imageTitle: "Imágenes",
     imageTitle: "Imágenes",
     imagePluginTitle: "Título",
     imagePluginTitle: "Título",
@@ -963,7 +1117,7 @@ module.exports = {
     imageUpdateSectionTitle: "Actualizar Imagen",
     imageUpdateSectionTitle: "Actualizar Imagen",
     imageAllSectionTitle: "Imágenes",
     imageAllSectionTitle: "Imágenes",
     imageFilterAll: "TODOS",
     imageFilterAll: "TODOS",
-    imageFilterMine: "MIAS",
+    imageFilterMine: "MÍAS",
     imageCreateButton: "Subir Imagen",
     imageCreateButton: "Subir Imagen",
     imageEditDescription: "Edita los detalles de tu imagen.",
     imageEditDescription: "Edita los detalles de tu imagen.",
     imageCreateDescription: "Crea una imagen.",
     imageCreateDescription: "Crea una imagen.",
@@ -1025,7 +1179,7 @@ module.exports = {
     activityList:         "Actividad",
     activityList:         "Actividad",
     activityDesc:         "Consulta la actividad reciente de tu red.",
     activityDesc:         "Consulta la actividad reciente de tu red.",
     allButton:            "TODOS",
     allButton:            "TODOS",
-    mineButton:           "MÍO",
+    mineButton:           "MÍAS",
     noActions:            "No hay actividad disponible.",
     noActions:            "No hay actividad disponible.",
     performed:            "→",
     performed:            "→",
     from:                 "De",
     from:                 "De",
@@ -1042,30 +1196,30 @@ module.exports = {
     playVideo:            "Reproducir vídeo",
     playVideo:            "Reproducir vídeo",
     typeRecent:           "RECIENTE",
     typeRecent:           "RECIENTE",
     errorActivity:        "Error al recuperar la actividad",
     errorActivity:        "Error al recuperar la actividad",
-    typePost:             "PUBLICACIÓN",
-    typeTribe:            "TRIBU",
-    typeAbout:            "HABITANTE",
-    typeCurriculum:       "CV",
-    typeImage:            "IMAGEN",
-    typeBookmark:         "MARCADOR",
-    typeDocument:         "DOCUMENTO",
-    typeVotes:            "GOBERNANZA",
-    typeAudio:            "AUDIO",
+    typePost:             "PUBLICACIONES",
+    typeTribe:            "TRIBUS",
+    typeAbout:            "HABITANTES",
+    typeCurriculum:       "CVs",
+    typeImage:            "IMAGENES",
+    typeBookmark:         "MARCADORES",
+    typeDocument:         "DOCUMENTOS",
+    typeVotes:            "VOTACIONES",
+    typeAudio:            "AUDIOS",
     typeMarket:           "MERCADO",
     typeMarket:           "MERCADO",
-    typeJob:              "TRABAJO",
-    typeProject:          "PROYECTO",
-    typeVideo:            "VÍDEO",
+    typeJob:              "TRABAJOS",
+    typeProject:          "PROYECTOS",
+    typeVideo:            "VÍDEOS",
     typeVote:             "DIFUSIÓN",
     typeVote:             "DIFUSIÓN",
-    typeEvent:            "EVENTO",
-    typeTransfer:         "TRANSFERENCIA",
+    typeEvent:            "EVENTOS",
+    typeTransfer:         "TRANSFERENCIAS",
     typeTask:             "TAREAS",
     typeTask:             "TAREAS",
     typePixelia:          "PIXELIA",
     typePixelia:          "PIXELIA",
-    typeForum:            "FORO",
-    typeReport:           "REPORTE",
+    typeForum:            "FOROS",
+    typeReport:           "REPORTES",
     typeFeed:             "FEED",
     typeFeed:             "FEED",
-    typeContact:          "CONTACTO",
-    typePub:              "PUB",
-    typeTombstone:        "TOMBSTONE",
+    typeContact:          "CONTACTOS",
+    typePub:              "PUBs",
+    typeTombstone:        "TOMBSTONES",
     typeBanking:          "BANCA",
     typeBanking:          "BANCA",
     activitySupport:      "Nueva alianza forjada",
     activitySupport:      "Nueva alianza forjada",
     activityJoin:         "Nuevo PUB unido",
     activityJoin:         "Nuevo PUB unido",
@@ -1175,7 +1329,7 @@ module.exports = {
     tribeviewTribeButton: "Visitar Tribu",
     tribeviewTribeButton: "Visitar Tribu",
     tribeDescription: "Explora o crea tribus en tu red.",
     tribeDescription: "Explora o crea tribus en tu red.",
     tribeFilterAll: "TODOS",
     tribeFilterAll: "TODOS",
-    tribeFilterMine: "MIAS",
+    tribeFilterMine: "MÍAS",
     tribeFilterMembership: "MIEMBROS",
     tribeFilterMembership: "MIEMBROS",
     tribeFilterRecent: "RECIENTES",
     tribeFilterRecent: "RECIENTES",
     tribeFilterLarp: "L.A.R.P.",
     tribeFilterLarp: "L.A.R.P.",
@@ -1283,7 +1437,7 @@ module.exports = {
     alreadyVoted:         "Ya has opinado.",
     alreadyVoted:         "Ya has opinado.",
     noOpinionsFound:      "No se encontraron opiniones.",
     noOpinionsFound:      "No se encontraron opiniones.",
     ALLButton:            "TODOS",
     ALLButton:            "TODOS",
-    MINEButton:           "MIAS",
+    MINEButton:           "MÍAS",
     RECENTButton:         "RECIENTES",
     RECENTButton:         "RECIENTES",
     TOPButton:            "TOP",
     TOPButton:            "TOP",
     interestingButton:    "INTERESANTE",
     interestingButton:    "INTERESANTE",
@@ -1451,8 +1605,8 @@ module.exports = {
     statsInhabitant: "Estadísticas de Habitantes",
     statsInhabitant: "Estadísticas de Habitantes",
     statsDescription: "Descubre las estadísticas de tu red.",
     statsDescription: "Descubre las estadísticas de tu red.",
     ALLButton: "TODOS",
     ALLButton: "TODOS",
-    MINEButton: "MIS",
-    TOMBSTONEButton: "LÁPIDAS",
+    MINEButton: "MÍAS",
+    TOMBSTONEButton: "TUMBAS",
     statsYou: "Tú",
     statsYou: "Tú",
     statsUserId: "ID de Oasis",
     statsUserId: "ID de Oasis",
     statsCreatedAt: "Creado el",
     statsCreatedAt: "Creado el",
@@ -1853,8 +2007,10 @@ module.exports = {
     modulesMarketDescription: "Módulo para intercambiar bienes o servicios.",
     modulesMarketDescription: "Módulo para intercambiar bienes o servicios.",
     modulesTribesLabel: "Tribus",
     modulesTribesLabel: "Tribus",
     modulesTribesDescription: "Módulo para explorar o crear tribus (grupos).",
     modulesTribesDescription: "Módulo para explorar o crear tribus (grupos).",
-    modulesGovernanceLabel: "Gobierno",
-    modulesGovernanceDescription: "Módulo para descubrir y gestionar votos.",
+    modulesVotationsLabel: "Votaciones",
+    modulesVotationsDescription: "Módulo para descubrir y gestionar votaciones.",
+    modulesParliamentLabel: "Parlamento",
+    modulesParliamentDescription: "Módulo para elegir gobiernos y votar leyes.",
     modulesReportsLabel: "Informes",
     modulesReportsLabel: "Informes",
     modulesReportsDescription: "Módulo para gestionar y hacer un seguimiento de informes relacionados con problemas, errores, abusos y advertencias de contenido.",
     modulesReportsDescription: "Módulo para gestionar y hacer un seguimiento de informes relacionados con problemas, errores, abusos y advertencias de contenido.",
     modulesOpinionsLabel: "Opiniones",
     modulesOpinionsLabel: "Opiniones",

+ 163 - 7
src/client/assets/translations/oasis_eu.js

@@ -305,7 +305,7 @@ module.exports = {
     searchStatusLabel:"Egoera",
     searchStatusLabel:"Egoera",
     statusLabel:"Egoera",
     statusLabel:"Egoera",
     totalVotesLabel:"Bozkak Guztira",
     totalVotesLabel:"Bozkak Guztira",
-    votesLabel:"Bozkak",
+    votesLabel:"Bozketak",
     noResultsFound:"Emaitzik ez.",
     noResultsFound:"Emaitzik ez.",
     author:"Egilea",
     author:"Egilea",
     createdAtLabel:"Noiz",
     createdAtLabel:"Noiz",
@@ -583,7 +583,7 @@ module.exports = {
     audioNoFile: "Ez duzu audio fitxategirik eman",
     audioNoFile: "Ez duzu audio fitxategirik eman",
     audioNotSupported: "Zure arakatzaileak ez du audio elementua jasaten.",
     audioNotSupported: "Zure arakatzaileak ez du audio elementua jasaten.",
     noAudios: "Audiorik ez.",
     noAudios: "Audiorik ez.",
-    // inhabitants
+    //inhabitants
     yourContacts:       "Zure Kontaktuak",
     yourContacts:       "Zure Kontaktuak",
     allInhabitants:     "Bizilagunak",
     allInhabitants:     "Bizilagunak",
     allCVs:             "CV guztiak",
     allCVs:             "CV guztiak",
@@ -619,6 +619,144 @@ module.exports = {
     oasisId: "ID-a",
     oasisId: "ID-a",
     noInhabitantsFound:  "Bizilagunik ez, oraindik.",
     noInhabitantsFound:  "Bizilagunik ez, oraindik.",
     inhabitantActivityLevel: "Jarduera Maila",
     inhabitantActivityLevel: "Jarduera Maila",
+    //parliament
+    parliamentTitle: "Legebiltzarra",
+    parliamentDescription: "Gobernu formak eta kudeaketa kolektiboko politikak arakatu.",
+    parliamentFilterGovernment: "GOBERNUA",
+    parliamentFilterCandidatures: "KANDIDATURAK",
+    parliamentFilterProposals: "PROPOSAMENAK",
+    parliamentFilterLaws: "LEGEAK",
+    parliamentFilterHistorical: "HISTORIKOA",
+    parliamentFilterLeaders: "LIDERAK",
+    parliamentFilterRules: "ARAUAK",
+    parliamentGovernmentCard: "Uneko gobernua",
+    parliamentGovMethod: "GOBERNU METODOA",
+    parliamentActorInPowerInhabitant: 'BIZTANLEA AGINTEAN',
+    parliamentActorInPowerTribe: 'TRIBUA AGINTEAN',
+    parliamentHistoricalGovernmentsTitle: 'GOBERNUAK',
+    parliamentHistoricalElectionsTitle: 'HAUTESKUNDE ZIKLOAK',
+    parliamentHistoricalLawsTitle: 'HISTORIKOA',
+    parliamentHistoricalLeadersTitle: 'BURUZAGIAK',
+    parliamentThCycles: 'ZIKLOAK',
+    parliamentThTimesInPower: 'AGINTEAN',
+    parliamentThTotalCandidatures: 'KANDIDATURAK',
+    parliamentThProposed: 'PROPOSATUTAKO LEGEAK',
+    parliamentThApproved: 'ONARTUTAKO LEGEAK',
+    parliamentThDeclined: 'UKATUTAKO LEGEAK',
+    parliamentThDiscarded: 'BAZTERTUTAKO LEGEAK',
+    parliamentVotesReceived: "JASOTAKO BOTOAK",
+    parliamentMembers: "KIDEAK",
+    parliamentLegSince: "ZIKLOAREN HASIERA",
+    parliamentLegEnd: "ZIKLOAREN AMAIERA",
+    parliamentPoliciesProposal: "POLITIKA PROPOSAMENAK",
+    parliamentPoliciesApproved: "ONARTUTAKO LEGEAK",
+    parliamentPoliciesDeclined: "BAZTERTUTAKO LEGEAK",
+    parliamentPoliciesDiscarded: "POLITIKA BAZTERTUAK",
+    parliamentEfficiency: "% ERAGINKORTASUNA",
+    parliamentFilterRevocations: 'INDARGABETZEAK',
+    parliamentRevocationFormTitle: 'INDARGABETU',
+    parliamentRevocationLaw: 'Legea',
+    parliamentRevocationTitle: 'Izenburua',
+    parliamentRevocationReasons: 'Arrazoiak',
+    parliamentRevocationPublish: "Indargabetzea Argitaratu",
+    parliamentCurrentRevocationsTitle: 'Uneko Indargabetzeak',
+    parliamentFutureRevocationsTitle: 'Etorkizuneko Indargabetzeak',
+    parliamentPoliciesRevocated: 'HISTORIKOA',
+    parliamentPoliciesTitle: 'HISTORICAL',
+    parliamentLawsTitle: 'ONARTUTAKO LEGEAK',
+    parliamentRulesRevocations: 'Onartutako edozein lege indargabetu daiteke indarrean dagoen gobernu metodoa erabiliz.',
+    parliamentNoStableGov: "Oraindik ez da gobernurik hautatu.",
+    parliamentNoGovernments: "Oraindik ez dago gobernurik.",
+    parliamentCandidatureFormTitle: "Kandidatura Proposatu",
+    parliamentCandidatureId: "Kandidatura",
+    parliamentCandidatureIdPh: "Oasis ID (@...) edo tribuaren izena",
+    parliamentCandidatureSlogan: "Leloa (geh. 140 karaktere)",
+    parliamentCandidatureSloganPh: "Lelo labur bat",
+    parliamentCandidatureMethod: "Metodoa",
+    parliamentCandidatureProposeBtn: "Hautagaitza Proposatu",
+    parliamentThType: "Mota",
+    parliamentThId: "ID",
+    parliamentThDate: "Proposamen data",
+    parliamentThSlogan: "Leloa",
+    parliamentThMethod: "Metodoa",
+    parliamentThKarma: "Karma",
+    parliamentThSince: "Profila noiztik",
+    parliamentThVotes: "Jasotako botoak",
+    parliamentThVoteAction: "Bozkatu",
+    parliamentTypeUser: "Biztanlea",
+    parliamentTypeTribe: "Tribu",
+    parliamentVoteBtn: "Bozkatu",
+    parliamentProposalFormTitle: "Legea Proposatu",
+    parliamentProposalTitle: "Izenburua",
+    parliamentProposalDescription: "Deskribapena (≤1000)",
+    parliamentProposalPublish: "Proposamena Argitaratu",
+    parliamentOpenVote: "Bozketa irekia",
+    parliamentFinalize: "Amaitu",
+    parliamentDeadline: "Epea",
+    parliamentStatus: "Egoera",
+    parliamentNoProposals: "Oraindik ez dago lege-proposamenik.",
+    parliamentNoLaws: "Oraindik ez dago onartutako legerik.",
+    parliamentLawMethod: "Metodoa",
+    parliamentLawProposer: "Nork proposatua",
+    parliamentLawVotes: "BAI/Guztira",
+    parliamentLawEnacted: "Indarrean sartu den data",
+    parliamentMethodDEMOCRACY: "Demokrazia",
+    parliamentMethodMAJORITY: "Gehiengoa (80%)",
+    parliamentMethodMINORITY: "Gutxiengoa (20%)",
+    parliamentMethodDICTATORSHIP: "Diktadura",
+    parliamentMethodKARMATOCRACY: "Karmatokrazia",
+    parliamentMethodANARCHY: "Anarkia",
+    parliamentThId: "ID",
+    parliamentThProposalDate: "Proposamenaren data",
+    parliamentThMethod: "Metodoa",
+    parliamentThKarma: "Karma",
+    parliamentThSupports: "Babesak",
+    parliamentThVote: "Bozkatu",
+    parliamentCurrentProposalsTitle: "Uneko proposamenak",
+    parliamentVotesSlashTotal: "Botoak/Guztira",
+    parliamentVotesNeeded: "Beharrezko Botoak",
+    parliamentFutureLawsTitle: "Etorkizuneko legeak",
+    parliamentNoFutureLaws: "Oraindik ez dago etorkizuneko legerik",
+    parliamentVoteAction: "Bozkatu",
+    parliamentLeadersTitle: "Liderak",
+    parliamentThLeader: "AVATAR",
+    parliamentPopulation: "Biztanleria",
+    parliamentThType: "Mota",
+    parliamentThInPower: "Agintean",
+    parliamentThPresented: "KANDIDATURAK",
+    parliamentNoLeaders: "Oraindik ez dago liderrik.",
+    typeParliament: "PARLAMENTU",
+    typeParliamentCandidature: "Parlamentu · Hautagaitza",
+    typeParliamentTerm: "Parlamentu · Zikloa",
+    typeParliamentProposal: "Parlamentu · Proposamena",
+    typeParliamentLaw: "Parlamentu · Lege Berria",
+    parliamentLawQuestion: "Galdera",
+    parliamentStatus: "Egoera",
+    parliamentCandidaturesListTitle: "Kandidaturen Zerrenda",
+    parliamentElectionsEnd: "Hauteskundeen amaiera",
+    parliamentTimeRemaining: "Geratzen den denbora",
+    parliamentCurrentLeader: "Irabazten ari den kandidatura",
+    parliamentNoLeader: "Oraindik ez dago irabazten ari den kandidaturarik.",
+    parliamentElectionsStatusTitle: "Hurrengo Gobernua",
+    parliamentElectionsStart: "Hauteskundeen hasiera",
+    parliamentProposalDeadlineLabel: "Epemuga",
+    parliamentProposalTimeLeft: "Geratzen den denbora",
+    parliamentProposalOnTrack: "Onartzeko bidean",
+    parliamentProposalOffTrack: "Euskarri nahikorik ez",
+    parliamentRulesTitle: "Legebiltzarrak nola funtzionatzen duen",
+    parliamentRulesIntro: "Hauteskundeak 2 hilean behin ebazten dira; hautagaitzak etengabeak dira eta gobernua aukeratzean berrabiarazten dira.",
+    parliamentRulesCandidates: "Edozein biztanlek bere burua, beste biztanle bat edo edozein tribu proposa dezake. Biztanle bakoitzak gehienez 3 hautagaitza proposa ditzake ziklo bakoitzean; ziklo bereko bikoiztuak baztertzen dira.",
+    parliamentRulesElection: "Irabazlea ebazpen unean boto gehien dituen hautagaitza da.",
+    parliamentRulesTies: "Berdinketa haustea: biztanlearen karma handiena; berdin jarraituz gero, profilaren antzinatasun handiena; oraindik berdin, proposamen goiztiarrena; ondoren, IDaren hurrenkera lexikografikoa.",
+    parliamentRulesFallback: "Inork bozkatzen ez badu, azken proposatutako hautagaitzak irabazten du. Hautagaitzarik ez badago, ausazko tribu bat hautatzen da.",
+    parliamentRulesTerm: "Gobernua fitxak uneko gobernua eta estatistikak erakusten ditu.",
+    parliamentRulesMethods: "Ereduak: Anarkia (gehiengo sinplea), Demokrazia (%50+1), Gehiengoa (%80), Gutxiengoa (%20), Karmatokrazia (karma handiena duten proposamenak), Diktadura (berehalako onarpena).",
+    parliamentRulesAnarchy: "Anarkia modu lehenetsia da: ebazpenean ez bada kandidaturarik hautatzen, Anarkia ezartzen da. Anarkian, edozein biztanlek legeak proposa ditzake.",
+    parliamentRulesProposals: "Agintean dagoen biztanlea bazara edo aginteko tribuko kidea bazara, lege-proposamenak argitaratu ditzakezu. Diktaduraz besteko metodoek boz publiko bat sortzen dute.",
+    parliamentRulesLimit: "Biztanle bakoitzak gehienez 3 proposamen argitaratu ditzake ziklo bakoitzean.",
+    parliamentRulesLaws: "Proposamenak bere atalasea gainditzen duenean, Lege bihurtzen da eta Legeak fitxan agertzen da indarrean sartzeko datarekin.",
+    parliamentRulesHistorical: "Historia fitxan egondako gobernu ziklo bakoitza eta haren kudeaketari buruzko datuak ikus daitezke.",
+    parliamentRulesLeaders: "Liderak fitxan, gobernatu duten (edo aurkeztu diren) biztanle/tribuen sailkapena ikus daiteke, eraginkortasunaren arabera ordenatuta.",
     //trending
     //trending
     trendingTitle: "Pil-pilean",
     trendingTitle: "Pil-pilean",
     exploreTrending: "Aurkitu pil-pileko edukia zure sarean.",
     exploreTrending: "Aurkitu pil-pileko edukia zure sarean.",
@@ -834,9 +972,9 @@ module.exports = {
     transfersClosedSectionTitle: "Transferentziak Itxita",
     transfersClosedSectionTitle: "Transferentziak Itxita",
     transfersDiscardedSectionTitle: "Transferentziak Baztertuta",
     transfersDiscardedSectionTitle: "Transferentziak Baztertuta",
     transfersAllSectionTitle: "Transferentziak",
     transfersAllSectionTitle: "Transferentziak",
-    //governance (voting/polls)
-    governanceTitle: "Gobernua",
-    governanceDescription: "Aurkitu eta kudeatu bozketak zure sarean.",
+    //votations (voting/polls)
+    votationsTitle: "Bozketak",
+    votationsDescription: "Aurkitu eta kudeatu bozketak zure sarean.",
     voteMineSectionTitle: "Zeure Bozketak",
     voteMineSectionTitle: "Zeure Bozketak",
     voteCreateSectionTitle: "Sortu Bozketa",
     voteCreateSectionTitle: "Sortu Bozketa",
     voteUpdateSectionTitle: "Eguneratu",
     voteUpdateSectionTitle: "Eguneratu",
@@ -953,6 +1091,22 @@ module.exports = {
     forumVisitForum: "Bisitatu Foroaren",
     forumVisitForum: "Bisitatu Foroaren",
     noForums: "Ez da fororik aurkitu.",
     noForums: "Ez da fororik aurkitu.",
     forumVisitButton: "Foroa bisitatu", 
     forumVisitButton: "Foroa bisitatu", 
+    forumCatGENERAL: "Orokorra",
+    forumCatOASIS: "Oasis",
+    forumCatLARP: "L.A.R.P.",
+    forumCatPOLITICS: "Politika",
+    forumCatTECH: "Teknologia",
+    forumCatSCIENCE: "Zientzia",
+    forumCatMUSIC: "Musika",
+    forumCatART: "Artea",
+    forumCatGAMING: "Bideojokoak",
+    forumCatBOOKS: "Liburuak",
+    forumCatFILMS: "Filmak",
+    forumCatPHILOSOPHY: "Filosofia",
+    forumCatSOCIETY: "Gizartea",
+    forumCatPRIVACY: "Pribatutasuna",
+    forumCatCYBERWARFARE: "Zibergerra",
+    forumCatSURVIVALISM: "Biziraupena",
     // images
     // images
     imageTitle: "Irudiak",
     imageTitle: "Irudiak",
     imagePluginTitle: "Izenburua",
     imagePluginTitle: "Izenburua",
@@ -1852,8 +2006,10 @@ module.exports = {
     modulesMarketDescription: "Ondasun edo zerbitzuak trukatzeko modulua.",
     modulesMarketDescription: "Ondasun edo zerbitzuak trukatzeko modulua.",
     modulesTribesLabel: "Tribuak",
     modulesTribesLabel: "Tribuak",
     modulesTribesDescription: "Tribuak (taldeak) esploratu edo sortzeko modulua.",
     modulesTribesDescription: "Tribuak (taldeak) esploratu edo sortzeko modulua.",
-    modulesGovernanceLabel: "Gobernua",
-    modulesGovernanceDescription: "Bozketak aurkitu eta kudeatzeko modulua.",
+    modulesVotationsLabel: "Bozketak",
+    modulesVotationsDescription: "Bozketak aurkitu eta kudeatzeko modulua.", 
+    modulesParliamentLabel: "Legebiltzarra",
+    modulesParliamentDescription: "Gobernuak hautatu eta legeak bozkatzeko modulua.",
     modulesReportsLabel: "Txostenak",
     modulesReportsLabel: "Txostenak",
     modulesReportsDescription: "Arazo, akats, abusu eta eduki-abisuetan erlazionatutako txostenak kudeatzeko modulua.",
     modulesReportsDescription: "Arazo, akats, abusu eta eduki-abisuetan erlazionatutako txostenak kudeatzeko modulua.",
     modulesOpinionsLabel: "Iritziak",
     modulesOpinionsLabel: "Iritziak",

+ 163 - 7
src/client/assets/translations/oasis_fr.js

@@ -305,7 +305,7 @@ module.exports = {
     searchStatusLabel:"État",
     searchStatusLabel:"État",
     statusLabel:"État",
     statusLabel:"État",
     totalVotesLabel:"Total des votes",
     totalVotesLabel:"Total des votes",
-    votesLabel:"Votes",
+    votesLabel:"Votations",
     noResultsFound:"Aucun résultat trouvé.",
     noResultsFound:"Aucun résultat trouvé.",
     author:"Auteur",
     author:"Auteur",
     createdAtLabel:"Créé le",
     createdAtLabel:"Créé le",
@@ -582,7 +582,7 @@ module.exports = {
     audioNoFile: "Aucun fichier audio fourni",
     audioNoFile: "Aucun fichier audio fourni",
     audioNotSupported: "Votre navigateur ne prend pas en charge l’élément audio.",
     audioNotSupported: "Votre navigateur ne prend pas en charge l’élément audio.",
     noAudios: "Aucun audio disponible.",
     noAudios: "Aucun audio disponible.",
-    // inhabitants
+    //inhabitants
     yourContacts:       "Vos contacts",
     yourContacts:       "Vos contacts",
     allInhabitants:     "Habitants",
     allInhabitants:     "Habitants",
     allCVs:             "Tous les CV",
     allCVs:             "Tous les CV",
@@ -618,6 +618,144 @@ module.exports = {
     oasisId: "ID",
     oasisId: "ID",
     noInhabitantsFound:    "Aucun habitant trouvé pour l’instant.",
     noInhabitantsFound:    "Aucun habitant trouvé pour l’instant.",
     inhabitantActivityLevel: "Niveau Activité",
     inhabitantActivityLevel: "Niveau Activité",
+    //parliament
+    parliamentTitle: "Parlement",
+    parliamentDescription: "Explorez les formes de gouvernement et les politiques de gestion collective.",
+    parliamentFilterGovernment: "GOUVERNEMENT",
+    parliamentFilterCandidatures: "CANDIDATURES",
+    parliamentFilterProposals: "PROPOSITIONS",
+    parliamentFilterLaws: "LOIS",
+    parliamentFilterHistorical: "HISTORIQUE",
+    parliamentFilterLeaders: "DIRIGEANTS",
+    parliamentFilterRules: "RÈGLES",
+    parliamentGovernmentCard: "Gouvernement actuel",
+    parliamentGovMethod: "MÉTHODE DE GOUVERNEMENT",
+    parliamentActorInPowerInhabitant: 'HABITANT AU POUVOIR',
+    parliamentActorInPowerTribe: 'TRIBU AU POUVOIR',
+    parliamentHistoricalGovernmentsTitle: 'GOUVERNEMENTS',
+    parliamentHistoricalElectionsTitle: 'CYCLES ÉLECTORAUX',
+    parliamentHistoricalLawsTitle: 'HISTORIQUE',
+    parliamentHistoricalLeadersTitle: 'DIRIGEANTS',
+    parliamentThCycles: 'CYCLES',
+    parliamentThTimesInPower: 'AU POUVOIR',
+    parliamentThTotalCandidatures: 'CANDIDATURES',
+    parliamentThProposed: 'LOIS PROPOSÉES',
+    parliamentThApproved: 'LOIS APPROUVÉES',
+    parliamentThDeclined: 'LOIS REJETÉES',
+    parliamentThDiscarded: 'LOIS ÉCARTÉES',
+    parliamentVotesReceived: "VOTES REÇUS",
+    parliamentMembers: "MEMBRES",
+    parliamentLegSince: "DÉBUT DU CYCLE",
+    parliamentLegEnd: "FIN DU CYCLE",
+    parliamentPoliciesProposal: "PROPOSITIONS DE LOIS",
+    parliamentPoliciesApproved: "LOIS APPROUVÉES",
+    parliamentPoliciesDeclined: "LOIS REJETÉES",
+    parliamentPoliciesDiscarded: "LOIS REJETÉES",
+    parliamentEfficiency: "% EFFICACITÉ",
+    parliamentFilterRevocations: 'RÉVOCATIONS',
+    parliamentRevocationFormTitle: 'Révoquer la Loi',
+    parliamentRevocationLaw: 'Loi',
+    parliamentRevocationTitle: 'Titre',
+    parliamentRevocationReasons: 'Motifs',
+    parliamentRevocationPublish: "Publier la Révocation",
+    parliamentCurrentRevocationsTitle: 'Révocations en Cours',
+    parliamentFutureRevocationsTitle: 'Révocations à Venir',
+    parliamentPoliciesRevocated: 'HISTORIQUE',
+    parliamentPoliciesTitle: 'HISTÓRICO',
+    parliamentLawsTitle: 'LOIS APPROUVÉES',
+    parliamentRulesRevocations: 'Toute loi approuvée peut être révoquée en utilisant la méthode de gouvernement en vigueur.',
+    parliamentNoStableGov: "Aucun gouvernement choisi pour l’instant.",
+    parliamentNoGovernments: "Il n’y a pas encore de gouvernements.",
+    parliamentCandidatureFormTitle: "Proposer une Candidature",
+    parliamentCandidatureId: "Candidature",
+    parliamentCandidatureIdPh: "ID Oasis (@...) ou nom de la tribu",
+    parliamentCandidatureSlogan: "Slogan (max 140 car.)",
+    parliamentCandidatureSloganPh: "Un court slogan",
+    parliamentCandidatureMethod: "Méthode",
+    parliamentCandidatureProposeBtn: "Proposer une Candidature",
+    parliamentThType: "Type",
+    parliamentThId: "ID",
+    parliamentThDate: "Date de proposition",
+    parliamentThSlogan: "Slogan",
+    parliamentThMethod: "Méthode",
+    parliamentThKarma: "Karma",
+    parliamentThSince: "Profil depuis",
+    parliamentThVotes: "Votes reçus",
+    parliamentThVoteAction: "Voter",
+    parliamentTypeUser: "Habitant",
+    parliamentTypeTribe: "Tribu",
+    parliamentVoteBtn: "Voter",
+    parliamentProposalFormTitle: "Proposer une Loi",
+    parliamentProposalTitle: "Titre",
+    parliamentProposalDescription: "Description (≤1000)",
+    parliamentProposalPublish: "Publier la Proposition",
+    parliamentOpenVote: "Vote ouvert",
+    parliamentFinalize: "Finaliser",
+    parliamentDeadline: "Date limite",
+    parliamentStatus: "Statut",
+    parliamentNoProposals: "Il n’y a pas encore de propositions de loi.",
+    parliamentNoLaws: "Il n’y a pas encore de lois approuvées.",
+    parliamentLawMethod: "Méthode",
+    parliamentLawProposer: "Proposée par",
+    parliamentLawVotes: "OUI/Total",
+    parliamentLawEnacted: "Promulguée le",
+    parliamentMethodDEMOCRACY: "Démocratie",
+    parliamentMethodMAJORITY: "Majorité (80%)",
+    parliamentMethodMINORITY: "Minorité (20%)",
+    parliamentMethodDICTATORSHIP: "Dictature",
+    parliamentMethodKARMATOCRACY: "Karmatocratie",
+    parliamentMethodANARCHY: "Anarchie",
+    parliamentThId: "ID",
+    parliamentThProposalDate: "Date de proposition",
+    parliamentThMethod: "Méthode",
+    parliamentThKarma: "Karma",
+    parliamentThSupports: "Soutiens",
+    parliamentThVote: "Voter",
+    parliamentCurrentProposalsTitle: "Propositions en cours",
+    parliamentVotesSlashTotal: "Votes/Total",
+    parliamentVotesNeeded: "Votes Requis",
+    parliamentFutureLawsTitle: "Lois futures",
+    parliamentNoFutureLaws: "Pas encore de lois futures",
+    parliamentVoteAction: "Voter",
+    parliamentLeadersTitle: "Dirigeants",
+    parliamentThLeader: "AVATAR",
+    parliamentPopulation: "Population",
+    parliamentThType: "Type",
+    parliamentThInPower: "Au pouvoir",
+    parliamentThPresented: "CANDIDATURES",
+    parliamentNoLeaders: "Aucun dirigeant",
+    typeParliament: "PARLEMENT",
+    typeParliamentCandidature: "Parlement · Candidature",
+    typeParliamentTerm: "Parlement · Mandat",
+    typeParliamentProposal: "Parlement · Proposition",
+    typeParliamentLaw: "Parlement · Nouvelle Loi",
+    parliamentLawQuestion: "Question",
+    parliamentStatus: "Statut",
+    parliamentCandidaturesListTitle: "Liste des Candidatures",
+    parliamentElectionsEnd: "Fin des élections",
+    parliamentTimeRemaining: "Temps restant",
+    parliamentCurrentLeader: "Candidature gagnante",
+    parliamentNoLeader: "Pas encore de dirigeants.",
+    parliamentElectionsStatusTitle: "Prochain Gouvernement",
+    parliamentElectionsStart: "Début des élections",
+    parliamentProposalDeadlineLabel: "Date limite",
+    parliamentProposalTimeLeft: "Temps restant",
+    parliamentProposalOnTrack: "En bonne voie pour être adoptée",
+    parliamentProposalOffTrack: "Soutien insuffisant",
+    parliamentRulesTitle: "Fonctionnement du Parlement",
+    parliamentRulesIntro: "Les élections se résolvent tous les 2 mois ; les candidatures sont continues et sont réinitialisées lorsqu’un gouvernement est choisi.",
+    parliamentRulesCandidates: "Tout habitant peut se proposer, proposer un autre habitant ou n’importe quelle tribu. Chaque habitant peut proposer jusqu’à 3 candidatures par cycle ; les doublons dans le même cycle sont rejetés.",
+    parliamentRulesElection: "Gagne la candidature ayant le plus de votes au moment de la résolution.",
+    parliamentRulesTies: "En cas d’égalité : karma d’habitant le plus élevé ; sinon, profil le plus ancien ; sinon, proposition la plus précoce ; puis ordre lexicographique par ID.",
+    parliamentRulesFallback: "Si personne ne vote, la dernière candidature proposée gagne. S’il n’y a aucune candidature, une tribu aléatoire est sélectionnée.",
+    parliamentRulesTerm: "L’onglet Gouvernement affiche le gouvernement actuel et ses statistiques.",
+    parliamentRulesMethods: "Formes : Anarchie (majorité simple), Démocratie (50 % + 1), Majorité (80 %), Minorité (20 %), Karmatocratie (propositions au karma le plus élevé), Dictature (approbation instantanée).",
+    parliamentRulesAnarchy: "L’Anarchie est le mode par défaut : si aucune candidature n’est élue à la résolution, l’Anarchie est proclamée. En Anarchie, tout habitant peut proposer des lois.",
+    parliamentRulesProposals: "Si vous êtes l’habitant au pouvoir ou membre de la tribu au pouvoir, vous pouvez publier des propositions de loi. Les méthodes non dictatoriales ouvrent un vote public.",
+    parliamentRulesLimit: "Chaque habitant peut publier au maximum 3 loi propositions par cycle.",
+    parliamentRulesLaws: "Lorsqu’une proposition atteint son seuil, elle devient une Loi et apparaît dans l’onglet Lois avec sa date d’entrée en vigueur.",
+    parliamentRulesHistorical: "Dans l’onglet Historique, vous pouvez voir chaque cycle de gouvernement qui a eu lieu ainsi que des données sur sa gestion.",
+    parliamentRulesLeaders: "Dans l’onglet Dirigeants, vous pouvez voir un classement des habitants/tribus qui ont gouverné (ou se sont présentés), classés par efficacité.",
     //trending
     //trending
     trendingTitle: "Tendances",
     trendingTitle: "Tendances",
     exploreTrending: "Explorez le contenu le plus populaire dans votre réseau.",
     exploreTrending: "Explorez le contenu le plus populaire dans votre réseau.",
@@ -833,9 +971,9 @@ module.exports = {
     transfersClosedSectionTitle: "Transferts fermés",
     transfersClosedSectionTitle: "Transferts fermés",
     transfersDiscardedSectionTitle: "Transferts rejetés",
     transfersDiscardedSectionTitle: "Transferts rejetés",
     transfersAllSectionTitle: "Transferts",
     transfersAllSectionTitle: "Transferts",
-    //governance (voting/polls)
-    governanceTitle: "Gouvernance",
-    governanceDescription: "Découvrez et gérez les votes dans votre réseau.",
+    //votations (voting/polls)
+    votationsTitle: "Votations",
+    votationsDescription: "Découvrez et gérez les votes dans votre réseau.",
     voteMineSectionTitle: "Vos votes",
     voteMineSectionTitle: "Vos votes",
     voteCreateSectionTitle: "Créer un vote",
     voteCreateSectionTitle: "Créer un vote",
     voteUpdateSectionTitle: "Mettre à jour",
     voteUpdateSectionTitle: "Mettre à jour",
@@ -952,6 +1090,22 @@ module.exports = {
     forumVisitForum: "Visiter le forum",
     forumVisitForum: "Visiter le forum",
     noForums: "Aucun forum disponible pour l’instant.",
     noForums: "Aucun forum disponible pour l’instant.",
     forumVisitButton: "Visiter le forum",
     forumVisitButton: "Visiter le forum",
+    forumCatGENERAL: "Général",
+    forumCatOASIS: "Oasis",
+    forumCatLARP: "L.A.R.P.",
+    forumCatPOLITICS: "Politique",
+    forumCatTECH: "Technologie",
+    forumCatSCIENCE: "Science",
+    forumCatMUSIC: "Musique",
+    forumCatART: "Art",
+    forumCatGAMING: "Jeux vidéo",
+    forumCatBOOKS: "Livres",
+    forumCatFILMS: "Films",
+    forumCatPHILOSOPHY: "Philosophie",
+    forumCatSOCIETY: "Société",
+    forumCatPRIVACY: "Vie privée",
+    forumCatCYBERWARFARE: "Cyberguerre",
+    forumCatSURVIVALISM: "Survivalisme",
     // images
     // images
     imageTitle: "Images",
     imageTitle: "Images",
     imagePluginTitle: "Titre",
     imagePluginTitle: "Titre",
@@ -1853,8 +2007,10 @@ module.exports = {
     modulesMarketDescription: "Module pour échanger des biens ou services.",
     modulesMarketDescription: "Module pour échanger des biens ou services.",
     modulesTribesLabel: "Tribus",
     modulesTribesLabel: "Tribus",
     modulesTribesDescription: "Module pour explorer ou créer des tribus (groupes).",
     modulesTribesDescription: "Module pour explorer ou créer des tribus (groupes).",
-    modulesGovernanceLabel: "Gouvernance",
-    modulesGovernanceDescription: "Module pour découvrir et gérer les votes.",
+    modulesVotationsLabel: "Votations",
+    modulesVotationsDescription: "Module pour découvrir et gérer les votations.",  
+    modulesParliamentLabel: "Parlement",
+    modulesParliamentDescription: "Module pour élire des gouvernements et voter des lois.",
     modulesReportsLabel: "Rapports",
     modulesReportsLabel: "Rapports",
     modulesReportsDescription: "Module pour gérer et suivre les rapports liés aux problèmes, erreurs, abus et avertissements de contenu.",
     modulesReportsDescription: "Module pour gérer et suivre les rapports liés aux problèmes, erreurs, abus et avertissements de contenu.",
     modulesOpinionsLabel: "Opinions",
     modulesOpinionsLabel: "Opinions",

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

@@ -30,7 +30,7 @@ if (!fs.existsSync(configFilePath)) {
       "tasksMod": "on",
       "tasksMod": "on",
       "marketMod": "on",
       "marketMod": "on",
       "tribesMod": "on",
       "tribesMod": "on",
-      "governanceMod": "on",
+      "votesMod": "on",
       "reportsMod": "on",
       "reportsMod": "on",
       "opinionsMod": "on",
       "opinionsMod": "on",
       "transfersMod": "on",
       "transfersMod": "on",
@@ -41,7 +41,8 @@ if (!fs.existsSync(configFilePath)) {
       "forumMod": "on",
       "forumMod": "on",
       "jobsMod": "on",
       "jobsMod": "on",
       "projectsMod": "on",
       "projectsMod": "on",
-      "bankingMod": "on"
+      "bankingMod": "on",
+      "parliamentMod": "on"
     },
     },
     "wallet": {
     "wallet": {
       "url": "http://localhost:7474",
       "url": "http://localhost:7474",

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

@@ -23,7 +23,7 @@
     "eventsMod": "on",
     "eventsMod": "on",
     "tasksMod": "on",
     "tasksMod": "on",
     "marketMod": "on",
     "marketMod": "on",
-    "governanceMod": "on",
+    "votesMod": "on",
     "tribesMod": "on",
     "tribesMod": "on",
     "reportsMod": "on",
     "reportsMod": "on",
     "opinionsMod": "on",
     "opinionsMod": "on",
@@ -35,7 +35,8 @@
     "forumMod": "on",
     "forumMod": "on",
     "jobsMod": "on",
     "jobsMod": "on",
     "projectsMod": "on",
     "projectsMod": "on",
-    "bankingMod": "on"
+    "bankingMod": "on",
+    "parliamentMod": "on"
   },
   },
   "wallet": {
   "wallet": {
     "url": "http://localhost:7474",
     "url": "http://localhost:7474",

+ 4 - 0
src/models/activity_model.js

@@ -166,6 +166,10 @@ module.exports = ({ cooler }) => {
       else if (filter === 'all') out = deduped;
       else if (filter === 'all') out = deduped;
       else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim');
       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 === 'karma') out = deduped.filter(a => a.type === 'karmaScore');
+      else if (filter === 'parliament')
+      out = deduped.filter(a =>
+        ['parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw'].includes(a.type)
+      );
       else out = deduped.filter(a => a.type === filter);
       else out = deduped.filter(a => a.type === filter);
 
 
       out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
       out.sort((a, b) => (b.ts || 0) - (a.ts || 0));

文件差异内容过多而无法显示
+ 1048 - 0
src/models/parliament_model.js


+ 47 - 4
src/models/votes_model.js

@@ -50,12 +50,14 @@ module.exports = ({ cooler }) => {
       const c = oldMsg.content;
       const c = oldMsg.content;
       if (c.type !== 'votes') throw new Error('Invalid type');
       if (c.type !== 'votes') throw new Error('Invalid type');
       if (c.createdBy !== userId) throw new Error('Not the author');
       if (c.createdBy !== userId) throw new Error('Not the author');
+
       let newDeadline = c.deadline;
       let newDeadline = c.deadline;
       if (deadline != null && deadline !== '') {
       if (deadline != null && deadline !== '') {
         const parsed = moment(deadline, moment.ISO_8601, true);
         const parsed = moment(deadline, moment.ISO_8601, true);
         if (!parsed.isValid() || parsed.isBefore(moment())) throw new Error('Invalid deadline');
         if (!parsed.isValid() || parsed.isBefore(moment())) throw new Error('Invalid deadline');
         newDeadline = parsed.toISOString();
         newDeadline = parsed.toISOString();
       }
       }
+
       let newOptions = c.options;
       let newOptions = c.options;
       let newVotesMap = c.votes;
       let newVotesMap = c.votes;
       let newTotalVotes = c.totalVotes;
       let newTotalVotes = c.totalVotes;
@@ -71,10 +73,12 @@ module.exports = ({ cooler }) => {
         newVotesMap = newOptions.reduce((acc, opt) => (acc[opt] = 0, acc), {});
         newVotesMap = newOptions.reduce((acc, opt) => (acc[opt] = 0, acc), {});
         newTotalVotes = 0;
         newTotalVotes = 0;
       }
       }
+
       const newTags =
       const newTags =
         Array.isArray(tags) ? tags.filter(Boolean)
         Array.isArray(tags) ? tags.filter(Boolean)
         : typeof tags === 'string' ? tags.split(',').map(t => t.trim()).filter(Boolean)
         : typeof tags === 'string' ? tags.split(',').map(t => t.trim()).filter(Boolean)
         : c.tags || [];
         : c.tags || [];
+
       const updated = {
       const updated = {
         ...c,
         ...c,
         replaces: id,
         replaces: id,
@@ -95,11 +99,14 @@ module.exports = ({ cooler }) => {
       const vote = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
       const vote = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
       if (!vote.content.options.includes(choice)) throw new Error('Invalid choice');
       if (!vote.content.options.includes(choice)) throw new Error('Invalid choice');
       if (vote.content.voters.includes(userId)) throw new Error('Already voted');
       if (vote.content.voters.includes(userId)) throw new Error('Already voted');
+
       vote.content.votes[choice] += 1;
       vote.content.votes[choice] += 1;
       vote.content.voters.push(userId);
       vote.content.voters.push(userId);
       vote.content.totalVotes += 1;
       vote.content.totalVotes += 1;
+
       const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
       const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
       const updated = { ...vote.content, updatedAt: new Date().toISOString(), replaces: id };
       const updated = { ...vote.content, updatedAt: new Date().toISOString(), replaces: id };
+
       await new Promise((res, rej) => ssb.publish(tombstone, err => err ? rej(err) : res()));
       await new Promise((res, rej) => ssb.publish(tombstone, err => err ? rej(err) : res()));
       return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
       return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
     },
     },
@@ -107,16 +114,50 @@ module.exports = ({ cooler }) => {
     async getVoteById(id) {
     async getVoteById(id) {
       const ssb = await openSsb();
       const ssb = await openSsb();
       const now = moment();
       const now = moment();
-      const vote = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
-      const c = vote.content;
-      const status = c.status === 'OPEN' && moment(c.deadline).isBefore(now) ? 'CLOSED' : c.status;
-      return { id, ...c, status };
+
+      const results = await new Promise((resolve, reject) => {
+        pull(
+          ssb.createLogStream({ limit: logLimit }),
+          pull.collect((err, arr) => err ? reject(err) : resolve(arr))
+        );
+      });
+
+      const votesByKey = new Map();
+      const latestByRoot = new Map();
+
+      for (const r of results) {
+        const key = r.key;
+        const v = r.value;
+        const c = v && v.content;
+        if (!c) continue;
+        if (c.type === 'votes') {
+          votesByKey.set(key, c);
+          const ts = Number(v.timestamp || r.timestamp || Date.now());
+          const root = c.replaces || key;
+          const prev = latestByRoot.get(root);
+          if (!prev || ts > prev.ts) latestByRoot.set(root, { key, ts });
+        }
+      }
+
+      const latestEntry = latestByRoot.get(id);
+      let latestId = latestEntry ? latestEntry.key : id;
+      let content = votesByKey.get(latestId);
+
+      if (!content) {
+        const orig = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
+        content = orig.content;
+        latestId = id;
+      }
+
+      const status = content.status === 'OPEN' && moment(content.deadline).isBefore(now) ? 'CLOSED' : content.status;
+      return { id, latestId, ...content, status };
     },
     },
 
 
     async listAll(filter = 'all') {
     async listAll(filter = 'all') {
       const ssb = await openSsb();
       const ssb = await openSsb();
       const userId = ssb.id;
       const userId = ssb.id;
       const now = moment();
       const now = moment();
+
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
         pull(ssb.createLogStream({ limit: logLimit }), 
         pull(ssb.createLogStream({ limit: logLimit }), 
         pull.collect((err, results) => {
         pull.collect((err, results) => {
@@ -153,6 +194,7 @@ module.exports = ({ cooler }) => {
       const userId = ssb.id;
       const userId = ssb.id;
       const vote = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
       const vote = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
       if (vote.content.opinions_inhabitants.includes(userId)) throw new Error('Already voted');
       if (vote.content.opinions_inhabitants.includes(userId)) throw new Error('Already voted');
+
       const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
       const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
       const updated = {
       const updated = {
         ...vote.content,
         ...vote.content,
@@ -161,6 +203,7 @@ module.exports = ({ cooler }) => {
         updatedAt: new Date().toISOString(),
         updatedAt: new Date().toISOString(),
         replaces: id
         replaces: id
       };
       };
+
       await new Promise((res, rej) => ssb.publish(tombstone, err => err ? rej(err) : res()));
       await new Promise((res, rej) => ssb.publish(tombstone, err => err ? rej(err) : res()));
       return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
       return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
     }
     }

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

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

+ 1 - 1
src/server/package.json

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

+ 171 - 33
src/views/activity_view.js

@@ -41,7 +41,16 @@ function renderActionCards(actions, userId) {
       ? a({ href: `/author/${encodeURIComponent(action.author)}` }, action.author)
       ? a({ href: `/author/${encodeURIComponent(action.author)}` }, action.author)
       : 'unknown';
       : 'unknown';
     const type = action.type || 'unknown';
     const type = action.type || 'unknown';
-    const typeLabel = i18n[`type${capitalize(type)}`] || type;
+
+    let headerText;
+    if (type.startsWith('parliament')) {
+      const sub = type.replace('parliament', '');
+      headerText = `[PARLIAMENT · ${sub.toUpperCase()}]`;
+    } else {
+      const typeLabel = i18n[`type${capitalize(type)}`] || type;
+      headerText = `[${String(typeLabel).toUpperCase()}]`;
+    }
+
     const content = action.content || {};
     const content = action.content || {};
     const cardBody = [];
     const cardBody = [];
 
 
@@ -643,12 +652,132 @@ function renderActionCards(actions, userId) {
       );
       );
     }
     }
 
 
+    if (type === 'parliamentCandidature') {
+      const { targetType, targetId, targetTitle, method, votes, proposer } = content;
+      const link = targetType === 'tribe'
+        ? a({ href: `/tribe/${encodeURIComponent(targetId)}`, class: 'user-link' }, targetTitle || targetId)
+        : a({ href: `/author/${encodeURIComponent(targetId)}`, class: 'user-link' }, targetId);
+
+      const methodUpper = String(
+        i18n['parliamentMethod' + String(method || '').toUpperCase()] || method
+      ).toUpperCase();
+
+      cardBody.push(
+        div({ class: 'card-section parliament' },
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (String(i18n.parliamentCandidatureId || 'Candidature').toUpperCase()) + ':'), span({ class: 'card-value' }, link)),
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod || 'METHOD') + ':'), span({ class: 'card-value' }, methodUpper)),
+          typeof votes !== 'undefined'
+            ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesReceived || 'VOTES RECEIVED') + ':'), span({ class: 'card-value' }, String(votes)))
+            : ''
+        )
+      );
+    }
+
+    if (type === 'parliamentTerm') {
+      const { method, powerType, powerId, powerTitle, winnerVotes, totalVotes, startAt, endAt } = content;
+      const powerTypeNorm = String(powerType || '').toLowerCase();
+      const winnerLink =
+        powerTypeNorm === 'tribe'
+          ? a({ href: `/tribe/${encodeURIComponent(powerId)}`, class: 'user-link' }, powerTitle || powerId)
+          : powerTypeNorm === 'none' || !powerTypeNorm
+            ? a({ href: `/parliament?filter=government`, class: 'user-link' }, (i18n.parliamentAnarchy || 'ANARCHY'))
+            : a({ href: `/author/${encodeURIComponent(powerId)}`, class: 'user-link' }, powerTitle || powerId);
+
+      const methodUpper = String(
+        i18n['parliamentMethod' + String(method || '').toUpperCase()] || method
+      ).toUpperCase();
+
+      cardBody.push(
+        div({ class: 'card-section parliament' },
+          startAt ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentElectionsStart.toUpperCase() || 'Elections start') + ':'), span({ class: 'card-value' }, new Date(startAt).toLocaleString())) : '',
+          endAt ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentElectionsEnd.toUpperCase() || 'Elections end') + ':'), span({ class: 'card-value' }, new Date(endAt).toLocaleString())) : '',
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentCurrentLeader.toUpperCase() || 'Winning candidature') + ':'), span({ class: 'card-value' }, winnerLink)),
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod.toUpperCase() || 'Method') + ':'), span({ class: 'card-value' }, methodUpper)),
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesReceived.toUpperCase() || 'Votes received') + ':'), span({ class: 'card-value' }, `${Number(winnerVotes || 0)} (${Number(totalVotes || 0)})`))
+        )
+      );
+    }
+
+    if (type === 'parliamentProposal') {
+      const { title, description, method, status, voteId, createdAt } = content;
+
+      const methodUpper = String(
+        i18n['parliamentMethod' + String(method || '').toUpperCase()] || method
+      ).toUpperCase();
+
+      cardBody.push(
+        div({ class: 'card-section parliament' },
+          title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentProposalTitle.toUpperCase() || 'Title') + ':'), span({ class: 'card-value' }, title)) : '',
+          description ? p({ style: 'margin:.4rem 0' }, description) : '',
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod || 'Method') + ':'), span({ class: 'card-value' }, methodUpper)),
+          createdAt ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.createdAt.toUpperCase() || 'Created at') + ':'), span({ class: 'card-value' }, new Date(createdAt).toLocaleString())) : '',
+          voteId ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentOpenVote.toUpperCase() || 'Open vote') + ':'), a({ href: `/votes/${encodeURIComponent(voteId)}`, class: 'tag-link' }, i18n.viewDetails || 'View details')) : '',
+          status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentStatus.toUpperCase() || 'Status') + ':'), span({ class: 'card-value' }, status)) : ''
+        )
+      );
+    }
+    
+    if (type === 'parliamentRevocation') {
+      const { title, reasons, method, status, voteId, createdAt } = content;
+      const methodUpper = String(
+        i18n['parliamentMethod' + String(method || '').toUpperCase()] || method
+      ).toUpperCase();
+
+      cardBody.push(
+        div({ class: 'card-section parliament' },
+          title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentProposalTitle.toUpperCase() || 'Title') + ':'), span({ class: 'card-value' }, title)) : '',
+          reasons ? p({ style: 'margin:.4rem 0' }, reasons) : '',
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod || 'Method') + ':'), span({ class: 'card-value' }, methodUpper)),
+          createdAt ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.createdAt.toUpperCase() || 'Created at') + ':'), span({ class: 'card-value' }, new Date(createdAt).toLocaleString())) : '',
+          voteId ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentOpenVote.toUpperCase() || 'Open vote') + ':'), a({ href: `/votes/${encodeURIComponent(voteId)}`, class: 'tag-link' }, i18n.viewDetails || 'View details')) : '',
+          status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentStatus.toUpperCase() || 'Status') + ':'), span({ class: 'card-value' }, status)) : ''
+        )
+      );
+    }
+
+    if (type === 'parliamentLaw') {
+      const { question, description, method, proposer, enactedAt, votes } = content;
+      const yes = Number(votes?.YES || 0);
+      const total = Number(votes?.total || votes?.TOTAL || 0);
+
+      const methodUpper = String(
+        i18n['parliamentMethod' + String(method || '').toUpperCase()] || method
+      ).toUpperCase();
+
+      cardBody.push(
+        div({ class: 'card-section parliament' },
+          question ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawQuestion || 'Question') + ':'), span({ class: 'card-value' }, question)) : '',
+          description ? p({ style: 'margin:.4rem 0' }, description) : '',
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawMethod || 'Method') + ':'), span({ class: 'card-value' }, methodUpper)),
+          proposer ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawProposer || 'Proposer') + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(proposer)}`, class: 'user-link' }, proposer))) : '',
+          enactedAt ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawEnacted || 'Enacted at') + ':'), span({ class: 'card-value' }, new Date(enactedAt).toLocaleString())) : '',
+          (total || yes) ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawVotes || 'Votes') + ':'), span({ class: 'card-value' }, `${yes}/${total}`)) : ''
+        )
+      );
+    }
+
+    const viewHref = getViewDetailsAction(type, action);
+    const isParliamentTarget =
+      viewHref === '/parliament?filter=candidatures' ||
+      viewHref === '/parliament?filter=government'   ||
+      viewHref === '/parliament?filter=proposals'    ||
+      viewHref === '/parliament?filter=revocations'    ||
+      viewHref === '/parliament?filter=laws';
+    const parliamentFilter = isParliamentTarget ? (viewHref.split('filter=')[1] || '') : '';
+
     return div({ class: 'card card-rpg' },
     return div({ class: 'card card-rpg' },
       div({ class: 'card-header' },
       div({ class: 'card-header' },
-        h2({ class: 'card-label' }, `[${typeLabel}]`),
+        h2({ class: 'card-label' }, headerText),
         type !== 'feed' && type !== 'aiExchange' && type !== 'bankWallet' && (!action.tipId || action.tipId === action.id)
         type !== 'feed' && type !== 'aiExchange' && type !== 'bankWallet' && (!action.tipId || action.tipId === action.id)
-          ? form({ method: "GET", action: getViewDetailsAction(type, action) },
-              button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          ? (
+              isParliamentTarget
+                ? form({ method: "GET", action: "/parliament" },
+                    input({ type: "hidden", name: "filter", value: parliamentFilter }),
+                    button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+                  )
+                : form({ method: "GET", action: viewHref },
+                    button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+                  )
             )
             )
           : ''
           : ''
       ),
       ),
@@ -662,35 +791,40 @@ function renderActionCards(actions, userId) {
 }
 }
 
 
 function getViewDetailsAction(type, action) {
 function getViewDetailsAction(type, action) {
-  const id = encodeURIComponent(action.tipId || action.id);
-  switch (type) {
-    case 'votes': return `/votes/${id}`;
-    case 'transfer': return `/transfers/${id}`;
-    case 'pixelia': return `/pixelia`;
-    case 'tribe': return `/tribe/${id}`;
-    case 'curriculum': return `/inhabitant/${encodeURIComponent(action.author)}`;
-    case 'karmaScore': return `/author/${encodeURIComponent(action.author)}`;
-    case 'image': return `/images/${id}`;
-    case 'audio': return `/audios/${id}`;
-    case 'video': return `/videos/${id}`;
-    case 'forum':
-      return `/forum/${encodeURIComponent(action.content?.key || action.tipId || action.id)}`;
-    case 'document': return `/documents/${id}`;
-    case 'bookmark': return `/bookmarks/${id}`;
-    case 'event': return `/events/${id}`;
-    case 'task': return `/tasks/${id}`;
-    case 'about': return `/author/${encodeURIComponent(action.author)}`;
-    case 'post': return `/thread/${id}#${id}`;
-    case 'vote': return `/thread/${encodeURIComponent(action.content.vote.link)}#${encodeURIComponent(action.content.vote.link)}`;
-    case 'contact': return `/inhabitants`;
-    case 'pub': return `/invites`;
-    case 'market': return `/market/${id}`;
-    case 'job': return `/jobs/${id}`;
-    case 'project': return `/projects/${id}`;
-    case 'report': return `/reports/${id}`;
-    case 'bankWallet': return `/wallet`;
-    case 'bankClaim': return `/banking${action.content?.epochId ? `/epoch/${encodeURIComponent(action.content.epochId)}` : ''}`;
-  }
+    const id = encodeURIComponent(action.tipId || action.id);
+    switch (type) {
+        case 'parliamentCandidature': return `/parliament?filter=candidatures`;
+        case 'parliamentTerm':        return `/parliament?filter=government`;
+        case 'parliamentProposal':    return `/parliament?filter=proposals`;
+        case 'parliamentRevocation':  return `/parliament?filter=revocations`;
+        case 'parliamentLaw':         return `/parliament?filter=laws`;
+        case 'votes':      return `/votes/${id}`;
+        case 'transfer':   return `/transfers/${id}`;
+        case 'pixelia':    return `/pixelia`;
+        case 'tribe':      return `/tribe/${id}`;
+        case 'curriculum': return `/inhabitant/${encodeURIComponent(action.author)}`;
+        case 'karmaScore': return `/author/${encodeURIComponent(action.author)}`;
+        case 'image':      return `/images/${id}`;
+        case 'audio':      return `/audios/${id}`;
+        case 'video':      return `/videos/${id}`;
+        case 'forum':      return `/forum/${encodeURIComponent(action.content?.key || action.tipId || action.id)}`;
+        case 'document':   return `/documents/${id}`;
+        case 'bookmark':   return `/bookmarks/${id}`;
+        case 'event':      return `/events/${id}`;
+        case 'task':       return `/tasks/${id}`;
+        case 'about':      return `/author/${encodeURIComponent(action.author)}`;
+        case 'post':       return `/thread/${id}#${id}`;
+        case 'vote':       return `/thread/${encodeURIComponent(action.content.vote.link)}#${encodeURIComponent(action.content.vote.link)}`;
+        case 'contact':    return `/inhabitants`;
+        case 'pub':        return `/invites`;
+        case 'market':     return `/market/${id}`;
+        case 'job':        return `/jobs/${id}`;
+        case 'project':    return `/projects/${id}`;
+        case 'report':     return `/reports/${id}`;
+        case 'bankWallet': return `/wallet`;
+        case 'bankClaim':  return `/banking${action.content?.epochId ? `/epoch/${encodeURIComponent(action.content.epochId)}` : ''}`;
+        default:           return `/activity`;
+    }
 }
 }
 
 
 exports.activityView = (actions, filter, userId) => {
 exports.activityView = (actions, filter, userId) => {
@@ -706,6 +840,7 @@ exports.activityView = (actions, filter, userId) => {
     { type: 'project',   label: i18n.typeProject },
     { type: 'project',   label: i18n.typeProject },
     { type: 'job',       label: i18n.typeJob },
     { type: 'job',       label: i18n.typeJob },
     { type: 'transfer',  label: i18n.typeTransfer },
     { type: 'transfer',  label: i18n.typeTransfer },
+    { type: 'parliament',label: i18n.typeParliament },
     { type: 'votes',     label: i18n.typeVotes },
     { type: 'votes',     label: i18n.typeVotes },
     { type: 'event',     label: i18n.typeEvent },
     { type: 'event',     label: i18n.typeEvent },
     { type: 'task',      label: i18n.typeTask },
     { type: 'task',      label: i18n.typeTask },
@@ -734,6 +869,8 @@ exports.activityView = (actions, filter, userId) => {
     filteredActions = actions.filter(action => action.type !== 'tombstone' && action.ts && now - action.ts < 24 * 60 * 60 * 1000);
     filteredActions = actions.filter(action => action.type !== 'tombstone' && action.ts && now - action.ts < 24 * 60 * 60 * 1000);
   } else if (filter === 'banking') {
   } 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'));
+  } else if (filter === 'parliament') {
+    filteredActions = actions.filter(action => ['parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw'].includes(action.type));
   } else {
   } else {
     filteredActions = actions.filter(action => (action.type === filter || filter === 'all') && action.type !== 'tombstone');
     filteredActions = actions.filter(action => (action.type === filter || filter === 'all') && action.type !== 'tombstone');
   }
   }
@@ -810,3 +947,4 @@ exports.activityView = (actions, filter, userId) => {
   }
   }
   return html;
   return html;
 };
 };
+

+ 24 - 17
src/views/forum_view.js

@@ -12,6 +12,10 @@ const BASE_FILTERS = ['hot','all','mine','recent','top'];
 const CAT_BLOCK1 = ['GENERAL','OASIS','L.A.R.P.','POLITICS','TECH'];
 const CAT_BLOCK1 = ['GENERAL','OASIS','L.A.R.P.','POLITICS','TECH'];
 const CAT_BLOCK2 = ['SCIENCE','MUSIC','ART','GAMING','BOOKS','FILMS'];
 const CAT_BLOCK2 = ['SCIENCE','MUSIC','ART','GAMING','BOOKS','FILMS'];
 const CAT_BLOCK3 = ['PHILOSOPHY','SOCIETY','PRIVACY','CYBERWARFARE','SURVIVALISM'];
 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 Z = 1.96;
 const Z = 1.96;
 function wilsonScore(pos, neg) {
 function wilsonScore(pos, neg) {
@@ -29,7 +33,7 @@ function getFilteredForums(filter, forums) {
   if (filter === 'hot')     return forums
   if (filter === 'hot')     return forums
     .filter(f => new Date(f.createdAt).getTime() >= now - 86400000)
     .filter(f => new Date(f.createdAt).getTime() >= now - 86400000)
     .sort((a,b) => b.score - a.score);
     .sort((a,b) => b.score - a.score);
-  if ([...CAT_BLOCK1, ...CAT_BLOCK2, ...CAT_BLOCK3].includes(filter))
+  if (ALL_CATS.includes(filter))
     return forums.filter(f => f.category === filter);
     return forums.filter(f => f.category === filter);
   return forums;
   return forums;
 }
 }
@@ -40,7 +44,7 @@ const generateFilterButtons = (filters, currentFilter, action, i18nMap = {}) =>
       form({ method: 'GET', action },
       form({ method: 'GET', action },
         input({ type: 'hidden', name: 'filter', value: mode }),
         input({ type: 'hidden', name: 'filter', value: mode }),
         button({ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' },
         button({ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' },
-          i18nMap[mode] || mode.toUpperCase()
+          String(i18nMap[mode] || mode).toUpperCase()
         )
         )
       )
       )
     )
     )
@@ -71,9 +75,7 @@ const renderForumForm = () =>
     form({ action: '/forum/create', method: 'POST' },
     form({ action: '/forum/create', method: 'POST' },
       label(i18n.forumCategoryLabel), br(),
       label(i18n.forumCategoryLabel), br(),
       select({ name: 'category', required: true },
       select({ name: 'category', required: true },
-        [...CAT_BLOCK1, ...CAT_BLOCK2, ...CAT_BLOCK3].map(cat =>
-          option({ value: cat }, cat)
-        )
+        ALL_CATS.map(cat => option({ value: cat }, catLabel(cat)))
       ), br(), br(),
       ), br(), br(),
       label(i18n.forumTitleLabel), br(),
       label(i18n.forumTitleLabel), br(),
       input({
       input({
@@ -173,7 +175,7 @@ const renderForumList = (forums, currentFilter) =>
               a({
               a({
                 class: 'forum-category',
                 class: 'forum-category',
                 href: `/forum?filter=${encodeURIComponent(f.category)}`
                 href: `/forum?filter=${encodeURIComponent(f.category)}`
-              }, `[${f.category}]`),
+              }, `[${catLabel(f.category)}]`),
               a({
               a({
                 class: 'forum-title',
                 class: 'forum-title',
                 href: `/forum/${encodeURIComponent(f.key)}`
                 href: `/forum/${encodeURIComponent(f.key)}`
@@ -220,8 +222,9 @@ const renderForumList = (forums, currentFilter) =>
       : p(i18n.noForums)
       : p(i18n.noForums)
   );
   );
 
 
-exports.forumView = async (forums, currentFilter) =>
-  template(i18n.forumTitle,
+exports.forumView = async (forums, currentFilter) => {
+  const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{});
+  return template(i18n.forumTitle,
     section(
     section(
       div({ class: 'tags-header' },
       div({ class: 'tags-header' },
         h2(currentFilter === 'create'
         h2(currentFilter === 'create'
@@ -237,9 +240,9 @@ exports.forumView = async (forums, currentFilter) =>
           recent: i18n.forumFilterRecent,
           recent: i18n.forumFilterRecent,
           top: i18n.forumFilterTop
           top: i18n.forumFilterTop
         }),
         }),
-        generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum'),
-        generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum'),
-        generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum'),
+        generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum', CAT_I18N_MAP_UP),
+        generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum', CAT_I18N_MAP_UP),
+        generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum', CAT_I18N_MAP_UP),
         renderCreateForumButton()
         renderCreateForumButton()
       ),
       ),
       currentFilter === 'create'
       currentFilter === 'create'
@@ -250,9 +253,11 @@ exports.forumView = async (forums, currentFilter) =>
         )
         )
     )
     )
   );
   );
+};
 
 
-exports.singleForumView = async (forum, messagesData, currentFilter) =>
-  template(forum.title,
+exports.singleForumView = async (forum, messagesData, currentFilter) => {
+  const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{});
+  return template(forum.title,
     section(
     section(
       div({ class: 'tags-header' },
       div({ class: 'tags-header' },
         h2(i18n.forumTitle),
         h2(i18n.forumTitle),
@@ -260,14 +265,15 @@ exports.singleForumView = async (forum, messagesData, currentFilter) =>
       ),
       ),
       div({ class: 'mode-buttons' },
       div({ class: 'mode-buttons' },
         generateFilterButtons(BASE_FILTERS, currentFilter, '/forum', {
         generateFilterButtons(BASE_FILTERS, currentFilter, '/forum', {
+          hot: i18n.forumFilterHot,
           all: i18n.forumFilterAll,
           all: i18n.forumFilterAll,
           mine: i18n.forumFilterMine,
           mine: i18n.forumFilterMine,
           recent: i18n.forumFilterRecent,
           recent: i18n.forumFilterRecent,
           top: i18n.forumFilterTop
           top: i18n.forumFilterTop
         }),
         }),
-        generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum'),
-        generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum'),
-        generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum'),
+        generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum', CAT_I18N_MAP_UP),
+        generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum', CAT_I18N_MAP_UP),
+        generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum', CAT_I18N_MAP_UP),
         renderCreateForumButton()
         renderCreateForumButton()
       )
       )
     ),
     ),
@@ -292,7 +298,7 @@ exports.singleForumView = async (forum, messagesData, currentFilter) =>
             a({
             a({
               class: 'forum-category',
               class: 'forum-category',
               href: `/forum?filter=${encodeURIComponent(forum.category)}`
               href: `/forum?filter=${encodeURIComponent(forum.category)}`
-            }, `[${forum.category}]`),
+            }, `[${catLabel(forum.category)}]`),
             a({
             a({
               class: 'forum-title',
               class: 'forum-title',
               href: `/forum/${encodeURIComponent(forum.key)}`
               href: `/forum/${encodeURIComponent(forum.key)}`
@@ -353,4 +359,5 @@ exports.singleForumView = async (forum, messagesData, currentFilter) =>
       ...renderThread(messagesData.messages, 0, forum.key)
       ...renderThread(messagesData.messages, 0, forum.key)
     )
     )
   );
   );
+};
 
 

+ 15 - 5
src/views/main_views.js

@@ -274,16 +274,25 @@ const renderTribesLink = () => {
   return tribesMod 
   return tribesMod 
     ? [
     ? [
         navLink({ href: "/tribes", emoji: "ꖥ", text: i18n.tribesTitle, class: "tribes-link enabled" }),
         navLink({ href: "/tribes", emoji: "ꖥ", text: i18n.tribesTitle, class: "tribes-link enabled" }),
+      ]
+    : '';
+};
+
+const renderParliamentLink = () => {
+  const parliamentMod = getConfig().modules.parliamentMod === 'on';
+  return parliamentMod 
+    ? [
+        navLink({ href: "/parliament", emoji: "ꗞ", text: i18n.parliamentTitle, class: "parliament-link enabled" }),
         hr(),
         hr(),
       ]
       ]
     : '';
     : '';
 };
 };
 
 
-const renderGovernanceLink = () => {
-  const governanceMod = getConfig().modules.governanceMod === 'on';
-  return governanceMod 
+const renderVotationsLink = () => {
+  const votesMod = getConfig().modules.votesMod === 'on';
+  return votesMod 
     ? [
     ? [
-       navLink({ href: "/votes", emoji: "ꔰ", text: i18n.governanceTitle, class: "votes-link enabled" }),
+       navLink({ href: "/votes", emoji: "ꔰ", text: i18n.votationsTitle, class: "votations-link enabled" }),
       ]
       ]
     : '';
     : '';
 };
 };
@@ -460,7 +469,8 @@ const template = (titlePrefix, ...elements) => {
               renderPopularLink(),
               renderPopularLink(),
               navLink({ href: "/inhabitants", emoji: "ꖘ", text: i18n.inhabitantsLabel }),
               navLink({ href: "/inhabitants", emoji: "ꖘ", text: i18n.inhabitantsLabel }),
               renderTribesLink(),
               renderTribesLink(),
-              renderGovernanceLink(),
+              renderParliamentLink(),
+              renderVotationsLink(),
               renderEventsLink(),
               renderEventsLink(),
               renderTasksLink(),
               renderTasksLink(),
               renderReportsLink(),
               renderReportsLink(),

+ 2 - 1
src/views/modules_view.js

@@ -15,7 +15,6 @@ const modulesView = () => {
     { name: 'events', label: i18n.modulesEventsLabel, description: i18n.modulesEventsDescription },
     { name: 'events', label: i18n.modulesEventsLabel, description: i18n.modulesEventsDescription },
     { name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription },
     { name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription },
     { name: 'forum', label: i18n.modulesForumLabel, description: i18n.modulesForumDescription },
     { name: 'forum', label: i18n.modulesForumLabel, description: i18n.modulesForumDescription },
-    { name: 'governance', label: i18n.modulesGovernanceLabel, description: i18n.modulesGovernanceDescription },
     { name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription },
     { name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription },
     { name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
     { name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
     { name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
     { name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
@@ -24,6 +23,7 @@ const modulesView = () => {
     { name: 'market', label: i18n.modulesMarketLabel, description: i18n.modulesMarketDescription },
     { name: 'market', label: i18n.modulesMarketLabel, description: i18n.modulesMarketDescription },
     { name: 'multiverse', label: i18n.modulesMultiverseLabel, description: i18n.modulesMultiverseDescription },
     { name: 'multiverse', label: i18n.modulesMultiverseLabel, description: i18n.modulesMultiverseDescription },
     { name: 'opinions', label: i18n.modulesOpinionsLabel, description: i18n.modulesOpinionsDescription },
     { name: 'opinions', label: i18n.modulesOpinionsLabel, description: i18n.modulesOpinionsDescription },
+    { name: 'parliament', label: i18n.modulesParliamentLabel, description: i18n.modulesParliamentDescription },
     { name: 'pixelia', label: i18n.modulesPixeliaLabel, description: i18n.modulesPixeliaDescription },
     { name: 'pixelia', label: i18n.modulesPixeliaLabel, description: i18n.modulesPixeliaDescription },
     { name: 'projects', label: i18n.modulesProjectsLabel, description: i18n.modulesProjectsDescription },
     { name: 'projects', label: i18n.modulesProjectsLabel, description: i18n.modulesProjectsDescription },
     { name: 'popular', label: i18n.modulesPopularLabel, description: i18n.modulesPopularDescription },
     { name: 'popular', label: i18n.modulesPopularLabel, description: i18n.modulesPopularDescription },
@@ -36,6 +36,7 @@ const modulesView = () => {
     { name: 'trending', label: i18n.modulesTrendingLabel, description: i18n.modulesTrendingDescription },
     { name: 'trending', label: i18n.modulesTrendingLabel, description: i18n.modulesTrendingDescription },
     { name: 'tribes', label: i18n.modulesTribesLabel, description: i18n.modulesTribesDescription },
     { name: 'tribes', label: i18n.modulesTribesLabel, description: i18n.modulesTribesDescription },
     { name: 'videos', label: i18n.modulesVideosLabel, description: i18n.modulesVideosDescription },
     { name: 'videos', label: i18n.modulesVideosLabel, description: i18n.modulesVideosDescription },
+    { name: 'votes', label: i18n.modulesVotationsLabel, description: i18n.modulesVotationsDescription },
     { name: 'wallet', label: i18n.modulesWalletLabel, description: i18n.modulesWalletDescription },
     { name: 'wallet', label: i18n.modulesWalletLabel, description: i18n.modulesWalletDescription },
     { name: 'topics', label: i18n.modulesTopicsLabel, description: i18n.modulesTopicsDescription }
     { name: 'topics', label: i18n.modulesTopicsLabel, description: i18n.modulesTopicsDescription }
   ];
   ];

+ 872 - 0
src/views/parliament_view.js

@@ -0,0 +1,872 @@
+const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe');
+const moment = require("../server/node_modules/moment");
+const { template, i18n } = require('./main_views');
+
+const fmt = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss');
+const timeLeft = (end) => {
+  const diff = moment(end).diff(moment());
+  if (diff <= 0) return '0d 00:00:00';
+  const dur = moment.duration(diff);
+  const d = Math.floor(dur.asDays());
+  const h = String(dur.hours()).padStart(2,'0');
+  const m = String(dur.minutes()).padStart(2,'0');
+  const s = String(dur.seconds()).padStart(2,'0');
+  return `${d}d ${h}:${m}:${s}`;
+};
+const reqVotes = (method, total) => {
+  const m = String(method || '').toUpperCase();
+  if (m === 'DEMOCRACY' || m === 'ANARCHY') return Math.floor(Number(total || 0) / 2) + 1;
+  if (m === 'MAJORITY') return Math.ceil(Number(total || 0) * 0.8);
+  if (m === 'MINORITY') return Math.ceil(Number(total || 0) * 0.2);
+  return 0;
+};
+const showVoteMetrics = (method) => {
+  const m = String(method || '').toUpperCase();
+  return !(m === 'DICTATORSHIP' || m === 'KARMATOCRACY');
+};
+
+const applyEl = (fn, attrs, kids) => fn.apply(null, [attrs || {}].concat(kids || []));
+
+const methodImageSrc = (method) => `assets/images/${String(method || '').toUpperCase().toLowerCase()}.png`;
+
+const MethodBadge = (method) => {
+  const m = String(method || '').toUpperCase();
+  const label = String(i18n[`parliamentMethod${m}`] || m).toUpperCase();
+  return span(
+    { class: 'method-badge' },
+    label,
+    br(),br(),
+    img({ src: methodImageSrc(m), alt: label, class: 'method-badge__icon' })
+  );
+};
+
+const MethodHero = (method) => {
+  const m = String(method || '').toUpperCase();
+  const label = String(i18n[`parliamentMethod${m}`] || m).toUpperCase();
+  return span(
+    { class: 'method-hero' },
+    label,
+    br(),br(),
+    img({ src: methodImageSrc(m), alt: label, class: 'method-hero__icon' })
+  );
+};
+
+const KPI = (label, value) =>
+  div({ class: 'kpi' },
+    span({ class: 'kpi__label' }, label),
+    span({ class: 'kpi__value' }, value)
+  );
+
+const CycleInfo = (start, end, labels = {
+  since: i18n.parliamentLegSince,
+  end: i18n.parliamentLegEnd,
+  remaining: i18n.parliamentTimeRemaining
+}) =>
+  div({ class: 'cycle-info' },
+    KPI((labels.since + ': ').toUpperCase(), fmt(start)),
+    KPI((labels.end + ': ').toUpperCase(), fmt(end)),
+    KPI((labels.remaining + ': ').toUpperCase(), timeLeft(end))
+  );
+
+const Tabs = (active) =>
+  div(
+    { class: 'filters' },
+    form(
+      { method: 'GET', action: '/parliament' },
+      ['government', 'candidatures', 'proposals', 'laws', 'revocations', 'historical', 'leaders', 'rules'].map(f =>
+        button({ type: 'submit', name: 'filter', value: f, class: active === f ? 'filter-btn active' : 'filter-btn' }, i18n[`parliamentFilter${f.charAt(0).toUpperCase()+f.slice(1)}`])
+      )
+    )
+  );
+
+const GovHeader = (g) => {
+  const termStart = g && g.since ? g.since : moment().toISOString();
+  const termEnd = g && g.end ? g.end : moment(termStart).add(1, 'minutes').toISOString();
+  const methodKeyRaw = g && g.method ? String(g.method) : 'ANARCHY';
+  const methodKey = methodKeyRaw.toUpperCase();
+  const i18nMeth = i18n[`parliamentMethod${methodKey}`];
+  const methodLabel = (i18nMeth && String(i18nMeth).trim() ? String(i18nMeth) : methodKey).toUpperCase();
+  const isAnarchy = methodKey === 'ANARCHY';
+  const population = String(Number(g.inhabitantsTotal || 0));
+  const votesReceivedNum = Number.isFinite(Number(g.votesReceived)) ? Number(g.votesReceived) : 0;
+  const totalVotesNum = Number.isFinite(Number(g.totalVotes)) ? Number(g.totalVotes) : 0;
+  const votesDisplay = `${votesReceivedNum} (${totalVotesNum})`;
+
+  return div(
+    { class: 'cycle-info' },
+    div({ class: 'kpi' },
+      span({ class: 'kpi__label' }, (i18n.parliamentLegSince + ': ').toUpperCase()),
+      span({ class: 'kpi__value' }, fmt(termStart))
+    ),
+    div({ class: 'kpi' },
+      span({ class: 'kpi__label' }, (i18n.parliamentLegEnd + ': ').toUpperCase()),
+      span({ class: 'kpi__value' }, fmt(termEnd))
+    ),
+    div({ class: 'kpi' },
+      span({ class: 'kpi__label' }, (i18n.parliamentTimeRemaining + ': ').toUpperCase()),
+      span({ class: 'kpi__value' }, timeLeft(termEnd))
+    ),
+    div({ class: 'kpi' },
+      span({ class: 'kpi__label' }, (i18n.parliamentPopulation + ': ').toUpperCase()),
+      span({ class: 'kpi__value' }, population)
+    ),
+    div({ class: 'kpi' },
+      span({ class: 'kpi__label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()),
+      span({ class: 'kpi__value' }, methodLabel)
+    ),
+    !isAnarchy
+      ? div({ class: 'kpi' },
+          span({ class: 'kpi__label' }, (i18n.parliamentVotesReceived + ': ').toUpperCase()),
+          span({ class: 'kpi__value' }, votesDisplay)
+        )
+      : null
+  );
+};
+
+const GovernmentCard = (g, meta) => {
+  const termStart = g && g.since ? g.since : moment().toISOString();
+  const termEnd   = g && g.end   ? g.end   : moment(termStart).add(1, 'minutes').toISOString();
+
+  const actorLabel =
+    g.powerType === 'tribe'
+      ? (i18n.parliamentActorInPowerTribe || i18n.parliamentActorInPower || 'TRIBE RULING')
+      : (i18n.parliamentActorInPowerInhabitant || i18n.parliamentActorInPower || 'INHABITANT RULING');
+
+  const methodKeyRaw = g && g.method ? String(g.method) : 'ANARCHY';
+  const methodKey    = methodKeyRaw.toUpperCase();
+  const i18nMeth     = i18n[`parliamentMethod${methodKey}`];
+  const methodLabel  = (i18nMeth && String(i18nMeth).trim() ? String(i18nMeth) : methodKey).toUpperCase();
+
+  const actorLink =
+    g.powerType === 'tribe'
+      ? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId)
+      : a({ class: 'user-link', href: `/author/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId);
+
+  const actorBio = meta && meta.bio ? meta.bio : '';
+  const memberIds = Array.isArray(g.membersList) ? g.membersList : (Array.isArray(g.members) ? g.members : []);
+
+  const membersRow =
+    g.powerType === 'tribe'
+      ? tr(
+          { class: 'parliament-members-row' },
+          td(
+            { colspan: 2 },
+            div(
+              span({ class: 'card-label' }, (i18n.parliamentMembers + ': ').toUpperCase()),
+              memberIds && memberIds.length
+                ? ul({ class: 'parliament-members-list' }, ...memberIds.map(id => li(a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id))))
+                : span({ class: 'card-value' }, String(g.members || 0))
+            )
+          )
+        )
+      : null;
+
+  return div(
+    { class: 'card' },
+    h2(i18n.parliamentGovernmentCard),
+    GovHeader(g),
+    div(
+      { class: 'table-wrap' },
+      applyEl(table, { class: 'table table--centered gov-overview' }, [
+        thead(tr(
+          th(i18n.parliamentGovMethod),
+          th(i18n.parliamentPoliciesProposal || 'LAWS PROPOSAL'),
+          th(i18n.parliamentPoliciesApproved || 'LAWS APPROVED'),
+          th(i18n.parliamentPoliciesDeclined || 'LAWS DECLINED'),
+          th(i18n.parliamentPoliciesDiscarded || 'LAWS DISCARDED'),
+          th(i18n.parliamentPoliciesRevocated || 'LAWS REVOCATED'),
+          th(i18n.parliamentEfficiency || '% EFFICIENCY')
+        )),
+        tbody(tr(
+          td(div({ class: 'method-cell' }, img({ src: methodImageSrc(methodKey), alt: methodLabel }))),
+          td(String(g.proposed || 0)),
+          td(String(g.approved || 0)),
+          td(String(g.declined || 0)),
+          td(String(g.discarded || 0)),
+          td(String(g.revocated || 0)),
+          td(`${String(g.efficiency || 0)} %`)
+        ))
+      ])
+    ),
+    (g.powerType === 'tribe' || g.powerType === 'inhabitant')
+      ? div(
+          { class: 'table-wrap mt-2' },
+          applyEl(table, { class: 'table parliament-actor-table' }, [
+            thead(tr(
+              th({ class: 'parliament-actor-col' }, String(actorLabel).toUpperCase()),
+              th({ class: 'parliament-description-col' }, i18n.description.toUpperCase())
+            )),
+            tbody(
+              tr(
+                td({ class: 'parliament-actor-col' }, div({ class: 'leader-cell' }, actorLink)),
+                td({ class: 'parliament-description-col' }, p(actorBio || '-'))
+              ),
+              membersRow
+            )
+          ])
+        )
+      : null
+  );
+};
+
+const NoGovernment = () => div({ class: 'empty' }, p(i18n.parliamentNoStableGov));
+const NoProposals = () => div({ class: 'empty' }, p(i18n.parliamentNoProposals));
+const NoLaws = () => div({ class: 'empty' }, p(i18n.parliamentNoLaws));
+const NoGovernments = () => div({ class: 'empty' }, p(i18n.parliamentNoGovernments));
+const NoRevocations = () => null;
+
+const CandidatureForm = () =>
+  div(
+    { class: 'div-center' },
+    h2(i18n.parliamentCandidatureFormTitle),
+    form(
+      { method: 'POST', action: '/parliament/candidatures/propose' },
+      label(i18n.parliamentCandidatureId), br(),
+      input({ type: 'text', name: 'candidateId', placeholder: i18n.parliamentCandidatureIdPh, required: true }), br(), br(),
+      label(i18n.parliamentCandidatureMethod), br(),
+      select({ name: 'method' },
+        ['DEMOCRACY','MAJORITY','MINORITY','DICTATORSHIP','KARMATOCRACY'].map(m => option({ value: m }, i18n[`parliamentMethod${m}`] || m))
+      ), br(), br(),
+      button({ type: 'submit', class: 'create-button' }, i18n.parliamentCandidatureProposeBtn)
+    )
+  );
+
+const pickLeader = (arr) => {
+  if (!arr || !arr.length) return null;
+  const sorted = [...arr].sort((a, b) => {
+    const va = Number(a.votes || 0), vb = Number(b.votes || 0);
+    if (vb !== va) return vb - va;
+    const ka = Number(a.karma || 0), kb = Number(b.karma || 0);
+    if (kb !== ka) return kb - ka;
+    const sa = Number(a.profileSince || 0), sb = Number(b.profileSince || 0);
+    if (sa !== sb) return sa - sb;
+    const ca = new Date(a.createdAt).getTime(), cb = new Date(b.createdAt).getTime();
+    if (ca !== cb) return ca - cb;
+    return String(a.targetId).localeCompare(String(b.targetId));
+  });
+  return sorted[0];
+};
+
+const CandidatureStats = (cands, govCard, leaderMeta) => {
+  if (!cands || !cands.length) return null;
+
+  const leader      = pickLeader(cands || []);
+  const methodKey   = String(leader.method || '').toUpperCase();
+  const methodLabel = String(i18n[`parliamentMethod${methodKey}`] || methodKey).toUpperCase();
+  const votes       = String(leader.votes || 0);
+  const avatarSrc   = (leaderMeta && leaderMeta.avatarUrl) ? leaderMeta.avatarUrl : '/assets/images/default-avatar.png';
+
+  const winLbl = (i18n.parliamentWinningCandidature || i18n.parliamentCurrentLeader || 'WINNING CANDIDATURE').toUpperCase();
+  const idLink = leader
+    ? (leader.targetType === 'inhabitant'
+        ? a({ class: 'user-link', href: `/author/${encodeURIComponent(leader.targetId)}` }, leader.targetId)
+        : a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(leader.targetId)}?` }, leader.targetTitle || leader.targetId))
+    : null;
+
+  return div(
+    { class: 'card' },
+    h2(i18n.parliamentElectionsStatusTitle),
+
+    div({ class: 'card-field card-field--spaced' },
+      span({ class: 'card-label' }, winLbl + ': '),
+      span({ class: 'card-value' }, idLink)
+    ),
+
+    div({ class: 'card-field card-field--spaced' },
+      span({ class: 'card-label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()),
+      span({ class: 'card-value' }, methodLabel)
+    ),
+
+    div(
+      { class: 'table-wrap mt-2' },
+      applyEl(table, [
+        thead(tr(
+          th(i18n.parliamentThLeader),
+          th({ class: 'parliament-method-col' }, i18n.parliamentGovMethod),
+          th({ class: 'parliament-votes-col'  }, i18n.parliamentVotesReceived)
+        )),
+        tbody(tr(
+          td(
+            img({ src: avatarSrc })
+          ),
+          td({ class: 'parliament-method-col' },
+            img({ src: methodImageSrc(methodKey), alt: methodLabel, class: 'method-hero__icon' })
+          ),
+          td({ class: 'parliament-votes-col'  }, span({ class: 'votes-value' }, votes))
+        ))
+      ])
+    )
+  );
+};
+
+const CandidaturesTable = (candidatures) => {
+  const rows = (candidatures || []).map(c => {
+    const idLink =
+      c.targetType === 'inhabitant'
+        ? p(a({ class: 'user-link break-all', href: `/author/${encodeURIComponent(c.targetId)}` }, c.targetId))
+        : p(a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(c.targetId)}?` }, c.targetTitle || c.targetId));
+    return tr(
+      td(idLink),
+      td(fmt(c.createdAt)),
+      td({ class: 'nowrap' }, c.method),
+      td(c.targetType === 'inhabitant' ? String(c.karma || 0) : '-'),
+      td(String(c.votes || 0)),
+      td(form({ method: 'POST', action: `/parliament/candidatures/${encodeURIComponent(c.id)}/vote` }, button({ class: 'vote-btn' }, i18n.parliamentVoteBtn)))
+    );
+  });
+  return div(
+    { class: 'table-wrap' },
+    h2(i18n.parliamentCandidaturesListTitle),
+    applyEl(table, { class: 'table table--centered' }, [
+      thead(tr(
+        th(i18n.parliamentThId),
+        th(i18n.parliamentThProposalDate),
+        th(i18n.parliamentThMethod),
+        th(i18n.parliamentThKarma),
+        th(i18n.parliamentThSupports),
+        th(i18n.parliamentThVote)
+      )),
+      applyEl(tbody, null, rows)
+    ])
+  );
+};
+
+const ProposalForm = () =>
+  div(
+    { class: 'div-center' },
+    h2(i18n.parliamentProposalFormTitle),
+    form(
+      { method: 'POST', action: '/parliament/proposals/create' },
+      label(i18n.parliamentProposalTitle), br(),
+      input({ type: 'text', name: 'title', required: true }), br(), br(),
+      label(i18n.parliamentProposalDescription), br(),
+      textarea({ name: 'description', rows: 5, maxlength: 1000 }), br(), br(),
+      button({ type: 'submit', class: 'create-button' }, i18n.parliamentProposalPublish)
+    )
+  );
+
+const ProposalsList = (proposals) => {
+  if (!proposals || !proposals.length) return null;
+  const cards = proposals.map(pItem =>
+    div(
+      { class: 'card' },
+      br(),
+      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))),
+      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer))),
+      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)),
+      br(),
+      div(
+      h2(pItem.title || ''),
+      p(pItem.description || '')
+      ),
+      pItem.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentProposalDeadlineLabel.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.deadline))) : null,
+      pItem.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentProposalTimeLeft.toUpperCase() + ': '), span({ class: 'card-value' }, timeLeft(pItem.deadline))) : null,
+      showVoteMetrics(pItem.method) ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentVotesNeeded.toUpperCase() + ': '), span({ class: 'card-value' }, String(pItem.needed || reqVotes(pItem.method, pItem.total)))) : null,
+      showVoteMetrics(pItem.method) ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentVotesSlashTotal.toUpperCase() + ': '), span({ class: 'card-value' }, `${Number(pItem.yes||0)}/${Number(pItem.total||0)}`)) : null,
+      pItem && pItem.voteId
+        ? form({ method: 'GET', action: `/votes/${encodeURIComponent(pItem.voteId)}` }, button({ type: 'submit', class: 'vote-btn' }, i18n.parliamentVoteAction))
+        : null
+    )
+  );
+  return div(
+    { class: 'cards' },
+    h2(i18n.parliamentCurrentProposalsTitle),
+    applyEl(div, null, cards)
+  );
+};
+
+const FutureLawsList = (rows) => {
+  if (!rows || !rows.length) return null;
+  const cards = rows.map(pItem =>
+    div(
+      { class: 'card' },
+      br(),
+      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)),
+      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))),
+      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer))),
+      h2(pItem.title || ''),
+      p(pItem.description || '')
+    )
+  );
+  return div(
+    { class: 'cards' },
+    h2(i18n.parliamentFutureLawsTitle),
+    applyEl(div, null, cards)
+  );
+};
+
+const RevocationForm = (laws = []) =>
+  div(
+    { class: 'div-center' },
+    h2(i18n.parliamentRevocationFormTitle),
+    form(
+      {
+        method: 'POST',
+        action: '/parliament/revocations/create'
+      },
+      label(i18n.parliamentRevocationLaw), br(),
+      select(
+        { name: 'lawId', required: true },
+        ...(laws || []).map(l =>
+          option(
+            { value: l.id },
+            `${l.question || l.title || l.id}`
+          )
+        )
+      ),
+      br(), br(),
+      label(i18n.parliamentRevocationReasons), br(),
+      textarea({ name: 'reasons', rows: 4, maxlength: 1000 }),
+      br(), br(),
+      button({ type: 'submit', class: 'create-button' }, i18n.parliamentRevocationPublish || 'Publish Revocation')
+    )
+  );
+
+const RevocationsList = (revocations) => {
+  if (!revocations || !revocations.length) return null;
+  const cards = revocations.map(pItem =>
+    div(
+      { class: 'card' },
+      br(),
+      div(
+        { class: 'card-field' },
+        span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '),
+        span({ class: 'card-value' }, fmt(pItem.createdAt))
+      ),
+      div(
+        { class: 'card-field' },
+        span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '),
+        span(
+          { class: 'card-value' },
+          a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer)
+        )
+      ),
+      div(
+        { class: 'card-field' },
+        span({ class: 'card-label' }, i18n.parliamentGovMethod + ': '),
+        span({ class: 'card-value' }, pItem.method)
+      ),
+      br(),
+      div(
+        h2(pItem.title || pItem.lawTitle || ''),
+        p(pItem.reasons || '')
+      ),
+      pItem.deadline
+        ? div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.parliamentProposalDeadlineLabel.toUpperCase() + ': '),
+            span({ class: 'card-value' }, fmt(pItem.deadline))
+          )
+        : null,
+      pItem.deadline
+        ? div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.parliamentProposalTimeLeft.toUpperCase() + ': '),
+            span({ class: 'card-value' }, timeLeft(pItem.deadline))
+          )
+        : null,
+      showVoteMetrics(pItem.method)
+        ? div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.parliamentVotesNeeded.toUpperCase() + ': '),
+            span({ class: 'card-value' }, String(pItem.needed || reqVotes(pItem.method, pItem.total)))
+          )
+        : null,
+      showVoteMetrics(pItem.method)
+        ? div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, i18n.parliamentVotesSlashTotal.toUpperCase() + ': '),
+            span({ class: 'card-value' }, `${Number(pItem.yes || 0)}/${Number(pItem.total || 0)}`)
+          )
+        : null,
+      pItem && pItem.voteId
+        ? form(
+            { method: 'GET', action: `/votes/${encodeURIComponent(pItem.voteId)}` },
+            button({ type: 'submit', class: 'vote-btn' }, i18n.parliamentVoteAction)
+          )
+        : null
+    )
+  );
+  return div(
+    { class: 'cards' },
+    h2(i18n.parliamentCurrentRevocationsTitle),
+    applyEl(div, null, cards)
+  );
+};
+
+const FutureRevocationsList = (rows) => {
+  if (!rows || !rows.length) return null;
+  const cards = rows.map(pItem =>
+    div(
+      { class: 'card' },
+      br(),
+      pItem.method ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)) : null,
+      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))),
+      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer))),
+      h2(pItem.title || pItem.lawTitle || ''),
+      p(pItem.reasons || '')
+    )
+  );
+  return div(
+    { class: 'cards' },
+    h2(i18n.parliamentFutureRevocationsTitle),
+    applyEl(div, null, cards)
+  );
+};
+
+const LawsStats = (laws = [], revocatedCount = 0) => {
+  const proposed = laws.length;
+  const approved = laws.length;
+  const declined = 0;
+  const discarded = 0;
+  const revocated = Number(revocatedCount || 0);
+  return div(
+    { class: 'table-wrap' },
+    h2(i18n.parliamentPoliciesTitle || 'POLICIES'),
+    applyEl(table, { class: 'table table--centered' }, [
+      thead(tr(
+        th(i18n.parliamentThProposed),
+        th(i18n.parliamentThApproved),
+        th(i18n.parliamentThDeclined),
+        th(i18n.parliamentThDiscarded),
+        th(i18n.parliamentPoliciesRevocated)
+      )),
+      tbody(
+        tr(
+          td(String(proposed)),
+          td(String(approved)),
+          td(String(declined)),
+          td(String(discarded)),
+          td(String(revocated))
+        )
+      )
+    ])
+  );
+};
+
+const LawsList = (laws) => {
+  if (!laws || !laws.length) return NoLaws();
+  const cards = laws.map(l => {
+    const total = Number((l.votes && (l.votes.total || l.votes.TOTAL)) || 0);
+    const yes = Number((l.votes && (l.votes.YES || l.votes.Yes || l.votes.yes)) || 0);
+    const needed = reqVotes(l.method, total);
+    const showMetricsFlag = showVoteMetrics(l.method);
+    return div(
+      { class: 'card' },
+      br(),
+      div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()), span({ class: 'card-value' }, l.method)),
+      div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawEnacted + ': ').toUpperCase()), span({ class: 'card-value' }, fmt(l.enactedAt))),
+      div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(l.proposer)}` }, l.proposer))),
+      h2(l.question || ''),
+      p(l.description || ''),
+      showMetricsFlag ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesNeeded + ': ').toUpperCase()), span({ class: 'card-value' }, String(needed))) : null,
+      showMetricsFlag ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesSlashTotal + ': ').toUpperCase()), span({ class: 'card-value' }, `${yes}/${total}`)) : null
+    );
+  });
+  return div(
+    { class: 'cards' },
+    h2(i18n.parliamentLawsTitle || 'LAWS'),
+    applyEl(div, null, cards)
+  );
+};
+
+const HistoricalGovsSummary = (rows = []) => {
+  const byMethod = new Map();
+  for (const g of rows) {
+    const k = String(g.method || 'ANARCHY').toUpperCase();
+    byMethod.set(k, (byMethod.get(k) || 0) + 1);
+  }
+  const entries = Array.from(byMethod.entries()).sort((a,b) => String(a[0]).localeCompare(String(b[0])));
+  const lines = entries.map(([method, count]) =>
+    tr(td(method), td(String(count)))
+  );
+  return div(
+    { class: 'table-wrap' },
+    h2(i18n.parliamentHistoricalGovernmentsTitle || 'Governments'),
+    applyEl(table, { class: 'table table--centered' }, [
+      thead(tr(th(i18n.parliamentGovMethod), th(i18n.parliamentThCycles))),
+      applyEl(tbody, null, lines)
+    ])
+  );
+};
+
+const HistoricalList = (rows, metasByKey = {}) => {
+  if (!rows || !rows.length) return NoGovernments();
+  const cards = rows.map(g => {
+    const key = `${g.powerType}:${g.powerId}`;
+    const meta = metasByKey[key];
+    const showActor = g.powerType === 'tribe' || g.powerType === 'inhabitant';
+    const showMembers = g.powerType === 'tribe';
+    const actorLabel =
+      g.powerType === 'tribe'
+        ? (i18n.parliamentActorInPowerTribe || 'TRIBE RULING')
+        : (i18n.parliamentActorInPowerInhabitant || 'INHABITANT RULING');
+    return div(
+      { class: 'card' },
+      h2(g.method),
+      div({ class: 'card-field' },
+        span({ class: 'card-label' }, (i18n.parliamentLegSince + ': ').toUpperCase()),
+        span({ class: 'card-value' }, fmt(g.since))
+      ),
+      div({ class: 'card-field' },
+        span({ class: 'card-label' }, (i18n.parliamentLegEnd + ': ').toUpperCase()),
+        span({ class: 'card-value' }, fmt(g.end))
+      ),
+      showActor ? div({ class: 'card-field' },
+        span({ class: 'card-label' }, String(actorLabel).toUpperCase() + ': '),
+        span({ class: 'card-value' },
+          g.powerType === 'tribe'
+            ? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId)
+            : a({ class: 'user-link', href: `/author/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId)
+        )
+      ) : null,
+      (g.method !== 'ANARCHY')
+        ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.parliamentVotesReceived + ': '),
+            span({ class: 'card-value' }, `${g.votesReceived} (${g.totalVotes})`)
+          )
+        : null,
+      br(),
+      showActor && meta && (meta.avatarUrl || meta.bio)
+        ? div(
+            { class: 'actor-meta' },
+            meta.avatarUrl ? img({ src: meta.avatarUrl, alt: '', class: 'avatar--lg' }) : null,
+            meta.bio ? p({ class: 'bio' }, meta.bio) : null
+          )
+        : null,
+      showMembers
+        ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.parliamentMembers + ': '),
+            span({ class: 'card-value' }, String(g.members || 0))
+          )
+        : null,
+      div({ class: 'card-field' },
+        span({ class: 'card-label' }, i18n.parliamentPoliciesProposal + ': '),
+        span({ class: 'card-value' }, String(g.proposed || 0))
+      ),
+      div({ class: 'card-field' },
+        span({ class: 'card-label' }, i18n.parliamentPoliciesApproved + ': '),
+        span({ class: 'card-value' }, String(g.approved || 0))
+      ),
+      div({ class: 'card-field' },
+        span({ class: 'card-label' }, i18n.parliamentPoliciesDeclined + ': '),
+        span({ class: 'card-value' }, String(g.declined || 0))
+      ),
+      div({ class: 'card-field' },
+        span({ class: 'card-label' }, i18n.parliamentPoliciesDiscarded + ': '),
+        span({ class: 'card-value' }, String(g.discarded || 0))
+      ),
+      div({ class: 'card-field' },
+        span({ class: 'card-label' }, i18n.parliamentPoliciesRevocated + ': '),
+        span({ class: 'card-value' }, String(g.revocated || 0))
+      ),
+      div({ class: 'card-field' },
+        span({ class: 'card-label' }, i18n.parliamentEfficiency + ': '),
+        span({ class: 'card-value' }, `${g.efficiency || 0} %`)
+      )
+    );
+  });
+  return div(
+    { class: 'cards' },
+    h2(i18n.parliamentHistoricalElectionsTitle || 'ELECTION CYCLES'),
+    applyEl(div, null, cards)
+  );
+};
+
+const countCandidaturesByActor = (cands = []) => {
+  const m = new Map();
+  for (const c of cands) {
+    const key = `${c.targetType}:${c.targetId}`;
+    m.set(key, (m.get(key) || 0) + 1);
+  }
+  return m;
+};
+
+const LeadersSummary = (leaders = [], candidatures = []) => {
+  const candCounts = countCandidaturesByActor(candidatures);
+  const totals = leaders.reduce((acc, l) => {
+    const key = `${l.powerType}:${l.powerId}`;
+    const candsFromMap = candCounts.get(key) || 0;
+    const presentedNorm = Math.max(Number(l.presented || 0), Number(l.inPower || 0), candsFromMap);
+    acc.presented += presentedNorm;
+    acc.inPower += Number(l.inPower || 0);
+    acc.proposed += Number(l.proposed || 0);
+    acc.approved += Number(l.approved || 0);
+    acc.declined += Number(l.declined || 0);
+    acc.discarded += Number(l.discarded || 0);
+    acc.revocated += Number(l.revocated || 0);
+    return acc;
+  }, { presented:0, inPower:0, proposed:0, approved:0, declined:0, discarded:0, revocated:0 });
+  const efficiencyPct = totals.proposed > 0 ? Math.round((totals.approved / totals.proposed) * 100) : 0;
+  return div(
+    { class: 'table-wrap' },
+    h2(i18n.parliamentHistoricalLawsTitle || 'Actions'),
+    applyEl(table, { class: 'table table--centered' }, [
+      thead(tr(
+        th(i18n.parliamentThTotalCandidatures),
+        th(i18n.parliamentThTimesInPower),
+        th(i18n.parliamentThProposed),
+        th(i18n.parliamentThApproved),
+        th(i18n.parliamentThDeclined),
+        th(i18n.parliamentThDiscarded),
+        th(i18n.parliamentPoliciesRevocated),
+        th(i18n.parliamentEfficiency)
+      )),
+      tbody(
+        tr(
+          td(String(totals.presented)),
+          td(String(totals.inPower)),
+          td(String(totals.proposed)),
+          td(String(totals.approved)),
+          td(String(totals.declined)),
+          td(String(totals.discarded)),
+          td(String(totals.revocated)),
+          td(`${efficiencyPct} %`)
+        )
+      )
+    ])
+  );
+};
+
+const LeadersList = (leaders, metas = {}, candidatures = []) => {
+  if (!leaders || !leaders.length) return div({ class: 'empty' }, p(i18n.parliamentNoLeaders));
+  const rows = leaders.map(l => {
+    const key = `${l.powerType}:${l.powerId}`;
+    const meta = metas[key] || {};
+    const avatar = meta.avatarUrl ? img({ src: meta.avatarUrl, alt: '', class: 'leader-table__avatar' }) : null;
+    const link = l.powerType === 'tribe'
+      ? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(l.powerId)}` }, l.powerTitle || l.powerId)
+      : a({ class: 'user-link', href: `/author/${encodeURIComponent(l.powerId)}` }, l.powerTitle || l.powerId);
+    const leaderCell = div({ class: 'leader-cell' }, avatar, link);
+    return tr(
+      td(leaderCell),
+      td(String(l.proposed || 0)),
+      td(String(l.approved || 0)),
+      td(String(l.declined || 0)),
+      td(String(l.discarded || 0)),
+      td(String(l.revocated || 0)),
+      td(`${(l.efficiency != null ? Math.round(l.efficiency * 100) : (l.proposed > 0 ? Math.round((l.approved / l.proposed) * 100) : 0))} %`)
+    );
+  });
+  return div(
+    { class: 'table-wrap' },
+    h2(i18n.parliamentHistoricalLeadersTitle),
+    applyEl(table, { class: 'table table--centered gov-overview' }, [
+      thead(tr(
+        th(i18n.parliamentActorInPowerInhabitant),
+        th(i18n.parliamentPoliciesProposal),
+        th(i18n.parliamentPoliciesApproved),
+        th(i18n.parliamentPoliciesDeclined),
+        th(i18n.parliamentPoliciesDiscarded),
+        th(i18n.parliamentPoliciesRevocated),
+        th(i18n.parliamentEfficiency)
+      )),
+      applyEl(tbody, null, rows)
+    ])
+  );
+};
+
+const RulesContent = () =>
+  div(
+    { class: 'card' },
+    h2(i18n.parliamentRulesTitle),
+    ul(
+      li(i18n.parliamentRulesIntro),
+      li(i18n.parliamentRulesTerm),
+      li(i18n.parliamentRulesMethods),
+      li(i18n.parliamentRulesAnarchy),
+      li(i18n.parliamentRulesCandidates),
+      li(i18n.parliamentRulesElection),
+      li(i18n.parliamentRulesTies),
+      li(i18n.parliamentRulesProposals),
+      li(i18n.parliamentRulesLimit),
+      li(i18n.parliamentRulesLaws),
+      li(i18n.parliamentRulesRevocations),
+      li(i18n.parliamentRulesHistorical),
+      li(i18n.parliamentRulesLeaders)
+    )
+  );
+
+const CandidaturesSection = (governmentCard, candidatures, leaderMeta) => {
+  const termStart = governmentCard && governmentCard.since ? governmentCard.since : moment().toISOString();
+  const termEnd   = governmentCard && governmentCard.end   ? governmentCard.end   : moment(termStart).add(1, 'minutes').toISOString();
+  return div(
+    h2(i18n.parliamentGovernmentCard),
+    GovHeader(governmentCard || {}),
+    CandidatureStats(candidatures || [], governmentCard || null, leaderMeta || null),
+    CandidatureForm(),
+    candidatures && candidatures.length ? CandidaturesTable(candidatures) : null
+  );
+};
+
+const ProposalsSection = (governmentCard, proposals, futureLaws, canPropose) => {
+  const has = proposals && proposals.length > 0;
+  const fl = FutureLawsList(futureLaws || []);
+  if (!has && canPropose) return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), ProposalForm(), fl);
+  if (!has && !canPropose) return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), NoProposals(), fl);
+  return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), ProposalForm(), ProposalsList(proposals), fl);
+};
+
+const RevocationsSection = (governmentCard, laws, revocations, futureRevocations) =>
+  div(
+    h2(i18n.parliamentGovernmentCard),
+    GovHeader(governmentCard || {}),
+    RevocationForm(laws || []),
+    RevocationsList(revocations || []) || '',
+    FutureRevocationsList(futureRevocations || []) || ''
+  );
+
+exports.parliamentView = async (state) => {
+  const {
+    filter,
+    governmentCard,
+    candidatures,
+    proposals,
+    futureLaws,
+    canPropose,
+    laws,
+    historical,
+    leaders,
+    leaderMeta,
+    powerMeta,
+    revocations,
+    futureRevocations,
+    revocationsEnactedCount,
+    historicalMetas = {},
+    leadersMetas = {}
+  } = state;
+
+  const LawsSectionWrap = () =>
+    div(
+      LawsStats(laws || [], revocationsEnactedCount || 0),
+      LawsList(laws || [])
+    );
+
+  const fallbackAnarchy = {
+    method: 'ANARCHY',
+    votesReceived: 0,
+    totalVotes: 0,
+    proposed: 0,
+    approved: 0,
+    declined: 0,
+    discarded: 0,
+    revocated: 0,
+    efficiency: 0
+  };
+
+  return template(
+    i18n.parliamentTitle,
+    section(div({ class: 'tags-header' }, h2(i18n.parliamentTitle), p(i18n.parliamentDescription)), Tabs(filter)),
+    section(
+      filter === 'government' ? GovernmentCard(governmentCard || fallbackAnarchy, powerMeta) : null,
+      filter === 'candidatures' ? CandidaturesSection(governmentCard || fallbackAnarchy, candidatures, leaderMeta) : null,
+      filter === 'proposals' ? ProposalsSection(governmentCard || fallbackAnarchy, proposals, futureLaws, canPropose) : null,
+      filter === 'laws' ? LawsSectionWrap() : null,
+      filter === 'revocations' ? RevocationsSection(governmentCard || fallbackAnarchy, laws, revocations, futureRevocations) : null,
+      filter === 'historical' ? div(HistoricalGovsSummary(historical || []), HistoricalList(historical || [], historicalMetas)) : null,
+      filter === 'leaders' ? div(LeadersSummary(leaders || [], candidatures || []), LeadersList(leaders || [], leadersMetas, candidatures || [])) : null,
+      filter === 'rules' ? RulesContent() : null
+    )
+  );
+};
+

+ 2 - 2
src/views/vote_view.js

@@ -126,8 +126,8 @@ exports.voteView = async (votes, filter, voteId) => {
   const secondRow = ['CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'];
   const secondRow = ['CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'];
 
 
   const header = div({ class: 'tags-header' },
   const header = div({ class: 'tags-header' },
-    h2(i18n.governanceTitle),
-    p(i18n.governanceDescription)
+    h2(i18n.votationsTitle),
+    p(i18n.votationsDescription)
   );
   );
 
 
   return template(
   return template(