Compare commits

...

17 Commits

Author SHA1 Message Date
Francesco Lorenzo D'Amico 769919384a Fix highlighted state on the white service-box variant
Deploy Staging / deploy (push) Successful in 1m21s
2026-09-18 14:47:32 +02:00
Francesco Lorenzo D'Amico fca00abb35 Remove self styled hexagon
Deploy Staging / deploy (push) Successful in 1m16s
2026-09-13 23:04:52 +02:00
Francesco Lorenzo D'Amico ec8f55555e Fix character-counter leak into saved values and hero/heading layout
Deploy Staging / deploy (push) Successful in 1m15s
2026-09-11 14:38:09 +02:00
Francesco Lorenzo D'Amico e9cd7daa8f Add the admin editor frontend: collect/save, character limits, counter, error highlighting, layout safeguards
Deploy Staging / deploy (push) Successful in 2m40s
2026-09-11 13:43:43 +02:00
Francesco Lorenzo D'Amico 0f05f663e7 Seed the initial page content from page-content.json 2026-09-11 13:43:05 +02:00
Francesco Lorenzo D'Amico f1e5c3fb12 Add the admin editor page (toolbar, sections, re-login, section refresh) 2026-09-11 13:42:22 +02:00
Francesco Lorenzo D'Amico bac82b1568 Render the public homepage from the editable section partials 2026-09-11 13:41:45 +02:00
Francesco Lorenzo D'Amico 22bfcfcedf Add shared portfolio styles and scripts, with the Hero height fixed to grow with its content 2026-09-11 13:40:54 +02:00
Francesco Lorenzo D'Amico ff5f1f340c Add the editable section partials (Hero, About, Skills, Projects, Goals, Experience, Documents, Contact) 2026-09-11 13:40:04 +02:00
Francesco Lorenzo D'Amico c72da7f501 Add shared editor partials (_EditableText, _EditItemControls) 2026-09-11 13:39:32 +02:00
Francesco Lorenzo D'Amico 6dd0ecaceb Add the page-content API endpoint for saving and file uploads 2026-09-11 13:38:55 +02:00
Francesco Lorenzo D'Amico 2d365eae95 Add PageContentService and PageContentSerializer (load/save the page content) 2026-09-11 13:38:27 +02:00
Francesco Lorenzo D'Amico b2e04fcb1d Add EditMarkup and SectionView/EditableText helpers for the editor 2026-09-11 13:37:40 +02:00
Francesco Lorenzo D'Amico 27b0bd67c4 Add the section content models (Hero, About, Skills, Projects, Goals, Experience, Documents, Contact) with per-field character limits 2026-09-11 13:36:55 +02:00
Francesco Lorenzo D'Amico b3eb66b2cb Add the PageContent root model and ContentLimits (field-length limits) 2026-09-11 13:32:45 +02:00
Francesco Lorenzo D'Amico 1c3c2d54e1 Configure auth cookie expiry, antiforgery header and the page-content service 2026-09-11 13:30:18 +02:00
Francesco Lorenzo D'Amico 776d62a724 Add the PageContent table (entity, DbContext, migration, model snapshot) 2026-09-11 13:29:15 +02:00
44 changed files with 4065 additions and 1518 deletions
+6
View File
@@ -20,4 +20,10 @@
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
</ItemGroup>
<ItemGroup>
<Content Update="SeedData\page-content.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,111 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using BewerbungsSeite.Models.SiteContent;
using BewerbungsSeite.Services;
namespace BewerbungsSeite.Controllers.Api.V1;
/// <summary>
/// Schreibzugriff auf den Seiteninhalt. Nur fuer angemeldete Administratoren:
/// die Konvention AuthorizeFolder("/Admin") gilt nur fuer Razor Pages, deshalb
/// steht das [Authorize] hier ausdruecklich am Controller.
/// </summary>
[Authorize]
[AutoValidateAntiforgeryToken]
[ApiController]
[Route("api/v1/[controller]")]
public class PageContentController(
IPageContentService pageContentService,
IWebHostEnvironment environment,
ILogger<PageContentController> logger) : ControllerBase
{
private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".webp", ".gif"];
private static readonly string[] AllowedDocumentExtensions = [".pdf"];
private const long MaxImageBytes = 5 * 1024 * 1024;
private const long MaxDocumentBytes = 20 * 1024 * 1024;
/// <summary>Schreibt den kompletten Seiteninhalt zurueck.</summary>
[HttpPut]
public async Task<IActionResult> Put([FromBody] PageContent content)
{
if (content is null)
{
return BadRequest(new { error = "Kein Inhalt übermittelt." });
}
await pageContentService.SaveAsync(content, HttpContext.RequestAborted);
return Ok(new { success = true, savedAt = DateTime.UtcNow });
}
/// <summary>
/// Nimmt ein Bild entgegen, legt es unter wwwroot/uploads/images ab und
/// liefert den Pfad zurueck, der anschliessend im JSON gespeichert wird.
/// </summary>
[HttpPost("images")]
[RequestSizeLimit(MaxImageBytes)]
public Task<IActionResult> UploadImage(IFormFile? file) =>
StoreAsync(file, "images", AllowedImageExtensions, MaxImageBytes,
requiredContentTypePrefix: "image/",
wrongTypeMessage: "Erlaubt sind JPG, PNG, WEBP und GIF.",
tooLargeMessage: "Die Datei ist grösser als 5 MB.");
/// <summary>
/// Nimmt ein Dokument entgegen (PDF), legt es unter wwwroot/uploads/documents
/// ab und liefert den Pfad zurueck.
/// </summary>
[HttpPost("documents")]
[RequestSizeLimit(MaxDocumentBytes)]
public Task<IActionResult> UploadDocument(IFormFile? file) =>
StoreAsync(file, "documents", AllowedDocumentExtensions, MaxDocumentBytes,
requiredContentTypePrefix: null,
wrongTypeMessage: "Erlaubt sind nur PDF-Dateien.",
tooLargeMessage: "Die Datei ist grösser als 20 MB.");
private async Task<IActionResult> StoreAsync(
IFormFile? file,
string folder,
string[] allowedExtensions,
long maxBytes,
string? requiredContentTypePrefix,
string wrongTypeMessage,
string tooLargeMessage)
{
if (file is null || file.Length == 0)
{
return BadRequest(new { error = "Keine Datei übermittelt." });
}
if (file.Length > maxBytes)
{
return BadRequest(new { error = tooLargeMessage });
}
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!allowedExtensions.Contains(extension))
{
return BadRequest(new { error = wrongTypeMessage });
}
if (requiredContentTypePrefix is not null &&
!file.ContentType.StartsWith(requiredContentTypePrefix, StringComparison.OrdinalIgnoreCase))
{
return BadRequest(new { error = wrongTypeMessage });
}
// Der Dateiname wird selbst vergeben; der Name aus dem Browser wird nie uebernommen.
var fileName = $"{Guid.NewGuid():N}{extension}";
var uploadDirectory = Path.Combine(environment.WebRootPath, "uploads", folder);
Directory.CreateDirectory(uploadDirectory);
var targetPath = Path.Combine(uploadDirectory, fileName);
await using (var stream = System.IO.File.Create(targetPath))
{
await file.CopyToAsync(stream, HttpContext.RequestAborted);
}
logger.LogInformation("Datei hochgeladen: {Folder}/{FileName}", folder, fileName);
return Ok(new { url = $"/uploads/{folder}/{fileName}" });
}
}
@@ -1,9 +1,21 @@
using Microsoft.EntityFrameworkCore;
using BewerbungsSeite.Models;
namespace BewerbungsSeite.Data;
public class RazorPagesMovieContext(DbContextOptions<RazorPagesMovieContext> options) : DbContext(options)
{
public DbSet<BewerbungsSeite.Models.AdminUser> AdminUser { get; set; } = default!;
public DbSet<BewerbungsSeite.Models.ContactMessage> ContactMessage { get; set; } = default!;
public DbSet<AdminUser> AdminUser { get; set; } = default!;
public DbSet<ContactMessage> ContactMessage { get; set; } = default!;
public DbSet<PageContentEntity> PageContent { get; set; } = default!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Es gibt genau eine Zeile mit fester Id, deshalb keine Identity-Spalte.
modelBuilder.Entity<PageContentEntity>()
.Property(p => p.Id)
.ValueGeneratedNever();
}
}
@@ -0,0 +1,108 @@
// <auto-generated />
using System;
using BewerbungsSeite.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BewerbungsSeite.Migrations
{
[DbContext(typeof(RazorPagesMovieContext))]
[Migration("20260909095802_AddPageContent")]
partial class AddPageContent
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.28")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("BewerbungsSeite.Models.AdminUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.ToTable("AdminUser");
});
modelBuilder.Entity("BewerbungsSeite.Models.ContactMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(5000)
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("NotificationSent")
.HasColumnType("bit");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<DateTime>("SentAt")
.HasColumnType("datetime2");
b.Property<string>("Subject")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.HasKey("Id");
b.ToTable("ContactMessage");
});
modelBuilder.Entity("BewerbungsSeite.Models.PageContentEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("varbinary(max)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.ToTable("PageContent");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,35 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BewerbungsSeite.Migrations
{
/// <inheritdoc />
public partial class AddPageContent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PageContent",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
Content = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PageContent", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PageContent");
}
}
}
@@ -1,10 +1,10 @@
// <auto-generated />
using System;
using BewerbungsSeite.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using BewerbungsSeite.Data;
#nullable disable
@@ -82,6 +82,23 @@ namespace BewerbungsSeite.Migrations
b.ToTable("ContactMessage");
});
modelBuilder.Entity("BewerbungsSeite.Models.PageContentEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("varbinary(max)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.ToTable("PageContent");
});
#pragma warning restore 612, 618
}
}
@@ -0,0 +1,19 @@
namespace BewerbungsSeite.Models;
/// <summary>
/// Speicherzeile fuer den gesamten Seiteninhalt. Es existiert genau ein
/// Datensatz mit <see cref="SingletonId"/>; der Inhalt liegt serialisiert
/// als UTF-8-JSON im Feld <see cref="Content"/>.
/// </summary>
public class PageContentEntity
{
/// <summary>Feste Id des einzigen Datensatzes.</summary>
public const int SingletonId = 1;
public int Id { get; set; }
public byte[] Content { get; set; } = Array.Empty<byte>();
/// <summary>Zeitpunkt der letzten Speicherung (UTC).</summary>
public DateTime UpdatedAt { get; set; }
}
@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
namespace BewerbungsSeite.Models.SiteContent;
/// <summary>Bereich "Ueber mich" (section id="about").</summary>
public class AboutSection
{
[StringLength(ContentLimits.Url, ErrorMessage = ContentLimits.TooLongMessage)]
public string Image { get; set; } = string.Empty;
[StringLength(ContentLimits.AltText, ErrorMessage = ContentLimits.TooLongMessage)]
public string ImageAlt { get; set; } = string.Empty;
[StringLength(ContentLimits.Kicker, ErrorMessage = ContentLimits.TooLongMessage)]
public string Kicker { get; set; } = string.Empty;
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string TitleLine1 { get; set; } = string.Empty;
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string TitleLine2 { get; set; } = string.Empty;
/// <summary>Hervorgehobener Teil der zweiten Titelzeile.</summary>
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string TitleLine2Highlight { get; set; } = string.Empty;
[StringLength(ContentLimits.ParagraphText, ErrorMessage = ContentLimits.TooLongMessage)]
public string Text { get; set; } = string.Empty;
public List<FactItem> Facts { get; set; } = new();
}
/// <summary>Eine Zeile der Kurzuebersicht rechts unter dem Text.</summary>
public class FactItem
{
[StringLength(ContentLimits.FactLabel, ErrorMessage = ContentLimits.TooLongMessage)]
public string Label { get; set; } = string.Empty;
[StringLength(ContentLimits.FactValue, ErrorMessage = ContentLimits.TooLongMessage)]
public string Value { get; set; } = string.Empty;
}
@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
namespace BewerbungsSeite.Models.SiteContent;
/// <summary>
/// Bereich "Kontakt" (section id="contact"). Enthaelt nur die Texte;
/// das Formular selbst bleibt Bestandteil der Seite und ist nicht editierbar.
/// </summary>
public class ContactSection
{
[StringLength(ContentLimits.Kicker, ErrorMessage = ContentLimits.TooLongMessage)]
public string Kicker { get; set; } = string.Empty;
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string Title { get; set; } = string.Empty;
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string TitleHighlight { get; set; } = string.Empty;
public List<ContactInfoItem> InfoBoxes { get; set; } = new();
/// <summary>Einleitungstext oberhalb der Formularfelder.</summary>
[StringLength(ContentLimits.FormIntro, ErrorMessage = ContentLimits.TooLongMessage)]
public string FormHeading { get; set; } = string.Empty;
}
/// <summary>Eine Kontaktangabe (Standort, Telefon, E-Mail, ...).</summary>
public class ContactInfoItem
{
/// <summary>CSS-Klasse des Icons, z. B. "flaticon-call".</summary>
[StringLength(ContentLimits.IconClass, ErrorMessage = ContentLimits.TooLongMessage)]
public string Icon { get; set; } = string.Empty;
[StringLength(ContentLimits.FactLabel, ErrorMessage = ContentLimits.TooLongMessage)]
public string Title { get; set; } = string.Empty;
[StringLength(ContentLimits.FactValue, ErrorMessage = ContentLimits.TooLongMessage)]
public string Text { get; set; } = string.Empty;
/// <summary>Optional: macht den Text zum Link, z. B. "tel:0797367355".</summary>
[StringLength(ContentLimits.Url, ErrorMessage = ContentLimits.TooLongMessage)]
public string LinkUrl { get; set; } = string.Empty;
}
@@ -0,0 +1,83 @@
namespace BewerbungsSeite.Models.SiteContent;
/// <summary>
/// Einzige Quelle der Wahrheit fuer die Laenge der CMS-Textfelder. Jede Zahl
/// steht hier genau einmal und wird von drei Stellen verwendet:
/// - den [StringLength]-Attributen auf den SiteContent-Models (harte
/// Grenze, die ein manipulierter Request nicht umgehen kann),
/// - den data-max-Attributen, die EditMarkup im Admin-Editor rendert,
/// - dem Live-Zaehler in wwwroot/js/page-editor.js, der data-max ausliest.
/// Ändert sich eine Grenze, reicht die Aenderung an genau dieser Zahl.
///
/// Die Werte sind nicht pro einzelnem Feld gewaehlt, sondern pro Feld-Typ:
/// viele Felder teilen denselben Zweck (z. B. alle "kicker"-Felder) und
/// damit denselben verfuegbaren Platz im Layout. Herleitung siehe die
/// Analyse-Tabelle in der Aufgabenbeschreibung; kurz zusammengefasst:
/// - Kicker/Titel/Buttons/Icons sitzen einzeilig neben anderen Elementen
/// -> enge, harte Grenzen.
/// - Fliesstexte (Intro, Beschreibungen) haben viel Breite und brechen
/// dank CSS sauber um -> grosszuegiger Deckel, kein hartes Sperren beim
/// Tippen im Editor.
/// </summary>
public static class ContentLimits
{
/// <summary>Kicker-Label ueber einem Section-Titel (alle 8 Sections).</summary>
public const int Kicker = 40;
/// <summary>Grosser Section-/Hero-Titel bzw. dessen hervorgehobener Teil.</summary>
public const int Heading = 50;
/// <summary>Titel einer Skill-/Ziel-Kachel.</summary>
public const int TileTitle = 50;
/// <summary>
/// Text einer Skill-/Ziel-Kachel. Kacheln stehen im selben Flex-Row-Verbund
/// wie ihre Nachbarn und werden dadurch alle gleich hoch -- deshalb der
/// engste Deckel unter den Fliesstext-Feldern.
/// </summary>
public const int TileText = 180;
/// <summary>Label einer Kurzfakten-Zeile (about.facts.label).</summary>
public const int FactLabel = 30;
/// <summary>Wert einer Kurzfakten-Zeile (about.facts.value).</summary>
public const int FactValue = 80;
/// <summary>CSS-Klasse eines Icons (kein Freitext).</summary>
public const int IconClass = 60;
/// <summary>Titel einer Projekt-/Erfahrungs-/Dokumentkarte.</summary>
public const int CardTitle = 70;
/// <summary>Kleiner Zwischentitel innerhalb einer Karte (z. B. "TECH:").</summary>
public const int DetailHeading = 50;
/// <summary>Kurze Meta-Zeile unter einem Kartentitel (Experience.meta).</summary>
public const int Meta = 80;
/// <summary>Beschriftung eines Buttons oder Links.</summary>
public const int ButtonLabel = 40;
/// <summary>
/// Fliesstext in einer breiten Spalte (Hero-Intro, About-Text,
/// Projekt-/Detail-/Erfahrungstext, Dokumentbeschreibung). Grosszuegiger
/// Deckel als Rueckfallgrenze -- im Editor nur Warnfarbe, kein Sperren.
/// </summary>
public const int ParagraphText = 500;
/// <summary>Zweizeilige Einleitung ueber dem Kontaktformular.</summary>
public const int FormIntro = 200;
/// <summary>Url, Datei- oder Bildpfad. Kein Layoutrisiko, nur Schutz vor Missbrauch.</summary>
public const int Url = 300;
/// <summary>Alt-Text eines Bildes. Unsichtbar, nur Hygiene-Deckel.</summary>
public const int AltText = 200;
/// <summary>
/// Deutsche Fehlermeldung fuer alle [StringLength]-Attribute unten (der
/// ASP.NET-Standardtext ist Englisch). {1} fuellt ASP.NET Core automatisch
/// mit der maximalen Laenge des jeweiligen Attributs.
/// </summary>
public const string TooLongMessage = "Text ist zu lang (maximal {1} Zeichen erlaubt).";
}
@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
namespace BewerbungsSeite.Models.SiteContent;
/// <summary>Bereich "Dokumente und Zertifikate" (section id="documents").</summary>
public class DocumentsSection
{
[StringLength(ContentLimits.Kicker, ErrorMessage = ContentLimits.TooLongMessage)]
public string Kicker { get; set; } = string.Empty;
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string Title { get; set; } = string.Empty;
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string TitleHighlight { get; set; } = string.Empty;
public List<DocumentItem> Items { get; set; } = new();
}
/// <summary>Ein Dokument bzw. Zertifikat mit Link auf die PDF-Datei.</summary>
public class DocumentItem
{
[StringLength(ContentLimits.CardTitle, ErrorMessage = ContentLimits.TooLongMessage)]
public string Title { get; set; } = string.Empty;
[StringLength(ContentLimits.ParagraphText, ErrorMessage = ContentLimits.TooLongMessage)]
public string Description { get; set; } = string.Empty;
[StringLength(ContentLimits.Url, ErrorMessage = ContentLimits.TooLongMessage)]
public string FileUrl { get; set; } = string.Empty;
[StringLength(ContentLimits.ButtonLabel, ErrorMessage = ContentLimits.TooLongMessage)]
public string LinkText { get; set; } = string.Empty;
}
@@ -0,0 +1,60 @@
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Html;
namespace BewerbungsSeite.Models.SiteContent;
/// <summary>
/// Erzeugt die data-Attribute, an denen der Editor die bearbeitbaren Stellen
/// erkennt. Ausserhalb des Edit-Modus kommt nichts zurueck, damit das Markup
/// der oeffentlichen Seite unveraendert bleibt.
/// (Razor laesst Attribute mit null-Wert nicht weg, deshalb dieser Weg.)
/// </summary>
public static class EditMarkup
{
private static readonly HtmlEncoder Encoder = HtmlEncoder.Default;
private static IHtmlContent Attributes(bool editMode, string markup) =>
editMode ? new HtmlString(markup) : HtmlString.Empty;
/// <summary>Kennzeichnet einen Bereich.</summary>
public static IHtmlContent Section(bool editMode, string name) =>
Attributes(editMode, $"data-section=\"{Encoder.Encode(name)}\"");
/// <summary>
/// Kennzeichnet ein bearbeitbares Textfeld. <paramref name="max"/> kommt aus
/// <see cref="ContentLimits"/> und landet als data-max im Markup, wo es der
/// Editor (page-editor.js) fuer den Live-Zaehler und die Eingabegrenze liest.
/// </summary>
public static IHtmlContent Field(bool editMode, string name, bool multiline = false, int? max = null) =>
Attributes(editMode,
$"data-field=\"{Encoder.Encode(name)}\"" +
(multiline ? " data-multiline=\"true\"" : string.Empty) +
(max.HasValue ? $" data-max=\"{max}\"" : string.Empty) +
" contenteditable=\"true\"");
/// <summary>Macht ein Element bearbeitbar, ohne ihm ein eigenes Feld zuzuordnen.</summary>
public static IHtmlContent Editable(bool editMode, int? max = null) =>
Attributes(editMode,
"contenteditable=\"true\"" + (max.HasValue ? $" data-max=\"{max}\"" : string.Empty));
/// <summary>Kennzeichnet einen Listencontainer.</summary>
public static IHtmlContent List(bool editMode, string name) =>
Attributes(editMode, $"data-list=\"{Encoder.Encode(name)}\"");
/// <summary>Kennzeichnet eine Liste aus reinen Texten.</summary>
public static IHtmlContent StringList(bool editMode, string name) =>
Attributes(editMode, $"data-list=\"{Encoder.Encode(name)}\" data-string-list=\"true\"");
/// <summary>Kennzeichnet einen Listeneintrag.</summary>
public static IHtmlContent Item(bool editMode) =>
Attributes(editMode, "data-item=\"true\"");
/// <summary>Kennzeichnet ein verschachteltes Objekt.</summary>
public static IHtmlContent Object(bool editMode, string name) =>
Attributes(editMode, $"data-object=\"{Encoder.Encode(name)}\"");
/// <summary>Kennzeichnet ein austauschbares Bild und haelt dessen gespeicherten Pfad fest.</summary>
public static IHtmlContent Image(bool editMode, string name, string path) =>
Attributes(editMode,
$"data-image=\"{Encoder.Encode(name)}\" data-value=\"{Encoder.Encode(path ?? string.Empty)}\"");
}
@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
namespace BewerbungsSeite.Models.SiteContent;
/// <summary>Bereich "Berufliche Erfahrung" (section id="experience").</summary>
public class ExperienceSection
{
[StringLength(ContentLimits.Kicker, ErrorMessage = ContentLimits.TooLongMessage)]
public string Kicker { get; set; } = string.Empty;
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string Title { get; set; } = string.Empty;
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string TitleHighlight { get; set; } = string.Empty;
public List<ExperienceItem> Items { get; set; } = new();
[StringLength(ContentLimits.ButtonLabel, ErrorMessage = ContentLimits.TooLongMessage)]
public string ToggleMoreText { get; set; } = string.Empty;
[StringLength(ContentLimits.ButtonLabel, ErrorMessage = ContentLimits.TooLongMessage)]
public string ToggleLessText { get; set; } = string.Empty;
}
/// <summary>Ein Eintrag der Erfahrungs-Timeline.</summary>
public class ExperienceItem
{
[StringLength(ContentLimits.CardTitle, ErrorMessage = ContentLimits.TooLongMessage)]
public string Title { get; set; } = string.Empty;
[StringLength(ContentLimits.Meta, ErrorMessage = ContentLimits.TooLongMessage)]
public string Meta { get; set; } = string.Empty;
[StringLength(ContentLimits.DetailHeading, ErrorMessage = ContentLimits.TooLongMessage)]
public string DetailHeading { get; set; } = string.Empty;
[StringLength(ContentLimits.ParagraphText, ErrorMessage = ContentLimits.TooLongMessage)]
public string Text { get; set; } = string.Empty;
/// <summary>true = erst nach Klick auf "Mehr erfahren" sichtbar.</summary>
public bool Collapsed { get; set; }
}
@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
namespace BewerbungsSeite.Models.SiteContent;
/// <summary>Bereich "Meine Ziele" (section id="goals").</summary>
public class GoalsSection
{
[StringLength(ContentLimits.Kicker, ErrorMessage = ContentLimits.TooLongMessage)]
public string Kicker { get; set; } = string.Empty;
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string Title { get; set; } = string.Empty;
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string TitleHighlight { get; set; } = string.Empty;
public List<GoalItem> Items { get; set; } = new();
}
/// <summary>Eine Ziel-Kachel.</summary>
public class GoalItem
{
/// <summary>CSS-Klasse des Icons, z. B. "flaticon-growth".</summary>
[StringLength(ContentLimits.IconClass, ErrorMessage = ContentLimits.TooLongMessage)]
public string Icon { get; set; } = string.Empty;
[StringLength(ContentLimits.TileTitle, ErrorMessage = ContentLimits.TooLongMessage)]
public string Title { get; set; } = string.Empty;
[StringLength(ContentLimits.TileText, ErrorMessage = ContentLimits.TooLongMessage)]
public string Text { get; set; } = string.Empty;
/// <summary>true = hervorgehobene Kachel (CSS-Klasse "active").</summary>
public bool Highlighted { get; set; }
}
@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
namespace BewerbungsSeite.Models.SiteContent;
/// <summary>Startbereich der Seite (section id="hero").</summary>
public class HeroSection : IValidatableObject
{
[StringLength(ContentLimits.Kicker, ErrorMessage = ContentLimits.TooLongMessage)]
public string Kicker { get; set; } = string.Empty;
/// <summary>Grosse Titelzeilen. Die letzte Zeile wird farblich hervorgehoben.</summary>
public List<string> TitleLines { get; set; } = new();
[StringLength(ContentLimits.ParagraphText, ErrorMessage = ContentLimits.TooLongMessage)]
public string Intro { get; set; } = string.Empty;
public LinkItem PrimaryButton { get; set; } = new();
public LinkItem SecondaryButton { get; set; } = new();
/// <summary>Kleine Links unter den Buttons (LinkedIn, E-Mail, ...).</summary>
public List<LinkItem> SocialLinks { get; set; } = new();
[StringLength(ContentLimits.Url, ErrorMessage = ContentLimits.TooLongMessage)]
public string PortraitImage { get; set; } = string.Empty;
[StringLength(ContentLimits.AltText, ErrorMessage = ContentLimits.TooLongMessage)]
public string PortraitAlt { get; set; } = string.Empty;
/// <summary>
/// Prueft die Titelzeilen einzeln: [StringLength] laesst sich nicht direkt
/// auf eine List&lt;string&gt; anwenden, deshalb hier von Hand.
/// </summary>
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
for (var i = 0; i < TitleLines.Count; i++)
{
if (TitleLines[i]?.Length > ContentLimits.Heading)
{
yield return new ValidationResult(
$"Titelzeile {i + 1} darf höchstens {ContentLimits.Heading} Zeichen lang sein.",
[nameof(TitleLines)]);
}
}
}
}
@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
namespace BewerbungsSeite.Models.SiteContent;
/// <summary>
/// Wurzelobjekt des gesamten Seiteninhalts. Wird als JSON serialisiert und
/// in einer einzigen Zeile der Tabelle PageContent abgelegt.
/// Neue Bereiche kommen als zusaetzliche Property dazu; alte Datensaetze
/// kennen die Property noch nicht und erhalten beim Laden den Initialwert.
/// </summary>
public class PageContent
{
public HeroSection Hero { get; set; } = new();
public AboutSection About { get; set; } = new();
public SkillsSection Skills { get; set; } = new();
public ProjectsSection Projects { get; set; } = new();
public GoalsSection Goals { get; set; } = new();
public ExperienceSection Experience { get; set; } = new();
public DocumentsSection Documents { get; set; } = new();
public ContactSection Contact { get; set; } = new();
}
/// <summary>Ein Link mit Beschriftung, z. B. in der Hero-Section oder auf einer Projektkarte.</summary>
public class LinkItem
{
[StringLength(ContentLimits.ButtonLabel, ErrorMessage = ContentLimits.TooLongMessage)]
public string Text { get; set; } = string.Empty;
[StringLength(ContentLimits.Url, ErrorMessage = ContentLimits.TooLongMessage)]
public string Url { get; set; } = string.Empty;
}
/// <summary>Ein Abschnitt aus Zwischentitel und Text, z. B. "TECH:" auf einer Projektkarte.</summary>
public class DetailBlock
{
[StringLength(ContentLimits.DetailHeading, ErrorMessage = ContentLimits.TooLongMessage)]
public string Heading { get; set; } = string.Empty;
[StringLength(ContentLimits.ParagraphText, ErrorMessage = ContentLimits.TooLongMessage)]
public string Text { get; set; } = string.Empty;
}
@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations;
namespace BewerbungsSeite.Models.SiteContent;
/// <summary>Bereich "Projekte" (section id="projects").</summary>
public class ProjectsSection
{
[StringLength(ContentLimits.Kicker, ErrorMessage = ContentLimits.TooLongMessage)]
public string Kicker { get; set; } = string.Empty;
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string Title { get; set; } = string.Empty;
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string TitleHighlight { get; set; } = string.Empty;
public List<ProjectItem> Items { get; set; } = new();
}
/// <summary>Eine Projektkarte.</summary>
public class ProjectItem
{
[StringLength(ContentLimits.Url, ErrorMessage = ContentLimits.TooLongMessage)]
public string Image { get; set; } = string.Empty;
[StringLength(ContentLimits.AltText, ErrorMessage = ContentLimits.TooLongMessage)]
public string ImageAlt { get; set; } = string.Empty;
[StringLength(ContentLimits.CardTitle, ErrorMessage = ContentLimits.TooLongMessage)]
public string Title { get; set; } = string.Empty;
/// <summary>Optionaler fett gesetzter Auftakt vor der Beschreibung, z. B. "Ziel:".</summary>
[StringLength(ContentLimits.DetailHeading, ErrorMessage = ContentLimits.TooLongMessage)]
public string DescriptionLead { get; set; } = string.Empty;
[StringLength(ContentLimits.ParagraphText, ErrorMessage = ContentLimits.TooLongMessage)]
public string Description { get; set; } = string.Empty;
/// <summary>Zwischentitel mit Text ("TECH:", "Meine Arbeit:", ...).</summary>
public List<DetailBlock> Details { get; set; } = new();
public List<LinkItem> Links { get; set; } = new();
/// <summary>true = breite Karte ueber die volle Breite mit Bild links.</summary>
public bool Wide { get; set; }
}
@@ -0,0 +1,30 @@
namespace BewerbungsSeite.Models.SiteContent;
/// <summary>
/// Modell eines Section-Partials: der Inhalt des Bereichs und die Angabe,
/// ob die Seite im Bearbeitungsmodus gerendert wird. Dasselbe Partial wird
/// so von der oeffentlichen Seite und von der Admin-Seite benutzt.
/// </summary>
public class SectionView<T>(T section, bool editMode)
{
public T Section { get; } = section;
public bool EditMode { get; } = editMode;
}
public static class SectionView
{
public static SectionView<T> For<T>(T section, bool editMode) => new(section, editMode);
}
/// <summary>
/// Ein Textstueck, das im Edit-Modus in ein bearbeitbares Element eingepackt wird
/// und im normalen Modus als reiner Text erscheint. So bleibt das oeffentliche
/// Markup unveraendert, auch wenn der Text mit anderen Elementen eine Zeile teilt.
/// </summary>
public class EditableText(string text, string field, bool editMode, int? max = null)
{
public string Text { get; } = text;
public string Field { get; } = field;
public bool EditMode { get; } = editMode;
public int? Max { get; } = max;
}
@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
namespace BewerbungsSeite.Models.SiteContent;
/// <summary>Bereich "Kompetenzen" (section id="skills").</summary>
public class SkillsSection
{
[StringLength(ContentLimits.Kicker, ErrorMessage = ContentLimits.TooLongMessage)]
public string Kicker { get; set; } = string.Empty;
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string Title { get; set; } = string.Empty;
/// <summary>Hervorgehobener Teil des Titels.</summary>
[StringLength(ContentLimits.Heading, ErrorMessage = ContentLimits.TooLongMessage)]
public string TitleHighlight { get; set; } = string.Empty;
[StringLength(ContentLimits.ParagraphText, ErrorMessage = ContentLimits.TooLongMessage)]
public string Intro { get; set; } = string.Empty;
public List<SkillItem> Items { get; set; } = new();
}
/// <summary>Eine Kompetenz-Kachel.</summary>
public class SkillItem
{
/// <summary>CSS-Klasse des Icons, z. B. "flaticon-pen".</summary>
[StringLength(ContentLimits.IconClass, ErrorMessage = ContentLimits.TooLongMessage)]
public string Icon { get; set; } = string.Empty;
[StringLength(ContentLimits.TileTitle, ErrorMessage = ContentLimits.TooLongMessage)]
public string Title { get; set; } = string.Empty;
[StringLength(ContentLimits.TileText, ErrorMessage = ContentLimits.TooLongMessage)]
public string Text { get; set; } = string.Empty;
/// <summary>true = hervorgehobene Kachel (CSS-Klasse "active").</summary>
public bool Highlighted { get; set; }
}
+70 -5
View File
@@ -1,11 +1,76 @@
@page
@page
@model BewerbungsSeite.Pages.Admin.IndexModel
@{
ViewData["Title"] = "Admin";
ViewData["Title"] = "Admin – Bewerbungsseite";
var edit = Model.EditMode;
}
<h1>Welcome, @User.Identity?.Name</h1>
@section Styles {
<partial name="_PortfolioStyles" />
<link rel="stylesheet" href="~/css/page-editor.css" />
}
<form method="post" asp-page-handler="Logout">
<button type="submit" class="btn btn-secondary">Logout</button>
<div class="pe-toolbar">
<span class="pe-user">Angemeldet als @User.Identity?.Name</span>
@if (edit)
{
<span id="pe-status" class="pe-status" role="status">Bereit</span>
<a class="pe-toolbar-btn pe-toolbar-btn-primary" href="/Admin">Bearbeiten beenden</a>
}
else
{
<a class="pe-toolbar-btn pe-toolbar-btn-primary" href="/Admin?edit=true" title="Seite bearbeiten">&#9998; Bearbeiten</a>
}
<a class="pe-toolbar-btn" href="/" target="_blank" rel="noopener">Öffentliche Seite</a>
<form method="post" asp-page-handler="Logout" class="pe-logout">
<button type="submit" class="pe-toolbar-btn">Logout</button>
</form>
</div>
<div id="page-editor" class="@(edit ? "pe-editing" : null)">
<partial name="Sections/_Hero" model="SectionView.For(Model.PageContent.Hero, edit)" />
<partial name="Sections/_About" model="SectionView.For(Model.PageContent.About, edit)" />
<partial name="Sections/_Skills" model="SectionView.For(Model.PageContent.Skills, edit)" />
<partial name="Sections/_Projects" model="SectionView.For(Model.PageContent.Projects, edit)" />
<partial name="Sections/_Goals" model="SectionView.For(Model.PageContent.Goals, edit)" />
<partial name="Sections/_Experience" model="SectionView.For(Model.PageContent.Experience, edit)" />
<partial name="Sections/_Documents" model="SectionView.For(Model.PageContent.Documents, edit)" />
<partial name="Sections/_Contact" model="SectionView.For(Model.PageContent.Contact, edit)" />
</div>
@if (edit)
{
@Html.AntiForgeryToken()
@* accept wird je nach Auswahl vom Editor gesetzt (Bild oder PDF). *@
<input type="file" id="pe-file-input" hidden />
@* Erscheint nur, wenn die Sitzung abgelaufen ist. Nach dem Anmelden wird
die fehlgeschlagene Aktion wiederholt, die Seite bleibt stehen. *@
<div id="pe-login" class="pe-login" hidden>
<div class="pe-login-box">
<h2>Sitzung abgelaufen</h2>
<p>Deine Änderungen sind noch da. Melde dich an, dann wird der letzte Schritt automatisch wiederholt.</p>
<label for="pe-login-user">Benutzername</label>
<input type="text" id="pe-login-user" autocomplete="username" />
<label for="pe-login-pass">Passwort</label>
<input type="password" id="pe-login-pass" autocomplete="current-password" />
<p class="pe-login-error" id="pe-login-error" hidden></p>
<button type="button" id="pe-login-submit">Anmelden</button>
</div>
</div>
}
@section Scripts {
<partial name="_PortfolioScripts" />
@if (edit)
{
<script src="~/js/page-editor.js" defer></script>
}
}
+34 -2
View File
@@ -2,13 +2,45 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using BewerbungsSeite.Models.SiteContent;
using BewerbungsSeite.Services;
namespace BewerbungsSeite.Pages.Admin;
public class IndexModel : PageModel
public class IndexModel(IPageContentService pageContentService) : PageModel
{
public void OnGet()
public PageContent PageContent { get; private set; } = new();
/// <summary>Wird ueber ?edit=1 eingeschaltet, wenn der Benutzer auf das Edit-Icon klickt.</summary>
public bool EditMode { get; private set; }
public async Task OnGetAsync(bool edit)
{
EditMode = edit;
PageContent = await pageContentService.GetAsync(HttpContext.RequestAborted);
}
/// <summary>
/// Liefert einen einzelnen Bereich neu gerendert zurueck. Wird nach dem
/// Hinzufuegen, Verschieben oder Loeschen eines Eintrags aufgerufen, damit
/// die Seite ohne vollstaendiges Neuladen wieder stimmt.
/// </summary>
public async Task<IActionResult> OnGetSectionAsync(string name)
{
var content = await pageContentService.GetAsync(HttpContext.RequestAborted);
return name switch
{
"hero" => Partial("Sections/_Hero", SectionView.For(content.Hero, true)),
"about" => Partial("Sections/_About", SectionView.For(content.About, true)),
"skills" => Partial("Sections/_Skills", SectionView.For(content.Skills, true)),
"projects" => Partial("Sections/_Projects", SectionView.For(content.Projects, true)),
"goals" => Partial("Sections/_Goals", SectionView.For(content.Goals, true)),
"experience" => Partial("Sections/_Experience", SectionView.For(content.Experience, true)),
"documents" => Partial("Sections/_Documents", SectionView.For(content.Documents, true)),
"contact" => Partial("Sections/_Contact", SectionView.For(content.Contact, true)),
_ => NotFound()
};
}
public async Task<IActionResult> OnPostLogoutAsync()
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -1,12 +1,20 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using BewerbungsSeite.Models.SiteContent;
using BewerbungsSeite.Services;
namespace BewerbungsSeite.Pages
{
public class IndexModel : PageModel
{
public void OnGet()
public class IndexModel(IPageContentService pageContentService) : PageModel
{
/// <summary>Der aus der Datenbank geladene Seiteninhalt.</summary>
public PageContent PageContent { get; private set; } = new();
/// <summary>Auf der oeffentlichen Seite nie aktiv; nur die Admin-Seite rendert im Editiermodus.</summary>
public bool EditMode => false;
public async Task OnGetAsync()
{
PageContent = await pageContentService.GetAsync(HttpContext.RequestAborted);
}
}
}
@@ -0,0 +1,43 @@
@model SectionView<AboutSection>
@{
var about = Model.Section;
var edit = Model.EditMode;
}
<section id="about" @EditMarkup.Section(edit, "about")>
<div class="about-area style-three">
<div class="container">
<div class="row align-items-center">
<div class="col-lg-6 col-md-6 col-sm-12">
<div class="techno-about-thumb-box">
<div class="techno-about-thumb-3">
<img src="@about.Image" alt="@about.ImageAlt" @EditMarkup.Image(edit, "image", about.Image)>
</div>
</div>
</div>
<div class="col-lg-6 col-md-6 col-sm-12">
<div class="row about-left">
<div class="col-lg-12">
<div class="dreamit-section-title style-four white">
<h5 @EditMarkup.Field(edit, "kicker", max: ContentLimits.Kicker)>@about.Kicker</h5>
<h1 @EditMarkup.Field(edit, "titleLine1", max: ContentLimits.Heading)>@about.TitleLine1</h1>
<h1><partial name="_EditableText" model='new EditableText(about.TitleLine2, "titleLine2", edit, ContentLimits.Heading)' /> <span class="color2" @EditMarkup.Field(edit, "titleLine2Highlight", max: ContentLimits.Heading)>@about.TitleLine2Highlight</span></h1>
<p class="pt-text" @EditMarkup.Field(edit, "text", multiline: true, max: ContentLimits.ParagraphText)>@about.Text</p>
</div>
<div class="about-facts" @EditMarkup.List(edit, "facts")>
@foreach (var fact in about.Facts)
{
<div class="about-fact" @EditMarkup.Item(edit)><strong @EditMarkup.Field(edit, "label", max: ContentLimits.FactLabel)>@fact.Label</strong><span><partial name="_EditableText" model='new EditableText(fact.Value, "value", edit, ContentLimits.FactValue)' /> <i class="fas fa-angle-right"></i></span>@if (edit) {<partial name="_EditItemControls" />}</div>
}
@if (edit)
{
<button type="button" class="pe-add" data-add="facts">+ Eintrag</button>
}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
@@ -0,0 +1,187 @@
@model SectionView<ContactSection>
@{
var contact = Model.Section;
var edit = Model.EditMode;
}
<section id="contact" @EditMarkup.Section(edit, "contact")>
<div class="contact-us-area style-two pt-100">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div class="dreamit-section-title style-two text-center">
<h5 @EditMarkup.Field(edit, "kicker", max: ContentLimits.Kicker)>@contact.Kicker</h5>
<h1><partial name="_EditableText" model='new EditableText(contact.Title, "title", edit, ContentLimits.Heading)' /> <span @EditMarkup.Field(edit, "titleHighlight", max: ContentLimits.Heading)>@contact.TitleHighlight</span></h1>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-4 col-md-6 col-sm-12">
<div class="row" @EditMarkup.List(edit, "infoBoxes")>
@foreach (var box in contact.InfoBoxes)
{
<div class="col-lg-12" @EditMarkup.Item(edit)>
<div class="contact-us-single-box">
<div class="contact-us-icon">
<i class="@box.Icon"></i>
</div>
<div class="contact-us-title">
<h2 @EditMarkup.Field(edit, "title", max: ContentLimits.FactLabel)>@box.Title</h2>
<p>
@if (string.IsNullOrWhiteSpace(box.LinkUrl) && !edit)
{
@box.Text
}
else if (!edit)
{
<a href="@box.LinkUrl">@box.Text</a>
}
else
{
<span data-field="text" data-max="@ContentLimits.FactValue" contenteditable="true">@box.Text</span>
<input class="pe-url" type="text" data-field="linkUrl" maxlength="@ContentLimits.Url" value="@box.LinkUrl" aria-label="Link-Ziel (leer lassen fuer reinen Text)" />
}
</p>
</div>
</div>
@if (edit)
{
<input class="pe-icon" type="text" data-field="icon" maxlength="@ContentLimits.IconClass" value="@box.Icon" aria-label="Icon-Klasse" />
<partial name="_EditItemControls" />
}
</div>
}
@if (edit)
{
<button type="button" class="pe-add" data-add="infoBoxes">+ Kontaktangabe</button>
}
</div>
</div>
@* Das Formular selbst ist Funktionalitaet und bleibt fest im Code;
nur die Einleitung darueber kommt aus dem Inhalt. *@
<div class="col-lg-8 col-md-6 col-sm-12">
<div class="row">
<div class="col-lg-12">
<div class="contact-form-box style-two">
<div id="contact-form-message" class="alert" role="alert" style="display:none;"></div>
<form method="post" id="dreamit-form" novalidate>
@* Zeilenumbrueche im Text werden als <br> gerendert. *@
<h4 @EditMarkup.Field(edit, "formHeading", multiline: true, max: ContentLimits.FormIntro)>
@{ var headingLines = contact.FormHeading.Replace("\r\n", "\n").Split('\n'); }
@for (var i = 0; i < headingLines.Length; i++)
{
if (i > 0)
{
<br />
}
@headingLines[i]
}
</h4>
<div class="row">
<div class="col-lg-6 col-md-6 col-sm-12">
<div class="form-title">
<p>Name (Pflichtfeld)</p>
</div>
<div class="from-box">
<input
type="text"
name="name"
placeholder="Name*"
required>
<div class="invalid-feedback"></div>
</div>
</div>
<div class="col-lg-6 col-md-6 col-sm-12">
<div class="form-title">
<p>E-Mail (Pflichtfeld)</p>
</div>
<div class="from-box">
<input
type="email"
name="email"
placeholder="E-Mail*"
required>
<div class="invalid-feedback"></div>
</div>
</div>
<div class="col-lg-6 col-md-6 col-sm-12">
<div class="form-title">
<p>Telefon (optional)</p>
</div>
<div class="from-box">
<input
type="tel"
name="phone"
placeholder="z. B. 079 123 45 67 oder +41 79 123 45 67">
<div class="invalid-feedback"></div>
</div>
</div>
<div class="col-lg-6 col-md-6 col-sm-12">
<div class="form-title">
<p>Betreff (Pflichtfeld)</p>
</div>
<div class="from-box">
<input
type="text"
name="subject"
placeholder="Betreff*"
required>
<div class="invalid-feedback"></div>
</div>
</div>
<div class="col-lg-12">
<div class="form-title">
<p>Nachricht</p>
</div>
<div class="from-box">
<textarea
name="message"
cols="30"
rows="10"
placeholder="Nachricht*"
required></textarea>
<div class="invalid-feedback"></div>
</div>
</div>
<div class="col-lg-12">
<div class="contact-form">
<button type="submit">
NACHRICHT SENDEN
<i class="fas fa-check"></i>
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
@@ -0,0 +1,43 @@
@model SectionView<DocumentsSection>
@{
var documents = Model.Section;
var edit = Model.EditMode;
}
<section id="documents" class="portfolio-section" @EditMarkup.Section(edit, "documents")>
<div class="container">
<div class="dreamit-section-title style-two text-center section-title">
<h5 @EditMarkup.Field(edit, "kicker", max: ContentLimits.Kicker)>@documents.Kicker</h5>
<h1><partial name="_EditableText" model='new EditableText(documents.Title, "title", edit, ContentLimits.Heading)' /> <span @EditMarkup.Field(edit, "titleHighlight", max: ContentLimits.Heading)>@documents.TitleHighlight</span></h1>
</div>
<div class="row documents-grid" @EditMarkup.List(edit, "items")>
@foreach (var item in documents.Items)
{
<div class="col-lg-6" @EditMarkup.Item(edit)>
<div class="single-circular-box">
<div class="circular-title">
<h3 @EditMarkup.Field(edit, "title", max: ContentLimits.CardTitle)>@item.Title</h3>
<p @EditMarkup.Field(edit, "description", multiline: true, max: ContentLimits.ParagraphText)>@item.Description</p>
</div>
<div class="circular-btn">
<a href="@item.FileUrl" target="_blank" rel="noopener" @EditMarkup.Field(edit, "linkText", max: ContentLimits.ButtonLabel)>@item.LinkText</a>
</div>
</div>
@if (edit)
{
<div class="pe-file-row">
<input class="pe-url" type="text" data-field="fileUrl" maxlength="@ContentLimits.Url" value="@item.FileUrl" aria-label="Pfad zur Datei" />
<button type="button" class="pe-file" data-file="documents" data-accept="application/pdf">PDF hochladen</button>
</div>
<partial name="_EditItemControls" />
}
</div>
}
</div>
@if (edit)
{
<button type="button" class="pe-add" data-add="items">+ Dokument</button>
}
</div>
</section>
@@ -0,0 +1,61 @@
@model SectionView<ExperienceSection>
@{
var experience = Model.Section;
var edit = Model.EditMode;
}
<section id="experience" @EditMarkup.Section(edit, "experience")>
<div class="career-details">
<div class="container">
<div class="dreamit-section-title style-two text-center section-title">
<h5 @EditMarkup.Field(edit, "kicker", max: ContentLimits.Kicker)>@experience.Kicker</h5>
<h1><partial name="_EditableText" model='new EditableText(experience.Title, "title", edit, ContentLimits.Heading)' /> <span @EditMarkup.Field(edit, "titleHighlight", max: ContentLimits.Heading)>@experience.TitleHighlight</span></h1>
</div>
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="experience-timeline single-details-box" @EditMarkup.List(edit, "items")>
@foreach (var item in experience.Items)
{
<article class="experience-item@(item.Collapsed && !edit ? " experience-item-extra" : null)" @EditMarkup.Item(edit)>
<h3 @EditMarkup.Field(edit, "title", max: ContentLimits.CardTitle)>@item.Title</h3>
<div class="experience-meta" @EditMarkup.Field(edit, "meta", max: ContentLimits.Meta)>@item.Meta</div>
<div class="details-text">
<h4 @EditMarkup.Field(edit, "detailHeading", max: ContentLimits.DetailHeading)>@item.DetailHeading</h4>
<p @EditMarkup.Field(edit, "text", multiline: true, max: ContentLimits.ParagraphText)>@item.Text</p>
</div>
@if (edit)
{
<partial name="_EditItemControls" />
<label class="pe-flag"><input type="checkbox" data-flag="collapsed" checked="@(item.Collapsed ? "checked" : null)" /> erst nach Klick auf den Button sichtbar</label>
}
</article>
}
</div>
@if (edit)
{
<button type="button" class="pe-add" data-add="items">+ Erfahrung</button>
}
<div class="experience-toggle-wrap">
<button type="button"
id="experience-toggle"
class="experience-toggle-btn"
aria-expanded="false"
data-more-text="@experience.ToggleMoreText"
data-less-text="@experience.ToggleLessText">
@experience.ToggleMoreText
</button>
</div>
@if (edit)
{
<div class="pe-toggle-texts">
<label>Button zugeklappt <input type="text" data-field="toggleMoreText" maxlength="@ContentLimits.ButtonLabel" value="@experience.ToggleMoreText" /></label>
<label>Button aufgeklappt <input type="text" data-field="toggleLessText" maxlength="@ContentLimits.ButtonLabel" value="@experience.ToggleLessText" /></label>
</div>
}
</div>
</div>
</div>
</div>
</section>
@@ -0,0 +1,54 @@
@model SectionView<GoalsSection>
@{
var goals = Model.Section;
var edit = Model.EditMode;
}
<section id="goals" @EditMarkup.Section(edit, "goals")>
<div class="service-area">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div class="dreamit-section-title text-center">
<h5 @EditMarkup.Field(edit, "kicker", max: ContentLimits.Kicker)>@goals.Kicker</h5>
<h1><partial name="_EditableText" model='new EditableText(goals.Title, "title", edit, ContentLimits.Heading)' /> <span @EditMarkup.Field(edit, "titleHighlight", max: ContentLimits.Heading)>@goals.TitleHighlight</span></h1>
</div>
</div>
</div>
<div class="row goals-grid" @EditMarkup.List(edit, "items")>
@for (var i = 0; i < goals.Items.Count; i++)
{
var item = goals.Items[i];
@* Vier Kacheln pro Reihe, jede zweite in der abweichenden Theme-Variante. *@
var boxClass = i % 2 == 1 ? "techno-sinlge-service-box-1" : "techno-sinlge-service-box";
if (item.Highlighted)
{
boxClass += " active";
}
<div class="col-lg-3 col-md-6 col-sm-12" @EditMarkup.Item(edit)>
<div class="@boxClass">
<div class="techno-service-box-inner">
<div class="techno-service-content">
<div class="techno-service-icon"><i class="@item.Icon"></i></div>
<div class="techno-service-title"><h2 @EditMarkup.Field(edit, "title", max: ContentLimits.TileTitle)>@item.Title</h2><p @EditMarkup.Field(edit, "text", multiline: true, max: ContentLimits.TileText)>@item.Text</p></div>
</div>
</div>
</div>
@if (edit)
{
<input class="pe-icon" type="text" data-field="icon" maxlength="@ContentLimits.IconClass" value="@item.Icon" aria-label="Icon-Klasse" />
<partial name="_EditItemControls" />
<label class="pe-flag"><input type="checkbox" data-flag="highlighted" checked="@(item.Highlighted ? "checked" : null)" /> hervorgehoben</label>
}
</div>
}
</div>
@if (edit)
{
<button type="button" class="pe-add" data-add="items">+ Ziel</button>
}
</div>
</div>
</section>
@@ -0,0 +1,93 @@
@model SectionView<HeroSection>
@{
var hero = Model.Section;
var edit = Model.EditMode;
var lastLine = hero.TitleLines.Count - 1;
}
<section id="hero" class="slider-area slider8 d-flex align-items-center" @EditMarkup.Section(edit, "hero")>
<div class="container">
<div class="row d-flex align-items-center slider">
<div class="col-lg-7 col-md-12 col-sm-12">
<div class="slider-content" @EditMarkup.StringList(edit, "titleLines")>
<p class="section-kicker" @EditMarkup.Field(edit, "kicker", max: ContentLimits.Kicker)>@hero.Kicker</p>
@for (var i = 0; i < hero.TitleLines.Count; i++)
{
@if (i == lastLine)
{
<h1 @EditMarkup.Item(edit)><span class="color3" @EditMarkup.Editable(edit, max: ContentLimits.Heading)>@hero.TitleLines[i]</span></h1>
}
else
{
<h1 @EditMarkup.Item(edit) @EditMarkup.Editable(edit, max: ContentLimits.Heading)>@hero.TitleLines[i]</h1>
}
}
<p @EditMarkup.Field(edit, "intro", multiline: true, max: ContentLimits.ParagraphText)>@hero.Intro</p>
<div class="hero-actions">
<div class="slider-button2" @EditMarkup.Object(edit, "primaryButton")>
<a href="@hero.PrimaryButton.Url"><partial name="_EditableText" model='new EditableText(hero.PrimaryButton.Text, "text", edit, ContentLimits.ButtonLabel)' /> <i class="fas fa-check-square"></i></a>
@if (edit)
{
<input class="pe-url" type="url" data-field="url" maxlength="@ContentLimits.Url" value="@hero.PrimaryButton.Url" aria-label="Ziel des Buttons" />
}
</div>
@if (edit)
{
<span data-object="secondaryButton">
<a class="hero-secondary" href="@hero.SecondaryButton.Url">
<span data-field="text" data-max="@ContentLimits.ButtonLabel" contenteditable="true">@hero.SecondaryButton.Text</span>
</a>
<input class="pe-url" type="url" data-field="url" maxlength="@ContentLimits.Url" value="@hero.SecondaryButton.Url" aria-label="Ziel des zweiten Buttons" />
</span>
}
else
{
<a class="hero-secondary" href="@hero.SecondaryButton.Url">
@hero.SecondaryButton.Text
</a>
}
</div>
<div class="hero-social" @EditMarkup.List(edit, "socialLinks")>
@foreach (var link in hero.SocialLinks)
{
var external = !link.Url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase);
@if (edit)
{
<span class="pe-item-inline" data-item="true">
<a href="@link.Url" target="@(external ? "_blank" : null)" rel="@(external ? "noopener" : null)" data-field="text" data-max="@ContentLimits.ButtonLabel" contenteditable="true">@link.Text</a>
<input class="pe-url" type="url" data-field="url" maxlength="@ContentLimits.Url" value="@link.Url" aria-label="Link-Ziel" />
<partial name="_EditItemControls" />
</span>
}
else if (external)
{
<a href="@link.Url" target="_blank" rel="noopener">@link.Text</a>
}
else
{
<a href="@link.Url">@link.Text</a>
}
}
@if (edit)
{
<button type="button" class="pe-add" data-add="socialLinks">+ Link</button>
}
</div>
</div>
</div>
<div class="col-lg-5">
<div class="slider-thumb-images">
<img src="@hero.PortraitImage" alt="@hero.PortraitAlt" @EditMarkup.Image(edit, "portraitImage", hero.PortraitImage)>
</div>
<div class="slider-shape">
<div class="shape-inner bounce-animate2">
<img src="~/images/slider/shape1.png" alt="">
</div>
<div class="shape-inner-1 bounce-animate4">
<img src="~/images/slider/shape2.png" alt="">
</div>
</div>
</div>
</div>
</div>
</section>
@@ -0,0 +1,141 @@
@model SectionView<ProjectsSection>
@{
var projects = Model.Section;
var edit = Model.EditMode;
}
@functions {
// Titel, Beschreibung, Detailbloecke und Links -- identisch fuer schmale und breite Karten.
async Task RenderCardBody(ProjectItem project, bool edit)
{
<div class="portfolio-details-titles">
<h3 @EditMarkup.Field(edit, "title", max: ContentLimits.CardTitle)>@project.Title</h3>
<p>
@if (edit)
{
<strong data-field="descriptionLead" data-max="@ContentLimits.DetailHeading" contenteditable="true">@project.DescriptionLead</strong>
}
else if (!string.IsNullOrWhiteSpace(project.DescriptionLead))
{
<strong>@project.DescriptionLead</strong>@:
}
<partial name="_EditableText" model='new EditableText(project.Description, "description", edit, ContentLimits.ParagraphText)' />
</p>
</div>
<div class="details-category" @EditMarkup.List(edit, "details")>
@foreach (var detail in project.Details)
{
@if (edit)
{
<div data-item="true">
<h4 data-field="heading" data-max="@ContentLimits.DetailHeading" contenteditable="true">@detail.Heading</h4>
<p data-field="text" data-multiline="true" data-max="@ContentLimits.ParagraphText" contenteditable="true">@detail.Text</p>
<partial name="_EditItemControls" />
</div>
}
else
{
<h4>@detail.Heading</h4>
<p>@detail.Text</p>
}
}
@if (edit)
{
<button type="button" class="pe-add" data-add="details">+ Abschnitt</button>
}
</div>
@if (edit)
{
<div class="project-actions" data-list="links">
@foreach (var link in project.Links)
{
<span class="pe-item-inline" data-item="true">
<a href="@link.Url" target="_blank" rel="noopener" data-field="text" data-max="@ContentLimits.ButtonLabel" contenteditable="true">@link.Text</a>
<input class="pe-url" type="url" data-field="url" maxlength="@ContentLimits.Url" value="@link.Url" aria-label="Link-Ziel" />
<partial name="_EditItemControls" />
</span>
}
<button type="button" class="pe-add" data-add="links">+ Link</button>
</div>
}
else if (project.Links.Count > 0)
{
<div class="project-actions">
@for (var i = 0; i < project.Links.Count; i++)
{
var link = project.Links[i];
<a class="@(i == 0 ? null : "github")" href="@link.Url" target="_blank" rel="noopener">@link.Text</a>
}
</div>
}
}
}
<section id="projects" class="portfolio-section" @EditMarkup.Section(edit, "projects")>
<div class="container" @EditMarkup.List(edit, "items")>
<div class="dreamit-section-title style-two text-center section-title">
<h5 @EditMarkup.Field(edit, "kicker", max: ContentLimits.Kicker)>@projects.Kicker</h5>
<h1><partial name="_EditableText" model='new EditableText(projects.Title, "title", edit, ContentLimits.Heading)' /> <span @EditMarkup.Field(edit, "titleHighlight", max: ContentLimits.Heading)>@projects.TitleHighlight</span></h1>
</div>
@{
// Schmale Karten stehen zu zweit in einer Reihe, breite Karten je allein.
var index = 0;
}
@while (index < projects.Items.Count)
{
var project = projects.Items[index];
if (project.Wide)
{
<div class="row">
<div class="col-lg-12" @EditMarkup.Item(edit)>
<article class="project-card project-card-expanded">
<div class="row align-items-stretch g-0">
<div class="col-lg-5">
<div class="portfolio-details-thumb"><img src="@project.Image" alt="@project.ImageAlt" @EditMarkup.Image(edit, "image", project.Image)></div>
</div>
<div class="col-lg-7">
@{ await RenderCardBody(project, edit); }
</div>
</div>
</article>
@if (edit)
{
<partial name="_EditItemControls" />
<label class="pe-flag"><input type="checkbox" data-flag="wide" checked="checked" /> breite Karte</label>
}
</div>
</div>
index++;
}
else
{
var pair = projects.Items.Skip(index).TakeWhile(p => !p.Wide).Take(2).ToList();
<div class="row">
@foreach (var narrow in pair)
{
<div class="col-lg-6 col-md-6" @EditMarkup.Item(edit)>
<article class="project-card">
<div class="portfolio-details-thumb"><img src="@narrow.Image" alt="@narrow.ImageAlt" @EditMarkup.Image(edit, "image", narrow.Image)></div>
@{ await RenderCardBody(narrow, edit); }
</article>
@if (edit)
{
<partial name="_EditItemControls" />
<label class="pe-flag"><input type="checkbox" data-flag="wide" /> breite Karte</label>
}
</div>
}
</div>
index += pair.Count;
}
}
@if (edit)
{
<button type="button" class="pe-add" data-add="items">+ Projekt</button>
}
</div>
</section>
@@ -0,0 +1,49 @@
@model SectionView<SkillsSection>
@{
var skills = Model.Section;
var edit = Model.EditMode;
}
<section id="skills" class="portfolio-section" @EditMarkup.Section(edit, "skills")>
<div class="container">
<div class="dreamit-section-title style-two text-center section-title">
<h5 @EditMarkup.Field(edit, "kicker", max: ContentLimits.Kicker)>@skills.Kicker</h5>
<h1><partial name="_EditableText" model='new EditableText(skills.Title, "title", edit, ContentLimits.Heading)' /> <span @EditMarkup.Field(edit, "titleHighlight", max: ContentLimits.Heading)>@skills.TitleHighlight</span></h1>
<p @EditMarkup.Field(edit, "intro", multiline: true, max: ContentLimits.ParagraphText)>@skills.Intro</p>
</div>
<div class="row skills-grid" @EditMarkup.List(edit, "items")>
@for (var i = 0; i < skills.Items.Count; i++)
{
var item = skills.Items[i];
@* Drei Kacheln pro Reihe; die mittlere Spalte hat im Theme eine eigene Variante. *@
var boxClass = i % 3 == 1 ? "techno-sinlge-service-box-1" : "techno-sinlge-service-box";
if (item.Highlighted)
{
boxClass += " active";
}
<div class="col-lg-4 col-md-6 col-sm-12" @EditMarkup.Item(edit)>
<div class="@boxClass">
<div class="techno-service-box-inner">
<div class="techno-service-content">
<div class="techno-service-icon"><i class="@item.Icon"></i></div>
<div class="techno-service-title"><h2 @EditMarkup.Field(edit, "title", max: ContentLimits.TileTitle)>@item.Title</h2><p @EditMarkup.Field(edit, "text", multiline: true, max: ContentLimits.TileText)>@item.Text</p></div>
</div>
</div>
</div>
@if (edit)
{
<input class="pe-icon" type="text" data-field="icon" maxlength="@ContentLimits.IconClass" value="@item.Icon" aria-label="Icon-Klasse" />
<partial name="_EditItemControls" />
<label class="pe-flag"><input type="checkbox" data-flag="highlighted" checked="@(item.Highlighted ? "checked" : null)" /> hervorgehoben</label>
}
</div>
}
</div>
@if (edit)
{
<button type="button" class="pe-add" data-add="items">+ Kompetenz</button>
}
</div>
</section>
@@ -0,0 +1,6 @@
@* Schaltflaechen zum Verschieben und Loeschen eines Listeneintrags. Nur im Edit-Modus gerendert. *@
<span class="pe-item-controls">
<button type="button" class="pe-move-up" title="Nach oben">&#9650;</button>
<button type="button" class="pe-move-down" title="Nach unten">&#9660;</button>
<button type="button" class="pe-delete" title="Eintrag löschen">&#10005;</button>
</span>
@@ -0,0 +1,5 @@
@model EditableText
@if (Model.EditMode)
{<span data-field="@Model.Field" @(Model.Max.HasValue ? Html.Raw($"data-max=\"{Model.Max}\"") : Html.Raw("")) contenteditable="true">@Model.Text</span>}
else
{@Model.Text}
@@ -0,0 +1,132 @@
@* Kontaktformular und Erfahrungs-Toggle. Von /Index und /Admin/Index gemeinsam genutzt. *@
<script>
(function () {
var form = document.getElementById("dreamit-form");
var messageBox = document.getElementById("contact-form-message");
var submitButton = form.querySelector("button[type=submit]");
function showMessage(text, isSuccess) {
messageBox.textContent = text;
messageBox.className = "alert " + (isSuccess ? "alert-success" : "alert-danger");
messageBox.style.display = "block";
}
function isValidPhone(value) {
if (!value) return true;
return /^(?:\+41|0041|0)[\s-]?\d{2}(?:[\s-]?\d{3})(?:[\s-]?\d{2}){2}$/.test(value);
}
function isValidEmail(value) {
return /^[^\s@@]+@@[^\s@@]+\.[^\s@@]+$/.test(value);
}
var validators = {
name: function (value) {
return value.trim() ? "" : "Bitte geben Sie Ihren Namen ein.";
},
email: function (value) {
if (!value.trim()) return "Bitte geben Sie Ihre E-Mail-Adresse ein.";
return isValidEmail(value.trim()) ? "" : "Bitte geben Sie eine gültige E-Mail-Adresse ein.";
},
phone: function (value) {
return isValidPhone(value.trim()) ? "" : "Bitte geben Sie eine gültige Telefonnummer ein.";
},
subject: function (value) {
return value.trim() ? "" : "Bitte geben Sie einen Betreff ein.";
},
message: function (value) {
return value.trim() ? "" : "Bitte geben Sie eine Nachricht ein.";
}
};
function fieldOf(name) {
return form.querySelector("[name=" + name + "]");
}
function validateField(name) {
var field = fieldOf(name);
var feedback = field.closest(".from-box").querySelector(".invalid-feedback");
var error = validators[name](field.value);
field.classList.toggle("is-invalid", !!error);
feedback.textContent = error;
return !error;
}
function validateAll() {
var allValid = true;
Object.keys(validators).forEach(function (name) {
if (!validateField(name)) allValid = false;
});
return allValid;
}
Object.keys(validators).forEach(function (name) {
var field = fieldOf(name);
field.addEventListener("blur", function () { validateField(name); });
field.addEventListener("input", function () {
if (field.classList.contains("is-invalid")) validateField(name);
});
});
form.addEventListener("submit", function (event) {
event.preventDefault();
if (!validateAll()) {
showMessage("Bitte korrigieren Sie die markierten Felder.", false);
return;
}
var payload = {
name: fieldOf("name").value.trim(),
email: fieldOf("email").value.trim(),
phone: fieldOf("phone").value.trim(),
subject: fieldOf("subject").value.trim(),
message: fieldOf("message").value.trim()
};
submitButton.disabled = true;
fetch("/api/v1/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
.then(function (response) {
if (response.ok) {
showMessage("Danke für die Nachricht! Ich melde mich so schnell wie möglich.", true);
form.reset();
} else {
showMessage("Beim Senden ist leider ein Fehler aufgetreten. Bitte versuchen Sie es erneut", false);
}
})
.catch(function () {
showMessage("Beim Senden ist leider ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", false);
})
.finally(function () {
submitButton.disabled = false;
});
});
})();
(function () {
var toggleButton = document.getElementById("experience-toggle");
var timeline = document.querySelector("#experience .experience-timeline");
var experienceSection = document.getElementById("experience");
if (!toggleButton || !timeline) return;
toggleButton.addEventListener("click", function () {
var isExpanded = timeline.classList.toggle("is-expanded");
var moreText = toggleButton.dataset.moreText || "Mehr erfahren";
var lessText = toggleButton.dataset.lessText || "Weniger anzeigen";
toggleButton.textContent = isExpanded ? lessText : moreText;
toggleButton.setAttribute("aria-expanded", isExpanded ? "true" : "false");
if (!isExpanded && experienceSection) {
experienceSection.scrollIntoView({ behavior: "smooth", block: "start" });
}
});
})();
</script>
@@ -0,0 +1,708 @@
@* Layout-Styles der Portfolio-Seite. Von /Index und /Admin/Index gemeinsam genutzt. *@
<style>
.mean-container .mean-bar::before {
content: "Francesco L. D'Amico";
font-size: 16px;
top: 20px;
max-width: 75%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.portfolio-section {
padding: 90px 0;
}
/* Schutz vor zu langen CMS-Texten: Admin-seitig verhindern data-max
und [StringLength] lange Werte von vornherein, aber ein einzelnes,
nicht umbrechbares Wort (z. B. eine ohne Leerzeichen eingefuegte
URL) oder ein aelterer Datensatz von vor den Limits soll auch hier
nie aus seiner Karte herauslaufen. */
#hero, #about, #skills, #projects, #goals, #experience, #documents, #contact {
overflow-wrap: break-word;
}
/* Grosse Ueberschriften (h1) sind meist ein einzelnes Wort oder wenige
kurze Woerter -- ein Umbruch mitten im Wort sieht dort schlimmer aus
als ein leichtes Ueberlaufen. Nur an Leerzeichen umbrechen; ein
einzelnes zu langes Wort laeuft notfalls ueber statt zerrissen zu
werden. */
#hero h1, #about h1, #skills h1, #projects h1, #goals h1, #experience h1, #documents h1, #contact h1 {
overflow-wrap: normal;
word-break: normal;
}
#about {
background: #fff;
}
#skills {
background: #f8f9fb;
}
#projects {
background: #fff;
}
#goals {
background: #f8f9fb;
}
#experience {
background: #fff;
}
#documents {
background: #f8f9fb;
}
#contact {
background: #fff;
}
.section-kicker {
text-transform: uppercase;
letter-spacing: .08em;
font-weight: 700;
}
.section-title {
margin-bottom: 40px;
}
.section-title h2 {
font-size: 38px;
margin-bottom: 8px;
}
.section-title p {
max-width: 760px;
margin: 0 auto;
}
#sticky-header.portfolio-header .container {
max-width: 100%;
padding-left: 40px;
padding-right: 40px;
}
.portfolio-header .techno_menu ul li a {
padding-left: 6px;
padding-right: 6px;
}
#sticky-header.portfolio-header .techno_menu ul.nav_scroll {
display: flex;
flex-wrap: nowrap;
justify-content: center;
margin-left: 0;
}
#sticky-header.portfolio-header .techno_menu > ul > li > a {
margin: 30px 8px;
font-size: 18px;
white-space: nowrap;
}
@@media (min-width: 1650px) {
#sticky-header.portfolio-header {
position: relative;
}
#sticky-header.portfolio-header .col-lg-8 {
position: static;
}
#sticky-header.portfolio-header .techno_menu {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
#sticky-header.portfolio-header {
background: rgba(9, 12, 26, .65);
}
/* Der Header liegt halbtransparent ueber dem Hero. Das Template loest die
Ueberlappung mit margin-bottom: -91px -- ein fester Wert, der nicht mehr
passt, sobald der Name umbricht und der Header hoeher als 91px wird.
Dann bleibt oben ein Streifen ohne Hero dahinter und das weisse body
scheint als grauer Strich durch. Deshalb den Header aus dem Fluss nehmen:
der Hero beginnt so immer bei 0, unabhaengig von der Header-Hoehe. */
#sticky-header.portfolio-header:not(.sticky) {
position: absolute;
top: 0;
left: 0;
width: 100%;
margin-bottom: 0;
}
.portfolio-brand {
font-size: 24px;
font-weight: 800;
color: #fff;
display: inline-block;
padding: 22px 0;
letter-spacing: .02em;
}
.portfolio-brand:hover {
color: #fff;
}
.portfolio-header .header-button,
.portfolio-header .slider-button-text {
display: none;
}
#hero.slider-area.slider8 {
/* Das Grund-Theme setzt hier eine feste height: 950px (style.css).
Damit lief laengerer Text (Titel, Buttons, Links) unten aus dem
dunklen/roten Hintergrund heraus, statt die Section wachsen zu
lassen. height: auto + min-height als Untergrenze behebt das --
der Hintergrund (background-size: cover) deckt dabei jede Hoehe
weiterhin vollstaendig ab. */
height: auto;
min-height: 650px;
background-color: #0b0e1a;
background-image: url(/images/slider/banner-a.png);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
/* Feste Innenabstaende statt sich auf die Zentrierung durch
align-items: center zu verlassen: bei kurzem Text sackt der
Inhalt sonst so weit nach oben, dass der Kicker unter der
halbtransparenten Nav-Leiste verschwindet, und unten fehlt
dann Platz nach den Social-Links. */
padding: 140px 0 60px;
}
#hero .slider-content h1 {
line-height: 1.05;
}
#hero .slider-thumb-images {
margin-left: 40px;
}
#hero .slider-thumb-images img {
aspect-ratio: 1 / 1;
object-fit: cover;
}
#hero .hero-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 25px;
}
#hero .slider-button2 a {
margin-top: 0;
}
#hero .hero-secondary {
display: inline-block;
padding: 17px 28px;
border: 2px solid #fff;
color: #fff;
font-weight: 700;
font-size: 18px;
}
#hero .hero-secondary:hover {
background: #fff;
color: #111;
}
@@media (min-width: 992px) and (max-width: 1199px) {
#hero .hero-secondary {
padding: 13px 26px;
font-size: 16px;
}
}
@@media (min-width: 768px) and (max-width: 991px) {
#hero .hero-secondary {
padding: 12px 24px;
font-size: 16px;
}
}
@@media (min-width: 600px) and (max-width: 767px) {
#hero .hero-secondary {
padding: 13px 23px;
font-size: 15px;
}
}
@@media (max-width: 599px) {
#hero .hero-secondary {
padding: 12px 20px;
font-size: 15px;
}
}
#hero .hero-social {
margin-top: 25px;
}
#hero .hero-social a {
color: #fff;
margin-right: 18px;
text-decoration: underline;
}
#hero .slider-socail-icon2 {
display: none;
}
#about .about-area {
padding: 90px 0;
margin-top: 0;
}
#about .techno-about-thumb-3 {
margin-left: 0;
}
#about .about-facts {
margin-top: 20px;
}
#about .dreamit-section-title h1,
#about .dreamit-section-title p {
color: #151423;
}
#about .about-fact {
background: #f8f9fb;
border: 1px solid #e6e8f0;
padding: 18px 20px;
margin-bottom: 12px;
border-radius: 4px;
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: space-between;
align-items: center;
}
/* Label nicht zusammendruecken lassen, wenn der Wert lang wird. */
#about .about-fact strong {
flex-shrink: 0;
max-width: 40%;
color: #151423;
}
#about .about-fact span {
min-width: 0;
color: #33354b;
}
#about .about-fact i {
color: #0c5adb;
}
#skills .skills-grid {
display: flex;
flex-wrap: wrap;
}
#skills .skills-grid > div {
margin-bottom: 30px;
display: flex;
}
#skills .techno-sinlge-service-box,
#skills .techno-sinlge-service-box-1,
#skills .techno-sinlge-service-box.active {
min-height: 220px;
width: 100%;
}
#skills .techno-service-title p {
margin-bottom: 0;
}
#projects .project-card {
background: #fff;
height: 100%;
margin-bottom: 30px;
box-shadow: 0 8px 30px rgba(20,30,60,.08);
display: flex;
flex-direction: column;
}
#projects .row + .row {
margin-top: 30px;
}
#projects .portfolio-details-thumb img {
width: 100%;
height: 210px;
object-fit: cover;
}
#projects .portfolio-details-titles {
padding: 24px 24px 8px;
}
#projects .details-category {
padding: 0 24px;
}
#projects .project-actions {
padding: 14px 24px 24px;
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: auto;
}
#projects .project-actions a {
display: inline-block;
padding: 10px 14px;
background: #0c5adb;
color: #fff;
font-weight: 700;
border-radius: 2px;
}
#projects .project-actions a.github {
background: #171717;
}
#projects .details-category h4 {
margin-top: 12px;
margin-bottom: 2px;
}
#projects .details-category h4:first-child {
margin-top: 0;
}
#projects .project-card-expanded .portfolio-details-thumb {
height: 100%;
}
#projects .project-card-expanded .portfolio-details-thumb img {
height: 100%;
min-height: 260px;
}
#goals .service-area {
padding: 90px 0;
}
#goals .goals-grid {
display: flex;
flex-wrap: wrap;
}
#goals .goals-grid > div {
margin-bottom: 30px;
display: flex;
}
#goals .techno-sinlge-service-box,
#goals .techno-sinlge-service-box-1 {
min-height: 220px;
width: 100%;
}
#experience .career-details {
padding: 90px 0;
}
#experience .experience-timeline {
position: relative;
padding-left: 36px;
}
#experience .experience-timeline:before {
content: "";
position: absolute;
top: 8px;
bottom: 8px;
left: 8px;
width: 2px;
background: #0c5adb;
}
#experience .experience-item {
position: relative;
margin-bottom: 28px;
padding: 26px 30px;
background: #fff;
box-shadow: 0 8px 25px rgba(20,30,60,.08);
}
#experience .experience-item:before {
content: "";
position: absolute;
left: -34px;
top: 31px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
border: 3px solid #0c5adb;
}
#experience .experience-item h3 {
margin-bottom: 6px;
}
#experience .experience-meta {
color: #0c5adb;
font-weight: 700;
margin-bottom: 12px;
}
#experience .experience-item-extra {
display: none;
}
#experience .experience-timeline.is-expanded .experience-item-extra {
display: block;
}
#experience .experience-toggle-wrap {
text-align: center;
margin-top: 30px;
}
#experience .experience-toggle-btn {
display: inline-block;
padding: 14px 28px;
background: #0c5adb;
color: #fff;
font-weight: 700;
border: none;
border-radius: 2px;
cursor: pointer;
}
#documents .documents-grid {
display: flex;
flex-wrap: wrap;
}
#documents .documents-grid > div {
margin-bottom: 30px;
display: flex;
}
#documents .single-circular-box {
width: 100%;
min-height: 300px;
display: flex;
flex-direction: column;
justify-content: space-between;
margin-bottom: 0;
padding-bottom: 24px;
}
#documents .circular-btn {
position: static;
text-align: left;
margin-top: 20px;
clear: both;
}
#contact .contact-us-area {
padding: 90px 0;
}
#contact .contact-copy {
padding-right: 35px;
}
#contact .contact-links {
margin-top: 22px;
display: flex;
gap: 18px;
flex-wrap: wrap;
}
#contact .contact-links a {
font-weight: 700;
text-decoration: underline;
}
#contact .contact-visual img {
width: 100%;
max-height: 390px;
object-fit: contain;
}
#contact .contact-button {
margin-top: 22px;
}
#contact .contact-form {
margin-top: 10px;
}
#contact .contact-form button[type="submit"] {
display: inline-block;
padding: 12px 26px;
background: #0c5adb;
color: #fff;
font-weight: 700;
font-family: "Rajdhani";
font-size: 15px;
text-transform: uppercase;
letter-spacing: .04em;
border: none;
border-radius: 2px;
cursor: pointer;
transition: 0.3s;
}
#contact .contact-form button[type="submit"] i {
margin-left: 8px;
}
#contact .contact-form button[type="submit"]:hover {
background: #094aab;
}
.portfolio-footer.footer-middle {
padding: 0;
background: #0b0e1a;
}
.portfolio-footer .footer-bottom {
margin-top: 0;
padding: 28px 0;
}
.portfolio-footer .footer-bottom-content-copy p {
margin: 0;
text-align: center;
}
.portfolio-footer .footer-bottom .col-lg-12 {
text-align: center;
}
@@media (max-width: 991px) {
#hero.slider-area.slider8 {
min-height: auto;
padding: 130px 0 80px;
}
#hero .slider-thumb-images {
margin-top: 40px;
text-align: center;
}
.portfolio-brand {
color: #111;
padding: 15px 0;
}
#about .techno-about-thumb-box {
margin-bottom: 30px;
}
.portfolio-section {
padding: 60px 0;
}
#about .about-area,
#goals .service-area,
#experience .career-details,
#contact .contact-us-area {
padding: 60px 0;
}
}
@@media (max-width: 767px) {
.section-title h2,
.dreamit-section-title h1 {
font-size: 28px;
}
#hero.slider-area.slider8 {
padding: 110px 0 60px;
text-align: center;
}
#hero .hero-actions,
#hero .hero-social {
justify-content: center;
}
#about .about-fact {
flex-wrap: wrap;
gap: 6px;
}
#skills .techno-sinlge-service-box,
#skills .techno-sinlge-service-box-1,
#skills .techno-sinlge-service-box.active,
#goals .techno-sinlge-service-box,
#goals .techno-sinlge-service-box-1 {
min-height: 0;
margin-bottom: 20px;
}
#projects .portfolio-details-thumb img {
height: 180px;
}
#projects .project-card {
margin-bottom: 60px;
}
#projects .row + .row {
margin-top: 0;
}
#experience .experience-timeline {
padding-left: 26px;
}
#experience .experience-item {
padding: 20px 18px;
}
#experience .experience-item:before {
left: -24px;
}
#documents .single-circular-box {
text-align: center;
}
#contact .contact-copy {
padding-right: 0;
text-align: center;
margin-bottom: 30px;
}
#contact .contact-links {
justify-content: center;
}
}
@@media (max-width: 480px) {
#hero .slider-content h1 {
font-size: 32px !important;
}
.mean-container .mean-bar::before {
font-size: 14px;
max-width: 65%;
}
}
</style>
@@ -1,3 +1,4 @@
@using BewerbungsSeite
@using BewerbungsSeite.Models.SiteContent
@namespace BewerbungsSeite.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+28
View File
@@ -21,6 +21,31 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
{
options.LoginPath = "/Login";
options.AccessDeniedPath = "/Login";
// Nach 30 Minuten ohne Aktivitaet automatisch abmelden. Die Frist steht im
// verschluesselten Ticket und wird serverseitig geprueft -- sie gilt also auch
// dann, wenn der Browser das Cookie ueber einen Neustart hinweg wiederherstellt.
// SlidingExpiration erneuert sie bei jedem Aufruf, beim Arbeiten fliegt niemand raus.
options.ExpireTimeSpan = TimeSpan.FromMinutes(5);
options.SlidingExpiration = true;
// Das Cookie wird bei Aufrufen von fremden Seiten nicht mitgeschickt.
options.Cookie.SameSite = SameSiteMode.Strict;
// Aufrufe der API sollen 401 bekommen statt einer Weiterleitung auf die
// Login-Seite -- sonst haelt der Editor eine abgelaufene Sitzung faelschlich
// fuer eine erfolgreiche Speicherung.
options.Events.OnRedirectToLogin = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
});
builder.Services.AddAuthorization();
@@ -33,6 +58,9 @@ builder.Services.AddControllers();
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
builder.Services.AddHttpClient();
builder.Services.AddAntiforgery(options => options.HeaderName = "RequestVerificationToken");
builder.Services.AddScoped<IPageContentService, PageContentService>();
builder.Services.AddSingleton<IContactMessageNotificationQueue, ContactMessageNotificationQueue>();
builder.Services.AddHostedService<ContactMessageNotificationService>();
+31
View File
@@ -39,9 +39,40 @@ public static class SeedData
context.SaveChanges();
}
SeedPageContent(context);
}
}
/// <summary>
/// Legt den Startinhalt der Seite einmalig an. Der Startwert stammt aus
/// SeedData/page-content.json, damit die Seite nach der Umstellung optisch
/// unveraendert bleibt. Existiert die Zeile bereits, passiert nichts --
/// spaetere Aenderungen im Editor werden also nie ueberschrieben.
/// </summary>
private static void SeedPageContent(RazorPagesMovieContext context)
{
if (context.PageContent.Any())
{
return;
}
var path = Path.Combine(AppContext.BaseDirectory, "SeedData", "page-content.json");
if (!File.Exists(path))
{
return;
}
context.PageContent.Add(new PageContentEntity
{
Id = PageContentEntity.SingletonId,
Content = File.ReadAllBytes(path),
UpdatedAt = DateTime.UtcNow
});
context.SaveChanges();
}
private class AdminAccountSeed
{
public string Username { get; set; } = string.Empty;
@@ -0,0 +1,170 @@
{
"hero": {
"kicker": "Applikationsentwickler",
"titleLines": [ "FRANCESCO", "LORENZO", "D'AMICO" ],
"intro": "Ich entwickle gerne Anwendungen, bei denen aus einer Idee Schritt für Schritt eine funktionierende Lösung entsteht. Auf dieser Seite zeige ich ausgewählte Projekte, Technologien, mit denen ich bereits gearbeitet habe, und was ich bisher in der Applikationsentwicklung gelernt habe.",
"primaryButton": { "text": "PROJEKTE ANSEHEN", "url": "#projects" },
"secondaryButton": { "text": "Kontakt aufnehmen", "url": "#contact" },
"socialLinks": [
{ "text": "LinkedIn", "url": "https://www.linkedin.com/in/francesco-d-amico-342b61356/" },
{ "text": "E-Mail", "url": "mailto:Francescodamico649@gmail.com" }
],
"portraitImage": "/images/resource/Bewerbung_Foto.jpeg",
"portraitAlt": "Francesco Lorenzo D'Amico"
},
"about": {
"image": "/images/about/my-personal-workspace.jpg",
"imageAlt": "Über mich",
"kicker": "Über mich",
"titleLine1": "APPLIKATIONSENTWICKLUNG",
"titleLine2": "MIT",
"titleLine2Highlight": "NEUGIER",
"text": "Mich fasziniert an der Softwareentwicklung, dass aus einer Idee Schritt für Schritt eine funktionierende Anwendung entsteht. Dabei möchte ich nicht nur wissen, dass etwas funktioniert, sondern auch verstehen, warum. Ich arbeite gerne strukturiert, probiere neue Technologien aus und verbessere Lösungen iterativ.",
"facts": [
{ "label": "Denkweise", "value": "Produktorientiert & zukunftsgerichtet" },
{ "label": "Arbeitsweise", "value": "Strukturiert & lösungsorientiert" },
{ "label": "Interessen", "value": "Moderne Technologien & Trends" },
{ "label": "Lernhaltung", "value": "Neugier & praxisorientiert" }
]
},
"skills": {
"kicker": "Kompetenzen",
"title": "MEINE TECHNISCHEN",
"titleHighlight": "SKILLS",
"intro": "Technologien und Arbeitsweisen, mit denen ich bereits gearbeitet habe.",
"items": [
{ "icon": "flaticon-pen", "title": "Backend", "text": "ASP.NET Core · Entity Framework Core · REST APIs · C# · PHP · Symfony" },
{ "icon": "flaticon-chat", "title": "Frontend", "text": "React · SASS · Symfony-Twig · PHP Bootstrap · razor-pages · html · css · javascript" },
{ "icon": "flaticon-database", "title": "DB", "text": "MongoDB · MS SQL Server", "highlighted": true },
{ "icon": "flaticon-settings", "title": "Systeme", "text": "Docker · TrueNAS · Webhosting-Grundlagen · Windows · Linux basics" },
{ "icon": "flaticon-growth", "title": "AI", "text": "Prompting · Code-Unterstützung · Recherche" },
{ "icon": "flaticon-clock", "title": "Soft Skills", "text": "Strukturiertes Arbeiten · Lernbereitschaft · selbstständige Fehlersuche · Zuverlässigkeit" }
]
},
"projects": {
"kicker": "Projekte",
"title": "MEINE",
"titleHighlight": "PROJEKTE",
"items": [
{
"image": "/images/projects/bewerbungsseite.png",
"imageAlt": "Bewerbungsseite",
"title": "Bewerbungsseite",
"descriptionLead": "",
"description": "Meine eigene Bewerbungswebsite, entwickelt mit ASP.NET Core Razor Pages, um Lebenslauf, Projekte und Fähigkeiten in einer selbst gebauten Anwendung zu präsentieren.",
"details": [
{ "heading": "TECH:", "text": "C# · ASP.NET Core Razor Pages · Entity Framework Core · Bootstrap · JavaScript" },
{ "heading": "Meine Arbeit:", "text": "Konzeption, Umsetzung und laufende Weiterentwicklung von Struktur, Styling und Inhalten sowie Deployment im eigenen Homelab." }
],
"links": [
{ "text": "Projekt ansehen", "url": "https://francesco-damico.ch/" },
{ "text": "GitHub", "url": "https://github.com/francesco448/Bewerbungs-Portfolio" }
],
"wide": false
},
{
"image": "/images/projects/Screenshot 2026-08-24 153904.png",
"imageAlt": "Francesco's Recipes World",
"title": "Francesco's Recipes World",
"descriptionLead": "",
"description": "Full-Stack-Rezeptverwaltung zum Erstellen, Bearbeiten und Kategorisieren von Rezepten, Zutaten und Einheiten. Die Anwendung basiert auf einer strukturierten MVC-Architektur und relationaler Datenmodellierung.",
"details": [
{ "heading": "TECH:", "text": "C# · ASP.NET Core MVC · Entity Framework Core · JavaScript · AJAX" },
{ "heading": "Meine Arbeit:", "text": "Datenmodellierung, Controller- und Geschäftslogik, Razor-Views, CRUD-Funktionen sowie AJAX-Integration umgesetzt." }
],
"links": [
{ "text": "Projekt ansehen", "url": "https://francescos-rezepten-tool.francesco-damico.ch/" },
{ "text": "GitHub", "url": "https://github.com/francesco448/Francescos.Recipes.World" }
],
"wide": false
},
{
"image": "/images/projects/Screenshot 2026-08-24 154143.png",
"imageAlt": "Realtime Chat App",
"title": "Realtime Chat App",
"descriptionLead": "",
"description": "Echtzeit-Chat-Anwendung mit React-Frontend, Node.js-Backend und persistenter Datenspeicherung. Nachrichten und Räume werden über Socket.IO in Echtzeit synchronisiert.",
"details": [
{ "heading": "TECH:", "text": "React · Node.js · Express · Socket.IO · MongoDB · Docker" },
{ "heading": "Meine Arbeit:", "text": "Frontend und Backend umgesetzt, Echtzeitkommunikation integriert, Datenhaltung angebunden und Anwendung mit Docker containerisiert." }
],
"links": [
{ "text": "Projekt ansehen", "url": "https://chatapp.francesco-damico.ch/" },
{ "text": "GitHub", "url": "https://github.com/francesco448/chat-app" }
],
"wide": true
},
{
"image": "/images/projects/Screenshot 2026-08-24 175017.png",
"imageAlt": "TrueNAS SCALE Dashboard",
"title": "Homelab / Self-Hosting",
"descriptionLead": "Ziel:",
"description": "Aufbau einer eigenen Hosting-Umgebung, um Anwendungen und Dienste selbst zu betreiben und praktische Erfahrung mit Infrastruktur, Netzwerken und Deployment zu sammeln.",
"details": [
{ "heading": "Hardware / System:", "text": "TrueNAS SCALE · Intel Core i5-6600 @ 3.30 GHz · 4 Kerne / 4 Threads · 16 GB RAM · ZFS-Speicherpool mit RAIDZ1 aus 3 HDDs · 1 Gbit Ethernet / Full Duplex" },
{ "heading": "Services:", "text": "Nginx Proxy Manager · Duplicati · Gitea · Scrutiny · Planka sowie selbst gehostete eigene Anwendungen wie Realtime Chat App, Francesco's Recipes World und diese Bewerbungsseite" },
{ "heading": "Sicherheit:", "text": "HTTPS für öffentlich erreichbare Dienste · Reverse Proxy über Nginx Proxy Manager · DNS-basierte Weiterleitung · getrennte Container und Services" },
{ "heading": "Meine Arbeit:", "text": "Planung und Einrichtung der Homelab-Umgebung, Konfiguration von TrueNAS SCALE, Aufbau des ZFS-/RAIDZ1-Speichers, Deployment und Verwaltung von Docker-Containern über Custom Apps und YAML, Einrichtung von Nginx Proxy Manager, DNS-Routing über Subdomains der eigenen Domain francesco-damico.ch und HTTPS sowie Betrieb eigener Webanwendungen und Datenbanken." },
{ "heading": "Gelernt:", "text": "Docker und Container Networking · YAML-basierte Deployments · Reverse Proxy · DNS · HTTPS · ZFS und RAIDZ1 · Self-Hosting · Deployment eigener Anwendungen · Backup-Konzept · Fehleranalyse und Troubleshooting" }
],
"links": [],
"wide": true
}
]
},
"goals": {
"kicker": "Meine Ziele",
"title": "WAS ICH",
"titleHighlight": "ERREICHEN MÖCHTE",
"items": [
{ "icon": "flaticon-growth", "title": "Praxis vertiefen", "text": "Meine bisherigen Kenntnisse in weiteren Projekten anwenden, Verantwortung übernehmen und mich fachlich weiterentwickeln." },
{ "icon": "flaticon-pen", "title": "Moderne Technologien einsetzen", "text": "Mit aktuellen Technologien arbeiten, neue Ansätze kennenlernen und passende Lösungen für reale Anforderungen finden." },
{ "icon": "flaticon-chat", "title": "Eigene Ideen umsetzen", "text": "Projekte weiterdenken, verbessern und aus Ideen langfristig funktionierende Lösungen entwickeln.", "highlighted": true },
{ "icon": "flaticon-clock", "title": "Als Entwickler wachsen", "text": "Meine technischen und persönlichen Fähigkeiten kontinuierlich ausbauen und Schritt für Schritt mehr Verantwortung übernehmen." }
]
},
"experience": {
"kicker": "Erfahrung",
"title": "BERUFLICHE",
"titleHighlight": "ERFAHRUNG",
"toggleMoreText": "Mehr erfahren",
"toggleLessText": "Weniger anzeigen",
"items": [
{ "title": "Agile Methodiken, wie Scrum", "meta": "Schule & Praxis · laufend angewendet", "detailHeading": "Aufgaben & Verantwortlichkeiten:", "text": "Arbeit in klar definierten Aufgaben und Etappen mit regelmässigen Abstimmungen im Team, iterativer Entwicklung von Lösungen mit regelmässigem Feedback sowie flexibler Anpassung an neue Anforderungen und Nachverfolgung des Projektstands.", "collapsed": false },
{ "title": "Git Flow", "meta": "Schule & Praxis · laufend angewendet", "detailHeading": "Aufgaben & Verantwortlichkeiten:", "text": "Strukturierte Versionsverwaltung mit separaten Branches für neue Funktionen und Änderungen sowie kontrolliertes Zusammenführen des Codes im Team.", "collapsed": false },
{ "title": "IPERKA", "meta": "Schule & Praxis · laufend angewendet", "detailHeading": "Aufgaben & Verantwortlichkeiten:", "text": "Strukturiertes Vorgehen bei Aufträgen nach dem Handlungszyklus Informieren, Planen, Entscheiden, Realisieren, Kontrollieren und Auswerten.", "collapsed": false },
{ "title": "Ticketsystem-Arbeit", "meta": "Schule & Praxis · laufend angewendet", "detailHeading": "Aufgaben & Verantwortlichkeiten:", "text": "Erfassen, Priorisieren und Abarbeiten von Aufgaben in Ticketsystemen wie Jira zur strukturierten Nachverfolgung des Projektstands.", "collapsed": true },
{ "title": "Testing & Fehleranalyse", "meta": "Schule & Praxis · laufend angewendet", "detailHeading": "Aufgaben & Verantwortlichkeiten:", "text": "Manuelles Testen von Funktionen sowie systematische Fehleranalyse und Debugging in echten Projekten zur Sicherstellung der Softwarequalität.", "collapsed": true },
{ "title": "Dokumentation", "meta": "Schule · vereinzelt in Projekten", "detailHeading": "Aufgaben & Verantwortlichkeiten:", "text": "Vor allem in der Berufsschule Dokumentationen verfasst; in Projekten mit weiteren Mitarbeitenden zusätzlich Code an relevanten Stellen kommentiert, im Lehrbetrieb selbst eher selten.", "collapsed": true },
{ "title": "CI/CD & Deployment", "meta": "Schule & Praxis · laufend angewendet", "detailHeading": "Aufgaben & Verantwortlichkeiten:", "text": "Containerisierung und Deployment von Anwendungen mit Docker sowie strukturierte Versionierung von Releases.", "collapsed": true },
{ "title": "Datenbank-Design", "meta": "Schule & Praxis · laufend angewendet", "detailHeading": "Aufgaben & Verantwortlichkeiten:", "text": "Modellierung und Normalisierung relationaler Datenbanken in echten Projekten zur strukturierten Datenhaltung.", "collapsed": true },
{ "title": "Code Reviews", "meta": "Schule & Praxis · laufend angewendet", "detailHeading": "Aufgaben & Verantwortlichkeiten:", "text": "Eigenen Code von anderen prüfen lassen, um Feedback zur Codequalität einzuholen und daraus zu lernen.", "collapsed": true }
]
},
"documents": {
"kicker": "Dokumente & Zertifikate",
"title": "DOKUMENTE &",
"titleHighlight": "ZERTIFIKATE",
"items": [
{ "title": "BBC-Zertifikat", "description": "Erfolgreich abgeschlossenes Basislehrjahr Informatik mit praxisnahen Grundlagen in Programmierung, Betriebssystemen, Datenbanken, IoT, Applikationsentwicklung, Webentwicklung Frontend/Backend, Mobile App Entwicklung und individueller Projektarbeit.", "fileUrl": "/documents/Bbc-Zertifikat.pdf", "linkText": "DOKUMENT ÖFFNEN" },
{ "title": "ÜK 106 – Kompetenznachweis", "description": "Vertiefung der Grundlagen im Umgang mit relationalen Datenbanken, insbesondere beim Abfragen, Bearbeiten und Pflegen von Daten sowie beim strukturierten Arbeiten mit Datenbanksystemen.", "fileUrl": "/documents/üK-106-Kompetenznachweis.pdf", "linkText": "DOKUMENT ÖFFNEN" },
{ "title": "ÜK 187 – Kompetenznachweis", "description": "Praktische Auseinandersetzung mit der Einrichtung und Inbetriebnahme eines ICT-Arbeitsplatzes inklusive Betriebssystem, grundlegender Systemkonfiguration und Vorbereitung für den produktiven Einsatz.", "fileUrl": "/documents/üK-187-Kompetenznachweis.pdf", "linkText": "DOKUMENT ÖFFNEN" },
{ "title": "ÜK 216 – Kompetenznachweis", "description": "Integration vernetzter Endgeräte in bestehende Plattformen mit Fokus auf Kommunikation, Einbindung von Geräten und Zusammenspiel verschiedener Komponenten innerhalb einer IoT-Umgebung.", "fileUrl": "/documents/üK-216-Kompetenznachweis.pdf", "linkText": "DOKUMENT ÖFFNEN" },
{ "title": "ÜK 223 – Kompetenznachweis", "description": "Entwicklung einer objektorientierten Anwendung für mehrere Benutzer mit Fokus auf sauberer Programmstruktur, Objektorientierung und der Umsetzung von Multi-User-Funktionalitäten.", "fileUrl": "/documents/üK-223-Kompetenznachweis.pdf", "linkText": "DOKUMENT ÖFFNEN" },
{ "title": "ÜK 295 – Kompetenznachweis", "description": "Umsetzung serverseitiger Applikationslogik mit Fokus auf Backend-Strukturen, Datenverarbeitung und der technischen Grundlage für funktionsfähige Webanwendungen.", "fileUrl": "/documents/üK-295-Kompetenznachweis.pdf", "linkText": "DOKUMENT ÖFFNEN" },
{ "title": "ÜK 335 – Kompetenznachweis", "description": "Planung und Umsetzung einer mobilen Anwendung mit Fokus auf mobile Benutzeroberflächen, Applikationslogik und die praktische Entwicklung für mobile Endgeräte.", "fileUrl": "/documents/üK-335-Kompetenznachweis.pdf", "linkText": "DOKUMENT ÖFFNEN" },
{ "title": "ÜK 294 – Kompetenznachweis", "description": "Entwicklung interaktiver Weboberflächen mit Fokus auf Benutzerinteraktion, strukturiertem Frontend-Aufbau und der Umsetzung funktionaler Webanwendungen.", "fileUrl": "/documents/üK-294-Kompetenznachweis.pdf", "linkText": "DOKUMENT ÖFFNEN" }
]
},
"contact": {
"kicker": "KONTAKT",
"title": "GET IN",
"titleHighlight": "TOUCH",
"formHeading": "Sie möchten mehr über meine Projekte oder meine Bewerbung erfahren?\nIch freue mich über eine Nachricht.",
"infoBoxes": [
{ "icon": "flaticon-suitcase", "title": "STANDORT", "text": "Schweiz, Bern", "linkUrl": "" },
{ "icon": "flaticon-call", "title": "TELEFON", "text": "079 736 73 55", "linkUrl": "tel:0797367355" },
{ "icon": "flaticon-communications", "title": "E-MAIL", "text": "francesco.damico@gmx.ch", "linkUrl": "mailto:francesco.damico@gmx.ch" }
]
}
}
@@ -0,0 +1,77 @@
using System.Text.Json;
using BewerbungsSeite.Models.SiteContent;
namespace BewerbungsSeite.Services;
/// <summary>
/// Wandelt den Seiteninhalt zwischen Objektmodell und UTF-8-JSON um, so wie er
/// im Feld PageContent.Content abgelegt wird.
/// </summary>
public static class PageContentSerializer
{
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
public static byte[] Serialize(PageContent content) =>
JsonSerializer.SerializeToUtf8Bytes(content, Options);
/// <summary>
/// Liest den Inhalt zurueck. Fehlende oder unbekannte Felder fuehren nicht zu
/// einem Fehler: unbekannte werden ignoriert, fehlende erhalten ihren Initialwert.
/// </summary>
public static PageContent Deserialize(byte[]? content)
{
if (content is null || content.Length == 0)
{
return new PageContent();
}
var result = JsonSerializer.Deserialize<PageContent>(content, Options) ?? new PageContent();
return Normalize(result);
}
/// <summary>
/// Stellt sicher, dass kein Bereich null ist, auch wenn ein aelterer
/// Datensatz ihn noch nicht kennt oder ihn ausdruecklich als null enthaelt.
/// </summary>
private static PageContent Normalize(PageContent content)
{
content.Hero ??= new HeroSection();
content.Hero.TitleLines ??= new List<string>();
content.Hero.PrimaryButton ??= new LinkItem();
content.Hero.SecondaryButton ??= new LinkItem();
content.Hero.SocialLinks ??= new List<LinkItem>();
content.About ??= new AboutSection();
content.About.Facts ??= new List<FactItem>();
content.Skills ??= new SkillsSection();
content.Skills.Items ??= new List<SkillItem>();
content.Projects ??= new ProjectsSection();
content.Projects.Items ??= new List<ProjectItem>();
foreach (var project in content.Projects.Items)
{
project.Details ??= new List<DetailBlock>();
project.Links ??= new List<LinkItem>();
}
content.Goals ??= new GoalsSection();
content.Goals.Items ??= new List<GoalItem>();
content.Experience ??= new ExperienceSection();
content.Experience.Items ??= new List<ExperienceItem>();
content.Documents ??= new DocumentsSection();
content.Documents.Items ??= new List<DocumentItem>();
content.Contact ??= new ContactSection();
content.Contact.InfoBoxes ??= new List<ContactInfoItem>();
return content;
}
}
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using BewerbungsSeite.Data;
using BewerbungsSeite.Models;
using BewerbungsSeite.Models.SiteContent;
namespace BewerbungsSeite.Services;
public interface IPageContentService
{
/// <summary>Laedt den Seiteninhalt. Existiert noch keine Zeile, kommt ein leerer Inhalt zurueck.</summary>
Task<PageContent> GetAsync(CancellationToken cancellationToken = default);
/// <summary>Schreibt den kompletten Seiteninhalt in die eine Zeile zurueck.</summary>
Task SaveAsync(PageContent content, CancellationToken cancellationToken = default);
}
public class PageContentService(RazorPagesMovieContext context) : IPageContentService
{
public async Task<PageContent> GetAsync(CancellationToken cancellationToken = default)
{
var row = await context.PageContent
.AsNoTracking()
.SingleOrDefaultAsync(p => p.Id == PageContentEntity.SingletonId, cancellationToken);
return PageContentSerializer.Deserialize(row?.Content);
}
public async Task SaveAsync(PageContent content, CancellationToken cancellationToken = default)
{
var row = await context.PageContent
.SingleOrDefaultAsync(p => p.Id == PageContentEntity.SingletonId, cancellationToken);
if (row is null)
{
row = new PageContentEntity { Id = PageContentEntity.SingletonId };
context.PageContent.Add(row);
}
row.Content = PageContentSerializer.Serialize(content);
row.UpdatedAt = DateTime.UtcNow;
await context.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,373 @@
/* Oberflaeche des Seiten-Editors auf /Admin. Wird auf der oeffentlichen Seite nie geladen. */
.pe-toolbar {
position: sticky;
top: 0;
z-index: 1200;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
background: #101426;
color: #fff;
font-size: 14px;
box-shadow: 0 2px 10px rgba(0, 0, 0, .25);
}
.pe-user {
margin-right: auto;
opacity: .8;
}
.pe-toolbar-btn {
display: inline-block;
padding: 7px 14px;
border: 1px solid rgba(255, 255, 255, .25);
border-radius: 4px;
background: transparent;
color: #fff;
font-size: 14px;
line-height: 1.2;
text-decoration: none;
cursor: pointer;
}
.pe-toolbar-btn:hover {
background: rgba(255, 255, 255, .12);
color: #fff;
}
.pe-toolbar-btn-primary {
background: #2f6bff;
border-color: #2f6bff;
}
.pe-logout {
display: inline;
margin: 0;
}
.pe-status {
padding: 5px 10px;
border-radius: 4px;
background: rgba(255, 255, 255, .1);
font-size: 13px;
white-space: nowrap;
}
.pe-status.is-saving {
background: #7a5d00;
}
.pe-status.is-saved {
background: #1d6b3a;
}
.pe-status.is-error {
background: #8c1f1f;
}
/* --- Bearbeitbare Elemente --- */
.pe-editing [contenteditable="true"] {
outline: 1px dashed rgba(47, 107, 255, .5);
outline-offset: 3px;
min-width: 20px;
min-height: 1em;
max-width: 100%;
/* Lange Woerter ohne Leerzeichen umbrechen, statt aus dem Container zu laufen.
"anywhere" senkt auch die Mindestbreite, damit Flex-/Grid-Eltern nicht mitwachsen. */
overflow-wrap: anywhere;
word-break: break-word;
cursor: text;
}
/* Grosse Ueberschriften (h1) sind meist ein einzelnes Wort oder wenige kurze
Woerter -- ein Umbruch mitten im Wort (siehe oben) sieht dort schlimmer aus
als ein leichtes Ueberlaufen. Deshalb hier zurueck auf normales Verhalten:
nur an Leerzeichen umbrechen, ein einzelnes zu langes Wort laeuft notfalls
ueber statt zerrissen zu werden. */
.pe-editing h1[contenteditable="true"] {
overflow-wrap: normal;
word-break: normal;
}
.pe-editing [contenteditable="true"]:focus {
outline: 2px solid #2f6bff;
background: rgba(47, 107, 255, .08);
}
/* Kurzes Hervorheben des Feldes, das beim Speichern vom Server abgelehnt
wurde (zu lang) -- gesetzt und nach 2.5s wieder entfernt von reportError
in page-editor.js. */
.pe-editing .pe-field-error {
outline: 2px solid #d32f2f !important;
background: rgba(211, 47, 47, .1) !important;
animation: pe-field-error-shake .4s;
}
@keyframes pe-field-error-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
/* Beschriftung der Kurzfakten nicht zusammendruecken, wenn der Wert lang wird. */
.pe-editing #about .about-fact > strong {
flex-shrink: 0;
max-width: 40%;
margin-right: 12px;
}
.pe-editing #about .about-fact > span {
min-width: 0;
}
/* Zeichenzaehler "aktuell / maximal", von page-editor.js hinter jedes
Feld mit data-max eingefuegt. Steht bewusst nicht im Textfluss der
contenteditable-Elemente (eigenes Element danach), damit er beim
Speichern nie versehentlich mit ausgelesen wird. */
.pe-editing .pe-charcount {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 3px;
background: rgba(51, 65, 92, .08);
color: #6b7690;
font-size: 11px;
font-weight: 400;
vertical-align: middle;
white-space: nowrap;
}
.pe-editing .pe-charcount.is-warning {
background: #fff1cc;
color: #8a6400;
}
.pe-editing .pe-charcount.is-limit {
background: #ffe0e0;
color: #a12626;
font-weight: 700;
}
.pe-editing [data-image] {
outline: 2px dashed rgba(47, 107, 255, .6);
outline-offset: 3px;
cursor: pointer;
}
.pe-editing [data-image]:hover {
outline-color: #2f6bff;
}
.pe-editing [data-item] {
position: relative;
}
.pe-item-controls {
display: inline-flex;
gap: 4px;
flex-shrink: 0;
margin: 6px 0;
vertical-align: middle;
}
.pe-item-controls button {
width: 26px;
height: 26px;
padding: 0;
border: 1px solid #c3ccdb;
border-radius: 4px;
background: #fff;
color: #33415c;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.pe-item-controls button:hover {
background: #eef2fa;
}
.pe-item-controls .pe-delete:hover {
background: #ffe9e9;
border-color: #d98686;
color: #a12626;
}
.pe-add {
display: inline-block;
margin: 10px 0;
padding: 7px 14px;
border: 1px dashed #7a92c4;
border-radius: 4px;
background: #f4f7ff;
color: #2f4d94;
font-size: 14px;
cursor: pointer;
}
.pe-add:hover {
background: #e6edff;
}
.pe-url,
.pe-icon {
display: block;
width: 100%;
max-width: 420px;
margin: 6px 0;
padding: 5px 8px;
border: 1px solid #c3ccdb;
border-radius: 4px;
background: #fff;
color: #22304a;
font-size: 13px;
}
.pe-file-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 6px 0;
}
.pe-file-row .pe-url {
flex: 1 1 240px;
margin: 0;
}
.pe-file {
padding: 6px 12px;
border: 1px solid #7a92c4;
border-radius: 4px;
background: #f4f7ff;
color: #2f4d94;
font-size: 13px;
white-space: nowrap;
cursor: pointer;
}
.pe-file:hover {
background: #e6edff;
}
.pe-item-inline {
display: inline-block;
margin-right: 14px;
vertical-align: top;
}
.pe-flag {
display: block;
margin: 4px 0 14px;
color: #445;
font-size: 13px;
font-weight: 400;
}
.pe-toggle-texts {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin: 10px 0 20px;
}
.pe-toggle-texts label {
display: flex;
flex-direction: column;
gap: 4px;
color: #445;
font-size: 13px;
}
.pe-toggle-texts input {
padding: 5px 8px;
border: 1px solid #c3ccdb;
border-radius: 4px;
font-size: 13px;
}
/* Im Bearbeitungsmodus sind auch die eingeklappten Erfahrungseintraege sichtbar. */
.pe-editing .experience-timeline .experience-item-extra {
display: block;
}
/* --- Erneutes Anmelden bei abgelaufener Sitzung --- */
.pe-login {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(9, 12, 26, .6);
}
.pe-login-box {
width: 100%;
max-width: 380px;
padding: 24px;
border-radius: 8px;
background: #fff;
box-shadow: 0 12px 40px rgba(0, 0, 0, .3);
}
.pe-login-box h2 {
margin: 0 0 8px;
color: #101426;
font-size: 20px;
}
.pe-login-box p {
margin: 0 0 16px;
color: #55607a;
font-size: 14px;
line-height: 1.5;
}
.pe-login-box label {
display: block;
margin-bottom: 4px;
color: #33415c;
font-size: 13px;
font-weight: 600;
}
.pe-login-box input {
width: 100%;
margin-bottom: 14px;
padding: 9px 10px;
border: 1px solid #c3ccdb;
border-radius: 4px;
font-size: 14px;
}
.pe-login-box button {
width: 100%;
padding: 10px;
border: 0;
border-radius: 4px;
background: #2f6bff;
color: #fff;
font-size: 15px;
cursor: pointer;
}
.pe-login-box button:disabled {
opacity: .6;
cursor: default;
}
.pe-login-error {
margin: 0 0 12px !important;
padding: 8px 10px;
border-radius: 4px;
background: #ffe9e9;
color: #a12626 !important;
font-size: 13px;
}
+22 -1
View File
@@ -2813,9 +2813,30 @@ li.nav-item {
bottom: 0;
transition: 0.5s;
}
.active.techno-sinlge-service-box:hover:before {
.active.techno-sinlge-service-box-1:before {
position: absolute;
content: "";
z-index: -1;
width: 100%;
height: 100%;
background: #ed2c41;
left: 0;
bottom: 0;
transition: 0.5s;
}
.active.techno-sinlge-service-box:hover:before,
.active.techno-sinlge-service-box-1:hover:before {
height: 0;
}
.active.techno-sinlge-service-box-1:hover .techno-service-icon i {
color: #ed2c41;
}
.active.techno-sinlge-service-box-1:hover .techno-service-title h2 {
color: #151423;
}
.active.techno-sinlge-service-box-1:hover .techno-service-title p {
color: #33354b;
}
.active.techno-sinlge-service-box:hover .techno-service-icon i {
color: #ed2c41;
}
+817
View File
@@ -0,0 +1,817 @@
// Seiten-Editor fuer /Admin?edit=true
//
// Der Inhalt wird nicht in einer JavaScript-Kopie gehalten, sondern jedes Mal
// direkt aus dem DOM gelesen. Die Partials markieren dafuer:
// data-section="hero" ein Bereich
// data-field="kicker" ein Textfeld (Element-Text oder Wert eines <input>)
// data-multiline="true" Zeilenumbrueche im Text erhalten
// data-object="button" ein verschachteltes Objekt
// data-list="items" eine Liste, deren Eintraege data-item tragen
// data-string-list="true" Liste aus reinen Texten statt Objekten
// data-image="image" ein Bild, Pfad steht in data-value
// data-flag="wide" eine Checkbox fuer einen Ja/Nein-Wert
// data-file="documents" Knopf, der eine Datei hochlaedt und den Pfad ins Feld schreibt
// data-max="500" Zeichengrenze eines contenteditable-Feldes, aus
// ContentLimits gerendert (siehe Models/SiteContent)
//
// Dadurch braucht es keine Index-Verwaltung: nach Verschieben oder Loeschen
// ergibt sich die Reihenfolge wieder aus der Reihenfolge im DOM.
(function () {
"use strict";
var root = document.getElementById("page-editor");
if (!root || !root.classList.contains("pe-editing")) {
return;
}
var statusBox = document.getElementById("pe-status");
var fileInput = document.getElementById("pe-file-input");
var loginBox = document.getElementById("pe-login");
var loginError = document.getElementById("pe-login-error");
var loginSubmit = document.getElementById("pe-login-submit");
var tokenField = document.querySelector('input[name="__RequestVerificationToken"]');
var token = tokenField ? tokenField.value : "";
var saveTimer = null;
var pendingRetry = null;
// ---------- Status ----------
function setStatus(text, state) {
if (!statusBox) return;
statusBox.textContent = text;
statusBox.className = "pe-status" + (state ? " is-" + state : "");
}
// ---------- Aus dem DOM lesen ----------
// Das Element, zu dem ein Feld gehoert: der naechste Eintrag, das naechste
// Objekt oder der Bereich darueber.
function ownerOf(node) {
return node.parentElement.closest("[data-item], [data-object], [data-section]");
}
function ownedBy(container, selector) {
return Array.prototype.filter.call(
container.querySelectorAll(selector),
function (node) { return ownerOf(node) === container; });
}
function itemsOf(listElement) {
return Array.prototype.filter.call(
listElement.querySelectorAll("[data-item]"),
function (node) { return node.parentElement.closest("[data-list]") === listElement; });
}
// Gleiche Normalisierung wie beim Speichern: bei einzeiligen Feldern
// werden mehrere Leer- bzw. Zeilenumbruchzeichen zu einem Leerzeichen.
// Der Zeichenzaehler misst dieselbe Laenge, damit er nicht etwas anderes
// anzeigt als das, was tatsaechlich gespeichert wird.
function normalizeText(text, multiline) {
if (!multiline) {
text = text.replace(/\s+/g, " ");
}
return text.trim();
}
// Liest den sichtbaren Text eines Feldes. Der Zeichenzaehler (.pe-charcount)
// steht bei den meisten Feldern als Geschwister ausserhalb -- bei der
// hervorgehobenen letzten Hero-Titelzeile aber technisch bedingt *innerhalb*
// desselben Elements, das hier ausgelesen wird (Feld und Listen-Eintrag sind
// dort zwei verschachtelte Elemente statt eines). Ohne dieses Ausblenden
// wuerde "3 / 50" als echter Text mitgespeichert. Kurzes Verstecken statt
// eines Klons, damit innerText zuverlaessig auf dem echten, gerenderten
// Element berechnet wird (auf einem loesgeloesten Klon liefert innerText in
// manchen Browsern keine verlaesslichen Werte).
function readValue(node) {
if (node.tagName === "INPUT" || node.tagName === "TEXTAREA") {
return node.value;
}
var counters = node.querySelectorAll(".pe-charcount");
var previousDisplay = [];
counters.forEach(function (counter, i) {
previousDisplay[i] = counter.style.display;
counter.style.display = "none";
});
var text = node.innerText;
counters.forEach(function (counter, i) {
counter.style.display = previousDisplay[i];
});
return normalizeText(text, node.dataset.multiline === "true");
}
function collect(container) {
var result = {};
ownedBy(container, "[data-field]").forEach(function (node) {
result[node.dataset.field] = readValue(node);
});
ownedBy(container, "[data-image]").forEach(function (node) {
result[node.dataset.image] = node.dataset.value || "";
});
ownedBy(container, "[data-flag]").forEach(function (node) {
result[node.dataset.flag] = node.checked === true;
});
ownedBy(container, "[data-object]").forEach(function (node) {
result[node.dataset.object] = collect(node);
});
// Listen: nur die, die direkt zu diesem Container gehoeren
Array.prototype.filter.call(
container.querySelectorAll("[data-list]"),
function (node) { return ownerOf(node) === container; }
).forEach(function (listElement) {
result[listElement.dataset.list] = collectList(listElement);
});
return result;
}
function collectList(listElement) {
var asStrings = listElement.dataset.stringList === "true";
return itemsOf(listElement).map(function (item) {
return asStrings ? readValue(item) : collect(item);
});
}
function collectAll() {
var content = {};
root.querySelectorAll("[data-section]").forEach(function (section) {
content[section.dataset.section] = collect(section);
});
return content;
}
// ---------- Zeichenzaehler ----------
//
// Jedes contenteditable-Feld mit data-max bekommt einen Zaehler
// "aktuell / maximal" direkt danach und wird an genau dieser Grenze
// gekappt -- die Zahl kommt serverseitig aus ContentLimits und ist
// dieselbe, die auch [StringLength] beim Speichern durchsetzt.
//
// Ein MutationObserver haelt das auch fuer Felder aktuell, die erst nach
// dem Laden entstehen (neuer Listeneintrag, neu geladener Bereich nach
// saveAndRefresh/putAndRefresh) -- ohne dass jede Stelle, die das DOM
// aendert, den Zaehler von Hand anstossen muesste.
function maxOf(field) {
var max = parseInt(field.dataset.max, 10);
return isNaN(max) ? null : max;
}
function ensureCounter(field) {
var next = field.nextElementSibling;
if (next && next.classList.contains("pe-charcount")) {
return next;
}
var counter = document.createElement("span");
counter.className = "pe-charcount";
field.insertAdjacentElement("afterend", counter);
return counter;
}
function updateCounter(field) {
var max = maxOf(field);
if (max === null) return;
var length = normalizeText(field.innerText, field.dataset.multiline === "true").length;
var counter = ensureCounter(field);
counter.textContent = length + " / " + max;
counter.classList.toggle("is-warning", length >= max * 0.8 && length < max);
counter.classList.toggle("is-limit", length >= max);
}
// Kappt den Rohtext an der Grenze. Bewusst am rohen innerText statt am
// normalisierten Wert, damit die gespeicherte (normalisierte) Laenge nie
// ueber dem serverseitigen Limit liegen kann.
function enforceMax(field) {
var max = maxOf(field);
if (max === null) return;
var raw = field.innerText;
if (raw.length <= max) return;
field.innerText = raw.slice(0, max);
placeCaretAtEnd(field);
}
function placeCaretAtEnd(field) {
var range = document.createRange();
range.selectNodeContents(field);
range.collapse(false);
var selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
var charCountObserver = new MutationObserver(function () {
refreshAllCounters();
});
function refreshAllCounters() {
// Waehrend des eigenen Umbaus nicht auf die dabei entstehenden
// Aenderungen (neue Zaehler-Spans) reagieren -- sonst beobachtet
// sich der Observer selbst und laeuft endlos.
charCountObserver.disconnect();
root.querySelectorAll('[contenteditable="true"][data-max]').forEach(updateCounter);
charCountObserver.observe(root, { childList: true, subtree: true });
}
// ---------- Fehler einordnen ----------
// Wird eine abgelaufene Sitzung erkannt, wirft diese Funktion einen Fehler
// mit sessionExpired = true; der Aufrufer blendet dann die Anmeldung ein.
// Bei allen anderen Fehlern kommt eine lesbare Meldung heraus statt eines
// JSON-Parserfehlers -- 401 und 413 liefern naemlich einen leeren Body.
function describeError(response) {
if (response.status === 401) {
var expired = new Error("Sitzung abgelaufen");
expired.sessionExpired = true;
return Promise.resolve(expired);
}
if (response.status === 413) {
return Promise.resolve(new Error("Die Datei ist zu gross."));
}
return response.text().then(function (body) {
// Eine zu grosse Datei meldet ASP.NET als Validierungsfehler mit
// englischem Text -- daraus wird hier eine verstaendliche Meldung.
if (/request body too large|exceeded the request limit/i.test(body)) {
return new Error("Die Datei ist zu gross.");
}
if (body) {
try {
var parsed = JSON.parse(body);
// Eigene Meldungen des Controllers
if (parsed && parsed.error) {
return new Error(parsed.error);
}
// ProblemDetails: erste Fehlermeldung herausziehen. Der
// Schluessel (z. B. "Skills.Items[2].Text") sagt, welches
// Feld betroffen ist -- reportError springt damit direkt
// dorthin, statt nur eine allgemeine Meldung zu zeigen.
if (parsed && parsed.errors) {
for (var key in parsed.errors) {
var messages = parsed.errors[key];
if (messages && messages.length) {
var validationError = new Error(messages[0]);
validationError.fieldPath = key;
return validationError;
}
}
}
} catch (ignored) {
// Kein JSON -- dann bleibt es bei der allgemeinen Meldung.
}
}
return new Error("Fehler " + response.status);
});
}
function reportError(error) {
if (error.sessionExpired) {
requireLogin(error.retry);
return;
}
setStatus("Nicht gespeichert – " + error.message, "error");
if (error.fieldPath) {
highlightField(error.fieldPath);
}
}
// ---------- Zum fehlerhaften Feld springen ----------
//
// Wandelt einen Validierungs-Schluessel wie "Skills.Items[2].Text" oder
// "Projects.Items[4].Details[0].Heading" in den passenden DOM-Knoten um.
// Die Segmente entsprechen genau den data-section/-list/-item/-object/
// -field-Attributen, nur in PascalCase statt camelCase -- derselbe Weg,
// den collect() in die andere Richtung geht.
function decodeSegment(raw) {
var match = raw.match(/^([^[\]]+)(?:\[(\d+)\])?$/);
if (!match) return null;
var name = match[1].charAt(0).toLowerCase() + match[1].slice(1);
var index = match[2] !== undefined ? parseInt(match[2], 10) : null;
return { name: name, index: index };
}
function locateField(fieldPath) {
var segments = fieldPath.split(".").map(decodeSegment);
if (segments.some(function (s) { return s === null; })) return null;
var first = segments.shift();
var node = root.querySelector('[data-section="' + first.name + '"]');
for (var i = 0; node && i < segments.length; i++) {
var seg = segments[i];
if (seg.index !== null) {
// Liste: erst den Listencontainer im aktuellen Knoten finden,
// dann den Eintrag an der gemeldeten Stelle.
var list = ownedBy(node, '[data-list="' + seg.name + '"]')[0];
node = list ? (itemsOf(list)[seg.index] || list) : null;
} else {
// Feld oder verschachteltes Objekt.
node = ownedBy(node, '[data-field="' + seg.name + '"]')[0] ||
ownedBy(node, '[data-object="' + seg.name + '"]')[0] ||
null;
}
}
return node;
}
function highlightField(fieldPath) {
var node = locateField(fieldPath);
if (!node) return;
node.scrollIntoView({ behavior: "smooth", block: "center" });
node.classList.add("pe-field-error");
window.setTimeout(function () { node.classList.remove("pe-field-error"); }, 2500);
if (node.getAttribute("contenteditable") === "true") {
node.focus();
}
}
// ---------- Speichern ----------
// Schickt einen fertigen Inhalt. Schlaegt es an der Sitzung fehl, merkt sich
// der Fehler genau diesen Inhalt, damit nach dem Anmelden derselbe Stand
// erneut gesendet wird -- auch eine gerade hinzugefuegte Karte.
function putContent(content) {
setStatus("Speichern …", "saving");
return fetch("/api/v1/pagecontent", {
method: "PUT",
headers: {
"Content-Type": "application/json",
"RequestVerificationToken": token
},
body: JSON.stringify(content)
}).then(function (response) {
if (!response.ok) {
return describeError(response).then(function (error) { throw error; });
}
setStatus("Gespeichert " + new Date().toLocaleTimeString("de-CH"), "saved");
});
}
function save() {
return putContent(collectAll()).catch(function (error) {
error.retry = save;
reportError(error);
throw error;
});
}
function saveSoon() {
setStatus("Nicht gespeichert …");
window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(save, 600);
}
// ---------- Bereich neu rendern ----------
// Nach einer Strukturaenderung laesst der Server den Bereich neu rendern.
// Das haelt Reihenfolge, Kachel-Varianten und Zeilenaufteilung korrekt,
// ohne die ganze Seite neu zu laden.
function refreshSection(name) {
return fetch("/Admin?handler=Section&name=" + encodeURIComponent(name), {
headers: { "X-Requested-With": "fetch" }
}).then(function (response) {
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
return response.text();
}).then(function (html) {
var current = root.querySelector('[data-section="' + name + '"]');
if (!current) return;
var holder = document.createElement("div");
holder.innerHTML = html;
var replacement = holder.querySelector('[data-section="' + name + '"]');
if (replacement) {
current.replaceWith(replacement);
}
});
}
// Speichern und danach den betroffenen Bereich neu aufbauen.
function saveAndRefresh(sectionName) {
window.clearTimeout(saveTimer);
save().then(function () {
return refreshSection(sectionName);
}).catch(function () {
// Fehlermeldung steht bereits im Status.
});
}
function sectionNameOf(node) {
var section = node.closest("[data-section]");
return section ? section.dataset.section : null;
}
// ---------- Eingaben ----------
root.addEventListener("input", function (event) {
var target = event.target;
var field = target.closest("[contenteditable='true']");
if (field) {
if (field.dataset.max) {
enforceMax(field);
updateCounter(field);
}
saveSoon();
return;
}
if (target.matches("input[data-field], textarea[data-field]")) {
// Icon-Vorschau sofort mitziehen
if (target.matches("input[data-field='icon']")) {
var item = target.closest("[data-item]");
var icon = item && item.querySelector(".techno-service-icon i, .contact-us-icon i");
if (icon) {
icon.className = target.value;
}
}
saveSoon();
}
});
root.addEventListener("change", function (event) {
if (event.target.matches("input[data-flag]")) {
saveAndRefresh(sectionNameOf(event.target));
}
});
// Beim Verlassen eines Feldes sofort sichern statt auf den Timer zu warten.
root.addEventListener("focusout", function (event) {
if (event.target.closest("[contenteditable='true']") && saveTimer) {
window.clearTimeout(saveTimer);
save().catch(function () { });
}
});
// Zeilenumbrueche nur dort zulassen, wo das Feld mehrzeilig ist.
root.addEventListener("keydown", function (event) {
if (event.key !== "Enter") return;
var field = event.target.closest("[contenteditable='true']");
if (field && field.dataset.multiline !== "true") {
event.preventDefault();
}
});
// Formatierten Text beim Einfuegen auf reinen Text reduzieren.
root.addEventListener("paste", function (event) {
if (!event.target.closest("[contenteditable='true']")) return;
event.preventDefault();
var text = (event.clipboardData || window.clipboardData).getData("text/plain");
document.execCommand("insertText", false, text);
});
// ---------- Listen: hinzufuegen, verschieben, loeschen ----------
root.addEventListener("click", function (event) {
var addButton = event.target.closest(".pe-add");
if (addButton) {
event.preventDefault();
addItem(addButton);
return;
}
var controlButton = event.target.closest(".pe-item-controls button");
if (controlButton) {
event.preventDefault();
handleItemControl(controlButton);
return;
}
var fileButton = event.target.closest(".pe-file");
if (fileButton) {
event.preventDefault();
pickFile(fileButton);
return;
}
var image = event.target.closest("[data-image]");
if (image) {
event.preventDefault();
pickImage(image);
}
});
function listForButton(button) {
// Der Knopf steht innerhalb des Listencontainers oder direkt daneben.
var name = button.dataset.add;
var section = button.closest("[data-section]");
var candidates = section.querySelectorAll('[data-list="' + name + '"]');
for (var i = 0; i < candidates.length; i++) {
if (candidates[i].contains(button) ||
candidates[i].parentElement === button.parentElement ||
button.closest("[data-item]") === candidates[i].closest("[data-item]")) {
return candidates[i];
}
}
return candidates[0] || null;
}
function addItem(button) {
var listElement = listForButton(button);
if (!listElement) return;
var content = collectAll();
var target = resolveList(content, listElement);
if (!target) return;
// Ein leeres Objekt genuegt: der Server fuellt beim Einlesen die
// Standardwerte aus dem C#-Modell ein.
target.array.push(listElement.dataset.stringList === "true" ? "" : {});
putAndRefresh(content, sectionNameOf(button));
}
function handleItemControl(button) {
var item = button.closest("[data-item]");
var listElement = item.parentElement.closest("[data-list]");
if (!listElement) return;
var index = itemsOf(listElement).indexOf(item);
if (index < 0) return;
var content = collectAll();
var target = resolveList(content, listElement);
if (!target) return;
var array = target.array;
if (button.classList.contains("pe-delete")) {
array.splice(index, 1);
} else if (button.classList.contains("pe-move-up") && index > 0) {
array.splice(index - 1, 0, array.splice(index, 1)[0]);
} else if (button.classList.contains("pe-move-down") && index < array.length - 1) {
array.splice(index + 1, 0, array.splice(index, 1)[0]);
} else {
return;
}
putAndRefresh(content, sectionNameOf(button));
}
// Findet im eingesammelten Objekt die Liste, die zu einem DOM-Container gehoert.
// Der Weg wird ueber dieselbe Zugehoerigkeit gebildet wie beim Einsammeln,
// damit ineinander liegende Listen korrekt aufgeloest werden.
function resolveList(content, listElement) {
var path = [listElement.dataset.list];
var owner = ownerOf(listElement);
while (owner && owner.dataset.section === undefined) {
if (owner.dataset.item !== undefined) {
var parentList = owner.parentElement.closest("[data-list]");
if (!parentList) return null;
path.unshift(itemsOf(parentList).indexOf(owner));
path.unshift(parentList.dataset.list);
owner = ownerOf(parentList);
} else if (owner.dataset.object !== undefined) {
path.unshift(owner.dataset.object);
owner = ownerOf(owner);
} else {
return null;
}
}
if (!owner) return null;
path.unshift(owner.dataset.section);
var current = content;
for (var i = 0; i < path.length; i++) {
current = current[path[i]];
if (current === undefined || current === null) return null;
}
return Array.isArray(current) ? { array: current } : null;
}
function putAndRefresh(content, sectionName) {
window.clearTimeout(saveTimer);
putContent(content).then(function () {
return refreshSection(sectionName);
}).catch(function (error) {
error.retry = function () { putAndRefresh(content, sectionName); };
reportError(error);
});
}
// ---------- Bilder und Dokumente ----------
// Merkt sich, was nach dem Hochladen aktualisiert werden soll.
var pendingTarget = null;
function pickImage(image) {
if (!fileInput) return;
pendingTarget = { kind: "image", element: image, endpoint: "images" };
fileInput.accept = "image/*";
fileInput.value = "";
fileInput.click();
}
function pickFile(button) {
if (!fileInput) return;
var row = button.closest(".pe-file-row");
var field = row && row.querySelector("input[data-field]");
if (!field) return;
pendingTarget = {
kind: "file",
element: field,
endpoint: button.dataset.file || "documents",
item: button.closest("[data-item]")
};
fileInput.accept = button.dataset.accept || "";
fileInput.value = "";
fileInput.click();
}
function upload(file, target) {
var data = new FormData();
data.append("file", file);
setStatus((target.kind === "image" ? "Bild" : "Datei") + " wird hochgeladen …", "saving");
return fetch("/api/v1/pagecontent/" + target.endpoint, {
method: "POST",
headers: { "RequestVerificationToken": token },
body: data
}).then(function (response) {
if (!response.ok) {
return describeError(response).then(function (error) { throw error; });
}
return response.json();
}).then(function (body) {
if (target.kind === "image") {
target.element.src = body.url;
target.element.dataset.value = body.url;
} else {
target.element.value = body.url;
// Der sichtbare Link soll sofort auf die neue Datei zeigen.
var link = target.item && target.item.querySelector(".circular-btn a");
if (link) {
link.href = body.url;
}
}
return save();
}).catch(function (error) {
error.retry = function () { upload(file, target); };
reportError(error);
});
}
if (fileInput) {
fileInput.addEventListener("change", function () {
var file = fileInput.files && fileInput.files[0];
var target = pendingTarget;
pendingTarget = null;
if (!file || !target) return;
upload(file, target);
});
}
// ---------- Erneut anmelden, ohne die Seite zu verlassen ----------
// Bei einer abgelaufenen Sitzung wird hier angemeldet und danach genau die
// Aktion wiederholt, die fehlgeschlagen ist. Die Seite bleibt stehen, alle
// Eingaben bleiben erhalten.
function requireLogin(retry) {
if (!loginBox) {
setStatus("Sitzung abgelaufen – bitte Seite neu laden", "error");
return;
}
pendingRetry = retry || null;
setStatus("Sitzung abgelaufen", "error");
loginError.hidden = true;
loginBox.hidden = false;
var user = document.getElementById("pe-login-user");
if (user) {
user.focus();
}
}
// Holt ein frisches Antiforgery-Token von der Login-Seite: das Token der
// Editor-Seite gehoert zur inzwischen abgelaufenen Anmeldung.
function submitLogin() {
var user = document.getElementById("pe-login-user");
var password = document.getElementById("pe-login-pass");
if (!user.value || !password.value) {
showLoginError("Bitte Benutzername und Passwort eingeben.");
return;
}
loginSubmit.disabled = true;
loginError.hidden = true;
fetch("/Login", { headers: { "X-Requested-With": "fetch" } })
.then(function (response) { return response.text(); })
.then(function (html) {
var match = html.match(/name="__RequestVerificationToken"[^>]*?value="([^"]+)"/);
if (!match) {
throw new Error("Login-Seite nicht lesbar");
}
var body = new URLSearchParams();
body.set("Username", user.value);
body.set("Password", password.value);
body.set("__RequestVerificationToken", match[1]);
return fetch("/Login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
redirect: "manual"
});
})
.then(function (response) {
// Erfolg beantwortet der Server mit einer Weiterleitung; bei
// falschen Daten kommt die Login-Seite mit Status 200 zurueck.
if (response.type !== "opaqueredirect" && response.status !== 302) {
throw new Error("Benutzername oder Passwort ist falsch.");
}
loginBox.hidden = true;
password.value = "";
loginSubmit.disabled = false;
var retry = pendingRetry;
pendingRetry = null;
if (retry) {
retry();
} else {
setStatus("Wieder angemeldet", "saved");
}
})
.catch(function (error) {
loginSubmit.disabled = false;
showLoginError(error.message);
});
}
function showLoginError(message) {
if (!loginError) return;
loginError.textContent = message;
loginError.hidden = false;
}
if (loginSubmit) {
loginSubmit.addEventListener("click", submitLogin);
}
if (loginBox) {
loginBox.addEventListener("keydown", function (event) {
if (event.key === "Enter") {
event.preventDefault();
submitLogin();
}
});
}
// ---------- Ungespeicherte Aenderungen ----------
window.addEventListener("beforeunload", function (event) {
if (saveTimer) {
event.preventDefault();
event.returnValue = "";
}
});
// Zaehler fuer alle beim Laden vorhandenen Felder anzeigen und den
// Observer fuer spaeter hinzukommende Felder scharf schalten.
refreshAllCounters();
setStatus("Bereit");
})();