Portfolio website

Dit project draait om mijn eigen portfolio website: een plek waar ik mijn werk, werkwijze en technische keuzes samenbreng. Niet alleen om eindresultaten te tonen, maar juist om context te geven. Waarom ik bepaalde beslissingen neem, hoe ik projecten aanpak en welke rol ik daarin vervul.

Portfolio website
Project info
Start
oktober 2025
End
januari 0001
Complexity
4 / 10
Team size
1
Type
Hobby Portfolio
Stack
C# Javascript HTML & CSS SQL ASP.NET Core RazorPages Bootstrap GIT Umbraco
Live
https://bartvanderburg.nl
Source
https://github.com/Bart023/PortfolioWebsite.Static

Doel en motivatie

Het doel van deze website is om een helder en eerlijk beeld te geven van mijn manier van werken. Ik wil laten zien wat ik bouw, maar vooral ook hoe en waarom. Denk aan codevoorbeelden, architecturale keuzes en de afwegingen die daarbij horen.

Daarnaast was dit project een bewuste keuze om hands-on ervaring op te doen met Umbraco, binnen een setup die eenvoudig uitbreidbaar blijft. Nieuwe projecten en content moeten zonder veel moeite toegevoegd kunnen worden, zonder in te leveren op technische controle.


Technische stack

De website is gebouwd met Umbraco CMS in combinatie met ASP.NET Core Razor Pages. Omdat ik al ervaring had met Razor Pages kon ik snel productief zijn en focussen op structuur, herbruikbare componenten en consistente styling.

Voor de front-end heb ik gekozen voor een lichte setup met Bootstrap en eigen CSS en JavaScript. Geen overbodige frameworks, maar wel voldoende flexibiliteit voor een nette, toegankelijke UI. Interactieve onderdelen zijn bewust simpel gehouden om de code leesbaar en onderhoudbaar te houden.


Hosting en statische generatie

Voor hosting heb ik gekozen voor een statische aanpak. De Umbraco-site wordt lokaal omgezet naar een statische website, waarbij alle pagina’s en assets automatisch worden gegenereerd. Hierdoor kan de website volledig gratis worden gehost, zonder concessies te doen aan performance of stabiliteit.

Om dit goed te testen heb ik een eenvoudige statische host gebouwd die lokaal start samen met Umbraco en direct de gegenereerde bestanden serveert. Dit maakt snel testen en itereren mogelijk.


Architectuur en codekwaliteit

De architectuur is bewust eenvoudig gehouden. Het project bevat weinig complexe businesslogica en volgt grotendeels het patroon dat Umbraco voorschrijft. Dit zorgt voor duidelijkheid en dwingt tot gestructureerde keuzes, zonder onnodige complexiteit toe te voegen.

De focus lag hierbij op een duidelijke contentstructuur, herbruikbare block components, consistente naamgeving en overzichtelijke Razor views. Hieronder geef ik met enkele afbeeldingen inzicht in de opzet van het project. Om mijn code style en manier van werken concreet te laten zien, toon ik ook enkele codevoorbeelden. Deze laten zien hoe ik omga met structuur, scheiding van verantwoordelijkheden en leesbaarheid.

Solution-folder-structure-overview.png
Umbraco-content.png
Umbraco-settings-1.png
Umbraco-settings-2.png
Umbraco-translations.png

ProjectsOverviewPage.cshtml

