From 90ac30bba3ce7c751244aa308bb8d5b843818250 Mon Sep 17 00:00:00 2001
From: Patrick Jentsch <p.jentsch@uni-bielefeld.de>
Date: Mon, 13 Mar 2023 16:22:42 +0100
Subject: [PATCH] Implement Flask-Breadcrumbs

---
 app/__init__.py                               |   7 +
 app/auth/routes.py                            |  15 +-
 app/contributions/__init__.py                 |  14 +-
 app/contributions/forms.py                    |  81 +++++++-
 app/contributions/json_routes.py              | 107 ++++++++++
 app/contributions/routes.py                   | 183 +++++++++++++++++-
 .../spacy_nlp_pipeline_models/__init__.py     |   8 -
 .../spacy_nlp_pipeline_models/forms.py        |  44 -----
 .../spacy_nlp_pipeline_models/json_routes.py  |  54 ------
 .../spacy_nlp_pipeline_models/routes.py       |  76 --------
 .../tesseract_ocr_pipeline_models/__init__.py |   8 -
 .../tesseract_ocr_pipeline_models/forms.py    |  35 ----
 .../json_routes.py                            |  54 ------
 .../tesseract_ocr_pipeline_models/routes.py   |  75 -------
 app/corpora/routes.py                         |  21 +-
 app/main/routes.py                            |   9 +
 app/services/routes.py                        |  17 +-
 app/templates/_navbar.html.j2                 |   8 +-
 app/templates/_sidenav.html.j2                |   2 +-
 app/templates/admin/_breadcrumbs.html.j2      |  18 --
 app/templates/admin/edit_user.html.j2         |   1 -
 app/templates/admin/user.html.j2              |   1 -
 app/templates/admin/users.html.j2             |   1 -
 app/templates/auth/_breadcrumbs.html.j2       |  14 --
 app/templates/auth/login.html.j2              |   1 -
 app/templates/auth/register.html.j2           |   1 -
 app/templates/auth/reset_password.html.j2     |   1 -
 .../auth/reset_password_request.html.j2       |   1 -
 app/templates/auth/unconfirmed.html.j2        |   1 -
 .../contributions/_breadcrumbs.html.j2        |  46 -----
 .../contributions/contributions.html.j2       |   4 +-
 .../create_spacy_nlp_pipeline_model.html.j2   |   0
 ...reate_tesseract_ocr_pipeline_model.html.j2 |   0
 .../spacy_nlp_pipeline_model.html.j2          |   0
 .../spacy_nlp_pipeline_models.html.j2         |   0
 .../tesseract_ocr_pipeline_model.html.j2      |   0
 .../tesseract_ocr_pipeline_models.html.j2     |   0
 app/templates/corpora/_breadcrumbs.html.j2    |  28 ---
 app/templates/corpora/corpus.html.j2          |   1 -
 app/templates/corpora/create_corpus.html.j2   |   1 -
 .../corpora/files/corpus_file.html.j2         |   1 -
 .../corpora/files/create_corpus_file.html.j2  |   1 -
 app/templates/corpora/import_corpus.html.j2   |   1 -
 app/templates/jobs/_breadcrumbs.html.j2       |   8 -
 app/templates/jobs/job.html.j2                |   1 -
 app/templates/main/_breadcrumbs.html.j2       |  14 --
 app/templates/main/dashboard.html.j2          |   1 -
 app/templates/main/faq.html.j2                |   1 -
 app/templates/main/index.html.j2              |   1 -
 app/templates/main/news.html.j2               |   1 -
 app/templates/main/news_new.html.j2           |   1 -
 app/templates/main/privacy_policy.html.j2     |   1 -
 app/templates/main/terms_of_use.html.j2       |   1 -
 app/templates/main/user_manual.html.j2        |   1 -
 app/templates/services/_breadcrumbs.html.j2   |  16 --
 .../services/corpus_analysis.html.j2          |   1 -
 .../services/file_setup_pipeline.html.j2      |   1 -
 .../services/spacy_nlp_pipeline.html.j2       |   3 +-
 .../services/tesseract_ocr_pipeline.html.j2   |   3 +-
 .../services/transkribus_htr_pipeline.html.j2 |   1 -
 app/templates/settings/_breadcrumbs.html.j2   |   6 -
 app/templates/settings/settings.html.j2       |   1 -
 requirements.txt                              |   1 +
 63 files changed, 425 insertions(+), 580 deletions(-)
 create mode 100644 app/contributions/json_routes.py
 delete mode 100644 app/contributions/spacy_nlp_pipeline_models/__init__.py
 delete mode 100644 app/contributions/spacy_nlp_pipeline_models/forms.py
 delete mode 100644 app/contributions/spacy_nlp_pipeline_models/json_routes.py
 delete mode 100644 app/contributions/spacy_nlp_pipeline_models/routes.py
 delete mode 100644 app/contributions/tesseract_ocr_pipeline_models/__init__.py
 delete mode 100644 app/contributions/tesseract_ocr_pipeline_models/forms.py
 delete mode 100644 app/contributions/tesseract_ocr_pipeline_models/json_routes.py
 delete mode 100644 app/contributions/tesseract_ocr_pipeline_models/routes.py
 delete mode 100644 app/templates/admin/_breadcrumbs.html.j2
 delete mode 100644 app/templates/auth/_breadcrumbs.html.j2
 delete mode 100644 app/templates/contributions/_breadcrumbs.html.j2
 rename app/templates/contributions/{spacy_nlp_pipeline_models => }/create_spacy_nlp_pipeline_model.html.j2 (100%)
 rename app/templates/contributions/{tesseract_ocr_pipeline_models => }/create_tesseract_ocr_pipeline_model.html.j2 (100%)
 rename app/templates/contributions/{spacy_nlp_pipeline_models => }/spacy_nlp_pipeline_model.html.j2 (100%)
 rename app/templates/contributions/{spacy_nlp_pipeline_models => }/spacy_nlp_pipeline_models.html.j2 (100%)
 rename app/templates/contributions/{tesseract_ocr_pipeline_models => }/tesseract_ocr_pipeline_model.html.j2 (100%)
 rename app/templates/contributions/{tesseract_ocr_pipeline_models => }/tesseract_ocr_pipeline_models.html.j2 (100%)
 delete mode 100644 app/templates/corpora/_breadcrumbs.html.j2
 delete mode 100644 app/templates/jobs/_breadcrumbs.html.j2
 delete mode 100644 app/templates/main/_breadcrumbs.html.j2
 delete mode 100644 app/templates/services/_breadcrumbs.html.j2
 delete mode 100644 app/templates/settings/_breadcrumbs.html.j2

diff --git a/app/__init__.py b/app/__init__.py
index dcb58e47..59d6d5c9 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -4,6 +4,7 @@ from docker import DockerClient
 from flask import Flask
 from flask_apscheduler import APScheduler
 from flask_assets import Environment
+from flask_breadcrumbs import Breadcrumbs, default_breadcrumb_root
 from flask_login import LoginManager
 from flask_mail import Mail
 from flask_marshmallow import Marshmallow
@@ -16,6 +17,7 @@ from flask_hashids import Hashids
 
 apifairy = APIFairy()
 assets = Environment()
+breadcrumbs = Breadcrumbs()
 db = SQLAlchemy()
 docker_client = DockerClient()
 hashids = Hashids()
@@ -44,6 +46,7 @@ def create_app(config: Config = Config) -> Flask:
 
     apifairy.init_app(app)
     assets.init_app(app)
+    breadcrumbs.init_app(app)
     db.init_app(app)
     hashids.init_app(app)
     login.init_app(app)
@@ -64,9 +67,11 @@ def create_app(config: Config = Config) -> Flask:
     app.register_blueprint(api_blueprint, url_prefix='/api')
 
     from .auth import bp as auth_blueprint
+    default_breadcrumb_root(auth_blueprint, '.')
     app.register_blueprint(auth_blueprint, url_prefix='/auth')
 
     from .contributions import bp as contributions_blueprint
+    default_breadcrumb_root(contributions_blueprint, '.contributions')
     app.register_blueprint(contributions_blueprint, url_prefix='/contributions')
 
     from .corpora import bp as corpora_blueprint
@@ -76,9 +81,11 @@ def create_app(config: Config = Config) -> Flask:
     app.register_blueprint(jobs_blueprint, url_prefix='/jobs')
 
     from .main import bp as main_blueprint
+    default_breadcrumb_root(main_blueprint, '.')
     app.register_blueprint(main_blueprint, url_prefix='/')
 
     from .services import bp as services_blueprint
+    default_breadcrumb_root(services_blueprint, '.services')
     app.register_blueprint(services_blueprint, url_prefix='/services')
 
     from .settings import bp as settings_blueprint
diff --git a/app/auth/routes.py b/app/auth/routes.py
index 5655d0dc..6e11a140 100644
--- a/app/auth/routes.py
+++ b/app/auth/routes.py
@@ -1,11 +1,5 @@
-from flask import (
-    abort,
-    flash,
-    redirect,
-    render_template,
-    request,
-    url_for
-)
+from flask import abort, flash, redirect, render_template, request, url_for
+from flask_breadcrumbs import register_breadcrumb
 from flask_login import current_user, login_user, login_required, logout_user
 from app import db
 from app.email import create_message, send
@@ -36,6 +30,7 @@ def before_request():
 
 
 @bp.route('/register', methods=['GET', 'POST'])
+@register_breadcrumb(bp, '.register', 'Register')
 def register():
     if current_user.is_authenticated:
         return redirect(url_for('main.dashboard'))
@@ -71,6 +66,7 @@ def register():
 
 
 @bp.route('/login', methods=['GET', 'POST'])
