Create .NET Project in VS Code

Steps to initialize and run a new .NET project:

  • Open an empty folder in VS Code.
  • Open the integrated terminal (Ctrl + `).
  • Run dotnet new console -n MyProject to create a console app.
  • Navigate to the project folder: cd MyProject.
  • Run dotnet run to execute the program.

To add a class library or web app, replace console with classlib or web.

Create WPF Project in VS Code

Steps to initialize and run a new WPF (Windows Presentation Foundation) desktop application:

  • Open an empty folder in VS Code.
  • Open the integrated terminal (Ctrl + `).
  • Run dotnet new wpf -n MyWpfApp to create a WPF app.
  • Navigate to the project folder: cd MyWpfApp.
  • Run dotnet run to start the application.

Data Models

Create a new file for your data model (e.g., Book.cs).

public sealed class Book
{
    public int Id { get; init; }
    public string Title { get; init; } = string.Empty;
    public string Author { get; init; } = string.Empty;
    public int PublicationYear { get; init; }
    public string LibraryName { get; init; } = string.Empty;
    public int LoanStatus { get; init; }
}

File Reading

Important: The file you want to read (e.g., books.txt) needs to be in the output directory. Place it in bin/Debug/net<version> (for example bin/Debug/net8.0).

1. Initialization
Create a list to hold the parsed objects.

using PageTurn_Console;

var books = new List<Book>();

2. Reading and Looping
Read all lines from the file and iterate through them. Trim whitespace and skip empty lines.

foreach (var rawLine in File.ReadAllLines("books.txt"))
{
    var line = rawLine.Trim();
    if (line.Length == 0)
        continue;

3. Parsing and Adding
Split the line by the delimiter (e.g., tab character \t) and map the string parts to the data model properties. Use int.Parse() for numbers.

    var parts = line.Split('\t');

    books.Add(new Book
    {
        Id = int.Parse(parts[0]),
        Title = parts[1],
        Author = parts[2],
        PublicationYear = int.Parse(parts[3]),
        LibraryName = parts[4],
        LoanStatus = int.Parse(parts[5]),
    });
}

4. Verification
Output the total number of items read to ensure it worked correctly.

Console.WriteLine($"Beolvasva: {books.Count} könyv.");

Q1: Print All Records

Goal: Display all items in the list with a header.

Explanation: string.Join('\t', ...) creates a single tab-separated string from the provided arguments. This is used for both the header and each book's properties inside the loop.

// Q1
Console.WriteLine("--- Q1 - All Books ---");
Console.WriteLine(string.Join('\t', "ID", "Title", "Author", "PubYear", "Library", "OnLoan"));

foreach (var b in books)
{
    Console.WriteLine(string.Join('\t', b.Id, b.Title, b.Author, b.PublicationYear, b.LibraryName, b.LoanStatus));
}

Q2: Filter and Sort (Bubble Sort)

Goal: Filter out books that are not on loan, then sort them by publication year.

1. Filtering: Create a new list and add only books where LoanStatus == 0.

// Q2
Console.WriteLine();
Console.WriteLine("--- Q2 - Not On Loan ---");
var notOnLoan = new List<Book>();

foreach (var b in books)
{
    if (b.LoanStatus == 0)
        notOnLoan.Add(b);
}

2. Sorting (Bubble Sort): Use a nested loop to compare adjacent items. If the current item's year is greater than the next, swap them. This sorts the list in ascending order.

for (int i = 0; i < notOnLoan.Count - 1; i++)
{
    for (int j = i + 1; j < notOnLoan.Count; j++)
    {
        if (notOnLoan[i].PublicationYear > notOnLoan[j].PublicationYear)
        {
            Book tmp = notOnLoan[i];
            notOnLoan[i] = notOnLoan[j];
            notOnLoan[j] = tmp;
        }
    }
}

3. Displaying: Loop through the sorted list and print.

foreach (var b in notOnLoan)
{
    Console.WriteLine($"{b.Title} published {b.PublicationYear}");
}

Q3: Group and Count (Dictionary)

Goal: Count how many books each author has.

Explanation: A Dictionary<string, int> is used. The key is the author's name, and the value is the count. If the author is already in the dictionary, increment their count. Otherwise, add them with a count of 1.

// Q3
Console.WriteLine();
Console.WriteLine("--- Q3 - Books per Author ---");
var authorCounts = new Dictionary<string, int>();

foreach (var b in books)
{
    if (authorCounts.ContainsKey(b.Author))
        authorCounts[b.Author]++;
    else
        authorCounts[b.Author] = 1;
}

foreach (var pair in authorCounts)
{
    Console.WriteLine($"{pair.Key}: {pair.Value}");
}

Q4: Find Maximum (Busiest Library)

Goal: Find the library with the most books.

1. Counting: Just like Q3, use a Dictionary to count books per library.

