Compare commits

..

7 Commits

Author SHA1 Message Date
Hördle Bot
72f8b99092 Adjust costs section donation note and bump version to v0.1.4.11 2025-12-02 14:50:02 +01:00
Hördle Bot
e60daa511b Add donation note for political beauty and bump version to v0.1.4.10 2025-12-02 14:42:46 +01:00
Hördle Bot
19706abacb Bump version to v0.1.4.9 2025-12-02 14:26:17 +01:00
Hördle Bot
170e7b5402 Store political statements in database 2025-12-02 14:14:53 +01:00
Hördle Bot
ade1043c3c chore: Update .gitignore to include new script and documentation files 2025-12-02 14:11:11 +01:00
Hördle Bot
d69af49e24 Bump version to v0.1.4.8 2025-12-02 13:56:45 +01:00
Hördle Bot
63687524e7 Merge branch 'partnerpuzzles' 2025-12-02 13:56:10 +01:00
8 changed files with 115 additions and 77 deletions

2
.gitignore vendored
View File

@@ -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

View File

@@ -98,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" }}>

View File

@@ -1,99 +1,94 @@
import { promises as fs } from 'fs';
import path from 'path';
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;
active: boolean;
source?: string | null;
};
function getFilePath(locale: string): string {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
return path.join(process.cwd(), 'data', `political-statements.${safeLocale}.json`);
}
async function readStatementsFile(locale: string): Promise<PoliticalStatement[]> {
const filePath = getFilePath(locale);
try {
const raw = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(raw);
if (Array.isArray(data)) {
return data;
}
return [];
} catch (err: any) {
if (err.code === 'ENOENT') {
// File does not exist yet
return [];
}
console.error('[politicalStatements] Failed to read file', filePath, err);
return [];
}
}
async function writeStatementsFile(locale: string, statements: PoliticalStatement[]): Promise<void> {
const filePath = getFilePath(locale);
const dir = path.dirname(filePath);
try {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, JSON.stringify(statements, null, 2), 'utf-8');
} catch (err) {
console.error('[politicalStatements] Failed to write file', filePath, err);
throw err;
}
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 statements = await readStatementsFile(locale);
const active = statements.filter((s) => s.active !== false);
if (active.length === 0) {
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() * active.length);
return active[index] ?? null;
const index = Math.floor(Math.random() * all.length);
return mapFromPrisma(all[index]);
}
export async function getAllStatements(locale: string): Promise<PoliticalStatement[]> {
return readStatementsFile(locale);
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'>): Promise<PoliticalStatement> {
const statements = await readStatementsFile(locale);
const nextId = statements.length > 0 ? Math.max(...statements.map((s) => s.id)) + 1 : 1;
const newStatement: PoliticalStatement = {
id: nextId,
active: true,
...input,
};
statements.push(newStatement);
await writeStatementsFile(locale, statements);
return newStatement;
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'>>): Promise<PoliticalStatement | null> {
const statements = await readStatementsFile(locale);
const index = statements.findIndex((s) => s.id === id);
if (index === -1) return null;
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';
const updated: PoliticalStatement = {
...statements[index],
...input,
id,
};
statements[index] = updated;
await writeStatementsFile(locale, statements);
return updated;
// 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 statements = await readStatementsFile(locale);
const filtered = statements.filter((s) => s.id !== id);
if (filtered.length === statements.length) {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
if (!existing || existing.locale !== safeLocale) {
return false;
}
await writeStatementsFile(locale, filtered);
await prisma.politicalStatement.delete({ where: { id } });
return true;
}

View File

@@ -159,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",
@@ -171,12 +171,13 @@
"imprintEmailLabel": "E-Mail:",
"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)",

View File

@@ -171,12 +171,13 @@
"imprintEmailLabel": "Email:",
"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)",

View File

@@ -1,6 +1,6 @@
{
"name": "hoerdle",
"version": "0.1.4.7",
"version": "0.1.4.11",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -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");

View File

@@ -101,3 +101,15 @@ model PlayerState {
@@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])
}