+@register_breadcrumb(bp, '.login', 'Login')
 def login():
     if current_user.is_authenticated:
         return redirect(url_for('main.dashboard'))
@@ -97,6 +93,7 @@ def logout():
 
 
 @bp.route('/unconfirmed')
+@register_breadcrumb(bp, '.unconfirmed', 'Unconfirmed')
 @login_required
 def unconfirmed():
     if current_user.confirmed:
@@ -136,6 +133,7 @@ def confirm(token):
 
 
 @bp.route('/reset_password', methods=['GET', 'POST'])
+@register_breadcrumb(bp, '.reset_password_request', 'Password Reset')
 def reset_password_request():
     if current_user.is_authenticated:
         return redirect(url_for('main.dashboard'))
@@ -165,6 +163,7 @@ def reset_password_request():
 
 
 @bp.route('/reset_password/<token>', methods=['GET', 'POST'])
+@register_breadcrumb(bp, '.reset_password', 'Password Reset')
 def reset_password(token):
     if current_user.is_authenticated:
         return redirect(url_for('main.dashboard'))
diff --git a/app/contributions/__init__.py b/app/contributions/__init__.py
index 5175c0ce..7749a278 100644
--- a/app/contributions/__init__.py
+++ b/app/contributions/__init__.py
@@ -2,16 +2,4 @@ from flask import Blueprint
 
 
 bp = Blueprint('contributions', __name__)
-from . import routes
-
-from .spacy_nlp_pipeline_models import bp as spacy_nlp_pipeline_models_bp
-bp.register_blueprint(
-    spacy_nlp_pipeline_models_bp,
-    url_prefix='/spacy-nlp-pipeline-models'
-)
-
-from .tesseract_ocr_pipeline_models import bp as tesseract_ocr_pipeline_models_bp
-bp.register_blueprint(
-    tesseract_ocr_pipeline_models_bp,
-    url_prefix='/tesseract-ocr-pipeline-models'
-)
+from . import json_routes, routes
diff --git a/app/contributions/forms.py b/app/contributions/forms.py
index acec307f..1ef4fdc7 100644
--- a/app/contributions/forms.py
+++ b/app/contributions/forms.py
@@ -1,11 +1,14 @@
 from flask_wtf import FlaskForm
+from flask_wtf.file import FileField, FileRequired
 from wtforms import (
     StringField,
     SubmitField,
     SelectMultipleField,
-    IntegerField
+    IntegerField,
+    ValidationError
 )
 from wtforms.validators import InputRequired, Length
+from app.services import SERVICES
 
 
 class ContributionBaseForm(FlaskForm):
@@ -45,3 +48,79 @@ class ContributionBaseForm(FlaskForm):
 
 class EditContributionBaseForm(ContributionBaseForm):
     pass