ProjectsOverviewPage.cshtml
@using Umbraco.Cms.Core.Models
@using Umbraco.Cms.Web.Common.PublishedModels;
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage
@{
    Layout = "master.cshtml";

    var projects = Model?
        .ChildrenOfType("ProjectDetailPage")?
        .Where(x => x.IsVisible())
        .OrderBy(x => x.SortOrder)
        .Select(x => new
        {
            Url = x.Url(),
            Title = x.Name,
            SortOrder = x.SortOrder,
            Intro = x.Value<string>("intro") ?? "",
            Img = x.Value<MediaWithCrops>("headerImage"),
            Employer = x.Value<string>("employer"),
            Start = x.Value<DateTime?>("startDate"),
            End = x.Value<DateTime?>("endDate"),
            Difficulty = x.Value<int>("difficulty"),
            Type = (x.Value<IEnumerable<string>>("projectType") ?? Enumerable.Empty<string>()).ToArray(),
            Stack_languages = x.Value<IEnumerable<string>>("languages") ?? Enumerable.Empty<string>(),
            Stack_frameworks = x.Value<IEnumerable<string>>("frameworks") ?? Enumerable.Empty<string>(),
            Stack_tools = x.Value<IEnumerable<string>>("tools") ?? Enumerable.Empty<string>(),
            Stack_methodologies = x.Value<IEnumerable<string>>("methodologies") ?? Enumerable.Empty<string>()
        });

    if(projects?.Any() != true)
        return;

    var totalProjects = projects.Count();
    var employers = projects
        .Select(x => x.Employer)
        .Where(e => !string.IsNullOrWhiteSpace(e))
        .Distinct()
        .OrderBy(e => e)
        .ToList();
    var employerCounts = projects
        .Where(p => !string.IsNullOrWhiteSpace(p.Employer))
        .GroupBy(p => p.Employer ?? "-")
        .ToDictionary(g => g.Key, g => g.Count()) ?? new Dictionary<string, int>();

    var stackFilters = new[]
    {
        new { Label = "Languages", Items = BuildFilterList(projects, p => p.Stack_languages) },
        new { Label = "Frameworks", Items = BuildFilterList(projects, p => p.Stack_frameworks) },
        new { Label = "Tools", Items = BuildFilterList(projects, p => p.Stack_tools) },
        new { Label = "Methodologies", Items = BuildFilterList(projects, p => p.Stack_methodologies) }
    };
}

@functions
{
    private Dictionary<string, int> BuildFilterList<T>(IEnumerable<T> projects, Func<T, IEnumerable<string>> selector) where T : class
    {
        return projects?
            .SelectMany(selector)
            .Where(s => !string.IsNullOrWhiteSpace(s))
            .GroupBy(s => s)
            .OrderBy(g => g.Key)
            .ToDictionary(g => g.Key, g => g.Count()) ?? new Dictionary<string, int>();
    }
}

