This commit is contained in:
Andreas Binczyk 2026-06-04 19:18:27 +02:00
commit 61f6235445
14 changed files with 3366 additions and 0 deletions

35
.gitignore vendored Normal file
View file

@ -0,0 +1,35 @@
# Dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# Testing
/coverage
# Next.js
/.next/
/out/
# Production
/build
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*.local
.env
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts

View file

@ -0,0 +1,70 @@
import { NextResponse } from 'next/server';
import { toggleAufgabe, deleteAufgabe, getAufgabeById } from '@/lib/db';
// PUT /api/tasks/:id - Aufgabe als erledigt/rückgängig markieren
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Ungültige Aufgaben-ID' },
{ status: 400 }
);
}
const updatedAufgabe = await toggleAufgabe(id);
if (!updatedAufgabe) {
return NextResponse.json(
{ error: 'Aufgabe nicht gefunden' },
{ status: 404 }
);
}
return NextResponse.json(updatedAufgabe);
} catch (error) {
console.error('Fehler beim Aktualisieren:', error);
return NextResponse.json(
{ error: 'Fehler beim Aktualisieren der Aufgabe' },
{ status: 500 }
);
}
}
// DELETE /api/tasks/:id - Aufgabe löschen
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Ungültige Aufgaben-ID' },
{ status: 400 }
);
}
const deleted = await deleteAufgabe(id);
if (!deleted) {
return NextResponse.json(
{ error: 'Aufgabe nicht gefunden' },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Fehler beim Löschen:', error);
return NextResponse.json(
{ error: 'Fehler beim Löschen der Aufgabe' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { getAufgaben } from '@/lib/db';
// CSV-Header definieren
const CSV_HEADER = 'ID,Beschreibung,Erledigt,Erstellt\n';
// Aufgabe in CSV-Zeile konvertieren
function aufgabeToCsvRow(aufgabe: any): string {
const erledigt = aufgabe.erledigt ? 'Ja' : 'Nein';
const erstellt = new Date(aufgabe.erstellt).toLocaleString('de-DE');
// Kommas in der Beschreibung durch Semikolon ersetzen und Anführungszeichen escapen
const beschreibung = `"${aufgabe.beschreibung.replace(/"/g, '""')}"`;
return `${aufgabe.id},${beschreibung},${erledigt},"${erstellt}"`;
}
// GET /api/tasks/export - CSV-Export
export async function GET() {
try {
const aufgaben = await getAufgaben();
// CSV generieren
const csvRows = aufgaben.map(aufgabeToCsvRow);
const csvContent = CSV_HEADER + csvRows.join('\n');
// CSV als Datei-Download zurückgeben
return new NextResponse(csvContent, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="aufgaben.csv"',
},
});
} catch (error) {
console.error('Fehler beim CSV-Export:', error);
return NextResponse.json(
{ error: 'Fehler beim Erstellen des CSV-Exports' },
{ status: 500 }
);
}
}

56
app/api/tasks/route.ts Normal file
View file

@ -0,0 +1,56 @@
import { NextResponse } from 'next/server';
import { getAufgaben, addAufgabe, getAufgabenCount } from '@/lib/db';
// GET /api/tasks - Aufgaben mit Paginierung und Sortierung abrufen
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const sort = searchParams.get('sort') as 'text_asc' | 'text_desc' | 'datum_asc' | 'datum_desc' || 'datum_desc';
const offset = (page - 1) * limit;
const aufgaben = await getAufgaben(limit, offset, sort);
const total = await getAufgabenCount();
const totalPages = Math.ceil(total / limit);
return NextResponse.json({
aufgaben,
pagination: {
page,
limit,
total,
totalPages
}
});
} catch (error) {
console.error('Fehler beim Abrufen:', error);
return NextResponse.json(
{ error: 'Fehler beim Abrufen der Aufgaben' },
{ status: 500 }
);
}
}
// POST /api/tasks - Neue Aufgabe hinzufügen
export async function POST(request: Request) {
try {
const { beschreibung } = await request.json();
if (!beschreibung || typeof beschreibung !== 'string') {
return NextResponse.json(
{ error: 'Beschreibung ist erforderlich' },
{ status: 400 }
);
}
const neueAufgabe = await addAufgabe(beschreibung.trim());
return NextResponse.json(neueAufgabe, { status: 201 });
} catch (error) {
console.error('Fehler beim Hinzufügen:', error);
return NextResponse.json(
{ error: 'Fehler beim Hinzufügen der Aufgabe' },
{ status: 500 }
);
}
}