+
+
+##############################################################################
+# /spacy-nlp-pipeline-models                                                 #
+##############################################################################
+class CreateSpaCyNLPPipelineModelForm(ContributionBaseForm):
+    spacy_model_file = FileField(
+        'File',
+        validators=[FileRequired()]
+    )
+    pipeline_name = StringField(
+        'Pipeline name',
+        validators=[InputRequired(), Length(max=64)]
+    )
+
+    def validate_spacy_model_file(self, field):
+        if not field.data.filename.lower().endswith('.tar.gz'):
+            raise ValidationError('.tar.gz files only!')
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        service_manifest = SERVICES['spacy-nlp-pipeline']
+        self.compatible_service_versions.choices = [('', 'Choose your option')]
+        self.compatible_service_versions.choices += [
+            (x, x) for x in service_manifest['versions'].keys()
+        ]
+        self.compatible_service_versions.default = ''
+
+
+class EditSpaCyNLPPipelineModelForm(EditContributionBaseForm):
+    pipeline_name = StringField(
+        'Pipeline name',
+        validators=[InputRequired(), Length(max=64)]
+    )
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        service_manifest = SERVICES['spacy-nlp-pipeline']
+        self.compatible_service_versions.choices = [('', 'Choose your option')]
+        self.compatible_service_versions.choices += [
+            (x, x) for x in service_manifest['versions'].keys()
+        ]
+        self.compatible_service_versions.default = ''
+
+
+##############################################################################
+# /tesseract-ocr-pipeline-models                                             #
+##############################################################################
+class CreateTesseractOCRPipelineModelForm(ContributionBaseForm):
+    tesseract_model_file = FileField(
+        'File',
+        validators=[FileRequired()]
+    )
+    
+    def validate_tesseract_model_file(self, field):
+        if not field.data.filename.lower().endswith('.traineddata'):
+            raise ValidationError('traineddata files only!')
+
+    def __init__(self, *args, **kwargs):
+        service_manifest = SERVICES['tesseract-ocr-pipeline']
+        super().__init__(*args, **kwargs)
+        self.compatible_service_versions.choices = [('', 'Choose your option')]
+        self.compatible_service_versions.choices += [
+            (x, x) for x in service_manifest['versions'].keys()
+        ]
+        self.compatible_service_versions.default = ''
+
+
+class EditTesseractOCRPipelineModelForm(EditContributionBaseForm):
+    def __init__(self, *args, **kwargs):
+        service_manifest = SERVICES['tesseract-ocr-pipeline']
+        super().__init__(*args, **kwargs)
+        self.compatible_service_versions.choices = [('', 'Choose your option')]
+        self.compatible_service_versions.choices += [
+            (x, x) for x in service_manifest['versions'].keys()
+        ]
+        self.compatible_service_versions.default = ''
diff --git a/app/contributions/json_routes.py b/app/contributions/json_routes.py
new file mode 100644
index 00000000..c44a4c9c
--- /dev/null
+++ b/app/contributions/json_routes.py
@@ -0,0 +1,107 @@
+from flask import abort, current_app, request
+from flask_login import login_required, current_user
+from threading import Thread
+from app import db
+from app.decorators import content_negotiation, permission_required
+from app.models import SpaCyNLPPipelineModel, TesseractOCRPipelineModel
+from . import bp
+
+
+##############################################################################
+# /spacy-nlp-pipeline-models                                                 #
+##############################################################################
+@bp.route('/spacy-nlp-pipeline-models<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
+@login_required
+@content_negotiation(produces='application/json')
+def delete_spacy_model(spacy_nlp_pipeline_model_id):
+    def _delete_spacy_model(app, spacy_nlp_pipeline_model_id):
+        with app.app_context():
+            snpm = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
+            snpm.delete()
+            db.session.commit()
+    
+    snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
+    if not (snpm.user == current_user or current_user.is_administrator()):
+        abort(403)
+    thread = Thread(
+        target=_delete_spacy_model,
+        args=(current_app._get_current_object(), snpm.id)
+    )
+    thread.start()
+    resonse_data = {
+        'message': \
+            f'SpaCy NLP Pipeline Model "{snpm.title}" marked for deletion'
+    }
+    return resonse_data, 202
+
+
+@bp.route('/spacy-nlp-pipeline-models<hashid:spacy_nlp_pipeline_model_id>/is_public', methods=['PUT'])
+@login_required
+@permission_required('CONTRIBUTE')
+@content_negotiation(consumes='application/json', produces='application/json')
+def update_spacy_nlp_pipeline_model_is_public(spacy_nlp_pipeline_model_id):
+    is_public = request.json
+    if not isinstance(is_public, bool):
+        abort(400)
+    snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
+    if not (snpm.user == current_user or current_user.is_administrator()):
+        abort(403)
+    snpm.is_public = is_public
+    db.session.commit()
+    response_data = {
+        'message': (
+            f'SpaCy NLP Pipeline Model "{snpm.title}"'
+            f' is now {"public" if is_public else "private"}'
+        )
+    }
+    return response_data, 200
+
+
+##############################################################################
+# /tesseract-ocr-pipeline-models                                             #
+##############################################################################
+@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
+@login_required
+@content_negotiation(produces='application/json')
+def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
+    def _delete_tesseract_ocr_pipeline_model(app, tesseract_ocr_pipeline_model_id):
+        with app.app_context():
+            topm = TesseractOCRPipelineModel.query.get(tesseract_ocr_pipeline_model_id)
+            topm.delete()
+            db.session.commit()
+
+    topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
+    if not (topm.user == current_user or current_user.is_administrator()):
+        abort(403)
+    thread = Thread(
+        target=_delete_tesseract_ocr_pipeline_model,
+        args=(current_app._get_current_object(), topm.id)
+    )
+    thread.start()
+    response_data = {
+        'message': \
+            f'Tesseract OCR Pipeline Model "{topm.title}" marked for deletion'
+    }
+    return response_data, 202
+
+
+@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>/is_public', methods=['PUT'])
+@login_required
+@permission_required('CONTRIBUTE')
+@content_negotiation(consumes='application/json', produces='application/json')
+def update_tesseract_ocr_pipeline_model_is_public(tesseract_ocr_pipeline_model_id):
+    is_public = request.json
+    if not isinstance(is_public, bool):
+        abort(400)
+    topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
+    if not (topm.user == current_user or current_user.is_administrator()):
+        abort(403)
+    topm.is_public = is_public
+    db.session.commit()
+    response_data = {
+        'message': (
+            f'Tesseract OCR Pipeline Model "{topm.title}"'
+            f' is now {"public" if is_public else "private"}'
+        )
+    }
+    return response_data, 200
diff --git a/app/contributions/routes.py b/app/contributions/routes.py
index 6d8b9cc3..6da7dc12 100644
--- a/app/contributions/routes.py
+++ b/app/contributions/routes.py
@@ -1,12 +1,189 @@
-from flask import render_template
-from flask_login import login_required
+from flask import abort, flash, redirect, render_template, request, url_for
+from flask_breadcrumbs import register_breadcrumb
+from flask_login import current_user, login_required
+from app import db
+from app.models import SpaCyNLPPipelineModel, TesseractOCRPipelineModel
 from . import bp
+from .forms import (
+    CreateSpaCyNLPPipelineModelForm,
+    EditSpaCyNLPPipelineModelForm,
+    CreateTesseractOCRPipelineModelForm,
+    EditTesseractOCRPipelineModelForm
+)
 
 
-@bp.route('/')
+@bp.route('')
+@register_breadcrumb(bp, '.', 'Contributions')
 @login_required
 def contributions():
     return render_template(
         'contributions/contributions.html.j2',
         title='Contributions'
     )
+
+
+##############################################################################
+# /spacy-nlp-pipeline-models                                                 #
+##############################################################################
+@bp.route('/spacy-nlp-pipeline-models')
+@register_breadcrumb(bp, '.spacy_nlp_pipeline_models', 'SpaCy NLP Pipeline Models')
+@login_required
+def spacy_nlp_pipeline_models():
+    return render_template(
+        'contributions/spacy_nlp_pipeline_models.html.j2',
+        title='SpaCy NLP Pipeline Models'
+    )
+
+
+@bp.route('/spacy-nlp-pipeline-models/create', methods=['GET', 'POST'])
+@register_breadcrumb(bp, '.spacy_nlp_pipeline_models.create', 'Create')
+@login_required
+def create_spacy_nlp_pipeline_model():
+    form_prefix = 'create-spacy-nlp-pipeline-model-form'
+    form = CreateSpaCyNLPPipelineModelForm(prefix=form_prefix)
+    if form.is_submitted():
+        if not form.validate():
+            return {'errors': form.errors}, 400
+        try:
+            snpm = SpaCyNLPPipelineModel.create(
+                form.spacy_model_file.data,
+                compatible_service_versions=form.compatible_service_versions.data,
+                description=form.description.data,
+                pipeline_name=form.pipeline_name.data,
+                publisher=form.publisher.data,
+                publisher_url=form.publisher_url.data,
+                publishing_url=form.publishing_url.data,
+                publishing_year=form.publishing_year.data,
+                is_public=False,
+                title=form.title.data,
+                version=form.version.data,
+                user=current_user
+            )
+        except OSError:
+            abort(500)
+        db.session.commit()
+        flash(f'SpaCy NLP Pipeline model "{snpm.title}" created')
+        return {}, 201, {'Location': url_for('.spacy_nlp_pipeline_models')}
+    return render_template(
+        'contributions/create_spacy_nlp_pipeline_model.html.j2',
+        form=form,
+        title='Create SpaCy NLP Pipeline Model'
+    )
+
+
+def spacy_nlp_pipeline_model_dlc(*args, **kwargs):
+    snpm_id = request.view_args['spacy_nlp_pipeline_model_id']
+    snpm = SpaCyNLPPipelineModel.query.get(snpm_id)
+    return [
+        {
+            'text': f'{snpm.title} {snpm.version}',
+            'url': url_for('.spacy_nlp_pipeline_model', spacy_nlp_pipeline_model_id=snpm_id)
+        }
+    ]
+
+
+@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
+@register_breadcrumb(bp, '.spacy_nlp_pipeline_models.entity', '', dynamic_list_constructor=spacy_nlp_pipeline_model_dlc)
+@login_required
+def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id):
+    snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
+    form_prefix = 'edit-spacy-nlp-pipeline-model-form'
+    form = EditSpaCyNLPPipelineModelForm(
+        data=snpm.to_json_serializeable(),
+        prefix=form_prefix
+    )
+    if form.validate_on_submit():
+        form.populate_obj(snpm)
+        if db.session.is_modified(snpm):
+            flash(f'SpaCy NLP Pipeline model "{snpm.title}" updated')
+            db.session.commit()
+        return redirect(url_for('.spacy_nlp_pipeline_models'))
+    return render_template(
+        'contributions/spacy_nlp_pipeline_model.html.j2',
+        form=form,
+        spacy_nlp_pipeline_model=snpm,
+        title=f'{snpm.title} {snpm.version}'
+    )
+
+
+##############################################################################
+# /tesseract-ocr-pipeline-models                                             #
+##############################################################################
+@bp.route('/tesseract-ocr-pipeline-models')
+@register_breadcrumb(bp, '.tesseract_ocr_pipeline_models', 'Tesseract OCR Pipeline Models')
+@login_required
+def tesseract_ocr_pipeline_models():
+    return render_template(
+        'contributions/tesseract_ocr_pipeline_models.html.j2',
+        title='Tesseract OCR Pipeline Models'
+    )
+
+
+@bp.route('/tesseract-ocr-pipeline-models/create', methods=['GET', 'POST'])
+@register_breadcrumb(bp, '.tesseract_ocr_pipeline_models.create', 'Create')
+@login_required
+def create_tesseract_ocr_pipeline_model():
+    form_prefix = 'create-tesseract-ocr-pipeline-model-form'
+    form = CreateTesseractOCRPipelineModelForm(prefix=form_prefix)
+    if form.is_submitted():
+        if not form.validate():
+            return {'errors': form.errors}, 400
+        try:
+            topm = TesseractOCRPipelineModel.create(
+                form.tesseract_model_file.data,
+                compatible_service_versions=form.compatible_service_versions.data,
+                description=form.description.data,
+                publisher=form.publisher.data,
+                publisher_url=form.publisher_url.data,
+                publishing_url=form.publishing_url.data,
+                publishing_year=form.publishing_year.data,
+                is_public=False,
+                title=form.title.data,
+                version=form.version.data,
+                user=current_user
+            )
+        except OSError:
+            abort(500)
+        db.session.commit()
+        flash(f'Tesseract OCR Pipeline model "{topm.title}" created')
+        return {}, 201, {'Location': url_for('.tesseract_ocr_pipeline_models')}
+    return render_template(
+        'contributions/create_tesseract_ocr_pipeline_model.html.j2',
+        form=form,
+        title='Create Tesseract OCR Pipeline Model'
+    )
+
+
+def tesseract_ocr_pipeline_model_dlc(*args, **kwargs):
+    topm_id = request.view_args['tesseract_ocr_pipeline_model_id']
+    topm = TesseractOCRPipelineModel.query.get(topm_id)
+    return [
+        {
+            'text': f'{topm.title} {topm.version}',
+            'url': url_for('.tesseract_ocr_pipeline_model', tesseract_ocr_pipeline_model_id=topm_id)
+        }
+    ]
+
+
+@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
+@register_breadcrumb(bp, '.tesseract_ocr_pipeline_models.entity', '', dynamic_list_constructor=tesseract_ocr_pipeline_model_dlc)
+@login_required
+def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id):
+    topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
+    form_prefix = 'edit-tesseract-ocr-pipeline-model-form'
+    form = EditTesseractOCRPipelineModelForm(
+        data=topm.to_json_serializeable(),
+        prefix=form_prefix
+    )
+    if form.validate_on_submit():
+        form.populate_obj(topm)
+        if db.session.is_modified(topm):
+            flash(f'Tesseract OCR Pipeline model "{topm.title}" updated')
+            db.session.commit()
+        return redirect(url_for('.tesseract_ocr_pipeline_models'))
+    return render_template(
+        'contributions/tesseract_ocr_pipeline_model.html.j2',
+        form=form,
+        tesseract_ocr_pipeline_model=topm,
+        title=f'{topm.title} {topm.version}'
+    )
diff --git a/app/contributions/spacy_nlp_pipeline_models/__init__.py b/app/contributions/spacy_nlp_pipeline_models/__init__.py
deleted file mode 100644
index 8ff119d0..00000000
--- a/app/contributions/spacy_nlp_pipeline_models/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from flask import Blueprint
-
-
-template_base_dir = 'contributions/spacy_nlp_pipeline_models'
-
-
-bp = Blueprint('spacy_nlp_pipeline_models', __name__)
-from . import routes, json_routes
diff --git a/app/contributions/spacy_nlp_pipeline_models/forms.py b/app/contributions/spacy_nlp_pipeline_models/forms.py
deleted file mode 100644
index 2670c1d1..00000000
--- a/app/contributions/spacy_nlp_pipeline_models/forms.py
+++ /dev/null
@@ -1,44 +0,0 @@
-from flask_wtf.file import FileField, FileRequired
-from wtforms import StringField, ValidationError
-from wtforms.validators import InputRequired, Length
-from app.services import SERVICES
-from ..forms import ContributionBaseForm, EditContributionBaseForm
-
-
-class CreateSpaCyNLPPipelineModelForm(ContributionBaseForm):
-    spacy_model_file = FileField(
-        'File',
-        validators=[FileRequired()]
-    )
-    pipeline_name = StringField(
-        'Pipeline name',
-        validators=[InputRequired(), Length(max=64)]
-    )
-
-    def validate_spacy_model_file(self, field):
-        if not field.data.filename.lower().endswith('.tar.gz'):
-            raise ValidationError('.tar.gz files only!')
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        service_manifest = SERVICES['spacy-nlp-pipeline']
-        self.compatible_service_versions.choices = [('', 'Choose your option')]
-        self.compatible_service_versions.choices += [
-            (x, x) for x in service_manifest['versions'].keys()
-        ]
-        self.compatible_service_versions.default = ''
-
-
-class EditSpaCyNLPPipelineModelForm(EditContributionBaseForm):
-    pipeline_name = StringField(
-        'Pipeline name',
-        validators=[InputRequired(), Length(max=64)]
-    )
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        service_manifest = SERVICES['spacy-nlp-pipeline']
-        self.compatible_service_versions.choices = [('', 'Choose your option')]
-        self.compatible_service_versions.choices += [
-            (x, x) for x in service_manifest['versions'].keys()
-        ]
-        self.compatible_service_versions.default = ''
diff --git a/app/contributions/spacy_nlp_pipeline_models/json_routes.py b/app/contributions/spacy_nlp_pipeline_models/json_routes.py
deleted file mode 100644
index f7a9a254..00000000
--- a/app/contributions/spacy_nlp_pipeline_models/json_routes.py
+++ /dev/null
@@ -1,54 +0,0 @@
-from flask import abort, current_app, request
-from flask_login import login_required, current_user
-from threading import Thread
-from app import db
-from app.decorators import content_negotiation, permission_required
-from app.models import SpaCyNLPPipelineModel
-from . import bp
-
-
-@bp.route('/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
-@login_required
-@content_negotiation(produces='application/json')
-def delete_spacy_model(spacy_nlp_pipeline_model_id):
-    def _delete_spacy_model(app, spacy_nlp_pipeline_model_id):
-        with app.app_context():
-            snpm = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
-            snpm.delete()
-            db.session.commit()
-    
-    snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
-    if not (snpm.user == current_user or current_user.is_administrator()):
-        abort(403)
-    thread = Thread(
-        target=_delete_spacy_model,
-        args=(current_app._get_current_object(), snpm.id)
-    )
-    thread.start()
-    resonse_data = {
-        'message': \
-            f'SpaCy NLP Pipeline Model "{snpm.title}" marked for deletion'
-    }
-    return resonse_data, 202
-
-
-@bp.route('/<hashid:spacy_nlp_pipeline_model_id>/is_public', methods=['PUT'])
-@login_required
-@permission_required('CONTRIBUTE')
-@content_negotiation(consumes='application/json', produces='application/json')
-def update_spacy_nlp_pipeline_model_is_public(spacy_nlp_pipeline_model_id):
-    is_public = request.json
-    if not isinstance(is_public, bool):
-        abort(400)
-    snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
-    if not (snpm.user == current_user or current_user.is_administrator()):
-        abort(403)
-    snpm.is_public = is_public
-    db.session.commit()
-    response_data = {
-        'message': (
-            f'SpaCy NLP Pipeline Model "{snpm.title}"'
-            f' is now {"public" if is_public else "private"}'
-        )
-    }
-    return response_data, 200
diff --git a/app/contributions/spacy_nlp_pipeline_models/routes.py b/app/contributions/spacy_nlp_pipeline_models/routes.py
deleted file mode 100644
index fd972902..00000000
--- a/app/contributions/spacy_nlp_pipeline_models/routes.py
+++ /dev/null
@@ -1,76 +0,0 @@
-from flask import abort, flash, redirect, render_template, url_for
-from flask_login import current_user, login_required
-from app import db
-from app.models import SpaCyNLPPipelineModel
-from . import bp, template_base_dir
-from .forms import (
-    CreateSpaCyNLPPipelineModelForm,
-    EditSpaCyNLPPipelineModelForm
-)
-
-
-@bp.route('')
-@login_required
-def spacy_nlp_pipeline_models():
-    return render_template(
-        f'{template_base_dir}/spacy_nlp_pipeline_models.html.j2',
-        title='SpaCy NLP Pipeline Models'
-    )
-
-
-@bp.route('/create', methods=['GET', 'POST'])
-@login_required
-def create_spacy_nlp_pipeline_model():
-    form_prefix = 'create-spacy-nlp-pipeline-model-form'
-    form = CreateSpaCyNLPPipelineModelForm(prefix=form_prefix)
-    if form.is_submitted():
-        if not form.validate():
-            return {'errors': form.errors}, 400
-        try:
-            snpm = SpaCyNLPPipelineModel.create(
-                form.spacy_model_file.data,
-                compatible_service_versions=form.compatible_service_versions.data,
-                description=form.description.data,
-                pipeline_name=form.pipeline_name.data,
-                publisher=form.publisher.data,
-                publisher_url=form.publisher_url.data,
-                publishing_url=form.publishing_url.data,
-                publishing_year=form.publishing_year.data,
-                is_public=False,
-                title=form.title.data,
-                version=form.version.data,
-                user=current_user
-            )
-        except OSError:
-            abort(500)
-        db.session.commit()
-        flash(f'SpaCy NLP Pipeline model "{snpm.title}" created')
-        return {}, 201, {'Location': url_for('.spacy_nlp_pipeline_models')}
-    return render_template(
-        f'{template_base_dir}/create_spacy_nlp_pipeline_model.html.j2',
-        form=form,
-        title='Create SpaCy NLP Pipeline Model'
-    )
-
-
-@bp.route('/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
-@login_required
-def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id):
-    snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
-    form_prefix = 'edit-spacy-nlp-pipeline-model-form'
-    form = EditSpaCyNLPPipelineModelForm(
-        data=snpm.to_json_serializeable(),
-        prefix=form_prefix
-    )
-    if form.validate_on_submit():
-        form.populate_obj(snpm)
-        if db.session.is_modified(snpm):
-            flash(f'SpaCy NLP Pipeline model "{snpm.title}" updated')
-            db.session.commit()
-        return redirect(url_for('.spacy_nlp_pipeline_models'))
-    return render_template(
-        f'{template_base_dir}/spacy_nlp_pipeline_model.html.j2',
-        form=form,
-        spacy_nlp_pipeline_model=snpm,
-        title=f'{snpm.title} {snpm.version}'
-    )
diff --git a/app/contributions/tesseract_ocr_pipeline_models/__init__.py b/app/contributions/tesseract_ocr_pipeline_models/__init__.py
deleted file mode 100644
index cf44126d..00000000
--- a/app/contributions/tesseract_ocr_pipeline_models/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from flask import Blueprint
-
-
-template_base_dir = 'contributions/tesseract_ocr_pipeline_models'
-
-
-bp = Blueprint('tesseract_ocr_pipeline_models', __name__)
-from . import routes, json_routes
diff --git a/app/contributions/tesseract_ocr_pipeline_models/forms.py b/app/contributions/tesseract_ocr_pipeline_models/forms.py
deleted file mode 100644
index 51f0d76c..00000000
--- a/app/contributions/tesseract_ocr_pipeline_models/forms.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from flask_wtf.file import FileField, FileRequired
-from wtforms import ValidationError
-from app.services import SERVICES
-from ..forms import ContributionBaseForm, EditContributionBaseForm
-
-
-class CreateTesseractOCRPipelineModelForm(ContributionBaseForm):
-    tesseract_model_file = FileField(
-        'File',
-        validators=[FileRequired()]
-    )
-    
-    def validate_tesseract_model_file(self, field):
-        if not field.data.filename.lower().endswith('.traineddata'):
-            raise ValidationError('traineddata files only!')
-
-    def __init__(self, *args, **kwargs):
-        service_manifest = SERVICES['tesseract-ocr-pipeline']
-        super().__init__(*args, **kwargs)
-        self.compatible_service_versions.choices = [('', 'Choose your option')]
-        self.compatible_service_versions.choices += [
-            (x, x) for x in service_manifest['versions'].keys()
-        ]
-        self.compatible_service_versions.default = ''
-
-
-class EditTesseractOCRPipelineModelForm(EditContributionBaseForm):
-    def __init__(self, *args, **kwargs):
-        service_manifest = SERVICES['tesseract-ocr-pipeline']
-        super().__init__(*args, **kwargs)
-        self.compatible_service_versions.choices = [('', 'Choose your option')]
-        self.compatible_service_versions.choices += [
-            (x, x) for x in service_manifest['versions'].keys()
-        ]
-        self.compatible_service_versions.default = ''
diff --git a/app/contributions/tesseract_ocr_pipeline_models/json_routes.py b/app/contributions/tesseract_ocr_pipeline_models/json_routes.py
deleted file mode 100644
index 81aa6598..00000000
--- a/app/contributions/tesseract_ocr_pipeline_models/json_routes.py
+++ /dev/null
@@ -1,54 +0,0 @@
-from flask import abort, current_app, request
-from flask_login import login_required, current_user
-from threading import Thread
-from app import db
-from app.decorators import content_negotiation, permission_required
-from app.models import TesseractOCRPipelineModel
-from . import bp
-
-
-@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
-@login_required
-@content_negotiation(produces='application/json')
-def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
-    def _delete_tesseract_ocr_pipeline_model(app, tesseract_ocr_pipeline_model_id):
-        with app.app_context():
-            topm = TesseractOCRPipelineModel.query.get(tesseract_ocr_pipeline_model_id)
-            topm.delete()
-            db.session.commit()
-
-    topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
-    if not (topm.user == current_user or current_user.is_administrator()):
-        abort(403)
-    thread = Thread(
-        target=_delete_tesseract_ocr_pipeline_model,
-        args=(current_app._get_current_object(), topm.id)
-    )
-    thread.start()
-    response_data = {
-        'message': \
-            f'Tesseract OCR Pipeline Model "{topm.title}" marked for deletion'
-    }
-    return response_data, 202
-
-
-@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>/is_public', methods=['PUT'])
-@login_required
-@permission_required('CONTRIBUTE')
-@content_negotiation(consumes='application/json', produces='application/json')
-def update_tesseract_ocr_pipeline_model_is_public(tesseract_ocr_pipeline_model_id):
-    is_public = request.json
-    if not isinstance(is_public, bool):
-        abort(400)
-    topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
-    if not (topm.user == current_user or current_user.is_administrator()):
-        abort(403)
-    topm.is_public = is_public
-    db.session.commit()
-    response_data = {
-        'message': (
-            f'Tesseract OCR Pipeline Model "{topm.title}"'
-            f' is now {"public" if is_public else "private"}'
-        )
-    }
-    return response_data, 200
diff --git a/app/contributions/tesseract_ocr_pipeline_models/routes.py b/app/contributions/tesseract_ocr_pipeline_models/routes.py
deleted file mode 100644
index b888cef7..00000000
--- a/app/contributions/tesseract_ocr_pipeline_models/routes.py
+++ /dev/null
@@ -1,75 +0,0 @@
-from flask import abort, flash, redirect, render_template, url_for
-from flask_login import login_required, current_user
-from app import db
-from app.models import TesseractOCRPipelineModel
-from . import bp, template_base_dir
-from .forms import (
-    CreateTesseractOCRPipelineModelForm,
-    EditTesseractOCRPipelineModelForm
-)
-
-
-@bp.route('')
-@login_required
-def tesseract_ocr_pipeline_models():
-    return render_template(
-        f'{template_base_dir}/tesseract_ocr_pipeline_models.html.j2',
-        title='Tesseract OCR Pipeline Models'
-    )
-
-
-@bp.route('/create', methods=['GET', 'POST'])
-@login_required
-def create_tesseract_ocr_pipeline_model():
-    form_prefix = 'create-tesseract-ocr-pipeline-model-form'
-    form = CreateTesseractOCRPipelineModelForm(prefix=form_prefix)
-    if form.is_submitted():
-        if not form.validate():
-            return {'errors': form.errors}, 400
-        try:
-            topm = TesseractOCRPipelineModel.create(
-                form.tesseract_model_file.data,
-                compatible_service_versions=form.compatible_service_versions.data,
-                description=form.description.data,
-                publisher=form.publisher.data,
-                publisher_url=form.publisher_url.data,
-                publishing_url=form.publishing_url.data,
-                publishing_year=form.publishing_year.data,
-                is_public=False,
-                title=form.title.data,
-                version=form.version.data,
-                user=current_user
-            )
-        except OSError:
-            abort(500)
-        db.session.commit()
-        flash(f'Tesseract OCR Pipeline model "{topm.title}" created')
-        return {}, 201, {'Location': url_for('.tesseract_ocr_pipeline_models')}
-    return render_template(
-        f'{template_base_dir}/create_tesseract_ocr_pipeline_model.html.j2',
-        form=form,
-        title='Create Tesseract OCR Pipeline Model'
-    )
-
-
-@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
-@login_required
-def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id):
-    topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
-    form_prefix = 'edit-tesseract-ocr-pipeline-model-form'
-    form = EditTesseractOCRPipelineModelForm(
-        data=topm.to_json_serializeable(),
-        prefix=form_prefix
-    )
-    if form.validate_on_submit():
-        form.populate_obj(topm)
-        if db.session.is_modified(topm):
-            flash(f'Tesseract OCR Pipeline model "{topm.title}" updated')
-            db.session.commit()
-        return redirect(url_for('.tesseract_ocr_pipeline_models'))
-    return render_template(
-        f'{template_base_dir}/tesseract_ocr_pipeline_model.html.j2',
-        form=form,
-        tesseract_ocr_pipeline_model=topm,
-        title=f'{topm.title} {topm.version}'
-    )
diff --git a/app/corpora/routes.py b/app/corpora/routes.py
index 5484fb50..82701cea 100644
--- a/app/corpora/routes.py
+++ b/app/corpora/routes.py
@@ -1,23 +1,18 @@
-from flask import (
-    abort,
-    flash,
-    Markup,
-    redirect,
-    render_template,
-    url_for
-)
+from flask import abort, flash, redirect, render_template, url_for
 from flask_login import current_user, login_required
 from .decorators import corpus_follower_permission_required
 from app import db