// Q4
Console.WriteLine();
Console.WriteLine("--- Q4 - Busiest Library ---");
var libraryCounts = new Dictionary<string, int>();
foreach (var b in books)
{
    if (libraryCounts.ContainsKey(b.LibraryName))
        libraryCounts[b.LibraryName]++;
    else
        libraryCounts[b.LibraryName] = 1;
}

2. Finding Max: Loop through the dictionary values to find the highest count.

int maxBooks = 0;
foreach (var pair in libraryCounts)
{
    if (pair.Value > maxBooks)
        maxBooks = pair.Value;
}

3. Finding Matches: There might be multiple libraries with the maximum count, so find all keys matching maxBooks.

var busiestNames = new List<string>();
foreach (var pair in libraryCounts)
{
    if (pair.Value == maxBooks)
        busiestNames.Add(pair.Key);
}

busiestNames.Sort(); // Built-in sort for alphabetical order

foreach (var name in busiestNames)
{
    Console.WriteLine($"{name} has {maxBooks} books");
}

Q5: Custom Filter & Multi-criteria Sort

Goal: Ask user for a year, filter books published on or after that year, then sort by year (ascending) and title (alphabetical).

1. Input & Filter: Read year from console and filter.

// Q5
Console.WriteLine();
Console.WriteLine("--- Q5 - Publication year ---");
Console.Write("Enter a publication year: ");
Console.Out.Flush();
var yearIn = int.Parse(Console.ReadLine()!.Trim());
Console.WriteLine($"Books published after {yearIn}:");

var fromYear = new List<Book>();
foreach (var b in books)
{
    if (b.PublicationYear >= yearIn)
        fromYear.Add(b);
}

2. Multi-criteria Bubble Sort: The swap condition checks if the first year is greater than the second. If they are equal, it uses string.CompareOrdinal to check if the first title comes alphabetically after the second title.

for (int i = 0; i < fromYear.Count - 1; i++)
{
    for (int j = i + 1; j < fromYear.Count; j++)
    {
        bool swap = false;
        
        if (fromYear[i].PublicationYear > fromYear[j].PublicationYear)
            swap = true;
        else if (fromYear[i].PublicationYear == fromYear[j].PublicationYear
            && string.CompareOrdinal(fromYear[i].Title, fromYear[j].Title) > 0)
            swap = true;
            
        if (swap)
        {
            Book tmp = fromYear[i];
            fromYear[i] = fromYear[j];
            fromYear[j] = tmp;
        }
    }
}

3. Displaying:

foreach (var b in fromYear)
{
    Console.WriteLine($"{b.Title} ({b.Author}) published {b.PublicationYear}");
}

Q6: Conditional Average

Goal: Calculate the average publication year of unlent books.

Explanation: You need two variables: one for the sum and one for the count. Only add to them if the condition (LoanStatus == 0) is met. Finally, divide the sum by the count. Remember to cast to double to avoid integer division losing the decimal parts.

// Q6
Console.WriteLine();
Console.WriteLine("--- Q6 - Average Publication Year of Unlent Books ---");
long sumYears = 0;
int freeCount = 0;

foreach (var b in books)
{
    if (b.LoanStatus == 0)
    {
        sumYears += b.PublicationYear;
        freeCount++;
    }
}

// Cast to double for precision, :F1 formats to 1 decimal place
double avgYear = (double)sumYears / freeCount;
Console.WriteLine($"Average year: {avgYear:F1}");

File Editing (Saving changes)

Goal: Find a specific book by ID, update its LoanStatus, and save the entire list back to books.txt.

1. Input with Validation: Ask the user for an ID in a while (true) loop until a valid integer is provided.

// 1.C — kölcsönzés és books.txt mentés
Console.WriteLine();
Console.WriteLine("--- 1.C - Loan ---");

int loanId;
while (true)
{
    Console.Write("Enter book ID to mark as on loan: ");
    Console.Out.Flush();
    string? idLine = Console.ReadLine();
    if (idLine == null)
        continue;
    idLine = idLine.Trim();
    
    // TryParse returns true if successful, breaking the infinite loop
    if (int.TryParse(idLine, out loanId))
        break;
}

2. Finding the Item: Loop through the list to find the index of the book with the matching ID.

int bookIndex = -1;
for (int i = 0; i < books.Count; i++)
{
    if (books[i].Id == loanId)
    {
        bookIndex = i;
        break; // Stop searching once found
    }
}

3. Updating & Saving: If found, replace the old immutable Book object with a new one that has LoanStatus = 1. Finally, reconstruct the tab-separated lines and overwrite the file using File.WriteAllLines.

if (bookIndex < 0)
{
    Console.WriteLine("No book with that id exists");
}
else
{
    Book was = books[bookIndex];
    
    // Create a new Book instance based on the old one, but with LoanStatus = 1
    books[bookIndex] = new Book
    {
        Id = was.Id,
        Title = was.Title,
        Author = was.Author,
        PublicationYear = was.PublicationYear,
        LibraryName = was.LibraryName,
        LoanStatus = 1,
    };
    Console.WriteLine($"{was.Title} has been marked as on loan.");

    // Convert the list of Book objects back into a list of strings
    var outLines = new List<string>(books.Count);
    foreach (var b in books)
    {
        outLines.Add(string.Join('\t', b.Id, b.Title, b.Author, b.PublicationYear, b.LibraryName, b.LoanStatus));
    }
    
    // Overwrite the file with the new data
    File.WriteAllLines("books.txt", outLines);
}

