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 {
|
export default {
|
||||||
async getNews(req, res) {
|
async getNews(req, res) {
|
||||||
const counter = Math.max(0, parseInt(req.query.counter, 10) || 0);
|
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 language = (req.query.language || 'de').slice(0, 10);
|
||||||
const category = (req.query.category || 'top').slice(0, 50);
|
const category = (req.query.category || 'top').slice(0, 50);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { results, nextPage } = await newsService.getNews({ counter, language, category });
|
const { results, nextPage } = await newsService.getNews({ counter, count, language, category });
|
||||||
res.json({ results, nextPage });
|
res.json({ results, nextPage });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('News getNews:', error);
|
console.error('News getNews:', error);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { authenticate } from '../middleware/authMiddleware.js';
|
|
||||||
import newsController from '../controllers/newsController.js';
|
import newsController from '../controllers/newsController.js';
|
||||||
|
|
||||||
const router = Router();
|
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;
|
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 {number} options.counter - Index des Artikels (0 = erster, 1 = zweiter, …)
|
||||||
* @param {string} [options.language]
|
* @param {string} [options.language]
|
||||||
* @param {string} [options.category]
|
* @param {string} [options.category]
|
||||||
|
* @param {number} [options.count] - Anzahl aufeinanderfolgender Artikel
|
||||||
* @returns {Promise<{ results: Array, nextPage: string|null }>}
|
* @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 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
|
// Mindestens so viele Artikel laden wie benötigt
|
||||||
const articles = await getCachedNews({
|
const articles = await getCachedNews({
|
||||||
language,
|
language,
|
||||||
category,
|
category,
|
||||||
minArticles: neededIndex + 1
|
minArticles: neededIndex + requestedCount
|
||||||
});
|
});
|
||||||
|
|
||||||
const single = articles[neededIndex] ? [articles[neededIndex]] : [];
|
return { results: articles.slice(neededIndex, neededIndex + requestedCount), nextPage: null };
|
||||||
return { results: single, nextPage: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default { getNews };
|
export default { getNews };
|
||||||
|
|||||||
@@ -80,6 +80,13 @@
|
|||||||
"title": "Sugdi na",
|
"title": "Sugdi na",
|
||||||
"text": "Pwede na nimo gamiton, sulayan ug hatagan og feedback. Pagrehistro pinaagi sa “{register}” o sugdi ang random chat."
|
"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": {
|
"languageTrainerSeo": {
|
||||||
"title": "Language trainers para sa adlaw-adlaw (beginner)",
|
"title": "Language trainers para sa adlaw-adlaw (beginner)",
|
||||||
"introBefore": "Ang YourPart adunay duha ka",
|
"introBefore": "Ang YourPart adunay duha ka",
|
||||||
|
|||||||
@@ -80,6 +80,13 @@
|
|||||||
"title": "Mitmachen",
|
"title": "Mitmachen",
|
||||||
"text": "Du kannst die Plattform bereits nutzen, testen und Feedback geben. Registriere dich über „{register}“ oder starte unverbindlich den Random‑Chat."
|
"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": {
|
"languageTrainerSeo": {
|
||||||
"title": "Sprachtrainer fuer den Alltag (Anfaenger)",
|
"title": "Sprachtrainer fuer den Alltag (Anfaenger)",
|
||||||
"introBefore": "YourPart bietet zwei",
|
"introBefore": "YourPart bietet zwei",
|
||||||
|
|||||||
@@ -80,6 +80,13 @@
|
|||||||
"title": "Get started",
|
"title": "Get started",
|
||||||
"text": "You can already use, test and give feedback. Register via “{register}” or start the random chat."
|
"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": {
|
"languageTrainerSeo": {
|
||||||
"title": "Language trainers for everyday use (beginners)",
|
"title": "Language trainers for everyday use (beginners)",
|
||||||
"introBefore": "YourPart offers two",
|
"introBefore": "YourPart offers two",
|
||||||
|
|||||||
@@ -80,6 +80,13 @@
|
|||||||
"title": "Participa",
|
"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."
|
"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": {
|
"languageTrainerSeo": {
|
||||||
"title": "Entrenadores de idiomas para el dia a dia (principiantes)",
|
"title": "Entrenadores de idiomas para el dia a dia (principiantes)",
|
||||||
"introBefore": "YourPart ofrece dos",
|
"introBefore": "YourPart ofrece dos",
|
||||||
|
|||||||
@@ -80,6 +80,13 @@
|
|||||||
"title": "Se joindre à",
|
"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."
|
"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": {
|
"languageTrainerSeo": {
|
||||||
"title": "Formations langues pour le quotidien (debutants)",
|
"title": "Formations langues pour le quotidien (debutants)",
|
||||||
"introBefore": "YourPart propose deux",
|
"introBefore": "YourPart propose deux",
|
||||||
|
|||||||
@@ -101,6 +101,28 @@
|
|||||||
<PasswordResetDialog ref="passwordResetDialog" />
|
<PasswordResetDialog ref="passwordResetDialog" />
|
||||||
</div>
|
</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">
|
<section class="seo-content surface-card" aria-label="Sprachtrainer">
|
||||||
<h2>{{ $t('home.nologin.languageTrainerSeo.title') }}</h2>
|
<h2>{{ $t('home.nologin.languageTrainerSeo.title') }}</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -160,6 +182,9 @@ export default {
|
|||||||
oauthProviders: [],
|
oauthProviders: [],
|
||||||
oauthLoading: false,
|
oauthLoading: false,
|
||||||
isStoryCollapsed: true,
|
isStoryCollapsed: true,
|
||||||
|
news: [],
|
||||||
|
newsLoading: true,
|
||||||
|
newsError: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
@@ -190,6 +215,36 @@ export default {
|
|||||||
this.oauthProviders = [];
|
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) {
|
startOAuthLogin(providerSlug) {
|
||||||
if (this.oauthLoading) {
|
if (this.oauthLoading) {
|
||||||
return;
|
return;
|
||||||
@@ -229,7 +284,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async created() {
|
async created() {
|
||||||
await this.loadOAuthProviders();
|
await Promise.all([this.loadOAuthProviders(), this.loadNews()]);
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -554,6 +609,71 @@ export default {
|
|||||||
border-radius: 4px;
|
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 {
|
.seo-content h1 {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
@@ -626,7 +746,8 @@ export default {
|
|||||||
.story-columns,
|
.story-columns,
|
||||||
.access-split,
|
.access-split,
|
||||||
.login-fields,
|
.login-fields,
|
||||||
.oauth-provider-list {
|
.oauth-provider-list,
|
||||||
|
.public-news__grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user