-from app.models import (
-    Corpus,
-    CorpusFollowerAssociation,
-    CorpusFollowerRole,
-)
+from app.models import Corpus, CorpusFollowerAssociation, CorpusFollowerRole
 from . import bp
 from .forms import CreateCorpusForm
 
 
+@bp.route('')
+@login_required
+def corpora():
+    return redirect(url_for('main.dashboard', _anchor='corpora'))
+
+
 @bp.route('/create', methods=['GET', 'POST'])
 @login_required
 def create_corpus():
diff --git a/app/main/routes.py b/app/main/routes.py
index 918f7414..5399b5e9 100644
--- a/app/main/routes.py
+++ b/app/main/routes.py
@@ -1,4 +1,5 @@
 from flask import flash, redirect, render_template, url_for
+from flask_breadcrumbs import register_breadcrumb
 from flask_login import current_user, login_required, login_user
 from app.auth.forms import LoginForm
 from app.models import Corpus, User
@@ -6,6 +7,7 @@ from . import bp
 
 
 @bp.route('', methods=['GET', 'POST'])
+@register_breadcrumb(bp, '.', '<i class="material-icons">home</i>')
 def index():
     form = LoginForm(prefix='login-form')
     if form.validate_on_submit():
@@ -20,37 +22,44 @@ def index():
 
 
 @bp.route('/faq')
