Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd0b92ea52 | ||
|
|
9f948173f7 | ||
|
|
04a1376cdd | ||
|
|
e375ef6c2c | ||
|
|
0f88bdf587 | ||
|
|
cc306921d7 | ||
|
|
c5c3fe290e | ||
|
|
9a43fdf43c | ||
|
|
48f8e16cda | ||
|
|
ea3cd47fe6 | ||
|
|
631a52d56b | ||
|
|
35138cec66 | ||
|
|
0fcd6812d3 | ||
|
|
5c0cd06b44 | ||
|
|
804418d7a9 | ||
|
|
66819b7e84 | ||
|
|
6a56be0275 | ||
|
|
d768fba43d | ||
|
|
cb1070f75a | ||
|
|
5d536fdc89 | ||
|
|
bc275e272f | ||
|
|
a31797a807 | ||
|
|
69d9d87778 | ||
|
|
f710a7b6d3 | ||
|
|
2bc310b817 | ||
|
|
d8516424ea | ||
|
|
ccc30dff96 | ||
|
|
b3d5fe05e4 | ||
|
|
b10b7f7b19 | ||
|
|
8e3d647c1c | ||
|
|
2f9071d604 | ||
|
|
fe2804cc4d | ||
|
|
a395b18013 | ||
|
|
aeb4c69916 | ||
|
|
cc65cc16e6 | ||
|
|
af1ab5762c | ||
|
|
360c433a05 | ||
|
|
85776692ae | ||
|
|
98e0c05af2 | ||
|
|
1bd20efbac | ||
|
|
7a57fcfbfd | ||
|
|
3ca2a02618 | ||
|
|
49209f0a28 | ||
|
|
08dad04317 | ||
|
|
2c0db039eb | ||
|
|
b0ba87fb6f | ||
|
|
66bc6dd9dc | ||
|
|
5bc16ae27f | ||
|
|
8325ec4784 | ||
|
|
22cdb79c0e | ||
|
|
c847f526d3 | ||
|
|
56ff5712b4 | ||
|
|
16f8a257c6 | ||
|
|
035b56a644 | ||
|
|
c507289753 | ||
|
|
71bfa3ad9b | ||
|
|
1a35d9a747 | ||
|
|
c17c9b762e | ||
|
|
2a043e3ad6 | ||
|
|
39426459df | ||
|
|
739781d521 | ||
|
|
cc9afe9e28 | ||
|
|
7a25bf73cf | ||
|
|
cdf45b9933 | ||
|
|
6855f2a911 | ||
|
|
ccc0b6b3c5 | ||
|
|
91e84e05fe | ||
|
|
7098b54cf3 | ||
|
|
13ef1cdbbc | ||
|
|
c697623e5d | ||
|
|
6a0ef7f2cf | ||
|
|
7f7c5e05d1 | ||
|
|
ed47a05bff | ||
|
|
8e29ad8e41 | ||
|
|
fc7e9c38a0 | ||
|
|
fd9121e8db | ||
|
|
b9b2a6c1f2 | ||
|
|
85e7a1e7c7 | ||
|
|
12694d6db8 | ||
|
|
095636137e | ||
|
|
aacf46d523 | ||
|
|
9c0e088f08 | ||
|
|
ef5df9cf77 | ||
|
|
600e3cb13d | ||
|
|
61fe48fb8b | ||
|
|
77b40ed906 | ||
|
|
98b57fb6c8 | ||
|
|
a39322b729 | ||
|
|
e66f604f25 | ||
|
|
656be0cbff | ||
|
|
039323ea7d | ||
|
|
43eff55ba3 | ||
|
|
907e63d3d2 | ||
|
|
5b519019d2 | ||
|
|
db9eac20e2 | ||
|
|
10c24d3e8c | ||
|
|
a218e76f9d | ||
|
|
3d1918c2cc | ||
|
|
debbf5ff1f | ||
|
|
b9f5b82eb9 | ||
|
|
91be6d716a | ||
|
|
47273fc1eb | ||
|
|
9a92530097 | ||
|
|
2e57f27270 | ||
|
|
ade6c86857 | ||
|
|
dc8a531d6b | ||
|
|
4576b04f4c | ||
|
|
2d321b4e57 | ||
|
|
0b60da3593 | ||
|
|
bc915249fe | ||
|
|
cee85ef210 | ||
|
|
ed8872226b | ||
|
|
b77fd4a28b | ||
|
|
ed063af866 | ||
|
|
2de0fa2c9b | ||
|
|
d325901182 | ||
|
|
27e29b9c9a | ||
|
|
0e438b1d3c | ||
|
|
1e9f854f26 | ||
|
|
c8b7def69c | ||
|
|
3c9da492a2 | ||
|
|
79e466dc98 | ||
|
|
808e9a5438 | ||
|
|
ca47bc8698 | ||
|
|
2ec7c259c3 | ||
|
|
df15293ea3 | ||
|
|
160994f531 | ||
|
|
11e61e5405 | ||
|
|
70f16e21c7 | ||
|
|
9a75e88932 | ||
|
|
9d20f0da8e | ||
|
|
0b7cf9e5f5 | ||
|
|
78ef1848dc | ||
|
|
32f7738ee8 | ||
|
|
056e1d08d6 | ||
|
|
164775f2b8 | ||
|
|
23d780477a | ||
|
|
a8a958e15c | ||
|
|
23a684325b | ||
|
|
59279fa039 | ||
|
|
e10501af78 | ||
|
|
514955bc03 | ||
|
|
9f7a7a727c | ||
|
|
003186ce1e | ||
|
|
561d15d8b3 | ||
|
|
590e5438d3 | ||
|
|
8bf90c6942 | ||
|
|
a039cf48da | ||
|
|
3c429a3aa1 | ||
|
|
b92c39d9f0 | ||
|
|
001a05fe16 | ||
|
|
b5732e872d | ||
|
|
e6a951620e | ||
|
|
b6eb0c6d0e | ||
|
|
10af4563a5 | ||
|
|
6f4cfa8ee9 | ||
|
|
3c6079d720 | ||
|
|
68fe291b55 | ||
|
|
f4acd541c5 | ||
|
|
7dcee28bf2 | ||
|
|
b61bfac5d3 | ||
|
|
63f27e7236 | ||
|
|
262636ef52 | ||
|
|
69d400a209 | ||
|
|
fda5e2ce62 | ||
|
|
ef33ad68fa | ||
|
|
d1765f2028 | ||
|
|
2085ce55fe | ||
|
|
66b74307ab | ||
|
|
b74a65bea5 | ||
|
|
f51c21c716 | ||
|
|
0e5ab138cb | ||
|
|
5fa365726b | ||
|
|
4a01c0bf1a | ||
|
|
bcb130683b | ||
|
|
a20c72e65e | ||
|
|
87cfbf743c | ||
|
|
dac7e60a4d | ||
|
|
9de740168a | ||
|
|
3fdf54ff55 | ||
|
|
0dd5092e1b | ||
|
|
bbf0420e7d | ||
|
|
c874bbc75b | ||
|
|
fc56ef06f6 | ||
|
|
a5e48e6613 | ||
|
|
b03d998b2a | ||
|
|
45ca3f20fc | ||
|
|
8107419131 | ||
|
|
0a2f477654 | ||
|
|
365da96f5b | ||
|
|
6c34d44cea | ||
|
|
c8b0989d4c | ||
|
|
ede172d2f8 | ||
|
|
245aa55a4e | ||
|
|
dfbd8acba3 | ||
|
|
83815503e1 | ||
|
|
47d9e25f1c | ||
|
|
27f0b92deb | ||
|
|
3bb6cf8f15 | ||
|
|
88fd916f8e | ||
|
|
dd13cc667a | ||
|
|
beeb87f036 | ||
|
|
e5fcb182e7 | ||
|
|
4ece9e4ccd | ||
|
|
8a263dee0f |
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.sql
|
||||||
|
.replit
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ pkg-config \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/client/public ./client/public
|
||||||
|
COPY --from=builder /app/attached_assets ./attached_assets
|
||||||
|
COPY --from=builder /app/server ./server
|
||||||
|
EXPOSE 5000
|
||||||
|
CMD ["node", "dist/index.cjs"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
Danke.
|
||||||
|
Danke an alle unsere treuen Zuschauerinnen, die mit uns singen, tanzen und träumen – jeden Tag, Jahr für Jahr.
|
||||||
|
Danke an alle Künstlerinnen auf der Bühne, Moderatorinnen vor der Kamera und alle Frauen hinter den Kulissen, ohne die FOLX TV nicht das Zuhause der Volksmusik und des Schlagers wäre, das Sie kennen und lieben.
|
||||||
|
|
||||||
|
Der 8. März, der Internationale Frauentag, ist unser Anlass, laut „Danke“ zu sagen und die Bühne ganz den Frauen zu überlassen.
|
||||||
|
|
||||||
|
Frauen schreiben seit jeher die schönsten Geschichten der Musik:
|
||||||
|
Sie stehen auf der Bühne, wenn im „Folx Stadl“ die Stimmung kocht, sie moderieren mit Herz und Humor unsere Sendungen und sie sitzen zu Hause vor dem Fernseher, singen mit, wählen ihre Lieblingsvideos und halten FOLX TV mit ihrer Treue am Laufen.
|
||||||
|
Ob junge Fans oder Best Ager – Sie alle machen unsere Volksmusik- und Schlagerwelt bunt, lebendig und voller Gefühl.
|
||||||
|
|
||||||
|
Der Internationale Frauentag erinnert daran, wie viel Frauen bereits erreicht haben – und dass ihr Einsatz noch lange nicht zu Ende ist.
|
||||||
|
Es geht um Respekt, faire Chancen, Gleichberechtigung und darum, gesehen und gehört zu werden.
|
||||||
|
Mit jeder starken Stimme, jeder mutigen Entscheidung und jedem Lächeln verändern Frauen Tag für Tag die Welt – auch in unserer FOLX Community.
|
||||||
|
|
||||||
|
Und was passt besser zu einem Feiertag für starke Frauen als Musik?
|
||||||
|
Wenn die ersten Akkorde einer Oberkrainer-Polka erklingen, wenn ein moderner Schlager-Hit Gänsehaut macht oder wenn im „Folx Stadl“ gemeinsam geklatscht, gelacht und getanzt wird, dann spürt man: Musik verbindet Generationen.
|
||||||
|
Sie weckt Erinnerungen, schenkt Kraft und begleitet durch gute wie durch schwierige Zeiten.
|
||||||
|
|
||||||
|
Heute sagen wir: Dieser Tag gehört Ihnen.
|
||||||
|
Drehen Sie den Fernseher ruhig ein wenig lauter, tanzen Sie durchs Wohnzimmer, stoßen Sie mit Freundinnen an oder gönnen Sie sich einfach einen ruhigen Moment nur für sich und Ihre Lieblingsmusik.
|
||||||
|
Feiern Sie sich selbst – mit all dem, was Sie leisten, was Sie lieben und wovon Sie träumen.
|
||||||
|
|
||||||
|
FOLX TV bedankt sich von Herzen bei allen Frauen – heute, am 8. März, und an jedem anderen Tag des Jahres.
|
||||||
|
Schön, dass Sie Teil der FOLX Familie sind.
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de" class="dark">
|
<html lang="de" class="dark">
|
||||||
<head>
|
<head>
|
||||||
|
<!-- Google tag (gtag.js) -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-2FYMHBCNLQ"></script>
|
||||||
|
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag("js",new Date());gtag("config","G-2FYMHBCNLQ");</script>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="google-site-verification" content="_rO4EdfjGp2lPdm3fpxkWv8k1id0wkthn9GxwsXLpeo" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
||||||
<title>Volksmusik & Schlager TV | FOLX Music Television – Nachrichten, Videos & mehr</title>
|
<title>Schlager & Volksmusik News | FOLX TV</title>
|
||||||
<meta name="description" content="FOLX TV – Ihr Fernsehsender Nr. 1 für Volksmusik und Schlager. Aktuelle Nachrichten, Musikvideos, Künstlerporträts, Live-Shows und Interviews aus der Welt der volkstümlichen Musik. Jetzt einschalten!" />
|
<meta name="description" content="Aktuelle Schlager- und Volksmusik-News, Künstler-Interviews und Nachrichten aus der Musikszene. Jetzt Artikel und Videos auf FOLX TV entdecken." />
|
||||||
<meta name="keywords" content="Volksmusik, Schlager, Volksmusik TV, Schlager TV, Folx TV, FOLX Music Television, volkstümliche Musik, Musiksender, Alpenmusik, Volksmusik Nachrichten, Schlager News, Musikvideos, Live Shows, Volksmusik Künstler, Schlager Stars, Oberkrainer, Volksmusik Sender, Schlager Hits, volkstümliche Schlager" />
|
<meta name="keywords" content="Volksmusik, Schlager, Volksmusik TV, Schlager TV, Folx TV, FOLX Music Television, volkstümliche Musik, Musiksender, Alpenmusik, Volksmusik Nachrichten, Schlager News, Musikvideos, Live Shows, Volksmusik Künstler, Schlager Stars, Oberkrainer, Volksmusik Sender, Schlager Hits, volkstümliche Schlager" />
|
||||||
<meta property="og:title" content="Volksmusik & Schlager TV | FOLX Music Television" />
|
<meta property="og:title" content="Schlager & Volksmusik News | FOLX TV" />
|
||||||
<meta property="og:description" content="FOLX TV – Ihr Fernsehsender Nr. 1 für Volksmusik und Schlager. Aktuelle Nachrichten, Musikvideos, Künstlerporträts und Live-Shows." />
|
<meta property="og:description" content="Aktuelle Schlager- und Volksmusik-News, Künstler-Interviews und Nachrichten aus der Musikszene. Jetzt Artikel und Videos auf FOLX TV entdecken." />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:site_name" content="Folx Music Television" />
|
<meta property="og:site_name" content="Folx Music Television" />
|
||||||
<meta property="og:url" content="https://folx.tv/" />
|
<meta property="og:url" content="https://folx.tv/" />
|
||||||
@@ -19,12 +23,13 @@
|
|||||||
<meta property="og:image:alt" content="FOLX Network - Volksmusik & Schlager TV" />
|
<meta property="og:image:alt" content="FOLX Network - Volksmusik & Schlager TV" />
|
||||||
<meta property="og:locale" content="de_DE" />
|
<meta property="og:locale" content="de_DE" />
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="Volksmusik & Schlager TV | FOLX Music Television" />
|
<meta name="twitter:title" content="Schlager & Volksmusik News | FOLX TV" />
|
||||||
<meta name="twitter:description" content="FOLX TV – Ihr Fernsehsender Nr. 1 für Volksmusik und Schlager. Aktuelle Nachrichten, Musikvideos und Live-Shows." />
|
<meta name="twitter:description" content="Aktuelle Schlager- und Volksmusik-News, Künstler-Interviews und Nachrichten aus der Musikszene. Jetzt Artikel und Videos auf FOLX TV entdecken." />
|
||||||
<meta name="twitter:image" content="https://folx.tv/og-image.jpg" />
|
<meta name="twitter:image" content="https://folx.tv/og-image.jpg" />
|
||||||
<meta property="fb:pages" content="1428520781492675" />
|
<meta property="fb:pages" content="281200958640258" />
|
||||||
<meta name="facebook-domain-verification" content="b4y482htm54uybm563tfr1ucb1n46w" />
|
<meta name="facebook-domain-verification" content="b4y482htm54uybm563tfr1ucb1n46w" />
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="FOLX TV - Volksmusik & Schlager News" href="https://folx.tv/rss.xml" />
|
||||||
<link rel="canonical" href="https://folx.tv/" />
|
<link rel="canonical" href="https://folx.tv/" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||||
@@ -61,6 +66,14 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=Architects+Daughter&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Fira+Code:wght@300..700&family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Lora:ital,wght@0,400..700;1,400..700&family=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Outfit:wght@100..900&family=Oxanium:wght@200..800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100..900;1,100..900&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Architects+Daughter&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Fira+Code:wght@300..700&family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Lora:ital,wght@0,400..700;1,400..700&family=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Outfit:wght@100..900&family=Oxanium:wght@200..800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100..900;1,100..900&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<nav id="ssr-nav" aria-label="Hauptmenü" style="display:flex;gap:1.25rem;padding:.75rem 1rem;font:500 15px/1.4 system-ui,sans-serif">
|
||||||
|
<a href="/">Start</a>
|
||||||
|
<a href="/category/News">News</a>
|
||||||
|
<a href="/videos">Video</a>
|
||||||
|
<a href="/empfang-folx-tv">Empfang</a>
|
||||||
|
<a href="/ueber-uns">Über uns</a>
|
||||||
|
<a href="/kontakt">Kontakt</a>
|
||||||
|
</nav>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1 +1,9 @@
|
|||||||
google.com, pub-4465464714854276, DIRECT, f08c47fec0942fa0
|
google.com, pub-4465464714854276, DIRECT, f08c47fec0942fa0
|
||||||
|
google.com, pub-3452022011371122, DIRECT, f08c47fec0942fa0
|
||||||
|
google.com, pub-8414854470825940, DIRECT, f08c47fec0942fa0
|
||||||
|
google.com, pub-7766717304456825, DIRECT, f08c47fec0942fa0
|
||||||
|
google.com, pub-2506150943914125, DIRECT, f08c47fec0942fa0
|
||||||
|
google.com, pub-6968738577620513, DIRECT, f08c47fec0942fa0
|
||||||
|
google.com, pub-4727832945977825, DIRECT, f08c47fec0942fa0
|
||||||
|
google.com, pub-6598661725544892, DIRECT, f08c47fec0942fa0
|
||||||
|
subdomain=games.folx.tv
|
||||||
|
|||||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
|
||||||
|
<title>Download_on_the_App_Store_Badge_DE_RGB_blk_092917</title>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
|
||||||
|
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
|
||||||
|
</g>
|
||||||
|
<g id="_Group_" data-name="<Group>">
|
||||||
|
<g id="_Group_2" data-name="<Group>">
|
||||||
|
<g id="_Group_3" data-name="<Group>">
|
||||||
|
<path id="_Path_" data-name="<Path>" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
|
||||||
|
<path id="_Path_2" data-name="<Path>" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
|
||||||
|
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
|
||||||
|
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
|
||||||
|
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
|
||||||
|
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
|
||||||
|
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
|
||||||
|
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
|
||||||
|
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="_Group_4" data-name="<Group>">
|
||||||
|
<g>
|
||||||
|
<path d="M39.3926,14.69775H35.67092V8.731h.92676V13.8457H39.3926Z" style="fill: #fff"/>
|
||||||
|
<path d="M40.32912,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,40.32912,13.42432Zm2.89453-.38477v-.37646L42.124,12.7334c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,43.22365,13.03955Z" style="fill: #fff"/>
|
||||||
|
<path d="M45.27639,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074H48.6299v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C46,14.772,45.27639,13.87061,45.27639,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C46.64943,10.91846,46.19436,11.49707,46.19436,12.44434Z" style="fill: #fff"/>
|
||||||
|
<path d="M54.74709,13.48193a1.828,1.828,0,0,1-1.95117,1.30273,2.04531,2.04531,0,0,1-2.08008-2.32422A2.07685,2.07685,0,0,1,52.792,10.10791c1.25293,0,2.00879.856,2.00879,2.27V12.688H51.62111v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,51.62111,12.03076Z" style="fill: #fff"/>
|
||||||
|
<path d="M55.99416,10.19482h.85547v.71533H56.916a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
|
||||||
|
<path d="M63.51955,8.86328a.57572.57572,0,1,1,.5752.5415A.54735.54735,0,0,1,63.51955,8.86328Zm.13281,1.33154h.88477v4.50293h-.88477Z" style="fill: #fff"/>
|
||||||
|
<path d="M65.97121,10.19482h.85547v.72363h.06641a1.36385,1.36385,0,0,1,2.49316,0h.07031a1.46325,1.46325,0,0,1,1.36914-.81055,1.33821,1.33821,0,0,1,1.43848,1.48828v3.10156h-.88867V11.82813c0-.60791-.29-.90576-.873-.90576a.91167.91167,0,0,0-.9502.94287v2.83252h-.873V11.74121a.78468.78468,0,0,0-.86816-.81885.96854.96854,0,0,0-.95117,1.02148v2.75391h-.88867Z" style="fill: #fff"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 88 KiB |
@@ -8,7 +8,9 @@ import NotFound from "@/pages/not-found";
|
|||||||
import Home from "@/pages/home";
|
import Home from "@/pages/home";
|
||||||
import ArticlePage from "@/pages/article";
|
import ArticlePage from "@/pages/article";
|
||||||
import CategoryPage from "@/pages/category";
|
import CategoryPage from "@/pages/category";
|
||||||
|
import AktuellesPage from "@/pages/aktuelles";
|
||||||
import VideosPage from "@/pages/videos";
|
import VideosPage from "@/pages/videos";
|
||||||
|
import VideoDetailPage from "@/pages/video-detail";
|
||||||
import GalleryPageWrapper from "@/pages/gallery";
|
import GalleryPageWrapper from "@/pages/gallery";
|
||||||
import HoroscopePage from "@/pages/horoscope";
|
import HoroscopePage from "@/pages/horoscope";
|
||||||
import RecipesPage from "@/pages/recipes";
|
import RecipesPage from "@/pages/recipes";
|
||||||
@@ -20,9 +22,33 @@ import DatenschutzPage from "@/pages/datenschutz";
|
|||||||
import KontaktPage from "@/pages/kontakt";
|
import KontaktPage from "@/pages/kontakt";
|
||||||
import AdminGalleryPage from "@/pages/admin-gallery";
|
import AdminGalleryPage from "@/pages/admin-gallery";
|
||||||
import AdminPushPage from "@/pages/admin-push";
|
import AdminPushPage from "@/pages/admin-push";
|
||||||
import CookieConsent from "@/components/cookie-consent";
|
|
||||||
import MobileStickyAd from "@/components/mobile-sticky-ad";
|
import MobileStickyAd from "@/components/mobile-sticky-ad";
|
||||||
|
|
||||||
|
// Vse povezave, ki vodijo z nase domene, se odprejo v novem zavihku. Delegiran
|
||||||
|
// klik pokrije tudi povezave znotraj HTML vsebine clankov, kjer atributa
|
||||||
|
// target ne moremo nastaviti vnaprej.
|
||||||
|
function ExternalLinksNewTab() {
|
||||||
|
useEffect(() => {
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
|
const el = e.target as HTMLElement | null;
|
||||||
|
const a = el && el.closest ? (el.closest("a[href]") as HTMLAnchorElement | null) : null;
|
||||||
|
if (!a) return;
|
||||||
|
const href = a.getAttribute("href") || "";
|
||||||
|
if (!/^https?:\/\//i.test(href)) return;
|
||||||
|
let u: URL;
|
||||||
|
try { u = new URL(href, window.location.href); } catch { return; }
|
||||||
|
if (u.host === window.location.host) return;
|
||||||
|
if (a.target === "_blank") return;
|
||||||
|
e.preventDefault();
|
||||||
|
window.open(u.href, "_blank", "noopener,noreferrer");
|
||||||
|
};
|
||||||
|
document.addEventListener("click", onClick);
|
||||||
|
return () => document.removeEventListener("click", onClick);
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function ScrollToTop() {
|
function ScrollToTop() {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,12 +61,15 @@ function Router() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
|
<ExternalLinksNewTab />
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" component={Home} />
|
<Route path="/" component={Home} />
|
||||||
<Route path="/search" component={SearchPage} />
|
<Route path="/search" component={SearchPage} />
|
||||||
<Route path="/article/:slug" component={ArticlePage} />
|
<Route path="/article/:slug" component={ArticlePage} />
|
||||||
<Route path="/category/:category" component={CategoryPage} />
|
<Route path="/category/:category" component={CategoryPage} />
|
||||||
|
<Route path="/aktuelles" component={AktuellesPage} />
|
||||||
<Route path="/videos" component={VideosPage} />
|
<Route path="/videos" component={VideosPage} />
|
||||||
|
<Route path="/video/:slug" component={VideoDetailPage} />
|
||||||
<Route path="/gallery" component={GalleryPageWrapper} />
|
<Route path="/gallery" component={GalleryPageWrapper} />
|
||||||
<Route path="/horoskop" component={HoroscopePage} />
|
<Route path="/horoskop" component={HoroscopePage} />
|
||||||
<Route path="/horoskop/:sign" component={HoroscopePage} />
|
<Route path="/horoskop/:sign" component={HoroscopePage} />
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Switch, Route, useLocation } from "wouter";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { queryClient } from "./lib/queryClient";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import NotFound from "@/pages/not-found";
|
||||||
|
import Home from "@/pages/home";
|
||||||
|
import ArticlePage from "@/pages/article";
|
||||||
|
import CategoryPage from "@/pages/category";
|
||||||
|
import AktuellesPage from "@/pages/aktuelles";
|
||||||
|
import VideosPage from "@/pages/videos";
|
||||||
|
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 KontaktPage from "@/pages/kontakt";
|
||||||
|
import AdminGalleryPage from "@/pages/admin-gallery";
|
||||||
|
import AdminPushPage from "@/pages/admin-push";
|
||||||
|
import CookieConsent from "@/components/cookie-consent";
|
||||||
|
import MobileStickyAd from "@/components/mobile-sticky-ad";
|
||||||
|
|
||||||
|
function ScrollToTop() {
|
||||||
|
const [location] = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [location]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Router() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ScrollToTop />
|
||||||
|
<Switch>
|
||||||
|
<Route path="/" component={Home} />
|
||||||
|
<Route path="/search" component={SearchPage} />
|
||||||
|
<Route path="/article/:slug" component={ArticlePage} />
|
||||||
|
<Route path="/category/:category" component={CategoryPage} />
|
||||||
|
<Route path="/aktuelles" component={AktuellesPage} />
|
||||||
|
<Route path="/videos" component={VideosPage} />
|
||||||
|
<Route path="/gallery" component={GalleryPageWrapper} />
|
||||||
|
<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 path="/kontakt" component={KontaktPage} />
|
||||||
|
<Route path="/admin/gallery" component={AdminGalleryPage} />
|
||||||
|
<Route path="/admin/push" component={AdminPushPage} />
|
||||||
|
<Route component={NotFound} />
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Toaster />
|
||||||
|
<Router />
|
||||||
|
<MobileStickyAd />
|
||||||
|
</TooltipProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -36,29 +36,37 @@ export default function AdSense({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onAdStatus) {
|
if (!onAdStatus) return;
|
||||||
const checkAd = () => {
|
|
||||||
if (adRef.current) {
|
|
||||||
const ins = adRef.current;
|
const ins = adRef.current;
|
||||||
const status = ins.getAttribute("data-ad-status");
|
if (!ins) return;
|
||||||
if (status === "filled") {
|
|
||||||
onAdStatus(true);
|
// Google postavi data-ad-status na "filled", "unfilled" ali "unfill-optimized".
|
||||||
return;
|
// Ce statusa ni, se zanesemo na dejansko visino. Odlocimo se enkrat.
|
||||||
}
|
let decided = false;
|
||||||
if (status === "unfilled") {
|
const decide = (filled: boolean) => {
|
||||||
onAdStatus(false);
|
if (decided) return;
|
||||||
return;
|
decided = true;
|
||||||
}
|
obs.disconnect();
|
||||||
if (ins.clientHeight > 10) {
|
clearTimeout(timer);
|
||||||
onAdStatus(true);
|
onAdStatus(filled);
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const t1 = setTimeout(checkAd, 2000);
|
// Visina samega <ins> ni dokaz - nekatere enote imajo rezerviran prostor.
|
||||||
const t2 = setTimeout(checkAd, 5000);
|
// Napolnjen oglas ima notri okvir (iframe) z dejansko visino.
|
||||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
const hasFrame = () => {
|
||||||
}
|
const f = ins.querySelector("iframe");
|
||||||
|
return !!f && (f as HTMLIFrameElement).clientHeight > 10;
|
||||||
|
};
|
||||||
|
const check = () => {
|
||||||
|
const st = ins.getAttribute("data-ad-status") || "";
|
||||||
|
if (st === "filled") return decide(true);
|
||||||
|
if (st.startsWith("unfill")) return decide(false);
|
||||||
|
if (hasFrame()) return decide(true);
|
||||||
|
};
|
||||||
|
const obs = new MutationObserver(check);
|
||||||
|
obs.observe(ins, { attributes: true, childList: true, subtree: true, attributeFilter: ["data-ad-status", "style"] });
|
||||||
|
const timer = setTimeout(() => decide(hasFrame()), 3000);
|
||||||
|
check();
|
||||||
|
return () => { obs.disconnect(); clearTimeout(timer); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,6 +90,8 @@ let adSeedCounter = 1;
|
|||||||
|
|
||||||
export function ArticleCardAd() {
|
export function ArticleCardAd() {
|
||||||
const seed = useMemo(() => adSeedCounter++, []);
|
const seed = useMemo(() => adSeedCounter++, []);
|
||||||
|
const [filled, setFilled] = useState<boolean | null>(null);
|
||||||
|
if (filled === false) return null;
|
||||||
return (
|
return (
|
||||||
<ArtistPatternBg className="rounded-md border border-card-border h-full bg-card" seed={seed}>
|
<ArtistPatternBg className="rounded-md border border-card-border h-full bg-card" seed={seed}>
|
||||||
<AdSense
|
<AdSense
|
||||||
@@ -89,33 +99,75 @@ export function ArticleCardAd() {
|
|||||||
format="fluid"
|
format="fluid"
|
||||||
layoutKey="-6r+cy-10+8a-3"
|
layoutKey="-6r+cy-10+8a-3"
|
||||||
style={{ display: "block" }}
|
style={{ display: "block" }}
|
||||||
|
onAdStatus={setFilled}
|
||||||
/>
|
/>
|
||||||
</ArtistPatternBg>
|
</ArtistPatternBg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InArticleAd() {
|
export function TopBannerAd() {
|
||||||
|
const [filled, setFilled] = useState<boolean | null>(null);
|
||||||
|
if (filled === false) return null;
|
||||||
|
const shown = filled === true;
|
||||||
return (
|
return (
|
||||||
<div className="my-6 rounded-md overflow-hidden bg-card border border-card-border">
|
<div
|
||||||
<div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest">Anzeige</div>
|
className={shown
|
||||||
|
? "rounded-md overflow-hidden bg-card border border-card-border mb-4 flex flex-col items-center"
|
||||||
|
: "flex flex-col items-center"}
|
||||||
|
data-testid="ad-top-banner"
|
||||||
|
>
|
||||||
|
{shown && (
|
||||||
|
<div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest w-full">Anzeige</div>
|
||||||
|
)}
|
||||||
<AdSense
|
<AdSense
|
||||||
slot="4154017639"
|
slot="4154017639"
|
||||||
format="horizontal"
|
format="horizontal"
|
||||||
style={{ display: "block", width: "100%", height: "90px" }}
|
className="w-full"
|
||||||
|
style={{ display: "block", width: "100%", height: "90px", textAlign: "center", margin: "0 auto" }}
|
||||||
responsive={false}
|
responsive={false}
|
||||||
|
onAdStatus={setFilled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InArticleAd() {
|
||||||
|
// Prava AdSense In-article enota (fluid + data-ad-layout="in-article").
|
||||||
|
// Kadar oglasa ni, se okvir in rezerviran prostor ne izriseta.
|
||||||
|
const [filled, setFilled] = useState<boolean | null>(null);
|
||||||
|
if (filled === false) return null;
|
||||||
|
const shown = filled === true;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={shown ? "my-6 rounded-md overflow-hidden bg-card border border-card-border" : ""}
|
||||||
|
style={shown ? { minHeight: "260px" } : undefined}
|
||||||
|
>
|
||||||
|
{shown && (
|
||||||
|
<div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest">Anzeige</div>
|
||||||
|
)}
|
||||||
|
<AdSense
|
||||||
|
slot="7672178246"
|
||||||
|
format="fluid"
|
||||||
|
layout="in-article"
|
||||||
|
style={{ display: "block", textAlign: "center" }}
|
||||||
|
onAdStatus={setFilled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultiplexAd() {
|
export function MultiplexAd() {
|
||||||
|
const [filled, setFilled] = useState<boolean | null>(null);
|
||||||
|
if (filled === false) return null;
|
||||||
|
const shown = filled === true;
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-md border border-card-border overflow-hidden">
|
<div className={shown ? "bg-card rounded-md border border-card-border overflow-hidden" : ""}>
|
||||||
<AdSense
|
<AdSense
|
||||||
slot="4593001335"
|
slot="4593001335"
|
||||||
format="autorelaxed"
|
format="autorelaxed"
|
||||||
style={{ display: "block" }}
|
style={{ display: "block" }}
|
||||||
className="w-full min-h-[250px]"
|
className={shown ? "w-full min-h-[250px]" : "w-full"}
|
||||||
|
onAdStatus={setFilled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -163,3 +215,32 @@ export function SidebarAd() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vodoravni pas med vrsticami mreze na naslovnici. 4. 9. 2026: nazaj na staro
|
||||||
|
// enoto 4154017639 (horizontal), enako kot vrhnji pas pred 28. 8.
|
||||||
|
export function GridBannerAd() {
|
||||||
|
const [filled, setFilled] = useState<boolean | null>(null);
|
||||||
|
if (filled === false) return null;
|
||||||
|
const shown = filled === true;
|
||||||
|
return (
|
||||||
|
<div className="my-2 mx-auto w-full max-w-[728px]" data-testid="ad-grid-banner">
|
||||||
|
<div
|
||||||
|
className={shown
|
||||||
|
? "rounded-md overflow-hidden bg-card border border-card-border flex flex-col items-center"
|
||||||
|
: "flex flex-col items-center"}
|
||||||
|
>
|
||||||
|
{shown && (
|
||||||
|
<div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest w-full">Anzeige</div>
|
||||||
|
)}
|
||||||
|
<AdSense
|
||||||
|
slot="4154017639"
|
||||||
|
format="horizontal"
|
||||||
|
className="w-full"
|
||||||
|
style={{ display: "block", width: "100%", height: "90px", textAlign: "center", margin: "0 auto" }}
|
||||||
|
responsive={false}
|
||||||
|
onAdStatus={setFilled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { artistGroup } from "@/lib/artists";
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Globe, ExternalLink } from "lucide-react";
|
import { Globe, ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
@@ -9,17 +10,37 @@ interface NewsItem {
|
|||||||
pubDate: string;
|
pubDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VISIBLE_COUNT = 5;
|
// 31. 8. 2026: pet vrstic je pustilo pod seznamom prazen kos kartice.
|
||||||
|
const VISIBLE_COUNT = 7;
|
||||||
|
|
||||||
export function BreakingNewsWidget() {
|
// startOffset zamakne zacetek vrtenja: dva primerka na isti strani sicer
|
||||||
|
// kazeta identicnih pet vrstic.
|
||||||
|
export function BreakingNewsWidget({ startOffset = 0 }: { startOffset?: number } = {}) {
|
||||||
const { data: news, isLoading } = useQuery<NewsItem[]>({
|
const { data: news, isLoading } = useQuery<NewsItem[]>({
|
||||||
queryKey: ["/api/breaking-news"],
|
queryKey: ["/api/breaking-news"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(startOffset);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
const items = news || [];
|
// Isti izvajalec ne sme stati drug ob drugem: vsaj 5 mest razmika.
|
||||||
|
const items = (() => {
|
||||||
|
const rest = [...(news || [])];
|
||||||
|
const out: NewsItem[] = [];
|
||||||
|
const recent: string[] = [];
|
||||||
|
while (rest.length) {
|
||||||
|
let idx = rest.findIndex((n: any) => {
|
||||||
|
const k = artistGroup(n?.title || "");
|
||||||
|
return !k || !recent.includes(k);
|
||||||
|
});
|
||||||
|
if (idx < 0) idx = 0;
|
||||||
|
const n = rest.splice(idx, 1)[0];
|
||||||
|
out.push(n);
|
||||||
|
recent.push(artistGroup((n as any)?.title || ""));
|
||||||
|
if (recent.length >= 5) recent.shift();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})();
|
||||||
const total = items.length;
|
const total = items.length;
|
||||||
|
|
||||||
const advance = useCallback(() => {
|
const advance = useCallback(() => {
|
||||||
@@ -40,7 +61,7 @@ export function BreakingNewsWidget() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-card rounded-lg border border-card-border overflow-hidden h-full flex flex-col min-h-[320px]"
|
className="bg-card rounded-lg border border-card-border overflow-hidden h-full flex flex-col"
|
||||||
onMouseEnter={() => setPaused(true)}
|
onMouseEnter={() => setPaused(true)}
|
||||||
onMouseLeave={() => setPaused(false)}
|
onMouseLeave={() => setPaused(false)}
|
||||||
data-testid="widget-breaking-news"
|
data-testid="widget-breaking-news"
|
||||||
@@ -49,14 +70,14 @@ export function BreakingNewsWidget() {
|
|||||||
<Globe className="w-4 h-4 text-primary" />
|
<Globe className="w-4 h-4 text-primary" />
|
||||||
<h3 className="font-bold text-card-foreground text-sm">Aktuelle Nachrichten</h3>
|
<h3 className="font-bold text-card-foreground text-sm">Aktuelle Nachrichten</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 flex-1 flex flex-col">
|
<div className="p-3 flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
{visible.map((item, i) => (
|
{visible.map((item, i) => (
|
||||||
<a
|
<a
|
||||||
key={`${offset}-${i}`}
|
key={`${offset}-${i}`}
|
||||||
href={item.link}
|
href={item.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block group cursor-pointer flex-1 flex flex-col justify-center"
|
className="block group cursor-pointer flex-1 min-h-0 flex flex-col justify-center overflow-hidden"
|
||||||
data-testid={`link-breaking-news-${i}`}
|
data-testid={`link-breaking-news-${i}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
|
|
||||||
const CONSENT_KEY = "folx_cookie_consent";
|
|
||||||
|
|
||||||
export default function CookieConsent() {
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const consent = localStorage.getItem(CONSENT_KEY);
|
|
||||||
if (!consent) {
|
|
||||||
const timer = setTimeout(() => setVisible(true), 1500);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const accept = () => {
|
|
||||||
localStorage.setItem(CONSENT_KEY, "accepted");
|
|
||||||
setVisible(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!visible) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed bottom-0 left-0 right-0 z-[9999] bg-card border-t border-card-border shadow-2xl"
|
|
||||||
data-testid="cookie-consent-banner"
|
|
||||||
>
|
|
||||||
<div className="max-w-4xl mx-auto px-4 py-4 flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
|
||||||
<div className="flex-1 text-sm text-card-foreground">
|
|
||||||
Diese Website verwendet Cookies und Google-Dienste, um Ihnen das bestmögliche
|
|
||||||
Erlebnis zu bieten. Mit der Nutzung dieser Website stimmen Sie der Verwendung
|
|
||||||
von Cookies zu.
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={accept}
|
|
||||||
className="px-5 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-md hover:bg-primary/90 transition-colors"
|
|
||||||
data-testid="button-accept-cookies"
|
|
||||||
>
|
|
||||||
Akzeptieren
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={accept}
|
|
||||||
className="p-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
data-testid="button-close-cookies"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -19,12 +19,12 @@ export default function Footer({ narrow }: { narrow?: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<footer className="border-t border-border mt-10" data-testid="footer">
|
<footer className="border-t border-border mt-10" data-testid="footer">
|
||||||
<div className={`${narrow ? "max-w-4xl" : "max-w-7xl"} mx-auto px-4 sm:px-6 lg:px-8 py-8 relative z-20`}>
|
<div className={`${narrow ? "max-w-4xl" : "max-w-7xl"} mx-auto px-4 sm:px-6 lg:px-8 py-8 relative z-20`}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-card-foreground text-lg mb-3">Folx Music Television</h3>
|
<h3 className="font-bold text-card-foreground text-lg mb-3">Folx Music Television</h3>
|
||||||
<p className="text-muted-foreground text-sm leading-relaxed mb-4">
|
<p className="text-muted-foreground text-sm leading-relaxed mb-4">
|
||||||
FOLX TV – Ihr Fernsehsender Nr. 1 für Volksmusik und Schlager. Aktuelle Nachrichten,
|
FOLX TV – Aktuelle Schlager- und Volksmusik-News, Künstlerporträts und Interviews.
|
||||||
Musikvideos, Künstlerporträts, Live-Shows und Interviews aus der Welt der volkstümlichen Musik.
|
Entdecken Sie Nachrichten, Artikel und Videos aus der Musikszene sowie Informationen zu unserem TV-Angebot.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{SOCIAL_LINKS.map((s) => (
|
{SOCIAL_LINKS.map((s) => (
|
||||||
@@ -50,26 +50,16 @@ export default function Footer({ narrow }: { narrow?: boolean }) {
|
|||||||
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-news">News</span>
|
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-news">News</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://folx.news/" target="_blank" rel="noopener">
|
||||||
|
<span className="hover:text-primary transition-colors cursor-pointer">FOLX NEWS</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/videos">
|
<Link href="/videos">
|
||||||
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-video">Video</span>
|
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-video">Video</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<Link href="/gallery">
|
|
||||||
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-gallery">Fotogalerie</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link href="/horoskop">
|
|
||||||
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-horoskop">Horoskop</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link href="/rezepte">
|
|
||||||
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-rezepte">Rezepte</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<Link href="/kontakt">
|
<Link href="/kontakt">
|
||||||
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-kontakt-cat">Kontakt</span>
|
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-kontakt-cat">Kontakt</span>
|
||||||
@@ -96,6 +86,39 @@ export default function Footer({ narrow }: { narrow?: boolean }) {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-card-foreground mb-3">FOLX TV App</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">Live-TV kostenlos auf Smartphone, Tablet, Apple TV, Android TV und Fire TV.</p>
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<a
|
||||||
|
href="https://apps.apple.com/de/app/folx-tv/id6782307034"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="FOLX TV im App Store laden"
|
||||||
|
data-testid="link-footer-appstore"
|
||||||
|
>
|
||||||
|
<img src="/badges/app-store-de.svg" alt="Laden im App Store" width="120" height="40" className="h-10 w-auto" loading="lazy" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://play.google.com/store/apps/details?id=tv.folx.app"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="FOLX TV bei Google Play"
|
||||||
|
data-testid="link-footer-googleplay"
|
||||||
|
>
|
||||||
|
<img src="/badges/google-play-de.png" alt="Jetzt bei Google Play" width="135" height="40" className="h-10 w-auto" loading="lazy" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.amazon.de/dp/B0H62MFNDQ"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="FOLX TV bei Amazon Appstore für Fire TV"
|
||||||
|
data-testid="link-footer-amazon"
|
||||||
|
>
|
||||||
|
<img src="/badges/amazon-appstore-de.png" alt="Erhältlich bei Amazon Appstore" width="136" height="40" className="h-10 w-auto" loading="lazy" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border mt-8 pt-6 flex flex-col items-center gap-3">
|
<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">
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs">
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ const navItems = [
|
|||||||
{ label: "Start", href: "/" },
|
{ label: "Start", href: "/" },
|
||||||
{ label: "News", href: "/category/News" },
|
{ label: "News", href: "/category/News" },
|
||||||
{ label: "Video", href: "/videos" },
|
{ label: "Video", href: "/videos" },
|
||||||
{ label: "Galerie", href: "/gallery" },
|
{ label: "Empfang", href: "/empfang-folx-tv" },
|
||||||
{ label: "Horoskop", href: "/horoskop" },
|
|
||||||
{ label: "Rezepte", href: "/rezepte" },
|
|
||||||
{ label: "Kontakt", href: "/kontakt" },
|
{ label: "Kontakt", href: "/kontakt" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -45,7 +43,7 @@ export default function Header() {
|
|||||||
<div className="flex items-center justify-between gap-4 h-16">
|
<div className="flex items-center justify-between gap-4 h-16">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<div className="flex items-center gap-2 cursor-pointer" data-testid="link-logo">
|
<div className="flex items-center gap-2 cursor-pointer" data-testid="link-logo">
|
||||||
<img src={folxLogo} alt="FOLX TV – Volksmusik & Schlager Fernsehsender" className="h-32 w-auto object-contain py-1" />
|
<img src={folxLogo} alt="FOLX TV – Schlager & Volksmusik News" className="h-32 w-auto object-contain py-1" />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Star, Heart, Briefcase, TrendingUp, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Star, Heart, Briefcase, TrendingUp, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { SIGNS, ELEMENT_COLORS, getHoroscope, getRating } from "@/lib/horoscope-data";
|
import { SIGNS, ELEMENT_COLORS, getHoroscope, getRating } from "@/lib/horoscope-data";
|
||||||
|
|
||||||
|
interface AIHoroscope {
|
||||||
|
signIndex: number;
|
||||||
|
signName: string;
|
||||||
|
general: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function MiniStars({ count, max = 5 }: { count: number; max?: number }) {
|
function MiniStars({ count, max = 5 }: { count: number; max?: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-px">
|
<div className="flex gap-px">
|
||||||
@@ -18,6 +26,12 @@ export function HoroscopeWidget() {
|
|||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
|
const { data: aiHoroscopes = [] } = useQuery<AIHoroscope[]>({
|
||||||
|
queryKey: ["/api/horoscopes/today"],
|
||||||
|
staleTime: 1000 * 60 * 30,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
const next = useCallback(() => setIndex((i) => (i + 1) % SIGNS.length), []);
|
const next = useCallback(() => setIndex((i) => (i + 1) % SIGNS.length), []);
|
||||||
const prev = useCallback(() => setIndex((i) => (i - 1 + SIGNS.length) % SIGNS.length), []);
|
const prev = useCallback(() => setIndex((i) => (i - 1 + SIGNS.length) % SIGNS.length), []);
|
||||||
|
|
||||||
@@ -28,7 +42,12 @@ export function HoroscopeWidget() {
|
|||||||
}, [paused, next]);
|
}, [paused, next]);
|
||||||
|
|
||||||
const sign = SIGNS[index];
|
const sign = SIGNS[index];
|
||||||
const horoscope = getHoroscope(index);
|
// Prefer the real AI-generated horoscope for today; fall back to static text until loaded.
|
||||||
|
const aiForSign = aiHoroscopes.find((h) => h.signIndex === index);
|
||||||
|
const generalText = aiForSign?.general || getHoroscope(index).general;
|
||||||
|
const imageFor = (i: number) => aiHoroscopes.find((h) => h.signIndex === i)?.imageUrl || null;
|
||||||
|
const currentImage = imageFor(index);
|
||||||
|
const signPath = (i: number) => `/horoskop/${encodeURIComponent(SIGNS[i].name.toLowerCase())}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -36,7 +55,7 @@ export function HoroscopeWidget() {
|
|||||||
style={{ background: "linear-gradient(135deg, hsl(250 30% 14%), hsl(270 25% 10%))" }}
|
style={{ background: "linear-gradient(135deg, hsl(250 30% 14%), hsl(270 25% 10%))" }}
|
||||||
onMouseEnter={() => setPaused(true)}
|
onMouseEnter={() => setPaused(true)}
|
||||||
onMouseLeave={() => setPaused(false)}
|
onMouseLeave={() => setPaused(false)}
|
||||||
onClick={() => navigate("/horoskop")}
|
onClick={() => navigate(signPath(index))}
|
||||||
data-testid="widget-horoscope"
|
data-testid="widget-horoscope"
|
||||||
>
|
>
|
||||||
<div className="p-3 flex items-center justify-between border-b border-white/10 flex-shrink-0">
|
<div className="p-3 flex items-center justify-between border-b border-white/10 flex-shrink-0">
|
||||||
@@ -63,8 +82,18 @@ export function HoroscopeWidget() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<div className="p-4 flex items-center gap-3">
|
<div className="p-4 flex items-center gap-3">
|
||||||
<div className="w-14 h-14 rounded-xl bg-white/5 border border-white/10 flex items-center justify-center flex-shrink-0">
|
<div className="w-14 h-14 rounded-xl bg-white/5 border border-white/10 overflow-hidden flex items-center justify-center flex-shrink-0">
|
||||||
|
{currentImage ? (
|
||||||
|
<img
|
||||||
|
src={currentImage}
|
||||||
|
alt={`Tageshoroskop ${sign.name}`}
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
data-testid="img-widget-horoscope-sign"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<span className="text-3xl">{sign.symbol}</span>
|
<span className="text-3xl">{sign.symbol}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-bold text-lg text-white">{sign.name}</p>
|
<p className="font-bold text-lg text-white">{sign.name}</p>
|
||||||
@@ -88,19 +117,34 @@ export function HoroscopeWidget() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 pb-3 pt-2 flex-1 flex flex-col justify-between">
|
<div className="px-3 pb-3 pt-2 flex-1 flex flex-col justify-between">
|
||||||
<p className="text-xs text-white/60 leading-relaxed line-clamp-5">{horoscope.general}</p>
|
<p className="text-xs text-white/60 leading-relaxed line-clamp-5">{generalText}</p>
|
||||||
<p className="text-[10px] text-amber-400 mt-2 group-hover:underline">Mehr lesen</p>
|
<p className="text-[10px] text-amber-400 mt-2 group-hover:underline">Mehr lesen</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center gap-1 pb-3">
|
<div className="grid grid-cols-6 gap-1 px-2 pb-3">
|
||||||
{SIGNS.map((_, i) => (
|
{SIGNS.map((s, i) => {
|
||||||
|
const img = imageFor(i);
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={s.name}
|
||||||
onClick={(e) => { e.stopPropagation(); setIndex(i); }}
|
title={s.name}
|
||||||
className={`w-1.5 h-1.5 rounded-full transition-all ${i === index ? "bg-amber-400 w-3" : "bg-white/15 hover:bg-white/30"}`}
|
onClick={(e) => { e.stopPropagation(); navigate(signPath(i)); }}
|
||||||
data-testid={`button-horoscope-dot-${i}`}
|
onMouseEnter={() => setIndex(i)}
|
||||||
/>
|
className={`aspect-square rounded-md overflow-hidden border transition-all flex items-center justify-center ${
|
||||||
))}
|
i === index
|
||||||
|
? "border-amber-400 ring-1 ring-amber-400/40"
|
||||||
|
: "border-white/10 hover:border-white/40 opacity-70 hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
data-testid={`button-horoscope-thumb-${s.name.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
{img ? (
|
||||||
|
<img src={img} alt={s.name} loading="lazy" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm">{s.symbol}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import AdSense from "./adsense";
|
|
||||||
|
|
||||||
const COUNTER_KEY = "folx-interstitial-count";
|
|
||||||
const DELAY_MS = 3000;
|
|
||||||
|
|
||||||
export default function InterstitialAd({ forceShow }: { forceShow?: boolean } = {}) {
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (forceShow) {
|
|
||||||
const timer = setTimeout(() => setVisible(true), DELAY_MS);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = sessionStorage.getItem(COUNTER_KEY);
|
|
||||||
const count = raw ? parseInt(raw, 10) : 0;
|
|
||||||
const next = count + 1;
|
|
||||||
sessionStorage.setItem(COUNTER_KEY, next.toString());
|
|
||||||
|
|
||||||
const shouldShow = next % 2 === 1;
|
|
||||||
if (!shouldShow) return;
|
|
||||||
|
|
||||||
const timer = setTimeout(() => setVisible(true), DELAY_MS);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [forceShow]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
if (!visible) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 animate-in fade-in duration-300"
|
|
||||||
onClick={() => setVisible(false)}
|
|
||||||
data-testid="interstitial-ad-overlay"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative bg-card rounded-xl shadow-2xl w-[90vw] max-w-lg p-6"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
data-testid="interstitial-ad-container"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setVisible(false)}
|
|
||||||
className="absolute top-3 right-3 z-10 w-8 h-8 flex items-center justify-center rounded-full bg-muted hover:bg-muted/80 text-foreground transition-colors"
|
|
||||||
data-testid="button-interstitial-close"
|
|
||||||
aria-label="Schließen"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<div className="text-[10px] text-muted-foreground/40 text-center mb-2 uppercase tracking-widest">Anzeige</div>
|
|
||||||
<div className="min-h-[250px] flex items-center justify-center">
|
|
||||||
<AdSense
|
|
||||||
slot="4154017639"
|
|
||||||
format="rectangle"
|
|
||||||
responsive={false}
|
|
||||||
style={{ display: "inline-block", width: "300px", height: "250px" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { artistGroup } from "@/lib/artists";
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Newspaper, ExternalLink } from "lucide-react";
|
import { Newspaper, ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
@@ -9,17 +10,37 @@ interface NewsItem {
|
|||||||
pubDate: string;
|
pubDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VISIBLE_COUNT = 5;
|
// 31. 8. 2026: pet vrstic je pustilo pod seznamom prazen kos kartice.
|
||||||
|
const VISIBLE_COUNT = 7;
|
||||||
|
|
||||||
export function NewsWidget() {
|
// startOffset zamakne zacetek vrtenja: dva primerka na isti strani sicer
|
||||||
|
// kazeta identicnih pet vrstic.
|
||||||
|
export function NewsWidget({ startOffset = 0 }: { startOffset?: number } = {}) {
|
||||||
const { data: news, isLoading } = useQuery<NewsItem[]>({
|
const { data: news, isLoading } = useQuery<NewsItem[]>({
|
||||||
queryKey: ["/api/news-feed"],
|
queryKey: ["/api/news-feed"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(startOffset);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
const items = news || [];
|
// Isti izvajalec ne sme stati drug ob drugem: vsaj 5 mest razmika.
|
||||||
|
const items = (() => {
|
||||||
|
const rest = [...(news || [])];
|
||||||
|
const out: NewsItem[] = [];
|
||||||
|
const recent: string[] = [];
|
||||||
|
while (rest.length) {
|
||||||
|
let idx = rest.findIndex((n: any) => {
|
||||||
|
const k = artistGroup(n?.title || "");
|
||||||
|
return !k || !recent.includes(k);
|
||||||
|
});
|
||||||
|
if (idx < 0) idx = 0;
|
||||||
|
const n = rest.splice(idx, 1)[0];
|
||||||
|
out.push(n);
|
||||||
|
recent.push(artistGroup((n as any)?.title || ""));
|
||||||
|
if (recent.length >= 5) recent.shift();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})();
|
||||||
const total = items.length;
|
const total = items.length;
|
||||||
|
|
||||||
const advance = useCallback(() => {
|
const advance = useCallback(() => {
|
||||||
@@ -40,7 +61,7 @@ export function NewsWidget() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-card rounded-lg border border-card-border overflow-hidden h-full flex flex-col min-h-[320px]"
|
className="bg-card rounded-lg border border-card-border overflow-hidden h-full flex flex-col"
|
||||||
onMouseEnter={() => setPaused(true)}
|
onMouseEnter={() => setPaused(true)}
|
||||||
onMouseLeave={() => setPaused(false)}
|
onMouseLeave={() => setPaused(false)}
|
||||||
data-testid="widget-news"
|
data-testid="widget-news"
|
||||||
@@ -49,14 +70,14 @@ export function NewsWidget() {
|
|||||||
<Newspaper className="w-4 h-4 text-primary" />
|
<Newspaper className="w-4 h-4 text-primary" />
|
||||||
<h3 className="font-bold text-card-foreground text-sm">Musiknachrichten</h3>
|
<h3 className="font-bold text-card-foreground text-sm">Musiknachrichten</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 flex-1 flex flex-col">
|
<div className="p-3 flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
{visible.map((item, i) => (
|
{visible.map((item, i) => (
|
||||||
<a
|
<a
|
||||||
key={`${offset}-${i}`}
|
key={`${offset}-${i}`}
|
||||||
href={item.link}
|
href={item.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block group cursor-pointer flex-1 flex flex-col justify-center"
|
className="block group cursor-pointer flex-1 min-h-0 flex flex-col justify-center overflow-hidden"
|
||||||
data-testid={`link-news-${i}`}
|
data-testid={`link-news-${i}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import AdSense from "./adsense";
|
|
||||||
|
|
||||||
export default function ParallaxAd() {
|
|
||||||
return (
|
|
||||||
<div className="relative" data-testid="parallax-ad-wrapper">
|
|
||||||
<div className="sticky top-0 h-[350px] flex items-center justify-center bg-background z-0">
|
|
||||||
<div className="text-center w-full max-w-lg mx-auto">
|
|
||||||
<div className="text-[10px] text-muted-foreground/40 text-center mb-2 uppercase tracking-widest">Anzeige</div>
|
|
||||||
<AdSense
|
|
||||||
slot="2398082838"
|
|
||||||
format="rectangle"
|
|
||||||
style={{ display: "block", minHeight: "250px" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-[350px]" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { ChevronLeft, ChevronRight, X, Images, Maximize2 } from "lucide-react";
|
import { ChevronLeft, ChevronRight, X, Images, Maximize2 } from "lucide-react";
|
||||||
|
import { InArticleAd } from "./adsense";
|
||||||
|
|
||||||
export interface GalleryImage {
|
export interface GalleryImage {
|
||||||
folder: string;
|
folder: string;
|
||||||
@@ -340,13 +341,24 @@ export default function GalleryPage() {
|
|||||||
<p className="text-muted-foreground text-sm mb-4" data-testid="text-gallery-count">
|
<p className="text-muted-foreground text-sm mb-4" data-testid="text-gallery-count">
|
||||||
{Math.min(visibleCount, totalCount)} von {totalCount} Fotos
|
{Math.min(visibleCount, totalCount)} von {totalCount} Fotos
|
||||||
</p>
|
</p>
|
||||||
|
{(() => {
|
||||||
|
const AD_EVERY = 12;
|
||||||
|
const chunks: GalleryImage[][] = [];
|
||||||
|
for (let i = 0; i < visibleImages.length; i += AD_EVERY) {
|
||||||
|
chunks.push(visibleImages.slice(i, i + AD_EVERY));
|
||||||
|
}
|
||||||
|
return chunks.map((chunk, ci) => (
|
||||||
|
<div key={ci}>
|
||||||
|
{ci > 0 && <InArticleAd />}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
{visibleImages.map((img, i) => (
|
{chunk.map((img, j) => {
|
||||||
|
const globalIdx = ci * AD_EVERY + j;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${img.folder}-${img.fileName}`}
|
key={`${img.folder}-${img.fileName}`}
|
||||||
onClick={() => setLightboxIndex(i)}
|
onClick={() => setLightboxIndex(globalIdx)}
|
||||||
className="group relative rounded-lg overflow-hidden bg-card border border-card-border cursor-pointer flex flex-col"
|
className="group relative rounded-lg overflow-hidden bg-card border border-card-border cursor-pointer flex flex-col"
|
||||||
data-testid={`button-gallery-image-${i}`}
|
data-testid={`button-gallery-image-${globalIdx}`}
|
||||||
>
|
>
|
||||||
<div className="relative w-full aspect-[16/9]">
|
<div className="relative w-full aspect-[16/9]">
|
||||||
<LazyImage
|
<LazyImage
|
||||||
@@ -358,7 +370,7 @@ export default function GalleryPage() {
|
|||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||||
{img.artist && (
|
{img.artist && (
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 hidden md:block opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 hidden md:block opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<span className="text-white text-xs font-medium drop-shadow-lg" data-testid={`text-artist-desktop-${i}`}>
|
<span className="text-white text-xs font-medium drop-shadow-lg" data-testid={`text-artist-desktop-${globalIdx}`}>
|
||||||
{img.artist}
|
{img.artist}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,14 +381,18 @@ export default function GalleryPage() {
|
|||||||
</div>
|
</div>
|
||||||
{img.artist && (
|
{img.artist && (
|
||||||
<div className="md:hidden px-2 py-1.5 bg-card border-t border-card-border">
|
<div className="md:hidden px-2 py-1.5 bg-card border-t border-card-border">
|
||||||
<span className="text-[11px] text-card-foreground font-medium line-clamp-1" data-testid={`text-artist-mobile-${i}`}>
|
<span className="text-[11px] text-card-foreground font-medium line-clamp-1" data-testid={`text-artist-mobile-${globalIdx}`}>
|
||||||
{img.artist}
|
{img.artist}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div ref={sentinelRef} className="flex justify-center py-8" data-testid="gallery-load-more">
|
<div ref={sentinelRef} className="flex justify-center py-8" data-testid="gallery-load-more">
|
||||||
|
|||||||
@@ -1,33 +1,42 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { ChefHat, Clock, Users } from "lucide-react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ChefHat } from "lucide-react";
|
||||||
|
|
||||||
interface WidgetRecipe {
|
interface DailyRecipe {
|
||||||
title: string;
|
id: number;
|
||||||
image: string;
|
dateStr: string;
|
||||||
time: string;
|
name: string;
|
||||||
servings: string;
|
intro?: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WIDGET_RECIPES: WidgetRecipe[] = [
|
|
||||||
{ title: "Kaiserschmarrn", image: "/uploads/recipe-kaiserschmarrn.jpg", time: "25 Min.", servings: "2" },
|
|
||||||
{ title: "Wiener Schnitzel", image: "/uploads/recipe-wiener-schnitzel.jpg", time: "30 Min.", servings: "4" },
|
|
||||||
{ title: "Apfelstrudel", image: "/uploads/recipe-apfelstrudel.jpg", time: "60 Min.", servings: "6" },
|
|
||||||
{ title: "Schweinshaxe", image: "/uploads/recipe-schweinshaxe.jpg", time: "180 Min.", servings: "4" },
|
|
||||||
{ title: "Käsespätzle", image: "/uploads/recipe-kaesespaetzle.jpg", time: "45 Min.", servings: "4" },
|
|
||||||
{ title: "Sachertorte", image: "/uploads/recipe-sachertorte.jpg", time: "90 Min.", servings: "8" },
|
|
||||||
{ title: "Maultaschen", image: "/uploads/recipe-maultaschen.jpg", time: "90 Min.", servings: "4" },
|
|
||||||
{ title: "Schlutzkrapfen", image: "/uploads/recipe-schlutzkrapfen.jpg", time: "60 Min.", servings: "4" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function RecipeWidget() {
|
export function RecipeWidget() {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const dayIndex = Math.floor(Date.now() / 86400000) % WIDGET_RECIPES.length;
|
const [index, setIndex] = useState(0);
|
||||||
const recipe = WIDGET_RECIPES[dayIndex];
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
|
const { data = [] } = useQuery<DailyRecipe[]>({
|
||||||
|
queryKey: ["/api/recipes"],
|
||||||
|
staleTime: 1000 * 60 * 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pool = data.filter((r) => r.imageUrl).slice(0, 10);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || pool.length <= 1) return;
|
||||||
|
const t = setInterval(() => setIndex((i) => (i + 1) % pool.length), 8000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [paused, pool.length]);
|
||||||
|
|
||||||
|
if (pool.length === 0) return null;
|
||||||
|
const recipe = pool[index % pool.length];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/rezepte")}
|
onClick={() => navigate("/rezepte")}
|
||||||
|
onMouseEnter={() => setPaused(true)}
|
||||||
|
onMouseLeave={() => setPaused(false)}
|
||||||
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]"
|
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"
|
data-testid="widget-recipe"
|
||||||
>
|
>
|
||||||
@@ -37,20 +46,29 @@ export function RecipeWidget() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<img
|
<img
|
||||||
src={recipe.image}
|
src={recipe.imageUrl as string}
|
||||||
alt={recipe.title}
|
alt={recipe.name}
|
||||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent" />
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||||
<h4 className="text-white font-bold text-base">{recipe.title}</h4>
|
<h4 className="text-white font-bold text-base leading-snug line-clamp-2">{recipe.name}</h4>
|
||||||
<p className="text-white/60 text-xs mt-0.5 flex items-center gap-2">
|
{recipe.intro && (
|
||||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{recipe.time}</span>
|
<p className="text-white/60 text-xs mt-1 line-clamp-2">{recipe.intro}</p>
|
||||||
<span className="flex items-center gap-1"><Users className="w-3 h-3" />{recipe.servings}</span>
|
)}
|
||||||
</p>
|
|
||||||
<p className="text-[10px] text-primary mt-1 group-hover:underline">Alle Rezepte ansehen</p>
|
<p className="text-[10px] text-primary mt-1 group-hover:underline">Alle Rezepte ansehen</p>
|
||||||
</div>
|
</div>
|
||||||
|
{pool.length > 1 && (
|
||||||
|
<div className="absolute top-2 right-2 flex gap-1">
|
||||||
|
{pool.slice(0, 6).map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`h-1 rounded-full transition-all ${i === index % 6 ? "bg-primary w-3" : "bg-white/40 w-1"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import { HOME_TITLE, HOME_DESCRIPTION, INFO_SEO, publisherSchema } from "@shared/seo";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
interface OgMeta {
|
interface OgMeta {
|
||||||
|
siteName?: string;
|
||||||
ogTitle?: string;
|
ogTitle?: string;
|
||||||
ogDescription?: string;
|
ogDescription?: string;
|
||||||
ogImage?: string;
|
ogImage?: string;
|
||||||
ogType?: string;
|
ogType?: string;
|
||||||
ogUrl?: string;
|
ogUrl?: string;
|
||||||
articlePublishedTime?: string;
|
articlePublishedTime?: string;
|
||||||
|
articleModifiedTime?: string;
|
||||||
articleAuthor?: string;
|
articleAuthor?: string;
|
||||||
articleSection?: string;
|
articleSection?: string;
|
||||||
}
|
}
|
||||||
@@ -31,10 +34,10 @@ function removeMetaTag(selector: string) {
|
|||||||
if (el) el.remove();
|
if (el) el.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TITLE = "Volksmusik & Schlager TV | FOLX Music Television – Nachrichten, Videos & mehr";
|
const DEFAULT_TITLE = `${HOME_TITLE} | FOLX TV`;
|
||||||
const DEFAULT_DESC = "FOLX TV – Ihr Fernsehsender Nr. 1 für Volksmusik und Schlager. Aktuelle Nachrichten, Musikvideos, Künstlerporträts, Live-Shows und Interviews aus der Welt der volkstümlichen Musik. Jetzt einschalten!";
|
const DEFAULT_DESC = HOME_DESCRIPTION;
|
||||||
const DEFAULT_OG_TITLE = "Volksmusik & Schlager TV | FOLX Music Television";
|
const DEFAULT_OG_TITLE = `${HOME_TITLE} | FOLX TV`;
|
||||||
const DEFAULT_OG_DESC = "FOLX TV – Ihr Fernsehsender Nr. 1 für Volksmusik und Schlager. Aktuelle Nachrichten, Musikvideos, Künstlerporträts und Live-Shows.";
|
const DEFAULT_OG_DESC = HOME_DESCRIPTION;
|
||||||
const DEFAULT_OG_IMAGE = "https://folx.tv/og-image.jpg";
|
const DEFAULT_OG_IMAGE = "https://folx.tv/og-image.jpg";
|
||||||
const CANONICAL_BASE = "https://folx.tv";
|
const CANONICAL_BASE = "https://folx.tv";
|
||||||
|
|
||||||
@@ -52,7 +55,7 @@ function setCanonical(href: string) {
|
|||||||
|
|
||||||
export function usePageMeta(title: string, description?: string, og?: OgMeta) {
|
export function usePageMeta(title: string, description?: string, og?: OgMeta) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const suffix = " | Folx Music Television";
|
const suffix = ` | ${og?.siteName || "Folx Music Television"}`;
|
||||||
document.title = title + suffix;
|
document.title = title + suffix;
|
||||||
if (description) {
|
if (description) {
|
||||||
setMetaTag('meta[name="description"]', description);
|
setMetaTag('meta[name="description"]', description);
|
||||||
@@ -61,8 +64,24 @@ export function usePageMeta(title: string, description?: string, og?: OgMeta) {
|
|||||||
const canonicalPath = og?.ogUrl || `${CANONICAL_BASE}${window.location.pathname}`;
|
const canonicalPath = og?.ogUrl || `${CANONICAL_BASE}${window.location.pathname}`;
|
||||||
const canonicalUrl = canonicalPath.startsWith("http") ? canonicalPath : `${CANONICAL_BASE}${canonicalPath}`;
|
const canonicalUrl = canonicalPath.startsWith("http") ? canonicalPath : `${CANONICAL_BASE}${canonicalPath}`;
|
||||||
setCanonical(canonicalUrl);
|
setCanonical(canonicalUrl);
|
||||||
|
const info = INFO_SEO[window.location.pathname.replace(/\/+$/, "")];
|
||||||
|
document.getElementById("info-jsonld")?.remove();
|
||||||
|
if (info) {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.id = "info-jsonld";
|
||||||
|
script.type = "application/ld+json";
|
||||||
|
script.textContent = JSON.stringify({ "@context": "https://schema.org", "@type": info.type,
|
||||||
|
"@id": `${canonicalUrl}#webpage`, url: canonicalUrl, name: title + suffix,
|
||||||
|
description: info.description, inLanguage: "de-DE", publisher: publisherSchema() });
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
setMetaTag('meta[property="og:url"]', canonicalUrl);
|
setMetaTag('meta[property="og:url"]', canonicalUrl);
|
||||||
|
|
||||||
|
setMetaTag('meta[property="og:title"]', og?.ogTitle || title + suffix);
|
||||||
|
setMetaTag('meta[property="og:description"]', og?.ogDescription || description || DEFAULT_DESC);
|
||||||
|
setMetaTag('meta[property="og:type"]', og?.ogType || "website");
|
||||||
|
setMetaTag('meta[name="twitter:title"]', og?.ogTitle || title + suffix);
|
||||||
|
setMetaTag('meta[name="twitter:description"]', og?.ogDescription || description || DEFAULT_DESC);
|
||||||
if (og) {
|
if (og) {
|
||||||
if (og.ogTitle) setMetaTag('meta[property="og:title"]', og.ogTitle);
|
if (og.ogTitle) setMetaTag('meta[property="og:title"]', og.ogTitle);
|
||||||
if (og.ogDescription) setMetaTag('meta[property="og:description"]', og.ogDescription);
|
if (og.ogDescription) setMetaTag('meta[property="og:description"]', og.ogDescription);
|
||||||
@@ -77,7 +96,7 @@ export function usePageMeta(title: string, description?: string, og?: OgMeta) {
|
|||||||
|
|
||||||
if (og.articlePublishedTime) {
|
if (og.articlePublishedTime) {
|
||||||
setMetaTag('meta[property="article:published_time"]', og.articlePublishedTime);
|
setMetaTag('meta[property="article:published_time"]', og.articlePublishedTime);
|
||||||
setMetaTag('meta[property="article:modified_time"]', og.articlePublishedTime);
|
setMetaTag('meta[property="article:modified_time"]', og.articleModifiedTime || og.articlePublishedTime);
|
||||||
}
|
}
|
||||||
if (og.articleAuthor) {
|
if (og.articleAuthor) {
|
||||||
setMetaTag('meta[property="article:author"]', og.articleAuthor);
|
setMetaTag('meta[property="article:author"]', og.articleAuthor);
|
||||||
@@ -87,6 +106,7 @@ export function usePageMeta(title: string, description?: string, og?: OgMeta) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
|
document.getElementById("info-jsonld")?.remove();
|
||||||
document.title = DEFAULT_TITLE;
|
document.title = DEFAULT_TITLE;
|
||||||
setMetaTag('meta[name="description"]', DEFAULT_DESC);
|
setMetaTag('meta[name="description"]', DEFAULT_DESC);
|
||||||
setMetaTag('meta[property="og:title"]', DEFAULT_OG_TITLE);
|
setMetaTag('meta[property="og:title"]', DEFAULT_OG_TITLE);
|
||||||
@@ -104,5 +124,5 @@ export function usePageMeta(title: string, description?: string, og?: OgMeta) {
|
|||||||
removeMetaTag('meta[property="article:author"]');
|
removeMetaTag('meta[property="article:author"]');
|
||||||
removeMetaTag('meta[property="article:section"]');
|
removeMetaTag('meta[property="article:section"]');
|
||||||
};
|
};
|
||||||
}, [title, description, og?.ogTitle, og?.ogDescription, og?.ogImage, og?.ogType, og?.ogUrl, og?.articlePublishedTime, og?.articleAuthor, og?.articleSection]);
|
}, [title, description, og?.siteName, og?.ogTitle, og?.ogDescription, og?.ogImage, og?.ogType, og?.ogUrl, og?.articlePublishedTime, og?.articleModifiedTime, og?.articleAuthor, og?.articleSection]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,3 +303,17 @@ div[class^="fc-"] {
|
|||||||
inset: -1px;
|
inset: -1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ad-container {
|
||||||
|
contain: layout style;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adsbygoogle {
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sirina stranskih stolpcev: 160x600 od 1536 px, 300x600 od 1700 px naprej.
|
||||||
|
Vrednost bere PageSideAds za izracun odmika od sredine. */
|
||||||
|
.page-side-rails { --rail-w: 160px; }
|
||||||
|
/* Enota v pasu je fiksna 160x600, zato sirsi pas ni potreben. */
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Samodejno iz /opt/folx-shared/artists.json (29. 8. 2026). Ne urejaj rocno.
|
||||||
|
export const ARTISTS: string[] = [
|
||||||
|
"Zillertaler Haderlumpen",
|
||||||
|
"Die Jungen Klostertaler",
|
||||||
|
"Die Jungen Zillertaler",
|
||||||
|
"Anna-Carina Woitschack",
|
||||||
|
"Engelbert Humperdinck",
|
||||||
|
"Kastelruther Spatzen",
|
||||||
|
"Die 4 von der Koralm",
|
||||||
|
"Florian Silbereisen",
|
||||||
|
"Oesch's die Dritten",
|
||||||
|
"Melissa Naschenweng",
|
||||||
|
"Die Schlagerpiloten",
|
||||||
|
"Gioiello Sabbatelli",
|
||||||
|
"Maximilian Sedlmayr",
|
||||||
|
"Wildecker Herzbuben",
|
||||||
|
"Michelle\" Schlager",
|
||||||
|
"Marianne Rosenberg",
|
||||||
|
"Esteriore Brothers",
|
||||||
|
"Alexandra Hofmann",
|
||||||
|
"Fantasy\" Schlager",
|
||||||
|
"Alpen Oberkrainer",
|
||||||
|
"Sigrid und Marina",
|
||||||
|
"Giovanni Zarrella",
|
||||||
|
"Paolo Santa Maria",
|
||||||
|
"Vanessa Siziliano",
|
||||||
|
"Hansi Hinterseer",
|
||||||
|
"Andreas Gabalier",
|
||||||
|
"Daniela Alfinito",
|
||||||
|
"Olaf der Flipper",
|
||||||
|
"Nockalm Quintett",
|
||||||
|
"Damiano Maiolini",
|
||||||
|
"Stefanie Hertel",
|
||||||
|
"Die Draufgänger",
|
||||||
|
"Die Zillertaler",
|
||||||
|
"Ybbstal Express",
|
||||||
|
"Die Pagger Buam",
|
||||||
|
"Die Grubertaler",
|
||||||
|
"Michael Wendler",
|
||||||
|
"Die Cappuccinos",
|
||||||
|
"Alexander Fuchs",
|
||||||
|
"Gerrit Grünberg",
|
||||||
|
"Helene Fischer",
|
||||||
|
"Wolfgang Petry",
|
||||||
|
"Christin Stark",
|
||||||
|
"Oswald Sattler",
|
||||||
|
"Lucas Cordalis",
|
||||||
|
"Die Stoakogler",
|
||||||
|
"Die Mayrhofner",
|
||||||
|
"Francine Jordi",
|
||||||
|
"Bernhard Brink",
|
||||||
|
"Alexander Rier",
|
||||||
|
"Nino de Angelo",
|
||||||
|
"Costa Cordalis",
|
||||||
|
"Matthias Reim",
|
||||||
|
"Beatrice Egli",
|
||||||
|
"Roland Kaiser",
|
||||||
|
"Anita Hofmann",
|
||||||
|
"Vincent Gross",
|
||||||
|
"Ramon Roselly",
|
||||||
|
"Sonia Liebing",
|
||||||
|
"Melanie Payer",
|
||||||
|
"Joelina Drews",
|
||||||
|
"Ursprung Buam",
|
||||||
|
"Steirergreens",
|
||||||
|
"Zellberg Buam",
|
||||||
|
"Melanie Oesch",
|
||||||
|
"Monika Martin",
|
||||||
|
"Eric Philippi",
|
||||||
|
"Rosanna Rocci",
|
||||||
|
"Andrea Kiewel",
|
||||||
|
"Pietro Basile",
|
||||||
|
"Nevio Passero",
|
||||||
|
"Laterndl Trio",
|
||||||
|
"Stefan Mross",
|
||||||
|
"Semino Rossi",
|
||||||
|
"Norbert Rier",
|
||||||
|
"Angela Wiedl",
|
||||||
|
"Peter Maffay",
|
||||||
|
"Saso Avsenik",
|
||||||
|
"Jürgen Drews",
|
||||||
|
"Die Flippers",
|
||||||
|
"Klostertaler",
|
||||||
|
"Lechner Buam",
|
||||||
|
"Die Paldauer",
|
||||||
|
"Marc Pircher",
|
||||||
|
"Hansi Berger",
|
||||||
|
"Ella Endlich",
|
||||||
|
"Oliver Haidt",
|
||||||
|
"Ralph Siegel",
|
||||||
|
"Johnny Logan",
|
||||||
|
"Bonnie Tyler",
|
||||||
|
"Sašo Avsenik",
|
||||||
|
"Mark Medlock",
|
||||||
|
"Laura Müller",
|
||||||
|
"Andrea Berg",
|
||||||
|
"Achim Petry",
|
||||||
|
"Maite Kelly",
|
||||||
|
"Vanessa Mai",
|
||||||
|
"Ross Antony",
|
||||||
|
"Ireen Sheer",
|
||||||
|
"Kerstin Ott",
|
||||||
|
"Die Edlseer",
|
||||||
|
"Die Fetzign",
|
||||||
|
"Die Ladiner",
|
||||||
|
"Jörg Bausch",
|
||||||
|
"Stefan Roos",
|
||||||
|
"Julian Reim",
|
||||||
|
"Linda Hesse",
|
||||||
|
"Anna Heesch",
|
||||||
|
"Rod Stewart",
|
||||||
|
"Luca Stangl",
|
||||||
|
"Hein Simons",
|
||||||
|
"Grubertaler",
|
||||||
|
"Marie Reim",
|
||||||
|
"Die Amigos",
|
||||||
|
"Ben Zucker",
|
||||||
|
"Luca Hänni",
|
||||||
|
"Die Lauser",
|
||||||
|
"Ralph Sieg",
|
||||||
|
"Anni Perka",
|
||||||
|
"Andy Borg",
|
||||||
|
"Stereoact",
|
||||||
|
"Calimeros",
|
||||||
|
"Feuerherz",
|
||||||
|
"Mary Roos",
|
||||||
|
"Voxxclub",
|
||||||
|
"Die Seer",
|
||||||
|
"Michelle",
|
||||||
|
"voXXclub",
|
||||||
|
"DJ Ötzi",
|
||||||
|
"Ragazzi",
|
||||||
|
"Fantasy",
|
||||||
|
"Nik P.",
|
||||||
|
"Nockis",
|
||||||
|
"Nicole",
|
||||||
|
"Amigos",
|
||||||
|
"Andita",
|
||||||
|
"Belsy",
|
||||||
|
"Harpo",
|
||||||
|
];
|
||||||
|
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s.toLowerCase().replace(/[-\u2010-\u2015\u00ad]/g, " ").replace(/\s+/g, " ");
|
||||||
|
|
||||||
|
const NORMED = ARTISTS.map((a) => [a, norm(a)] as const);
|
||||||
|
|
||||||
|
/** Ime izvajalca iz naslova; prazen niz, ce ga ni na seznamu. */
|
||||||
|
export function artistKey(title: string): string {
|
||||||
|
const t = norm(title || "");
|
||||||
|
let best = "";
|
||||||
|
let bestPos = Infinity;
|
||||||
|
for (const [orig, n] of NORMED) {
|
||||||
|
const p = t.indexOf(n);
|
||||||
|
if (p >= 0 && (p < bestPos || (p === bestPos && orig.length > best.length))) {
|
||||||
|
best = orig;
|
||||||
|
bestPos = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ? norm(best) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rocne povezave: druzine in zasedbe, ki spadajo skupaj. */
|
||||||
|
const SKUPINE: Record<string, string> = {
|
||||||
|
"alexander rier": "kastelruther spatzen",
|
||||||
|
"norbert rier": "kastelruther spatzen",
|
||||||
|
"kastelruther spatzen": "kastelruther spatzen",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Druga beseda, ki ni priimek in zato ne sme delati skupine. */
|
||||||
|
const NI_PRIIMEK = new Set([
|
||||||
|
"schlager", "music", "band", "express", "quintett", "brothers",
|
||||||
|
"oberkrainer", "buam", "boys", "girls", "sisters",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skupina izvajalca za razmik na naslovnici. Alexander in Norbert Rier sta ista
|
||||||
|
* skupina (Kastelruther Spatzen), prav tako Costa in Lucas Cordalis ali sestri
|
||||||
|
* Hofmann - dva taka clanka drug ob drugem bralcu izgledata kot podvojitev.
|
||||||
|
*/
|
||||||
|
export function artistGroup(title: string): string {
|
||||||
|
const k = artistKey(title);
|
||||||
|
if (!k) return "";
|
||||||
|
if (SKUPINE[k]) return SKUPINE[k];
|
||||||
|
if (k.includes('"')) return k;
|
||||||
|
const d = k.split(" ");
|
||||||
|
if (d.length === 2 && !/^(die|das|der|dj)$/.test(d[0]) && d[1].length >= 4 && !NI_PRIIMEK.has(d[1])) {
|
||||||
|
return d[1];
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export type FocalPoint = { x: number; y: number };
|
||||||
|
export type FocalMap = Record<string, FocalPoint>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fokusne tocke naslovnic (obraz na sliki), ki jih izracuna streznik
|
||||||
|
* (server/focal-point.ts) in postreze na /api/focal-points.
|
||||||
|
* Uporabi POVSOD, kjer se naslovnica clanka prikazuje z object-cover,
|
||||||
|
* sicer brskalnik obreze na sredino in odreze glave.
|
||||||
|
*/
|
||||||
|
export function useFocalPoints() {
|
||||||
|
const { data } = useQuery<FocalMap>({
|
||||||
|
queryKey: ["/api/focal-points"],
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrne vrednost za CSS object-position. */
|
||||||
|
export function objectPositionFor(coverImage: string | null | undefined, focalPoints?: FocalMap): string {
|
||||||
|
const FALLBACK = "center 25%";
|
||||||
|
if (!coverImage || !focalPoints) return FALLBACK;
|
||||||
|
const fp =
|
||||||
|
focalPoints[coverImage] ||
|
||||||
|
focalPoints[coverImage.replace(/^https?:\/\/[^/]+/, "")] ||
|
||||||
|
focalPoints["https://folx.tv" + coverImage];
|
||||||
|
if (!fp) return FALLBACK;
|
||||||
|
return `${fp.x}% ${fp.y}%`;
|
||||||
|
}
|
||||||
@@ -2,4 +2,5 @@ import { createRoot } from "react-dom/client";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
document.getElementById("ssr-nav")?.remove();
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
createRoot(document.getElementById("root")!).render(<App />);
|
||||||
|
|||||||
@@ -1,16 +1,56 @@
|
|||||||
import { Tv } from "lucide-react";
|
import { INFO_SEO } from "@shared/seo";
|
||||||
|
import { Tv, Youtube, Facebook, Instagram, PlayCircle, Radio } from "lucide-react";
|
||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||||
import { PageSideAds } from "@/components/adsense";
|
import { PageSideAds, TopBannerAd } from "@/components/adsense";
|
||||||
|
|
||||||
|
const KANAELE = [
|
||||||
|
{
|
||||||
|
name: "FOLX TV Livestream",
|
||||||
|
detail: "Rund um die Uhr im Browser",
|
||||||
|
href: "https://folx.live",
|
||||||
|
icon: Radio,
|
||||||
|
testid: "link-livestream",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "FOLX Plus",
|
||||||
|
detail: "Der 24/7 Live-Kanal auf YouTube",
|
||||||
|
href: "https://www.youtube.com/watch?v=Dib1h-IkR9s",
|
||||||
|
icon: PlayCircle,
|
||||||
|
testid: "link-folx-plus",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "YouTube",
|
||||||
|
detail: "@folxmtv – Interviews, Musik News und ganze Sendungen",
|
||||||
|
href: "https://www.youtube.com/@folxmtv",
|
||||||
|
icon: Youtube,
|
||||||
|
testid: "link-youtube",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Facebook",
|
||||||
|
detail: "facebook.com/folxtv",
|
||||||
|
href: "https://www.facebook.com/folxtv",
|
||||||
|
icon: Facebook,
|
||||||
|
testid: "link-facebook",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Instagram",
|
||||||
|
detail: "@folxtv",
|
||||||
|
href: "https://www.instagram.com/folxtv",
|
||||||
|
icon: Instagram,
|
||||||
|
testid: "link-instagram",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
usePageMeta("Über FOLX TV - Volksmusik & Schlager Fernsehsender", "Alles über FOLX TV – Ihren Volksmusik & Schlager Fernsehsender seit 2013. Programm, Geschichte und das Team hinter dem Volksmusik-Sender.");
|
usePageMeta(INFO_SEO["/ueber-uns"].title, INFO_SEO["/ueber-uns"].description, { siteName: "FOLX TV" });
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<PageSideAds />
|
<PageSideAds />
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<TopBannerAd />
|
||||||
<div className="flex items-center gap-3 mb-8">
|
<div className="flex items-center gap-3 mb-8">
|
||||||
<Tv className="w-7 h-7 text-primary" />
|
<Tv className="w-7 h-7 text-primary" />
|
||||||
<h1 className="text-3xl font-bold text-foreground" data-testid="text-about-title">
|
<h1 className="text-3xl font-bold text-foreground" data-testid="text-about-title">
|
||||||
@@ -24,29 +64,54 @@ export default function AboutPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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.
|
Im Mittelpunkt stehen bei uns immer die <span className="text-foreground font-medium">Künstlerinnen und Künstler</span>. Wir holen sie vor die Kamera, hören ihnen zu und lassen sie ihre Geschichten selbst erzählen – die grossen Namen der Szene genauso wie die jungen Talente, die gerade ihre erste Single veröffentlichen. Auftritt, Interview, Blick hinter die Kulissen: Wer bei FOLX TV zu Gast ist, bekommt Zeit und eine Bühne.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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.
|
Unser Auftrag ist einfach: Die Menschen sollen eine schöne Zeit haben – beim <span className="text-foreground font-medium">Zuschauen</span>, beim <span className="text-foreground font-medium">Zuhören</span> und beim <span className="text-foreground font-medium">Lesen</span>. Deshalb gibt es bei uns nicht nur das Fernsehprogramm, sondern auch täglich neue Musik News, Interviews und Videos auf folx.tv.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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.
|
Zu den beliebtesten Eigenproduktionen zählt der <span className="text-foreground font-medium">FOLX Stadl</span> mit Moderator Hansi Berger, der regelmässig Stars der volkstümlichen Musik und des Schlagers auf die Bühne bringt – mit Live-Auftritten, Gesprächen und einer guten Portion Humor.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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.
|
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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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.
|
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. 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>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<section className="mt-10" data-testid="section-kanaele">
|
||||||
|
<h2 className="text-xl font-bold text-foreground mb-2">Wo Sie uns finden</h2>
|
||||||
|
<p className="text-muted-foreground text-sm mb-5">
|
||||||
|
Ob im Fernsehen, im Livestream oder unterwegs am Handy – FOLX TV ist überall dort, wo Sie sind.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{KANAELE.map((k) => (
|
||||||
|
<a
|
||||||
|
key={k.name}
|
||||||
|
href={k.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-card-border bg-card px-4 py-3.5 transition-colors hover:border-primary/60"
|
||||||
|
data-testid={k.testid}
|
||||||
|
>
|
||||||
|
<k.icon className="w-5 h-5 text-primary flex-shrink-0" />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-semibold text-card-foreground">{k.name}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground truncate">{k.detail}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="text-primary font-semibold mt-8 text-[15px]">
|
||||||
|
Schalten Sie ein und erleben Sie die Welt der Volksmusik und des Schlagers – nur bei FOLX TV.
|
||||||
|
</p>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, useLocation, useSearch } from "wouter";
|
||||||
|
import { type Article } from "@shared/schema";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||||
|
import { Eye, 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 { PageSideAds, TopBannerAd, MultiplexAd, ArticleCardAd } from "@/components/adsense";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
import { useFocalPoints, objectPositionFor } from "@/lib/focal";
|
||||||
|
|
||||||
|
interface Res {
|
||||||
|
articles: Article[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
rubriken: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW1 = ["Politik", "Promis", "Sport", "Musik & Film"];
|
||||||
|
const ROW2 = ["Wetter", "Finanzen"];
|
||||||
|
|
||||||
|
export default function AktuellesPage() {
|
||||||
|
const focalPoints = useFocalPoints();
|
||||||
|
const search = useSearch();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const page = Math.max(1, parseInt(params.get("page") || "1"));
|
||||||
|
const rubrik = params.get("rubrik") || "";
|
||||||
|
|
||||||
|
usePageMeta(
|
||||||
|
`Aktuelles${rubrik ? ` – ${rubrik}` : ""}${page > 1 ? ` – Seite ${page}` : ""} | FOLX TV`,
|
||||||
|
"Aktuelle Nachrichten aus Politik, Sport, Promis sowie Musik und Film – täglich neu bei FOLX TV."
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<Res>({
|
||||||
|
queryKey: ["/api/articles/aktuelles", rubrik, page],
|
||||||
|
queryFn: async () => {
|
||||||
|
const q = new URLSearchParams({ page: String(page), limit: "12" });
|
||||||
|
if (rubrik) q.set("rubrik", rubrik);
|
||||||
|
const r = await fetch(`/api/articles/aktuelles?${q}`);
|
||||||
|
if (!r.ok) throw new Error("laden fehlgeschlagen");
|
||||||
|
return r.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
}, [page, rubrik]);
|
||||||
|
|
||||||
|
const go = (r: string, p = 1) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (r) q.set("rubrik", r);
|
||||||
|
if (p > 1) q.set("page", String(p));
|
||||||
|
setLocation(`/aktuelles${q.toString() ? `?${q}` : ""}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = data?.articles || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
<PageSideAds />
|
||||||
|
|
||||||
|
{/* Vrstici rubrik kot na ONE - brez horoskopa in brez Auf diesen Tag */}
|
||||||
|
<nav className="border-t border-card-border bg-card" data-testid="nav-aktuelles-rubriken">
|
||||||
|
<div className="max-w-7xl mx-auto grid grid-cols-2 sm:grid-cols-4">
|
||||||
|
{ROW1.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
onClick={() => go(r)}
|
||||||
|
className={`px-3 py-3 text-[13px] sm:text-sm font-bold uppercase tracking-wide text-center border-r border-b border-card-border transition-colors ${
|
||||||
|
rubrik === r ? "bg-primary text-primary-foreground" : "text-foreground/80 hover:text-primary"
|
||||||
|
}`}
|
||||||
|
data-testid={`link-rubrik-${r.toLowerCase().replace(/[^a-z]+/g, "-")}`}
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<nav className="border-b border-card-border bg-card" data-testid="nav-aktuelles-rubriken-2">
|
||||||
|
<div className="max-w-7xl mx-auto grid grid-cols-3">
|
||||||
|
{ROW2.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
onClick={() => go(r)}
|
||||||
|
className={`px-3 py-3 text-[13px] sm:text-sm font-bold uppercase tracking-wide text-center border-r border-card-border transition-colors ${
|
||||||
|
rubrik === r ? "bg-primary text-primary-foreground" : "text-foreground/80 hover:text-primary"
|
||||||
|
}`}
|
||||||
|
data-testid={`link-rubrik-${r.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<Link href="/ueber-uns">
|
||||||
|
<span
|
||||||
|
className="block px-3 py-3 text-[13px] sm:text-sm font-bold uppercase tracking-wide text-center text-foreground/80 hover:text-primary transition-colors cursor-pointer"
|
||||||
|
data-testid="link-rubrik-ueber-uns"
|
||||||
|
>
|
||||||
|
Über uns
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<TopBannerAd />
|
||||||
|
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-4" data-testid="text-aktuelles-title">
|
||||||
|
{rubrik || "Aktuelles"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="w-full h-64 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground py-12">In dieser Rubrik gibt es derzeit keine Beiträge.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{items.map((a, idx) => (
|
||||||
|
<div key={a.id} className="contents">
|
||||||
|
{idx > 0 && idx % 6 === 0 && (
|
||||||
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
|
<ArticleCardAd />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Link href={`/article/${a.slug}`}>
|
||||||
|
<article
|
||||||
|
className="group cursor-pointer rounded-lg overflow-hidden bg-card border border-card-border h-full flex flex-col"
|
||||||
|
data-testid={`card-aktuelles-${a.id}`}
|
||||||
|
>
|
||||||
|
{a.coverImage && (
|
||||||
|
<div className="aspect-[16/9] overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={a.coverImage}
|
||||||
|
alt={a.title}
|
||||||
|
loading="lazy"
|
||||||
|
style={{ objectPosition: objectPositionFor(a.coverImage, focalPoints) }} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4 flex flex-col flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="text-xs font-semibold text-primary uppercase">{a.category}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{format(new Date(a.publishedAt), "d. MMM yyyy", { locale: de })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-bold text-card-foreground leading-snug line-clamp-2 group-hover:text-primary transition-colors">
|
||||||
|
{a.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 leading-relaxed line-clamp-3 flex-1">
|
||||||
|
{a.excerpt}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-3 text-muted-foreground text-[11px]">
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
{a.views.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && data.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-8" data-testid="pagination-aktuelles">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => go(rubrik, page - 1)}>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground px-2">
|
||||||
|
Seite {page} von {data.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= data.totalPages}
|
||||||
|
onClick={() => go(rubrik, page + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<div className="text-[9px] text-muted-foreground/40 text-center pb-1 uppercase tracking-widest">Anzeige</div>
|
||||||
|
<MultiplexAd />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { publisherSchema, articleModifiedTime, articleGenre } from "@shared/seo";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useParams, Link } from "wouter";
|
import { useParams, Link, useLocation } from "wouter";
|
||||||
import { type Article } from "@shared/schema";
|
import { type Article } from "@shared/schema";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
@@ -9,13 +10,13 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import AdSense, { InArticleAd, PageSideAds } from "@/components/adsense";
|
import { InArticleAd, PageSideAds, MultiplexAd, TopBannerAd } from "@/components/adsense";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import ShareButtons from "@/components/share-buttons";
|
import ShareButtons from "@/components/share-buttons";
|
||||||
import InterstitialAd from "@/components/interstitial-ad";
|
|
||||||
import ParallaxAd from "@/components/parallax-ad";
|
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useFocalPoints, objectPositionFor } from "@/lib/focal";
|
||||||
|
import { artistGroup } from "@/lib/artists";
|
||||||
|
|
||||||
const ALLOWED_IFRAME_DOMAINS = [
|
const ALLOWED_IFRAME_DOMAINS = [
|
||||||
"iframe.mediadelivery.net",
|
"iframe.mediadelivery.net",
|
||||||
@@ -28,6 +29,17 @@ const ALLOWED_IFRAME_DOMAINS = [
|
|||||||
"player.vimeo.com",
|
"player.vimeo.com",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Napis pod sliko (»Auf dem Bild: …« / »Foto: …«) locimo od besedila, da stoji
|
||||||
|
* tik pod naslovnico in ne sele za prvim oglasom. */
|
||||||
|
function splitCaption(html: string): { caption: string; body: string } {
|
||||||
|
const re = /<p[^>]*>\s*(?:<em>)?\s*(?:Auf dem Bild|Foto)\s*:[\s\S]*?<\/p>/gi;
|
||||||
|
const found = html.match(re);
|
||||||
|
if (!found || found.length === 0) return { caption: "", body: html };
|
||||||
|
let body = html;
|
||||||
|
for (const f of found) body = body.replace(f, "");
|
||||||
|
return { caption: found.join(" "), body };
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeContent(html: string): string {
|
function sanitizeContent(html: string): string {
|
||||||
return DOMPurify.sanitize(html, {
|
return DOMPurify.sanitize(html, {
|
||||||
ADD_TAGS: ["iframe", "blockquote"],
|
ADD_TAGS: ["iframe", "blockquote"],
|
||||||
@@ -58,12 +70,36 @@ function ArticleSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RelatedArticles({ currentSlug }: { currentSlug: string }) {
|
function RelatedArticles({ currentSlug, currentTitle }: { currentSlug: string; currentTitle?: string }) {
|
||||||
const { data: articles } = useQuery<Article[]>({
|
const { data: articles } = useQuery<Article[]>({
|
||||||
queryKey: ["/api/articles/popular"],
|
queryKey: ["/api/articles/popular"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const filtered = articles?.filter((a) => a.slug !== currentSlug);
|
// Pod clankom ne sme stati clanek o istem izvajalcu (niti o istem, kot je
|
||||||
|
// clanek sam) - bralcu izgleda kot podvojitev.
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const list = (articles || []).filter((a) => a.slug !== currentSlug);
|
||||||
|
const g0 = artistGroup(currentTitle || "");
|
||||||
|
const seen = new Set<string>(g0 ? [g0] : []);
|
||||||
|
const out: Article[] = [];
|
||||||
|
for (const a of list) {
|
||||||
|
const g = artistGroup(a.title || "");
|
||||||
|
if (g && seen.has(g)) continue;
|
||||||
|
if (g) seen.add(g);
|
||||||
|
out.push(a);
|
||||||
|
if (out.length >= 3) break;
|
||||||
|
}
|
||||||
|
if (out.length < 3) {
|
||||||
|
for (const a of list) {
|
||||||
|
if (out.indexOf(a) < 0) {
|
||||||
|
out.push(a);
|
||||||
|
if (out.length >= 3) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [articles, currentSlug, currentTitle]);
|
||||||
|
const focalPoints = useFocalPoints();
|
||||||
|
|
||||||
if (!filtered || filtered.length === 0) return null;
|
if (!filtered || filtered.length === 0) return null;
|
||||||
|
|
||||||
@@ -79,7 +115,7 @@ function RelatedArticles({ currentSlug }: { currentSlug: string }) {
|
|||||||
src={article.coverImage ? article.coverImage.replace(".webp", "-thumb.webp") : "/images/article-1.jpg"}
|
src={article.coverImage ? article.coverImage.replace(".webp", "-thumb.webp") : "/images/article-1.jpg"}
|
||||||
alt={article.title}
|
alt={article.title}
|
||||||
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
style={{ objectPosition: "center 25%" }}
|
style={{ objectPosition: objectPositionFor(article.coverImage, focalPoints) }}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,30 +134,46 @@ function RelatedArticles({ currentSlug }: { currentSlug: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ArticlePage() {
|
export default function ArticlePage() {
|
||||||
|
const focalPoints = useFocalPoints();
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const [, navigate] = useLocation();
|
||||||
|
|
||||||
const { data: article, isLoading, error } = useQuery<Article>({
|
const { data: article, isLoading, error } = useQuery<Article>({
|
||||||
queryKey: ["/api/articles", slug],
|
queryKey: ["/api/articles", slug],
|
||||||
});
|
});
|
||||||
|
|
||||||
const articleUrl = article ? `${window.location.origin}/article/${article.slug}` : "";
|
// Kartice (horoskop, recept) samo napotijo naprej — tudi pri kliku znotraj strani.
|
||||||
|
const targetUrl = (article as any)?.targetUrl as string | undefined;
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetUrl) navigate(targetUrl, { replace: true });
|
||||||
|
}, [targetUrl, navigate]);
|
||||||
|
|
||||||
|
const { caption: coverCaption, body: articleBody } = useMemo(
|
||||||
|
() => splitCaption(article?.content || ""),
|
||||||
|
[article?.content],
|
||||||
|
);
|
||||||
|
|
||||||
|
const articleUrl = article ? `https://folx.tv/article/${article.slug}` : "";
|
||||||
const articleImage = article?.coverImage
|
const articleImage = article?.coverImage
|
||||||
? (article.coverImage.startsWith("http") ? article.coverImage : `${window.location.origin}${article.coverImage}`)
|
? (article.coverImage.startsWith("http") ? article.coverImage : `https://folx.tv${article.coverImage}`)
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
usePageMeta(
|
usePageMeta(
|
||||||
article ? `${article.title} - Volksmusik & Schlager` : "Volksmusik & Schlager Artikel",
|
article ? article.title : "Nachrichten",
|
||||||
article?.excerpt || "Aktuelle Nachrichten aus der Volksmusik- und Schlagerszene bei FOLX TV.",
|
article?.excerpt || "Aktuelle Nachrichten aus der Volksmusik- und Schlagerszene bei FOLX TV.",
|
||||||
article ? {
|
article ? {
|
||||||
|
siteName: "FOLX TV",
|
||||||
ogTitle: article.title,
|
ogTitle: article.title,
|
||||||
ogDescription: article.excerpt,
|
ogDescription: article.excerpt,
|
||||||
ogImage: articleImage || undefined,
|
ogImage: articleImage || undefined,
|
||||||
ogType: "article",
|
ogType: "article",
|
||||||
ogUrl: articleUrl,
|
ogUrl: articleUrl,
|
||||||
articlePublishedTime: new Date(article.publishedAt).toISOString(),
|
articlePublishedTime: new Date(article.publishedAt).toISOString(),
|
||||||
articleAuthor: article.author || "Folx Music Television",
|
articleModifiedTime: articleModifiedTime(article.content || "", new Date(article.publishedAt).toISOString()),
|
||||||
articleSection: article.category || "News",
|
articleAuthor: "FOLX Music Television",
|
||||||
|
articleSection: articleGenre(article.content || "").section,
|
||||||
} : undefined
|
} : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -130,32 +182,25 @@ export default function ArticlePage() {
|
|||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "NewsArticle",
|
"@type": "NewsArticle",
|
||||||
|
"@id": `${articleUrl}#article`,
|
||||||
"headline": article.title,
|
"headline": article.title,
|
||||||
"description": article.excerpt,
|
"description": article.excerpt,
|
||||||
"image": articleImage ? [articleImage] : [],
|
"image": articleImage ? [articleImage] : [],
|
||||||
"datePublished": new Date(article.publishedAt).toISOString(),
|
"datePublished": new Date(article.publishedAt).toISOString(),
|
||||||
"dateModified": new Date(article.publishedAt).toISOString(),
|
"dateModified": articleModifiedTime(article.content || "", new Date(article.publishedAt).toISOString()),
|
||||||
"author": {
|
"author": {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
"name": article.author || "Folx Music Television",
|
"name": "FOLX Music Television",
|
||||||
"url": "https://folx.tv"
|
"url": "https://folx.tv"
|
||||||
},
|
},
|
||||||
"publisher": {
|
"publisher": publisherSchema(),
|
||||||
"@type": "Organization",
|
|
||||||
"name": "Folx Music Television",
|
|
||||||
"url": "https://folx.tv",
|
|
||||||
"logo": {
|
|
||||||
"@type": "ImageObject",
|
|
||||||
"url": `${window.location.origin}/favicon.png`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"mainEntityOfPage": {
|
"mainEntityOfPage": {
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
"@id": articleUrl
|
"@id": articleUrl
|
||||||
},
|
},
|
||||||
"articleSection": article.category || "News",
|
"articleSection": articleGenre(article.content || "").section,
|
||||||
"inLanguage": "de",
|
"inLanguage": "de",
|
||||||
"keywords": `Volksmusik, Schlager, ${article.category || "News"}, ${article.title}`
|
"keywords": articleGenre(article.content || "").words.join(", ")
|
||||||
};
|
};
|
||||||
const breadcrumbLd = {
|
const breadcrumbLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
@@ -204,6 +249,66 @@ export default function ArticlePage() {
|
|||||||
}
|
}
|
||||||
}, [article?.content]);
|
}, [article?.content]);
|
||||||
|
|
||||||
|
// Replace Bunny iframe embeds with native HLS player (folxvideos S3 origin)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!article?.content) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadHlsJs = (): Promise<any> => new Promise((resolve, reject) => {
|
||||||
|
if ((window as any).Hls) return resolve((window as any).Hls);
|
||||||
|
const s = document.createElement("script");
|
||||||
|
s.src = "https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js";
|
||||||
|
s.async = true;
|
||||||
|
s.onload = () => resolve((window as any).Hls);
|
||||||
|
s.onerror = reject;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
const transform = async () => {
|
||||||
|
const iframes = document.querySelectorAll<HTMLIFrameElement>(
|
||||||
|
'iframe[src*="mediadelivery.net/embed/"], iframe[src*="iframe.mediadelivery.net/embed/"]'
|
||||||
|
);
|
||||||
|
if (iframes.length === 0) return;
|
||||||
|
let Hls: any = null;
|
||||||
|
try { Hls = await loadHlsJs(); } catch {}
|
||||||
|
for (const iframe of Array.from(iframes)) {
|
||||||
|
if (cancelled) break;
|
||||||
|
const m = iframe.src.match(/embed\/\d+\/([a-f0-9-]+)/i);
|
||||||
|
if (!m) continue;
|
||||||
|
const guid = m[1];
|
||||||
|
const folxUrl = `https://folxvideos.b-cdn.net/folx-tv/${guid}/master.m3u8`;
|
||||||
|
try {
|
||||||
|
const head = await fetch(folxUrl, { method: "HEAD" });
|
||||||
|
if (!head.ok) continue;
|
||||||
|
} catch { continue; }
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.controls = true;
|
||||||
|
video.preload = "metadata";
|
||||||
|
video.playsInline = true;
|
||||||
|
video.style.cssText = iframe.getAttribute("style") || "width:100%;aspect-ratio:16/9;border:none;";
|
||||||
|
video.className = iframe.className || "";
|
||||||
|
if (Hls && Hls.isSupported()) {
|
||||||
|
const hls = new Hls();
|
||||||
|
hls.loadSource(folxUrl);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
|
video.src = folxUrl;
|
||||||
|
} else {
|
||||||
|
continue; // can't play, keep iframe
|
||||||
|
}
|
||||||
|
iframe.replaceWith(video);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run after content is in DOM (next tick)
|
||||||
|
const t = setTimeout(transform, 50);
|
||||||
|
return () => { cancelled = true; clearTimeout(t); };
|
||||||
|
}, [article?.content]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (targetUrl) return null;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
@@ -235,6 +340,7 @@ export default function ArticlePage() {
|
|||||||
<Header />
|
<Header />
|
||||||
<PageSideAds contentHalfWidth={480} />
|
<PageSideAds contentHalfWidth={480} />
|
||||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<TopBannerAd />
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Button variant="ghost" size="sm" className="mb-3 gap-2" data-testid="button-back">
|
<Button variant="ghost" size="sm" className="mb-3 gap-2" data-testid="button-back">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
@@ -242,27 +348,26 @@ export default function ArticlePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mb-2 rounded-md overflow-hidden bg-card border border-card-border" data-testid="ad-above-image">
|
|
||||||
<div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest">Anzeige</div>
|
|
||||||
<AdSense
|
|
||||||
slot="4154017639"
|
|
||||||
format="horizontal"
|
|
||||||
style={{ display: "block", width: "100%", height: "90px" }}
|
|
||||||
responsive={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{article.coverImage && (
|
{article.coverImage && (
|
||||||
<div className="relative overflow-hidden rounded-md mb-5 max-h-[420px]">
|
<figure className="mb-5">
|
||||||
|
<div className="relative overflow-hidden rounded-md max-h-[420px]">
|
||||||
<img
|
<img
|
||||||
src={article.coverImage}
|
src={article.coverImage}
|
||||||
alt={article.title}
|
alt={article.title}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
style={{ maxHeight: "420px" }}
|
style={{ maxHeight: "420px", objectPosition: objectPositionFor(article.coverImage, focalPoints) }}
|
||||||
data-testid="img-article-cover"
|
data-testid="img-article-cover"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
{coverCaption && (
|
||||||
|
<figcaption
|
||||||
|
className="mt-2 text-xs text-muted-foreground leading-snug [&_p]:m-0 [&_em]:not-italic"
|
||||||
|
data-testid="text-cover-caption"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizeContent(coverCaption) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
@@ -276,7 +381,7 @@ export default function ArticlePage() {
|
|||||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<User className="w-3.5 h-3.5" />
|
<User className="w-3.5 h-3.5" />
|
||||||
{article.author}
|
FOLX Music Television
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Calendar className="w-3.5 h-3.5" />
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
@@ -290,15 +395,16 @@ export default function ArticlePage() {
|
|||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<ShareButtons
|
<ShareButtons
|
||||||
url={`${window.location.origin}/article/${article.slug}`}
|
url={`https://folx.tv/article/${article.slug}`}
|
||||||
title={article.title}
|
title={article.title}
|
||||||
image={article.coverImage ? (article.coverImage.startsWith("http") ? article.coverImage : `${window.location.origin}${article.coverImage}`) : undefined}
|
image={article.coverImage ? (article.coverImage.startsWith("http") ? article.coverImage : `https://folx.tv${article.coverImage}`) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const sanitized = sanitizeContent(article.content);
|
const sanitized = sanitizeContent(articleBody || article.content);
|
||||||
const blocks = sanitized.split(/(?=<(?:p|h[2-4]|div)[\s>])/i).filter(Boolean);
|
const blocks = sanitized.split(/(?=<(?:p|h[2-4]|div)[\s>])/i).filter(Boolean);
|
||||||
const AD_INTERVAL = isMobile ? 2 : 3;
|
const AD_INTERVAL = isMobile ? 2 : 3;
|
||||||
const segments: string[] = [];
|
const segments: string[] = [];
|
||||||
@@ -315,6 +421,13 @@ export default function ArticlePage() {
|
|||||||
[&_div[style]]:flex [&_div[style]]:justify-center
|
[&_div[style]]:flex [&_div[style]]:justify-center
|
||||||
[&_blockquote:not(.instagram-media)]:border-l-primary [&_blockquote:not(.instagram-media)]:bg-accent/50 [&_blockquote:not(.instagram-media)]:rounded-r-md [&_blockquote:not(.instagram-media)]:py-1
|
[&_blockquote:not(.instagram-media)]:border-l-primary [&_blockquote:not(.instagram-media)]:bg-accent/50 [&_blockquote:not(.instagram-media)]:rounded-r-md [&_blockquote:not(.instagram-media)]:py-1
|
||||||
[&_.instagram-media]:!bg-transparent [&_.instagram-media]:!border-0 [&_.instagram-media]:!shadow-none [&_.instagram-media]:!p-0 [&_.instagram-media]:mx-auto`;
|
[&_.instagram-media]:!bg-transparent [&_.instagram-media]:!border-0 [&_.instagram-media]:!shadow-none [&_.instagram-media]:!p-0 [&_.instagram-media]:mx-auto`;
|
||||||
|
// Stevilo oglasov je vezano na dolzino clanka, ne fiksno na dva:
|
||||||
|
// dva osnovna, plus eden na vsakih pet blokov cez deset, strop pet.
|
||||||
|
// Vsi so fluid in-article enota 7672178246 (najvecji zasluzkar).
|
||||||
|
|
||||||
|
const last = segments.length - 1;
|
||||||
|
// Najvec dva oglasa v besedilu: po prvem odstavku in na sredini (stanje pred 28. 8. 2026).
|
||||||
|
const AD_AFTER = last <= 0 ? [] : (last === 1 ? [0] : [0, Math.floor(last / 2)]);
|
||||||
return (
|
return (
|
||||||
<div ref={(el) => {
|
<div ref={(el) => {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -335,7 +448,7 @@ export default function ArticlePage() {
|
|||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
data-testid={idx === 0 ? "article-content" : `article-content-${idx}`}
|
data-testid={idx === 0 ? "article-content" : `article-content-${idx}`}
|
||||||
/>
|
/>
|
||||||
{idx < segments.length - 1 && <InArticleAd />}
|
{AD_AFTER.includes(idx) && <InArticleAd />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -344,17 +457,20 @@ export default function ArticlePage() {
|
|||||||
|
|
||||||
<div className="mt-6 pt-5 border-t border-border">
|
<div className="mt-6 pt-5 border-t border-border">
|
||||||
<ShareButtons
|
<ShareButtons
|
||||||
url={`${window.location.origin}/article/${article.slug}`}
|
url={`https://folx.tv/article/${article.slug}`}
|
||||||
title={article.title}
|
title={article.title}
|
||||||
image={article.coverImage ? (article.coverImage.startsWith("http") ? article.coverImage : `${window.location.origin}${article.coverImage}`) : undefined}
|
image={article.coverImage ? (article.coverImage.startsWith("http") ? article.coverImage : `https://folx.tv${article.coverImage}`) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RelatedArticles currentSlug={slug || ""} />
|
<div className="mt-6" data-testid="ad-multiplex-end">
|
||||||
|
<div className="text-[9px] text-muted-foreground/40 text-center pb-1 uppercase tracking-widest">Anzeige</div>
|
||||||
|
<MultiplexAd />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RelatedArticles currentSlug={slug || ""} currentTitle={article?.title} />
|
||||||
</main>
|
</main>
|
||||||
<ParallaxAd />
|
|
||||||
<Footer narrow />
|
<Footer narrow />
|
||||||
<InterstitialAd />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import { ArticleCardAd, PageSideAds } from "@/components/adsense";
|
import { ArticleCardAd, PageSideAds, TopBannerAd, MultiplexAd } from "@/components/adsense";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useFocalPoints, objectPositionFor } from "@/lib/focal";
|
||||||
|
|
||||||
interface PaginatedResponse {
|
interface PaginatedResponse {
|
||||||
articles: Article[];
|
articles: Article[];
|
||||||
@@ -20,18 +21,30 @@ interface PaginatedResponse {
|
|||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CategoryPage() {
|
export default function CategoryPage({ fixedCategory }: { fixedCategory?: string } = {}) {
|
||||||
const { category } = useParams<{ category: string }>();
|
const focalPoints = useFocalPoints();
|
||||||
|
const params = useParams<{ category: string }>();
|
||||||
|
const category = fixedCategory || params.category;
|
||||||
const searchString = useSearch();
|
const searchString = useSearch();
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(searchString);
|
const searchParams = new URLSearchParams(searchString);
|
||||||
const currentPage = Math.max(1, parseInt(searchParams.get("page") || "1"));
|
const currentPage = Math.max(1, parseInt(searchParams.get("page") || "1"));
|
||||||
|
|
||||||
usePageMeta(
|
const isNews = category?.toLowerCase() === "news";
|
||||||
`${category}${currentPage > 1 ? ` – Seite ${currentPage}` : ""} - Volksmusik & Schlager`,
|
const heading = isNews ? "Schlager & Volksmusik News" : category;
|
||||||
`Aktuelle ${category}-Beiträge und Nachrichten aus der Volksmusik- und Schlagerszene. Neueste Volksmusik-Meldungen bei FOLX TV.`
|
const pageSuffix = currentPage > 1 ? ` – Seite ${currentPage}` : "";
|
||||||
);
|
const pageTitle = isNews ? `${heading}${pageSuffix}` : `${category}${pageSuffix} - Volksmusik & Schlager`;
|
||||||
|
const description = isNews
|
||||||
|
? "Aktuelle Schlager & Volksmusik News: Nachrichten, Interviews und Neuigkeiten aus der Musikszene bei FOLX TV."
|
||||||
|
: `Aktuelle ${category}-Beiträge und Nachrichten aus der Volksmusik- und Schlagerszene. Neueste Volksmusik-Meldungen bei FOLX TV.`;
|
||||||
|
|
||||||
|
usePageMeta(pageTitle, description, isNews ? {
|
||||||
|
siteName: "FOLX TV",
|
||||||
|
ogTitle: `${pageTitle} | FOLX TV`,
|
||||||
|
ogDescription: description,
|
||||||
|
ogType: "website",
|
||||||
|
} : undefined);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<PaginatedResponse>({
|
const { data, isLoading } = useQuery<PaginatedResponse>({
|
||||||
queryKey: ["/api/articles/category", category, { page: currentPage }],
|
queryKey: ["/api/articles/category", category, { page: currentPage }],
|
||||||
@@ -115,6 +128,7 @@ export default function CategoryPage() {
|
|||||||
<Header />
|
<Header />
|
||||||
<PageSideAds />
|
<PageSideAds />
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<TopBannerAd />
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Button variant="ghost" size="sm" className="mb-6 gap-2" data-testid="button-back">
|
<Button variant="ghost" size="sm" className="mb-6 gap-2" data-testid="button-back">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
@@ -123,7 +137,7 @@ export default function CategoryPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 mb-6 flex-wrap">
|
<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">
|
<h1 className="min-w-0 text-2xl font-bold text-foreground break-words" data-testid="text-category-title">
|
||||||
{category}
|
{category}
|
||||||
</h1>
|
</h1>
|
||||||
{data && data.total > 0 && (
|
{data && data.total > 0 && (
|
||||||
@@ -149,18 +163,24 @@ export default function CategoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : data && data.articles.length > 0 ? (
|
) : data && data.articles.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
|
{/* Oglas je fiksen 300x600 in bi vrstico raztegnil na svojo visino.
|
||||||
|
Zato so vrstice zaklenjene na 292 px, oglas pa zasede dve, tako
|
||||||
|
da se visini ujameta in pod karticami ni praznega prostora. */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5 lg:auto-rows-[300px]">
|
||||||
{(() => {
|
{(() => {
|
||||||
const items: React.ReactNode[] = [];
|
const items: React.ReactNode[] = [];
|
||||||
const articles = data.articles;
|
const articles = data.articles;
|
||||||
let ai = 0;
|
let ai = 0;
|
||||||
let row = 0;
|
let row = 0;
|
||||||
while (ai < articles.length) {
|
while (ai < articles.length) {
|
||||||
const adCol = row % 4;
|
// V prvi vrstici oglas nikoli ne sme biti prva celica: na
|
||||||
|
// mobilnem je mreza enostolpcna, zato bi bralec po pasici na
|
||||||
|
// vrhu takoj dobil se en oglas in sele nato prvi clanek.
|
||||||
|
const adCol = row === 0 ? 3 : row % 4;
|
||||||
for (let col = 0; col < 4; col++) {
|
for (let col = 0; col < 4; col++) {
|
||||||
if (col === adCol) {
|
// Oglas je zacasno umaknjen iz mreze: enota se v celici
|
||||||
items.push(<ArticleCardAd key={`ad-row-${row}`} />);
|
// 300 px izrise kot 548 px in razteguje vrstico.
|
||||||
} else if (ai < articles.length) {
|
if (ai < articles.length) {
|
||||||
const a = articles[ai];
|
const a = articles[ai];
|
||||||
items.push(
|
items.push(
|
||||||
<Link key={a.id} href={`/article/${a.slug}`}>
|
<Link key={a.id} href={`/article/${a.slug}`}>
|
||||||
@@ -173,7 +193,7 @@ export default function CategoryPage() {
|
|||||||
src={a.coverImage || "/images/article-1.jpg"}
|
src={a.coverImage || "/images/article-1.jpg"}
|
||||||
alt={a.title}
|
alt={a.title}
|
||||||
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
style={{ objectPosition: "center 25%" }}
|
style={{ objectPosition: objectPositionFor(a.coverImage, focalPoints) }}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,7 +228,11 @@ export default function CategoryPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
<div className="mt-8" data-testid="ad-multiplex-end">
|
||||||
|
<div className="text-[9px] text-muted-foreground/40 text-center pb-1 uppercase tracking-widest">Anzeige</div>
|
||||||
|
<MultiplexAd />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { INFO_SEO } from "@shared/seo";
|
||||||
import { Shield } from "lucide-react";
|
import { Shield } from "lucide-react";
|
||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
@@ -5,7 +6,7 @@ import { usePageMeta } from "@/hooks/use-page-meta";
|
|||||||
import { PageSideAds } from "@/components/adsense";
|
import { PageSideAds } from "@/components/adsense";
|
||||||
|
|
||||||
export default function DatenschutzPage() {
|
export default function DatenschutzPage() {
|
||||||
usePageMeta("Datenschutz - FOLX TV Volksmusik Sender", "Datenschutzerklärung von FOLX TV – Ihrem Volksmusik & Schlager Fernsehsender.");
|
usePageMeta(INFO_SEO["/datenschutz"].title, INFO_SEO["/datenschutz"].description, { siteName: "FOLX TV" });
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
|
|||||||
@@ -1,55 +1,234 @@
|
|||||||
import { Tv, Globe, MapPin } from "lucide-react";
|
import { INFO_SEO } from "@shared/seo";
|
||||||
|
import { Tv, MapPin, PlayCircle, Youtube, Smartphone, Radio, ExternalLink } from "lucide-react";
|
||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||||
import { PageSideAds } from "@/components/adsense";
|
import { PageSideAds, TopBannerAd } from "@/components/adsense";
|
||||||
|
|
||||||
const COUNTRIES = [
|
type Provider = { name: string; detail: string | null; url: string; how: string };
|
||||||
|
|
||||||
|
const COUNTRIES: { country: string; flag: string; providers: Provider[] }[] = [
|
||||||
{
|
{
|
||||||
country: "Deutschland",
|
country: "Deutschland",
|
||||||
flag: "DE",
|
flag: "DE",
|
||||||
providers: [
|
providers: [
|
||||||
{ name: "MagentaTV", detail: "Telekom Deutschland" },
|
{
|
||||||
{ name: "Zattoo", detail: null },
|
name: "MagentaTV",
|
||||||
{ name: "O2 TV", detail: null },
|
detail: "Telekom Deutschland",
|
||||||
|
url: "https://www.telekom.de/magenta-tv/sender",
|
||||||
|
how: "In der MagentaTV-Senderliste unter FOLX TV – am Receiver, in der MagentaTV-App und im Browser.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Zattoo",
|
||||||
|
detail: null,
|
||||||
|
url: "https://zattoo.com/de/sender",
|
||||||
|
how: "Kostenlos im Zattoo-Free-Paket. App öffnen oder zattoo.com aufrufen, anmelden, in der Senderliste FOLX TV wählen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "O2 TV",
|
||||||
|
detail: null,
|
||||||
|
url: "https://www.o2online.de/extras/o2-tv/",
|
||||||
|
how: "Im O2 TV-Paket M enthalten. In der O2 TV-App oder im Browser in der Senderliste FOLX TV wählen.",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
country: "Österreich",
|
country: "Österreich",
|
||||||
flag: "AT",
|
flag: "AT",
|
||||||
providers: [
|
providers: [
|
||||||
{ name: "A1 Xplore TV", detail: null },
|
{
|
||||||
{ name: "Salzburg AG CableLink", detail: null },
|
name: "A1 Xplore TV",
|
||||||
{ name: "Zattoo", detail: null },
|
detail: null,
|
||||||
|
url: "https://www.a1.net/tv",
|
||||||
|
how: "Bei A1 Xplore TV in der Senderliste unter FOLX TV – an der Box, in der A1 Xplore TV-App und im Browser.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Salzburg AG CableLink",
|
||||||
|
detail: null,
|
||||||
|
url: "https://www.salzburg-ag.at/internet-tv-telefon/fuer-privat/tv/fernsehen-internet.html",
|
||||||
|
how: "Im CableLink-Kabelnetz der Salzburg AG. Sendersuchlauf am Fernseher starten, FOLX TV erscheint in der Senderliste.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Zattoo",
|
||||||
|
detail: null,
|
||||||
|
url: "https://zattoo.com/at/sender",
|
||||||
|
how: "Kostenlos im Zattoo-Free-Paket. App öffnen oder zattoo.com aufrufen, anmelden, in der Senderliste FOLX TV wählen.",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
country: "Schweiz",
|
country: "Schweiz",
|
||||||
flag: "CH",
|
flag: "CH",
|
||||||
providers: [
|
providers: [
|
||||||
{ name: "Swisscom blue TV", detail: null },
|
{
|
||||||
{ name: "Zattoo", detail: null },
|
name: "Swisscom blue TV",
|
||||||
|
detail: null,
|
||||||
|
url: "https://www.swisscom.ch/de/privatkunden/tv-abo.html",
|
||||||
|
how: "Bei blue TV in der Senderliste unter FOLX TV – an der blue TV Box, in der blue TV-App und im Browser.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Zattoo",
|
||||||
|
detail: null,
|
||||||
|
url: "https://zattoo.com/ch/sender",
|
||||||
|
how: "Kostenlos im Zattoo-Free-Paket. App öffnen oder zattoo.com aufrufen, anmelden, in der Senderliste FOLX TV wählen.",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const APPS = [
|
||||||
|
{
|
||||||
|
name: "iPhone, iPad & Apple TV",
|
||||||
|
how: "App Store öffnen, nach „FOLX TV“ suchen und laden. Auf dem Apple TV die App aus dem App Store des Geräts installieren.",
|
||||||
|
url: "https://apps.apple.com/de/app/folx-tv/id6782307034",
|
||||||
|
linkLabel: "Im App Store öffnen",
|
||||||
|
badge: { src: "/badges/app-store-de.svg", alt: "Laden im App Store" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Android-Handy, Tablet & Android TV",
|
||||||
|
how: "Google Play öffnen, nach „FOLX TV“ suchen und installieren. Auf Android TV und Google TV direkt im Play Store des Fernsehers.",
|
||||||
|
url: "https://play.google.com/store/apps/details?id=tv.folx.app",
|
||||||
|
linkLabel: "Bei Google Play öffnen",
|
||||||
|
badge: { src: "/badges/google-play-de.png", alt: "Jetzt bei Google Play" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Samsung Smart TV",
|
||||||
|
how: "Auf Samsung-Fernsehern der Modelljahre 2022 und 2023 (Tizen 6.5 und 9.0): Apps öffnen, nach „FOLX TV“ suchen und installieren. Weitere Modelljahre folgen.",
|
||||||
|
url: null,
|
||||||
|
linkLabel: null,
|
||||||
|
badge: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Amazon Fire TV Stick & Fire TV",
|
||||||
|
how: "Auf dem Fire TV Stick: Suche öffnen, „FOLX TV“ eingeben und installieren. Oder am Computer bei Amazon öffnen und die App direkt an Ihr Fire TV senden. Gilt auch für den waipu.tv 4K Stick und andere Sticks und Set-Top-Boxen mit Fire TV: Sie laufen mit Fire TV und haben den Amazon Appstore an Bord – dort finden Sie die FOLX TV App genauso.",
|
||||||
|
url: "https://www.amazon.de/dp/B0H62MFNDQ",
|
||||||
|
linkLabel: "Bei Amazon Appstore öffnen",
|
||||||
|
badge: { src: "/badges/amazon-appstore-de.png", alt: "Erhältlich bei Amazon Appstore" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function EmpfangPage() {
|
export default function EmpfangPage() {
|
||||||
usePageMeta("Empfang FOLX TV - Volksmusik & Schlager Sender empfangen", "So empfangen Sie FOLX TV – den Volksmusik & Schlager Sender in Deutschland, Österreich und der Schweiz. Kabel, Satellit und Streaming-Optionen.");
|
usePageMeta(INFO_SEO["/empfang-folx-tv"].title, INFO_SEO["/empfang-folx-tv"].description, { siteName: "FOLX TV" });
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<PageSideAds />
|
<PageSideAds />
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<TopBannerAd />
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Tv className="w-7 h-7 text-primary" />
|
<Tv className="w-7 h-7 text-primary" />
|
||||||
<h1 className="text-3xl font-bold text-foreground" data-testid="text-empfang-title">
|
<h1 className="text-3xl font-bold text-foreground" data-testid="text-empfang-title">
|
||||||
Empfang von FOLX TV
|
Empfang von FOLX TV
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mb-8" data-testid="text-empfang-subtitle">
|
<p className="text-muted-foreground mb-6" data-testid="text-empfang-subtitle">
|
||||||
So empfangen Sie Folx Music Television in Ihrem Land
|
Drei Wege zu FOLX TV: kostenloser Livestream, die FOLX TV App und Ihr Kabel- oder IPTV-Anbieter.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-8" data-testid="app-badges-top">
|
||||||
|
<a
|
||||||
|
href="https://apps.apple.com/de/app/folx-tv/id6782307034"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="FOLX TV im App Store laden"
|
||||||
|
data-testid="link-empfang-appstore"
|
||||||
|
>
|
||||||
|
<img src="/badges/app-store-de.svg" alt="Laden im App Store" width="180" height="60" className="h-12 sm:h-14 w-auto" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://play.google.com/store/apps/details?id=tv.folx.app"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="FOLX TV bei Google Play"
|
||||||
|
data-testid="link-empfang-googleplay"
|
||||||
|
>
|
||||||
|
<img src="/badges/google-play-de.png" alt="Jetzt bei Google Play" width="202" height="60" className="h-12 sm:h-14 w-auto" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 1. Livestream */}
|
||||||
|
<section
|
||||||
|
className="bg-card rounded-lg border border-card-border overflow-hidden mb-8"
|
||||||
|
data-testid="section-livestream"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 px-5 py-4 border-b border-card-border bg-muted/20">
|
||||||
|
<PlayCircle className="w-4 h-4 text-primary" />
|
||||||
|
<h2 className="text-lg font-bold text-card-foreground">1. Livestream – sofort und kostenlos</h2>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-5 space-y-4">
|
||||||
|
<p className="text-card-foreground text-sm leading-relaxed">
|
||||||
|
FOLX TV läuft rund um die Uhr live im Internet. Einfach{" "}
|
||||||
|
<a
|
||||||
|
href="https://folx.live"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="text-primary hover:underline font-medium"
|
||||||
|
data-testid="link-livestream-inline"
|
||||||
|
>
|
||||||
|
folx.live
|
||||||
|
</a>{" "}
|
||||||
|
im Browser öffnen – am Computer, Handy, Tablet oder Smart-TV. Ohne Anmeldung, ohne Abo.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<a
|
||||||
|
href="https://folx.live"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground hover:opacity-90"
|
||||||
|
data-testid="link-livestream-top"
|
||||||
|
>
|
||||||
|
<PlayCircle className="w-4 h-4" />
|
||||||
|
Livestream auf folx.live ansehen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 2. App */}
|
||||||
|
<section
|
||||||
|
className="bg-card rounded-lg border border-card-border overflow-hidden mb-8"
|
||||||
|
data-testid="section-app"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 px-5 py-4 border-b border-card-border bg-muted/20">
|
||||||
|
<Smartphone className="w-4 h-4 text-primary" />
|
||||||
|
<h2 className="text-lg font-bold text-card-foreground">2. FOLX TV App – kostenlos, ohne Abo</h2>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-5 space-y-5">
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{APPS.map((a) => (
|
||||||
|
<li key={a.name} className="flex flex-col sm:flex-row sm:items-start gap-3 sm:gap-6" data-testid={`app-${a.name.toLowerCase().replace(/[^a-z]+/g, "-")}`}>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-card-foreground font-semibold">{a.name}</div>
|
||||||
|
<p className="text-muted-foreground text-sm leading-relaxed mt-1">{a.how}</p>
|
||||||
|
</div>
|
||||||
|
{a.url && a.badge && (
|
||||||
|
<a href={a.url} target="_blank" rel="noopener noreferrer" aria-label={a.linkLabel ?? a.name} className="shrink-0">
|
||||||
|
<img src={a.badge.src} alt={a.badge.alt} width="120" height="40" className="h-10 w-auto" loading="lazy" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex flex-wrap gap-3 pt-1">
|
||||||
|
<a
|
||||||
|
href="https://youtu.be/zqQwZ12CWIk"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-card-border px-4 py-2 text-sm font-semibold text-card-foreground hover:bg-muted/40"
|
||||||
|
data-testid="link-app-spot"
|
||||||
|
>
|
||||||
|
<Youtube className="w-4 h-4 text-primary" />
|
||||||
|
App-Spot ansehen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 3. Kabel & IPTV */}
|
||||||
|
<div className="flex items-center gap-3 px-1 mb-3">
|
||||||
|
<Radio className="w-4 h-4 text-primary" />
|
||||||
|
<h2 className="text-lg font-bold text-foreground">3. Kabel & IPTV – bei Ihrem Anbieter</h2>
|
||||||
|
</div>
|
||||||
<div className="space-y-6 mb-10">
|
<div className="space-y-6 mb-10">
|
||||||
{COUNTRIES.map((c) => (
|
{COUNTRIES.map((c) => (
|
||||||
<section
|
<section
|
||||||
@@ -59,51 +238,35 @@ export default function EmpfangPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-card-border bg-muted/20">
|
<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" />
|
<MapPin className="w-4 h-4 text-primary" />
|
||||||
<h2 className="text-lg font-bold text-card-foreground">{c.country}</h2>
|
<h3 className="text-lg font-bold text-card-foreground">{c.country}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4">
|
<ul className="divide-y divide-card-border">
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
{c.providers.map((p) => (
|
||||||
{c.providers.map((p, i) => (
|
<li key={p.name} className="px-5 py-4 flex flex-col sm:flex-row sm:items-start gap-2 sm:gap-6">
|
||||||
<span key={p.name} className="flex items-center gap-x-2">
|
<div className="flex-1">
|
||||||
<span className="text-card-foreground font-medium" data-testid={`text-provider-${p.name.toLowerCase().replace(/\s+/g, "-")}`}>
|
<div className="text-card-foreground font-semibold" data-testid={`text-provider-${p.name.toLowerCase().replace(/\s+/g, "-")}`}>
|
||||||
{p.name}
|
{p.name}
|
||||||
{p.detail && (
|
{p.detail && (
|
||||||
<span className="text-muted-foreground font-normal text-sm ml-1">
|
<span className="text-muted-foreground font-normal text-sm ml-1">({p.detail})</span>
|
||||||
({p.detail})
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
|
||||||
{i < c.providers.length - 1 && (
|
|
||||||
<span className="text-muted-foreground/40 select-none" aria-hidden="true">·</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm leading-relaxed mt-1">{p.how}</p>
|
||||||
</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
|
<a
|
||||||
href="https://www.folx.tv"
|
href={p.url}
|
||||||
className="text-primary hover:underline font-medium"
|
target="_blank"
|
||||||
data-testid="link-livestream"
|
rel="noopener noreferrer"
|
||||||
|
className="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||||
>
|
>
|
||||||
www.folx.tv
|
Zum Anbieter
|
||||||
</a>{" "}
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
für den direkten Zugang.
|
</a>
|
||||||
</p>
|
</li>
|
||||||
</div>
|
))}
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Tv, Globe, MapPin } from "lucide-react";
|
||||||
|
import Header from "@/components/header";
|
||||||
|
import Footer from "@/components/footer";
|
||||||
|
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||||
|
import { PageSideAds, TopBannerAd } from "@/components/adsense";
|
||||||
|
|
||||||
|
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 – den Volksmusik & Schlager Sender in Deutschland, Österreich und der Schweiz. Kabel, Satellit und Streaming-Optionen.");
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
<PageSideAds />
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<TopBannerAd />
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,15 +4,16 @@ import GalleryPage from "@/components/photo-gallery";
|
|||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||||
import { InArticleAd, PageSideAds } from "@/components/adsense";
|
import { InArticleAd, PageSideAds, TopBannerAd, MultiplexAd } from "@/components/adsense";
|
||||||
|
|
||||||
export default function GalleryPageWrapper() {
|
export default function GalleryPageWrapper() {
|
||||||
usePageMeta("Fotogalerie - Volksmusik & Schlager Bilder", "Exklusive Fotos und Bilder von Volksmusik- und Schlager-Stars, Konzerten und Events. Die Volksmusik-Fotogalerie von FOLX TV.");
|
usePageMeta("Fotogalerie – Einzigartige Momente von unseren Aufzeichnungen | FOLX TV", "Spannende Schnappschüsse und besondere Augenblicke, eingefangen bei unseren Aufzeichnungen. Authentische Bilder der Volksmusik- und Schlager-Stars in einzigartigen Momenten bei FOLX TV.");
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<PageSideAds />
|
<PageSideAds />
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<TopBannerAd />
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<button className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors mb-6 text-sm" data-testid="button-back">
|
<button className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors mb-6 text-sm" data-testid="button-back">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
@@ -25,7 +26,11 @@ export default function GalleryPageWrapper() {
|
|||||||
<InArticleAd />
|
<InArticleAd />
|
||||||
<GalleryPage />
|
<GalleryPage />
|
||||||
<InArticleAd />
|
<InArticleAd />
|
||||||
</main>
|
<div className="mt-8" data-testid="ad-multiplex-end">
|
||||||
|
<div className="text-[9px] text-muted-foreground/40 text-center pb-1 uppercase tracking-widest">Anzeige</div>
|
||||||
|
<MultiplexAd />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { HOME_TITLE, HOME_DESCRIPTION, homeSchema } from "@shared/seo";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { artistGroup } from "@/lib/artists";
|
||||||
|
import { objectPositionFor } from "@/lib/focal";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { type Article } from "@shared/schema";
|
import { type Article } from "@shared/schema";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
@@ -8,12 +11,8 @@ import { Eye, Play, Images } from "lucide-react";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import AdSense, { ArticleCardAd, SidebarAd, PageSideAds } from "@/components/adsense";
|
import AdSense, { ArticleCardAd, SidebarAd, PageSideAds, MultiplexAd, TopBannerAd, GridBannerAd } from "@/components/adsense";
|
||||||
import { PhotoGalleryWidget } from "@/components/photo-gallery";
|
|
||||||
import { HoroscopeWidget } from "@/components/horoscope-widget";
|
|
||||||
import { RecipeWidget } from "@/components/recipe-widget";
|
|
||||||
import { NewsWidget } from "@/components/news-widget";
|
import { NewsWidget } from "@/components/news-widget";
|
||||||
import { SidebarWeatherWidget } from "@/components/weather-widget";
|
|
||||||
import { BreakingNewsWidget } from "@/components/breaking-news-widget";
|
import { BreakingNewsWidget } from "@/components/breaking-news-widget";
|
||||||
import PushPromptBanner from "@/components/push-prompt-banner";
|
import PushPromptBanner from "@/components/push-prompt-banner";
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo, lazy, Suspense } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo, lazy, Suspense } from "react";
|
||||||
@@ -141,14 +140,250 @@ function GalleryHeroCard({ images }: { images: GalleryImage[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zaris naslovnice. Kljuci v /api/focal-points so relativni (/uploads/x.webp),
|
||||||
|
* coverImage pa je pogosto absoluten (https://folx.tv/uploads/x.webp) - prej se
|
||||||
|
* kljuc ni ujel, zaris se ni uporabil in kvadratne ploscice so rezale glave.
|
||||||
|
* Zato uporabimo skupno objectPositionFor, ki obe obliki poravna.
|
||||||
|
*/
|
||||||
function getObjectPosition(coverImage: string | null, focalPoints?: Record<string, { x: number; y: number }>): string {
|
function getObjectPosition(coverImage: string | null, focalPoints?: Record<string, { x: number; y: number }>): string {
|
||||||
if (!coverImage || !focalPoints) return "center 20%";
|
return objectPositionFor(coverImage, focalPoints as any);
|
||||||
const fp = focalPoints[coverImage];
|
|
||||||
if (!fp) return "center 20%";
|
|
||||||
return `${fp.x}% ${fp.y}%`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediumCard({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
|
function MosaicTile({ article, focalPoints, tall = false }: { article: Article; focalPoints?: Record<string, { x: number; y: number }>; tall?: boolean }) {
|
||||||
|
const objPos = getObjectPosition(article.coverImage, focalPoints);
|
||||||
|
return (
|
||||||
|
<Link href={`/article/${article.slug}`}>
|
||||||
|
<div className="relative rounded-lg overflow-hidden" data-testid={`tile-mosaic-${article.id}`}>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(article.coverImage)}
|
||||||
|
alt={article.title}
|
||||||
|
className={`w-full object-cover ${tall ? "aspect-[16/10]" : "aspect-square"}`}
|
||||||
|
style={{ objectPosition: objPos }}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
|
||||||
|
<span className="absolute top-2 left-2 bg-primary text-white text-[10px] font-semibold px-2 py-0.5 rounded">
|
||||||
|
{article.category || "News"}
|
||||||
|
</span>
|
||||||
|
<h3 className={`absolute bottom-2 left-2 right-2 text-white font-semibold leading-snug ${tall ? "text-base line-clamp-2" : "text-xs line-clamp-3"}`}>
|
||||||
|
{article.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MosaicHeading({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<h2 className="text-primary font-bold text-lg leading-none mb-2">{children}</h2>
|
||||||
|
<div className="h-0.5 w-16 bg-primary" />
|
||||||
|
<div className="h-px w-full bg-card-border -mt-px" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Isti izvajalec ne sme stati drug ob drugem: med dvema clankoma vsaj 5 mest. */
|
||||||
|
function razmakniPoIzvajalcu(list: Article[], razmik = 5, seed: string[] = []): Article[] {
|
||||||
|
const rest = [...(list || [])];
|
||||||
|
const out: Article[] = [];
|
||||||
|
const recent: string[] = seed.filter(Boolean).slice(-(razmik - 1));
|
||||||
|
while (rest.length) {
|
||||||
|
let idx = rest.findIndex((a) => {
|
||||||
|
const k = artistGroup(a?.title || "");
|
||||||
|
return !k || !recent.includes(k);
|
||||||
|
});
|
||||||
|
if (idx < 0) idx = 0;
|
||||||
|
const a = rest.splice(idx, 1)[0];
|
||||||
|
out.push(a);
|
||||||
|
recent.push(artistGroup(a?.title || ""));
|
||||||
|
if (recent.length >= razmik) recent.shift();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileMosaic({ latest, popular, focalPoints }: { latest: Article[]; popular?: Article[]; focalPoints?: Record<string, { x: number; y: number }> }) {
|
||||||
|
const pageSize = 3;
|
||||||
|
const pool = razmakniPoIzvajalcu(latest || []).slice(0, 9);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(pool.length / 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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
changePage(page);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || totalPages <= 1) return;
|
||||||
|
const timer = setInterval(() => setPage((p) => (p + 1) % totalPages), 8000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [paused, totalPages]);
|
||||||
|
|
||||||
|
const touchX = useRef<number | null>(null);
|
||||||
|
const resumeTimer = useRef<any>(null);
|
||||||
|
|
||||||
|
const goTo = useCallback((dir: number) => {
|
||||||
|
setPage((p) => (p + dir + totalPages) % totalPages);
|
||||||
|
}, [totalPages]);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
touchX.current = e.touches[0].clientX;
|
||||||
|
setPaused(true);
|
||||||
|
if (resumeTimer.current) clearTimeout(resumeTimer.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||||
|
const x0 = touchX.current;
|
||||||
|
touchX.current = null;
|
||||||
|
if (x0 !== null) {
|
||||||
|
const dx = e.changedTouches[0].clientX - x0;
|
||||||
|
if (Math.abs(dx) > 40) goTo(dx < 0 ? 1 : -1);
|
||||||
|
}
|
||||||
|
if (resumeTimer.current) clearTimeout(resumeTimer.current);
|
||||||
|
resumeTimer.current = setTimeout(() => setPaused(false), 6000);
|
||||||
|
}, [goTo]);
|
||||||
|
|
||||||
|
useEffect(() => () => { if (resumeTimer.current) clearTimeout(resumeTimer.current); }, []);
|
||||||
|
|
||||||
|
const l = pool.slice(displayPage * pageSize, displayPage * pageSize + pageSize);
|
||||||
|
if (l.length === 0) return null;
|
||||||
|
|
||||||
|
const usedIds = new Set(pool.map((a) => a.id));
|
||||||
|
const poolGroups = new Set(pool.map((a) => artistGroup(a?.title || "")).filter(Boolean));
|
||||||
|
const kandidati = (popular || []).filter((a) => !usedIds.has(a.id));
|
||||||
|
const brezPodvojitve = kandidati.filter((a) => {
|
||||||
|
const g = artistGroup(a?.title || "");
|
||||||
|
return !g || !poolGroups.has(g);
|
||||||
|
});
|
||||||
|
const p = razmakniPoIzvajalcu(
|
||||||
|
brezPodvojitve.length >= 3 ? brezPodvojitve : kandidati,
|
||||||
|
).slice(0, 3);
|
||||||
|
|
||||||
|
const nextStart = ((displayPage + 1) % totalPages) * pageSize;
|
||||||
|
const preload = pool.slice(nextStart, nextStart + pageSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:hidden space-y-6" data-testid="section-mobile-mosaic">
|
||||||
|
<section
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
style={{ touchAction: "pan-y" }}
|
||||||
|
>
|
||||||
|
<MosaicHeading>Neueste Artikel</MosaicHeading>
|
||||||
|
{preload.length > 0 && (
|
||||||
|
<div className="hidden" aria-hidden="true">
|
||||||
|
{preload.map((a) => a.coverImage && <img key={`pre-${a.id}`} src={thumbUrl(a.coverImage)} alt="" />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="transition-opacity duration-300" style={{ opacity: fading ? 0 : 1 }}>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{l.slice(0, 2).map((a) => (
|
||||||
|
<MosaicTile key={a.id} article={a} focalPoints={focalPoints} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{l[2] && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<MosaicTile article={l[2]} focalPoints={focalPoints} tall />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center gap-2 mt-3" data-testid="mosaic-dots">
|
||||||
|
{Array.from({ length: totalPages }).map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setPage(i)}
|
||||||
|
className={`h-2.5 rounded-full transition-all duration-300 ${i === page ? "bg-primary w-8" : "bg-foreground/35 w-2.5"}`}
|
||||||
|
data-testid={`button-mosaic-dot-${i}`}
|
||||||
|
aria-label={`Seite ${i + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{p.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<MosaicHeading>Beliebt</MosaicHeading>
|
||||||
|
<MosaicTile article={p[0]} focalPoints={focalPoints} tall />
|
||||||
|
{p.length > 1 && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||||
|
{p.slice(1, 3).map((a) => (
|
||||||
|
<MosaicTile key={a.id} article={a} focalPoints={focalPoints} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YtVideo { ytId: string; slug: string; shortTitle?: string; title: string; rubrik?: string; publishedAt?: string; durationS?: number; teaser?: string }
|
||||||
|
|
||||||
|
// Video kartica v mrezi: enaka velikost kot kartica clanka, da v vrstici ni
|
||||||
|
// luknje. Pelje na /video/<slug>, kjer je oglasni prostor, ne na YouTube.
|
||||||
|
function VideoCard({ video }: { video: YtVideo }) {
|
||||||
|
const mm = Math.floor((video.durationS || 0) / 60);
|
||||||
|
const ss = String((video.durationS || 0) % 60).padStart(2, "0");
|
||||||
|
return (
|
||||||
|
<Link href={`/video/${video.slug}`}>
|
||||||
|
<div className="relative group cursor-pointer rounded-lg overflow-hidden h-full bg-card border border-card-border flex flex-col" data-testid={`card-video-${video.ytId}`}>
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={`https://i.ytimg.com/vi/${video.ytId}/hqdefault.jpg`}
|
||||||
|
alt={video.shortTitle || video.title}
|
||||||
|
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors">
|
||||||
|
<Play className="w-4 h-4 text-white ml-0.5" fill="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{video.durationS ? (
|
||||||
|
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||||
|
{mm}:{ss}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="p-3.5 flex flex-col flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="text-[10px] font-medium text-primary">VIDEO</span>
|
||||||
|
{video.publishedAt && (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{timeAgo(new Date(video.publishedAt))}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-card-foreground text-sm leading-snug line-clamp-2 group-hover:text-primary transition-colors">
|
||||||
|
{video.shortTitle || video.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-muted-foreground text-[10px]">
|
||||||
|
<Play className="w-3 h-3" />
|
||||||
|
Video ansehen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// compact: kartica ob oglasu je visoka 292 px namesto 399, zato se izvlecek
|
||||||
|
// omeji na dve vrstici, sicer ga spodaj odreze sredi stavka.
|
||||||
|
function MediumCard({ article, focalPoints, compact }: { article: Article; focalPoints?: Record<string, { x: number; y: number }>; compact?: boolean }) {
|
||||||
const isVideo = article.category === "Video";
|
const isVideo = article.category === "Video";
|
||||||
const objPos = getObjectPosition(article.coverImage, focalPoints);
|
const objPos = getObjectPosition(article.coverImage, focalPoints);
|
||||||
return (
|
return (
|
||||||
@@ -172,7 +407,9 @@ function MediumCard({ article, focalPoints }: { article: Article; focalPoints?:
|
|||||||
<span className="text-muted-foreground text-[10px]">{timeAgo(new Date(article.publishedAt))}</span>
|
<span className="text-muted-foreground text-[10px]">{timeAgo(new Date(article.publishedAt))}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold text-card-foreground text-sm leading-snug line-clamp-2 group-hover:text-primary transition-colors">{article.title}</h3>
|
<h3 className="font-semibold text-card-foreground text-sm leading-snug line-clamp-2 group-hover:text-primary transition-colors">{article.title}</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-1.5 leading-relaxed flex-1">{article.excerpt}</p>
|
{/* Kartica je visoka 300 px, kolikor meri oglas 300x250 z napisom
|
||||||
|
Anzeige. Za izvlecek ni prostora, ostanejo slika, rubrika, cas,
|
||||||
|
naslov na dve vrstici in stevilo ogledov. */}
|
||||||
<div className="flex items-center gap-2 mt-2 text-muted-foreground text-[10px]">
|
<div className="flex items-center gap-2 mt-2 text-muted-foreground text-[10px]">
|
||||||
<Eye className="w-3 h-3" />
|
<Eye className="w-3 h-3" />
|
||||||
{article.views.toLocaleString()}
|
{article.views.toLocaleString()}
|
||||||
@@ -346,7 +583,7 @@ function TopStoriesList({ articles, className }: { articles: Article[]; classNam
|
|||||||
Zuletzt hinzugefügt
|
Zuletzt hinzugefügt
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{articles.slice(0, 5).map((article) => (
|
{razmakniPoIzvajalcu(articles).slice(0, 5).map((article) => (
|
||||||
<Link key={article.id} href={`/article/${article.slug}`}>
|
<Link key={article.id} href={`/article/${article.slug}`}>
|
||||||
<div className="group cursor-pointer py-1.5 border-b border-card-border last:border-0" data-testid={`card-top-${article.id}`}>
|
<div className="group cursor-pointer py-1.5 border-b border-card-border last:border-0" data-testid={`card-top-${article.id}`}>
|
||||||
<h4 className="text-xs font-medium text-card-foreground line-clamp-2 group-hover:text-primary transition-colors leading-snug">{article.title}</h4>
|
<h4 className="text-xs font-medium text-card-foreground line-clamp-2 group-hover:text-primary transition-colors leading-snug">{article.title}</h4>
|
||||||
@@ -368,7 +605,10 @@ function FeaturedHeroCard({ article, focalPoints }: { article: Article; focalPoi
|
|||||||
return (
|
return (
|
||||||
<Link href={`/article/${article.slug}`}>
|
<Link href={`/article/${article.slug}`}>
|
||||||
<div className="relative group cursor-pointer rounded-lg overflow-hidden bg-card border border-card-border h-full flex flex-col" data-testid={`card-featured-hero-${article.id}`}>
|
<div className="relative group cursor-pointer rounded-lg overflow-hidden bg-card border border-card-border h-full flex flex-col" data-testid={`card-featured-hero-${article.id}`}>
|
||||||
<div className="relative flex-1 min-h-[300px]">
|
{/* Visek visine gre v sliko, ne v prazen prostor pod besedilom. Prej je
|
||||||
|
imela slika fiksno razmerje in se je raztegnil besedilni del, kar je
|
||||||
|
pod novico naredilo velik crn pas. */}
|
||||||
|
<div className="relative flex-none aspect-[16/10] lg:flex-1 lg:aspect-auto lg:min-h-0">
|
||||||
<img src={article.coverImage || ""} alt={article.title} className="w-full h-full object-cover absolute inset-0 transition-transform duration-700 group-hover:scale-105" style={{ objectPosition: objPos }} loading="lazy" />
|
<img src={article.coverImage || ""} alt={article.title} className="w-full h-full object-cover absolute inset-0 transition-transform duration-700 group-hover:scale-105" style={{ objectPosition: objPos }} loading="lazy" />
|
||||||
{isVideo && (
|
{isVideo && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||||
@@ -378,7 +618,7 @@ function FeaturedHeroCard({ article, focalPoints }: { article: Article; focalPoi
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 flex-shrink-0">
|
<div className="p-4 flex-none">
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
<span className="text-xs font-medium text-primary">{article.category}</span>
|
<span className="text-xs font-medium text-primary">{article.category}</span>
|
||||||
<span className="text-muted-foreground text-xs">{timeAgo(new Date(article.publishedAt))}</span>
|
<span className="text-muted-foreground text-xs">{timeAgo(new Date(article.publishedAt))}</span>
|
||||||
@@ -437,14 +677,14 @@ function FeaturedCarousel({ articles, popular, galleryImages, focalPoints }: { a
|
|||||||
const nextPageArticles = articles.slice(nextPageStart, nextPageStart + pageSize);
|
const nextPageArticles = articles.slice(nextPageStart, nextPageStart + pageSize);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section data-testid="featured-carousel" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
<section className="lg:h-full lg:flex lg:flex-col" data-testid="featured-carousel" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
||||||
{nextPageArticles.length > 0 && (
|
{nextPageArticles.length > 0 && (
|
||||||
<div className="hidden" aria-hidden="true">
|
<div className="hidden" aria-hidden="true">
|
||||||
{nextPageArticles.map((a) => a.coverImage && <img key={`preload-${a.id}`} src={a.coverImage} alt="" />)}
|
{nextPageArticles.map((a) => a.coverImage && <img key={`preload-${a.id}`} src={a.coverImage} alt="" />)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4 transition-opacity duration-300"
|
className="grid grid-cols-1 lg:grid-cols-5 lg:grid-rows-1 gap-4 lg:flex-1 lg:min-h-[460px] lg:max-h-[560px] transition-opacity duration-300"
|
||||||
style={{ opacity: fading ? 0 : 1 }}
|
style={{ opacity: fading ? 0 : 1 }}
|
||||||
>
|
>
|
||||||
<div className="lg:col-span-3">
|
<div className="lg:col-span-3">
|
||||||
@@ -457,9 +697,15 @@ function FeaturedCarousel({ articles, popular, galleryImages, focalPoints }: { a
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex justify-center gap-2 mt-3" data-testid="carousel-dots">
|
<div className="flex justify-center items-center gap-2.5 mt-4" data-testid="carousel-dots">
|
||||||
{Array.from({ length: totalPages }).map((_, i) => (
|
{Array.from({ length: totalPages }).map((_, i) => (
|
||||||
<button key={i} onClick={() => setPage(i)} className={`w-2 h-2 rounded-full transition-all duration-300 ${i === page ? "bg-primary w-5" : "bg-muted-foreground/30 hover:bg-muted-foreground/50"}`} data-testid={`button-carousel-dot-${i}`} />
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setPage(i)}
|
||||||
|
aria-label={`Seite ${i + 1}`}
|
||||||
|
className={`h-2.5 rounded-full transition-all duration-300 ${i === page ? "bg-primary w-8" : "bg-foreground/35 hover:bg-foreground/60 w-2.5"}`}
|
||||||
|
data-testid={`button-carousel-dot-${i}`}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -511,38 +757,10 @@ function BentoSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
usePageMeta("Volksmusik & Schlager TV", "FOLX TV – Ihr Fernsehsender Nr. 1 für Volksmusik und Schlager. Aktuelle Nachrichten, Musikvideos, Künstlerporträts, Live-Shows und Interviews aus der Welt der volkstümlichen Musik.");
|
usePageMeta(HOME_TITLE, HOME_DESCRIPTION, { siteName: "FOLX TV" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const jsonLd = {
|
const jsonLd = homeSchema();
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "WebSite",
|
|
||||||
"name": "Folx Music Television",
|
|
||||||
"alternateName": ["FOLX TV", "Volksmusik TV"],
|
|
||||||
"url": "https://folx.tv",
|
|
||||||
"description": "FOLX TV – Ihr Fernsehsender Nr. 1 für Volksmusik und Schlager. Aktuelle Nachrichten, Musikvideos, Künstlerporträts, Live-Shows und Interviews aus der Welt der volkstümlichen Musik.",
|
|
||||||
"publisher": {
|
|
||||||
"@type": "Organization",
|
|
||||||
"name": "Folx Music Television",
|
|
||||||
"url": "https://folx.tv",
|
|
||||||
"logo": {
|
|
||||||
"@type": "ImageObject",
|
|
||||||
"url": `${window.location.origin}/favicon.png`
|
|
||||||
},
|
|
||||||
"sameAs": [
|
|
||||||
"https://www.facebook.com/folxtvmusic",
|
|
||||||
"https://www.instagram.com/folxtv",
|
|
||||||
"https://www.youtube.com/@FolxTV",
|
|
||||||
"https://www.tiktok.com/@folx.tv"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"potentialAction": {
|
|
||||||
"@type": "SearchAction",
|
|
||||||
"target": "https://folx.tv/search?q={search_term_string}",
|
|
||||||
"query-input": "required name=search_term_string"
|
|
||||||
},
|
|
||||||
"inLanguage": "de"
|
|
||||||
};
|
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.type = "application/ld+json";
|
script.type = "application/ld+json";
|
||||||
script.id = "home-jsonld";
|
script.id = "home-jsonld";
|
||||||
@@ -556,14 +774,48 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { data: articles, isLoading } = useQuery<Article[]>({
|
// Prvi paket je majhen in brez polja content, da se naslovnica izrise takoj;
|
||||||
queryKey: ["/api/articles"],
|
// ostalo se dolije v ozadju. Prej je sla naslovnica po vseh 550 clankov s
|
||||||
|
// celotnim besedilom (1,77 MB).
|
||||||
|
const { data: firstBatch, isLoading } = useQuery<Article[]>({
|
||||||
|
queryKey: ["/api/articles?limit=30&light=1"],
|
||||||
});
|
});
|
||||||
|
// 31. 8. 2026: naslovnica je nalozila se vseh 273 preostalih clankov (142 kB)
|
||||||
|
// in jih izrisala v eno neskoncno mrezo. Zdaj se ustavi pri 48, naprej pa
|
||||||
|
// pelje gumb v rubriko, kjer je stranicenje.
|
||||||
|
const { data: restBatch } = useQuery<Article[]>({
|
||||||
|
queryKey: ["/api/articles?offset=30&limit=40&light=1"],
|
||||||
|
enabled: !!firstBatch,
|
||||||
|
});
|
||||||
|
const allArticles = useMemo(() => {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: Article[] = [];
|
||||||
|
for (const a of [...(firstBatch || []), ...(restBatch || [])]) {
|
||||||
|
if (!seen.has(a.id)) { seen.add(a.id); out.push(a); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [firstBatch, restBatch]);
|
||||||
|
|
||||||
|
// Splosne novice imajo svojo rubriko /aktuelles in ne sodijo na naslovnico,
|
||||||
|
// ki ostaja Volksmusik in Schlager.
|
||||||
|
const articles = useMemo(
|
||||||
|
() =>
|
||||||
|
(allArticles || []).filter(
|
||||||
|
(a) => !["Aktuelles", "Politik", "Promis", "Sport", "Musik & Film", "Wetter", "Finanzen"].includes(a.category)
|
||||||
|
),
|
||||||
|
[allArticles]
|
||||||
|
);
|
||||||
|
|
||||||
const { data: popular } = useQuery<Article[]>({
|
const { data: popular } = useQuery<Article[]>({
|
||||||
queryKey: ["/api/articles/popular"],
|
queryKey: ["/api/articles/popular"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Videi v mrezi: gledalca zadrzijo pri nas in odprejo se eno stran z oglasi.
|
||||||
|
const { data: ytData } = useQuery<{ items: YtVideo[] }>({
|
||||||
|
queryKey: ["/api/yt-videos?limit=12"],
|
||||||
|
});
|
||||||
|
const videos = ytData?.items || [];
|
||||||
|
|
||||||
const { data: galleryImages } = useQuery<GalleryImage[]>({
|
const { data: galleryImages } = useQuery<GalleryImage[]>({
|
||||||
queryKey: ["/api/gallery", "home"],
|
queryKey: ["/api/gallery", "home"],
|
||||||
queryFn: () => fetch("/api/gallery?limit=20").then((r) => r.json()),
|
queryFn: () => fetch("/api/gallery?limit=20").then((r) => r.json()),
|
||||||
@@ -573,76 +825,129 @@ export default function Home() {
|
|||||||
|
|
||||||
const shuffled = useMemo(() => {
|
const shuffled = useMemo(() => {
|
||||||
if (!articles) return [];
|
if (!articles) return [];
|
||||||
const arr = [...articles];
|
const key = (id: number) => {
|
||||||
for (let i = arr.length - 1; i > 0; i--) {
|
let h = id * 2654435761 % 4294967296;
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
h ^= h >>> 13; h = (h * 1274126177) % 4294967296;
|
||||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
return h;
|
||||||
}
|
};
|
||||||
return arr;
|
const head = articles.slice(0, 30).sort((a, b) => key(a.id) - key(b.id));
|
||||||
|
const tail = articles.slice(30).sort((a, b) => key(a.id) - key(b.id));
|
||||||
|
return [...head, ...tail];
|
||||||
}, [articles]);
|
}, [articles]);
|
||||||
|
|
||||||
|
// Na naslovnici isti izvajalec ne sme stati drug ob drugem: med dvema clankoma
|
||||||
|
// istega izvajalca mora biti vsaj 5 mest. Clanek ostane v rubriki News in v indeksu.
|
||||||
|
const RAZMIK = 5;
|
||||||
|
const spaced = useMemo(() => {
|
||||||
|
const rest = [...shuffled];
|
||||||
|
const out: Article[] = [];
|
||||||
|
const recent: string[] = [];
|
||||||
|
while (rest.length) {
|
||||||
|
let idx = rest.findIndex((a) => {
|
||||||
|
const k = artistGroup(a.title || "");
|
||||||
|
return !k || !recent.includes(k);
|
||||||
|
});
|
||||||
|
if (idx < 0) idx = 0;
|
||||||
|
const a = rest.splice(idx, 1)[0];
|
||||||
|
out.push(a);
|
||||||
|
recent.push(artistGroup(a.title || ""));
|
||||||
|
if (recent.length >= RAZMIK) recent.shift();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [shuffled]);
|
||||||
|
|
||||||
|
// 30. 8. 2026: horoskop, recept in galerija so vzeti z naslovnice. Po GA4 so
|
||||||
|
// v 28 dneh naredili 242, 60 in 16 ogledov od 96.307, clanki pa 93.001.
|
||||||
|
// Sprosceni prostor gre clankom.
|
||||||
const widgets = useMemo(() => [
|
const widgets = useMemo(() => [
|
||||||
{ id: "horoscope", el: <HoroscopeWidget key="horoscope" /> },
|
|
||||||
{ id: "news", el: <div key="news" className="flex flex-col gap-4"><NewsWidget /></div> },
|
{ id: "news", el: <div key="news" className="flex flex-col gap-4"><NewsWidget /></div> },
|
||||||
{ id: "gallery", el: <PhotoGalleryWidget key="gallery" /> },
|
|
||||||
{ id: "recipe", el: <RecipeWidget key="recipe" /> },
|
|
||||||
{ id: "breaking", el: <div key="breaking" className="flex flex-col gap-4"><BreakingNewsWidget /></div> },
|
{ id: "breaking", el: <div key="breaking" className="flex flex-col gap-4"><BreakingNewsWidget /></div> },
|
||||||
{ id: "gallery2", el: <PhotoGalleryWidget key="gallery2" reverseOrder={true} /> },
|
{ id: "news2", el: <div key="news2" className="flex flex-col gap-4"><NewsWidget startOffset={5} /></div> },
|
||||||
{ id: "horoscope2", el: <HoroscopeWidget key="horoscope2" /> },
|
{ id: "breaking2", el: <div key="breaking2" className="flex flex-col gap-4"><BreakingNewsWidget startOffset={5} /></div> },
|
||||||
{ 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(() => {
|
const gridItems = useMemo(() => {
|
||||||
const items: { type: "article" | "widget" | "ad"; key: string; article?: Article; widget?: typeof widgets[0] }[] = [];
|
const items: { type: "article" | "widget" | "ad"; key: string; article?: Article; widget?: typeof widgets[0] }[] = [];
|
||||||
let ai = 0;
|
let ai = 0;
|
||||||
let wi = 0;
|
let wi = 0;
|
||||||
const widgetRows = Math.ceil(widgets.length / 2);
|
// Pripomocki z zunanjimi viri ne smejo stati skupaj: prej sta bila dva v isti
|
||||||
|
// vrstici in nato spet dva v naslednji, kar je izgledalo kot podvojen blok.
|
||||||
for (let r = 0; r < widgetRows; r++) {
|
// Zdaj gre en pripomocek in za njim pet clankov.
|
||||||
if (wi < widgets.length) items.push({ type: "widget", key: `w-${widgets[wi].id}`, widget: widgets[wi++] });
|
const RAZMIK_PRIPOMOCKOV = 5;
|
||||||
if (wi < widgets.length) items.push({ type: "widget", key: `w-${widgets[wi].id}`, widget: widgets[wi++] });
|
while (wi < widgets.length) {
|
||||||
if (ai < shuffled.length) items.push({ type: "article", key: `a-${shuffled[ai].id}`, article: shuffled[ai++] });
|
items.push({ type: "widget", key: `w-${widgets[wi].id}`, widget: widgets[wi++] });
|
||||||
if (ai < shuffled.length) items.push({ type: "article", key: `a-${shuffled[ai].id}`, article: shuffled[ai++] });
|
for (let k = 0; k < RAZMIK_PRIPOMOCKOV && ai < spaced.length; k++) {
|
||||||
|
items.push({ type: "article", key: `a-${spaced[ai].id}`, article: spaced[ai++] });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while (ai < shuffled.length) {
|
while (ai < spaced.length) {
|
||||||
items.push({ type: "article", key: `a-${shuffled[ai].id}`, article: shuffled[ai++] });
|
items.push({ type: "article", key: `a-${spaced[ai].id}`, article: spaced[ai++] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainder = items.length % 4;
|
const remainder = items.length % 4;
|
||||||
if (remainder > 0 && shuffled.length > 0) {
|
if (remainder > 0 && spaced.length > 0) {
|
||||||
let fill = 0;
|
let fill = 0;
|
||||||
while (items.length % 4 !== 0) {
|
while (items.length % 4 !== 0) {
|
||||||
const art = shuffled[fill % shuffled.length];
|
const art = spaced[fill % spaced.length];
|
||||||
items.push({ type: "article", key: `fill-${fill}`, article: art });
|
items.push({ type: "article", key: `fill-${fill}`, article: art });
|
||||||
fill++;
|
fill++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let adCount = 0;
|
// Oglas gre le v vsako drugo vrstico. Meritev 31. 8. 2026: od 13 enot na
|
||||||
|
// naslovnici jih je 9 vrnilo unfilled, torej je mest vec, kot jih Google
|
||||||
|
// za to stran zapolni. Manj mest z visjo zapolnjenostjo prinese vec.
|
||||||
|
let vidCount = 0;
|
||||||
const totalRows = items.length / 4;
|
const totalRows = items.length / 4;
|
||||||
for (let r = 0; r < totalRows; r++) {
|
for (let r = 0; r < totalRows; r++) {
|
||||||
const rowStart = r * 4;
|
const rowStart = r * 4;
|
||||||
const hasWidget = items.slice(rowStart, rowStart + 4).some((it) => it.type === "widget");
|
const hasWidget = items.slice(rowStart, rowStart + 4).some((it) => it.type === "widget");
|
||||||
if (!hasWidget) {
|
if (hasWidget) continue;
|
||||||
const pos = adCount % 2 === 0 ? 0 : 3;
|
// 31. 8. 2026 zvecer: enota 4126524750 je v AdSense se vedno Responsiv in
|
||||||
items[rowStart + pos] = { type: "ad", key: `ad-row-${adCount++}` };
|
// se v celici 300 px izrise kot 548 ali 600 px, zato vrstico razteguje.
|
||||||
|
// Do popravka enote je oglas iz mreze umaknjen; ostane vodoravni pas.
|
||||||
|
if (r > 0 && videos.length) {
|
||||||
|
// video namesto zadnjega clanka v vrstici brez oglasa
|
||||||
|
const v = videos[vidCount % videos.length];
|
||||||
|
vidCount += 1;
|
||||||
|
items[rowStart + 2] = { type: "video", key: `v-${v.ytId}`, video: v };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [shuffled, widgets]);
|
}, [spaced, widgets, videos]);
|
||||||
|
|
||||||
|
const NA_NASLOVNICI = 48;
|
||||||
const gridRows: (typeof gridItems)[] = useMemo(() => {
|
const gridRows: (typeof gridItems)[] = useMemo(() => {
|
||||||
const rows: (typeof gridItems)[] = [];
|
const rows: (typeof gridItems)[] = [];
|
||||||
for (let i = 0; i < gridItems.length; i += 4) {
|
let clankov = 0;
|
||||||
rows.push(gridItems.slice(i, i + 4));
|
const omejeno = gridItems.filter((it) => {
|
||||||
|
if (it.type !== "article") return true;
|
||||||
|
clankov += 1;
|
||||||
|
return clankov <= NA_NASLOVNICI;
|
||||||
|
});
|
||||||
|
// Zadnja vrstica ne sme biti nepopolna, sicer ostanejo prazne celice.
|
||||||
|
const polne = omejeno.slice(0, omejeno.length - (omejeno.length % 4));
|
||||||
|
for (let i = 0; i < polne.length; i += 4) {
|
||||||
|
rows.push(polne.slice(i, i + 4));
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}, [gridItems]);
|
}, [gridItems]);
|
||||||
|
|
||||||
|
// Siroka postavitev z dvema novicama: prej samo na koncu mreze, zdaj po
|
||||||
|
// vsaki tretji vrstici. Clanke jemljemo izza omejitve naslovnice, da se
|
||||||
|
// nobeden ne ponovi.
|
||||||
|
const widePairs = useMemo(() => {
|
||||||
|
const vMrezi = new Set(spaced.slice(0, 48).map((a) => a.id));
|
||||||
|
const ostanek = spaced.filter((a) => !vMrezi.has(a.id));
|
||||||
|
const pari: Article[][] = [];
|
||||||
|
for (let i = 0; i + 1 < ostanek.length && pari.length < 6; i += 2) {
|
||||||
|
pari.push([ostanek[i], ostanek[i + 1]]);
|
||||||
|
}
|
||||||
|
return pari;
|
||||||
|
}, [spaced]);
|
||||||
|
|
||||||
const widePickedArticles = useMemo(() => {
|
const widePickedArticles = useMemo(() => {
|
||||||
if (!articles || articles.length < 3) return [];
|
if (!articles || articles.length < 3) return [];
|
||||||
const carouselIds = new Set(articles.slice(0, 9).map((a) => a.id));
|
const carouselIds = new Set(articles.slice(0, 9).map((a) => a.id));
|
||||||
@@ -673,12 +978,10 @@ export default function Home() {
|
|||||||
const bottomSection = useMemo(() => {
|
const bottomSection = useMemo(() => {
|
||||||
const items: { type: "widget" | "ad" | "article"; el: JSX.Element }[] = [
|
const items: { type: "widget" | "ad" | "article"; el: JSX.Element }[] = [
|
||||||
{ type: "widget", el: <NewsWidget key="bottom-news" /> },
|
{ type: "widget", el: <NewsWidget key="bottom-news" /> },
|
||||||
{ type: "widget", el: <HoroscopeWidget key="bottom-horoscope" /> },
|
|
||||||
{ type: "widget", el: <BreakingNewsWidget key="bottom-breaking" /> },
|
{ type: "widget", el: <BreakingNewsWidget key="bottom-breaking" /> },
|
||||||
{ type: "article", el: <BlogCard key={`bottom-art-0`} article={bottomArticles[0]} focalPoints={focalPoints} /> },
|
{ type: "article", el: <BlogCard key={`bottom-art-0`} article={bottomArticles[0]} focalPoints={focalPoints} /> },
|
||||||
{ type: "ad", el: <div key="ad-bottom-1"><ArticleCardAd /></div> },
|
{ type: "ad", el: <div key="ad-bottom-1"><ArticleCardAd /></div> },
|
||||||
{ type: "article", el: <BlogCard key={`bottom-art-1`} article={bottomArticles[1]} focalPoints={focalPoints} /> },
|
{ type: "article", el: <BlogCard key={`bottom-art-1`} article={bottomArticles[1]} focalPoints={focalPoints} /> },
|
||||||
{ type: "widget", el: <PhotoGalleryWidget key="bottom-gallery" /> },
|
|
||||||
{ type: "article", el: <BlogCard key={`bottom-art-2`} article={bottomArticles[2]} focalPoints={focalPoints} /> },
|
{ type: "article", el: <BlogCard key={`bottom-art-2`} article={bottomArticles[2]} focalPoints={focalPoints} /> },
|
||||||
{ type: "article", el: <BlogCard key={`bottom-art-3`} article={bottomArticles[3]} focalPoints={focalPoints} /> },
|
{ type: "article", el: <BlogCard key={`bottom-art-3`} article={bottomArticles[3]} focalPoints={focalPoints} /> },
|
||||||
{ type: "article", el: <BlogCard key={`bottom-art-4`} article={bottomArticles[4]} focalPoints={focalPoints} /> },
|
{ type: "article", el: <BlogCard key={`bottom-art-4`} article={bottomArticles[4]} focalPoints={focalPoints} /> },
|
||||||
@@ -703,34 +1006,74 @@ export default function Home() {
|
|||||||
<Header />
|
<Header />
|
||||||
<PageSideAds />
|
<PageSideAds />
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 space-y-4">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 space-y-4">
|
||||||
<h1 className="sr-only">FOLX TV – Volksmusik & Schlager Fernsehsender: Nachrichten, Videos und Live-Shows</h1>
|
<h1 className="sr-only">FOLX TV – Schlager & Volksmusik News</h1>
|
||||||
|
|
||||||
|
{/* Prej lastna kopija pasu s trdo visino 90 px in responsive=false:
|
||||||
|
Google je vanjo postavil 320x50 in okrog je ostal siv rob.
|
||||||
|
Zdaj skupna odzivna komponenta - kreativa zapolni sirino, prazna
|
||||||
|
enota pa se sploh ne izrise. */}
|
||||||
|
<TopBannerAd />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||||
<div className="lg:col-span-3 space-y-4">
|
<div className="lg:col-span-3 space-y-4 lg:flex lg:flex-col">
|
||||||
<FeaturedCarousel articles={articles.filter(a => a.featured)} popular={popular} galleryImages={galleryImages} focalPoints={focalPoints} />
|
{/* Prej je bila visina karusela fiksna, desni stolpec pa visji —
|
||||||
|
pod karuselom je zato ostal prazen pas. Zdaj se karusel
|
||||||
|
raztegne do visine stranskega stolpca. */}
|
||||||
|
<div className="hidden md:block lg:flex-1 lg:min-h-0">
|
||||||
|
<FeaturedCarousel articles={razmakniPoIzvajalcu(articles.filter(a => a.featured))} popular={popular} galleryImages={galleryImages} focalPoints={focalPoints} />
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-1 space-y-4">
|
<MobileMosaic
|
||||||
<SidebarWeatherWidget />
|
latest={[...articles].sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())}
|
||||||
{articles && articles.length > 0 && <TopStoriesList articles={[...articles].sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()).slice(0, 5)} />}
|
popular={popular}
|
||||||
|
focalPoints={focalPoints}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-1 space-y-4 lg:flex lg:flex-col">
|
||||||
|
{articles && articles.length > 0 && <TopStoriesList articles={razmakniPoIzvajalcu([...articles].sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())).slice(0, 14)} className="lg:flex-1 lg:min-h-0 overflow-hidden" />}
|
||||||
<SidebarAd />
|
<SidebarAd />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{gridRows.map((row, ri) => (
|
{gridRows.map((row, ri) => {
|
||||||
|
// Oglas je visok kot dve kartici, zato vrstico z oglasom zdruzimo z
|
||||||
|
// naslednjo: oglas zasede levi stolpec cez dve vrstici, ob njem pa
|
||||||
|
// gresta dve vrsti novic (sest kartic). Prej je pod tremi karticami
|
||||||
|
// ob visokem oglasu ostal prazen prostor.
|
||||||
|
// 31. 8. 2026: oglas je zdaj 300x250 in gre v eno samo celico, zato
|
||||||
|
// zdruzevanja dveh vrstic ne rabimo vec.
|
||||||
|
return (
|
||||||
<LazySection key={`row-group-${ri}`} minHeight="280px">
|
<LazySection key={`row-group-${ri}`} minHeight="280px">
|
||||||
<div>
|
<div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
{/* Trdna visina vrstice: brez nje je vsaka vrstica visoka toliko kot
|
||||||
|
najdaljsi izvlecek v njej (izmerjeno 369, 399, 418 px) in stran
|
||||||
|
ob vsakem nalaganju poskakuje. */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:auto-rows-[300px]">
|
||||||
{row.map((item) =>
|
{row.map((item) =>
|
||||||
item.type === "widget"
|
item.type === "widget"
|
||||||
? <div key={item.key}>{item.widget!.el}</div>
|
? <div key={item.key} className="h-full overflow-hidden">{item.widget!.el}</div>
|
||||||
: item.type === "ad"
|
: item.type === "ad"
|
||||||
? <div key={item.key} className="h-full" data-testid={`ad-grid-${item.key}`}><ArticleCardAd /></div>
|
? <div key={item.key} className="h-full empty:hidden" data-testid={`ad-grid-${item.key}`}><ArticleCardAd /></div>
|
||||||
|
: item.type === "video" && item.video
|
||||||
|
? <VideoCard key={item.key} video={item.video} />
|
||||||
: item.article
|
: item.article
|
||||||
? <MediumCard key={item.key} article={item.article} focalPoints={focalPoints} />
|
? <MediumCard key={item.key} article={item.article} focalPoints={focalPoints} />
|
||||||
: null
|
: null
|
||||||
)}
|
)}
|
||||||
|
{ri > 0 && (ri + 1) % 3 === 0 && (
|
||||||
|
<div className="sm:col-span-2 lg:col-span-4 lg:row-auto lg:h-auto self-start">
|
||||||
|
<GridBannerAd />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Siroka dvojica ni kartica in ne sme prevzeti trdne visine
|
||||||
|
vrstice, ker bi pod besedilom ostal prazen pas. */}
|
||||||
|
{ri > 0 && (ri + 1) % 3 === 0 && widePairs[Math.floor(ri / 3) % Math.max(1, widePairs.length)] && (
|
||||||
|
<div className="sm:col-span-2 lg:col-span-4 lg:row-auto lg:h-auto self-start grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<WideCardClassic article={widePairs[Math.floor(ri / 3) % widePairs.length][0]} focalPoints={focalPoints} />
|
||||||
|
<WideCardClassic article={widePairs[Math.floor(ri / 3) % widePairs.length][1]} focalPoints={focalPoints} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{ri === gridRows.length - 1 && widePickedArticles.length > 0 && (
|
{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">
|
<div className="sm:col-span-2 lg:col-span-4 lg:row-auto lg:h-auto self-start grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<WideCardClassic article={widePickedArticles[0]} focalPoints={focalPoints} />
|
<WideCardClassic article={widePickedArticles[0]} focalPoints={focalPoints} />
|
||||||
{widePickedArticles[1] && <WideCardClassic article={widePickedArticles[1]} focalPoints={focalPoints} />}
|
{widePickedArticles[1] && <WideCardClassic article={widePickedArticles[1]} focalPoints={focalPoints} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -738,26 +1081,30 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</LazySection>
|
</LazySection>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<LazySection minHeight="280px">
|
{/* Naslovnica se konca pri 48 clankih, naprej pelje rubrika s stranicenjem. */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="flex justify-center my-8">
|
||||||
{bottomSection.map((item, i) => (
|
<a
|
||||||
<div key={`bottom-${i}`}>
|
href="/category/News"
|
||||||
{item.el}
|
data-testid="link-alle-nachrichten"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-border bg-card px-6 py-3 text-sm font-semibold hover:border-primary hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Alle Nachrichten
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</LazySection>
|
|
||||||
|
|
||||||
<LazySection minHeight="280px">
|
{/* 31. 8. 2026: naslovnica se konca pri gumbu Alle Nachrichten. Bloka
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
pod njim (bottomSection in ponovljena pripomocka) sta odstranjena,
|
||||||
<PhotoGalleryWidget key="extra-gallery" />
|
ker sta podvajala vsebino iz mreze. Za gumbom ostane samo multiplex. */}
|
||||||
<NewsWidget key="extra-news" />
|
{/* Multiplex je zadnjih sedem dni najhitreje rastoca enota (+500 %),
|
||||||
<RecipeWidget key="extra-recipe" />
|
naslovnica ga edina ni imela. */}
|
||||||
<div className="h-full"><ArticleCardAd /></div>
|
<div className="mt-6" data-testid="ad-multiplex-home">
|
||||||
|
<div className="text-[9px] text-muted-foreground/40 text-center pb-1 uppercase tracking-widest">Anzeige</div>
|
||||||
|
<MultiplexAd />
|
||||||
</div>
|
</div>
|
||||||
</LazySection>
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -0,0 +1,949 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
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, SidebarAd, PageSideAds } from "@/components/adsense";
|
||||||
|
import { PhotoGalleryWidget } from "@/components/photo-gallery";
|
||||||
|
import { HoroscopeWidget } from "@/components/horoscope-widget";
|
||||||
|
import { RecipeWidget } from "@/components/recipe-widget";
|
||||||
|
import { NewsWidget } from "@/components/news-widget";
|
||||||
|
import { SidebarWeatherWidget } from "@/components/weather-widget";
|
||||||
|
import { BreakingNewsWidget } from "@/components/breaking-news-widget";
|
||||||
|
import PushPromptBanner from "@/components/push-prompt-banner";
|
||||||
|
import { useState, useEffect, useCallback, useRef, useMemo, lazy, Suspense } from "react";
|
||||||
|
|
||||||
|
function useFocalPoints() {
|
||||||
|
const { data } = useQuery<Record<string, { x: number; y: number }>>({
|
||||||
|
queryKey: ["/api/focal-points"],
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
return data || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmartImage({ src, alt, className = "", focalPoints }: { src: string; alt: string; className?: string; focalPoints?: Record<string, { x: number; y: number }> }) {
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
const [isPortrait, setIsPortrait] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const img = new window.Image();
|
||||||
|
img.onload = () => {
|
||||||
|
if (img.naturalHeight > img.naturalWidth * 1.2) {
|
||||||
|
setIsPortrait(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = src;
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
const fp = focalPoints?.[src];
|
||||||
|
const posStyle = fp
|
||||||
|
? { objectPosition: `${fp.x}% ${fp.y}%` }
|
||||||
|
: { objectPosition: "center 20%" };
|
||||||
|
|
||||||
|
if (isPortrait) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<img src={src} alt="" className="absolute inset-0 w-full h-full object-cover scale-150 blur-2xl brightness-50" aria-hidden="true" />
|
||||||
|
<img ref={imgRef} src={src} alt={alt} className={`relative w-full h-full object-contain z-[1] ${className}`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img ref={imgRef} src={src} alt={alt} className={`w-full h-full object-cover absolute inset-0 ${className}`} style={posStyle} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GalleryImage {
|
||||||
|
folder: string;
|
||||||
|
fileName: string;
|
||||||
|
thumb: string;
|
||||||
|
large: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbUrl(src: string | null): string {
|
||||||
|
if (!src) return "/images/article-1.jpg";
|
||||||
|
if (src.endsWith(".webp")) return src.replace(".webp", "-thumb.webp");
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(date: Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffH = Math.floor(diffMs / 3600000);
|
||||||
|
const diffD = Math.floor(diffMs / 86400000);
|
||||||
|
if (diffH < 1) return "Gerade eben";
|
||||||
|
if (diffH < 24) return `vor ${diffH} Std.`;
|
||||||
|
if (diffD < 7) return `vor ${diffD} T.`;
|
||||||
|
return format(date, "d. MMM yyyy", { locale: de });
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroCard({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
|
||||||
|
const isVideo = article.category === "Video";
|
||||||
|
return (
|
||||||
|
<Link href={`/article/${article.slug}`}>
|
||||||
|
<div className="relative group cursor-pointer rounded-lg overflow-hidden h-full" data-testid={`card-hero-${article.id}`}>
|
||||||
|
<div className="relative h-full min-h-[280px]">
|
||||||
|
<SmartImage src={article.coverImage || "/images/article-1.jpg"} alt={article.title} className="transition-transform duration-700 group-hover:scale-105" focalPoints={focalPoints} />
|
||||||
|
{isVideo && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors shadow-lg">
|
||||||
|
<Play className="w-6 h-6 text-white ml-0.5" fill="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent" />
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs font-medium text-primary bg-primary/20 px-2 py-0.5 rounded">{article.category}</span>
|
||||||
|
<span className="text-white/60 text-xs">{timeAgo(new Date(article.publishedAt))}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white font-bold text-lg md:text-xl leading-tight line-clamp-3">{article.title}</h3>
|
||||||
|
<p className="text-white/50 text-sm mt-1.5 line-clamp-2 max-w-lg hidden md:block">{article.excerpt}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GalleryHeroCard({ images }: { images: GalleryImage[] }) {
|
||||||
|
const [idx, setIdx] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setIdx((i) => (i + 1) % images.length), 10000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [images.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href="/gallery">
|
||||||
|
<div className="relative group cursor-pointer rounded-lg overflow-hidden h-full" data-testid="card-hero-gallery">
|
||||||
|
<div className="relative h-full min-h-[300px]">
|
||||||
|
<img src={images[idx].large || images[idx].thumb} alt={images[idx].fileName} className="w-full h-full object-cover absolute inset-0 transition-opacity duration-1000" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent" />
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs font-medium text-primary bg-primary/20 px-2 py-0.5 rounded flex items-center gap-1">
|
||||||
|
<Images className="w-3 h-3" /> Fotogalerie
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white font-bold text-lg md:text-xl leading-tight">Backstage & Events</h3>
|
||||||
|
<p className="text-white/50 text-sm mt-1.5 hidden md:block">{images.length} exklusive Fotos aus der Welt der Volksmusik</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getObjectPosition(coverImage: string | null, focalPoints?: Record<string, { x: number; y: number }>): string {
|
||||||
|
if (!coverImage || !focalPoints) return "center 25%";
|
||||||
|
const fp = focalPoints[coverImage];
|
||||||
|
if (!fp) return "center 25%";
|
||||||
|
return `${fp.x}% ${fp.y}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MosaicTile({ article, focalPoints, tall = false }: { article: Article; focalPoints?: Record<string, { x: number; y: number }>; tall?: boolean }) {
|
||||||
|
const objPos = getObjectPosition(article.coverImage, focalPoints);
|
||||||
|
return (
|
||||||
|
<Link href={`/article/${article.slug}`}>
|
||||||
|
<div className="relative rounded-lg overflow-hidden" data-testid={`tile-mosaic-${article.id}`}>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(article.coverImage)}
|
||||||
|
alt={article.title}
|
||||||
|
className={`w-full object-cover ${tall ? "aspect-[16/10]" : "aspect-square"}`}
|
||||||
|
style={{ objectPosition: objPos }}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
|
||||||
|
<span className="absolute top-2 left-2 bg-primary text-white text-[10px] font-semibold px-2 py-0.5 rounded">
|
||||||
|
{article.category || "News"}
|
||||||
|
</span>
|
||||||
|
<h3 className={`absolute bottom-2 left-2 right-2 text-white font-semibold leading-snug ${tall ? "text-base line-clamp-2" : "text-xs line-clamp-3"}`}>
|
||||||
|
{article.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MosaicHeading({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<h2 className="text-primary font-bold text-lg leading-none mb-2">{children}</h2>
|
||||||
|
<div className="h-0.5 w-16 bg-primary" />
|
||||||
|
<div className="h-px w-full bg-card-border -mt-px" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileMosaic({ latest, popular, focalPoints }: { latest: Article[]; popular?: Article[]; focalPoints?: Record<string, { x: number; y: number }> }) {
|
||||||
|
const pageSize = 3;
|
||||||
|
const pool = (latest || []).slice(0, 9);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(pool.length / 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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
changePage(page);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || totalPages <= 1) return;
|
||||||
|
const timer = setInterval(() => setPage((p) => (p + 1) % totalPages), 8000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [paused, totalPages]);
|
||||||
|
|
||||||
|
const touchX = useRef<number | null>(null);
|
||||||
|
const resumeTimer = useRef<any>(null);
|
||||||
|
|
||||||
|
const goTo = useCallback((dir: number) => {
|
||||||
|
setPage((p) => (p + dir + totalPages) % totalPages);
|
||||||
|
}, [totalPages]);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
touchX.current = e.touches[0].clientX;
|
||||||
|
setPaused(true);
|
||||||
|
if (resumeTimer.current) clearTimeout(resumeTimer.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||||
|
const x0 = touchX.current;
|
||||||
|
touchX.current = null;
|
||||||
|
if (x0 !== null) {
|
||||||
|
const dx = e.changedTouches[0].clientX - x0;
|
||||||
|
if (Math.abs(dx) > 40) goTo(dx < 0 ? 1 : -1);
|
||||||
|
}
|
||||||
|
if (resumeTimer.current) clearTimeout(resumeTimer.current);
|
||||||
|
resumeTimer.current = setTimeout(() => setPaused(false), 6000);
|
||||||
|
}, [goTo]);
|
||||||
|
|
||||||
|
useEffect(() => () => { if (resumeTimer.current) clearTimeout(resumeTimer.current); }, []);
|
||||||
|
|
||||||
|
const l = pool.slice(displayPage * pageSize, displayPage * pageSize + pageSize);
|
||||||
|
if (l.length === 0) return null;
|
||||||
|
|
||||||
|
const usedIds = new Set(pool.map((a) => a.id));
|
||||||
|
const p = (popular || []).filter((a) => !usedIds.has(a.id)).slice(0, 3);
|
||||||
|
|
||||||
|
const nextStart = ((displayPage + 1) % totalPages) * pageSize;
|
||||||
|
const preload = pool.slice(nextStart, nextStart + pageSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:hidden space-y-6" data-testid="section-mobile-mosaic">
|
||||||
|
<section
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
style={{ touchAction: "pan-y" }}
|
||||||
|
>
|
||||||
|
<MosaicHeading>Neueste Artikel</MosaicHeading>
|
||||||
|
{preload.length > 0 && (
|
||||||
|
<div className="hidden" aria-hidden="true">
|
||||||
|
{preload.map((a) => a.coverImage && <img key={`pre-${a.id}`} src={thumbUrl(a.coverImage)} alt="" />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="transition-opacity duration-300" style={{ opacity: fading ? 0 : 1 }}>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{l.slice(0, 2).map((a) => (
|
||||||
|
<MosaicTile key={a.id} article={a} focalPoints={focalPoints} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{l[2] && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<MosaicTile article={l[2]} focalPoints={focalPoints} tall />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center gap-2 mt-3" data-testid="mosaic-dots">
|
||||||
|
{Array.from({ length: totalPages }).map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setPage(i)}
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ${i === page ? "bg-primary w-5" : "bg-muted-foreground/30 w-2"}`}
|
||||||
|
data-testid={`button-mosaic-dot-${i}`}
|
||||||
|
aria-label={`Seite ${i + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{p.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<MosaicHeading>Beliebt</MosaicHeading>
|
||||||
|
<MosaicTile article={p[0]} focalPoints={focalPoints} tall />
|
||||||
|
{p.length > 1 && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||||
|
{p.slice(1, 3).map((a) => (
|
||||||
|
<MosaicTile key={a.id} article={a} focalPoints={focalPoints} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediumCard({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
|
||||||
|
const isVideo = article.category === "Video";
|
||||||
|
const objPos = getObjectPosition(article.coverImage, focalPoints);
|
||||||
|
return (
|
||||||
|
<Link href={`/article/${article.slug}`}>
|
||||||
|
<div className="relative group cursor-pointer rounded-lg overflow-hidden h-full bg-card border border-card-border flex flex-col" data-testid={`card-medium-${article.id}`}>
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<img src={thumbUrl(article.coverImage)} alt={article.title} className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105" style={{ objectPosition: objPos }} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
{isVideo && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors">
|
||||||
|
<Play className="w-4 h-4 text-white ml-0.5" fill="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3.5 flex flex-col flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="text-[10px] font-medium text-primary">{article.author}</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">{timeAgo(new Date(article.publishedAt))}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-card-foreground text-sm leading-snug line-clamp-2 group-hover:text-primary transition-colors">{article.title}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5 leading-relaxed flex-1">{article.excerpt}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-muted-foreground text-[10px]">
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
{article.views.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WideCard({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
|
||||||
|
const isVideo = article.category === "Video";
|
||||||
|
const objPos = getObjectPosition(article.coverImage, focalPoints);
|
||||||
|
return (
|
||||||
|
<Link href={`/article/${article.slug}`}>
|
||||||
|
<div className="relative group cursor-pointer rounded-lg overflow-hidden bg-card border border-card-border h-full" data-testid={`card-wide-${article.id}`}>
|
||||||
|
<div className="relative aspect-[16/9] overflow-hidden">
|
||||||
|
<img src={thumbUrl(article.coverImage)} alt={article.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" style={{ objectPosition: objPos }} loading="lazy" />
|
||||||
|
{isVideo && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors">
|
||||||
|
<Play className="w-5 h-5 text-white ml-0.5" fill="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="text-xs font-medium text-primary">{article.author}</span>
|
||||||
|
<span className="text-white/60 text-xs">{timeAgo(new Date(article.publishedAt))}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-white text-base leading-snug line-clamp-2 group-hover:text-primary transition-colors">{article.title}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-white/50 text-[10px]">
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
{article.views.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WideCardClassic({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
|
||||||
|
const isVideo = article.category === "Video";
|
||||||
|
const objPos = getObjectPosition(article.coverImage, focalPoints);
|
||||||
|
return (
|
||||||
|
<Link href={`/article/${article.slug}`}>
|
||||||
|
<div className="relative group cursor-pointer rounded-lg overflow-hidden bg-card border border-card-border flex flex-col sm:flex-row h-full" data-testid={`card-wide-classic-${article.id}`}>
|
||||||
|
<div className="relative flex-shrink-0 sm:w-1/2">
|
||||||
|
<div className="overflow-hidden h-full">
|
||||||
|
<img src={thumbUrl(article.coverImage)} alt={article.title} className="w-full h-full aspect-video sm:aspect-auto object-cover transition-transform duration-500 group-hover:scale-105" style={{ objectPosition: objPos, minHeight: "100%" }} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
{isVideo && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors">
|
||||||
|
<Play className="w-5 h-5 text-white ml-0.5" fill="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 sm:p-5 flex flex-col flex-1 justify-center">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs font-medium text-primary">{article.author}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">{timeAgo(new Date(article.publishedAt))}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-card-foreground text-lg leading-snug line-clamp-2 group-hover:text-primary transition-colors">{article.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 leading-relaxed line-clamp-4 flex-1">{article.excerpt}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-3 text-muted-foreground text-[10px]">
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
{article.views.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlogCard({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
|
||||||
|
const isVideo = article.category === "Video";
|
||||||
|
const objPos = getObjectPosition(article.coverImage, focalPoints);
|
||||||
|
return (
|
||||||
|
<Link href={`/article/${article.slug}`}>
|
||||||
|
<div className="relative group cursor-pointer rounded-lg overflow-hidden bg-card border border-card-border flex flex-col" data-testid={`card-blog-${article.id}`}>
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<img src={article.coverImage} alt={article.title} className="w-full aspect-[16/9] object-cover transition-transform duration-500 group-hover:scale-105" style={{ objectPosition: objPos }} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
{isVideo && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors">
|
||||||
|
<Play className="w-5 h-5 text-white ml-0.5" fill="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex flex-col flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs font-medium text-primary">{article.author}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">{timeAgo(new Date(article.publishedAt))}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-card-foreground text-base leading-snug line-clamp-2 group-hover:text-primary transition-colors">{article.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 leading-relaxed line-clamp-3 flex-1">{article.excerpt}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-3 text-muted-foreground text-xs">
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
{article.views.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SideCard({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
|
||||||
|
const isVideo = article.category === "Video";
|
||||||
|
const objPos = getObjectPosition(article.coverImage, focalPoints);
|
||||||
|
return (
|
||||||
|
<Link href={`/article/${article.slug}`}>
|
||||||
|
<div className="relative group cursor-pointer rounded-lg overflow-hidden bg-card border border-card-border h-full flex flex-col" data-testid={`card-side-${article.id}`}>
|
||||||
|
<div className="relative flex-1 min-h-0">
|
||||||
|
<div className="overflow-hidden h-full">
|
||||||
|
<img src={thumbUrl(article.coverImage)} alt={article.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" style={{ objectPosition: objPos }} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
{isVideo && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors">
|
||||||
|
<Play className="w-4 h-4 text-white ml-0.5" fill="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-medium text-primary">{article.author}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">{timeAgo(new Date(article.publishedAt))}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-card-foreground text-sm leading-snug line-clamp-2 group-hover:text-primary transition-colors">{article.title}</h3>
|
||||||
|
<div className="flex items-center gap-1.5 mt-2 text-muted-foreground text-xs">
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
{article.views.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeAdCard() {
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-lg overflow-hidden h-full bg-card border border-card-border" data-testid="card-native-ad">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="overflow-hidden aspect-video bg-muted">
|
||||||
|
<AdSense
|
||||||
|
slot="3854634730"
|
||||||
|
format="fluid"
|
||||||
|
layoutKey="-7o+et-x-1e+6a"
|
||||||
|
style={{ display: "block" }}
|
||||||
|
className="w-full h-full min-h-[160px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopStoriesList({ articles, className }: { articles: Article[]; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-card rounded-lg border border-card-border p-4 ${className || ""}`} data-testid="sidebar-top-stories">
|
||||||
|
<h3 className="font-bold text-card-foreground text-sm mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-4 bg-primary rounded-full" />
|
||||||
|
Zuletzt hinzugefügt
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0">
|
||||||
|
{articles.slice(0, 5).map((article) => (
|
||||||
|
<Link key={article.id} href={`/article/${article.slug}`}>
|
||||||
|
<div className="group cursor-pointer py-1.5 border-b border-card-border last:border-0" data-testid={`card-top-${article.id}`}>
|
||||||
|
<h4 className="text-xs font-medium text-card-foreground line-clamp-2 group-hover:text-primary transition-colors leading-snug">{article.title}</h4>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">{article.author}</span>
|
||||||
|
<span className="text-muted-foreground/50 text-[10px]">{timeAgo(new Date(article.publishedAt))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeaturedHeroCard({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
|
||||||
|
const isVideo = article.category === "Video";
|
||||||
|
const objPos = getObjectPosition(article.coverImage, focalPoints);
|
||||||
|
return (
|
||||||
|
<Link href={`/article/${article.slug}`}>
|
||||||
|
<div className="relative group cursor-pointer rounded-lg overflow-hidden bg-card border border-card-border h-full flex flex-col" data-testid={`card-featured-hero-${article.id}`}>
|
||||||
|
<div className="relative flex-none aspect-[16/10] min-h-[300px]">
|
||||||
|
<img src={article.coverImage || ""} alt={article.title} className="w-full h-full object-cover absolute inset-0 transition-transform duration-700 group-hover:scale-105" style={{ objectPosition: objPos }} loading="lazy" />
|
||||||
|
{isVideo && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors shadow-lg">
|
||||||
|
<Play className="w-6 h-6 text-white ml-0.5" fill="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="text-xs font-medium text-primary">{article.category}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">{timeAgo(new Date(article.publishedAt))}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-card-foreground text-lg leading-tight line-clamp-3 group-hover:text-primary transition-colors">{article.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 leading-relaxed line-clamp-3">{article.excerpt}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-3 text-muted-foreground text-xs">
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
{article.views.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeaturedCarousel({ articles, popular, galleryImages, focalPoints }: { articles: Article[]; popular?: Article[]; galleryImages?: GalleryImage[]; focalPoints?: Record<string, { x: number; y: number }> }) {
|
||||||
|
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) => {
|
||||||
|
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 = 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)}>
|
||||||
|
{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>
|
||||||
|
<div className="lg:col-span-2 grid grid-rows-2 gap-4">
|
||||||
|
{sideCards.map((a) => (
|
||||||
|
<SideCard key={a.id} article={a} focalPoints={focalPoints} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center gap-2 mt-3" data-testid="carousel-dots">
|
||||||
|
{Array.from({ length: totalPages }).map((_, i) => (
|
||||||
|
<button key={i} onClick={() => setPage(i)} className={`w-2 h-2 rounded-full transition-all duration-300 ${i === page ? "bg-primary w-5" : "bg-muted-foreground/30 hover:bg-muted-foreground/50"}`} data-testid={`button-carousel-dot-${i}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LazySection({ children, minHeight = "200px" }: { children: JSX.Element; minHeight?: string }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setVisible(true);
|
||||||
|
obs.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "300px" }
|
||||||
|
);
|
||||||
|
obs.observe(el);
|
||||||
|
return () => obs.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ minHeight: visible ? undefined : minHeight }}>
|
||||||
|
{visible ? children : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BentoSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-6 gap-4">
|
||||||
|
<div className="col-span-6 lg:col-span-3"><Skeleton className="w-full h-[380px] rounded-lg" /></div>
|
||||||
|
<div className="col-span-6 lg:col-span-3 grid grid-cols-2 gap-4">
|
||||||
|
<Skeleton className="w-full h-[180px] rounded-lg" />
|
||||||
|
<Skeleton className="w-full h-[180px] rounded-lg" />
|
||||||
|
<Skeleton className="w-full h-[180px] rounded-lg" />
|
||||||
|
<Skeleton className="w-full h-[180px] rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
usePageMeta("Volksmusik & Schlager TV", "FOLX TV – Ihr Fernsehsender Nr. 1 für Volksmusik und Schlager. Aktuelle Nachrichten, Musikvideos, Künstlerporträts, Live-Shows und Interviews aus der Welt der volkstümlichen Musik.");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "Folx Music Television",
|
||||||
|
"alternateName": ["FOLX TV", "Volksmusik TV"],
|
||||||
|
"url": "https://folx.tv",
|
||||||
|
"description": "FOLX TV – Ihr Fernsehsender Nr. 1 für Volksmusik und Schlager. Aktuelle Nachrichten, Musikvideos, Künstlerporträts, Live-Shows und Interviews aus der Welt der volkstümlichen Musik.",
|
||||||
|
"publisher": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "Folx Music Television",
|
||||||
|
"url": "https://folx.tv",
|
||||||
|
"logo": {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
"url": `${window.location.origin}/favicon.png`
|
||||||
|
},
|
||||||
|
"sameAs": [
|
||||||
|
"https://www.facebook.com/folxtvmusic",
|
||||||
|
"https://www.instagram.com/folxtv",
|
||||||
|
"https://www.youtube.com/@FolxTV",
|
||||||
|
"https://www.tiktok.com/@folx.tv"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"potentialAction": {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
"target": "https://folx.tv/search?q={search_term_string}",
|
||||||
|
"query-input": "required name=search_term_string"
|
||||||
|
},
|
||||||
|
"inLanguage": "de"
|
||||||
|
};
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.type = "application/ld+json";
|
||||||
|
script.id = "home-jsonld";
|
||||||
|
script.textContent = JSON.stringify(jsonLd);
|
||||||
|
const existing = document.getElementById("home-jsonld");
|
||||||
|
if (existing) existing.remove();
|
||||||
|
document.head.appendChild(script);
|
||||||
|
return () => {
|
||||||
|
const el = document.getElementById("home-jsonld");
|
||||||
|
if (el) el.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data: allArticles, isLoading } = useQuery<Article[]>({
|
||||||
|
queryKey: ["/api/articles"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Splosne novice imajo svojo rubriko /aktuelles in ne sodijo na naslovnico,
|
||||||
|
// ki ostaja Volksmusik in Schlager.
|
||||||
|
const articles = useMemo(
|
||||||
|
() =>
|
||||||
|
(allArticles || []).filter(
|
||||||
|
(a) => !["Aktuelles", "Politik", "Promis", "Sport", "Musik & Film", "Wetter", "Finanzen"].includes(a.category)
|
||||||
|
),
|
||||||
|
[allArticles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: popular } = useQuery<Article[]>({
|
||||||
|
queryKey: ["/api/articles/popular"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: galleryImages } = useQuery<GalleryImage[]>({
|
||||||
|
queryKey: ["/api/gallery", "home"],
|
||||||
|
queryFn: () => fetch("/api/gallery?limit=20").then((r) => r.json()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const focalPoints = useFocalPoints();
|
||||||
|
|
||||||
|
const shuffled = useMemo(() => {
|
||||||
|
if (!articles) return [];
|
||||||
|
const arr = [...articles];
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, [articles]);
|
||||||
|
|
||||||
|
const widgets = useMemo(() => [
|
||||||
|
{ id: "horoscope", el: <HoroscopeWidget key="horoscope" /> },
|
||||||
|
{ id: "news", el: <div key="news" className="flex flex-col gap-4"><NewsWidget /></div> },
|
||||||
|
{ id: "gallery", el: <PhotoGalleryWidget key="gallery" /> },
|
||||||
|
{ 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(() => {
|
||||||
|
const items: { type: "article" | "widget" | "ad"; key: string; article?: Article; widget?: typeof widgets[0] }[] = [];
|
||||||
|
let ai = 0;
|
||||||
|
let wi = 0;
|
||||||
|
const widgetRows = Math.ceil(widgets.length / 2);
|
||||||
|
|
||||||
|
for (let r = 0; r < widgetRows; r++) {
|
||||||
|
if (wi < widgets.length) items.push({ type: "widget", key: `w-${widgets[wi].id}`, widget: widgets[wi++] });
|
||||||
|
if (wi < widgets.length) items.push({ type: "widget", key: `w-${widgets[wi].id}`, widget: widgets[wi++] });
|
||||||
|
if (ai < shuffled.length) items.push({ type: "article", key: `a-${shuffled[ai].id}`, article: shuffled[ai++] });
|
||||||
|
if (ai < shuffled.length) items.push({ type: "article", key: `a-${shuffled[ai].id}`, article: shuffled[ai++] });
|
||||||
|
}
|
||||||
|
|
||||||
|
while (ai < shuffled.length) {
|
||||||
|
items.push({ type: "article", key: `a-${shuffled[ai].id}`, article: shuffled[ai++] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainder = items.length % 4;
|
||||||
|
if (remainder > 0 && shuffled.length > 0) {
|
||||||
|
let fill = 0;
|
||||||
|
while (items.length % 4 !== 0) {
|
||||||
|
const art = shuffled[fill % shuffled.length];
|
||||||
|
items.push({ type: "article", key: `fill-${fill}`, article: art });
|
||||||
|
fill++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let adCount = 0;
|
||||||
|
const totalRows = items.length / 4;
|
||||||
|
for (let r = 0; r < totalRows; r++) {
|
||||||
|
const rowStart = r * 4;
|
||||||
|
const hasWidget = items.slice(rowStart, rowStart + 4).some((it) => it.type === "widget");
|
||||||
|
if (!hasWidget) {
|
||||||
|
const pos = adCount % 2 === 0 ? 0 : 3;
|
||||||
|
items[rowStart + pos] = { type: "ad", key: `ad-row-${adCount++}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [shuffled, widgets]);
|
||||||
|
|
||||||
|
const gridRows: (typeof gridItems)[] = useMemo(() => {
|
||||||
|
const rows: (typeof gridItems)[] = [];
|
||||||
|
for (let i = 0; i < gridItems.length; i += 4) {
|
||||||
|
rows.push(gridItems.slice(i, i + 4));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, [gridItems]);
|
||||||
|
|
||||||
|
const widePickedArticles = useMemo(() => {
|
||||||
|
if (!articles || articles.length < 3) return [];
|
||||||
|
const carouselIds = new Set(articles.slice(0, 9).map((a) => a.id));
|
||||||
|
const candidates = articles.filter((a) => !carouselIds.has(a.id));
|
||||||
|
const pool = candidates.length >= 2 ? candidates : articles;
|
||||||
|
const copy = [...pool];
|
||||||
|
for (let i = copy.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||||
|
}
|
||||||
|
return copy.slice(0, 2);
|
||||||
|
}, [articles]);
|
||||||
|
|
||||||
|
const bottomArticles = useMemo(() => {
|
||||||
|
if (!articles || articles.length === 0) return [];
|
||||||
|
const carouselIds = new Set(articles.slice(0, 9).map((a) => a.id));
|
||||||
|
const nonCarousel = articles.filter((a) => !carouselIds.has(a.id));
|
||||||
|
const pool = nonCarousel.length > 0 ? nonCarousel : articles;
|
||||||
|
const result: Article[] = [];
|
||||||
|
let idx = 0;
|
||||||
|
while (result.length < 9) {
|
||||||
|
result.push(pool[idx % pool.length]);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [articles]);
|
||||||
|
|
||||||
|
const bottomSection = useMemo(() => {
|
||||||
|
const items: { type: "widget" | "ad" | "article"; el: JSX.Element }[] = [
|
||||||
|
{ type: "widget", el: <NewsWidget key="bottom-news" /> },
|
||||||
|
{ type: "widget", el: <HoroscopeWidget key="bottom-horoscope" /> },
|
||||||
|
{ type: "widget", el: <BreakingNewsWidget key="bottom-breaking" /> },
|
||||||
|
{ type: "article", el: <BlogCard key={`bottom-art-0`} article={bottomArticles[0]} focalPoints={focalPoints} /> },
|
||||||
|
{ type: "ad", el: <div key="ad-bottom-1"><ArticleCardAd /></div> },
|
||||||
|
{ type: "article", el: <BlogCard key={`bottom-art-1`} article={bottomArticles[1]} focalPoints={focalPoints} /> },
|
||||||
|
{ type: "widget", el: <PhotoGalleryWidget key="bottom-gallery" /> },
|
||||||
|
{ type: "article", el: <BlogCard key={`bottom-art-2`} article={bottomArticles[2]} focalPoints={focalPoints} /> },
|
||||||
|
{ type: "article", el: <BlogCard key={`bottom-art-3`} article={bottomArticles[3]} focalPoints={focalPoints} /> },
|
||||||
|
{ type: "article", el: <BlogCard key={`bottom-art-4`} article={bottomArticles[4]} focalPoints={focalPoints} /> },
|
||||||
|
{ type: "ad", el: <div key="ad-bottom-2"><ArticleCardAd /></div> },
|
||||||
|
{ type: "article", el: <BlogCard key={`bottom-art-5`} article={bottomArticles[5]} focalPoints={focalPoints} /> },
|
||||||
|
];
|
||||||
|
return items;
|
||||||
|
}, [bottomArticles, focalPoints]);
|
||||||
|
|
||||||
|
if (isLoading || !articles) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4"><BentoSkeleton /></main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
<PageSideAds />
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 space-y-4">
|
||||||
|
<h1 className="sr-only">FOLX TV – Volksmusik & Schlager Fernsehsender: Nachrichten, Videos und Live-Shows</h1>
|
||||||
|
|
||||||
|
<div className="rounded-md overflow-hidden bg-card border border-card-border" data-testid="ad-top-banner">
|
||||||
|
<div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest">Anzeige</div>
|
||||||
|
<AdSense
|
||||||
|
slot="4154017639"
|
||||||
|
format="horizontal"
|
||||||
|
style={{ display: "block", width: "100%", height: "90px" }}
|
||||||
|
responsive={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="lg:col-span-3 space-y-4">
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<FeaturedCarousel articles={articles.filter(a => a.featured)} popular={popular} galleryImages={galleryImages} focalPoints={focalPoints} />
|
||||||
|
</div>
|
||||||
|
<MobileMosaic
|
||||||
|
latest={[...articles].sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())}
|
||||||
|
popular={popular}
|
||||||
|
focalPoints={focalPoints}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-1 space-y-4">
|
||||||
|
<SidebarWeatherWidget />
|
||||||
|
{articles && articles.length > 0 && <TopStoriesList articles={[...articles].sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()).slice(0, 5)} />}
|
||||||
|
<SidebarAd />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gridRows.map((row, ri) => (
|
||||||
|
<LazySection key={`row-group-${ri}`} minHeight="280px">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</LazySection>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<LazySection minHeight="280px">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{bottomSection.map((item, i) => (
|
||||||
|
<div key={`bottom-${i}`}>
|
||||||
|
{item.el}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</LazySection>
|
||||||
|
|
||||||
|
<LazySection minHeight="280px">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<PhotoGalleryWidget key="extra-gallery" />
|
||||||
|
<NewsWidget key="extra-news" />
|
||||||
|
<RecipeWidget key="extra-recipe" />
|
||||||
|
<div className="h-full"><ArticleCardAd /></div>
|
||||||
|
</div>
|
||||||
|
</LazySection>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
<PushPromptBanner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,12 +21,10 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import InterstitialAd from "@/components/interstitial-ad";
|
import { InArticleAd, PageSideAds, MultiplexAd } from "@/components/adsense";
|
||||||
import { InArticleAd, PageSideAds } from "@/components/adsense";
|
|
||||||
import {
|
import {
|
||||||
SIGNS,
|
SIGNS,
|
||||||
ELEMENT_COLORS,
|
ELEMENT_COLORS,
|
||||||
ASTRO_EVENTS,
|
|
||||||
getHoroscope as getStaticHoroscope,
|
getHoroscope as getStaticHoroscope,
|
||||||
getRating,
|
getRating,
|
||||||
getLuckyNumbers,
|
getLuckyNumbers,
|
||||||
@@ -43,6 +41,7 @@ interface AIHoroscope {
|
|||||||
tip: string;
|
tip: string;
|
||||||
weekly: string;
|
weekly: string;
|
||||||
monthly: string;
|
monthly: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarRating({ count }: { count: number }) {
|
function StarRating({ count }: { count: number }) {
|
||||||
@@ -55,70 +54,6 @@ function StarRating({ count }: { count: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventIcon(icon: string) {
|
|
||||||
switch (icon) {
|
|
||||||
case "mercury": return <AlertTriangle className="w-5 h-5 text-amber-400" />;
|
|
||||||
case "moon": return <Moon className="w-5 h-5 text-blue-300" />;
|
|
||||||
case "venus": return <Heart className="w-5 h-5 text-pink-400" />;
|
|
||||||
case "sun": return <Sun className="w-5 h-5 text-amber-300" />;
|
|
||||||
case "saturn": return <Briefcase className="w-5 h-5 text-slate-400" />;
|
|
||||||
case "newmoon": return <Moon className="w-5 h-5 text-violet-400" />;
|
|
||||||
default: return <Star className="w-5 h-5 text-primary" />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEventColor(type: string) {
|
|
||||||
switch (type) {
|
|
||||||
case "retrograde": return { bg: "bg-amber-500/10", border: "border-amber-500/20", accent: "text-amber-400" };
|
|
||||||
case "moon": return { bg: "bg-blue-500/10", border: "border-blue-500/20", accent: "text-blue-400" };
|
|
||||||
case "transit": return { bg: "bg-pink-500/10", border: "border-pink-500/20", accent: "text-pink-400" };
|
|
||||||
case "season": return { bg: "bg-emerald-500/10", border: "border-emerald-500/20", accent: "text-emerald-400" };
|
|
||||||
default: return { bg: "bg-primary/10", border: "border-primary/20", accent: "text-primary" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AstroEventsSection() {
|
|
||||||
return (
|
|
||||||
<section className="mb-10" data-testid="section-astro-events">
|
|
||||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2 mb-4">
|
|
||||||
<Sparkles className="w-5 h-5 text-primary" />
|
|
||||||
Aktuelle kosmische Ereignisse
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{ASTRO_EVENTS.map((event, i) => {
|
|
||||||
const ec = getEventColor(event.type);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`${ec.bg} border ${ec.border} rounded-xl p-4 transition-all hover:scale-[1.02]`}
|
|
||||||
data-testid={`card-astro-event-${i}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3 mb-2">
|
|
||||||
{getEventIcon(event.icon)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className={`font-semibold text-sm ${ec.accent}`}>{event.title}</h3>
|
|
||||||
<p className="text-[11px] text-muted-foreground">{event.dateRange}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-foreground/70 leading-relaxed mb-3">{event.description}</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{event.affectedSigns.map((s) => {
|
|
||||||
const sign = SIGNS.find((x) => x.name === s);
|
|
||||||
return (
|
|
||||||
<span key={s} className="text-[10px] bg-white/5 border border-white/10 rounded px-1.5 py-0.5 text-muted-foreground">
|
|
||||||
{sign?.symbol} {s}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SignGrid({ onSelect, selectedIndex, aiHoroscopes }: { onSelect: (i: number) => void; selectedIndex: number | null; aiHoroscopes: AIHoroscope[] }) {
|
function SignGrid({ onSelect, selectedIndex, aiHoroscopes }: { onSelect: (i: number) => void; selectedIndex: number | null; aiHoroscopes: AIHoroscope[] }) {
|
||||||
return (
|
return (
|
||||||
<section className="mb-10" data-testid="section-sign-grid">
|
<section className="mb-10" data-testid="section-sign-grid">
|
||||||
@@ -126,7 +61,7 @@ function SignGrid({ onSelect, selectedIndex, aiHoroscopes }: { onSelect: (i: num
|
|||||||
<Star className="w-5 h-5 text-primary" />
|
<Star className="w-5 h-5 text-primary" />
|
||||||
Ihr Sternzeichen wählen
|
Ihr Sternzeichen wählen
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
{SIGNS.map((sign, i) => {
|
{SIGNS.map((sign, i) => {
|
||||||
const ec = ELEMENT_COLORS[sign.element];
|
const ec = ELEMENT_COLORS[sign.element];
|
||||||
const aiH = aiHoroscopes.find(h => h.signIndex === i);
|
const aiH = aiHoroscopes.find(h => h.signIndex === i);
|
||||||
@@ -143,14 +78,22 @@ function SignGrid({ onSelect, selectedIndex, aiHoroscopes }: { onSelect: (i: num
|
|||||||
}`}
|
}`}
|
||||||
data-testid={`button-sign-grid-${sign.name.toLowerCase()}`}
|
data-testid={`button-sign-grid-${sign.name.toLowerCase()}`}
|
||||||
>
|
>
|
||||||
|
{(horoscope as any).imageUrl && (
|
||||||
|
<img
|
||||||
|
src={(horoscope as any).imageUrl}
|
||||||
|
alt={`Sternzeichen ${sign.name}`}
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-40 md:h-44 object-cover rounded-lg mb-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-2xl">{sign.symbol}</span>
|
<span className="text-2xl">{sign.symbol}</span>
|
||||||
<div>
|
<div>
|
||||||
<span className={`text-sm font-semibold block ${isSelected ? ec.text : "text-foreground"}`}>{sign.name}</span>
|
<span className={`text-base font-semibold block ${isSelected ? ec.text : "text-foreground"}`}>{sign.name}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">{sign.date}</span>
|
<span className="text-[11px] text-muted-foreground">{sign.date}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-foreground/60 leading-relaxed line-clamp-3">{horoscope.general.substring(0, 100)}...</p>
|
<p className="text-xs text-foreground/70 leading-relaxed line-clamp-4">{horoscope.general.substring(0, 180)}...</p>
|
||||||
<div className="flex items-center gap-1 mt-2 text-[10px] text-primary">
|
<div className="flex items-center gap-1 mt-2 text-[10px] text-primary">
|
||||||
Lesen <ArrowRight className="w-3 h-3" />
|
Lesen <ArrowRight className="w-3 h-3" />
|
||||||
</div>
|
</div>
|
||||||
@@ -172,6 +115,7 @@ function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number
|
|||||||
const dailyColor = getDailyColor(signIndex);
|
const dailyColor = getDailyColor(signIndex);
|
||||||
const detailRef = useRef<HTMLDivElement>(null);
|
const detailRef = useRef<HTMLDivElement>(null);
|
||||||
const isAI = !!aiH;
|
const isAI = !!aiH;
|
||||||
|
const imgFor = (i: number) => aiHoroscopes.find((h) => h.signIndex === i)?.imageUrl || null;
|
||||||
|
|
||||||
const prevIndex = (signIndex - 1 + SIGNS.length) % SIGNS.length;
|
const prevIndex = (signIndex - 1 + SIGNS.length) % SIGNS.length;
|
||||||
const nextIndex = (signIndex + 1) % SIGNS.length;
|
const nextIndex = (signIndex + 1) % SIGNS.length;
|
||||||
@@ -209,6 +153,15 @@ function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number
|
|||||||
|
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
<div className="bg-card rounded-xl border border-card-border p-6 md:p-8" data-testid={`card-horoscope-${sign.name.toLowerCase()}`}>
|
<div className="bg-card rounded-xl border border-card-border p-6 md:p-8" data-testid={`card-horoscope-${sign.name.toLowerCase()}`}>
|
||||||
|
{(horoscope as any).imageUrl && (
|
||||||
|
<img
|
||||||
|
src={(horoscope as any).imageUrl}
|
||||||
|
alt={`Tageshoroskop ${sign.name}`}
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full rounded-xl mb-6 object-cover aspect-[3/2]"
|
||||||
|
data-testid="img-horoscope-sign"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex items-start gap-4 mb-6">
|
<div className="flex items-start gap-4 mb-6">
|
||||||
<div className={`w-20 h-20 rounded-2xl ${ec.bg} border ${ec.border} flex items-center justify-center flex-shrink-0`}>
|
<div className={`w-20 h-20 rounded-2xl ${ec.bg} border ${ec.border} flex items-center justify-center flex-shrink-0`}>
|
||||||
<span className="text-5xl">{sign.symbol}</span>
|
<span className="text-5xl">{sign.symbol}</span>
|
||||||
@@ -276,6 +229,7 @@ function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ minHeight: "180px" }}>
|
||||||
{tab === "daily" && (
|
{tab === "daily" && (
|
||||||
<div className="space-y-5" data-testid="section-horoscope-daily">
|
<div className="space-y-5" data-testid="section-horoscope-daily">
|
||||||
<div>
|
<div>
|
||||||
@@ -311,6 +265,7 @@ function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<InArticleAd />
|
<InArticleAd />
|
||||||
|
|
||||||
@@ -354,6 +309,14 @@ function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number
|
|||||||
className={`${sEc.bg} hover:opacity-80 border ${sEc.border} rounded-lg p-3 transition-all text-left`}
|
className={`${sEc.bg} hover:opacity-80 border ${sEc.border} rounded-lg p-3 transition-all text-left`}
|
||||||
data-testid={`button-sign-other-${s.name.toLowerCase()}`}
|
data-testid={`button-sign-other-${s.name.toLowerCase()}`}
|
||||||
>
|
>
|
||||||
|
{imgFor(origIdx) && (
|
||||||
|
<img
|
||||||
|
src={imgFor(origIdx)!}
|
||||||
|
alt={`Tageshoroskop ${s.name}`}
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-20 object-cover rounded-md mb-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-xl">{s.symbol}</span>
|
<span className="text-xl">{s.symbol}</span>
|
||||||
<span className={`font-medium text-sm ${sEc.text}`}>{s.name}</span>
|
<span className={`font-medium text-sm ${sEc.text}`}>{s.name}</span>
|
||||||
@@ -370,22 +333,47 @@ function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HoroscopePage() {
|
export default function HoroscopePage() {
|
||||||
usePageMeta("Horoskop - Volksmusik & Schlager", "Tägliches Horoskop für alle Sternzeichen. Ihr persönliches Tageshoroskop bei FOLX TV – dem Volksmusik & Schlager Sender.");
|
|
||||||
const params = useParams<{ sign?: string }>();
|
const params = useParams<{ sign?: string }>();
|
||||||
const [selected, setSelected] = useState<number | null>(null);
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
const detailRef = useRef<HTMLDivElement>(null);
|
const detailRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: aiHoroscopes = [], isLoading: aiLoading } = useQuery<AIHoroscope[]>({
|
const { data: aiHoroscopes = [], isLoading: aiLoading } = useQuery<AIHoroscope[]>({
|
||||||
queryKey: ["/api/horoscopes/today"],
|
queryKey: ["/api/horoscopes/today"],
|
||||||
|
staleTime: 1000 * 60 * 30,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (params.sign) {
|
if (params.sign) {
|
||||||
const idx = SIGNS.findIndex((s) => s.name.toLowerCase() === params.sign?.toLowerCase());
|
let raw = params.sign;
|
||||||
|
try { raw = decodeURIComponent(params.sign); } catch { /* ostane surov */ }
|
||||||
|
const wanted = raw.toLowerCase();
|
||||||
|
const idx = SIGNS.findIndex((s) => s.name.toLowerCase() === wanted);
|
||||||
if (idx >= 0) setSelected(idx);
|
if (idx >= 0) setSelected(idx);
|
||||||
}
|
}
|
||||||
}, [params.sign]);
|
}, [params.sign]);
|
||||||
|
|
||||||
|
const selectedSign = selected !== null ? SIGNS[selected] : null;
|
||||||
|
const selectedAi = selected !== null ? aiHoroscopes.find(h => h.signIndex === selected) : null;
|
||||||
|
const today = new Date().toLocaleDateString("de-AT", { day: "numeric", month: "long", year: "numeric" });
|
||||||
|
|
||||||
|
const metaTitle = selectedSign
|
||||||
|
? `${selectedSign.symbol} ${selectedSign.name} Horoskop – Tages-, Wochen- & Monatshoroskop`
|
||||||
|
: "Horoskop – Tages-, Wochen- & Monatshoroskop für alle Sternzeichen";
|
||||||
|
|
||||||
|
const metaDesc = selectedSign
|
||||||
|
? `${selectedSign.name} Horoskop für heute, ${today}. Tägliches, wöchentliches und monatliches Horoskop für ${selectedSign.name} (${selectedSign.date}). Liebe, Beruf & Gesundheit bei FOLX TV.`
|
||||||
|
: `Kostenloses Tageshoroskop, Wochenhoroskop & Monatshoroskop für alle 12 Sternzeichen. Liebe, Beruf, Gesundheit – Ihr persönliches Horoskop bei FOLX TV, ${today}.`;
|
||||||
|
|
||||||
|
const ogImage = "https://folx.tv/images/horoskop-og.jpg";
|
||||||
|
|
||||||
|
usePageMeta(metaTitle, metaDesc, {
|
||||||
|
ogTitle: metaTitle,
|
||||||
|
ogDescription: metaDesc,
|
||||||
|
ogImage,
|
||||||
|
ogType: "website",
|
||||||
|
});
|
||||||
|
|
||||||
const handleSelect = (i: number) => {
|
const handleSelect = (i: number) => {
|
||||||
setSelected(i);
|
setSelected(i);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -396,23 +384,11 @@ export default function HoroscopePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<InterstitialAd forceShow />
|
|
||||||
<PageSideAds />
|
<PageSideAds />
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<h1 className="sr-only" data-testid="text-horoscope-title">
|
||||||
<Link href="/">
|
Horoskop — Tageshoroskop für alle zwölf Sternzeichen
|
||||||
<button className="text-muted-foreground hover:text-foreground transition-colors" data-testid="button-back-home">
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2" data-testid="text-horoscope-title">
|
|
||||||
<Star className="w-6 h-6 text-primary" />
|
|
||||||
Horoskop
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-sm mb-8 ml-8" data-testid="text-horoscope-subtitle">
|
|
||||||
Entdecken Sie, was die Sterne für Sie bereithalten. Aktuelle kosmische Ereignisse und Ihr persönliches Tageshoroskop.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{aiLoading && (
|
{aiLoading && (
|
||||||
<div className="flex items-center gap-2 mb-6 text-sm text-primary bg-primary/10 border border-primary/20 rounded-lg px-4 py-2" data-testid="text-horoscope-loading">
|
<div className="flex items-center gap-2 mb-6 text-sm text-primary bg-primary/10 border border-primary/20 rounded-lg px-4 py-2" data-testid="text-horoscope-loading">
|
||||||
@@ -421,8 +397,6 @@ export default function HoroscopePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AstroEventsSection />
|
|
||||||
|
|
||||||
<InArticleAd />
|
<InArticleAd />
|
||||||
|
|
||||||
<SignGrid onSelect={handleSelect} selectedIndex={selected} aiHoroscopes={aiHoroscopes} />
|
<SignGrid onSelect={handleSelect} selectedIndex={selected} aiHoroscopes={aiHoroscopes} />
|
||||||
@@ -430,15 +404,13 @@ export default function HoroscopePage() {
|
|||||||
<div ref={detailRef}>
|
<div ref={detailRef}>
|
||||||
{selected !== null ? (
|
{selected !== null ? (
|
||||||
<SignDetail signIndex={selected} onNavigate={handleSelect} aiHoroscopes={aiHoroscopes} />
|
<SignDetail signIndex={selected} onNavigate={handleSelect} aiHoroscopes={aiHoroscopes} />
|
||||||
) : (
|
) : null}
|
||||||
<div className="text-center py-16 bg-card rounded-xl border border-card-border" data-testid="text-horoscope-prompt">
|
|
||||||
<Sparkles className="w-16 h-16 text-primary mx-auto mb-4" />
|
|
||||||
<p className="text-muted-foreground text-lg">Wählen Sie oben Ihr Sternzeichen</p>
|
|
||||||
<p className="text-muted-foreground/60 text-sm mt-1">für Ihr persönliches Tages-, Wochen- und Monatshoroskop</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="mt-8" data-testid="ad-multiplex-end">
|
||||||
|
<div className="text-[9px] text-muted-foreground/40 text-center pb-1 uppercase tracking-widest">Anzeige</div>
|
||||||
|
<MultiplexAd />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { INFO_SEO } from "@shared/seo";
|
||||||
import { Scale } from "lucide-react";
|
import { Scale } from "lucide-react";
|
||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
@@ -5,7 +6,7 @@ import { usePageMeta } from "@/hooks/use-page-meta";
|
|||||||
import { PageSideAds } from "@/components/adsense";
|
import { PageSideAds } from "@/components/adsense";
|
||||||
|
|
||||||
export default function ImpressumPage() {
|
export default function ImpressumPage() {
|
||||||
usePageMeta("Impressum - FOLX TV Volksmusik Sender", "Impressum und rechtliche Informationen zu FOLX TV – Ihrem Volksmusik & Schlager Fernsehsender.");
|
usePageMeta(INFO_SEO["/impressum"].title, INFO_SEO["/impressum"].description, { siteName: "FOLX TV" });
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
@@ -22,20 +23,34 @@ export default function ImpressumPage() {
|
|||||||
<section>
|
<section>
|
||||||
<h2 className="text-foreground font-semibold text-lg mb-3">Herausgeber</h2>
|
<h2 className="text-foreground font-semibold text-lg mb-3">Herausgeber</h2>
|
||||||
<p>
|
<p>
|
||||||
BoldFrame Productions d.o.o.
|
PRIME TIME Consulting GmbH
|
||||||
<br />
|
<br />
|
||||||
Sokolska ulica 46
|
Sigmaringer Str. 10
|
||||||
<br />
|
<br />
|
||||||
2000 Maribor
|
72379 Hechingen
|
||||||
<br />
|
<br />
|
||||||
Slowenien
|
Deutschland
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
UID-Nr.: SI38853507
|
Geschäftsführerin: Heike Artič
|
||||||
<br />
|
<br />
|
||||||
E-Mail:{" "}
|
E-Mail:{" "}
|
||||||
<a href="mailto:office@boldframe.productions" className="text-primary hover:underline" data-testid="link-email">
|
<a href="mailto:office@primetime.consulting" className="text-primary hover:underline" data-testid="link-email">
|
||||||
office@boldframe.productions
|
office@primetime.consulting
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-foreground font-semibold text-lg mb-3">Redaktion</h2>
|
||||||
|
<p>
|
||||||
|
Chefredakteur: Sebastjan Artič
|
||||||
|
<br />
|
||||||
|
Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV: Sebastjan Artič, Sigmaringer Str. 10, 72379 Hechingen
|
||||||
|
<br />
|
||||||
|
E-Mail:{" "}
|
||||||
|
<a href="mailto:office@folx.tv" className="text-primary hover:underline">
|
||||||
|
office@folx.tv
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -58,7 +73,7 @@ export default function ImpressumPage() {
|
|||||||
<section>
|
<section>
|
||||||
<h2 className="text-foreground font-semibold text-lg mb-3">Copyright</h2>
|
<h2 className="text-foreground font-semibold text-lg mb-3">Copyright</h2>
|
||||||
<p>
|
<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.
|
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 der PRIME TIME Consulting GmbH. Unerlaubtes Kopieren oder Verwenden von Bildern und Texten (auch teilweise) bedarf der vorherigen schriftlichen Genehmigung. Alle Rechte vorbehalten.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3">
|
<p className="mt-3">
|
||||||
Wir widersprechen jeder kommerziellen Verwendung und jeder sonstigen Weitergabe und anderweitigen Veröffentlichung dieser Daten.
|
Wir widersprechen jeder kommerziellen Verwendung und jeder sonstigen Weitergabe und anderweitigen Veröffentlichung dieser Daten.
|
||||||
@@ -68,7 +83,7 @@ export default function ImpressumPage() {
|
|||||||
<section>
|
<section>
|
||||||
<h2 className="text-foreground font-semibold text-lg mb-3">Links</h2>
|
<h2 className="text-foreground font-semibold text-lg mb-3">Links</h2>
|
||||||
<p>
|
<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.
|
Die PRIME TIME Consulting GmbH ü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. Die PRIME TIME Consulting GmbH distanziert sich daher ausdrücklich und unwiderruflich von jeglichen rechtswidrigen Inhalten auf den verlinkten Seiten.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
|
import { INFO_SEO } from "@shared/seo";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Mail, Phone, Send, CheckCircle, AlertCircle } from "lucide-react";
|
import { Mail, Phone, Send, CheckCircle, AlertCircle, Smartphone, Printer } from "lucide-react";
|
||||||
import { SiFacebook, SiInstagram, SiYoutube, SiTiktok } from "react-icons/si";
|
import { SiFacebook, SiInstagram, SiYoutube, SiTiktok } from "react-icons/si";
|
||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||||
import { PageSideAds } from "@/components/adsense";
|
import { PageSideAds, TopBannerAd } from "@/components/adsense";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
|
||||||
export default function KontaktPage() {
|
export default function KontaktPage() {
|
||||||
usePageMeta("Kontakt - FOLX TV Volksmusik Sender", "Kontaktieren Sie FOLX TV – Ihren Fernsehsender Nr. 1 für Volksmusik und Schlager. Wir freuen uns auf Ihre Nachricht.");
|
usePageMeta(INFO_SEO["/kontakt"].title, INFO_SEO["/kontakt"].description, { siteName: "FOLX TV" });
|
||||||
|
|
||||||
const [formData, setFormData] = useState({ name: "", email: "", subject: "", message: "" });
|
const [formData, setFormData] = useState({ name: "", email: "", subject: "", message: "" });
|
||||||
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
|
||||||
@@ -42,6 +43,7 @@ export default function KontaktPage() {
|
|||||||
<Header />
|
<Header />
|
||||||
<PageSideAds />
|
<PageSideAds />
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<TopBannerAd />
|
||||||
<div className="flex items-center gap-3 mb-8">
|
<div className="flex items-center gap-3 mb-8">
|
||||||
<Mail className="w-7 h-7 text-primary" />
|
<Mail className="w-7 h-7 text-primary" />
|
||||||
<h1 className="text-3xl font-bold text-foreground" data-testid="text-kontakt-title">
|
<h1 className="text-3xl font-bold text-foreground" data-testid="text-kontakt-title">
|
||||||
@@ -57,7 +59,7 @@ export default function KontaktPage() {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<a
|
<a
|
||||||
href="tel:+491725675800"
|
href="tel:+4974719429966"
|
||||||
className="flex items-start gap-3 group"
|
className="flex items-start gap-3 group"
|
||||||
data-testid="link-kontakt-phone"
|
data-testid="link-kontakt-phone"
|
||||||
>
|
>
|
||||||
@@ -66,10 +68,34 @@ export default function KontaktPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Telefon</p>
|
<p className="text-xs text-muted-foreground">Telefon</p>
|
||||||
<p className="text-foreground font-medium group-hover:text-primary transition-colors" data-testid="text-kontakt-phone">+49 172 567 58 00</p>
|
<p className="text-foreground font-medium group-hover:text-primary transition-colors" data-testid="text-kontakt-phone">+49 7471 9429966</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="tel:+491725675800"
|
||||||
|
className="flex items-start gap-3 group"
|
||||||
|
data-testid="link-kontakt-mobile"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors">
|
||||||
|
<Smartphone className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Mobil</p>
|
||||||
|
<p className="text-foreground font-medium group-hover:text-primary transition-colors" data-testid="text-kontakt-mobile">+49 172 5675800</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3" data-testid="block-kontakt-fax">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Printer className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Fax</p>
|
||||||
|
<p className="text-foreground font-medium" data-testid="text-kontakt-fax">+49 7471 9429990</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="mailto:office@folx.tv"
|
href="mailto:office@folx.tv"
|
||||||
className="flex items-start gap-3 group"
|
className="flex items-start gap-3 group"
|
||||||
|
|||||||
@@ -1,363 +1,233 @@
|
|||||||
import { useState } from "react";
|
import { useState, Fragment } from "react";
|
||||||
import { Link } from "wouter";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChefHat, Clock, Users, X, ChevronLeft } from "lucide-react";
|
import { ChefHat, X, Calendar } from "lucide-react";
|
||||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import { InArticleAd, PageSideAds } from "@/components/adsense";
|
import { InArticleAd, PageSideAds, TopBannerAd, MultiplexAd } from "@/components/adsense";
|
||||||
|
|
||||||
interface Recipe {
|
interface DailyRecipe {
|
||||||
title: string;
|
id: number;
|
||||||
image: string;
|
dateStr: string;
|
||||||
time: string;
|
|
||||||
servings: string;
|
|
||||||
ingredients: string[];
|
|
||||||
steps: string[];
|
|
||||||
description: string;
|
|
||||||
category: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecipeRegion {
|
|
||||||
name: string;
|
name: string;
|
||||||
recipes: Recipe[];
|
intro?: string;
|
||||||
|
recipe?: string;
|
||||||
|
seasonNote?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RECIPE_REGIONS: RecipeRegion[] = [
|
const MONTHS_DE = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli",
|
||||||
{
|
"August", "September", "Oktober", "November", "Dezember"];
|
||||||
name: "Österreich",
|
|
||||||
recipes: [
|
|
||||||
{
|
|
||||||
title: "Kaiserschmarrn",
|
|
||||||
image: "/uploads/recipe-kaiserschmarrn.jpg",
|
|
||||||
time: "25 Min.",
|
|
||||||
servings: "2 Portionen",
|
|
||||||
category: "Nachspeise",
|
|
||||||
description: "Der Klassiker der österreichischen Küche -- fluffig, süß und einfach unwiderstehlich.",
|
|
||||||
ingredients: ["3 Eier", "200 ml Milch", "120 g Mehl", "30 g Zucker", "1 Prise Salz", "50 g Butter", "Rosinen nach Belieben", "Puderzucker"],
|
|
||||||
steps: ["Eigelb, Milch, Mehl und Salz verrühren.", "Eiweiß mit Zucker steif schlagen und unterheben.", "Butter in der Pfanne erhitzen, Teig eingießen.", "Rosinen darüber streuen, von unten goldbraun backen.", "Mit zwei Gabeln in Stücke reißen.", "Mit Puderzucker bestreut servieren."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Wiener Schnitzel",
|
|
||||||
image: "/uploads/recipe-wiener-schnitzel.jpg",
|
|
||||||
time: "30 Min.",
|
|
||||||
servings: "4 Portionen",
|
|
||||||
category: "Hauptspeise",
|
|
||||||
description: "Das berühmteste Gericht Wiens -- goldbraun paniert und herrlich knusprig.",
|
|
||||||
ingredients: ["4 Kalbsschnitzel", "2 Eier", "Mehl", "Semmelbrösel", "Butterschmalz", "Salz", "Zitrone"],
|
|
||||||
steps: ["Schnitzel dünn klopfen, salzen.", "In Mehl, verquirltem Ei und Semmelbröseln panieren.", "In reichlich heißem Butterschmalz goldbraun backen.", "Auf Küchenpapier abtropfen lassen.", "Mit Zitrone und Petersilienkartoffeln servieren."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Apfelstrudel",
|
|
||||||
image: "/uploads/recipe-apfelstrudel.jpg",
|
|
||||||
time: "60 Min.",
|
|
||||||
servings: "6 Portionen",
|
|
||||||
category: "Nachspeise",
|
|
||||||
description: "Dünn ausgezogener Strudelteig mit saftiger Apfelfüllung -- ein Traum aus Omas Küche.",
|
|
||||||
ingredients: ["250 g Mehl", "1 Ei", "2 EL Öl", "125 ml Wasser", "1 kg Äpfel", "100 g Zucker", "Zimt", "80 g Semmelbrösel", "80 g Butter"],
|
|
||||||
steps: ["Strudelteig kneten, 30 Min. ruhen lassen.", "Äpfel schälen, in dünne Scheiben schneiden.", "Mit Zucker, Zimt und Rosinen mischen.", "Teig dünn ausziehen, Brösel verteilen.", "Füllung auflegen, einrollen.", "Bei 180°C 40 Min. goldbraun backen."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Tiroler Knödel",
|
|
||||||
image: "/uploads/recipe-tiroler-knoedel.jpg",
|
|
||||||
time: "40 Min.",
|
|
||||||
servings: "4 Portionen",
|
|
||||||
category: "Hauptspeise",
|
|
||||||
description: "Herzhafte Speckknödel aus Tirol -- perfekt zu einer kräftigen Suppe oder Sauerkraut.",
|
|
||||||
ingredients: ["300 g Knödelbrot", "200 ml Milch", "3 Eier", "150 g Speck", "1 Zwiebel", "Petersilie", "Salz, Pfeffer", "Mehl"],
|
|
||||||
steps: ["Knödelbrot in eine Schüssel geben, warme Milch darüber.", "Speck und Zwiebel anbraten.", "Eier, Speck, Petersilie zum Brot geben, mischen.", "30 Min. rasten lassen.", "Knödel formen, in Salzwasser 15 Min. kochen.", "Mit Butter und Schnittlauch servieren."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Sachertorte",
|
|
||||||
image: "/uploads/recipe-sachertorte.jpg",
|
|
||||||
time: "90 Min.",
|
|
||||||
servings: "8 Portionen",
|
|
||||||
category: "Nachspeise",
|
|
||||||
description: "Die weltberühmte Wiener Schokoladentorte mit Marillenmarmelade und Glasur.",
|
|
||||||
ingredients: ["150 g Butter", "110 g Zucker", "6 Eier", "130 g Zartbitterschokolade", "130 g Mehl", "Marillenmarmelade", "200 g Kuvertüre"],
|
|
||||||
steps: ["Butter und Zucker schaumig rühren.", "Geschmolzene Schokolade und Eigelb unterrühren.", "Eiweiß steif schlagen, mit Mehl unterheben.", "Bei 170°C 50 Min. backen.", "Auskühlen lassen, mit Marmelade bestreichen.", "Mit Schokoladen-Glasur überziehen."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Kärntner Kasnudeln",
|
|
||||||
image: "/uploads/recipe-kaerntner-kasnudeln.jpg",
|
|
||||||
time: "50 Min.",
|
|
||||||
servings: "4 Portionen",
|
|
||||||
category: "Hauptspeise",
|
|
||||||
description: "Handgemachte Teigtaschen aus Kärnten mit Topfen-Kartoffel-Füllung und frischer Minze.",
|
|
||||||
ingredients: ["400 g Mehl", "2 Eier", "Wasser", "500 g Topfen", "200 g Kartoffeln", "Minze", "Kerbel", "Butter", "Salz"],
|
|
||||||
steps: ["Nudelteig aus Mehl, Eiern und Wasser herstellen.", "Kartoffeln kochen und stampfen.", "Mit Topfen, Minze und Kerbel mischen.", "Teig ausrollen, Kreise ausstechen.", "Füllung aufsetzen, Ränder krendeln.", "In Salzwasser kochen, mit Butter servieren."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Germknödel",
|
|
||||||
image: "/uploads/recipe-germknoedel.jpg",
|
|
||||||
time: "120 Min.",
|
|
||||||
servings: "4 Portionen",
|
|
||||||
category: "Nachspeise",
|
|
||||||
description: "Fluffige Hefeteig-Knödel mit Powidl-Füllung, Vanillesoße und Mohn-Zucker-Topping.",
|
|
||||||
ingredients: ["500 g Mehl", "250 ml Milch", "1 Würfel Hefe", "60 g Zucker", "60 g Butter", "2 Eigelb", "Powidl (Pflaumenmus)", "Mohn", "Vanillesoße"],
|
|
||||||
steps: ["Hefeteig zubereiten und 1 Stunde gehen lassen.", "Teig in Portionen teilen, flach drücken.", "Je 1 TL Powidl in die Mitte geben.", "Zu Knödeln formen, nochmals 20 Min. gehen lassen.", "In Salzwasser oder über Dampf 15 Min. garen.", "Mit Mohn-Zucker und Vanillesoße servieren."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Tafelspitz",
|
|
||||||
image: "/uploads/recipe-tafelspitz.jpg",
|
|
||||||
time: "180 Min.",
|
|
||||||
servings: "6 Portionen",
|
|
||||||
category: "Hauptspeise",
|
|
||||||
description: "Zart gekochtes Rindfleisch mit Schnittlauchsoße und Apfelkren -- ein Festmahl der Wiener Küche.",
|
|
||||||
ingredients: ["1,5 kg Tafelspitz", "Suppengemüse", "Lorbeerblatt", "Pfefferkörner", "Schnittlauch", "Sauerrahm", "Äpfel", "Kren (Meerrettich)"],
|
|
||||||
steps: ["Fleisch in kaltes Wasser legen, langsam erhitzen.", "Schaum abschöpfen, Gemüse und Gewürze zugeben.", "3 Stunden sanft köcheln lassen.", "Schnittlauchsoße aus Sauerrahm zubereiten.", "Apfelkren frisch reiben und mischen.", "Fleisch aufschneiden, mit Soßen und Gemüse servieren."],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Bayern",
|
|
||||||
recipes: [
|
|
||||||
{
|
|
||||||
title: "Schweinshaxe",
|
|
||||||
image: "/uploads/recipe-schweinshaxe.jpg",
|
|
||||||
time: "180 Min.",
|
|
||||||
servings: "4 Portionen",
|
|
||||||
category: "Hauptspeise",
|
|
||||||
description: "Knusprig gebratene Schweinshaxe mit krachender Kruste -- das Herzstück jeder bayerischen Wirtschaft.",
|
|
||||||
ingredients: ["4 Schweinshaxen", "2 Zwiebeln", "2 Karotten", "500 ml Dunkelbier", "Kümmel", "Knoblauch", "Salz, Pfeffer", "Majoran"],
|
|
||||||
steps: ["Haxen waschen, Schwarte rautenförmig einschneiden.", "Mit Salz, Kümmel und Knoblauch einreiben.", "Mit Gemüse und Bier in die Bratpfanne geben.", "Bei 180°C ca. 2,5 Stunden braten.", "Regelmäßig mit Bratensud übergießen.", "Zum Schluss Grill zuschalten für knusprige Kruste."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Obatzda",
|
|
||||||
image: "/uploads/recipe-obatzda.jpg",
|
|
||||||
time: "15 Min.",
|
|
||||||
servings: "4 Portionen",
|
|
||||||
category: "Vorspeise",
|
|
||||||
description: "Der bayerische Biergarten-Klassiker -- würziger Camembert-Aufstrich mit Brezn.",
|
|
||||||
ingredients: ["200 g reifer Camembert", "100 g Frischkäse", "1 kleine Zwiebel", "1 EL Butter", "Paprikapulver", "Kümmel", "Salz, Pfeffer", "Schnittlauch"],
|
|
||||||
steps: ["Camembert mit einer Gabel zerdrücken.", "Frischkäse und weiche Butter untermischen.", "Fein gehackte Zwiebel dazugeben.", "Mit Paprika, Kümmel, Salz und Pfeffer würzen.", "Mindestens 1 Stunde durchziehen lassen.", "Mit Schnittlauch bestreut zu frischen Brezn servieren."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Dampfnudeln",
|
|
||||||
image: "/uploads/recipe-dampfnudeln.jpg",
|
|
||||||
time: "90 Min.",
|
|
||||||
servings: "4 Portionen",
|
|
||||||
category: "Nachspeise",
|
|
||||||
description: "Bayerische Dampfnudeln mit goldener Kruste und Vanillesoße -- wie bei der Oma auf dem Bauernhof.",
|
|
||||||
ingredients: ["500 g Mehl", "250 ml Milch", "1 Würfel Hefe", "80 g Zucker", "80 g Butter", "2 Eier", "1 Prise Salz", "Vanillesoße"],
|
|
||||||
steps: ["Hefe in lauwarmer Milch auflösen.", "Mit Mehl, Zucker, Eiern und Butter zu einem Teig kneten.", "1 Stunde gehen lassen, bis sich das Volumen verdoppelt.", "Kugeln formen und nochmals 20 Min. ruhen lassen.", "In einem geschlossenen Topf mit Milch-Butter-Gemisch dämpfen.", "Servieren, wenn die Kruste goldbraun knistert."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Käsespätzle",
|
|
||||||
image: "/uploads/recipe-kaesespaetzle.jpg",
|
|
||||||
time: "45 Min.",
|
|
||||||
servings: "4 Portionen",
|
|
||||||
category: "Hauptspeise",
|
|
||||||
description: "Handgeschabte Spätzle mit würzigem Bergkäse und knusprigen Röstzwiebeln -- ein Allgäuer Traum.",
|
|
||||||
ingredients: ["400 g Mehl", "4 Eier", "200 ml Wasser", "300 g Bergkäse", "3 große Zwiebeln", "Butter", "Salz, Pfeffer", "Muskatnuss"],
|
|
||||||
steps: ["Mehl, Eier, Wasser und Salz zu einem zähen Teig schlagen.", "Durch ein Spätzlebrett in kochendes Salzwasser schaben.", "Wenn die Spätzle oben schwimmen, abschöpfen.", "Zwiebeln in Butter goldbraun rösten.", "Spätzle und geriebenen Käse schichtweise in eine Form geben.", "Mit Röstzwiebeln krönen und kurz überbacken."],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Schwaben & Baden",
|
|
||||||
recipes: [
|
|
||||||
{
|
|
||||||
title: "Maultaschen",
|
|
||||||
image: "/uploads/recipe-maultaschen.jpg",
|
|
||||||
time: "90 Min.",
|
|
||||||
servings: "4 Portionen",
|
|
||||||
category: "Hauptspeise",
|
|
||||||
description: "Schwäbische Herrgottsbscheißerle -- gefüllte Teigtaschen, die einst das Fleisch vor Gott versteckten.",
|
|
||||||
ingredients: ["Nudelteig (400 g Mehl, 4 Eier)", "300 g Hackfleisch", "200 g Spinat", "1 Brötchen", "1 Zwiebel", "2 Eier", "Petersilie", "Muskatnuss, Salz, Pfeffer"],
|
|
||||||
steps: ["Nudelteig kneten und 30 Min. ruhen lassen.", "Brötchen einweichen, Spinat blanchieren und hacken.", "Hackfleisch, Spinat, Brötchen, Eier und Gewürze mischen.", "Teig dünn ausrollen, Füllung in Häufchen setzen.", "Zuklappen, Taschen ausschneiden, Ränder fest andrücken.", "In kräftiger Brühe 10 Min. ziehen lassen oder anbraten."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Zwiebelrostbraten",
|
|
||||||
image: "/uploads/recipe-zwiebelrostbraten.jpg",
|
|
||||||
time: "40 Min.",
|
|
||||||
servings: "4 Portionen",
|
|
||||||
category: "Hauptspeise",
|
|
||||||
description: "Saftiges Rostbraten-Stück mit einem Berg goldener Röstzwiebeln -- der schwäbische Sonntagsklassiker.",
|
|
||||||
ingredients: ["4 Scheiben Roastbeef (je 200 g)", "6 große Zwiebeln", "3 EL Mehl", "Butterschmalz", "200 ml Brühe", "Salz, Pfeffer", "1 Schuss Rotwein"],
|
|
||||||
steps: ["Zwiebeln in Ringe schneiden, in Mehl wenden.", "In heißem Butterschmalz goldbraun und knusprig frittieren.", "Fleisch salzen, pfeffern und scharf anbraten.", "Fleisch herausnehmen und warm halten.", "Bratensatz mit Rotwein und Brühe ablöschen.", "Fleisch mit Soße und einem Berg Röstzwiebeln servieren."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Flammkuchen",
|
|
||||||
image: "/uploads/recipe-flammkuchen.jpg",
|
|
||||||
time: "30 Min.",
|
|
||||||
servings: "4 Portionen",
|
|
||||||
category: "Hauptspeise",
|
|
||||||
description: "Hauchdünner elsässisch-badischer Flammkuchen mit Crème fraîche, Speck und Zwiebeln.",
|
|
||||||
ingredients: ["300 g Mehl", "150 ml Wasser", "3 EL Öl", "200 g Crème fraîche", "200 g Speck", "3 Zwiebeln", "Salz, Pfeffer", "Muskatnuss"],
|
|
||||||
steps: ["Mehl, Wasser, Öl und Salz zu einem dünnen Teig kneten.", "Sehr dünn ausrollen auf einem bemehlten Blech.", "Crème fraîche gleichmäßig verstreichen.", "Mit dünnen Zwiebelringen und Speckwürfeln belegen.", "Bei 250°C auf unterster Schiene 12-15 Min. backen.", "Sofort heiß servieren, am besten mit einem Glas Weißwein."],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Südtirol & Alpen",
|
|
||||||
recipes: [
|
|
||||||
{
|
|
||||||
title: "Schlutzkrapfen",
|
|
||||||
image: "/uploads/recipe-schlutzkrapfen.jpg",
|
|
||||||
time: "60 Min.",
|
|
||||||
servings: "4 Portionen",
|
|
||||||
category: "Hauptspeise",
|
|
||||||
description: "Südtiroler Halbmond-Teigtaschen mit Spinat-Ricotta-Füllung und brauner Butter.",
|
|
||||||
ingredients: ["300 g Roggenmehl", "100 g Weizenmehl", "3 Eier", "Wasser", "400 g Spinat", "200 g Ricotta", "1 Zwiebel", "Butter, Parmesan, Salbei"],
|
|
||||||
steps: ["Roggen- und Weizenmehl mit Eiern und Wasser verkneten.", "30 Min. ruhen lassen.", "Spinat dünsten, hacken, mit Ricotta und Zwiebel mischen.", "Teig dünn ausrollen, Kreise ausstechen.", "Füllung aufsetzen, zu Halbmonden falten, Ränder fest drücken.", "In Salzwasser kochen, mit brauner Salbeibutter und Parmesan servieren."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Sauerbraten",
|
|
||||||
image: "/uploads/recipe-sauerbraten.jpg",
|
|
||||||
time: "240 Min.",
|
|
||||||
servings: "6 Portionen",
|
|
||||||
category: "Hauptspeise",
|
|
||||||
description: "Tagelang marinierter Schmorbraten mit dunkler Soße -- das Geduldsgericht der deutschen Küche.",
|
|
||||||
ingredients: ["1,5 kg Rindfleisch", "500 ml Rotweinessig", "500 ml Wasser", "2 Zwiebeln", "Lorbeer, Nelken, Piment", "Wacholderbeeren", "3 EL Lebkuchengewürz", "Rosinen"],
|
|
||||||
steps: ["Fleisch 3-5 Tage in Essig-Wasser-Marinade mit Gewürzen einlegen.", "Fleisch herausnehmen, trocken tupfen, scharf anbraten.", "Mit gefilterter Marinade ablöschen.", "Bei 160°C 3 Stunden schmoren.", "Soße mit Lebkuchen-Bröseln und Rosinen binden.", "Mit Kartoffelklößen und Rotkraut servieren."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Schwarzwälder Kirschtorte",
|
|
||||||
image: "/uploads/recipe-schwarzwaelder-kirschtorte.jpg",
|
|
||||||
time: "120 Min.",
|
|
||||||
servings: "12 Portionen",
|
|
||||||
category: "Nachspeise",
|
|
||||||
description: "Die Königin der deutschen Torten -- Schokolade, Kirschen, Sahne und ein Schuss Kirschwasser.",
|
|
||||||
ingredients: ["6 Eier", "200 g Zucker", "150 g Mehl", "50 g Kakao", "80 g Butter", "1 Glas Sauerkirschen", "600 ml Sahne", "Kirschwasser", "Schokoraspeln"],
|
|
||||||
steps: ["Biskuit aus Eiern, Zucker, Mehl, Kakao und Butter backen.", "Auskühlen lassen und in 3 Böden schneiden.", "Böden mit Kirschwasser tränken.", "Sahne steif schlagen, zwischen die Böden streichen.", "Kirschen auf die Schichten verteilen.", "Torte mit Sahne, Schokoraspeln und Kirschen dekorieren."],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Norddeutschland",
|
|
||||||
recipes: [
|
|
||||||
{
|
|
||||||
title: "Grünkohl mit Pinkel",
|
|
||||||
image: "/uploads/recipe-gruenkohl.jpg",
|
|
||||||
time: "120 Min.",
|
|
||||||
servings: "6 Portionen",
|
|
||||||
category: "Hauptspeise",
|
|
||||||
description: "Norddeutscher Winterklassiker -- deftiger Grünkohl mit geräucherter Pinkelwurst nach dem ersten Frost.",
|
|
||||||
ingredients: ["1,5 kg frischer Grünkohl", "4 Pinkelwürste", "250 g Kasseler", "250 g durchwachsener Speck", "2 Zwiebeln", "Schmalz", "Haferflocken", "Senf"],
|
|
||||||
steps: ["Grünkohl waschen, Stiele entfernen und grob hacken.", "Zwiebeln in Schmalz anbraten, Grünkohl zugeben.", "Mit Brühe aufgießen, Speck und Kasseler einlegen.", "90 Min. köcheln lassen, gelegentlich umrühren.", "Pinkelwürste in den letzten 30 Min. mitgaren.", "Mit Haferflocken binden, mit Senf und Bratkartoffeln servieren."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Kartoffelpuffer",
|
|
||||||
image: "/uploads/recipe-kartoffelpuffer.jpg",
|
|
||||||
time: "30 Min.",
|
|
||||||
servings: "4 Portionen",
|
|
||||||
category: "Hauptspeise",
|
|
||||||
description: "Goldbraun gebratene Kartoffelreibekuchen -- ob süß mit Apfelmus oder herzhaft mit Lachs.",
|
|
||||||
ingredients: ["1 kg festkochende Kartoffeln", "1 Zwiebel", "2 Eier", "3 EL Mehl", "Salz, Pfeffer", "Muskatnuss", "Öl zum Braten", "Apfelmus"],
|
|
||||||
steps: ["Kartoffeln und Zwiebel fein reiben.", "Überschüssige Flüssigkeit gut ausdrücken.", "Mit Eiern, Mehl, Salz und Muskatnuss verrühren.", "In heißem Öl löffelweise flache Puffer braten.", "Von beiden Seiten goldbraun und knusprig backen.", "Auf Küchenpapier abtropfen, mit Apfelmus servieren."],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function RecipeModal({ recipe, onClose }: { recipe: Recipe; onClose: () => void }) {
|
function formatDate(dateStr: string): string {
|
||||||
|
const [y, m, d] = dateStr.split("-").map((x) => parseInt(x, 10));
|
||||||
|
if (!y || !m || !d) return dateStr;
|
||||||
|
return `${d}. ${MONTHS_DE[m - 1]} ${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecipeBody({ recipe }: { recipe: DailyRecipe }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4" onClick={onClose} data-testid="modal-recipe-page">
|
<>
|
||||||
<div
|
{recipe.intro && <p className="text-sm text-muted-foreground mb-4">{recipe.intro}</p>}
|
||||||
className="bg-card rounded-xl border border-card-border max-w-lg w-full max-h-[85vh] overflow-y-auto"
|
{recipe.recipe && (
|
||||||
onClick={(e) => e.stopPropagation()}
|
<div className="text-sm text-foreground/85 whitespace-pre-line leading-relaxed">{recipe.recipe}</div>
|
||||||
>
|
)}
|
||||||
<div className="relative">
|
{recipe.seasonNote && <p className="text-xs text-primary mt-4">{recipe.seasonNote}</p>}
|
||||||
<img src={recipe.image} alt={recipe.title} className="w-full h-56 object-cover rounded-t-xl" />
|
</>
|
||||||
<button onClick={onClose} className="absolute top-3 right-3 bg-black/60 rounded-full p-1.5 text-white hover:bg-black/80" data-testid="button-recipe-modal-close">
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-5">
|
|
||||||
<h2 className="text-xl font-bold text-foreground mb-1">{recipe.title}</h2>
|
|
||||||
<span className="text-[11px] font-medium text-primary">{recipe.category}</span>
|
|
||||||
<p className="text-sm text-muted-foreground mb-3 mt-1">{recipe.description}</p>
|
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-5">
|
|
||||||
<span className="flex items-center gap-1"><Clock className="w-4 h-4" />{recipe.time}</span>
|
|
||||||
<span className="flex items-center gap-1"><Users className="w-4 h-4" />{recipe.servings}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="font-semibold text-foreground text-sm mb-2">Zutaten</h3>
|
|
||||||
<ul className="text-sm text-foreground/80 mb-5 space-y-1">
|
|
||||||
{recipe.ingredients.map((ing, i) => (
|
|
||||||
<li key={i} className="flex items-start gap-2">
|
|
||||||
<span className="text-primary mt-1">•</span>{ing}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3 className="font-semibold text-foreground text-sm mb-2">Zubereitung</h3>
|
|
||||||
<ol className="text-sm text-foreground/80 space-y-2">
|
|
||||||
{recipe.steps.map((step, i) => (
|
|
||||||
<li key={i} className="flex gap-2">
|
|
||||||
<span className="font-bold text-primary flex-shrink-0">{i + 1}.</span>{step}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecipesPage() {
|
export default function RecipesPage() {
|
||||||
usePageMeta("Rezepte - Alpenküche & Volksmusik", "Traditionelle Rezepte aus der Alpenküche und österreichische Hausmannskost. Kochen wie die Volksmusik-Stars bei FOLX TV.");
|
usePageMeta(
|
||||||
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
|
"Rezepte - Alpenküche & Volksmusik",
|
||||||
|
"Das Rezept des Tages und alle bisherigen Rezepte aus der Alpenküche bei FOLX TV — österreichische, bayerische und schweizer Hausmannskost."
|
||||||
|
);
|
||||||
|
const [selected, setSelected] = useState<DailyRecipe | null>(null);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const PAGE_SIZE = 12;
|
||||||
|
|
||||||
|
const { data = [], isLoading } = useQuery<DailyRecipe[]>({
|
||||||
|
queryKey: ["/api/recipes"],
|
||||||
|
staleTime: 1000 * 60 * 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const today = data[0];
|
||||||
|
const rest = data.slice(1);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(rest.length / PAGE_SIZE));
|
||||||
|
const pageItems = rest.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||||
|
|
||||||
|
const goPage = (p: number) => {
|
||||||
|
setPage(Math.max(0, Math.min(totalPages - 1, p)));
|
||||||
|
document.getElementById("rezept-archiv")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<PageSideAds />
|
<PageSideAds />
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<TopBannerAd />
|
||||||
<Link href="/">
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2 mb-1" data-testid="text-recipes-title">
|
||||||
<button className="text-muted-foreground hover:text-foreground transition-colors" data-testid="button-recipes-back">
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2" data-testid="text-recipes-title">
|
|
||||||
<ChefHat className="w-6 h-6 text-primary" />
|
<ChefHat className="w-6 h-6 text-primary" />
|
||||||
Legendäre Bauernküche
|
Rezepte
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
<p className="text-muted-foreground text-sm mb-8">
|
||||||
<p className="text-muted-foreground text-sm mb-8 ml-8" data-testid="text-recipes-subtitle">
|
Jeden Tag ein neues Rezept aus der Alpenküche — und das ganze Archiv zum Nachkochen.
|
||||||
Traditionelle Rezepte aus den germanischen Regionen -- von der Alm bis zur Küste, von der Oma überliefert.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{RECIPE_REGIONS.map((region, ri) => (
|
{today && (
|
||||||
<div key={region.name} data-testid={`section-region-${ri}`}>
|
<section className="mb-10" data-testid="section-recipe-of-day">
|
||||||
<div className="flex items-center gap-3 mb-4 mt-8 first:mt-0">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="h-px flex-1 bg-card-border" />
|
<div className="h-px flex-1 bg-card-border" />
|
||||||
<h2 className="text-lg font-bold text-foreground px-3" data-testid={`text-region-${ri}`}>{region.name}</h2>
|
<h2 className="text-lg font-bold text-foreground px-3">Rezept des Tages</h2>
|
||||||
<div className="h-px flex-1 bg-card-border" />
|
<div className="h-px flex-1 bg-card-border" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-card rounded-xl border border-card-border overflow-hidden md:flex">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5 mb-4">
|
{today.imageUrl && (
|
||||||
{region.recipes.map((recipe, i) => (
|
<img
|
||||||
<button
|
src={today.imageUrl}
|
||||||
key={recipe.title}
|
alt={today.name}
|
||||||
onClick={() => setSelectedRecipe(recipe)}
|
className="w-full md:w-1/2 aspect-video object-cover"
|
||||||
className="bg-card rounded-xl border border-card-border overflow-hidden text-left group hover:border-primary/50 transition-all hover:shadow-lg hover:shadow-primary/5"
|
data-testid="img-recipe-of-day"
|
||||||
data-testid={`card-recipe-${ri}-${i}`}
|
/>
|
||||||
>
|
)}
|
||||||
<div className="relative overflow-hidden">
|
<div className="p-5 md:w-1/2">
|
||||||
<img src={recipe.image} alt={recipe.title} className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105" loading="lazy" />
|
<p className="text-[11px] text-muted-foreground flex items-center gap-1 mb-1">
|
||||||
</div>
|
<Calendar className="w-3 h-3" /> {formatDate(today.dateStr)}
|
||||||
<div className="p-4">
|
</p>
|
||||||
<h3 className="font-bold text-card-foreground text-base group-hover:text-primary transition-colors">{recipe.title}</h3>
|
<h3 className="text-xl font-bold text-foreground mb-3">{today.name}</h3>
|
||||||
<span className="text-[10px] font-medium text-primary">{recipe.category}</span>
|
<RecipeBody recipe={today} />
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{recipe.description}</p>
|
|
||||||
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{recipe.time}</span>
|
|
||||||
<span className="flex items-center gap-1"><Users className="w-3 h-3" />{recipe.servings}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</section>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<InArticleAd />
|
<InArticleAd />
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
|
|
||||||
{selectedRecipe && <RecipeModal recipe={selectedRecipe} onClose={() => setSelectedRecipe(null)} />}
|
{rest.length > 0 && (
|
||||||
|
<section data-testid="section-recipe-archive" id="rezept-archiv">
|
||||||
|
<div className="flex items-center gap-3 mb-4 mt-8">
|
||||||
|
<div className="h-px flex-1 bg-card-border" />
|
||||||
|
<h2 className="text-lg font-bold text-foreground px-3">Alle Rezepte</h2>
|
||||||
|
<div className="h-px flex-1 bg-card-border" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
|
{rest.length} Rezepte · Seite {page + 1} von {totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||||
|
{pageItems.map((r, idx) => (
|
||||||
|
<Fragment key={r.id}>
|
||||||
|
{idx > 0 && idx % 4 === 0 && (
|
||||||
|
<div key={`ad-${idx}`} className="sm:col-span-2 lg:col-span-3 xl:col-span-4">
|
||||||
|
<InArticleAd />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelected(r)}
|
||||||
|
className="bg-card rounded-xl border border-card-border overflow-hidden text-left group hover:border-primary/50 transition-all hover:shadow-lg hover:shadow-primary/5"
|
||||||
|
data-testid={`card-recipe-${r.id}`}
|
||||||
|
>
|
||||||
|
{r.imageUrl && (
|
||||||
|
<img
|
||||||
|
src={r.imageUrl}
|
||||||
|
alt={r.name}
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-[11px] text-muted-foreground mb-1">{formatDate(r.dateStr)}</p>
|
||||||
|
<h3 className="font-semibold text-card-foreground leading-snug line-clamp-2 group-hover:text-primary transition-colors">
|
||||||
|
{r.name}
|
||||||
|
</h3>
|
||||||
|
{r.intro && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2 line-clamp-3">{r.intro}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2 mt-8" data-testid="recipe-pagination">
|
||||||
|
<button
|
||||||
|
onClick={() => goPage(page - 1)}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="px-3 py-2 text-sm rounded-lg border border-card-border bg-card text-foreground disabled:opacity-40 hover:border-primary/50 transition-colors"
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: totalPages }).map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => goPage(i)}
|
||||||
|
className={`w-9 h-9 text-sm rounded-lg border transition-colors ${
|
||||||
|
i === page
|
||||||
|
? "bg-primary text-white border-primary"
|
||||||
|
: "bg-card text-foreground border-card-border hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => goPage(page + 1)}
|
||||||
|
disabled={page === totalPages - 1}
|
||||||
|
className="px-3 py-2 text-sm rounded-lg border border-card-border bg-card text-foreground disabled:opacity-40 hover:border-primary/50 transition-colors"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && data.length === 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm">Noch keine Rezepte vorhanden.</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-8" data-testid="ad-multiplex-end">
|
||||||
|
<div className="text-[9px] text-muted-foreground/40 text-center pb-1 uppercase tracking-widest">Anzeige</div>
|
||||||
|
<MultiplexAd />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[120] bg-black/80 flex items-start justify-center overflow-y-auto p-4"
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
data-testid="modal-recipe"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-card rounded-xl border border-card-border max-w-2xl w-full my-8 overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{selected.imageUrl && (
|
||||||
|
<img src={selected.imageUrl} alt={selected.name} className="w-full aspect-video object-cover" />
|
||||||
|
)}
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-muted-foreground flex items-center gap-1 mb-1">
|
||||||
|
<Calendar className="w-3 h-3" /> {formatDate(selected.dateStr)}
|
||||||
|
</p>
|
||||||
|
<h3 className="text-xl font-bold text-foreground">{selected.name}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
data-testid="button-recipe-close"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<RecipeBody recipe={selected} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Link, useSearch, useLocation } from "wouter";
|
|||||||
import { Search, Play, FileText, ArrowLeft } from "lucide-react";
|
import { Search, Play, FileText, ArrowLeft } from "lucide-react";
|
||||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import { PageSideAds } from "@/components/adsense";
|
import { PageSideAds, TopBannerAd } from "@/components/adsense";
|
||||||
|
import { useFocalPoints, objectPositionFor } from "@/lib/focal";
|
||||||
|
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
articles: {
|
articles: {
|
||||||
@@ -27,6 +28,7 @@ interface SearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
|
const focalPoints = useFocalPoints();
|
||||||
usePageMeta("Suche - Volksmusik & Schlager", "Durchsuchen Sie FOLX TV nach Volksmusik- und Schlager-Nachrichten, Künstlern, Videos und mehr.");
|
usePageMeta("Suche - Volksmusik & Schlager", "Durchsuchen Sie FOLX TV nach Volksmusik- und Schlager-Nachrichten, Künstlern, Videos und mehr.");
|
||||||
const searchString = useSearch();
|
const searchString = useSearch();
|
||||||
const initialQuery = new URLSearchParams(searchString).get("q") || "";
|
const initialQuery = new URLSearchParams(searchString).get("q") || "";
|
||||||
@@ -79,6 +81,7 @@ export default function SearchPage() {
|
|||||||
<Header />
|
<Header />
|
||||||
<PageSideAds />
|
<PageSideAds />
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<TopBannerAd />
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground text-sm mb-4 transition-colors" data-testid="link-back-home">
|
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground text-sm mb-4 transition-colors" data-testid="link-back-home">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
@@ -129,7 +132,7 @@ export default function SearchPage() {
|
|||||||
<img
|
<img
|
||||||
src={article.coverImage}
|
src={article.coverImage}
|
||||||
alt={article.title}
|
alt={article.title}
|
||||||
className="w-24 h-24 sm:w-32 sm:h-20 object-cover rounded-md flex-shrink-0"
|
style={{ objectPosition: objectPositionFor(article.coverImage, focalPoints) }} className="w-24 h-24 sm:w-32 sm:h-20 object-cover rounded-md flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -164,7 +167,7 @@ export default function SearchPage() {
|
|||||||
<img
|
<img
|
||||||
src={video.thumbnail}
|
src={video.thumbnail}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
className="w-full h-full object-cover rounded-md"
|
style={{ objectPosition: objectPositionFor(video.thumbnail, focalPoints) }} className="w-full h-full object-cover rounded-md"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="w-8 h-8 rounded-full bg-primary/80 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-full bg-primary/80 flex items-center justify-center">
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, useRoute } from "wouter";
|
||||||
|
import { ArrowLeft, Play, Clock, Youtube, FileText } from "lucide-react";
|
||||||
|
import Header from "@/components/header";
|
||||||
|
import Footer from "@/components/footer";
|
||||||
|
import { PageSideAds, TopBannerAd, MultiplexAd } from "@/components/adsense";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
|
||||||
|
export interface YtVideo {
|
||||||
|
ytId: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
shortTitle: string;
|
||||||
|
rubrik: string;
|
||||||
|
publishedAt: string;
|
||||||
|
durationS: number;
|
||||||
|
teaser: string;
|
||||||
|
description: string;
|
||||||
|
descriptionClean?: string;
|
||||||
|
articleUrl?: string | null;
|
||||||
|
chapters: { t: string; label: string }[];
|
||||||
|
tags: string[];
|
||||||
|
previewUrl: string | null;
|
||||||
|
previewS: number;
|
||||||
|
embedUrl?: string;
|
||||||
|
posterUrl: string;
|
||||||
|
youtubeUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chapSecs(t: string) {
|
||||||
|
const p = String(t).split(":").map((x) => parseInt(x, 10) || 0);
|
||||||
|
return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p[0] * 60 + p[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dauer(s: number) {
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const r = s % 60;
|
||||||
|
return `${m}:${String(r).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelatedCard({ v }: { v: YtVideo }) {
|
||||||
|
return (
|
||||||
|
<Link href={`/video/${v.slug}`}>
|
||||||
|
<article className="group cursor-pointer bg-card rounded-md border border-card-border h-full flex flex-col" data-testid={`card-related-${v.ytId}`}>
|
||||||
|
<div className="relative overflow-hidden rounded-t-md">
|
||||||
|
<img src={v.posterUrl} alt={v.shortTitle} loading="lazy"
|
||||||
|
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||||
|
<span className="absolute bottom-2 right-2 rounded bg-black/75 px-1.5 py-0.5 text-[11px] font-medium text-white">
|
||||||
|
{dauer(v.durationS)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<h3 className="text-sm font-semibold leading-snug line-clamp-2 text-card-foreground group-hover:text-primary transition-colors">
|
||||||
|
{v.shortTitle}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoDetailPage() {
|
||||||
|
const [, params] = useRoute("/video/:slug");
|
||||||
|
// ?t=<sekunde> odpre predvajalnik na tem mestu; enak parameter uporablja
|
||||||
|
// tudi Clip oznacba, iz katere Google naredi "Wichtige Momente".
|
||||||
|
const [startAt, setStartAt] = useState(() => {
|
||||||
|
const n = parseInt(new URLSearchParams(window.location.search).get("t") || "", 10);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||||
|
});
|
||||||
|
const slug = params?.slug || "";
|
||||||
|
const { data, isLoading } = useQuery<{ item: YtVideo; related: YtVideo[] }>({
|
||||||
|
queryKey: [`/api/yt-videos/${slug}`],
|
||||||
|
enabled: !!slug,
|
||||||
|
});
|
||||||
|
const v = data?.item;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
<PageSideAds />
|
||||||
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<TopBannerAd />
|
||||||
|
<Link href="/videos">
|
||||||
|
<button className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors mb-5 text-sm" data-testid="button-back-videos">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Alle Videos
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{isLoading || !v ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="w-full aspect-video rounded-lg" />
|
||||||
|
<Skeleton className="h-7 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Pravi YouTube predvajalnik: Google zahteva predvajljiv video na
|
||||||
|
strani, oglasi pa tecejo enako kot na youtube.com. Navadna
|
||||||
|
vdelava s klikom na play — brez samodejnega predvajanja, ker
|
||||||
|
YouTube v tem primeru oglasov ne servira. */}
|
||||||
|
<div className="rounded-lg overflow-hidden bg-black aspect-video">
|
||||||
|
<iframe
|
||||||
|
key={startAt}
|
||||||
|
src={`${v.embedUrl || `https://www.youtube-nocookie.com/embed/${v.ytId}`}${startAt ? `?start=${startAt}` : ""}`}
|
||||||
|
title={v.shortTitle}
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{ border: 0 }}
|
||||||
|
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
allowFullScreen
|
||||||
|
data-testid="video-embed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
|
||||||
|
<span className="rounded bg-primary/10 px-2 py-1 font-semibold text-primary">{v.rubrik}</span>
|
||||||
|
<span className="flex items-center gap-1"><Clock className="w-3.5 h-3.5" />{dauer(v.durationS)}</span>
|
||||||
|
<span>{format(new Date(v.publishedAt), "d. MMMM yyyy", { locale: de })}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mt-3 text-2xl sm:text-3xl font-bold text-foreground leading-tight" data-testid="text-video-title">
|
||||||
|
{v.shortTitle}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{v.teaser && (
|
||||||
|
<p className="mt-3 text-base text-card-foreground leading-relaxed" data-testid="text-video-teaser">
|
||||||
|
{v.teaser}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-col sm:flex-row sm:items-stretch gap-3">
|
||||||
|
<a
|
||||||
|
href={v.youtubeUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="inline-flex w-full sm:w-auto items-center justify-center gap-2 rounded-md bg-primary px-6 py-3 text-base font-semibold text-primary-foreground hover:opacity-90"
|
||||||
|
data-testid="link-watch-youtube"
|
||||||
|
>
|
||||||
|
<Youtube className="w-5 h-5" />
|
||||||
|
Ganzes Video auf YouTube ansehen
|
||||||
|
</a>
|
||||||
|
{v.articleUrl && (
|
||||||
|
<a
|
||||||
|
href={v.articleUrl}
|
||||||
|
className="inline-flex w-full sm:w-auto items-center justify-center gap-2 rounded-md border border-border bg-transparent px-6 py-3 text-base font-semibold text-foreground hover:bg-muted"
|
||||||
|
data-testid="link-article"
|
||||||
|
>
|
||||||
|
<FileText className="w-5 h-5" />
|
||||||
|
Ganzen Artikel lesen
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{v.chapters?.length > 0 && (
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="text-lg font-bold text-foreground mb-3">Kapitel</h2>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{v.chapters.map((c) => (
|
||||||
|
<li key={c.t} className="text-sm">
|
||||||
|
<a
|
||||||
|
href={`?t=${chapSecs(c.t)}`}
|
||||||
|
onClick={(e) => { e.preventDefault(); setStartAt(chapSecs(c.t)); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
<span className="inline-block w-14 font-mono text-primary">{c.t}</span>
|
||||||
|
<span className="text-card-foreground">{c.label}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(v.descriptionClean || v.description) && (
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="text-lg font-bold text-foreground mb-2">Zum Video</h2>
|
||||||
|
<p className="whitespace-pre-line text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{(v.descriptionClean || v.description).replace(/\n{3,}/g, "\n\n")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MultiplexAd />
|
||||||
|
|
||||||
|
{data?.related?.length ? (
|
||||||
|
<section className="mt-10">
|
||||||
|
<h2 className="text-lg font-bold text-foreground mb-4">Mehr aus {v.rubrik}</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{data.related.map((r) => <RelatedCard key={r.ytId} v={r} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,57 +1,58 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, useLocation, useSearch } from "wouter";
|
import { Link, useLocation, useSearch } from "wouter";
|
||||||
import { type Article } from "@shared/schema";
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||||
import { Play, ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Play, ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import { ArticleCardAd, PageSideAds } from "@/components/adsense";
|
import { PageSideAds, TopBannerAd, MultiplexAd } from "@/components/adsense";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import type { YtVideo } from "@/pages/video-detail";
|
||||||
|
import { dauer } from "@/pages/video-detail";
|
||||||
|
|
||||||
interface PaginatedResponse {
|
interface YtResponse {
|
||||||
articles: Article[];
|
items: YtVideo[];
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
hasMore: boolean;
|
rubriken: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function VideoCard({ article }: { article: Article }) {
|
function VideoCard({ v }: { v: YtVideo }) {
|
||||||
const thumbSrc = article.coverImage
|
|
||||||
? article.coverImage.replace(".webp", "-thumb.webp")
|
|
||||||
: "/images/article-1.jpg";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/article/${article.slug}`}>
|
<Link href={`/video/${v.slug}`}>
|
||||||
<article
|
<article
|
||||||
className="group cursor-pointer bg-card rounded-md border border-card-border transition-all duration-300 h-full flex flex-col"
|
className="group cursor-pointer bg-card rounded-md border border-card-border transition-all duration-300 h-full flex flex-col"
|
||||||
data-testid={`card-video-${article.id}`}
|
data-testid={`card-video-${v.ytId}`}
|
||||||
>
|
>
|
||||||
<div className="relative rounded-t-md">
|
<div className="relative rounded-t-md overflow-hidden">
|
||||||
<div className="overflow-hidden rounded-t-md">
|
|
||||||
<img
|
<img
|
||||||
src={thumbSrc}
|
src={v.posterUrl}
|
||||||
alt={article.title}
|
alt={v.shortTitle}
|
||||||
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors">
|
<div className="w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors">
|
||||||
<Play className="w-5 h-5 text-white ml-0.5" fill="white" />
|
<Play className="w-5 h-5 text-white ml-0.5" fill="white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="absolute bottom-2 right-2 rounded bg-black/75 px-1.5 py-0.5 text-[11px] font-medium text-white">
|
||||||
|
{dauer(v.durationS)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 flex flex-col flex-1">
|
<div className="p-4 flex flex-col flex-1">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-primary mb-1">{v.rubrik}</span>
|
||||||
<h3 className="font-semibold text-card-foreground mb-1 line-clamp-2 group-hover:text-primary transition-colors text-sm leading-snug">
|
<h3 className="font-semibold text-card-foreground mb-1 line-clamp-2 group-hover:text-primary transition-colors text-sm leading-snug">
|
||||||
{article.title}
|
{v.shortTitle}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-auto">
|
{v.teaser && (
|
||||||
{format(new Date(article.publishedAt), "d. MMMM yyyy", { locale: de })}
|
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{v.teaser}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-auto pt-2">
|
||||||
|
{format(new Date(v.publishedAt), "d. MMMM yyyy", { locale: de })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -59,7 +60,7 @@ function VideoCard({ article }: { article: Article }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VideoCardSkeleton() {
|
function CardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-md border border-card-border">
|
<div className="bg-card rounded-md border border-card-border">
|
||||||
<Skeleton className="w-full aspect-video rounded-t-md" />
|
<Skeleton className="w-full aspect-video rounded-t-md" />
|
||||||
@@ -72,43 +73,46 @@ function VideoCardSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function VideosPage() {
|
export default function VideosPage() {
|
||||||
usePageMeta("Volksmusik & Schlager Videos", "Volksmusik und Schlager Musikvideos, Live-Auftritte und Konzertmitschnitte bei FOLX TV. Die besten volkstümlichen Hits und Schlager-Stars im Video.");
|
usePageMeta(
|
||||||
|
"Volksmusik & Schlager Videos",
|
||||||
|
"Musik News, Interviews und FOLX STADL: alle Videos von FOLX TV auf einen Blick — Volksmusik, Schlager und die groessten Stars.",
|
||||||
|
);
|
||||||
|
|
||||||
const searchString = useSearch();
|
const searchString = useSearch();
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const params = new URLSearchParams(searchString);
|
const params = new URLSearchParams(searchString);
|
||||||
const currentPage = Math.max(1, parseInt(params.get("page") || "1"));
|
const currentPage = Math.max(1, parseInt(params.get("page") || "1"));
|
||||||
const limit = 12;
|
const rubrik = params.get("rubrik") || "Alle";
|
||||||
|
const limit = 24;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<PaginatedResponse>({
|
const { data, isLoading } = useQuery<YtResponse>({
|
||||||
queryKey: [`/api/articles/category/Video?page=${currentPage}&limit=${limit}`],
|
queryKey: [`/api/yt-videos?page=${currentPage}&limit=${limit}&rubrik=${encodeURIComponent(rubrik)}`],
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}, [currentPage]);
|
}, [currentPage, rubrik]);
|
||||||
|
|
||||||
const goToPage = (page: number) => {
|
const go = (page: number, r = rubrik) => {
|
||||||
if (page === 1) {
|
const q = new URLSearchParams();
|
||||||
setLocation("/videos");
|
if (r && r !== "Alle") q.set("rubrik", r);
|
||||||
} else {
|
if (page > 1) q.set("page", String(page));
|
||||||
setLocation(`/videos?page=${page}`);
|
const qs = q.toString();
|
||||||
}
|
setLocation(qs ? `/videos?${qs}` : "/videos");
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPages = data?.totalPages || 1;
|
const totalPages = data?.totalPages || 1;
|
||||||
const articles = data?.articles || [];
|
const items = data?.items || [];
|
||||||
|
const rubriken = ["Alle", ...(data?.rubriken || [])];
|
||||||
|
|
||||||
const getPageNumbers = () => {
|
const pageNumbers = () => {
|
||||||
const pages: (number | "...")[] = [];
|
const pages: (number | "...")[] = [];
|
||||||
if (totalPages <= 7) {
|
if (totalPages <= 7) {
|
||||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||||
} else {
|
} else {
|
||||||
pages.push(1);
|
pages.push(1);
|
||||||
if (currentPage > 3) pages.push("...");
|
if (currentPage > 3) pages.push("...");
|
||||||
const start = Math.max(2, currentPage - 1);
|
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) pages.push(i);
|
||||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
|
||||||
for (let i = start; i <= end; i++) pages.push(i);
|
|
||||||
if (currentPage < totalPages - 2) pages.push("...");
|
if (currentPage < totalPages - 2) pages.push("...");
|
||||||
pages.push(totalPages);
|
pages.push(totalPages);
|
||||||
}
|
}
|
||||||
@@ -120,6 +124,7 @@ export default function VideosPage() {
|
|||||||
<Header />
|
<Header />
|
||||||
<PageSideAds />
|
<PageSideAds />
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<TopBannerAd />
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<button className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors mb-6 text-sm" data-testid="button-back">
|
<button className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors mb-6 text-sm" data-testid="button-back">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
@@ -127,73 +132,74 @@ export default function VideosPage() {
|
|||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-foreground mb-6" data-testid="text-videos-title">
|
<h1 className="text-2xl font-bold text-foreground mb-4" data-testid="text-videos-title">
|
||||||
Videos
|
Videos
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{isLoading ? (
|
{rubriken.length > 1 && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
|
<div className="flex flex-wrap gap-2 mb-6" data-testid="videos-rubriken">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{rubriken.map((r) => (
|
||||||
<VideoCardSkeleton key={i} />
|
<button
|
||||||
|
key={r}
|
||||||
|
onClick={() => go(1, r)}
|
||||||
|
className={`rounded-full px-3.5 py-1.5 text-xs font-semibold transition-colors ${
|
||||||
|
r === rubrik ? "bg-primary text-primary-foreground" : "bg-card border border-card-border text-card-foreground hover:bg-muted/40"
|
||||||
|
}`}
|
||||||
|
data-testid={`button-rubrik-${r}`}
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : articles.length > 0 ? (
|
)}
|
||||||
<>
|
|
||||||
|
{isLoading ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
|
||||||
{articles.flatMap((article, index) => {
|
{Array.from({ length: 8 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||||
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>
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Zurzeit keine Videos in dieser Rubrik.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
|
||||||
|
{items.map((v) => <VideoCard key={v.ytId} v={v} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MultiplexAd />
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<nav className="flex items-center justify-center gap-1 mt-10" data-testid="pagination-videos">
|
<div className="flex justify-center items-center gap-2 mt-8" data-testid="videos-pagination">
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
onClick={() => go(currentPage - 1)}
|
||||||
size="icon"
|
|
||||||
onClick={() => goToPage(currentPage - 1)}
|
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
data-testid="button-prev-page"
|
className="p-2 rounded-md border border-card-border text-card-foreground disabled:opacity-30"
|
||||||
|
aria-label="Zurück"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</Button>
|
</button>
|
||||||
|
{pageNumbers().map((p, i) =>
|
||||||
{getPageNumbers().map((p, i) =>
|
|
||||||
p === "..." ? (
|
p === "..." ? (
|
||||||
<span key={`ellipsis-${i}`} className="px-2 text-muted-foreground">...</span>
|
<span key={`e-${i}`} className="px-2 text-muted-foreground">…</span>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
variant={p === currentPage ? "default" : "outline"}
|
onClick={() => go(p)}
|
||||||
size="sm"
|
className={`min-w-9 rounded-md px-3 py-1.5 text-sm font-semibold ${
|
||||||
onClick={() => goToPage(p as number)}
|
p === currentPage ? "bg-primary text-primary-foreground" : "border border-card-border text-card-foreground hover:bg-muted/40"
|
||||||
data-testid={`button-page-${p}`}
|
}`}
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
</Button>
|
</button>
|
||||||
)
|
),
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
<Button
|
onClick={() => go(currentPage + 1)}
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => goToPage(currentPage + 1)}
|
|
||||||
disabled={currentPage >= totalPages}
|
disabled={currentPage >= totalPages}
|
||||||
data-testid="button-next-page"
|
className="p-2 rounded-md border border-card-border text-card-foreground disabled:opacity-30"
|
||||||
|
aria-label="Weiter"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</Button>
|
</button>
|
||||||
</nav>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-16">
|
|
||||||
<p className="text-muted-foreground">Noch keine Videos vorhanden.</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"express": "^5.0.1",
|
"express": "^5.0.1",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
|
"hls.js": "^1.5.17",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
@@ -6030,6 +6031,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hls.js": {
|
||||||
|
"version": "1.6.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||||
|
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/http_ece": {
|
"node_modules/http_ece": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
|||||||
@@ -86,7 +86,8 @@
|
|||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
"zod-validation-error": "^3.5.4"
|
"zod-validation-error": "^3.5.4",
|
||||||
|
"hls.js": "^1.5.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@replit/vite-plugin-cartographer": "^0.4.4",
|
"@replit/vite-plugin-cartographer": "^0.4.4",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ The official website for Folx Music Television (folx.tv). Dark-themed bento grid
|
|||||||
- Primary keyword: "Volksmusik" — used across all page titles, meta descriptions, OG tags, and structured data
|
- Primary keyword: "Volksmusik" — used across all page titles, meta descriptions, OG tags, and structured data
|
||||||
- Dynamic canonical URLs via `usePageMeta` hook (updates `<link rel="canonical">` per page)
|
- Dynamic canonical URLs via `usePageMeta` hook (updates `<link rel="canonical">` per page)
|
||||||
- SSR article pages: server-side meta tags (OG, Twitter, description, keywords, canonical) in both `server/vite.ts` (dev) and `server/static.ts` (prod)
|
- SSR article pages: server-side meta tags (OG, Twitter, description, keywords, canonical) in both `server/vite.ts` (dev) and `server/static.ts` (prod)
|
||||||
|
- SSR gallery page (`/gallery`): OG meta, Twitter card, description, canonical — emphasizes exclusive backstage/concert photos from recordings
|
||||||
- `stripExistingMeta()` removes duplicate meta/canonical from base HTML before injecting article-specific ones
|
- `stripExistingMeta()` removes duplicate meta/canonical from base HTML before injecting article-specific ones
|
||||||
- JSON-LD structured data: WebSite (home) with SearchAction, NewsArticle + BreadcrumbList (articles)
|
- JSON-LD structured data: WebSite (home) with SearchAction, NewsArticle + BreadcrumbList (articles)
|
||||||
- Sitemap at `/sitemap.xml` — includes all static pages, categories, horoscope signs, and articles
|
- Sitemap at `/sitemap.xml` — includes all static pages, categories, horoscope signs, and articles
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { homeSchema, publisherSchema, articleModifiedTime, articleGenre, INFO_SEO } from "../shared/seo";
|
||||||
|
const home = homeSchema();
|
||||||
|
assert.equal(home["@graph"].filter(x => x["@type"] === "WebSite").length, 1);
|
||||||
|
assert.equal(publisherSchema()["@type"], "NewsMediaOrganization");
|
||||||
|
assert.ok(JSON.stringify(home).includes("/search?q="));
|
||||||
|
assert.ok(!JSON.stringify(home).includes("/suche"));
|
||||||
|
assert.ok(publisherSchema().sameAs.includes("https://www.youtube.com/@folxmtv"));
|
||||||
|
const pub = "2026-01-01T12:00:00.000Z";
|
||||||
|
assert.equal(articleModifiedTime("", pub), pub);
|
||||||
|
assert.equal(articleModifiedTime("<!--folx-updated:invalid-->", pub), pub);
|
||||||
|
assert.equal(articleModifiedTime("<!--folx-updated:2025-01-01-->", pub), pub);
|
||||||
|
assert.equal(articleModifiedTime("<!--folx-updated:2099-01-01-->", pub), pub);
|
||||||
|
assert.equal(articleModifiedTime("<!--folx-updated:2026-02-01-->", pub), "2026-02-01T00:00:00.000Z");
|
||||||
|
assert.equal(articleGenre("<!--folx-badge:STAR NEWS-->").section, "Star News");
|
||||||
|
assert.ok(!articleGenre("<!--folx-badge:STAR NEWS-->").words.includes("Schlager"));
|
||||||
|
assert.equal(articleGenre("").section, "Musik News");
|
||||||
|
assert.equal(INFO_SEO["/ueber-uns"].type, "AboutPage");
|
||||||
|
assert.equal(INFO_SEO["/kontakt"].type, "ContactPage");
|
||||||
|
console.log("SEO metadata checks passed");
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { db } from "./db";
|
||||||
|
import { dailyRecipes } from "@shared/schema";
|
||||||
|
import { eq, desc } from "drizzle-orm";
|
||||||
|
|
||||||
|
function todayStr(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecipeForToday(): Promise<any | null> {
|
||||||
|
const rows = await db.select().from(dailyRecipes).where(eq(dailyRecipes.dateStr, todayStr()));
|
||||||
|
return rows.length > 0 ? rows[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertRecipe(data: {
|
||||||
|
dateStr?: string;
|
||||||
|
name: string;
|
||||||
|
intro?: string;
|
||||||
|
recipe?: string;
|
||||||
|
seasonNote?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
}): Promise<any> {
|
||||||
|
const dateStr = data.dateStr || todayStr();
|
||||||
|
const values = {
|
||||||
|
dateStr,
|
||||||
|
name: data.name,
|
||||||
|
intro: data.intro || "",
|
||||||
|
recipe: data.recipe || "",
|
||||||
|
seasonNote: data.seasonNote || null,
|
||||||
|
imageUrl: data.imageUrl || null,
|
||||||
|
};
|
||||||
|
const existing = await db.select().from(dailyRecipes).where(eq(dailyRecipes.dateStr, dateStr));
|
||||||
|
if (existing.length > 0) {
|
||||||
|
const [row] = await db.update(dailyRecipes).set(values).where(eq(dailyRecipes.id, existing[0].id)).returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const [row] = await db.insert(dailyRecipes).values(values).returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRecipes(limit = 200): Promise<any[]> {
|
||||||
|
return await db.select().from(dailyRecipes).orderBy(desc(dailyRecipes.dateStr)).limit(limit);
|
||||||
|
}
|
||||||
@@ -7,8 +7,61 @@ const openai = new OpenAI({
|
|||||||
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
|
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Zaris iz zaznave obrazov (OpenCV) — /opt/folxtv-uploads/focal_points.json,
|
||||||
|
// ki ga polni /opt/folx-fix/focal_calc.py na openclaw1. LLM je zaris ugibal in
|
||||||
|
// je pri portretnih slikah rezal glave, zato ima ta datoteka prednost.
|
||||||
|
const DETECTED_PATH = path.join(process.cwd(), "client/public/uploads/focal_points.json");
|
||||||
|
let detected: Record<string, { x: number; y: number }> = {};
|
||||||
|
let detectedMtime = 0;
|
||||||
|
function loadDetected() {
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(DETECTED_PATH);
|
||||||
|
if (st.mtimeMs === detectedMtime) return;
|
||||||
|
detected = JSON.parse(fs.readFileSync(DETECTED_PATH, "utf-8"));
|
||||||
|
detectedMtime = st.mtimeMs;
|
||||||
|
console.log("[focal-point] zaznanih zarisov:", Object.keys(detected).length);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const cache = new Map<string, { x: number; y: number }>();
|
const cache = new Map<string, { x: number; y: number }>();
|
||||||
|
|
||||||
|
// Trajni predpomnilnik LLM zarisov. Lezi na /opt/folxtv-uploads (bind mount),
|
||||||
|
// zato prezivi deploy in restart — brez tega je vsak deploy pomenil ponovno
|
||||||
|
// analizo vseh naslovnih slik na gpt-4o-mini.
|
||||||
|
const CACHE_PATH = path.join(process.cwd(), "client/public/uploads/focal_cache.json");
|
||||||
|
let cacheLoaded = false;
|
||||||
|
let cacheDirty = false;
|
||||||
|
|
||||||
|
function loadCache() {
|
||||||
|
if (cacheLoaded) return;
|
||||||
|
cacheLoaded = true;
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(CACHE_PATH, "utf-8")) as Record<string, { x: number; y: number }>;
|
||||||
|
for (const [k, v] of Object.entries(raw)) {
|
||||||
|
if (v && typeof v.x === "number" && typeof v.y === "number") cache.set(k, v);
|
||||||
|
}
|
||||||
|
console.log("[focal-point] iz predpomnilnika nalozenih:", cache.size);
|
||||||
|
} catch {
|
||||||
|
console.log("[focal-point] predpomnilnika se ni, zacenjam prazen");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCache() {
|
||||||
|
if (!cacheDirty) return;
|
||||||
|
try {
|
||||||
|
const obj: Record<string, { x: number; y: number }> = {};
|
||||||
|
for (const [k, v] of cache.entries()) obj[k] = v;
|
||||||
|
fs.mkdirSync(path.dirname(CACHE_PATH), { recursive: true });
|
||||||
|
fs.writeFileSync(CACHE_PATH + ".tmp", JSON.stringify(obj));
|
||||||
|
fs.renameSync(CACHE_PATH + ".tmp", CACHE_PATH);
|
||||||
|
cacheDirty = false;
|
||||||
|
console.log("[focal-point] predpomnilnik shranjen, vnosov:", cache.size);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[focal-point] predpomnilnika ni bilo mogoce shraniti:", (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const manualOverrides: Record<string, { x: number; y: number }> = {
|
const manualOverrides: Record<string, { x: number; y: number }> = {
|
||||||
"/uploads/70-jahre-oberkrainer.webp": { x: 50, y: 55 },
|
"/uploads/70-jahre-oberkrainer.webp": { x: 50, y: 55 },
|
||||||
};
|
};
|
||||||
@@ -29,10 +82,16 @@ Return ONLY a JSON object like {"x":42,"y":38} with no other text.`;
|
|||||||
|
|
||||||
export async function analyzeFocalPoint(imagePath: string): Promise<{ x: number; y: number }> {
|
export async function analyzeFocalPoint(imagePath: string): Promise<{ x: number; y: number }> {
|
||||||
const originalPath = imagePath;
|
const originalPath = imagePath;
|
||||||
|
loadCache();
|
||||||
if (manualOverrides[originalPath]) {
|
if (manualOverrides[originalPath]) {
|
||||||
cache.set(originalPath, manualOverrides[originalPath]);
|
cache.set(originalPath, manualOverrides[originalPath]);
|
||||||
return manualOverrides[originalPath];
|
return manualOverrides[originalPath];
|
||||||
}
|
}
|
||||||
|
loadDetected();
|
||||||
|
if (detected[originalPath]) {
|
||||||
|
cache.set(originalPath, detected[originalPath]);
|
||||||
|
return detected[originalPath];
|
||||||
|
}
|
||||||
if (cache.has(originalPath)) {
|
if (cache.has(originalPath)) {
|
||||||
return cache.get(originalPath)!;
|
return cache.get(originalPath)!;
|
||||||
}
|
}
|
||||||
@@ -82,6 +141,7 @@ export async function analyzeFocalPoint(imagePath: string): Promise<{ x: number;
|
|||||||
y: Math.max(0, Math.min(100, Number(parsed.y) || 30)),
|
y: Math.max(0, Math.min(100, Number(parsed.y) || 30)),
|
||||||
};
|
};
|
||||||
cache.set(originalPath, point);
|
cache.set(originalPath, point);
|
||||||
|
cacheDirty = true;
|
||||||
return point;
|
return point;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -98,13 +158,20 @@ export async function analyzeAllArticleImages(articles: Array<{ coverImage: stri
|
|||||||
if (!article.coverImage) continue;
|
if (!article.coverImage) continue;
|
||||||
await analyzeFocalPoint(article.coverImage);
|
await analyzeFocalPoint(article.coverImage);
|
||||||
}
|
}
|
||||||
|
saveCache();
|
||||||
console.log("[focal-point] Analyzed", cache.size, "images");
|
console.log("[focal-point] Analyzed", cache.size, "images");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCachedFocalPoints(): Record<string, { x: number; y: number }> {
|
export function getCachedFocalPoints(): Record<string, { x: number; y: number }> {
|
||||||
|
loadCache();
|
||||||
|
loadDetected();
|
||||||
const result: Record<string, { x: number; y: number }> = {};
|
const result: Record<string, { x: number; y: number }> = {};
|
||||||
for (const [key, value] of cache.entries()) {
|
for (const [key, value] of cache.entries()) {
|
||||||
result[key] = value;
|
result[key] = value;
|
||||||
}
|
}
|
||||||
|
// zaznani zaris ima prednost pred LLM ugibanjem
|
||||||
|
for (const [key, value] of Object.entries(detected)) {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { dailyHoroscopes } from "@shared/schema";
|
import { dailyHoroscopes, cosmicEvents } from "@shared/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
@@ -25,6 +25,19 @@ export async function getHoroscopesForToday(): Promise<any[]> {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setHoroscopeImages(dateStr: string, images: Record<string, string>): Promise<number> {
|
||||||
|
let n = 0;
|
||||||
|
for (const [key, url] of Object.entries(images || {})) {
|
||||||
|
const idx = parseInt(key, 10);
|
||||||
|
if (Number.isNaN(idx) || !url) continue;
|
||||||
|
await db.update(dailyHoroscopes)
|
||||||
|
.set({ imageUrl: url })
|
||||||
|
.where(and(eq(dailyHoroscopes.dateStr, dateStr), eq(dailyHoroscopes.signIndex, idx)));
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateDailyHoroscopes(): Promise<void> {
|
export async function generateDailyHoroscopes(): Promise<void> {
|
||||||
const today = getTodayStr();
|
const today = getTodayStr();
|
||||||
|
|
||||||
@@ -44,7 +57,7 @@ export async function generateDailyHoroscopes(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
model: "gpt-5-mini",
|
model: "openai/gpt-5.4",
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
@@ -66,7 +79,7 @@ Antworte NUR mit einem JSON-Objekt in diesem exakten Format (kein Markdown, kein
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
max_completion_tokens: 2000,
|
max_completion_tokens: 4000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const content = response.choices[0]?.message?.content || "";
|
const content = response.choices[0]?.message?.content || "";
|
||||||
@@ -110,3 +123,107 @@ export async function getOrGenerateHoroscope(signIndex: number): Promise<any | n
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_ICONS = ["mercury", "venus", "moon", "newmoon", "sun", "saturn", "jupiter", "mars"];
|
||||||
|
const VALID_TYPES = ["retrograde", "moon", "transit", "season"];
|
||||||
|
|
||||||
|
export async function getCosmicEventsForToday(): Promise<any[]> {
|
||||||
|
const today = getTodayStr();
|
||||||
|
const rows = await db.select().from(cosmicEvents)
|
||||||
|
.where(eq(cosmicEvents.dateStr, today));
|
||||||
|
return rows
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((r) => ({
|
||||||
|
title: r.title,
|
||||||
|
description: r.description,
|
||||||
|
dateRange: r.dateRange,
|
||||||
|
icon: r.icon,
|
||||||
|
type: r.type,
|
||||||
|
affectedSigns: JSON.parse(r.affectedSigns || "[]"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCosmicEvents(): Promise<void> {
|
||||||
|
const today = getTodayStr();
|
||||||
|
|
||||||
|
const existing = await db.select().from(cosmicEvents)
|
||||||
|
.where(eq(cosmicEvents.dateStr, today));
|
||||||
|
if (existing.length >= 6) {
|
||||||
|
console.log(`Cosmic events for ${today} already exist.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Generating cosmic events for ${today}...`);
|
||||||
|
|
||||||
|
const d = new Date();
|
||||||
|
const monthName = d.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
|
||||||
|
|
||||||
|
const eventSlots = [
|
||||||
|
"die aktuelle Tierkreis-Saison (Sonne im aktuellen Sternzeichen), type=season, icon=sun",
|
||||||
|
"die aktuelle Mondphase (Vollmond oder Neumond, je nachdem was näher ist), type=moon, icon=moon oder newmoon",
|
||||||
|
"ein aktueller Venus-Transit (Venus im aktuellen Zeichen), type=transit, icon=venus",
|
||||||
|
"ein aktueller Mars- oder Merkur-Aspekt (inkl. eventueller Retrograde), type=transit oder retrograde, icon=mars oder mercury",
|
||||||
|
"ein aktueller Saturn-Transit, type=transit, icon=saturn",
|
||||||
|
"ein aktueller Jupiter-Transit, type=transit, icon=jupiter",
|
||||||
|
];
|
||||||
|
|
||||||
|
const results: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < eventSlots.length; i++) {
|
||||||
|
try {
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: "gpt-5-mini",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `Du bist ein erfahrener Astrologe für eine deutschsprachige Volksmusik- und Schlager-Website. Du kennst die realen astronomischen Daten. Heute ist der ${today} (${monthName}). Beschreibe nur Ereignisse, die rund um dieses Datum tatsächlich aktiv sind, mit realistischen Datumsangaben. Antworte ausschließlich auf Deutsch.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Beschreibe folgendes aktuelles kosmisches Ereignis für heute (${today}): ${eventSlots[i]}.
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Objekt (kein Markdown, keine Erklärung) in diesem exakten Format:
|
||||||
|
{
|
||||||
|
"title": "Kurzer Titel, z.B. 'Vollmond im Skorpion' oder 'Zwillinge-Saison'",
|
||||||
|
"description": "3-4 Sätze über die Bedeutung dieses Ereignisses und seinen Einfluss.",
|
||||||
|
"dateRange": "Realistische Datumsangabe passend zum heutigen Datum, z.B. '21. Mai – 20. Juni 2026' oder '11. Juni 2026'",
|
||||||
|
"icon": "EINER VON: mercury, venus, moon, newmoon, sun, saturn, jupiter, mars",
|
||||||
|
"type": "EINER VON: retrograde, moon, transit, season",
|
||||||
|
"affectedSigns": ["3-4 betroffene Sternzeichen auf Deutsch"]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
max_completion_tokens: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let content = response.choices[0]?.message?.content || "";
|
||||||
|
content = content.replace(/```json/gi, "").replace(/```/g, "").trim();
|
||||||
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) {
|
||||||
|
console.error(`Failed to parse cosmic event ${i}. Raw:`, content.slice(0, 300));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ev = JSON.parse(jsonMatch[0]);
|
||||||
|
const icon = VALID_ICONS.includes(ev.icon) ? ev.icon : "moon";
|
||||||
|
const type = VALID_TYPES.includes(ev.type) ? ev.type : "transit";
|
||||||
|
const signs = Array.isArray(ev.affectedSigns) ? ev.affectedSigns : [];
|
||||||
|
|
||||||
|
await db.insert(cosmicEvents).values({
|
||||||
|
dateStr: today,
|
||||||
|
position: i,
|
||||||
|
title: String(ev.title || "").slice(0, 120),
|
||||||
|
description: String(ev.description || ""),
|
||||||
|
dateRange: String(ev.dateRange || "").slice(0, 120),
|
||||||
|
icon,
|
||||||
|
type,
|
||||||
|
affectedSigns: JSON.stringify(signs),
|
||||||
|
});
|
||||||
|
results.push(ev.title);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`Error generating cosmic event ${i}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Generated ${results.length} cosmic events for ${today}.`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { createServer, type Server } from "http";
|
|||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
import { insertArticleSchema } from "@shared/schema";
|
import { insertArticleSchema } from "@shared/schema";
|
||||||
import { seedDatabase } from "./seed";
|
import { seedDatabase } from "./seed";
|
||||||
import { generateDailyHoroscopes, getHoroscopesForToday, getOrGenerateHoroscope } from "./horoscope-generator";
|
import { generateDailyHoroscopes, getHoroscopesForToday, getOrGenerateHoroscope, setHoroscopeImages } from "./horoscope-generator";
|
||||||
|
import { getRecipeForToday, upsertRecipe, listRecipes } from "./daily-recipe";
|
||||||
|
import { startDailyScheduler } from "./scheduler";
|
||||||
import { analyzeAllArticleImages, getCachedFocalPoints } from "./focal-point";
|
import { analyzeAllArticleImages, getCachedFocalPoints } from "./focal-point";
|
||||||
import { optimizeImage } from "./image-optimizer";
|
import { optimizeImage } from "./image-optimizer";
|
||||||
import { getAuthUrl, exchangeCodeForTokens, isConnected, fetchGalleryFromDropbox, getValidAccessToken, migrateGalleryToCloudinary } from "./dropbox";
|
import { getAuthUrl, exchangeCodeForTokens, isConnected, fetchGalleryFromDropbox, getValidAccessToken, migrateGalleryToCloudinary } from "./dropbox";
|
||||||
@@ -56,25 +58,200 @@ export async function registerRoutes(
|
|||||||
httpServer: Server,
|
httpServer: Server,
|
||||||
app: Express
|
app: Express
|
||||||
): Promise<Server> {
|
): Promise<Server> {
|
||||||
|
try {
|
||||||
await seedDatabase();
|
await seedDatabase();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[seed] seedDatabase failed (server continues):", err?.message);
|
||||||
|
}
|
||||||
|
|
||||||
generateDailyHoroscopes().catch((err) =>
|
startDailyScheduler();
|
||||||
console.error("Background horoscope generation failed:", err.message)
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// ---- API zascita za pisalne endpointe (boti posiljajo x-api-key) ----
|
||||||
|
const FOLX_API_KEY = process.env.FOLX_API_KEY || "";
|
||||||
|
const requireApiKey = (req: any, res: any, next: any) => {
|
||||||
|
if (!FOLX_API_KEY) return next(); // brez nastavljenega kljuca: ne zaklepamo (rollout varovalka)
|
||||||
|
const provided = req.get("x-api-key") || (req.get("authorization") || "").replace(/^Bearer\s+/i, "");
|
||||||
|
if (provided && provided === FOLX_API_KEY) return next();
|
||||||
|
return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fokusne tocke naslovnic (kje je obraz) — brez njih object-cover obreze na
|
||||||
|
// sredino in odreze glave. Racunamo ob zagonu IN vsakih 20 minut, da jih
|
||||||
|
// dobijo tudi clanki, ki jih boti objavijo med tem.
|
||||||
|
const refreshFocalPoints = () => {
|
||||||
storage.getArticles().then((articles) => {
|
storage.getArticles().then((articles) => {
|
||||||
analyzeAllArticleImages(articles).catch(err =>
|
analyzeAllArticleImages(articles).catch(err =>
|
||||||
console.log("[focal-point] Analysis skipped:", err.message)
|
console.log("[focal-point] Analysis skipped:", err.message)
|
||||||
);
|
);
|
||||||
|
}).catch(err => console.log("[focal-point] getArticles failed:", err?.message));
|
||||||
|
};
|
||||||
|
refreshFocalPoints();
|
||||||
|
setInterval(refreshFocalPoints, 20 * 60 * 1000);
|
||||||
|
|
||||||
|
// ---- SEO: robots.txt, sitemap.xml, news sitemap, RSS ----
|
||||||
|
// Rubrike z onemusic.news imajo isto besedilo kot tam, zato jih ne oddajamo
|
||||||
|
// Googlu. V bazi in v /api/articles ostanejo — sidebar na TV jih potrebuje.
|
||||||
|
const ONE_RUBRIKEN = new Set(["Politik", "Promis", "Sport", "Musik & Film", "Wetter", "Finanzen", "Aktuelles"]);
|
||||||
|
// targetUrl = clanek je samo preusmeritev (dnevni horoskop, recept). Taki
|
||||||
|
// ne smejo v sitemap: Google jih oznaci kot "stran s preusmeritvijo" in
|
||||||
|
// porabi crawl budget za nic.
|
||||||
|
const isOwn = (a: any) => !ONE_RUBRIKEN.has(a?.category || "") && !a?.targetUrl;
|
||||||
|
|
||||||
|
const SITE = "https://folx.tv";
|
||||||
|
const xmlEscape = (s: string) =>
|
||||||
|
String(s || "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
|
app.get("/robots.txt", (_req, res) => {
|
||||||
|
res.type("text/plain").send(
|
||||||
|
[
|
||||||
|
"User-agent: *",
|
||||||
|
"Allow: /",
|
||||||
|
"",
|
||||||
|
`Sitemap: ${SITE}/sitemap.xml`,
|
||||||
|
`Sitemap: ${SITE}/news-sitemap.xml`,
|
||||||
|
`Sitemap: ${SITE}/video-sitemap.xml`,
|
||||||
|
"",
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stare oziroma logicne poti, ki jih React router ne pozna. Brez tega
|
||||||
|
// uporabnik in Google dobita 404 (npr. /empfang/, ki je bil v obtoku).
|
||||||
|
const LEGACY_REDIRECTS: Record<string, string> = {
|
||||||
|
"/empfang": "/empfang-folx-tv",
|
||||||
|
"/empfang-folx": "/empfang-folx-tv",
|
||||||
|
"/news": "/category/News",
|
||||||
|
"/nachrichten": "/category/News",
|
||||||
|
"/video": "/videos",
|
||||||
|
"/galerie": "/",
|
||||||
|
"/ueber": "/ueber-uns",
|
||||||
|
"/about": "/ueber-uns",
|
||||||
|
"/contact": "/kontakt",
|
||||||
|
"/rezept": "/rezepte",
|
||||||
|
"/horoscope": "/horoskop",
|
||||||
|
};
|
||||||
|
app.get(Object.keys(LEGACY_REDIRECTS), (req, res) => {
|
||||||
|
const to = LEGACY_REDIRECTS[req.path.replace(/\/+$/, "") || "/"];
|
||||||
|
if (to) return res.redirect(301, to);
|
||||||
|
return res.redirect(301, "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/sitemap.xml", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const articles = await storage.getArticles();
|
||||||
|
// POZOR: tu smejo biti samo poti, ki jih pozna React router v App.tsx.
|
||||||
|
// Prej so bile "/news", "/video", "/galerie" — teh poti ni, zato je Google
|
||||||
|
// dobival soft 404. Prave so "/category/News", "/videos", "/gallery".
|
||||||
|
const staticPaths = ["/", "/category/News", "/videos",
|
||||||
|
"/empfang-folx-tv", "/ueber-uns",
|
||||||
|
"/kontakt", "/impressum", "/datenschutz"];
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const p of staticPaths) {
|
||||||
|
urls.push(` <url>\n <loc>${SITE}${p}</loc>\n <changefreq>daily</changefreq>\n <priority>${p === "/" ? "1.0" : "0.7"}</priority>\n </url>`);
|
||||||
|
}
|
||||||
|
for (const a of articles) {
|
||||||
|
if (!isOwn(a)) continue;
|
||||||
|
const lastmod = a.publishedAt ? new Date(a.publishedAt as any).toISOString() : new Date().toISOString();
|
||||||
|
urls.push(` <url>\n <loc>${SITE}/article/${xmlEscape(a.slug)}</loc>\n <lastmod>${lastmod}</lastmod>\n <changefreq>weekly</changefreq>\n <priority>0.8</priority>\n </url>`);
|
||||||
|
}
|
||||||
|
for (const v of ytItems()) {
|
||||||
|
urls.push(` <url>\n <loc>${SITE}/video/${xmlEscape(v.slug)}</loc>\n <lastmod>${new Date(v.publishedAt).toISOString()}</lastmod>\n <changefreq>weekly</changefreq>\n <priority>0.8</priority>\n </url>`);
|
||||||
|
}
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join("\n")}\n</urlset>`;
|
||||||
|
res.type("application/xml").send(xml);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).type("application/xml").send('<?xml version="1.0"?><urlset/>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Video sitemap: Google ga bere posebej in iz njega ve, katere strani nosijo
|
||||||
|
// video. Vsak vnos kaze na naso stran, predvajalnik pa je YouTube.
|
||||||
|
app.get("/video-sitemap.xml", (_req, res) => {
|
||||||
|
try {
|
||||||
|
const urls = ytItems().map((v) => {
|
||||||
|
const page = `${SITE}/video/${xmlEscape(v.slug)}`;
|
||||||
|
return ` <url>\n <loc>${page}</loc>\n <video:video>\n` +
|
||||||
|
` <video:thumbnail_loc>${SITE}${xmlEscape(v.posterUrl)}</video:thumbnail_loc>\n` +
|
||||||
|
` <video:title>${xmlEscape(v.shortTitle)}</video:title>\n` +
|
||||||
|
` <video:description>${xmlEscape((v.teaser || v.shortTitle).slice(0, 1900))}</video:description>\n` +
|
||||||
|
` <video:player_loc>${xmlEscape(v.embedUrl || `https://www.youtube-nocookie.com/embed/${v.ytId}`)}</video:player_loc>\n` +
|
||||||
|
` <video:duration>${v.durationS || 0}</video:duration>\n` +
|
||||||
|
` <video:publication_date>${new Date(v.publishedAt).toISOString()}</video:publication_date>\n` +
|
||||||
|
` <video:family_friendly>yes</video:family_friendly>\n` +
|
||||||
|
` <video:live>no</video:live>\n` +
|
||||||
|
` </video:video>\n </url>`;
|
||||||
|
});
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">\n${urls.join("\n")}\n</urlset>`;
|
||||||
|
res.type("application/xml").send(xml);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).type("application/xml").send('<?xml version="1.0"?><urlset/>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/news-sitemap.xml", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const articles = await storage.getArticles();
|
||||||
|
const now = Date.now();
|
||||||
|
const twoDays = 48 * 60 * 60 * 1000;
|
||||||
|
const recent = articles.filter((a) => {
|
||||||
|
if (!isOwn(a)) return false;
|
||||||
|
const t = a.publishedAt ? new Date(a.publishedAt as any).getTime() : now;
|
||||||
|
return now - t <= twoDays;
|
||||||
|
});
|
||||||
|
const urls = recent.map((a) => {
|
||||||
|
const pub = a.publishedAt ? new Date(a.publishedAt as any).toISOString() : new Date().toISOString();
|
||||||
|
return ` <url>\n <loc>${SITE}/article/${xmlEscape(a.slug)}</loc>\n <news:news>\n <news:publication>\n <news:name>Folx Music Television</news:name>\n <news:language>de</news:language>\n </news:publication>\n <news:publication_date>${pub}</news:publication_date>\n <news:title>${xmlEscape(a.title)}</news:title>\n </news:news>\n </url>`;
|
||||||
|
});
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">\n${urls.join("\n")}\n</urlset>`;
|
||||||
|
res.type("application/xml").send(xml);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).type("application/xml").send('<?xml version="1.0"?><urlset/>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(["/rss.xml", "/feed", "/feed.xml"], async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const articles = (await storage.getArticles()).slice(0, 30);
|
||||||
|
const items = articles.map((a) => {
|
||||||
|
const pub = a.publishedAt ? new Date(a.publishedAt as any).toUTCString() : new Date().toUTCString();
|
||||||
|
const link = `${SITE}/article/${xmlEscape(a.slug)}`;
|
||||||
|
const img = a.coverImage ? (a.coverImage.startsWith("http") ? a.coverImage : SITE + a.coverImage) : "";
|
||||||
|
const enclosure = img ? `\n <enclosure url="${xmlEscape(img.replace(/\.webp$/, ".jpg"))}" type="image/jpeg" />` : "";
|
||||||
|
return ` <item>\n <title>${xmlEscape(a.title)}</title>\n <link>${link}</link>\n <guid isPermaLink="true">${link}</guid>\n <pubDate>${pub}</pubDate>\n <category>${xmlEscape(a.category || "News")}</category>\n <description>${xmlEscape(a.excerpt || "")}</description>${enclosure}\n </item>`;
|
||||||
|
});
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n <channel>\n <title>Folx Music Television – News</title>\n <link>${SITE}</link>\n <atom:link href="${SITE}/rss.xml" rel="self" type="application/rss+xml" />\n <description>Aktuelle Nachrichten aus der Welt der Volksmusik und des Schlagers.</description>\n <language>de-DE</language>\n <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>\n${items.join("\n")}\n </channel>\n</rss>`;
|
||||||
|
res.type("application/rss+xml").send(xml);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).type("application/rss+xml").send('<?xml version="1.0"?><rss/>');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/focal-points", (_req, res) => {
|
app.get("/api/focal-points", (_req, res) => {
|
||||||
res.json(getCachedFocalPoints());
|
res.json(getCachedFocalPoints());
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/articles", async (_req, res) => {
|
// Naslovnica je do 24.8.2026 potegnila VSEH 550 clankov skupaj s poljem
|
||||||
const articles = await storage.getArticles();
|
// content (1,77 MB). Zdaj sprejme limit/offset in light=1, ki odrezeta
|
||||||
res.json(articles);
|
// content in targetUrl — za kartico nista potrebna.
|
||||||
|
app.get("/api/articles", async (req, res) => {
|
||||||
|
const all = await storage.getArticles();
|
||||||
|
const offset = Math.max(0, parseInt(String(req.query.offset ?? "0")) || 0);
|
||||||
|
const limRaw = parseInt(String(req.query.limit ?? ""));
|
||||||
|
const lim = Number.isFinite(limRaw) && limRaw > 0 ? limRaw : undefined;
|
||||||
|
let out: any[] = all.slice(offset, lim ? offset + lim : undefined);
|
||||||
|
if (String(req.query.light ?? "") === "1") {
|
||||||
|
out = out.map((a: any) => {
|
||||||
|
const { content, targetUrl, ...rest } = a;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.set("Cache-Control", "public, max-age=60");
|
||||||
|
res.json(out);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/articles/featured", async (_req, res) => {
|
app.get("/api/articles/featured", async (_req, res) => {
|
||||||
@@ -84,10 +261,37 @@ export async function registerRoutes(
|
|||||||
|
|
||||||
app.get("/api/articles/popular", async (req, res) => {
|
app.get("/api/articles/popular", async (req, res) => {
|
||||||
const limit = parseInt(req.query.limit as string) || 6;
|
const limit = parseInt(req.query.limit as string) || 6;
|
||||||
const articles = await storage.getPopularArticles(limit);
|
const days = parseInt(req.query.days as string) || 7;
|
||||||
|
const articles = await storage.getPopularArticles(limit, days);
|
||||||
res.json(articles);
|
res.json(articles);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rubrike, ki skupaj sestavljajo /aktuelles. Horoskop, recepti in vreme so
|
||||||
|
// na folx.tv svoje strani, zato jih tu ni.
|
||||||
|
const AKTUELLES_RUBRIKEN = ["Politik", "Promis", "Sport", "Musik & Film", "Wetter", "Finanzen"];
|
||||||
|
|
||||||
|
app.get("/api/articles/aktuelles", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||||
|
const limit = Math.min(48, parseInt(req.query.limit as string) || 12);
|
||||||
|
const rubrik = (req.query.rubrik as string) || "";
|
||||||
|
const wanted = rubrik && AKTUELLES_RUBRIKEN.includes(rubrik) ? [rubrik] : AKTUELLES_RUBRIKEN;
|
||||||
|
const all = (await storage.getArticles()).filter((a) => wanted.includes(a.category));
|
||||||
|
const total = all.length;
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
res.json({
|
||||||
|
articles: all.slice(start, start + limit),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
hasMore: start + limit < total,
|
||||||
|
rubriken: AKTUELLES_RUBRIKEN,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ message: e?.message || "error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/articles/category/:category", async (req, res) => {
|
app.get("/api/articles/category/:category", async (req, res) => {
|
||||||
const page = parseInt(req.query.page as string);
|
const page = parseInt(req.query.page as string);
|
||||||
const limit = parseInt(req.query.limit as string) || 12;
|
const limit = parseInt(req.query.limit as string) || 12;
|
||||||
@@ -150,7 +354,13 @@ export async function registerRoutes(
|
|||||||
res.json({ ...article, views: article.views + (isNew ? 1 : 0) });
|
res.json({ ...article, views: article.views + (isNew ? 1 : 0) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/articles", async (req, res) => {
|
function _parseDate(v: any): Date | null {
|
||||||
|
if (!v) return null;
|
||||||
|
const d = new Date(v);
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post("/api/articles", requireApiKey, async (req, res) => {
|
||||||
const parsed = insertArticleSchema.safeParse(req.body);
|
const parsed = insertArticleSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return res.status(400).json({ message: "Invalid article data", errors: parsed.error.flatten() });
|
return res.status(400).json({ message: "Invalid article data", errors: parsed.error.flatten() });
|
||||||
@@ -159,30 +369,38 @@ export async function registerRoutes(
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
return res.status(409).json({ message: `Artikel "${existing.title}" existiert bereits (ID: ${existing.id})` });
|
return res.status(409).json({ message: `Artikel "${existing.title}" existiert bereits (ID: ${existing.id})` });
|
||||||
}
|
}
|
||||||
const article = await storage.createArticle(parsed.data);
|
// publishedAt iz zunanjega vira (uvoz z ONE): shema ga sicer izpusti, zato
|
||||||
|
// ga sprejmemo posebej - drugace bi vsi uvozeni clanki nosili danasnji datum.
|
||||||
|
const data: any = { ...parsed.data };
|
||||||
|
const pub = _parseDate(req.body?.publishedAt);
|
||||||
|
if (pub) data.publishedAt = pub;
|
||||||
|
const article = await storage.createArticle(data);
|
||||||
res.status(201).json(article);
|
res.status(201).json(article);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/api/articles/:id", async (req, res) => {
|
app.patch("/api/articles/:id", requireApiKey, async (req, res) => {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
const partial = insertArticleSchema.partial().safeParse(req.body);
|
const partial = insertArticleSchema.partial().safeParse(req.body);
|
||||||
if (!partial.success) {
|
if (!partial.success) {
|
||||||
return res.status(400).json({ message: "Invalid article data", errors: partial.error.flatten() });
|
return res.status(400).json({ message: "Invalid article data", errors: partial.error.flatten() });
|
||||||
}
|
}
|
||||||
const article = await storage.updateArticle(id, partial.data);
|
const patch: any = { ...partial.data };
|
||||||
|
const pubP = _parseDate(req.body?.publishedAt);
|
||||||
|
if (pubP) patch.publishedAt = pubP;
|
||||||
|
const article = await storage.updateArticle(id, patch);
|
||||||
if (!article) {
|
if (!article) {
|
||||||
return res.status(404).json({ message: "Article not found" });
|
return res.status(404).json({ message: "Article not found" });
|
||||||
}
|
}
|
||||||
res.json(article);
|
res.json(article);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/api/articles/:id", async (req, res) => {
|
app.delete("/api/articles/:id", requireApiKey, async (req, res) => {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
await storage.deleteArticle(id);
|
await storage.deleteArticle(id);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/upload", upload.single("image"), async (req, res) => {
|
app.post("/api/upload", requireApiKey, upload.single("image"), async (req, res) => {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ message: "No file uploaded" });
|
return res.status(400).json({ message: "No file uploaded" });
|
||||||
}
|
}
|
||||||
@@ -219,6 +437,37 @@ export async function registerRoutes(
|
|||||||
const LIBRARY_ID = process.env.BUNNY_LIBRARY_ID || "476412";
|
const LIBRARY_ID = process.env.BUNNY_LIBRARY_ID || "476412";
|
||||||
const CDN_HOST = process.env.BUNNY_CDN_HOST || "vz-7982dfc4-cc8.b-cdn.net";
|
const CDN_HOST = process.env.BUNNY_CDN_HOST || "vz-7982dfc4-cc8.b-cdn.net";
|
||||||
|
|
||||||
|
// ---- FOLX YouTube video sekcija (napovednik na strani, cel video na YouTube) ----
|
||||||
|
const ytIndexPath = path.join(uploadDir, "video", "index.json");
|
||||||
|
function ytItems(): any[] {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(ytIndexPath, "utf-8");
|
||||||
|
const j = JSON.parse(raw);
|
||||||
|
return Array.isArray(j.items) ? j.items : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.get("/api/yt-videos", (req, res) => {
|
||||||
|
const rubrik = String(req.query.rubrik || "").trim();
|
||||||
|
const page = Math.max(1, parseInt(String(req.query.page || "1")));
|
||||||
|
const limit = Math.min(48, Math.max(1, parseInt(String(req.query.limit || "24"))));
|
||||||
|
let items = ytItems();
|
||||||
|
if (rubrik && rubrik !== "Alle") items = items.filter((i) => i.rubrik === rubrik);
|
||||||
|
const total = items.length;
|
||||||
|
const slice = items.slice((page - 1) * limit, page * limit);
|
||||||
|
const rubriken = Array.from(new Set(ytItems().map((i) => i.rubrik)));
|
||||||
|
res.set("Cache-Control", "public, max-age=120");
|
||||||
|
res.json({ items: slice, total, page, totalPages: Math.max(1, Math.ceil(total / limit)), rubriken });
|
||||||
|
});
|
||||||
|
app.get("/api/yt-videos/:slug", (req, res) => {
|
||||||
|
const it = ytItems().find((i) => i.slug === req.params.slug || i.ytId === req.params.slug);
|
||||||
|
if (!it) return res.status(404).json({ message: "Video nicht gefunden" });
|
||||||
|
const same = ytItems().filter((x) => x.rubrik === it.rubrik && x.slug !== it.slug).slice(0, 4);
|
||||||
|
res.set("Cache-Control", "public, max-age=120");
|
||||||
|
res.json({ item: it, related: same });
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/videos", async (req, res) => {
|
app.get("/api/videos", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
@@ -650,6 +899,47 @@ export async function registerRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Horoscope API
|
// Horoscope API
|
||||||
|
app.post("/api/horoscopes/images", requireApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { dateStr, images } = req.body || {};
|
||||||
|
if (!dateStr || typeof images !== "object" || images === null) {
|
||||||
|
return res.status(400).json({ message: "dateStr und images sind erforderlich" });
|
||||||
|
}
|
||||||
|
const updated = await setHoroscopeImages(dateStr, images);
|
||||||
|
res.json({ updated });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/recipes", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await listRecipes());
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/recipes/today", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const recipe = await getRecipeForToday();
|
||||||
|
res.json(recipe);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/recipes", requireApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body || {};
|
||||||
|
if (!name) return res.status(400).json({ message: "name ist erforderlich" });
|
||||||
|
const row = await upsertRecipe(req.body);
|
||||||
|
res.json(row);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/horoscopes/today", async (_req, res) => {
|
app.get("/api/horoscopes/today", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const horoscopes = await getHoroscopesForToday();
|
const horoscopes = await getHoroscopesForToday();
|
||||||
@@ -769,35 +1059,69 @@ export async function registerRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Blok pricakuje ze izpisan relativni cas, kakrsnega je prej vracal RSS
|
||||||
|
// parser; brez tega se izpise gola ISO znacka.
|
||||||
|
function relativniCas(iso?: string): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (!t) return "";
|
||||||
|
const min = Math.max(0, Math.floor((Date.now() - t) / 60000));
|
||||||
|
if (min < 2) return "Gerade eben";
|
||||||
|
if (min < 60) return `vor ${min} Min.`;
|
||||||
|
const ur = Math.floor(min / 60);
|
||||||
|
if (ur < 24) return `vor ${ur} Std.`;
|
||||||
|
return `vor ${Math.floor(ur / 24)} T.`;
|
||||||
|
}
|
||||||
|
|
||||||
app.get("/api/breaking-news", async (_req, res) => {
|
app.get("/api/breaking-news", async (_req, res) => {
|
||||||
|
// 31. 8. 2026: blok Aktuelle Nachrichten je vodil na tagesschau, n-tv, ORF
|
||||||
|
// in Welt, torej smo obiskovalce posiljali s strani. Zdaj kaze nase novice
|
||||||
|
// z folx.news (locena domena, brez oglasov).
|
||||||
const cacheKey = "breaking-news";
|
const cacheKey = "breaking-news";
|
||||||
const cached = getCached<any[]>(cacheKey, 15 * 60 * 1000);
|
const cached = getCached<any[]>(cacheKey, 5 * 60 * 1000);
|
||||||
if (cached) return res.json(cached);
|
if (cached) return res.json(cached);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newsFeeds = [
|
const r = await fetch("https://folx.news/api/articles", {
|
||||||
"https://www.tagesschau.de/xml/rss2_https/",
|
headers: { "user-agent": "folx.tv-widget" },
|
||||||
"https://www.n-tv.de/rss",
|
signal: AbortSignal.timeout(10000),
|
||||||
"https://rss.orf.at/oesterreich.xml",
|
});
|
||||||
"https://www.welt.de/feeds/latest.rss",
|
const arts: any[] = await r.json();
|
||||||
];
|
const sveze = (Array.isArray(arts) ? arts : [])
|
||||||
let allItems: { title: string; link: string; source: string; pubDate: string }[] = [];
|
.filter((a) => a && a.slug && a.title)
|
||||||
const results = await Promise.allSettled(
|
.sort((a, b) => String(b.publishedAt || "").localeCompare(String(a.publishedAt || "")))
|
||||||
newsFeeds.map(async (url) => {
|
.slice(0, 60);
|
||||||
try {
|
|
||||||
const xml = await fetchRssFeed(url);
|
// Rubrike se morajo izmenjevati: sicer pridejo trije Sporti zapored,
|
||||||
return parseRssItems(xml, 1);
|
// ker jih bot objavi v svezju. Vzamemo po eno iz vsake rubrike v krogu,
|
||||||
} catch { return []; }
|
// znotraj rubrike pa ostane vrstni red po datumu.
|
||||||
})
|
const poRubriki = new Map<string, any[]>();
|
||||||
);
|
for (const a of sveze) {
|
||||||
for (const r of results) {
|
const k = String(a.category || "FOLX NEWS");
|
||||||
if (r.status === "fulfilled") allItems.push(...r.value);
|
if (!poRubriki.has(k)) poRubriki.set(k, []);
|
||||||
|
poRubriki.get(k)!.push(a);
|
||||||
}
|
}
|
||||||
allItems = allItems.slice(0, 10);
|
const kroznik: any[] = [];
|
||||||
if (allItems.length > 0) setCache(cacheKey, allItems);
|
let se = true;
|
||||||
res.json(allItems);
|
while (se && kroznik.length < 12) {
|
||||||
|
se = false;
|
||||||
|
for (const vrsta of poRubriki.values()) {
|
||||||
|
const a = vrsta.shift();
|
||||||
|
if (a) { kroznik.push(a); se = true; }
|
||||||
|
if (kroznik.length >= 12) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = kroznik.map((a) => ({
|
||||||
|
title: String(a.title),
|
||||||
|
link: `https://folx.news/${a.slug}`,
|
||||||
|
source: String(a.category || "FOLX NEWS"),
|
||||||
|
pubDate: relativniCas(a.publishedAt),
|
||||||
|
}));
|
||||||
|
if (items.length > 0) setCache(cacheKey, items);
|
||||||
|
res.json(items);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.log("[breaking-news] RSS fetch error:", err.message);
|
console.log("[breaking-news] folx.news error:", err.message);
|
||||||
const stale = getStale<any[]>(cacheKey);
|
const stale = getStale<any[]>(cacheKey);
|
||||||
res.json(stale || []);
|
res.json(stale || []);
|
||||||
}
|
}
|
||||||
@@ -892,6 +1216,7 @@ Disallow: /search
|
|||||||
Disallow: /admin/
|
Disallow: /admin/
|
||||||
|
|
||||||
Sitemap: https://folx.tv/sitemap.xml
|
Sitemap: https://folx.tv/sitemap.xml
|
||||||
|
Sitemap: https://folx.tv/video-sitemap.xml
|
||||||
`;
|
`;
|
||||||
res.set("Content-Type", "text/plain");
|
res.set("Content-Type", "text/plain");
|
||||||
res.set("Cache-Control", "public, max-age=86400");
|
res.set("Cache-Control", "public, max-age=86400");
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { generateDailyHoroscopes } from "./horoscope-generator";
|
||||||
|
|
||||||
|
async function runDailyGeneration() {
|
||||||
|
try {
|
||||||
|
await generateDailyHoroscopes();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[scheduler] Horoscope generation failed:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startDailyScheduler() {
|
||||||
|
// Run once at startup (generators are idempotent — they skip if today already exists).
|
||||||
|
runDailyGeneration();
|
||||||
|
|
||||||
|
// Re-check every 6 hours. Covers the day rollover without relying on container restarts.
|
||||||
|
const SIX_HOURS = 6 * 60 * 60 * 1000;
|
||||||
|
setInterval(() => {
|
||||||
|
console.log("[scheduler] Running scheduled daily generation check...");
|
||||||
|
runDailyGeneration();
|
||||||
|
}, SIX_HOURS);
|
||||||
|
|
||||||
|
console.log("[scheduler] Daily horoscope scheduler started.");
|
||||||
|
}
|
||||||
@@ -138,7 +138,7 @@ const seedArticles = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "John Prisco – Die Geschichte des Liedes",
|
"title": "John Prisco – Die Geschichte des Liedes",
|
||||||
"slug": "john-prisco-die-geschichte-des-liedes",
|
"slug": "john-prisco-die-geschichte-eines-musikers",
|
||||||
"excerpt": "John Prisco erzählt seine musikalische Geschichte in dieser fesselnden Folge von Die Geschichte des Liedes. Ein Einblick in das Leben und Schaffen eines Künstlers, der die Volksmusikszene bereichert.",
|
"excerpt": "John Prisco erzählt seine musikalische Geschichte in dieser fesselnden Folge von Die Geschichte des Liedes. Ein Einblick in das Leben und Schaffen eines Künstlers, der die Volksmusikszene bereichert.",
|
||||||
"category": "Video",
|
"category": "Video",
|
||||||
"author": "Folx Music Television",
|
"author": "Folx Music Television",
|
||||||
@@ -158,17 +158,6 @@ const seedArticles = [
|
|||||||
"publishedAt": "2025-10-18T10:00:00.000Z",
|
"publishedAt": "2025-10-18T10:00:00.000Z",
|
||||||
"content": "<p><strong>Igor und seine Oberkrainer</strong> stehen im Mittelpunkt dieser Folge von <strong>Die Geschichte des Liedes</strong>. Die Gruppe, die für ihren authentischen Oberkrainer-Sound bekannt ist, erzählt von ihren Anfängen und der Liebe zur traditionellen Volksmusik.</p>\n<div style=\"display:flex;justify-content:center;margin:2rem 0;\"><iframe src=\"https://player.mediadelivery.net/embed/476412/11e02f98-02c7-42ec-9855-8e7327156ab9\" loading=\"lazy\" style=\"border:none;width:100%;aspect-ratio:16/9;\" allow=\"accelerometer;gyroscope;encrypted-media;picture-in-picture;\" allowfullscreen=\"true\"></iframe></div>\n<p>Mit ihrer einzigartigen Mischung aus traditionellen Melodien und frischen Arrangements begeistern Igor und seine Oberkrainer Fans in ganz Europa. Diese Dokumentation gibt einen tiefen Einblick in die Welt der Oberkrainer Musik und die Menschen, die sie am Leben erhalten.</p>"
|
"content": "<p><strong>Igor und seine Oberkrainer</strong> stehen im Mittelpunkt dieser Folge von <strong>Die Geschichte des Liedes</strong>. Die Gruppe, die für ihren authentischen Oberkrainer-Sound bekannt ist, erzählt von ihren Anfängen und der Liebe zur traditionellen Volksmusik.</p>\n<div style=\"display:flex;justify-content:center;margin:2rem 0;\"><iframe src=\"https://player.mediadelivery.net/embed/476412/11e02f98-02c7-42ec-9855-8e7327156ab9\" loading=\"lazy\" style=\"border:none;width:100%;aspect-ratio:16/9;\" allow=\"accelerometer;gyroscope;encrypted-media;picture-in-picture;\" allowfullscreen=\"true\"></iframe></div>\n<p>Mit ihrer einzigartigen Mischung aus traditionellen Melodien und frischen Arrangements begeistern Igor und seine Oberkrainer Fans in ganz Europa. Diese Dokumentation gibt einen tiefen Einblick in die Welt der Oberkrainer Musik und die Menschen, die sie am Leben erhalten.</p>"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"title": "Folx Stadl – Sendung 29: Musik und gute Laune",
|
|
||||||
"slug": "folx-stadl-sendung-29-musik-und-gute-laune",
|
|
||||||
"excerpt": "Die 29. Ausgabe von Folx Stadl bietet wieder einen Abend voller Volksmusik, Schlager und bester Unterhaltung. Erleben Sie die Highlights der Sendung auf Folx TV.",
|
|
||||||
"category": "Video",
|
|
||||||
"author": "Folx Music Television",
|
|
||||||
"coverImage": "/uploads/folx-stadl-29.jpg",
|
|
||||||
"featured": false,
|
|
||||||
"publishedAt": "2025-10-25T10:00:00.000Z",
|
|
||||||
"content": "<p>Die <strong>29. Ausgabe von Folx Stadl</strong> bietet wieder einen Abend voller Volksmusik, Schlager und bester Unterhaltung. Das bewährte Moderatorenteam führt mit Witz und Charme durch das Programm.</p>\n<div style=\"display:flex;justify-content:center;margin:2rem 0;\"><iframe src=\"https://player.mediadelivery.net/embed/476412/8b8cca87-3d96-4d16-87d8-3d05476b915c\" loading=\"lazy\" style=\"border:none;width:100%;aspect-ratio:16/9;\" allow=\"accelerometer;gyroscope;encrypted-media;picture-in-picture;\" allowfullscreen=\"true\"></iframe></div>\n<p>Von traditioneller Volksmusik bis zu modernen Schlagerhits – die Sendung 29 hat für jeden Geschmack etwas zu bieten. Lehnen Sie sich zurück und genießen Sie die gesamte Show direkt hier auf Folx TV.</p>"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"title": "Linda Feller: Die Country-Pionierin aus Thüringen, die Nashville eroberte",
|
"title": "Linda Feller: Die Country-Pionierin aus Thüringen, die Nashville eroberte",
|
||||||
"slug": "linda-feller-country-pionierin-aus-thueringen",
|
"slug": "linda-feller-country-pionierin-aus-thueringen",
|
||||||
@@ -223,14 +212,29 @@ const seedArticles = [
|
|||||||
"featured": true,
|
"featured": true,
|
||||||
"publishedAt": "2026-03-07T12:00:00.000Z",
|
"publishedAt": "2026-03-07T12:00:00.000Z",
|
||||||
"content": "<p>Es gibt Künstlerinnen, die man einfach kennt – und dann gibt es <strong>Anita Hofmann</strong>. Aufgewachsen im schwäbischen Jungnau bei Sigmaringen, hat sie die Bühne von Kindesbeinen an als ihr zweites Zuhause betrachtet. Gemeinsam mit ihrer älteren Schwester Alexandra spielte sie schon als Teenagerin auf Dorffesten und Vereinsfeiern in der Region – mit Trompete, Alphorn und dem unerschütterlichen Glauben daran, dass Musik verbindet. Heute, über drei Jahrzehnte später, steht Anita Hofmann als Solistin auf der Bühne und begeistert mit Schlagermusik, die mitten ins Herz trifft.</p>\n <h3>Vom Schwesternpaar zur Solostimme</h3>\n <p>Was als Kindheitstraum begann, wuchs schnell zu einer ernsthaften Karriere heran. Unter dem Namen <strong>Geschwister Hofmann</strong> feierten Anita und Alexandra ihren ersten großen TV-Auftritt 1990 in der ARD-Sendung <em>Musikanten sind da</em> – und wurden kurz darauf von <strong>Marianne und Michael</strong> für das Fernsehen entdeckt. In den folgenden Jahrzehnten tourten die beiden Schwestern als <strong>Anita & Alexandra Hofmann</strong> durch den deutschsprachigen Raum, gewannen mehrfach die <em>Krone der Volksmusik</em> und die <em>Goldene Stimmgabel</em>, und veröffentlichten über 20 Alben – einige davon mit Gold und Platin ausgezeichnet.</p>\n <div style=\"display:flex;justify-content:center;margin:2rem 0;\"><iframe src=\"https://player.mediadelivery.net/embed/476412/9f9b0e1b-1434-4269-b762-8b1bcfaaf2c3\" loading=\"lazy\" style=\"border:none;width:100%;aspect-ratio:16/9;\" allow=\"accelerometer;gyroscope;encrypted-media;picture-in-picture;\" allowfullscreen=\"true\"></iframe></div>\n <h3>Ein neues Kapitel mit vollem Herz</h3>\n <p>Ende 2022 trennten sich die Karrierewege der Schwestern – und Anita Hofmann nutzte diese Zäsur für einen mutigen Neuanfang. Statt innezuhalten, startete sie mit frischer Energie durch: Die Solodebütsingle „Halb so schlimm“ wurde zum Auftakt einer neuen Ära, gefolgt von der Sommerhymne „Leben XXL“, die sich zu einem echten Airplay-Dauerbrenner entwickelte. Im Mai 2024 erschien ihr erstes Soloalbum <strong>„Voll auf Schlager“</strong> bei TELAMO / Warner Music – ein klares Bekenntnis zur guten Laune und zum authentischen deutschen Schlager.</p>\n <h3>Mehr als Musik – eine Frau, die Klartext redet</h3>\n <p>Was Anita Hofmann von vielen ihrer Kolleginnen unterscheidet, ist ihre Bereitschaft zur Offenheit. In ihrer Autobiografie <strong>„Sei einfach nur schön“</strong> (erschienen 2024) lässt sie die Leserinnen und Leser tief in ihr Leben blicken: Sie spricht über toxische Beziehungen, den Kampf gegen Magersucht, den Druck der Öffentlichkeit und den langen Weg zu sich selbst. „Mein Lebensfehler war, immer den Erwartungen anderer zu entsprechen“, sagt sie. „Jetzt habe ich die Kraft und den Mut, ich selbst zu sein.“</p>\n <h3>Moderatorin, Multiinstrumentalistin, Energie-Bündel</h3>\n <p>Seit 2021 ist Anita Hofmann auch als Radiomoderatorin tätig und beweist, dass ihr Talent weit über den Gesang hinausgeht. Trompete, Alphorn, Xylophon – sie spielte sie alle, lange bevor der erste Plattenvertrag unterschrieben wurde. Auf der Bühne ist sie ein Energiebündel, das das Publikum von der ersten Sekunde in seinen Bann zieht: mit mitreißenden Melodien, echten Emotionen und der spürbaren Freude an jedem einzelnen Moment.</p>\n <p><strong>Folge Anita Hofmann auch online:</strong></p>\n <p><a href=\"https://www.facebook.com/anitahofmannofficial\" target=\"_blank\" rel=\"noopener noreferrer\">Facebook</a> | <a href=\"https://www.instagram.com/anitahofmannofficial\" target=\"_blank\" rel=\"noopener noreferrer\">Instagram</a> | <a href=\"https://www.youtube.com/@AnitaHofmann\" target=\"_blank\" rel=\"noopener noreferrer\">YouTube</a></p>"
|
"content": "<p>Es gibt Künstlerinnen, die man einfach kennt – und dann gibt es <strong>Anita Hofmann</strong>. Aufgewachsen im schwäbischen Jungnau bei Sigmaringen, hat sie die Bühne von Kindesbeinen an als ihr zweites Zuhause betrachtet. Gemeinsam mit ihrer älteren Schwester Alexandra spielte sie schon als Teenagerin auf Dorffesten und Vereinsfeiern in der Region – mit Trompete, Alphorn und dem unerschütterlichen Glauben daran, dass Musik verbindet. Heute, über drei Jahrzehnte später, steht Anita Hofmann als Solistin auf der Bühne und begeistert mit Schlagermusik, die mitten ins Herz trifft.</p>\n <h3>Vom Schwesternpaar zur Solostimme</h3>\n <p>Was als Kindheitstraum begann, wuchs schnell zu einer ernsthaften Karriere heran. Unter dem Namen <strong>Geschwister Hofmann</strong> feierten Anita und Alexandra ihren ersten großen TV-Auftritt 1990 in der ARD-Sendung <em>Musikanten sind da</em> – und wurden kurz darauf von <strong>Marianne und Michael</strong> für das Fernsehen entdeckt. In den folgenden Jahrzehnten tourten die beiden Schwestern als <strong>Anita & Alexandra Hofmann</strong> durch den deutschsprachigen Raum, gewannen mehrfach die <em>Krone der Volksmusik</em> und die <em>Goldene Stimmgabel</em>, und veröffentlichten über 20 Alben – einige davon mit Gold und Platin ausgezeichnet.</p>\n <div style=\"display:flex;justify-content:center;margin:2rem 0;\"><iframe src=\"https://player.mediadelivery.net/embed/476412/9f9b0e1b-1434-4269-b762-8b1bcfaaf2c3\" loading=\"lazy\" style=\"border:none;width:100%;aspect-ratio:16/9;\" allow=\"accelerometer;gyroscope;encrypted-media;picture-in-picture;\" allowfullscreen=\"true\"></iframe></div>\n <h3>Ein neues Kapitel mit vollem Herz</h3>\n <p>Ende 2022 trennten sich die Karrierewege der Schwestern – und Anita Hofmann nutzte diese Zäsur für einen mutigen Neuanfang. Statt innezuhalten, startete sie mit frischer Energie durch: Die Solodebütsingle „Halb so schlimm“ wurde zum Auftakt einer neuen Ära, gefolgt von der Sommerhymne „Leben XXL“, die sich zu einem echten Airplay-Dauerbrenner entwickelte. Im Mai 2024 erschien ihr erstes Soloalbum <strong>„Voll auf Schlager“</strong> bei TELAMO / Warner Music – ein klares Bekenntnis zur guten Laune und zum authentischen deutschen Schlager.</p>\n <h3>Mehr als Musik – eine Frau, die Klartext redet</h3>\n <p>Was Anita Hofmann von vielen ihrer Kolleginnen unterscheidet, ist ihre Bereitschaft zur Offenheit. In ihrer Autobiografie <strong>„Sei einfach nur schön“</strong> (erschienen 2024) lässt sie die Leserinnen und Leser tief in ihr Leben blicken: Sie spricht über toxische Beziehungen, den Kampf gegen Magersucht, den Druck der Öffentlichkeit und den langen Weg zu sich selbst. „Mein Lebensfehler war, immer den Erwartungen anderer zu entsprechen“, sagt sie. „Jetzt habe ich die Kraft und den Mut, ich selbst zu sein.“</p>\n <h3>Moderatorin, Multiinstrumentalistin, Energie-Bündel</h3>\n <p>Seit 2021 ist Anita Hofmann auch als Radiomoderatorin tätig und beweist, dass ihr Talent weit über den Gesang hinausgeht. Trompete, Alphorn, Xylophon – sie spielte sie alle, lange bevor der erste Plattenvertrag unterschrieben wurde. Auf der Bühne ist sie ein Energiebündel, das das Publikum von der ersten Sekunde in seinen Bann zieht: mit mitreißenden Melodien, echten Emotionen und der spürbaren Freude an jedem einzelnen Moment.</p>\n <p><strong>Folge Anita Hofmann auch online:</strong></p>\n <p><a href=\"https://www.facebook.com/anitahofmannofficial\" target=\"_blank\" rel=\"noopener noreferrer\">Facebook</a> | <a href=\"https://www.instagram.com/anitahofmannofficial\" target=\"_blank\" rel=\"noopener noreferrer\">Instagram</a> | <a href=\"https://www.youtube.com/@AnitaHofmann\" target=\"_blank\" rel=\"noopener noreferrer\">YouTube</a></p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Internationaler Frauentag: Danke an alle Frauen der FOLX Familie",
|
||||||
|
"slug": "internationaler-frauentag-danke-an-alle-frauen-der-folx-familie",
|
||||||
|
"excerpt": "Der 8. M\u00e4rz ist unser Anlass, laut Danke zu sagen: an alle treuen Zuschauerinnen, K\u00fcnstlerinnen auf der B\u00fchne, Moderatorinnen vor der Kamera und alle Frauen hinter den Kulissen von FOLX TV.",
|
||||||
|
"category": "News",
|
||||||
|
"author": "Folx Music Television",
|
||||||
|
"coverImage": "/uploads/frauentag-2026.jpg",
|
||||||
|
"featured": true,
|
||||||
|
"publishedAt": "2026-03-08T08:00:00.000Z",
|
||||||
|
"content": "<p><strong>Danke.</strong></p>\n<p>Danke an alle unsere treuen Zuschauerinnen, die mit uns singen, tanzen und tr\u00e4umen \u2013 jeden Tag, Jahr f\u00fcr Jahr.</p>\n<p>Danke an alle K\u00fcnstlerinnen auf der B\u00fchne, Moderatorinnen vor der Kamera und alle Frauen hinter den Kulissen, ohne die FOLX TV nicht das Zuhause der Volksmusik und des Schlagers w\u00e4re, das Sie kennen und lieben.</p>\n<p>Der <strong>8. M\u00e4rz</strong>, der <strong>Internationale Frauentag</strong>, ist unser Anlass, laut „Danke“ zu sagen und die B\u00fchne ganz den Frauen zu \u00fcberlassen.</p>\n<h3>Frauen schreiben die sch\u00f6nsten Geschichten der Musik</h3>\n<p>Sie stehen auf der B\u00fchne, wenn im „Folx Stadl“ die Stimmung kocht, sie moderieren mit Herz und Humor unsere Sendungen und sie sitzen zu Hause vor dem Fernseher, singen mit, w\u00e4hlen ihre Lieblingsvideos und halten FOLX TV mit ihrer Treue am Laufen.</p>\n<p>Ob junge Fans oder Best Ager \u2013 Sie alle machen unsere Volksmusik- und Schlagerwelt bunt, lebendig und voller Gef\u00fchl.</p>\n<h3>Respekt, Chancen und Gleichberechtigung</h3>\n<p>Der Internationale Frauentag erinnert daran, wie viel Frauen bereits erreicht haben \u2013 und dass ihr Einsatz noch lange nicht zu Ende ist. Es geht um Respekt, faire Chancen, Gleichberechtigung und darum, gesehen und geh\u00f6rt zu werden.</p>\n<p>Mit jeder starken Stimme, jeder mutigen Entscheidung und jedem L\u00e4cheln ver\u00e4ndern Frauen Tag f\u00fcr Tag die Welt \u2013 auch in unserer FOLX Community.</p>\n<h3>Musik verbindet Generationen</h3>\n<p>Und was passt besser zu einem Feiertag f\u00fcr starke Frauen als Musik? Wenn die ersten Akkorde einer Oberkrainer-Polka erklingen, wenn ein moderner Schlager-Hit G\u00e4nsehaut macht oder wenn im „Folx Stadl“ gemeinsam geklatscht, gelacht und getanzt wird, dann sp\u00fcrt man: Musik verbindet Generationen.</p>\n<p>Sie weckt Erinnerungen, schenkt Kraft und begleitet durch gute wie durch schwierige Zeiten.</p>\n<h3>Dieser Tag geh\u00f6rt Ihnen</h3>\n<p>Heute sagen wir: <strong>Dieser Tag geh\u00f6rt Ihnen.</strong></p>\n<p>Drehen Sie den Fernseher ruhig ein wenig lauter, tanzen Sie durchs Wohnzimmer, sto\u00dfen Sie mit Freundinnen an oder g\u00f6nnen Sie sich einfach einen ruhigen Moment nur f\u00fcr sich und Ihre Lieblingsmusik.</p>\n<p>Feiern Sie sich selbst \u2013 mit all dem, was Sie leisten, was Sie lieben und wovon Sie tr\u00e4umen.</p>\n<p><strong>FOLX TV bedankt sich von Herzen bei allen Frauen \u2013 heute, am 8. M\u00e4rz, und an jedem anderen Tag des Jahres.</strong></p>\n<p>Sch\u00f6n, dass Sie Teil der FOLX Familie sind.</p>"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function seedDatabase() {
|
export async function seedDatabase() {
|
||||||
|
// stari podvojeni Folx Stadl 29 (nadomescen z avtomatiko: folx-stadl-sendung-29)
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
UPDATE articles SET
|
DELETE FROM articles WHERE slug = 'folx-stadl-sendung-29-musik-und-gute-laune'
|
||||||
slug = 'john-prisco-die-geschichte-eines-musikers'
|
`);
|
||||||
WHERE slug = 'john-prisco-die-geschichte-des-liedes'
|
|
||||||
|
// stari slug je bil preimenovan v -eines-musikers; ce se je kdaj vrnil podvojen, ga pobrisi
|
||||||
|
await db.execute(sql`
|
||||||
|
DELETE FROM articles WHERE slug = 'john-prisco-die-geschichte-des-liedes'
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
@@ -245,17 +249,12 @@ export async function seedDatabase() {
|
|||||||
OR excerpt LIKE '%„%' OR excerpt LIKE '%“%'
|
OR excerpt LIKE '%„%' OR excerpt LIKE '%“%'
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const validSlugs = new Set(seedArticles.map((a) => a.slug));
|
|
||||||
const existing = await storage.getArticles();
|
const existing = await storage.getArticles();
|
||||||
const existingSlugs = new Set(existing.map((a) => a.slug));
|
const existingSlugs = new Set(existing.map((a) => a.slug));
|
||||||
|
|
||||||
const toDelete = existing.filter((a) => !validSlugs.has(a.slug));
|
// NOTE: cleanup/delete of non-seed articles je ODSTRANJEN namenoma.
|
||||||
if (toDelete.length > 0) {
|
// Seed samo doda ali posodobi svoje zacetne clanke; clankov, ki so bili
|
||||||
for (const article of toDelete) {
|
// dodani kasneje (generator, admin), NE brise. Tako prezivijo vsak deploy.
|
||||||
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;
|
let added = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
@@ -282,6 +281,7 @@ export async function seedDatabase() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await storage.createArticle({
|
await storage.createArticle({
|
||||||
title: article.title,
|
title: article.title,
|
||||||
slug: article.slug,
|
slug: article.slug,
|
||||||
@@ -300,6 +300,31 @@ export async function seedDatabase() {
|
|||||||
}
|
}
|
||||||
newArticles.push(article);
|
newArticles.push(article);
|
||||||
added++;
|
added++;
|
||||||
|
} catch (err: any) {
|
||||||
|
// Slug ze obstaja (npr. paralelni deploy/generator je clanek vstavil
|
||||||
|
// med branjem in pisanjem) -> ne sesuj serverja, samo posodobi.
|
||||||
|
if (String(err?.message || "").includes("articles_slug_unique")) {
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE articles SET
|
||||||
|
title = ${article.title},
|
||||||
|
excerpt = ${article.excerpt},
|
||||||
|
content = ${article.content},
|
||||||
|
cover_image = ${article.coverImage},
|
||||||
|
category = ${article.category},
|
||||||
|
author = ${article.author},
|
||||||
|
featured = ${article.featured}
|
||||||
|
WHERE slug = ${article.slug}`
|
||||||
|
);
|
||||||
|
if (article.publishedAt) {
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE articles SET published_at = ${article.publishedAt} WHERE slug = ${article.slug}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
console.error("[seed] createArticle failed for " + article.slug + ": " + err?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (added > 0) {
|
if (added > 0) {
|
||||||
|
|||||||
@@ -1,21 +1,125 @@
|
|||||||
|
import { publisherSchema, homeSchema, INFO_SEO, articleModifiedTime, articleGenre } from "../shared/seo";
|
||||||
import express, { type Express } from "express";
|
import express, { type Express } from "express";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
// 15. 9. 2026 (Sebastjan): stran je v keywords vsakemu clanku pisala
|
||||||
|
// "Volksmusik, Schlager" — tudi Beckerju, Geissensom in Ivanovic. Cevovod ze ve,
|
||||||
|
// kaj kos je (znacka MUSIC NEWS / STAR NEWS), zato jo zapise v vsebino kot
|
||||||
|
// marker <!--folx-badge:STAR NEWS-->, enako kot <!--folx-updated:...-->.
|
||||||
|
// Brez markerja ostane vse kot prej (glasbeno), da starejsi clanki ne nazadujejo.
|
||||||
|
// Besedilne zaznave namenoma NI: preizkus na 30 clankih jo je zgresil pri
|
||||||
|
// Fernsehgartnu in Carpendaleju.
|
||||||
|
const folxGenre = (article: any) => articleGenre(String(article?.content || ""));
|
||||||
|
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
return str.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
return str.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dejanske dimenzije naslovnice. Prej sta bili sirina in visina v meta
|
||||||
|
// oznakah fiksno zapisani kot 1200x630, slike pa so v resnici 1200x900 —
|
||||||
|
// Facebook in Google sta dobila napacno vrednost. Ce dimenzij ne moremo
|
||||||
|
// prebrati, oznak raje ne zapisemo.
|
||||||
|
const ogDimCache = new Map<string, { w: number; h: number } | null>();
|
||||||
|
|
||||||
|
async function ogImageDims(imgUrl: string, publicDir: string) {
|
||||||
|
if (ogDimCache.has(imgUrl)) return ogDimCache.get(imgUrl) ?? null;
|
||||||
|
let dims: { w: number; h: number } | null = null;
|
||||||
|
try {
|
||||||
|
const m = /\/(uploads|images)\/([^/?#]+)$/.exec(imgUrl);
|
||||||
|
if (m) {
|
||||||
|
const cand = [
|
||||||
|
path.join(publicDir, m[1], m[2]),
|
||||||
|
path.join(publicDir, m[1], m[2].replace(/\.jpg$/, ".webp")),
|
||||||
|
];
|
||||||
|
for (const p of cand) {
|
||||||
|
if (!fs.existsSync(p)) continue;
|
||||||
|
const meta = await sharp(p).metadata();
|
||||||
|
if (meta.width && meta.height) { dims = { w: meta.width, h: meta.height }; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
dims = null;
|
||||||
|
}
|
||||||
|
ogDimCache.set(imgUrl, dims);
|
||||||
|
return dims;
|
||||||
|
}
|
||||||
|
|
||||||
function ogImageUrl(coverImage: string, baseUrl: string): string {
|
function ogImageUrl(coverImage: string, baseUrl: string): string {
|
||||||
if (!coverImage) return "";
|
if (!coverImage) return "";
|
||||||
let imgPath = coverImage;
|
let imgPath = coverImage;
|
||||||
|
// Cloudinary: vsili FB razmerje 1200x630 z g_faces (da FB ne reze glav)
|
||||||
|
if (imgPath.includes("res.cloudinary.com")) {
|
||||||
|
imgPath = imgPath.replace(/([,&/?])h_\d+/i, "$1h_630").replace(/([,&/?])w_\d+/i, "$1w_1200");
|
||||||
|
// ce ni g_faces, ga dodaj
|
||||||
|
if (!/g_faces/.test(imgPath)) {
|
||||||
|
imgPath = imgPath.replace(/\/(image\/(upload|fetch))\//, "/$1/g_faces,");
|
||||||
|
}
|
||||||
|
return imgPath;
|
||||||
|
}
|
||||||
if (imgPath.endsWith(".webp")) {
|
if (imgPath.endsWith(".webp")) {
|
||||||
imgPath = imgPath.replace(/\.webp$/, ".jpg");
|
imgPath = imgPath.replace(/\.webp$/, ".jpg");
|
||||||
}
|
}
|
||||||
return imgPath.startsWith("http") ? imgPath : `${baseUrl}${imgPath}`;
|
return imgPath.startsWith("http") ? imgPath : `${baseUrl}${imgPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stari slugi clankov, ki so bili pozneje preimenovani. Brez tega vsak star
|
||||||
|
// link (FB objave, komentarji, go.folx.tv kratki linki, Googlov indeks) pade
|
||||||
|
// na trdi 404. Kljuc = star slug, vrednost = trenutni slug.
|
||||||
|
const ARTICLE_SLUG_REDIRECTS: Record<string, string> = {
|
||||||
|
"alexander-rier-verraet-das-ist-die-neue-frau-an-seiner-seite":
|
||||||
|
"alexander-rier-von-mann-zu-mann-immer-wieder-sonntags",
|
||||||
|
// 25. 8. 2026 izbrisan dvojnik: bot je isto zgodbo objavil dvakrat.
|
||||||
|
// Andreas Martin je bil vrnjen (bil je ze v Search Console), zato tu ni.
|
||||||
|
"isi-glueck-sagt-konzerte-ab-stimme-in-gefahr":
|
||||||
|
"isi-glueck-sagt-konzerte-ab-sorge-um-ihre-stimme",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rubrike, ki jih pisemo enkrat in objavimo tudi na onemusic.news. Besedilo je
|
||||||
|
// isto na obeh domenah, zato jih na folx.tv drzimo zunaj Googlovega indeksa —
|
||||||
|
// v bazi in v /api/articles ostanejo, ker jih bere sidebar na TV kanalih.
|
||||||
|
// Izdajatelj: doslej samo ime in logo. Brez registrirane druzbe, naslova in
|
||||||
|
// profilov Google znamke nima s cim povezati.
|
||||||
|
const PUBLISHER = publisherSchema;
|
||||||
|
|
||||||
|
const ONE_RUBRIKEN = new Set(["Politik", "Promis", "Sport", "Musik & Film", "Wetter", "Finanzen", "Aktuelles"]);
|
||||||
|
|
||||||
|
// 30. 8. 2026: rubrike so izbrisane iz baze folx.tv in zivijo samo se na
|
||||||
|
// folx.news. Stare povezave s Facebooka in Googla morajo se vedno delati,
|
||||||
|
// zato ob neznanem slugu vprasamo folx.news, ali ga pozna, in preusmerimo.
|
||||||
|
let NEWS_SLUGS: Set<string> = new Set();
|
||||||
|
let NEWS_SLUGS_AT = 0;
|
||||||
|
async function newsHasSlug(slug: string): Promise<boolean> {
|
||||||
|
if (Date.now() - NEWS_SLUGS_AT > 600000) {
|
||||||
|
try {
|
||||||
|
const r = await fetch("https://folx.news/api/articles", {
|
||||||
|
headers: { "user-agent": "folx.tv-redirect" },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
const j: any = await r.json();
|
||||||
|
if (Array.isArray(j) && j.length) {
|
||||||
|
NEWS_SLUGS = new Set(j.map((a: any) => String(a.slug)));
|
||||||
|
NEWS_SLUGS_AT = Date.now();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
NEWS_SLUGS_AT = Date.now() - 540000; // ob napaki poskusi cez minuto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NEWS_SLUGS.has(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function setRobots(html: string, content: string): string {
|
||||||
|
const tag = `<meta name="robots" content="${content}" />`;
|
||||||
|
if (/<meta\s+name="robots"[^>]*>/i.test(html)) {
|
||||||
|
return html.replace(/<meta\s+name="robots"[^>]*>/i, tag);
|
||||||
|
}
|
||||||
|
return html.replace("</head>", ` ${tag}\n </head>`);
|
||||||
|
}
|
||||||
|
|
||||||
function stripExistingMeta(html: string): string {
|
function stripExistingMeta(html: string): string {
|
||||||
html = html.replace(/<meta\s+property="og:[^"]*"[^>]*>\s*/gi, "");
|
html = html.replace(/<meta\s+property="og:[^"]*"[^>]*>\s*/gi, "");
|
||||||
html = html.replace(/<meta\s+name="twitter:[^"]*"[^>]*>\s*/gi, "");
|
html = html.replace(/<meta\s+name="twitter:[^"]*"[^>]*>\s*/gi, "");
|
||||||
@@ -34,6 +138,9 @@ export function serveStatic(app: Express) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.use(express.static(distPath, {
|
app.use(express.static(distPath, {
|
||||||
|
// brez tega express.static sam postrezhe index.html za "/" in nasa
|
||||||
|
// naslovnica nikoli ne pride do handlerja, ki ji doda strukturirane podatke
|
||||||
|
index: false,
|
||||||
setHeaders(res, filePath) {
|
setHeaders(res, filePath) {
|
||||||
if (filePath.endsWith("og-image.jpg") || filePath.includes("/uploads/") || filePath.includes("/images/")) {
|
if (filePath.endsWith("og-image.jpg") || filePath.includes("/uploads/") || filePath.includes("/images/")) {
|
||||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||||
@@ -50,52 +157,644 @@ export function serveStatic(app: Express) {
|
|||||||
const protocol = req.get("x-forwarded-proto") || "https";
|
const protocol = req.get("x-forwarded-proto") || "https";
|
||||||
const baseUrl = `${protocol}://${host}`;
|
const baseUrl = `${protocol}://${host}`;
|
||||||
|
|
||||||
|
// ---- /video/<slug>: napovednik je predvajljiv na strani, cel video na YouTube ----
|
||||||
|
const ytVideoMatch = url.match(/^\/video\/([^?#\/]+)/);
|
||||||
|
if (ytVideoMatch) {
|
||||||
|
try {
|
||||||
|
const slug = decodeURIComponent(ytVideoMatch[1]);
|
||||||
|
const idxFile = path.resolve(process.cwd(), "client/public/uploads/video/index.json");
|
||||||
|
const list: any[] = JSON.parse(await fs.promises.readFile(idxFile, "utf-8")).items || [];
|
||||||
|
const v = list.find((i) => i.slug === slug || i.ytId === slug);
|
||||||
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
if (!v) {
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, [
|
||||||
|
'<meta name="robots" content="noindex, follow" />',
|
||||||
|
'<title>Video nicht gefunden | Folx Music Television</title>',
|
||||||
|
].join("\n "));
|
||||||
|
res.status(404).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pageUrl = `${canonicalBase}/video/${v.slug}`;
|
||||||
|
const poster = `${canonicalBase}${v.posterUrl}`;
|
||||||
|
const desc = (v.teaser || v.shortTitle || v.title).slice(0, 300);
|
||||||
|
const longDesc = String(v.descriptionClean || v.description || v.teaser || v.shortTitle)
|
||||||
|
.replace(/https?:\/\/\S+/g, "").replace(/\s+/g, " ").trim().slice(0, 900);
|
||||||
|
const mins = Math.floor((v.durationS || 0) / 60), secs = (v.durationS || 0) % 60;
|
||||||
|
const embedUrl = v.embedUrl || `https://www.youtube-nocookie.com/embed/${v.ytId}`;
|
||||||
|
// Poglavja: iz "1:24" naredimo sekunde, da jih lahko damo v Clip
|
||||||
|
// oznacbo (Google iz nje naredi "Wichtige Momente" pod videom) in da
|
||||||
|
// je vsako poglavje klikljiva povezava z ?t=<sekunde>.
|
||||||
|
const chapSecs = (t: string) => {
|
||||||
|
const p = String(t).split(":").map((x) => parseInt(x, 10) || 0);
|
||||||
|
return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p[0] * 60 + p[1];
|
||||||
|
};
|
||||||
|
const chaps = (v.chapters || [])
|
||||||
|
.map((c: any) => ({ ...c, s: chapSecs(c.t) }))
|
||||||
|
.filter((c: any) => c.s < (v.durationS || 0))
|
||||||
|
.sort((a: any, b: any) => a.s - b.s);
|
||||||
|
const startAt = parseInt(String((req.query as any)?.t || ""), 10);
|
||||||
|
const embedSrc = Number.isFinite(startAt) && startAt > 0
|
||||||
|
? `${embedUrl}?start=${startAt}` : embedUrl;
|
||||||
|
const videoLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "VideoObject",
|
||||||
|
"name": v.shortTitle,
|
||||||
|
"description": longDesc || desc,
|
||||||
|
"thumbnailUrl": [poster],
|
||||||
|
"uploadDate": v.publishedAt,
|
||||||
|
"duration": `PT${mins}M${secs}S`,
|
||||||
|
"embedUrl": embedUrl,
|
||||||
|
"contentUrl": v.youtubeUrl,
|
||||||
|
"isFamilyFriendly": true,
|
||||||
|
"inLanguage": "de",
|
||||||
|
"publisher": PUBLISHER(canonicalBase),
|
||||||
|
"mainEntityOfPage": { "@type": "WebPage", "@id": pageUrl },
|
||||||
|
...(chaps.length >= 3 ? {
|
||||||
|
hasPart: chaps.map((c: any, i: number) => ({
|
||||||
|
"@type": "Clip",
|
||||||
|
"name": c.label,
|
||||||
|
"startOffset": c.s,
|
||||||
|
"endOffset": i + 1 < chaps.length ? chaps[i + 1].s : (v.durationS || c.s + 30),
|
||||||
|
"url": `${pageUrl}?t=${c.s}`,
|
||||||
|
})),
|
||||||
|
potentialAction: {
|
||||||
|
"@type": "SeekToAction",
|
||||||
|
"target": `${pageUrl}?t={seek_to_second_number}`,
|
||||||
|
"startOffset-input": "required name=seek_to_second_number",
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
const tags = [
|
||||||
|
`<meta property="og:title" content="${escapeHtml(v.shortTitle)}" />`,
|
||||||
|
`<meta property="og:description" content="${escapeHtml(desc)}" />`,
|
||||||
|
`<meta property="og:type" content="video.other" />`,
|
||||||
|
`<meta property="og:url" content="${escapeHtml(pageUrl)}" />`,
|
||||||
|
`<meta property="og:image" content="${escapeHtml(poster)}" />`,
|
||||||
|
`<meta property="og:image:width" content="1280" />`,
|
||||||
|
`<meta property="og:image:height" content="720" />`,
|
||||||
|
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||||
|
`<meta property="og:locale" content="de_DE" />`,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
`<meta name="twitter:title" content="${escapeHtml(v.shortTitle)}" />`,
|
||||||
|
`<meta name="twitter:description" content="${escapeHtml(desc)}" />`,
|
||||||
|
`<meta name="twitter:image" content="${escapeHtml(poster)}" />`,
|
||||||
|
`<meta name="description" content="${escapeHtml(desc)}" />`,
|
||||||
|
`<link rel="canonical" href="${escapeHtml(pageUrl)}" />`,
|
||||||
|
`<title>${escapeHtml(v.shortTitle)} | ${escapeHtml(v.rubrik)} | Folx Music Television</title>`,
|
||||||
|
`<script type="application/ld+json">${JSON.stringify(videoLd).replace(/</g, "\\u003c")}</script>`,
|
||||||
|
].join("\n ");
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, tags);
|
||||||
|
|
||||||
|
const chapterHtml = chaps.length
|
||||||
|
? `<h2>Kapitel</h2><ul>${chaps.map((c: any) => `<li><a href="${escapeHtml(pageUrl)}?t=${c.s}">${escapeHtml(c.t)}</a> – ${escapeHtml(c.label)}</li>`).join("")}</ul>`
|
||||||
|
: "";
|
||||||
|
// Celoten opis gre v SSR, da ga Google prebere brez JavaScripta.
|
||||||
|
// Prej je bil v HTML samo naslov in teaser (~320 znakov), ostalo je
|
||||||
|
// izrisal sele React.
|
||||||
|
const descHtml = String(v.descriptionClean || v.description || "")
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((par: string) => par.trim())
|
||||||
|
.filter((par: string) => par.length > 2 && !/^https?:\/\//.test(par) && !/^[\u2013\u2014-]{4,}$/.test(par))
|
||||||
|
.map((par: string) => `<p>${escapeHtml(par).replace(/\n/g, "<br />")}</p>`)
|
||||||
|
.join("");
|
||||||
|
const ssr = [
|
||||||
|
'<article class="ssr-content">',
|
||||||
|
`<h1>${escapeHtml(v.shortTitle)}</h1>`,
|
||||||
|
`<iframe width="640" height="360" src="${escapeHtml(embedSrc)}" title="${escapeHtml(v.shortTitle)}" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen style="border:0;max-width:100%;"></iframe>`,
|
||||||
|
`<p><strong>${escapeHtml(desc)}</strong></p>`,
|
||||||
|
chapterHtml,
|
||||||
|
`<p><a href="${escapeHtml(v.youtubeUrl)}" target="_blank" rel="noopener">Das ganze Video auf YouTube ansehen</a></p>`,
|
||||||
|
v.articleUrl ? `<p><a href="${escapeHtml(v.articleUrl)}">Ganzen Artikel lesen</a></p>` : "",
|
||||||
|
descHtml ? `<h2>Zum Video</h2>${descHtml}` : "",
|
||||||
|
"</article>",
|
||||||
|
].join("\n");
|
||||||
|
template = template.replace('<div id="root"></div>', () => `<div id="root">${ssr}</div>`);
|
||||||
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Naslovnica: doslej brez strukturiranih podatkov. Organization in
|
||||||
|
// WebSite sta tista dva vozlisca, na katera se veze vse ostalo. ----
|
||||||
|
if (url === "/" || url.startsWith("/?")) {
|
||||||
|
try {
|
||||||
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
const homeLd = homeSchema(canonicalBase);
|
||||||
|
template = template.replace(
|
||||||
|
"</head>",
|
||||||
|
`<script id="home-jsonld" type="application/ld+json">${JSON.stringify(homeLd).replace(/</g, "\\u003c")}</script>\n</head>`);
|
||||||
|
|
||||||
|
// 31. 8. 2026: naslovnica je React aplikacija, zato je robot dobil samo
|
||||||
|
// lupino brez ene same povezave na clanek. Clanki se do Googla pridejo
|
||||||
|
// prek sitemapa, a odkrivanje je pocasnejse. Zato v #root vpisemo se
|
||||||
|
// strezniski seznam zadnjih 40 clankov; React ga ob zagonu zamenja s
|
||||||
|
// pravo mrezo, robot pa povezave dobi takoj in brez izrisa JavaScripta.
|
||||||
|
try {
|
||||||
|
const vsi = await storage.getArticles();
|
||||||
|
const zadnji = [...vsi]
|
||||||
|
.filter((a: any) => !a.targetUrl && !ONE_RUBRIKEN.has(a.category || ""))
|
||||||
|
.sort((a: any, b: any) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
|
||||||
|
.slice(0, 40);
|
||||||
|
if (zadnji.length) {
|
||||||
|
const seznam = zadnji
|
||||||
|
.filter((a: any) => a.slug && a.title)
|
||||||
|
.map((a: any) =>
|
||||||
|
`<li><a href="/article/${escapeHtml(a.slug)}">${escapeHtml(a.title)}</a></li>`)
|
||||||
|
.join("");
|
||||||
|
const blok = `<div id="ssr-neueste"><h2>Neueste Artikel</h2><ul>${seznam}</ul>` +
|
||||||
|
`<p><a href="/category/News">Alle Nachrichten</a></p></div>`;
|
||||||
|
template = template.replace('<div id="root"></div>', () => `<div id="root">${blok}</div>`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 29. 8. 2026: rubrike Aktuelles so preseljene na loceno stran folx.news.
|
||||||
|
// Clanki ostanejo v bazi in v /api/articles (bere jih folx.news in TV sidebar),
|
||||||
|
// na folx.tv pa se naslovi trajno preusmerijo.
|
||||||
|
const NEWS_HOST = "https://folx.news";
|
||||||
|
const NEWS_RUBRIK_SLUG: Record<string, string | undefined> = {
|
||||||
|
"Politik": "politik",
|
||||||
|
"Promis": "promis",
|
||||||
|
"Sport": "sport",
|
||||||
|
"Musik & Film": "musik-film",
|
||||||
|
"Wetter": "wetter",
|
||||||
|
"Finanzen": "finanzen",
|
||||||
|
"Aktuelles": "",
|
||||||
|
};
|
||||||
|
const pathOnly = url.split("?")[0].split("#")[0];
|
||||||
|
// 30. 8. 2026: horoskop, recepti in galerija so ukinjeni. Stare povezave
|
||||||
|
// ne smejo v 404, zato gredo trajno na naslovnico.
|
||||||
|
if (/^\/(horoskop|rezepte|gallery|galerie)(\/.*)?$/.test(pathOnly)) {
|
||||||
|
res.redirect(301, "/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/^\/aktuelles\/?$/.test(pathOnly)) {
|
||||||
|
res.redirect(301, `${NEWS_HOST}/`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const catRedirect = pathOnly.match(/^\/category\/([^/]+)\/?$/);
|
||||||
|
if (catRedirect) {
|
||||||
|
const catName = decodeURIComponent(catRedirect[1]);
|
||||||
|
const key = Array.from(ONE_RUBRIKEN).find((r) => r.toLowerCase() === catName.toLowerCase());
|
||||||
|
if (key) {
|
||||||
|
const rs = NEWS_RUBRIK_SLUG[key];
|
||||||
|
res.redirect(301, rs ? `${NEWS_HOST}/rubrik/${rs}` : `${NEWS_HOST}/`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- /category/<ime>: prej je vrnil HTML naslovnice s canonical "/" in
|
||||||
|
// brez vsebine, kar je Google bral kot soft 404 / podvojeno naslovnico ----
|
||||||
|
const catMatch = url.match(/^\/category\/([^?#\/]+)/);
|
||||||
|
if (catMatch) {
|
||||||
|
try {
|
||||||
|
const cat = decodeURIComponent(catMatch[1]);
|
||||||
|
const all = await storage.getArticles();
|
||||||
|
const list = all
|
||||||
|
.filter((a: any) => (a.category || "").toLowerCase() === cat.toLowerCase() && !a.targetUrl)
|
||||||
|
.slice(0, 40);
|
||||||
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
if (!list.length) {
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, [
|
||||||
|
'<meta name="robots" content="noindex, follow" />',
|
||||||
|
`<title>${escapeHtml(cat)} | Folx Music Television</title>`,
|
||||||
|
].join("\n "));
|
||||||
|
res.status(404).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const catUrl = `${canonicalBase}/category/${encodeURIComponent(cat)}`;
|
||||||
|
const isNews = cat.toLowerCase() === "news";
|
||||||
|
const catHeading = isNews ? "Schlager & Volksmusik News" : cat;
|
||||||
|
const catTitle = isNews
|
||||||
|
? `${catHeading} | FOLX TV`
|
||||||
|
: `${cat} – Volksmusik & Schlager | Folx Music Television`;
|
||||||
|
const catDesc = isNews
|
||||||
|
? "Aktuelle Schlager & Volksmusik News: Nachrichten, Interviews und Neuigkeiten aus der Musikszene bei FOLX TV."
|
||||||
|
: `Alle Beiträge aus der Rubrik ${cat} bei FOLX TV: aktuelle Meldungen, Interviews und Videos aus Volksmusik und Schlager.`;
|
||||||
|
const firstImg = ogImageUrl(list[0]?.coverImage || "", canonicalBase) || `${canonicalBase}/og-image.jpg`;
|
||||||
|
const itemLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "CollectionPage",
|
||||||
|
"name": catTitle,
|
||||||
|
"description": catDesc,
|
||||||
|
"url": catUrl,
|
||||||
|
"mainEntity": {
|
||||||
|
"@type": "ItemList",
|
||||||
|
"itemListElement": list.slice(0, 20).map((a: any, i: number) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
"position": i + 1,
|
||||||
|
"url": `${canonicalBase}/article/${a.slug}`,
|
||||||
|
"name": a.title,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const tags = [
|
||||||
|
`<meta property="og:title" content="${escapeHtml(catTitle)}" />`,
|
||||||
|
`<meta property="og:description" content="${escapeHtml(catDesc)}" />`,
|
||||||
|
`<meta property="og:type" content="website" />`,
|
||||||
|
`<meta property="og:url" content="${escapeHtml(catUrl)}" />`,
|
||||||
|
`<meta property="og:image" content="${escapeHtml(firstImg)}" />`,
|
||||||
|
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||||
|
`<meta property="og:locale" content="de_DE" />`,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
`<meta name="twitter:title" content="${escapeHtml(catTitle)}" />`,
|
||||||
|
`<meta name="twitter:image" content="${escapeHtml(firstImg)}" />`,
|
||||||
|
`<meta name="description" content="${escapeHtml(catDesc)}" />`,
|
||||||
|
`<link rel="canonical" href="${escapeHtml(catUrl)}" />`,
|
||||||
|
`<title>${escapeHtml(catTitle)}</title>`,
|
||||||
|
`<script type="application/ld+json">${JSON.stringify(itemLd).replace(/</g, "\\u003c")}</script>`,
|
||||||
|
].join("\n ");
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, tags);
|
||||||
|
// Rubrike, ki sestavljajo /aktuelles, niso del indeksiranega dela strani:
|
||||||
|
// clanki so ze noindex, zato tudi njihov seznam ne sme v indeks.
|
||||||
|
if (Array.from(ONE_RUBRIKEN).some((r) => r.toLowerCase() === cat.toLowerCase())) {
|
||||||
|
template = setRobots(template, "noindex, follow");
|
||||||
|
}
|
||||||
|
const ssr = [
|
||||||
|
'<section class="ssr-content">',
|
||||||
|
`<h1>${escapeHtml(cat)}</h1>`,
|
||||||
|
"<ul>",
|
||||||
|
...list.map((a: any) => {
|
||||||
|
const img = ogImageUrl(a.coverImage || "", canonicalBase);
|
||||||
|
return `<li><a href="/article/${escapeHtml(a.slug)}">` +
|
||||||
|
(img ? `<img src="${escapeHtml(img)}" alt="${escapeHtml(a.title)}" width="320" style="max-width:100%;height:auto;" /> ` : "") +
|
||||||
|
`<strong>${escapeHtml(a.title)}</strong></a><p>${escapeHtml(a.excerpt || "")}</p></li>`;
|
||||||
|
}),
|
||||||
|
"</ul>",
|
||||||
|
"</section>",
|
||||||
|
].join("\n");
|
||||||
|
template = template.replace('<div id="root"></div>', () => `<div id="root">${ssr}</div>`);
|
||||||
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const articleMatch = url.match(/^\/article\/([^?#]+)/);
|
const articleMatch = url.match(/^\/article\/([^?#]+)/);
|
||||||
if (articleMatch) {
|
if (articleMatch) {
|
||||||
try {
|
try {
|
||||||
const slug = decodeURIComponent(articleMatch[1]);
|
const slug = decodeURIComponent(articleMatch[1]);
|
||||||
const article = await storage.getArticleBySlug(slug);
|
const article = await storage.getArticleBySlug(slug);
|
||||||
|
if (article && ONE_RUBRIKEN.has((article as any).category || "")) {
|
||||||
|
res.redirect(301, `${NEWS_HOST}/${article.slug}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!article && (await newsHasSlug(slug))) {
|
||||||
|
res.redirect(301, `${NEWS_HOST}/${slug}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (article && (article as any).targetUrl) {
|
||||||
|
res.redirect(302, (article as any).targetUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (article) {
|
if (article) {
|
||||||
const articleUrl = `${canonicalBase}/article/${article.slug}`;
|
const articleUrl = `${canonicalBase}/article/${article.slug}`;
|
||||||
const imageUrl = ogImageUrl(article.coverImage || "", canonicalBase);
|
const imageUrl = ogImageUrl(article.coverImage || "", canonicalBase);
|
||||||
const finalImage = imageUrl || `${canonicalBase}/og-image.jpg`;
|
const finalImage = imageUrl || `${canonicalBase}/og-image.jpg`;
|
||||||
|
const articleImgDims = await ogImageDims(finalImage, distPath);
|
||||||
|
if (!imageUrl) {
|
||||||
|
console.warn(`[og] clanek brez naslovnice, pada na logotip: ${article.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
let template = await fs.promises.readFile(indexPath, "utf-8");
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
template = stripExistingMeta(template);
|
template = stripExistingMeta(template);
|
||||||
|
|
||||||
|
const pubDate = article.publishedAt
|
||||||
|
? new Date(article.publishedAt as any).toISOString()
|
||||||
|
: new Date().toISOString();
|
||||||
|
|
||||||
|
// dateModified: ce je clanek dopolnjen (enrich), preberi marker <!--folx-updated:ISO-->
|
||||||
|
const modDate = articleModifiedTime(article.content || "", pubDate);
|
||||||
|
|
||||||
|
// VideoObject: ce je v clanku vgrajen YouTube video
|
||||||
|
const videoId = (() => {
|
||||||
|
const mm = /youtube(?:-nocookie)?\.com\/embed\/([A-Za-z0-9_-]{6,})/.exec(article.content || "");
|
||||||
|
return mm ? mm[1] : null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "NewsArticle",
|
||||||
|
"@id": `${articleUrl}#article`,
|
||||||
|
"inLanguage": "de-DE",
|
||||||
|
"headline": article.title,
|
||||||
|
"description": article.excerpt,
|
||||||
|
"image": [finalImage],
|
||||||
|
"datePublished": pubDate,
|
||||||
|
"dateModified": modDate,
|
||||||
|
"articleSection": folxGenre(article).section,
|
||||||
|
"keywords": folxGenre(article).words.join(", "),
|
||||||
|
"author": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "FOLX Music Television",
|
||||||
|
"url": canonicalBase,
|
||||||
|
},
|
||||||
|
"publisher": PUBLISHER(canonicalBase),
|
||||||
|
"mainEntityOfPage": {
|
||||||
|
"@type": "WebPage",
|
||||||
|
"@id": articleUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const jsonLdTag = `<script id="article-jsonld" type="application/ld+json">${JSON.stringify(jsonLd).replace(/</g, "\\u003c")}</script>`;
|
||||||
|
|
||||||
|
const videoLd = videoId ? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "VideoObject",
|
||||||
|
"name": article.title,
|
||||||
|
"description": article.excerpt,
|
||||||
|
"thumbnailUrl": [`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`],
|
||||||
|
"uploadDate": pubDate,
|
||||||
|
"embedUrl": `https://www.youtube.com/embed/${videoId}`,
|
||||||
|
"contentUrl": `https://www.youtube.com/watch?v=${videoId}`,
|
||||||
|
"publisher": PUBLISHER(canonicalBase),
|
||||||
|
} : null;
|
||||||
|
const videoLdTag = videoLd
|
||||||
|
? `<script type="application/ld+json">${JSON.stringify(videoLd).replace(/</g, "\\u003c")}</script>`
|
||||||
|
: "";
|
||||||
|
|
||||||
const ogTags = [
|
const ogTags = [
|
||||||
`<meta property="og:title" content="${escapeHtml(article.title)}" />`,
|
`<meta property="og:title" content="${escapeHtml(article.title)}" />`,
|
||||||
`<meta property="og:description" content="${escapeHtml(article.excerpt)}" />`,
|
`<meta property="og:description" content="${escapeHtml(article.excerpt)}" />`,
|
||||||
`<meta property="og:type" content="article" />`,
|
`<meta property="og:type" content="article" />`,
|
||||||
|
`<meta property="article:published_time" content="${pubDate}" />`,
|
||||||
|
`<meta property="article:modified_time" content="${modDate}" />`,
|
||||||
`<meta property="og:url" content="${escapeHtml(articleUrl)}" />`,
|
`<meta property="og:url" content="${escapeHtml(articleUrl)}" />`,
|
||||||
`<meta property="og:image" content="${escapeHtml(finalImage)}" />`,
|
`<meta property="og:image" content="${escapeHtml(finalImage)}" />`,
|
||||||
`<meta property="og:image:secure_url" content="${escapeHtml(finalImage)}" />`,
|
`<meta property="og:image:secure_url" content="${escapeHtml(finalImage)}" />`,
|
||||||
`<meta property="og:image:width" content="800" />`,
|
...(articleImgDims
|
||||||
`<meta property="og:image:height" content="450" />`,
|
? [`<meta property="og:image:width" content="${articleImgDims.w}" />`,
|
||||||
|
`<meta property="og:image:height" content="${articleImgDims.h}" />`]
|
||||||
|
: []),
|
||||||
`<meta property="og:image:type" content="image/jpeg" />`,
|
`<meta property="og:image:type" content="image/jpeg" />`,
|
||||||
`<meta property="og:site_name" content="Folx Music Television" />`,
|
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||||
`<meta property="og:locale" content="de_DE" />`,
|
`<meta property="og:locale" content="de_DE" />`,
|
||||||
`<meta property="fb:pages" content="1428520781492675" />`,
|
`<meta property="fb:pages" content="281200958640258" />`,
|
||||||
`<meta name="twitter:card" content="summary_large_image" />`,
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
`<meta name="twitter:title" content="${escapeHtml(article.title)}" />`,
|
`<meta name="twitter:title" content="${escapeHtml(article.title)}" />`,
|
||||||
`<meta name="twitter:description" content="${escapeHtml(article.excerpt)}" />`,
|
`<meta name="twitter:description" content="${escapeHtml(article.excerpt)}" />`,
|
||||||
`<meta name="twitter:image" content="${escapeHtml(finalImage)}" />`,
|
`<meta name="twitter:image" content="${escapeHtml(finalImage)}" />`,
|
||||||
`<meta name="description" content="${escapeHtml(article.excerpt)}" />`,
|
`<meta name="description" content="${escapeHtml(article.excerpt)}" />`,
|
||||||
`<meta name="keywords" content="Volksmusik, Schlager, ${escapeHtml(article.title)}, ${escapeHtml(article.category || 'News')}, FOLX TV" />`,
|
`<meta name="keywords" content="${escapeHtml(folxGenre(article).words.join(", "))}, ${escapeHtml(article.title)}, ${escapeHtml(article.category || 'News')}, FOLX TV" />`,
|
||||||
`<link rel="canonical" href="${escapeHtml(articleUrl)}" />`,
|
`<link rel="canonical" href="${escapeHtml(articleUrl)}" />`,
|
||||||
`<title>${escapeHtml(article.title)} - Volksmusik & Schlager | Folx Music Television</title>`,
|
`<title>${escapeHtml(article.title)} | FOLX TV</title>`,
|
||||||
|
jsonLdTag,
|
||||||
|
videoLdTag,
|
||||||
].join("\n ");
|
].join("\n ");
|
||||||
|
|
||||||
template = template.replace(/<title>[^<]*<\/title>/, ogTags);
|
template = template.replace(/<title>[^<]*<\/title>/, ogTags);
|
||||||
|
|
||||||
|
// SSR: vgradi vsebino clanka v HTML, da jo Google/Discover vidita brez JS
|
||||||
|
const ssrBlock = [
|
||||||
|
'<article class="ssr-content">',
|
||||||
|
`<h1>${escapeHtml(article.title)}</h1>`,
|
||||||
|
imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(article.title)}" style="max-width:100%;height:auto;" />` : "",
|
||||||
|
`<p><strong>${escapeHtml(article.excerpt)}</strong></p>`,
|
||||||
|
article.content || "",
|
||||||
|
"</article>",
|
||||||
|
].join("\n");
|
||||||
|
template = template.replace('<div id="root"></div>', () => `<div id="root">${ssrBlock}</div>`);
|
||||||
|
|
||||||
|
if (ONE_RUBRIKEN.has(article.category || "")) {
|
||||||
|
template = setRobots(template, "noindex, follow, max-image-preview:large");
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Star slug preimenovanega clanka -> trajna preusmeritev na novega.
|
||||||
|
const renamedTo = ARTICLE_SLUG_REDIRECTS[slug];
|
||||||
|
if (renamedTo) {
|
||||||
|
res.redirect(301, `/article/${renamedTo}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clanka s tem slugom ni. Brez tega bi SPA vrnil HTTP 200 z naslovnico
|
||||||
|
// in canonical na "/" — Google to bere kot podvojeno naslovnico (soft 404),
|
||||||
|
// izbrisani clanki pa ostanejo v indeksu in v Discovru.
|
||||||
|
{
|
||||||
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
template = template.replace(
|
||||||
|
/<title>[^<]*<\/title>/,
|
||||||
|
[
|
||||||
|
'<meta name="robots" content="noindex, follow" />',
|
||||||
|
'<meta name="description" content="Dieser Artikel ist nicht mehr verfuegbar." />',
|
||||||
|
'<title>Artikel nicht gefunden | Folx Music Television</title>',
|
||||||
|
].join("\n "),
|
||||||
|
);
|
||||||
|
res.status(404).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /rezepte in /videos sta prej vracala canonical na naslovnico, zato ju Google
|
||||||
|
// ni mogel indeksirati kot samostojni strani (29. 8. 2026).
|
||||||
|
const SEKCIJE: Record<string, { pot: string; naslov: string; opis: string } | undefined> = {
|
||||||
|
"/rezepte": {
|
||||||
|
pot: "/rezepte",
|
||||||
|
naslov: "Rezepte \u2013 Rezept des Tages | Folx Music Television",
|
||||||
|
opis: "Jeden Tag ein neues Rezept: einfache Gerichte, Klassiker und Ideen f\u00fcr jeden Anlass \u2013 bei FOLX TV.",
|
||||||
|
},
|
||||||
|
"/videos": {
|
||||||
|
pot: "/videos",
|
||||||
|
naslov: "Videos \u2013 Schlager und Volksmusik | Folx Music Television",
|
||||||
|
opis: "Alle Videos von FOLX TV: Interviews, Auftritte und Musik News aus Schlager und Volksmusik.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const sekPot = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
||||||
|
const sek = SEKCIJE[sekPot];
|
||||||
|
if (sek) {
|
||||||
let template = await fs.promises.readFile(indexPath, "utf-8");
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
const sUrl = `${canonicalBase}${sek.pot}`;
|
||||||
|
const sImg = `${canonicalBase}/images/og-image.jpg`;
|
||||||
|
const sTags = [
|
||||||
|
`<meta property="og:title" content="${escapeHtml(sek.naslov)}" />`,
|
||||||
|
`<meta property="og:description" content="${escapeHtml(sek.opis)}" />`,
|
||||||
|
`<meta property="og:type" content="website" />`,
|
||||||
|
`<meta property="og:url" content="${escapeHtml(sUrl)}" />`,
|
||||||
|
`<meta property="og:image" content="${escapeHtml(sImg)}" />`,
|
||||||
|
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||||
|
`<meta property="og:locale" content="de_DE" />`,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
`<meta name="twitter:title" content="${escapeHtml(sek.naslov)}" />`,
|
||||||
|
`<meta name="twitter:description" content="${escapeHtml(sek.opis)}" />`,
|
||||||
|
`<meta name="twitter:image" content="${escapeHtml(sImg)}" />`,
|
||||||
|
`<meta name="description" content="${escapeHtml(sek.opis)}" />`,
|
||||||
|
`<link rel="canonical" href="${escapeHtml(sUrl)}" />`,
|
||||||
|
`<title>${escapeHtml(sek.naslov)}</title>`,
|
||||||
|
].join("\n ");
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, sTags);
|
||||||
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.match(/^\/gallery(\/|$|\?)/)) {
|
||||||
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
const galTitle = "Fotogalerie \u2013 Einzigartige Momente von unseren Aufzeichnungen | FOLX TV";
|
||||||
|
const galDesc = "Spannende Schnappsch\u00fcsse und besondere Augenblicke, eingefangen bei unseren Aufzeichnungen. Authentische Bilder der Volksmusik- und Schlager-Stars in einzigartigen Momenten bei FOLX TV.";
|
||||||
|
const galUrl = `${canonicalBase}/gallery`;
|
||||||
|
const galImage = `${canonicalBase}/images/og-image.jpg`;
|
||||||
|
|
||||||
|
const galTags = [
|
||||||
|
`<meta property="og:title" content="${escapeHtml(galTitle)}" />`,
|
||||||
|
`<meta property="og:description" content="${escapeHtml(galDesc)}" />`,
|
||||||
|
`<meta property="og:type" content="website" />`,
|
||||||
|
`<meta property="og:url" content="${escapeHtml(galUrl)}" />`,
|
||||||
|
`<meta property="og:image" content="${escapeHtml(galImage)}" />`,
|
||||||
|
`<meta property="og:image:secure_url" content="${escapeHtml(galImage)}" />`,
|
||||||
|
`<meta property="og:image:width" content="1200" />`,
|
||||||
|
`<meta property="og:image:height" content="630" />`,
|
||||||
|
`<meta property="og:image:type" content="image/jpeg" />`,
|
||||||
|
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||||
|
`<meta property="og:locale" content="de_DE" />`,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
`<meta name="twitter:title" content="${escapeHtml(galTitle)}" />`,
|
||||||
|
`<meta name="twitter:description" content="${escapeHtml(galDesc)}" />`,
|
||||||
|
`<meta name="twitter:image" content="${escapeHtml(galImage)}" />`,
|
||||||
|
`<meta name="description" content="${escapeHtml(galDesc)}" />`,
|
||||||
|
`<meta name="keywords" content="Fotogalerie, Volksmusik Fotos, Schlager Bilder, Aufzeichnungen, Schnappschüsse, FOLX TV, Volksmusik Stars" />`,
|
||||||
|
`<link rel="canonical" href="${escapeHtml(galUrl)}" />`,
|
||||||
|
`<title>${escapeHtml(galTitle)}</title>`,
|
||||||
|
].join("\n ");
|
||||||
|
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, galTags);
|
||||||
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.match(/^\/horoskop(\/|$|\?)/)) {
|
||||||
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
const signMatch = url.match(/^\/horoskop\/([^?#]+)/);
|
||||||
|
const signName = signMatch ? decodeURIComponent(signMatch[1]) : null;
|
||||||
|
const capitalSign = signName ? signName.charAt(0).toUpperCase() + signName.slice(1) : null;
|
||||||
|
|
||||||
|
const title = capitalSign
|
||||||
|
? `${capitalSign} Horoskop – Tages-, Wochen- & Monatshoroskop | Folx Music Television`
|
||||||
|
: "Horoskop – Tages-, Wochen- & Monatshoroskop für alle Sternzeichen | Folx Music Television";
|
||||||
|
const desc = capitalSign
|
||||||
|
? `${capitalSign} Horoskop: Tägliches, wöchentliches und monatliches Horoskop. Liebe, Beruf & Gesundheit bei FOLX TV.`
|
||||||
|
: "Kostenloses Tageshoroskop, Wochenhoroskop & Monatshoroskop für alle 12 Sternzeichen. Liebe, Beruf, Gesundheit bei FOLX TV.";
|
||||||
|
const horoUrl = `${canonicalBase}${signName ? `/horoskop/${signName}` : "/horoskop"}`;
|
||||||
|
const horoImage = `${canonicalBase}/images/horoskop-og.jpg`;
|
||||||
|
|
||||||
|
const horoTags = [
|
||||||
|
`<meta property="og:title" content="${escapeHtml(title)}" />`,
|
||||||
|
`<meta property="og:description" content="${escapeHtml(desc)}" />`,
|
||||||
|
`<meta property="og:type" content="website" />`,
|
||||||
|
`<meta property="og:url" content="${escapeHtml(horoUrl)}" />`,
|
||||||
|
`<meta property="og:image" content="${escapeHtml(horoImage)}" />`,
|
||||||
|
`<meta property="og:image:secure_url" content="${escapeHtml(horoImage)}" />`,
|
||||||
|
`<meta property="og:image:width" content="1200" />`,
|
||||||
|
`<meta property="og:image:height" content="675" />`,
|
||||||
|
`<meta property="og:image:type" content="image/jpeg" />`,
|
||||||
|
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||||
|
`<meta property="og:locale" content="de_DE" />`,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
`<meta name="twitter:title" content="${escapeHtml(title)}" />`,
|
||||||
|
`<meta name="twitter:description" content="${escapeHtml(desc)}" />`,
|
||||||
|
`<meta name="twitter:image" content="${escapeHtml(horoImage)}" />`,
|
||||||
|
`<meta name="description" content="${escapeHtml(desc)}" />`,
|
||||||
|
`<meta name="keywords" content="Horoskop, Tageshoroskop, Wochenhoroskop, Monatshoroskop, Sternzeichen, Volksmusik, FOLX TV${capitalSign ? `, ${capitalSign}` : ""}" />`,
|
||||||
|
`<link rel="canonical" href="${escapeHtml(horoUrl)}" />`,
|
||||||
|
`<title>${escapeHtml(title)}</title>`,
|
||||||
|
].join("\n ");
|
||||||
|
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, horoTags);
|
||||||
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- /aktuelles: zbirna stran rubrik, ki so vse noindex. Prej je vracala
|
||||||
|
// HTML naslovnice s canonical "/", kar je Google bral kot podvojeno
|
||||||
|
// naslovnico. Zdaj ima svoj canonical in noindex, follow. ----
|
||||||
|
if (/^\/aktuelles\/?$/.test(url.split("?")[0].split("#")[0])) {
|
||||||
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
const aktTitle = "Aktuelles | Folx Music Television";
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, [
|
||||||
|
`<link rel="canonical" href="${canonicalBase}/aktuelles" />`,
|
||||||
|
`<title>${escapeHtml(aktTitle)}</title>`,
|
||||||
|
].join("\n "));
|
||||||
|
template = setRobots(template, "noindex, follow");
|
||||||
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoPath = pathOnly.replace(/\/+$/, "");
|
||||||
|
const info = INFO_SEO[infoPath];
|
||||||
|
if (info) {
|
||||||
|
let template = stripExistingMeta(await fs.promises.readFile(indexPath, "utf-8"));
|
||||||
|
const pageUrl = `${canonicalBase}${infoPath}`;
|
||||||
|
const title = `${info.title} | FOLX TV`;
|
||||||
|
const ld = { "@context": "https://schema.org", "@type": info.type,
|
||||||
|
"@id": `${pageUrl}#webpage`, url: pageUrl, name: title, description: info.description,
|
||||||
|
inLanguage: "de-DE", isPartOf: { "@id": `${canonicalBase}/#website` }, publisher: PUBLISHER(canonicalBase) };
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, [
|
||||||
|
`<title>${escapeHtml(title)}</title>`,
|
||||||
|
`<meta name="description" content="${escapeHtml(info.description)}" />`,
|
||||||
|
`<link rel="canonical" href="${pageUrl}" />`,
|
||||||
|
`<meta property="og:title" content="${escapeHtml(title)}" />`,
|
||||||
|
`<meta property="og:description" content="${escapeHtml(info.description)}" />`,
|
||||||
|
`<meta property="og:type" content="website" />`,
|
||||||
|
`<meta property="og:url" content="${pageUrl}" />`,
|
||||||
|
`<meta property="og:image" content="${canonicalBase}/og-image.jpg" />`,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
`<meta name="twitter:title" content="${escapeHtml(title)}" />`,
|
||||||
|
`<meta name="twitter:description" content="${escapeHtml(info.description)}" />`,
|
||||||
|
`<meta name="twitter:image" content="${canonicalBase}/og-image.jpg" />`,
|
||||||
|
`<script id="info-jsonld" type="application/ld+json">${JSON.stringify(ld).replace(/</g, "\\u003c")}</script>`,
|
||||||
|
].join("\n"));
|
||||||
|
// 13. 10. 2026: Impressum (izdajatelj + odgovorni urednik) tudi v SSR, da ga Google vidi brez JS (E-E-A-T)
|
||||||
|
if (infoPath === "/impressum") {
|
||||||
|
const imp = `<main style="max-width:760px;margin:0 auto;padding:24px"><h1>Impressum</h1>`
|
||||||
|
+ `<h2>Herausgeber</h2><p>PRIME TIME Consulting GmbH<br/>Sigmaringer Str. 10<br/>72379 Hechingen<br/>Deutschland<br/>Geschäftsführerin: Heike Artič<br/>E-Mail: office@primetime.consulting</p>`
|
||||||
|
+ `<h2>Redaktion</h2><p>Chefredakteur: Sebastjan Artič<br/>Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV: Sebastjan Artič, Sigmaringer Str. 10, 72379 Hechingen<br/>E-Mail: office@folx.tv</p>`
|
||||||
|
+ `<h2>Offenlegung nach Mediengesetz</h2><p>Medieninhaber: BoldFrame Productions d.o.o.<br/>Sokolska ulica 46, 2000 Maribor, Slowenien</p></main>`;
|
||||||
|
template = template.replace('<div id="root"></div>', () => `<div id="root">${imp}</div>`);
|
||||||
|
}
|
||||||
|
res.status(200).type("html").send(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neznana pot mora vrniti PRAVI 404, sicer Google dobi soft 404 (HTTP 200
|
||||||
|
// z naslovnico). Seznam mora ustrezati potem v client/src/App.tsx.
|
||||||
|
const KNOWN = [
|
||||||
|
/^\/$/, /^\/search\/?$/, /^\/article\//, /^\/category\//, /^\/aktuelles\/?$/,
|
||||||
|
/^\/videos\/?$/, /^\/video\/[^/]+\/?$/, /^\/gallery\/?$/, /^\/horoskop(\/|$)/, /^\/rezepte\/?$/,
|
||||||
|
/^\/empfang-folx-tv\/?$/, /^\/ueber-uns\/?$/, /^\/impressum\/?$/,
|
||||||
|
/^\/datenschutz\/?$/, /^\/kontakt\/?$/, /^\/admin(\/|$)/,
|
||||||
|
];
|
||||||
|
const cleanPath = url.split("?")[0].split("#")[0];
|
||||||
|
const known = KNOWN.some((re) => re.test(cleanPath));
|
||||||
|
|
||||||
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
if (!known) {
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
template = template.replace(
|
||||||
|
/<title>[^<]*<\/title>/,
|
||||||
|
[
|
||||||
|
'<title>Seite nicht gefunden | FOLX TV</title>',
|
||||||
|
'<meta name="robots" content="noindex, follow" />',
|
||||||
|
].join("\n ")
|
||||||
|
);
|
||||||
|
res.status(404).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import express, { type Express } from "express";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { storage } from "./storage";
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ogImageUrl(coverImage: string, baseUrl: string): string {
|
||||||
|
if (!coverImage) return "";
|
||||||
|
let imgPath = coverImage;
|
||||||
|
// Cloudinary: vsili FB razmerje 1200x630 z g_faces (da FB ne reze glav)
|
||||||
|
if (imgPath.includes("res.cloudinary.com")) {
|
||||||
|
imgPath = imgPath.replace(/([,&/?])h_\d+/i, "$1h_630").replace(/([,&/?])w_\d+/i, "$1w_1200");
|
||||||
|
// ce ni g_faces, ga dodaj
|
||||||
|
if (!/g_faces/.test(imgPath)) {
|
||||||
|
imgPath = imgPath.replace(/\/(image\/(upload|fetch))\//, "/$1/g_faces,");
|
||||||
|
}
|
||||||
|
return imgPath;
|
||||||
|
}
|
||||||
|
if (imgPath.endsWith(".webp")) {
|
||||||
|
imgPath = imgPath.replace(/\.webp$/, ".jpg");
|
||||||
|
}
|
||||||
|
return imgPath.startsWith("http") ? imgPath : `${baseUrl}${imgPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stari slugi clankov, ki so bili pozneje preimenovani. Brez tega vsak star
|
||||||
|
// link (FB objave, komentarji, go.folx.tv kratki linki, Googlov indeks) pade
|
||||||
|
// na trdi 404. Kljuc = star slug, vrednost = trenutni slug.
|
||||||
|
const ARTICLE_SLUG_REDIRECTS: Record<string, string> = {
|
||||||
|
"alexander-rier-verraet-das-ist-die-neue-frau-an-seiner-seite":
|
||||||
|
"alexander-rier-von-mann-zu-mann-immer-wieder-sonntags",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rubrike, ki jih pisemo enkrat in objavimo tudi na onemusic.news. Besedilo je
|
||||||
|
// isto na obeh domenah, zato jih na folx.tv drzimo zunaj Googlovega indeksa —
|
||||||
|
// v bazi in v /api/articles ostanejo, ker jih bere sidebar na TV kanalih.
|
||||||
|
const ONE_RUBRIKEN = new Set(["Politik", "Promis", "Sport", "Musik & Film", "Wetter", "Finanzen", "Aktuelles"]);
|
||||||
|
|
||||||
|
function setRobots(html: string, content: string): string {
|
||||||
|
const tag = `<meta name="robots" content="${content}" />`;
|
||||||
|
if (/<meta\s+name="robots"[^>]*>/i.test(html)) {
|
||||||
|
return html.replace(/<meta\s+name="robots"[^>]*>/i, tag);
|
||||||
|
}
|
||||||
|
return html.replace("</head>", ` ${tag}\n </head>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripExistingMeta(html: string): string {
|
||||||
|
html = html.replace(/<meta\s+property="og:[^"]*"[^>]*>\s*/gi, "");
|
||||||
|
html = html.replace(/<meta\s+name="twitter:[^"]*"[^>]*>\s*/gi, "");
|
||||||
|
html = html.replace(/<meta\s+name="description"[^>]*>\s*/gi, "");
|
||||||
|
html = html.replace(/<meta\s+name="keywords"[^>]*>\s*/gi, "");
|
||||||
|
html = html.replace(/<link\s+rel="canonical"[^>]*>\s*/gi, "");
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serveStatic(app: Express) {
|
||||||
|
const distPath = path.resolve(__dirname, "public");
|
||||||
|
if (!fs.existsSync(distPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not find the build directory: ${distPath}, make sure to build the client first`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(express.static(distPath, {
|
||||||
|
setHeaders(res, filePath) {
|
||||||
|
if (filePath.endsWith("og-image.jpg") || filePath.includes("/uploads/") || filePath.includes("/images/")) {
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use("/{*path}", async (req, res) => {
|
||||||
|
const url = req.originalUrl;
|
||||||
|
const indexPath = path.resolve(distPath, "index.html");
|
||||||
|
|
||||||
|
const canonicalBase = "https://folx.tv";
|
||||||
|
const host = req.get("host") || "folx.tv";
|
||||||
|
const protocol = req.get("x-forwarded-proto") || "https";
|
||||||
|
const baseUrl = `${protocol}://${host}`;
|
||||||
|
|
||||||
|
const articleMatch = url.match(/^\/article\/([^?#]+)/);
|
||||||
|
if (articleMatch) {
|
||||||
|
try {
|
||||||
|
const slug = decodeURIComponent(articleMatch[1]);
|
||||||
|
const article = await storage.getArticleBySlug(slug);
|
||||||
|
if (article && (article as any).targetUrl) {
|
||||||
|
res.redirect(302, (article as any).targetUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (article) {
|
||||||
|
const articleUrl = `${canonicalBase}/article/${article.slug}`;
|
||||||
|
const imageUrl = ogImageUrl(article.coverImage || "", canonicalBase);
|
||||||
|
const finalImage = imageUrl || `${canonicalBase}/og-image.jpg`;
|
||||||
|
|
||||||
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
|
||||||
|
const pubDate = article.publishedAt
|
||||||
|
? new Date(article.publishedAt as any).toISOString()
|
||||||
|
: new Date().toISOString();
|
||||||
|
|
||||||
|
// dateModified: ce je clanek dopolnjen (enrich), preberi marker <!--folx-updated:ISO-->
|
||||||
|
const modDate = (() => {
|
||||||
|
const mm = /<!--folx-updated:([^>]+?)-->/.exec(article.content || "");
|
||||||
|
if (mm) { try { return new Date(mm[1]).toISOString(); } catch (e) { /* ignore */ } }
|
||||||
|
return pubDate;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// VideoObject: ce je v clanku vgrajen YouTube video
|
||||||
|
const videoId = (() => {
|
||||||
|
const mm = /youtube(?:-nocookie)?\.com\/embed\/([A-Za-z0-9_-]{6,})/.exec(article.content || "");
|
||||||
|
return mm ? mm[1] : null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "NewsArticle",
|
||||||
|
"headline": article.title,
|
||||||
|
"description": article.excerpt,
|
||||||
|
"image": [finalImage],
|
||||||
|
"datePublished": pubDate,
|
||||||
|
"dateModified": modDate,
|
||||||
|
"articleSection": article.category || "News",
|
||||||
|
"author": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": article.author || "Folx Music Television",
|
||||||
|
"url": canonicalBase,
|
||||||
|
},
|
||||||
|
"publisher": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "Folx Music Television",
|
||||||
|
"logo": {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
"url": `${canonicalBase}/og-image.jpg`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"mainEntityOfPage": {
|
||||||
|
"@type": "WebPage",
|
||||||
|
"@id": articleUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const jsonLdTag = `<script type="application/ld+json">${JSON.stringify(jsonLd).replace(/</g, "\\u003c")}</script>`;
|
||||||
|
|
||||||
|
const videoLd = videoId ? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "VideoObject",
|
||||||
|
"name": article.title,
|
||||||
|
"description": article.excerpt,
|
||||||
|
"thumbnailUrl": [`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`],
|
||||||
|
"uploadDate": pubDate,
|
||||||
|
"embedUrl": `https://www.youtube.com/embed/${videoId}`,
|
||||||
|
"contentUrl": `https://www.youtube.com/watch?v=${videoId}`,
|
||||||
|
"publisher": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "Folx Music Television",
|
||||||
|
"logo": { "@type": "ImageObject", "url": `${canonicalBase}/og-image.jpg` },
|
||||||
|
},
|
||||||
|
} : null;
|
||||||
|
const videoLdTag = videoLd
|
||||||
|
? `<script type="application/ld+json">${JSON.stringify(videoLd).replace(/</g, "\\u003c")}</script>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const ogTags = [
|
||||||
|
`<meta property="og:title" content="${escapeHtml(article.title)}" />`,
|
||||||
|
`<meta property="og:description" content="${escapeHtml(article.excerpt)}" />`,
|
||||||
|
`<meta property="og:type" content="article" />`,
|
||||||
|
`<meta property="og:url" content="${escapeHtml(articleUrl)}" />`,
|
||||||
|
`<meta property="og:image" content="${escapeHtml(finalImage)}" />`,
|
||||||
|
`<meta property="og:image:secure_url" content="${escapeHtml(finalImage)}" />`,
|
||||||
|
`<meta property="og:image:width" content="1200" />`,
|
||||||
|
`<meta property="og:image:height" content="630" />`,
|
||||||
|
`<meta property="og:image:type" content="image/jpeg" />`,
|
||||||
|
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||||
|
`<meta property="og:locale" content="de_DE" />`,
|
||||||
|
`<meta property="fb:pages" content="281200958640258" />`,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
`<meta name="twitter:title" content="${escapeHtml(article.title)}" />`,
|
||||||
|
`<meta name="twitter:description" content="${escapeHtml(article.excerpt)}" />`,
|
||||||
|
`<meta name="twitter:image" content="${escapeHtml(finalImage)}" />`,
|
||||||
|
`<meta name="description" content="${escapeHtml(article.excerpt)}" />`,
|
||||||
|
`<meta name="keywords" content="Volksmusik, Schlager, ${escapeHtml(article.title)}, ${escapeHtml(article.category || 'News')}, FOLX TV" />`,
|
||||||
|
`<link rel="canonical" href="${escapeHtml(articleUrl)}" />`,
|
||||||
|
`<title>${escapeHtml(article.title)} - Volksmusik & Schlager | Folx Music Television</title>`,
|
||||||
|
jsonLdTag,
|
||||||
|
videoLdTag,
|
||||||
|
].join("\n ");
|
||||||
|
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, ogTags);
|
||||||
|
|
||||||
|
// SSR: vgradi vsebino clanka v HTML, da jo Google/Discover vidita brez JS
|
||||||
|
const ssrBlock = [
|
||||||
|
'<article class="ssr-content">',
|
||||||
|
`<h1>${escapeHtml(article.title)}</h1>`,
|
||||||
|
imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(article.title)}" style="max-width:100%;height:auto;" />` : "",
|
||||||
|
`<p><strong>${escapeHtml(article.excerpt)}</strong></p>`,
|
||||||
|
article.content || "",
|
||||||
|
"</article>",
|
||||||
|
].join("\n");
|
||||||
|
template = template.replace('<div id="root"></div>', () => `<div id="root">${ssrBlock}</div>`);
|
||||||
|
|
||||||
|
if (ONE_RUBRIKEN.has(article.category || "")) {
|
||||||
|
template = setRobots(template, "noindex, follow, max-image-preview:large");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Star slug preimenovanega clanka -> trajna preusmeritev na novega.
|
||||||
|
const renamedTo = ARTICLE_SLUG_REDIRECTS[slug];
|
||||||
|
if (renamedTo) {
|
||||||
|
res.redirect(301, `/article/${renamedTo}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clanka s tem slugom ni. Brez tega bi SPA vrnil HTTP 200 z naslovnico
|
||||||
|
// in canonical na "/" — Google to bere kot podvojeno naslovnico (soft 404),
|
||||||
|
// izbrisani clanki pa ostanejo v indeksu in v Discovru.
|
||||||
|
{
|
||||||
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
template = template.replace(
|
||||||
|
/<title>[^<]*<\/title>/,
|
||||||
|
[
|
||||||
|
'<meta name="robots" content="noindex, follow" />',
|
||||||
|
'<meta name="description" content="Dieser Artikel ist nicht mehr verfuegbar." />',
|
||||||
|
'<title>Artikel nicht gefunden | Folx Music Television</title>',
|
||||||
|
].join("\n "),
|
||||||
|
);
|
||||||
|
res.status(404).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.match(/^\/gallery(\/|$|\?)/)) {
|
||||||
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
const galTitle = "Fotogalerie \u2013 Einzigartige Momente von unseren Aufzeichnungen | FOLX TV";
|
||||||
|
const galDesc = "Spannende Schnappsch\u00fcsse und besondere Augenblicke, eingefangen bei unseren Aufzeichnungen. Authentische Bilder der Volksmusik- und Schlager-Stars in einzigartigen Momenten bei FOLX TV.";
|
||||||
|
const galUrl = `${canonicalBase}/gallery`;
|
||||||
|
const galImage = `${canonicalBase}/images/og-image.jpg`;
|
||||||
|
|
||||||
|
const galTags = [
|
||||||
|
`<meta property="og:title" content="${escapeHtml(galTitle)}" />`,
|
||||||
|
`<meta property="og:description" content="${escapeHtml(galDesc)}" />`,
|
||||||
|
`<meta property="og:type" content="website" />`,
|
||||||
|
`<meta property="og:url" content="${escapeHtml(galUrl)}" />`,
|
||||||
|
`<meta property="og:image" content="${escapeHtml(galImage)}" />`,
|
||||||
|
`<meta property="og:image:secure_url" content="${escapeHtml(galImage)}" />`,
|
||||||
|
`<meta property="og:image:width" content="1200" />`,
|
||||||
|
`<meta property="og:image:height" content="630" />`,
|
||||||
|
`<meta property="og:image:type" content="image/jpeg" />`,
|
||||||
|
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||||
|
`<meta property="og:locale" content="de_DE" />`,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
`<meta name="twitter:title" content="${escapeHtml(galTitle)}" />`,
|
||||||
|
`<meta name="twitter:description" content="${escapeHtml(galDesc)}" />`,
|
||||||
|
`<meta name="twitter:image" content="${escapeHtml(galImage)}" />`,
|
||||||
|
`<meta name="description" content="${escapeHtml(galDesc)}" />`,
|
||||||
|
`<meta name="keywords" content="Fotogalerie, Volksmusik Fotos, Schlager Bilder, Aufzeichnungen, Schnappschüsse, FOLX TV, Volksmusik Stars" />`,
|
||||||
|
`<link rel="canonical" href="${escapeHtml(galUrl)}" />`,
|
||||||
|
`<title>${escapeHtml(galTitle)}</title>`,
|
||||||
|
].join("\n ");
|
||||||
|
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, galTags);
|
||||||
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.match(/^\/horoskop(\/|$|\?)/)) {
|
||||||
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
const signMatch = url.match(/^\/horoskop\/([^?#]+)/);
|
||||||
|
const signName = signMatch ? decodeURIComponent(signMatch[1]) : null;
|
||||||
|
const capitalSign = signName ? signName.charAt(0).toUpperCase() + signName.slice(1) : null;
|
||||||
|
|
||||||
|
const title = capitalSign
|
||||||
|
? `${capitalSign} Horoskop – Tages-, Wochen- & Monatshoroskop | Folx Music Television`
|
||||||
|
: "Horoskop – Tages-, Wochen- & Monatshoroskop für alle Sternzeichen | Folx Music Television";
|
||||||
|
const desc = capitalSign
|
||||||
|
? `${capitalSign} Horoskop: Tägliches, wöchentliches und monatliches Horoskop. Liebe, Beruf & Gesundheit bei FOLX TV.`
|
||||||
|
: "Kostenloses Tageshoroskop, Wochenhoroskop & Monatshoroskop für alle 12 Sternzeichen. Liebe, Beruf, Gesundheit bei FOLX TV.";
|
||||||
|
const horoUrl = `${canonicalBase}${signName ? `/horoskop/${signName}` : "/horoskop"}`;
|
||||||
|
const horoImage = `${canonicalBase}/images/horoskop-og.jpg`;
|
||||||
|
|
||||||
|
const horoTags = [
|
||||||
|
`<meta property="og:title" content="${escapeHtml(title)}" />`,
|
||||||
|
`<meta property="og:description" content="${escapeHtml(desc)}" />`,
|
||||||
|
`<meta property="og:type" content="website" />`,
|
||||||
|
`<meta property="og:url" content="${escapeHtml(horoUrl)}" />`,
|
||||||
|
`<meta property="og:image" content="${escapeHtml(horoImage)}" />`,
|
||||||
|
`<meta property="og:image:secure_url" content="${escapeHtml(horoImage)}" />`,
|
||||||
|
`<meta property="og:image:width" content="1200" />`,
|
||||||
|
`<meta property="og:image:height" content="675" />`,
|
||||||
|
`<meta property="og:image:type" content="image/jpeg" />`,
|
||||||
|
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||||
|
`<meta property="og:locale" content="de_DE" />`,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
`<meta name="twitter:title" content="${escapeHtml(title)}" />`,
|
||||||
|
`<meta name="twitter:description" content="${escapeHtml(desc)}" />`,
|
||||||
|
`<meta name="twitter:image" content="${escapeHtml(horoImage)}" />`,
|
||||||
|
`<meta name="description" content="${escapeHtml(desc)}" />`,
|
||||||
|
`<meta name="keywords" content="Horoskop, Tageshoroskop, Wochenhoroskop, Monatshoroskop, Sternzeichen, Volksmusik, FOLX TV${capitalSign ? `, ${capitalSign}` : ""}" />`,
|
||||||
|
`<link rel="canonical" href="${escapeHtml(horoUrl)}" />`,
|
||||||
|
`<title>${escapeHtml(title)}</title>`,
|
||||||
|
].join("\n ");
|
||||||
|
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, horoTags);
|
||||||
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neznana pot mora vrniti PRAVI 404, sicer Google dobi soft 404 (HTTP 200
|
||||||
|
// z naslovnico). Seznam mora ustrezati potem v client/src/App.tsx.
|
||||||
|
const KNOWN = [
|
||||||
|
/^\/$/, /^\/search\/?$/, /^\/article\//, /^\/category\//, /^\/aktuelles\/?$/,
|
||||||
|
/^\/videos\/?$/, /^\/gallery\/?$/, /^\/horoskop(\/|$)/, /^\/rezepte\/?$/,
|
||||||
|
/^\/empfang-folx-tv\/?$/, /^\/ueber-uns\/?$/, /^\/impressum\/?$/,
|
||||||
|
/^\/datenschutz\/?$/, /^\/kontakt\/?$/, /^\/admin(\/|$)/,
|
||||||
|
];
|
||||||
|
const cleanPath = url.split("?")[0].split("#")[0];
|
||||||
|
const known = KNOWN.some((re) => re.test(cleanPath));
|
||||||
|
|
||||||
|
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||||
|
if (!known) {
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
template = template.replace(
|
||||||
|
/<title>[^<]*<\/title>/,
|
||||||
|
[
|
||||||
|
'<title>Seite nicht gefunden | FOLX TV</title>',
|
||||||
|
'<meta name="robots" content="noindex, follow" />',
|
||||||
|
].join("\n ")
|
||||||
|
);
|
||||||
|
res.status(404).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type Article, type InsertArticle, articles, articleViews, type InsertPushSubscription, type PushSubscription, pushSubscriptions } from "@shared/schema";
|
import { type Article, type InsertArticle, articles, articleViews, type InsertPushSubscription, type PushSubscription, pushSubscriptions } from "@shared/schema";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { eq, desc, sql, count, and } from "drizzle-orm";
|
import { eq, desc, sql, count, and, gte, inArray } from "drizzle-orm";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
|
||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
@@ -40,8 +40,33 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return db.select().from(articles).orderBy(desc(articles.publishedAt)).limit(9);
|
return db.select().from(articles).orderBy(desc(articles.publishedAt)).limit(9);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPopularArticles(limit: number): Promise<Article[]> {
|
// "Beliebt" mora dejansko rotirati: steje ogled v zadnjih `days` dneh, ne
|
||||||
return db.select().from(articles).orderBy(desc(articles.views)).limit(limit);
|
// stevec od zacetka. Ta je namrec za stare uspesnice tako visok, da se blok
|
||||||
|
// nikoli ne premakne. Ce svezih ogledov ni dovolj, dopolnimo po skupnem stevcu.
|
||||||
|
async getPopularArticles(limit: number, days: number = 7): Promise<Article[]> {
|
||||||
|
const since = new Date(Date.now() - days * 86400000);
|
||||||
|
const recent = await db
|
||||||
|
.select({ id: articleViews.articleId, n: count() })
|
||||||
|
.from(articleViews)
|
||||||
|
.where(gte(articleViews.viewedAt, since))
|
||||||
|
.groupBy(articleViews.articleId)
|
||||||
|
.orderBy(desc(count()))
|
||||||
|
.limit(limit * 3);
|
||||||
|
const ids = recent.map((r) => r.id);
|
||||||
|
let out: Article[] = [];
|
||||||
|
if (ids.length) {
|
||||||
|
const rows = await db.select().from(articles).where(inArray(articles.id, ids));
|
||||||
|
const byId = new Map(rows.map((a) => [a.id, a]));
|
||||||
|
out = ids.map((i) => byId.get(i)).filter(Boolean) as Article[];
|
||||||
|
}
|
||||||
|
if (out.length < limit) {
|
||||||
|
const fill = await db.select().from(articles).orderBy(desc(articles.views)).limit(limit * 2);
|
||||||
|
const have = new Set(out.map((a) => a.id));
|
||||||
|
for (const a of fill) {
|
||||||
|
if (!have.has(a.id)) { out.push(a); have.add(a.id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getArticlesByCategory(category: string): Promise<Article[]> {
|
async getArticlesByCategory(category: string): Promise<Article[]> {
|
||||||
|
|||||||
@@ -7,6 +7,22 @@ import path from "path";
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
|
|
||||||
|
// 15. 9. 2026 (Sebastjan): stran je v keywords vsakemu clanku pisala
|
||||||
|
// "Volksmusik, Schlager" — tudi Beckerju, Geissensom in Ivanovic. Cevovod ze ve,
|
||||||
|
// kaj kos je (znacka MUSIC NEWS / STAR NEWS), zato jo zapise v vsebino kot
|
||||||
|
// marker <!--folx-badge:STAR NEWS-->, enako kot <!--folx-updated:...-->.
|
||||||
|
// Brez markerja ostane vse kot prej (glasbeno), da starejsi clanki ne nazadujejo.
|
||||||
|
// Besedilne zaznave namenoma NI: preizkus na 30 clankih jo je zgresil pri
|
||||||
|
// Fernsehgartnu in Carpendaleju.
|
||||||
|
function folxGenre(article: any): { words: string[]; section: string } {
|
||||||
|
const m = /<!--\s*folx-badge:\s*([^>]*?)\s*-->/i.exec(String(article?.content || ""));
|
||||||
|
const badge = (m ? m[1] : "MUSIC NEWS").toUpperCase();
|
||||||
|
return badge === "MUSIC NEWS"
|
||||||
|
? { words: ["Volksmusik", "Schlager", "Musik News"], section: "Musik News" }
|
||||||
|
: { words: ["Star News", "Promi News", "Promi"], section: "Star News" };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const viteLogger = createLogger();
|
const viteLogger = createLogger();
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
@@ -96,13 +112,13 @@ export async function setupVite(server: Server, app: Express) {
|
|||||||
`<meta property="og:image:type" content="image/jpeg" />`,
|
`<meta property="og:image:type" content="image/jpeg" />`,
|
||||||
`<meta property="og:site_name" content="Folx Music Television" />`,
|
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||||
`<meta property="og:locale" content="de_DE" />`,
|
`<meta property="og:locale" content="de_DE" />`,
|
||||||
`<meta property="fb:pages" content="1428520781492675" />`,
|
`<meta property="fb:pages" content="281200958640258" />`,
|
||||||
`<meta name="twitter:card" content="summary_large_image" />`,
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
`<meta name="twitter:title" content="${escapeHtml(article.title)}" />`,
|
`<meta name="twitter:title" content="${escapeHtml(article.title)}" />`,
|
||||||
`<meta name="twitter:description" content="${escapeHtml(article.excerpt)}" />`,
|
`<meta name="twitter:description" content="${escapeHtml(article.excerpt)}" />`,
|
||||||
`<meta name="twitter:image" content="${escapeHtml(finalImage)}" />`,
|
`<meta name="twitter:image" content="${escapeHtml(finalImage)}" />`,
|
||||||
`<meta name="description" content="${escapeHtml(article.excerpt)}" />`,
|
`<meta name="description" content="${escapeHtml(article.excerpt)}" />`,
|
||||||
`<meta name="keywords" content="Volksmusik, Schlager, ${escapeHtml(article.title)}, ${escapeHtml(article.category || 'News')}, FOLX TV" />`,
|
`<meta name="keywords" content="${escapeHtml(folxGenre(article).words.join(", "))}, ${escapeHtml(article.title)}, ${escapeHtml(article.category || 'News')}, FOLX TV" />`,
|
||||||
`<link rel="canonical" href="${escapeHtml(articleUrl)}" />`,
|
`<link rel="canonical" href="${escapeHtml(articleUrl)}" />`,
|
||||||
`<title>${escapeHtml(article.title)} - Volksmusik & Schlager | Folx Music Television</title>`,
|
`<title>${escapeHtml(article.title)} - Volksmusik & Schlager | Folx Music Television</title>`,
|
||||||
].join("\n ");
|
].join("\n ");
|
||||||
@@ -113,6 +129,78 @@ export async function setupVite(server: Server, app: Express) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.match(/^\/gallery(\/|$|\?)/)) {
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
const galTitle = "Fotogalerie \u2013 Einzigartige Momente von unseren Aufzeichnungen | FOLX TV";
|
||||||
|
const galDesc = "Spannende Schnappsch\u00fcsse und besondere Augenblicke, eingefangen bei unseren Aufzeichnungen. Authentische Bilder der Volksmusik- und Schlager-Stars in einzigartigen Momenten bei FOLX TV.";
|
||||||
|
const galUrl = `${canonicalBase}/gallery`;
|
||||||
|
const galImage = `${canonicalBase}/images/og-image.jpg`;
|
||||||
|
|
||||||
|
const galTags = [
|
||||||
|
`<meta property="og:title" content="${escapeHtml(galTitle)}" />`,
|
||||||
|
`<meta property="og:description" content="${escapeHtml(galDesc)}" />`,
|
||||||
|
`<meta property="og:type" content="website" />`,
|
||||||
|
`<meta property="og:url" content="${escapeHtml(galUrl)}" />`,
|
||||||
|
`<meta property="og:image" content="${escapeHtml(galImage)}" />`,
|
||||||
|
`<meta property="og:image:secure_url" content="${escapeHtml(galImage)}" />`,
|
||||||
|
`<meta property="og:image:width" content="1200" />`,
|
||||||
|
`<meta property="og:image:height" content="630" />`,
|
||||||
|
`<meta property="og:image:type" content="image/jpeg" />`,
|
||||||
|
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||||
|
`<meta property="og:locale" content="de_DE" />`,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
`<meta name="twitter:title" content="${escapeHtml(galTitle)}" />`,
|
||||||
|
`<meta name="twitter:description" content="${escapeHtml(galDesc)}" />`,
|
||||||
|
`<meta name="twitter:image" content="${escapeHtml(galImage)}" />`,
|
||||||
|
`<meta name="description" content="${escapeHtml(galDesc)}" />`,
|
||||||
|
`<meta name="keywords" content="Fotogalerie, Volksmusik Fotos, Schlager Bilder, Aufzeichnungen, Schnappschüsse, FOLX TV, Volksmusik Stars" />`,
|
||||||
|
`<link rel="canonical" href="${escapeHtml(galUrl)}" />`,
|
||||||
|
`<title>${escapeHtml(galTitle)}</title>`,
|
||||||
|
].join("\n ");
|
||||||
|
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, galTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.match(/^\/horoskop(\/|$|\?)/)) {
|
||||||
|
template = stripExistingMeta(template);
|
||||||
|
const signMatch = url.match(/^\/horoskop\/([^?#]+)/);
|
||||||
|
const signName = signMatch ? decodeURIComponent(signMatch[1]) : null;
|
||||||
|
const capitalSign = signName ? signName.charAt(0).toUpperCase() + signName.slice(1) : null;
|
||||||
|
|
||||||
|
const title = capitalSign
|
||||||
|
? `${capitalSign} Horoskop – Tages-, Wochen- & Monatshoroskop | Folx Music Television`
|
||||||
|
: "Horoskop – Tages-, Wochen- & Monatshoroskop für alle Sternzeichen | Folx Music Television";
|
||||||
|
const desc = capitalSign
|
||||||
|
? `${capitalSign} Horoskop: Tägliches, wöchentliches und monatliches Horoskop. Liebe, Beruf & Gesundheit bei FOLX TV.`
|
||||||
|
: "Kostenloses Tageshoroskop, Wochenhoroskop & Monatshoroskop für alle 12 Sternzeichen. Liebe, Beruf, Gesundheit bei FOLX TV.";
|
||||||
|
const horoUrl = `${canonicalBase}${signName ? `/horoskop/${signName}` : "/horoskop"}`;
|
||||||
|
const horoImage = `${canonicalBase}/images/horoskop-og.jpg`;
|
||||||
|
|
||||||
|
const horoTags = [
|
||||||
|
`<meta property="og:title" content="${escapeHtml(title)}" />`,
|
||||||
|
`<meta property="og:description" content="${escapeHtml(desc)}" />`,
|
||||||
|
`<meta property="og:type" content="website" />`,
|
||||||
|
`<meta property="og:url" content="${escapeHtml(horoUrl)}" />`,
|
||||||
|
`<meta property="og:image" content="${escapeHtml(horoImage)}" />`,
|
||||||
|
`<meta property="og:image:secure_url" content="${escapeHtml(horoImage)}" />`,
|
||||||
|
`<meta property="og:image:width" content="1200" />`,
|
||||||
|
`<meta property="og:image:height" content="675" />`,
|
||||||
|
`<meta property="og:image:type" content="image/jpeg" />`,
|
||||||
|
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||||
|
`<meta property="og:locale" content="de_DE" />`,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
`<meta name="twitter:title" content="${escapeHtml(title)}" />`,
|
||||||
|
`<meta name="twitter:description" content="${escapeHtml(desc)}" />`,
|
||||||
|
`<meta name="twitter:image" content="${escapeHtml(horoImage)}" />`,
|
||||||
|
`<meta name="description" content="${escapeHtml(desc)}" />`,
|
||||||
|
`<meta name="keywords" content="Horoskop, Tageshoroskop, Wochenhoroskop, Monatshoroskop, Sternzeichen, Volksmusik, FOLX TV${capitalSign ? `, ${capitalSign}` : ""}" />`,
|
||||||
|
`<link rel="canonical" href="${escapeHtml(horoUrl)}" />`,
|
||||||
|
`<title>${escapeHtml(title)}</title>`,
|
||||||
|
].join("\n ");
|
||||||
|
|
||||||
|
template = template.replace(/<title>[^<]*<\/title>/, horoTags);
|
||||||
|
}
|
||||||
|
|
||||||
const page = await vite.transformIndexHtml(url, template);
|
const page = await vite.transformIndexHtml(url, template);
|
||||||
res.status(200).set({ "Content-Type": "text/html" }).end(page);
|
res.status(200).set({ "Content-Type": "text/html" }).end(page);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const articles = pgTable("articles", {
|
|||||||
author: varchar("author", { length: 255 }).notNull().default("Folx Music Television"),
|
author: varchar("author", { length: 255 }).notNull().default("Folx Music Television"),
|
||||||
featured: boolean("featured").notNull().default(false),
|
featured: boolean("featured").notNull().default(false),
|
||||||
views: integer("views").notNull().default(0),
|
views: integer("views").notNull().default(0),
|
||||||
|
targetUrl: text("target_url"),
|
||||||
publishedAt: timestamp("published_at").notNull().defaultNow(),
|
publishedAt: timestamp("published_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,6 +34,17 @@ export const articleViews = pgTable("article_views", {
|
|||||||
viewedAt: timestamp("viewed_at").notNull().defaultNow(),
|
viewedAt: timestamp("viewed_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const dailyRecipes = pgTable("daily_recipes", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
dateStr: varchar("date_str", { length: 10 }).notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
intro: text("intro").notNull().default(""),
|
||||||
|
recipe: text("recipe").notNull().default(""),
|
||||||
|
seasonNote: text("season_note"),
|
||||||
|
imageUrl: text("image_url"),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
export const dailyHoroscopes = pgTable("daily_horoscopes", {
|
export const dailyHoroscopes = pgTable("daily_horoscopes", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
signIndex: integer("sign_index").notNull(),
|
signIndex: integer("sign_index").notNull(),
|
||||||
@@ -45,11 +57,27 @@ export const dailyHoroscopes = pgTable("daily_horoscopes", {
|
|||||||
tip: text("tip").notNull(),
|
tip: text("tip").notNull(),
|
||||||
weekly: text("weekly").notNull(),
|
weekly: text("weekly").notNull(),
|
||||||
monthly: text("monthly").notNull(),
|
monthly: text("monthly").notNull(),
|
||||||
|
imageUrl: text("image_url"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DailyHoroscope = typeof dailyHoroscopes.$inferSelect;
|
export type DailyHoroscope = typeof dailyHoroscopes.$inferSelect;
|
||||||
|
|
||||||
|
export const cosmicEvents = pgTable("cosmic_events", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
dateStr: varchar("date_str", { length: 10 }).notNull(),
|
||||||
|
position: integer("position").notNull(),
|
||||||
|
title: varchar("title", { length: 120 }).notNull(),
|
||||||
|
description: text("description").notNull(),
|
||||||
|
dateRange: varchar("date_range", { length: 120 }).notNull(),
|
||||||
|
icon: varchar("icon", { length: 20 }).notNull(),
|
||||||
|
type: varchar("type", { length: 20 }).notNull(),
|
||||||
|
affectedSigns: text("affected_signs").notNull(),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CosmicEvent = typeof cosmicEvents.$inferSelect;
|
||||||
|
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
username: text("username").notNull().unique(),
|
username: text("username").notNull().unique(),
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export const SITE_URL = "https://folx.tv";
|
||||||
|
export const HOME_TITLE = "Schlager & Volksmusik News";
|
||||||
|
export const HOME_DESCRIPTION = "Aktuelle Schlager- und Volksmusik-News, Künstler-Interviews und Nachrichten aus der Musikszene. Jetzt Artikel und Videos auf FOLX TV entdecken.";
|
||||||
|
|
||||||
|
export function publisherSchema(base = SITE_URL) {
|
||||||
|
return {
|
||||||
|
"@type": "NewsMediaOrganization",
|
||||||
|
"@id": `${base}/#organization`,
|
||||||
|
name: "Folx Music Television",
|
||||||
|
alternateName: "FOLX TV",
|
||||||
|
url: base,
|
||||||
|
description: HOME_DESCRIPTION,
|
||||||
|
logo: { "@type": "ImageObject", url: `${base}/og-image.jpg` },
|
||||||
|
sameAs: ["https://www.facebook.com/folxtv", "https://www.instagram.com/folxtv", "https://www.youtube.com/@folxmtv"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function homeSchema(base = SITE_URL) {
|
||||||
|
return { "@context": "https://schema.org", "@graph": [
|
||||||
|
publisherSchema(base),
|
||||||
|
{ "@type": "WebSite", "@id": `${base}/#website`, url: `${base}/`,
|
||||||
|
name: "FOLX TV", alternateName: "Folx Music Television", description: HOME_DESCRIPTION,
|
||||||
|
inLanguage: "de-DE", publisher: { "@id": `${base}/#organization` },
|
||||||
|
potentialAction: { "@type": "SearchAction", target: { "@type": "EntryPoint", urlTemplate: `${base}/search?q={search_term_string}` }, "query-input": "required name=search_term_string" } },
|
||||||
|
{ "@type": "CollectionPage", "@id": `${base}/#webpage`, url: `${base}/`,
|
||||||
|
name: `${HOME_TITLE} | FOLX TV`, description: HOME_DESCRIPTION, inLanguage: "de-DE",
|
||||||
|
isPartOf: { "@id": `${base}/#website` }, publisher: { "@id": `${base}/#organization` },
|
||||||
|
hasPart: { "@type": "CollectionPage", "@id": `${base}/category/News#webpage`, url: `${base}/category/News`, name: "News" } }
|
||||||
|
] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function articleModifiedTime(content: string, published: string): string {
|
||||||
|
const marker = /<!--folx-updated:([^>]+?)-->/.exec(content || "");
|
||||||
|
const time = marker ? new Date(marker[1]).getTime() : NaN;
|
||||||
|
return Number.isFinite(time) && time >= new Date(published).getTime() && time <= Date.now()
|
||||||
|
? new Date(time).toISOString() : published;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function articleGenre(content: string) {
|
||||||
|
const marker = /<!--\s*folx-badge:\s*([^>]*?)\s*-->/i.exec(content || "");
|
||||||
|
const badge = (marker ? marker[1] : "MUSIC NEWS").toUpperCase();
|
||||||
|
return badge === "MUSIC NEWS"
|
||||||
|
? { words: ["Volksmusik", "Schlager", "Musik News"], section: "Musik News" }
|
||||||
|
: { words: ["Star News", "Promi News", "Promi"], section: "Star News" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INFO_SEO: Record<string, { title: string; description: string; type: string }> = {
|
||||||
|
"/ueber-uns": { title: "Über FOLX TV – Musik-News, Interviews und Fernsehen", description: "FOLX TV verbindet aktuelle Schlager- und Volksmusik-News mit Künstler-Interviews, Videos und eigenen TV-Produktionen. Mehr über unser Angebot.", type: "AboutPage" },
|
||||||
|
"/kontakt": { title: "Kontakt", description: "Kontakt zu FOLX TV: Fragen zu Artikeln, Musik-News, Interviews und unserem Programm. Schreiben Sie uns über das Kontaktformular.", type: "ContactPage" },
|
||||||
|
"/impressum": { title: "Impressum", description: "Anbieterangaben und rechtliche Informationen zu FOLX TV und dem Nachrichtenangebot auf folx.tv.", type: "WebPage" },
|
||||||
|
"/datenschutz": { title: "Datenschutz", description: "Informationen zum Datenschutz und zur Verarbeitung personenbezogener Daten auf folx.tv.", type: "WebPage" },
|
||||||
|
"/empfang-folx-tv": { title: "FOLX TV empfangen – Livestream, App, Kabel und IPTV", description: "Informationen zum Empfang des FOLX TV Fernsehprogramms: Livestream, Apps sowie Kabel und IPTV in Deutschland, Österreich und der Schweiz.", type: "WebPage" }
|
||||||
|
};
|
||||||