WPF App UI (XAML)

Goal: Create a layout with a list of books on the left and a detail/edit view on the right. We use a Grid to split the screen into two columns.

The first column contains a ListView bound to the book title. The second column contains a DockPanel with Save/Delete buttons at the bottom and a ScrollViewer for the form fields filling the remaining space.

<Window x:Class="PageTurn_GUI.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Page Turn Manager" Height="520" Width="880">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />    <!-- 1 part of width -->
            <ColumnDefinition Width="3*" />   <!-- 3 parts of width -->
        </Grid.ColumnDefinitions>

        <ListView x:Name="BooksListView"
                  Grid.Column="0" Margin="8"
                  DisplayMemberPath="Title"
                  SelectionChanged="BooksListView_SelectionChanged" />

        <DockPanel Grid.Column="1" Margin="12,8,8,8" LastChildFill="True">
            <!-- Action buttons docked to the bottom -->
            <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,16,0,0">
                <Button Content="Delete Book" Width="120" Margin="0,0,8,0" Click="DeleteBook_Click" />
                <Button Content="Save Changes" Width="120" Click="SaveChanges_Click" />
            </StackPanel>

            <!-- Form fields -->
            <ScrollViewer VerticalScrollBarVisibility="Auto">
                <StackPanel>
                    <TextBlock Text="Selected Book" FontWeight="Bold" FontSize="14" Margin="0,0,0,12" />

                    <TextBlock Text="Title:" />
                    <TextBox x:Name="TitleBox" Margin="0,0,0,12" />

                    <TextBlock Text="Author:" />
                    <TextBox x:Name="AuthorBox" Margin="0,0,0,12" />

                    <TextBlock Text="Publication Year:" />
                    <TextBox x:Name="YearBox" Margin="0,0,0,12" />

                    <TextBlock Text="Library's name:" />
                    <TextBox x:Name="LibraryBox" Margin="0,0,0,12" />

                    <TextBlock Text="Is it currently on loan?" Margin="0,0,0,8" />
                    <StackPanel Orientation="Horizontal">
                        <RadioButton x:Name="LoanYesRadio" GroupName="LoanStatus" Content="Yes" Margin="0,0,16,0" />
                        <RadioButton x:Name="LoanNoRadio" GroupName="LoanStatus" Content="No" />
                    </StackPanel>
                </StackPanel>
            </ScrollViewer>
        </DockPanel>
    </Grid>
</Window>

