Restored to '3905f52571b4fa7e048e16d7a3b94ee66307d072'

Replit-Restored-To: 3905f52571
This commit is contained in:
sebastjanartic 2026-03-05 12:19:10 +00:00
parent 7434438c1e
commit c13a5150b8
41 changed files with 1706 additions and 667 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

View File

@ -0,0 +1,144 @@
Allgemeiner Hinweis und Pflichtinformationen
Benennung der verantwortlichen Stelle
Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist:
PRIME TIME ENTERPRISE d.o.o.
Hrastovec 6
1263 Trzin
Die verantwortliche Stelle entscheidet allein oder gemeinsam mit anderen über die Zwecke und Mittel der Verarbeitung von personenbezogenen Daten (z.B. Namen, Kontaktdaten o. Ä.).
Widerruf Ihrer Einwilligung zur Datenverarbeitung
Nur mit Ihrer ausdrücklichen Einwilligung sind einige Vorgänge der Datenverarbeitung möglich. Ein Widerruf Ihrer bereits erteilten Einwilligung ist jederzeit möglich. Für den Widerruf genügt eine formlose Mitteilung per E-Mail. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Datenverarbeitung bleibt vom Widerruf unberührt.
Recht auf Beschwerde bei der zuständigen Aufsichtsbehörde
Als Betroffener steht Ihnen im Falle eines datenschutzrechtlichen Verstoßes ein Beschwerderecht bei der zuständigen Aufsichtsbehörde zu. Zuständige Aufsichtsbehörde bezüglich datenschutzrechtlicher Fragen ist der Landesdatenschutzbeauftragte des Bundeslandes, in dem sich der Sitz unseres Unternehmens befindet. Der folgende Link stellt eine Liste der Datenschutzbeauftragten sowie deren Kontaktdaten bereit: https://www.bfdi.bund.de/DE/Infothek/Anschriften_Links/anschriften_links-node.html.
Recht auf Datenübertragbarkeit
Ihnen steht das Recht zu, Daten, die wir auf Grundlage Ihrer Einwilligung oder in Erfüllung eines Vertrags automatisiert verarbeiten, an sich oder an Dritte aushändigen zu lassen. Die Bereitstellung erfolgt in einem maschinenlesbaren Format. Sofern Sie die direkte Übertragung der Daten an einen anderen Verantwortlichen verlangen, erfolgt dies nur, soweit es technisch machbar ist.
Recht auf Auskunft, Berichtigung, Sperrung, Löschung
Sie haben jederzeit im Rahmen der geltenden gesetzlichen Bestimmungen das Recht auf unentgeltliche Auskunft über Ihre gespeicherten personenbezogenen Daten, Herkunft der Daten, deren Empfänger und den Zweck der Datenverarbeitung und ggf. ein Recht auf Berichtigung, Sperrung oder Löschung dieser Daten. Diesbezüglich und auch zu weiteren Fragen zum Thema personenbezogene Daten können Sie sich jederzeit über die im Impressum aufgeführten Kontaktmöglichkeiten an uns wenden.
SSL- bzw. TLS-Verschlüsselung
Aus Sicherheitsgründen und zum Schutz der Übertragung vertraulicher Inhalte, die Sie an uns als Seitenbetreiber senden, nutzt unsere Website eine SSL-bzw. TLS-Verschlüsselung. Damit sind Daten, die Sie über diese Website übermitteln, für Dritte nicht mitlesbar. Sie erkennen eine verschlüsselte Verbindung an der „https://“ Adresszeile Ihres Browsers und am Schloss-Symbol in der Browserzeile.
Kontaktformular
Per Kontaktformular übermittelte Daten werden einschließlich Ihrer Kontaktdaten gespeichert, um Ihre Anfrage bearbeiten zu können oder um für Anschlussfragen bereitzustehen. Eine Weitergabe dieser Daten findet ohne Ihre Einwilligung nicht statt.
Die Verarbeitung der in das Kontaktformular eingegebenen Daten erfolgt ausschließlich auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Ein Widerruf Ihrer bereits erteilten Einwilligung ist jederzeit möglich. Für den Widerruf genügt eine formlose Mitteilung per E-Mail. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Datenverarbeitungsvorgänge bleibt vom Widerruf unberührt.
Über das Kontaktformular übermittelte Daten verbleiben bei uns, bis Sie uns zur Löschung auffordern, Ihre Einwilligung zur Speicherung widerrufen oder keine Notwendigkeit der Datenspeicherung mehr besteht. Zwingende gesetzliche Bestimmungen insbesondere Aufbewahrungsfristen bleiben unberührt.
Speicherdauer von Beiträgen und Kommentaren
Beiträge und Kommentare sowie damit in Verbindung stehende Daten, wie beispielsweise IP-Adressen, werden gespeichert. Der Inhalt verbleibt auf unserer Website, bis er vollständig gelöscht wurde oder aus rechtlichen Gründen gelöscht werden musste.
Die Speicherung der Beiträge und Kommentare erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Ein Widerruf Ihrer bereits erteilten Einwilligung ist jederzeit möglich. Für den Widerruf genügt eine formlose Mitteilung per E-Mail. Die
Rechtmäßigkeit bereits erfolgter Datenverarbeitungsvorgänge bleibt vom Widerruf unberührt.
YouTube
Für Integration und Darstellung von Videoinhalten nutzt unsere Website Plugins von YouTube. Anbieter des Videoportals ist die YouTube, LLC, 901 Cherry Ave., San Bruno, CA 94066, USA.
Bei Aufruf einer Seite mit integriertem YouTube-Plugin wird eine Verbindung zu den Servern von YouTube hergestellt. YouTube erfährt hierdurch, welche unserer Seiten Sie aufgerufen haben.
YouTube kann Ihr Surfverhalten direkt Ihrem persönlichen Profil zuzuordnen, sollten Sie in Ihrem YouTube Konto eingeloggt sein. Durch vorheriges Ausloggen haben Sie die Möglichkeit, dies zu unterbinden.
Die Nutzung von YouTube erfolgt im Interesse einer ansprechenden Darstellung unserer Online-Angebote. Dies stellt ein berechtigtes Interesse im Sinne von Art. 6 Abs. 1 lit. f DSGVO dar.
Einzelheiten zum Umgang mit Nutzerdaten finden Sie in der Datenschutzerklärung von YouTube unter: https://www.google.de/intl/de/policies/privacy.
Cookies
Unsere Website verwendet Cookies. Das sind kleine Textdateien, die Ihr Webbrowser auf Ihrem Endgerät speichert. Cookies helfen uns dabei, unser Angebot nutzerfreundlicher, effektiver und sicherer zu machen.
Einige Cookies sind „Session-Cookies.“ Solche Cookies werden nach Ende Ihrer Browser-Sitzung von selbst gelöscht. Hingegen bleiben andere Cookies auf Ihrem Endgerät bestehen, bis Sie diese selbst löschen. Solche Cookies helfen uns, Sie bei Rückkehr auf
unserer Website wiederzuerkennen.
Mit einem modernen Webbrowser können Sie das Setzen von Cookies überwachen, einschränken oder unterbinden. Viele Webbrowser lassen sich so konfigurieren, dass Cookies mit dem Schließen des Programms von selbst gelöscht werden. Die Deaktivierung von Cookies
kann eine eingeschränkte Funktionalität unserer Website zur Folge haben.
Das Setzen von Cookies, die zur Ausübung elektronischer Kommunikationsvorgänge oder der Bereitstellung bestimmter, von Ihnen erwünschter Funktionen (z.B. Warenkorb) notwendig sind, erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Als Betreiber dieser
Website haben wir ein berechtigtes Interesse an der Speicherung von Cookies zur technisch fehlerfreien und reibungslosen Bereitstellung unserer Dienste. Sofern die Setzung anderer Cookies (z.B. für Analyse-Funktionen) erfolgt, werden diese in dieser
Datenschutzerklärung separat behandelt.
Google Analytics
Unsere Website verwendet Funktionen des Webanalysedienstes Google Analytics. Anbieter des Webanalysedienstes ist die Google Inc., 1600 Amphitheatre Parkway, Mountain View, CA 94043, USA.
Google Analytics verwendet „Cookies.“ Das sind kleine Textdateien, die Ihr Webbrowser auf Ihrem Endgerät speichert und eine Analyse der Website-Benutzung ermöglichen. Mittels Cookie erzeugte Informationen über Ihre Benutzung unserer Website
werden an einen Server von Google übermittelt und dort gespeichert. Server-Standort ist im Regelfall die USA.
Das Setzen von Google-Analytics-Cookies erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Als Betreiber dieser Website haben wir ein berechtigtes Interesse an der Analyse des Nutzerverhaltens, um unser Webangebot und ggf. auch Werbung zu optimieren.
IP-Anonymisierung
Wir setzen Google Analytics in Verbindung mit der Funktion IP-Anonymisierung ein. Sie gewährleistet, dass Google Ihre IP-Adresse innerhalb von Mitgliedstaaten der Europäischen Union oder in anderen Vertragsstaaten des Abkommens über den Europäischen Wirtschaftsraum
vor der Übermittlung in die USA kürzt. Es kann Ausnahmefälle geben, in denen Google die volle IP-Adresse an einen Server in den USA überträgt und dort kürzt. In unserem Auftrag wird Google diese Informationen benutzen, um Ihre Nutzung der Website
auszuwerten, um Reports über Websiteaktivitäten zu erstellen und um weitere mit der Websitenutzung und der Internetnutzung verbundene Dienstleistungen gegenüber uns zu erbringen. Es findet keine Zusammenführung der von Google Analytics übermittelten
IP-Adresse mit anderen Daten von Google statt.
Browser Plugin
Das Setzen von Cookies durch Ihren Webbrowser ist verhinderbar. Einige Funktionen unserer Website könnten dadurch jedoch eingeschränkt werden. Ebenso können Sie die Erfassung von Daten bezüglich Ihrer Website-Nutzung einschließlich Ihrer IP-Adresse mitsamt
anschließender Verarbeitung durch Google unterbinden. Dies ist möglich, indem Sie das über folgenden Link erreichbare Browser-Plugin herunterladen und installieren: https://tools.google.com/dlpage/gaoptout?hl=de.
Widerspruch gegen die Datenerfassung
Sie können die Erfassung Ihrer Daten durch Google Analytics verhindern, indem Sie auf folgenden Link klicken. Es wird ein Opt-Out-Cookie gesetzt, der die Erfassung Ihrer Daten bei zukünftigen Besuchen unserer Website verhindert: https://tools.google.com/dlpage/gaoptout?hl=de.
Einzelheiten zum Umgang mit Nutzerdaten bei Google Analytics finden Sie in der Datenschutzerklärung von Google: https://support.google.com/analytics/answer/6004245?hl=de.
Auftragsverarbeitung
Zur vollständigen Erfüllung der gesetzlichen Datenschutzvorgaben haben wir mit Google einen Vertrag über die Auftragsverarbeitung abgeschlossen.
Demografische Merkmale bei Google Analytics
Unsere Website verwendet die Funktion „demografische Merkmale“ von Google Analytics. Mit ihr lassen sich Berichte erstellen, die Aussagen zu Alter, Geschlecht und Interessen der Seitenbesucher enthalten. Diese Daten stammen aus interessenbezogener Werbung
von Google sowie aus Besucherdaten von Drittanbietern. Eine Zuordnung der Daten zu einer bestimmten Person ist nicht möglich. Sie können diese Funktion jederzeit deaktivieren. Dies ist über die Anzeigeneinstellungen in Ihrem Google-Konto möglich oder
indem Sie die Erfassung Ihrer Daten durch Google Analytics, wie im Punkt „Widerspruch gegen die Datenerfassung“ erläutert, generell untersagen.
Google AdSense
Unsere Website verwendet Google AdSense. Anbieter ist die Google Inc., 1600 Amphitheatre Parkway, Mountain View, CA 94043, USA.
Google AdSense dient der Einbindung von Werbeanzeigen und setzt Cookies. Cookies sind kleine Textdateien, die Ihr Webbrowser auf Ihrem Endgerät speichert, um die Nutzung der Website analysieren. Google AdSense setzt außerdem Web Beacons ein. Web Beacons
sind unsichtbare Grafiken, die eine Analyse des Besucherverkehrs auf unserer Wesite ermöglichen.
Durch Cookies und Web Beacons erzeugten Informationen werden an Server von Google übertragen und dort gespeichert. Serverstandort sind die USA. Google kann diese Informationen an Vertragspartner weiterreichen. Ihre IP-Adresse wird Google jedoch nicht
mit anderen von Ihnen gespeicherten Daten zusammenführen.
Die Speicherung von AdSense-Cookies erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Wir als Websitebetreiber haben ein berechtigtes Interesse an der Analyse des Nutzerverhaltens, um unser Webangebot und unsere Werbung zu optimieren.
Mit einem modernen Webbrowser können Sie das Setzen von Cookies überwachen, einschränken und unterbinden. Die Deaktivierung von Cookies kann eine eingeschränkte Funktionalität unserer Website zur Folge haben. Durch die Nutzung unserer Website erklären
Sie sich mit der Bearbeitung der über Sie erhobenen Daten durch Google in der zuvor beschriebenen Art und Weise sowie dem zuvor benannten Zweck einverstanden.
Google AdWords und Google Conversion-Tracking
Unsere Website verwendet Google AdWords. Anbieter ist die Google Inc., 1600 Amphitheatre Parkway, Mountain View, CA 94043, United States.
AdWords ist ein Online-Werbeprogramm. Im Rahmen des Online-Werbeprogramms arbeiten wir mit Conversion-Tracking. Nach einem Klick auf eine von Google geschaltete Anzeige wird ein Cookie für das Conversion-Tracking gesetzt. Cookies sind kleine Textdateien,
die Ihr Webbrowser auf Ihrem Endgerät speichert. Google AdWords Cookies verlieren nach 30 Tagen ihre Gültigkeit und dienen nicht der persönlichen Identifizierung der Nutzer. Am Cookie können Google und wir erkennen, dass Sie auf eine Anzeige geklickt
haben und zu unserer Website weitergeleitet wurden.
Jeder Google AdWords-Kunde erhält ein anderes Cookie. Die Cookies sind nicht über Websites von AdWords-Kunden nachverfolgbar. Mit Conversion-Cookies werden Conversion-Statistiken für AdWords-Kunden, die Conversion-Tracking einsetzen, erstellt. Adwords-Kunden
erfahren wie viele Nutzer auf ihre Anzeige geklickt haben und auf Seiten mit Conversion-Tracking-Tag weitergeleitet wurden. AdWords-Kunden erhalten jedoch keine Informationen, die eine persönliche Identifikation der Nutzer ermöglichen. Wenn Sie nicht
am Tracking teilnehmen möchten, können Sie einer Nutzung widersprechen. Hier ist das Conversion-Cookie in den Nutzereinstellungen des Browsers zu deaktivieren. So findet auch keine Aufnahme in die Conversion-Tracking Statistiken statt.
Die Speicherung von „Conversion-Cookies“ erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Wir als Websitebetreiber haben ein berechtigtes Interesse an der Analyse des Nutzerverhaltens, um unser Webangebot und unsere Werbung zu optimieren.
Einzelheiten zu Google AdWords und Google Conversion-Tracking finden Sie in den Datenschutzbestimmungen von Google: https://www.google.de/policies/privacy/.
Mit einem modernen Webbrowser können Sie das Setzen von Cookies überwachen, einschränken oder unterbinden. Die Deaktivierung von Cookies kann eine eingeschränkte Funktionalität unserer Website zur Folge haben.
Quelle: Datenschutz-Konfigurator von mein-datenschutzbeauftragter.de

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