+@register_breadcrumb(bp, '.faq', 'Frequently Asked Questions')
 def faq():
     return render_template('main/faq.html.j2', title='Frequently Asked Questions')
 
 
 @bp.route('/dashboard')
+@register_breadcrumb(bp, '.dashboard', 'Dashboard')
 @login_required
 def dashboard():
     return render_template('main/dashboard.html.j2', title='Dashboard')
 
 
 @bp.route('/user_manual')
+@register_breadcrumb(bp, '.user_manual', 'User manual')
 def user_manual():
     return render_template('main/user_manual.html.j2', title='User manual')
 
 
 @bp.route('/news')
+@register_breadcrumb(bp, '.news', 'News')
 def news():
     return render_template('main/news.html.j2', title='News')
 
 
 @bp.route('/privacy_policy')
+@register_breadcrumb(bp, '.privacy_policy', 'Private statement (GDPR)')
 def privacy_policy():
     return render_template('main/privacy_policy.html.j2', title='Privacy statement (GDPR)')
 
 
 @bp.route('/terms_of_use')
+@register_breadcrumb(bp, '.terms_of_use', 'Terms of Use')
 def terms_of_use():
     return render_template('main/terms_of_use.html.j2', title='Terms of Use')
 
 
 @bp.route('/social-area')
