OASIS_MOBILE/src/views/larp_view.js
SITO f964e61a1c upstream: integrar 0.9.5 en lo que no diverge (57 ficheros) + dependencias nuevas
Primera tanda de la integracion 0.9.1 -> 0.9.5. Se actualizan los 57 ficheros que
upstream cambio y la rama no tocaba, entre ellos los 11 de traduccion: eso ya solo
es posible gracias al overlay de i18n, antes eran 11 conflictos garantizados.

Se portan ademas las dependencias que 0.9.5 introduce y de las que dependen los
ficheros actualizados: pdfDocument, content_favorites, recurrence, media_gallery,
comments_view, gallery_view y polls (modelo, limites y vista).

Quedan 48 ficheros con divergencia real por resolver a mano (backend.js,
main_views.js, OasisMobile.css y 45 vistas y modelos).

Verificado: 0 errores de sintaxis en src/, 0 requires relativos rotos, la app
arranca declarando 0.9.5 y las 47 rutas principales responden 200/302.
2026-08-18 21:42:59 +02:00

468 lines
21 KiB
JavaScript

const { div, h1, h2, h3, p, section, button, form, input, span, img, a, br, table, tr, td, textarea, label, strong, details, summary } = require("../server/node_modules/hyperaxe");
const { template, i18n, userLink } = require("./main_views");
const { config } = require("../server/SSB_server.js");
const userId = config.keys.id;
const moment = require("../server/node_modules/moment");
const fmtCycle = (c) => c && c.formatted ? c.formatted : '';
const houseImageSrc = (house) => (house && house.image) ? house.image : '/assets/larp/images/default.jpg';
const renderCycleBanner = (cycle) => div({ class: 'larp-cycle-banner' },
span({ class: 'larp-cycle-label' }, i18n.larpCycleLabel || 'Cycle'),
span({ class: 'larp-cycle-value' }, fmtCycle(cycle))
);
const renderHouseBadges = ({ myHouse, governingHouse, houses }) => {
const academia = Array.isArray(houses) ? houses.find(h => h.key === 'academia') : null;
const isNewcomer = !myHouse;
return div({ class: 'larp-house-badges' },
myHouse ? div({ class: 'larp-my-house' },
span({ class: 'larp-my-house-label' }, i18n.larpMyHouse || 'My House'),
a({ href: `/larp/${myHouse.key}`, class: 'larp-my-house-link' }, myHouse.name)
) : null,
governingHouse ? div({ class: 'larp-my-house' },
span({ class: 'larp-my-house-label' }, i18n.larpGoverning || 'Ruling:'),
a({ href: `/larp/${governingHouse.key}`, class: 'larp-my-house-link' }, governingHouse.name)
) : null,
isNewcomer && academia ? div({ class: 'larp-my-house larp-academia-hint' },
span({ class: 'larp-my-house-label' }, i18n.larpStartHere),
a({ href: `/larp/${academia.key}`, class: 'larp-my-house-link' }, academia.name)
) : null
);
};
const renderHouseNav = (houses, currentKey) => div({ class: 'larp-house-nav' },
span({ class: 'larp-house-nav-label' }, i18n.larpAllHouses || 'Houses:'),
houses.map(h => a({
href: `/larp/${h.key}`,
class: h.key === currentKey ? 'larp-house-nav-chip active' : 'larp-house-nav-chip',
title: h.name
}, h.name))
);
const renderHousePanel = (house, members, posts, { canPost, viewerHouseKey, cyclesUntilRuling, canTakeTest = false }) => {
const { renderEncryptedChip } = require('./clearnet_view');
const isInHouse = viewerHouseKey === house.key;
const isInLarp = viewerHouseKey !== null && viewerHouseKey !== undefined;
const isMemberE2E = isInHouse && house.key !== 'academia';
const isAcademia = house.key === 'academia';
const canInvite = isInHouse && !isAcademia;
const canLeaveToAcademia = isInHouse && !isAcademia;
const canLeaveLarp = isInHouse && isAcademia;
const canJoinAcademia = !isInLarp && isAcademia;
const showInviteForm = !isAcademia && !isInHouse && (viewerHouseKey === 'academia' || !isInLarp);
return div({ class: 'larp-detail' },
div({ class: 'larp-detail-image-col' },
img({ src: houseImageSrc(house), alt: house.name, class: 'larp-detail-image' })
),
div({ class: 'larp-detail-body' },
div({ class: 'title-with-chip' },
h2({ class: 'larp-detail-name' }, house.name),
(cyclesUntilRuling === 0 && !isAcademia)
? span({ class: 'larp-badge larp-badge-ruling' }, i18n.larpBadgeRuling || 'Ruling')
: null,
isMemberE2E ? renderEncryptedChip(i18n) : null
),
p({ class: 'larp-detail-motto' }, '“' + house.motto + '”'),
table({ class: 'larp-info-table' },
tr(td({ class: 'card-label' }, i18n.larpRolesLabel || 'Roles'), td({ class: 'card-value' }, house.roles)),
tr(td({ class: 'card-label' }, i18n.larpFunctionLabel || 'Function'), td({ class: 'card-value' }, house.function)),
tr(td({ class: 'card-label' }, i18n.larpMonthLabel || 'Governance cycle'), td({ class: 'card-value' }, String(house.month || ''))),
(typeof cyclesUntilRuling === 'number' && cyclesUntilRuling > 0)
? tr(td({ class: 'card-label' }, i18n.larpCyclesUntilRuling || 'Next governance cycle'), td({ class: 'card-value' }, String(cyclesUntilRuling)))
: null,
tr(td({ class: 'card-label' }, i18n.larpMembersCount || 'Members'), td({ class: 'card-value' }, String(Array.isArray(members) ? members.length : (house.memberCount || 0))))
),
p({ class: 'larp-detail-description' }, house.description),
div({ class: 'larp-actions' },
isAcademia && isInHouse && canTakeTest
? a({ href: '/larp/test', class: 'filter-btn larp-start-journey' }, i18n.larpStartJourney)
: null,
isInHouse
? a({ href: `/larp/tribe/${encodeURIComponent(house.key)}`, class: 'filter-btn' }, i18n.larpVisitTribe || 'Visit Tribe')
: null,
canInvite
? form({ method: 'POST', action: '/larp/invite/create', class: 'larp-invite-create-form' },
input({ type: 'hidden', name: 'house', value: house.key }),
button({ type: 'submit', class: 'filter-btn' }, i18n.larpInviteCreate || 'Generate invitation code')
)
: null,
canLeaveToAcademia
? form({ method: 'POST', action: '/larp/join' },
input({ type: 'hidden', name: 'house', value: 'academia' }),
button({ type: 'submit', class: 'filter-btn danger-btn' }, i18n.larpLeaveToAcademia || 'Leave House')
)
: null,
canLeaveLarp
? form({ method: 'POST', action: '/larp/leave' },
button({ type: 'submit', class: 'filter-btn danger-btn' }, i18n.larpLeaveToAcademia || 'Leave House')
)
: null,
canJoinAcademia
? form({ method: 'POST', action: '/larp/join' },
input({ type: 'hidden', name: 'house', value: 'academia' }),
button({ type: 'submit', class: 'filter-btn' }, i18n.larpInviteRedeem || 'Join House')
)
: null,
showInviteForm
? form({ method: 'POST', action: '/larp/invite/redeem', class: 'larp-card-invite-form' },
input({ type: 'hidden', name: 'returnTo', value: `/larp/${house.key}` }),
input({ type: 'text', name: 'code', placeholder: i18n.larpInvitePlaceholder || 'Invitation code', maxlength: '32', required: 'required' }),
button({ type: 'submit', class: 'filter-btn' }, i18n.larpInviteRedeem || 'Join House')
)
: null
)
)
);
};
const formatCooldown = (ms) => {
if (ms <= 0) return '';
const days = Math.ceil(ms / (24 * 60 * 60 * 1000));
return String(days);
};
const renderAcademiaJoinPanel = (allHouses, testStatus, housesById, questions, myHouseKey) => {
const myKey = myHouseKey || 'academia';
const ordered = [
...allHouses.filter(h => h.key === myKey),
...allHouses.filter(h => h.key !== myKey)
];
const last = testStatus && testStatus.last ? testStatus.last : null;
const lastHouse = last && housesById ? housesById[last.house] : null;
const lastHouseName = lastHouse ? lastHouse.name : (last ? last.house : null);
const remainingMs = testStatus && !testStatus.allowed ? (testStatus.nextAt - Date.now()) : 0;
const canTake = testStatus && testStatus.allowed;
const qs = Array.isArray(questions) ? questions : [];
return div({ class: 'larp-academia-join' },
h2(i18n.larpAcademiaJoinTitle || 'Houses Current Status'),
last
? div({ class: 'larp-last-attempt' },
h2({ class: 'larp-last-attempt-title' }, i18n.larpLastAttemptTitle || 'Your last attempt'),
table({ class: 'larp-info-table' },
tr(
td({ class: 'card-label' }, i18n.larpLastAttemptHouse || 'House'),
td({ class: 'card-value' }, lastHouse
? a({ href: `/larp/${lastHouse.key}` }, lastHouseName)
: lastHouseName || '—')
),
Number.isFinite(last.score)
? tr(
td({ class: 'card-label' }, i18n.larpTestScore || 'Score'),
td({ class: 'card-value' }, String(last.score))
)
: null,
last.ts
? tr(
td({ class: 'card-label' }, i18n.larpLastAttemptWhen || 'When'),
td({ class: 'card-value' }, moment(last.ts).format('YYYY/MM/DD HH:mm:ss'))
)
: null,
!testStatus.allowed
? tr(
td({ class: 'card-label' }, i18n.larpLastAttemptCooldown || 'Next attempt in'),
td({ class: 'card-value' }, formatCooldown(remainingMs) + ' ' + (i18n.larpTestCooldownDays || 'cycles'))
)
: null
)
)
: (testStatus && !testStatus.allowed
? p({ class: 'larp-test-cooldown-banner' },
(i18n.larpTestCooldownActive || 'Next test available in') + ' ' + formatCooldown(remainingMs) + ' ' + (i18n.larpTestCooldownDays || 'cycles')
)
: null),
div({ class: 'larp-academia-grid' },
ordered.map(h => a({ href: `/larp/${h.key}`, class: h.key === myKey ? 'larp-academia-thumb larp-academia-thumb-mine' : 'larp-academia-thumb' },
img({ src: houseImageSrc(h), alt: h.name, class: 'larp-academia-thumb-image' }),
span({ class: 'larp-academia-thumb-name' }, h.name),
table({ class: 'larp-info-table larp-academia-thumb-table' },
tr(td({ class: 'card-label' }, i18n.larpRolesLabel || 'Roles'), td({ class: 'card-value' }, h.roles)),
tr(td({ class: 'card-label' }, i18n.larpFunctionLabel || 'Function'), td({ class: 'card-value' }, h.function)),
tr(td({ class: 'card-label' }, i18n.larpMembersCount || 'Members'), td({ class: 'card-value' }, String(h.memberCount || 0)))
)
))
),
);
};
const renderPostsBlock = (posts, house, canPost) => {
const stream = (Array.isArray(posts) ? posts : [])
.map(post => ({ ts: Date.parse(post.createdAt) || post.ts || 0, post }))
.sort((a, b) => b.ts - a.ts);
return div({ class: 'larp-posts-block' },
h2(i18n.larpPostsTitle || 'Wall'),
canPost
? form({ method: 'POST', action: '/larp/post', class: 'larp-post-form' },
input({ type: 'hidden', name: 'house', value: house.key }),
textarea({ id: 'larp_post_text', name: 'text', rows: '3', maxlength: '4000', placeholder: i18n.larpPostPlaceholder || 'What does this house need to say?' }),
button({ type: 'submit', class: 'filter-btn larp-post-submit' }, i18n.larpPostSubmit || 'Publish')
)
: null,
stream.length === 0
? p({ class: 'empty' }, i18n.larpPostsEmpty || 'No posts yet.')
: div({ class: 'larp-posts-list' },
stream.map(entry => div({ class: 'larp-post' },
div({ class: 'larp-post-head' },
userLink(entry.post.author),
span({ class: 'larp-post-time' }, moment(entry.post.createdAt).format('YYYY/MM/DD HH:mm'))
),
p({ class: 'larp-post-text' }, entry.post.text)
))
)
);
};
const renderHouseSearch = (q) => div({ class: 'filters' },
form({ method: 'GET', action: '/larp', class: 'filter-box' },
input({ type: 'hidden', name: 'filter', value: 'houses' }),
input({ type: 'text', name: 'q', value: q || '', placeholder: i18n.larpSearchPlaceholder, class: 'filter-box__input' }),
div({ class: 'filter-box__controls' },
button({ type: 'submit', class: 'filter-box__button' }, i18n.searchButton)
)
)
);
const matchesHouse = (house, q) => {
const needle = String(q || '').trim().toLowerCase();
if (!needle) return true;
return ['name', 'motto', 'roles', 'function', 'description']
.some(f => String(house[f] || '').toLowerCase().includes(needle));
};
const renderModeButtons = (filter) => div({ class: 'mode-buttons stats-mode-row' },
['ruling', 'houses', 'rules'].map(m =>
form({ method: 'GET', action: '/larp' },
input({ type: 'hidden', name: 'filter', value: m }),
button({
type: 'submit',
class: filter === m ? 'filter-btn active' : 'filter-btn'
},
m === 'ruling' ? (i18n.larpFilterRuling || 'RULING')
: m === 'houses' ? (i18n.larpFilterHouses || 'HOUSES')
: (i18n.larpFilterRules || 'FAQ')
)
)
)
);
const renderRules = () => {
const points = [
i18n.larpRulesEntryText,
i18n.larpRulesOneHouseText,
i18n.larpRulesTestEntryText,
i18n.larpRulesInviteEntryText,
i18n.larpRulesGovernanceText,
i18n.larpRulesWallText,
i18n.larpRulesSignText
];
return div(
{ class: 'card' },
h2(i18n.larpRulesTitle || 'F.A.Q.'),
div({ class: 'rules-points' },
points.filter(Boolean).map((text, i) =>
div({ class: 'rules-point' },
span({ class: 'rules-point-num' }, String(i + 1)),
span({ class: 'rules-point-text' }, text)
)
)
)
);
};
const renderHousesGrid = (houses, myHouseKey, governingKey) => {
const inLarp = !!myHouseKey;
const mine = inLarp ? houses.filter(h => h.key === myHouseKey) : [];
const ruling = houses.filter(h => h.key === governingKey && h.key !== myHouseKey);
const rest = houses.filter(h => h.key !== myHouseKey && h.key !== governingKey);
const ordered = [...mine, ...ruling, ...rest];
return div({ class: 'larp-grid' },
ordered.map(h => {
const isMine = h.key === myHouseKey;
const isGoverning = h.key === governingKey;
const highlight = isGoverning ? 'larp-card-ruling' : (isMine ? 'larp-card-mine' : '');
return div({ class: `larp-card larp-card-${h.key}${highlight ? ' ' + highlight : ''}` },
a({ href: `/larp/${h.key}`, class: 'larp-card-image-link' },
img({ class: 'larp-card-image', src: houseImageSrc(h), alt: h.name })
),
div({ class: 'larp-card-body' },
div({ class: 'larp-card-title-row' },
a({ href: `/larp/${h.key}`, class: 'larp-card-title' }, h.name),
isMine ? span({ class: 'larp-badge larp-badge-mine' }, i18n.larpBadgeYou || 'You') : null,
isGoverning ? span({ class: 'larp-badge larp-badge-ruling' }, i18n.larpBadgeRuling || 'Ruling') : null
),
p({ class: 'larp-card-motto' }, '“' + h.motto + '”'),
p({ class: 'larp-card-roles' }, h.roles),
div({ class: 'tribe-card-members' },
span({ class: 'tribe-members-count' }, `${i18n.larpMembersCount || 'Members'}: ${h.memberCount || 0}`)
),
h.openInviteCode ? p({ class: 'larp-card-count' }, i18n.tribeInviteCodeText, span({ class: 'tribe-open-invite-code' }, h.openInviteCode)) : null
)
);
})
);
};
exports.larpListView = ({ filter, houses, myHouseKey, cycle, governingKey, governingHouse, governingMembers, governingPosts, canPost, q }) => {
const title = i18n.larpTitle || 'L.A.R.P.';
const description = i18n.larpDescription || 'A live action role-playing layer for collaborative experimentation.';
const myHouse = houses.find(h => h.key === myHouseKey) || null;
const search = String(q || '').trim();
const mode = search ? 'houses' : filter === 'houses' ? 'houses' : filter === 'rules' ? 'rules' : 'ruling';
const matched = search ? houses.filter(h => matchesHouse(h, search)) : houses;
return template(
title,
section(
div({ class: 'tags-header' },
h1(title),
p(description)
),
renderCycleBanner(cycle),
renderHouseBadges({ myHouse, governingHouse, houses }),
renderModeButtons(mode),
renderHouseSearch(search),
mode === 'houses'
? (matched.length
? renderHousesGrid(matched, myHouseKey, governingKey)
: p({ class: 'no-content' }, i18n.larpNoHousesMatch))
: mode === 'rules'
? renderRules()
: [
renderHousePanel(governingHouse, governingMembers, governingPosts, { canPost, viewerHouseKey: myHouseKey, cyclesUntilRuling: 0 }),
renderPostsBlock(governingPosts, governingHouse, canPost)
]
)
);
};
const computeCyclesUntilRuling = (house, governingKey, now = new Date()) => {
if (!house || house.key === governingKey) return 0;
const houseIdx = (Number(house.month) || 1) - 1;
const currentMonth = now.getMonth();
for (let i = 1; i <= 12; i += 1) {
const futureMonth = (currentMonth + i) % 12;
if (futureMonth % 9 === houseIdx) return i;
}
return 0;
};
exports.larpHouseView = ({ house, members, myHouseKey, cycle, governingKey, houses, posts, canPost, testStatus, inviteCode, questions }) => {
const myHouse = houses.find(h => h.key === myHouseKey) || null;
const governingHouse = houses.find(h => h.key === governingKey) || null;
const isAcademia = house.key === 'academia';
const viewerInAcademia = myHouseKey === 'academia';
const viewerIsMember = myHouseKey === house.key && house.key !== 'academia';
const canWriteWall = myHouseKey === house.key;
const showWall = viewerIsMember || house.key === governingKey || house.key === 'academia';
const cycles = computeCyclesUntilRuling(house, governingKey);
const housesById = Object.fromEntries(houses.map(h => [h.key, h]));
return template(
house.name,
section(
div({ class: 'tags-header' },
h1(house.name),
p({ class: 'larp-detail-motto' }, '“' + house.motto + '”')
),
renderCycleBanner(cycle),
renderHouseBadges({ myHouse, governingHouse, houses }),
renderHouseNav(houses, house.key),
renderHousePanel(house, members, posts, {
canPost,
viewerHouseKey: myHouseKey,
cyclesUntilRuling: cycles,
canTakeTest: isAcademia && viewerInAcademia && !!(testStatus && testStatus.allowed)
}),
inviteCode
? div({ class: 'larp-invite-banner' },
p({ class: 'larp-invite-banner-title' }, strong(i18n.larpInviteBannerTitle || 'New invitation code')),
p({ class: 'larp-invite-banner-hint' }, i18n.larpInviteBannerHint || 'Share this code with someone in ACADEMIA. It expires in 30 cycles or after one use.'),
p({ class: 'larp-invite-banner-code' }, inviteCode)
)
: null,
isAcademia && viewerInAcademia ? renderAcademiaJoinPanel(houses, testStatus, housesById, questions, myHouseKey) : null,
showWall
? renderPostsBlock(posts, house, canWriteWall)
: null
)
);
};
exports.larpTestView = ({ questions, cycle, houses, myHouseKey, governingKey, testStatus }) => {
const myHouse = houses.find(h => h.key === myHouseKey) || null;
const governingHouse = houses.find(h => h.key === governingKey) || null;
const title = i18n.larpTitle || 'L.A.R.P.';
const description = i18n.larpDescription || 'A live action role-playing layer for collaborative experimentation.';
return template(
title,
section(
div({ class: 'tags-header' },
h1(title),
p(description)
),
renderCycleBanner(cycle),
renderHouseBadges({ myHouse, governingHouse, houses }),
renderModeButtons(''),
testStatus && !testStatus.allowed
? div({ class: 'larp-test-cooldown' },
p((i18n.larpTestCooldownActive || 'Next test available in') + ' ' + formatCooldown(testStatus.nextAt - Date.now()) + ' ' + (i18n.larpTestCooldownDays || 'cycles')),
p(a({ href: '/larp/academia', class: 'filter-btn' }, i18n.larpBackToAcademia || 'Back to ACADEMIA'))
)
: form({ method: 'POST', action: '/larp/test', class: 'larp-test-form' },
questions.map((q, idx) => div({ class: 'larp-test-question' },
p({ class: 'larp-test-q-text' }, strong(`${idx + 1}. `), i18n[q.key] || q.question),
div({ class: 'larp-test-options' },
q.options.map((opt, oi) => label({ class: 'larp-test-option' },
input({ type: 'radio', name: `q${idx}`, value: String(oi), required: 'required' }),
' ', i18n[opt.key] || opt.text
))
)
)),
div({ class: 'larp-actions' },
button({ type: 'submit', class: 'filter-btn' }, i18n.larpTestSubmit || 'Join House')
)
)
)
);
};
exports.larpTestResultView = ({ house, result, cycle, houses, myHouseKey, governingKey }) => {
const myHouse = houses.find(h => h.key === myHouseKey) || null;
const governingHouse = houses.find(h => h.key === governingKey) || null;
const titleText = (i18n.larpTestResultTitle || 'Test result') + (house && house.name ? ' — ' + house.name : '');
const ranking = Array.isArray(result && result.ranking) ? result.ranking : [];
const housesById = Object.fromEntries(houses.map(h => [h.key, h]));
return template(
titleText,
section(
div({ class: 'tags-header' }, h1(titleText)),
renderCycleBanner(cycle),
renderHouseBadges({ myHouse, governingHouse, houses }),
div({ class: 'larp-test-result' },
ranking.length
? div({ class: 'larp-test-ranking' },
h2({ class: 'larp-test-ranking-title' }, i18n.larpTestRankingTitle || 'Score by house'),
table({ class: 'larp-info-table' },
ranking.map(([key, score]) => tr(
td({ class: 'card-label' }, housesById[key] ? a({ href: `/larp/${key}` }, housesById[key].name) : key),
td({ class: 'card-value' }, String(score))
))
)
)
: null,
p({ class: 'larp-test-next' }, i18n.larpTestNextAttempt || 'You can attempt another test in 30 cycles.'),
div({ class: 'larp-actions' },
a({ href: house ? `/larp/${house.key}` : '/larp/academia', class: 'filter-btn' },
i18n.larpGoToHouse || 'Go to your house'
)
)
)
)
);
};