Add news widget functionality: Integrate newsRouter for fetching news data, update initializeWidgetTypes to include news endpoint, and enhance DashboardWidget component to display news articles with pagination support. Update LoggedInView to manage widget request counters for unique endpoint handling.
This commit is contained in:
@@ -21,6 +21,7 @@ import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js';
|
||||
import termineRouter from './routers/termineRouter.js';
|
||||
import vocabRouter from './routers/vocabRouter.js';
|
||||
import dashboardRouter from './routers/dashboardRouter.js';
|
||||
import newsRouter from './routers/newsRouter.js';
|
||||
import cors from 'cors';
|
||||
import './jobs/sessionCleanup.js';
|
||||
|
||||
@@ -80,6 +81,7 @@ app.use('/api/models', modelsProxyRouter);
|
||||
app.use('/api/blog', blogRouter);
|
||||
app.use('/api/termine', termineRouter);
|
||||
app.use('/api/dashboard', dashboardRouter);
|
||||
app.use('/api/news', newsRouter);
|
||||
|
||||
// Serve frontend SPA for non-API routes to support history mode clean URLs
|
||||
// /models/* nicht statisch ausliefern – nur über /api/models (Proxy mit Komprimierung)
|
||||
|
||||
21
backend/controllers/newsController.js
Normal file
21
backend/controllers/newsController.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import newsService from '../services/newsService.js';
|
||||
|
||||
/**
|
||||
* GET /api/news?counter=0&language=de&category=top
|
||||
* counter = wievieltes News-Widget aufgerufen wird (0, 1, 2, …), damit keine doppelten Artikel.
|
||||
*/
|
||||
export default {
|
||||
async getNews(req, res) {
|
||||
const counter = Math.max(0, parseInt(req.query.counter, 10) || 0);
|
||||
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 });
|
||||
res.json({ results, nextPage });
|
||||
} catch (error) {
|
||||
console.error('News getNews:', error);
|
||||
res.status(500).json({ error: error.message || 'News konnten nicht geladen werden.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
9
backend/routers/newsRouter.js
Normal file
9
backend/routers/newsRouter.js
Normal file
@@ -0,0 +1,9 @@
|
||||
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));
|
||||
|
||||
export default router;
|
||||
70
backend/services/newsService.js
Normal file
70
backend/services/newsService.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Proxy für newsdata.io API.
|
||||
* Endpoint: https://newsdata.io/api/1/news?apikey=...&language=...&category=...
|
||||
* Pagination: counter = wievieltes Widget dieser Art (0 = erste Seite, 1 = zweite, …), damit News nicht doppelt gezeigt werden.
|
||||
*/
|
||||
|
||||
const NEWS_BASE = 'https://newsdata.io/api/1/news';
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {number} options.counter - 0 = erste Seite, 1 = zweite, … (für Pagination/nextPage)
|
||||
* @param {string} [options.language] - z. B. de, en
|
||||
* @param {string} [options.category] - z. B. top, technology
|
||||
* @returns {Promise<{ results: Array, nextPage: string|null }>}
|
||||
*/
|
||||
async function fetchNewsPage({ counter, language = 'de', category = 'top', nextPageToken = null }) {
|
||||
const apiKey = process.env.NEWSDATA_IO_API_KEY;
|
||||
if (!apiKey || !apiKey.trim()) {
|
||||
throw new Error('NEWSDATA_IO_API_KEY is not set in .env');
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('apikey', apiKey.trim());
|
||||
params.set('language', String(language));
|
||||
params.set('category', String(category));
|
||||
if (nextPageToken) params.set('page', nextPageToken);
|
||||
|
||||
const url = `${NEWS_BASE}?${params.toString()}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`newsdata.io: ${res.status} ${text.slice(0, 200)}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return {
|
||||
results: data.results ?? [],
|
||||
nextPage: data.nextPage ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Seite für das „counter.“-te Widget (0 = erste Seite, 1 = zweite, …).
|
||||
* Ruft die API ggf. mehrfach auf (nextPage), damit jede Widget-Instanz andere Artikel bekommt.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {number} options.counter
|
||||
* @param {string} [options.language]
|
||||
* @param {string} [options.category]
|
||||
* @returns {Promise<{ results: Array, nextPage: string|null }>}
|
||||
*/
|
||||
async function getNews({ counter = 0, language = 'de', category = 'top' }) {
|
||||
let nextPageToken = null;
|
||||
let lastResult = { results: [], nextPage: null };
|
||||
|
||||
for (let i = 0; i <= counter; i++) {
|
||||
lastResult = await fetchNewsPage({
|
||||
counter: i,
|
||||
language,
|
||||
category,
|
||||
nextPageToken: nextPageToken || undefined
|
||||
});
|
||||
nextPageToken = lastResult.nextPage;
|
||||
if (!nextPageToken && i < counter) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return lastResult;
|
||||
}
|
||||
|
||||
export default { getNews };
|
||||
@@ -2,7 +2,8 @@ import WidgetType from '../models/type/widget_type.js';
|
||||
|
||||
const DEFAULT_WIDGET_TYPES = [
|
||||
{ label: 'Termine', endpoint: '/api/termine', description: 'Bevorstehende Termine', orderId: 1 },
|
||||
{ label: 'Falukant', endpoint: '/api/falukant/dashboard-widget', description: 'Charakter, Geld, Nachrichten, Kinder', orderId: 2 }
|
||||
{ label: 'Falukant', endpoint: '/api/falukant/dashboard-widget', description: 'Charakter, Geld, Nachrichten, Kinder', orderId: 2 },
|
||||
{ label: 'News', endpoint: '/api/news?language=de&category=top', description: 'Nachrichten (newsdata.io), Counter für Pagination', orderId: 3 }
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,19 +14,29 @@
|
||||
<div v-else-if="error" class="dashboard-widget__state dashboard-widget__error">{{ error }}</div>
|
||||
<div v-else class="dashboard-widget__body">
|
||||
<slot :data="data">
|
||||
<template v-if="falukantData">
|
||||
<template v-else-if="newsDataResults.length">
|
||||
<ul class="dashboard-widget__list">
|
||||
<li v-for="(item, i) in newsDataResults" :key="item.article_id || i" class="dashboard-widget__list-item">
|
||||
<a v-if="item.link" :href="item.link" target="_blank" rel="noopener noreferrer" class="dashboard-widget__news-title">{{ item.title || '—' }}</a>
|
||||
<span v-else class="dashboard-widget__title-text">{{ item.title || '—' }}</span>
|
||||
<span v-if="item.pubDate" class="dashboard-widget__date">{{ formatNewsDate(item.pubDate) }}</span>
|
||||
<p v-if="item.description" class="dashboard-widget__desc">{{ item.description }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template v-else-if="falukantData">
|
||||
<dl class="dashboard-widget__falukant">
|
||||
<dt>Name</dt>
|
||||
<dt>{{ $t('falukant.overview.metadata.name') }}</dt>
|
||||
<dd>{{ falukantData.characterName }}</dd>
|
||||
<dt>Geschlecht</dt>
|
||||
<dd>{{ falukantData.gender ?? '—' }}</dd>
|
||||
<dt>Alter</dt>
|
||||
<dd>{{ falukantData.age != null ? falukantData.age + ' Tage' : '—' }}</dd>
|
||||
<dt>Geld</dt>
|
||||
<dt>{{ $t('falukant.create.gender') }}</dt>
|
||||
<dd class="falukant-gender">{{ falukantGenderLabel }}</dd>
|
||||
<dt>{{ $t('falukant.overview.metadata.age') }}</dt>
|
||||
<dd class="falukant-age">{{ falukantAgeLabel }}</dd>
|
||||
<dt>{{ $t('falukant.overview.metadata.money') }}</dt>
|
||||
<dd>{{ formatMoney(falukantData.money) }}</dd>
|
||||
<dt>Ungelesene Nachrichten</dt>
|
||||
<dt>{{ $t('falukant.messages.title') }}</dt>
|
||||
<dd>{{ falukantData.unreadNotificationsCount }}</dd>
|
||||
<dt>Kinder</dt>
|
||||
<dt>{{ $t('falukant.statusbar.children') }}</dt>
|
||||
<dd>{{ falukantData.childrenCount }}</dd>
|
||||
</dl>
|
||||
</template>
|
||||
@@ -57,7 +67,9 @@ export default {
|
||||
title: { type: String, required: true },
|
||||
endpoint: { type: String, required: true },
|
||||
/** Wenn true, wird nicht automatisch geladen (z. B. bei Bearbeitung). */
|
||||
pauseFetch: { type: Boolean, default: false }
|
||||
pauseFetch: { type: Boolean, default: false },
|
||||
/** Wievieltes Widget dieser Art (0, 1, 2, …), wird als &counter=N ans EP gehängt (z. B. für News-Pagination). */
|
||||
requestCounter: { type: Number, default: undefined }
|
||||
},
|
||||
emits: ['drag-start', 'drag-end'],
|
||||
data() {
|
||||
@@ -69,11 +81,29 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
newsDataResults() {
|
||||
const d = this.data;
|
||||
if (d && typeof d === 'object' && Array.isArray(d.results)) return d.results;
|
||||
return [];
|
||||
},
|
||||
falukantData() {
|
||||
const d = this.data;
|
||||
if (d && typeof d === 'object' && 'characterName' in d && 'money' in d) return d;
|
||||
return null;
|
||||
},
|
||||
falukantGenderLabel() {
|
||||
const g = this.falukantData?.gender;
|
||||
if (g == null || g === '') return '—';
|
||||
const key = `falukant.create.${g}`;
|
||||
const t = this.$t(key);
|
||||
return t === key ? this.$t(`general.gender.${g}`) || g : t;
|
||||
},
|
||||
falukantAgeLabel() {
|
||||
const days = this.falukantData?.age;
|
||||
if (days == null) return '—';
|
||||
const years = Math.floor(Number(days) / 365);
|
||||
return `${years} ${this.$t('admin.createNPCs.years')}`;
|
||||
},
|
||||
dataList() {
|
||||
if (!Array.isArray(this.data) || this.data.length === 0) return [];
|
||||
const first = this.data[0];
|
||||
@@ -94,6 +124,7 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
endpoint: { handler: 'fetchData', immediate: false },
|
||||
requestCounter: { handler: 'fetchData', immediate: false },
|
||||
pauseFetch: { handler(now) { if (!now) this.fetchData(); } }
|
||||
},
|
||||
mounted() {
|
||||
@@ -105,9 +136,13 @@ export default {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const path = this.endpoint.startsWith('/') ? this.endpoint : `/${this.endpoint}`;
|
||||
const url = path.startsWith('/api') ? path : `/api${path}`;
|
||||
const res = await apiClient.get(url);
|
||||
let path = this.endpoint.startsWith('/') ? this.endpoint : `/${this.endpoint}`;
|
||||
path = path.startsWith('/api') ? path : `/api${path}`;
|
||||
if (this.requestCounter !== undefined && this.requestCounter !== null) {
|
||||
const sep = path.includes('?') ? '&' : '?';
|
||||
path = `${path}${sep}counter=${Number(this.requestCounter)}`;
|
||||
}
|
||||
const res = await apiClient.get(path);
|
||||
this.data = res.data;
|
||||
} catch (e) {
|
||||
this.error = e.response?.data?.message || e.message || 'Fehler beim Laden';
|
||||
@@ -136,6 +171,12 @@ export default {
|
||||
const n = Number(value);
|
||||
if (Number.isNaN(n)) return '—';
|
||||
return n.toLocaleString('de-DE');
|
||||
},
|
||||
formatNewsDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
if (Number.isNaN(d.getTime())) return String(dateStr);
|
||||
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -235,6 +276,15 @@ export default {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dashboard-widget__news-title {
|
||||
font-weight: 600;
|
||||
color: #0d6efd;
|
||||
text-decoration: none;
|
||||
}
|
||||
.dashboard-widget__news-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dashboard-widget__desc {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
@@ -261,4 +311,14 @@ export default {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-widget__falukant dd.falukant-gender {
|
||||
color: #0d6efd;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-widget__falukant dd.falukant-age {
|
||||
color: #198754;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
:widget-id="w.id"
|
||||
:title="w.title"
|
||||
:endpoint="w.endpoint"
|
||||
:request-counter="widgetRequestCounter(index)"
|
||||
@drag-start="() => (draggedIndex = index)"
|
||||
@drag-end="() => (draggedIndex = null)"
|
||||
/>
|
||||
@@ -143,6 +144,16 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['logout']),
|
||||
/** Counter für EP: wievieltes Widget mit gleichem Endpoint (0, 1, 2, …), damit z. B. News nicht doppelt. */
|
||||
widgetRequestCounter(index) {
|
||||
const endpoint = this.widgets[index]?.endpoint;
|
||||
if (endpoint == null) return undefined;
|
||||
let count = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
if (this.widgets[i].endpoint === endpoint) count++;
|
||||
}
|
||||
return count;
|
||||
},
|
||||
handleLogout() {
|
||||
this.logout();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user