todos-contra-el-fuego-web/imports/ui/components/Comments/CommentsBox.js
vjrj 12bdbe8fed deps: i18next 10 -> 23, react-i18next 7 -> 14
Modernizes the whole i18n stack:
- 48 translate([], {wait:true}) / translate() HOCs -> withTranslation()
- react.wait:true -> react.useSuspense:false
- whitelist -> supportedLngs; added compatibilityJSON: 'v3' so our
  natural-language keys + v3 _plural layout keep working (custom
  separators ss/eth/dj preserved)
- backends: xhr -> i18next-http-backend (client), sync-fs ->
  i18next-fs-backend (server); dropped i18next-localstorage-cache
  (was enabled:false); languagedetector 2 -> 8
- ReSendEmail: <Interpolate> (removed in v10) -> <Trans values={{email}}/>,
  dropped the removed bare t export
- saveMissing handler signature (lngs, ns, key, fallbackValue)

Silences the per-component Translate/I18n legacy-context warnings.
Browser-verified es/en switch and <Trans> interpolation on /fires;
REST smoke byte-identical.
2026-07-18 06:28:41 +02:00

152 lines
5.7 KiB
JavaScript

/* eslint-disable import/no-absolute-path */
/* eslint-disable jsx-a11y/media-has-caption */
import React from 'react';
import PropTypes from 'prop-types';
import { withTracker } from 'meteor/react-meteor-data';
import { withTranslation } from 'react-i18next';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';
import CommentsCollection from '/imports/api/Comments/Comments';
import './Comments.scss';
function MediaEmbed({ media }) {
if (!media || !media.content) return null;
if (media.type === 'image') {
return <img className="comment-media-image" src={media.content} alt="" />;
}
if (media.type === 'youtube') {
return (
<div className="comment-media-youtube">
<iframe title="youtube" src={media.content} frameBorder="0" allowFullScreen />
</div>
);
}
return null;
}
MediaEmbed.propTypes = { media: PropTypes.object };
MediaEmbed.defaultProps = { media: null };
class CommentsBox extends React.Component {
constructor(props) {
super(props);
this.state = { draft: '', editingId: null, editDraft: '' };
}
call(method, ...args) {
Meteor.call(method, ...args, (err) => {
if (err) console.warn(`${method}: ${err.reason || err.message}`);
});
}
submit = (e) => {
e.preventDefault();
const content = this.state.draft.trim();
if (!content) return;
this.setState({ draft: '' });
this.call('comments.insert', this.props.referenceId, content);
};
saveEdit = (id) => {
const content = this.state.editDraft.trim();
if (!content) return;
this.setState({ editingId: null, editDraft: '' });
this.call('comments.edit', id, content);
};
renderComment(c) {
const { t } = this.props;
const currentUserId = Meteor.userId();
const isOwner = currentUserId && c.userId === currentUserId;
const likes = (c.likes || []).length;
const dislikes = (c.dislikes || []).length;
const liked = currentUserId && (c.likes || []).includes(currentUserId);
const disliked = currentUserId && (c.dislikes || []).includes(currentUserId);
const author = c.username || t('Anónimo');
return (
<li key={c._id} className="comment">
<div className="comment-header">
<span className="comment-author">{author}</span>
<span className="comment-date">{moment(c.createdAt).format('LLL')}</span>
</div>
{this.state.editingId === c._id ? (
<div className="comment-edit">
<textarea
className="form-control"
value={this.state.editDraft}
onChange={ev => this.setState({ editDraft: ev.target.value })}
/>
<button type="button" className="btn btn-primary btn-sm" onClick={() => this.saveEdit(c._id)}>{t('Guardar')}</button>
<button type="button" className="btn btn-link btn-sm" onClick={() => this.setState({ editingId: null })}>{t('Borrar')}</button>
</div>
) : (
// Plain text (whitespace preserved via CSS). Rendered as text — not
// HTML — so user content can never inject markup (safer than the old
// markdown-to-HTML comments package).
<p className="comment-content">{c.content}</p>
)}
<MediaEmbed media={c.media} />
<div className="comment-actions">
<button type="button" className={`btn btn-link btn-sm${liked ? ' active' : ''}`} disabled={!currentUserId} onClick={() => this.call('comments.like', c._id)}>
{'👍'} {likes}
</button>
<button type="button" className={`btn btn-link btn-sm${disliked ? ' active' : ''}`} disabled={!currentUserId} onClick={() => this.call('comments.dislike', c._id)}>
{'👎'} {dislikes}
</button>
{isOwner && (
<span className="comment-owner-actions">
<button type="button" className="btn btn-link btn-sm" onClick={() => this.setState({ editingId: c._id, editDraft: c.content })}>{t('Editar')}</button>
<button type="button" className="btn btn-link btn-sm remove-action" onClick={() => this.call('comments.remove', c._id)}>{t('Borrar')}</button>
</span>
)}
</div>
</li>
);
}
render() {
const { t, comments, ready } = this.props;
const loggedIn = !!Meteor.userId();
return (
<div className="comments-box">
<ul className="comments-list">
{comments.map(c => this.renderComment(c))}
</ul>
{ready && comments.length === 0 && (
<p className="comments-empty">{t('Añadir un comentario')}</p>
)}
{loggedIn ? (
<form className="comment-form" onSubmit={this.submit}>
<textarea
className="form-control create-comment"
placeholder={t('Añadir un comentario')}
value={this.state.draft}
onChange={ev => this.setState({ draft: ev.target.value })}
/>
<button type="submit" className="btn btn-primary">{t('Añadir comentario')}</button>
</form>
) : (
<p className="comment-login-hint">
{t('Necesitas iniciar sesión para')} {t('añadir comentarios')}
</p>
)}
</div>
);
}
}
CommentsBox.propTypes = {
t: PropTypes.func.isRequired,
referenceId: PropTypes.string.isRequired,
comments: PropTypes.arrayOf(PropTypes.object).isRequired,
ready: PropTypes.bool.isRequired
};
const CommentsBoxContainer = withTracker(({ referenceId }) => {
const handle = Meteor.subscribe('comments.forReference', referenceId);
return {
ready: handle.ready(),
comments: CommentsCollection.find({ referenceId }, { sort: { createdAt: 1 } }).fetch()
};
})(CommentsBox);
export default withTranslation()(CommentsBoxContainer);