+@register_breadcrumb(bp, '.social_area', 'Social Area')
 def social_area():
     users = [
         u.to_json_serializeable(relationships=True, filter_by_privacy_settings=True,) for u
diff --git a/app/services/routes.py b/app/services/routes.py
index 0fb4b168..5d5a69a0 100644
--- a/app/services/routes.py
+++ b/app/services/routes.py
@@ -1,4 +1,5 @@
-from flask import abort, current_app, flash, make_response, Markup, render_template, request
+from flask import abort, current_app, flash, Markup, redirect, render_template, request, url_for
+from flask_breadcrumbs import register_breadcrumb
 from flask_login import current_user, login_required
 import requests
 from app import db, hashids
@@ -18,7 +19,15 @@ from .forms import (
 )
 
 
+@bp.route('/services')
+@register_breadcrumb(bp, '.', 'Services')
+@login_required
+def services():
+    return redirect(url_for('main.dashboard'))
+
+
 @bp.route('/file-setup-pipeline', methods=['GET', 'POST'])
+@register_breadcrumb(bp, '.file_setup_pipeline', 'File Setup')
 @login_required
 def file_setup_pipeline():
     service = 'file-setup-pipeline'
@@ -60,6 +69,7 @@ def file_setup_pipeline():
 
 
 @bp.route('/tesseract-ocr-pipeline', methods=['GET', 'POST'])
+@register_breadcrumb(bp, '.tesseract_ocr_pipeline', 'Tesseract OCR Pipeline')
 @login_required
 def tesseract_ocr_pipeline():
     service_name = 'tesseract-ocr-pipeline'
@@ -109,6 +119,7 @@ def tesseract_ocr_pipeline():
 
 
 @bp.route('/transkribus-htr-pipeline', methods=['GET', 'POST'])
+@register_breadcrumb(bp, '.transkribus_htr_pipeline', 'Transkribus HTR Pipeline')
 @login_required
 def transkribus_htr_pipeline():
     if not current_app.config.get('NOPAQUE_TRANSKRIBUS_ENABLED'):
@@ -168,6 +179,7 @@ def transkribus_htr_pipeline():
 
 
 @bp.route('/spacy-nlp-pipeline', methods=['GET', 'POST'])
+@register_breadcrumb(bp, '.spacy_nlp_pipeline', 'SpaCy NLP Pipeline')
 @login_required
 def spacy_nlp_pipeline():
     service = 'spacy-nlp-pipeline'
@@ -213,9 +225,10 @@ def spacy_nlp_pipeline():
 
 
 @bp.route('/corpus-analysis')
+@register_breadcrumb(bp, '.corpus_analysis', 'Corpus Analysis')
 @login_required
 def corpus_analysis():
     return render_template(
         'services/corpus_analysis.html.j2',
-        title='Corpus analysis'
+        title='Corpus Analysis'
     )
diff --git a/app/templates/_navbar.html.j2 b/app/templates/_navbar.html.j2
index d943c0e4..2790ef1a 100644
--- a/app/templates/_navbar.html.j2
+++ b/app/templates/_navbar.html.j2
@@ -14,10 +14,12 @@
     </div>
     <div class="nav-content primary-variant-color">
       <ul class="tabs tabs-transparent">
-        <li class="tab"><a href="{{ url_for('main.index') }}" target="_self"><i class="material-icons">home</i></a></li>
-        {% if breadcrumbs is defined %}
-        {{ breadcrumbs }}
+        {%- for breadcrumb in breadcrumbs -%}
+        <li class="tab"><a {{ 'class="active"' if loop.last }} href="{{ breadcrumb.url }}" target="_self">{{ breadcrumb.text }}</a></li>
+        {% if not loop.last %}
+        <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
         {% endif %}
+        {%- endfor -%}
       </ul>
       {% if current_user.is_authenticated %}
       <a class="btn-floating btn-large halfway-fab modal-trigger pink tooltipped waves-effect waves-light" data-tooltip="Roadmap" href="#roadmap-modal"><i class="material-icons">explore</i></a>
diff --git a/app/templates/_sidenav.html.j2 b/app/templates/_sidenav.html.j2
index 0027772e..fbaeddbe 100644
--- a/app/templates/_sidenav.html.j2
+++ b/app/templates/_sidenav.html.j2
@@ -35,7 +35,7 @@
   <li class="service-color service-color-border border-darken" data-service="transkribus-htr-pipeline" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.transkribus_htr_pipeline') }}"><i class="nopaque-icons service-icons" data-service="transkribus-htr-pipeline"></i>HTR</a></li>
   {% endif %}
   <li class="service-color service-color-border border-darken" data-service="spacy-nlp-pipeline" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.spacy_nlp_pipeline') }}"><i class="nopaque-icons service-icons" data-service="spacy-nlp-pipeline"></i>NLP</a></li>
-  <li class="service-color service-color-border border-darken" data-service="corpus-analysis" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.corpus_analysis') }}"><i class="nopaque-icons service-icons" data-service="corpus-analysis"></i>Corpus analysis</a></li>
+  <li class="service-color service-color-border border-darken" data-service="corpus-analysis" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.corpus_analysis') }}"><i class="nopaque-icons service-icons" data-service="corpus-analysis"></i>Corpus Analysis</a></li>
   <li><div class="divider"></div></li>
   <li><a class="subheader">Account</a></li>
   <li><a href="{{ url_for('settings.settings') }}"><i class="material-icons">settings</i>General Settings</a></li>
diff --git a/app/templates/admin/_breadcrumbs.html.j2 b/app/templates/admin/_breadcrumbs.html.j2
deleted file mode 100644
index c4a64046..00000000
--- a/app/templates/admin/_breadcrumbs.html.j2
+++ /dev/null
@@ -1,18 +0,0 @@
-{% set breadcrumbs %}
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a href="{{ url_for('.index') }}" target="_self">Administration</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-{% if request.path == url_for('.users') %}
-<li class="tab"><a class="active" href="{{ url_for('.users') }}" target="_self">Users</a></li>
-{% elif request.path == url_for('.user', user_id=user.id) %}
-<li class="tab"><a href="{{ url_for('.users') }}" target="_self">Users</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a class="active" href="{{ url_for('.user', user_id=user.id) }}" target="_self">{{ user.username }}</a></li>
-{% elif request.path == url_for('.edit_user', user_id=user.id) %}
-<li class="tab"><a href="{{ url_for('.users') }}" target="_self">Users</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a href="{{ url_for('.user', user_id=user.id) }}" target="_self">{{ user.username }}</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a class="active" href="{{ url_for('.edit_user', user_id=user.id) }}" target="_self">Edit</a></li>
-{% endif %}
-{% endset %}
diff --git a/app/templates/admin/edit_user.html.j2 b/app/templates/admin/edit_user.html.j2
index c963b7f7..0ac4f27d 100644
--- a/app/templates/admin/edit_user.html.j2
+++ b/app/templates/admin/edit_user.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "admin/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 {% block page_content %}
diff --git a/app/templates/admin/user.html.j2 b/app/templates/admin/user.html.j2
index b4b0e303..aa4899ce 100644
--- a/app/templates/admin/user.html.j2
+++ b/app/templates/admin/user.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "admin/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block page_content %}
 <div class="container">
diff --git a/app/templates/admin/users.html.j2 b/app/templates/admin/users.html.j2
index c96024cf..25b27c95 100644
--- a/app/templates/admin/users.html.j2
+++ b/app/templates/admin/users.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "admin/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block page_content %}
 <div class="container">
diff --git a/app/templates/auth/_breadcrumbs.html.j2 b/app/templates/auth/_breadcrumbs.html.j2
deleted file mode 100644
index 2f46d9dc..00000000
--- a/app/templates/auth/_breadcrumbs.html.j2
+++ /dev/null
@@ -1,14 +0,0 @@
-{% set breadcrumbs %}
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-{% if request.path == url_for('.login') %}
-<li class="tab"><a class="active" href="{{ url_for('.login') }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.register') %}
-<li class="tab"><a class="active" href="{{ url_for('.register') }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.reset_password', token=token) %}
-<li class="tab"><a class="active" href="{{ url_for('.reset_password', token=token) }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.reset_password_request') %}
-<li class="tab"><a class="active" href="{{ url_for('.reset_password_request') }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.unconfirmed') %}
-<li class="tab"><a class="active" href="{{ url_for('.unconfirmed') }}" target="_self">{{ title }}</a></li>
-{% endif %}
-{% endset %}
diff --git a/app/templates/auth/login.html.j2 b/app/templates/auth/login.html.j2
index 213c0a5f..7aa402f6 100644
--- a/app/templates/auth/login.html.j2
+++ b/app/templates/auth/login.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "auth/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 
diff --git a/app/templates/auth/register.html.j2 b/app/templates/auth/register.html.j2
index 69a01912..d8e023a7 100644
--- a/app/templates/auth/register.html.j2
+++ b/app/templates/auth/register.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "auth/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 
diff --git a/app/templates/auth/reset_password.html.j2 b/app/templates/auth/reset_password.html.j2
index 06f11059..7df460bb 100644
--- a/app/templates/auth/reset_password.html.j2
+++ b/app/templates/auth/reset_password.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "auth/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 {% block page_content %}
diff --git a/app/templates/auth/reset_password_request.html.j2 b/app/templates/auth/reset_password_request.html.j2
index a94d18da..5bf28111 100644
--- a/app/templates/auth/reset_password_request.html.j2
+++ b/app/templates/auth/reset_password_request.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "auth/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 {% block page_content %}
diff --git a/app/templates/auth/unconfirmed.html.j2 b/app/templates/auth/unconfirmed.html.j2
index 26384ecc..f927fcff 100644
--- a/app/templates/auth/unconfirmed.html.j2
+++ b/app/templates/auth/unconfirmed.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "auth/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block page_content %}
 <div class="container">
diff --git a/app/templates/contributions/_breadcrumbs.html.j2 b/app/templates/contributions/_breadcrumbs.html.j2
deleted file mode 100644
index ab64dec4..00000000
--- a/app/templates/contributions/_breadcrumbs.html.j2
+++ /dev/null
@@ -1,46 +0,0 @@
-{% set breadcrumbs %}
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-{% if request.path == url_for('.contributions') %}
-<li class="tab"><a class="active" href="{{ url_for('.contributions') }}" target="_self">Contributions Overview</a></li>
-{% elif request.path == url_for('.tesseract_ocr_pipeline_models')%}
-<li class="tab"><a href="{{ url_for('.contributions') }}" target="_self">Contributions Overview</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a class="active" href="{{ url_for('.tesseract_ocr_pipeline_models') }}" target="_self">Tesseract OCR Pipeline Models</a></li>
-{% elif request.path == url_for('.spacy_nlp_pipeline_models')%}
-<li class="tab"><a href="{{ url_for('.contributions') }}" target="_self">Contributions Overview</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a class="active" href="{{ url_for('.spacy_nlp_pipeline_models') }}" target="_self">SpaCy NLP Pipeline Models</a></li>
-{% elif request.path == url_for('.create_tesseract_ocr_pipeline_model') %}
-<li class="tab"><a href="{{ url_for('.contributions') }}" target="_self">Contributions Overview</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a href="{{ url_for('.tesseract_ocr_pipeline_models') }}" target="_self">Tesseract OCR Pipeline Models</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a class="active" href="{{ url_for('.create_tesseract_ocr_pipeline_model') }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.create_spacy_nlp_pipeline_model') %}
-<li class="tab"><a href="{{ url_for('.contributions') }}" target="_self">Contributions Overview</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a href="{{ url_for('.spacy_nlp_pipeline_models') }}" target="_self">SpaCy NLP Pipeline Models</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a class="active" href="{{ url_for('.create_spacy_nlp_pipeline_model') }}" target="_self">{{ title }}</a></li>
-{% elif tesseract_ocr_pipeline_model and request.path == url_for('.tesseract_ocr_pipeline_model', tesseract_ocr_pipeline_model_id=tesseract_ocr_pipeline_model.id) %}
-<li class="tab"><a href="{{ url_for('.contributions') }}" target="_self">Contributions Overview</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a href="{{ url_for('.tesseract_ocr_pipeline_models') }}" target="_self">Tesseract OCR Pipeline Models</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab">
-  <a class="active" href="{{ url_for('.tesseract_ocr_pipeline_model', tesseract_ocr_pipeline_model_id=tesseract_ocr_pipeline_model.id) }}" target="_self">
-    {{ tesseract_ocr_pipeline_model.title }} {{ tesseract_ocr_pipeline_model.version }}
-  </a>
-</li>
-{% elif spacy_nlp_pipeline_model and request.path == url_for('.spacy_nlp_pipeline_model', spacy_nlp_pipeline_model_id=spacy_nlp_pipeline_model.id) %}
-<li class="tab"><a href="{{ url_for('.contributions') }}" target="_self">Contributions Overview</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a href="{{ url_for('.spacy_nlp_pipeline_models') }}" target="_self">SpaCy NLP Pipeline Models</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab">
-  <a class="active" href="{{ url_for('.spacy_nlp_pipeline_model', spacy_nlp_pipeline_model_id=spacy_nlp_pipeline_model.id) }}" target="_self">
-    {{ spacy_nlp_pipeline_model.title }} {{ spacy_nlp_pipeline_model.version }}
-  </a>
-</li>
-{% endif %}
-{% endset %}
diff --git a/app/templates/contributions/contributions.html.j2 b/app/templates/contributions/contributions.html.j2
index efefcbbb..a0944723 100644
--- a/app/templates/contributions/contributions.html.j2
+++ b/app/templates/contributions/contributions.html.j2
@@ -10,7 +10,7 @@
 
     <div class="col s4">
       <div class="card extension-selector hoverable service-color" data-service="tesseract-ocr-pipeline">
-        <a href="{{ url_for('.tesseract_ocr_pipeline_models.tesseract_ocr_pipeline_models') }}" style="position: absolute; width: 100%; height: 100%;"></a>
+        <a href="{{ url_for('.tesseract_ocr_pipeline_models') }}" style="position: absolute; width: 100%; height: 100%;"></a>
         <div class="card-content">
           <span class="card-title" data-service="tesseract-ocr-pipeline"><i class="nopaque-icons service-icons" data-service="tesseract-ocr-pipeline"></i>Tesseract OCR Pipeline Models</span>
           <p>Here you can see and edit the models that you have created. You can also create new models.</p>
@@ -20,7 +20,7 @@
 
     <div class="col s4">
       <div class="card extension-selector hoverable service-color" data-service="spacy-nlp-pipeline">
-        <a href="{{ url_for('.spacy_nlp_pipeline_models.spacy_nlp_pipeline_models') }}" style="position: absolute; width: 100%; height: 100%;"></a>
+        <a href="{{ url_for('.spacy_nlp_pipeline_models') }}" style="position: absolute; width: 100%; height: 100%;"></a>
         <div class="card-content">
           <span class="card-title"><i class="nopaque-icons service-icons" data-service="spacy-nlp-pipeline"></i>SpaCy NLP Pipeline Models</span>
           <p>Here you can see and edit the models that you have created. You can also create new models.</p>
diff --git a/app/templates/contributions/spacy_nlp_pipeline_models/create_spacy_nlp_pipeline_model.html.j2 b/app/templates/contributions/create_spacy_nlp_pipeline_model.html.j2
similarity index 100%
rename from app/templates/contributions/spacy_nlp_pipeline_models/create_spacy_nlp_pipeline_model.html.j2
rename to app/templates/contributions/create_spacy_nlp_pipeline_model.html.j2
diff --git a/app/templates/contributions/tesseract_ocr_pipeline_models/create_tesseract_ocr_pipeline_model.html.j2 b/app/templates/contributions/create_tesseract_ocr_pipeline_model.html.j2
similarity index 100%
rename from app/templates/contributions/tesseract_ocr_pipeline_models/create_tesseract_ocr_pipeline_model.html.j2
rename to app/templates/contributions/create_tesseract_ocr_pipeline_model.html.j2
diff --git a/app/templates/contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_model.html.j2 b/app/templates/contributions/spacy_nlp_pipeline_model.html.j2
similarity index 100%
rename from app/templates/contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_model.html.j2
rename to app/templates/contributions/spacy_nlp_pipeline_model.html.j2
diff --git a/app/templates/contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_models.html.j2 b/app/templates/contributions/spacy_nlp_pipeline_models.html.j2
similarity index 100%
rename from app/templates/contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_models.html.j2
rename to app/templates/contributions/spacy_nlp_pipeline_models.html.j2
diff --git a/app/templates/contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_model.html.j2 b/app/templates/contributions/tesseract_ocr_pipeline_model.html.j2
similarity index 100%
rename from app/templates/contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_model.html.j2
rename to app/templates/contributions/tesseract_ocr_pipeline_model.html.j2
diff --git a/app/templates/contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_models.html.j2 b/app/templates/contributions/tesseract_ocr_pipeline_models.html.j2
similarity index 100%
rename from app/templates/contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_models.html.j2
rename to app/templates/contributions/tesseract_ocr_pipeline_models.html.j2
diff --git a/app/templates/corpora/_breadcrumbs.html.j2 b/app/templates/corpora/_breadcrumbs.html.j2
deleted file mode 100644
index bdbe0f34..00000000
--- a/app/templates/corpora/_breadcrumbs.html.j2
+++ /dev/null
@@ -1,28 +0,0 @@
-{# {% set breadcrumbs %}
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a href="{{ url_for('main.dashboard', _anchor='jobs') }}" target="_self">My corpora</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-{% if request.path == url_for('.create_corpus') %}
-<li class="tab"><a class="active" href="{{ url_for('.create_corpus') }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.import_corpus') %}
-<li class="tab"><a class="active" href="{{ url_for('.import_corpus') }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.corpus', corpus_id=corpus.id) %}
-<li class="tab"><a class="active" href="{{ url_for('.corpus', corpus_id=corpus.id) }}" target="_self">{{ corpus.title }}</a></li>
-{% elif request.path == url_for('.analyse_corpus', corpus_id=corpus.id) %}
-<li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id) }}" target="_self">{{ corpus.title }}</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a class="active" href="{{ url_for('.analyse_corpus', corpus_id=corpus.id) }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.create_corpus_file', corpus_id=corpus.id) %}
-<li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id) }}" target="_self">{{ corpus.title }}</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id, _anchor='files') }}" target="_self">Corpus files</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a class="active" href="{{ url_for('.create_corpus_file', corpus_id=corpus.id) }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.corpus_file', corpus_file_id=corpus_file.id, corpus_id=corpus.id) %}
-<li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id) }}" target="_self">{{ corpus.title }}</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id, _anchor='files') }}" target="_self">Corpus files</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a class="active" href="{{ url_for('.corpus_file', corpus_file_id=corpus_file.id, corpus_id=corpus.id) }}" target="_self">{{ corpus_file.author }}: {{ corpus_file.title }} ({{ corpus_file.publishing_year }})</a></li>
-{% endif %}
-{% endset %} #}
diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2
index 053dbdc0..f17e1e88 100644
--- a/app/templates/corpora/corpus.html.j2
+++ b/app/templates/corpora/corpus.html.j2
@@ -1,6 +1,5 @@
 {% extends "base.html.j2" %}
 {% import "materialize/wtf.html.j2" as wtf %}