<section id="projects" class="projects container view-list">

    @* Sidebar / Filterbar *@
    <aside id="projects-sidebar" class="sidebar filterbar-content">
        <div class="d-flex mb-2 border-bottom">
            <div class="d-flex justify-content-between align-items-center gap-2">
                <h5 class="fw-semibold mt-3">Filters</h5>
                <span class="active-filter-count badge rounded-pill text-bg-warning gap-1" style="display:inline-block; top:0;">0</span>
                <small class="visible-projects-count badge rounded-pill text-bg-light border">@totalProjects projects</small>
            </div>
            <button id="projects-sidebar-close-btn" class="btn">×</button>
        </div>

        @if (employers?.Any() ?? false)
        {
            <div class="mb-4">
                <h6 class="fw-semibold">Employer</h6>
                <div class="list-group small">
                    @foreach (var employer in employers)
                    {
                        var count = employerCounts.ContainsKey(employer ?? "") ? employerCounts[employer ?? ""] : 0;
                        <label class="list-group-item d-flex justify-content-between align-items-center">
                            <span>@employer <span class="text-muted">(@count)</span></span>
                            <input type="checkbox" class="form-check-input employer-filter" value="@employer" />
                        </label>
                    }
                </div>
            </div>
        }

        @foreach (var filter in stackFilters)
        {
            @if (filter.Items?.Any() ?? false)
            {
                <div class="mb-4">
                    <h6 class="fw-semibold">@filter.Label</h6>
                    <div class="list-group small">
                        @foreach (var item in filter.Items)
                        {
                            <label class="list-group-item d-flex justify-content-between align-items-center">
                                <span>@item.Key <span class="text-muted">(@item.Value)</span></span>
                                <input type="checkbox" class="form-check-input stack-filter" value="@item.Key" />
                            </label>
                        }
                    </div>
                </div>
            }
        }

    </aside>

    <div id="projects-overlay" class="overlay"></div>

    <div class="projects-content-container m-2">

        @* Top bar *@
        <div class="projects-header-container d-flex flex-wrap justify-content-between align-items-center mt-1 gap-2">
            <div class="d-flex gap-2 mb-2 w-100">
                <div class="dropdown w-100">
                    <button class="btn btn-outline-secondary dropdown-toggle w-100" type="button" data-bs-toggle="dropdown" aria-expanded="false">
                        Sorteren op
                    </button>
                    <ul class="dropdown-menu" id="sortOptions">
                        <li><a class="dropdown-item active" href="#" data-sort="default">Standaard</a></li>
                        <li><a class="dropdown-item" href="#" data-sort="date-desc">Datum: Hoog - Laag</a></li>
                        <li><a class="dropdown-item" href="#" data-sort="date-asc">Datum: Laag - Hoog</a></li>
                        <li><a class="dropdown-item" href="#" data-sort="complexity">Complexity</a></li>
                    </ul>
                </div>
                <div class="d-flex align-items-center gap-2 w-100">
                    <button id="btnList" type="button" class="btn btn-outline-secondary w-100">☰ List</button>
                    <button id="btnTiles" type="button" class="btn btn-outline-secondary w-100 d-none">β–¦ Tiles</button>
                </div>
            </div>
            <button id="projects-sidebar-toggler" class="btn btn-outline-secondary sidebar-toggle-projects gap-2 mb-3">
                <span>☰ Filters</span>
                <span class="active-filter-count badge rounded-pill text-bg-warning" style="display:inline-block; top:0;">0</span>
                <small class="visible-projects-count badge rounded-pill text-bg-light border">@totalProjects projects</small>
            </button>
        </div>

        @* Content *@
        <div id="projectList" class="row g-3">
            @foreach (var project in projects ?? [])
            {
                <article class="col-12 col-sm-6 col-lg-4 project"
                         data-sort-order="@project.SortOrder"
                         data-start="@project.Start?.ToString("yyyy-MM-dd")"
                         data-difficulty="@project.Difficulty">
                    <a href="@project.Url" class="card project-card">

                        @if (project.Img != null)
                        {
                            <div class="image-container m-2">
                                <img class="project-img card-img-top" src="@project.Img.Url()" alt="@project.Title" />
                            </div>
                        }
                        <div class="project-body">
                            <div class="project-main">
                                <h3 class="mb-2">@project.Title</h3>
                                @if (!string.IsNullOrWhiteSpace(project.Intro))
                                {
                                    <div class="project-intro">
                                        @Html.Raw(project.Intro)
                                    </div>
                                }
                            </div>

                            <div class="project-meta small text-body-secondary">
                                @if (!string.IsNullOrWhiteSpace(project.Employer))
                                {
                                    <div>Employer: @project.Employer</div>
                                }
                                @if (project.Start.HasValue && project.Start != DateTime.MinValue)
                                {
                                    <div>Start: @project.Start?.ToString("MMMM yyyy")</div>
                                }
                                <div>End: @(project.End != null ? project.End?.ToString("MMMM yyyy") : "active")</div>
                                @if (project.Difficulty > 0)
                                {
                                    <div>
                                        Complexity:
                                        <span class="badge rounded-pill text-bg-secondary">@project.Difficulty / 10</span>
                                    </div>
                                }
                                @if (((string[])project.Type).Any())
                                {
                                    <div>
                                        Type: 
                                        @foreach (var type in project.Type)
                                        {
                                            <span class="badge rounded-pill text-bg-light border">@type</span>
                                        }
                                    </div>
                                }
                                @if (project.Stack_languages?.Any() ?? false)
                                {
                                    <div>
                                        Languages:
                                        @foreach (var lang in project.Stack_languages)
                                        {
                                            <span class="badge rounded-pill text-bg-light border">@lang</span>
                                        }
                                    </div>
                                }
                                @if (project.Stack_frameworks?.Any() ?? false)
                                {
                                    <div>
                                        Frameworks:
                                        @foreach (var framework in project.Stack_frameworks)
                                        {
                                            <span class="badge rounded-pill text-bg-light border">@framework</span>
                                        }
                                    </div>
                                }
                                @if (project.Stack_tools?.Any() ?? false)
                                {
                                    <div>
                                        Tools:
                                        @foreach (var tool in project.Stack_tools)
                                        {
                                            <span class="badge rounded-pill text-bg-light border">@tool</span>
                                        }
                                    </div>
                                }
                                @if (project.Stack_methodologies?.Any() ?? false)
                                {
                                    <div class="mb-4">
                                        Methodologies:
                                        @foreach (var methodology in project.Stack_methodologies)
                                        {
                                            <span class="badge rounded-pill text-bg-light border">@methodology</span>
                                        }
                                    </div>
                                }
                            </div>
                        </div>
                    </a>
                </article>
            }
        </div>
    </div>
</section>

ProjectsOverviewPage.css