WPF App Logic (C#)

Goal: Provide the data loading, updating, saving, and deleting capabilities for the UI. Uses an ObservableCollection<Book> which automatically updates the UI when items are added or removed.

Important Reminders:

  • You must include the Book.cs data model class file in this WPF project directory.
  • Just like the console app, the books.txt file must be placed in the output directory (e.g., bin/Debug/net<version>-windows/) for the app to read it successfully at runtime.

1. Initialization & State
Initializes the path, loads books, and maps them to the ListView. A _loadingSelection flag prevents UI events from firing while code updates the TextBoxes.

public partial class MainWindow : Window
{
    private readonly ObservableCollection<Book> _books = new();
    private readonly string _dataPath;
    private bool _loadingSelection;

    public MainWindow()
    {
        InitializeComponent();
        _dataPath = Path.Combine(AppContext.BaseDirectory, "books.txt");
        LoadBooks();
        BooksListView.ItemsSource = _books;
        
        if (_books.Count > 0)
            BooksListView.SelectedIndex = 0;
        else
            ClearDetails();
    }

2. Reading and Writing to File
Parses the text file and populates the observable collection. The save method iterates through the collection to save state back to disk.

    private void LoadBooks()
    {
        _books.Clear();
        if (!File.Exists(_dataPath)) return;

        try
        {
            foreach (var rawLine in File.ReadAllLines(_dataPath))
            {
                var parts = rawLine.Trim().Split('\t');
                if (parts.Length < 6) continue;

                if (int.TryParse(parts[0], out int id) && 
                    int.TryParse(parts[3], out int year) && 
                    int.TryParse(parts[5], out int loan))
                {
                    _books.Add(new Book {
                        Id = id, Title = parts[1], Author = parts[2],
                        PublicationYear = year, LibraryName = parts[4], LoanStatus = loan
                    });
                }
            }
        }
        catch { _books.Clear(); }
    }

    private void SaveToFile()
    {
        var lines = new List<string>(_books.Count);
        foreach (var b in _books)
            lines.Add(string.Join('\t', b.Id, b.Title, b.Author, b.PublicationYear, b.LibraryName, b.LoanStatus));
        File.WriteAllLines(_dataPath, lines);
    }

3. Event Handlers (Selection, Save, Delete)
Updates form fields on selection, saves modifications (with input validation), and handles deletion of items.

    private void BooksListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        if (_loadingSelection) return;

        if (BooksListView.SelectedItem is not Book b)
        {
            ClearDetails();
            return;
        }

        _loadingSelection = true;
        TitleBox.Text = b.Title;
        AuthorBox.Text = b.Author;
        YearBox.Text = b.PublicationYear.ToString();
        LibraryBox.Text = b.LibraryName;
        LoanYesRadio.IsChecked = b.LoanStatus == 1;
        LoanNoRadio.IsChecked = b.LoanStatus == 0;
        _loadingSelection = false;
    }

    private void SaveChanges_Click(object sender, RoutedEventArgs e)
    {
        if (BooksListView.SelectedItem is not Book b) return;

        if (!int.TryParse(YearBox.Text.Trim(), out int year))
        {
            MessageBox.Show(this, "Publication year must be a valid number.", "Error", MessageBoxButton.OK, MessageBoxImage.Warning);
            return;
        }

        // Note: For this to work, the Book class properties must have 'set' instead of 'init'
        b.Title = TitleBox.Text.Trim();
        b.Author = AuthorBox.Text.Trim();
        b.PublicationYear = year;
        b.LibraryName = LibraryBox.Text.Trim();
        b.LoanStatus = LoanYesRadio.IsChecked == true ? 1 : 0;

        BooksListView.Items.Refresh(); // Refresh UI to show updated title in list
        SaveToFile();
    }

    private void DeleteBook_Click(object sender, RoutedEventArgs e)
    {
        if (BooksListView.SelectedItem is not Book b) return;

        int idx = BooksListView.SelectedIndex;
        _books.Remove(b);
        SaveToFile();

        if (_books.Count == 0)
            ClearDetails();
        else
            BooksListView.SelectedIndex = Math.Min(idx, _books.Count - 1);
    }
    
    private void ClearDetails()
    {
        _loadingSelection = true;
        TitleBox.Text = string.Empty;
        AuthorBox.Text = string.Empty;
        YearBox.Text = string.Empty;
        LibraryBox.Text = string.Empty;
        LoanYesRadio.IsChecked = false;
        LoanNoRadio.IsChecked = false;
        _loadingSelection = false;
    }
}

Fullstack DB Connections (MySQL)

Goal: Connect your applications to a MySQL database.

1. C# Connection Template (e.g. for WPF/Console)
Use the following snippet to safely open a connection using the MySql.Data package. The using block ensures the connection is automatically closed when you're done.

using (var conn = new MySql.Data.MySqlClient.MySqlConnection("Server=localhost;Database=database;Uid=username;Pwd=password;")) 
{ 
    conn.Open(); 
    // Execute queries here...
}

Note for ASP.NET / modern .NET apps: The connection string should ideally be placed in your appsettings.json file rather than hardcoded. For example:

{
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft.AspNetCore": "Warning"
        }
    },
    "ConnectionStrings": {
        "DefaultConnection": "Server=localhost;Port=3306;Database=booksdb;User=root;Password=;TreatTinyAsBoolean=True"
    },
    "AllowedHosts": "*"
}

2. PHP Connection String Hints
When connecting to the same MySQL database from a PHP backend, it's recommended to use PDO (PHP Data Objects). Here is the standard secure setup:

<?php
$host = 'localhost'; // or 127.0.0.1
$db   = 'database';
$user = 'username';
$pass = 'password';
$charset = 'utf8mb4';

$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
];

try {
    $pdo = new PDO($dsn, $user, $pass, $options);
} catch (\PDOException $e) {
    // Handle error...
    throw new \PDOException($e->getMessage(), (int)$e->getCode());
}
?>

Fullstack API Models & Entities

Goal: Define the data shapes for JSON serialization and validate incoming HTTP requests in your backend.

1. Data Representation (BookJson)

This model maps database entities to a specific JSON structure using [JsonPropertyName]. This ensures the frontend receives the exact property names it expects (e.g., pub_year instead of PubYear).

using System.Text.Json.Serialization;

namespace PageTurn_Backend.Models;

public class BookJson
{
    [JsonPropertyName("id")]
    public int Id { get; set; }

    [JsonPropertyName("title")]
    public string Title { get; set; } = string.Empty;

    [JsonPropertyName("author")]
    public string Author { get; set; } = string.Empty;

    [JsonPropertyName("pub_year")]
    public int PubYear { get; set; }

    [JsonPropertyName("library")]
    public string Library { get; set; } = string.Empty;

    [JsonPropertyName("is_loaned")]
    public bool IsLoaned { get; set; }
}

2. Request Validation (CreateBookRequest)

This model is used when creating new resources (e.g., via a POST request). It uses Data Annotations like [Required] to validate that the client sent all necessary fields. Making properties nullable (string?) allows ASP.NET to detect missing values and return a 400 Bad Request if the constraint fails.

