Initial commit: Notes Manager App (notes.braetter-int.de)

- Python/Flask Backend
- SQLAlchemy Models (notes, tasks, templates, users)
- Gunicorn + Nginx Deploy-Konfiguration
- Static Assets (CSS/JS)
- Jinja2 Templates
This commit is contained in:
2026-04-15 09:28:33 +02:00
commit 5c7ce5d0ca
24 changed files with 1666 additions and 0 deletions

77
app/__init__.py Executable file
View File

@@ -0,0 +1,77 @@
from flask import Flask
from .models import db, login_manager, User, DocumentationTemplate, DocumentationEntry, TaskItem
from .routes import main_bp
from pathlib import Path
from datetime import date
def create_app():
app = Flask(__name__, instance_relative_config=True)
Path(app.instance_path).mkdir(parents=True, exist_ok=True)
app.config['SECRET_KEY'] = 'change-me-in-production'
app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{Path(app.instance_path) / 'notesmanager.db'}"
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'main.login'
app.register_blueprint(main_bp)
with app.app_context():
db.create_all()
seed_data()
return app
def seed_data():
if DocumentationTemplate.query.count() == 0:
templates = [
DocumentationTemplate(
name='Tagesabschluss',
description='Standardvorlage für den täglichen Abschluss.',
content=(
'## Tagesziel\n- \n\n'
'## Erledigte Arbeiten\n- \n\n'
'## Offene Punkte\n- \n\n'
'## Besonderheiten / Störungen\n- \n\n'
'## Übergabe\n- '
)
),
DocumentationTemplate(
name='Wartung / Änderung',
description='Vorlage für technische Änderungen oder Wartungen.',
content=(
'## Betroffenes System\n- \n\n'
'## Anlass\n- \n\n'
'## Durchgeführte Schritte\n1. \n2. \n\n'
'## Ergebnis\n- \n\n'
'## Rollback / Risiko\n- '
)
)
]
db.session.add_all(templates)
if TaskItem.query.count() == 0:
demo_tasks = [
TaskItem(title='Backup-Prüfung dokumentieren', description='Kontrolle des letzten Backup-Laufs eintragen.', due_date=date.today(), status='offen', priority='hoch'),
TaskItem(title='Tagesbericht erstellen', description='Wichtige Arbeiten des Tages zusammenfassen.', due_date=date.today(), status='in_bearbeitung', priority='mittel')
]
db.session.add_all(demo_tasks)
if DocumentationEntry.query.count() == 0:
db.session.add(DocumentationEntry(
title='Beispiel: Morgenroutine',
work_date=date.today(),
category='Betrieb',
system_name='Allgemein',
priority='mittel',
status='erledigt',
content='Server geprüft, Tickets gesichtet und Tagesprioritäten festgelegt.',
tags='betrieb,morgenroutine',
created_by='Administrator'
))
db.session.commit()

54
app/models.py Executable file
View File

