Restored to '84d7de2da64f8f2fd1e2d64959817c64c8fb7c04'
Replit-Restored-To: 84d7de2da6
3
.replit
@ -44,3 +44,6 @@ waitForPort = 5000
|
||||
[userenv.shared]
|
||||
BUNNY_LIBRARY_ID = "476412"
|
||||
BUNNY_CDN_HOST = "vz-7982dfc4-cc8.b-cdn.net"
|
||||
|
||||
[agent]
|
||||
integrations = ["javascript_openai_ai_integrations:2.0.0"]
|
||||
|
||||
BIN
attached_assets/image_1772302455700.png
Normal file
|
After Width: | Height: | Size: 962 KiB |
BIN
attached_assets/image_1772302510311.png
Normal file
|
After Width: | Height: | Size: 962 KiB |
BIN
attached_assets/image_1772303485706.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
attached_assets/image_1772303573465.png
Normal file
|
After Width: | Height: | Size: 666 KiB |
BIN
attached_assets/image_1772303671336.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
attached_assets/image_1772303816735.png
Normal file
|
After Width: | Height: | Size: 400 KiB |
BIN
attached_assets/image_1772306629624.png
Normal file
|
After Width: | Height: | Size: 792 KiB |
BIN
attached_assets/image_1772307061989.png
Normal file
|
After Width: | Height: | Size: 926 KiB |
BIN
attached_assets/image_1772307180177.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
attached_assets/image_1772308254979.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
attached_assets/image_1772308461147.png
Normal file
|
After Width: | Height: | Size: 829 KiB |
BIN
attached_assets/image_1772308809989.png
Normal file
|
After Width: | Height: | Size: 818 KiB |
BIN
attached_assets/image_1772310288253.png
Normal file
|
After Width: | Height: | Size: 769 KiB |
BIN
attached_assets/image_1772310381166.png
Normal file
|
After Width: | Height: | Size: 463 KiB |
BIN
client/public/uploads/melanie-payer-gipfelstammtisch.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
client/public/uploads/recipe-apfelstrudel.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
client/public/uploads/recipe-dampfnudeln.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
client/public/uploads/recipe-flammkuchen.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
client/public/uploads/recipe-germknoedel.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
client/public/uploads/recipe-gruenkohl.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
client/public/uploads/recipe-kaerntner-kasnudeln.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
client/public/uploads/recipe-kaesespaetzle.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
client/public/uploads/recipe-kaiserschmarrn.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
client/public/uploads/recipe-kartoffelpuffer.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
client/public/uploads/recipe-maultaschen.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
client/public/uploads/recipe-obatzda.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
client/public/uploads/recipe-sachertorte.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
client/public/uploads/recipe-sauerbraten.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
client/public/uploads/recipe-schlutzkrapfen.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
client/public/uploads/recipe-schwarzwaelder-kirschtorte.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
client/public/uploads/recipe-schweinshaxe.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
client/public/uploads/recipe-tafelspitz.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
client/public/uploads/recipe-tiroler-knoedel.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
client/public/uploads/recipe-wiener-schnitzel.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
client/public/uploads/recipe-zwiebelrostbraten.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
112
client/replit_integrations/audio/audio-playback-worklet.js
Normal file
@ -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);
|
||||
|
||||
36
client/replit_integrations/audio/audio-utils.ts
Normal file
@ -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 };
|
||||
}
|
||||
|
||||
45
client/replit_integrations/audio/index.ts
Normal file
@ -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";
|
||||
|
||||
105
client/replit_integrations/audio/useAudioPlayback.ts
Normal file
@ -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 };
|
||||
}
|
||||
52
client/replit_integrations/audio/useVoiceRecorder.ts
Normal file
@ -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 };
|
||||
}
|
||||
|
||||
91
client/replit_integrations/audio/useVoiceStream.ts
Normal file
@ -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() {
|
||||
|
||||
108
client/src/components/horoscope-widget.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
95
client/src/components/news-widget.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
255
client/src/components/photo-gallery.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
57
client/src/components/recipe-widget.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
222
client/src/lib/horoscope-data.ts
Normal file
@ -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>
|
||||
|
||||
26
client/src/pages/gallery.tsx
Normal file
@ -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>
|
||||
|
||||
{row2Items.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{row2Items.map((a) => (
|
||||
{row2Left.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
<ArticleCardAd key="ad-row2" />
|
||||
<div className="aspect-[4/5] sm:aspect-auto">
|
||||
<PhotoGalleryWidget />
|
||||
</div>
|
||||
<div className="aspect-[4/5] sm:aspect-auto">
|
||||
<RecipeWidget />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{row4Articles.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
<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) => (
|
||||
{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} />
|
||||
))}
|
||||
{row3Items.slice(3).map((a) => (
|
||||
<CompactCard key={a.id} article={a} />
|
||||
))}
|
||||
<ArticleCardAd key="ad-row3" />
|
||||
</div>
|
||||
<NativeAdCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
438
client/src/pages/horoscope.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
360
client/src/pages/recipes.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
105
package-lock.json
generated
@ -49,7 +49,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"drizzle-zod": "^0.7.0",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"express": "^5.0.1",
|
||||
"express-session": "^1.18.1",
|
||||
@ -59,6 +59,9 @@
|
||||
"memorystore": "^1.6.7",
|
||||
"multer": "^2.1.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^6.25.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"p-retry": "^7.1.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.16.3",
|
||||
@ -75,8 +78,8 @@
|
||||
"vaul": "^1.1.2",
|
||||
"wouter": "^3.3.5",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.2",
|
||||
"zod-validation-error": "^3.4.0"
|
||||
"zod": "^3.25.76",
|
||||
"zod-validation-error": "^3.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@replit/vite-plugin-cartographer": "^0.4.4",
|
||||
@ -4722,9 +4725,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/drizzle-zod": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-zod/-/drizzle-zod-0.7.0.tgz",
|
||||
"integrity": "sha512-xgCRYYVEzRkeXTS33GSMgoowe3vKsMNBjSI+cwG1oLQVEhAWWbqtb/AAMlm7tkmV4fG/uJjEmWzdzlEmTgWOoQ==",
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-zod/-/drizzle-zod-0.7.1.tgz",
|
||||
"integrity": "sha512-nZzALOdz44/AL2U005UlmMqaQ1qe5JfanvLujiTHiiT8+vZJTBFhj3pY4Vk+L6UWyKFfNmLhk602Hn4kCTynKQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"drizzle-orm": ">=0.36.0",
|
||||
@ -5505,6 +5508,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-network-error": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz",
|
||||
"integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
@ -6298,6 +6313,57 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.25.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.25.0.tgz",
|
||||
"integrity": "sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
|
||||
"integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-retry": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz",
|
||||
"integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-network-error": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@ -8840,24 +8906,37 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.24.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
|
||||
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-validation-error": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.0.tgz",
|
||||
"integrity": "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==",
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz",
|
||||
"integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.18.0"
|
||||
"zod": "^3.24.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"drizzle-zod": "^0.7.0",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"express": "^5.0.1",
|
||||
"express-session": "^1.18.1",
|
||||
@ -61,6 +61,9 @@
|
||||
"memorystore": "^1.6.7",
|
||||
"multer": "^2.1.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^6.25.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"p-retry": "^7.1.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.16.3",
|
||||
@ -77,8 +80,8 @@
|
||||
"vaul": "^1.1.2",
|
||||
"wouter": "^3.3.5",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.2",
|
||||
"zod-validation-error": "^3.4.0"
|
||||
"zod": "^3.25.76",
|
||||
"zod-validation-error": "^3.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@replit/vite-plugin-cartographer": "^0.4.4",
|
||||
|
||||
77
replit.md
@ -1,7 +1,7 @@
|
||||
# news.folx.tv - Blog Platform
|
||||
# news.folx.tv - MSN-Style News Portal
|
||||
|
||||
## Overview
|
||||
A clean, modern blog/news platform for Folx Music Television (news.folx.tv). Dark-themed, content-first design inspired by Medium and The Verge, with support for video embeds from bunny.net, Facebook, Instagram, and TikTok.
|
||||
A professional MSN-style news portal for Folx Music Television (news.folx.tv). Dark-themed bento grid layout with content for folk music (Volksmusik/Schlager) fans. Features articles, videos, photo gallery, horoscope widget + subpage, recipe widget + subpage, Google News feed, and integrated AdSense ads. All content is hardcoded in seed for production deployments.
|
||||
|
||||
## Architecture
|
||||
- **Frontend**: React + Vite + TailwindCSS + shadcn/ui (dark mode)
|
||||
@ -10,14 +10,17 @@ A clean, modern blog/news platform for Folx Music Television (news.folx.tv). Dar
|
||||
- **Routing**: wouter (frontend), Express (backend API)
|
||||
|
||||
## Key Features
|
||||
- Article listing with featured carousel, grid layout, and popular sidebar
|
||||
- Individual article pages with full HTML content rendering
|
||||
- Category filtering (News, Star-News)
|
||||
- MSN-style bento grid homepage with mixed article/widget layout
|
||||
- FeaturedCarousel with 5 article pages (page 2 = wide layout) + gallery page
|
||||
- Photo gallery widget (547 Dropbox images) with fullscreen lightbox carousel
|
||||
- Horoscope widget with element colors, star ratings, full /horoskop subpage
|
||||
- Recipe widget + full /rezepte subpage (21 recipes across 5 regions: Österreich, Bayern, Schwaben/Baden, Südtirol/Alpen, Norddeutschland) with AI-generated images
|
||||
- Google News RSS widget (Volksmusik/Schlager news, 5 items, auto-rotate)
|
||||
- Google AdSense integration (ca-pub-4465464714854276)
|
||||
- Article listing with featured carousel and category filtering
|
||||
- HTML content supports embedded iframes (bunny.net, YouTube, Facebook, Instagram, TikTok)
|
||||
- DOMPurify sanitization for safe HTML rendering
|
||||
- Image upload endpoint (multer) for article images
|
||||
- Responsive design with mobile navigation
|
||||
- SEO meta tags
|
||||
|
||||
## Data Model
|
||||
- `articles`: id (serial), title, slug (unique), excerpt, content (HTML), coverImage, category, author, featured, views, publishedAt
|
||||
@ -32,27 +35,57 @@ A clean, modern blog/news platform for Folx Music Television (news.folx.tv). Dar
|
||||
- `PATCH /api/articles/:id` - Update article
|
||||
- `DELETE /api/articles/:id` - Delete article
|
||||
- `POST /api/upload` - Upload image file
|
||||
- `GET /api/gallery` - Shuffled Dropbox gallery images
|
||||
- `GET /api/news-feed` - Google News RSS feed for Volksmusik/Schlager
|
||||
- `GET /api/videos` - BunnyCDN video list
|
||||
- `GET /api/videos/:guid` - BunnyCDN video details
|
||||
|
||||
## File Structure
|
||||
- `shared/schema.ts` - Drizzle schema + Zod validation
|
||||
- `server/db.ts` - Database connection (pg Pool)
|
||||
- `server/storage.ts` - Storage interface + DatabaseStorage implementation
|
||||
- `server/routes.ts` - API routes + file upload (multer)
|
||||
- `server/seed.ts` - Seed data for initial articles
|
||||
- `client/src/pages/home.tsx` - Homepage
|
||||
- `server/db.ts` - Database connection
|
||||
- `server/storage.ts` - Storage interface + DatabaseStorage
|
||||
- `server/routes.ts` - API routes + gallery + news feed
|
||||
- `server/seed.ts` - Hardcoded seed data (articles)
|
||||
- `server/gallery-data.json` - 547 Dropbox gallery images
|
||||
- `client/src/pages/home.tsx` - MSN-style bento grid homepage
|
||||
- `client/src/pages/article.tsx` - Article detail page
|
||||
- `client/src/pages/category.tsx` - Category listing page
|
||||
- `client/src/components/header.tsx` - Site header with nav
|
||||
- `client/src/components/footer.tsx` - Site footer
|
||||
- `client/src/pages/videos.tsx` - Videos page
|
||||
- `client/src/pages/gallery.tsx` - Full gallery page
|
||||
- `client/src/pages/horoscope.tsx` - Full horoscope page (12 signs, love/career/health, weekly/monthly)
|
||||
- `client/src/pages/recipes.tsx` - Full recipes page (21 recipes, 5 regions, AI-generated images)
|
||||
- `client/src/lib/horoscope-data.ts` - Shared horoscope data (signs, texts, helpers)
|
||||
- `client/src/components/header.tsx` - Header with nav (Start, News, Video, Galerie, Horoskop, Rezepte)
|
||||
- `client/src/components/footer.tsx` - Footer with links
|
||||
- `client/src/components/photo-gallery.tsx` - Gallery widget + lightbox carousel
|
||||
- `client/src/components/horoscope-widget.tsx` - Horoscope widget with element colors
|
||||
- `client/src/components/recipe-widget.tsx` - Recipe widget with modal
|
||||
- `client/src/components/news-widget.tsx` - Google News RSS widget
|
||||
- `client/src/components/adsense.tsx` - AdSense ad components
|
||||
|
||||
## Video Embeds
|
||||
Article content (HTML) supports iframe embeds. Allowed domains:
|
||||
- iframe.mediadelivery.net / video.bunny.net (Bunny.net)
|
||||
- www.facebook.com, www.instagram.com, www.tiktok.com
|
||||
- www.youtube.com, player.vimeo.com
|
||||
## Homepage Layout (MSN Bento Grid)
|
||||
- Row 1: FeaturedCarousel (hero + side articles + TopStorys, page 2 = wide hero)
|
||||
- Row 2: 2 articles + PhotoGalleryWidget + RecipeWidget (mixed)
|
||||
- Row 3: HoroscopeWidget + 2 articles + NewsWidget (mixed)
|
||||
- Row 4: 3 articles + NativeAdCard
|
||||
- Row 5: 3 articles + NativeAdCard
|
||||
|
||||
## Branding
|
||||
- Dark theme by default (class="dark" on html)
|
||||
- Primary color: crimson/red (hsl 342 85% 53% light, hsl 9 75% 61% dark)
|
||||
- Dark theme (class="dark" on html)
|
||||
- Primary/brand color: crimson/red (RGB 218,35,77)
|
||||
- Background: 0 0% 5%, Card: 0 0% 9%
|
||||
- Font: Poppins
|
||||
- Logo: Folx TV branding image in header
|
||||
- Logo: folx_MT_poz_b_1772296729169.png
|
||||
|
||||
## External Services
|
||||
- Bunny.net: Library 476412, CDN vz-7982dfc4-cc8.b-cdn.net (NO autoplay)
|
||||
- Google AdSense: ca-pub-4465464714854276
|
||||
- Dropbox: Gallery image thumbnails (547 images from 16 folders)
|
||||
- Google News RSS: Volksmusik/Schlager news feed
|
||||
|
||||
## Important Notes
|
||||
- Tailwind `object-[center_25%]` does NOT work — must use inline `style={{ objectPosition: "center 25%" }}`
|
||||
- Horoscope widget navigates to /horoskop on click (no modal)
|
||||
- News widget external links open in new tab (target="_blank")
|
||||
- FeaturedCarousel: page 1 = wide (5 cols, no side cards), last page = gallery
|
||||
- All images use `objectPosition: "center 25%"` for better face cropping
|
||||
|
||||
1094
server/gallery-data.json
Normal file
113
server/horoscope-generator.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import OpenAI from "openai";
|
||||
import { db } from "./db";
|
||||
import { dailyHoroscopes } from "@shared/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
|
||||
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
|
||||
});
|
||||
|
||||
const SIGN_NAMES = [
|
||||
"Widder", "Stier", "Zwillinge", "Krebs", "Löwe", "Jungfrau",
|
||||
"Waage", "Skorpion", "Schütze", "Steinbock", "Wassermann", "Fische"
|
||||
];
|
||||
|
||||
function getTodayStr(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export async function getHoroscopesForToday(): Promise<any[]> {
|
||||
const today = getTodayStr();
|
||||
const existing = await db.select().from(dailyHoroscopes).where(eq(dailyHoroscopes.dateStr, today));
|
||||
if (existing.length === 12) return existing;
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function generateDailyHoroscopes(): Promise<void> {
|
||||
const today = getTodayStr();
|
||||
|
||||
const existing = await db.select().from(dailyHoroscopes).where(eq(dailyHoroscopes.dateStr, today));
|
||||
if (existing.length >= 12) {
|
||||
console.log(`Horoscopes for ${today} already exist.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Generating horoscopes for ${today}...`);
|
||||
|
||||
for (let i = 0; i < SIGN_NAMES.length; i++) {
|
||||
const signName = SIGN_NAMES[i];
|
||||
|
||||
const alreadyExists = existing.find(h => h.signIndex === i);
|
||||
if (alreadyExists) continue;
|
||||
|
||||
try {
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-5-mini",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Du bist ein erfahrener Astrologe, der tägliche Horoskope für eine deutschsprachige Volksmusik- und Schlager-Nachrichtenwebsite schreibt. Dein Stil ist warm, ermutigend und poetisch. Du beziehst manchmal Musik, Natur und alpine Kultur in deine Texte ein. Schreibe immer auf Deutsch. Das heutige Datum ist ${today}.`
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Erstelle ein ausführliches Tageshoroskop für das Sternzeichen ${signName} für heute (${today}).
|
||||
|
||||
Antworte NUR mit einem JSON-Objekt in diesem exakten Format (kein Markdown, keine Erklärung):
|
||||
{
|
||||
"general": "Ausführlicher allgemeiner Tagestext, mindestens 4-5 Sätze über die allgemeine Energie, Stimmung und Möglichkeiten des Tages.",
|
||||
"love": "Ausführlicher Text über Liebe und Partnerschaft, mindestens 3-4 Sätze mit konkreten Ratschlägen für Singles und Paare.",
|
||||
"career": "Ausführlicher Text über Beruf und Finanzen, mindestens 3-4 Sätze mit konkreten Tipps.",
|
||||
"health": "Ausführlicher Text über Gesundheit und Wohlbefinden, mindestens 3-4 Sätze.",
|
||||
"tip": "Ein konkreter, umsetzbarer Tipp des Tages in 1-2 Sätzen.",
|
||||
"weekly": "Ausführliche Wochenvorschau, mindestens 4-5 Sätze mit Hinweisen für jeden Wochentag.",
|
||||
"monthly": "Ausführliche Monatsvorschau, mindestens 4-5 Sätze über die wichtigsten Themen des Monats."
|
||||
}`
|
||||
}
|
||||
],
|
||||
temperature: 0.9,
|
||||
max_tokens: 2000,
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content || "";
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
console.error(`Failed to parse horoscope for ${signName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
|
||||
await db.insert(dailyHoroscopes).values({
|
||||
signIndex: i,
|
||||
signName,
|
||||
dateStr: today,
|
||||
general: parsed.general || "",
|
||||
love: parsed.love || "",
|
||||
career: parsed.career || "",
|
||||
health: parsed.health || "",
|
||||
tip: parsed.tip || "",
|
||||
weekly: parsed.weekly || "",
|
||||
monthly: parsed.monthly || "",
|
||||
});
|
||||
|
||||
console.log(`Generated horoscope for ${signName}`);
|
||||
} catch (err: any) {
|
||||
console.error(`Error generating horoscope for ${signName}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Horoscope generation complete for ${today}.`);
|
||||
}
|
||||
|
||||
export async function getOrGenerateHoroscope(signIndex: number): Promise<any | null> {
|
||||
const today = getTodayStr();
|
||||
|
||||
const [existing] = await db.select().from(dailyHoroscopes)
|
||||
.where(and(eq(dailyHoroscopes.dateStr, today), eq(dailyHoroscopes.signIndex, signIndex)));
|
||||
|
||||
if (existing) return existing;
|
||||
|
||||
return null;
|
||||
}
|
||||
274
server/replit_integrations/audio/client.ts
Normal file
@ -0,0 +1,274 @@
|
||||
import OpenAI, { toFile } from "openai";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { spawn } from "child_process";
|
||||
import { writeFile, unlink, readFile } from "fs/promises";
|
||||
import { randomUUID } from "crypto";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
export const openai = new OpenAI({
|
||||
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
|
||||
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
|
||||
});
|
||||
|
||||
export type AudioFormat = "wav" | "mp3" | "webm" | "mp4" | "ogg" | "unknown";
|
||||
|
||||
/**
|
||||
* Detect audio format from buffer magic bytes.
|
||||
* Supports: WAV, MP3, WebM (Chrome/Firefox), MP4/M4A/MOV (Safari/iOS), OGG
|
||||
*/
|
||||
export function detectAudioFormat(buffer: Buffer): AudioFormat {
|
||||
if (buffer.length < 12) return "unknown";
|
||||
|
||||
// WAV: RIFF....WAVE
|
||||
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) {
|
||||
return "wav";
|
||||
}
|
||||
// WebM: EBML header
|
||||
if (buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) {
|
||||
return "webm";
|
||||
}
|
||||
// MP3: ID3 tag or frame sync
|
||||
if (
|
||||
(buffer[0] === 0xff && (buffer[1] === 0xfb || buffer[1] === 0xfa || buffer[1] === 0xf3)) ||
|
||||
(buffer[0] === 0x49 && buffer[1] === 0x44 && buffer[2] === 0x33)
|
||||
) {
|
||||
return "mp3";
|
||||
}
|
||||
// MP4/M4A/MOV: ....ftyp (Safari/iOS records in these containers)
|
||||
if (buffer[4] === 0x66 && buffer[5] === 0x74 && buffer[6] === 0x79 && buffer[7] === 0x70) {
|
||||
return "mp4";
|
||||
}
|
||||
// OGG: OggS
|
||||
if (buffer[0] === 0x4f && buffer[1] === 0x67 && buffer[2] === 0x67 && buffer[3] === 0x53) {
|
||||
return "ogg";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any audio/video format to WAV using ffmpeg.
|
||||
* Uses temp files instead of pipes because video containers (MP4/MOV)
|
||||
* require seeking to find the audio track.
|
||||
*/
|
||||
export async function convertToWav(audioBuffer: Buffer): Promise<Buffer> {
|
||||
const inputPath = join(tmpdir(), `input-${randomUUID()}`);
|
||||
const outputPath = join(tmpdir(), `output-${randomUUID()}.wav`);
|
||||
|
||||
try {
|
||||
// Write input to temp file (required for video containers that need seeking)
|
||||
await writeFile(inputPath, audioBuffer);
|
||||
|
||||
// Run ffmpeg with file paths
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ffmpeg = spawn("ffmpeg", [
|
||||
"-i", inputPath,
|
||||
"-vn", // Extract audio only (ignore video track)
|
||||
"-f", "wav",
|
||||
"-ar", "16000", // 16kHz sample rate (good for speech)
|
||||
"-ac", "1", // Mono
|
||||
"-acodec", "pcm_s16le",
|
||||
"-y", // Overwrite output
|
||||
outputPath,
|
||||
]);
|
||||
|
||||
ffmpeg.stderr.on("data", () => {}); // Suppress logs
|
||||
ffmpeg.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`ffmpeg exited with code ${code}`));
|
||||
});
|
||||
ffmpeg.on("error", reject);
|
||||
});
|
||||
|
||||
// Read converted audio
|
||||
return await readFile(outputPath);
|
||||
} finally {
|
||||
// Clean up temp files
|
||||
await unlink(inputPath).catch(() => {});
|
||||
await unlink(outputPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect and convert audio to OpenAI-compatible format.
|
||||
* - WAV/MP3: Pass through (already compatible)
|
||||
* - WebM/MP4/OGG: Convert to WAV via ffmpeg
|
||||
*/
|
||||
export async function ensureCompatibleFormat(
|
||||
audioBuffer: Buffer
|
||||
): Promise<{ buffer: Buffer; format: "wav" | "mp3" }> {
|
||||
const detected = detectAudioFormat(audioBuffer);
|
||||
if (detected === "wav") return { buffer: audioBuffer, format: "wav" };
|
||||
if (detected === "mp3") return { buffer: audioBuffer, format: "mp3" };
|
||||
// Convert WebM, MP4, OGG, or unknown to WAV
|
||||
const wavBuffer = await convertToWav(audioBuffer);
|
||||
return { buffer: wavBuffer, format: "wav" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice Chat: User speaks, LLM responds with audio (audio-in, audio-out).
|
||||
* Uses gpt-audio model via Replit AI Integrations.
|
||||
* Note: Browser records WebM/opus - convert to WAV using ffmpeg before calling this.
|
||||
*/
|
||||
export async function voiceChat(
|
||||
audioBuffer: Buffer,
|
||||
voice: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" = "alloy",
|
||||
inputFormat: "wav" | "mp3" = "wav",
|
||||
outputFormat: "wav" | "mp3" = "mp3"
|
||||
): Promise<{ transcript: string; audioResponse: Buffer }> {
|
||||
const audioBase64 = audioBuffer.toString("base64");
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-audio",
|
||||
modalities: ["text", "audio"],
|
||||
audio: { voice, format: outputFormat },
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_audio", input_audio: { data: audioBase64, format: inputFormat } },
|
||||
],
|
||||
}],
|
||||
});
|
||||
const message = response.choices[0]?.message as any;
|
||||
const transcript = message?.audio?.transcript || message?.content || "";
|
||||
const audioData = message?.audio?.data ?? "";
|
||||
return {
|
||||
transcript,
|
||||
audioResponse: Buffer.from(audioData, "base64"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming Voice Chat: For real-time audio responses.
|
||||
* Note: Streaming only supports pcm16 output format.
|
||||
*
|
||||
* @example
|
||||
* // Converting browser WebM to WAV before calling:
|
||||
* const webmBuffer = Buffer.from(req.body.audio, "base64");
|
||||
* const wavBuffer = await convertWebmToWav(webmBuffer);
|
||||
* for await (const chunk of voiceChatStream(wavBuffer)) { ... }
|
||||
*/
|
||||
export async function voiceChatStream(
|
||||
audioBuffer: Buffer,
|
||||
voice: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" = "alloy",
|
||||
inputFormat: "wav" | "mp3" = "wav"
|
||||
): Promise<AsyncIterable<{ type: "transcript" | "audio"; data: string }>> {
|
||||
const audioBase64 = audioBuffer.toString("base64");
|
||||
const stream = await openai.chat.completions.create({
|
||||
model: "gpt-audio",
|
||||
modalities: ["text", "audio"],
|
||||
audio: { voice, format: "pcm16" },
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_audio", input_audio: { data: audioBase64, format: inputFormat } },
|
||||
],
|
||||
}],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
return (async function* () {
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta as any;
|
||||
if (!delta) continue;
|
||||
if (delta?.audio?.transcript) {
|
||||
yield { type: "transcript", data: delta.audio.transcript };
|
||||
}
|
||||
if (delta?.audio?.data) {
|
||||
yield { type: "audio", data: delta.audio.data };
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Text-to-Speech: Converts text to speech verbatim.
|
||||
* Uses gpt-audio model via Replit AI Integrations.
|
||||
*/
|
||||
export async function textToSpeech(
|
||||
text: string,
|
||||
voice: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" = "alloy",
|
||||
format: "wav" | "mp3" | "flac" | "opus" | "pcm16" = "wav"
|
||||
): Promise<Buffer> {
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-audio",
|
||||
modalities: ["text", "audio"],
|
||||
audio: { voice, format },
|
||||
messages: [
|
||||
{ role: "system", content: "You are an assistant that performs text-to-speech." },
|
||||
{ role: "user", content: `Repeat the following text verbatim: ${text}` },
|
||||
],
|
||||
});
|
||||
const audioData = (response.choices[0]?.message as any)?.audio?.data ?? "";
|
||||
return Buffer.from(audioData, "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming Text-to-Speech: Converts text to speech with real-time streaming.
|
||||
* Uses gpt-audio model via Replit AI Integrations.
|
||||
* Note: Streaming only supports pcm16 output format.
|
||||
*/
|
||||
export async function textToSpeechStream(
|
||||
text: string,
|
||||
voice: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" = "alloy"
|
||||
): Promise<AsyncIterable<string>> {
|
||||
const stream = await openai.chat.completions.create({
|
||||
model: "gpt-audio",
|
||||
modalities: ["text", "audio"],
|
||||
audio: { voice, format: "pcm16" },
|
||||
messages: [
|
||||
{ role: "system", content: "You are an assistant that performs text-to-speech." },
|
||||
{ role: "user", content: `Repeat the following text verbatim: ${text}` },
|
||||
],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
return (async function* () {
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta as any;
|
||||
if (!delta) continue;
|
||||
if (delta?.audio?.data) {
|
||||
yield delta.audio.data;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Speech-to-Text: Transcribes audio using dedicated transcription model.
|
||||
* Uses gpt-4o-mini-transcribe for accurate transcription.
|
||||
*/
|
||||
export async function speechToText(
|
||||
audioBuffer: Buffer,
|
||||
format: "wav" | "mp3" | "webm" = "wav"
|
||||
): Promise<string> {
|
||||
const file = await toFile(audioBuffer, `audio.${format}`);
|
||||
const response = await openai.audio.transcriptions.create({
|
||||
file,
|
||||
model: "gpt-4o-mini-transcribe",
|
||||
});
|
||||
return response.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming Speech-to-Text: Transcribes audio with real-time streaming.
|
||||
* Uses gpt-4o-mini-transcribe for accurate transcription.
|
||||
*/
|
||||
export async function speechToTextStream(
|
||||
audioBuffer: Buffer,
|
||||
format: "wav" | "mp3" | "webm" = "wav"
|
||||
): Promise<AsyncIterable<string>> {
|
||||
const file = await toFile(audioBuffer, `audio.${format}`);
|
||||
const stream = await openai.audio.transcriptions.create({
|
||||
file,
|
||||
model: "gpt-4o-mini-transcribe",
|
||||
stream: true,
|
||||
});
|
||||
|
||||
return (async function* () {
|
||||
for await (const event of stream) {
|
||||
if (event.type === "transcript.text.delta") {
|
||||
yield event.delta;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
14
server/replit_integrations/audio/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export { registerAudioRoutes } from "./routes";
|
||||
export {
|
||||
openai,
|
||||
detectAudioFormat,
|
||||
convertToWav,
|
||||
ensureCompatibleFormat,
|
||||
type AudioFormat,
|
||||
voiceChat,
|
||||
voiceChatStream,
|
||||
textToSpeech,
|
||||
textToSpeechStream,
|
||||
speechToText,
|
||||
speechToTextStream,
|
||||
} from "./client";
|
||||
136
server/replit_integrations/audio/routes.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import express, { type Express, type Request, type Response } from "express";
|
||||
import { chatStorage } from "../chat/storage";
|
||||
import { openai, speechToText, ensureCompatibleFormat } from "./client";
|
||||
|
||||
// Body parser with 50MB limit for audio payloads
|
||||
const audioBodyParser = express.json({ limit: "50mb" });
|
||||
|
||||
export function registerAudioRoutes(app: Express): void {
|
||||
// Get all conversations
|
||||
app.get("/api/conversations", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const conversations = await chatStorage.getAllConversations();
|
||||
res.json(conversations);
|
||||
} catch (error) {
|
||||
console.error("Error fetching conversations:", error);
|
||||
res.status(500).json({ error: "Failed to fetch conversations" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single conversation with messages
|
||||
app.get("/api/conversations/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const conversation = await chatStorage.getConversation(id);
|
||||
if (!conversation) {
|
||||
return res.status(404).json({ error: "Conversation not found" });
|
||||
}
|
||||
const messages = await chatStorage.getMessagesByConversation(id);
|
||||
res.json({ ...conversation, messages });
|
||||
} catch (error) {
|
||||
console.error("Error fetching conversation:", error);
|
||||
res.status(500).json({ error: "Failed to fetch conversation" });
|
||||
}
|
||||
});
|
||||
|
||||
// Create new conversation
|
||||
app.post("/api/conversations", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { title } = req.body;
|
||||
const conversation = await chatStorage.createConversation(title || "New Chat");
|
||||
res.status(201).json(conversation);
|
||||
} catch (error) {
|
||||
console.error("Error creating conversation:", error);
|
||||
res.status(500).json({ error: "Failed to create conversation" });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete conversation
|
||||
app.delete("/api/conversations/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
await chatStorage.deleteConversation(id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error("Error deleting conversation:", error);
|
||||
res.status(500).json({ error: "Failed to delete conversation" });
|
||||
}
|
||||
});
|
||||
|
||||
// Send voice message and get streaming audio response
|
||||
// Auto-detects audio format and converts WebM/MP4/OGG to WAV
|
||||
// Uses gpt-4o-mini-transcribe for STT, gpt-audio for voice response
|
||||
app.post("/api/conversations/:id/messages", audioBodyParser, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const conversationId = parseInt(req.params.id);
|
||||
const { audio, voice = "alloy" } = req.body;
|
||||
|
||||
if (!audio) {
|
||||
return res.status(400).json({ error: "Audio data (base64) is required" });
|
||||
}
|
||||
|
||||
// 1. Auto-detect format and convert to OpenAI-compatible format
|
||||
const rawBuffer = Buffer.from(audio, "base64");
|
||||
const { buffer: audioBuffer, format: inputFormat } = await ensureCompatibleFormat(rawBuffer);
|
||||
|
||||
// 2. Transcribe user audio
|
||||
const userTranscript = await speechToText(audioBuffer, inputFormat);
|
||||
|
||||
// 3. Save user message
|
||||
await chatStorage.createMessage(conversationId, "user", userTranscript);
|
||||
|
||||
// 4. Get conversation history
|
||||
const existingMessages = await chatStorage.getMessagesByConversation(conversationId);
|
||||
const chatHistory = existingMessages.map((m) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
// 5. Set up SSE
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: "user_transcript", data: userTranscript })}\n\n`);
|
||||
|
||||
// 6. Stream audio response from gpt-audio
|
||||
const stream = await openai.chat.completions.create({
|
||||
model: "gpt-audio",
|
||||
modalities: ["text", "audio"],
|
||||
audio: { voice, format: "pcm16" },
|
||||
messages: chatHistory,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let assistantTranscript = "";
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta as any;
|
||||
if (!delta) continue;
|
||||
|
||||
if (delta?.audio?.transcript) {
|
||||
assistantTranscript += delta.audio.transcript;
|
||||
res.write(`data: ${JSON.stringify({ type: "transcript", data: delta.audio.transcript })}\n\n`);
|
||||
}
|
||||
|
||||
if (delta?.audio?.data) {
|
||||
res.write(`data: ${JSON.stringify({ type: "audio", data: delta.audio.data })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Save assistant message
|
||||
await chatStorage.createMessage(conversationId, "assistant", assistantTranscript);
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: "done", transcript: assistantTranscript })}\n\n`);
|
||||
res.end();
|
||||
} catch (error) {
|
||||
console.error("Error processing voice message:", error);
|
||||
if (res.headersSent) {
|
||||
res.write(`data: ${JSON.stringify({ type: "error", error: "Failed to process voice message" })}\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
res.status(500).json({ error: "Failed to process voice message" });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
7
server/replit_integrations/batch/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export {
|
||||
batchProcess,
|
||||
batchProcessWithSSE,
|
||||
isRateLimitError,
|
||||
type BatchOptions,
|
||||
} from "./utils";
|
||||
|
||||
182
server/replit_integrations/batch/utils.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import pLimit from "p-limit";
|
||||
import pRetry from "p-retry";
|
||||
|
||||
/**
|
||||
* Batch Processing Utilities
|
||||
*
|
||||
* This module provides a generic batch processing function with built-in
|
||||
* rate limiting and automatic retries. Use it for any task that requires
|
||||
* processing multiple items through an LLM or external API.
|
||||
*
|
||||
* USAGE:
|
||||
* ```typescript
|
||||
* import { batchProcess, isRateLimitError } from "./replit_integrations/batch";
|
||||
*
|
||||
* const results = await batchProcess(
|
||||
* artworks,
|
||||
* async (artwork) => {
|
||||
* // Your custom LLM logic here
|
||||
* const response = await openai.chat.completions.create({
|
||||
* model: "gpt-5.1",
|
||||
* messages: [{ role: "user", content: `Categorize: ${artwork.name}` }],
|
||||
* response_format: { type: "json_object" },
|
||||
* });
|
||||
* return JSON.parse(response.choices[0]?.message?.content || "{}");
|
||||
* },
|
||||
* { concurrency: 2, retries: 5 }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface BatchOptions {
|
||||
/** Max concurrent requests (default: 2) */
|
||||
concurrency?: number;
|
||||
/** Max retry attempts for rate limit errors (default: 7) */
|
||||
retries?: number;
|
||||
/** Initial retry delay in ms (default: 2000) */
|
||||
minTimeout?: number;
|
||||
/** Max retry delay in ms (default: 128000) */
|
||||
maxTimeout?: number;
|
||||
/** Callback for progress updates */
|
||||
onProgress?: (completed: number, total: number, item: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a rate limit or quota violation.
|
||||
* Use this in custom error handling if needed.
|
||||
*/
|
||||
export function isRateLimitError(error: unknown): boolean {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
errorMsg.includes("429") ||
|
||||
errorMsg.includes("RATELIMIT_EXCEEDED") ||
|
||||
errorMsg.toLowerCase().includes("quota") ||
|
||||
errorMsg.toLowerCase().includes("rate limit")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process items in batches with rate limiting and automatic retries.
|
||||
*
|
||||
* @param items - Array of items to process
|
||||
* @param processor - Async function to process each item (write your LLM logic here)
|
||||
* @param options - Concurrency and retry settings
|
||||
* @returns Promise resolving to array of results in the same order as input
|
||||
*
|
||||
* @example
|
||||
* // Process CSV artwork data with custom categorization
|
||||
* const categorized = await batchProcess(
|
||||
* csvRows,
|
||||
* async (row) => {
|
||||
* const response = await openai.chat.completions.create({
|
||||
* model: "gpt-5.1", // the newest OpenAI model
|
||||
* messages: [{ role: "user", content: `Categorize artwork: ${row.name}` }],
|
||||
* response_format: { type: "json_object" },
|
||||
* });
|
||||
* return { ...row, category: JSON.parse(response.choices[0]?.message?.content || "{}") };
|
||||
* }
|
||||
* );
|
||||
*/
|
||||
export async function batchProcess<T, R>(
|
||||
items: T[],
|
||||
processor: (item: T, index: number) => Promise<R>,
|
||||
options: BatchOptions = {}
|
||||
): Promise<R[]> {
|
||||
const {
|
||||
concurrency = 2,
|
||||
retries = 7,
|
||||
minTimeout = 2000,
|
||||
maxTimeout = 128000,
|
||||
onProgress,
|
||||
} = options;
|
||||
|
||||
const limit = pLimit(concurrency);
|
||||
let completed = 0;
|
||||
|
||||
const promises = items.map((item, index) =>
|
||||
limit(() =>
|
||||
pRetry(
|
||||
async () => {
|
||||
try {
|
||||
const result = await processor(item, index);
|
||||
completed++;
|
||||
onProgress?.(completed, items.length, item);
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isRateLimitError(error)) {
|
||||
throw error; // Rethrow to trigger p-retry
|
||||
}
|
||||
// For non-rate-limit errors, abort immediately
|
||||
throw new pRetry.AbortError(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
},
|
||||
{ retries, minTimeout, maxTimeout, factor: 2 }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process items sequentially with SSE progress streaming.
|
||||
* Use this when you need real-time progress updates to the client.
|
||||
*
|
||||
* @param items - Array of items to process
|
||||
* @param processor - Async function to process each item
|
||||
* @param sendEvent - Function to send SSE events to the client
|
||||
* @param options - Retry settings (concurrency is always 1 for sequential)
|
||||
*/
|
||||
export async function batchProcessWithSSE<T, R>(
|
||||
items: T[],
|
||||
processor: (item: T, index: number) => Promise<R>,
|
||||
sendEvent: (event: { type: string; [key: string]: unknown }) => void,
|
||||
options: Omit<BatchOptions, "concurrency" | "onProgress"> = {}
|
||||
): Promise<R[]> {
|
||||
const { retries = 5, minTimeout = 1000, maxTimeout = 15000 } = options;
|
||||
|
||||
sendEvent({ type: "started", total: items.length });
|
||||
|
||||
const results: R[] = [];
|
||||
let errors = 0;
|
||||
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
const item = items[index];
|
||||
sendEvent({ type: "processing", index, item });
|
||||
|
||||
try {
|
||||
const result = await pRetry(
|
||||
() => processor(item, index),
|
||||
{
|
||||
retries,
|
||||
minTimeout,
|
||||
maxTimeout,
|
||||
factor: 2,
|
||||
onFailedAttempt: (error) => {
|
||||
if (!isRateLimitError(error)) {
|
||||
throw new pRetry.AbortError(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
results.push(result);
|
||||
sendEvent({ type: "progress", index, result });
|
||||
} catch (error) {
|
||||
errors++;
|
||||
results.push(undefined as R); // Placeholder for failed items
|
||||
sendEvent({
|
||||
type: "progress",
|
||||
index,
|
||||
error: error instanceof Error ? error.message : "Processing failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sendEvent({ type: "complete", processed: items.length, errors });
|
||||
return results;
|
||||
}
|
||||
|
||||
3
server/replit_integrations/chat/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { registerChatRoutes } from "./routes";
|
||||
export { chatStorage, type IChatStorage } from "./storage";
|
||||
|
||||
118
server/replit_integrations/chat/routes.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import type { Express, Request, Response } from "express";
|
||||
import OpenAI from "openai";
|
||||
import { chatStorage } from "./storage";
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
|
||||
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
|
||||
});
|
||||
|
||||
export function registerChatRoutes(app: Express): void {
|
||||
// Get all conversations
|
||||
app.get("/api/conversations", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const conversations = await chatStorage.getAllConversations();
|
||||
res.json(conversations);
|
||||
} catch (error) {
|
||||
console.error("Error fetching conversations:", error);
|
||||
res.status(500).json({ error: "Failed to fetch conversations" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single conversation with messages
|
||||
app.get("/api/conversations/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const conversation = await chatStorage.getConversation(id);
|
||||
if (!conversation) {
|
||||
return res.status(404).json({ error: "Conversation not found" });
|
||||
}
|
||||
const messages = await chatStorage.getMessagesByConversation(id);
|
||||
res.json({ ...conversation, messages });
|
||||
} catch (error) {
|
||||
console.error("Error fetching conversation:", error);
|
||||
res.status(500).json({ error: "Failed to fetch conversation" });
|
||||
}
|
||||
});
|
||||
|
||||
// Create new conversation
|
||||
app.post("/api/conversations", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { title } = req.body;
|
||||
const conversation = await chatStorage.createConversation(title || "New Chat");
|
||||
res.status(201).json(conversation);
|
||||
} catch (error) {
|
||||
console.error("Error creating conversation:", error);
|
||||
res.status(500).json({ error: "Failed to create conversation" });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete conversation
|
||||
app.delete("/api/conversations/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
await chatStorage.deleteConversation(id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error("Error deleting conversation:", error);
|
||||
res.status(500).json({ error: "Failed to delete conversation" });
|
||||
}
|
||||
});
|
||||
|
||||
// Send message and get AI response (streaming)
|
||||
app.post("/api/conversations/:id/messages", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const conversationId = parseInt(req.params.id);
|
||||
const { content } = req.body;
|
||||
|
||||
// Save user message
|
||||
await chatStorage.createMessage(conversationId, "user", content);
|
||||
|
||||
// Get conversation history for context
|
||||
const messages = await chatStorage.getMessagesByConversation(conversationId);
|
||||
const chatMessages = messages.map((m) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
// Set up SSE
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
|
||||
// Stream response from OpenAI
|
||||
const stream = await openai.chat.completions.create({
|
||||
model: "gpt-5.1",
|
||||
messages: chatMessages,
|
||||
stream: true,
|
||||
max_completion_tokens: 8192,
|
||||
});
|
||||
|
||||
let fullResponse = "";
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const content = chunk.choices[0]?.delta?.content || "";
|
||||
if (content) {
|
||||
fullResponse += content;
|
||||
res.write(`data: ${JSON.stringify({ content })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save assistant message
|
||||
await chatStorage.createMessage(conversationId, "assistant", fullResponse);
|
||||
|
||||
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
|
||||
res.end();
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
// Check if headers already sent (SSE streaming started)
|
||||
if (res.headersSent) {
|
||||
res.write(`data: ${JSON.stringify({ error: "Failed to send message" })}\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
res.status(500).json({ error: "Failed to send message" });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
43
server/replit_integrations/chat/storage.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { db } from "../../db";
|
||||
import { conversations, messages } from "@shared/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
|
||||
export interface IChatStorage {
|
||||
getConversation(id: number): Promise<typeof conversations.$inferSelect | undefined>;
|
||||
getAllConversations(): Promise<(typeof conversations.$inferSelect)[]>;
|
||||
createConversation(title: string): Promise<typeof conversations.$inferSelect>;
|
||||
deleteConversation(id: number): Promise<void>;
|
||||
getMessagesByConversation(conversationId: number): Promise<(typeof messages.$inferSelect)[]>;
|
||||
createMessage(conversationId: number, role: string, content: string): Promise<typeof messages.$inferSelect>;
|
||||
}
|
||||
|
||||
export const chatStorage: IChatStorage = {
|
||||
async getConversation(id: number) {
|
||||
const [conversation] = await db.select().from(conversations).where(eq(conversations.id, id));
|
||||
return conversation;
|
||||
},
|
||||
|
||||
async getAllConversations() {
|
||||
return db.select().from(conversations).orderBy(desc(conversations.createdAt));
|
||||
},
|
||||
|
||||
async createConversation(title: string) {
|
||||
const [conversation] = await db.insert(conversations).values({ title }).returning();
|
||||
return conversation;
|
||||
},
|
||||
|
||||
async deleteConversation(id: number) {
|
||||
await db.delete(messages).where(eq(messages.conversationId, id));
|
||||
await db.delete(conversations).where(eq(conversations.id, id));
|
||||
},
|
||||
|
||||
async getMessagesByConversation(conversationId: number) {
|
||||
return db.select().from(messages).where(eq(messages.conversationId, conversationId)).orderBy(messages.createdAt);
|
||||
},
|
||||
|
||||
async createMessage(conversationId: number, role: string, content: string) {
|
||||
const [message] = await db.insert(messages).values({ conversationId, role, content }).returning();
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
59
server/replit_integrations/image/client.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import fs from "node:fs";
|
||||
import OpenAI, { toFile } from "openai";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
export const openai = new OpenAI({
|
||||
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
|
||||
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate an image and return as Buffer.
|
||||
* Uses gpt-image-1 model via Replit AI Integrations.
|
||||
*/
|
||||
export async function generateImageBuffer(
|
||||
prompt: string,
|
||||
size: "1024x1024" | "512x512" | "256x256" = "1024x1024"
|
||||
): Promise<Buffer> {
|
||||
const response = await openai.images.generate({
|
||||
model: "gpt-image-1",
|
||||
prompt,
|
||||
size,
|
||||
});
|
||||
const base64 = response.data[0]?.b64_json ?? "";
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit/combine multiple images into a composite.
|
||||
* Uses gpt-image-1 model via Replit AI Integrations.
|
||||
*/
|
||||
export async function editImages(
|
||||
imageFiles: string[],
|
||||
prompt: string,
|
||||
outputPath?: string
|
||||
): Promise<Buffer> {
|
||||
const images = await Promise.all(
|
||||
imageFiles.map((file) =>
|
||||
toFile(fs.createReadStream(file), file, {
|
||||
type: "image/png",
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const response = await openai.images.edit({
|
||||
model: "gpt-image-1",
|
||||
image: images,
|
||||
prompt,
|
||||
});
|
||||
|
||||
const imageBase64 = response.data[0]?.b64_json ?? "";
|
||||
const imageBytes = Buffer.from(imageBase64, "base64");
|
||||
|
||||
if (outputPath) {
|
||||
fs.writeFileSync(outputPath, imageBytes);
|
||||
}
|
||||
|
||||
return imageBytes;
|
||||
}
|
||||
|
||||
3
server/replit_integrations/image/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { registerImageRoutes } from "./routes";
|
||||
export { openai, generateImageBuffer, editImages } from "./client";
|
||||
|
||||
31
server/replit_integrations/image/routes.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type { Express, Request, Response } from "express";
|
||||
import { openai } from "./client";
|
||||
|
||||
export function registerImageRoutes(app: Express): void {
|
||||
app.post("/api/generate-image", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { prompt, size = "1024x1024" } = req.body;
|
||||
|
||||
if (!prompt) {
|
||||
return res.status(400).json({ error: "Prompt is required" });
|
||||
}
|
||||
|
||||
const response = await openai.images.generate({
|
||||
model: "gpt-image-1",
|
||||
prompt,
|
||||
n: 1,
|
||||
size: size as "1024x1024" | "512x512" | "256x256",
|
||||
});
|
||||
|
||||
const imageData = response.data[0];
|
||||
res.json({
|
||||
url: imageData.url,
|
||||
b64_json: imageData.b64_json,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating image:", error);
|
||||
res.status(500).json({ error: "Failed to generate image" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
103
server/routes.ts
@ -3,6 +3,7 @@ import { createServer, type Server } from "http";
|
||||
import { storage } from "./storage";
|
||||
import { insertArticleSchema } from "@shared/schema";
|
||||
import { seedDatabase } from "./seed";
|
||||
import { generateDailyHoroscopes, getHoroscopesForToday, getOrGenerateHoroscope } from "./horoscope-generator";
|
||||
import multer from "multer";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
@ -37,6 +38,10 @@ export async function registerRoutes(
|
||||
): Promise<Server> {
|
||||
await seedDatabase();
|
||||
|
||||
generateDailyHoroscopes().catch((err) =>
|
||||
console.error("Background horoscope generation failed:", err.message)
|
||||
);
|
||||
|
||||
app.get("/api/articles", async (_req, res) => {
|
||||
const articles = await storage.getArticles();
|
||||
res.json(articles);
|
||||
@ -172,5 +177,103 @@ export async function registerRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// Gallery API - serves shuffled photos from Dropbox
|
||||
app.get("/api/gallery", (_req, res) => {
|
||||
try {
|
||||
const galleryPath = path.join(process.cwd(), "server/gallery-data.json");
|
||||
const data = JSON.parse(fs.readFileSync(galleryPath, "utf-8"));
|
||||
// Shuffle using Fisher-Yates
|
||||
const shuffled = [...data];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
res.json(shuffled);
|
||||
} catch (err: any) {
|
||||
res.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
// Horoscope API
|
||||
app.get("/api/horoscopes/today", async (_req, res) => {
|
||||
try {
|
||||
const horoscopes = await getHoroscopesForToday();
|
||||
res.json(horoscopes);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/horoscopes/sign/:index", async (req, res) => {
|
||||
try {
|
||||
const signIndex = parseInt(req.params.index);
|
||||
if (isNaN(signIndex) || signIndex < 0 || signIndex > 11) {
|
||||
return res.status(400).json({ message: "Invalid sign index" });
|
||||
}
|
||||
const horoscope = await getOrGenerateHoroscope(signIndex);
|
||||
res.json(horoscope);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/horoscopes/generate", async (_req, res) => {
|
||||
try {
|
||||
await generateDailyHoroscopes();
|
||||
const horoscopes = await getHoroscopesForToday();
|
||||
res.json({ generated: horoscopes.length, horoscopes });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// News feed - Volksmusik/Schlager news from Google News RSS
|
||||
app.get("/api/news-feed", async (_req, res) => {
|
||||
try {
|
||||
const topics = ["Volksmusik", "Schlager+Musik", "Oberkrainer"];
|
||||
const topic = topics[Math.floor(Date.now() / 3600000) % topics.length];
|
||||
const rssUrl = `https://news.google.com/rss/search?q=${topic}&hl=de&gl=DE&ceid=DE:de`;
|
||||
|
||||
const response = await new Promise<string>((resolve, reject) => {
|
||||
https.get(rssUrl, (resp) => {
|
||||
let data = "";
|
||||
resp.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||
resp.on("end", () => resolve(data));
|
||||
resp.on("error", reject);
|
||||
}).on("error", reject);
|
||||
});
|
||||
|
||||
const items: { title: string; link: string; source: string; pubDate: string }[] = [];
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
||||
let match;
|
||||
while ((match = itemRegex.exec(response)) !== null && items.length < 10) {
|
||||
const block = match[1];
|
||||
const title = block.match(/<title>(.*?)<\/title>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "") || "";
|
||||
const link = block.match(/<link>(.*?)<\/link>/)?.[1] || "";
|
||||
const source = block.match(/<source[^>]*>(.*?)<\/source>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "") || "";
|
||||
const pubDateRaw = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] || "";
|
||||
|
||||
let pubDate = "";
|
||||
try {
|
||||
const d = new Date(pubDateRaw);
|
||||
const diffH = Math.floor((Date.now() - d.getTime()) / 3600000);
|
||||
if (diffH < 1) pubDate = "Gerade eben";
|
||||
else if (diffH < 24) pubDate = `vor ${diffH} Std.`;
|
||||
else pubDate = `vor ${Math.floor(diffH / 24)} T.`;
|
||||
} catch {
|
||||
pubDate = "";
|
||||
}
|
||||
|
||||
if (title && link) {
|
||||
items.push({ title, link, source, pubDate });
|
||||
}
|
||||
}
|
||||
|
||||
res.json(items);
|
||||
} catch (err: any) {
|
||||
res.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
@ -79,14 +79,28 @@ const seedArticles = [
|
||||
"featured": false,
|
||||
"publishedAt": "2025-08-31T14:29:00.000Z",
|
||||
"content": "<p>Eine faszinierende Dokumentation über die <strong>Oberkrainer aus Begunje</strong> und die Geschichte ihres legendären Liedes. Tauchen Sie ein in die Welt der slowenischen Volksmusik und erfahren Sie, wie diese einzigartige Musiktradition entstanden ist.</p>\n<div style=\"display:flex;justify-content:center;margin:2rem 0;\"><iframe src=\"https://player.mediadelivery.net/embed/476412/e87a6341-df47-4fa9-bd2e-b2f99be111dc\" loading=\"lazy\" style=\"border:none;width:100%;aspect-ratio:16/9;\" allow=\"accelerometer;gyroscope;encrypted-media;picture-in-picture;\" allowfullscreen=\"true\"></iframe></div>\n<p>Die Oberkrainer Musik, begründet von <strong>Slavko Avsenik</strong> in den 1950er Jahren, hat sich zu einem Weltphänomen entwickelt. Diese Dokumentation erzählt die Geschichte hinter den Melodien, die Millionen von Menschen auf der ganzen Welt berührt haben.</p>"
|
||||
},
|
||||
{
|
||||
"title": "Melanie Payer präsentiert den neuen Song „Endlich wieder Gipfelstammtisch“",
|
||||
"slug": "melanie-payer-endlich-wieder-gipfelstammtisch",
|
||||
"excerpt": "Die talentierte Sängerin Melanie Payer bringt mit ihrem neuen Titel „Endlich wieder Gipfelstammtisch“ frischen Wind in die Musikwelt. Der Song markiert die erste Veröffentlichung des Labels mymusic.media und ist das Herzstück der neuen Staffel der beliebten Sendung auf Folx TV.",
|
||||
"category": "News",
|
||||
"author": "Folx Music Television",
|
||||
"coverImage": "/uploads/melanie-payer-gipfelstammtisch.jpg",
|
||||
"featured": true,
|
||||
"publishedAt": "2025-09-05T10:00:00.000Z",
|
||||
"content": "<p>Die talentierte Sängerin <strong>Melanie Payer</strong> bringt mit ihrem neuen Titel „Endlich wieder Gipfelstammtisch“ frischen Wind in die Musikwelt. Der Song wird ab dem 27. September 2024 zum Streaming und Download verfügbar sein und markiert die erste Veröffentlichung des Labels mymusic.media.</p>\n<div style=\"display:flex;justify-content:center;margin:2rem 0;\"><iframe src=\"https://www.facebook.com/plugins/video.php?height=476&href=https%3A%2F%2Fwww.facebook.com%2Freel%2F1201643474458180%2F&show_text=true&width=380&t=0\" width=\"380\" height=\"591\" style=\"border:none;overflow:hidden\" scrolling=\"no\" frameborder=\"0\" allowfullscreen=\"true\" allow=\"autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share\"></iframe></div>\n<p>Die Schaffung dieser musikalischen Perle fand im renommierten Tonstudio FD-Musics in Gmunden statt. Der Song wurde von <strong>Flo Daxner</strong> und <strong>Hanneliese Kreißl Wurth</strong> komponiert, die auch die Texte verfasste. Flo Daxner übernahm zudem die Produktion und das Arrangement, was dem Lied seine einzigartige Klangtiefe verleiht.</p>\n<div style=\"display:flex;justify-content:center;margin:2rem 0;\"><blockquote class=\"instagram-media\" data-instgrm-permalink=\"https://www.instagram.com/reel/C9UphycIry4/\" data-instgrm-version=\"14\" style=\"background:#000;border:0;border-radius:3px;max-width:540px;min-width:326px;padding:0;width:calc(100% - 2px);\"><a href=\"https://www.instagram.com/reel/C9UphycIry4/\">View on Instagram</a></blockquote></div>\n<p>„Endlich wieder Gipfelstammtisch“ ist das Herzstück der neuen Staffel der beliebten Sendung <strong>„Gipfelstammtisch“</strong> auf Folx TV. Moderiert von dem charismatischen <strong>Wijbrand van der Sande</strong>, verspricht die Show, Zuschauer mit einzigartigen Einblicken und der bezaubernden Volksmusik der Wilder Kaiser Region zu fesseln. Der Song nimmt Zuhörer auf eine musikalische Reise mit, die die Magie der Berge und der Volksmusik in jedem Ton spürbar macht.</p>\n<div style=\"display:flex;justify-content:center;margin:2rem 0;\"><blockquote class=\"instagram-media\" data-instgrm-permalink=\"https://www.instagram.com/reel/C__LXF_x-a5/\" data-instgrm-version=\"14\" style=\"background:#000;border:0;border-radius:3px;max-width:540px;min-width:326px;padding:0;width:calc(100% - 2px);\"><a href=\"https://www.instagram.com/reel/C__LXF_x-a5/\">View on Instagram</a></blockquote></div>\n<p>Folx TV setzt sich für innovative Unterhaltung und Musik ein. Die neue Staffel des Gipfelstammtischs und der Song von Melanie Payer sind mit Spannung erwartet. Verpassen Sie nicht die neue Staffel und erleben Sie den Song und den Gipfelstammtisch hautnah!</p>"
|
||||
}
|
||||
];
|
||||
|
||||
export async function seedDatabase() {
|
||||
const existing = await storage.getArticles();
|
||||
if (existing.length > 0) return;
|
||||
const existingSlugs = new Set(existing.map((a) => a.slug));
|
||||
|
||||
let added = 0;
|
||||
for (const article of seedArticles) {
|
||||
if (existingSlugs.has(article.slug)) continue;
|
||||
|
||||
await storage.createArticle({
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
@ -97,15 +111,24 @@ export async function seedDatabase() {
|
||||
author: article.author,
|
||||
featured: article.featured,
|
||||
});
|
||||
}
|
||||
|
||||
for (const article of seedArticles) {
|
||||
if (article.publishedAt) {
|
||||
await db.execute(
|
||||
sql`UPDATE articles SET published_at = ${article.publishedAt} WHERE slug = ${article.slug}`
|
||||
);
|
||||
}
|
||||
added++;
|
||||
}
|
||||
|
||||
console.log("Database seeded with " + seedArticles.length + " articles.");
|
||||
if (added > 0) {
|
||||
console.log("Database seeded: added " + added + " new articles.");
|
||||
}
|
||||
|
||||
for (const article of seedArticles) {
|
||||
if (article.publishedAt && existingSlugs.has(article.slug)) {
|
||||
await db.execute(
|
||||
sql`UPDATE articles SET published_at = ${article.publishedAt} WHERE slug = ${article.slug}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
34
shared/models/chat.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { pgTable, serial, integer, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const conversations = pgTable("conversations", {
|
||||
id: serial("id").primaryKey(),
|
||||
title: text("title").notNull(),
|
||||
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
});
|
||||
|
||||
export const messages = pgTable("messages", {
|
||||
id: serial("id").primaryKey(),
|
||||
conversationId: integer("conversation_id").notNull().references(() => conversations.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull(),
|
||||
content: text("content").notNull(),
|
||||
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
});
|
||||
|
||||
export const insertConversationSchema = createInsertSchema(conversations).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
});
|
||||
|
||||
export const insertMessageSchema = createInsertSchema(messages).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
});
|
||||
|
||||
export type Conversation = typeof conversations.$inferSelect;
|
||||
export type InsertConversation = z.infer<typeof insertConversationSchema>;
|
||||
export type Message = typeof messages.$inferSelect;
|
||||
export type InsertMessage = z.infer<typeof insertMessageSchema>;
|
||||
|
||||
@ -26,6 +26,23 @@ export const insertArticleSchema = createInsertSchema(articles).omit({
|
||||
export type InsertArticle = z.infer<typeof insertArticleSchema>;
|
||||
export type Article = typeof articles.$inferSelect;
|
||||
|
||||
export const dailyHoroscopes = pgTable("daily_horoscopes", {
|
||||
id: serial("id").primaryKey(),
|
||||
signIndex: integer("sign_index").notNull(),
|
||||
signName: varchar("sign_name", { length: 50 }).notNull(),
|
||||
dateStr: varchar("date_str", { length: 10 }).notNull(),
|
||||
general: text("general").notNull(),
|
||||
love: text("love").notNull(),
|
||||
career: text("career").notNull(),
|
||||
health: text("health").notNull(),
|
||||
tip: text("tip").notNull(),
|
||||
weekly: text("weekly").notNull(),
|
||||
monthly: text("monthly").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type DailyHoroscope = typeof dailyHoroscopes.$inferSelect;
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
username: text("username").notNull().unique(),
|
||||
|
||||