using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;

namespace PageTurn_Backend.Models;

public class CreateBookRequest
{
    [Required]
    [JsonPropertyName("title")]
    public string? Title { get; set; }

    [Required]
    [JsonPropertyName("author")]
    public string? Author { get; set; }

    [Required]
    [JsonPropertyName("pub_year")]
    public int? PubYear { get; set; }

    [Required]
    [JsonPropertyName("library")]
    public string? Library { get; set; }
}

Database Entities (EF Core)

Goal: Map your C# classes directly to database tables using Entity Framework Core data annotations like [Table], [Key], and [Column].

1. Author Entity

Maps to the author table. Contains a one-to-many navigation property ICollection<Book> allowing you to easily retrieve all books by an author.

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace PageTurn_Backend.Entities;

[Table("author")]
public class Author
{
    [Key]
    [Column("id")]
    public int Id { get; set; }

    [Required]
    [Column("name")]
    [MaxLength(100)]
    public string Name { get; set; } = string.Empty;

    public ICollection<Book>? Books { get; set; }
}

2. Book Entity

Maps to the book table. It defines foreign keys (AuthorId, LibraryId) and uses the [ForeignKey] attribute on the navigation properties to link back to the parent entities.

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace PageTurn_Backend.Entities;

[Table("book")]
public class Book
{
    [Key]
    [Column("id")]
    public int Id { get; set; }

    [Required]
    [Column("title")]
    [MaxLength(200)]
    public string Title { get; set; } = string.Empty;

    [Column("pubyear")]
    public int? PubYear { get; set; }

    [Column("author_id")]
    public int? AuthorId { get; set; }

    [Column("library_id")]
    public int? LibraryId { get; set; }

    [Column("OnLoan")]
    public bool OnLoan { get; set; }

    [ForeignKey(nameof(AuthorId))]
    public Author? Author { get; set; }

    [ForeignKey(nameof(LibraryId))]
    public Library? Library { get; set; }
}

3. Library Entity

Maps to the library table. Similar to the Author entity, it contains a collection of books representing a one-to-many relationship.

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace PageTurn_Backend.Entities;

[Table("library")]
public class Library
{
    [Key]
    [Column("id")]
    public int Id { get; set; }

    [Required]
    [Column("name")]
    [MaxLength(100)]
    public string Name { get; set; } = string.Empty;

    [Column("city")]
    [MaxLength(60)]
    public string? City { get; set; }

    public ICollection<Book>? Books { get; set; }
}

Database Context (EF Core)

Goal: Define the DbContext that acts as a bridge between your entities and the database.

This class contains DbSet properties for each table. The OnModelCreating method is overridden to explicitly configure the relationships (e.g., HasOne / WithMany) and constraint behaviors like DeleteBehavior.Restrict to prevent accidental cascading deletes.

using Microsoft.EntityFrameworkCore;
using PageTurn_Backend.Entities;

namespace PageTurn_Backend
{
    public class PageTurnDbContext : DbContext
    {
        public DbSet<Author> Authors => Set<Author>();
        public DbSet<Library> Libraries => Set<Library>();
        public DbSet<Book> Books => Set<Book>();

        public PageTurnDbContext(DbContextOptions<PageTurnDbContext> options) : base(options) { }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Book>(entity =>
            {
                entity.HasOne(b => b.Author)
                    .WithMany(a => a.Books)
                    .HasForeignKey(b => b.AuthorId)
                    .OnDelete(DeleteBehavior.Restrict);

                entity.HasOne(b => b.Library)
                    .WithMany(l => l.Books)
                    .HasForeignKey(b => b.LibraryId)
                    .OnDelete(DeleteBehavior.Restrict);
            });
        }
    }
}

API Controller

Goal: Expose HTTP endpoints (GET, POST, PUT, DELETE) to handle incoming API requests and interact with the database via the DbContext.

1. Controller Setup & Helper Methods

The controller uses Dependency Injection to receive the PageTurnDbContext. It also defines helper methods to include related data (Include(b => b.Author)) and map entities to the BookJson DTO.

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PageTurn_Backend.Entities;
using PageTurn_Backend.Models;

namespace PageTurn_Backend.Controllers
{
    [ApiController]
    [Route("api/books")]
    public class PageTurnController : ControllerBase
    {
        private readonly PageTurnDbContext _context;

        public PageTurnController(PageTurnDbContext context)
        {
            _context = context;
        }

        private static IQueryable<Book> BooksWithRelated(PageTurnDbContext ctx) =>
            ctx.Books
                .AsNoTracking()
                .Include(b => b.Author)
                .Include(b => b.Library);

        private static BookJson ToJson(Book b) => new()
        {
            Id = b.Id,
            Title = b.Title,
            Author = b.Author?.Name ?? string.Empty,
            PubYear = b.PubYear ?? 0,
            Library = b.Library?.Name ?? string.Empty,
            IsLoaned = b.OnLoan
        };

2. GET Endpoints

Retrieve all books or a single book by ID. Returns HTTP 200 (Ok) or HTTP 404 (Not Found).

