Compare commits

..
205 Commits
Author SHA1 Message Date
PTCandClaude Opus 5.5 fd0b92ea52 Avtor clankov: FOLX Music Television (Sebastjan 13. 10.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:59:25 +02:00
PTCandClaude Opus 5.5 9f948173f7 Avtor clankov FOLX TV (organizacija); Sebastjan Artič samo v Impressumu (Sebastjan 13. 10.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:56:32 +02:00
PTCandClaude Opus 5.5 04a1376cdd Impressum SSR v veji INFO_SEO (prej ga je ta veja postregla pred SSR blokom)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:54:01 +02:00
PTCandClaude Opus 5.5 e375ef6c2c Urednik Sebastjan Artič: avtor clankov (Person, Chefredakteur) + Impressum (Redaktion, § 18 Abs. 2 MStV) tudi v SSR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:49:23 +02:00
PTCandClaude Opus 5.5 0f88bdf587 Canonical/og:url clanka vedno https://folx.tv (prej window.location.origin -> www/biba dvojniki v Googlu)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:19:38 +02:00
Codex cc306921d7 Present FOLX as a music news publisher and unify site SEO 2026-10-07 13:42:48 +02:00
Codex c5c3fe290e Restore short visible News heading while retaining SEO metadata 2026-10-07 13:31:55 +02:00
Codex 9a43fdf43c Clarify News category heading and SEO metadata 2026-10-07 13:27:10 +02:00
Claude Opus 5andClaude Opus 5 48f8e16cda SEO: keywords in articleSection sledita znacki kosa (folx-badge marker)
Stran je vsakemu clanku v <meta name=keywords> pisala Volksmusik, Schlager --
tudi neglasbenim (Becker, Geissens, Ana Ivanovic) -- in v JSON-LD dajala
articleSection iz kategorije, ki je pri vseh nasih clankih News.

Zdaj SSR prebere marker <!--folx-badge:STAR NEWS--> iz vsebine clanka (isti
vzorec kot <!--folx-updated:...-->, ker tabela articles nima stolpca za to):
  MUSIC NEWS (ali brez markerja) -> Volksmusik, Schlager, Musik News / Musik News
  karkoli drugega               -> Star News, Promi News, Promi / Star News

Brez markerja ostane vedenje nespremenjeno, da starejsi clanki ne nazadujejo.
Besedilne zaznave namenoma ni: preizkus na 30 clankih jo je zgresil pri
ZDF-Fernsehgartnu in pri Wayne Carpendaleju.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcwnGD7Je9Z4sKiHDJhEwP
2026-09-15 12:22:24 +02:00
folx bot ea3cd47fe6 Empfang: Fire TV velja tudi za waipu.tv 4K Stick in druge Fire TV naprave 2026-09-13 09:27:08 +02:00
folx bot 631a52d56b Fire TV: uradna znacka Amazon Appstore (de) + povezava na amazon.de/dp/B0H62MFNDQ v nogi in na Empfang 2026-09-13 09:24:42 +02:00
folx bot 35138cec66 Empfang: tri poti (Livestream folx.live, App z navodili po napravah incl. Samsung 2022/2023, Kabel & IPTV s povezavami in navodili); blok Online z www.folx.tv odstranjen 2026-09-13 09:01:18 +02:00
folx bot 0fcd6812d3 noga: znacki 40 px 2026-09-13 08:46:56 +02:00
folx bot 5c0cd06b44 Empfang: znacki 48 px na telefonu, 56 px na namizju 2026-09-13 08:35:13 +02:00
folx bot 804418d7a9 meni: Empfang med Video in Kontakt 2026-09-13 08:25:20 +02:00
folx bot 66819b7e84 Empfang: znacki App Store in Google Play takoj pod naslovom 2026-09-13 08:22:14 +02:00
folx bot 6a56be0275 noga: Google Play znacka obrezana na vidni rob, ista visina kot App Store 2026-09-13 08:11:31 +02:00
folx bot d768fba43d noga: stolpec FOLX TV App z uradnima znackama App Store (SVG, de-de) in Google Play (PNG, de) 2026-09-13 08:08:18 +02:00
folx bot cb1070f75a SSR meni: News, Video, Empfang, Über uns, Kontakt kot prave povezave v HTML brez JavaScripta (React ga ob zagonu odstrani) 2026-09-13 07:37:43 +02:00
ClaudeandClaude Fable 5.1 5d536fdc89 schema: izdajatelj spet samo Folx Music Television z logom (kot do 26. 8. 2026)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViFFkiQmVM2yhrd6pU1UBQ
2026-09-04 07:41:51 +02:00
ClaudeandClaude Fable 5.1 bc275e272f oglasi in CMP nazaj na stanje pred 28. 8. 2026 (Discover padec)
- index.html: Googlovo CMP sporocilo spet zaduseno (controlledMessagingFunction + skrivanje fc-*), kot do 28. 8.
- adsense.tsx in mobile-sticky-ad.tsx: stara verzija (stare enote 4154017639, 7672178246, 4593001335, 3854634730, 2398082838; in-article okvir se izrise sele ob polnem oglasu; sticky 320x50)
- clanek: vrhnji pas nad naslovnico, brez display oglasa pred besedilom, najvec dva in-article oglasa
- naslovnica: postavitev ostane, GridBannerAd na stari enoti 4154017639

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViFFkiQmVM2yhrd6pU1UBQ
2026-09-04 07:10:37 +02:00
folx bot a31797a807 Aktuelles: preusmeritve, breaking-news in noga kazejo na folx.news namesto na news.folx.tv 2026-09-02 10:47:43 +02:00
sebastjan 69d9d87778 CLS: prostor za oglase rezerviran vnaprej, okvir se ne dodaja po nalozitvi (izmerjeno 0,10 na clanku in 0,15 na naslovnici) 2026-09-01 10:16:35 +02:00
sebastjan f710a7b6d3 vse za spletno stran je PTC (izdajatelj, pravice do slik in besedil, odgovornost za povezave); BoldFrame ostane samo kot Medieninhaber za televizijo 2026-09-01 05:29:53 +02:00
sebastjan 2bc310b817 izdajatelj spletne strani je PRIME TIME Consulting GmbH, Hechingen (Impressum in schema); Medieninhaber za TV ostaja BoldFrame 2026-09-01 05:26:36 +02:00
sebastjan d8516424ea naslovnica: strezniski seznam zadnjih 40 clankov v HTML, da robot dobi povezave brez izrisa JavaScripta 2026-08-31 19:01:10 +02:00
sebastjan ccc30dff96 naslovnica se konca pri gumbu Alle Nachrichten, za njim samo multiplex; podvojena bloka pod gumbom odstranjena 2026-08-31 18:53:42 +02:00
sebastjan b3d5fe05e4 zacasno umaknjen prevelik oglas iz mreze na naslovnici in v rubrikah, dokler enota v AdSense ni Fest 300x250 2026-08-31 18:39:27 +02:00
sebastjan b10b7f7b19 popravek: JSX komentar znotraj pogojnega izraza je podrl gradnjo, zato zadnje tri objave niso prisle na stran 2026-08-31 18:25:08 +02:00
sebastjan 8e3d647c1c enota FOLX 2026 Card 4126524750 (300x250) v mrezi namesto Grid 300x600 2026-08-31 18:09:38 +02:00
sebastjan 2f9071d604 kartica 300 px in oglas 300x250 v eni vrstici: brez dvojne visine na naslovnici in v rubrikah, kartica brez izvlecka 2026-08-31 18:08:45 +02:00
sebastjan fe2804cc4d pripomocka ne prerastejo vrstice 399 px, prazna oglasna celica v rubriki se skrije, vrhnji pas omejen na 728 px 2026-08-31 18:05:29 +02:00
sebastjan a395b18013 siroke kartice in pas ne prevzamejo trdne visine vrstice 399 px, ki je pod njihovim besedilom pustila prazen pas 2026-08-31 17:57:50 +02:00
sebastjan aeb4c69916 pripomocka v spodnjem bloku dobita trdno visino 600 px, vrstice v njiju pa enak prostor, da stran ob vrtenju ne poskakuje 2026-08-31 17:55:06 +02:00
sebastjan cc65cc16e6 trdna visina vrstic v mrezi 399 px, pripomocki in izvlecki omejeni, da stran ne poskakuje 2026-08-31 17:53:04 +02:00
sebastjan af1ab5762c pripomocka Musiknachrichten in Aktuelle Nachrichten kazeta sedem vrstic namesto petih, da kartice ne ostanejo napol prazne 2026-08-31 17:51:24 +02:00
sebastjan 360c433a05 Zuletzt hinzugefuegt: stirinajst naslovov namesto osmih, da stolpec ni napol prazen 2026-08-31 17:50:11 +02:00
sebastjan 85776692ae spodnji blok: seznama se ne raztegneta na visino oglasa 300x600, med novicami ni vec lukenj 2026-08-31 17:46:29 +02:00
sebastjan 98e0c05af2 strani rubrik: oglas cez dve vrstici in vrstice po 292 px, da kartic ne razteguje 2026-08-31 17:45:15 +02:00
sebastjan 1bd20efbac nova enota FOLX 2026 Sidebar 9953566507 (fiksno 300x600) namesto zadnje stare 2398082838 2026-08-31 17:40:58 +02:00
sebastjan 7a57fcfbfd kartice ob oglasu: izvlecek omejen na dve vrstici, da ga ne odreze sredi stavka 2026-08-31 17:39:57 +02:00
sebastjan 3ca2a02618 nova enota FOLX 2026 Multiplex 8995602853 namesto 4593001335 2026-08-31 17:37:14 +02:00
sebastjan 49209f0a28 nova enota FOLX 2026 In-Article 6313599193 namesto 7672178246 2026-08-31 17:36:47 +02:00
sebastjan 08dad04317 nova enota FOLX 2026 Skyscraper 4349780836 v stranska pasova namesto 2398082838 2026-08-31 17:35:23 +02:00
sebastjan 2c0db039eb nova enota FOLX 2026 Leaderboard 2757497569 na vseh vodoravnih mestih namesto 1023820241 in 4154017639 2026-08-31 17:34:45 +02:00
sebastjan b0ba87fb6f nova enota FOLX 2026 Grid 8009824249 (fiksno 300x600), kartici ob njej po 292 px, da se visini ujameta 2026-08-31 17:33:50 +02:00
sebastjan 66bc6dd9dc ob visokem oglasu gresta dve vrsti novic: oglas cez dve vrstici, ob njem sest kartic 2026-08-31 17:25:38 +02:00
sebastjan 5bc16ae27f popravek: vecji gap je pasova potisnil se bolj navzven, pravilno je manjsi 2026-08-31 17:18:53 +02:00
sebastjan 8325ec4784 stranska pasova pomaknjena k vsebini: enotna sirina 160 px in razmik 24 px namesto lepljenja na rob zaslona 2026-08-31 17:18:33 +02:00
sebastjan 22cdb79c0e oglas v mrezi enak kartici: okvir prevzame visino vrstice, fiksna enota 300x400 centrirana v njem 2026-08-31 17:09:37 +02:00
sebastjan c847f526d3 stranska pasova na trdno mero 160x600: responsive vertical si je visino racunal sam in se raztegoval 2026-08-31 17:08:05 +02:00
sebastjan 56ff5712b4 oglas pod naslovom clanka zozan na 728 px: leaderboard 90 px namesto nativnega bloka visokega 250 px 2026-08-31 17:07:10 +02:00
sebastjan 16f8a257c6 oglasni pas zozan na 728 px (leaderboard 90 px namesto 248-280) in redkejsi, po vsaki tretji vrstici 2026-08-31 16:59:43 +02:00
sebastjan 035b56a644 oglas iz celice v mrezi v vodoraven pas med vrstice: visok oglas je vrstico raztegnil na 546 px in pod izvlecki je ostalo prazno 2026-08-31 16:53:51 +02:00
sebastjan c507289753 fiksna enota ohrani svojo visino: prilagajanje kreativi je 300x400 raztegnilo na 300x600 2026-08-31 16:43:25 +02:00
sebastjan 71bfa3ad9b oglas v mrezi na novo fiksno Display enoto 9916590834 (300x400), da je enako visok kot kartica 2026-08-31 16:36:37 +02:00
sebastjan 1a35d9a747 video kartica dobi opis iz teaserja in vrstico Video ansehen, da ni prazna spodaj 2026-08-31 16:25:30 +02:00
sebastjan c17c9b762e oglasna kartica brez vzorca pod oglasom, prazna oglasna celica se skrije, spodnji blok tristolpcen namesto stiristolpcnega 2026-08-31 16:22:42 +02:00
sebastjan 2a043e3ad6 siroka postavitev z dvema novicama se ponovi po vsaki tretji vrstici, ne le na koncu mreze 2026-08-31 13:48:52 +02:00
sebastjan 39426459df oglas v mrezi nazaj na fluid in poravnan na vrh celice: Googlova skripta inline visino prepise, zato je 400 px zraslo na 600 2026-08-31 13:45:46 +02:00
sebastjan 739781d521 oglas v mrezi dobi trdno visino 400 px, kolikor meri kartica; z minHeight ga je Google raztegnil na 600 2026-08-31 13:36:48 +02:00
sebastjan cc9afe9e28 video kartice v mrezi, oglas le v vsako drugo vrstico in v visini kartice, vremenski pripomocek z naslovnice (prosil je za lokacijo) 2026-08-31 13:31:11 +02:00
sebastjan 7a25bf73cf mreza brez praznih celic (samo polne vrstice po stiri) in relativni cas namesto ISO znacke v bloku Aktuelle Nachrichten 2026-08-31 12:48:15 +02:00
sebastjan cdf45b9933 naslovnica omejena na 48 clankov in gumb Alle Nachrichten v rubriko; drugi paket ne vlece vec vseh 273 clankov (142 kB) 2026-08-31 12:37:03 +02:00
sebastjan 6855f2a911 blok Aktuelle Nachrichten: rubrike se izmenjujejo v krogu, da ne pride trikrat zapored Sport 2026-08-31 12:14:10 +02:00
sebastjan ccc0b6b3c5 blok Aktuelle Nachrichten bere news.folx.tv namesto tagesschau, n-tv, ORF in Welt: promet ostane pri nas 2026-08-31 11:58:53 +02:00
sebastjan 91e84e05fe zunanji pripomocki (Musiknachrichten, Aktuelle Nachrichten) razmaknjeni: en pripomocek na pet clankov, drugi primerek se zacne z zamikom, da ne ponavlja istih vrstic 2026-08-30 16:36:16 +02:00
sebastjan 7098b54cf3 galerie: 301 naravnost na naslovnico, brez vmesnega skoka na /gallery 2026-08-30 16:33:33 +02:00
sebastjan 13ef1cdbbc se zadnji dve rabi galerije in recepta na naslovnici zamenjani z novicami 2026-08-30 16:30:12 +02:00
sebastjan c697623e5d horoskop, recepti in galerija ven s folx.tv: pripomocki z naslovnice, povezave iz glave in noge, poti 301 na naslovnico, iz sitemapa (GA4: 318 ogledov od 96.307 v 28 dneh) 2026-08-30 16:29:45 +02:00
sebastjan 6a0ef7f2cf oglasi: v prvi vrstici mreze oglas ni prva celica (na mobilnem sta bila pasica in kartica oglasa zapored pred prvim clankom) 2026-08-30 13:52:25 +02:00
sebastjan 7f7c5e05d1 blok Beliebt: po ogledih zadnjih 7 dni namesto skupnega stevca, da se dejansko menja (parameter ?days=) 2026-08-30 09:43:29 +02:00
OpenClaw Agent ed47a05bff focal-point: trajni predpomnilnik zarisov na /uploads, da deploy ne analizira vseh slik znova 2026-08-30 09:33:27 +02:00
sebastjan 8e29ad8e41 folx.tv: stari slugi izbrisanih rubrik se 301 preusmerijo na news.folx.tv (seznam slugov se bere z news API, predpomnjen 10 min) 2026-08-30 09:13:40 +02:00
folx bot fc7e9c38a0 Naslovnica: zaris obraza se zdaj ujame tudi pri absolutnih URL-jih (kvadratne ploscice ne rezejo glav) 2026-08-29 21:36:51 +02:00
folx bot fd9121e8db Clanek: napis pod sliko stoji tik pod naslovnico, ne za prvim oglasom 2026-08-29 21:32:01 +02:00
folx bot b9b2a6c1f2 Clanek: Weitere Artikel brez istega izvajalca (tudi ne istega kot clanek) 2026-08-29 21:27:58 +02:00
folx bot 85e7a1e7c7 Naslovnica: razmik po skupini izvajalca (Rier = Kastelruther Spatzen), Beliebt brez podvojitev iz bazena 2026-08-29 20:34:27 +02:00
folx bot 12694d6db8 Naslovnica: razmik po izvajalcu tudi v bloku Beliebt in v featured karuselu 2026-08-29 20:19:58 +02:00
FOLX Claude 095636137e Zuletzt hinzugefuegt: razmik po izvajalcu tudi znotraj seznama 2026-08-29 19:46:42 +02:00
FOLX Claude aacf46d523 Neueste Artikel in Top Stories: isti izvajalec vsaj 5 mest narazen 2026-08-29 19:43:47 +02:00
FOLX Claude 9c0e088f08 widgeta z novicami: isti izvajalec vsaj 5 mest narazen 2026-08-29 19:40:16 +02:00
FOLX Claude ef5df9cf77 naslovnica: isti izvajalec vsaj 5 mest narazen namesto premikanja clankov v drugo rubriko 2026-08-29 19:36:24 +02:00
FOLX Claude 600e3cb13d rezepte in videos: lasten canonical namesto naslovnice, lasten naslov in opis 2026-08-29 17:28:10 +02:00
FOLX Claude 61fe48fb8b Aktuelles ven iz menija; v nogi povezava FOLX NEWS na news.folx.tv 2026-08-29 16:38:11 +02:00
FOLX Claude 77b40ed906 preusmeritev rubrik na news.folx.tv mora biti pred obravnavo /category, sicer se ne sprozi 2026-08-29 16:28:59 +02:00
FOLX Claude 98b57fb6c8 Aktuelles preseljen na news.folx.tv: 301 s /article/<slug>, /category/<rubrika> in /aktuelles 2026-08-29 16:19:02 +02:00
Claude a39322b729 naslovnica: naslovna slika v karuselu ne more vec postati pokoncna
Vzrok: slika v FeaturedHeroCard ima lg:flex-1 lg:aspect-auto, torej pozre ves
visek visine vrstice. Visina vrstice je najvisji stolpec, in ko je bil ta
visok, je 16:9 naslovnica v object-cover postala pokoncna in mocno prirezana -
obraz cez cel okvir.

Dvoje:
- karusel dobi lg:max-h-[560px] poleg obstojecega lg:min-h-[460px], tako da je
  visina slike omejena med priblizno 16:10 in 16:13 in nikoli pokoncna;
- stranski stolpec je zdaj lg:flex lg:flex-col, seznam Zuletzt hinzugefuegt pa
  lg:flex-1 z overflow-hidden in osmimi namesto petimi novicami, tako da visek
  visine poziera on in ne slika.
2026-08-28 11:45:17 +00:00
Claude e66f604f25 CMP: odstranjena koda, ki je dusila Googlovo sporocilo o privolitvi
V AdSense je pod Privacy & messaging / European regulations sporocilo ze
objavljeno in folx.tv je med izbranimi stranmi, prikazati pa se ni moglo,
ker je client/index.html:

  window.googlefc.controlledMessagingFunction = message => message.proceed(false)

sporocilo zavracal, funkcija hideGoogleCMP() pa je s CSS-om in
MutationObserverjem sproti brisala vse elemente fc-* iz DOM-a.

Oboje odstranjeno. Lastna pasica cookie-consent.tsx je bila samo importana in
se sploh ni izrisovala (v App.tsx je ni bilo v drevesu), zato je stran do zdaj
tekla brez kakrsnekoli privolitve - komponenta in import sta izbrisana.

Posledica: promet iz EEA postane upravicen do personaliziranih oglasov namesto
samo do non-personalized in limited.
2026-08-28 11:33:24 +00:00
Claude 656be0cbff clanek: vsa mesta v besedilu spet enota 7672178246
Prejsnji commit je sredinsko mesto v besedilu preklopil na 4154017639. To je
bila napaka po podatkih: 7672178246 nese 98,93 EUR v sedmih dneh in je v
celoti folx.tv, medtem ko je 82,45 EUR pri 4154017639 skupna stevilka za
folx.tv IN folx.si (mu-vticnik tam uporablja isti slot), zato je prispevek
folx.tv neznan.

Zdaj: v besedilu vedno 7672178246 (prej dve mesti, zdaj dve do pet po
dolzini clanka), 4154017639 pa ima eno mesto pred besedilom namesto
prejsnjega pasu nad naslovnico, plus lepljivi pas. Nobena enota nima manj
mest kot prej.
2026-08-28 10:46:51 +00:00
Claude 039323ea7d oglasi: izklopljen full-width-responsive - vzrok odrezanih in kvadratnih oglasov
Izmerjeno s Chromium na zivem clanku (mobilni viewport 390 px):

  ins 390x390, iframe 390x390, .ad-container samo 356 px

Z data-full-width-responsive=true AdSense enoto raztegne na celo sirino
zaslona (390) mimo vsebnika (356 = stran ima px-4), zato je oglas gledal cez
rob in ga je .ad-container z overflow:hidden odrezal - od tod odrezan MEXC.
Hkrati je izbiral kvadrat 390x390 tudi za vodoravno sidro in za enoto v
besedilu; kreativa je bila manjsa od kvadrata, ostanek pa prazen crn prostor.

Zdaj data-full-width-responsive=false na vseh enotah in brez width:100% v
inline stilu - enota se drzi sirine svojega vsebnika, Google pa visino izbere
po tej sirini. Poravnava visine na kreativo iz prejsnjega commita ostane.
2026-08-28 09:53:52 +00:00
Claude 43eff55ba3 oglasi: brez praznega prostora okrog kreative
AdSense odzivni enoti vnaprej doloci visino po sirini zaslona. Ce je
postrezena kreativa nizja, ostane pod njo prazen rezerviran pas znotraj
okvirja - to je bilo vidno na clanku pod DonCarlos oglasom.

- AdSense komponenta po zapolnitvi poravna visino <ins> na dejansko visino
  oglasnega okvirja (ob 0/300/1000/2500 ms, ob vsaki spremembi DOM enote in
  ob resize). Nic se ne odreze, visina je natanko taksna kot oglas.
  MutationObserver zdaj tece tudi pri enotah brez onAdStatus.
- InArticleAd: odstranjen minHeight 260 px, ki je rezerviral prostor tudi ce
  je bila kreativa nizja.
- MultiplexAd: odstranjen min-h-[250px] iz istega razloga.
2026-08-28 09:47:04 +00:00
Claude 907e63d3d2 oglasi: pas zapolni sirino namesto 320x50 sredi praznega okvirja
Naslovnica je imela lastno kopijo zgornjega pasu s trdo visino 90 px in
responsive=false. Google je vanjo postavil 320x50 in okrog je ostal siv rob -
tako je izgledalo na telefonu. Zdaj uporablja skupno komponento TopBannerAd,
ki je format=auto z full-width-responsive: kreativa dobi celo sirino, prazna
enota se ne izrise. Isto velja za vse ostale strani, ki TopBannerAd ze
uporabljajo.

Lepljivemu pasu odstranjen overflow-hidden in maxHeight - oglas mora biti cel
viden, vodoravni format itak ne da vec kot 320x100.
2026-08-28 09:42:14 +00:00
Claude 5b519019d2 oglasi: nic manj prikazov, vec mest za enote ki nesejo
Po podatkih AdSense (zadnjih 7 dni) prihodek nesejo folx.tv in-article
(98,93 EUR), Folx News In-Article = slot 4154017639 (82,45 EUR) in Multiplex
(63,59 EUR). Prejsnji commit je pri dveh od teh zmanjsal stevilo mest.

- odlozena zahteva (IntersectionObserver) NI vec privzeta: eager=true.
  Odlozeno nalaganje dvigne vidnost, a znizza stevilo prikazov pri enotah
  globlje na strani - in prav te nesejo denar. Pot ostaja v kodi za kasneje,
  ko bomo ucinek lahko izmerili. Popravek 3-sekundnega odstevanja ostane.
- clanek: enota 4154017639 vrnjena na stran, a pod naslovom in tik pred
  besedilom namesto kot pas nad naslovnico - isti prikazi, boljse mesto,
  naslov ne pade pod pregib.
- PageSideAds: oba stolpca spet tu. Sirina se prilagodi zaslonu prek
  --rail-w: 160x600 od 1536 px, 300x600 od 1700 px. Dodan onAdStatus, da
  prazna enota ne pusti luknje.
- naslovnica dobi Multiplex, edina strana ga ni imela.

Skupaj na clanku (telefon): display pred besedilom, in-article za uvodom,
display na sredini (in vec pri dolgih), multiplex, lepljivi pas.
2026-08-28 09:32:45 +00:00
Claude db9eac20e2 oglasi: manj vsiljivo in bolje placano
- vse enote se zahtevajo sele ~200 px pred vstopom v vidno polje
  (IntersectionObserver); prej so se vse pushale ob nalaganju strani, tudi
  tiste globoko spodaj, ki jih uporabnik ni nikoli videl. Odlocitev o praznosti
  (3 s) tece sele od zahteve, ne od mounta.
- clanek: zgornji pas odstranjen (potiskal je naslovnico in naslov pod pregib,
  placan pa je najslabse). Prvi oglas pride sele v besedilu.
- clanek: stevilo oglasov vezano na dolzino — 2 osnovna + 1 na vsakih 5 blokov
  cez 10, strop 5. Mediana (9 blokov) ostane pri 2, dolgi clanki dobijo do 5.
  Prvi je fluid in-article, vsi nadaljnji nov InContentDisplayAd (odzivni
  display, visja kreativa).
- TopBannerAd na seznamih: odzivni namesto trdih 90 px.
- mobilni lepljivi pas: odzivni vodoravni (do 110 px) namesto trdih 320x50,
  z onAdStatus — prazen se ne izrise.
- PageSideAds: en desni 300x600 sticky z onAdStatus namesto dveh 160x600 brez
  zaznave (prazna je pustila luknjo); prikaze se od 1700 px navzgor.
- interstitial odstranjen s horoskopa (prekrivalo cez zaslon, zaklenilo scroll);
  interstitial-ad.tsx in neuporabljen parallax-ad.tsx izbrisana.

CMP ostaja nedotaknjen po dogovoru.
2026-08-28 09:25:49 +00:00
folx bot 10c24d3e8c Video stran: gumb na YouTube in povezava na clanek sta zdaj enakovredna gumba v eni vrsti (prej se je majhna rdeca povezava lepila ob gumb); besedilo Ganzen Artikel lesen namesto Ganzer Artikel auf folx.tv 2026-08-27 10:33:39 +02:00
folx bot a218e76f9d Aktuelles: robots preko setRobots, da ne ostane dvojni meta robots 2026-08-27 07:40:05 +02:00
folx bot 3d1918c2cc Aktuelles gre cel iz indeksa: /aktuelles dobi noindex, follow in lasten canonical (prej canonical na naslovnico), kategorijske strani rubrik iz Aktuelles noindex, /aktuelles ven iz sitemapa. Clanki teh rubrik so bili noindex ze od df15293. 2026-08-27 07:34:16 +02:00
folx bot debbf5ff1f Zaris naslovnic iz zaznave obrazov (OpenCV) namesto LLM ugibanja — glave se ne rezejo vec 2026-08-27 04:58:48 +02:00
folx bot b9f5b82eb9 SEO: izdajatelj BoldFrame Productions d.o.o. (uskladitev z Impressumom) 2026-08-26 12:16:46 +02:00
folx bot 91be6d716a naslovnica: express.static je sam postregel index.html, zato SSR handler ni prisel do besede (index: false) 2026-08-26 10:21:01 +02:00
folx bot 47273fc1eb seo: izdajatelj z registrirano druzbo in strukturirani podatki na naslovnici
Publisher je bil povsod samo ime in logo, naslovnica pa sploh brez JSON-LD.
Zdaj gre v NewsArticle, VideoObject in oba clanska grafa ista Organization z
legalName PRIME TIME Consulting GmbH, naslovom, kontaktom in sameAs profili,
naslovnica pa dobi graf Organization + WebSite s SearchAction.
2026-08-26 10:15:05 +02:00
folx bot 9a92530097 clanki: Andreas Martin vrnjen (bil je ze v Search Console), preusmeritev odstranjena 2026-08-25 21:50:18 +02:00
folx bot 2e57f27270 clanki: 301 preusmeritvi za dva izbrisana dvojnika (Andreas Martin, Isi Glueck)
Bot je isto zgodbo objavil dvakrat; novejsi kopiji sta izbrisani, njuna URL-ja
se preusmerita na prvi clanek, da ne dobimo 404 na ze deljenih povezavah.
2026-08-25 21:30:50 +02:00
folx bot ade6c86857 video: samodejna poglavja za intervjuje + Clip/SeekToAction oznacba
Poglavja se zdaj generirajo iz YouTube podnapisov (chapters.py, dnevno ob
9:20) in gredo tudi v opis videa na YouTubu. Na strani so poglavja klikljiva
(?t=<sekunde>, predvajalnik se odpre na tem mestu), v VideoObject pa gresta
hasPart/Clip in SeekToAction, iz katerih Google naredi Wichtige Momente.
2026-08-25 15:25:28 +02:00
folx bot dc8a531d6b naslovnica: prazen pas pod karuselom — levi stolpec se zdaj raztegne do visine stranskega stolpca, visino dobi vrstica mreze (grid-rows-1), ne vsebnik 2026-08-25 12:57:07 +02:00
folx bot 4576b04f4c clanki: manjkal je zgornji oglasni pas, ki ga imajo vse ostale strani 2026-08-25 12:54:03 +02:00
folx bot 2d321b4e57 zunanje povezave se odpirajo v novem zavihku (delegiran klik pokrije tudi povezave v HTML clankov) 2026-08-25 11:53:28 +02:00
folx bot 0b60da3593 video strani: iz izpisa odstranjeni hashtagi in stalni promo odstavek (bil je enak na vseh straneh), link do clanka je zdaj prava povezava in ne golo besedilo; VideoObject description uporablja ociscen opis 2026-08-25 11:37:38 +02:00
folx bot bc915249fe Ueber FOLX TV: Gipfelstammtisch in Geschichte des Liedes ven, ostane samo FOLX Stadl; poudarek na izvajalcih in poslanstvu (gledati, poslusati, brati); nov blok Wo Sie uns finden z livestreamom, FOLX Plus (YouTube 24/7), YouTube kanalom, Facebookom in Instagramom 2026-08-24 22:23:37 +02:00
folx bot cee85ef210 SEO: /category/<ime> dobi svoj naslov, opis, canonical, CollectionPage in seznam clankov v HTML (prej HTML naslovnice s canonical / — soft 404); clanki s targetUrl (dnevni horoskop, recept) ne gredo vec v sitemap, ker so samo preusmeritve 2026-08-24 22:18:01 +02:00
folx bot ed8872226b video strani: cel opis in kazalo gredo v HTML brez JavaScripta, VideoObject description je zdaj daljsi opis in ne le teaser 2026-08-24 22:07:59 +02:00
folx bot b77fd4a28b video sekcija: YouTube predvajalnik na strani (Google zahteva predvajljiv video za indeksiranje, oglasi tecejo YouTubov), VideoObject kaze na cel video; /videos je zdaj mreza YouTube videov z rubrikami; video-sitemap.xml in vpis v robots; ingest brez napovednika (samo metapodatki in naslovnica) 2026-08-24 21:54:20 +02:00
folx bot ed063af866 naslovnica: visina se nastavi na vrstici mreze (auto-rows), ne na vsebniku — prej so kartice presegle 520px in prekrile pike karusela 2026-08-24 21:42:50 +02:00
folx bot 2de0fa2c9b naslovnica: pike karusela so bile 8px in skoraj nevidne na temnem ozadju — vecje, svetlejse, aktivna sirsa (velja tudi za mobilni mozaik) 2026-08-24 21:35:43 +02:00
folx bot d325901182 naslovnica: vrstica z glavno novico dobi fiksno visino 520px na namizju — prej jo je dolocal desni stolpec in glavna slika je zrasla v portret; na mobilnem ostaja razmerje 16:10 2026-08-24 19:12:14 +02:00
folx bot 27e29b9c9a naslovnica: glavna kartica se ne razteguje vec pod besedilom — visek visine gre v sliko (flex-1), besedilni del je fiksne visine, kot je ze pri stranskih karticah 2026-08-24 18:56:53 +02:00
folx bot 0e438b1d3c naslovnica: /api/articles dobi limit, offset in light=1 (brez content in targetUrl); home naloži prvih 30 lahkih zapisov, ostalo dolije v ozadju; determinističen vrstni red namesto Math.random, da se mreža ob dolivanju ne premeša 2026-08-24 17:12:15 +02:00
claude 1e9f854f26 og:image: prave dimenzije naslovnice namesto fiksnih 1200x630 (slike so 1200x900); nadomestni logotip zdaj 1200x675, prej 1050x630 pod Googlovim minimumom; opozorilo v log, ko clanek nima naslovnice 2026-08-23 10:34:31 +02:00
folx bot c8b7def69c video sekcija: strani /video/<slug> z 20s napovednikom (predvajljiv na strani, VideoObject) in gumbom na YouTube; API /api/yt-videos; ingest skripta za FOLX YouTube kanal 2026-08-23 10:08:17 +02:00
claude 3c9da492a2 clanki: 301 preusmeritev za stare sluge preimenovanih clankov (alexander-rier-verraet-... -> alexander-rier-von-mann-zu-mann-...); star link je vracal trdi 404, kratki link jh5ml7 je imel 4573 klikov v prazno 2026-08-22 17:43:39 +02:00
folx bot 79e466dc98 empfang: blok FOLX TV empfangen na prvo mesto (livestream folx.live, App-Spot, App brez abo, Zattoo/MagentaTV/O2) 2026-08-22 17:35:44 +02:00
claude 808e9a5438 404 in sitemap: sitemap je kazal na /news /video /galerie, ki jih router ne pozna (soft 404); 301 preusmeritve za stare poti (/empfang, /news, /video, /galerie, /about ...); neznana pot vrne pravi HTTP 404 namesto 200 2026-08-20 13:07:56 +02:00
claude ca47bc8698 naslovnice: fokusna tocka obraza povsod (aktuelles, kategorije, videi, iskanje, clanek + sorodni), skupni pomocnik lib/focal, streznik osvezuje fokusne tocke vsakih 20 minut 2026-08-20 07:15:07 +02:00
claude 2ec7c259c3 naslovnice kartic: object-position center 25% na vseh straneh (aktuelles, videos, iskanje, domaca), da glave niso odrezane 2026-08-19 22:27:33 +02:00
FOLX Bot df15293ea3 Rubrike z onemusic.news dobijo noindex in gredo iz sitemapov; v API in na TV sidebar ostanejo 2026-08-17 20:14:31 +02:00
FOLX Bot 160994f531 Neobstojec clanek vrne pravi 404 z noindex namesto naslovnice s canonical na / 2026-08-17 19:37:06 +02:00
FOLX Bot 11e61e5405 Clanek: manj oglasov - brez vmesnega cez zaslon, brez parallaxa, brez pasu nad sliko, v besedilu najvec dva 2026-08-17 09:02:08 +02:00
FOLX Bot 70f16e21c7 Odlocitev o praznem oglasu po 3 s namesto 6 s 2026-08-16 21:50:05 +02:00
FOLX Bot 9a75e88932 Zaznava praznega oglasa: steje dejanski okvir oglasa, ne rezervirana visina 2026-08-16 21:41:58 +02:00
FOLX Bot 9d20f0da8e Oglasi: prazne enote se ne izrisejo - brez okvirja, napisa Anzeige in rezerviranega prostora, kadar oglasa ni 2026-08-16 21:36:58 +02:00
FOLX Bot 0b7cf9e5f5 API sprejme publishedAt pri uvozu, da uvozeni clanki ne nosijo danasnjega datuma 2026-08-16 21:04:12 +02:00
FOLX Bot 78ef1848dc Aktuelles: vrstici rubrik kot na ONE (brez Alle, brez horoskopa in Auf diesen Tag), dodani Wetter in Finanzen 2026-08-16 20:46:57 +02:00
FOLX Bot 32f7738ee8 Vrstica rubrik na /aktuelles se prelomi v mrezo namesto vodoravnega pomikanja - vse teme vidne in klikljive 2026-08-16 19:41:26 +02:00
FOLX Bot 056e1d08d6 Aktuelles kot na ONE: vrstica rubrik Politik, Promis, Sport, Musik & Film (brez horoskopa), lastna stran in zdruzen API 2026-08-16 18:33:50 +02:00
FOLX Bot 164775f2b8 Revert "Mobilna navigacija: vodoravna vrstica rubrik namesto hamburger menija (kot na ONE)"
This reverts commit 23d780477a.
2026-08-16 18:31:20 +02:00
FOLX Bot 23d780477a Mobilna navigacija: vodoravna vrstica rubrik namesto hamburger menija (kot na ONE) 2026-08-16 17:45:37 +02:00
FOLX Bot a8a958e15c Sitemap: /aktuelles v dejansko uporabljeno pot (prejsnji vnos je pristal v podvojeni, neaktivni definiciji) 2026-08-16 15:37:11 +02:00
FOLX Bot 23a684325b Nova rubrika /aktuelles: splosne novice locene od naslovnice, povezava v glavi in nogi, v sitemapu 2026-08-16 15:33:14 +02:00
FOLX Bot 59279fa039 Naslovna kartica: slika omejena na 16:10, ker je s flex-1 posrkala vso visino vrstice in potisnila naslov in opis izven okvirja 2026-08-16 13:58:23 +02:00
FOLX Bot e10501af78 Zgornji baner centriran (prej poravnan levo, desno je ostal prazen okvir) 2026-08-16 13:46:49 +02:00
FOLX Bot 514955bc03 Oglasi: prava In-article enota (slot 7672178246) namesto display enote v besedilu 2026-08-16 11:22:59 +02:00
sebastjan 9f7a7a727c Najava RSS feeda v head (pogoj za Follow v Chromu in Discoverju) 2026-08-16 09:45:25 +02:00
sebastjan 003186ce1e Multiplex oglas tudi na dnu vsebinskih strani 2026-08-16 08:58:38 +02:00
sebastjan 561d15d8b3 Oglasi: vsebinske enote odzivne namesto fiksnih 90px, multiplex na koncu clanka 2026-08-16 08:55:21 +02:00
sebastjan 590e5438d3 Kartica s targetUrl preusmeri tudi pri kliku znotraj strani (SPA) 2026-08-15 18:04:42 +02:00
sebastjan 8bf90c6942 Rezepte: listanje po 12 in oglasi med karticami 2026-08-15 17:53:42 +02:00
sebastjan a039cf48da Rezepte: prava baza namesto trdo zapisanih receptov, arhiv in vrtenje v widgetu 2026-08-15 17:43:02 +02:00
sebastjan 3c429a3aa1 API: seznam dnevnih receptov 2026-08-15 17:28:45 +02:00
sebastjan b92c39d9f0 Dnevni recept: tabela daily_recipes, API /api/recipes, prikaz na /rezepte 2026-08-15 17:06:09 +02:00
sebastjan 001a05fe16 Oglasna pasica pod glavo na vseh vsebinskih straneh (TopBannerAd) 2026-08-15 16:58:32 +02:00
sebastjan b5732e872d Naslovnica: oglasna pasica pod glavo 2026-08-15 16:49:06 +02:00
sebastjan e6a951620e Horoskop: brez naslovnega bloka in praznega poziva, slikce pri drugih znamenjih, mreza 4 v vrsti z vecjimi karticami 2026-08-15 16:41:03 +02:00
sebastjan b6eb0c6d0e Mobilni mozaik: podrsanje levo/desno med trojkami, v krogu 2026-08-15 16:35:17 +02:00
sebastjan 10af4563a5 Mobilni mozaik: prvi blok se vrti po 3 novice (9 najnovejsih, 8 s) 2026-08-15 16:28:48 +02:00
sebastjan 6f4cfa8ee9 Naslovnica: mobilni mozaik (2 mali + 1 siroka, nato 1 velika + 2 mali), desktop nespremenjen 2026-08-15 16:09:07 +02:00
sebastjan 3c6079d720 Horoskop widget na naslovnici: slikce znamenj + povezava na znamenje 2026-08-15 09:28:44 +02:00
sebastjan 68fe291b55 Clanek s targetUrl se ob kliku preusmeri (302) 2026-08-15 08:30:37 +02:00
sebastjan f4acd541c5 Uvodni stavek na /horoskop brez kozmicnih dogodkov 2026-08-15 08:24:24 +02:00
sebastjan 7dcee28bf2 Odstranjeni kozmicni dogodki: stran, scheduler in API 2026-08-15 08:19:44 +02:00
sebastjan b61bfac5d3 Horoskopska stran: slike znamenj (image_url + /api/horoscopes/images) 2026-08-15 08:13:30 +02:00
sebastjan 63f27e7236 Horoskop: gpt-5-mini -> openai/gpt-5.4, limit 4000 tokenov 2026-08-15 08:01:52 +02:00
OpenClaw Agent 262636ef52 SEO: VideoObject JSON-LD + dateModified iz folx-updated markerja; fb:pages popravek (281200958640258) 2026-08-13 03:08:58 +00:00
Claude 69d400a209 Security: API kljuc za pisalne endpointe (articles, upload) 2026-08-12 09:59:48 +02:00
Claude fda5e2ce62 SSR: vsebina clanka v HTML za Google/Discover 2026-08-12 09:14:37 +02:00
Claude ef33ad68fa SEO: max-image-preview:large za Google Discover 2026-08-12 07:39:56 +02:00
Folx Ops d1765f2028 Odstrani stari podvojeni Folx Stadl 29 iz seeda 2026-07-17 17:38:37 +00:00
Folx Ops 2085ce55fe Fix: John Prisco podvojen clanek (seed slug + migracija) 2026-07-15 19:41:56 +00:00
sebastjan 66b74307ab ads.txt: add Gamezop AdSense entries + games.folx.tv subdomain (steps 4+5) 2026-07-10 09:38:23 +02:00
OpenClaw Agent b74a65bea5 GA4 analytics G-2FYMHBCNLQ 2026-07-01 13:58:10 +00:00
Folx Ops f51c21c716 og:image: Cloudinary g_faces 1200x630 (FB ne reze glav) 2026-06-27 08:51:28 +00:00
Folx Ops 0e5ab138cb Article cover: object-top da glave niso odrezane 2026-06-27 07:35:07 +00:00
Folx Ops 5fa365726b Add cover image for Folx Stadl 28 2026-06-27 08:41:12 +02:00
Folx Ops 4a01c0bf1a Add cover image for Folx Stadl 27 2026-06-20 09:30:54 +00:00
Folx Ops bcb130683b Add Google Search Console verification meta tag 2026-06-14 07:16:51 +00:00
Folx Ops a20c72e65e SEO: JSON-LD NewsArticle + sitemap.xml + news sitemap + robots.txt + RSS feed 2026-06-14 06:43:33 +00:00
Folx Ops 87cfbf743c Add cover image for Folx Stadl Sendung 26 2026-06-13 10:56:07 +00:00
Folx Ops dac7e60a4d Fix: seed ne sme sesuti serverja ob duplicate slug (try/catch + guard) 2026-06-13 10:35:41 +00:00
Folx Ops 9de740168a Kontakt: dodaj fiksni telefon, mobil in faks 2026-06-13 10:29:18 +00:00
Folx Ops 3fdf54ff55 seed: odstrani brisanje ne-seed clankov (prezivijo deploy) 2026-06-08 04:10:21 +00:00
sebastjan 0dd5092e1b Dockerfile: copy server/*.json (gallery-data, focal-points, etc) into runtime container 2026-06-07 21:44:49 +02:00
sebastjan bbf0420e7d Revert "photo-gallery: zmanjšaj min-h na 220px (bolj kompaktna galerija)"
This reverts commit c874bbc75b.
2026-06-07 21:15:00 +02:00
sebastjan c874bbc75b photo-gallery: zmanjšaj min-h na 220px (bolj kompaktna galerija) 2026-06-07 21:13:02 +02:00
Folx Ops fc56ef06f6 4 novi clanki iz RSS: Beatrice Egli, Die Pharos, Forsberg/Linda Jung, Stefan Micha 2026-06-07 16:52:04 +00:00
Folx Ops a5e48e6613 Clanek + cover: Lyane Hegemann - Nicht dein Maerchen (id 142) 2026-06-07 16:30:18 +00:00
Folx Ops b03d998b2a Cover Teddy Herz: cela naslovnica vidna, glava ni odrezana 2026-06-07 16:19:45 +00:00
Folx Ops 45ca3f20fc Cover Teddy Herz: 16:9 brez rezanja glave (blur podlaga) 2026-06-07 16:16:47 +00:00
Folx Ops 8107419131 Cover: Teddy Herz - Der Schlagerkapitaen (clanek id 141) 2026-06-07 16:11:58 +00:00
Folx Ops 0a2f477654 Kozmicni dogodki: generiraj posamicno (6 klicev) + boljse parsanje
Array klic je padel (Failed to parse) ker gpt-5-mini ni vrnil cistega JSON
arraya. Zdaj 6 locenih klicev z {...} objektom (isti vzorec kot horoskop),
markdown fence stripping in vecji token limit.
2026-06-07 15:16:21 +00:00
Folx Ops 365da96f5b Horoskop in kozmicni dogodki: dnevno AI generiranje + scheduler
- Nov dnevni scheduler (server/scheduler.ts): vsak dan ob zagonu in vsakih 6h
  preveri/generira horoskope in kozmicne dogodke (prej samo enkrat ob zagonu)
- Kozmicni dogodki so zdaj AI-generirani in dnevni (nova tabela cosmic_events
  + /api/cosmic-events), namesto hardcoded fiksnih datumov iz feb/mar 2026
- Naslovni horoskop widget bere pravi AI horoskop za danes (prej staticni tekst)
- Frontend: staleTime 30min + refetchOnWindowFocus za dnevno osvezevanje
2026-06-07 15:05:20 +00:00
sebastjan 6c34d44cea article.tsx: transform Bunny iframe → folxvideos HLS player when S3 available 2026-06-07 16:27:15 +02:00
sebastjan c8b0989d4c Dockerfile: copy client/public and attached_assets to runtime 2026-06-07 16:16:43 +02:00
sebastjan ede172d2f8 Allow attached_assets and PNG in Docker build (Vite needs them) 2026-06-07 15:59:49 +02:00
sebastjan 245aa55a4e Add Dockerfile, remove DB dump for security 2026-06-07 15:55:34 +02:00
Sebastjan Artic dfbd8acba3 DB dump for migration 2026-06-07 13:53:45 +00:00
sebastjanartic 83815503e1 Published your App
Replit-Commit-Author: Deployment
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 172c7eca-ac09-4ef6-a53c-53534998a249
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/rgXHSxm
Replit-Commit-Deployment-Build-Id: 52c02c2a-5350-4e4d-bf83-174890404b08
Replit-Helium-Checkpoint-Created: true
2026-04-02 20:27:33 +00:00
sebastjanartic 47d9e25f1c Published your App
Replit-Commit-Author: Deployment
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: a24aedc4-9237-444b-aefb-f5411efd4326
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/rgXHSxm
Replit-Commit-Deployment-Build-Id: 52c02c2a-5350-4e4d-bf83-174890404b08
Replit-Helium-Checkpoint-Created: true
2026-04-02 20:11:59 +00:00
sebastjanartic 27f0b92deb Published your App
Replit-Commit-Author: Deployment
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 953a6ea1-583b-466e-b6e6-1437ecedeba1
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/rgXHSxm
Replit-Commit-Deployment-Build-Id: 52c02c2a-5350-4e4d-bf83-174890404b08
Replit-Helium-Checkpoint-Created: true
2026-04-02 19:59:59 +00:00
sebastjanartic 3bb6cf8f15 Published your App
Replit-Commit-Author: Deployment
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: a24641d9-5f07-492b-a28e-6bffd21320c9
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/rgXHSxm
Replit-Commit-Deployment-Build-Id: d59cc4d3-d5c3-4f47-8314-a6cbbc3ba80b
Replit-Helium-Checkpoint-Created: true
2026-04-02 19:48:23 +00:00
sebastjanartic 88fd916f8e Published your App
Replit-Commit-Author: Deployment
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 5ffb063e-b32c-4f99-bc48-9c3f0bb6c4c3
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/rgXHSxm
Replit-Commit-Deployment-Build-Id: 2bbcf99b-c3d6-4f6a-9ad0-d1d5f8b81daf
Replit-Helium-Checkpoint-Created: true
2026-03-08 07:50:38 +00:00
sebastjanartic dd13cc667a Add a new article to celebrate International Women's Day
Add a new article titled "Internationaler Frauentag: Danke an alle Frauen der FOLX Familie" with associated images and metadata to the seed data in `server/seed.ts`.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: ef00bbbf-e196-43a2-9cb3-5dbd2df2d5f4
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/rgXHSxm
Replit-Helium-Checkpoint-Created: true
2026-03-08 07:48:49 +00:00
sebastjanartic beeb87f036 Published your App
Replit-Commit-Author: Deployment
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: d7e712be-60ac-44c0-b354-afcf3a2784c0
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/OPD8Ro3
Replit-Commit-Deployment-Build-Id: bb31bf24-0b42-4ed7-82ee-f5e4eee99f64
Replit-Helium-Checkpoint-Created: true
2026-03-08 07:36:19 +00:00
sebastjanartic e5fcb182e7 Update gallery page metadata to reflect spontaneous moments from recordings
Update meta tags, title, and description for the gallery page to accurately describe it as featuring unique moments from recordings and backstage snapshots, rather than solely concert photos. This includes changes in client/src/pages/gallery.tsx, server/static.ts, and server/vite.ts.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 67a932ea-d8a9-4376-a0ba-5b355f4f70b7
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/OPD8Ro3
Replit-Helium-Checkpoint-Created: true
2026-03-08 07:34:21 +00:00
sebastjanartic 4ece9e4ccd Add exclusive photos from recordings to the gallery and improve site metadata
Integrate AdSense ads into the photo gallery, introduce SSR meta tags for the gallery page, and improve layout stability by reserving space for ads and dynamic content.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 81f3d8bc-2328-4374-a06e-57257dbb713b
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/OPD8Ro3
Replit-Helium-Checkpoint-Created: true
2026-03-08 07:33:00 +00:00
sebastjanartic 8a263dee0f Add personalized meta tags and optimize horoscope images for sharing
Implement dynamic meta tag generation for horoscope pages, including daily, weekly, and monthly predictions, and optimize the OG image for improved social sharing previews.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: dea1fe98-0dfb-4e47-9b94-3c50decbba8a
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/OPD8Ro3
Replit-Helium-Checkpoint-Created: true
2026-03-08 07:25:10 +00:00
87 changed files with 6807 additions and 1197 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
*.sql
.replit
+23
View File
@@ -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.
+21 -8
View File
@@ -1,13 +1,17 @@
<!DOCTYPE html>
<html lang="de" class="dark">
<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 name="google-site-verification" content="_rO4EdfjGp2lPdm3fpxkWv8k1id0wkthn9GxwsXLpeo" />
<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>
<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!" />
<title>Schlager &amp; Volksmusik News | FOLX TV</title>
<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 property="og:title" content="Volksmusik & Schlager TV | FOLX Music Television" />
<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:title" content="Schlager &amp; Volksmusik News | FOLX TV" />
<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:site_name" content="Folx Music Television" />
<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:locale" content="de_DE" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Volksmusik & Schlager TV | FOLX Music Television" />
<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:title" content="Schlager &amp; Volksmusik News | FOLX TV" />
<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 property="fb:pages" content="1428520781492675" />
<meta property="fb:pages" content="281200958640258" />
<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 &amp; Schlager News" href="https://folx.tv/rss.xml" />
<link rel="canonical" href="https://folx.tv/" />
<link rel="icon" type="image/png" 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">
</head>
<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>
<script type="module" src="/src/main.tsx"></script>
</body>
+8
View File
@@ -1 +1,9 @@
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+40
View File
@@ -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="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" 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="&lt;Path&gt;" 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="&lt;Group&gt;">
<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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+30 -1
View File
@@ -8,7 +8,9 @@ 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 VideoDetailPage from "@/pages/video-detail";
import GalleryPageWrapper from "@/pages/gallery";
import HoroscopePage from "@/pages/horoscope";
import RecipesPage from "@/pages/recipes";
@@ -20,9 +22,33 @@ 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";
// 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() {
const [location] = useLocation();
useEffect(() => {
@@ -35,12 +61,15 @@ function Router() {
return (
<div>
<ScrollToTop />
<ExternalLinksNewTab />
<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="/video/:slug" component={VideoDetailPage} />
<Route path="/gallery" component={GalleryPageWrapper} />
<Route path="/horoskop" component={HoroscopePage} />
<Route path="/horoskop/:sign" component={HoroscopePage} />
+75
View File
@@ -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;
+108 -27
View File
@@ -36,29 +36,37 @@ export default function AdSense({
} catch (e) {
}
if (onAdStatus) {
const checkAd = () => {
if (adRef.current) {
if (!onAdStatus) return;
const ins = adRef.current;
const status = ins.getAttribute("data-ad-status");
if (status === "filled") {
onAdStatus(true);
return;
}
if (status === "unfilled") {
onAdStatus(false);
return;
}
if (ins.clientHeight > 10) {
onAdStatus(true);
return;
}
}
if (!ins) return;
// Google postavi data-ad-status na "filled", "unfilled" ali "unfill-optimized".
// Ce statusa ni, se zanesemo na dejansko visino. Odlocimo se enkrat.
let decided = false;
const decide = (filled: boolean) => {
if (decided) return;
decided = true;
obs.disconnect();
clearTimeout(timer);
onAdStatus(filled);
};
const t1 = setTimeout(checkAd, 2000);
const t2 = setTimeout(checkAd, 5000);
return () => { clearTimeout(t1); clearTimeout(t2); };
}
// Visina samega <ins> ni dokaz - nekatere enote imajo rezerviran prostor.
// Napolnjen oglas ima notri okvir (iframe) z dejansko visino.
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 (
@@ -82,6 +90,8 @@ let adSeedCounter = 1;
export function ArticleCardAd() {
const seed = useMemo(() => adSeedCounter++, []);
const [filled, setFilled] = useState<boolean | null>(null);
if (filled === false) return null;
return (
<ArtistPatternBg className="rounded-md border border-card-border h-full bg-card" seed={seed}>
<AdSense
@@ -89,33 +99,75 @@ export function ArticleCardAd() {
format="fluid"
layoutKey="-6r+cy-10+8a-3"
style={{ display: "block" }}
onAdStatus={setFilled}
/>
</ArtistPatternBg>
);
}
export function InArticleAd() {
export function TopBannerAd() {
const [filled, setFilled] = useState<boolean | null>(null);
if (filled === false) return null;
const shown = filled === true;
return (
<div className="my-6 rounded-md overflow-hidden bg-card border border-card-border">
<div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest">Anzeige</div>
<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
slot="4154017639"
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}
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>
);
}
export function MultiplexAd() {
const [filled, setFilled] = useState<boolean | null>(null);
if (filled === false) return null;
const shown = filled === true;
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
slot="4593001335"
format="autorelaxed"
style={{ display: "block" }}
className="w-full min-h-[250px]"
className={shown ? "w-full min-h-[250px]" : "w-full"}
onAdStatus={setFilled}
/>
</div>
);
@@ -163,3 +215,32 @@ export function SidebarAd() {
</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>
);
}
+28 -7
View File
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { artistGroup } from "@/lib/artists";
import { useState, useEffect, useCallback } from "react";
import { Globe, ExternalLink } from "lucide-react";
@@ -9,17 +10,37 @@ interface NewsItem {
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[]>({
queryKey: ["/api/breaking-news"],
});
const [offset, setOffset] = useState(0);
const [offset, setOffset] = useState(startOffset);
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 advance = useCallback(() => {
@@ -40,7 +61,7 @@ export function BreakingNewsWidget() {
return (
<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)}
onMouseLeave={() => setPaused(false)}
data-testid="widget-breaking-news"
@@ -49,14 +70,14 @@ export function BreakingNewsWidget() {
<Globe className="w-4 h-4 text-primary" />
<h3 className="font-bold text-card-foreground text-sm">Aktuelle Nachrichten</h3>
</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) => (
<a
key={`${offset}-${i}`}
href={item.link}
target="_blank"
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}`}
>
<div className="flex items-start gap-2">
-54
View File
@@ -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>
);
}
+41 -18
View File
@@ -19,12 +19,12 @@ export default function Footer({ narrow }: { narrow?: boolean }) {
return (
<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="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div>
<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">
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.
FOLX TV – Aktuelle Schlager- und Volksmusik-News, Künstlerporträts und Interviews.
Entdecken Sie Nachrichten, Artikel und Videos aus der Musikszene sowie Informationen zu unserem TV-Angebot.
</p>
<div className="flex gap-3">
{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>
</Link>
</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>
<Link href="/videos">
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-video">Video</span>
</Link>
</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>
<Link href="/kontakt">
<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>
</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 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">
+2 -4
View File
@@ -9,9 +9,7 @@ const navItems = [
{ label: "Start", href: "/" },
{ label: "News", href: "/category/News" },
{ label: "Video", href: "/videos" },
{ label: "Galerie", href: "/gallery" },
{ label: "Horoskop", href: "/horoskop" },
{ label: "Rezepte", href: "/rezepte" },
{ label: "Empfang", href: "/empfang-folx-tv" },
{ label: "Kontakt", href: "/kontakt" },
];
@@ -45,7 +43,7 @@ export default function Header() {
<div className="flex items-center justify-between gap-4 h-16">
<Link href="/">
<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>
</Link>
+56 -12
View File
@@ -1,8 +1,16 @@
import { useState, useEffect, useCallback } from "react";
import { useLocation } from "wouter";
import { useQuery } from "@tanstack/react-query";
import { Star, Heart, Briefcase, TrendingUp, ChevronLeft, ChevronRight } from "lucide-react";
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 }) {
return (
<div className="flex gap-px">
@@ -18,6 +26,12 @@ export function HoroscopeWidget() {
const [index, setIndex] = useState(0);
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 prev = useCallback(() => setIndex((i) => (i - 1 + SIGNS.length) % SIGNS.length), []);
@@ -28,7 +42,12 @@ export function HoroscopeWidget() {
}, [paused, next]);
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 (
<div
@@ -36,7 +55,7 @@ export function HoroscopeWidget() {
style={{ background: "linear-gradient(135deg, hsl(250 30% 14%), hsl(270 25% 10%))" }}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onClick={() => navigate("/horoskop")}
onClick={() => navigate(signPath(index))}
data-testid="widget-horoscope"
>
<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 className="flex-1 flex flex-col">
<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>
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-lg text-white">{sign.name}</p>
@@ -88,19 +117,34 @@ export function HoroscopeWidget() {
</div>
<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>
</div>
<div className="flex justify-center gap-1 pb-3">
{SIGNS.map((_, i) => (
<div className="grid grid-cols-6 gap-1 px-2 pb-3">
{SIGNS.map((s, i) => {
const img = imageFor(i);
return (
<button
key={i}
onClick={(e) => { e.stopPropagation(); setIndex(i); }}
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"}`}
data-testid={`button-horoscope-dot-${i}`}
/>
))}
key={s.name}
title={s.name}
onClick={(e) => { e.stopPropagation(); navigate(signPath(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>
-71
View File
@@ -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>
);
}
+28 -7
View File
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { artistGroup } from "@/lib/artists";
import { useState, useEffect, useCallback } from "react";
import { Newspaper, ExternalLink } from "lucide-react";
@@ -9,17 +10,37 @@ interface NewsItem {
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[]>({
queryKey: ["/api/news-feed"],
});
const [offset, setOffset] = useState(0);
const [offset, setOffset] = useState(startOffset);
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 advance = useCallback(() => {
@@ -40,7 +61,7 @@ export function NewsWidget() {
return (
<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)}
onMouseLeave={() => setPaused(false)}
data-testid="widget-news"
@@ -49,14 +70,14 @@ export function NewsWidget() {
<Newspaper className="w-4 h-4 text-primary" />
<h3 className="font-bold text-card-foreground text-sm">Musiknachrichten</h3>
</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) => (
<a
key={`${offset}-${i}`}
href={item.link}
target="_blank"
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}`}
>
<div className="flex items-start gap-2">
-19
View File
@@ -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>
);
}
+22 -6
View File
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { useState, useEffect, useCallback, useRef } from "react";
import { ChevronLeft, ChevronRight, X, Images, Maximize2 } from "lucide-react";
import { InArticleAd } from "./adsense";
export interface GalleryImage {
folder: string;
@@ -340,13 +341,24 @@ export default function GalleryPage() {
<p className="text-muted-foreground text-sm mb-4" data-testid="text-gallery-count">
{Math.min(visibleCount, totalCount)} von {totalCount} Fotos
</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">
{visibleImages.map((img, i) => (
{chunk.map((img, j) => {
const globalIdx = ci * AD_EVERY + j;
return (
<button
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"
data-testid={`button-gallery-image-${i}`}
data-testid={`button-gallery-image-${globalIdx}`}
>
<div className="relative w-full aspect-[16/9]">
<LazyImage
@@ -358,7 +370,7 @@ export default function GalleryPage() {
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
{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">
<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}
</span>
</div>
@@ -369,14 +381,18 @@ export default function GalleryPage() {
</div>
{img.artist && (
<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}
</span>
</div>
)}
</button>
))}
);
})}
</div>
</div>
));
})()}
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-8" data-testid="gallery-load-more">
+46 -28
View File
@@ -1,33 +1,42 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useLocation } from "wouter";
import { ChefHat, Clock, Users } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { ChefHat } from "lucide-react";
interface WidgetRecipe {
title: string;
image: string;
time: string;
servings: string;
interface DailyRecipe {
id: number;
dateStr: string;
name: 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() {
const [, navigate] = useLocation();
const dayIndex = Math.floor(Date.now() / 86400000) % WIDGET_RECIPES.length;
const recipe = WIDGET_RECIPES[dayIndex];
const [index, setIndex] = useState(0);
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 (
<button
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]"
data-testid="widget-recipe"
>
@@ -37,20 +46,29 @@ export function RecipeWidget() {
</div>
<div className="relative flex-1">
<img
src={recipe.image}
alt={recipe.title}
src={recipe.imageUrl as string}
alt={recipe.name}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
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">
<h4 className="text-white font-bold text-base">{recipe.title}</h4>
<p className="text-white/60 text-xs mt-0.5 flex items-center gap-2">
<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>
</p>
<h4 className="text-white font-bold text-base leading-snug line-clamp-2">{recipe.name}</h4>
{recipe.intro && (
<p className="text-white/60 text-xs mt-1 line-clamp-2">{recipe.intro}</p>
)}
<p className="text-[10px] text-primary mt-1 group-hover:underline">Alle Rezepte ansehen</p>
</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>
</button>
);
+27 -7
View File
@@ -1,12 +1,15 @@
import { HOME_TITLE, HOME_DESCRIPTION, INFO_SEO, publisherSchema } from "@shared/seo";
import { useEffect } from "react";
interface OgMeta {
siteName?: string;
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
ogType?: string;
ogUrl?: string;
articlePublishedTime?: string;
articleModifiedTime?: string;
articleAuthor?: string;
articleSection?: string;
}
@@ -31,10 +34,10 @@ function removeMetaTag(selector: string) {
if (el) el.remove();
}
const DEFAULT_TITLE = "Volksmusik & Schlager TV | FOLX Music Television – Nachrichten, Videos & mehr";
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_OG_TITLE = "Volksmusik & Schlager TV | FOLX Music Television";
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_TITLE = `${HOME_TITLE} | FOLX TV`;
const DEFAULT_DESC = HOME_DESCRIPTION;
const DEFAULT_OG_TITLE = `${HOME_TITLE} | FOLX TV`;
const DEFAULT_OG_DESC = HOME_DESCRIPTION;
const DEFAULT_OG_IMAGE = "https://folx.tv/og-image.jpg";
const CANONICAL_BASE = "https://folx.tv";
@@ -52,7 +55,7 @@ function setCanonical(href: string) {
export function usePageMeta(title: string, description?: string, og?: OgMeta) {
useEffect(() => {
const suffix = " | Folx Music Television";
const suffix = ` | ${og?.siteName || "Folx Music Television"}`;
document.title = title + suffix;
if (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 canonicalUrl = canonicalPath.startsWith("http") ? canonicalPath : `${CANONICAL_BASE}${canonicalPath}`;
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: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.ogTitle) setMetaTag('meta[property="og:title"]', og.ogTitle);
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) {
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) {
setMetaTag('meta[property="article:author"]', og.articleAuthor);
@@ -87,6 +106,7 @@ export function usePageMeta(title: string, description?: string, og?: OgMeta) {
}
}
return () => {
document.getElementById("info-jsonld")?.remove();
document.title = DEFAULT_TITLE;
setMetaTag('meta[name="description"]', DEFAULT_DESC);
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: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]);
}
+14
View File
@@ -303,3 +303,17 @@ div[class^="fc-"] {
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. */
+191
View File
@@ -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;
}
+30
View File
@@ -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}%`;
}
+1
View File
@@ -2,4 +2,5 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
document.getElementById("ssr-nav")?.remove();
createRoot(document.getElementById("root")!).render(<App />);
+77 -12
View File
@@ -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 Footer from "@/components/footer";
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() {
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 (
<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-8">
<Tv className="w-7 h-7 text-primary" />
<h1 className="text-3xl font-bold text-foreground" data-testid="text-about-title">
@@ -24,29 +64,54 @@ export default function AboutPage() {
</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>
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>
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>
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>
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.
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>
</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>
<Footer />
</div>
+203
View File
@@ -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>
);
}
+163 -47
View File
@@ -1,5 +1,6 @@
import { publisherSchema, articleModifiedTime, articleGenre } from "@shared/seo";
import { useQuery } from "@tanstack/react-query";
import { useParams, Link } from "wouter";
import { useParams, Link, useLocation } from "wouter";
import { type Article } from "@shared/schema";
import { format } from "date-fns";
import { de } from "date-fns/locale";
@@ -9,13 +10,13 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import Header from "@/components/header";
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 DOMPurify from "dompurify";
import ShareButtons from "@/components/share-buttons";
import InterstitialAd from "@/components/interstitial-ad";
import ParallaxAd from "@/components/parallax-ad";
import { useEffect, useMemo } from "react";
import { useFocalPoints, objectPositionFor } from "@/lib/focal";
import { artistGroup } from "@/lib/artists";
const ALLOWED_IFRAME_DOMAINS = [
"iframe.mediadelivery.net",
@@ -28,6 +29,17 @@ const ALLOWED_IFRAME_DOMAINS = [
"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 {
return DOMPurify.sanitize(html, {
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[]>({
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;
@@ -79,7 +115,7 @@ function RelatedArticles({ currentSlug }: { currentSlug: string }) {
src={article.coverImage ? article.coverImage.replace(".webp", "-thumb.webp") : "/images/article-1.jpg"}
alt={article.title}
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"
/>
</div>
@@ -98,30 +134,46 @@ function RelatedArticles({ currentSlug }: { currentSlug: string }) {
}
export default function ArticlePage() {
const focalPoints = useFocalPoints();
const { slug } = useParams<{ slug: string }>();
const isMobile = useIsMobile();
const [, navigate] = useLocation();
const { data: article, isLoading, error } = useQuery<Article>({
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
? (article.coverImage.startsWith("http") ? article.coverImage : `${window.location.origin}${article.coverImage}`)
? (article.coverImage.startsWith("http") ? article.coverImage : `https://folx.tv${article.coverImage}`)
: "";
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 ? {
siteName: "FOLX TV",
ogTitle: article.title,
ogDescription: article.excerpt,
ogImage: articleImage || undefined,
ogType: "article",
ogUrl: articleUrl,
articlePublishedTime: new Date(article.publishedAt).toISOString(),
articleAuthor: article.author || "Folx Music Television",
articleSection: article.category || "News",
articleModifiedTime: articleModifiedTime(article.content || "", new Date(article.publishedAt).toISOString()),
articleAuthor: "FOLX Music Television",
articleSection: articleGenre(article.content || "").section,
} : undefined
);
@@ -130,32 +182,25 @@ export default function ArticlePage() {
const jsonLd = {
"@context": "https://schema.org",
"@type": "NewsArticle",
"@id": `${articleUrl}#article`,
"headline": article.title,
"description": article.excerpt,
"image": articleImage ? [articleImage] : [],
"datePublished": new Date(article.publishedAt).toISOString(),
"dateModified": new Date(article.publishedAt).toISOString(),
"dateModified": articleModifiedTime(article.content || "", new Date(article.publishedAt).toISOString()),
"author": {
"@type": "Organization",
"name": article.author || "Folx Music Television",
"name": "FOLX Music Television",
"url": "https://folx.tv"
},
"publisher": {
"@type": "Organization",
"name": "Folx Music Television",
"url": "https://folx.tv",
"logo": {
"@type": "ImageObject",
"url": `${window.location.origin}/favicon.png`
}
},
"publisher": publisherSchema(),
"mainEntityOfPage": {
"@type": "WebPage",
"@id": articleUrl
},
"articleSection": article.category || "News",
"articleSection": articleGenre(article.content || "").section,
"inLanguage": "de",
"keywords": `Volksmusik, Schlager, ${article.category || "News"}, ${article.title}`
"keywords": articleGenre(article.content || "").words.join(", ")
};
const breadcrumbLd = {
"@context": "https://schema.org",
@@ -204,6 +249,66 @@ export default function ArticlePage() {
}
}, [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) {
return (
<div className="min-h-screen bg-background">
@@ -235,6 +340,7 @@ export default function ArticlePage() {
<Header />
<PageSideAds contentHalfWidth={480} />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<TopBannerAd />
<Link href="/">
<Button variant="ghost" size="sm" className="mb-3 gap-2" data-testid="button-back">
<ArrowLeft className="w-4 h-4" />
@@ -242,27 +348,26 @@ export default function ArticlePage() {
</Button>
</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 && (
<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
src={article.coverImage}
alt={article.title}
className="w-full h-full object-cover"
style={{ maxHeight: "420px" }}
style={{ maxHeight: "420px", objectPosition: objectPositionFor(article.coverImage, focalPoints) }}
data-testid="img-article-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
</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">
@@ -276,7 +381,7 @@ export default function ArticlePage() {
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<User className="w-3.5 h-3.5" />
{article.author}
FOLX Music Television
</span>
<span className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
@@ -290,15 +395,16 @@ export default function ArticlePage() {
<div className="mt-3">
<ShareButtons
url={`${window.location.origin}/article/${article.slug}`}
url={`https://folx.tv/article/${article.slug}`}
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>
{(() => {
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 AD_INTERVAL = isMobile ? 2 : 3;
const segments: string[] = [];
@@ -315,6 +421,13 @@ export default function ArticlePage() {
[&_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
[&_.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 (
<div ref={(el) => {
if (!el) return;
@@ -335,7 +448,7 @@ export default function ArticlePage() {
dangerouslySetInnerHTML={{ __html: html }}
data-testid={idx === 0 ? "article-content" : `article-content-${idx}`}
/>
{idx < segments.length - 1 && <InArticleAd />}
{AD_AFTER.includes(idx) && <InArticleAd />}
</div>
))}
</div>
@@ -344,17 +457,20 @@ export default function ArticlePage() {
<div className="mt-6 pt-5 border-t border-border">
<ShareButtons
url={`${window.location.origin}/article/${article.slug}`}
url={`https://folx.tv/article/${article.slug}`}
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>
<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>
<ParallaxAd />
<Footer narrow />
<InterstitialAd />
</div>
);
}
+39 -15
View File
@@ -9,8 +9,9 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { ArticleCardAd, PageSideAds } from "@/components/adsense";
import { ArticleCardAd, PageSideAds, TopBannerAd, MultiplexAd } from "@/components/adsense";
import { useEffect } from "react";
import { useFocalPoints, objectPositionFor } from "@/lib/focal";
interface PaginatedResponse {
articles: Article[];
@@ -20,18 +21,30 @@ interface PaginatedResponse {
hasMore: boolean;
}
export default function CategoryPage() {
const { category } = useParams<{ category: string }>();
export default function CategoryPage({ fixedCategory }: { fixedCategory?: string } = {}) {
const focalPoints = useFocalPoints();
const params = useParams<{ category: string }>();
const category = fixedCategory || params.category;
const searchString = useSearch();
const [, setLocation] = useLocation();
const searchParams = new URLSearchParams(searchString);
const currentPage = Math.max(1, parseInt(searchParams.get("page") || "1"));
usePageMeta(
`${category}${currentPage > 1 ? ` – Seite ${currentPage}` : ""} - Volksmusik & Schlager`,
`Aktuelle ${category}-Beiträge und Nachrichten aus der Volksmusik- und Schlagerszene. Neueste Volksmusik-Meldungen bei FOLX TV.`
);
const isNews = category?.toLowerCase() === "news";
const heading = isNews ? "Schlager & Volksmusik News" : category;
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>({
queryKey: ["/api/articles/category", category, { page: currentPage }],
@@ -115,6 +128,7 @@ export default function CategoryPage() {
<Header />
<PageSideAds />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<TopBannerAd />
<Link href="/">
<Button variant="ghost" size="sm" className="mb-6 gap-2" data-testid="button-back">
<ArrowLeft className="w-4 h-4" />
@@ -123,7 +137,7 @@ export default function CategoryPage() {
</Link>
<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}
</h1>
{data && data.total > 0 && (
@@ -149,18 +163,24 @@ export default function CategoryPage() {
</div>
) : 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 articles = data.articles;
let ai = 0;
let row = 0;
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++) {
if (col === adCol) {
items.push(<ArticleCardAd key={`ad-row-${row}`} />);
} else if (ai < articles.length) {
// Oglas je zacasno umaknjen iz mreze: enota se v celici
// 300 px izrise kot 548 px in razteguje vrstico.
if (ai < articles.length) {
const a = articles[ai];
items.push(
<Link key={a.id} href={`/article/${a.slug}`}>
@@ -173,7 +193,7 @@ export default function CategoryPage() {
src={a.coverImage || "/images/article-1.jpg"}
alt={a.title}
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"
/>
</div>
@@ -208,7 +228,11 @@ export default function CategoryPage() {
</Link>
</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 />
</div>
);
+2 -1
View File
@@ -1,3 +1,4 @@
import { INFO_SEO } from "@shared/seo";
import { Shield } from "lucide-react";
import Header from "@/components/header";
import Footer from "@/components/footer";
@@ -5,7 +6,7 @@ import { usePageMeta } from "@/hooks/use-page-meta";
import { PageSideAds } from "@/components/adsense";
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 (
<div className="min-h-screen bg-background">
<Header />
+213 -50
View File
@@ -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 Footer from "@/components/footer";
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",
flag: "DE",
providers: [
{ name: "MagentaTV", detail: "Telekom Deutschland" },
{ name: "Zattoo", detail: null },
{ name: "O2 TV", detail: null },
{
name: "MagentaTV",
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",
flag: "AT",
providers: [
{ name: "A1 Xplore TV", detail: null },
{ name: "Salzburg AG CableLink", detail: null },
{ name: "Zattoo", detail: null },
{
name: "A1 Xplore TV",
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",
flag: "CH",
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() {
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 (
<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 className="text-muted-foreground mb-6" data-testid="text-empfang-subtitle">
Drei Wege zu FOLX TV: kostenloser Livestream, die FOLX TV App und Ihr Kabel- oder IPTV-Anbieter.
</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">
{COUNTRIES.map((c) => (
<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">
<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 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, "-")}`}>
<ul className="divide-y divide-card-border">
{c.providers.map((p) => (
<li key={p.name} className="px-5 py-4 flex flex-col sm:flex-row sm:items-start gap-2 sm:gap-6">
<div className="flex-1">
<div className="text-card-foreground font-semibold" 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 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>
<p className="text-muted-foreground text-sm leading-relaxed mt-1">{p.how}</p>
</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"
href={p.url}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
www.folx.tv
</a>{" "}
für den direkten Zugang.
</p>
</div>
Zum Anbieter
<ExternalLink className="w-3.5 h-3.5" />
</a>
</li>
))}
</ul>
</section>
))}
</div>
</main>
<Footer />
</div>
+112
View File
@@ -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>
);
}
+8 -3
View File
@@ -4,15 +4,16 @@ import GalleryPage from "@/components/photo-gallery";
import { ArrowLeft } from "lucide-react";
import { Link } from "wouter";
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() {
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 (
<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 />
<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">
<ArrowLeft className="w-4 h-4" />
@@ -25,7 +26,11 @@ export default function GalleryPageWrapper() {
<InArticleAd />
<GalleryPage />
<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 />
</div>
);
+458 -111
View File
@@ -1,4 +1,7 @@
import { HOME_TITLE, HOME_DESCRIPTION, homeSchema } from "@shared/seo";
import { useQuery } from "@tanstack/react-query";
import { artistGroup } from "@/lib/artists";
import { objectPositionFor } from "@/lib/focal";
import { Link } from "wouter";
import { type Article } from "@shared/schema";
import { format } from "date-fns";
@@ -8,12 +11,8 @@ 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 AdSense, { ArticleCardAd, SidebarAd, PageSideAds, MultiplexAd, TopBannerAd, GridBannerAd } from "@/components/adsense";
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";
@@ -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 {
if (!coverImage || !focalPoints) return "center 20%";
const fp = focalPoints[coverImage];
if (!fp) return "center 20%";
return `${fp.x}% ${fp.y}%`;
return objectPositionFor(coverImage, focalPoints as any);
}
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 objPos = getObjectPosition(article.coverImage, focalPoints);
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>
</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>
{/* 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]">
<Eye className="w-3 h-3" />
{article.views.toLocaleString()}
@@ -346,7 +583,7 @@ function TopStoriesList({ articles, className }: { articles: Article[]; classNam
Zuletzt hinzugefügt
</h3>
<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}`}>
<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>
@@ -368,7 +605,10 @@ function FeaturedHeroCard({ article, focalPoints }: { article: Article; focalPoi
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-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" />
{isVideo && (
<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 className="p-4 flex-shrink-0">
<div className="p-4 flex-none">
<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>
@@ -437,14 +677,14 @@ function FeaturedCarousel({ articles, popular, galleryImages, focalPoints }: { a
const nextPageArticles = articles.slice(nextPageStart, nextPageStart + pageSize);
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 && (
<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"
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 }}
>
<div className="lg:col-span-3">
@@ -457,9 +697,15 @@ function FeaturedCarousel({ articles, popular, galleryImages, focalPoints }: { a
</div>
</div>
{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) => (
<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>
)}
@@ -511,38 +757,10 @@ function BentoSkeleton() {
}
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(() => {
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 jsonLd = homeSchema();
const script = document.createElement("script");
script.type = "application/ld+json";
script.id = "home-jsonld";
@@ -556,14 +774,48 @@ export default function Home() {
};
}, []);
const { data: articles, isLoading } = useQuery<Article[]>({
queryKey: ["/api/articles"],
// Prvi paket je majhen in brez polja content, da se naslovnica izrise takoj;
// 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[]>({
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[]>({
queryKey: ["/api/gallery", "home"],
queryFn: () => fetch("/api/gallery?limit=20").then((r) => r.json()),
@@ -573,76 +825,129 @@ export default function Home() {
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;
const key = (id: number) => {
let h = id * 2654435761 % 4294967296;
h ^= h >>> 13; h = (h * 1274126177) % 4294967296;
return h;
};
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]);
// 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(() => [
{ 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> },
{ id: "news2", el: <div key="news2" className="flex flex-col gap-4"><NewsWidget startOffset={5} /></div> },
{ id: "breaking2", el: <div key="breaking2" className="flex flex-col gap-4"><BreakingNewsWidget startOffset={5} /></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++] });
// 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.
// Zdaj gre en pripomocek in za njim pet clankov.
const RAZMIK_PRIPOMOCKOV = 5;
while (wi < widgets.length) {
items.push({ type: "widget", key: `w-${widgets[wi].id}`, widget: widgets[wi++] });
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) {
items.push({ type: "article", key: `a-${shuffled[ai].id}`, article: shuffled[ai++] });
while (ai < spaced.length) {
items.push({ type: "article", key: `a-${spaced[ai].id}`, article: spaced[ai++] });
}
const remainder = items.length % 4;
if (remainder > 0 && shuffled.length > 0) {
if (remainder > 0 && spaced.length > 0) {
let fill = 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 });
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;
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++}` };
if (hasWidget) continue;
// 31. 8. 2026 zvecer: enota 4126524750 je v AdSense se vedno Responsiv in
// 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;
}, [shuffled, widgets]);
}, [spaced, widgets, videos]);
const NA_NASLOVNICI = 48;
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));
let clankov = 0;
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;
}, [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(() => {
if (!articles || articles.length < 3) return [];
const carouselIds = new Set(articles.slice(0, 9).map((a) => a.id));
@@ -673,12 +978,10 @@ export default function Home() {
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} /> },
@@ -703,34 +1006,74 @@ export default function Home() {
<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>
<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="lg:col-span-3 space-y-4">
<FeaturedCarousel articles={articles.filter(a => a.featured)} popular={popular} galleryImages={galleryImages} focalPoints={focalPoints} />
<div className="lg:col-span-3 space-y-4 lg:flex lg:flex-col">
{/* 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 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)} />}
<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 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 />
</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">
<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) =>
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"
? <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
? <MediumCard key={item.key} article={item.article} focalPoints={focalPoints} />
: 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 && (
<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} />
{widePickedArticles[1] && <WideCardClassic article={widePickedArticles[1]} focalPoints={focalPoints} />}
</div>
@@ -738,26 +1081,30 @@ export default function Home() {
</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}
{/* Naslovnica se konca pri 48 clankih, naprej pelje rubrika s stranicenjem. */}
<div className="flex justify-center my-8">
<a
href="/category/News"
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>
</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>
{/* 31. 8. 2026: naslovnica se konca pri gumbu Alle Nachrichten. Bloka
pod njim (bottomSection in ponovljena pripomocka) sta odstranjena,
ker sta podvajala vsebino iz mreze. Za gumbom ostane samo multiplex. */}
{/* Multiplex je zadnjih sedem dni najhitreje rastoca enota (+500 %),
naslovnica ga edina ni imela. */}
<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>
</LazySection>
</main>
<Footer />
+949
View File
@@ -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>
);
}
+68 -96
View File
@@ -21,12 +21,10 @@ import {
} from "lucide-react";
import Header from "@/components/header";
import Footer from "@/components/footer";
import InterstitialAd from "@/components/interstitial-ad";
import { InArticleAd, PageSideAds } from "@/components/adsense";
import { InArticleAd, PageSideAds, MultiplexAd } from "@/components/adsense";
import {
SIGNS,
ELEMENT_COLORS,
ASTRO_EVENTS,
getHoroscope as getStaticHoroscope,
getRating,
getLuckyNumbers,
@@ -43,6 +41,7 @@ interface AIHoroscope {
tip: string;
weekly: string;
monthly: string;
imageUrl?: string | null;
}
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[] }) {
return (
<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" />
Ihr Sternzeichen wählen
</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) => {
const ec = ELEMENT_COLORS[sign.element];
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()}`}
>
{(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">
<span className="text-2xl">{sign.symbol}</span>
<div>
<span className={`text-sm font-semibold block ${isSelected ? ec.text : "text-foreground"}`}>{sign.name}</span>
<span className="text-[10px] text-muted-foreground">{sign.date}</span>
<span className={`text-base font-semibold block ${isSelected ? ec.text : "text-foreground"}`}>{sign.name}</span>
<span className="text-[11px] text-muted-foreground">{sign.date}</span>
</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">
Lesen <ArrowRight className="w-3 h-3" />
</div>
@@ -172,6 +115,7 @@ function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number
const dailyColor = getDailyColor(signIndex);
const detailRef = useRef<HTMLDivElement>(null);
const isAI = !!aiH;
const imgFor = (i: number) => aiHoroscopes.find((h) => h.signIndex === i)?.imageUrl || null;
const prevIndex = (signIndex - 1 + SIGNS.length) % 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="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={`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>
@@ -276,6 +229,7 @@ function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number
))}
</div>
<div style={{ minHeight: "180px" }}>
{tab === "daily" && (
<div className="space-y-5" data-testid="section-horoscope-daily">
<div>
@@ -311,6 +265,7 @@ function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number
</div>
)}
</div>
</div>
<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`}
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">
<span className="text-xl">{s.symbol}</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() {
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 [selected, setSelected] = useState<number | null>(null);
const detailRef = useRef<HTMLDivElement>(null);
const { data: aiHoroscopes = [], isLoading: aiLoading } = useQuery<AIHoroscope[]>({
queryKey: ["/api/horoscopes/today"],
staleTime: 1000 * 60 * 30,
refetchOnWindowFocus: true,
});
useEffect(() => {
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);
}
}, [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) => {
setSelected(i);
setTimeout(() => {
@@ -396,23 +384,11 @@ export default function HoroscopePage() {
return (
<div className="min-h-screen bg-background">
<Header />
<InterstitialAd forceShow />
<PageSideAds />
<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">
<Link href="/">
<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 className="sr-only" data-testid="text-horoscope-title">
Horoskop — Tageshoroskop für alle zwölf Sternzeichen
</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 && (
<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>
)}
<AstroEventsSection />
<InArticleAd />
<SignGrid onSelect={handleSelect} selectedIndex={selected} aiHoroscopes={aiHoroscopes} />
@@ -430,15 +404,13 @@ export default function HoroscopePage() {
<div ref={detailRef}>
{selected !== null ? (
<SignDetail signIndex={selected} onNavigate={handleSelect} aiHoroscopes={aiHoroscopes} />
) : (
<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>
) : null}
</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>
</main>
</main>
<Footer />
</div>
);
+25 -10
View File
@@ -1,3 +1,4 @@
import { INFO_SEO } from "@shared/seo";
import { Scale } from "lucide-react";
import Header from "@/components/header";
import Footer from "@/components/footer";
@@ -5,7 +6,7 @@ import { usePageMeta } from "@/hooks/use-page-meta";
import { PageSideAds } from "@/components/adsense";
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 (
<div className="min-h-screen bg-background">
<Header />
@@ -22,20 +23,34 @@ export default function ImpressumPage() {
<section>
<h2 className="text-foreground font-semibold text-lg mb-3">Herausgeber</h2>
<p>
BoldFrame Productions d.o.o.
PRIME TIME Consulting GmbH
<br />
Sokolska ulica 46
Sigmaringer Str. 10
<br />
2000 Maribor
72379 Hechingen
<br />
Slowenien
Deutschland
</p>
<p className="mt-2">
UID-Nr.: SI38853507
Geschäftsführerin: Heike Artič
<br />
E-Mail:{" "}
<a href="mailto:office@boldframe.productions" className="text-primary hover:underline" data-testid="link-email">
office@boldframe.productions
<a href="mailto:office@primetime.consulting" className="text-primary hover:underline" data-testid="link-email">
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>
</p>
</section>
@@ -58,7 +73,7 @@ export default function ImpressumPage() {
<section>
<h2 className="text-foreground font-semibold text-lg mb-3">Copyright</h2>
<p>
Fotos stammen aus eigenen Archiven oder wurden von den Künstlern, Produzenten und Musikfirmen zugeliefert. Die Rechte für sämtliche Abbildungen, Grafiken, Texte liegen, sofern nicht anders gekennzeichnet, bei BoldFrame Productions d.o.o. Unerlaubtes Kopieren oder Verwenden von Bildern und Texten (auch teilweise) bedarf der vorherigen schriftlichen Genehmigung. Alle Rechte vorbehalten.
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 className="mt-3">
Wir widersprechen jeder kommerziellen Verwendung und jeder sonstigen Weitergabe und anderweitigen Veröffentlichung dieser Daten.
@@ -68,7 +83,7 @@ export default function ImpressumPage() {
<section>
<h2 className="text-foreground font-semibold text-lg mb-3">Links</h2>
<p>
BoldFrame Productions d.o.o. übernimmt keine Verantwortung oder Haftung hinsichtlich des Zugriffs auf bzw. des Materials in Websites, auf die von dieser Site aus zugegriffen wird. Zum Zeitpunkt der Verlinkung bestand nach eingehender Prüfung der gelinkten Seiten keinerlei Verdacht auf Inhalte, die nach jeweils geltendem Landesrecht gegen Gesetze verstoßen hätten. BoldFrame Productions d.o.o. distanziert sich daher ausdrücklich und unwiderruflich von jeglichen rechtswidrigen Inhalten auf den verlinkten Seiten.
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>
</section>
</article>
+31 -5
View File
@@ -1,14 +1,15 @@
import { INFO_SEO } from "@shared/seo";
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 Header from "@/components/header";
import Footer from "@/components/footer";
import { usePageMeta } from "@/hooks/use-page-meta";
import { PageSideAds } from "@/components/adsense";
import { PageSideAds, TopBannerAd } from "@/components/adsense";
import { apiRequest } from "@/lib/queryClient";
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 [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
@@ -42,6 +43,7 @@ export default function KontaktPage() {
<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-8">
<Mail className="w-7 h-7 text-primary" />
<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">
<a
href="tel:+491725675800"
href="tel:+4974719429966"
className="flex items-start gap-3 group"
data-testid="link-kontakt-phone"
>
@@ -66,10 +68,34 @@ export default function KontaktPage() {
</div>
<div>
<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>
</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
href="mailto:office@folx.tv"
className="flex items-start gap-3 group"
+199 -329
View File
@@ -1,363 +1,233 @@
import { useState } from "react";
import { Link } from "wouter";
import { ChefHat, Clock, Users, X, ChevronLeft } from "lucide-react";
import { useState, Fragment } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChefHat, X, Calendar } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { InArticleAd, PageSideAds } from "@/components/adsense";
import { InArticleAd, PageSideAds, TopBannerAd, MultiplexAd } from "@/components/adsense";
interface Recipe {
title: string;
image: string;
time: string;
servings: string;
ingredients: string[];
steps: string[];
description: string;
category: string;
}
interface RecipeRegion {
interface DailyRecipe {
id: number;
dateStr: string;
name: string;
recipes: Recipe[];
intro?: string;
recipe?: string;
seasonNote?: string | null;
imageUrl?: string | null;
}
const RECIPE_REGIONS: RecipeRegion[] = [
{
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."],
},
],
},
];
const MONTHS_DE = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli",
"August", "September", "Oktober", "November", "Dezember"];
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 (
<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
className="bg-card rounded-xl border border-card-border max-w-lg w-full max-h-[85vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="relative">
<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>
<>
{recipe.intro && <p className="text-sm text-muted-foreground mb-4">{recipe.intro}</p>}
{recipe.recipe && (
<div className="text-sm text-foreground/85 whitespace-pre-line leading-relaxed">{recipe.recipe}</div>
)}
{recipe.seasonNote && <p className="text-xs text-primary mt-4">{recipe.seasonNote}</p>}
</>
);
}
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.");
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
usePageMeta(
"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 (
<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">
<div className="flex items-center gap-3 mb-2">
<Link href="/">
<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">
<TopBannerAd />
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2 mb-1" data-testid="text-recipes-title">
<ChefHat className="w-6 h-6 text-primary" />
Legendäre Bauernküche
Rezepte
</h1>
</div>
<p className="text-muted-foreground text-sm mb-8 ml-8" data-testid="text-recipes-subtitle">
Traditionelle Rezepte aus den germanischen Regionen -- von der Alm bis zur Küste, von der Oma überliefert.
<p className="text-muted-foreground text-sm mb-8">
Jeden Tag ein neues Rezept aus der Alpenküche — und das ganze Archiv zum Nachkochen.
</p>
{RECIPE_REGIONS.map((region, ri) => (
<div key={region.name} data-testid={`section-region-${ri}`}>
<div className="flex items-center gap-3 mb-4 mt-8 first:mt-0">
{today && (
<section className="mb-10" data-testid="section-recipe-of-day">
<div className="flex items-center gap-3 mb-4">
<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>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5 mb-4">
{region.recipes.map((recipe, i) => (
<button
key={recipe.title}
onClick={() => setSelectedRecipe(recipe)}
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-${ri}-${i}`}
>
<div className="relative overflow-hidden">
<img src={recipe.image} alt={recipe.title} className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105" loading="lazy" />
</div>
<div className="p-4">
<h3 className="font-bold text-card-foreground text-base group-hover:text-primary transition-colors">{recipe.title}</h3>
<span className="text-[10px] font-medium text-primary">{recipe.category}</span>
<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 className="bg-card rounded-xl border border-card-border overflow-hidden md:flex">
{today.imageUrl && (
<img
src={today.imageUrl}
alt={today.name}
className="w-full md:w-1/2 aspect-video object-cover"
data-testid="img-recipe-of-day"
/>
)}
<div className="p-5 md:w-1/2">
<p className="text-[11px] text-muted-foreground flex items-center gap-1 mb-1">
<Calendar className="w-3 h-3" /> {formatDate(today.dateStr)}
</p>
<h3 className="text-xl font-bold text-foreground mb-3">{today.name}</h3>
<RecipeBody recipe={today} />
</div>
</div>
</button>
))}
</div>
</section>
)}
<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>
);
}
+6 -3
View File
@@ -4,7 +4,8 @@ import { Link, useSearch, useLocation } from "wouter";
import { Search, Play, FileText, ArrowLeft } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
import Header from "@/components/header";
import { PageSideAds } from "@/components/adsense";
import { PageSideAds, TopBannerAd } from "@/components/adsense";
import { useFocalPoints, objectPositionFor } from "@/lib/focal";
interface SearchResult {
articles: {
@@ -27,6 +28,7 @@ interface SearchResult {
}
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.");
const searchString = useSearch();
const initialQuery = new URLSearchParams(searchString).get("q") || "";
@@ -79,6 +81,7 @@ export default function SearchPage() {
<Header />
<PageSideAds />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<TopBannerAd />
<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">
<ArrowLeft className="w-4 h-4" />
@@ -129,7 +132,7 @@ export default function SearchPage() {
<img
src={article.coverImage}
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">
@@ -164,7 +167,7 @@ export default function SearchPage() {
<img
src={video.thumbnail}
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="w-8 h-8 rounded-full bg-primary/80 flex items-center justify-center">
+202
View File
@@ -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>
);
}
+90 -84
View File
@@ -1,57 +1,58 @@
import { useQuery } from "@tanstack/react-query";
import { Link, useLocation, useSearch } 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 { Play, ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { ArticleCardAd, PageSideAds } from "@/components/adsense";
import { PageSideAds, TopBannerAd, MultiplexAd } from "@/components/adsense";
import { useEffect } from "react";
import type { YtVideo } from "@/pages/video-detail";
import { dauer } from "@/pages/video-detail";
interface PaginatedResponse {
articles: Article[];
interface YtResponse {
items: YtVideo[];
total: number;
page: number;
totalPages: number;
hasMore: boolean;
rubriken: string[];
}
function VideoCard({ article }: { article: Article }) {
const thumbSrc = article.coverImage
? article.coverImage.replace(".webp", "-thumb.webp")
: "/images/article-1.jpg";
function VideoCard({ v }: { v: YtVideo }) {
return (
<Link href={`/article/${article.slug}`}>
<Link href={`/video/${v.slug}`}>
<article
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="overflow-hidden rounded-t-md">
<div className="relative rounded-t-md overflow-hidden">
<img
src={thumbSrc}
alt={article.title}
src={v.posterUrl}
alt={v.shortTitle}
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
</div>
<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>
<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-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">
{article.title}
{v.shortTitle}
</h3>
<p className="text-xs text-muted-foreground mt-auto">
{format(new Date(article.publishedAt), "d. MMMM yyyy", { locale: de })}
{v.teaser && (
<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>
</div>
</article>
@@ -59,7 +60,7 @@ function VideoCard({ article }: { article: Article }) {
);
}
function VideoCardSkeleton() {
function CardSkeleton() {
return (
<div className="bg-card rounded-md border border-card-border">
<Skeleton className="w-full aspect-video rounded-t-md" />
@@ -72,43 +73,46 @@ function VideoCardSkeleton() {
}
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 [, setLocation] = useLocation();
const params = new URLSearchParams(searchString);
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>({
queryKey: [`/api/articles/category/Video?page=${currentPage}&limit=${limit}`],
const { data, isLoading } = useQuery<YtResponse>({
queryKey: [`/api/yt-videos?page=${currentPage}&limit=${limit}&rubrik=${encodeURIComponent(rubrik)}`],
});
useEffect(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, [currentPage]);
}, [currentPage, rubrik]);
const goToPage = (page: number) => {
if (page === 1) {
setLocation("/videos");
} else {
setLocation(`/videos?page=${page}`);
}
const go = (page: number, r = rubrik) => {
const q = new URLSearchParams();
if (r && r !== "Alle") q.set("rubrik", r);
if (page > 1) q.set("page", String(page));
const qs = q.toString();
setLocation(qs ? `/videos?${qs}` : "/videos");
};
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 | "...")[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (currentPage > 3) pages.push("...");
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) pages.push(i);
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) pages.push(i);
if (currentPage < totalPages - 2) pages.push("...");
pages.push(totalPages);
}
@@ -120,6 +124,7 @@ export default function VideosPage() {
<Header />
<PageSideAds />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<TopBannerAd />
<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">
<ArrowLeft className="w-4 h-4" />
@@ -127,73 +132,74 @@ export default function VideosPage() {
</button>
</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
</h1>
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<VideoCardSkeleton key={i} />
{rubriken.length > 1 && (
<div className="flex flex-wrap gap-2 mb-6" data-testid="videos-rubriken">
{rubriken.map((r) => (
<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>
) : articles.length > 0 ? (
<>
)}
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
{articles.flatMap((article, index) => {
const items: JSX.Element[] = [
<VideoCard key={article.id} article={article} />,
];
if ((index + 1) % 3 === 0) {
items.push(<ArticleCardAd key={`ad-video-${index}`} />);
}
return items;
})}
{Array.from({ length: 8 }).map((_, i) => <CardSkeleton key={i} />)}
</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 && (
<nav className="flex items-center justify-center gap-1 mt-10" data-testid="pagination-videos">
<Button
variant="outline"
size="icon"
onClick={() => goToPage(currentPage - 1)}
<div className="flex justify-center items-center gap-2 mt-8" data-testid="videos-pagination">
<button
onClick={() => go(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" />
</Button>
{getPageNumbers().map((p, i) =>
</button>
{pageNumbers().map((p, i) =>
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}
variant={p === currentPage ? "default" : "outline"}
size="sm"
onClick={() => goToPage(p as number)}
data-testid={`button-page-${p}`}
onClick={() => go(p)}
className={`min-w-9 rounded-md px-3 py-1.5 text-sm font-semibold ${
p === currentPage ? "bg-primary text-primary-foreground" : "border border-card-border text-card-foreground hover:bg-muted/40"
}`}
>
{p}
</Button>
)
</button>
),
)}
<Button
variant="outline"
size="icon"
onClick={() => goToPage(currentPage + 1)}
<button
onClick={() => go(currentPage + 1)}
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" />
</Button>
</nav>
)}
</>
) : (
<div className="text-center py-16">
<p className="text-muted-foreground">Noch keine Videos vorhanden.</p>
</button>
</div>
)}
</main>
+7
View File
@@ -56,6 +56,7 @@
"express": "^5.0.1",
"express-session": "^1.18.1",
"framer-motion": "^11.13.1",
"hls.js": "^1.5.17",
"input-otp": "^1.4.2",
"lucide-react": "^0.453.0",
"memorystore": "^1.6.7",
@@ -6030,6 +6031,12 @@
"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": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
+2 -1
View File
@@ -86,7 +86,8 @@
"wouter": "^3.3.5",
"ws": "^8.18.0",
"zod": "^3.25.76",
"zod-validation-error": "^3.5.4"
"zod-validation-error": "^3.5.4",
"hls.js": "^1.5.17"
},
"devDependencies": {
"@replit/vite-plugin-cartographer": "^0.4.4",
+1
View File
@@ -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
- 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 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
- JSON-LD structured data: WebSite (home) with SearchAction, NewsArticle + BreadcrumbList (articles)
- Sitemap at `/sitemap.xml` — includes all static pages, categories, horoscope signs, and articles
+20
View File
@@ -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");
+43
View File
@@ -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);
}
+67
View File
@@ -7,8 +7,61 @@ const openai = new OpenAI({
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 }>();
// 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 }> = {
"/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 }> {
const originalPath = imagePath;
loadCache();
if (manualOverrides[originalPath]) {
cache.set(originalPath, manualOverrides[originalPath]);
return manualOverrides[originalPath];
}
loadDetected();
if (detected[originalPath]) {
cache.set(originalPath, detected[originalPath]);
return detected[originalPath];
}
if (cache.has(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)),
};
cache.set(originalPath, point);
cacheDirty = true;
return point;
}
} catch (e) {
@@ -98,13 +158,20 @@ export async function analyzeAllArticleImages(articles: Array<{ coverImage: stri
if (!article.coverImage) continue;
await analyzeFocalPoint(article.coverImage);
}
saveCache();
console.log("[focal-point] Analyzed", cache.size, "images");
}
export function getCachedFocalPoints(): Record<string, { x: number; y: number }> {
loadCache();
loadDetected();
const result: Record<string, { x: number; y: number }> = {};
for (const [key, value] of cache.entries()) {
result[key] = value;
}
// zaznani zaris ima prednost pred LLM ugibanjem
for (const [key, value] of Object.entries(detected)) {
result[key] = value;
}
return result;
}
+120 -3
View File
@@ -1,6 +1,6 @@
import OpenAI from "openai";
import { db } from "./db";
import { dailyHoroscopes } from "@shared/schema";
import { dailyHoroscopes, cosmicEvents } from "@shared/schema";
import { eq, and } from "drizzle-orm";
const openai = new OpenAI({
@@ -25,6 +25,19 @@ export async function getHoroscopesForToday(): Promise<any[]> {
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> {
const today = getTodayStr();
@@ -44,7 +57,7 @@ export async function generateDailyHoroscopes(): Promise<void> {
try {
const response = await openai.chat.completions.create({
model: "gpt-5-mini",
model: "openai/gpt-5.4",
messages: [
{
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 || "";
@@ -110,3 +123,107 @@ export async function getOrGenerateHoroscope(signIndex: number): Promise<any | n
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}.`);
}
+361 -36
View File
@@ -3,7 +3,9 @@ import { createServer, type Server } from "http";
import { storage } from "./storage";
import { insertArticleSchema } from "@shared/schema";
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 { optimizeImage } from "./image-optimizer";
import { getAuthUrl, exchangeCodeForTokens, isConnected, fetchGalleryFromDropbox, getValidAccessToken, migrateGalleryToCloudinary } from "./dropbox";
@@ -56,25 +58,200 @@ export async function registerRoutes(
httpServer: Server,
app: Express
): Promise<Server> {
try {
await seedDatabase();
} catch (err: any) {
console.error("[seed] seedDatabase failed (server continues):", err?.message);
}
generateDailyHoroscopes().catch((err) =>
console.error("Background horoscope generation failed:", err.message)
);
startDailyScheduler();
// ---- 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) => {
analyzeAllArticleImages(articles).catch(err =>
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
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) => {
res.json(getCachedFocalPoints());
});
app.get("/api/articles", async (_req, res) => {
const articles = await storage.getArticles();
res.json(articles);
// Naslovnica je do 24.8.2026 potegnila VSEH 550 clankov skupaj s poljem
// content (1,77 MB). Zdaj sprejme limit/offset in light=1, ki odrezeta
// 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) => {
@@ -84,10 +261,37 @@ export async function registerRoutes(
app.get("/api/articles/popular", async (req, res) => {
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);
});
// 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) => {
const page = parseInt(req.query.page as string);
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) });
});
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);
if (!parsed.success) {
return res.status(400).json({ message: "Invalid article data", errors: parsed.error.flatten() });
@@ -159,30 +369,38 @@ export async function registerRoutes(
if (existing) {
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);
});
app.patch("/api/articles/:id", async (req, res) => {
app.patch("/api/articles/:id", requireApiKey, async (req, res) => {
const id = parseInt(req.params.id);
const partial = insertArticleSchema.partial().safeParse(req.body);
if (!partial.success) {
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) {
return res.status(404).json({ message: "Article not found" });
}
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);
await storage.deleteArticle(id);
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) {
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 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) => {
try {
const page = parseInt(req.query.page as string) || 1;
@@ -650,6 +899,47 @@ export async function registerRoutes(
});
// 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) => {
try {
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) => {
// 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 cached = getCached<any[]>(cacheKey, 15 * 60 * 1000);
const cached = getCached<any[]>(cacheKey, 5 * 60 * 1000);
if (cached) return res.json(cached);
try {
const newsFeeds = [
"https://www.tagesschau.de/xml/rss2_https/",
"https://www.n-tv.de/rss",
"https://rss.orf.at/oesterreich.xml",
"https://www.welt.de/feeds/latest.rss",
];
let allItems: { title: string; link: string; source: string; pubDate: string }[] = [];
const results = await Promise.allSettled(
newsFeeds.map(async (url) => {
try {
const xml = await fetchRssFeed(url);
return parseRssItems(xml, 1);
} catch { return []; }
})
);
for (const r of results) {
if (r.status === "fulfilled") allItems.push(...r.value);
const r = await fetch("https://folx.news/api/articles", {
headers: { "user-agent": "folx.tv-widget" },
signal: AbortSignal.timeout(10000),
});
const arts: any[] = await r.json();
const sveze = (Array.isArray(arts) ? arts : [])
.filter((a) => a && a.slug && a.title)
.sort((a, b) => String(b.publishedAt || "").localeCompare(String(a.publishedAt || "")))
.slice(0, 60);
// Rubrike se morajo izmenjevati: sicer pridejo trije Sporti zapored,
// ker jih bot objavi v svezju. Vzamemo po eno iz vsake rubrike v krogu,
// znotraj rubrike pa ostane vrstni red po datumu.
const poRubriki = new Map<string, any[]>();
for (const a of sveze) {
const k = String(a.category || "FOLX NEWS");
if (!poRubriki.has(k)) poRubriki.set(k, []);
poRubriki.get(k)!.push(a);
}
allItems = allItems.slice(0, 10);
if (allItems.length > 0) setCache(cacheKey, allItems);
res.json(allItems);
const kroznik: any[] = [];
let se = true;
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) {
console.log("[breaking-news] RSS fetch error:", err.message);
console.log("[breaking-news] folx.news error:", err.message);
const stale = getStale<any[]>(cacheKey);
res.json(stale || []);
}
@@ -892,6 +1216,7 @@ Disallow: /search
Disallow: /admin/
Sitemap: https://folx.tv/sitemap.xml
Sitemap: https://folx.tv/video-sitemap.xml
`;
res.set("Content-Type", "text/plain");
res.set("Cache-Control", "public, max-age=86400");
+1115
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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.");
}
+48 -23
View File
@@ -138,7 +138,7 @@ const seedArticles = [
},
{
"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.",
"category": "Video",
"author": "Folx Music Television",
@@ -158,17 +158,6 @@ const seedArticles = [
"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>"
},
{
"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",
"slug": "linda-feller-country-pionierin-aus-thueringen",
@@ -223,14 +212,29 @@ const seedArticles = [
"featured": true,
"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 &amp; 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 &bdquo;Danke&ldquo; 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 &bdquo;Folx Stadl&ldquo; 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 &bdquo;Folx Stadl&ldquo; 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() {
// stari podvojeni Folx Stadl 29 (nadomescen z avtomatiko: folx-stadl-sendung-29)
await db.execute(sql`
UPDATE articles SET
slug = 'john-prisco-die-geschichte-eines-musikers'
WHERE slug = 'john-prisco-die-geschichte-des-liedes'
DELETE FROM articles WHERE slug = 'folx-stadl-sendung-29-musik-und-gute-laune'
`);
// 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`
@@ -245,17 +249,12 @@ export async function seedDatabase() {
OR excerpt LIKE '%&bdquo;%' OR excerpt LIKE '%&ldquo;%'
`);
const validSlugs = new Set(seedArticles.map((a) => a.slug));
const existing = await storage.getArticles();
const existingSlugs = new Set(existing.map((a) => a.slug));
const toDelete = existing.filter((a) => !validSlugs.has(a.slug));
if (toDelete.length > 0) {
for (const article of toDelete) {
await db.execute(sql`DELETE FROM articles WHERE id = ${article.id}`);
}
console.log("Cleanup: removed " + toDelete.length + " articles not in seed list.");
}
// NOTE: cleanup/delete of non-seed articles je ODSTRANJEN namenoma.
// Seed samo doda ali posodobi svoje zacetne clanke; clankov, ki so bili
// dodani kasneje (generator, admin), NE brise. Tako prezivijo vsak deploy.
let added = 0;
let updated = 0;
@@ -282,6 +281,7 @@ export async function seedDatabase() {
continue;
}
try {
await storage.createArticle({
title: article.title,
slug: article.slug,
@@ -300,6 +300,31 @@ export async function seedDatabase() {
}
newArticles.push(article);
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) {
+704 -5
View File
@@ -1,21 +1,125 @@
import { publisherSchema, homeSchema, INFO_SEO, articleModifiedTime, articleGenre } from "../shared/seo";
import express, { type Express } from "express";
import fs from "fs";
import path from "path";
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 {
return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// 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 {
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",
// 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 {
html = html.replace(/<meta\s+property="og:[^"]*"[^>]*>\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, {
// 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) {
if (filePath.endsWith("og-image.jpg") || filePath.includes("/uploads/") || filePath.includes("/images/")) {
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 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> &ndash; ${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\/([^?#]+)/);
if (articleMatch) {
try {
const slug = decodeURIComponent(articleMatch[1]);
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) {
const articleUrl = `${canonicalBase}/article/${article.slug}`;
const imageUrl = ogImageUrl(article.coverImage || "", canonicalBase);
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");
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 = [
`<meta property="og:title" content="${escapeHtml(article.title)}" />`,
`<meta property="og:description" content="${escapeHtml(article.excerpt)}" />`,
`<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:image" content="${escapeHtml(finalImage)}" />`,
`<meta property="og:image:secure_url" content="${escapeHtml(finalImage)}" />`,
`<meta property="og:image:width" content="800" />`,
`<meta property="og:image:height" content="450" />`,
...(articleImgDims
? [`<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:site_name" content="Folx Music Television" />`,
`<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: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" />`,
`<meta name="keywords" content="${escapeHtml(folxGenre(article).words.join(", "))}, ${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>`,
`<title>${escapeHtml(article.title)} | FOLX TV</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) {
}
}
// /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");
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);
});
}
+343
View File
@@ -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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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);
});
}
+28 -3
View File
@@ -1,6 +1,6 @@
import { type Article, type InsertArticle, articles, articleViews, type InsertPushSubscription, type PushSubscription, pushSubscriptions } from "@shared/schema";
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";
export interface IStorage {
@@ -40,8 +40,33 @@ export class DatabaseStorage implements IStorage {
return db.select().from(articles).orderBy(desc(articles.publishedAt)).limit(9);
}
async getPopularArticles(limit: number): Promise<Article[]> {
return db.select().from(articles).orderBy(desc(articles.views)).limit(limit);
// "Beliebt" mora dejansko rotirati: steje ogled v zadnjih `days` dneh, ne
// 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[]> {
+90 -2
View File
@@ -7,6 +7,22 @@ import path from "path";
import { nanoid } from "nanoid";
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();
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:site_name" content="Folx Music Television" />`,
`<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: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" />`,
`<meta name="keywords" content="${escapeHtml(folxGenre(article).words.join(", "))}, ${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>`,
].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);
res.status(200).set({ "Content-Type": "text/html" }).end(page);
} catch (e) {
+28
View File
@@ -14,6 +14,7 @@ export const articles = pgTable("articles", {
author: varchar("author", { length: 255 }).notNull().default("Folx Music Television"),
featured: boolean("featured").notNull().default(false),
views: integer("views").notNull().default(0),
targetUrl: text("target_url"),
publishedAt: timestamp("published_at").notNull().defaultNow(),
});
@@ -33,6 +34,17 @@ export const articleViews = pgTable("article_views", {
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", {
id: serial("id").primaryKey(),
signIndex: integer("sign_index").notNull(),
@@ -45,11 +57,27 @@ export const dailyHoroscopes = pgTable("daily_horoscopes", {
tip: text("tip").notNull(),
weekly: text("weekly").notNull(),
monthly: text("monthly").notNull(),
imageUrl: text("image_url"),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
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", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
username: text("username").notNull().unique(),
+53
View File
@@ -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" }
};