feat(news): implement news section with loading state and error handling in NoLoginView
All checks were successful
Deploy to production / deploy (push) Successful in 3m8s

This commit is contained in:
Torsten Schulz (local)
2026-09-03 14:18:14 +02:00
parent c4977ec826
commit d9a46dcb34
9 changed files with 168 additions and 9 deletions

View File

@@ -7,11 +7,12 @@ import newsService from '../services/newsService.js';
export default {
async getNews(req, res) {
const counter = Math.max(0, parseInt(req.query.counter, 10) || 0);
const count = Math.min(6, Math.max(1, parseInt(req.query.count, 10) || 1));
const language = (req.query.language || 'de').slice(0, 10);
const category = (req.query.category || 'top').slice(0, 50);
try {
const { results, nextPage } = await newsService.getNews({ counter, language, category });
const { results, nextPage } = await newsService.getNews({ counter, count, language, category });
res.json({ results, nextPage });
} catch (error) {
console.error('News getNews:', error);

View File

@@ -1,9 +1,10 @@
import { Router } from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import newsController from '../controllers/newsController.js';
const router = Router();
router.get('/', authenticate, newsController.getNews.bind(newsController));
// News are shown on the public landing page as well as in the dashboard.
// The endpoint only exposes cached third-party headlines, no user data.
router.get('/', newsController.getNews.bind(newsController));
export default router;

View File

@@ -104,20 +104,21 @@ async function getCachedNews({ language = 'de', category = 'top', minArticles =
* @param {number} options.counter - Index des Artikels (0 = erster, 1 = zweiter, …)
* @param {string} [options.language]
* @param {string} [options.category]
* @param {number} [options.count] - Anzahl aufeinanderfolgender Artikel
* @returns {Promise<{ results: Array, nextPage: string|null }>}
*/
async function getNews({ counter = 0, language = 'de', category = 'top' }) {
async function getNews({ counter = 0, count = 1, language = 'de', category = 'top' }) {
const neededIndex = Math.max(0, counter);
const requestedCount = Math.min(6, Math.max(1, Number.parseInt(count, 10) || 1));
// Mindestens so viele Artikel laden wie benötigt
const articles = await getCachedNews({
language,
category,
minArticles: neededIndex + 1
minArticles: neededIndex + requestedCount
});
const single = articles[neededIndex] ? [articles[neededIndex]] : [];
return { results: single, nextPage: null };
return { results: articles.slice(neededIndex, neededIndex + requestedCount), nextPage: null };
}
export default { getNews };

View File

@@ -80,6 +80,13 @@
"title": "Sugdi na",
"text": "Pwede na nimo gamiton, sulayan ug hatagan og feedback. Pagrehistro pinaagi sa “{register}” o sugdi ang random chat."
},
"news": {
"kicker": "Bag-ong balita",
"title": "Balita",
"loading": "Gikarga ang balita …",
"empty": "Walay balita nga anaa karon.",
"unavailable": "Dili magamit ang balita karon."
},
"languageTrainerSeo": {
"title": "Language trainers para sa adlaw-adlaw (beginner)",
"introBefore": "Ang YourPart adunay duha ka",

View File

@@ -80,6 +80,13 @@
"title": "Mitmachen",
"text": "Du kannst die Plattform bereits nutzen, testen und Feedback geben. Registriere dich über „{register}“ oder starte unverbindlich den RandomChat."
},
"news": {
"kicker": "Aktuelles",
"title": "News",
"loading": "News werden geladen …",
"empty": "Zurzeit sind keine News verfügbar.",
"unavailable": "News sind gerade nicht verfügbar."
},
"languageTrainerSeo": {
"title": "Sprachtrainer fuer den Alltag (Anfaenger)",
"introBefore": "YourPart bietet zwei",

View File

@@ -80,6 +80,13 @@
"title": "Get started",
"text": "You can already use, test and give feedback. Register via “{register}” or start the random chat."
},
"news": {
"kicker": "Latest",
"title": "News",
"loading": "Loading news …",
"empty": "There is no news available at the moment.",
"unavailable": "News are currently unavailable."
},
"languageTrainerSeo": {
"title": "Language trainers for everyday use (beginners)",
"introBefore": "YourPart offers two",

View File

@@ -80,6 +80,13 @@
"title": "Participa",
"text": "Ya puedes usar la plataforma, probarla y darnos tu opinión. Regístrate mediante “{register}” o inicia el chat aleatorio sin compromiso."
},
"news": {
"kicker": "Actualidad",
"title": "Noticias",
"loading": "Cargando noticias …",
"empty": "No hay noticias disponibles en este momento.",
"unavailable": "Las noticias no están disponibles en este momento."
},
"languageTrainerSeo": {
"title": "Entrenadores de idiomas para el dia a dia (principiantes)",
"introBefore": "YourPart ofrece dos",

View File

@@ -80,6 +80,13 @@
"title": "Se joindre à",
"text": "Vous pouvez déjà utiliser la plateforme, la tester et donner votre avis. Inscrivez-vous via « {register} » ou démarrez le chat aléatoire sans engagement."
},
"news": {
"kicker": "Actualités",
"title": "Nouvelles",
"loading": "Chargement des nouvelles …",
"empty": "Aucune nouvelle nest disponible pour le moment.",
"unavailable": "Les nouvelles ne sont pas disponibles actuellement."
},
"languageTrainerSeo": {
"title": "Formations langues pour le quotidien (debutants)",
"introBefore": "YourPart propose deux",

View File

@@ -101,6 +101,28 @@
<PasswordResetDialog ref="passwordResetDialog" />
</div>
<section class="public-news surface-card" aria-labelledby="public-news-title">
<div class="public-news__header">
<div>
<span class="panel-kicker">{{ $t('home.nologin.news.kicker') }}</span>
<h2 id="public-news-title">{{ $t('home.nologin.news.title') }}</h2>
</div>
<span v-if="newsLoading" class="public-news__status">{{ $t('home.nologin.news.loading') }}</span>
</div>
<p v-if="newsError" class="public-news__status">{{ $t('home.nologin.news.unavailable') }}</p>
<div v-else-if="news.length" class="public-news__grid">
<article v-for="article in news" :key="article.article_id || article.link || article.title" class="public-news__article">
<span v-if="article.pubDate" class="public-news__date">{{ formatNewsDate(article.pubDate) }}</span>
<a v-if="article.link" :href="article.link" target="_blank" rel="noopener noreferrer" class="public-news__title">
{{ article.title }}
</a>
<h3 v-else class="public-news__title">{{ article.title }}</h3>
<p v-if="article.description" class="public-news__description">{{ summarizeNews(article.description) }}</p>
</article>
</div>
<p v-else-if="!newsLoading" class="public-news__status">{{ $t('home.nologin.news.empty') }}</p>
</section>
<section class="seo-content surface-card" aria-label="Sprachtrainer">
<h2>{{ $t('home.nologin.languageTrainerSeo.title') }}</h2>
<p>
@@ -160,6 +182,9 @@ export default {
oauthProviders: [],
oauthLoading: false,
isStoryCollapsed: true,
news: [],
newsLoading: true,
newsError: false,
};
},
components: {
@@ -190,6 +215,36 @@ export default {
this.oauthProviders = [];
}
},
getNewsLanguage() {
const language = String(this.$i18n?.locale || 'de').toLowerCase();
return ['de', 'en', 'es', 'fr'].includes(language) ? language : 'en';
},
async loadNews() {
this.newsLoading = true;
this.newsError = false;
try {
const { data } = await apiClient.get('/api/news', {
params: { language: this.getNewsLanguage(), category: 'top', count: 3 }
});
this.news = Array.isArray(data?.results) ? data.results.filter((article) => article?.title) : [];
} catch (error) {
this.news = [];
this.newsError = true;
} finally {
this.newsLoading = false;
}
},
formatNewsDate(dateStr) {
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleDateString(this.$i18n?.locale || 'de-DE', {
day: 'numeric', month: 'short', year: 'numeric'
});
},
summarizeNews(description) {
const text = String(description || '').replace(/\s+/g, ' ').trim();
return text.length > 210 ? `${text.slice(0, 207).trimEnd()}` : text;
},
startOAuthLogin(providerSlug) {
if (this.oauthLoading) {
return;
@@ -229,7 +284,7 @@ export default {
}
},
async created() {
await this.loadOAuthProviders();
await Promise.all([this.loadOAuthProviders(), this.loadNews()]);
},
mounted() {
this.$nextTick(() => {
@@ -554,6 +609,71 @@ export default {
border-radius: 4px;
}
.public-news {
width: min(100%, 1120px);
margin: 24px auto 0;
padding: 1.25rem;
}
.public-news__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.public-news__header .panel-kicker {
margin-bottom: 0.45rem;
}
.public-news__header h2 {
margin: 0;
}
.public-news__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
}
.public-news__article {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.7);
}
.public-news__date,
.public-news__status {
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.public-news__title {
margin: 0;
color: var(--color-text-primary);
font-size: 1rem;
line-height: 1.35;
font-weight: 700;
text-decoration: none;
}
a.public-news__title:hover {
color: var(--color-primary);
text-decoration: underline;
}
.public-news__description {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.45;
}
.seo-content h1 {
font-size: 28px;
margin: 0 0 8px 0;
@@ -626,7 +746,8 @@ export default {
.story-columns,
.access-split,
.login-fields,
.oauth-provider-list {
.oauth-provider-list,
.public-news__grid {
grid-template-columns: 1fr;
}
}