        [HttpGet]
        public async Task<ActionResult<IEnumerable<BookJson>>> GetBooks()
        {
            var books = await BooksWithRelated(_context).OrderBy(b => b.Id).ToListAsync();
            return Ok(books.Select(ToJson));
        }

        [HttpGet("{id:int}")]
        public async Task<ActionResult<BookJson>> GetBook(int id)
        {
            var book = await BooksWithRelated(_context).FirstOrDefaultAsync(b => b.Id == id);
            if (book is null)
                return NotFound(new { error = "Nincs ilyen azonosítójú könyv." });

            return Ok(ToJson(book));
        }

3. POST Endpoint (Create)

Handles the creation of a new book. It manually validates the input, checks if the author and library exist (creating them if they don't), saves the new book, and returns HTTP 201 (Created).

        [HttpPost]
        public async Task<ActionResult<BookJson>> CreateBook([FromBody] CreateBookRequest? body)
        {
            if (body is null)
                return BadRequest(new { error = "A kérés törzse JSON legyen: title, author, pub_year, library mezőkkel." });

            if (!ModelState.IsValid)
                return BadRequest(new { error = "Érvénytelen kérés. A title, author, pub_year és library mezők mindegyike kötelező." });

            if (string.IsNullOrWhiteSpace(body.Title)
                || string.IsNullOrWhiteSpace(body.Author)
                || string.IsNullOrWhiteSpace(body.Library)
                || body.PubYear is null)
                return BadRequest(new { error = "Minden mező (title, author, pub_year, library) kötelező." });

            var authorName = body.Author.Trim();
            var libraryName = body.Library.Trim();

            var author = await _context.Authors.FirstOrDefaultAsync(a => a.Name == authorName);
            if (author is null)
            {
                author = new Author { Name = authorName };
                _context.Authors.Add(author);
                await _context.SaveChangesAsync();
            }

            var library = await _context.Libraries.FirstOrDefaultAsync(l => l.Name == libraryName);
            if (library is null)
            {
                library = new Library { Name = libraryName };
                _context.Libraries.Add(library);
                await _context.SaveChangesAsync();
            }

            var entity = new Book
            {
                Title = body.Title.Trim(),
                PubYear = body.PubYear.Value,
                AuthorId = author.Id,
                LibraryId = library.Id,
                OnLoan = false
            };

            _context.Books.Add(entity);
            await _context.SaveChangesAsync();

            var created = await BooksWithRelated(_context).FirstAsync(b => b.Id == entity.Id);
            return CreatedAtAction(nameof(GetBook), new { id = created.Id }, ToJson(created));
        }

4. PUT Endpoint (Update)

Updates an existing book to mark it as loaned. Returns the updated book or 404.

        [HttpPut("{id:int}/loan")]
        public async Task<ActionResult<BookJson>> LoanBook(int id)
        {
            var book = await _context.Books
                .Include(b => b.Author)
                .Include(b => b.Library)
                .FirstOrDefaultAsync(b => b.Id == id);
                
            if (book is null)
                return NotFound(new { error = "Nincs ilyen azonosítójú könyv." });

            book.OnLoan = true;
            await _context.SaveChangesAsync();

            return Ok(ToJson(book));
        }
    }
}

HTML Form

Goal: Build an accessible, semantic HTML form with labels, inputs, a select dropdown, a textarea, and a submit button.

Key Attributes

  • method="post" — sends form data in the request body (not in the URL).
  • required — triggers native browser validation before submission.
  • autocomplete="name" / autocomplete="email" — hints to the browser what to autofill.
  • aria-label — provides an accessible name for elements that have no visible label (e.g., the <select>).
  • class="visually-hidden" — visually hides the label text while keeping it accessible to screen readers.
  • &amp; — HTML entity for the & character inside attribute values and content.
<form class="contact-form" method="post" action="#">
    <label class="form-field">
        <span class="visually-hidden">Your name</span>
        <input type="text" name="name" placeholder="Your name"
               required autocomplete="name" />
    </label>

    <label class="form-field">
        <span class="visually-hidden">Your email</span>
        <input type="email" name="email" placeholder="Your email"
               required autocomplete="email" />
    </label>

    <label class="form-field">
        <span class="visually-hidden">Which book</span>
        <!-- select has no visible label, so aria-label is used instead -->
        <select name="book" aria-label="Which book? (optional)">
            <option value="">Which book? (optional)</option>
            <option value="The Midnight Garden">The Midnight Garden</option>
            <option value="Signal &amp; Noise">Signal &amp; Noise</option>
        </select>
    </label>

    <label class="form-field">
        <span class="visually-hidden">Message</span>
        <textarea name="message" placeholder="Tell us why..." rows="4"></textarea>
    </label>