ProjectsOverviewPage.css
.projects.container {
    padding: 0;
    display: flex;
}

/* Sidebar area */
#projects .filter-group {
    padding: 8px;
    box-shadow: var(--box-shadow-light);
}
#projects .sidebar-toggle-projects {
    padding: 0;
    width: 100%;
    cursor: pointer;
    font-size: 24px;
    align-items: center;
    display: inline-flex;
    justify-content: center !important;
}
#projects .filterbar-content {
    padding: 0 8px 8px 8px;
    min-height: -webkit-fill-available;
    background-color: var(--bs-body-bg);
}
#projects #projects-sidebar-close-btn {
    right: 8px;
    color: red;
    font-size: 24px;
    position: absolute;
    background: transparent;
}
@media (max-width: 960px) {
    #projects .filterbar-content {
        overflow-y: auto;
        z-index: 1040;
    }
}
@media (min-width: 960.02px) {
    #projects .sidebar-toggle-projects {
       display: none;
    }
    #projects #projects-sidebar-close-btn {
        display: none;
    }
}

/* Projects area */
#projects .projects-content-container{
    width: 100%;
}

#projects .project-card {
    text-decoration: none;
}
#projects .project-body > * {
    padding: .25rem;
}

#projects .project-img {
    aspect-ratio: 1 / 1;
    object-fit: cover;
}

#projects .project-intro {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 3;
    overflow: hidden;
}
    #projects .project-intro p {
        margin: 0;
    }

/* Tiles view */
#projects.view-tiles .project-meta {
    border-top: 1px solid var(--bs-card-border-color);
}
#projects.view-tiles .project-intro {
    display: none;
}

/* List view */
#projects.view-list .project {
    flex: 0 0 100% !important;
}

#projects.view-list .project-card {
    flex-direction: row;
}

#projects.view-list .project-body {
    width: 100%;
}

#projects.view-list .image-container .project-img {
    border-radius: var(--bs-border-radius);
    max-width: 220px;
}

#projects.view-list .project-meta {
    margin-left: auto;
}

@media (max-width:767.98px) {
    #projects .project-intro {
        display: none;
    }

    #projects.view-list .project-meta {
        border-top: 1px solid var(--bs-card-border-color);
    }
}
@media (min-width:768px) {
    #projects.view-list .project-body {
        display: flex;
    }

    #projects.view-list .project-meta {
        min-width: 35%;
        max-width: 35%;
        border-left: 1px solid var(--bs-card-border-color);
    }
}

ProjectsOverviewPage.js

ProjectsOverviewPage.js
// portfolio overview page - Toggle list/tile view
document.addEventListener("DOMContentLoaded", () => {
    const root = document.getElementById("projects");
    if (!root) return;

    const btnTiles = document.getElementById("btnTiles");
    const btnList = document.getElementById("btnList");

    const setView = view => {
        root.classList.toggle("view-tiles", view === "tiles");
        root.classList.toggle("view-list", view === "list");
        btnTiles.classList.toggle("d-none", view === "tiles");
        btnList.classList.toggle("d-none", view === "list");
        localStorage.setItem("projectView", view);
    };

    const saved = localStorage.getItem("projectView");
    setView(saved === 'list' ? 'list' : 'tiles');

    btnTiles.addEventListener("click", () => setView("tiles"));
    btnList.addEventListener("click", () => setView("list"));
});

// Portfolio overview page - Sorting
document.addEventListener("DOMContentLoaded", () => {
    const sortOptions = document.querySelectorAll("#sortOptions .dropdown-item");
    const projectsContainer = document.getElementById("projectList");
    if (!projectsContainer || sortOptions.length === 0) return;

    function sortProjects(sortType) {
        const projectItems = Array.from(projectsContainer.children);

        projectItems.sort((projectA, projectB) => {
            const sortOrderA = parseInt(projectA.dataset.sortOrder || "0", 10);
            const sortOrderB = parseInt(projectB.dataset.sortOrder || "0", 10);
            const startDateA = new Date(projectA.dataset.start || 0).getTime();
            const startDateB = new Date(projectB.dataset.start || 0).getTime();
            const difficultyA = parseInt(projectA.dataset.difficulty || "0", 10);
            const difficultyB = parseInt(projectB.dataset.difficulty || "0", 10);

            switch (sortType) {
                case "date-desc": return startDateB - startDateA;
                case "date-asc": return startDateA - startDateB;
                case "complexity": return difficultyB - difficultyA;
                default: return sortOrderA - sortOrderB;
            }
        });

        projectItems.forEach(project => projectsContainer.appendChild(project));
    }

    sortOptions.forEach(option => {
        option.addEventListener("click", event => {
            event.preventDefault();

            sortOptions.forEach(optionItem => optionItem.classList.remove("active"));
            option.classList.add("active");

            const sortType = option.dataset.sort;
            sortProjects(sortType);
        });
    });
});