210
app/globals.css Normal file
View file

@ -0,0 +1,210 @@
* {
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
h1 {
text-align: center;
color: #333;
}
.container {
background-color: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.form-group {
display: flex;
margin-bottom: 20px;
}
.form-group input[type="text"] {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
.form-group button {
padding: 10px 20px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-left: 10px;
font-size: 16px;
}
.form-group button:hover {
background-color: #45a049;
}
.todo-list {
list-style: none;
padding: 0;
}
.todo-item {
display: flex;
align-items: center;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 8px;
background-color: #f9f9f9;
}
.todo-item.completed {
background-color: #e8f5e9;
text-decoration: line-through;
color: #888;
}
.todo-item .content {
flex: 1;
display: flex;
align-items: center;
}
.todo-item span {
flex: 1;
}
.todo-item .datum {
font-size: 12px;
color: #666;
margin-left: 10px;
white-space: nowrap;
}
.todo-item .actions {
display: flex;
gap: 5px;
}
.todo-item .actions a {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
}
.erledigen-btn {
background-color: #2196F3;
color: white;
}
.loeschen-btn {
background-color: #f44336;
color: white;
}
.erledigen-btn:hover {
background-color: #0b7dda;
}
.loeschen-btn:hover {
background-color: #d32f2f;
}
.sort-dropdown {
padding: 5px;
border-radius: 4px;
border: 1px solid #ddd;
}
.export-button {
padding: 5px 15px;
background-color: #9C27B0;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.export-button:hover:not(:disabled) {
background-color: #7B1FA2;
}
.export-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.sort-container {
margin-bottom: 15px;
display: flex;
align-items: center;
justify-content: right;
gap: 10px;
}
.empty-message {
text-align: center;
color: #888;
font-style: italic;
padding: 20px;
}
.copyright {
text-align: center;
margin-top: 40px;
padding: 20px;
color: #666;
font-size: 14px;
}
.copyright p {
margin: 0;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-top: 20px;
padding: 15px;
}
.pagination button {
padding: 8px 16px;
background-color: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.pagination button:hover:not(:disabled) {
background-color: #0b7dda;
}
.pagination button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.pagination-info {
color: #666;
font-size: 14px;
}

19
app/layout.tsx Normal file
View file

@ -0,0 +1,19 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'To-Do-Liste',
description: 'Einfache Todo-App mit Next.js und TypeScript',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<body>{children}</body>
</html>
);
}

241
app/page.tsx Normal file
View file

@ -0,0 +1,241 @@
'use client';
import { useState, useEffect } from 'react';
interface Aufgabe {
id: number;
beschreibung: string;
erledigt: boolean;
erstellt: string;
}
interface Pagination {
page: number;
limit: number;
total: number;
totalPages: number;
}
type SortOption = 'text_asc' | 'text_desc' | 'datum_asc' | 'datum_desc';
export default function TodoApp() {
const [aufgaben, setAufgaben] = useState<Aufgabe[]>([]);
const [neueAufgabe, setNeueAufgabe] = useState('');
const [sortierung, setSortierung] = useState<SortOption>('datum_desc');
const [isLoading, setIsLoading] = useState(true);
const [pagination, setPagination] = useState<Pagination>({
page: 1,
limit: 10,
total: 0,
totalPages: 1
});
// Lade Aufgaben vom Server mit Paginierung und Sortierung
const loadAufgaben = async (page: number = 1) => {
try {
setIsLoading(true);
const response = await fetch(`/api/tasks?page=${page}&limit=10&sort=${sortierung}`);
if (response.ok) {
const data = await response.json();
setAufgaben(data.aufgaben);
setPagination(data.pagination);
}
} catch (error) {
console.error('Fehler beim Laden der Aufgaben:', error);
} finally {
setIsLoading(false);
}
};
// Initial laden
useEffect(() => {
loadAufgaben(1);
}, []);
// Aufgaben werden bereits serverseitig sortiert, keine clientseitige Sortierung nötig
const sortierteAufgaben = aufgaben;
const handleHinzufuegen = async (e: React.FormEvent) => {
e.preventDefault();
if (neueAufgabe.trim()) {
try {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ beschreibung: neueAufgabe.trim() }),
});
if (response.ok) {
setNeueAufgabe('');
// Zur ersten Seite zurück, wenn neue Aufgabe hinzugefügt wird
await loadAufgaben(1);
}
} catch (error) {
console.error('Fehler beim Hinzufügen:', error);
}
}
};
const handleLoeschen = async (id: number) => {
if (confirm('Möchtest du diese Aufgabe wirklich löschen?')) {
try {
const response = await fetch(`/api/tasks/${id}`, {
method: 'DELETE',
});
if (response.ok) {
// Aktuelle Seite neu laden oder zur vorherigen, wenn leer
await loadAufgaben(pagination.page);
}
} catch (error) {
console.error('Fehler beim Löschen:', error);
}
}
};
const handleErledigen = async (id: number) => {
try {
const response = await fetch(`/api/tasks/${id}`, {
method: 'PUT',
});
if (response.ok) {
await loadAufgaben(pagination.page);
}
} catch (error) {
console.error('Fehler beim Aktualisieren:', error);
}
};
// Sortierung ändern und Aufgaben neu laden
const handleSortierungAendern = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const neueSortierung = e.target.value as SortOption;
setSortierung(neueSortierung);
await loadAufgaben(1);
};
return (
<div className="container">
<h1>To-Do-Liste</h1>
{/* Sortieroptionen und Export */}
<div className="sort-container">
<span>Sortieren nach:</span>
<select
value={sortierung}
onChange={handleSortierungAendern}
className="sort-dropdown"
>
<option value="text_asc">Name (A-Z)</option>
<option value="text_desc">Name (Z-A)</option>
<option value="datum_asc">Datum (älteste)</option>
<option value="datum_desc">Datum (neueste)</option>
</select>
<button
onClick={() => window.location.href = '/api/tasks/export'}
className="export-button"
disabled={isLoading}
>
CSV Export
</button>
</div>
{/* Formular zum Hinzufügen */}
<form onSubmit={handleHinzufuegen} className="form-group">
<input
type="text"
value={neueAufgabe}
onChange={(e) => setNeueAufgabe(e.target.value)}
placeholder="Neue Aufgabe eingeben..."
required
disabled={isLoading}
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Lädt...' : 'Hinzufügen'}
</button>
</form>
{/* To-Do-Liste */}
{isLoading ? (
<p className="empty-message">Lädt Aufgaben...</p>
) : sortierteAufgaben.length > 0 ? (
<ul className="todo-list">
{sortierteAufgaben.map((aufgabe) => (
<li
key={aufgabe.id}
className={`todo-item ${aufgabe.erledigt ? 'completed' : ''}`}
>
<div className="content">
<span>{aufgabe.beschreibung}</span>
<span className="datum">
{new Date(aufgabe.erstellt).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<div className="actions">
<a
href="#"
className="erledigen-btn"
onClick={(e) => {
e.preventDefault();
handleErledigen(aufgabe.id);
}}
title={aufgabe.erledigt ? 'Rückgängig' : 'Erledigt'}
>
{aufgabe.erledigt ? '↩' : '✓'}
</a>
<a
href="#"
className="loeschen-btn"
onClick={(e) => {
e.preventDefault();
handleLoeschen(aufgabe.id);
}}
title="Löschen"
>
</a>
</div>
</li>
))}
</ul>
) : (
<p className="empty-message">
Die To-Do-Liste ist leer. Füge eine neue Aufgabe hinzu!
</p>
)}
{/* Paginierung */}
{pagination.totalPages > 1 && (
<div className="pagination">
<button
onClick={() => loadAufgaben(Math.max(1, pagination.page - 1))}
disabled={pagination.page <= 1 || isLoading}
>
Zurück
</button>
<span className="pagination-info">
Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Aufgaben)
</span>
<button
onClick={() => loadAufgaben(Math.min(pagination.totalPages, pagination.page + 1))}
disabled={pagination.page >= pagination.totalPages || isLoading}
>
Weiter
</button>
</div>
)}
<footer className="copyright">
<p>© 2026 GSTools</p>
</footer>
</div>
);
}