    <button type="submit">Send request</button>
</form>

JS Hamburger Menu & Form Validation

Goal: Toggle a mobile navigation menu, close it on link clicks or Escape, and intercept form submission for custom validation — all inside an IIFE to avoid polluting the global scope.

1. IIFE Wrapper & Element References

Wrap everything in an Immediately Invoked Function Expression (function () { ... })(). This keeps all variables private. Early return if elements don't exist is a safe guard.

(function () {
    const hamburger = document.getElementById('hamburger');
    const nav = document.getElementById('primary-navigation');
    if (!hamburger || !nav) return; // guard clause

    function setOpen(open) {
        nav.classList.toggle('open', open);         // toggle CSS class
        hamburger.classList.toggle('active', open);
        hamburger.setAttribute('aria-expanded', open ? 'true' : 'false'); // accessibility
    }

2. Toggle on Click

Clicking the hamburger flips the current state by checking if the nav already has the open class.

    hamburger.addEventListener('click', function () {
        setOpen(!nav.classList.contains('open'));
    });

3. Close on Nav Link Click (Mobile)

On small screens, clicking a nav link should close the menu. querySelectorAll returns all matching elements, and forEach attaches a listener to each.

    nav.querySelectorAll('a[href]').forEach(function (link) {
        link.addEventListener('click', function () {
            if (window.matchMedia('(max-width: 768px)').matches)
                setOpen(false);
        });
    });

4. Close on Escape Key

Listen for keydown on the whole document, and close the menu if Escape is pressed.

    document.addEventListener('keydown', function (e) {
        if (e.key === 'Escape') setOpen(false);
    });

5. Form Submission Interception

Only prevent default if the form is valid. checkValidity() runs the built-in HTML5 validation (e.g., required, type="email") and returns true if everything passes. If invalid, we simply return early and let the browser show its native error messages.

    var form = document.querySelector('.contact-form');
    if (form) {
        form.addEventListener('submit', function (e) {
            if (!form.checkValidity()) return; // let browser show native errors
            e.preventDefault();                // custom handling goes here
        });
    }
})();

Client App HTML Structure

Goal: Structure the frontend page that consumes the API. Key elements get IDs so the JS can grab them. The API base URL is passed in via a <meta> tag to avoid hardcoding it in the script.

1. API Base Meta Tag & Fonts

The api-base meta tag is read by the JS at runtime — change the URL here to point at any backend without touching the script file.

<meta name="api-base" content="http://localhost:5237/api" />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600;700
            &family=Source+Sans+3:wght@400;500;600&display=swap" rel="stylesheet" />

2. Navigation & Hamburger Button

The nav has id="nav-links" and the button has id="hamburger" with aria-expanded so JS can toggle mobile menus accessibly.

<nav id="nav-links" class="nav-links">
    <a href="#">Home</a>
    <a href="#books">Books</a>
    <a href="#donate">Donate</a>
</nav>
<button class="hamburger" id="hamburger" type="button"
        aria-label="Toggle menu" aria-expanded="false" aria-controls="nav-links">
    <span></span><span></span><span></span>
</button>

3. Book Search Form

inputmode="numeric" shows a numeric keyboard on mobile. pattern="[0-9]*" restricts typing to digits.

<form class="search-bar" id="book-search-form" action="#" method="get">
    <input type="text" id="book-search-input" name="id"
           inputmode="numeric" pattern="[0-9]*"
           placeholder="Search by book ID" autocomplete="off"
           aria-label="Search by book ID" />
    <button type="submit">Search</button>
</form>

4. Books Grid & Card Template

The <template> element is never rendered by the browser — the JS clones it with content.cloneNode(true) to stamp out each book card. This avoids building HTML via string concatenation.

<!-- Empty container; cards are injected by JS -->
<div class="book-grid" id="books-container"></div>

<template id="book-card-template">
    <article class="book-card" data-book-id="">
        <div class="card-badge">Available</div>
        <div class="card-icon" aria-hidden="true">📕</div>
        <h3 class="card-title"></h3>
        <p class="card-meta"></p>
        <button type="button" class="card-cta reserve-btn">Reserve</button>
    </article>
</template>

5. Donate Form

The hidden #donate-feedback paragraph is shown or hidden by the JS to give the user success/error messages after submission.

<p id="donate-feedback" class="form-feedback" hidden></p>
<form class="contact-form" id="donate-form">
    <input name="title" type="text" placeholder="Title" autocomplete="off" />
    <input name="author" type="text" placeholder="Author" autocomplete="off" />
    <input name="library" type="text" placeholder="Library's name" autocomplete="off" />
    <input name="pub_year" type="number" placeholder="Publication year" min="0" max="2100" step="1" />
    <button type="submit">Donate book</button>
</form>

Client App JS (API Usage)

Goal: Fetch books from the ASP.NET backend API and render them into the page, with search by ID, book reservation (loan), and a donate form that POSTs to the API.

1. Setup: API Base & State Variables

The API base URL is read from the <meta name="api-base"> tag, with a fallback to /api. State is kept in module-level variables inside an IIFE. AbortController lets us cancel in-flight ID searches if the user types faster than the response arrives.

const metaBase = document.querySelector('meta[name="api-base"]')
    ?.getAttribute('content')?.trim();
const API_BASE = (metaBase || '/api').replace(/\/$/, '');

let allBooksCache = [];   // holds the full list from GET /books
let searchDebounceId = 0; // timeout ID for debouncing input
let idSearchAbort = null; // AbortController for cancelling in-flight fetches

2. Rendering: Template Cloning

Instead of building HTML strings, the <template> element is cloned and its inner elements are filled in. replaceChildren() clears the grid before adding new cards.

function createCardFromTemplate(book) {
    const frag = bookTemplate.content.cloneNode(true);
    const article = frag.querySelector('.book-card');
    const onLoan = book.is_loaned === true;

    article.dataset.bookId = String(book.id);
    frag.querySelector('.card-badge').textContent = onLoan ? 'OnLoan' : 'Available';
    frag.querySelector('.card-icon').textContent = ICONS[book.id % ICONS.length];
    frag.querySelector('.card-title').textContent = book.title || '';
    frag.querySelector('.card-meta').textContent =
        [book.author, book.pub_year, book.library].filter(Boolean).join(' · ');

    const btn = frag.querySelector('.reserve-btn');
    btn.textContent = onLoan ? 'Loaned' : 'Reserve';
    btn.disabled = onLoan;
    return article;
}

function renderBookCards(books) {
    booksContainer.replaceChildren();
    for (const book of books)
        booksContainer.appendChild(createCardFromTemplate(book));
}

3. Fetching Data (GET)

All books are fetched on page load and cached. A search by ID uses the /books/{id} endpoint. An AbortController cancels the previous request if a new one starts before it finishes.

async function fetchAllBooks() {
    const res = await fetch(`${API_BASE}/books`);
    if (!res.ok) throw new Error(`GET /books failed: ${res.status}`);
    return res.json();
}

async function runIdSearch(idString) {
    if (idSearchAbort) idSearchAbort.abort(); // cancel any previous request
    idSearchAbort = new AbortController();

    const res = await fetch(`${API_BASE}/books/${idString}`,
        { signal: idSearchAbort.signal }
    );
    if (res.status === 404) { showGridMessage('No book found'); return; }
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    renderBookCards([await res.json()]);
}

4. Debounced Search Input

Debouncing waits 280 ms after the user stops typing before running the search, so the API isn't hit on every keystroke.

function scheduleSearchDebounce() {
    window.clearTimeout(searchDebounceId);
    searchDebounceId = window.setTimeout(() => {
        void syncDisplayWithSearch();
    }, 280); // wait 280ms after last keystroke
}

searchInput.addEventListener('input', () => {
    const v = searchInput.value.trim();
    if (v === '') { renderBookCards(allBooksCache); return; }
    scheduleSearchDebounce();
});

5. Donating a Book (POST)

The donate form is read with FormData, values are validated, then sent as JSON to POST /books. After success, the full book list is refreshed.

async function donateBook(payload) {
    const res = await fetch(`${API_BASE}/books`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
        body: JSON.stringify(payload), // { title, author, library, pub_year }
    });
    if (!res.ok) {
        const body = await res.json().catch(() => null);
        throw new Error(body?.error || `HTTP ${res.status}`);
    }
    return res.json();
}