// portfolio overview page - Sidebar toggle
document.addEventListener("DOMContentLoaded", () => {
    const sidebar = document.getElementById("projects-sidebar");
    const toggleBtn = document.getElementById("projects-sidebar-toggler");
    const closeBtn = document.getElementById("projects-sidebar-close-btn");
    const overlay = document.getElementById("projects-overlay");
    function openSidebar() {
        sidebar.classList.add("open");
        overlay.classList.add("show");
        document.body.style.overflow = "hidden";
    }

    function closeSidebar() {
        sidebar.classList.remove("open");
        overlay.classList.remove("show");
        document.body.style.overflow = "";
    }

    if (toggleBtn) toggleBtn.addEventListener("click", openSidebar);
    if (closeBtn) closeBtn.addEventListener("click", closeSidebar);
    if (overlay) overlay.addEventListener("click", closeSidebar);
});

// portfolio overview page - Filter bar
document.addEventListener("DOMContentLoaded", () => {
    const projects = document.querySelectorAll(".project");
    const badges = document.querySelectorAll(".active-filter-count");
    const visibleCountElements = document.querySelectorAll(".visible-projects-count");

    // Define all filter groups here (easily extendable)
    const filterGroups = {
        employers: document.querySelectorAll(".employer-filter"),
        stack: document.querySelectorAll(".stack-filter")
    };

    function updateVisibleProjectCount() {
        const visibleCount = Array.from(projects).filter(project => {
            return project.style.display !== "none";
        }).length;

        visibleCountElements.forEach(element => {
            element.textContent = `${visibleCount} project${visibleCount !== 1 ? "s" : ""}`;
        });
    }

    function applyFilters() {
        const params = new URLSearchParams(window.location.search);
        const activeFilters = {};

        // Collect all active filters for each group
        Object.keys(filterGroups).forEach(group => {
            const values = Array.from(filterGroups[group])
                .filter(cb => cb.checked)
                .map(cb => cb.value.toLowerCase());
            activeFilters[group] = values;

            // Update URL
            if (values.length > 0) params.set(group, values.join(","));
            else params.delete(group);
        });

        // Update URL without reload
        const query = params.toString();
        const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
        window.history.replaceState({}, "", newUrl);

        // Filter projects (AND logic between groups, OR logic within each group)
        projects.forEach(project => {
            const employer = (project.querySelector(".project-meta div:nth-child(1)")?.innerText || "").toLowerCase();
            const types = Array.from(project.querySelectorAll(".project-meta .badge.border")).map(b => b.innerText.toLowerCase());

            const matchEmployer =
                activeFilters.employers.length === 0 ||
                activeFilters.employers.some(e => employer.includes(e));

            const matchStack =
                activeFilters.stack.length === 0 ||
                activeFilters.stack.some(s => types.includes(s));

            project.style.display = matchEmployer && matchStack ? "" : "none";
        });

        // Update badges with total active filters
        const totalActive = Object.values(activeFilters).reduce((sum, list) => sum + list.length, 0);
        badges.forEach(badge => {
            badge.textContent = totalActive;
            badge.style.display = totalActive > 0 ? "inline-block" : "none";
        });

        // Update visible project count
        updateVisibleProjectCount();
    }

    // Hook up all checkboxes
    Object.keys(filterGroups).forEach(group => {
        filterGroups[group].forEach(cb => cb.addEventListener("change", applyFilters));
    });

    // Read URL params and set initial checkbox states
    const params = new URLSearchParams(window.location.search);
    Object.keys(filterGroups).forEach(group => {
        const urlValues = params.get(group)?.split(",").map(v => v.toLowerCase()) || [];
        if (urlValues.length > 0) {
            filterGroups[group].forEach(cb => {
                if (urlValues.includes(cb.value.toLowerCase())) cb.checked = true;
            });
        }
    });

    applyFilters();
});