View File

@ -3,14 +3,23 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>Folx Music Television - folx.tv</title>
<meta name="description" content="Folx Music Television. Aktuelle Nachrichten, Interviews und Hintergrundberichte aus der Welt der Volksmusik und des Schlagers." />
<meta property="og:title" content="Folx Music Television - folx.tv" />
<meta property="og:description" content="Aktuelle Nachrichten aus der Welt der Volksmusik und des Schlagers." />
<title>Folx Music Television - Volksmusik & Schlager TV Sender | folx.tv</title>
<meta name="description" content="FOLX TV Ihr Fernsehsender für Volksmusik und Schlager. Musikvideos, Live-Shows, Interviews und aktuelle Nachrichten aus der Welt der volkstümlichen Musik. Jetzt einschalten!" />
<meta name="keywords" content="Volksmusik, Schlager, Volksmusik TV, Schlager TV, Folx TV, volkstümliche Musik, Musiksender, Alpenmusik, Volksmusik Nachrichten, Schlager News, Musikvideos, Live Shows" />
<meta property="og:title" content="Folx Music Television - Volksmusik & Schlager TV Sender" />
<meta property="og:description" content="FOLX TV Ihr Fernsehsender für Volksmusik und Schlager. Musikvideos, Live-Shows und aktuelle Nachrichten aus der volkstümlichen Musikszene." />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Folx Music Television" />
<meta property="og:url" content="https://www.folx.tv/" />
<meta property="og:image" content="https://www.folx.tv/og-image.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/jpeg" />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://www.folx.tv/" />
<link rel="icon" type="image/png" href="/favicon.png" />
<script async src="https://fundingchoicesmessages.google.com/i/pub-4465464714854276?ers=1" nonce=""></script>
<script nonce="">(function() {function signalGooglefcPresent() {if (!window.frames['googlefcPresent']) {if (document.body) {const iframe = document.createElement('iframe'); iframe.style = 'width: 0; height: 0; border: none; z-index: -1000; left: -1000px; top: -1000px;'; iframe.style.display = 'none'; iframe.name = 'googlefcPresent'; iframe.id = 'googlefcPresent'; document.body.appendChild(iframe);} else {setTimeout(signalGooglefcPresent, 0);}}}signalGooglefcPresent();})();</script>
<link rel="apple-touch-icon" href="/favicon.png" />
<link rel="shortcut icon" href="/favicon.png" />
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4465464714854276" crossorigin="anonymous"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
client/public/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View File

