Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72f8b99092 | ||
|
|
e60daa511b | ||
|
|
19706abacb | ||
|
|
170e7b5402 | ||
|
|
ade1043c3c | ||
|
|
d69af49e24 | ||
|
|
63687524e7 | ||
|
|
0246cb58ee | ||
|
|
d76aa9f4e9 | ||
|
|
28afaf598b | ||
|
|
8239753911 | ||
|
|
0bfcf0737e | ||
|
|
5409196008 | ||
|
|
a59f6f747e | ||
|
|
dc763c88a3 | ||
|
|
1613bf0dda | ||
|
|
b872e87b50 | ||
|
|
87c1ee63ec | ||
|
|
8c57e938e8 | ||
|
|
9eb07ee8d5 | ||
|
|
3eb6c7f5cf | ||
|
|
2846afb6f7 | ||
|
|
27fa689b18 | ||
|
|
61846a6982 | ||
|
|
bba6b9ef31 | ||
|
|
a8867ac42e | ||
|
|
9006b208af | ||
|
|
20c8ad7eaf | ||
|
|
03129a5611 | ||
|
|
fd8f4adcc0 | ||
|
|
23997ccc3a | ||
|
|
85bdbf795c | ||
|
|
ac0bb02ba0 | ||
|
|
63269c2600 | ||
|
|
17a39d677d | ||
|
|
1ff0787e4e | ||
|
|
ed5f02bdec | ||
|
|
e3a09864a6 | ||
|
|
107739ade9 |
@@ -50,6 +50,7 @@ Dockerfile*
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
/docs
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -52,3 +52,5 @@ next-env.d.ts
|
||||
.release-years-migrated
|
||||
.covers-migrated
|
||||
docker-compose.yml
|
||||
scripts/scrape-bahn-expert-statements.js
|
||||
docs/bahn-expert-statements.txt
|
||||
|
||||
@@ -38,6 +38,9 @@ RUN if [ -n "$APP_VERSION" ]; then \
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Suppress baseline-browser-mapping warning about old data (informational only)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Generate Prisma Client
|
||||
ENV DATABASE_URL="file:./dev.db"
|
||||
RUN node_modules/.bin/prisma generate
|
||||
@@ -46,8 +49,6 @@ RUN node_modules/.bin/prisma generate
|
||||
ARG NEXT_PUBLIC_APP_NAME
|
||||
ARG NEXT_PUBLIC_APP_DESCRIPTION
|
||||
ARG NEXT_PUBLIC_DOMAIN
|
||||
ARG NEXT_PUBLIC_TWITTER_HANDLE
|
||||
ARG NEXT_PUBLIC_PLAUSIBLE_DOMAIN
|
||||
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
||||
ARG NEXT_PUBLIC_THEME_COLOR
|
||||
ARG NEXT_PUBLIC_BACKGROUND_COLOR
|
||||
@@ -60,8 +61,6 @@ ARG NEXT_PUBLIC_CREDITS_LINK_URL
|
||||
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
|
||||
ENV NEXT_PUBLIC_APP_DESCRIPTION=$NEXT_PUBLIC_APP_DESCRIPTION
|
||||
ENV NEXT_PUBLIC_DOMAIN=$NEXT_PUBLIC_DOMAIN
|
||||
ENV NEXT_PUBLIC_TWITTER_HANDLE=$NEXT_PUBLIC_TWITTER_HANDLE
|
||||
ENV NEXT_PUBLIC_PLAUSIBLE_DOMAIN=$NEXT_PUBLIC_PLAUSIBLE_DOMAIN
|
||||
ENV NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=$NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
||||
ENV NEXT_PUBLIC_THEME_COLOR=$NEXT_PUBLIC_THEME_COLOR
|
||||
ENV NEXT_PUBLIC_BACKGROUND_COLOR=$NEXT_PUBLIC_BACKGROUND_COLOR
|
||||
|
||||
14
README.md
14
README.md
@@ -56,18 +56,18 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
|
||||
Hördle unterstützt vollständige Mehrsprachigkeit für Deutsch und Englisch.
|
||||
|
||||
👉 **[Vollständige i18n-Dokumentation](I18N.md)**
|
||||
👉 **[Vollständige i18n-Dokumentation](docs/I18N.md)**
|
||||
|
||||
**Schnellstart:**
|
||||
- Deutsche Version: `http://localhost:3000/de`
|
||||
- Englische Version: `http://localhost:3000/en`
|
||||
- Root (`/`) leitet automatisch zur Standardsprache (Deutsch) um
|
||||
- Root (`/`) leitet automatisch zur Standardsprache (Englisch) um
|
||||
|
||||
## White Labeling
|
||||
|
||||
Hördle ist "White Label Ready". Das bedeutet, du kannst das Branding (Name, Farben, Logos) komplett anpassen, ohne den Code zu ändern.
|
||||
|
||||
👉 **[Anleitung zur Anpassung (White Label Guide)](WHITE_LABEL.md)**
|
||||
👉 **[Anleitung zur Anpassung (White Label Guide)](docs/WHITE_LABEL.md)**
|
||||
|
||||
Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen.
|
||||
|
||||
@@ -77,13 +77,15 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
|
||||
|
||||
- **Start-Punktestand:** 90 Punkte
|
||||
- **Richtige Antwort:** +20 Punkte
|
||||
- **Falsche Antwort:** -3 Punkte
|
||||
- **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
|
||||
- **Überspringen (Skip):** -5 Punkte
|
||||
- **Snippet erneut abspielen (Replay):** -1 Punkt
|
||||
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
|
||||
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
|
||||
- **Minimum:** Der Punktestand kann nicht unter 0 fallen.
|
||||
|
||||
**Hinweis:** Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die automatische Verlängerung des Audio-Snippets (unlockSteps) abgezogen, um die Verwendung dieses Hilfsmittels zu reflektieren.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 16 (App Router)
|
||||
@@ -115,13 +117,13 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Die App läuft unter `http://localhost:3000` (leitet automatisch zu `/de` um).
|
||||
Die App läuft unter `http://localhost:3000` (leitet automatisch zu `/en` um).
|
||||
|
||||
## Deployment mit Docker
|
||||
|
||||
Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||
|
||||
👉 **[White Labeling mit Docker? Hier klicken!](WHITE_LABEL.md#docker-deployment)**
|
||||
👉 **[White Labeling mit Docker? Hier klicken!](docs/WHITE_LABEL.md#docker-deployment)**
|
||||
|
||||
1. **Vorbereitung:**
|
||||
Kopiere die Beispiel-Konfiguration:
|
||||
|
||||
@@ -6,6 +6,8 @@ import { PrismaClient } from '@prisma/client';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -15,6 +17,32 @@ interface PageProps {
|
||||
params: Promise<{ locale: string; genre: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, genre } = await params;
|
||||
const decodedGenre = decodeURIComponent(genre);
|
||||
|
||||
// Fetch genre to get localized name
|
||||
const allGenres = await prisma.genre.findMany();
|
||||
const currentGenre = allGenres.find(g => getLocalizedValue(g.name, locale) === decodedGenre);
|
||||
|
||||
if (!currentGenre || !currentGenre.active) {
|
||||
return await generateBaseMetadata(locale, genre);
|
||||
}
|
||||
|
||||
const genreName = getLocalizedValue(currentGenre.name, locale);
|
||||
const genreSubtitle = getLocalizedValue(currentGenre.subtitle, locale);
|
||||
|
||||
const title = locale === 'de'
|
||||
? `${genreName} - Hördle`
|
||||
: `${genreName} - Hördle`;
|
||||
|
||||
const description = genreSubtitle || (locale === 'de'
|
||||
? `Spiele Hördle im Genre ${genreName} und errate Songs aus kurzen Audio-Clips!`
|
||||
: `Play Hördle in the ${genreName} genre and guess songs from short audio clips!`);
|
||||
|
||||
return await generateBaseMetadata(locale, genre, title, description);
|
||||
}
|
||||
|
||||
export default async function GenrePage({ params }: PageProps) {
|
||||
const { locale, genre } = await params;
|
||||
const decodedGenre = decodeURIComponent(genre);
|
||||
@@ -59,6 +87,9 @@ export default async function GenrePage({ params }: PageProps) {
|
||||
return s.launchDate && s.launchDate > now;
|
||||
});
|
||||
|
||||
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
|
||||
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||
@@ -128,7 +159,7 @@ export default async function GenrePage({ params }: PageProps) {
|
||||
)}
|
||||
</div>
|
||||
<NewsSection locale={locale} />
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} requiredDailyKeys={requiredDailyKeys} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Link } from "@/lib/navigation";
|
||||
import { generateBaseMetadata } from "@/lib/metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
interface AboutPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: AboutPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "About" });
|
||||
|
||||
const title = t("title");
|
||||
const description = t("intro");
|
||||
|
||||
return await generateBaseMetadata(locale, "about", title, description);
|
||||
}
|
||||
|
||||
export default async function AboutPage({ params }: AboutPageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "About" });
|
||||
@@ -51,11 +63,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
{t("imprintEmailLabel")}{" "}
|
||||
<a href="mailto:markus@hoerdle.de">markus@hoerdle.de</a>
|
||||
</p>
|
||||
<p
|
||||
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
|
||||
>
|
||||
{t("imprintDisclaimer")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
@@ -91,13 +98,27 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
marginBottom: "0.75rem",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "0.9rem",
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
{t("costsSheetPrivacyNote")}
|
||||
</p>
|
||||
<p style={{ marginBottom: "0.75rem" }}>
|
||||
{t.rich("costsDonationNote", {
|
||||
link: (chunks) => (
|
||||
<a
|
||||
href="https://politicalbeauty.de/ueber-das-ZPS.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
@@ -199,6 +220,58 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "#f9fafb",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: "600",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("supportCuratorTitle")}
|
||||
</h3>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
{t("supportCuratorText")}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "#f9fafb",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: "600",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("supportReportBugTitle")}
|
||||
</h3>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
{t.rich("supportReportBugText", {
|
||||
email: (chunks) => (
|
||||
<a
|
||||
href="mailto:admin@hoerdle.de"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
@@ -234,11 +307,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
</ul>
|
||||
<p style={{ marginBottom: "0.5rem" }}>{t("privacyServerLogs")}</p>
|
||||
<p style={{ marginBottom: "0.5rem" }}>{t("privacyContact")}</p>
|
||||
<p
|
||||
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
|
||||
>
|
||||
{t("privacyNoLegalAdvice")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
|
||||
@@ -68,6 +68,14 @@ interface News {
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface PoliticalStatement {
|
||||
id: number;
|
||||
text: string;
|
||||
active?: boolean;
|
||||
source?: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
@@ -169,6 +177,11 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
||||
const [showSpecials, setShowSpecials] = useState(false);
|
||||
const [showGenres, setShowGenres] = useState(false);
|
||||
const [showNews, setShowNews] = useState(false);
|
||||
const [showPoliticalStatements, setShowPoliticalStatements] = useState(false);
|
||||
const [politicalStatementsLocale, setPoliticalStatementsLocale] = useState<'de' | 'en'>('de');
|
||||
const [politicalStatements, setPoliticalStatements] = useState<PoliticalStatement[]>([]);
|
||||
const [newPoliticalStatementText, setNewPoliticalStatementText] = useState('');
|
||||
const [newPoliticalStatementActive, setNewPoliticalStatementActive] = useState(true);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Check for existing auth on mount
|
||||
@@ -447,6 +460,100 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Political Statements functions (JSON-backed via API)
|
||||
const fetchPoliticalStatements = async (targetLocale: 'de' | 'en') => {
|
||||
const res = await fetch(`/api/political-statements?locale=${encodeURIComponent(targetLocale)}&admin=true`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const enriched: PoliticalStatement[] = data.map((s: any) => ({
|
||||
id: s.id,
|
||||
text: s.text,
|
||||
active: s.active !== false,
|
||||
source: s.source,
|
||||
locale: targetLocale,
|
||||
}));
|
||||
setPoliticalStatements(prev => {
|
||||
const others = prev.filter(p => p.locale !== targetLocale);
|
||||
return [...others, ...enriched];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePoliticalStatement = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newPoliticalStatementText.trim()) return;
|
||||
|
||||
const res = await fetch('/api/political-statements', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
locale: politicalStatementsLocale,
|
||||
text: newPoliticalStatementText.trim(),
|
||||
active: newPoliticalStatementActive,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setNewPoliticalStatementText('');
|
||||
setNewPoliticalStatementActive(true);
|
||||
fetchPoliticalStatements(politicalStatementsLocale);
|
||||
} else {
|
||||
alert('Failed to create statement');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditPoliticalStatementText = (locale: string, id: number, text: string) => {
|
||||
setPoliticalStatements(prev =>
|
||||
prev.map(s => (s.locale === locale && s.id === id ? { ...s, text } : s)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleEditPoliticalStatementActive = (locale: string, id: number, active: boolean) => {
|
||||
setPoliticalStatements(prev =>
|
||||
prev.map(s => (s.locale === locale && s.id === id ? { ...s, active } : s)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSavePoliticalStatement = async (locale: string, id: number) => {
|
||||
const stmt = politicalStatements.find(s => s.locale === locale && s.id === id);
|
||||
if (!stmt) return;
|
||||
|
||||
const res = await fetch('/api/political-statements', {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
locale,
|
||||
id,
|
||||
text: stmt.text,
|
||||
active: stmt.active !== false,
|
||||
source: stmt.source,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
alert('Failed to save statement');
|
||||
fetchPoliticalStatements(locale as 'de' | 'en');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePoliticalStatement = async (locale: string, id: number) => {
|
||||
if (!confirm('Delete this statement?')) return;
|
||||
|
||||
const res = await fetch('/api/political-statements', {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ locale, id }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setPoliticalStatements(prev => prev.filter(s => !(s.locale === locale && s.id === id)));
|
||||
} else {
|
||||
alert('Failed to delete statement');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNews = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if ((!newNewsTitle.de.trim() && !newNewsTitle.en.trim()) || (!newNewsContent.de.trim() && !newNewsContent.en.trim())) return;
|
||||
@@ -524,9 +631,13 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Load specials after auth
|
||||
// Load specials and political statements after auth
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) fetchSpecials();
|
||||
if (isAuthenticated) {
|
||||
fetchSpecials();
|
||||
fetchPoliticalStatements('de');
|
||||
fetchPoliticalStatements('en');
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const deleteGenre = async (id: number) => {
|
||||
@@ -1580,6 +1691,141 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Political Statements Management */}
|
||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
|
||||
Political Statements
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowPoliticalStatements(!showPoliticalStatements)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#f3f4f6',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '0.25rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
{showPoliticalStatements ? t('hide') : t('show')}
|
||||
</button>
|
||||
</div>
|
||||
{showPoliticalStatements && (
|
||||
<>
|
||||
{/* Language Tabs */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||
{(['de', 'en'] as const).map(lang => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => setPoliticalStatementsLocale(lang)}
|
||||
style={{
|
||||
padding: '0.4rem 0.8rem',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid #d1d5db',
|
||||
background: politicalStatementsLocale === lang ? '#111827' : 'white',
|
||||
color: politicalStatementsLocale === lang ? 'white' : '#111827',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{lang.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
<form onSubmit={handleCreatePoliticalStatement} style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<textarea
|
||||
value={newPoliticalStatementText}
|
||||
onChange={e => setNewPoliticalStatementText(e.target.value)}
|
||||
placeholder="Statement text"
|
||||
className="form-input"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newPoliticalStatementActive}
|
||||
onChange={e => setNewPoliticalStatementActive(e.target.checked)}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
<button type="submit" className="btn-primary" style={{ fontSize: '0.875rem' }}>
|
||||
Add Statement ({politicalStatementsLocale.toUpperCase()})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* List */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{politicalStatements
|
||||
.filter(s => s.locale === politicalStatementsLocale)
|
||||
.map(stmt => (
|
||||
<div
|
||||
key={`${stmt.locale}-${stmt.id}`}
|
||||
style={{
|
||||
background: stmt.active ? '#ecfdf5' : '#f3f4f6',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem'
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
value={stmt.text}
|
||||
onChange={e => handleEditPoliticalStatementText(stmt.locale, stmt.id, e.target.value)}
|
||||
className="form-input"
|
||||
rows={3}
|
||||
style={{ fontSize: '0.85rem' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.8rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stmt.active !== false}
|
||||
onChange={e => handleEditPoliticalStatementActive(stmt.locale, stmt.id, e.target.checked)}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ padding: '0.25rem 0.6rem', fontSize: '0.75rem' }}
|
||||
onClick={() => handleSavePoliticalStatement(stmt.locale, stmt.id)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
style={{ padding: '0.25rem 0.6rem', fontSize: '0.75rem' }}
|
||||
onClick={() => handleDeletePoliticalStatement(stmt.locale, stmt.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{politicalStatements.filter(s => s.locale === politicalStatementsLocale).length === 0 && (
|
||||
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', padding: '0.5rem' }}>
|
||||
No statements for this language yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>{t('uploadSongs')}</h2>
|
||||
<form onSubmit={handleBatchUpload}>
|
||||
|
||||
@@ -5,10 +5,13 @@ import "../globals.css"; // Adjusted path
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { config } from "@/lib/config";
|
||||
import { generateBaseMetadata } from "@/lib/metadata";
|
||||
import InstallPrompt from "@/components/InstallPrompt";
|
||||
import AppFooter from "@/components/AppFooter";
|
||||
import PoliticalStatementBanner from "@/components/PoliticalStatementBanner";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -20,10 +23,10 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: config.appName,
|
||||
description: config.appDescription,
|
||||
};
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
return await generateBaseMetadata(locale);
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: config.colors.themeColor,
|
||||
@@ -52,12 +55,32 @@ export default async function LocaleLayout({
|
||||
// Providing all messages to the client
|
||||
const messages = await getMessages();
|
||||
|
||||
// Get current domain from request headers for dynamic Plausible tracking
|
||||
// This automatically tracks the correct domain (hoerdle.de or hördle.de)
|
||||
const headersList = await headers();
|
||||
const host = headersList.get('host') || headersList.get('x-forwarded-host') || '';
|
||||
|
||||
// Automatically detect which domain to track in Plausible based on the request
|
||||
let plausibleDomain = 'hoerdle.de'; // Default fallback
|
||||
|
||||
if (host) {
|
||||
// Extract domain from host (remove port if present)
|
||||
const domain = host.split(':')[0].toLowerCase();
|
||||
|
||||
// Map domains: automatically track the current domain
|
||||
if (domain === 'hoerdle.de') {
|
||||
plausibleDomain = 'hoerdle.de';
|
||||
} else if (domain === 'hördle.de' || domain === 'xn--hrdle-jua.de') {
|
||||
plausibleDomain = 'hördle.de';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<head>
|
||||
<Script
|
||||
defer
|
||||
data-domain={config.plausibleDomain}
|
||||
data-domain={plausibleDomain}
|
||||
src={config.plausibleScriptSrc}
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
@@ -67,6 +90,7 @@ export default async function LocaleLayout({
|
||||
{children}
|
||||
<InstallPrompt />
|
||||
<AppFooter />
|
||||
<PoliticalStatementBanner />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,15 +7,33 @@ import { Link } from '@/lib/navigation';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('Home');
|
||||
|
||||
// Get localized title and description
|
||||
const title = locale === 'de'
|
||||
? 'Hördle - Tägliches Musik-Erraten'
|
||||
: 'Hördle - Daily Music Guessing Game';
|
||||
|
||||
const description = locale === 'de'
|
||||
? 'Spiele Hördle und errate Songs aus kurzen Audio-Clips! Täglich neue Rätsel aus verschiedenen Genres. Inspiriert von Wordle, aber für Musikfans.'
|
||||
: 'Play Hördle and guess songs from short audio clips! Daily new puzzles from various genres. Inspired by Wordle, but for music lovers.';
|
||||
|
||||
return await generateBaseMetadata(locale, '', title, description);
|
||||
}
|
||||
|
||||
export default async function Home({
|
||||
params
|
||||
}: {
|
||||
params: { locale: string };
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('Home');
|
||||
@@ -43,6 +61,9 @@ export default async function Home({
|
||||
return s.launchDate && s.launchDate > now;
|
||||
});
|
||||
|
||||
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
|
||||
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
|
||||
@@ -131,7 +152,7 @@ export default async function Home({
|
||||
<NewsSection locale={locale} />
|
||||
</div>
|
||||
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={null} requiredDailyKeys={requiredDailyKeys} />
|
||||
<OnboardingTour />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Link } from '@/lib/navigation';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -14,6 +16,30 @@ interface PageProps {
|
||||
params: Promise<{ locale: string; name: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, name } = await params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
|
||||
// Fetch special to get localized name
|
||||
const allSpecials = await prisma.special.findMany();
|
||||
const currentSpecial = allSpecials.find(s => getLocalizedValue(s.name, locale) === decodedName);
|
||||
|
||||
if (!currentSpecial) {
|
||||
return await generateBaseMetadata(locale, `special/${name}`);
|
||||
}
|
||||
|
||||
const specialName = getLocalizedValue(currentSpecial.name, locale);
|
||||
const specialSubtitle = getLocalizedValue(currentSpecial.subtitle, locale);
|
||||
|
||||
const title = `★ ${specialName} - Hördle`;
|
||||
|
||||
const description = specialSubtitle || (locale === 'de'
|
||||
? `Spiele das Hördle-Special "${specialName}" und errate Songs aus kurzen Audio-Clips!`
|
||||
: `Play the Hördle special "${specialName}" and guess songs from short audio clips!`);
|
||||
|
||||
return await generateBaseMetadata(locale, `special/${name}`, title, description);
|
||||
}
|
||||
|
||||
export default async function SpecialPage({ params }: PageProps) {
|
||||
const { locale, name } = await params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
|
||||
78
app/api/og-image/route.ts
Normal file
78
app/api/og-image/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { config } from '@/lib/config';
|
||||
import { getBaseUrl } from '@/lib/seo';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Generate Open Graph image as SVG with correct aspect ratio (1.91:1 = 1200x630)
|
||||
* This prevents cropping on Facebook and Twitter
|
||||
*/
|
||||
export async function GET() {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const appName = config.appName;
|
||||
const bgColor = config.colors.backgroundColor || '#ffffff';
|
||||
const primaryColor = config.colors.themeColor || '#000000';
|
||||
|
||||
// SVG with correct Open Graph dimensions: 1200x630 (1.91:1 ratio)
|
||||
// Safe area: 150px padding on all sides to prevent cropping
|
||||
// This ensures content is never cut off on Facebook/Twitter
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="630" fill="${bgColor}"/>
|
||||
|
||||
<!-- Gradient definition -->
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Content container - centered with safe padding (150px on all sides) -->
|
||||
<g transform="translate(150, 150)">
|
||||
<!-- Main graphic area (centered horizontally) -->
|
||||
<g transform="translate(300, 0)">
|
||||
<!-- Musical note (left side, within safe area) -->
|
||||
<g fill="url(#gradient)" opacity="0.9">
|
||||
<!-- Note head -->
|
||||
<ellipse cx="0" cy="40" rx="40" ry="28"/>
|
||||
<!-- Note stem -->
|
||||
<rect x="30" y="-60" width="16" height="100" rx="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Waveform (center-right, within safe area) -->
|
||||
<g transform="translate(70, 15)" fill="none" stroke="url(#gradient)" stroke-width="8" stroke-linecap="round" opacity="0.8">
|
||||
<path d="M 0 25 Q 20 -15 40 25 T 80 25"/>
|
||||
<path d="M 0 40 Q 20 0 40 40 T 80 40"/>
|
||||
<path d="M 0 55 Q 20 15 40 55 T 80 55"/>
|
||||
</g>
|
||||
|
||||
<!-- Vertical bar (right side, within safe area) -->
|
||||
<rect x="170" y="0" width="10" height="120" fill="url(#gradient)" opacity="0.7" rx="5"/>
|
||||
</g>
|
||||
|
||||
<!-- App name (centered, within safe vertical area) -->
|
||||
<text x="450" y="180" font-family="system-ui, -apple-system, sans-serif" font-size="56" font-weight="bold" fill="${primaryColor}" text-anchor="middle" letter-spacing="-0.5">
|
||||
${appName}
|
||||
</text>
|
||||
|
||||
<!-- Domain/subtitle (centered, within safe vertical area) -->
|
||||
<text x="450" y="220" font-family="system-ui, -apple-system, sans-serif" font-size="28" fill="#666666" text-anchor="middle">
|
||||
${config.domain}
|
||||
</text>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
return new NextResponse(svg, {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
114
app/api/player-id/suggest/route.ts
Normal file
114
app/api/player-id/suggest/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* POST /api/player-id/suggest
|
||||
*
|
||||
* Tries to find a base player ID based on recently updated states for a genre and device.
|
||||
* This helps synchronize player IDs across different domains (hoerdle.de and hördle.de)
|
||||
* on the same device.
|
||||
*
|
||||
* Request body:
|
||||
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
|
||||
* - deviceId: Device identifier (UUID)
|
||||
*
|
||||
* Returns:
|
||||
* - basePlayerId: Suggested base player ID (UUID) if found, null otherwise
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { genreKey, deviceId } = body;
|
||||
|
||||
if (!genreKey || typeof genreKey !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing or invalid genreKey' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the most recently updated player state for this genre
|
||||
// Look for states updated in the last 48 hours
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setHours(cutoffDate.getHours() - 48);
|
||||
|
||||
// If deviceId is provided, search for states with matching device ID
|
||||
// Format: {basePlayerId}:{deviceId}
|
||||
if (deviceId && typeof deviceId === 'string') {
|
||||
// Search for states with the same device ID
|
||||
const recentStates = await prisma.playerState.findMany({
|
||||
where: {
|
||||
genreKey: genreKey,
|
||||
lastPlayed: {
|
||||
gte: cutoffDate,
|
||||
},
|
||||
identifier: {
|
||||
endsWith: `:${deviceId}`,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
lastPlayed: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (recentStates.length > 0) {
|
||||
const recentState = recentStates[0];
|
||||
// Extract base player ID from full identifier
|
||||
const colonIndex = recentState.identifier.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
const basePlayerId = recentState.identifier.substring(0, colonIndex);
|
||||
return NextResponse.json({
|
||||
basePlayerId: basePlayerId,
|
||||
lastPlayed: recentState.lastPlayed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Find any recent state for this genre (legacy support)
|
||||
const recentState = await prisma.playerState.findFirst({
|
||||
where: {
|
||||
genreKey: genreKey,
|
||||
lastPlayed: {
|
||||
gte: cutoffDate,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
lastPlayed: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
if (recentState) {
|
||||
// Extract base player ID if format is basePlayerId:deviceId
|
||||
const colonIndex = recentState.identifier.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
const basePlayerId = recentState.identifier.substring(0, colonIndex);
|
||||
return NextResponse.json({
|
||||
basePlayerId: basePlayerId,
|
||||
lastPlayed: recentState.lastPlayed,
|
||||
});
|
||||
} else {
|
||||
// Legacy format: return as-is
|
||||
return NextResponse.json({
|
||||
basePlayerId: recentState.identifier,
|
||||
lastPlayed: recentState.lastPlayed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// No recent state found
|
||||
return NextResponse.json({
|
||||
basePlayerId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[player-id/suggest] Error finding player ID:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
192
app/api/player-state/route.ts
Normal file
192
app/api/player-state/route.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import type { GameState, Statistics } from '@/lib/gameState';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Validate UUID format (basic check)
|
||||
* Supports both legacy format (single UUID) and new format (basePlayerId:deviceId)
|
||||
*/
|
||||
function isValidPlayerId(playerId: string): boolean {
|
||||
// Legacy format: single UUID
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
// New format: basePlayerId:deviceId (two UUIDs separated by colon)
|
||||
const combinedRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
return uuidRegex.test(playerId) || combinedRegex.test(playerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract base player ID from full player ID
|
||||
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
|
||||
* Legacy: {uuid} -> {uuid}
|
||||
*/
|
||||
function extractBasePlayerId(fullPlayerId: string): string {
|
||||
const colonIndex = fullPlayerId.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
// Legacy format (no device ID) - return as is
|
||||
return fullPlayerId;
|
||||
}
|
||||
return fullPlayerId.substring(0, colonIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/player-state
|
||||
*
|
||||
* Loads player state for a given identifier and genre/special.
|
||||
*
|
||||
* Query parameters:
|
||||
* - genre: Genre name (e.g., "Rock")
|
||||
* - special: Special name (e.g., "00725")
|
||||
*
|
||||
* Headers:
|
||||
* - X-Player-Id: Player identifier (UUID)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const genreName = searchParams.get('genre');
|
||||
const specialName = searchParams.get('special');
|
||||
|
||||
// Get player identifier from header
|
||||
const playerId = request.headers.get('X-Player-Id');
|
||||
if (!playerId || !isValidPlayerId(playerId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or missing player identifier' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine genre key
|
||||
let genreKey: string;
|
||||
if (specialName) {
|
||||
genreKey = `special:${specialName}`;
|
||||
} else if (genreName) {
|
||||
genreKey = genreName;
|
||||
} else {
|
||||
genreKey = 'global';
|
||||
}
|
||||
|
||||
// Load player state from database
|
||||
const playerState = await prisma.playerState.findUnique({
|
||||
where: {
|
||||
identifier_genreKey: {
|
||||
identifier: playerId,
|
||||
genreKey: genreKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!playerState) {
|
||||
return NextResponse.json(null, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse JSON strings
|
||||
let gameState: GameState;
|
||||
let statistics: Statistics;
|
||||
|
||||
try {
|
||||
gameState = JSON.parse(playerState.gameState) as GameState;
|
||||
statistics = JSON.parse(playerState.statistics) as Statistics;
|
||||
} catch (parseError) {
|
||||
console.error('[player-state] Failed to parse stored state:', parseError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid stored state format' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
gameState,
|
||||
statistics,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[player-state] Error loading player state:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/player-state
|
||||
*
|
||||
* Saves player state for a given identifier and genre/special.
|
||||
*
|
||||
* Request body:
|
||||
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
|
||||
* - gameState: GameState object
|
||||
* - statistics: Statistics object
|
||||
*
|
||||
* Headers:
|
||||
* - X-Player-Id: Player identifier (UUID)
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Get player identifier from header
|
||||
const playerId = request.headers.get('X-Player-Id');
|
||||
if (!playerId || !isValidPlayerId(playerId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or missing player identifier' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
const { genreKey, gameState, statistics } = body;
|
||||
|
||||
if (!genreKey || !gameState || !statistics) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: genreKey, gameState, statistics' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate genre key format
|
||||
if (typeof genreKey !== 'string' || genreKey.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid genreKey format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Serialize to JSON strings
|
||||
const gameStateJson = JSON.stringify(gameState);
|
||||
const statisticsJson = JSON.stringify(statistics);
|
||||
|
||||
// Upsert player state (update if exists, create if not)
|
||||
await prisma.playerState.upsert({
|
||||
where: {
|
||||
identifier_genreKey: {
|
||||
identifier: playerId,
|
||||
genreKey: genreKey,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
gameState: gameStateJson,
|
||||
statistics: statisticsJson,
|
||||
lastPlayed: new Date(),
|
||||
},
|
||||
create: {
|
||||
identifier: playerId,
|
||||
genreKey: genreKey,
|
||||
gameState: gameStateJson,
|
||||
statistics: statisticsJson,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[player-state] Error saving player state:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
113
app/api/political-statements/route.ts
Normal file
113
app/api/political-statements/route.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import {
|
||||
getRandomActiveStatement,
|
||||
getAllStatements,
|
||||
createStatement,
|
||||
updateStatement,
|
||||
deleteStatement,
|
||||
} from '@/lib/politicalStatements';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locale = searchParams.get('locale') || 'en';
|
||||
const admin = searchParams.get('admin') === 'true';
|
||||
|
||||
if (admin) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
const statements = await getAllStatements(locale);
|
||||
return NextResponse.json(statements);
|
||||
}
|
||||
|
||||
const statement = await getRandomActiveStatement(locale);
|
||||
return NextResponse.json(statement);
|
||||
} catch (error) {
|
||||
console.error('[political-statements] GET failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to load political statements' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { locale, text, active = true, source } = body;
|
||||
|
||||
if (!locale || typeof text !== 'string' || !text.trim()) {
|
||||
return NextResponse.json({ error: 'locale and text are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const created = await createStatement(locale, { text: text.trim(), active, source });
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('[political-statements] POST failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to create statement' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { locale, id, text, active, source } = body;
|
||||
|
||||
if (!locale || typeof id !== 'number') {
|
||||
return NextResponse.json({ error: 'locale and numeric id are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await updateStatement(locale, id, {
|
||||
text: typeof text === 'string' ? text.trim() : undefined,
|
||||
active,
|
||||
source,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: 'Statement not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('[political-statements] PUT failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to update statement' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { locale, id } = body;
|
||||
|
||||
if (!locale || typeof id !== 'number') {
|
||||
return NextResponse.json({ error: 'locale and numeric id are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ok = await deleteStatement(locale, id);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ error: 'Statement not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[political-statements] DELETE failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete statement' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
app/robots.ts
Normal file
20
app/robots.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
const siteUrl = `${protocol}://${baseUrl}`;
|
||||
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/admin/', '/api/'],
|
||||
},
|
||||
],
|
||||
sitemap: `${siteUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
128
app/sitemap.ts
Normal file
128
app/sitemap.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
const siteUrl = `${protocol}://${baseUrl}`;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Static pages
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: `${siteUrl}/en`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 1.0,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de`,
|
||||
'en': `${siteUrl}/en`,
|
||||
'x-default': `${siteUrl}/en`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/de`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de`,
|
||||
'en': `${siteUrl}/en`,
|
||||
'x-default': `${siteUrl}/en`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/en/about`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/about`,
|
||||
'en': `${siteUrl}/en/about`,
|
||||
'x-default': `${siteUrl}/en/about`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/de/about`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/about`,
|
||||
'en': `${siteUrl}/en/about`,
|
||||
'x-default': `${siteUrl}/en/about`,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Dynamic genre pages
|
||||
try {
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
});
|
||||
|
||||
const genrePages: MetadataRoute.Sitemap = [];
|
||||
|
||||
for (const genre of genres) {
|
||||
const genreNameEn = getLocalizedValue(genre.name, 'en');
|
||||
const genreNameDe = getLocalizedValue(genre.name, 'de');
|
||||
|
||||
// Only add if genre name is valid
|
||||
if (genreNameEn && genreNameDe) {
|
||||
const encodedEn = encodeURIComponent(genreNameEn);
|
||||
const encodedDe = encodeURIComponent(genreNameDe);
|
||||
|
||||
genrePages.push(
|
||||
{
|
||||
url: `${siteUrl}/en/${encodedEn}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/${encodedDe}`,
|
||||
'en': `${siteUrl}/en/${encodedEn}`,
|
||||
'x-default': `${siteUrl}/en/${encodedEn}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/de/${encodedDe}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/${encodedDe}`,
|
||||
'en': `${siteUrl}/en/${encodedEn}`,
|
||||
'x-default': `${siteUrl}/en/${encodedEn}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [...staticPages, ...genrePages];
|
||||
} catch (error) {
|
||||
console.error('Error generating sitemap:', error);
|
||||
// Return static pages only if database query fails
|
||||
return staticPages;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
98
components/ExtraPuzzlesPopover.tsx
Normal file
98
components/ExtraPuzzlesPopover.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import type { ExternalPuzzle } from '@/lib/externalPuzzles';
|
||||
|
||||
interface ExtraPuzzlesPopoverProps {
|
||||
puzzle: ExternalPuzzle;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ExtraPuzzlesPopover({ puzzle, onClose }: ExtraPuzzlesPopoverProps) {
|
||||
const t = useTranslations('ExtraPuzzles');
|
||||
const locale = useLocale();
|
||||
|
||||
const name = locale === 'de' ? puzzle.nameDe : puzzle.nameEn;
|
||||
|
||||
const handleClick = () => {
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('extra_puzzles_click', {
|
||||
props: {
|
||||
partner: puzzle.id,
|
||||
url: puzzle.url,
|
||||
},
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '1.5rem',
|
||||
right: '1.5rem',
|
||||
zIndex: 1100,
|
||||
maxWidth: '320px',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
|
||||
borderRadius: '0.75rem',
|
||||
background: 'white',
|
||||
padding: '1rem 1.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: 700 }}>
|
||||
{t('title')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={t('close')}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.1rem',
|
||||
lineHeight: 1,
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: 0, fontSize: '0.9rem', color: '#4b5563' }}>
|
||||
{t('message', { name })}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={puzzle.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.4rem',
|
||||
marginTop: '0.25rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, #4f46e5, #ec4899)',
|
||||
color: 'white',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('cta', { name })}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ import { useTranslations, useLocale } from 'next-intl';
|
||||
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||
import GuessInput from './GuessInput';
|
||||
import Statistics from './Statistics';
|
||||
import ExtraPuzzlesPopover from './ExtraPuzzlesPopover';
|
||||
import { useGameState } from '../lib/gameState';
|
||||
import { getGenreKey } from '@/lib/playerStorage';
|
||||
import type { ExternalPuzzle } from '@/lib/externalPuzzles';
|
||||
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
|
||||
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
|
||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||
|
||||
// Plausible Analytics
|
||||
@@ -32,14 +37,17 @@ interface GameProps {
|
||||
isSpecial?: boolean;
|
||||
maxAttempts?: number;
|
||||
unlockSteps?: number[];
|
||||
// List of genre keys that zusammen alle Tagesrätsel des Tages repräsentieren (z. B. ['global', 'Rock', 'Pop']).
|
||||
// Wird genutzt, um zu prüfen, ob der Spieler alle Tagesrätsel gespielt hat.
|
||||
requiredDailyKeys?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
||||
|
||||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
||||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS, requiredDailyKeys }: GameProps) {
|
||||
const t = useTranslations('Game');
|
||||
const locale = useLocale();
|
||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
|
||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
||||
const [hasWon, setHasWon] = useState(false);
|
||||
const [hasLost, setHasLost] = useState(false);
|
||||
const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
|
||||
@@ -49,6 +57,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const [hasRated, setHasRated] = useState(false);
|
||||
const [showYearModal, setShowYearModal] = useState(false);
|
||||
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
||||
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
||||
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
||||
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -81,6 +91,37 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
}, [gameState, dailyPuzzle]);
|
||||
|
||||
// Track gespielte Tagesrätsel & entscheide, ob das Partner-Popover gezeigt werden soll
|
||||
useEffect(() => {
|
||||
if (!gameState || !dailyPuzzle) return;
|
||||
|
||||
const gameEnded = gameState.isSolved || gameState.isFailed;
|
||||
if (!gameEnded) return;
|
||||
|
||||
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
markDailyPuzzlePlayedToday(genreKey);
|
||||
|
||||
if (!requiredDailyKeys || requiredDailyKeys.length === 0) return;
|
||||
if (hasSeenExtraPuzzlesPopoverToday()) return;
|
||||
if (!hasPlayedAllDailyPuzzlesForToday(requiredDailyKeys)) return;
|
||||
|
||||
const partnerPuzzle = getRandomExternalPuzzle();
|
||||
if (!partnerPuzzle) return;
|
||||
|
||||
setExtraPuzzle(partnerPuzzle);
|
||||
setShowExtraPuzzlesPopover(true);
|
||||
markExtraPuzzlesPopoverShownToday();
|
||||
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('extra_puzzles_popover_shown', {
|
||||
props: {
|
||||
partner: partnerPuzzle.id,
|
||||
url: partnerPuzzle.url,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [gameState?.isSolved, gameState?.isFailed, dailyPuzzle?.id, genre, isSpecial, requiredDailyKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
setLastAction(null);
|
||||
}, [dailyPuzzle?.id]);
|
||||
@@ -108,6 +149,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
const handleGuess = (song: any) => {
|
||||
if (isProcessingGuess) return;
|
||||
// Prevent guessing if already solved or failed
|
||||
if (gameState?.isSolved || gameState?.isFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessingGuess(true);
|
||||
setLastAction('GUESS');
|
||||
@@ -159,6 +204,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// Prevent skipping if already solved or failed
|
||||
if (gameState?.isSolved || gameState?.isFailed) return;
|
||||
|
||||
// If user hasn't played audio yet on first attempt, start it instead of skipping
|
||||
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
|
||||
handleStartAudio();
|
||||
@@ -187,6 +235,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
};
|
||||
|
||||
const handleGiveUp = () => {
|
||||
// Prevent giving up if already solved or failed
|
||||
if (gameState?.isSolved || gameState?.isFailed) return;
|
||||
|
||||
setLastAction('SKIP');
|
||||
addGuess("SKIPPED", false);
|
||||
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||||
@@ -274,9 +325,14 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
||||
|
||||
let shareUrl = `https://${config.domain}`;
|
||||
// Add locale prefix if not default (de)
|
||||
if (locale !== 'de') {
|
||||
// Use current domain from window.location to support both hoerdle.de and hördle.de,
|
||||
// but always share the pretty Unicode-Domain "hördle.de" instead of the Punycode variant.
|
||||
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
|
||||
const currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
|
||||
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
|
||||
let shareUrl = `${protocol}//${currentHost}`;
|
||||
// Add locale prefix if not default (en)
|
||||
if (locale !== 'en') {
|
||||
shareUrl += `/${locale}`;
|
||||
}
|
||||
if (genre) {
|
||||
@@ -475,6 +531,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
onSkip={handleYearSkip}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showExtraPuzzlesPopover && extraPuzzle && (
|
||||
<ExtraPuzzlesPopover
|
||||
puzzle={extraPuzzle}
|
||||
onClose={() => setShowExtraPuzzlesPopover(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -674,7 +737,11 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div
|
||||
className="star-rating"
|
||||
title={t('ratingTooltip')}
|
||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</span>
|
||||
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
|
||||
{[...Array(5)].map((_, index) => {
|
||||
|
||||
95
components/PoliticalStatementBanner.tsx
Normal file
95
components/PoliticalStatementBanner.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
interface ApiStatement {
|
||||
id: number;
|
||||
text: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export default function PoliticalStatementBanner() {
|
||||
const locale = useLocale();
|
||||
const [statement, setStatement] = useState<ApiStatement | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const storageKey = `hoerdle_political_statement_shown_${today}_${locale}`;
|
||||
|
||||
try {
|
||||
const alreadyShown = typeof window !== 'undefined' && window.localStorage.getItem(storageKey);
|
||||
if (alreadyShown) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore localStorage errors
|
||||
}
|
||||
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const fetchStatement = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/political-statements?locale=${encodeURIComponent(locale)}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!data || !data.text) return;
|
||||
setStatement(data);
|
||||
setVisible(true);
|
||||
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setVisible(false);
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, 'true');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 5000);
|
||||
} catch (e) {
|
||||
console.warn('[PoliticalStatementBanner] Failed to load statement', e);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatement();
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
if (!visible || !statement) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '1.25rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: '640px',
|
||||
width: 'calc(100% - 2.5rem)',
|
||||
zIndex: 1050,
|
||||
background: 'rgba(17,24,39,0.95)',
|
||||
color: '#e5e7eb',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '999px',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
boxShadow: '0 10px 25px rgba(0,0,0,0.45)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.9rem' }}>✊</span>
|
||||
<span style={{ flex: 1 }}>{statement.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ services:
|
||||
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
|
||||
NEXT_PUBLIC_APP_DESCRIPTION: ${NEXT_PUBLIC_APP_DESCRIPTION}
|
||||
NEXT_PUBLIC_DOMAIN: ${NEXT_PUBLIC_DOMAIN}
|
||||
NEXT_PUBLIC_TWITTER_HANDLE: ${NEXT_PUBLIC_TWITTER_HANDLE}
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN: ${NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
||||
NEXT_PUBLIC_THEME_COLOR: ${NEXT_PUBLIC_THEME_COLOR}
|
||||
NEXT_PUBLIC_BACKGROUND_COLOR: ${NEXT_PUBLIC_BACKGROUND_COLOR}
|
||||
|
||||
183
docs/CADDY_TROUBLESHOOTING.md
Normal file
183
docs/CADDY_TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Caddy Zertifikat-Troubleshooting
|
||||
|
||||
## Problem: Zertifikat für Punycode-Domain (hördle.de / xn--hrdle-jua.de) fehlt
|
||||
|
||||
Wenn die Domain `hördle.de` (xn--hrdle-jua.de) einen `ERR_SSL_PROTOCOL_ERROR` zeigt, bedeutet das, dass kein gültiges SSL-Zertifikat vorhanden ist.
|
||||
|
||||
### Schritt 1: Zertifikat-Status prüfen
|
||||
|
||||
Führe das Check-Script aus:
|
||||
|
||||
```bash
|
||||
./scripts/check-caddy-certificates.sh
|
||||
```
|
||||
|
||||
Dieses Script prüft:
|
||||
- Ob Caddy läuft
|
||||
- Welche Zertifikate vorhanden sind
|
||||
- Ob die DNS-Einträge korrekt sind
|
||||
- Ob die HTTPS-Verbindungen funktionieren
|
||||
|
||||
### Schritt 2: DNS-Einträge prüfen
|
||||
|
||||
**Wichtig**: Beide Domains müssen auf die gleiche Server-IP zeigen!
|
||||
|
||||
#### In GoDaddy prüfen:
|
||||
|
||||
1. Gehe zu [GoDaddy DNS-Verwaltung](https://dcc.godaddy.com/manage/hoerdle.de/dns)
|
||||
2. Prüfe die A-Records:
|
||||
|
||||
**Für hoerdle.de:**
|
||||
- Name: `@` oder `hoerdle.de`
|
||||
- Typ: `A`
|
||||
- Wert: `DEINE_SERVER_IP`
|
||||
|
||||
**Für hördle.de (Punycode):**
|
||||
- Name: `@` oder `xn--hrdle-jua.de` (oder der Unicode-Name, falls unterstützt)
|
||||
- Typ: `A`
|
||||
- Wert: **GLEICHE_SERVER_IP wie hoerdle.de**
|
||||
|
||||
#### DNS manuell testen:
|
||||
|
||||
```bash
|
||||
# Prüfe hoerdle.de
|
||||
dig +short hoerdle.de @8.8.8.8
|
||||
|
||||
# Prüfe xn--hrdle-jua.de (Punycode)
|
||||
dig +short xn--hrdle-jua.de @8.8.8.8
|
||||
|
||||
# Beide sollten die gleiche IP zurückgeben!
|
||||
```
|
||||
|
||||
### Schritt 3: Zertifikat neu erstellen
|
||||
|
||||
Wenn die DNS-Einträge korrekt sind, lösche das alte (fehlgeschlagene) Zertifikat und lass Caddy es neu erstellen:
|
||||
|
||||
```bash
|
||||
./scripts/renew-caddy-certificates.sh
|
||||
```
|
||||
|
||||
Wähle Option 2: "Nur Zertifikat für xn--hrdle-jua.de löschen"
|
||||
|
||||
### Schritt 4: Caddy-Logs überwachen
|
||||
|
||||
Während Caddy das Zertifikat erstellt, überwache die Logs:
|
||||
|
||||
```bash
|
||||
docker logs hoerdle-caddy -f
|
||||
```
|
||||
|
||||
Du solltest sehen:
|
||||
- `[INFO] attempting ACME challenge` - Caddy versucht die Challenge
|
||||
- `[INFO] successfully completed ACME challenge` - Challenge erfolgreich
|
||||
- `[INFO] certificate obtained successfully` - Zertifikat erstellt
|
||||
|
||||
Bei Fehlern siehst du:
|
||||
- `[ERROR] acme: error` - Challenge fehlgeschlagen
|
||||
- `[ERROR] unable to validate` - Validierung fehlgeschlagen
|
||||
|
||||
### Schritt 5: Häufige Probleme und Lösungen
|
||||
|
||||
#### Problem 1: DNS zeigt auf falsche IP
|
||||
|
||||
**Symptom**: `dig` zeigt eine andere IP als erwartet
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe DNS-Einträge in GoDaddy
|
||||
2. Warte auf DNS-Propagierung (kann 5-60 Minuten dauern)
|
||||
3. Verwende einen DNS-Checker: https://www.whatsmydns.net/
|
||||
|
||||
#### Problem 2: Port 80 nicht erreichbar
|
||||
|
||||
**Symptom**: Caddy-Logs zeigen "connection refused" oder Timeout
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe Firewall: `sudo ufw status`
|
||||
2. Prüfe ob Port 80 offen ist: `sudo netstat -tulpn | grep :80`
|
||||
3. Prüfe ob Caddy auf Port 80 lauscht: `docker exec hoerdle-caddy netstat -tulpn | grep :80`
|
||||
|
||||
#### Problem 3: Let's Encrypt Rate Limit
|
||||
|
||||
**Symptom**: Logs zeigen "too many certificates already issued"
|
||||
|
||||
**Lösung**:
|
||||
- Warte 1 Woche (Rate Limit von Let's Encrypt)
|
||||
- Oder verwende Staging-Environment zum Testen:
|
||||
```caddyfile
|
||||
tls {
|
||||
staging
|
||||
}
|
||||
```
|
||||
|
||||
#### Problem 4: Punycode-Domain wird nicht erkannt
|
||||
|
||||
**Symptom**: Caddy erstellt Zertifikat nur für hoerdle.de, nicht für xn--hrdle-jua.de
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe ob beide Domains in der Caddyfile stehen
|
||||
2. Prüfe DNS-Einträge (siehe Schritt 2)
|
||||
3. Erzwinge Zertifikat-Erstellung (siehe Schritt 3)
|
||||
|
||||
### Manuelle Zertifikat-Löschung
|
||||
|
||||
Falls das Script nicht funktioniert, kannst du Zertifikate manuell löschen:
|
||||
|
||||
```bash
|
||||
# Alle Zertifikate löschen
|
||||
docker exec hoerdle-caddy rm -rf /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/*
|
||||
|
||||
# Nur Punycode-Zertifikat löschen (manuell)
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--*" -delete
|
||||
|
||||
# Container neu starten
|
||||
docker compose -f docker-compose.caddy.yml --profile production restart caddy
|
||||
```
|
||||
|
||||
### DNS-Propagierung prüfen
|
||||
|
||||
Nach DNS-Änderungen kann es bis zu 60 Minuten dauern, bis alle DNS-Server aktualisiert sind:
|
||||
|
||||
```bash
|
||||
# Prüfe DNS-Propagierung weltweit
|
||||
curl "https://dnschecker.org/#A/hoerdle.de"
|
||||
curl "https://dnschecker.org/#A/xn--hrdle-jua.de"
|
||||
```
|
||||
|
||||
### Test-Zertifikat erstellen (Staging)
|
||||
|
||||
Zum Testen ohne Rate-Limits kannst du ein Staging-Zertifikat erstellen:
|
||||
|
||||
1. Temporär Caddyfile ändern (in beiden Domain-Blocks):
|
||||
```caddyfile
|
||||
tls {
|
||||
staging
|
||||
}
|
||||
```
|
||||
|
||||
2. Container neu starten
|
||||
3. Zertifikat erstellen lassen
|
||||
4. Zurück zu Produktion ändern (Staging-Block entfernen)
|
||||
5. Erneut Container neu starten
|
||||
|
||||
### Verifizieren, dass es funktioniert
|
||||
|
||||
Nach erfolgreicher Zertifikats-Erstellung:
|
||||
|
||||
```bash
|
||||
# Teste HTTPS-Verbindung
|
||||
curl -I https://hoerdle.de
|
||||
curl -I https://xn--hrdle-jua.de
|
||||
|
||||
# Prüfe Zertifikat-Details
|
||||
echo | openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -subject -dates
|
||||
echo | openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -subject -dates
|
||||
```
|
||||
|
||||
### Support
|
||||
|
||||
Falls das Problem weiterhin besteht:
|
||||
1. Prüfe Caddy-Logs: `docker logs hoerdle-caddy`
|
||||
2. Prüfe DNS: `dig +short xn--hrdle-jua.de @8.8.8.8`
|
||||
3. Prüfe Firewall: `sudo ufw status`
|
||||
4. Prüfe Port-Zugriff: `curl -I http://hoerdle.de`
|
||||
|
||||
@@ -8,14 +8,14 @@ Die i18n-Implementierung basiert auf [next-intl](https://next-intl-docs.vercel.a
|
||||
|
||||
## Unterstützte Sprachen
|
||||
|
||||
- **Deutsch (de)** - Standardsprache
|
||||
- **Englisch (en)**
|
||||
- **Englisch (en)** - Standardsprache
|
||||
- **Deutsch (de)**
|
||||
|
||||
## URL-Struktur
|
||||
|
||||
Alle Routen sind lokalisiert:
|
||||
|
||||
- `http://localhost:3000/` → Redirect zu `/de` (Standard)
|
||||
- `http://localhost:3000/` → Redirect zu `/en` (Standard)
|
||||
- `http://localhost:3000/de` → Deutsche Version
|
||||
- `http://localhost:3000/en` → Englische Version
|
||||
- `http://localhost:3000/de/admin` → Admin-Dashboard (Deutsch)
|
||||
@@ -103,8 +103,8 @@ const genreNameEn = getLocalizedValue(genre.name, 'en'); // "Rock"
|
||||
|
||||
**Fallback-Verhalten:**
|
||||
1. Versucht die angeforderte Locale (`de` oder `en`)
|
||||
2. Fallback zu `de` falls nicht vorhanden
|
||||
3. Fallback zu `en` falls nicht vorhanden
|
||||
2. Fallback zu `en` falls nicht vorhanden
|
||||
3. Fallback zu `de` falls nicht vorhanden
|
||||
4. Fallback zum ersten verfügbaren Schlüssel
|
||||
5. Fallback zum übergebenen `fallback`-Parameter
|
||||
|
||||
@@ -195,7 +195,7 @@ Bestehende Daten werden automatisch migriert:
|
||||
|
||||
Der Proxy (`proxy.ts`) leitet Anfragen automatisch um:
|
||||
|
||||
- `/` → `/de` (Standard)
|
||||
- `/` → `/en` (Standard)
|
||||
- Ungültige Locales → 404
|
||||
- Validiert Locale-Parameter
|
||||
|
||||
@@ -223,7 +223,7 @@ GET /api/specials?locale=en
|
||||
GET /api/news?locale=de
|
||||
```
|
||||
|
||||
Falls kein `locale` angegeben wird, wird `de` als Standard verwendet.
|
||||
Falls kein `locale` angegeben wird, wird `en` als Standard verwendet.
|
||||
|
||||
## Best Practices
|
||||
|
||||
167
docs/PLAUSIBLE_SETUP.md
Normal file
167
docs/PLAUSIBLE_SETUP.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Plausible Analytics Konfiguration
|
||||
|
||||
## Übersicht
|
||||
|
||||
Die App verwendet Plausible Analytics für anonyme Nutzungsstatistiken. Die Konfiguration erfolgt über Umgebungsvariablen.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Erforderliche Variablen
|
||||
|
||||
**Nur eine Variable ist erforderlich:**
|
||||
|
||||
1. **`NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`** (erforderlich)
|
||||
- Die vollständige URL zum Plausible-Script
|
||||
- Beispiel (selbst gehostet): `https://plausible.elpatron.me/js/script.js`
|
||||
- Beispiel (extern): `https://plausible.io/js/script.js`
|
||||
|
||||
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt. Beide Domains (`hoerdle.de` und `hördle.de`) werden automatisch getrackt.
|
||||
|
||||
### Konfiguration für Docker
|
||||
|
||||
Da es sich um **Build-Time Variablen** handelt (NEXT_PUBLIC_*), muss die App neu gebaut werden, wenn diese geändert werden.
|
||||
|
||||
#### Schritt 1: Umgebungsvariablen setzen
|
||||
|
||||
Erstelle oder bearbeite eine `.env`-Datei im Projektverzeichnis:
|
||||
|
||||
```bash
|
||||
# Plausible Analytics (Script-URL ist erforderlich)
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.elpatron.me/js/script.js
|
||||
|
||||
# Die Domain wird automatisch erkannt - keine weitere Konfiguration nötig!
|
||||
```
|
||||
|
||||
#### Schritt 2: docker-compose.yml konfigurieren
|
||||
|
||||
Stelle sicher, dass die Variablen als Build-Args übergeben werden:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
hoerdle:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
||||
```
|
||||
|
||||
Die `docker-compose.example.yml` enthält bereits diese Konfiguration.
|
||||
|
||||
#### Schritt 3: App neu bauen
|
||||
|
||||
**WICHTIG:** Nach Änderung der Plausible-Variablen muss die App neu gebaut werden:
|
||||
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Oder mit dem Deploy-Script:
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
### Konfiguration für beide Domains
|
||||
|
||||
Die App unterstützt **automatisches Tracking** für beide Domains (`hoerdle.de` und `hördle.de`). Die Domain wird automatisch aus dem Request-Header ausgelesen und entsprechend in Plausible getrackt.
|
||||
|
||||
#### Automatisches Domain-Tracking
|
||||
|
||||
**Standard-Verhalten:** Die App erkennt automatisch, welche Domain aufgerufen wurde, und setzt die entsprechende `data-domain` im Plausible-Script:
|
||||
- `https://hoerdle.de/*` → `data-domain="hoerdle.de"`
|
||||
- `https://hördle.de/*` → `data-domain="hördle.de"`
|
||||
|
||||
#### In Plausible konfigurieren
|
||||
|
||||
Du hast zwei Optionen:
|
||||
|
||||
##### Option 1: Beide Domains als separate Sites (separate Statistiken) - Empfohlen für getrenntes Tracking
|
||||
|
||||
1. Erstelle in Plausible zwei separate Sites:
|
||||
- `hoerdle.de`
|
||||
- `hördle.de`
|
||||
|
||||
2. Fertig! Die App trackt automatisch die richtige Domain.
|
||||
|
||||
**Vorteil:** Separate Statistiken für jede Domain.
|
||||
|
||||
##### Option 2: Beide Domains als Aliase für eine Site (gemeinsame Statistiken)
|
||||
|
||||
1. Erstelle in Plausible eine Site: `hoerdle.de`
|
||||
2. Füge `hördle.de` als Alias hinzu (in den Site-Einstellungen)
|
||||
|
||||
3. Fertig! Die App trackt automatisch die richtige Domain, und Plausible behandelt beide als Aliase für die gleiche Site.
|
||||
|
||||
**Hinweis:** Du musst nichts zusätzlich konfigurieren. Die App trackt automatisch `hoerdle.de` oder `hördle.de` basierend auf der Request-Domain, und Plausible erkennt beide als Aliase.
|
||||
|
||||
**Vorteil:** Gemeinsame Statistiken für beide Domains in einer Site.
|
||||
|
||||
#### Empfehlung
|
||||
|
||||
Für separate Statistiken: **Option 1** (automatisches Tracking)
|
||||
Für gemeinsame Statistiken: **Option 2** (Aliase in Plausible)
|
||||
|
||||
### Automatische CSP-Anpassung
|
||||
|
||||
Die Content Security Policy (CSP) in `proxy.ts` wird automatisch an die konfigurierte Plausible-URL angepasst. Die Domain wird automatisch aus der Script-URL extrahiert.
|
||||
|
||||
### Prüfen der Konfiguration
|
||||
|
||||
Nach dem Neubau kannst du prüfen, ob Plausible korrekt geladen wird:
|
||||
|
||||
1. **Browser-Entwicklertools öffnen**
|
||||
- Network-Tab: Suche nach dem Plausible-Script
|
||||
- Console: Prüfe auf Fehler
|
||||
|
||||
2. **Prüfe die Meta-Tags**
|
||||
```html
|
||||
<script defer data-domain="hoerdle.de" src="https://plausible.elpatron.me/js/script.js"></script>
|
||||
```
|
||||
|
||||
3. **Prüfe Plausible-Dashboard**
|
||||
- Öffne dein Plausible-Dashboard
|
||||
- Prüfe, ob Daten ankommen
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Plausible wird nicht geladen
|
||||
|
||||
- Prüfe, ob die Umgebungsvariablen korrekt gesetzt sind
|
||||
- Prüfe, ob die App neu gebaut wurde (Build-Time Variablen!)
|
||||
- Prüfe Browser-Console auf CSP-Fehler
|
||||
|
||||
#### CSP blockiert Plausible
|
||||
|
||||
Die CSP sollte automatisch angepasst werden. Falls Probleme auftreten:
|
||||
- Prüfe, ob `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` korrekt gesetzt ist
|
||||
- Prüfe die Logs des Containers
|
||||
|
||||
#### Daten werden nicht in Plausible angezeigt
|
||||
|
||||
- Prüfe, ob die Domain in Plausible als Site konfiguriert ist
|
||||
- Prüfe, ob `data-domain` Attribut mit der konfigurierten Domain übereinstimmt
|
||||
- Prüfe Browser-Console auf Fehler beim Laden des Scripts
|
||||
|
||||
### Beispiel-Konfiguration
|
||||
|
||||
#### Für selbst gehostetes Plausible:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.elpatron.me/js/script.js
|
||||
```
|
||||
|
||||
#### Für Plausible.io (extern):
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.io/js/script.js
|
||||
```
|
||||
|
||||
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt - keine weitere Konfiguration nötig!
|
||||
|
||||
### Weitere Informationen
|
||||
|
||||
- [Plausible Dokumentation](https://plausible.io/docs)
|
||||
- [Plausible Self-Hosting](https://plausible.io/docs/self-hosting)
|
||||
|
||||
293
docs/SCORING_OPTIONS.md
Normal file
293
docs/SCORING_OPTIONS.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Scoring-System Optionen
|
||||
|
||||
## Problem-Analyse
|
||||
|
||||
### Aktuelle Situation
|
||||
- **Start:** 90 Punkte
|
||||
- **Richtige Antwort:** +20 Punkte
|
||||
- **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
|
||||
- **Skip:** -5 Punkte
|
||||
- **Replay:** -1 Punkt
|
||||
|
||||
### Problem (vor der Änderung)
|
||||
Bei vielen Versuchen kam man mit einem relativ hohen Score heraus:
|
||||
- Beispiel (alt): 7 Versuche = 90 + 20 - (6 × 3) = **92 Punkte**
|
||||
|
||||
### Lösung (aktuell implementiert)
|
||||
Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die Track-Verlängerung (unlockSteps) abgezogen:
|
||||
- Beispiel (neu): 7 Versuche = 90 + 20 - (6 × 8) = **62 Punkte**
|
||||
- Start: 90 Punkte
|
||||
- 6 falsche Versuche: -48 Punkte (6 × -8, bestehend aus -3 für falsch + -5 für Verlängerung)
|
||||
- 1 richtiger Versuch: +20 Punkte
|
||||
- **Ergebnis: 62 Punkte**
|
||||
|
||||
Dies spiegelt nun besser die tatsächliche Leistung wider. Das System bleibt motivierend, da richtige Antworten weiterhin belohnt werden.
|
||||
|
||||
---
|
||||
|
||||
## Option 1: Progressive Abzüge ⚠️ (Intransparent)
|
||||
|
||||
### Konzept
|
||||
Abzüge steigen mit jedem Versuch, aber das System ist schwer nachvollziehbar.
|
||||
|
||||
```
|
||||
- Versuch 1-2: -2 Punkte pro falscher Antwort
|
||||
- Versuch 3-4: -4 Punkte pro falscher Antwort
|
||||
- Versuch 5-6: -6 Punkte pro falscher Antwort
|
||||
- Versuch 7: -8 Punkte
|
||||
```
|
||||
|
||||
### Beispiel
|
||||
Bei 7 Versuchen: 90 + 20 - (2+2+4+4+6+6) = **86 Punkte**
|
||||
|
||||
### Probleme
|
||||
- **Intransparent**: Spieler müssen sich merken, welche Abzüge in welcher Runde gelten
|
||||
- **Schwer erklärbar**: Das Regelwerk ist komplex
|
||||
- **Unklar im UI**: Aktuelle Abzüge sind nicht sofort ersichtlich
|
||||
|
||||
### Vorteile
|
||||
- Progressive Bestrafung für viele Versuche
|
||||
- Fairer als aktuelles System
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Bonus-Malus-System
|
||||
|
||||
### Konzept
|
||||
Höhere Belohnungen für frühe Erfolge + progressive Abzüge.
|
||||
|
||||
```
|
||||
Start: 90 Punkte
|
||||
|
||||
Richtige Antwort (Bonus abhängig vom Versuch):
|
||||
- Versuch 1: +30 Punkte (sehr gut!)
|
||||
- Versuch 2: +25 Punkte (gut!)
|
||||
- Versuch 3: +20 Punkte (okay)
|
||||
- Versuch 4: +15 Punkte
|
||||
- Versuch 5+: +10 Punkte
|
||||
|
||||
Falsche Antwort (progressive Abzüge):
|
||||
- Versuch 1-2: -3 Punkte
|
||||
- Versuch 3-4: -5 Punkte
|
||||
- Versuch 5-6: -8 Punkte
|
||||
- Versuch 7: -10 Punkte
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 1: 90 + 30 = **120 Punkte** ⭐
|
||||
- Gelöst in Versuch 4 (nach 3 Fehlern): 90 + 15 - (3+5+5) = **92 Punkte**
|
||||
- Gelöst in Versuch 7 (nach 6 Fehlern): 90 + 10 - (3+5+5+8+8+10) = **61 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- **Transparent**: Klare Regeln pro Versuch
|
||||
- **Motivierend**: Hohe Belohnungen für schnelles Lösen
|
||||
- **Fair**: Späte Erfolge werden abgewertet
|
||||
|
||||
### Nachteile
|
||||
- Etwas komplexer als aktuelles System
|
||||
- Muss im UI klar kommuniziert werden
|
||||
|
||||
---
|
||||
|
||||
## Option 3: Effizienz-Multiplikator
|
||||
|
||||
### Konzept
|
||||
Basis-System bleibt, aber Multiplikator basierend auf Versuchszahl.
|
||||
|
||||
```
|
||||
Basis-System (wie aktuell, aber mit höheren Abzügen):
|
||||
- Falsche Antwort: -5 Punkte (statt -3)
|
||||
- Skip: -7 Punkte (statt -5)
|
||||
|
||||
Bonus-Multiplikatoren (basierend auf Versuch, in dem gelöst wurde):
|
||||
- Gelöst in 1-2 Versuchen: ×1.2 (20% Bonus)
|
||||
- Gelöst in 3-4 Versuchen: ×1.1 (10% Bonus)
|
||||
- Gelöst in 5-6 Versuchen: ×1.0 (kein Bonus)
|
||||
- Gelöst in 7 Versuchen: ×0.9 (10% Abzug)
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 2 (1 Fehler): (90 + 20 - 5) × 1.2 = **126 Punkte**
|
||||
- Gelöst in Versuch 4 (3 Fehler): (90 + 20 - 15) × 1.1 = **104.5 → 105 Punkte**
|
||||
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- Multiplikator ist einfach zu verstehen ("20% Bonus für schnelles Lösen")
|
||||
- Basis-System bleibt ähnlich
|
||||
- Gerechte Bestrafung für viele Versuche
|
||||
|
||||
### Nachteile
|
||||
- Multiplikatoren müssen berechnet werden (könnte kompliziert wirken)
|
||||
- Kombination aus Basis + Multiplikator kann verwirrend sein
|
||||
|
||||
---
|
||||
|
||||
## Option 4: Kombiniertes System
|
||||
|
||||
### Konzept
|
||||
Höhere Abzüge + kleine Motivations-Boni.
|
||||
|
||||
```
|
||||
Basis-System (höhere Abzüge):
|
||||
- Falsche Antwort: -5 Punkte (statt -3)
|
||||
- Skip: -7 Punkte (statt -5)
|
||||
- Richtige Antwort: +20 Punkte (bleibt)
|
||||
|
||||
Motivations-Boni:
|
||||
- "Erstversuch" Bonus: +2 Punkte wenn erster Versuch nicht skipped wurde
|
||||
- "Perfekter Durchlauf": +5 Bonus wenn kein Skip verwendet wurde
|
||||
- "Knapp daneben": +1 Punkt für Versuche, die fast richtig waren (optional, komplex)
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 1: 90 + 20 + 2 + 5 = **117 Punkte**
|
||||
- Gelöst in Versuch 4 (3 Fehler, kein Skip): 90 + 20 - 15 + 5 = **100 Punkte**
|
||||
- Gelöst in Versuch 7 (6 Fehler, 2 Skips): 90 + 20 - 30 - 14 = **66 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- **Einfach verständlich**: Basis + kleine Boni
|
||||
- **Motivierend**: Positive Verstärkung für gutes Verhalten
|
||||
- **Fair**: Höhere Abzüge sorgen für differenzierten Score
|
||||
|
||||
### Nachteile
|
||||
- Mehrere kleine Boni können unübersichtlich werden
|
||||
- "Knapp daneben" ist schwer zu implementieren
|
||||
|
||||
---
|
||||
|
||||
## Option 5: Streak-System (Langfristige Motivation)
|
||||
|
||||
### Konzept
|
||||
Zusätzliche Belohnungen für konsequentes Spielen über mehrere Tage.
|
||||
|
||||
```
|
||||
Tägliche Streaks:
|
||||
- 3 Tage in Folge gelöst: +5 Bonus-Punkte
|
||||
- 7 Tage: +10 Bonus-Punkte
|
||||
- 30 Tage: +15 Bonus-Punkte
|
||||
```
|
||||
|
||||
**Kombiniert mit einem der anderen Systeme** (z.B. Option 2 oder 4).
|
||||
|
||||
### Vorteile
|
||||
- Langfristige Spielermotivation
|
||||
- Belohnt Engagement
|
||||
|
||||
### Nachteile
|
||||
- Braucht Tracking über mehrere Tage
|
||||
- Löst nicht das Hauptproblem (zu hoher Score bei vielen Versuchen)
|
||||
|
||||
---
|
||||
|
||||
## Option 6: Multiplikator-System (Vereinfacht)
|
||||
|
||||
### Konzept
|
||||
Höhere Abzüge + einfache Multiplikatoren für Versuchszahl.
|
||||
|
||||
```
|
||||
Höhere Basis-Abzüge:
|
||||
- Falsche Antwort: -5 Punkte
|
||||
- Skip: -7 Punkte
|
||||
|
||||
Multiplikator basierend auf Versuch, in dem gelöst wurde:
|
||||
- Versuch 1: ×1.5 (50% Bonus) → Sehr schnelles Lösen
|
||||
- Versuch 2: ×1.3 (30% Bonus)
|
||||
- Versuch 3: ×1.1 (10% Bonus)
|
||||
- Versuch 4: ×1.0 (kein Bonus/Aufschlag)
|
||||
- Versuch 5+: ×0.9 (10% Abzug)
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 1: (90 + 20) × 1.5 = **165 Punkte** ⭐⭐⭐
|
||||
- Gelöst in Versuch 3 (2 Fehler): (90 + 20 - 10) × 1.1 = **110 Punkte**
|
||||
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- **Sehr transparent**: "50% Bonus für Erstversuch" ist einfach zu verstehen
|
||||
- **Stark motivierend**: Hohe Belohnungen für schnelles Lösen
|
||||
- **Fair**: Viele Versuche = niedriger Score
|
||||
|
||||
### Nachteile
|
||||
- Multiplikatoren könnten als zu komplex empfunden werden
|
||||
- Hohe Scores bei frühen Erfolgen (könnte als "zu leicht" empfunden werden)
|
||||
|
||||
---
|
||||
|
||||
## Empfehlungen
|
||||
|
||||
### Für Transparenz und Einfachheit: **Option 2 oder Option 4**
|
||||
|
||||
**Option 2 (Bonus-Malus)** ist am transparentesten:
|
||||
- Klare Werte pro Versuch
|
||||
- Einfach zu kommunizieren: "Erstversuch gibt +30, jeder weitere Versuch reduziert den Bonus"
|
||||
- Fair und motivierend
|
||||
|
||||
**Option 4 (Kombiniert)** ist am einfachsten:
|
||||
- Basis-System bleibt ähnlich (nur höhere Abzüge)
|
||||
- Zusätzliche kleine Boni sind optional und motivierend
|
||||
- Sehr einfach zu verstehen
|
||||
|
||||
### Für maximale Motivation: **Option 6**
|
||||
|
||||
- Hohe Belohnungen für schnelles Lösen
|
||||
- Einfache Multiplikatoren ("50% Bonus")
|
||||
- Sehr fair für viele Versuche
|
||||
|
||||
---
|
||||
|
||||
## Implementierungs-Hinweise
|
||||
|
||||
### UI-Kommunikation
|
||||
Welche Option auch gewählt wird - sie muss im Spiel klar kommuniziert werden:
|
||||
- Tooltips bei Versuchen
|
||||
- Score-Breakdown zeigt Abzüge/Boni pro Versuch
|
||||
- Vorschau: "Dieser Versuch würde X Punkte kosten/geben"
|
||||
|
||||
### Testing
|
||||
Vor der Implementierung sollten verschiedene Szenarien durchgespielt werden:
|
||||
- Erstversuch-Lösung
|
||||
- Mittlere Versuche (3-4)
|
||||
- Knappe Lösung (6-7 Versuche)
|
||||
- Mit/ohne Skips
|
||||
- Mit/ohne Replays
|
||||
|
||||
### Migration
|
||||
- Bestehende Scores können nicht einfach migriert werden
|
||||
- Neue Regeln gelten ab Start des neuen Systems
|
||||
- Eventuell: "New Scoring System" Ankündigung
|
||||
|
||||
---
|
||||
|
||||
## ✅ Implementiert: Abzüge für zusätzliche Sekunden
|
||||
|
||||
**Status:** ✅ **Aktuell implementiert**
|
||||
|
||||
Bei falschen Rateversuchen werden zusätzlich **-5 Punkte für die Track-Verlängerung** abgezogen:
|
||||
- Falsche Antwort (Rateversuch): -3 Punkte (falsch) + -5 Punkte (Verlängerung) = **-8 Punkte total**
|
||||
- Skip: -5 Punkte (kein zusätzlicher Abzug, da Skip keine Verlängerung bedeutet)
|
||||
|
||||
**Vorteile:**
|
||||
- ✅ Reflektiert den "Hilfsmittel"-Charakter der zusätzlichen Sekunden
|
||||
- ✅ Macht viele Versuche deutlich teurer
|
||||
- ✅ Fairer Score bei vielen Versuchen
|
||||
- ✅ Transparent: Klar getrennt als "Wrong guess" und "Track extension"
|
||||
|
||||
**Hinweis:** Dies ist die erste Anpassung des Scoring-Systems. Weitere Optionen (siehe oben) können in Zukunft ergänzt werden.
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
1. Sollen Replays weiterhin -1 Punkt kosten?
|
||||
2. Soll das Jahr-Bonus-System (+10) beibehalten werden?
|
||||
3. Wie wichtig ist Backward-Compatibility mit bestehenden Scores?
|
||||
4. Soll es eine "Preview"-Funktion geben ("Dieser Versuch kostet X Punkte")?
|
||||
5. Sollen zusätzlich freigeschaltete Sekunden (Unlock-Steps) zusätzlich Punkte kosten?
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
📝 **Erstellt:** 2024-12-01
|
||||
✅ **Erste Änderung implementiert:** 2024-12-01 - Track-Verlängerung kostet jetzt -5 Punkte bei falschen Rateversuchen
|
||||
🔄 **Status:** Teilweise umgesetzt
|
||||
💡 **Nächste Schritte:** Weitere Optionen können bei Bedarf ergänzt werden (siehe Optionen oben)
|
||||
|
||||
235
docs/SEO_TESTING.md
Normal file
235
docs/SEO_TESTING.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# SEO & Open Graph Testing Guide
|
||||
|
||||
## Übersicht
|
||||
|
||||
Diese Anleitung zeigt dir, wie du die SEO-Implementierung (Meta-Tags, Open Graph, Twitter Cards) testen kannst.
|
||||
|
||||
## Lokales Testen
|
||||
|
||||
### 1. Browser-Entwicklertools
|
||||
|
||||
1. **App starten:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Die App läuft unter `http://localhost:3000`
|
||||
|
||||
2. **Meta-Tags im HTML prüfen:**
|
||||
- Öffne eine Seite (z.B. `http://localhost:3000/en` oder `http://localhost:3000/de/about`)
|
||||
- Rechtsklick → "Seite untersuchen" (F12)
|
||||
- Tab "Elements" → `<head>` Bereich erweitern
|
||||
- Suche nach Meta-Tags:
|
||||
- `<meta property="og:title">`
|
||||
- `<meta property="og:description">`
|
||||
- `<meta property="og:image">`
|
||||
- `<meta name="twitter:card">`
|
||||
|
||||
3. **View Page Source:**
|
||||
- Rechtsklick → "Seitenquelltext anzeigen"
|
||||
- Suche nach "og:" oder "twitter:" um alle Open Graph und Twitter Meta-Tags zu sehen
|
||||
|
||||
### 2. cURL-Test (für schnelle Prüfung)
|
||||
|
||||
```bash
|
||||
# Prüfe Meta-Tags einer Seite
|
||||
curl -s http://localhost:3000/en | grep -i "og:\|twitter:"
|
||||
```
|
||||
|
||||
### 3. Node.js-Script zum Testen
|
||||
|
||||
Erstelle eine Test-Datei `test-og.js`:
|
||||
|
||||
```javascript
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
function fetchHTML(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
client.get(url, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => data += chunk);
|
||||
res.on('end', () => resolve(data));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function testOGTags(url) {
|
||||
try {
|
||||
const html = await fetchHTML(url);
|
||||
const ogTags = {
|
||||
title: html.match(/<meta property="og:title" content="([^"]*)"/)?.[1],
|
||||
description: html.match(/<meta property="og:description" content="([^"]*)"/)?.[1],
|
||||
image: html.match(/<meta property="og:image" content="([^"]*)"/)?.[1],
|
||||
url: html.match(/<meta property="og:url" content="([^"]*)"/)?.[1],
|
||||
};
|
||||
|
||||
console.log('Open Graph Tags:', ogTags);
|
||||
return ogTags;
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
testOGTags('http://localhost:3000/en');
|
||||
```
|
||||
|
||||
## Online-Tools (für Produktions-URLs)
|
||||
|
||||
### 1. Facebook Sharing Debugger (Empfohlen)
|
||||
|
||||
**URL:** https://developers.facebook.com/tools/debug/
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL
|
||||
2. Gib deine Produktions-URL ein (z.B. `https://hoerdle.de/en`)
|
||||
3. Klicke auf "Debuggen"
|
||||
4. Prüfe die Vorschau und alle Meta-Tags
|
||||
|
||||
**Wichtig:**
|
||||
- Facebook cached die Vorschau! Klicke auf "Scraping erneut ausführen" um den Cache zu leeren
|
||||
- Funktioniert nur mit öffentlich erreichbaren URLs (nicht localhost)
|
||||
|
||||
### 2. Twitter Card Validator
|
||||
|
||||
**URL:** https://cards-dev.twitter.com/validator
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL
|
||||
2. Gib deine Produktions-URL ein
|
||||
3. Prüfe die Twitter Card Vorschau
|
||||
|
||||
**Hinweis:** Twitter hat den Validator eingestellt, aber die Cards funktionieren trotzdem. Du kannst auch einfach einen Tweet mit deiner URL erstellen, um zu sehen, ob die Card angezeigt wird.
|
||||
|
||||
### 3. LinkedIn Post Inspector
|
||||
|
||||
**URL:** https://www.linkedin.com/post-inspector/
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL (Login erforderlich)
|
||||
2. Gib deine Produktions-URL ein
|
||||
3. Prüfe die LinkedIn Vorschau
|
||||
|
||||
### 4. OpenGraph.xyz (Universelles Tool)
|
||||
|
||||
**URL:** https://www.opengraph.xyz/
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL
|
||||
2. Gib deine URL ein
|
||||
3. Sieh dir alle Open Graph und Twitter Meta-Tags an
|
||||
4. Sieh dir die Vorschau für verschiedene Plattformen an
|
||||
|
||||
### 5. Metatags.io
|
||||
|
||||
**URL:** https://metatags.io/
|
||||
|
||||
**Verwendung:**
|
||||
- Gebe deine URL ein
|
||||
- Sieh dir alle Meta-Tags an
|
||||
- Vorschau für verschiedene Plattformen
|
||||
|
||||
## Produktions-Test (hoerdle.de / hördle.de)
|
||||
|
||||
Sobald die App deployed ist, kannst du alle oben genannten Tools mit deinen Produktions-URLs verwenden:
|
||||
|
||||
### Test-URLs:
|
||||
|
||||
- Homepage (EN): `https://hoerdle.de/en`
|
||||
- Homepage (DE): `https://hoerdle.de/de`
|
||||
- About (EN): `https://hoerdle.de/en/about`
|
||||
- About (DE): `https://hoerdle.de/de/about`
|
||||
- Genre-Seiten: `https://hoerdle.de/en/Rock` (Beispiel)
|
||||
- Special-Seiten: `https://hoerdle.de/en/special/Weihnachtslieder` (Beispiel)
|
||||
|
||||
### Schnelltest mit cURL:
|
||||
|
||||
```bash
|
||||
# Teste Homepage
|
||||
curl -s https://hoerdle.de/en | grep -E "og:|twitter:" | head -10
|
||||
|
||||
# Teste About-Seite
|
||||
curl -s https://hoerdle.de/de/about | grep -E "og:|twitter:" | head -10
|
||||
```
|
||||
|
||||
## Erwartete Meta-Tags
|
||||
|
||||
Die folgenden Meta-Tags sollten auf allen Seiten vorhanden sein:
|
||||
|
||||
### Open Graph Tags:
|
||||
- `og:title` - Seitentitel
|
||||
- `og:description` - Seitenbeschreibung
|
||||
- `og:image` - Bild für Social Media (Standard: `/favicon.ico`)
|
||||
- `og:url` - Canonical URL
|
||||
- `og:type` - Typ (sollte "website" sein)
|
||||
- `og:site_name` - Name der Site
|
||||
- `og:locale` - Sprache (de/en)
|
||||
|
||||
### Twitter Tags:
|
||||
- `twitter:card` - Card-Typ (sollte "summary_large_image" sein)
|
||||
- `twitter:title` - Titel
|
||||
- `twitter:description` - Beschreibung
|
||||
- `twitter:image` - Bild
|
||||
|
||||
### Canonical & Alternates:
|
||||
- `<link rel="canonical">` - Canonical URL
|
||||
- `<link rel="alternate" hreflang="de">` - Deutsche Version
|
||||
- `<link rel="alternate" hreflang="en">` - Englische Version
|
||||
- `<link rel="alternate" hreflang="x-default">` - Standard-Version
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problem: Meta-Tags werden nicht angezeigt
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe, ob die App läuft: `npm run dev`
|
||||
2. Prüfe Browser-Console auf Fehler
|
||||
3. Stelle sicher, dass `generateMetadata` in der Seite exportiert ist
|
||||
4. Prüfe, ob `lib/metadata.ts` korrekt importiert wird
|
||||
|
||||
### Problem: Open Graph Image wird nicht angezeigt
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe, ob das Bild unter `/favicon.ico` existiert (oder konfiguriertes OG-Image)
|
||||
2. Für bessere Ergebnisse: Erstelle ein dediziertes Open Graph Bild (1200x630px)
|
||||
3. Platziere es in `public/og-image.png`
|
||||
4. Setze in `.env`: `NEXT_PUBLIC_OG_IMAGE=/og-image.png`
|
||||
|
||||
### Problem: Facebook zeigt alte Vorschau
|
||||
|
||||
**Lösung:**
|
||||
1. Öffne Facebook Sharing Debugger
|
||||
2. Gib deine URL ein
|
||||
3. Klicke auf "Scraping erneut ausführen" (mehrfach, falls nötig)
|
||||
4. Facebook cached die Vorschau - Cache kann mehrere Stunden dauern
|
||||
|
||||
### Problem: Domain-Erkennung funktioniert nicht
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe `lib/seo.ts` - `getBaseUrl()` Funktion
|
||||
2. Stelle sicher, dass Request-Headers korrekt sind
|
||||
3. In Produktion: Prüfe, ob Proxy-Headers (`x-forwarded-host`) korrekt gesetzt sind
|
||||
|
||||
## Open Graph Bild optimieren
|
||||
|
||||
Für bessere Social Media Vorschauen solltest du ein dediziertes OG-Bild erstellen:
|
||||
|
||||
**Empfohlene Größe:** 1200x630px
|
||||
**Format:** PNG oder JPG
|
||||
**Pfad:** `public/og-image.png`
|
||||
|
||||
**Konfiguration:**
|
||||
```bash
|
||||
# In .env
|
||||
NEXT_PUBLIC_OG_IMAGE=/og-image.png
|
||||
```
|
||||
|
||||
Dann wird dieses Bild in allen Open Graph Meta-Tags verwendet.
|
||||
|
||||
## Nützliche Links
|
||||
|
||||
- [Open Graph Protocol Dokumentation](https://ogp.me/)
|
||||
- [Twitter Cards Dokumentation](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards)
|
||||
- [Facebook Sharing Best Practices](https://developers.facebook.com/docs/sharing/webmasters)
|
||||
|
||||
@@ -12,15 +12,15 @@ The application is configured via environment variables. You can set these in a
|
||||
|----------|-------------|---------|
|
||||
| `NEXT_PUBLIC_APP_NAME` | The name of the application. | `Hördle` |
|
||||
| `NEXT_PUBLIC_APP_DESCRIPTION` | The description used in metadata. | `Daily music guessing game...` |
|
||||
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.elpatron.me` |
|
||||
| `NEXT_PUBLIC_TWITTER_HANDLE` | Twitter handle for metadata. | `@elpatron` |
|
||||
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.de` |
|
||||
|
||||
### Analytics (Plausible)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_DOMAIN` | The domain to track in Plausible. | `hoerdle.elpatron.me` |
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.elpatron.me/js/script.js` |
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.example.com/js/script.js` |
|
||||
|
||||
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt. Beide Domains (`hoerdle.de` und `hördle.de`) werden automatisch getrackt.
|
||||
|
||||
### Credits
|
||||
|
||||
@@ -9,7 +9,7 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
console.log('[i18n/request] incoming requestLocale:', locale);
|
||||
|
||||
if (!locale || !locales.includes(locale as (typeof locales)[number])) {
|
||||
locale = 'de';
|
||||
locale = 'en';
|
||||
console.log('[i18n/request] falling back to default locale:', locale);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
export const config = {
|
||||
appName: process.env.NEXT_PUBLIC_APP_NAME || 'Hördle',
|
||||
appDescription: process.env.NEXT_PUBLIC_APP_DESCRIPTION || 'Daily music guessing game - Guess the song from short audio clips',
|
||||
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.elpatron.me',
|
||||
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || '@elpatron',
|
||||
plausibleDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'hoerdle.elpatron.me',
|
||||
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.elpatron.me/js/script.js',
|
||||
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.de',
|
||||
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/js/script.js',
|
||||
colors: {
|
||||
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
|
||||
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
|
||||
@@ -14,5 +12,9 @@ export const config = {
|
||||
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
|
||||
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
||||
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
||||
},
|
||||
seo: {
|
||||
ogImage: process.env.NEXT_PUBLIC_OG_IMAGE || '/api/og-image',
|
||||
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || undefined,
|
||||
}
|
||||
};
|
||||
|
||||
47
lib/externalPuzzles.ts
Normal file
47
lib/externalPuzzles.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export type ExternalPuzzle = {
|
||||
id: string;
|
||||
nameDe: string;
|
||||
nameEn: string;
|
||||
url: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Zentrale Liste externer Rätselangebote.
|
||||
*
|
||||
* Erweiterung: Einfach neuen Eintrag in dieses Array hinzufügen.
|
||||
*/
|
||||
export const externalPuzzles: ExternalPuzzle[] = [
|
||||
{
|
||||
id: 'pastpuzzle',
|
||||
nameDe: 'Past Puzzle',
|
||||
nameEn: 'Past Puzzle',
|
||||
url: 'https://www.pastpuzzle.de/#/',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'woerdle',
|
||||
nameDe: 'Wördle',
|
||||
nameEn: 'Wördle',
|
||||
url: 'https://www.wördle.de',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'ciddle',
|
||||
nameDe: 'Ciddle',
|
||||
nameEn: 'Ciddle',
|
||||
url: 'https://ciddle.winklerweb.net',
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function getRandomExternalPuzzle(): ExternalPuzzle | null {
|
||||
const activePuzzles = externalPuzzles.filter(p => p.isActive !== false);
|
||||
if (activePuzzles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const index = Math.floor(Math.random() * activePuzzles.length);
|
||||
return activePuzzles[index] ?? null;
|
||||
}
|
||||
|
||||
|
||||
68
lib/extraPuzzlesTracker.ts
Normal file
68
lib/extraPuzzlesTracker.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { getTodayISOString } from './dateUtils';
|
||||
|
||||
const DAILY_PLAYED_PREFIX = 'hoerdle_daily_played_';
|
||||
const EXTRA_POPOVER_PREFIX = 'hoerdle_extra_puzzles_shown_';
|
||||
|
||||
function getTodayKey(prefix: string): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const today = getTodayISOString();
|
||||
return `${prefix}${today}`;
|
||||
}
|
||||
|
||||
export function markDailyPuzzlePlayedToday(genreKey: string) {
|
||||
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
|
||||
if (!storageKey) return;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
const list: string[] = raw ? JSON.parse(raw) : [];
|
||||
if (!list.includes(genreKey)) {
|
||||
list.push(genreKey);
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(list));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[extraPuzzles] Failed to mark daily puzzle as played', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPlayedAllDailyPuzzlesForToday(requiredGenreKeys: string[]): boolean {
|
||||
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
|
||||
if (!storageKey) return false;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
const played: string[] = raw ? JSON.parse(raw) : [];
|
||||
if (!Array.isArray(played) || played.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return requiredGenreKeys.every(key => played.includes(key));
|
||||
} catch (e) {
|
||||
console.warn('[extraPuzzles] Failed to read played puzzles', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasSeenExtraPuzzlesPopoverToday(): boolean {
|
||||
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
|
||||
if (!storageKey) return false;
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) === 'true';
|
||||
} catch (e) {
|
||||
console.warn('[extraPuzzles] Failed to read popover state', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function markExtraPuzzlesPopoverShownToday() {
|
||||
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
|
||||
if (!storageKey) return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, 'true');
|
||||
} catch (e) {
|
||||
console.warn('[extraPuzzles] Failed to persist popover state', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
169
lib/gameState.ts
169
lib/gameState.ts
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getTodayISOString } from './dateUtils';
|
||||
import { loadPlayerState, savePlayerState, getGenreKey } from './playerStorage';
|
||||
|
||||
export interface GameState {
|
||||
date: string;
|
||||
@@ -27,17 +28,19 @@ export interface Statistics {
|
||||
failed: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
|
||||
const STATS_KEY_PREFIX = 'hoerdle_statistics';
|
||||
|
||||
const INITIAL_SCORE = 90;
|
||||
|
||||
export function useGameState(genre: string | null = null, maxAttempts: number = 7) {
|
||||
export function useGameState(
|
||||
genre: string | null = null,
|
||||
maxAttempts: number = 7,
|
||||
isSpecial: boolean = false
|
||||
) {
|
||||
const [gameState, setGameState] = useState<GameState | null>(null);
|
||||
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||
|
||||
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
|
||||
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
|
||||
|
||||
// Get genre key for backend storage
|
||||
// For specials, genre contains the special name
|
||||
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
|
||||
const createNewState = (date: string): GameState => ({
|
||||
date,
|
||||
@@ -52,72 +55,94 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
yearGuessed: false
|
||||
});
|
||||
|
||||
const createNewStatistics = (): Statistics => ({
|
||||
solvedIn1: 0,
|
||||
solvedIn2: 0,
|
||||
solvedIn3: 0,
|
||||
solvedIn4: 0,
|
||||
solvedIn5: 0,
|
||||
solvedIn6: 0,
|
||||
solvedIn7: 0,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Load game state
|
||||
const storageKey = getStorageKey();
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
const today = getTodayISOString();
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.date === today) {
|
||||
// Migration for existing states without score
|
||||
if (parsed.score === undefined) {
|
||||
parsed.score = INITIAL_SCORE;
|
||||
parsed.replayCount = 0;
|
||||
parsed.skipCount = 0;
|
||||
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }];
|
||||
parsed.yearGuessed = false;
|
||||
|
||||
// Retroactively deduct points for existing guesses if possible,
|
||||
// but simpler to just start at 90 for active games to avoid confusion
|
||||
|
||||
// Always recompute genreKey to ensure it's current
|
||||
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
|
||||
// Try to load from backend first
|
||||
const loadFromBackend = async () => {
|
||||
try {
|
||||
const backendState = await loadPlayerState(currentGenreKey);
|
||||
|
||||
if (backendState) {
|
||||
const { gameState: loadedState, statistics: loadedStats } = backendState;
|
||||
|
||||
// Check if the loaded state is for today
|
||||
if (loadedState.date === today) {
|
||||
setGameState(loadedState);
|
||||
setStatistics(loadedStats);
|
||||
return; // Successfully loaded from backend
|
||||
} else {
|
||||
// State is for a different day - create new state
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
setStatistics(loadedStats); // Keep statistics across days
|
||||
// Save new state to backend
|
||||
await savePlayerState(currentGenreKey, newState, loadedStats);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No backend state found - create new state
|
||||
// This is the normal case for first-time players or new genre
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
const newStats = createNewStatistics();
|
||||
setStatistics(newStats);
|
||||
// Save to backend for cross-domain sync
|
||||
await savePlayerState(currentGenreKey, newState, newStats);
|
||||
return;
|
||||
}
|
||||
setGameState(parsed as GameState);
|
||||
} else {
|
||||
// New day
|
||||
} catch (error) {
|
||||
console.error('[gameState] Failed to load from backend:', error);
|
||||
|
||||
// On error, create new state and try to save to backend
|
||||
// This handles network errors gracefully
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||
const newStats = createNewStatistics();
|
||||
setStatistics(newStats);
|
||||
// Try to save to backend (may fail, but we try)
|
||||
try {
|
||||
await savePlayerState(currentGenreKey, newState, newStats);
|
||||
} catch (saveError) {
|
||||
console.error('[gameState] Failed to save new state to backend:', saveError);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No state
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||
}
|
||||
};
|
||||
|
||||
loadFromBackend();
|
||||
}, [genre, isSpecial]); // Re-run when genre or isSpecial changes
|
||||
|
||||
// Load statistics
|
||||
const statsKey = getStatsKey();
|
||||
const storedStats = localStorage.getItem(statsKey);
|
||||
if (storedStats) {
|
||||
const parsedStats = JSON.parse(storedStats);
|
||||
// Migration for existing stats without solvedIn7
|
||||
if (parsedStats.solvedIn7 === undefined) {
|
||||
parsedStats.solvedIn7 = 0;
|
||||
}
|
||||
setStatistics(parsedStats);
|
||||
} else {
|
||||
const newStats: Statistics = {
|
||||
solvedIn1: 0,
|
||||
solvedIn2: 0,
|
||||
solvedIn3: 0,
|
||||
solvedIn4: 0,
|
||||
solvedIn5: 0,
|
||||
solvedIn6: 0,
|
||||
solvedIn7: 0,
|
||||
failed: 0,
|
||||
};
|
||||
setStatistics(newStats);
|
||||
localStorage.setItem(statsKey, JSON.stringify(newStats));
|
||||
}
|
||||
}, [genre]); // Re-run when genre changes
|
||||
|
||||
const saveState = (newState: GameState) => {
|
||||
const saveState = async (newState: GameState) => {
|
||||
setGameState(newState);
|
||||
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
|
||||
|
||||
// Save to backend only
|
||||
if (statistics) {
|
||||
try {
|
||||
// Always use the current genreKey (recompute it in case genre/isSpecial changed)
|
||||
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
await savePlayerState(currentGenreKey, newState, statistics);
|
||||
} catch (error) {
|
||||
console.error('[gameState] Failed to save to backend:', error);
|
||||
// No fallback - backend is required for cross-domain sync
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatistics = (attempts: number, solved: boolean) => {
|
||||
const updateStatistics = async (attempts: number, solved: boolean) => {
|
||||
if (!statistics) return;
|
||||
|
||||
const newStats = { ...statistics };
|
||||
@@ -139,11 +164,24 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
}
|
||||
|
||||
setStatistics(newStats);
|
||||
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
|
||||
|
||||
// Save to backend only
|
||||
if (gameState) {
|
||||
try {
|
||||
// Always use the current genreKey (recompute it in case genre/isSpecial changed)
|
||||
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
await savePlayerState(currentGenreKey, gameState, newStats);
|
||||
} catch (error) {
|
||||
console.error('[gameState] Failed to save statistics to backend:', error);
|
||||
// No fallback - backend is required for cross-domain sync
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addGuess = (guess: string, correct: boolean) => {
|
||||
if (!gameState) return;
|
||||
// Prevent adding guesses if already solved or failed
|
||||
if (gameState.isSolved || gameState.isFailed) return;
|
||||
|
||||
const newGuesses = [...gameState.guesses, guess];
|
||||
const isSolved = correct;
|
||||
@@ -162,6 +200,9 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
} else {
|
||||
newScore -= 3;
|
||||
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
|
||||
// Additional penalty for track extension (unlock steps)
|
||||
newScore -= 5;
|
||||
newBreakdown.push({ value: -5, reason: 'Track extension' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,12 @@ export function getLocalizedValue(
|
||||
if (typeof value === 'object') {
|
||||
if (value[locale]) return value[locale];
|
||||
|
||||
// Fallback to 'de'
|
||||
if (value['de']) return value['de'];
|
||||
|
||||
// Fallback to 'en'
|
||||
if (value['en']) return value['en'];
|
||||
|
||||
// Fallback to 'de'
|
||||
if (value['de']) return value['de'];
|
||||
|
||||
// Fallback to first key
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > 0) return value[keys[0]];
|
||||
|
||||
64
lib/metadata.ts
Normal file
64
lib/metadata.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { config } from './config';
|
||||
import { getBaseUrl } from './seo';
|
||||
|
||||
/**
|
||||
* Generate base metadata with Open Graph, Twitter Cards, and canonical URLs
|
||||
*/
|
||||
export async function generateBaseMetadata(
|
||||
locale: string,
|
||||
path: string = '',
|
||||
title?: string,
|
||||
description?: string,
|
||||
image?: string
|
||||
): Promise<Metadata> {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const pathSegment = path ? `/${path}` : '';
|
||||
const fullUrl = `${baseUrl}/${locale}${pathSegment}`;
|
||||
|
||||
// Determine alternate URLs for both locales (same path for both)
|
||||
const alternateLocale = locale === 'de' ? 'en' : 'de';
|
||||
const alternateUrl = `${baseUrl}/${alternateLocale}${pathSegment}`;
|
||||
|
||||
// Default values
|
||||
const metaTitle = title || config.appName;
|
||||
const metaDescription = description || config.appDescription;
|
||||
const ogImage = image || `${baseUrl}${config.seo.ogImage}`;
|
||||
|
||||
return {
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
alternates: {
|
||||
canonical: fullUrl,
|
||||
languages: {
|
||||
[locale]: fullUrl,
|
||||
[alternateLocale]: alternateUrl,
|
||||
'x-default': `${baseUrl}/en${pathSegment}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
url: fullUrl,
|
||||
siteName: config.appName,
|
||||
images: [
|
||||
{
|
||||
url: ogImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: metaTitle,
|
||||
},
|
||||
],
|
||||
locale: locale,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
images: [ogImage],
|
||||
...(config.seo.twitterHandle && { creator: config.seo.twitterHandle }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
223
lib/playerId.ts
Normal file
223
lib/playerId.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Player Identifier Management
|
||||
*
|
||||
* Generates and manages a unique player identifier (UUID) that is stored
|
||||
* in localStorage. This identifier is used to sync game states across
|
||||
* different domains (hoerdle.de and hördle.de).
|
||||
*
|
||||
* Device-specific isolation:
|
||||
* - Each device has its own device ID stored in localStorage
|
||||
* - Player ID format: {basePlayerId}:{deviceId}
|
||||
* - This allows cross-domain sync on the same device while keeping devices isolated
|
||||
*/
|
||||
|
||||
const STORAGE_KEY_PLAYER = 'hoerdle_player_id';
|
||||
const STORAGE_KEY_DEVICE = 'hoerdle_device_id';
|
||||
|
||||
/**
|
||||
* Generate a UUID v4
|
||||
*/
|
||||
function generateUUID(): string {
|
||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a device ID (unique per device)
|
||||
*
|
||||
* The device ID is stored in localStorage and persists across sessions.
|
||||
* This allows device-specific isolation of game states.
|
||||
*
|
||||
* @returns Device identifier (UUID v4)
|
||||
*/
|
||||
export function getOrCreateDeviceId(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let deviceId = localStorage.getItem(STORAGE_KEY_DEVICE);
|
||||
if (!deviceId) {
|
||||
deviceId = generateUUID();
|
||||
localStorage.setItem(STORAGE_KEY_DEVICE, deviceId);
|
||||
}
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device ID without creating a new one
|
||||
*
|
||||
* @returns Device identifier or null if not set
|
||||
*/
|
||||
export function getDeviceId(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(STORAGE_KEY_DEVICE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a base player ID (for cross-domain sync)
|
||||
*/
|
||||
function generateBasePlayerId(): string {
|
||||
return generateUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find an existing base player ID from the backend
|
||||
*
|
||||
* Extracts the base player ID from a full player ID (format: {basePlayerId}:{deviceId})
|
||||
*
|
||||
* @param genreKey - Genre key to search for
|
||||
* @returns Base player ID if found, null otherwise
|
||||
*/
|
||||
async function findExistingBasePlayerId(genreKey: string): Promise<string | null> {
|
||||
try {
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
const response = await fetch('/api/player-id/suggest', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ genreKey, deviceId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.basePlayerId) {
|
||||
return data.basePlayerId;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[playerId] Failed to find existing base player ID:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine base player ID and device ID into full player ID
|
||||
* Format: {basePlayerId}:{deviceId}
|
||||
*/
|
||||
function combinePlayerId(basePlayerId: string, deviceId: string): string {
|
||||
return `${basePlayerId}:${deviceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract base player ID from full player ID
|
||||
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
|
||||
*/
|
||||
function extractBasePlayerId(fullPlayerId: string): string {
|
||||
const colonIndex = fullPlayerId.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
// Legacy format (no device ID) - return as is
|
||||
return fullPlayerId;
|
||||
}
|
||||
return fullPlayerId.substring(0, colonIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a player identifier
|
||||
*
|
||||
* Player ID format: {basePlayerId}:{deviceId}
|
||||
*
|
||||
* If no identifier exists in localStorage, tries to find an existing base player ID
|
||||
* from the backend (for cross-domain sync). If none found, generates a new base ID.
|
||||
* The device ID is always device-specific.
|
||||
*
|
||||
* This enables:
|
||||
* - Cross-domain synchronization on the same device (same base player ID)
|
||||
* - Device isolation (different device IDs)
|
||||
*
|
||||
* @param genreKey - Optional genre key to search for existing base player ID
|
||||
* @returns Full player identifier ({basePlayerId}:{deviceId})
|
||||
*/
|
||||
export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<string> {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Always get/create device ID (device-specific)
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
|
||||
// Try to get base player ID from localStorage
|
||||
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||
|
||||
if (!basePlayerId) {
|
||||
// Try to find an existing base player ID from backend if genreKey is provided
|
||||
if (genreKey) {
|
||||
const existingBaseId = await findExistingBasePlayerId(genreKey);
|
||||
if (existingBaseId) {
|
||||
basePlayerId = existingBaseId;
|
||||
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new base player ID if no existing one found
|
||||
if (!basePlayerId) {
|
||||
basePlayerId = generateBasePlayerId();
|
||||
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine base player ID with device ID
|
||||
return combinePlayerId(basePlayerId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a player identifier (synchronous version)
|
||||
*
|
||||
* This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead.
|
||||
*
|
||||
* @returns Full player identifier ({basePlayerId}:{deviceId})
|
||||
*/
|
||||
export function getOrCreatePlayerId(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||
|
||||
if (!basePlayerId) {
|
||||
basePlayerId = generateBasePlayerId();
|
||||
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||
}
|
||||
|
||||
return combinePlayerId(basePlayerId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current player identifier without creating a new one
|
||||
*
|
||||
* @returns Full player identifier ({basePlayerId}:{deviceId}) or null if not set
|
||||
*/
|
||||
export function getPlayerId(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deviceId = getDeviceId();
|
||||
const basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||
|
||||
if (!deviceId || !basePlayerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return combinePlayerId(basePlayerId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base player ID (for debugging/logging)
|
||||
*
|
||||
* @returns Base player ID or null if not set
|
||||
*/
|
||||
export function getBasePlayerId(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||
}
|
||||
|
||||
134
lib/playerStorage.ts
Normal file
134
lib/playerStorage.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Player Storage API
|
||||
*
|
||||
* Handles loading and saving player game states from/to the backend.
|
||||
* This enables cross-domain synchronization between hoerdle.de and hördle.de.
|
||||
*/
|
||||
|
||||
import { getOrCreatePlayerId } from './playerId';
|
||||
import type { GameState, Statistics } from './gameState';
|
||||
|
||||
/**
|
||||
* Get genre key for storage
|
||||
*
|
||||
* Formats the genre/special into a consistent key format:
|
||||
* - Global: "global"
|
||||
* - Genre: "Rock" (localized name)
|
||||
* - Special: "special:00725" (special name)
|
||||
*/
|
||||
export function getGenreKey(
|
||||
genre: string | null,
|
||||
isSpecial: boolean,
|
||||
specialName?: string
|
||||
): string {
|
||||
if (isSpecial && specialName) {
|
||||
return `special:${specialName}`;
|
||||
}
|
||||
return genre || 'global';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load player state from backend
|
||||
*
|
||||
* @param genreKey - Genre key (from getGenreKey)
|
||||
* @returns GameState and Statistics, or null if not found
|
||||
*/
|
||||
export async function loadPlayerState(
|
||||
genreKey: string
|
||||
): Promise<{ gameState: GameState; statistics: Statistics } | null> {
|
||||
try {
|
||||
// Use async version to enable cross-domain player ID sync
|
||||
const { getOrCreatePlayerIdAsync } = await import('./playerId');
|
||||
const playerId = await getOrCreatePlayerIdAsync(genreKey);
|
||||
if (!playerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine if it's a special or genre
|
||||
const isSpecial = genreKey.startsWith('special:');
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (isSpecial) {
|
||||
const specialName = genreKey.replace('special:', '');
|
||||
params.append('special', specialName);
|
||||
} else if (genreKey !== 'global') {
|
||||
params.append('genre', genreKey);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/player-state?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Player-Id': playerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
// No state found - this is normal for new players
|
||||
return null;
|
||||
}
|
||||
// Other errors: log and return null (will fallback to localStorage)
|
||||
console.warn('[playerStorage] Failed to load player state:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data || !data.gameState || !data.statistics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
gameState: data.gameState as GameState,
|
||||
statistics: data.statistics as Statistics,
|
||||
};
|
||||
} catch (error) {
|
||||
// Network errors or other issues: fallback to localStorage
|
||||
console.warn('[playerStorage] Error loading player state:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save player state to backend
|
||||
*
|
||||
* @param genreKey - Genre key (from getGenreKey)
|
||||
* @param gameState - Current game state
|
||||
* @param statistics - Current statistics
|
||||
*/
|
||||
export async function savePlayerState(
|
||||
genreKey: string,
|
||||
gameState: GameState,
|
||||
statistics: Statistics
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Use async version to ensure device ID is included
|
||||
const { getOrCreatePlayerIdAsync } = await import('./playerId');
|
||||
const playerId = await getOrCreatePlayerIdAsync();
|
||||
if (!playerId) {
|
||||
console.warn('[playerStorage] No player ID available, cannot save state');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/player-state', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Player-Id': playerId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
genreKey,
|
||||
gameState,
|
||||
statistics,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[playerStorage] Failed to save player state:', response.status);
|
||||
// Don't throw - allow fallback to localStorage
|
||||
}
|
||||
} catch (error) {
|
||||
// Network errors: log but don't throw (will fallback to localStorage)
|
||||
console.warn('[playerStorage] Error saving player state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
94
lib/politicalStatements.ts
Normal file
94
lib/politicalStatements.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { PrismaClient, PoliticalStatement as PrismaPoliticalStatement } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export type PoliticalStatement = {
|
||||
id: number;
|
||||
locale: string;
|
||||
text: string;
|
||||
active: boolean;
|
||||
source?: string | null;
|
||||
};
|
||||
|
||||
function mapFromPrisma(stmt: PrismaPoliticalStatement): PoliticalStatement {
|
||||
return {
|
||||
id: stmt.id,
|
||||
locale: stmt.locale,
|
||||
text: stmt.text,
|
||||
active: stmt.active,
|
||||
source: stmt.source,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRandomActiveStatement(locale: string): Promise<PoliticalStatement | null> {
|
||||
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||
const all = await prisma.politicalStatement.findMany({
|
||||
where: {
|
||||
locale: safeLocale,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (all.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = Math.floor(Math.random() * all.length);
|
||||
return mapFromPrisma(all[index]);
|
||||
}
|
||||
|
||||
export async function getAllStatements(locale: string): Promise<PoliticalStatement[]> {
|
||||
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||
const all = await prisma.politicalStatement.findMany({
|
||||
where: { locale: safeLocale },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return all.map(mapFromPrisma);
|
||||
}
|
||||
|
||||
export async function createStatement(locale: string, input: Omit<PoliticalStatement, 'id' | 'locale'>): Promise<PoliticalStatement> {
|
||||
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||
const created = await prisma.politicalStatement.create({
|
||||
data: {
|
||||
locale: safeLocale,
|
||||
text: input.text,
|
||||
active: input.active ?? true,
|
||||
source: input.source ?? null,
|
||||
},
|
||||
});
|
||||
return mapFromPrisma(created);
|
||||
}
|
||||
|
||||
export async function updateStatement(locale: string, id: number, input: Partial<Omit<PoliticalStatement, 'id' | 'locale'>>): Promise<PoliticalStatement | null> {
|
||||
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||
|
||||
// Optional: sicherstellen, dass das Statement zu dieser Locale gehört
|
||||
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
|
||||
if (!existing || existing.locale !== safeLocale) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated = await prisma.politicalStatement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
text: input.text ?? existing.text,
|
||||
active: input.active ?? existing.active,
|
||||
source: input.source !== undefined ? input.source : existing.source,
|
||||
},
|
||||
});
|
||||
|
||||
return mapFromPrisma(updated);
|
||||
}
|
||||
|
||||
export async function deleteStatement(locale: string, id: number): Promise<boolean> {
|
||||
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||
|
||||
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
|
||||
if (!existing || existing.locale !== safeLocale) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await prisma.politicalStatement.delete({ where: { id } });
|
||||
return true;
|
||||
}
|
||||
|
||||
43
lib/seo.ts
Normal file
43
lib/seo.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { config } from './config';
|
||||
|
||||
/**
|
||||
* Get the current base URL from request headers
|
||||
* Automatically detects hoerdle.de or hördle.de (xn--hrdle-jua.de)
|
||||
*/
|
||||
export async function getBaseUrl(): Promise<string> {
|
||||
const headersList = await headers();
|
||||
const host = headersList.get('host') || headersList.get('x-forwarded-host') || '';
|
||||
|
||||
let domain = config.domain; // Default fallback
|
||||
|
||||
if (host) {
|
||||
// Extract domain from host (remove port if present)
|
||||
const detectedDomain = host.split(':')[0].toLowerCase();
|
||||
|
||||
// Map domains
|
||||
if (detectedDomain === 'hoerdle.de') {
|
||||
domain = 'hoerdle.de';
|
||||
} else if (detectedDomain === 'hördle.de' || detectedDomain === 'xn--hrdle-jua.de') {
|
||||
domain = 'hördle.de';
|
||||
} else {
|
||||
// Use detected domain if it's different from default
|
||||
domain = detectedDomain;
|
||||
}
|
||||
}
|
||||
|
||||
// Always use HTTPS in production
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
return `${protocol}://${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base URL synchronously (for use in non-async contexts)
|
||||
* Uses environment variable or config as fallback
|
||||
*/
|
||||
export function getBaseUrlSync(): string {
|
||||
const domain = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
return `${protocol}://${domain}`;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
|
||||
"thanksForRating": "Danke für die Bewertung!",
|
||||
"rateThisPuzzle": "Bewerte dieses Rätsel:",
|
||||
"ratingTooltip": "Hilf unseren Kuratoren, gute Rätsel zu machen!",
|
||||
"shared": "✓ Geteilt!",
|
||||
"copied": "✓ Kopiert!",
|
||||
"shareFailed": "✗ Fehlgeschlagen",
|
||||
@@ -63,6 +64,12 @@
|
||||
"special": "Special",
|
||||
"genre": "Genre"
|
||||
},
|
||||
"ExtraPuzzles": {
|
||||
"title": "Noch nicht genug Rätsel?",
|
||||
"message": "Hey, hast du Lust auf weitere Rätsel? Dann schau doch mal bei {name} vorbei!",
|
||||
"cta": "Zu {name}",
|
||||
"close": "Schließen"
|
||||
},
|
||||
"Statistics": {
|
||||
"yourStatistics": "Deine Statistiken",
|
||||
"totalPuzzles": "Gesamte Rätsel",
|
||||
@@ -125,7 +132,7 @@
|
||||
"delete": "Löschen",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"curate": "Kurieren",
|
||||
"curate": "Kuratieren",
|
||||
"name": "Name",
|
||||
"subtitle": "Untertitel",
|
||||
"maxAttempts": "Max. Versuche",
|
||||
@@ -152,7 +159,7 @@
|
||||
"deletePuzzle": "Löschen",
|
||||
"wrongPassword": "Falsches Passwort"
|
||||
},
|
||||
"About": {
|
||||
"About": {
|
||||
"title": "Über Hördle & Impressum",
|
||||
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",
|
||||
"projectTitle": "Über dieses Projekt",
|
||||
@@ -162,15 +169,15 @@
|
||||
"imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):",
|
||||
"imprintCountry": "Deutschland",
|
||||
"imprintEmailLabel": "E-Mail:",
|
||||
"imprintDisclaimer": "Hinweis: Diese Angaben entsprechen dem aktuellen Stand. Für rechtliche Fragen solltest du eine Fachperson konsultieren.",
|
||||
"costsTitle": "Laufende Kosten des Projekts",
|
||||
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
||||
"costsDonationNote": "Alle Einnahmen, die die Betriebskosten des Projekts übersteigen, werden am Jahresende an die Aktion <link>Zentrum für politische Schönheit</link> gespendet.",
|
||||
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
|
||||
"costsServer": "Server / vServer für App und Tracking",
|
||||
"costsEmail": "E-Mail-Hosting",
|
||||
"costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen",
|
||||
"costsSheetLinkText": "Eine detaillierte, laufend gepflegte Übersicht über die aktuellen Kosten findest du in dieser <link>Google-Tabelle</link>.",
|
||||
"costsSheetPrivacyNote": "Beim Aufruf oder Einbetten der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
|
||||
"costsSheetPrivacyNote": "Beim Aufruf der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
|
||||
"supportTitle": "Hördle unterstützen",
|
||||
"supportIntro": "Hördle ist ein nicht-kommerzielles Projekt, das von laufenden Kosten finanziert werden muss. Wenn du das Projekt finanziell unterstützen möchtest, gibt es folgende Möglichkeiten:",
|
||||
"supportSepaTitle": "SEPA Banküberweisung (bevorzugt)",
|
||||
@@ -180,6 +187,10 @@
|
||||
"supportPaypalLink": "paypal.me/MBusche",
|
||||
"supportSteadyTitle": "Steady",
|
||||
"supportSteadyDescription": "Regelmäßige Unterstützung über Steady",
|
||||
"supportCuratorTitle": "Als Kurator bewerben",
|
||||
"supportCuratorText": "Du hast gute Kenntnisse in einem Genre und möchtest dich als Kurator bewerben? Wir freuen uns über deine Nachricht!",
|
||||
"supportReportBugTitle": "Fehler melden",
|
||||
"supportReportBugText": "Fehler in der App gefunden? Bitte melde sie per E-Mail an <email>admin@hoerdle.de</email>.",
|
||||
"privacyTitle": "Datenschutz",
|
||||
"privacyIntro": "Der Schutz deiner Privatsphäre ist wichtig. Dieses Projekt versucht, so datensparsam wie möglich zu arbeiten.",
|
||||
"privacyPlausibleTitle": "Selbst gehostetes Plausible Analytics",
|
||||
@@ -190,7 +201,6 @@
|
||||
"privacyPlausibleAggregated": "Auswertungen erfolgen ausschließlich in aggregierter Form (z. B. Seitenaufrufe, genutzte Browser).",
|
||||
"privacyServerLogs": "Der Server kann technisch bedingt Logdateien mit IP-Adresse, Zeitpunkt des Zugriffs und abgerufenen Ressourcen führen. Diese Daten werden nur zur Sicherstellung des Betriebs und zur Fehleranalyse verwendet und regelmäßig gelöscht.",
|
||||
"privacyContact": "Wenn du Fragen zu den verarbeiteten Daten hast oder Auskunft wünschst, kannst du dich über die im Impressum genannte E-Mail-Adresse melden.",
|
||||
"privacyNoLegalAdvice": "Hinweis: Diese Datenschutzhinweise dienen nur als Beispiel und ersetzen keine rechtliche Beratung. Für eine rechtskonforme Datenschutzerklärung solltest du eine Fachperson konsultieren.",
|
||||
"backTitle": "Zurück zum Spiel",
|
||||
"backToGame": "Zurück zu Hördle",
|
||||
"footerLinkLabel": "Über & Impressum"
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
|
||||
"thanksForRating": "Thanks for rating!",
|
||||
"rateThisPuzzle": "Rate this puzzle:",
|
||||
"ratingTooltip": "Help our curators create good puzzles!",
|
||||
"shared": "✓ Shared!",
|
||||
"copied": "✓ Copied!",
|
||||
"shareFailed": "✗ Failed",
|
||||
@@ -63,6 +64,12 @@
|
||||
"special": "Special",
|
||||
"genre": "Genre"
|
||||
},
|
||||
"ExtraPuzzles": {
|
||||
"title": "Still in the mood for puzzles?",
|
||||
"message": "Hey, would you like to try some more puzzles? Then take a look at {name}!",
|
||||
"cta": "Go to {name}",
|
||||
"close": "Close"
|
||||
},
|
||||
"Statistics": {
|
||||
"yourStatistics": "Your Statistics",
|
||||
"totalPuzzles": "Total puzzles",
|
||||
@@ -162,15 +169,15 @@
|
||||
"imprintOperator": "Responsible for the content of this site (provider under German law):",
|
||||
"imprintCountry": "Germany",
|
||||
"imprintEmailLabel": "Email:",
|
||||
"imprintDisclaimer": "Note: This information is current as of the date indicated. For legal questions you should consult a legal professional.",
|
||||
"costsTitle": "Ongoing costs of the project",
|
||||
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
||||
"costsDonationNote": "All income that exceeds the operating costs of the project will be donated at the end of the year to the campaign <link>Zentrum für politische Schönheit</link>.",
|
||||
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
|
||||
"costsServer": "Servers / vServers for the app and tracking",
|
||||
"costsEmail": "Email hosting",
|
||||
"costsLicenses": "Possible fees for copyrights or other licenses",
|
||||
"costsSheetLinkText": "You can find a detailed, continuously updated overview of the current costs in this <link>Google Sheet</link>.",
|
||||
"costsSheetPrivacyNote": "When accessing or embedding the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
|
||||
"costsSheetPrivacyNote": "When accessing the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
|
||||
"supportTitle": "Support Hördle",
|
||||
"supportIntro": "Hördle is a non-commercial project that needs to be financed by ongoing costs. If you would like to support the project financially, here are the options:",
|
||||
"supportSepaTitle": "SEPA Bank Transfer (preferred)",
|
||||
@@ -180,6 +187,10 @@
|
||||
"supportPaypalLink": "paypal.me/MBusche",
|
||||
"supportSteadyTitle": "Steady",
|
||||
"supportSteadyDescription": "Regular support via Steady",
|
||||
"supportCuratorTitle": "Apply as Curator",
|
||||
"supportCuratorText": "Do you have good knowledge in a genre and would like to apply as a curator? We'd be happy to hear from you!",
|
||||
"supportReportBugTitle": "Report Bugs",
|
||||
"supportReportBugText": "Found a bug in the app? Please report it via email to <email>admin@hoerdle.de</email>.",
|
||||
"privacyTitle": "Privacy",
|
||||
"privacyIntro": "Protecting your privacy matters. This project aims to collect as little data as possible.",
|
||||
"privacyPlausibleTitle": "Self-hosted Plausible Analytics",
|
||||
@@ -190,7 +201,6 @@
|
||||
"privacyPlausibleAggregated": "Analytics are only performed in aggregated form (e.g. page views, browsers used).",
|
||||
"privacyServerLogs": "For technical reasons, the server may log IP address, time of access and accessed resources. This data is only used to keep the service running and to debug issues and is deleted on a regular basis.",
|
||||
"privacyContact": "If you have questions about the data processed or want to request information, please contact the email address given in the imprint.",
|
||||
"privacyNoLegalAdvice": "Note: These privacy notes are only an example and do not replace legal advice. For a legally compliant privacy policy you should consult a professional.",
|
||||
"backTitle": "Back to the game",
|
||||
"backToGame": "Back to Hördle",
|
||||
"footerLinkLabel": "About & Imprint"
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.0.15",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.0.15",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PlayerState" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"genreKey" TEXT NOT NULL,
|
||||
"gameState" TEXT NOT NULL,
|
||||
"statistics" TEXT NOT NULL,
|
||||
"lastPlayed" DATETIME NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PlayerState_identifier_idx" ON "PlayerState"("identifier");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PlayerState_identifier_genreKey_key" ON "PlayerState"("identifier", "genreKey");
|
||||
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PoliticalStatement" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"locale" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"source" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PoliticalStatement_locale_active_idx" ON "PoliticalStatement"("locale", "active");
|
||||
@@ -88,3 +88,28 @@ model News {
|
||||
|
||||
@@index([publishedAt])
|
||||
}
|
||||
|
||||
model PlayerState {
|
||||
id Int @id @default(autoincrement())
|
||||
identifier String // UUID des Spielers (für Cross-Domain-Sync)
|
||||
genreKey String // Genre-Name oder "global" oder "special:<name>"
|
||||
gameState String // JSON-String des GameState
|
||||
statistics String // JSON-String der Statistics
|
||||
lastPlayed DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([identifier, genreKey])
|
||||
@@index([identifier])
|
||||
}
|
||||
|
||||
model PoliticalStatement {
|
||||
id Int @id @default(autoincrement())
|
||||
locale String
|
||||
text String
|
||||
active Boolean @default(true)
|
||||
source String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([locale, active])
|
||||
}
|
||||
|
||||
35
proxy.ts
35
proxy.ts
@@ -2,9 +2,9 @@ import createMiddleware from 'next-intl/middleware';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
const i18nMiddleware = createMiddleware({
|
||||
locales: ['de', 'en'],
|
||||
defaultLocale: 'de',
|
||||
// Wir nutzen überall Locale-Präfixe (`/de`, `/en`)
|
||||
locales: ['en', 'de'],
|
||||
defaultLocale: 'en',
|
||||
// Wir nutzen überall Locale-Präfixe (`/en`, `/de`)
|
||||
localePrefix: 'always'
|
||||
});
|
||||
|
||||
@@ -21,16 +21,41 @@ export default function proxy(request: NextRequest) {
|
||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
|
||||
// Extract Plausible domain from script URL for CSP
|
||||
const plausibleScriptSrc = process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/js/script.js';
|
||||
let plausibleOrigin = 'https://plausible.example.com';
|
||||
try {
|
||||
const url = new URL(plausibleScriptSrc);
|
||||
plausibleOrigin = url.origin;
|
||||
} catch {
|
||||
// If URL parsing fails, try to extract domain manually
|
||||
const match = plausibleScriptSrc.match(/https?:\/\/([^/]+)/);
|
||||
if (match) {
|
||||
plausibleOrigin = `https://${match[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get other service URLs from environment (only add to CSP if configured)
|
||||
const gotifyUrl = process.env.GOTIFY_URL;
|
||||
const openrouterUrl = process.env.NEXT_PUBLIC_OPENROUTER_URL || 'https://openrouter.ai';
|
||||
|
||||
// Build CSP dynamically based on environment variables
|
||||
const connectSrcParts = ["'self'", openrouterUrl, plausibleOrigin];
|
||||
if (gotifyUrl && !gotifyUrl.includes('example.com')) {
|
||||
connectSrcParts.push(gotifyUrl);
|
||||
}
|
||||
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me",
|
||||
`script-src 'self' 'unsafe-inline' 'unsafe-eval' ${plausibleOrigin}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
|
||||
`connect-src ${connectSrcParts.join(' ')}`,
|
||||
"media-src 'self' blob:",
|
||||
"frame-ancestors 'self'",
|
||||
].join('; ');
|
||||
|
||||
headers.set('Content-Security-Policy', csp);
|
||||
|
||||
return response;
|
||||
|
||||
94
scripts/check-caddy-certificates.sh
Executable file
94
scripts/check-caddy-certificates.sh
Executable file
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
# Script zum Prüfen der Caddy-Zertifikate
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔍 Prüfe Caddy-Zertifikat-Status..."
|
||||
echo ""
|
||||
|
||||
# Prüfe ob Caddy-Container läuft
|
||||
if ! docker ps | grep -q hoerdle-caddy; then
|
||||
echo "❌ Caddy-Container läuft nicht!"
|
||||
echo " Starte ihn mit: docker compose -f docker-compose.caddy.yml --profile production up -d"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Caddy-Container läuft"
|
||||
echo ""
|
||||
|
||||
# Prüfe Zertifikate im Volume
|
||||
echo "📜 Gespeicherte Zertifikate im Volume:"
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*.crt" -o -name "*.key" 2>/dev/null | head -20 || echo " (Keine Zertifikate gefunden oder Fehler beim Zugriff)"
|
||||
echo ""
|
||||
|
||||
# Prüfe Caddy-Logs für Zertifikats-Fehler
|
||||
echo "📋 Letzte Caddy-Logs (Zertifikat-bezogen):"
|
||||
docker logs hoerdle-caddy 2>&1 | grep -i -E "(certificate|tls|acme|challenge|error)" | tail -20 || echo " (Keine relevanten Log-Einträge gefunden)"
|
||||
echo ""
|
||||
|
||||
# Prüfe DNS-Einträge
|
||||
echo "🌐 DNS-Einträge prüfen:"
|
||||
echo ""
|
||||
|
||||
# Prüfe hoerdle.de
|
||||
echo "1️⃣ hoerdle.de:"
|
||||
HOERDLE_IP=$(dig +short hoerdle.de @8.8.8.8 | head -1 || echo "DNS-Fehler")
|
||||
if [ -n "$HOERDLE_IP" ] && [ "$HOERDLE_IP" != "DNS-Fehler" ]; then
|
||||
echo " ✅ DNS-A-Record: $HOERDLE_IP"
|
||||
else
|
||||
echo " ❌ DNS-A-Record: nicht aufgelöst"
|
||||
fi
|
||||
|
||||
# Prüfe xn--hrdle-jua.de (Punycode)
|
||||
echo "2️⃣ xn--hrdle-jua.de (hördle.de):"
|
||||
PUNYCODE_IP=$(dig +short xn--hrdle-jua.de @8.8.8.8 | head -1 || echo "DNS-Fehler")
|
||||
if [ -n "$PUNYCODE_IP" ] && [ "$PUNYCODE_IP" != "DNS-Fehler" ]; then
|
||||
echo " ✅ DNS-A-Record: $PUNYCODE_IP"
|
||||
else
|
||||
echo " ❌ DNS-A-Record: nicht aufgelöst"
|
||||
fi
|
||||
|
||||
# Prüfe auch die Unicode-Domain direkt (falls unterstützt)
|
||||
echo "3️⃣ hördle.de (Unicode):"
|
||||
UNICODE_IP=$(dig +short hördle.de @8.8.8.8 2>/dev/null | head -1 || echo "Nicht unterstützt")
|
||||
if [ -n "$UNICODE_IP" ] && [ "$UNICODE_IP" != "Nicht unterstützt" ]; then
|
||||
echo " ✅ DNS-A-Record: $UNICODE_IP"
|
||||
else
|
||||
echo " ⚠️ Unicode-Domain-Abfrage nicht unterstützt (normal)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🔒 Zertifikat-Test:"
|
||||
echo ""
|
||||
|
||||
# Teste HTTPS-Verbindung zu hoerdle.de
|
||||
echo "1️⃣ hoerdle.de HTTPS:"
|
||||
if echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
echo " ✅ Zertifikat gültig"
|
||||
CERT_INFO=$(echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -subject -dates 2>/dev/null || echo "")
|
||||
echo " $CERT_INFO" | sed 's/^/ /'
|
||||
else
|
||||
echo " ❌ Zertifikat fehlt oder ungültig"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "2️⃣ hördle.de (xn--hrdle-jua.de) HTTPS:"
|
||||
if echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
echo " ✅ Zertifikat gültig"
|
||||
CERT_INFO=$(echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -subject -dates 2>/dev/null || echo "")
|
||||
echo " $CERT_INFO" | sed 's/^/ /'
|
||||
else
|
||||
echo " ❌ Zertifikat fehlt oder ungültig"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📝 Zertifikat-Details aus Caddy Volume:"
|
||||
echo ""
|
||||
docker exec hoerdle-caddy ls -la /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/ 2>/dev/null | head -10 || echo " (Verzeichnis nicht gefunden oder leer)"
|
||||
|
||||
echo ""
|
||||
echo "💡 Tipps:"
|
||||
echo " - Wenn kein Zertifikat vorhanden ist, führe aus: ./scripts/renew-caddy-certificates.sh"
|
||||
echo " - Prüfe die Caddy-Logs: docker logs hoerdle-caddy"
|
||||
echo " - Prüfe DNS-Einträge in GoDaddy: https://dcc.godaddy.com/manage/hoerdle.de/dns"
|
||||
|
||||
110
scripts/quick-check-punycode-dns.sh
Executable file
110
scripts/quick-check-punycode-dns.sh
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/bin/bash
|
||||
# Quick-Check für Punycode-Domain DNS und Zertifikat
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔍 Quick-Check für hördle.de (xn--hrdle-jua.de)"
|
||||
echo ""
|
||||
|
||||
# Prüfe DNS
|
||||
echo "1️⃣ DNS-Auflösung:"
|
||||
echo ""
|
||||
echo " hoerdle.de:"
|
||||
HOERDLE_IP=$(dig +short hoerdle.de @8.8.8.8 | head -1 || echo "")
|
||||
if [ -z "$HOERDLE_IP" ]; then
|
||||
echo " ❌ Konnte nicht aufgelöst werden"
|
||||
else
|
||||
echo " ✅ IP: $HOERDLE_IP"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " xn--hrdle-jua.de:"
|
||||
PUNYCODE_IP=$(dig +short xn--hrdle-jua.de @8.8.8.8 | head -1 || echo "")
|
||||
if [ -z "$PUNYCODE_IP" ]; then
|
||||
echo " ❌ Konnte nicht aufgelöst werden"
|
||||
else
|
||||
echo " ✅ IP: $PUNYCODE_IP"
|
||||
fi
|
||||
|
||||
if [ -n "$HOERDLE_IP" ] && [ -n "$PUNYCODE_IP" ]; then
|
||||
if [ "$HOERDLE_IP" = "$PUNYCODE_IP" ]; then
|
||||
echo ""
|
||||
echo " ✅ Beide Domains zeigen auf die gleiche IP ($HOERDLE_IP)"
|
||||
else
|
||||
echo ""
|
||||
echo " ⚠️ WARNUNG: Domains zeigen auf unterschiedliche IPs!"
|
||||
echo " hoerdle.de: $HOERDLE_IP"
|
||||
echo " xn--hrdle-jua.de: $PUNYCODE_IP"
|
||||
echo " → Beide sollten auf die gleiche IP zeigen!"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "2️⃣ HTTPS-Verbindungstest:"
|
||||
echo ""
|
||||
|
||||
# Test hoerdle.de
|
||||
echo " hoerdle.de:"
|
||||
if timeout 5 bash -c "echo > /dev/tcp/hoerdle.de/443" 2>/dev/null; then
|
||||
echo " ✅ Port 443 ist erreichbar"
|
||||
if echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
CERT_VALID_UNTIL=$(echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 || echo "")
|
||||
echo " ✅ Zertifikat gültig bis: $CERT_VALID_UNTIL"
|
||||
else
|
||||
echo " ❌ Zertifikat ungültig oder fehlt"
|
||||
fi
|
||||
else
|
||||
echo " ❌ Port 443 nicht erreichbar"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " xn--hrdle-jua.de:"
|
||||
if timeout 5 bash -c "echo > /dev/tcp/xn--hrdle-jua.de/443" 2>/dev/null; then
|
||||
echo " ✅ Port 443 ist erreichbar"
|
||||
if echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
CERT_VALID_UNTIL=$(echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 || echo "")
|
||||
echo " ✅ Zertifikat gültig bis: $CERT_VALID_UNTIL"
|
||||
else
|
||||
echo " ❌ Zertifikat ungültig oder fehlt (ERR_SSL_PROTOCOL_ERROR)"
|
||||
echo ""
|
||||
echo " 💡 Lösung: Führe aus: ./scripts/renew-caddy-certificates.sh"
|
||||
fi
|
||||
else
|
||||
echo " ❌ Port 443 nicht erreichbar"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "3️⃣ Caddy-Container Status:"
|
||||
echo ""
|
||||
if docker ps | grep -q hoerdle-caddy; then
|
||||
echo " ✅ Caddy-Container läuft"
|
||||
echo ""
|
||||
echo " 📋 Letzte Caddy-Logs (Zertifikat-bezogen):"
|
||||
docker logs hoerdle-caddy 2>&1 | grep -i -E "(xn--hrdle|punycode|certificate|tls|acme|challenge)" | tail -5 || echo " (Keine relevanten Einträge gefunden)"
|
||||
else
|
||||
echo " ❌ Caddy-Container läuft nicht"
|
||||
echo " Starte mit: docker compose -f docker-compose.caddy.yml --profile production up -d"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "💡 Nächste Schritte:"
|
||||
echo ""
|
||||
if [ -n "$HOERDLE_IP" ] && [ -n "$PUNYCODE_IP" ] && [ "$HOERDLE_IP" != "$PUNYCODE_IP" ]; then
|
||||
echo " 1. ❗ DNS-Konfiguration prüfen!"
|
||||
echo " Beide Domains müssen auf die gleiche IP zeigen."
|
||||
echo " Prüfe in GoDaddy: https://dcc.godaddy.com/manage/hoerdle.de/dns"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo " 2. Wenn DNS korrekt ist, Zertifikat neu erstellen:"
|
||||
echo " ./scripts/renew-caddy-certificates.sh"
|
||||
echo ""
|
||||
echo " 3. Caddy-Logs überwachen:"
|
||||
echo " docker logs hoerdle-caddy -f"
|
||||
echo ""
|
||||
echo " 4. Detaillierte Diagnose:"
|
||||
echo " ./scripts/check-caddy-certificates.sh"
|
||||
echo ""
|
||||
|
||||
83
scripts/renew-caddy-certificates.sh
Executable file
83
scripts/renew-caddy-certificates.sh
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# Script zum Erneuern/Löschen und Neu-Erstellen von Caddy-Zertifikaten
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔄 Caddy-Zertifikat-Erneuerung"
|
||||
echo ""
|
||||
echo "⚠️ WICHTIG: Dieses Script wird die Zertifikate löschen und Caddy zwingen, sie neu zu erstellen."
|
||||
echo ""
|
||||
|
||||
# Prüfe ob Caddy-Container läuft
|
||||
if ! docker ps | grep -q hoerdle-caddy; then
|
||||
echo "❌ Caddy-Container läuft nicht!"
|
||||
echo " Starte ihn mit: docker compose -f docker-compose.caddy.yml --profile production up -d"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📋 Verfügbare Optionen:"
|
||||
echo " 1) Alle Zertifikate löschen und neu erstellen (empfohlen bei Problemen)"
|
||||
echo " 2) Nur Zertifikat für xn--hrdle-jua.de löschen (Punycode-Domain)"
|
||||
echo " 3) Nur Zertifikat für hoerdle.de löschen"
|
||||
echo " 4) Abbrechen"
|
||||
echo ""
|
||||
read -p "Wähle eine Option (1-4): " choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
echo ""
|
||||
echo "🗑️ Lösche ALLE Zertifikate..."
|
||||
docker exec hoerdle-caddy rm -rf /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/* 2>/dev/null || true
|
||||
echo "✅ Alle Zertifikate gelöscht"
|
||||
;;
|
||||
2)
|
||||
echo ""
|
||||
echo "🗑️ Lösche Zertifikat für xn--hrdle-jua.de..."
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--hrdle-jua.de*" -delete 2>/dev/null || true
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--*" -delete 2>/dev/null || true
|
||||
echo "✅ Zertifikat für Punycode-Domain gelöscht"
|
||||
;;
|
||||
3)
|
||||
echo ""
|
||||
echo "🗑️ Lösche Zertifikat für hoerdle.de..."
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*hoerdle.de*" ! -name "*xn--*" -delete 2>/dev/null || true
|
||||
echo "✅ Zertifikat für hoerdle.de gelöscht"
|
||||
;;
|
||||
4)
|
||||
echo "❌ Abgebrochen."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "❌ Ungültige Option. Abgebrochen."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "🔄 Starte Caddy-Container neu, um Zertifikate neu zu erstellen..."
|
||||
docker compose -f docker-compose.caddy.yml --profile production restart caddy
|
||||
|
||||
echo ""
|
||||
echo "⏳ Warte 5 Sekunden, damit Caddy startet..."
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "📋 Prüfe Caddy-Logs auf Zertifikats-Erstellung:"
|
||||
echo ""
|
||||
docker logs hoerdle-caddy --tail=30 2>&1 | grep -i -E "(certificate|tls|acme|challenge|error|success)" || echo " (Keine relevanten Log-Einträge in den letzten 30 Zeilen)"
|
||||
|
||||
echo ""
|
||||
echo "✅ Container wurde neu gestartet."
|
||||
echo ""
|
||||
echo "💡 Nächste Schritte:"
|
||||
echo " 1. Warte 1-2 Minuten, damit Caddy die Zertifikate erstellt"
|
||||
echo " 2. Prüfe die Logs: docker logs hoerdle-caddy -f"
|
||||
echo " 3. Prüfe den Status: ./scripts/check-caddy-certificates.sh"
|
||||
echo " 4. Teste die Domain: curl -I https://xn--hrdle-jua.de"
|
||||
echo ""
|
||||
echo "⚠️ Falls das Zertifikat nicht erstellt wird:"
|
||||
echo " - Prüfe DNS-Einträge: Beide Domains müssen auf die Server-IP zeigen"
|
||||
echo " - Prüfe Port 80: Muss von außen erreichbar sein (für HTTP-01 Challenge)"
|
||||
echo " - Prüfe Firewall: Ports 80 und 443 müssen offen sein"
|
||||
echo " - Prüfe Caddy-Logs: docker logs hoerdle-caddy"
|
||||
|
||||
Reference in New Issue
Block a user