-{% from "corpora/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block main_attribs %} class="service-scheme" data-service="corpus-analysis"{% endblock main_attribs %}
 
diff --git a/app/templates/corpora/create_corpus.html.j2 b/app/templates/corpora/create_corpus.html.j2
index ccb49877..5feb7225 100644
--- a/app/templates/corpora/create_corpus.html.j2
+++ b/app/templates/corpora/create_corpus.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "corpora/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 {% block main_attribs %} class="service-scheme" data-service="corpus-analysis"{% endblock main_attribs %}
diff --git a/app/templates/corpora/files/corpus_file.html.j2 b/app/templates/corpora/files/corpus_file.html.j2
index 781ea059..28a762d4 100644
--- a/app/templates/corpora/files/corpus_file.html.j2
+++ b/app/templates/corpora/files/corpus_file.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "corpora/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 {% block main_attribs %} class="service-scheme" data-service="corpus-analysis"{% endblock main_attribs %}
diff --git a/app/templates/corpora/files/create_corpus_file.html.j2 b/app/templates/corpora/files/create_corpus_file.html.j2
index 8cff13a6..ff634294 100644
--- a/app/templates/corpora/files/create_corpus_file.html.j2
+++ b/app/templates/corpora/files/create_corpus_file.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "corpora/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 {% block main_attribs %} class="service-scheme" data-service="corpus-analysis"{% endblock main_attribs %}
diff --git a/app/templates/corpora/import_corpus.html.j2 b/app/templates/corpora/import_corpus.html.j2
index 957668e6..dd0806a7 100644
--- a/app/templates/corpora/import_corpus.html.j2
+++ b/app/templates/corpora/import_corpus.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "corpora/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 {% block main_attribs %} class="service-scheme" data-service="corpus-analysis"{% endblock main_attribs %}
diff --git a/app/templates/jobs/_breadcrumbs.html.j2 b/app/templates/jobs/_breadcrumbs.html.j2
deleted file mode 100644
index e3de43f3..00000000
--- a/app/templates/jobs/_breadcrumbs.html.j2
+++ /dev/null
@@ -1,8 +0,0 @@
-{% set breadcrumbs %}
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a href="{{ url_for('main.dashboard', _anchor='jobs') }}" target="_self">My jobs</a></li>
-{% if request.path == url_for('.job', job_id=job.id) %}
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a class="active" href="{{ url_for('.job', job_id=job.id) }}" target="_self">{{ job.title }}</a></li>
-{% endif %}
-{% endset %}
diff --git a/app/templates/jobs/job.html.j2 b/app/templates/jobs/job.html.j2
index b5055ce6..51f2fc6d 100644
--- a/app/templates/jobs/job.html.j2
+++ b/app/templates/jobs/job.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "jobs/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block main_attribs %} class="service-scheme" data-service="{{ job.service }}"{% endblock main_attribs %}
 
diff --git a/app/templates/main/_breadcrumbs.html.j2 b/app/templates/main/_breadcrumbs.html.j2
deleted file mode 100644
index d65978d6..00000000
--- a/app/templates/main/_breadcrumbs.html.j2
+++ /dev/null
@@ -1,14 +0,0 @@
-{% set breadcrumbs %}
-{% if not (request.path == url_for('.index')) %}
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-{% endif %}
-{% if request.path == url_for('.faq') %}
-<li class="tab"><a class="active" href="{{ url_for('.faq') }}" target="_self">Frequently Asked Questions</a></li>
-{% elif request.path == url_for('.dashboard') %}
-<li class="tab"><a class="active" href="{{ url_for('.dashboard') }}" target="_self">Dashboard</a></li>
-{% elif request.path == url_for('.news') %}
-<li class="tab"><a class="active" href="{{ url_for('.news') }}" target="_self">News</a></li>
-{% elif request.path == url_for('.terms_of_use') %}
-<li class="tab"><a class="active" href="{{ url_for('.terms_of_use') }}" target="_self">Terms of use</a></li>
-{% endif %}
-{% endset %}
diff --git a/app/templates/main/dashboard.html.j2 b/app/templates/main/dashboard.html.j2
index 5474db97..1598e139 100644
--- a/app/templates/main/dashboard.html.j2
+++ b/app/templates/main/dashboard.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "main/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block page_content %}
 <div class="container">
diff --git a/app/templates/main/faq.html.j2 b/app/templates/main/faq.html.j2
index fb10e3b4..5c168007 100644
--- a/app/templates/main/faq.html.j2
+++ b/app/templates/main/faq.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "main/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block page_content %}
 <div class="container">
diff --git a/app/templates/main/index.html.j2 b/app/templates/main/index.html.j2
index 74bfa306..7b80a794 100644
--- a/app/templates/main/index.html.j2
+++ b/app/templates/main/index.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "main/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 {% block page_content %}
diff --git a/app/templates/main/news.html.j2 b/app/templates/main/news.html.j2
index b8961b02..d1111d8b 100644
--- a/app/templates/main/news.html.j2
+++ b/app/templates/main/news.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "main/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block page_content %}
 <div class="container">
diff --git a/app/templates/main/news_new.html.j2 b/app/templates/main/news_new.html.j2
index d3e2f9b6..d2b339e3 100644
--- a/app/templates/main/news_new.html.j2
+++ b/app/templates/main/news_new.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "main/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block page_content %}
 <div class="container">
diff --git a/app/templates/main/privacy_policy.html.j2 b/app/templates/main/privacy_policy.html.j2
index ff22b10a..c55ae722 100644
--- a/app/templates/main/privacy_policy.html.j2
+++ b/app/templates/main/privacy_policy.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "main/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block page_content %}
 <div class="container">
diff --git a/app/templates/main/terms_of_use.html.j2 b/app/templates/main/terms_of_use.html.j2
index baedd0a2..d61084d6 100644
--- a/app/templates/main/terms_of_use.html.j2
+++ b/app/templates/main/terms_of_use.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "main/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block page_content %}
 <div class="container">
diff --git a/app/templates/main/user_manual.html.j2 b/app/templates/main/user_manual.html.j2
index 0da85809..2ef1da01 100644
--- a/app/templates/main/user_manual.html.j2
+++ b/app/templates/main/user_manual.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "main/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block page_content %}
 <div class="container">
diff --git a/app/templates/services/_breadcrumbs.html.j2 b/app/templates/services/_breadcrumbs.html.j2
deleted file mode 100644
index a08beafa..00000000
--- a/app/templates/services/_breadcrumbs.html.j2
+++ /dev/null
@@ -1,16 +0,0 @@
-{% set breadcrumbs %}
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a href="{{ url_for('main.index', _anchor='services') }}" target="_self">Processes & Services</a></li>
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-{% if request.path == url_for('.corpus_analysis') %}
-<li class="tab"><a class="active" href="{{ url_for('.corpus_analysis') }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.file_setup_pipeline') %}
-<li class="tab"><a class="active" href="{{ url_for('.file_setup_pipeline') }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.spacy_nlp_pipeline') %}
-<li class="tab"><a class="active" href="{{ url_for('.spacy_nlp_pipeline') }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.tesseract_ocr_pipeline') %}
-<li class="tab"><a class="active" href="{{ url_for('.tesseract_ocr_pipeline') }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.transkribus_htr_pipeline') %}
-<li class="tab"><a class="active" href="{{ url_for('.transkribus_htr_pipeline') }}" target="_self">{{ title }}</a></li>
-{% endif %}
-{% endset %}
diff --git a/app/templates/services/corpus_analysis.html.j2 b/app/templates/services/corpus_analysis.html.j2
index a7e3da8e..9ddc9ec3 100644
--- a/app/templates/services/corpus_analysis.html.j2
+++ b/app/templates/services/corpus_analysis.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "services/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block main_attribs %} class="service-scheme" data-service="corpus-analysis"{% endblock main_attribs %}
 
diff --git a/app/templates/services/file_setup_pipeline.html.j2 b/app/templates/services/file_setup_pipeline.html.j2
index ebc4cfc4..0f046e98 100644
--- a/app/templates/services/file_setup_pipeline.html.j2
+++ b/app/templates/services/file_setup_pipeline.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "services/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 {% block main_attribs %} class="service-scheme" data-service="file-setup-pipeline"{% endblock main_attribs %}
diff --git a/app/templates/services/spacy_nlp_pipeline.html.j2 b/app/templates/services/spacy_nlp_pipeline.html.j2
index 8f466e3d..2ebba838 100644
--- a/app/templates/services/spacy_nlp_pipeline.html.j2
+++ b/app/templates/services/spacy_nlp_pipeline.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "services/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 {% block main_attribs %} class="service-scheme" data-service="spacy-nlp-pipeline"{% endblock main_attribs %}
@@ -77,7 +76,7 @@
                   {{ form.model.label }}
                   <span class="helper-text">
                     <a class="modal-trigger tooltipped" href="#models-modal" data-position="bottom" data-tooltip="See more information about models"><i class="material-icons" style="color:#0064A3;">help_outline</i></a>
-                    <a class="tooltipped" href="{{ url_for('contributions.spacy_nlp_pipeline_models.create_spacy_nlp_pipeline_model') }}" data-position="bottom" data-tooltip="Add your own spaCy NLP models"><i class="material-icons" style="color:#0064A3">new_label</i></a>
+                    <a class="tooltipped" href="{{ url_for('contributions.create_spacy_nlp_pipeline_model') }}" data-position="bottom" data-tooltip="Add your own spaCy NLP models"><i class="material-icons" style="color:#0064A3">new_label</i></a>
                   </span>
                 </div>
               </div>
diff --git a/app/templates/services/tesseract_ocr_pipeline.html.j2 b/app/templates/services/tesseract_ocr_pipeline.html.j2
index 11d65c64..9b1d6187 100644
--- a/app/templates/services/tesseract_ocr_pipeline.html.j2
+++ b/app/templates/services/tesseract_ocr_pipeline.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "services/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 {% block main_attribs %} class="service-scheme" data-service="tesseract-ocr-pipeline"{% endblock main_attribs %}
@@ -59,7 +58,7 @@
                   {{ form.model.label }}
                   <span class="helper-text">
                     <a class="modal-trigger tooltipped" href="#models-modal" data-position="bottom" data-tooltip="See more information about models"><i class="material-icons" style="color:#00A58B;">help_outline</i></a>
-                    <a class="tooltipped" href="{{ url_for('contributions.tesseract_ocr_pipeline_models.create_tesseract_ocr_pipeline_model') }}" data-position="bottom" data-tooltip="Add your own Tesseract OCR models"><i class="material-icons" style="color:#00A58B">new_label</i></a>
+                    <a class="tooltipped" href="{{ url_for('contributions.create_tesseract_ocr_pipeline_model') }}" data-position="bottom" data-tooltip="Add your own Tesseract OCR models"><i class="material-icons" style="color:#00A58B">new_label</i></a>
                   </span>
                   {% for error in form.model.errors %}
                   <span class="helper-text error-color-text">{{ error }}</span>
diff --git a/app/templates/services/transkribus_htr_pipeline.html.j2 b/app/templates/services/transkribus_htr_pipeline.html.j2
index f5468ce9..50eb3b6e 100644
--- a/app/templates/services/transkribus_htr_pipeline.html.j2
+++ b/app/templates/services/transkribus_htr_pipeline.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "services/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 {% block main_attribs %} class="service-scheme" data-service="transkribus-htr-pipeline"{% endblock main_attribs %}
diff --git a/app/templates/settings/_breadcrumbs.html.j2 b/app/templates/settings/_breadcrumbs.html.j2
deleted file mode 100644
index 3b5077bf..00000000
--- a/app/templates/settings/_breadcrumbs.html.j2
+++ /dev/null
@@ -1,6 +0,0 @@
-{% set breadcrumbs %}
-<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-{% if request.path == url_for('settings.settings') %}
-<li class="tab"><a{%if request.path == url_for('settings.settings') %} class="active"{% endif %} href="{{ url_for('settings.settings') }}" target="_self">Settings</a></li>
-{% endif %}
-{% endset %}
diff --git a/app/templates/settings/settings.html.j2 b/app/templates/settings/settings.html.j2
index e9cfb97b..fd830aee 100644
--- a/app/templates/settings/settings.html.j2
+++ b/app/templates/settings/settings.html.j2
@@ -1,5 +1,4 @@
 {% extends "base.html.j2" %}
-{% from "settings/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
 {% block page_content %}
diff --git a/requirements.txt b/requirements.txt
index 838cee8f..e621d2ad 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,6 +6,7 @@ eventlet
 Flask==2.1.3
 Flask-APScheduler
 Flask-Assets
+Flask-Breadcrumbs
 Flask-Hashids==1.0.1
 Flask-HTTPAuth
 Flask-Login
-- 
GitLab