Compare commits
5 Commits
d69af49e24
...
v0.1.4.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72f8b99092 | ||
|
|
e60daa511b | ||
|
|
19706abacb | ||
|
|
170e7b5402 | ||
|
|
ade1043c3c |
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
|
||||||
|
|||||||
@@ -98,13 +98,27 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
marginBottom: "0.75rem",
|
marginBottom: "0.5rem",
|
||||||
fontSize: "0.9rem",
|
fontSize: "0.9rem",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("costsSheetPrivacyNote")}
|
{t("costsSheetPrivacyNote")}
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<section style={{ marginBottom: "2rem" }}>
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
|||||||
@@ -1,99 +1,94 @@
|
|||||||
import { promises as fs } from 'fs';
|
import { PrismaClient, PoliticalStatement as PrismaPoliticalStatement } from '@prisma/client';
|
||||||
import path from 'path';
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export type PoliticalStatement = {
|
export type PoliticalStatement = {
|
||||||
id: number;
|
id: number;
|
||||||
|
locale: string;
|
||||||
text: string;
|
text: string;
|
||||||
active?: boolean;
|
active: boolean;
|
||||||
source?: string;
|
source?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getFilePath(locale: string): string {
|
function mapFromPrisma(stmt: PrismaPoliticalStatement): PoliticalStatement {
|
||||||
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
return {
|
||||||
return path.join(process.cwd(), 'data', `political-statements.${safeLocale}.json`);
|
id: stmt.id,
|
||||||
}
|
locale: stmt.locale,
|
||||||
|
text: stmt.text,
|
||||||
async function readStatementsFile(locale: string): Promise<PoliticalStatement[]> {
|
active: stmt.active,
|
||||||
const filePath = getFilePath(locale);
|
source: stmt.source,
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRandomActiveStatement(locale: string): Promise<PoliticalStatement | null> {
|
export async function getRandomActiveStatement(locale: string): Promise<PoliticalStatement | null> {
|
||||||
const statements = await readStatementsFile(locale);
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
const active = statements.filter((s) => s.active !== false);
|
const all = await prisma.politicalStatement.findMany({
|
||||||
if (active.length === 0) {
|
where: {
|
||||||
|
locale: safeLocale,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (all.length === 0) {
|
||||||
return null;
|
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[]> {
|
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> {
|
export async function createStatement(locale: string, input: Omit<PoliticalStatement, 'id' | 'locale'>): Promise<PoliticalStatement> {
|
||||||
const statements = await readStatementsFile(locale);
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
const nextId = statements.length > 0 ? Math.max(...statements.map((s) => s.id)) + 1 : 1;
|
const created = await prisma.politicalStatement.create({
|
||||||
const newStatement: PoliticalStatement = {
|
data: {
|
||||||
id: nextId,
|
locale: safeLocale,
|
||||||
active: true,
|
text: input.text,
|
||||||
...input,
|
active: input.active ?? true,
|
||||||
};
|
source: input.source ?? null,
|
||||||
statements.push(newStatement);
|
},
|
||||||
await writeStatementsFile(locale, statements);
|
});
|
||||||
return newStatement;
|
return mapFromPrisma(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateStatement(locale: string, id: number, input: Partial<Omit<PoliticalStatement, 'id'>>): Promise<PoliticalStatement | null> {
|
export async function updateStatement(locale: string, id: number, input: Partial<Omit<PoliticalStatement, 'id' | 'locale'>>): Promise<PoliticalStatement | null> {
|
||||||
const statements = await readStatementsFile(locale);
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
const index = statements.findIndex((s) => s.id === id);
|
|
||||||
if (index === -1) return null;
|
|
||||||
|
|
||||||
const updated: PoliticalStatement = {
|
// Optional: sicherstellen, dass das Statement zu dieser Locale gehört
|
||||||
...statements[index],
|
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
|
||||||
...input,
|
if (!existing || existing.locale !== safeLocale) {
|
||||||
id,
|
return null;
|
||||||
};
|
}
|
||||||
statements[index] = updated;
|
|
||||||
await writeStatementsFile(locale, statements);
|
const updated = await prisma.politicalStatement.update({
|
||||||
return updated;
|
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> {
|
export async function deleteStatement(locale: string, id: number): Promise<boolean> {
|
||||||
const statements = await readStatementsFile(locale);
|
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||||
const filtered = statements.filter((s) => s.id !== id);
|
|
||||||
if (filtered.length === statements.length) {
|
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.locale !== safeLocale) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await writeStatementsFile(locale, filtered);
|
|
||||||
|
await prisma.politicalStatement.delete({ where: { id } });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,7 @@
|
|||||||
"deletePuzzle": "Löschen",
|
"deletePuzzle": "Löschen",
|
||||||
"wrongPassword": "Falsches Passwort"
|
"wrongPassword": "Falsches Passwort"
|
||||||
},
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "Über Hördle & Impressum",
|
"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.",
|
"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",
|
"projectTitle": "Über dieses Projekt",
|
||||||
@@ -171,12 +171,13 @@
|
|||||||
"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",
|
||||||
"costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen",
|
"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>.",
|
"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",
|
"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:",
|
"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)",
|
"supportSepaTitle": "SEPA Banküberweisung (bevorzugt)",
|
||||||
|
|||||||
@@ -171,12 +171,13 @@
|
|||||||
"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",
|
||||||
"costsLicenses": "Possible fees for copyrights or other licenses",
|
"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>.",
|
"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",
|
"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:",
|
"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)",
|
"supportSepaTitle": "SEPA Bank Transfer (preferred)",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.4.8",
|
"version": "0.1.4.11",
|
||||||
"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