Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e60daa511b | ||
|
|
19706abacb | ||
|
|
170e7b5402 | ||
|
|
ade1043c3c | ||
|
|
d69af49e24 | ||
|
|
63687524e7 | ||
|
|
0246cb58ee | ||
|
|
d76aa9f4e9 | ||
|
|
28afaf598b | ||
|
|
8239753911 | ||
|
|
0bfcf0737e | ||
|
|
5409196008 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -52,3 +52,5 @@ next-env.d.ts
|
|||||||
.release-years-migrated
|
.release-years-migrated
|
||||||
.covers-migrated
|
.covers-migrated
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
scripts/scrape-bahn-expert-statements.js
|
||||||
|
docs/bahn-expert-statements.txt
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ export default async function GenrePage({ params }: PageProps) {
|
|||||||
return s.launchDate && s.launchDate > now;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||||
@@ -156,7 +159,7 @@ export default async function GenrePage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<NewsSection locale={locale} />
|
<NewsSection locale={locale} />
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} requiredDailyKeys={requiredDailyKeys} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,20 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
{t("costsTitle")}
|
{t("costsTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ marginBottom: "0.5rem" }}>{t("costsIntro")}</p>
|
<p style={{ marginBottom: "0.5rem" }}>{t("costsIntro")}</p>
|
||||||
|
<p style={{ marginBottom: "0.5rem" }}>
|
||||||
|
{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>
|
||||||
<ul
|
<ul
|
||||||
style={{
|
style={{
|
||||||
marginLeft: "1.25rem",
|
marginLeft: "1.25rem",
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ interface News {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PoliticalStatement {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
active?: boolean;
|
||||||
|
source?: string;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
|
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
@@ -169,6 +177,11 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
const [showSpecials, setShowSpecials] = useState(false);
|
const [showSpecials, setShowSpecials] = useState(false);
|
||||||
const [showGenres, setShowGenres] = useState(false);
|
const [showGenres, setShowGenres] = useState(false);
|
||||||
const [showNews, setShowNews] = 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);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Check for existing auth on mount
|
// 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) => {
|
const handleCreateNews = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if ((!newNewsTitle.de.trim() && !newNewsTitle.en.trim()) || (!newNewsContent.de.trim() && !newNewsContent.en.trim())) return;
|
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(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) fetchSpecials();
|
if (isAuthenticated) {
|
||||||
|
fetchSpecials();
|
||||||
|
fetchPoliticalStatements('de');
|
||||||
|
fetchPoliticalStatements('en');
|
||||||
|
}
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const deleteGenre = async (id: number) => {
|
const deleteGenre = async (id: number) => {
|
||||||
@@ -1580,6 +1691,141 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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' }}>
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>{t('uploadSongs')}</h2>
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>{t('uploadSongs')}</h2>
|
||||||
<form onSubmit={handleBatchUpload}>
|
<form onSubmit={handleBatchUpload}>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { config } from "@/lib/config";
|
|||||||
import { generateBaseMetadata } from "@/lib/metadata";
|
import { generateBaseMetadata } from "@/lib/metadata";
|
||||||
import InstallPrompt from "@/components/InstallPrompt";
|
import InstallPrompt from "@/components/InstallPrompt";
|
||||||
import AppFooter from "@/components/AppFooter";
|
import AppFooter from "@/components/AppFooter";
|
||||||
|
import PoliticalStatementBanner from "@/components/PoliticalStatementBanner";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -89,6 +90,7 @@ export default async function LocaleLayout({
|
|||||||
{children}
|
{children}
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
|
<PoliticalStatementBanner />
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export default async function Home({
|
|||||||
return s.launchDate && s.launchDate > now;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
|
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
|
||||||
@@ -149,7 +152,7 @@ export default async function Home({
|
|||||||
<NewsSection locale={locale} />
|
<NewsSection locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
<Game dailyPuzzle={dailyPuzzle} genre={null} requiredDailyKeys={requiredDailyKeys} />
|
||||||
<OnboardingTour />
|
<OnboardingTour />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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 AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||||
import GuessInput from './GuessInput';
|
import GuessInput from './GuessInput';
|
||||||
import Statistics from './Statistics';
|
import Statistics from './Statistics';
|
||||||
|
import ExtraPuzzlesPopover from './ExtraPuzzlesPopover';
|
||||||
import { useGameState } from '../lib/gameState';
|
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';
|
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||||
|
|
||||||
// Plausible Analytics
|
// Plausible Analytics
|
||||||
@@ -32,11 +37,14 @@ interface GameProps {
|
|||||||
isSpecial?: boolean;
|
isSpecial?: boolean;
|
||||||
maxAttempts?: number;
|
maxAttempts?: number;
|
||||||
unlockSteps?: 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];
|
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 t = useTranslations('Game');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
||||||
@@ -49,6 +57,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const [hasRated, setHasRated] = useState(false);
|
const [hasRated, setHasRated] = useState(false);
|
||||||
const [showYearModal, setShowYearModal] = useState(false);
|
const [showYearModal, setShowYearModal] = useState(false);
|
||||||
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
||||||
|
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
||||||
|
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
||||||
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -81,6 +91,37 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}
|
}
|
||||||
}, [gameState, dailyPuzzle]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setLastAction(null);
|
setLastAction(null);
|
||||||
}, [dailyPuzzle?.id]);
|
}, [dailyPuzzle?.id]);
|
||||||
@@ -284,8 +325,10 @@ 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 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` : '';
|
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
||||||
|
|
||||||
// Use current domain from window.location to support both hoerdle.de and hördle.de
|
// Use current domain from window.location to support both hoerdle.de and hördle.de,
|
||||||
const currentHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
|
// 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:';
|
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
|
||||||
let shareUrl = `${protocol}//${currentHost}`;
|
let shareUrl = `${protocol}//${currentHost}`;
|
||||||
// Add locale prefix if not default (en)
|
// Add locale prefix if not default (en)
|
||||||
@@ -488,6 +531,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
onSkip={handleYearSkip}
|
onSkip={handleYearSkip}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showExtraPuzzlesPopover && extraPuzzle && (
|
||||||
|
<ExtraPuzzlesPopover
|
||||||
|
puzzle={extraPuzzle}
|
||||||
|
onClose={() => setShowExtraPuzzlesPopover(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -64,6 +64,12 @@
|
|||||||
"special": "Special",
|
"special": "Special",
|
||||||
"genre": "Genre"
|
"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": {
|
"Statistics": {
|
||||||
"yourStatistics": "Deine Statistiken",
|
"yourStatistics": "Deine Statistiken",
|
||||||
"totalPuzzles": "Gesamte Rätsel",
|
"totalPuzzles": "Gesamte Rätsel",
|
||||||
@@ -165,6 +171,7 @@
|
|||||||
"imprintEmailLabel": "E-Mail:",
|
"imprintEmailLabel": "E-Mail:",
|
||||||
"costsTitle": "Laufende Kosten des Projekts",
|
"costsTitle": "Laufende Kosten des Projekts",
|
||||||
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
"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)",
|
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
|
||||||
"costsServer": "Server / vServer für App und Tracking",
|
"costsServer": "Server / vServer für App und Tracking",
|
||||||
"costsEmail": "E-Mail-Hosting",
|
"costsEmail": "E-Mail-Hosting",
|
||||||
|
|||||||
@@ -64,6 +64,12 @@
|
|||||||
"special": "Special",
|
"special": "Special",
|
||||||
"genre": "Genre"
|
"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": {
|
"Statistics": {
|
||||||
"yourStatistics": "Your Statistics",
|
"yourStatistics": "Your Statistics",
|
||||||
"totalPuzzles": "Total puzzles",
|
"totalPuzzles": "Total puzzles",
|
||||||
@@ -165,6 +171,7 @@
|
|||||||
"imprintEmailLabel": "Email:",
|
"imprintEmailLabel": "Email:",
|
||||||
"costsTitle": "Ongoing costs of the project",
|
"costsTitle": "Ongoing costs of the project",
|
||||||
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
"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)",
|
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
|
||||||
"costsServer": "Servers / vServers for the app and tracking",
|
"costsServer": "Servers / vServers for the app and tracking",
|
||||||
"costsEmail": "Email hosting",
|
"costsEmail": "Email hosting",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.4.4",
|
"version": "0.1.4.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -101,3 +101,15 @@ model PlayerState {
|
|||||||
@@unique([identifier, genreKey])
|
@@unique([identifier, genreKey])
|
||||||
@@index([identifier])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user