Restored to '84d7de2da64f8f2fd1e2d64959817c64c8fb7c04'
Replit-Restored-To: 84d7de2da6
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Reusable AudioWorklet for streaming PCM16 audio playback.
|
||||
* Place in public/ folder and load via audioContext.audioWorklet.addModule()
|
||||
*/
|
||||
class RingBuffer {
|
||||
constructor(initialCapacity) {
|
||||
this.capacity = initialCapacity;
|
||||
this.buffer = new Float32Array(initialCapacity);
|
||||
this.readIndex = 0;
|
||||
this.writeIndex = 0;
|
||||
this.availableData = 0;
|
||||
}
|
||||
|
||||
push(data) {
|
||||
const len = data.length;
|
||||
// Auto-grow if needed
|
||||
while (this.availableData + len > this.capacity) {
|
||||
this.grow();
|
||||
}
|
||||
for (let i = 0; i < len; i++) {
|
||||
this.buffer[this.writeIndex] = data[i];
|
||||
this.writeIndex = (this.writeIndex + 1) % this.capacity;
|
||||
this.availableData++;
|
||||
}
|
||||
}
|
||||
|
||||
grow() {
|
||||
const newCapacity = this.capacity * 2;
|
||||
const newBuffer = new Float32Array(newCapacity);
|
||||
// Copy existing data maintaining order
|
||||
for (let i = 0; i < this.availableData; i++) {
|
||||
const srcIndex = (this.readIndex + i) % this.capacity;
|
||||
newBuffer[i] = this.buffer[srcIndex];
|
||||
}
|
||||
this.buffer = newBuffer;
|
||||
this.readIndex = 0;
|
||||
this.writeIndex = this.availableData;
|
||||
this.capacity = newCapacity;
|
||||
}
|
||||
|
||||
pull(outputBuffer) {
|
||||
const len = outputBuffer.length;
|
||||
const available = Math.min(len, this.availableData);
|
||||
for (let i = 0; i < available; i++) {
|
||||
outputBuffer[i] = this.buffer[this.readIndex];
|
||||
this.readIndex = (this.readIndex + 1) % this.capacity;
|
||||
}
|
||||
// Pad remaining with silence
|
||||
for (let i = available; i < len; i++) {
|
||||
outputBuffer[i] = 0;
|
||||
}
|
||||
this.availableData -= available;
|
||||
return available > 0;
|
||||
}
|
||||
|
||||
available() {
|
||||
return this.availableData;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.readIndex = 0;
|
||||
this.writeIndex = 0;
|
||||
this.availableData = 0;
|
||||
}
|
||||
}
|
||||
|
||||
class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.ringBuffer = new RingBuffer(24000 * 30); // 30s initial capacity
|
||||
this.isPlaying = false;
|
||||
this.streamComplete = false;
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
const { type, samples } = event.data;
|
||||
if (type === "audio") {
|
||||
this.ringBuffer.push(samples);
|
||||
this.isPlaying = true;
|
||||
} else if (type === "clear") {
|
||||
this.ringBuffer.clear();
|
||||
this.isPlaying = false;
|
||||
this.streamComplete = false;
|
||||
} else if (type === "streamComplete") {
|
||||
this.streamComplete = true;
|
||||
} else if (type === "stop") {
|
||||
this.isPlaying = false;
|
||||
this.streamComplete = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
process(inputs, outputs) {
|
||||
const output = outputs[0];
|
||||
if (!output || output.length === 0) return true;
|
||||
|
||||
const channel = output[0];
|
||||
if (this.isPlaying) {
|
||||
this.ringBuffer.pull(channel);
|
||||
if (this.streamComplete && this.ringBuffer.available() === 0) {
|
||||
this.isPlaying = false;
|
||||
this.streamComplete = false;
|
||||
this.port.postMessage({ type: "ended" });
|
||||
}
|
||||
} else {
|
||||
channel.fill(0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("audio-playback-processor", AudioPlaybackProcessor);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Audio utility functions for voice chat.
|
||||
* Handles PCM16 decoding and AudioContext initialization.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decode base64 PCM16 audio to Float32Array for Web Audio API
|
||||
*/
|
||||
export function decodePCM16ToFloat32(base64Audio: string): Float32Array {
|
||||
const raw = atob(base64Audio);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
bytes[i] = raw.charCodeAt(i);
|
||||
}
|
||||
const pcm16 = new Int16Array(bytes.buffer);
|
||||
const float32 = new Float32Array(pcm16.length);
|
||||
for (let i = 0; i < pcm16.length; i++) {
|
||||
float32[i] = pcm16[i] / 32768;
|
||||
}
|
||||
return float32;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and initialize AudioContext with worklet
|
||||
*/
|
||||
export async function createAudioPlaybackContext(
|
||||
workletPath = "/audio-playback-worklet.js",
|
||||
sampleRate = 24000
|
||||
): Promise<{ ctx: AudioContext; worklet: AudioWorkletNode }> {
|
||||
const ctx = new AudioContext({ sampleRate });
|
||||
await ctx.audioWorklet.addModule(workletPath);
|
||||
const worklet = new AudioWorkletNode(ctx, "audio-playback-processor");
|
||||
worklet.connect(ctx.destination);
|
||||
return { ctx, worklet };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Voice chat client utilities for Replit AI Integrations.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Copy audio-playback-worklet.js to your public/ folder
|
||||
* 2. Import and use the React hooks in your components
|
||||
*
|
||||
* Example:
|
||||
* ```tsx
|
||||
* import { useVoiceRecorder, useVoiceStream } from "./audio";
|
||||
*
|
||||
* function VoiceChat() {
|
||||
* const [transcript, setTranscript] = useState("");
|
||||
* const recorder = useVoiceRecorder();
|
||||
* const stream = useVoiceStream({
|
||||
* onTranscript: (_, full) => setTranscript(full),
|
||||
* onComplete: (text) => console.log("Done:", text),
|
||||
* });
|
||||
*
|
||||
* const handleClick = async () => {
|
||||
* if (recorder.state === "recording") {
|
||||
* const blob = await recorder.stopRecording();
|
||||
* await stream.streamVoiceResponse("/api/voice-conversations/1/messages", blob);
|
||||
* } else {
|
||||
* await recorder.startRecording();
|
||||
* }
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <button onClick={handleClick}>
|
||||
* {recorder.state === "recording" ? "Stop" : "Record"}
|
||||
* </button>
|
||||
* <p>{transcript}</p>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { decodePCM16ToFloat32, createAudioPlaybackContext } from "./audio-utils";
|
||||
export { useVoiceRecorder, type RecordingState } from "./useVoiceRecorder";
|
||||
export { useAudioPlayback, type PlaybackState } from "./useAudioPlayback";
|
||||
export { useVoiceStream } from "./useVoiceStream";
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* React hook for streaming audio playback using AudioWorklet.
|
||||
* Supports real-time PCM16 audio streaming from SSE responses.
|
||||
* Includes sequence buffer for reordering out-of-order chunks.
|
||||
*/
|
||||
import { useRef, useCallback, useState } from "react";
|
||||
import { decodePCM16ToFloat32 } from "./audio-utils";
|
||||
|
||||
export type PlaybackState = "idle" | "playing" | "ended";
|
||||
|
||||
/**
|
||||
* Reorders audio chunks that may arrive out of sequence.
|
||||
* Buffers chunks until they can be played in correct order.
|
||||
*
|
||||
* Example: If chunks arrive as seq 2, seq 0, seq 1:
|
||||
* - seq 2 arrives → buffered (waiting for seq 0)
|
||||
* - seq 0 arrives → played immediately, then check buffer
|
||||
* - seq 1 arrives → played immediately (seq 0 done), seq 2 now plays
|
||||
*/
|
||||
class SequenceBuffer {
|
||||
private pending = new Map<number, string[]>();
|
||||
private nextSeq = 0;
|
||||
|
||||
/** Add chunk with sequence number, returns chunks ready to play in order */
|
||||
push(seq: number, data: string): string[] {
|
||||
// Store the chunk under its sequence number
|
||||
if (!this.pending.has(seq)) {
|
||||
this.pending.set(seq, []);
|
||||
}
|
||||
this.pending.get(seq)!.push(data);
|
||||
|
||||
// Drain consecutive ready sequences
|
||||
const ready: string[] = [];
|
||||
while (this.pending.has(this.nextSeq)) {
|
||||
ready.push(...this.pending.get(this.nextSeq)!);
|
||||
this.pending.delete(this.nextSeq);
|
||||
this.nextSeq++;
|
||||
}
|
||||
return ready;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.pending.clear();
|
||||
this.nextSeq = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function useAudioPlayback(workletPath = "/audio-playback-worklet.js") {
|
||||
const [state, setState] = useState<PlaybackState>("idle");
|
||||
const ctxRef = useRef<AudioContext | null>(null);
|
||||
const workletRef = useRef<AudioWorkletNode | null>(null);
|
||||
const readyRef = useRef(false);
|
||||
const seqBufferRef = useRef(new SequenceBuffer());
|
||||
|
||||
const init = useCallback(async () => {
|
||||
if (readyRef.current) return;
|
||||
|
||||
const ctx = new AudioContext({ sampleRate: 24000 });
|
||||
await ctx.audioWorklet.addModule(workletPath);
|
||||
const worklet = new AudioWorkletNode(ctx, "audio-playback-processor");
|
||||
worklet.connect(ctx.destination);
|
||||
|
||||
worklet.port.onmessage = (e) => {
|
||||
if (e.data.type === "ended") setState("idle");
|
||||
};
|
||||
|
||||
ctxRef.current = ctx;
|
||||
workletRef.current = worklet;
|
||||
readyRef.current = true;
|
||||
}, [workletPath]);
|
||||
|
||||
/** Push audio directly (no sequencing) - for simple streaming */
|
||||
const pushAudio = useCallback((base64Audio: string) => {
|
||||
if (!workletRef.current) return;
|
||||
const samples = decodePCM16ToFloat32(base64Audio);
|
||||
workletRef.current.port.postMessage({ type: "audio", samples });
|
||||
setState("playing");
|
||||
}, []);
|
||||
|
||||
/** Push audio with sequence number - reorders before playback */
|
||||
const pushSequencedAudio = useCallback((seq: number, base64Audio: string) => {
|
||||
if (!workletRef.current) return;
|
||||
|
||||
const readyChunks = seqBufferRef.current.push(seq, base64Audio);
|
||||
for (const chunk of readyChunks) {
|
||||
const samples = decodePCM16ToFloat32(chunk);
|
||||
workletRef.current.port.postMessage({ type: "audio", samples });
|
||||
}
|
||||
if (readyChunks.length > 0) {
|
||||
setState("playing");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signalComplete = useCallback(() => {
|
||||
workletRef.current?.port.postMessage({ type: "streamComplete" });
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
workletRef.current?.port.postMessage({ type: "clear" });
|
||||
seqBufferRef.current.reset();
|
||||
setState("idle");
|
||||
}, []);
|
||||
|
||||
return { state, init, pushAudio, pushSequencedAudio, signalComplete, clear };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* React hook for voice recording using MediaRecorder API.
|
||||
* Records audio in WebM/Opus format for efficient streaming.
|
||||
*/
|
||||
import { useRef, useCallback, useState } from "react";
|
||||
|
||||
export type RecordingState = "idle" | "recording" | "stopped";
|
||||
|
||||
export function useVoiceRecorder() {
|
||||
const [state, setState] = useState<RecordingState>("idle");
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
|
||||
const startRecording = useCallback(async (): Promise<void> => {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
mimeType: "audio/webm;codecs=opus",
|
||||
});
|
||||
|
||||
mediaRecorderRef.current = recorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
recorder.start(100); // Collect chunks every 100ms
|
||||
setState("recording");
|
||||
}, []);
|
||||
|
||||
const stopRecording = useCallback((): Promise<Blob> => {
|
||||
return new Promise((resolve) => {
|
||||
const recorder = mediaRecorderRef.current;
|
||||
if (!recorder || recorder.state !== "recording") {
|
||||
resolve(new Blob());
|
||||
return;
|
||||
}
|
||||
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||
recorder.stream.getTracks().forEach((t) => t.stop());
|
||||
setState("stopped");
|
||||
resolve(blob);
|
||||
};
|
||||
|
||||
recorder.stop();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { state, startRecording, stopRecording };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* React hook for handling SSE voice streaming responses.
|
||||
* Converts audio blob to base64 and sends as JSON to match server expectations.
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import { useAudioPlayback } from "./useAudioPlayback";
|
||||
|
||||
interface StreamCallbacks {
|
||||
onUserTranscript?: (text: string) => void;
|
||||
onTranscript?: (text: string, full: string) => void;
|
||||
onComplete?: (transcript: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function useVoiceStream(callbacks: StreamCallbacks = {}) {
|
||||
const playback = useAudioPlayback();
|
||||
|
||||
const streamVoiceResponse = useCallback(
|
||||
async (url: string, audioBlob: Blob) => {
|
||||
await playback.init();
|
||||
playback.clear();
|
||||
|
||||
// Convert blob to base64 for JSON body (server expects express.json())
|
||||
const base64Audio = await new Promise<string>((resolve) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = () => {
|
||||
const result = fileReader.result as string;
|
||||
resolve(result.split(",")[1]); // Remove data URL prefix
|
||||
};
|
||||
fileReader.readAsDataURL(audioBlob);
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ audio: base64Audio }),
|
||||
});
|
||||
if (!response.ok) throw new Error("Voice request failed");
|
||||
|
||||
const streamReader = response.body?.getReader();
|
||||
if (!streamReader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let fullTranscript = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await streamReader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6));
|
||||
|
||||
switch (event.type) {
|
||||
case "user_transcript":
|
||||
callbacks.onUserTranscript?.(event.data);
|
||||
break;
|
||||
case "transcript":
|
||||
fullTranscript += event.data;
|
||||
callbacks.onTranscript?.(event.data, fullTranscript);
|
||||
break;
|
||||
case "audio":
|
||||
playback.pushAudio(event.data);
|
||||
break;
|
||||
case "done":
|
||||
playback.signalComplete();
|
||||
callbacks.onComplete?.(fullTranscript);
|
||||
break;
|
||||
case "error":
|
||||
throw new Error(event.error);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!(e instanceof SyntaxError)) {
|
||||
callbacks.onError?.(e as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[playback, callbacks]
|
||||
);
|
||||
|
||||
return { streamVoiceResponse, playbackState: playback.state };
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import Home from "@/pages/home";
|
||||
import ArticlePage from "@/pages/article";
|
||||
import CategoryPage from "@/pages/category";
|
||||
import VideosPage from "@/pages/videos";
|
||||
import GalleryPageWrapper from "@/pages/gallery";
|
||||
import HoroscopePage from "@/pages/horoscope";
|
||||
import RecipesPage from "@/pages/recipes";
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
@@ -16,6 +19,10 @@ function Router() {
|
||||
<Route path="/article/:slug" component={ArticlePage} />
|
||||
<Route path="/category/:category" component={CategoryPage} />
|
||||
<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 component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@@ -17,9 +17,27 @@ export default function Footer() {
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link href="/category/News">
|
||||
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-news">
|
||||
News
|
||||
</span>
|
||||
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-news">News</span>
|
||||
</Link>
|
||||
</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>
|
||||
</ul>
|
||||
|
||||
@@ -8,6 +8,9 @@ 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" },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { Star, Heart, Briefcase, TrendingUp, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { SIGNS, ELEMENT_COLORS, getHoroscope, getRating } from "@/lib/horoscope-data";
|
||||
|
||||
function MiniStars({ count, max = 5 }: { count: number; max?: number }) {
|
||||
return (
|
||||
<div className="flex gap-px">
|
||||
{Array.from({ length: max }).map((_, i) => (
|
||||
<div key={i} className={`w-1.5 h-1.5 rounded-full ${i < count ? "bg-amber-400" : "bg-white/10"}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HoroscopeWidget() {
|
||||
const [, navigate] = useLocation();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const next = useCallback(() => setIndex((i) => (i + 1) % SIGNS.length), []);
|
||||
const prev = useCallback(() => setIndex((i) => (i - 1 + SIGNS.length) % SIGNS.length), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const timer = setInterval(next, 6000);
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, next]);
|
||||
|
||||
const sign = SIGNS[index];
|
||||
const horoscope = getHoroscope(index);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-card-border overflow-hidden h-full w-full cursor-pointer group hover:border-primary/50 transition-colors flex flex-col"
|
||||
style={{ background: "linear-gradient(135deg, hsl(250 30% 14%), hsl(270 25% 10%))" }}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onClick={() => navigate("/horoskop")}
|
||||
data-testid="widget-horoscope"
|
||||
>
|
||||
<div className="p-3 flex items-center justify-between border-b border-white/10 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-4 h-4 text-amber-400" />
|
||||
<h3 className="font-bold text-white text-sm">Horoskop</h3>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); prev(); }}
|
||||
className="w-5 h-5 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
||||
data-testid="button-horoscope-prev"
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3 text-white/70" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); next(); }}
|
||||
className="w-5 h-5 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
||||
data-testid="button-horoscope-next"
|
||||
>
|
||||
<ChevronRight className="w-3 h-3 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
<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>
|
||||
<p className="text-[10px] text-white/50">{sign.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-1 px-3 py-1.5 border-t border-b border-white/5">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<Heart className="w-3 h-3 text-rose-400" />
|
||||
<MiniStars count={getRating(index, "love")} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<Briefcase className="w-3 h-3 text-amber-400" />
|
||||
<MiniStars count={getRating(index, "career")} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<TrendingUp className="w-3 h-3 text-emerald-400" />
|
||||
<MiniStars count={getRating(index, "health")} />
|
||||
</div>
|
||||
</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-[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) => (
|
||||
<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}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Newspaper, ExternalLink } from "lucide-react";
|
||||
|
||||
interface NewsItem {
|
||||
title: string;
|
||||
link: string;
|
||||
source: string;
|
||||
pubDate: string;
|
||||
}
|
||||
|
||||
const VISIBLE_COUNT = 5;
|
||||
|
||||
export function NewsWidget() {
|
||||
const { data: news, isLoading } = useQuery<NewsItem[]>({
|
||||
queryKey: ["/api/news-feed"],
|
||||
});
|
||||
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const items = news || [];
|
||||
const total = items.length;
|
||||
|
||||
const advance = useCallback(() => {
|
||||
if (total <= VISIBLE_COUNT) return;
|
||||
setOffset((o) => (o + 1) % total);
|
||||
}, [total]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || total <= VISIBLE_COUNT) return;
|
||||
const timer = setInterval(advance, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, advance, total]);
|
||||
|
||||
const visible: NewsItem[] = [];
|
||||
for (let i = 0; i < Math.min(VISIBLE_COUNT, total); i++) {
|
||||
visible.push(items[(offset + i) % total]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div className="p-3 flex items-center gap-2 border-b border-card-border flex-shrink-0">
|
||||
<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 justify-between">
|
||||
{visible.map((item, i) => (
|
||||
<a
|
||||
key={`${offset}-${i}`}
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block group cursor-pointer"
|
||||
data-testid={`link-news-${i}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-xs font-medium text-card-foreground line-clamp-2 group-hover:text-primary transition-colors leading-snug">
|
||||
{item.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span className="text-[10px] text-primary font-medium">{item.source}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{item.pubDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-muted-foreground flex-shrink-0 mt-0.5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
{i < VISIBLE_COUNT - 1 && <div className="border-b border-card-border mt-2 mb-2" />}
|
||||
</a>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="space-y-3 flex-1">
|
||||
{Array.from({ length: VISIBLE_COUNT }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="h-3 bg-muted animate-pulse rounded w-full" />
|
||||
<div className="h-3 bg-muted animate-pulse rounded w-3/4" />
|
||||
<div className="h-2 bg-muted animate-pulse rounded w-1/3 mt-1" />
|
||||
{i < VISIBLE_COUNT - 1 && <div className="border-b border-card-border mt-3" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && total === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-2">Keine Nachrichten verfügbar</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { ChevronLeft, ChevronRight, X, Images, Maximize2 } from "lucide-react";
|
||||
|
||||
interface GalleryImage {
|
||||
folder: string;
|
||||
fileName: string;
|
||||
thumb: string;
|
||||
large: string;
|
||||
}
|
||||
|
||||
function Lightbox({
|
||||
images,
|
||||
startIndex,
|
||||
onClose,
|
||||
}: {
|
||||
images: GalleryImage[];
|
||||
startIndex: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(startIndex);
|
||||
|
||||
const prev = useCallback(() => setIndex((i) => (i - 1 + images.length) % images.length), [images.length]);
|
||||
const next = useCallback(() => setIndex((i) => (i + 1) % images.length), [images.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "ArrowLeft") prev();
|
||||
if (e.key === "ArrowRight") next();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose, prev, next]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
data-testid="lightbox-overlay"
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
className="absolute top-4 right-4 text-white/70 hover:text-white z-50 p-2"
|
||||
data-testid="button-lightbox-close"
|
||||
>
|
||||
<X className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); prev(); }}
|
||||
className="absolute left-2 md:left-6 text-white/70 hover:text-white z-50 p-2"
|
||||
data-testid="button-lightbox-prev"
|
||||
>
|
||||
<ChevronLeft className="w-10 h-10" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); next(); }}
|
||||
className="absolute right-2 md:right-6 text-white/70 hover:text-white z-50 p-2"
|
||||
data-testid="button-lightbox-next"
|
||||
>
|
||||
<ChevronRight className="w-10 h-10" />
|
||||
</button>
|
||||
|
||||
<div className="max-w-[90vw] max-h-[85vh] flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
|
||||
<img
|
||||
src={images[index].large}
|
||||
alt={images[index].fileName}
|
||||
className="max-w-full max-h-[85vh] object-contain rounded-lg"
|
||||
data-testid="img-lightbox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 text-white/60 text-sm" data-testid="text-lightbox-counter">
|
||||
{index + 1} / {images.length}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleImageCarousel({
|
||||
images,
|
||||
autoPlay = true,
|
||||
interval = 5000,
|
||||
onExpand,
|
||||
}: {
|
||||
images: GalleryImage[];
|
||||
autoPlay?: boolean;
|
||||
interval?: number;
|
||||
onExpand?: (index: number) => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const prev = useCallback(() => setIndex((i) => (i - 1 + images.length) % images.length), [images.length]);
|
||||
const next = useCallback(() => setIndex((i) => (i + 1) % images.length), [images.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoPlay || paused || images.length <= 1) return;
|
||||
const timer = setInterval(next, interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoPlay, paused, next, interval, images.length]);
|
||||
|
||||
if (images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full h-full"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<div className="relative w-full aspect-[4/5] overflow-hidden rounded-b-lg">
|
||||
<img
|
||||
src={images[index].large || images[index].thumb}
|
||||
alt={images[index].fileName}
|
||||
className="w-full h-full object-cover transition-opacity duration-500"
|
||||
style={{ objectPosition: "center 25%" }}
|
||||
loading="lazy"
|
||||
data-testid="img-gallery-current"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" />
|
||||
|
||||
<button
|
||||
onClick={prev}
|
||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white/80 hover:text-white transition-all"
|
||||
data-testid="button-gallery-prev"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={next}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white/80 hover:text-white transition-all"
|
||||
data-testid="button-gallery-next"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{onExpand && (
|
||||
<button
|
||||
onClick={() => onExpand(index)}
|
||||
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white/80 hover:text-white transition-all"
|
||||
data-testid="button-gallery-expand"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-1">
|
||||
{images.slice(0, Math.min(images.length, 20)).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all ${i === index ? "bg-white w-3" : "bg-white/40 hover:bg-white/60"}`}
|
||||
data-testid={`button-gallery-dot-${i}`}
|
||||
/>
|
||||
))}
|
||||
{images.length > 20 && (
|
||||
<span className="text-white/50 text-[9px] ml-1">+{images.length - 20}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PhotoGalleryWidget() {
|
||||
const { data: images, isLoading } = useQuery<GalleryImage[]>({
|
||||
queryKey: ["/api/gallery"],
|
||||
});
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-card rounded-lg border border-card-border overflow-hidden h-full" data-testid="widget-gallery-loading">
|
||||
<div className="p-3 flex items-center gap-2 border-b border-card-border">
|
||||
<Images className="w-4 h-4 text-primary" />
|
||||
<h3 className="font-bold text-card-foreground text-sm">Fotogalerie</h3>
|
||||
</div>
|
||||
<div className="aspect-[4/5] bg-muted animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!images || images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-card rounded-lg border border-card-border overflow-hidden h-full" data-testid="widget-gallery">
|
||||
<div className="p-3 flex items-center gap-2 border-b border-card-border">
|
||||
<Images className="w-4 h-4 text-primary" />
|
||||
<h3 className="font-bold text-card-foreground text-sm">Fotogalerie</h3>
|
||||
</div>
|
||||
<SingleImageCarousel
|
||||
images={images}
|
||||
autoPlay={true}
|
||||
interval={10000}
|
||||
onExpand={(i) => setLightboxIndex(i)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<Lightbox
|
||||
images={images}
|
||||
startIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GalleryPage() {
|
||||
const { data: images, isLoading } = useQuery<GalleryImage[]>({
|
||||
queryKey: ["/api/gallery"],
|
||||
});
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div data-testid="page-gallery">
|
||||
{isLoading ? (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="aspect-[4/3] bg-card rounded-lg animate-pulse" />
|
||||
</div>
|
||||
) : images && images.length > 0 ? (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-card rounded-lg border border-card-border overflow-hidden">
|
||||
<SingleImageCarousel
|
||||
images={images}
|
||||
autoPlay={false}
|
||||
onExpand={(i) => setLightboxIndex(i)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-muted-foreground text-sm mt-3" data-testid="text-gallery-count">
|
||||
{images.length} Fotos
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground">Keine Fotos vorhanden</p>
|
||||
)}
|
||||
|
||||
{lightboxIndex !== null && images && (
|
||||
<Lightbox
|
||||
images={images}
|
||||
startIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChefHat, Clock, Users } from "lucide-react";
|
||||
|
||||
interface WidgetRecipe {
|
||||
title: string;
|
||||
image: string;
|
||||
time: string;
|
||||
servings: string;
|
||||
}
|
||||
|
||||
const WIDGET_RECIPES: WidgetRecipe[] = [
|
||||
{ title: "Kaiserschmarrn", image: "/uploads/recipe-kaiserschmarrn.png", time: "25 Min.", servings: "2" },
|
||||
{ title: "Wiener Schnitzel", image: "/uploads/recipe-wiener-schnitzel.png", time: "30 Min.", servings: "4" },
|
||||
{ title: "Apfelstrudel", image: "/uploads/recipe-apfelstrudel.png", time: "60 Min.", servings: "6" },
|
||||
{ title: "Schweinshaxe", image: "/uploads/recipe-schweinshaxe.png", time: "180 Min.", servings: "4" },
|
||||
{ title: "Käsespätzle", image: "/uploads/recipe-kaesespaetzle.png", time: "45 Min.", servings: "4" },
|
||||
{ title: "Sachertorte", image: "/uploads/recipe-sachertorte.png", time: "90 Min.", servings: "8" },
|
||||
{ title: "Maultaschen", image: "/uploads/recipe-maultaschen.png", time: "90 Min.", servings: "4" },
|
||||
{ title: "Schlutzkrapfen", image: "/uploads/recipe-schlutzkrapfen.png", 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];
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate("/rezepte")}
|
||||
className="bg-card rounded-lg border border-card-border overflow-hidden h-full w-full text-left cursor-pointer group hover:border-primary/50 transition-colors flex flex-col"
|
||||
data-testid="widget-recipe"
|
||||
>
|
||||
<div className="p-3 flex items-center gap-2 border-b border-card-border flex-shrink-0">
|
||||
<ChefHat className="w-4 h-4 text-primary" />
|
||||
<h3 className="font-bold text-card-foreground text-sm">Rezept des Tages</h3>
|
||||
</div>
|
||||
<div className="relative flex-1">
|
||||
<img
|
||||
src={recipe.image}
|
||||
alt={recipe.title}
|
||||
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 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>
|
||||
<p className="text-[10px] text-primary mt-1 group-hover:underline">Alle Rezepte ansehen</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
export const ELEMENT_COLORS: Record<string, { bg: string; border: string; text: string; glow: string }> = {
|
||||
Feuer: { bg: "bg-red-500/10", border: "border-red-500/30", text: "text-red-400", glow: "shadow-red-500/20" },
|
||||
Erde: { bg: "bg-emerald-500/10", border: "border-emerald-500/30", text: "text-emerald-400", glow: "shadow-emerald-500/20" },
|
||||
Luft: { bg: "bg-violet-500/10", border: "border-violet-500/30", text: "text-violet-400", glow: "shadow-violet-500/20" },
|
||||
Wasser: { bg: "bg-blue-500/10", border: "border-blue-500/30", text: "text-blue-400", glow: "shadow-blue-500/20" },
|
||||
};
|
||||
|
||||
export const SIGNS = [
|
||||
{ name: "Widder", symbol: "♈", date: "21.03 – 19.04", element: "Feuer", planet: "Mars", color: "Rot", stone: "Diamant", compatible: ["Löwe", "Schütze", "Zwillinge"] },
|
||||
{ name: "Stier", symbol: "♉", date: "20.04 – 20.05", element: "Erde", planet: "Venus", color: "Grün", stone: "Smaragd", compatible: ["Jungfrau", "Steinbock", "Krebs"] },
|
||||
{ name: "Zwillinge", symbol: "♊", date: "21.05 – 20.06", element: "Luft", planet: "Merkur", color: "Gelb", stone: "Achat", compatible: ["Waage", "Wassermann", "Widder"] },
|
||||
{ name: "Krebs", symbol: "♋", date: "21.06 – 22.07", element: "Wasser", planet: "Mond", color: "Silber", stone: "Mondstein", compatible: ["Skorpion", "Fische", "Stier"] },
|
||||
{ name: "Löwe", symbol: "♌", date: "23.07 – 22.08", element: "Feuer", planet: "Sonne", color: "Gold", stone: "Rubin", compatible: ["Widder", "Schütze", "Waage"] },
|
||||
{ name: "Jungfrau", symbol: "♍", date: "23.08 – 22.09", element: "Erde", planet: "Merkur", color: "Braun", stone: "Saphir", compatible: ["Stier", "Steinbock", "Skorpion"] },
|
||||
{ name: "Waage", symbol: "♎", date: "23.09 – 22.10", element: "Luft", planet: "Venus", color: "Rosa", stone: "Opal", compatible: ["Zwillinge", "Wassermann", "Löwe"] },
|
||||
{ name: "Skorpion", symbol: "♏", date: "23.10 – 21.11", element: "Wasser", planet: "Pluto", color: "Dunkelrot", stone: "Topas", compatible: ["Krebs", "Fische", "Jungfrau"] },
|
||||
{ name: "Schütze", symbol: "♐", date: "22.11 – 21.12", element: "Feuer", planet: "Jupiter", color: "Violett", stone: "Türkis", compatible: ["Widder", "Löwe", "Wassermann"] },
|
||||
{ name: "Steinbock", symbol: "♑", date: "22.12 – 19.01", element: "Erde", planet: "Saturn", color: "Schwarz", stone: "Granat", compatible: ["Stier", "Jungfrau", "Fische"] },
|
||||
{ name: "Wassermann", symbol: "♒", date: "20.01 – 18.02", element: "Luft", planet: "Uranus", color: "Blau", stone: "Amethyst", compatible: ["Zwillinge", "Waage", "Schütze"] },
|
||||
{ name: "Fische", symbol: "♓", date: "19.02 – 20.03", element: "Wasser", planet: "Neptun", color: "Meeresblau", stone: "Aquamarin", compatible: ["Krebs", "Skorpion", "Steinbock"] },
|
||||
];
|
||||
|
||||
export const DAILY_HOROSCOPES = [
|
||||
{
|
||||
general: "Ein wunderbarer Tag für neue Begegnungen. Die Sterne stehen günstig für spontane Entscheidungen. Sie spüren eine besondere Energie, die Sie antreibt, Neues zu wagen. Nutzen Sie diese Kraft und gehen Sie auf Menschen zu – unerwartete Gespräche könnten Ihr Leben bereichern. Ihr natürlicher Optimismus strahlt heute besonders hell und zieht positive Situationen an.",
|
||||
love: "In der Liebe zeigt sich heute eine besondere Verbindung. Singles könnten jemand Besonderen treffen – halten Sie die Augen offen! Paare erleben einen harmonischen Tag voller Zärtlichkeit. Ein romantisches Abendessen oder ein gemeinsamer Spaziergang stärkt Ihre Beziehung.",
|
||||
career: "Beruflich stehen die Zeichen auf Erfolg. Ein neues Projekt könnte sich anbieten, das Ihre Fähigkeiten perfekt zur Geltung bringt. Zeigen Sie Initiative und Ihr Vorgesetzter wird beeindruckt sein. Finanzielle Entscheidungen sollten heute wohlüberlegt getroffen werden.",
|
||||
health: "Ihre Vitalität ist heute auf einem Höhepunkt. Nutzen Sie die Energie für sportliche Aktivitäten oder einen langen Spaziergang in der Natur. Achten Sie darauf, ausreichend Wasser zu trinken und gönnen Sie sich am Abend eine entspannende Auszeit.",
|
||||
tip: "Tragen Sie heute etwas Rotes – es verstärkt Ihre natürliche Ausstrahlung und zieht positive Energie an.",
|
||||
weekly: "Diese Woche steht im Zeichen des Neuanfangs. Am Montag und Dienstag sollten Sie wichtige Gespräche führen. Mittwoch bringt eine überraschende Wendung in finanziellen Angelegenheiten. Das Wochenende eignet sich perfekt für romantische Unternehmungen und Familientreffen.",
|
||||
monthly: "Der Monat beginnt mit einer Phase der Kreativität und neuer Ideen. Ab Mitte des Monats rückt die Karriere in den Vordergrund – nutzen Sie die Chance für berufliche Veränderungen. Ende des Monats bringt Venus harmonische Energien für Ihre Beziehungen."
|
||||
},
|
||||
{
|
||||
general: "Heute ist Geduld gefragt. Lassen Sie sich Zeit und genießen Sie die kleinen Freuden des Alltags. Nicht alles muss sofort erledigt werden – manchmal bringt ein langsameres Tempo bessere Ergebnisse. Die Sterne empfehlen Ihnen, sich auf das Wesentliche zu konzentrieren und unnötigen Stress zu vermeiden. Am Nachmittag lichtet sich der Nebel und Sie sehen klarer.",
|
||||
love: "Die Liebe braucht heute besondere Aufmerksamkeit. Nehmen Sie sich Zeit für tiefgründige Gespräche mit Ihrem Partner. Singles sollten nicht zu schnell urteilen – der erste Eindruck kann täuschen. Ein zweiter Blick lohnt sich, denn wahre Schönheit zeigt sich erst bei näherem Kennenlernen.",
|
||||
career: "Im Beruf ist Beharrlichkeit der Schlüssel zum Erfolg. Ein schwieriges Projekt erfordert Ihre volle Konzentration. Lassen Sie sich nicht von Rückschlägen entmutigen – Ihr Durchhaltevermögen wird sich langfristig auszahlen. Finanziell ist Sparsamkeit angesagt.",
|
||||
health: "Achten Sie heute besonders auf Ihre Ernährung. Frisches Obst und Gemüse geben Ihrem Körper die nötigen Vitamine. Eine kurze Meditation am Morgen oder Abend hilft Ihnen, innere Ruhe zu finden und den Alltagsstress abzubauen.",
|
||||
tip: "Kochen Sie heute Ihr Lieblingsgericht – es nährt nicht nur den Körper, sondern auch die Seele.",
|
||||
weekly: "Geduld ist diese Woche Ihre Stärke. Anfang der Woche klären sich finanzielle Fragen. Donnerstag und Freitag sind ideal für wichtige Verhandlungen. Am Wochenende belohnen Sie sich für Ihre harte Arbeit mit einem besonderen Genuss.",
|
||||
monthly: "Dieser Monat fördert Ihre materielle Sicherheit. Saturn unterstützt langfristige Investitionen. In der Monatsmitte stärkt ein Vollmond Ihre emotionalen Beziehungen. Gegen Ende des Monats ergeben sich neue berufliche Perspektiven."
|
||||
},
|
||||
{
|
||||
general: "Kommunikation steht im Vordergrund. Ein gutes Gespräch kann heute Wunder wirken. Ihre Worte haben besondere Kraft und können Brücken bauen. Nutzen Sie diese Gabe, um Missverständnisse zu klären oder neue Kontakte zu knüpfen. Am Abend erwartet Sie eine überraschende Nachricht, die Ihre Stimmung hebt.",
|
||||
love: "Reden Sie über Ihre Gefühle! Heute ist der perfekte Tag, um Ihrem Partner oder einer besonderen Person mitzuteilen, was Sie empfinden. Offenheit wird belohnt und kann Ihre Beziehung auf ein neues Level heben. Singles sollten gesellige Orte aufsuchen.",
|
||||
career: "Ein wichtiges Meeting oder Telefonat könnte heute den Durchbruch bringen. Bereiten Sie sich gut vor und präsentieren Sie Ihre Ideen selbstbewusst. Teamarbeit steht unter einem guten Stern – gemeinsam erreichen Sie mehr als allein.",
|
||||
health: "Soziale Kontakte sind heute Ihre beste Medizin. Ein Treffen mit Freunden oder ein anregendes Gespräch mit einem Kollegen hebt Ihre Stimmung und stärkt Ihr Wohlbefinden. Achten Sie auf ausreichend Schlaf – Ihr Geist braucht Erholung.",
|
||||
tip: "Schreiben Sie jemandem, den Sie schon lange nicht mehr kontaktiert haben – es wird beiden guttun.",
|
||||
weekly: "Eine kommunikationsreiche Woche erwartet Sie. Montag und Dienstag bringen wichtige Nachrichten. Am Mittwoch ist ein guter Zeitpunkt für kreative Projekte. Das Wochenende lädt zu geselligen Aktivitäten und neuen Bekanntschaften ein.",
|
||||
monthly: "Merkur verleiht Ihnen diesen Monat besondere Überzeugungskraft. Berufliche Präsentationen und Verhandlungen gelingen mühelos. Ab der Monatsmitte rücken Reisen und Weiterbildung in den Fokus. Ende des Monats bringt spannende soziale Ereignisse."
|
||||
},
|
||||
{
|
||||
general: "Vertrauen Sie auf Ihre Intuition. Ihr Bauchgefühl weist Ihnen den richtigen Weg. Heute spüren Sie besonders deutlich, was richtig und was falsch ist. Lassen Sie sich nicht von der Meinung anderer beirren – Ihre innere Stimme kennt die Antwort. Am Abend finden Sie Ruhe und Klarheit.",
|
||||
love: "Emotionale Tiefe prägt heute Ihre Beziehungen. Sie spüren intuitiv, was Ihr Partner braucht, und können liebevoll darauf eingehen. Singles erkennen sofort, ob jemand zu ihnen passt. Vertrauen Sie diesem Gefühl – es wird Sie nicht täuschen.",
|
||||
career: "Kreative Lösungen sind heute gefragt. Verlassen Sie ausgetretene Pfade und denken Sie um die Ecke. Ihre Intuition führt Sie zu innovativen Ansätzen, die andere beeindrucken werden. Finanzielle Entscheidungen sollten aus dem Bauch heraus getroffen werden.",
|
||||
health: "Hören Sie heute besonders auf Ihren Körper. Wenn er Ruhe fordert, gönnen Sie sie ihm. Ein warmes Bad, beruhigende Musik oder eine Tasse Kräutertee können Wunder wirken. Yoga oder leichte Dehnübungen harmonisieren Körper und Geist.",
|
||||
tip: "Zünden Sie am Abend eine Kerze an und nehmen Sie sich zehn Minuten nur für sich – Meditation oder einfaches Stillsein.",
|
||||
weekly: "Eine intuitive Woche, in der Sie Ihrem Bauchgefühl folgen sollten. Dienstag bringt emotionale Klarheit. Donnerstag und Freitag sind günstig für familiäre Angelegenheiten. Am Wochenende finden Sie Erholung am Wasser oder in der Natur.",
|
||||
monthly: "Der Mond schenkt Ihnen diesen Monat besondere emotionale Stärke. Familiäre Bindungen vertiefen sich in der ersten Monatshälfte. Ab Mitte des Monats rückt Ihr häusliches Umfeld in den Fokus – ideale Zeit für Renovierungen. Monatsende bringt romantische Überraschungen."
|
||||
},
|
||||
{
|
||||
general: "Kreativität und Mut werden heute belohnt. Zeigen Sie, was in Ihnen steckt! Die kosmische Energie unterstützt alle künstlerischen und kreativen Vorhaben. Ob Musik, Malerei oder ein neues Projekt – jetzt ist die Zeit, Ihre Talente zu zeigen. Ihre Begeisterung ist ansteckend und inspiriert auch andere.",
|
||||
love: "Romantik liegt in der Luft! Überraschen Sie Ihren Partner mit einer liebevollen Geste oder einem spontanen Ausflug. Singles strahlen heute eine besondere Anziehungskraft aus – nutzen Sie das! Ein Flirt beim Konzert oder Festival könnte der Beginn von etwas Schönem sein.",
|
||||
career: "Ihre kreative Seite ist heute Ihr größtes Kapital. Neue Ideen sprudeln nur so aus Ihnen heraus und finden Anerkennung bei Kollegen und Vorgesetzten. Ein Moment im Rampenlicht steht bevor – genießen Sie ihn! Finanziell könnten sich neue Möglichkeiten ergeben.",
|
||||
health: "Bewegung und Aktivität tun Ihnen heute besonders gut. Tanzen Sie zu Ihrer Lieblingsmusik, gehen Sie schwimmen oder machen Sie einen Ausflug in die Berge. Ihre Energie ist grenzenlos – nutzen Sie sie für Aktivitäten, die Ihnen Freude bereiten.",
|
||||
tip: "Hören Sie heute bewusst Volksmusik oder besuchen Sie ein Konzert – Musik nährt die Seele.",
|
||||
weekly: "Eine glamouröse Woche voller Möglichkeiten! Am Montag starten Sie mit Energie durch. Mittwoch bringt kreative Inspiration. Freitag und Samstag sind perfekt für gesellschaftliche Ereignisse, bei denen Sie im Mittelpunkt stehen.",
|
||||
monthly: "Die Sonne stärkt Ihr Selbstbewusstsein den ganzen Monat über. Kreative Projekte gelingen in der ersten Woche besonders gut. Mitte des Monats bringt beruflichen Aufstieg. Ende des Monats eignet sich für große romantische Gesten und Feiern."
|
||||
},
|
||||
{
|
||||
general: "Ordnung und Struktur bringen heute den Erfolg. Nutzen Sie die Energie für wichtige Aufgaben. Ein gut organisierter Tag bringt mehr Ergebnisse als hektisches Multitasking. Erstellen Sie eine Prioritätenliste und arbeiten Sie sie Schritt für Schritt ab. Am Ende des Tages werden Sie stolz auf das Erreichte sein.",
|
||||
love: "Kleine Aufmerksamkeiten machen heute den Unterschied in der Liebe. Ein liebevoll zubereitetes Frühstück, eine handgeschriebene Nachricht oder ein unerwartetes Kompliment – es sind die Details, die zählen. Singles sollten ihre Ansprüche nicht zu hoch schrauben.",
|
||||
career: "Perfektion ist heute Ihr Markenzeichen. Nutzen Sie Ihren Blick fürs Detail, um ein wichtiges Projekt zu vervollständigen. Kollegen schätzen Ihre Zuverlässigkeit und Gründlichkeit. Ein Gespräch mit dem Chef könnte positive Entwicklungen anstoßen.",
|
||||
health: "Strukturieren Sie Ihre Mahlzeiten und achten Sie auf regelmäßige Pausen. Ein aufgeräumter Arbeitsplatz und ein ordentliches Zuhause tragen zu Ihrem Wohlbefinden bei. Am Abend sorgt ein gutes Buch für die nötige Entspannung.",
|
||||
tip: "Räumen Sie heute eine Schublade oder einen Schrank auf – äußere Ordnung schafft innere Klarheit.",
|
||||
weekly: "Diese Woche ist ideal für Planung und Organisation. Anfang der Woche setzen Sie Prioritäten. Mittwoch und Donnerstag bringen Fortschritte bei wichtigen Projekten. Das Wochenende eignet sich für Gesundheitsvorsorge und Wellness.",
|
||||
monthly: "Merkur fördert Ihre analytischen Fähigkeiten den ganzen Monat. Gesundheitliche Verbesserungen zeigen sich ab der zweiten Woche. Berufliche Anerkennung kommt zur Monatsmitte. Ende des Monats bringt finanzielle Klarheit und neue Sparmöglichkeiten."
|
||||
},
|
||||
{
|
||||
general: "Harmonie und Ausgeglichenheit bestimmen den Tag. Genießen Sie die Zeit mit Ihren Liebsten. Die Sterne schenken Ihnen heute ein besonderes Gespür für Schönheit und Ästhetik. Umgeben Sie sich mit angenehmen Dingen und Menschen, die Ihnen guttun. Konflikte lassen sich heute leicht lösen.",
|
||||
love: "Ein Tag wie geschaffen für die Liebe! Paare erleben eine Phase tiefer Verbundenheit und gegenseitigen Verständnisses. Gemeinsame Aktivitäten stärken die Beziehung. Singles sollten sich schick machen und ausgehen – Venus begünstigt neue Begegnungen.",
|
||||
career: "Diplomatie ist heute Ihre Stärke. Vermitteln Sie geschickt zwischen verschiedenen Positionen und finden Sie Kompromisse, mit denen alle zufrieden sind. Ihre soziale Kompetenz wird anerkannt und könnte zu einer Beförderung führen.",
|
||||
health: "Gönnen Sie sich heute etwas Besonderes – einen Wellness-Tag, ein entspannendes Bad oder eine Massage. Ihr Körper und Geist verdienen Verwöhnung. Schöne Musik und gutes Essen tragen zu Ihrem Wohlbefinden bei.",
|
||||
tip: "Besuchen Sie heute ein Museum, eine Ausstellung oder einen schönen Garten – Schönheit nährt die Seele.",
|
||||
weekly: "Venus schenkt Ihnen eine harmonische Woche. Montag und Dienstag sind ideal für Partnerschaftsgespräche. Donnerstag bringt eine kreative Überraschung. Am Wochenende genießen Sie Kultur, Kunst und gute Gesellschaft.",
|
||||
monthly: "Dieser Monat steht unter dem Zeichen der Harmonie. Beziehungen vertiefen sich in den ersten zwei Wochen. Kreative Projekte blühen zur Monatsmitte auf. Ende des Monats bringt gesellschaftliche Höhepunkte und neue Freundschaften."
|
||||
},
|
||||
{
|
||||
general: "Tiefgründige Erkenntnisse warten auf Sie. Nehmen Sie sich Zeit für Reflexion. Heute können Sie unter die Oberfläche schauen und verborgene Wahrheiten entdecken. Ihre analytische Kraft ist besonders stark – nutzen Sie sie, um wichtige Lebensfragen zu beantworten. Transformation und Wandel liegen in der Luft.",
|
||||
love: "Intensive Emotionen prägen heute Ihre Beziehungen. Tiefe Gespräche über Wünsche und Sehnsüchte bringen Sie und Ihren Partner näher zusammen. Singles könnten eine magnetische Anziehung zu einer bestimmten Person spüren – lassen Sie sich darauf ein.",
|
||||
career: "Forschen Sie heute tiefer – ob bei der Arbeit oder in persönlichen Projekten. Ihre Fähigkeit, Zusammenhänge zu erkennen, die anderen verborgen bleiben, macht Sie heute unverzichtbar. Finanzielle Angelegenheiten erfordern gründliche Analyse.",
|
||||
health: "Emotionale Reinigung steht an. Lassen Sie alte Verletzungen los und vergeben Sie – sich selbst und anderen. Ein Tagebucheintrag oder ein Gespräch mit einer Vertrauensperson kann befreiend wirken. Achten Sie auf ausreichend Schlaf.",
|
||||
tip: "Schreiben Sie heute Ihre Gedanken und Gefühle auf – das Aufschreiben wirkt klärend und befreiend.",
|
||||
weekly: "Eine transformative Woche erwartet Sie. Dienstag bringt wichtige Erkenntnisse. Donnerstag ist günstig für finanzielle Entscheidungen. Am Wochenende können Sie alte Muster loslassen und Platz für Neues schaffen.",
|
||||
monthly: "Pluto fördert tiefgreifende Veränderungen in diesem Monat. Die erste Woche bringt emotionale Durchbrüche. Finanzen verbessern sich zur Monatsmitte. Ende des Monats stärkt Ihre Willenskraft und Entschlossenheit für neue Ziele."
|
||||
},
|
||||
{
|
||||
general: "Abenteuerlust liegt in der Luft! Planen Sie etwas Besonderes für heute. Die Sterne wecken Ihre Reiselust und den Wunsch nach neuen Erfahrungen. Ob ein Tagesausflug in die Berge, ein neues Restaurant oder ein spannendes Buch – erweitern Sie Ihren Horizont! Optimismus und Lebensfreude begleiten Sie durch den Tag.",
|
||||
love: "Abenteuer zu zweit stärken die Beziehung. Planen Sie etwas Ungewöhnliches mit Ihrem Partner – ein spontaner Ausflug oder ein gemeinsames Hobby. Singles finden die Liebe an unerwarteten Orten – vielleicht bei einer Wanderung oder auf einem Volksfest.",
|
||||
career: "Denken Sie heute groß! Neue berufliche Chancen könnten sich am Horizont zeigen. Internationale Kontakte oder Weiterbildungen stehen unter einem guten Stern. Ihre Begeisterungsfähigkeit öffnet Türen, die anderen verschlossen bleiben.",
|
||||
health: "Bewegung an der frischen Luft ist heute das beste Rezept für Körper und Seele. Eine Wanderung, Radtour oder ein Spaziergang durch die Natur laden Ihre Batterien auf. Probieren Sie ein neues Gericht aus einer anderen Kultur – es bereichert Ihren Speiseplan.",
|
||||
tip: "Machen Sie heute etwas, das Sie noch nie gemacht haben – neue Erfahrungen halten jung und vital.",
|
||||
weekly: "Eine abenteuerliche Woche! Montag startet mit Reiselust. Mittwoch und Donnerstag bringen Lernmöglichkeiten. Freitag ist ideal für kulturelle Erlebnisse. Am Wochenende zieht es Sie in die Ferne – folgen Sie dem Ruf!",
|
||||
monthly: "Jupiter erweitert Ihren Horizont diesen Monat. Reisemöglichkeiten ergeben sich in der ersten Hälfte. Weiterbildung und neue Kenntnisse stehen ab der Monatsmitte im Fokus. Ende des Monats bringt philosophische Erkenntnisse und spirituelles Wachstum."
|
||||
},
|
||||
{
|
||||
general: "Disziplin und Ausdauer zahlen sich aus. Bleiben Sie an Ihren Zielen dran. Die kosmische Energie unterstützt heute alle langfristigen Pläne und ambitionierten Vorhaben. Setzen Sie sich realistische Ziele und arbeiten Sie konsequent darauf hin. Geduld ist Ihre Stärke – Erfolg kommt Schritt für Schritt.",
|
||||
love: "Stabilität und Verlässlichkeit sind heute die Pfeiler Ihrer Beziehung. Zeigen Sie Ihrem Partner, dass er sich auf Sie verlassen kann. Gemeinsame Zukunftspläne zu schmieden stärkt Ihre Verbindung. Singles sollten nach jemandem Ausschau halten, der ihre Werte teilt.",
|
||||
career: "Heute ist der Tag für wichtige berufliche Entscheidungen. Ihre Erfahrung und Kompetenz werden anerkannt. Führungsqualitäten kommen zum Vorschein und ebnen den Weg für einen Aufstieg. Finanzielle Disziplin zahlt sich langfristig aus.",
|
||||
health: "Regelmäßigkeit ist heute der Schlüssel zu Ihrem Wohlbefinden. Halten Sie sich an Ihre Routinen – regelmäßige Mahlzeiten, Bewegung und ausreichend Schlaf. Ihre Knochen und Gelenke freuen sich über sanfte Dehnübungen.",
|
||||
tip: "Setzen Sie sich heute ein kleines Ziel und erreichen Sie es – Erfolgserlebnisse stärken das Selbstvertrauen.",
|
||||
weekly: "Eine disziplinierte Woche, die Ergebnisse bringt. Anfang der Woche setzen Sie berufliche Meilensteine. Mittwoch ist günstig für Finanzplanung. Donnerstag und Freitag bringen Anerkennung. Das Wochenende eignet sich für Familientraditionen.",
|
||||
monthly: "Saturn belohnt Ihre Ausdauer in diesem Monat. Beruflicher Aufstieg zeichnet sich in der ersten Woche ab. Finanzielle Stabilität festigt sich zur Monatsmitte. Ende des Monats bringt Anerkennung und verdiente Erfolge in allen Lebensbereichen."
|
||||
},
|
||||
{
|
||||
general: "Originelle Ideen kommen heute von ganz allein. Lassen Sie Ihrer Fantasie freien Lauf. Die Sterne begünstigen unkonventionelles Denken und innovative Lösungen. Sie sehen Dinge aus einer einzigartigen Perspektive, die andere inspiriert. Gemeinschaftliche Aktivitäten und Freundschaften stehen unter einem besonders guten Stern.",
|
||||
love: "Freiheit und Nähe in Balance zu halten ist heute das Thema in der Liebe. Geben Sie Ihrem Partner Raum und genießen Sie gleichzeitig die gemeinsame Zeit. Singles treffen heute interessante Menschen in Gruppen oder bei Vereinsaktivitäten.",
|
||||
career: "Innovation ist heute gefragt! Ihre unkonventionellen Ideen fallen auf fruchtbaren Boden. Teamarbeit bringt heute die besten Ergebnisse – tauschen Sie sich mit Gleichgesinnten aus. Technologische Neuerungen könnten Ihre Arbeit revolutionieren.",
|
||||
health: "Gruppenaktivitäten wie Sport im Verein, Wandergruppen oder gemeinsames Kochen tun Ihnen heute besonders gut. Der soziale Aspekt stärkt nicht nur den Körper, sondern auch die Seele. Probieren Sie eine neue Sportart oder Entspannungstechnik aus.",
|
||||
tip: "Engagieren Sie sich heute für eine gute Sache – Altruismus macht glücklich und erfüllt.",
|
||||
weekly: "Eine innovative Woche! Montag und Dienstag bringen kreative Durchbrüche. Mittwoch ist ideal für Teamarbeit. Freitag überrascht mit unerwarteten Möglichkeiten. Am Wochenende pflegen Sie Freundschaften und soziale Netzwerke.",
|
||||
monthly: "Uranus bringt überraschende Wendungen in diesem Monat. Technologische Neuerungen eröffnen Chancen in der ersten Hälfte. Freundschaften vertiefen sich zur Monatsmitte. Ende des Monats bringt humanitäre Projekte und Gemeinschaftsgeist."
|
||||
},
|
||||
{
|
||||
general: "Einfühlungsvermögen und Mitgefühl stärken heute Ihre Beziehungen. Die Sterne schärfen Ihre Sensibilität und Ihr Gespür für die Gefühle anderer. Kreative und spirituelle Aktivitäten stehen unter einem besonders guten Stern. Musik, Kunst und Natur sind heute Ihre Kraftquellen.",
|
||||
love: "Romantische Träume können heute wahr werden. Ihre einfühlsame Art berührt andere Menschen tief. Paare erleben Momente tiefer emotionaler Verbundenheit. Singles könnten eine fast schicksalhafte Begegnung haben – bei einem Konzert, am Wasser oder in einer kreativen Umgebung.",
|
||||
career: "Kreative Berufe profitieren heute besonders von der kosmischen Energie. Musiker, Künstler und alle, die mit Menschen arbeiten, haben einen glücklichen Tag. Lassen Sie sich von Ihrer Intuition leiten – sie führt Sie zu den richtigen Entscheidungen.",
|
||||
health: "Wasser ist heute Ihr Element – ob ein Besuch am See, ein langes Bad oder einfach genug trinken. Meditation und Entspannungsübungen harmonisieren Körper und Geist. Gönnen Sie sich Musik, die Ihre Seele berührt – Volksmusik oder klassische Klänge wirken heilsam.",
|
||||
tip: "Setzen Sie sich heute an einen ruhigen Ort in der Natur und lauschen Sie – die Stille hat heilende Kraft.",
|
||||
weekly: "Eine spirituelle und kreative Woche. Montag und Dienstag bringen künstlerische Inspiration. Mittwoch eignet sich für Meditation und innere Einkehr. Freitag überrascht mit einer romantischen Begegnung. Am Wochenende finden Sie Heilung am Wasser.",
|
||||
monthly: "Neptun verstärkt Ihre Intuition den ganzen Monat. Kreative Projekte blühen in der ersten Woche auf. Spirituelles Wachstum zeigt sich zur Monatsmitte. Ende des Monats bringt traumhafte romantische Momente und künstlerische Erfolge."
|
||||
},
|
||||
];
|
||||
|
||||
export interface AstroEvent {
|
||||
title: string;
|
||||
description: string;
|
||||
dateRange: string;
|
||||
icon: string;
|
||||
affectedSigns: string[];
|
||||
type: "retrograde" | "moon" | "transit" | "season";
|
||||
}
|
||||
|
||||
export const ASTRO_EVENTS: AstroEvent[] = [
|
||||
{
|
||||
title: "Merkur Retrograde",
|
||||
description: "Merkur ist rückläufig und sorgt für Verzögerungen in Kommunikation und Technik. Verträge und wichtige Entscheidungen sollten verschoben werden. Nutzen Sie die Zeit für Reflexion und das Überdenken alter Pläne.",
|
||||
dateRange: "25. Februar – 20. März 2026",
|
||||
icon: "mercury",
|
||||
affectedSigns: ["Zwillinge", "Jungfrau", "Fische", "Schütze"],
|
||||
type: "retrograde",
|
||||
},
|
||||
{
|
||||
title: "Vollmond im Zeichen Jungfrau",
|
||||
description: "Der Vollmond in der Jungfrau bringt Klarheit in Gesundheits- und Arbeitsfragen. Ein guter Zeitpunkt, um Ordnung zu schaffen und alte Gewohnheiten zu überdenken. Emotionale Themen kommen ans Licht.",
|
||||
dateRange: "1. März 2026",
|
||||
icon: "moon",
|
||||
affectedSigns: ["Jungfrau", "Fische", "Zwillinge", "Schütze"],
|
||||
type: "moon",
|
||||
},
|
||||
{
|
||||
title: "Venus im Widder",
|
||||
description: "Venus im Widder bringt Leidenschaft und Spontaneität in die Liebe. Neue Beziehungen können sich blitzschnell entwickeln. Singles sind besonders mutig bei der Partnersuche.",
|
||||
dateRange: "27. Februar – 23. März 2026",
|
||||
icon: "venus",
|
||||
affectedSigns: ["Widder", "Löwe", "Schütze", "Waage"],
|
||||
type: "transit",
|
||||
},
|
||||
{
|
||||
title: "Fische-Saison",
|
||||
description: "Die Sonne im Zeichen Fische fördert Intuition, Kreativität und Mitgefühl. Eine Zeit für spirituelle Entwicklung und künstlerische Projekte. Träume sind jetzt besonders aussagekräftig.",
|
||||
dateRange: "19. Februar – 20. März 2026",
|
||||
icon: "sun",
|
||||
affectedSigns: ["Fische", "Krebs", "Skorpion", "Steinbock"],
|
||||
type: "season",
|
||||
},
|
||||
{
|
||||
title: "Saturn im Widder",
|
||||
description: "Saturn fordert Disziplin und Verantwortung in neuen Projekten. Langfristige Ziele brauchen jetzt eine solide Grundlage. Geduld und Ausdauer werden belohnt.",
|
||||
dateRange: "ab 25. Mai 2025",
|
||||
icon: "saturn",
|
||||
affectedSigns: ["Widder", "Krebs", "Waage", "Steinbock"],
|
||||
type: "transit",
|
||||
},
|
||||
{
|
||||
title: "Neumond im Zeichen Fische",
|
||||
description: "Der Neumond in den Fischen ist ideal für neue Anfänge im spirituellen und kreativen Bereich. Setzen Sie Intentionen für Mitgefühl und innere Heilung. Meditation und Rückzug werden empfohlen.",
|
||||
dateRange: "14. März 2026",
|
||||
icon: "newmoon",
|
||||
affectedSigns: ["Fische", "Krebs", "Skorpion", "Jungfrau"],
|
||||
type: "moon",
|
||||
},
|
||||
];
|
||||
|
||||
export function getDayOfYear(): number {
|
||||
return Math.floor((Date.now() - new Date(new Date().getFullYear(), 0, 0).getTime()) / 86400000);
|
||||
}
|
||||
|
||||
export function getHoroscope(signIndex: number) {
|
||||
const day = getDayOfYear();
|
||||
return DAILY_HOROSCOPES[(signIndex + day) % DAILY_HOROSCOPES.length];
|
||||
}
|
||||
|
||||
export function getRating(signIndex: number, category: string): number {
|
||||
const day = getDayOfYear();
|
||||
const hash = (signIndex * 7 + day * 3 + category.length) % 5;
|
||||
return Math.max(3, hash + 3);
|
||||
}
|
||||
|
||||
export function getLuckyNumbers(signIndex: number): number[] {
|
||||
const day = getDayOfYear();
|
||||
const nums: number[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
nums.push(((signIndex * 13 + day * 7 + i * 11) % 49) + 1);
|
||||
}
|
||||
return [...new Set(nums)];
|
||||
}
|
||||
|
||||
export function getDailyColor(signIndex: number): string {
|
||||
const colors = ["Rot", "Blau", "Grün", "Gold", "Silber", "Violett", "Orange", "Rosa", "Türkis", "Weiß", "Bernstein", "Indigo"];
|
||||
const day = getDayOfYear();
|
||||
return colors[(signIndex + day) % colors.length];
|
||||
}
|
||||
@@ -74,6 +74,7 @@ function RelatedArticles({ currentSlug }: { currentSlug: string }) {
|
||||
src={article.coverImage ? article.coverImage.replace(".webp", "-thumb.webp") : "/images/article-1.png"}
|
||||
alt={article.title}
|
||||
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
style={{ objectPosition: "center 25%" }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -60,6 +60,7 @@ export default function CategoryPage() {
|
||||
src={article.coverImage || "/images/article-1.png"}
|
||||
alt={article.title}
|
||||
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
style={{ objectPosition: "center 25%" }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import GalleryPage from "@/components/photo-gallery";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
export default function GalleryPageWrapper() {
|
||||
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-8">
|
||||
<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" />
|
||||
Zurück
|
||||
</button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6" data-testid="text-gallery-title">
|
||||
Fotogalerie
|
||||
</h1>
|
||||
<GalleryPage />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,24 @@ import { Link } from "wouter";
|
||||
import { type Article } from "@shared/schema";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { Eye, Clock, Play } from "lucide-react";
|
||||
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 } from "@/components/adsense";
|
||||
import AdSense 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 { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface GalleryImage {
|
||||
folder: string;
|
||||
fileName: string;
|
||||
thumb: string;
|
||||
large: string;
|
||||
}
|
||||
|
||||
function thumbUrl(src: string | null): string {
|
||||
if (!src) return "/images/article-1.png";
|
||||
if (src.endsWith(".webp")) return src.replace(".webp", "-thumb.webp");
|
||||
@@ -31,17 +42,9 @@ function HeroCard({ article }: { article: Article }) {
|
||||
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-[300px] md:min-h-[380px]">
|
||||
<img
|
||||
src={article.coverImage || "/images/article-1.png"}
|
||||
alt={article.title}
|
||||
className="w-full h-full object-cover absolute inset-0 transition-transform duration-700 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="relative group cursor-pointer rounded-lg overflow-hidden" data-testid={`card-hero-${article.id}`}>
|
||||
<div className="relative aspect-[4/3]">
|
||||
<img src={article.coverImage || "/images/article-1.png"} alt={article.title} className="w-full h-full object-cover absolute inset-0 transition-transform duration-700 group-hover:scale-105" style={{ objectPosition: "center 25%" }} 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">
|
||||
@@ -52,23 +55,40 @@ function HeroCard({ article }: { article: Article }) {
|
||||
<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 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" data-testid="card-hero-gallery">
|
||||
<div className="relative aspect-[4/3]">
|
||||
<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" style={{ objectPosition: "center 25%" }} loading="lazy" />
|
||||
<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 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 className="flex items-center gap-2 mt-2 text-white/40 text-xs">
|
||||
<Eye className="w-3 h-3" />
|
||||
{article.views.toLocaleString()}
|
||||
</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>
|
||||
@@ -80,18 +100,10 @@ function MediumCard({ article }: { article: Article }) {
|
||||
const isVideo = article.category === "Video";
|
||||
return (
|
||||
<Link href={`/article/${article.slug}`}>
|
||||
<div
|
||||
className="relative group cursor-pointer rounded-lg overflow-hidden h-full bg-card border border-card-border"
|
||||
data-testid={`card-medium-${article.id}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<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"
|
||||
loading="lazy"
|
||||
/>
|
||||
<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: "center 25%" }} loading="lazy" />
|
||||
</div>
|
||||
{isVideo && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
@@ -101,14 +113,13 @@ function MediumCard({ article }: { article: Article }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3.5">
|
||||
<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>
|
||||
<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()}
|
||||
@@ -119,44 +130,54 @@ function MediumCard({ article }: { article: Article }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CompactCard({ article }: { article: Article }) {
|
||||
function SideCard({ article }: { article: Article }) {
|
||||
const isVideo = article.category === "Video";
|
||||
return (
|
||||
<Link href={`/article/${article.slug}`}>
|
||||
<div
|
||||
className="flex gap-3 cursor-pointer group bg-card rounded-lg border border-card-border p-3 h-full"
|
||||
data-testid={`card-compact-${article.id}`}
|
||||
>
|
||||
<div className="relative flex-shrink-0 w-24 h-20 rounded 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"
|
||||
loading="lazy"
|
||||
/>
|
||||
<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-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: "center 25%" }} loading="lazy" />
|
||||
</div>
|
||||
{isVideo && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-7 h-7 rounded-full bg-primary/90 flex items-center justify-center">
|
||||
<Play className="w-3 h-3 text-white ml-px" fill="white" />
|
||||
<div className="w-9 h-9 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="flex-1 min-w-0 flex flex-col">
|
||||
<h4 className="text-sm 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-2 mt-auto text-[10px] text-muted-foreground">
|
||||
<span>{article.author}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(new Date(article.publishedAt))}</span>
|
||||
<div className="p-3 flex flex-col flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<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 leading-relaxed line-clamp-2">{article.excerpt}</p>
|
||||
</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="auto"
|
||||
format="fluid"
|
||||
layoutKey="-6t+ed+2i-1n-4w"
|
||||
style={{ display: "block" }}
|
||||
className="w-full h-full min-h-[160px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopStoriesList({ articles }: { articles: Article[] }) {
|
||||
return (
|
||||
<div className="bg-card rounded-lg border border-card-border p-4 h-full" data-testid="sidebar-top-stories">
|
||||
@@ -172,9 +193,7 @@ function TopStoriesList({ articles }: { articles: Article[] }) {
|
||||
<span className="text-[10px] font-medium text-primary">{article.category}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{timeAgo(new Date(article.publishedAt))}</span>
|
||||
</div>
|
||||
<h4 className="text-xs font-medium text-card-foreground line-clamp-2 group-hover:text-primary transition-colors leading-snug">
|
||||
{article.title}
|
||||
</h4>
|
||||
<h4 className="text-xs font-medium text-card-foreground line-clamp-2 group-hover:text-primary transition-colors leading-snug">{article.title}</h4>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
@@ -183,6 +202,73 @@ function TopStoriesList({ articles }: { articles: Article[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedCarousel({ articles, popular, galleryImages }: { articles: Article[]; popular?: Article[]; galleryImages?: GalleryImage[] }) {
|
||||
const hasGallery = galleryImages && galleryImages.length > 0;
|
||||
const articlePages = Math.min(5, Math.max(1, articles.length));
|
||||
const total = articlePages + (hasGallery ? 1 : 0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setPage((p) => (p + 1) % total);
|
||||
}, [total]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || total <= 1) return;
|
||||
const timer = setInterval(next, 8000);
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, next, total]);
|
||||
|
||||
const isGalleryPage = hasGallery && page === total - 1;
|
||||
const isWidePage = page === 1;
|
||||
|
||||
let hero: Article | null = null;
|
||||
let side: Article[] = [];
|
||||
|
||||
if (!isGalleryPage && articles.length > 0) {
|
||||
hero = articles[page % articles.length];
|
||||
if (!isWidePage) {
|
||||
side = [
|
||||
articles[(page * 2 + 1) % articles.length],
|
||||
articles[(page * 2 + 2) % articles.length],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const wide = isGalleryPage || isWidePage;
|
||||
|
||||
return (
|
||||
<section data-testid="featured-carousel" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-6 gap-4">
|
||||
<div className={wide ? "lg:col-span-5" : "lg:col-span-3"}>
|
||||
{isGalleryPage && galleryImages ? (
|
||||
<GalleryHeroCard images={galleryImages.slice(0, 30)} />
|
||||
) : hero ? (
|
||||
<HeroCard article={hero} />
|
||||
) : null}
|
||||
</div>
|
||||
{!wide && (
|
||||
<div className="lg:col-span-2 grid grid-cols-1 gap-3">
|
||||
{side.map((a) => (
|
||||
<SideCard key={a.id} article={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="lg:col-span-1">
|
||||
{popular && popular.length > 0 && <TopStoriesList articles={popular} />}
|
||||
</div>
|
||||
</div>
|
||||
{total > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-3" data-testid="carousel-dots">
|
||||
{Array.from({ length: total }).map((_, i) => (
|
||||
<button key={i} onClick={() => setPage(i)} className={`w-2.5 h-2.5 rounded-full transition-all duration-300 ${i === page ? "bg-primary w-6" : "bg-muted-foreground/30 hover:bg-muted-foreground/50"}`} data-testid={`button-carousel-dot-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BentoSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -199,75 +285,6 @@ function BentoSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedCarousel({ articles, popular }: { articles: Article[]; popular?: Article[] }) {
|
||||
const totalPages = Math.ceil(Math.min(articles.length, 9) / 3);
|
||||
const [page, setPage] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setPage((p) => (p + 1) % totalPages);
|
||||
}, [totalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || totalPages <= 1) return;
|
||||
const timer = setInterval(next, 8000);
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, next, totalPages]);
|
||||
|
||||
const pool = articles.slice(0, 9);
|
||||
const start = page * 3;
|
||||
const visible = pool.slice(start, start + 3);
|
||||
while (visible.length < 3 && pool.length >= 3) {
|
||||
visible.push(pool[visible.length % pool.length]);
|
||||
}
|
||||
|
||||
const hero = visible[0];
|
||||
const side = visible.slice(1, 3);
|
||||
|
||||
if (!hero) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="featured-carousel"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-6 gap-4">
|
||||
<div className="lg:col-span-3">
|
||||
<HeroCard article={hero} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-4">
|
||||
{side.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
{popular && popular.length > 0 && (
|
||||
<TopStoriesList articles={popular} />
|
||||
)}
|
||||
</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.5 h-2.5 rounded-full transition-all duration-300 ${
|
||||
i === page ? "bg-primary w-6" : "bg-muted-foreground/30 hover:bg-muted-foreground/50"
|
||||
}`}
|
||||
data-testid={`button-carousel-dot-${i}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { data: articles, isLoading } = useQuery<Article[]>({
|
||||
queryKey: ["/api/articles"],
|
||||
@@ -277,60 +294,71 @@ export default function Home() {
|
||||
queryKey: ["/api/articles/popular"],
|
||||
});
|
||||
|
||||
const { data: galleryImages } = useQuery<GalleryImage[]>({
|
||||
queryKey: ["/api/gallery"],
|
||||
});
|
||||
|
||||
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>
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4"><BentoSkeleton /></main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const all = articles;
|
||||
const row2Items = all.slice(3, 6);
|
||||
const row3Items = all.slice(6);
|
||||
const row2Left = articles.slice(0, 2);
|
||||
const row3Middle = articles.slice(2, 4);
|
||||
const row4Articles = articles.slice(4, 7);
|
||||
const row5Articles = articles.slice(7, 10);
|
||||
|
||||
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 space-y-4">
|
||||
|
||||
<FeaturedCarousel articles={all} popular={popular} />
|
||||
<FeaturedCarousel articles={articles} popular={popular} galleryImages={galleryImages} />
|
||||
|
||||
<div className="rounded-lg border border-card-border bg-card overflow-hidden">
|
||||
<AdSense
|
||||
slot="auto"
|
||||
format="horizontal"
|
||||
style={{ display: "block", minHeight: "90px" }}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{row2Left.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
<div className="aspect-[4/5] sm:aspect-auto">
|
||||
<PhotoGalleryWidget />
|
||||
</div>
|
||||
<div className="aspect-[4/5] sm:aspect-auto">
|
||||
<RecipeWidget />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{row2Items.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="aspect-[4/5] sm:aspect-auto">
|
||||
<HoroscopeWidget />
|
||||
</div>
|
||||
{row3Middle.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
<div className="aspect-[4/5] sm:aspect-auto">
|
||||
<NewsWidget />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{row4Articles.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{row2Items.map((a) => (
|
||||
{row4Articles.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
<ArticleCardAd key="ad-row2" />
|
||||
<NativeAdCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{row3Items.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-6 gap-4">
|
||||
<div className="lg:col-span-3">
|
||||
{row3Items[0] && <HeroCard article={row3Items[0]} />}
|
||||
</div>
|
||||
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{row3Items.slice(1, 3).map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
{row3Items.slice(3).map((a) => (
|
||||
<CompactCard key={a.id} article={a} />
|
||||
))}
|
||||
<ArticleCardAd key="ad-row3" />
|
||||
</div>
|
||||
{row5Articles.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{row5Articles.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
<NativeAdCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "wouter";
|
||||
import {
|
||||
Star,
|
||||
Heart,
|
||||
Briefcase,
|
||||
TrendingUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
CalendarDays,
|
||||
Sparkles,
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
Moon,
|
||||
Sun,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import { InArticleAd } from "@/components/adsense";
|
||||
import {
|
||||
SIGNS,
|
||||
ELEMENT_COLORS,
|
||||
ASTRO_EVENTS,
|
||||
getHoroscope as getStaticHoroscope,
|
||||
getRating,
|
||||
getLuckyNumbers,
|
||||
getDailyColor,
|
||||
} from "@/lib/horoscope-data";
|
||||
|
||||
interface AIHoroscope {
|
||||
signIndex: number;
|
||||
signName: string;
|
||||
general: string;
|
||||
love: string;
|
||||
career: string;
|
||||
health: string;
|
||||
tip: string;
|
||||
weekly: string;
|
||||
monthly: string;
|
||||
}
|
||||
|
||||
function StarRating({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star key={i} className={`w-3.5 h-3.5 ${i < count ? "fill-primary text-primary" : "text-muted-foreground/30"}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2 mb-4">
|
||||
<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">
|
||||
{SIGNS.map((sign, i) => {
|
||||
const ec = ELEMENT_COLORS[sign.element];
|
||||
const aiH = aiHoroscopes.find(h => h.signIndex === i);
|
||||
const horoscope = aiH || getStaticHoroscope(i);
|
||||
const isSelected = selectedIndex === i;
|
||||
return (
|
||||
<button
|
||||
key={sign.name}
|
||||
onClick={() => onSelect(i)}
|
||||
className={`text-left rounded-xl p-4 transition-all duration-200 border ${
|
||||
isSelected
|
||||
? `${ec.bg} ${ec.border} shadow-lg ${ec.glow} ring-1 ring-primary/30`
|
||||
: "bg-card border-card-border hover:border-primary/40 hover:shadow-md"
|
||||
}`}
|
||||
data-testid={`button-sign-grid-${sign.name.toLowerCase()}`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-foreground/60 leading-relaxed line-clamp-3">{horoscope.general.substring(0, 100)}...</p>
|
||||
<div className="flex items-center gap-1 mt-2 text-[10px] text-primary">
|
||||
Lesen <ArrowRight className="w-3 h-3" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number; onNavigate: (i: number) => void; aiHoroscopes: AIHoroscope[] }) {
|
||||
const [tab, setTab] = useState<"daily" | "weekly" | "monthly">("daily");
|
||||
const sign = SIGNS[signIndex];
|
||||
const ec = ELEMENT_COLORS[sign.element];
|
||||
const aiH = aiHoroscopes.find(h => h.signIndex === signIndex);
|
||||
const horoscope = aiH || getStaticHoroscope(signIndex);
|
||||
const luckyNums = getLuckyNumbers(signIndex);
|
||||
const dailyColor = getDailyColor(signIndex);
|
||||
const detailRef = useRef<HTMLDivElement>(null);
|
||||
const isAI = !!aiH;
|
||||
|
||||
const prevIndex = (signIndex - 1 + SIGNS.length) % SIGNS.length;
|
||||
const nextIndex = (signIndex + 1) % SIGNS.length;
|
||||
|
||||
useEffect(() => {
|
||||
setTab("daily");
|
||||
}, [signIndex]);
|
||||
|
||||
return (
|
||||
<section ref={detailRef} data-testid={`section-sign-detail-${sign.name.toLowerCase()}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => onNavigate(prevIndex)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors bg-card border border-card-border rounded-lg px-3 py-2"
|
||||
data-testid="button-sign-prev"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="text-lg">{SIGNS[prevIndex].symbol}</span>
|
||||
<span className="hidden sm:inline">{SIGNS[prevIndex].name}</span>
|
||||
</button>
|
||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||
<span className="text-3xl">{sign.symbol}</span>
|
||||
{sign.name}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => onNavigate(nextIndex)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors bg-card border border-card-border rounded-lg px-3 py-2"
|
||||
data-testid="button-sign-next"
|
||||
>
|
||||
<span className="hidden sm:inline">{SIGNS[nextIndex].name}</span>
|
||||
<span className="text-lg">{SIGNS[nextIndex].symbol}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0">
|
||||
<div className="bg-card rounded-xl border border-card-border p-6 md:p-8" data-testid={`card-horoscope-${sign.name.toLowerCase()}`}>
|
||||
<div className="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>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-2xl font-bold ${ec.text}`}>{sign.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{sign.date}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded ${ec.bg} ${ec.text} border ${ec.border}`}>{sign.element}</span>
|
||||
<span className="text-[10px] bg-muted px-2 py-0.5 rounded text-muted-foreground">Planet: {sign.planet}</span>
|
||||
<span className="text-[10px] bg-muted px-2 py-0.5 rounded text-muted-foreground">Farbe: {sign.color}</span>
|
||||
<span className="text-[10px] bg-muted px-2 py-0.5 rounded text-muted-foreground">Stein: {sign.stone}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3 mb-6">
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-center">
|
||||
<Heart className="w-4 h-4 text-red-400 mx-auto mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Liebe</p>
|
||||
<StarRating count={getRating(signIndex, "love")} />
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-center">
|
||||
<Briefcase className="w-4 h-4 text-amber-400 mx-auto mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Beruf</p>
|
||||
<StarRating count={getRating(signIndex, "career")} />
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-center">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-400 mx-auto mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Gesundheit</p>
|
||||
<StarRating count={getRating(signIndex, "health")} />
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-center">
|
||||
<Sparkles className="w-4 h-4 text-violet-400 mx-auto mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Glückszahlen</p>
|
||||
<p className="text-xs font-bold text-foreground" data-testid="text-lucky-numbers">{luckyNums.join(", ")}</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-center">
|
||||
<div className="w-4 h-4 rounded-full bg-primary/60 mx-auto mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Tagesfarbe</p>
|
||||
<p className="text-xs font-bold text-foreground" data-testid="text-daily-color">{dailyColor}</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-center">
|
||||
<Heart className="w-4 h-4 text-pink-400 mx-auto mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Kompatibel</p>
|
||||
<p className="text-[10px] font-medium text-foreground leading-tight" data-testid="text-compatible-signs">{sign.compatible.join(", ")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-6 bg-muted/30 rounded-lg p-1">
|
||||
{(["daily", "weekly", "monthly"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 text-xs font-medium py-2 px-3 rounded-md transition-all flex items-center justify-center gap-1.5 ${
|
||||
tab === t ? "bg-primary text-white shadow" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
data-testid={`button-tab-${t}`}
|
||||
>
|
||||
{t === "daily" && <Star className="w-3 h-3" />}
|
||||
{t === "weekly" && <Calendar className="w-3 h-3" />}
|
||||
{t === "monthly" && <CalendarDays className="w-3 h-3" />}
|
||||
{t === "daily" ? "Heute" : t === "weekly" ? "Woche" : "Monat"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "daily" && (
|
||||
<div className="space-y-5" data-testid="section-horoscope-daily">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2 mb-2">
|
||||
<Star className="w-4 h-4 text-primary" /> Allgemein
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed" data-testid="text-horoscope-general">{horoscope.general}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2 mb-2">
|
||||
<Heart className="w-4 h-4 text-red-400" /> Liebe & Partnerschaft
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed" data-testid="text-horoscope-love">{horoscope.love}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "weekly" && (
|
||||
<div data-testid="section-horoscope-weekly">
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2 mb-2">
|
||||
<Calendar className="w-4 h-4 text-primary" /> Wochenhoroskop
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed" data-testid="text-horoscope-weekly">{horoscope.weekly}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "monthly" && (
|
||||
<div data-testid="section-horoscope-monthly">
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2 mb-2">
|
||||
<CalendarDays className="w-4 h-4 text-primary" /> Monatshoroskop
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed" data-testid="text-horoscope-monthly">{horoscope.monthly}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InArticleAd />
|
||||
|
||||
{tab === "daily" && (
|
||||
<div className="bg-card rounded-xl border border-card-border p-6 md:p-8" data-testid="card-horoscope-detail-2">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2 mb-2">
|
||||
<Briefcase className="w-4 h-4 text-amber-400" /> Beruf & Finanzen
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed">{horoscope.career}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-400" /> Gesundheit & Wohlbefinden
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed">{horoscope.health}</p>
|
||||
</div>
|
||||
<div className="bg-primary/10 border border-primary/20 rounded-lg p-4" data-testid="card-horoscope-tip">
|
||||
<p className="text-sm text-foreground/90 font-medium flex items-start gap-2">
|
||||
<Lightbulb className="w-5 h-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<span><strong>Tipp des Tages:</strong> {horoscope.tip}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InArticleAd />
|
||||
|
||||
<div className="bg-card rounded-xl border border-card-border p-6" data-testid="card-horoscope-others">
|
||||
<h3 className="font-semibold text-foreground mb-4">Weitere Sternzeichen entdecken</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{SIGNS.filter((_, i) => i !== signIndex).map((s) => {
|
||||
const origIdx = SIGNS.findIndex((x) => x.name === s.name);
|
||||
const sEc = ELEMENT_COLORS[s.element];
|
||||
return (
|
||||
<button
|
||||
key={s.name}
|
||||
onClick={() => onNavigate(origIdx)}
|
||||
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()}`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{s.date}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HoroscopePage() {
|
||||
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"],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (params.sign) {
|
||||
const idx = SIGNS.findIndex((s) => s.name.toLowerCase() === params.sign?.toLowerCase());
|
||||
if (idx >= 0) setSelected(idx);
|
||||
}
|
||||
}, [params.sign]);
|
||||
|
||||
const handleSelect = (i: number) => {
|
||||
setSelected(i);
|
||||
setTimeout(() => {
|
||||
detailRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, 100);
|
||||
};
|
||||
|
||||
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-8">
|
||||
<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>
|
||||
</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">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Horoskope werden von den Sternen gelesen...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AstroEventsSection />
|
||||
|
||||
<SignGrid onSelect={handleSelect} selectedIndex={selected} aiHoroscopes={aiHoroscopes} />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { ChefHat, Clock, Users, X, ChevronLeft } from "lucide-react";
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import { InArticleAd } from "@/components/adsense";
|
||||
|
||||
interface Recipe {
|
||||
title: string;
|
||||
image: string;
|
||||
time: string;
|
||||
servings: string;
|
||||
ingredients: string[];
|
||||
steps: string[];
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface RecipeRegion {
|
||||
name: string;
|
||||
recipes: Recipe[];
|
||||
}
|
||||
|
||||
const RECIPE_REGIONS: RecipeRegion[] = [
|
||||
{
|
||||
name: "Österreich",
|
||||
recipes: [
|
||||
{
|
||||
title: "Kaiserschmarrn",
|
||||
image: "/uploads/recipe-kaiserschmarrn.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
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.png",
|
||||
time: "30 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Goldbraun gebratene Kartoffelreibekuchen -- ob süß mit Apfelmus oder herzhaft mit Lachs.",
|
||||
ingredients: ["1 kg festkochende Kartoffeln", "1 Zwiebel", "2 Eier", "3 EL Mehl", "Salz, Pfeffer", "Muskatnuss", "Öl zum Braten", "Apfelmus"],
|
||||
steps: ["Kartoffeln und Zwiebel fein reiben.", "Überschüssige Flüssigkeit gut ausdrücken.", "Mit Eiern, Mehl, Salz und Muskatnuss verrühren.", "In heißem Öl löffelweise flache Puffer braten.", "Von beiden Seiten goldbraun und knusprig backen.", "Auf Küchenpapier abtropfen, mit Apfelmus servieren."],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function RecipeModal({ recipe, onClose }: { recipe: Recipe; onClose: () => void }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecipesPage() {
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
|
||||
|
||||
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-8">
|
||||
<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">
|
||||
<ChefHat className="w-6 h-6 text-primary" />
|
||||
Legendäre Bauernküche
|
||||
</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>
|
||||
|
||||
{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">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{ri % 2 === 0 && <InArticleAd />}
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
{selectedRecipe && <RecipeModal recipe={selectedRecipe} onClose={() => setSelectedRecipe(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||