@ -12,6 +12,10 @@ import GalleryPageWrapper from "@/pages/gallery";
import HoroscopePage from "@/pages/horoscope";
import RecipesPage from "@/pages/recipes";
import SearchPage from "@/pages/search";
import EmpfangPage from "@/pages/empfang";
import AboutPage from "@/pages/about";
import ImpressumPage from "@/pages/impressum";
import DatenschutzPage from "@/pages/datenschutz";
import CookieConsent from "@/components/cookie-consent";
function Router() {
@ -26,6 +30,10 @@ function Router() {
<Route path="/horoskop" component={HoroscopePage} />
<Route path="/horoskop/:sign" component={HoroscopePage} />
<Route path="/rezepte" component={RecipesPage} />
<Route path="/empfang-folx-tv" component={EmpfangPage} />
<Route path="/ueber-uns" component={AboutPage} />
<Route path="/impressum" component={ImpressumPage} />
<Route path="/datenschutz" component={DatenschutzPage} />
<Route component={NotFound} />
</Switch>
);

View File

@ -53,7 +53,7 @@ export default function ArtistPatternBg({ children, className = "", seed = 42 }:
return (
<div className={`relative overflow-hidden ${className}`}>
<div className="absolute inset-0 pointer-events-none select-none hidden lg:block" aria-hidden="true">
<div className="absolute inset-0 pointer-events-none select-none" aria-hidden="true">
{items.map((item, i) => (
<span
key={i}

View File

@ -40,7 +40,7 @@ export function BreakingNewsWidget() {
return (
<div
className="bg-card rounded-lg border border-card-border overflow-hidden h-full flex flex-col"
className="bg-card rounded-lg border border-card-border overflow-hidden h-full flex flex-col min-h-[320px]"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
data-testid="widget-breaking-news"

View File

@ -85,7 +85,21 @@ export default function Footer() {
</ul>
</div>
</div>
<div className="border-t border-border mt-8 pt-6 text-center">
<div className="border-t border-border mt-8 pt-6 flex flex-col items-center gap-3">
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs">
<Link href="/empfang-folx-tv">
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-empfang">Empfang</span>
</Link>
<Link href="/ueber-uns">
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-ueber-uns">Über uns</span>
</Link>
<Link href="/impressum">
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-impressum">Impressum</span>
</Link>
<Link href="/datenschutz">
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-datenschutz">Datenschutz</span>
</Link>
</div>
<p className="text-xs text-muted-foreground">
&copy; {new Date().getFullYear()} Folx Music Television. Alle Rechte vorbehalten.
</p>

View File

@ -32,7 +32,7 @@ export function HoroscopeWidget() {
return (
<div
className="rounded-lg border border-card-border overflow-hidden h-full w-full cursor-pointer group hover:border-primary/50 transition-colors flex flex-col"
className="rounded-lg border border-card-border overflow-hidden h-full w-full cursor-pointer group hover:border-primary/50 transition-colors flex flex-col min-h-[320px]"
style={{ background: "linear-gradient(135deg, hsl(250 30% 14%), hsl(270 25% 10%))" }}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}

View File

@ -40,7 +40,7 @@ export function NewsWidget() {
return (
<div
className="bg-card rounded-lg border border-card-border overflow-hidden h-full flex flex-col"
className="bg-card rounded-lg border border-card-border overflow-hidden h-full flex flex-col min-h-[320px]"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
data-testid="widget-news"

View File

@ -233,12 +233,12 @@ export function PhotoGalleryWidget({ reverseOrder = false }: { reverseOrder?: bo
if (isLoading) {
return (
<div className="bg-card rounded-lg border border-card-border overflow-hidden h-full" data-testid="widget-gallery-loading">
<div className="bg-card rounded-lg border border-card-border overflow-hidden h-full min-h-[320px]" data-testid="widget-gallery-loading">
<div className="p-3 flex items-center gap-2 border-b border-card-border">
<Images className="w-4 h-4 text-primary" />
<h3 className="font-bold text-card-foreground text-sm">Fotogalerie</h3>
</div>
<div className="aspect-[4/5] bg-muted animate-pulse" />
<div className="flex-1 bg-muted animate-pulse min-h-[280px]" />
</div>
);
}
@ -247,7 +247,7 @@ export function PhotoGalleryWidget({ reverseOrder = false }: { reverseOrder?: bo
return (
<>
<div className="bg-card rounded-lg border border-card-border overflow-hidden h-full flex flex-col" data-testid="widget-gallery">
<div className="bg-card rounded-lg border border-card-border overflow-hidden h-full flex flex-col min-h-[320px]" data-testid="widget-gallery">
<div className="p-3 flex items-center gap-2 border-b border-card-border flex-shrink-0">
<Images className="w-4 h-4 text-primary" />
<h3 className="font-bold text-card-foreground text-sm">Fotogalerie</h3>

View File

@ -28,7 +28,7 @@ export function RecipeWidget() {
return (
<button
onClick={() => navigate("/rezepte")}
className="bg-card rounded-lg border border-card-border overflow-hidden h-full w-full text-left cursor-pointer group hover:border-primary/50 transition-colors flex flex-col"
className="bg-card rounded-lg border border-card-border overflow-hidden h-full w-full text-left cursor-pointer group hover:border-primary/50 transition-colors flex flex-col min-h-[320px]"
data-testid="widget-recipe"
>
<div className="p-3 flex items-center gap-2 border-b border-card-border flex-shrink-0">

View File

@ -200,7 +200,7 @@ export function SidebarWeatherWidget() {
if (loading) {
return (
<div className="bg-card rounded-lg border border-card-border p-4 aspect-square flex items-center justify-center" data-testid="sidebar-weather-loading">
<div className="bg-card rounded-lg border border-card-border p-4 min-h-[280px] flex items-center justify-center" data-testid="sidebar-weather-loading">
<div className="w-full h-full bg-muted animate-pulse rounded" />
</div>
);

View File

@ -0,0 +1,17 @@
import { useEffect } from "react";
export function usePageMeta(title: string, description?: string) {
useEffect(() => {
const suffix = " | Folx Music Television";
document.title = title + suffix;
if (description) {
let meta = document.querySelector('meta[name="description"]') as HTMLMetaElement;
if (meta) meta.content = description;
}
return () => {
document.title = "Folx Music Television - Volksmusik & Schlager TV Sender | folx.tv";
let meta = document.querySelector('meta[name="description"]') as HTMLMetaElement;
if (meta) meta.content = "FOLX TV Ihr Fernsehsender für Volksmusik und Schlager. Musikvideos, Live-Shows, Interviews und aktuelle Nachrichten aus der Welt der volkstümlichen Musik. Jetzt einschalten!";
};
}, [title, description]);
}

View File

@ -0,0 +1,52 @@
import { Tv } from "lucide-react";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { usePageMeta } from "@/hooks/use-page-meta";
export default function AboutPage() {
usePageMeta("Über FOLX TV - Volksmusik & Schlager Fernsehsender", "Alles über FOLX TV Ihren Fernsehsender für Volksmusik und Schlager seit 2013.");
return (
<div className="min-h-screen bg-background">
<Header />
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center gap-3 mb-8">
<Tv className="w-7 h-7 text-primary" />
<h1 className="text-3xl font-bold text-foreground" data-testid="text-about-title">
Über FOLX TV
</h1>
</div>
<article className="prose-custom space-y-5 text-[15px] leading-relaxed text-muted-foreground" data-testid="section-about-content">
<p>
<span className="text-foreground font-semibold">FOLX TV</span> ist Ihr Fernsehsender für volkstümliche Musik und Schlager. Der Sender präsentiert exklusiven Content aus der Welt der volkstümlichen Musik und des Schlagers. Über 90 % des Programms werden von FOLX NETWORK selbst produziert und bieten den Zuschauern eine abwechslungsreiche Auswahl an Musikvideos, Live-Shows und Unterhaltung mit den beliebtesten Stars der Szene.
</p>
<p>
FOLX TV ist heute über IPTV-Plattformen und Kabelnetze in Deutschland, Österreich und der Schweiz empfangbar, unter anderem über MagentaTV, Zattoo, O2 TV, A1 Xplore TV, Swisscom blue TV sowie über verschiedene regionale Kabelnetze. Zusätzlich kann FOLX TV über den offiziellen Livestream im Internet verfolgt werden.
</p>
<p>
Als Teil des FOLX NETWORK steht der Sender für hochwertige Musikunterhaltung, authentische Volksmusik und modernen Schlager produziert mit Leidenschaft und großer Nähe zur Musikszene.
</p>
<p>
Zu den beliebtesten Eigenproduktionen zählt der <span className="text-foreground font-medium">FOLX Stadl</span> mit Moderator Hanzi Berger, der regelmäßig Stars der volkstümlichen Musik und des Schlagers auf die Bühne bringt. Ein weiteres Highlight ist der <span className="text-foreground font-medium">Gipfelstammtisch</span>, der spannende Gespräche und musikalische Beiträge aus der Welt der Alpen präsentiert. In der Sendung <span className="text-foreground font-medium">Die Geschichte des Liedes</span> erhalten Zuschauer zudem faszinierende Einblicke in die Entstehung und Bedeutung bekannter volkstümlicher Melodien.
</p>
<p>
FOLX TV wurde am 15. April 2013 gegründet und hat sich seitdem zu einem der wichtigsten Fernsehsender für volkstümliche Musik und Schlager im deutschsprachigen Raum entwickelt.
</p>
<p>
Unsere Vision ist es, die musikalische Tradition des Alpenraums und der Schlagerwelt in moderner TV-Produktion weiterzuführen und ein internationales Publikum für diese einzigartige Musik zu begeistern.
</p>
<p className="text-primary font-semibold">
Schalten Sie ein und erleben Sie die Welt der Volksmusik und des Schlagers nur bei FOLX TV.
</p>
</article>
</main>
<Footer />
</div>
);
}

View File

@ -4,6 +4,7 @@ import { type Article } from "@shared/schema";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { ArrowLeft, Eye, Calendar, User, Clock } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import Header from "@/components/header";
@ -100,6 +101,11 @@ export default function ArticlePage() {
queryKey: ["/api/articles", slug],
});
usePageMeta(
article ? `${article.title} - Volksmusik & Schlager` : "Volksmusik & Schlager Artikel",
article?.excerpt || "Aktuelle Nachrichten aus der Volksmusik- und Schlagerszene bei FOLX TV."
);
useEffect(() => {
window.scrollTo(0, 0);
}, [slug]);

View File

@ -1,22 +1,115 @@
import { useQuery } from "@tanstack/react-query";
import { useParams, Link } from "wouter";
import { useParams, Link, useLocation, useSearch } from "wouter";
import { type Article } from "@shared/schema";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Eye, ArrowLeft } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Eye, ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { ArticleCardAd } from "@/components/adsense";
import { useEffect } from "react";
interface PaginatedResponse {
articles: Article[];
total: number;
page: number;
totalPages: number;
hasMore: boolean;
}
export default function CategoryPage() {
const { category } = useParams<{ category: string }>();
const searchString = useSearch();
const [, setLocation] = useLocation();
const { data: articles, isLoading } = useQuery<Article[]>({
queryKey: ["/api/articles/category", category],
const searchParams = new URLSearchParams(searchString);
const currentPage = Math.max(1, parseInt(searchParams.get("page") || "1"));
usePageMeta(
`${category}${currentPage > 1 ? ` Seite ${currentPage}` : ""} - Volksmusik & Schlager`,
`Aktuelle ${category}-Beiträge aus der Volksmusik- und Schlagerszene bei FOLX TV.`
);
const { data, isLoading } = useQuery<PaginatedResponse>({
queryKey: ["/api/articles/category", category, { page: currentPage }],
queryFn: async () => {
const res = await fetch(`/api/articles/category/${category}?page=${currentPage}&limit=12`);
if (!res.ok) throw new Error("Failed to fetch articles");
return res.json();
},
});
useEffect(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, [currentPage]);
const goToPage = (page: number) => {
if (page === 1) {
setLocation(`/category/${category}`);
} else {
setLocation(`/category/${category}?page=${page}`);
}
};
const renderPagination = () => {
if (!data || data.totalPages <= 1) return null;
const pages: (number | string)[] = [];
const total = data.totalPages;
const current = data.page;
pages.push(1);
if (current > 3) pages.push("...");
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
pages.push(i);
}
if (current < total - 2) pages.push("...");
if (total > 1) pages.push(total);
return (
<nav className="flex items-center justify-center gap-1 mt-10" data-testid="pagination-nav">
<Button
variant="outline"
size="icon"
onClick={() => goToPage(current - 1)}
disabled={current <= 1}
data-testid="button-prev-page"
>
<ChevronLeft className="w-4 h-4" />
</Button>
{pages.map((p, i) =>
typeof p === "string" ? (
<span key={`ellipsis-${i}`} className="px-2 text-muted-foreground">
{p}
</span>
) : (
<Button
key={p}
variant={p === current ? "default" : "outline"}
size="sm"
onClick={() => goToPage(p)}
data-testid={`button-page-${p}`}
>
{p}
</Button>
)
)}
<Button
variant="outline"
size="icon"
onClick={() => goToPage(current + 1)}
disabled={!data.hasMore}
data-testid="button-next-page"
>
<ChevronRight className="w-4 h-4" />
</Button>
</nav>
);
};
return (
<div className="min-h-screen bg-background">
<Header />
@ -28,9 +121,17 @@ export default function CategoryPage() {
</Button>
</Link>
<h1 className="text-2xl font-bold text-foreground mb-6" data-testid="text-category-title">
{category}
</h1>
<div className="flex items-center justify-between gap-2 mb-6 flex-wrap">
<h1 className="text-2xl font-bold text-foreground" data-testid="text-category-title">
{category}
</h1>
{data && data.total > 0 && (
<span className="text-sm text-muted-foreground" data-testid="text-article-count">
{data.total} Beiträge
{data.totalPages > 1 && ` · Seite ${data.page} von ${data.totalPages}`}
</span>
)}
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@ -45,57 +146,60 @@ export default function CategoryPage() {
</div>
))}
</div>
) : articles && articles.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles.flatMap((article, index) => {
const items = [
<Link key={article.id} href={`/article/${article.slug}`}>
<article
className="group cursor-pointer bg-card rounded-md border border-card-border transition-all duration-300 h-full"
data-testid={`card-article-${article.id}`}
>
<div className="relative rounded-t-md">
<div className="overflow-hidden rounded-t-md">
<img
src={article.coverImage || "/images/article-1.png"}
alt={article.title}
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
style={{ objectPosition: "center 25%" }}
loading="lazy"
/>
) : data && data.articles.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.articles.flatMap((article, index) => {
const items = [
<Link key={article.id} href={`/article/${article.slug}`}>
<article
className="group cursor-pointer bg-card rounded-md border border-card-border transition-all duration-300 h-full"
data-testid={`card-article-${article.id}`}
>
<div className="relative rounded-t-md">
<div className="overflow-hidden rounded-t-md">
<img
src={article.coverImage || "/images/article-1.png"}
alt={article.title}
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
style={{ objectPosition: "center 25%" }}
loading="lazy"
/>
</div>
</div>
</div>
<div className="p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-2">
<span>{article.author}</span>
<span>&middot;</span>
<span>{format(new Date(article.publishedAt), "d. MMMM yyyy", { locale: de })}</span>
<div className="p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-2">
<span>{article.author}</span>
<span>&middot;</span>
<span>{format(new Date(article.publishedAt), "d. MMMM yyyy", { locale: de })}</span>
</div>
<h3 className="font-semibold text-card-foreground mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h3>
<p className="text-muted-foreground text-sm line-clamp-3 mb-3">
{article.excerpt}
</p>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Eye className="w-3.5 h-3.5" />
{article.views.toLocaleString()}
</span>
<Button size="sm" data-testid={`button-read-${article.id}`}>
Weiterlesen
</Button>
</div>
</div>
<h3 className="font-semibold text-card-foreground mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h3>
<p className="text-muted-foreground text-sm line-clamp-3 mb-3">
{article.excerpt}
</p>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Eye className="w-3.5 h-3.5" />
{article.views.toLocaleString()}
</span>
<Button size="sm" data-testid={`button-read-${article.id}`}>
Weiterlesen
</Button>
</div>
</div>
</article>
</Link>,
];
if (index === 2) {
items.push(<ArticleCardAd key="ad-cat-1" />);
}
return items;
})}
</div>
</article>
</Link>,
];
if ((index + 1) % 3 === 0) {
items.push(<ArticleCardAd key={`ad-cat-${index}`} />);
}
return items;
})}
</div>
{renderPagination()}
</>
) : (
<div className="text-center py-16">
<p className="text-muted-foreground text-lg">

View File

@ -0,0 +1,237 @@
import { Shield } from "lucide-react";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { usePageMeta } from "@/hooks/use-page-meta";
export default function DatenschutzPage() {
usePageMeta("Datenschutz - FOLX TV", "Datenschutzerklärung von FOLX TV Ihr Volksmusik & Schlager Fernsehsender.");
return (
<div className="min-h-screen bg-background">
<Header />
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center gap-3 mb-8">
<Shield className="w-7 h-7 text-primary" />
<h1 className="text-3xl font-bold text-foreground" data-testid="text-datenschutz-title">
Datenschutzerklärung
</h1>
</div>
<article className="space-y-6 text-[15px] leading-relaxed text-muted-foreground" data-testid="section-datenschutz-content">
<section>
<h2 className="text-foreground font-semibold text-lg mb-2">Allgemeiner Hinweis und Pflichtinformationen</h2>
<h3 className="text-foreground font-medium mb-2">Benennung der verantwortlichen Stelle</h3>
<p>Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist:</p>
<p className="mt-2">
BoldFrame Productions d.o.o.
<br />
Sokolska ulica 46
<br />
2000 Maribor
</p>
<p className="mt-2">
Die verantwortliche Stelle entscheidet allein oder gemeinsam mit anderen über die Zwecke und Mittel der Verarbeitung von personenbezogenen Daten (z.B. Namen, Kontaktdaten o. Ä.).
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-2">Widerruf Ihrer Einwilligung zur Datenverarbeitung</h2>
<p>
Nur mit Ihrer ausdrücklichen Einwilligung sind einige Vorgänge der Datenverarbeitung möglich. Ein Widerruf Ihrer bereits erteilten Einwilligung ist jederzeit möglich. Für den Widerruf genügt eine formlose Mitteilung per E-Mail. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Datenverarbeitung bleibt vom Widerruf unberührt.
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-2">Recht auf Beschwerde bei der zuständigen Aufsichtsbehörde</h2>
<p>
Als Betroffener steht Ihnen im Falle eines datenschutzrechtlichen Verstoßes ein Beschwerderecht bei der zuständigen Aufsichtsbehörde zu. Zuständige Aufsichtsbehörde bezüglich datenschutzrechtlicher Fragen ist der Landesdatenschutzbeauftragte des Bundeslandes, in dem sich der Sitz unseres Unternehmens befindet. Der folgende Link stellt eine Liste der Datenschutzbeauftragten sowie deren Kontaktdaten bereit:{" "}
<a href="https://www.bfdi.bund.de/DE/Infothek/Anschriften_Links/anschriften_links-node.html" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">
https://www.bfdi.bund.de/DE/Infothek/Anschriften_Links/anschriften_links-node.html
</a>.
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-2">Recht auf Datenübertragbarkeit</h2>
<p>
Ihnen steht das Recht zu, Daten, die wir auf Grundlage Ihrer Einwilligung oder in Erfüllung eines Vertrags automatisiert verarbeiten, an sich oder an Dritte aushändigen zu lassen. Die Bereitstellung erfolgt in einem maschinenlesbaren Format. Sofern Sie die direkte Übertragung der Daten an einen anderen Verantwortlichen verlangen, erfolgt dies nur, soweit es technisch machbar ist.
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-2">Recht auf Auskunft, Berichtigung, Sperrung, Löschung</h2>
<p>
Sie haben jederzeit im Rahmen der geltenden gesetzlichen Bestimmungen das Recht auf unentgeltliche Auskunft über Ihre gespeicherten personenbezogenen Daten, Herkunft der Daten, deren Empfänger und den Zweck der Datenverarbeitung und ggf. ein Recht auf Berichtigung, Sperrung oder Löschung dieser Daten. Diesbezüglich und auch zu weiteren Fragen zum Thema personenbezogene Daten können Sie sich jederzeit über die im Impressum aufgeführten Kontaktmöglichkeiten an uns wenden.
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-2">SSL- bzw. TLS-Verschlüsselung</h2>
<p>
Aus Sicherheitsgründen und zum Schutz der Übertragung vertraulicher Inhalte, die Sie an uns als Seitenbetreiber senden, nutzt unsere Website eine SSL-bzw. TLS-Verschlüsselung. Damit sind Daten, die Sie über diese Website übermitteln, für Dritte nicht mitlesbar. Sie erkennen eine verschlüsselte Verbindung an der https://" Adresszeile Ihres Browsers und am Schloss-Symbol in der Browserzeile.
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-2">Kontaktformular</h2>
<p>
Per Kontaktformular übermittelte Daten werden einschließlich Ihrer Kontaktdaten gespeichert, um Ihre Anfrage bearbeiten zu können oder um für Anschlussfragen bereitzustehen. Eine Weitergabe dieser Daten findet ohne Ihre Einwilligung nicht statt.
</p>
<p className="mt-2">
Die Verarbeitung der in das Kontaktformular eingegebenen Daten erfolgt ausschließlich auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Ein Widerruf Ihrer bereits erteilten Einwilligung ist jederzeit möglich. Für den Widerruf genügt eine formlose Mitteilung per E-Mail. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Datenverarbeitungsvorgänge bleibt vom Widerruf unberührt.
</p>
<p className="mt-2">
Über das Kontaktformular übermittelte Daten verbleiben bei uns, bis Sie uns zur Löschung auffordern, Ihre Einwilligung zur Speicherung widerrufen oder keine Notwendigkeit der Datenspeicherung mehr besteht. Zwingende gesetzliche Bestimmungen insbesondere Aufbewahrungsfristen bleiben unberührt.
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-2">Speicherdauer von Beiträgen und Kommentaren</h2>
<p>
Beiträge und Kommentare sowie damit in Verbindung stehende Daten, wie beispielsweise IP-Adressen, werden gespeichert. Der Inhalt verbleibt auf unserer Website, bis er vollständig gelöscht wurde oder aus rechtlichen Gründen gelöscht werden musste.
</p>
<p className="mt-2">
Die Speicherung der Beiträge und Kommentare erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Ein Widerruf Ihrer bereits erteilten Einwilligung ist jederzeit möglich. Für den Widerruf genügt eine formlose Mitteilung per E-Mail. Die Rechtmäßigkeit bereits erfolgter Datenverarbeitungsvorgänge bleibt vom Widerruf unberührt.
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-2">YouTube</h2>
<p>
Für Integration und Darstellung von Videoinhalten nutzt unsere Website Plugins von YouTube. Anbieter des Videoportals ist die YouTube, LLC, 901 Cherry Ave., San Bruno, CA 94066, USA.
</p>
<p className="mt-2">
Bei Aufruf einer Seite mit integriertem YouTube-Plugin wird eine Verbindung zu den Servern von YouTube hergestellt. YouTube erfährt hierdurch, welche unserer Seiten Sie aufgerufen haben.
</p>
<p className="mt-2">
YouTube kann Ihr Surfverhalten direkt Ihrem persönlichen Profil zuzuordnen, sollten Sie in Ihrem YouTube Konto eingeloggt sein. Durch vorheriges Ausloggen haben Sie die Möglichkeit, dies zu unterbinden.
</p>
<p className="mt-2">
Die Nutzung von YouTube erfolgt im Interesse einer ansprechenden Darstellung unserer Online-Angebote. Dies stellt ein berechtigtes Interesse im Sinne von Art. 6 Abs. 1 lit. f DSGVO dar.
</p>
<p className="mt-2">
Einzelheiten zum Umgang mit Nutzerdaten finden Sie in der Datenschutzerklärung von YouTube unter:{" "}
<a href="https://www.google.de/intl/de/policies/privacy" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">
https://www.google.de/intl/de/policies/privacy
</a>.
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-2">Cookies</h2>
<p>
Unsere Website verwendet Cookies. Das sind kleine Textdateien, die Ihr Webbrowser auf Ihrem Endgerät speichert. Cookies helfen uns dabei, unser Angebot nutzerfreundlicher, effektiver und sicherer zu machen.
</p>
<p className="mt-2">
Einige Cookies sind Session-Cookies." Solche Cookies werden nach Ende Ihrer Browser-Sitzung von selbst gelöscht. Hingegen bleiben andere Cookies auf Ihrem Endgerät bestehen, bis Sie diese selbst löschen. Solche Cookies helfen uns, Sie bei Rückkehr auf unserer Website wiederzuerkennen.
</p>
<p className="mt-2">
Mit einem modernen Webbrowser können Sie das Setzen von Cookies überwachen, einschränken oder unterbinden. Viele Webbrowser lassen sich so konfigurieren, dass Cookies mit dem Schließen des Programms von selbst gelöscht werden. Die Deaktivierung von Cookies kann eine eingeschränkte Funktionalität unserer Website zur Folge haben.
</p>
<p className="mt-2">
Das Setzen von Cookies, die zur Ausübung elektronischer Kommunikationsvorgänge oder der Bereitstellung bestimmter, von Ihnen erwünschter Funktionen (z.B. Warenkorb) notwendig sind, erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Als Betreiber dieser Website haben wir ein berechtigtes Interesse an der Speicherung von Cookies zur technisch fehlerfreien und reibungslosen Bereitstellung unserer Dienste. Sofern die Setzung anderer Cookies (z.B. für Analyse-Funktionen) erfolgt, werden diese in dieser Datenschutzerklärung separat behandelt.
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-2">Google Analytics</h2>
<p>
Unsere Website verwendet Funktionen des Webanalysedienstes Google Analytics. Anbieter des Webanalysedienstes ist die Google Inc., 1600 Amphitheatre Parkway, Mountain View, CA 94043, USA.
</p>
<p className="mt-2">
Google Analytics verwendet Cookies." Das sind kleine Textdateien, die Ihr Webbrowser auf Ihrem Endgerät speichert und eine Analyse der Website-Benutzung ermöglichen. Mittels Cookie erzeugte Informationen über Ihre Benutzung unserer Website werden an einen Server von Google übermittelt und dort gespeichert. Server-Standort ist im Regelfall die USA.
</p>
<p className="mt-2">
Das Setzen von Google-Analytics-Cookies erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Als Betreiber dieser Website haben wir ein berechtigtes Interesse an der Analyse des Nutzerverhaltens, um unser Webangebot und ggf. auch Werbung zu optimieren.
</p>
<h3 className="text-foreground font-medium mt-4 mb-2">IP-Anonymisierung</h3>
<p>
Wir setzen Google Analytics in Verbindung mit der Funktion IP-Anonymisierung ein. Sie gewährleistet, dass Google Ihre IP-Adresse innerhalb von Mitgliedstaaten der Europäischen Union oder in anderen Vertragsstaaten des Abkommens über den Europäischen Wirtschaftsraum vor der Übermittlung in die USA kürzt. Es kann Ausnahmefälle geben, in denen Google die volle IP-Adresse an einen Server in den USA überträgt und dort kürzt. In unserem Auftrag wird Google diese Informationen benutzen, um Ihre Nutzung der Website auszuwerten, um Reports über Websiteaktivitäten zu erstellen und um weitere mit der Websitenutzung und der Internetnutzung verbundene Dienstleistungen gegenüber uns zu erbringen. Es findet keine Zusammenführung der von Google Analytics übermittelten IP-Adresse mit anderen Daten von Google statt.
</p>
<h3 className="text-foreground font-medium mt-4 mb-2">Browser Plugin</h3>
<p>
Das Setzen von Cookies durch Ihren Webbrowser ist verhinderbar. Einige Funktionen unserer Website könnten dadurch jedoch eingeschränkt werden. Ebenso können Sie die Erfassung von Daten bezüglich Ihrer Website-Nutzung einschließlich Ihrer IP-Adresse mitsamt anschließender Verarbeitung durch Google unterbinden. Dies ist möglich, indem Sie das über folgenden Link erreichbare Browser-Plugin herunterladen und installieren:{" "}
<a href="https://tools.google.com/dlpage/gaoptout?hl=de" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">
https://tools.google.com/dlpage/gaoptout?hl=de
</a>.
</p>
<h3 className="text-foreground font-medium mt-4 mb-2">Widerspruch gegen die Datenerfassung</h3>
<p>
Sie können die Erfassung Ihrer Daten durch Google Analytics verhindern, indem Sie auf folgenden Link klicken. Es wird ein Opt-Out-Cookie gesetzt, der die Erfassung Ihrer Daten bei zukünftigen Besuchen unserer Website verhindert:{" "}
<a href="https://tools.google.com/dlpage/gaoptout?hl=de" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">
Google Analytics deaktivieren
</a>.
</p>
<p className="mt-2">
Einzelheiten zum Umgang mit Nutzerdaten bei Google Analytics finden Sie in der Datenschutzerklärung von Google:{" "}
<a href="https://support.google.com/analytics/answer/6004245?hl=de" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">
https://support.google.com/analytics/answer/6004245?hl=de
</a>.
</p>
<h3 className="text-foreground font-medium mt-4 mb-2">Auftragsverarbeitung</h3>
<p>
Zur vollständigen Erfüllung der gesetzlichen Datenschutzvorgaben haben wir mit Google einen Vertrag über die Auftragsverarbeitung abgeschlossen.
</p>
<h3 className="text-foreground font-medium mt-4 mb-2">Demografische Merkmale bei Google Analytics</h3>
<p>
Unsere Website verwendet die Funktion demografische Merkmale" von Google Analytics. Mit ihr lassen sich Berichte erstellen, die Aussagen zu Alter, Geschlecht und Interessen der Seitenbesucher enthalten. Diese Daten stammen aus interessenbezogener Werbung von Google sowie aus Besucherdaten von Drittanbietern. Eine Zuordnung der Daten zu einer bestimmten Person ist nicht möglich. Sie können diese Funktion jederzeit deaktivieren. Dies ist über die Anzeigeneinstellungen in Ihrem Google-Konto möglich oder indem Sie die Erfassung Ihrer Daten durch Google Analytics, wie im Punkt „Widerspruch gegen die Datenerfassung" erläutert, generell untersagen.
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-2">Google AdSense</h2>
<p>
Unsere Website verwendet Google AdSense. Anbieter ist die Google Inc., 1600 Amphitheatre Parkway, Mountain View, CA 94043, USA.
</p>
<p className="mt-2">
Google AdSense dient der Einbindung von Werbeanzeigen und setzt Cookies. Cookies sind kleine Textdateien, die Ihr Webbrowser auf Ihrem Endgerät speichert, um die Nutzung der Website analysieren. Google AdSense setzt außerdem Web Beacons ein. Web Beacons sind unsichtbare Grafiken, die eine Analyse des Besucherverkehrs auf unserer Website ermöglichen.
</p>
<p className="mt-2">
Durch Cookies und Web Beacons erzeugten Informationen werden an Server von Google übertragen und dort gespeichert. Serverstandort sind die USA. Google kann diese Informationen an Vertragspartner weiterreichen. Ihre IP-Adresse wird Google jedoch nicht mit anderen von Ihnen gespeicherten Daten zusammenführen.
</p>
<p className="mt-2">
Die Speicherung von AdSense-Cookies erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Wir als Websitebetreiber haben ein berechtigtes Interesse an der Analyse des Nutzerverhaltens, um unser Webangebot und unsere Werbung zu optimieren.
</p>
<p className="mt-2">
Mit einem modernen Webbrowser können Sie das Setzen von Cookies überwachen, einschränken und unterbinden. Die Deaktivierung von Cookies kann eine eingeschränkte Funktionalität unserer Website zur Folge haben. Durch die Nutzung unserer Website erklären Sie sich mit der Bearbeitung der über Sie erhobenen Daten durch Google in der zuvor beschriebenen Art und Weise sowie dem zuvor benannten Zweck einverstanden.
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-2">Google AdWords und Google Conversion-Tracking</h2>
<p>
Unsere Website verwendet Google AdWords. Anbieter ist die Google Inc., 1600 Amphitheatre Parkway, Mountain View, CA 94043, United States.
</p>
<p className="mt-2">
AdWords ist ein Online-Werbeprogramm. Im Rahmen des Online-Werbeprogramms arbeiten wir mit Conversion-Tracking. Nach einem Klick auf eine von Google geschaltete Anzeige wird ein Cookie für das Conversion-Tracking gesetzt. Cookies sind kleine Textdateien, die Ihr Webbrowser auf Ihrem Endgerät speichert. Google AdWords Cookies verlieren nach 30 Tagen ihre Gültigkeit und dienen nicht der persönlichen Identifizierung der Nutzer. Am Cookie können Google und wir erkennen, dass Sie auf eine Anzeige geklickt haben und zu unserer Website weitergeleitet wurden.
</p>
<p className="mt-2">
Jeder Google AdWords-Kunde erhält ein anderes Cookie. Die Cookies sind nicht über Websites von AdWords-Kunden nachverfolgbar. Mit Conversion-Cookies werden Conversion-Statistiken für AdWords-Kunden, die Conversion-Tracking einsetzen, erstellt. AdWords-Kunden erfahren wie viele Nutzer auf ihre Anzeige geklickt haben und auf Seiten mit Conversion-Tracking-Tag weitergeleitet wurden. AdWords-Kunden erhalten jedoch keine Informationen, die eine persönliche Identifikation der Nutzer ermöglichen. Wenn Sie nicht am Tracking teilnehmen möchten, können Sie einer Nutzung widersprechen. Hier ist das Conversion-Cookie in den Nutzereinstellungen des Browsers zu deaktivieren. So findet auch keine Aufnahme in die Conversion-Tracking Statistiken statt.
</p>
<p className="mt-2">
Die Speicherung von Conversion-Cookies" erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Wir als Websitebetreiber haben ein berechtigtes Interesse an der Analyse des Nutzerverhaltens, um unser Webangebot und unsere Werbung zu optimieren.
</p>
<p className="mt-2">
Einzelheiten zu Google AdWords und Google Conversion-Tracking finden Sie in den Datenschutzbestimmungen von Google:{" "}
<a href="https://www.google.de/policies/privacy/" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">
https://www.google.de/policies/privacy/
</a>.
</p>
<p className="mt-2">
Mit einem modernen Webbrowser können Sie das Setzen von Cookies überwachen, einschränken oder unterbinden. Die Deaktivierung von Cookies kann eine eingeschränkte Funktionalität unserer Website zur Folge haben.
</p>
</section>
<p className="text-xs text-muted-foreground/60 pt-4">
Quelle: Datenschutz-Konfigurator von mein-datenschutzbeauftragter.de
</p>
</article>
</main>
<Footer />
</div>
);
}

View File

@ -0,0 +1,109 @@
import { Tv, Globe, MapPin } from "lucide-react";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { usePageMeta } from "@/hooks/use-page-meta";
const COUNTRIES = [
{
country: "Deutschland",
flag: "DE",
providers: [
{ name: "MagentaTV", detail: "Telekom Deutschland" },
{ name: "Zattoo", detail: null },
{ name: "O2 TV", detail: null },
],
},
{
country: "Österreich",
flag: "AT",
providers: [
{ name: "A1 Xplore TV", detail: null },
{ name: "Salzburg AG CableLink", detail: null },
{ name: "Zattoo", detail: null },
],
},
{
country: "Schweiz",
flag: "CH",
providers: [
{ name: "Swisscom blue TV", detail: null },
{ name: "Zattoo", detail: null },
],
},
];
export default function EmpfangPage() {
usePageMeta("Empfang FOLX TV - Volksmusik & Schlager Sender empfangen", "So empfangen Sie FOLX TV Ihren Volksmusik & Schlager Sender in Deutschland, Österreich und der Schweiz.");
return (
<div className="min-h-screen bg-background">
<Header />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center gap-3 mb-2">
<Tv className="w-7 h-7 text-primary" />
<h1 className="text-3xl font-bold text-foreground" data-testid="text-empfang-title">
Empfang von FOLX TV
</h1>
</div>
<p className="text-muted-foreground mb-8" data-testid="text-empfang-subtitle">
So empfangen Sie Folx Music Television in Ihrem Land
</p>
<div className="space-y-6 mb-10">
{COUNTRIES.map((c) => (
<section
key={c.country}
className="bg-card rounded-lg border border-card-border overflow-hidden"
data-testid={`section-country-${c.flag.toLowerCase()}`}
>
<div className="flex items-center gap-3 px-5 py-4 border-b border-card-border bg-muted/20">
<MapPin className="w-4 h-4 text-primary" />
<h2 className="text-lg font-bold text-card-foreground">{c.country}</h2>
</div>
<div className="px-5 py-4">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{c.providers.map((p, i) => (
<span key={p.name} className="flex items-center gap-x-2">
<span className="text-card-foreground font-medium" data-testid={`text-provider-${p.name.toLowerCase().replace(/\s+/g, "-")}`}>
{p.name}
{p.detail && (
<span className="text-muted-foreground font-normal text-sm ml-1">
({p.detail})
</span>
)}
</span>
{i < c.providers.length - 1 && (
<span className="text-muted-foreground/40 select-none" aria-hidden="true">·</span>
)}
</span>
))}
</div>
</div>
</section>
))}
</div>
<section className="bg-card rounded-lg border border-card-border overflow-hidden" data-testid="section-online">
<div className="flex items-center gap-3 px-5 py-4 border-b border-card-border bg-muted/20">
<Globe className="w-4 h-4 text-primary" />
<h2 className="text-lg font-bold text-card-foreground">Online</h2>
</div>
<div className="px-5 py-4">
<p className="text-muted-foreground text-sm leading-relaxed">
FOLX TV kann auch über den offiziellen Livestream im Internet empfangen werden.
Besuchen Sie{" "}
<a
href="https://www.folx.tv"
className="text-primary hover:underline font-medium"
data-testid="link-livestream"
>
www.folx.tv
</a>{" "}
für den direkten Zugang.
</p>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@ -3,8 +3,11 @@ import Footer from "@/components/footer";
import GalleryPage from "@/components/photo-gallery";
import { ArrowLeft } from "lucide-react";
import { Link } from "wouter";
import { usePageMeta } from "@/hooks/use-page-meta";
import { InArticleAd } from "@/components/adsense";
export default function GalleryPageWrapper() {
usePageMeta("Fotogalerie - Volksmusik & Schlager Bilder", "Fotos und Bilder von Volksmusik- und Schlager-Stars bei FOLX TV.");
return (
<div className="min-h-screen bg-background">
<Header />
@ -18,7 +21,9 @@ export default function GalleryPageWrapper() {
<h1 className="text-2xl font-bold text-foreground mb-6" data-testid="text-gallery-title">
Fotogalerie
</h1>
<InArticleAd />
<GalleryPage />
<InArticleAd />
</main>
<Footer />
</div>

View File

@ -3,12 +3,12 @@ import { Link } from "wouter";
import { type Article } from "@shared/schema";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Eye, Play, Images } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import Header from "@/components/header";
import Footer from "@/components/footer";
import AdSense, { ArticleCardAd, MultiplexAd, SidebarAd } from "@/components/adsense";
import ArtistPatternBg from "@/components/artist-pattern-bg";
import AdSense, { ArticleCardAd, SidebarAd } from "@/components/adsense";
import { PhotoGalleryWidget } from "@/components/photo-gallery";
import { HoroscopeWidget } from "@/components/horoscope-widget";
import { RecipeWidget } from "@/components/recipe-widget";
@ -398,25 +398,54 @@ function FeaturedCarousel({ articles, popular, galleryImages, focalPoints }: { a
const pageSize = 3;
const totalPages = Math.max(1, Math.ceil(Math.min(articles.length, 9) / pageSize));
const [page, setPage] = useState(0);
const [displayPage, setDisplayPage] = useState(0);
const [fading, setFading] = useState(false);
const [paused, setPaused] = useState(false);
const changePage = useCallback((newPage: number) => {
if (newPage === displayPage) return;
setFading(true);
setTimeout(() => {
setDisplayPage(newPage);
setFading(false);
}, 300);
}, [displayPage]);
const next = useCallback(() => {
setPage((p) => (p + 1) % totalPages);
setPage((p) => {
const np = (p + 1) % totalPages;
return np;
});
}, [totalPages]);
useEffect(() => {
changePage(page);
}, [page]);
useEffect(() => {
if (paused || totalPages <= 1) return;
const timer = setInterval(next, 8000);
return () => clearInterval(timer);
}, [paused, next, totalPages]);
const start = page * pageSize;
const start = displayPage * pageSize;
const hero = articles[start];
const sideCards = articles.slice(start + 1, start + 3);
const nextPageStart = ((displayPage + 1) % totalPages) * pageSize;
const nextPageArticles = articles.slice(nextPageStart, nextPageStart + pageSize);
return (
<section data-testid="featured-carousel" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
{nextPageArticles.length > 0 && (
<div className="hidden" aria-hidden="true">
{nextPageArticles.map((a) => a.coverImage && <img key={`preload-${a.id}`} src={a.coverImage} alt="" />)}
</div>
)}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4 transition-opacity duration-300"
style={{ opacity: fading ? 0 : 1 }}
>
<div className="lg:col-span-3">
{hero && <FeaturedHeroCard article={hero} focalPoints={focalPoints} />}
</div>
@ -454,6 +483,7 @@ function BentoSkeleton() {
}
export default function Home() {
usePageMeta("Volksmusik & Schlager News", "FOLX TV Aktuelle Nachrichten, Musikvideos und Interviews aus der Welt der Volksmusik und des Schlagers.");
const { data: articles, isLoading } = useQuery<Article[]>({
queryKey: ["/api/articles"],
});
@ -485,6 +515,10 @@ export default function Home() {
{ id: "recipe", el: <RecipeWidget key="recipe" /> },
{ id: "breaking", el: <div key="breaking" className="flex flex-col gap-4"><BreakingNewsWidget /></div> },
{ id: "gallery2", el: <PhotoGalleryWidget key="gallery2" reverseOrder={true} /> },
{ id: "horoscope2", el: <HoroscopeWidget key="horoscope2" /> },
{ id: "news2", el: <div key="news2" className="flex flex-col gap-4"><NewsWidget /></div> },
{ id: "recipe2", el: <RecipeWidget key="recipe2" /> },
{ id: "breaking2", el: <div key="breaking2" className="flex flex-col gap-4"><BreakingNewsWidget /></div> },
], []);
const gridItems = useMemo(() => {
@ -515,7 +549,7 @@ export default function Home() {
}
const totalRows = items.length / 4;
const adRows = [1, 3, 5, 7];
const adRows = [1, 3, 5, 7, 9, 11, 13];
let adCount = 0;
for (const row of adRows) {
if (row >= totalRows) continue;
@ -568,6 +602,16 @@ export default function Home() {
return articles.filter((a) => !usedIds.has(a.id)).slice(0, 3);
}, [articles, widePickedArticles]);
const extraBottomArticles = useMemo(() => {
if (!articles || articles.length < 15) return [];
const usedIds = new Set([
...articles.slice(0, 9).map((a) => a.id),
...widePickedArticles.map((a) => a.id),
...bottomArticles.map((a) => a.id),
]);
return articles.filter((a) => !usedIds.has(a.id)).slice(0, 6);
}, [articles, widePickedArticles, bottomArticles]);
const bottomSection = useMemo(() => {
const items: { type: "widget" | "ad" | "article"; el: JSX.Element }[] = [
{ type: "widget", el: <NewsWidget key="bottom-news" /> },
@ -580,9 +624,14 @@ export default function Home() {
})),
{ type: "ad", el: <div key="ad-bottom-1"><ArticleCardAd /></div> },
{ type: "widget", el: <PhotoGalleryWidget key="bottom-gallery" /> },
{ type: "ad", el: <div key="ad-bottom-2"><ArticleCardAd /></div> },
...extraBottomArticles.slice(0, 3).map((a) => ({
type: "article" as const,
el: <BlogCard key={`bottom-extra-${a.id}`} article={a} focalPoints={focalPoints} />,
})),
];
return items;
}, [bottomArticles, focalPoints]);
}, [bottomArticles, extraBottomArticles, focalPoints]);
if (isLoading || !articles) {
return (
@ -630,47 +679,41 @@ export default function Home() {
</div>
</div>
<ArticleCardAd />
{widePickedArticles.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
<ArtistPatternBg className="hidden lg:block rounded-lg overflow-hidden bg-card border border-card-border" seed={99}>
<AdSense
slot="3854634730"
format="fluid"
layoutKey="-6r+cy-10+8a-3"
style={{ display: "block" }}
className="w-full h-full min-h-[250px]"
/>
</ArtistPatternBg>
<div className="lg:col-span-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{widePickedArticles.map((a) => (
<WideCard key={`wide-top-${a.id}`} article={a} focalPoints={focalPoints} />
))}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{widePickedArticles.map((a) => (
<WideCard key={`wide-top-${a.id}`} article={a} focalPoints={focalPoints} />
))}
</div>
)}
{gridRows.map((row, ri) => (
<div key={`row-${ri}`} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{row.map((item) =>
item.type === "widget"
? <div key={item.key}>{item.widget!.el}</div>
: item.type === "ad"
? <div key={item.key} className="h-full" data-testid={`ad-grid-${item.key}`}><ArticleCardAd /></div>
: item.article
? <MediumCard key={item.key} article={item.article} focalPoints={focalPoints} />
: null
)}
{ri === gridRows.length - 1 && widePickedArticles.length > 0 && (
<div className="sm:col-span-2 lg:col-span-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
<WideCardClassic article={widePickedArticles[0]} focalPoints={focalPoints} />
{widePickedArticles[1] && <WideCardClassic article={widePickedArticles[1]} focalPoints={focalPoints} />}
</div>
)}
<div key={`row-group-${ri}`}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{row.map((item) =>
item.type === "widget"
? <div key={item.key}>{item.widget!.el}</div>
: item.type === "ad"
? <div key={item.key} className="h-full" data-testid={`ad-grid-${item.key}`}><ArticleCardAd /></div>
: item.article
? <MediumCard key={item.key} article={item.article} focalPoints={focalPoints} />
: null
)}
{ri === gridRows.length - 1 && widePickedArticles.length > 0 && (
<div className="sm:col-span-2 lg:col-span-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
<WideCardClassic article={widePickedArticles[0]} focalPoints={focalPoints} />
{widePickedArticles[1] && <WideCardClassic article={widePickedArticles[1]} focalPoints={focalPoints} />}
</div>
)}
</div>
{ri % 3 === 2 && ri < gridRows.length - 1 && <ArticleCardAd />}
</div>
))}
<ArticleCardAd />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{bottomSection.map((item, i) => (
<div key={`bottom-${i}`}>
@ -679,7 +722,25 @@ export default function Home() {
))}
</div>
<MultiplexAd />
<ArticleCardAd />
{extraBottomArticles.length > 3 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{extraBottomArticles.slice(3, 6).map((a) => (
<BlogCard key={`extra-bottom-${a.id}`} article={a} focalPoints={focalPoints} />
))}
</div>
)}
<ArticleCardAd />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<PhotoGalleryWidget key="extra-gallery" />
<NewsWidget key="extra-news" />
<RecipeWidget key="extra-recipe" />
</div>
<ArticleCardAd />
</main>
<Footer />

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "wouter";
import { usePageMeta } from "@/hooks/use-page-meta";
import {
Star,
Heart,
@ -368,6 +369,7 @@ function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number
}
export default function HoroscopePage() {
usePageMeta("Horoskop - Volksmusik & Schlager", "Tägliches Horoskop für Volksmusik- und Schlager-Fans bei FOLX TV.");
const params = useParams<{ sign?: string }>();
const [selected, setSelected] = useState<number | null>(null);
const detailRef = useRef<HTMLDivElement>(null);
@ -418,6 +420,8 @@ export default function HoroscopePage() {
<AstroEventsSection />
<InArticleAd />
<SignGrid onSelect={handleSelect} selectedIndex={selected} aiHoroscopes={aiHoroscopes} />
<div ref={detailRef}>

View File

@ -0,0 +1,77 @@
import { Scale } from "lucide-react";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { usePageMeta } from "@/hooks/use-page-meta";
export default function ImpressumPage() {
usePageMeta("Impressum - FOLX TV", "Impressum und rechtliche Informationen zu FOLX TV Volksmusik & Schlager Fernsehsender.");
return (
<div className="min-h-screen bg-background">
<Header />
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center gap-3 mb-8">
<Scale className="w-7 h-7 text-primary" />
<h1 className="text-3xl font-bold text-foreground" data-testid="text-impressum-title">
Impressum
</h1>
</div>
<article className="space-y-8 text-[15px] leading-relaxed text-muted-foreground" data-testid="section-impressum-content">
<section>
<h2 className="text-foreground font-semibold text-lg mb-3">Herausgeber</h2>
<p>
BoldFrame Productions d.o.o.
<br />
Sokolska ulica 46
<br />
2000 Maribor
<br />
Slowenien
</p>
<p className="mt-2">
UID-Nr.: SI38853507
<br />
E-Mail:{" "}
<a href="mailto:office@boldframe.productions" className="text-primary hover:underline" data-testid="link-email">
office@boldframe.productions
</a>
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-3">Offenlegung nach Mediengesetz</h2>
<p>
Medieninhaber: BoldFrame Productions d.o.o.
<br />
Unternehmensgegenstand: Privatfernsehveranstalter, Film, TV und Videoproduktionen
<br />
Sokolska ulica 46
<br />
2000 Maribor
<br />
Slowenien
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-3">Copyright</h2>
<p>
Fotos stammen aus eigenen Archiven oder wurden von den Künstlern, Produzenten und Musikfirmen zugeliefert. Die Rechte für sämtliche Abbildungen, Grafiken, Texte liegen, sofern nicht anders gekennzeichnet, bei BoldFrame Productions d.o.o. Unerlaubtes Kopieren oder Verwenden von Bildern und Texten (auch teilweise) bedarf der vorherigen schriftlichen Genehmigung. Alle Rechte vorbehalten.
</p>
<p className="mt-3">
Wir widersprechen jeder kommerziellen Verwendung und jeder sonstigen Weitergabe und anderweitigen Veröffentlichung dieser Daten.
</p>
</section>
<section>
<h2 className="text-foreground font-semibold text-lg mb-3">Links</h2>
<p>
BoldFrame Productions d.o.o. übernimmt keine Verantwortung oder Haftung hinsichtlich des Zugriffs auf bzw. des Materials in Websites, auf die von dieser Site aus zugegriffen wird. Zum Zeitpunkt der Verlinkung bestand nach eingehender Prüfung der gelinkten Seiten keinerlei Verdacht auf Inhalte, die nach jeweils geltendem Landesrecht gegen Gesetze verstoßen hätten. BoldFrame Productions d.o.o. distanziert sich daher ausdrücklich und unwiderruflich von jeglichen rechtswidrigen Inhalten auf den verlinkten Seiten.
</p>
</section>
</article>
</main>
<Footer />
</div>
);
}

View File

@ -1,6 +1,7 @@
import { useState } from "react";
import { Link } from "wouter";
import { ChefHat, Clock, Users, X, ChevronLeft } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { InArticleAd } from "@/components/adsense";
@ -295,6 +296,7 @@ function RecipeModal({ recipe, onClose }: { recipe: Recipe; onClose: () => void
}
export default function RecipesPage() {
usePageMeta("Rezepte - Alpenküche & Schlager", "Traditionelle Rezepte aus der Alpenküche bei FOLX TV kochen wie die Volksmusik-Stars.");
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
return (
@ -348,7 +350,7 @@ export default function RecipesPage() {
))}
</div>
{ri % 2 === 0 && <InArticleAd />}
<InArticleAd />
</div>
))}
</main>

View File

@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { useState, useEffect, useRef } from "react";
import { Link, useSearch, useLocation } from "wouter";
import { Search, Play, FileText, ArrowLeft } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
import Header from "@/components/header";
interface SearchResult {
@ -25,6 +26,7 @@ interface SearchResult {
}
export default function SearchPage() {
usePageMeta("Suche - Volksmusik & Schlager", "Durchsuchen Sie FOLX TV nach Volksmusik- und Schlager-Inhalten.");
const searchString = useSearch();
const initialQuery = new URLSearchParams(searchString).get("q") || "";
const [query, setQuery] = useState(initialQuery);

View File

@ -1,13 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "wouter";
import { Link, useLocation, useSearch } from "wouter";
import { type Article } from "@shared/schema";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Play, ArrowLeft } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Play, ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { ArticleCardAd } from "@/components/adsense";
import { useEffect } from "react";
interface PaginatedResponse {
articles: Article[];
total: number;
page: number;
totalPages: number;
hasMore: boolean;
}
function VideoCard({ article }: { article: Article }) {
const thumbSrc = article.coverImage
@ -61,10 +72,49 @@ function VideoCardSkeleton() {
}
export default function VideosPage() {
const { data: articles, isLoading } = useQuery<Article[]>({
queryKey: ["/api/articles/category/Video"],
usePageMeta("Volksmusik & Schlager Videos", "Musikvideos und Live-Auftritte aus der Volksmusik- und Schlagerszene bei FOLX TV.");
const searchString = useSearch();
const [, setLocation] = useLocation();
const params = new URLSearchParams(searchString);
const currentPage = Math.max(1, parseInt(params.get("page") || "1"));
const limit = 12;
const { data, isLoading } = useQuery<PaginatedResponse>({
queryKey: [`/api/articles/category/Video?page=${currentPage}&limit=${limit}`],
});
useEffect(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, [currentPage]);
const goToPage = (page: number) => {
if (page === 1) {
setLocation("/videos");
} else {
setLocation(`/videos?page=${page}`);
}
};
const totalPages = data?.totalPages || 1;
const articles = data?.articles || [];
const getPageNumbers = () => {
const pages: (number | "...")[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (currentPage > 3) pages.push("...");
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (currentPage < totalPages - 2) pages.push("...");
pages.push(totalPages);
}
return pages;
};
return (
<div className="min-h-screen bg-background">
<Header />
@ -76,24 +126,70 @@ export default function VideosPage() {
</button>
</Link>
<h1 className="text-2xl font-bold text-foreground mb-6" data-testid="text-videos-title">
Videos
</h1>
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
{Array.from({ length: 4 }).map((_, i) => (
{Array.from({ length: 8 }).map((_, i) => (
<VideoCardSkeleton key={i} />
))}
</div>
) : articles && articles.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
{articles.flatMap((article, index) => {
const items = [
<VideoCard key={article.id} article={article} />,
];
if (index === 2) {
items.push(<ArticleCardAd key="ad-video-1" />);
}
return items;
})}
</div>
) : articles.length > 0 ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
{articles.flatMap((article, index) => {
const items: JSX.Element[] = [
<VideoCard key={article.id} article={article} />,
];
if ((index + 1) % 3 === 0) {
items.push(<ArticleCardAd key={`ad-video-${index}`} />);
}
return items;
})}
</div>
{totalPages > 1 && (
<nav className="flex items-center justify-center gap-1 mt-10" data-testid="pagination-videos">
<Button
variant="outline"
size="icon"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
data-testid="button-prev-page"
>
<ChevronLeft className="w-4 h-4" />
</Button>
{getPageNumbers().map((p, i) =>
p === "..." ? (
<span key={`ellipsis-${i}`} className="px-2 text-muted-foreground">...</span>
) : (
<Button
key={p}
variant={p === currentPage ? "default" : "outline"}
size="sm"
onClick={() => goToPage(p as number)}
data-testid={`button-page-${p}`}
>
{p}
</Button>
)
)}
<Button
variant="outline"
size="icon"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages}
data-testid="button-next-page"
>
<ChevronRight className="w-4 h-4" />
</Button>
</nav>
)}
</>
) : (
<div className="text-center py-16">
<p className="text-muted-foreground">Noch keine Videos vorhanden.</p>

View File

@ -85,6 +85,40 @@ The official website for Folx Music Television (folx.tv). Dark-themed bento grid
- Dropbox: Gallery image thumbnails (547 images from 16 folders)
- Google News RSS: Volksmusik/Schlager news feed
## Publishing Workflow
### Adding or Updating Articles
1. **Edit seed data**: Update `server/seed.ts` with new article data
- Add new articles to the `articlesData` array
- Update existing articles as needed
- Use unique slugs for SEO-friendly URLs
2. **Run seed locally** (for development testing):
- Execute: `npm run seed`
- This populates the development database with article data
3. **Commit changes**: Push code changes to the repository
4. **Deploy to production**:
- Deployment is configured as "autoscale" type in `.replit`
- Build command: `npm run build`
- Run command: `node ./dist/index.cjs`
- The production database is separate from the development database
- **Important**: Seed must run on every deploy to ensure the production database is updated with new/modified articles
- To run seed on deploy, add seed execution to the deployment process or manually trigger seed after deployment
### Database Management
- **Development**: Local PostgreSQL database (populated by `npm run seed`)
- **Production**: Separate PostgreSQL database on Replit deployment
- All article content is hardcoded in `server/seed.ts` for reproducible deployments
- The `DATABASE_URL` environment variable automatically points to the correct database (local for dev, production for deploy)
### Deployment Checklist
- [ ] Update `server/seed.ts` with new article data
- [ ] Test locally: run `npm run seed` and verify articles appear in dev
- [ ] Run `npm run build` to ensure no build errors
- [ ] Commit all changes
- [ ] Deploy via Replit deployment interface
- [ ] After deployment, ensure seed runs on the production database (may require manual trigger or additional setup)
- [ ] Verify live site at https://www.folx.tv shows updated content
## Important Notes
- Tailwind `object-[center_25%]` does NOT work — must use inline `style={{ objectPosition: "center 25%" }}`
- Horoscope widget navigates to /horoskop on click (no modal)

View File

@ -1,7 +1,7 @@
{
"access_token": "sl.u.AGWH7nNDNxu6n0TGi2319rCdcQYXkcyuFER6OlIPEJgMxmrw66-u-NwXroP7EBFd1Vgm3tAFViiSt0rx2Y_iGgqBv9bt40L-w13r2m00xK0FKE5HKjhCSEm1VDnHxPjjJQ4pnsgOnLsLPR6NMvuHWwE_zFQF5xOOFaMo9SrFcTX7tVmI0-lXer9nHo6ar8XJi7lDzcdA-lX_GMgK1sk6xt_fZJ5wJ0bwiV9NA-0f5hsWa2IOP00dkccZAJI6u3B-4e8Z_Rn0eY1SdPQlqyK6mFM1IGtavSQQeREuvKhtHy0xCaC4MJwPUV9ctfJ6Z181FaOrjoUfd37XLcEZL9DkAeamw_HAU1AFALH9DWJpWgcIKFPjYfCc9Pp4KkHaqG1pCQIRe4XUuW-A56TM9lNoWbmepbJza1n0jl-NvdqbPkXyxXL9t9oSIF2r_SfSokgGPWnycaKmAHl2IK5duiglVyTPjnWFDZrdBKgqMGGCKd_bZ2JSvGMGzXoWGI09oSNkYBToCf4SIbXc4kkiW6FrbRVPB44NDQElWD34oA8dkdFM8wuA3Tes8wMnBrixoW__YQN-BQhiR3FyeNGuOFWGc5k2ZeRljztj1RgNQKKEqe73G-o7fdjw6BwtMectTu-iKarjtDBq2AlKGpinBT5cRb6ndIqnRy7xNFL-BkQ1n-9W3b4uB4h3fYEsVk0tFFPuWQUboA71f60EATJMmGto1j8uTAiqmJK0rjoKm2vbeKfIebLPMqsTuvXltEnTeVz_wOvIm1k1PpTfnautsdJrDx6IrUavGUQ3h1w_dF3pF9K5svXhQZ_XfjTTW-aSQ2lqfDgfxZV6Osqd-8rq00LT0jQIwJhKIwloIXNpfK-IJbh7DuUJHQmGAFeLb7nurSO-Rd9qJDvp9gWxQWEBPPncFrGt23ERBuWaSrB6kO181SszyrxX-4-4s4D6Q98ZGd_5mGZu_rVCbIek_3LTIqoTP_cYtGLx4p4UhybJ-X_OIyfkiAafmuKP8A-Xy5W26tXyYVt6XcdmQb3MPs6GwkGjGutQNcJ7B0uBELrs2PAqWoWb_uL7OwKtUSj2pNeqagnZjhUqvkGTZPRnAE91z9VTvtBzLllj1TrT22-k-KXhzmED8tVOa4mjpzsMhKVp74Fh1fntQmFvBFttPpDmORKigcyqA__587g8lHtsNf1R9vXnVogeN6DSwi5n1taZRpbHoAQvJ-jIxdIfirvmN2kzKgmBYBWGm32ViO0vBoWqeR150ka1Zzl0i6aDBNXuD271BIDIAmdPvKx_7pXzGvR-6i3yIEP6uaTVIvzzIAZz3hDmag",
"access_token": "sl.u.AGWJ8sxDwmUlPT2LcHxWop31dWjgQBdBEFRmm7UFkk6NrkdA_Wcjn5cmlJ1JSNcwQEOv6seTzt5_1NbyWc-12KujpapH4aMNY75sYMTZcklc63GAgoUFouTQFvTW1Dd4UR2uEiP8z09MU3DX-trdEy9CfAXt4gilBx3apibz-vFpxHrsZlQDeEnx7KY_XmRFRlEKg3AHBsezx7O2DAEhDjMCF7M9CrxaCPEMNszI_oi7eXOcjyDn1LpEI7xb8JpeLUJSJ2DtLTut3hBgIpsAMfR0NDfesXLAeyA0NdpL75k_5pKpDCVsqUZJAITyBWqxSthPaalhboLFQAEPMOYNKLPeZpgFvPDeQdEIqLGbH0ob6nJzJbJz2fyYGEUXPgNBzq_3fJpxIThXNOluSmhBIVjuJwzK1M4nbzs_9EIUwq8YRndfm76yBUHz-YG1mQIJwYX-1gnMZEM4v72QZfI2pCo4igGfFWhSvdbW17ni8SRt46_LpxzM9uXoLTOEeMf2VXDqZxRMar1CxdORcz2gHfZHkBLfTkRHB9QkrtcE9ckU3B3srHtBKwhYxL-FT5gKix5u9o4dTvQGQzHnUmBX_5AvoWayATE3BPVXoAIBYFVX3oMwkRElyKyZmwSzmoi1LvoDnzkF7ZuWtTF6GaCPvv6S1aUsTLh6yd0Izu3T_-N7TeblmcHnE3OgIO8IWOFr3jQutrhIiY4D9X637Pr5-GubYtX-sptVPzEhvRM_epIrsBYZWFSvA7HN4JUcZaB7Gn0_ibcuNhDBBwYiai35AslEOSfLrwrDwoygAC2asMILGn8vXEeZNybZKmmyQY2jcBynuHBXh-Jnx18of0Xk2sQ-Q_sCDiA3m3CQCmn2_zF_hdi1AWiC0oPANKlqPhXMuaToEqBSIqtny1PdFyZgK1Q-p2_Gz-Tz5BD9CHaEu6iHRpt5uFwbQFCHa-iumAyJq5NFIPlQ0Gb56P7JLt6Ks0o6VCIFGP2itDRR1kWcaRIFwdeK9v67QTuYjomFszM0P73ZhdZp_AeaWlCF0lovY32cLIxjzpKQ3Z6VIa10s2abc2Gj6fdgnh6OQDHcFaBqHO1jAA-lTmEQ4CSVma7JLfl00LcqS5bwfV2y7eM6Gfck0uF6JUXmfjHyV5qDpLcok-n-sOHLbkmF551_NwpRTTvXUBxaEYzDJ3U8QF-yFQOD5KPREyZfEitATZikay78XT67tyrCPMQ4TmkmZSf5evKlb542x4H1KHT2QW0b_gU7c6jBzZyeR9ki0Fnop3-VwqP0GKDG1_FzexDN1T8dNjCosJXjBcvt1X6u5pcf_Uoe0w",
"refresh_token": "8-fiK0Io8Z8AAAAAAAAAAXn1QIGTwFTVWKF47COXY2bjqYlWyd4aRnFtQJ7usZ0y",
"expires_at": 1772727518350,
"expires_at": 1772717208506,
"app_key": "sjwprgka82p8tpv",
"app_secret": "g3vuczqo0rx3crz"
}

File diff suppressed because it is too large Load Diff

View File

@ -86,6 +86,19 @@ export async function registerRoutes(
});
app.get("/api/articles/category/:category", async (req, res) => {
const page = parseInt(req.query.page as string);
const limit = parseInt(req.query.limit as string) || 12;
if (page && page > 0) {
const result = await storage.getArticlesByCategoryPaginated(req.params.category, page, limit);
const totalPages = Math.ceil(result.total / limit);
return res.json({
articles: result.articles,
total: result.total,
page,
totalPages,
hasMore: page < totalPages,
});
}
const articles = await storage.getArticlesByCategory(req.params.category);
res.json(articles);
});

View File

@ -193,9 +193,18 @@ const seedArticles = [
];
export async function seedDatabase() {
const validSlugs = new Set(seedArticles.map((a) => a.slug));
const existing = await storage.getArticles();
const existingSlugs = new Set(existing.map((a) => a.slug));
const toDelete = existing.filter((a) => !validSlugs.has(a.slug));
if (toDelete.length > 0) {
for (const article of toDelete) {
await db.execute(sql`DELETE FROM articles WHERE id = ${article.id}`);
}
console.log("Cleanup: removed " + toDelete.length + " articles not in seed list.");
}
let added = 0;
for (const article of seedArticles) {
if (existingSlugs.has(article.slug)) continue;
@ -246,4 +255,12 @@ export async function seedDatabase() {
content = ${"<p>Die beliebte S\u00e4ngerin <strong>Melanie Payer</strong> stellt ihren Titel \u201eEndlich wieder Gipfelstammtisch\u201c vor, der eigens als Titelsong f\u00fcr die neue Staffel der Sendung <strong>\u201eGipfelstammtisch\u201c</strong> auf Folx TV geschrieben wurde. Im Zuge der Ver\u00f6ffentlichung wurde auch ein offizielles Musikvideo produziert, das die musikalische Idee und die beteiligten K\u00fcnstler in den Mittelpunkt stellt.</p>\n<p>Die Produktion entstand im renommierten Tonstudio FD-Musics in Gmunden. Komponiert wurde der Titel von <strong>Flo Daxner</strong> und <strong>Hanneliese Krei\u00dfl Wurth</strong>, die auch den Text verfasste. Produktion und Arrangement \u00fcbernahm Flo Daxner. Ver\u00f6ffentlicht wurde der Song unter dem Label mymusic.media und ist auf allen g\u00e4ngigen Plattformen als Download und Stream verf\u00fcgbar.</p>\n<p><strong>Song &amp; Musikvideo:</strong><br><a href=\"https://music.imusician.pro/a/K2P_nWeA\" target=\"_blank\" rel=\"noopener noreferrer\">https://music.imusician.pro/a/K2P_nWeA</a></p>\n<div style=\"position:relative;padding-bottom:56.25%;height:0;overflow:hidden;max-width:100%;margin:1.5rem 0;\"><iframe style=\"position:absolute;top:0;left:0;width:100%;height:100%;border:0;border-radius:8px;\" src=\"https://www.youtube.com/embed/38HygQCVFoo\" title=\"Melanie Payer - Endlich wieder Gipfelstammtisch\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe></div>\n<p>Der Titel wurde speziell f\u00fcr die Sendung \u201eGipfelstammtisch\u201c geschrieben, um deren Charakter musikalisch widerzuspiegeln \u2013 Geselligkeit, Tradition und die authentische Volksmusik aus der Region Wilder Kaiser. Im Fokus stehen die Musik und die K\u00fcnstler, die die neue Staffel begleiten.</p>\n<p>Im Musikvideo wirken mit: <strong>Natascha</strong>, <strong>Meli Stein</strong>, <strong>Linda Feller</strong>, <strong>Mark Ed</strong>, <strong>Da Wadltreiber von Amadeus</strong>, <strong>Tauern Echo</strong>, <strong>Melanie Payer</strong>, <strong>Hansi Berger</strong>, <strong>Sanny \u2013 Die Stimme der Berge</strong>, <strong>Julia Raich</strong>, <strong>Spitzbua Markus</strong>, <strong>Meissnitzer Band</strong>, <strong>Brennholz</strong>, <strong>Charly Kaiser</strong>, <strong>Franz Nolf</strong>, <strong>Die Grubertaler</strong>, <strong>Franz Steiner</strong>, <strong>Marlena Martinelli</strong>, <strong>Die 3 Z'widern</strong>, <strong>SUSAL</strong> und <strong>Pfundskerle</strong>. Wijbrand van der Sande ist im Hintergrund zu sehen, w\u00e4hrend der Fokus klar auf dem Titel und den beteiligten K\u00fcnstlern liegt.</p>\n<p>\u201eEndlich wieder Gipfelstammtisch\u201c verbindet die neue Staffel der Sendung mit der Klangwelt der alpenl\u00e4ndischen Volksmusik und unterstreicht die musikalische Identit\u00e4t des Formats.</p>"}
WHERE slug = 'melanie-payer-endlich-wieder-gipfelstammtisch'
`);
await db.execute(sql`
UPDATE articles SET
title = REPLACE(REPLACE(title, '&bdquo;', '\u201e'), '&ldquo;', '\u201c'),
excerpt = REPLACE(REPLACE(excerpt, '&bdquo;', '\u201e'), '&ldquo;', '\u201c')
WHERE title LIKE '%&bdquo;%' OR title LIKE '%&ldquo;%'
OR excerpt LIKE '%&bdquo;%' OR excerpt LIKE '%&ldquo;%'
`);
}

View File

@ -7,6 +7,15 @@ function escapeHtml(str: string): string {
return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function ogImageUrl(coverImage: string, baseUrl: string): string {
if (!coverImage) return "";
let imgPath = coverImage;
if (imgPath.endsWith(".webp")) {
imgPath = imgPath.replace(/\.webp$/, ".jpg");
}
return imgPath.startsWith("http") ? imgPath : `${baseUrl}${imgPath}`;
}
export function serveStatic(app: Express) {
const distPath = path.resolve(__dirname, "public");
if (!fs.existsSync(distPath)) {
@ -31,7 +40,7 @@ export function serveStatic(app: Express) {
const protocol = req.get("x-forwarded-proto") || "https";
const baseUrl = `${protocol}://${host}`;
const articleUrl = `${baseUrl}/article/${article.slug}`;
const imageUrl = article.coverImage ? (article.coverImage.startsWith("http") ? article.coverImage : `${baseUrl}${article.coverImage}`) : "";
const imageUrl = ogImageUrl(article.coverImage || "", baseUrl);
let template = await fs.promises.readFile(indexPath, "utf-8");
@ -40,13 +49,14 @@ export function serveStatic(app: Express) {
`<meta property="og:description" content="${escapeHtml(article.excerpt)}" />`,
`<meta property="og:type" content="article" />`,
`<meta property="og:url" content="${escapeHtml(articleUrl)}" />`,
imageUrl ? `<meta property="og:image" content="${escapeHtml(imageUrl)}" />` : "",
`<meta property="og:image" content="${escapeHtml(imageUrl || `${baseUrl}/og-image.jpg`)}" />`,
`<meta property="og:site_name" content="Folx Music Television" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(article.title)}" />`,
`<meta name="twitter:description" content="${escapeHtml(article.excerpt)}" />`,
imageUrl ? `<meta name="twitter:image" content="${escapeHtml(imageUrl)}" />` : "",
`<title>${escapeHtml(article.title)} - Folx Music Television</title>`,
`<meta name="twitter:image" content="${escapeHtml(imageUrl || `${baseUrl}/og-image.jpg`)}" />`,
`<meta name="description" content="${escapeHtml(article.excerpt)}" />`,
`<title>${escapeHtml(article.title)} - Volksmusik & Schlager | Folx Music Television</title>`,
].filter(Boolean).join("\n ");
template = template.replace(/<meta property="og:[^"]*"[^>]*\/>\s*/g, "");

View File

@ -1,6 +1,6 @@
import { type Article, type InsertArticle, articles } from "@shared/schema";
import { db } from "./db";
import { eq, desc, sql } from "drizzle-orm";
import { eq, desc, sql, count } from "drizzle-orm";
export interface IStorage {
getArticles(): Promise<Article[]>;
@ -9,6 +9,7 @@ export interface IStorage {
getFeaturedArticles(): Promise<Article[]>;
getPopularArticles(limit: number): Promise<Article[]>;
getArticlesByCategory(category: string): Promise<Article[]>;
getArticlesByCategoryPaginated(category: string, page: number, limit: number): Promise<{ articles: Article[]; total: number }>;
createArticle(article: InsertArticle): Promise<Article>;
updateArticle(id: number, article: Partial<InsertArticle>): Promise<Article | undefined>;
incrementViews(id: number): Promise<void>;
@ -42,6 +43,14 @@ export class DatabaseStorage implements IStorage {
return db.select().from(articles).where(eq(articles.category, category)).orderBy(desc(articles.publishedAt));
}
async getArticlesByCategoryPaginated(category: string, page: number, limit: number): Promise<{ articles: Article[]; total: number }> {
const offset = (page - 1) * limit;
const [totalResult] = await db.select({ count: count() }).from(articles).where(eq(articles.category, category));
const total = totalResult.count;
const items = await db.select().from(articles).where(eq(articles.category, category)).orderBy(desc(articles.publishedAt)).limit(limit).offset(offset);
return { articles: items, total };
}
async createArticle(article: InsertArticle): Promise<Article> {
const [created] = await db.insert(articles).values(article).returning();
return created;

View File

@ -13,6 +13,15 @@ function escapeHtml(str: string): string {
return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function ogImageUrl(coverImage: string, baseUrl: string): string {
if (!coverImage) return "";
let imgPath = coverImage;
if (imgPath.endsWith(".webp")) {
imgPath = imgPath.replace(/\.webp$/, ".jpg");
}
return imgPath.startsWith("http") ? imgPath : `${baseUrl}${imgPath}`;
}
export async function setupVite(server: Server, app: Express) {
const serverOptions = {
middlewareMode: true,
@ -63,7 +72,7 @@ export async function setupVite(server: Server, app: Express) {
const protocol = req.get("x-forwarded-proto") || "https";
const baseUrl = `${protocol}://${host}`;
const articleUrl = `${baseUrl}/article/${article.slug}`;
const imageUrl = article.coverImage ? (article.coverImage.startsWith("http") ? article.coverImage : `${baseUrl}${article.coverImage}`) : "";
const imageUrl = ogImageUrl(article.coverImage || "", baseUrl);
const ogTags = [
`<meta property="og:title" content="${escapeHtml(article.title)}" />`,