13
coolify.yaml Normal file
View file

@ -0,0 +1,13 @@
name: todo-app
type: node
buildCommand: npm install && npm run build
startCommand: npm run start
port: 3000
# WICHTIG: Volume für SQLite-Datenbank
volumes:
- ./todo.db:/app/todo.db
# Automatisches Deployment bei Git-Push
autoDeployment: true
branch: main

120
lib/db.ts Normal file
View file

@ -0,0 +1,120 @@
import sqlite3 from 'sqlite3';
import { open, Database } from 'sqlite';
import path from 'path';
// Datenbank-Datei-Pfad
const dbPath = path.join(process.cwd(), 'todo.db');
// Datenbank-Instanz (wird asynchron initialisiert)
let db: Database | null = null;
// Datenbank öffnen
export async function initDb(): Promise<Database> {
if (db) return db;
db = await open({
filename: dbPath,
driver: sqlite3.Database
});
// Tabelle erstellen, falls nicht vorhanden
await db.exec(`
CREATE TABLE IF NOT EXISTS aufgaben (
id INTEGER PRIMARY KEY AUTOINCREMENT,
beschreibung TEXT NOT NULL,
erledigt INTEGER DEFAULT 0,
erstellt TEXT NOT NULL
)
`);
return db;
}
// Typdefinition
export interface Aufgabe {
id: number;
beschreibung: string;
erledigt: boolean;
erstellt: string;
}
type SortOption = 'text_asc' | 'text_desc' | 'datum_asc' | 'datum_desc';
// Sortier-Order für SQLite
function getOrderBy(sort: SortOption): string {
switch (sort) {
case 'text_asc':
return 'beschreibung ASC';
case 'text_desc':
return 'beschreibung DESC';
case 'datum_asc':
return 'erstellt ASC';
case 'datum_desc':
default:
return 'erstellt DESC';
}
}
// Alle Aufgaben abrufen (mit Paginierung und Sortierung)
export async function getAufgaben(limit: number = 1000, offset: number = 0, sort: SortOption = 'datum_desc'): Promise<Aufgabe[]> {
const database = await initDb();
const orderBy = getOrderBy(sort);
const rows = await database.all(
`SELECT * FROM aufgaben ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
[limit, offset]
) as any[];
return rows.map(row => ({
...row,
erledigt: Boolean(row.erledigt)
}));
}
// Anzahl aller Aufgaben abrufen
export async function getAufgabenCount(): Promise<number> {
const database = await initDb();
const result = await database.get('SELECT COUNT(*) as count FROM aufgaben') as { count: number };
return result.count;
}
// Aufgabe hinzufügen
export async function addAufgabe(beschreibung: string): Promise<Aufgabe> {
const database = await initDb();
const result = await database.run(
'INSERT INTO aufgaben (beschreibung, erledigt, erstellt) VALUES (?, 0, datetime("now"))',
[beschreibung]
);
const newId = result.lastID as number;
return getAufgabeById(newId)!;
}
// Aufgabe nach ID abrufen
export async function getAufgabeById(id: number): Promise<Aufgabe | null> {
const database = await initDb();
const row = await database.get('SELECT * FROM aufgaben WHERE id = ?', id) as any;
if (!row) return null;
return {
...row,
erledigt: Boolean(row.erledigt)
};
}
// Aufgabe als erledigt/rückgängig markieren
export async function toggleAufgabe(id: number): Promise<Aufgabe | null> {
const database = await initDb();
await database.run('UPDATE aufgaben SET erledigt = 1 - erledigt WHERE id = ?', id);
return getAufgabeById(id);
}
// Aufgabe löschen
export async function deleteAufgabe(id: number): Promise<boolean> {
const database = await initDb();
const result = await database.run('DELETE FROM aufgaben WHERE id = ?', id);
return result.changes > 0;
}
// Datenbank schließen
export async function closeDatabase() {
if (db) {
await db.close();
db = null;
}
}

4
next.config.mjs Normal file
View file

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

2494
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "nextjs-todo",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^16.2.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"typescript": "^5.3.0"
}
}

BIN
todo.db Normal file

Binary file not shown.

41
tsconfig.json Normal file
View file

@ -0,0 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}