donateForm.addEventListener('submit', async (ev) => {
    ev.preventDefault();
    const fd = new FormData(donateForm);
    const payload = {
        title:    String(fd.get('title') || '').trim(),
        author:   String(fd.get('author') || '').trim(),
        library:  String(fd.get('library') || '').trim(),
        pub_year: parseInt(fd.get('pub_year'), 10),
    };
    await donateBook(payload);
    donateForm.reset();
    await refreshAllBooks();
    await syncDisplayWithSearch();
});

6. Loaning a Book (PUT) via Event Delegation

Instead of attaching a click listener to every card button, one listener on the parent grid uses closest('.reserve-btn') to find the clicked button. This is event delegation — it works even for cards added dynamically after the listener was attached.

async function loanBook(id) {
    const res = await fetch(`${API_BASE}/books/${id}/loan`, {
        method: 'PUT',
        headers: { Accept: 'application/json' },
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.text().then(t => t ? JSON.parse(t) : null);
}

// Event delegation on the container, not on each button
booksContainer.addEventListener('click', async (ev) => {
    const btn = ev.target.closest('.reserve-btn');
    if (!btn || btn.disabled) return;

    const idStr = btn.closest('.book-card')?.dataset.bookId;
    if (!idStr) return;

    btn.disabled = true; // optimistically disable to prevent double-clicks
    try {
        await loanBook(idStr);
        await refreshAllBooks();
        await syncDisplayWithSearch();
    } catch (e) {
        window.alert(e.message);
        btn.disabled = false; // re-enable on error
    }
});