@@ -0,0 +1,54 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin
from datetime import datetime
db = SQLAlchemy()
login_manager = LoginManager()
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
display_name = db.Column(db.String(120), nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(30), nullable=False, default='user')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class DocumentationEntry(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
work_date = db.Column(db.Date, nullable=False)
category = db.Column(db.String(100), nullable=False)
system_name = db.Column(db.String(120), nullable=True)
priority = db.Column(db.String(20), nullable=False, default='mittel')
status = db.Column(db.String(30), nullable=False, default='offen')
content = db.Column(db.Text, nullable=False)
tags = db.Column(db.String(255), nullable=True)
created_by = db.Column(db.String(120), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class DocumentationTemplate(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False, unique=True)
description = db.Column(db.String(255), nullable=True)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class TaskItem(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
due_date = db.Column(db.Date, nullable=True)
status = db.Column(db.String(30), nullable=False, default='offen')
priority = db.Column(db.String(20), nullable=False, default='mittel')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))

285
app/routes.py Executable file
View File

@@ -0,0 +1,285 @@
from datetime import datetime, date
from flask import Blueprint, render_template, request, redirect, url_for, flash, Response
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import check_password_hash, generate_password_hash
from .models import db, User, DocumentationEntry, DocumentationTemplate, TaskItem
main_bp = Blueprint('main', __name__)
def parse_date(value):
if not value:
return date.today()
return datetime.strptime(value, '%Y-%m-%d').date()
@main_bp.route('/')
def index():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
return redirect(url_for('main.login'))
@main_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
user = User.query.filter_by(username=username).first()
if user and check_password_hash(user.password_hash, password):
login_user(user)
flash('Anmeldung erfolgreich.', 'success')
return redirect(url_for('main.dashboard'))
flash('Ungültige Zugangsdaten.', 'danger')
return render_template('login.html')
@main_bp.route('/logout')
@login_required
def logout():
logout_user()
flash('Du wurdest abgemeldet.', 'info')
return redirect(url_for('main.login'))
@main_bp.route('/dashboard')
@login_required
def dashboard():
today = date.today()
entries_today = DocumentationEntry.query.filter_by(work_date=today).order_by(DocumentationEntry.created_at.desc()).all()
open_tasks = TaskItem.query.filter(TaskItem.status != 'erledigt').order_by(TaskItem.priority.desc(), TaskItem.due_date.asc()).all()
latest_entries = DocumentationEntry.query.order_by(DocumentationEntry.updated_at.desc()).limit(8).all()
stats = {
'entries_total': DocumentationEntry.query.count(),
'entries_today': len(entries_today),
'templates_total': DocumentationTemplate.query.count(),
'open_tasks': len(open_tasks),
}
return render_template('dashboard.html', today=today, entries_today=entries_today, open_tasks=open_tasks, latest_entries=latest_entries, stats=stats)
@main_bp.route('/entries')
@login_required
def entries():
q = request.args.get('q', '').strip()
status = request.args.get('status', '').strip()
category = request.args.get('category', '').strip()
query = DocumentationEntry.query
if q:
like = f'%{q}%'
query = query.filter(
db.or_(
DocumentationEntry.title.ilike(like),
DocumentationEntry.system_name.ilike(like),
DocumentationEntry.content.ilike(like),
DocumentationEntry.tags.ilike(like),
)
)
if status:
query = query.filter_by(status=status)
if category:
query = query.filter_by(category=category)
items = query.order_by(DocumentationEntry.work_date.desc(), DocumentationEntry.updated_at.desc()).all()
categories = [c[0] for c in db.session.query(DocumentationEntry.category).distinct().order_by(DocumentationEntry.category).all()]
return render_template('entries.html', items=items, q=q, status=status, category=category, categories=categories)
@main_bp.route('/entries/new', methods=['GET', 'POST'])
@login_required
def entry_new():
templates = DocumentationTemplate.query.order_by(DocumentationTemplate.name).all()
if request.method == 'POST':
entry = DocumentationEntry(
title=request.form.get('title', '').strip(),
work_date=parse_date(request.form.get('work_date')),
category=request.form.get('category', '').strip() or 'Allgemein',
system_name=request.form.get('system_name', '').strip(),
priority=request.form.get('priority', 'mittel').strip(),
status=request.form.get('status', 'offen').strip(),
content=request.form.get('content', '').strip(),
tags=request.form.get('tags', '').strip(),
created_by=current_user.display_name,
)
if not entry.title or not entry.content:
flash('Titel und Inhalt sind Pflichtfelder.', 'danger')
return render_template('entry_form.html', templates=templates, entry=None)
db.session.add(entry)
db.session.commit()
flash('Dokumentationseintrag wurde erstellt.', 'success')
return redirect(url_for('main.entries'))
return render_template('entry_form.html', templates=templates, entry=None)
@main_bp.route('/entries/<int:entry_id>/edit', methods=['GET', 'POST'])
@login_required
def entry_edit(entry_id):
entry = DocumentationEntry.query.get_or_404(entry_id)
templates = DocumentationTemplate.query.order_by(DocumentationTemplate.name).all()
if request.method == 'POST':
entry.title = request.form.get('title', '').strip()
entry.work_date = parse_date(request.form.get('work_date'))
entry.category = request.form.get('category', '').strip() or 'Allgemein'
entry.system_name = request.form.get('system_name', '').strip()
entry.priority = request.form.get('priority', 'mittel').strip()
entry.status = request.form.get('status', 'offen').strip()
entry.content = request.form.get('content', '').strip()
entry.tags = request.form.get('tags', '').strip()
if not entry.title or not entry.content:
flash('Titel und Inhalt sind Pflichtfelder.', 'danger')
return render_template('entry_form.html', templates=templates, entry=entry)
db.session.commit()
flash('Eintrag aktualisiert.', 'success')
return redirect(url_for('main.entries'))
return render_template('entry_form.html', templates=templates, entry=entry)
@main_bp.route('/entries/<int:entry_id>/delete', methods=['POST'])
@login_required
def entry_delete(entry_id):
entry = DocumentationEntry.query.get_or_404(entry_id)
db.session.delete(entry)
db.session.commit()
flash('Eintrag gelöscht.', 'info')
return redirect(url_for('main.entries'))
@main_bp.route('/entries/<int:entry_id>')
@login_required
def entry_view(entry_id):
entry = DocumentationEntry.query.get_or_404(entry_id)
return render_template('entry_view.html', entry=entry)
@main_bp.route('/templates', methods=['GET', 'POST'])
@login_required
def templates():
if request.method == 'POST':
name = request.form.get('name', '').strip()
content = request.form.get('content', '').strip()
description = request.form.get('description', '').strip()
if name and content:
db.session.add(DocumentationTemplate(name=name, description=description, content=content))
db.session.commit()
flash('Vorlage gespeichert.', 'success')
return redirect(url_for('main.templates'))
flash('Name und Inhalt sind Pflichtfelder.', 'danger')
items = DocumentationTemplate.query.order_by(DocumentationTemplate.name).all()
return render_template('templates.html', items=items)
@main_bp.route('/templates/<int:template_id>/delete', methods=['POST'])
@login_required
def template_delete(template_id):
item = DocumentationTemplate.query.get_or_404(template_id)
db.session.delete(item)
db.session.commit()
flash('Vorlage gelöscht.', 'info')
return redirect(url_for('main.templates'))
@main_bp.route('/tasks', methods=['GET', 'POST'])
@login_required
def tasks():
if request.method == 'POST':
task = TaskItem(
title=request.form.get('title', '').strip(),
description=request.form.get('description', '').strip(),
due_date=parse_date(request.form.get('due_date')) if request.form.get('due_date') else None,
status=request.form.get('status', 'offen').strip(),
priority=request.form.get('priority', 'mittel').strip(),
)
if not task.title:
flash('Titel ist ein Pflichtfeld.', 'danger')
else:
db.session.add(task)
db.session.commit()
flash('Aufgabe gespeichert.', 'success')
return redirect(url_for('main.tasks'))
items = TaskItem.query.order_by(TaskItem.status.asc(), TaskItem.priority.desc(), TaskItem.due_date.asc()).all()
return render_template('tasks.html', items=items)
@main_bp.route('/tasks/<int:task_id>/toggle', methods=['POST'])
@login_required
def task_toggle(task_id):
task = TaskItem.query.get_or_404(task_id)
task.status = 'erledigt' if task.status != 'erledigt' else 'offen'
db.session.commit()
flash('Aufgabenstatus geändert.', 'success')
return redirect(url_for('main.tasks'))
@main_bp.route('/tasks/<int:task_id>/delete', methods=['POST'])
@login_required
def task_delete(task_id):
task = TaskItem.query.get_or_404(task_id)
db.session.delete(task)
db.session.commit()
flash('Aufgabe gelöscht.', 'info')
return redirect(url_for('main.tasks'))
@main_bp.route('/users', methods=['GET', 'POST'])
@login_required
def users():
if current_user.role != 'admin':
flash('Nur Administratoren dürfen Benutzer verwalten.', 'danger')
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
username = request.form.get('username', '').strip()
display_name = request.form.get('display_name', '').strip()
password = request.form.get('password', '').strip()
role = request.form.get('role', 'user').strip()
if not username or not display_name or not password:
flash('Bitte alle Pflichtfelder füllen.', 'danger')
elif User.query.filter_by(username=username).first():
flash('Benutzername existiert bereits.', 'warning')
else:
db.session.add(User(
username=username,
display_name=display_name,
role=role,
password_hash=generate_password_hash(password)
))
db.session.commit()
flash('Benutzer angelegt.', 'success')
return redirect(url_for('main.users'))
items = User.query.order_by(User.username).all()
return render_template('users.html', items=items)
@main_bp.route('/export/markdown')
@login_required
def export_markdown():
items = DocumentationEntry.query.order_by(DocumentationEntry.work_date.desc(), DocumentationEntry.id.desc()).all()
lines = ['# Export Dokumentation', '']
for item in items:
lines.extend([
f'## {item.title}',
f'- Datum: {item.work_date.isoformat()}',
f'- Kategorie: {item.category}',
f'- System: {item.system_name or "-"}',
f'- Priorität: {item.priority}',
f'- Status: {item.status}',
f'- Tags: {item.tags or "-"}',
f'- Erstellt von: {item.created_by}',
'',
item.content,
'',
'---',
''
])
content = '\n'.join(lines)
return Response(
content,
mimetype='text/markdown',
headers={'Content-Disposition': 'attachment; filename=dokumentation_export.md'}
)

370
app/static/css/style.css Executable file
View File

@@ -0,0 +1,370 @@
/* ============================================================
NotesManager Modernes Grau-Design, barrierefrei (WCAG 2.1 AA)
============================================================ */
/* --- Farb-Variablen --- */
:root {
--nm-bg: #f0f2f5;
--nm-surface: #ffffff;
--nm-nav-bg: #1c1f26;
--nm-nav-hover: #2a2e38;
--nm-nav-active: #353a47;
--nm-nav-text: #c8cdd8;
--nm-nav-text-act: #ffffff;
--nm-accent: #4a6fa5;
--nm-accent-hover: #3a5a8f;
--nm-accent-light: #eaf0fa;
--nm-text: #1a1d23;
--nm-text-muted: #5a6070;
--nm-border: #d4d8e1;
--nm-shadow-sm: 0 1px 4px rgba(0,0,0,0.07), 0 2px 8px rgba(0,0,0,0.05);
--nm-shadow-md: 0 2px 8px rgba(0,0,0,0.09), 0 4px 16px rgba(0,0,0,0.07);
--nm-radius: 0.5rem;
--nm-radius-sm: 0.35rem;
--nm-transition: 0.18s ease;
}
/* --- Basis --- */
*, *::before, *::after { box-sizing: border-box; }
body {
background: var(--nm-bg);
color: var(--nm-text);
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 0.9375rem;
line-height: 1.6;
min-height: 100vh;
}
/* --- Barrierefreiheit: Fokus-Indikator --- */
:focus-visible {
outline: 3px solid var(--nm-accent);
outline-offset: 2px;
border-radius: var(--nm-radius-sm);
}
a:focus-visible, button:focus-visible, input:focus-visible,
select:focus-visible, textarea:focus-visible {
outline: 3px solid var(--nm-accent);
outline-offset: 2px;
}
/* --- Navigation --- */
.navbar {
background: var(--nm-nav-bg) !important;
border-bottom: 1px solid rgba(255,255,255,0.06);
padding: 0.65rem 1.25rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
}
.navbar-brand {
color: var(--nm-nav-text-act) !important;
font-weight: 700;
font-size: 1.1rem;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 0.5rem;
}
.navbar-brand::before {
content: "▪";
color: var(--nm-accent);
font-size: 1.3rem;
}
.nav-link {
color: var(--nm-nav-text) !important;
font-size: 0.875rem;
font-weight: 500;
padding: 0.45rem 0.8rem !important;
border-radius: var(--nm-radius-sm);
transition: background var(--nm-transition), color var(--nm-transition);
}
.nav-link:hover {
background: var(--nm-nav-hover);
color: var(--nm-nav-text-act) !important;
}
.nav-link.active {
background: var(--nm-nav-active);
color: var(--nm-nav-text-act) !important;
}
/* Navbar-User-Bereich */
.navbar .nm-user-info {
font-size: 0.8rem;
color: var(--nm-nav-text);
padding: 0.3rem 0.7rem;
background: rgba(255,255,255,0.06);
border-radius: var(--nm-radius-sm);
border: 1px solid rgba(255,255,255,0.1);
}
.btn-outline-light.btn-sm {
font-size: 0.8rem;
padding: 0.3rem 0.85rem;
border-color: rgba(255,255,255,0.3);
color: #fff;
transition: background var(--nm-transition), border-color var(--nm-transition);
}
.btn-outline-light.btn-sm:hover {
background: rgba(255,255,255,0.12);
border-color: rgba(255,255,255,0.5);
color: #fff;
}
/* --- Main-Content --- */
main.container {
max-width: 1200px;
padding-top: 1.75rem;
padding-bottom: 4rem;
}
/* --- Seitenüberschriften --- */
h1, h2, h3, h4, h5, h6 {
color: var(--nm-text);
font-weight: 600;
line-height: 1.3;
}
/* --- Cards --- */
.card {
background: var(--nm-surface);
border: 1px solid var(--nm-border) !important;
border-radius: var(--nm-radius) !important;
box-shadow: var(--nm-shadow-sm);
transition: box-shadow var(--nm-transition);
}
.card:hover {
box-shadow: var(--nm-shadow-md);
}
.card-body {
padding: 1.4rem 1.5rem;
}
/* --- Stat-Cards (Dashboard) --- */
.stat-card {
border-left: 3px solid var(--nm-accent) !important;
transition: transform var(--nm-transition), box-shadow var(--nm-transition);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--nm-shadow-md);
}
.stat-number {
font-size: 2.1rem;
font-weight: 700;
color: var(--nm-accent);
line-height: 1.1;
}
.stat-label {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--nm-text-muted);
margin-top: 0.2rem;
}
/* --- Buttons --- */
.btn {
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--nm-radius-sm);
transition: background var(--nm-transition), border-color var(--nm-transition),
transform var(--nm-transition), box-shadow var(--nm-transition);
}
.btn:active { transform: translateY(1px); }
.btn-primary {
background: var(--nm-accent);
border-color: var(--nm-accent);
color: #fff;
}
.btn-primary:hover, .btn-primary:focus {
background: var(--nm-accent-hover);
border-color: var(--nm-accent-hover);
color: #fff;
box-shadow: 0 2px 8px rgba(74,111,165,0.35);
}
.btn-outline-secondary {
color: var(--nm-text-muted);
border-color: var(--nm-border);
}
.btn-outline-secondary:hover {
background: #eef0f4;
color: var(--nm-text);
border-color: #c0c5d0;
}
.btn-outline-primary {
color: var(--nm-accent);
border-color: var(--nm-accent);
}
.btn-outline-primary:hover {
background: var(--nm-accent);
color: #fff;
}
.btn-danger {
background: #c0392b;
border-color: #a93226;
}
.btn-danger:hover { background: #a93226; border-color: #922b21; }
.btn-sm {
font-size: 0.8rem;
padding: 0.28rem 0.7rem;
}
/* --- Formularelemente --- */
.form-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--nm-text);
margin-bottom: 0.35rem;
}
.form-control, .form-select {
border: 1px solid var(--nm-border);
border-radius: var(--nm-radius-sm);
color: var(--nm-text);
background: #fff;
font-size: 0.9rem;
transition: border-color var(--nm-transition), box-shadow var(--nm-transition);
padding: 0.5rem 0.75rem;
}
.form-control:focus, .form-select:focus {
border-color: var(--nm-accent);
box-shadow: 0 0 0 3px rgba(74,111,165,0.18);
outline: none;
}
textarea.form-control { min-height: 9rem; resize: vertical; }
/* --- Tabellen --- */
.table {
color: var(--nm-text);
border-color: var(--nm-border);
font-size: 0.875rem;
}
.table > thead > tr > th {
background: #f4f6f9;
color: var(--nm-text-muted);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
border-bottom: 2px solid var(--nm-border);
padding: 0.75rem 1rem;
}
.table > tbody > tr > td {
padding: 0.75rem 1rem;
vertical-align: middle;
border-bottom: 1px solid #eef0f4;
}
.table-hover > tbody > tr:hover > td {
background: #f7f9fc;
}
/* --- Badges --- */
.badge {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.28em 0.65em;
border-radius: 0.25rem;
}
.text-bg-secondary {
background: #e4e7ed !important;
color: var(--nm-text) !important;
}
.text-bg-success { background: #d4edda !important; color: #1a5c35 !important; }
.text-bg-danger { background: #f8d7da !important; color: #7a1c1c !important; }
.text-bg-warning { background: #fff3cd !important; color: #664d03 !important; }
.text-bg-info { background: #d1ecf1 !important; color: #0c4a55 !important; }
.text-bg-primary { background: var(--nm-accent-light) !important; color: var(--nm-accent) !important; }
/* --- List-Group --- */
.list-group-item {
border-color: var(--nm-border);
color: var(--nm-text);
font-size: 0.875rem;
padding: 0.8rem 1rem;
transition: background var(--nm-transition);
}
.list-group-item-action:hover,
.list-group-item-action:focus {
background: #f4f6fa;
color: var(--nm-text);
}
.list-group-flush .list-group-item { border-left: 0; border-right: 0; }
/* --- Alerts --- */
.alert {
border-radius: var(--nm-radius-sm);
font-size: 0.875rem;
border-width: 1px;
}
.alert-success { background:#d4edda; color:#1a5c35; border-color:#b8dfc7; }
.alert-danger { background:#f8d7da; color:#7a1c1c; border-color:#f1b0b5; }
.alert-warning { background:#fff3cd; color:#664d03; border-color:#ffe69c; }
.alert-info { background:#d1ecf1; color:#0c4a55; border-color:#a8d8e1; }
/* --- Login-Seite --- */
.login-wrapper {
min-height: calc(100vh - 5rem);
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
width: 100%;
max-width: 400px;
}
.login-logo {
width: 2.5rem;
height: 2.5rem;
background: var(--nm-accent);
border-radius: var(--nm-radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
color: #fff;
font-weight: 800;
margin: 0 auto 1rem;
}
/* --- Text-Hilfklassen --- */
.text-muted { color: var(--nm-text-muted) !important; }
/* --- Seitenüberschrift-Bereich --- */
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.page-header h1 {
margin-bottom: 0.15rem;
font-size: 1.5rem;
}
/* --- Content pre (Dokumentation) --- */
.content-pre {
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
margin: 0;
color: var(--nm-text);
line-height: 1.7;
}
/* --- Trennlinie --- */
hr { border-color: var(--nm-border); opacity: 1; }
/* --- Scrollbar (Webkit) --- */
::-webkit-scrollbar { width: 7px; height: 7px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #c5c9d4; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #a8adb9; }

14
app/static/js/app.js Executable file
View File

@@ -0,0 +1,14 @@
document.addEventListener('DOMContentLoaded', () => {
const content = document.getElementById('content');
document.querySelectorAll('.template-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!content) return;
const tpl = btn.getAttribute('data-template');
if (content.value.trim() && !confirm('Vorhandenen Inhalt durch Vorlage ersetzen?')) {
return;
}
content.value = tpl;
content.focus();
});
});
});

54
app/templates/base.html Executable file
View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}NotesManager{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4" aria-label="Hauptnavigation">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">NotesManager</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Navigation umschalten">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
{% if current_user.is_authenticated %}
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.dashboard') }}">Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.entries') }}">Dokumentation</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.templates') }}">Vorlagen</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.tasks') }}">Aufgaben</a></li>
{% if current_user.role == 'admin' %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.users') }}">Benutzer</a></li>
{% endif %}
</ul>
<div class="d-flex align-items-center gap-2">
<span class="nm-user-info">{{ current_user.display_name }}</span>
<a class="btn btn-outline-light btn-sm" href="{{ url_for('main.logout') }}">Abmelden</a>
</div>
{% endif %}
</div>
</div>
</nav>
<main class="container pb-5">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Schließen"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>

82
app/templates/dashboard.html Executable file
View File

@@ -0,0 +1,82 @@
{% extends 'base.html' %}
{% block title %}Dashboard - NotesManager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h2 mb-1">Dashboard</h1>
<div class="text-muted">Stand: {{ today.strftime('%d.%m.%Y') }}</div>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('main.entry_new') }}" class="btn btn-primary">Neuer Eintrag</a>
<a href="{{ url_for('main.export_markdown') }}" class="btn btn-outline-secondary">Markdown-Export</a>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><div class="stat-number">{{ stats.entries_total }}</div><div class="text-muted">Einträge gesamt</div></div></div></div>
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><div class="stat-number">{{ stats.entries_today }}</div><div class="text-muted">Einträge heute</div></div></div></div>
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><div class="stat-number">{{ stats.templates_total }}</div><div class="text-muted">Vorlagen</div></div></div></div>
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><div class="stat-number">{{ stats.open_tasks }}</div><div class="text-muted">Offene Aufgaben</div></div></div></div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body">
<h2 class="h5 mb-3">Neueste Dokumentation</h2>
{% if latest_entries %}
<div class="list-group list-group-flush">
{% for item in latest_entries %}
<a href="{{ url_for('main.entry_view', entry_id=item.id) }}" class="list-group-item list-group-item-action">
<div class="d-flex justify-content-between flex-wrap gap-2">
<strong>{{ item.title }}</strong>
<span class="badge text-bg-secondary">{{ item.work_date.strftime('%d.%m.%Y') }}</span>
</div>
<div class="small text-muted">{{ item.category }} · {{ item.system_name or 'kein System' }} · {{ item.status }}</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-muted">Noch keine Einträge vorhanden.</p>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card shadow-sm mb-4">
<div class="card-body">
<h2 class="h5 mb-3">Heutige Einträge</h2>
{% if entries_today %}
<ul class="list-group list-group-flush">
{% for item in entries_today %}
<li class="list-group-item px-0">
<strong>{{ item.title }}</strong><br>
<span class="small text-muted">{{ item.category }} · {{ item.priority }} · {{ item.created_by }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted mb-0">Heute gibt es noch keine Einträge.</p>
{% endif %}
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<h2 class="h5 mb-3">Offene Aufgaben</h2>
{% if open_tasks %}
<ul class="list-group list-group-flush">
{% for task in open_tasks[:6] %}
<li class="list-group-item px-0">
<strong>{{ task.title }}</strong><br>
<span class="small text-muted">{{ task.priority }}{% if task.due_date %} · Fällig {{ task.due_date.strftime('%d.%m.%Y') }}{% endif %}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted mb-0">Keine offenen Aufgaben.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

75
app/templates/entries.html Executable file
View File

@@ -0,0 +1,75 @@
{% extends 'base.html' %}
{% block title %}Dokumentation - NotesManager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h1 class="h2 mb-0">Dokumentation</h1>
<a href="{{ url_for('main.entry_new') }}" class="btn btn-primary">Neuer Eintrag</a>
</div>
<form class="card card-body shadow-sm mb-4" method="get">
<div class="row g-3">
<div class="col-md-5">
<label class="form-label">Suche</label>
<input name="q" value="{{ q }}" class="form-control" placeholder="Titel, Inhalt, Tags, System ...">
</div>
<div class="col-md-3">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="">Alle</option>
{% for s in ['offen', 'in_bearbeitung', 'erledigt'] %}
<option value="{{ s }}" {% if status == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Kategorie</label>
<select name="category" class="form-select">
<option value="">Alle</option>
{% for c in categories %}
<option value="{{ c }}" {% if category == c %}selected{% endif %}>{{ c }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-1 d-flex align-items-end">
<button class="btn btn-outline-secondary w-100">Filter</button>
</div>
</div>
</form>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Datum</th>
<th>Titel</th>
<th>Kategorie</th>
<th>System</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.work_date.strftime('%d.%m.%Y') }}</td>
<td><strong>{{ item.title }}</strong><div class="small text-muted">{{ item.tags or '' }}</div></td>
<td>{{ item.category }}</td>
<td>{{ item.system_name or '-' }}</td>
<td><span class="badge text-bg-light border">{{ item.status }}</span></td>
<td class="text-nowrap">
<a href="{{ url_for('main.entry_view', entry_id=item.id) }}" class="btn btn-sm btn-outline-primary">Ansehen</a>
<a href="{{ url_for('main.entry_edit', entry_id=item.id) }}" class="btn btn-sm btn-outline-secondary">Bearbeiten</a>
<form method="post" action="{{ url_for('main.entry_delete', entry_id=item.id) }}" class="d-inline" onsubmit="return confirm('Eintrag wirklich löschen?');">
<button class="btn btn-sm btn-outline-danger">Löschen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">Keine Einträge gefunden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

77
app/templates/entry_form.html Executable file
View File

@@ -0,0 +1,77 @@
{% extends 'base.html' %}
{% block title %}Eintrag {% if entry %}bearbeiten{% else %}anlegen{% endif %} - NotesManager{% endblock %}
{% block content %}
<h1 class="h2 mb-4">{% if entry %}Eintrag bearbeiten{% else %}Neuen Eintrag anlegen{% endif %}</h1>
<div class="row g-4">
<div class="col-lg-8">
<form method="post" class="card shadow-sm">
<div class="card-body">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">Titel</label>
<input name="title" class="form-control" required value="{{ entry.title if entry else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">Datum</label>
<input type="date" name="work_date" class="form-control" value="{{ entry.work_date.isoformat() if entry else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">Kategorie</label>
<input name="category" class="form-control" value="{{ entry.category if entry else '' }}" placeholder="z. B. Betrieb">
</div>
<div class="col-md-4">
<label class="form-label">System / Bereich</label>
<input name="system_name" class="form-control" value="{{ entry.system_name if entry else '' }}" placeholder="Server, Kunde, Projekt ...">
</div>
<div class="col-md-2">
<label class="form-label">Priorität</label>
<select name="priority" class="form-select">
{% for p in ['niedrig', 'mittel', 'hoch'] %}
<option value="{{ p }}" {% if (entry and entry.priority == p) or (not entry and p == 'mittel') %}selected{% endif %}>{{ p }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label">Status</label>
<select name="status" class="form-select">
{% for s in ['offen', 'in_bearbeitung', 'erledigt'] %}
<option value="{{ s }}" {% if (entry and entry.status == s) or (not entry and s == 'offen') %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<label class="form-label">Tags</label>
<input name="tags" class="form-control" value="{{ entry.tags if entry else '' }}" placeholder="z. B. backup, kunde-a, patchday">
</div>
<div class="col-12">
<label class="form-label">Inhalt</label>
<textarea id="content" name="content" rows="16" class="form-control" required>{{ entry.content if entry else '' }}</textarea>
</div>
</div>
</div>
<div class="card-footer d-flex gap-2 justify-content-end">
<a href="{{ url_for('main.entries') }}" class="btn btn-outline-secondary">Abbrechen</a>
<button class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
<div class="col-lg-4">
<div class="card shadow-sm">
<div class="card-body">
<h2 class="h5">Vorlagen</h2>
<p class="text-muted small">Klicke auf eine Vorlage, um den Inhalt in das Formular zu übernehmen.</p>
<div class="list-group">
{% for t in templates %}
<button type="button" class="list-group-item list-group-item-action template-btn" data-template="{{ t.content | e }}">
<strong>{{ t.name }}</strong>
<div class="small text-muted">{{ t.description }}</div>
</button>
{% else %}
<div class="text-muted">Keine Vorlagen vorhanden.</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

25
app/templates/entry_view.html Executable file
View File

@@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% block title %}{{ entry.title }} - NotesManager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h2 mb-1">{{ entry.title }}</h1>
<div class="text-muted">{{ entry.work_date.strftime('%d.%m.%Y') }} · {{ entry.category }} · {{ entry.system_name or 'kein System' }}</div>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('main.entry_edit', entry_id=entry.id) }}" class="btn btn-outline-secondary">Bearbeiten</a>
<a href="{{ url_for('main.entries') }}" class="btn btn-primary">Zur Übersicht</a>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="mb-3 small text-muted">
<strong>Status:</strong> {{ entry.status }} ·
<strong>Priorität:</strong> {{ entry.priority }} ·
<strong>Tags:</strong> {{ entry.tags or '-' }} ·
<strong>Von:</strong> {{ entry.created_by }}
</div>
<pre class="content-pre">{{ entry.content }}</pre>
</div>
</div>
{% endblock %}

26
app/templates/login.html Executable file
View File

@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block title %}Anmelden NotesManager{% endblock %}
{% block content %}
<div class="login-wrapper">
<div class="login-card">
<div class="card">
<div class="card-body p-4 p-sm-5">
<div class="login-logo" aria-hidden="true">N</div>
<h1 class="h4 text-center mb-1">NotesManager</h1>
<p class="text-muted text-center small mb-4">Bitte melde dich an, um fortzufahren.</p>
<form method="post" novalidate>
<div class="mb-3">
<label for="username" class="form-label">Benutzername</label>
<input id="username" name="username" class="form-control" autocomplete="username" required autofocus>
</div>
<div class="mb-4">
<label for="password" class="form-label">Passwort</label>
<input id="password" name="password" type="password" class="form-control" autocomplete="current-password" required>
</div>
<button class="btn btn-primary w-100" type="submit">Anmelden</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

76
app/templates/tasks.html Executable file
View File

@@ -0,0 +1,76 @@
{% extends 'base.html' %}
{% block title %}Aufgaben - NotesManager{% endblock %}
{% block content %}
<div class="row g-4">
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Neue Aufgabe</h1>
<form method="post">
<div class="mb-3">
<label class="form-label">Titel</label>
<input name="title" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Beschreibung</label>
<textarea name="description" class="form-control" rows="4"></textarea>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Fällig am</label>
<input type="date" name="due_date" class="form-control">
</div>
<div class="col-md-3">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="offen">offen</option>
<option value="in_bearbeitung">in_bearbeitung</option>
<option value="erledigt">erledigt</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Priorität</label>
<select name="priority" class="form-select">
<option value="niedrig">niedrig</option>
<option value="mittel" selected>mittel</option>
<option value="hoch">hoch</option>
</select>
</div>
</div>
<button class="btn btn-primary">Speichern</button>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Aufgabenliste</h1>
<div class="list-group">
{% for item in items %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<strong>{{ item.title }}</strong>
<div class="small text-muted">{{ item.description or '' }}</div>
<div class="small text-muted mt-1">{{ item.status }} · {{ item.priority }}{% if item.due_date %} · {{ item.due_date.strftime('%d.%m.%Y') }}{% endif %}</div>
</div>
<div class="d-flex gap-2">
<form method="post" action="{{ url_for('main.task_toggle', task_id=item.id) }}">
<button class="btn btn-sm btn-outline-success">Umschalten</button>
</form>
<form method="post" action="{{ url_for('main.task_delete', task_id=item.id) }}" onsubmit="return confirm('Aufgabe wirklich löschen?');">
<button class="btn btn-sm btn-outline-danger">Löschen</button>
</form>
</div>
</div>
</div>
{% else %}
<p class="text-muted">Noch keine Aufgaben vorhanden.</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

57
app/templates/templates.html Executable file
View File

@@ -0,0 +1,57 @@
{% extends 'base.html' %}
{% block title %}Vorlagen - NotesManager{% endblock %}
{% block content %}
<div class="row g-4">
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Neue Vorlage</h1>
<form method="post">
<div class="mb-3">
<label class="form-label">Name</label>
<input name="name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Beschreibung</label>
<input name="description" class="form-control">
</div>
<div class="mb-3">
<label class="form-label">Inhalt</label>
<textarea name="content" rows="12" class="form-control" required></textarea>
</div>
<button class="btn btn-primary">Speichern</button>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Vorhandene Vorlagen</h1>
<div class="accordion" id="templateList">
{% for item in items %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#tpl{{ item.id }}">
{{ item.name }}
</button>
</h2>
<div id="tpl{{ item.id }}" class="accordion-collapse collapse" data-bs-parent="#templateList">
<div class="accordion-body">
<div class="small text-muted mb-2">{{ item.description }}</div>
<pre class="content-pre">{{ item.content }}</pre>
<form method="post" action="{{ url_for('main.template_delete', template_id=item.id) }}" onsubmit="return confirm('Vorlage wirklich löschen?');">
<button class="btn btn-sm btn-outline-danger">Löschen</button>
</form>
</div>
</div>
</div>
{% else %}
<p class="text-muted">Noch keine Vorlagen vorhanden.</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

59
app/templates/users.html Executable file
View File

@@ -0,0 +1,59 @@
{% extends 'base.html' %}
{% block title %}Benutzer - NotesManager{% endblock %}
{% block content %}
<div class="row g-4">
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Benutzer anlegen</h1>
<form method="post">
<div class="mb-3">
<label class="form-label">Benutzername</label>
<input name="username" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Anzeigename</label>
<input name="display_name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Passwort</label>
<input type="password" name="password" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Rolle</label>
<select name="role" class="form-select">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
<button class="btn btn-primary">Benutzer speichern</button>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Vorhandene Benutzer</h1>
<div class="table-responsive">
<table class="table align-middle">
<thead><tr><th>Benutzername</th><th>Anzeigename</th><th>Rolle</th><th>Erstellt</th></tr></thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.username }}</td>
<td>{{ item.display_name }}</td>
<td>{{ item.role }}</td>
<td>{{ item.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-muted">Keine Benutzer vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}