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
All checks were successful
Deploy to production / deploy (push) Successful in 3m8s
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 Random‑Chat."
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 n’est disponible pour le moment.",
|
||||
"unavailable": "Les nouvelles ne sont pas disponibles actuellement."
|
||||
},
|
||||
"languageTrainerSeo": {
|
||||
"title": "Formations langues pour le quotidien (debutants)",
|
||||
"introBefore": "YourPart propose deux",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user