Back to "Havaintonäkymä Komponentti ASP.NET Core Tag Helper (osa 1 Bare-Bones)"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

ASP.NET Core PagingTagHelper TagHelper

Havaintonäkymä Komponentti ASP.NET Core Tag Helper (osa 1 Bare-Bones)

Tuesday, 11 March 2025

Johdanto

Toissapäivänä työprojekti edellytti hakulomakkeen tulosten toteuttamista. Matkahakuapulaiseni on aina ollut Darrel O'Neillin pagination Tag Helper kuten olen kirjoittanut täällä Mutta jostain syystä se on vain lopetti työskentelyn Minulle. Sen sijaan, että yrittäisin selvittää, mikä näyttää hylätyltä projektilta tässä vaiheessa, päätin rakentaa sellaisen itse.

Kuten tavallista, voit saada lähteen tälle GitHubillani

Minulla on näytesivusto tätä projektia varten. isännöidyt täällä

Tässä on näytteet ulostulosta näin:

Etsi demosta Tag Helpergin avulla

Vaatimukset

Tämän tagin auttajalle minulla oli muutama vaatimus:

  1. Toimii saumattomasti Perätuuli sekä DaisyUI; Suosittu CSS-kehys.
  2. Pitäisi työskennellä HTMX:n kanssa aiheuttamatta ongelmia.
  3. Jos et käytä HTMX:ää, sen pitäisi silti toimia, mutta sinun täytyy lisätä painiketta.
  4. Sen pitäisi olla helppo konfiguroida ja käyttää
    1. Hyväksyy hakumallin, joten sitä on helppo käyttää Razor-sivulla
    2. Pitäisi pystyä konfiguroimaan muutamalla yksinkertaisella muuttujalla
  5. Sen pitäisi olla mahtava paketti, jotta kaikki mahtavat ihmiset voivat leikkiä sillä.
  6. Sen pitäisi olla sekä ViewComponentti että TagHelper, jotta sitä voidaan käyttää sekä Razor-sivuilla että -näkymissä. Dafault.cshtml näkymä.
  7. Toimii yksinkertaisen hakutoiminnon kanssa.

Tulevaisuudessa lisään kyvyn:

  1. Lisää muokattu CSS, jotta vältytään sidonnalta DaisyUI:iin ja Tailwindiin. Olen lisännyt tämän ominaisuuden jo katso demo tästä: https://taghelper sample.mostlylucid.net/Home/PlainView
  2. Kyky määritellä sivukokoja
  3. Kyky lisätä oma JS-kutsu sivun koon laskuun (jotta et voi käyttää HTMX:ää).
  4. Käytä Alppien tehdä hakulaite aktiivisempi ja reagoivampi (kuten tein minun aiempi artikkeli).

Asennus

Taghelper on nyt kiiltävä uusi Nuget-paketti, joten voit asentaa sen seuraavalla komennolla:

dotnet add package mostlylucid.pagingtaghelper

Sitten lisäisit tunnisteen apuriksesi. _ViewImports.cshtml tiedosto näin:

@addTagHelper *, mostlylucid.pagingtaghelper

Sitten voit vain alkaa käyttää sitä. Tarjoan joitakin auttajatunteja, joita voit käyttää sen konfigurointiin, kuten

IPagingModel

Tämä on perusasia, joka sinun pitää aloittaa. Se on yksinkertainen käyttöliittymä, jonka voit toteuttaa mallillasi, jotta haku toimii. Huomaa, että ViewType on valinnainen tässä se oletukset TailwindANdDaisy mutta voit asettaa sen Custom, Plain tai Bootstrap jos haluaa käyttää toisenlaista näkemystä.

public enum ViewType
{
TailwindANdDaisy,
Custom,
Plain,
Bootstrap
}

TAI voit jopa määritellä mukautetun näkymän TagHelperin avulla use-local-view kiinteistöt.

namespace mostlylucid.pagingtaghelper.Models;

public interface IPagingModel
{
    public int Page { get; set; }
    public int TotalItems { get; set; }
    public int PageSize { get; set; }

    public ViewType ViewType { get; set; }
    
    public string LinkUrl { get; set; }
}
namespace mostlylucid.pagingtaghelper.Models;

public interface IPagingSearchModel : IPagingModel
{
    public string? SearchTerm { get; set; }
}

Olen pannut nämä täytäntöön myös hankkeessa, jonka tavoitteena on luoda perustaso:

public abstract class BasePagerModel : IPagingModel
{
    public int Page { get; set; } = 1;
    public int TotalItems { get; set; } = 0;
    public int PageSize { get; set; } = 10;
    public ViewType ViewType { get; set; } = ViewType.TailwindANdDaisy;

    public string LinkUrl { get; set; } = "";

}
public abstract class BasePagerSearchMdodel : BasePagerModel, IPagingSearchModel
{
    public string? SearchTerm { get; set; }
}

Kirjoitan hakutoiminnon tulevaan artikkeliin..

Tag Helper

Sitten selvitin, miltä toivoisin TagHelperin näyttävän käytössä:

<paging
    x-ref="pager"
    hx-boost="true"
    hx-indicator="#loading-modal"
    hx-target="#user-list "
    hx-swap="show:none"
    model="Model"
    pages-to-display="10"
    hx-headers='{"pagerequest": "true"}'>
</paging>

Tässä näet muutaman HTMX-parametrin ja mallin, joita käytetään hakuun. Asetan myös näytettävän sivumäärän ja otsikot lähetettäväksi pyynnöstä (tämän avulla voin käyttää HTMX:ää sivun kansoittamiseen).

Komponentissa on myös BUNCH muita konfiguraatioelementtejä, joita käsittelen tulevissa artikkeleissa. Kuten näet, siellä on paljon mahdollista kokoonpanoa.

<paging css-class=""
        first-last-navigation=""
        first-page-text=""
        next-page-aria-label=""
        next-page-text=""
        page=""
        pages-to-display=""
        page-size=""
        previous-page-text=""
        search-term=""
        skip-forward-back-navigation=""
        skip-back-text=""
        skip-forward-text="true"
        total-items=""
        link-url=""
        last-page-text=""
        show-pagesize=""
        use-htmx=""
        use-local-view=""
        view-type="Bootstrap"
        htmx-target=""
        id=""
></paging>

TagHelper on melko yksinkertainen, mutta sillä on joukko ominaisuuksia, joiden avulla käyttäjä voi muokata käyttäytymistä (ks. alla näkymä Ominaisuuksia lukuun ottamatta (jota en liitä tähän lyhyyden vuoksi) koodi on melko yksinkertainen:

    /// <summary>
    /// Processes the tag helper to generate the pagination component.
    /// </summary>

    /// <param name="context">The tag helper context.</param>
    /// <param name="output">The tag helper output.</param>
    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
   
        output.TagName = "div";
        
        //Remove all the properties that are not needed for the rendered content.
        output.Attributes.RemoveAll("page");
        output.Attributes.RemoveAll("link-url");
        output.Attributes.RemoveAll("page-size");
        output.Attributes.RemoveAll("total-items");
        output.Attributes.RemoveAll("pages-to-display");
        output.Attributes.RemoveAll("css-class");
        output.Attributes.RemoveAll("first-page-text");
        output.Attributes.RemoveAll("previous-page-text");
        output.Attributes.RemoveAll("skip-back-text");
        output.Attributes.RemoveAll("skip-forward-text");
        output.Attributes.RemoveAll("next-page-text");
        output.Attributes.RemoveAll("next-page-aria-label");
        output.Attributes.RemoveAll("last-page-text");
        output.Attributes.RemoveAll("first-last-navigation");
        output.Attributes.RemoveAll("skip-forward-back-navigation");
        output.Attributes.RemoveAll("model");
        output.Attributes.RemoveAll("show-pagesize");
        output.Attributes.RemoveAll("pagingmodel");
        output.Attributes.RemoveAll("use-local-view");
        
        var pagerId =  PagerId ?? $"pager-{Guid.NewGuid():N}";
        var linkUrl = LinkUrl ?? ViewContext.HttpContext.Request.Path;
        PageSize = Model?.PageSize ?? PageSize ?? 10;
        Page = Model?.Page ?? Page ?? 1;
        ViewType = Model?.ViewType ?? ViewType;
        TotalItems = Model?.TotalItems ?? TotalItems ?? 0;
        if(Model is IPagingSearchModel searchModel)
            SearchTerm = searchModel?.SearchTerm ?? SearchTerm ?? "";
        output.Attributes.SetAttribute("id", pagerId);
        var viewComponentHelper = (IViewComponentHelper)ViewContext.HttpContext.RequestServices.GetService(typeof(IViewComponentHelper))!;
        ((IViewContextAware)viewComponentHelper).Contextualize(ViewContext);

        var pagerModel = PagerModel ?? new PagerViewModel()
        {
            
            ViewType = ViewType,
            UseLocalView = UseLocalView,
            UseHtmx = UseHtmx,
            PagerId = pagerId,
            SearchTerm = SearchTerm,
            ShowPageSize = ShowPageSize,
            Model = Model,
            LinkUrl = linkUrl,
            Page = Page,
            PageSize = PageSize,
            TotalItems = TotalItems,
            PagesToDisplay = PagesToDisplay,
            CssClass = CssClass,
            FirstPageText = FirstPageText,
            PreviousPageText = PreviousPageText,
            SkipBackText = SkipBackText,
            SkipForwardText = SkipForwardText,
            NextPageText = NextPageText,
            NextPageAriaLabel = NextPageAriaLabel,
            LastPageText = LastPageText,
            FirstLastNavigation = FirstLastNavigation,
            SkipForwardBackNavigation = SkipForwardBackNavigation,
            HtmxTarget = HtmxTarget,
            
        };

        var result = await viewComponentHelper.InvokeAsync("Pager", pagerModel);
        output.Content.SetHtmlContent(result);
    }

Se koostuu seuraavista vaiheista:

  1. Aseta tulostustagin nimi divTämä on hakulaitteen kontti.
  2. Poista kaikki ominaisuudet, joita ei tarvita renderoituun sisältöön (mutta jätä kaikki käyttäjälle tarjotut ominaisuudet, tämä mahdollistaa yksinkertaisen räätälöinnin).
  3. Aseta hakulaite satunnaiseen GUIDiin, jos sitä ei ole annettu (tätä käytetään todella mukautetun koodin käyttöön, voit määrittää tunnisteen tai vain antaa tämän koodin hoitaa sen).
  4. Aseta linkkiUrl nykyiselle polulle, jos sitä ei ole annettu - tämän avulla voit ohittaa tämän, jos haluat käyttää toista URL- osoitetta.
  5. Aseta malliin PageSize, Page, ViewType, TotalItems ja SearchTerm, jos sellainen on annettu, tai oletus, jos ei ole. Tämä mahdollistaa sen, että voimme vain ohittaa IPagingModel ja antaa hakulaitteen toimia ilman lisäasetuksia.
  6. Aseta tunnisteen attribuutti hakulaitteeseen.
  7. Hae ViewConponentHelper DI-kontista ja kontekstisoi se nykyisellä ViewContextillä.
  8. Luo uusi PagerViewModel Ominaisuudet on asetettu arvoille, jotka meillä on, tai oletukset, jos niitä ei ole annettu.
  9. Äänitys Pager ViewConponent with the PagerViewModel ja aseta tulossisältö tulokseen.

Tämäkin on aika yksinkertaista.

Näkemyskompostori

Näkymä

Näkymä ViewComponentille on melko yksinkertainen; se on vain silmukka sivujen läpi ja muutama linkki ensimmäiselle, viimeiselle, seuraavalle ja edelliselle sivulle.

Complete source code for the Default TailwindUI & Daisy view
@model mostlylucid.pagingtaghelper.Components.PagerViewModel
@{
    var totalPages = (int)Math.Ceiling((double)Model.TotalItems! / (double)Model.PageSize!);
    var pageSizes = new List<int>();
    if (Model.ShowPageSize)
    {
        // Build a dynamic list of page sizes.

        // Fixed steps as a starting point.
        int[] fixedSteps = { 10, 25, 50, 75, 100, 125, 150, 200, 250, 500, 1000 };

        // Add only those fixed steps that are less than or equal to TotalItems.
        foreach (var step in fixedSteps)
        {
            if (step <= Model.TotalItems)
            {
                pageSizes.Add(step);
            }
        }

        // If TotalItems is greater than the largest fixed step,
        // add additional steps by doubling until reaching TotalItems.
        if (Model.TotalItems > fixedSteps.Last())
        {
            int next = fixedSteps.Last();
            while (next < Model.TotalItems)
            {
                next *= 2;
                // Only add if it doesn't exceed TotalItems.
                if (next < Model.TotalItems)
                {
                    pageSizes.Add(next);
                }
            }

            // Always include the actual TotalItems as the maximum option.
            if (!pageSizes.Contains(Model.TotalItems.Value))
            {
                pageSizes.Add(Model.TotalItems.Value);
            }
        }
    }
}
@if (totalPages > 1)
{
    <div class="@Model.CssClass flex items-center justify-center" id="pager-container">
        @if (Model.ShowPageSize)
        {
            var pagerId = Model.PagerId;
            var htmxAttributes = Model.UseHtmx
                ? $"hx-get=\"{Model.LinkUrl}\" hx-trigger=\"change\" hx-include=\"#{pagerId} [name='page'], #{pagerId} [name='search']\" hx-push-url=\"true\""
                : "";


            <!-- Preserve current page -->
            <input type="hidden" name="page" value="@Model.Page"/>
            <input type="hidden" name="search" value="@Model.SearchTerm"/>
            <input type="hidden" class="useHtmx" value="@Model.UseHtmx.ToString().ToLowerInvariant()"/>
            if (!Model.UseHtmx)
            {
                <input type="hidden" class="linkUrl" value="@Model.LinkUrl"/>
            }

            <!-- Page size select with label -->
            <div class="flex items-center mr-8">
                <label for="pageSize-@pagerId" class="text-sm text-gray-600 mr-2">Page size:</label>
                <select id="pageSize-@pagerId"
                        name="pageSize"
                        class="border rounded select select-primary select-sm pt-0 mt-0 min-w-[80px] pr-4"
                        @Html.Raw(htmxAttributes)>
                    @foreach (var option in pageSizes.ToList())
                    {
                        var optionString = option.ToString();
                        if (option == Model.PageSize)
                        {
                            <option value="@optionString" selected="selected">@optionString</option>
                        }
                        else
                        {
                            <option value="@optionString">@optionString</option>
                        }
                    }
                </select>
            </div>
        }

        @* "First" page link *@
        @if (Model.FirstLastNavigation && Model.Page > 1)
        {
            var href = $"{Model.LinkUrl}?page=1&pageSize={Model.PageSize}";
            if (!string.IsNullOrEmpty(Model.SearchTerm))
            {
                href += $"&search={Model.SearchTerm}";
            }

            <a class="btn btn-sm"
               href="@href">
                @Model.FirstPageText
            </a>
        }

        @* "Previous" page link *@
        @if (Model.Page > 1)
        {
            var href = $"{Model.LinkUrl}?page={Model.Page - 1}&pageSize={Model.PageSize}";
            if (!string.IsNullOrEmpty(Model.SearchTerm))
            {
                href += $"&search={Model.SearchTerm}";
            }

            <a class="btn btn-sm"
               href="@href">
                @Model.PreviousPageText
            </a>
        }

        @* Optional skip back indicator *@
        @if (Model.SkipForwardBackNavigation && Model.Page > Model.PagesToDisplay)
        {
            <a class="btn btn-sm btn-disabled">
                @Model.SkipBackText
            </a>
        }

        @* Determine visible page range *@
        @{
            int halfDisplay = Model.PagesToDisplay / 2;
            int startPage = Math.Max(1, Model.Page.Value - halfDisplay);
            int endPage = Math.Min(totalPages, startPage + Model.PagesToDisplay - 1);
            startPage = Math.Max(1, endPage - Model.PagesToDisplay + 1);
        }
        @for (int i = startPage; i <= endPage; i++)
        {
            var href = $"{Model.LinkUrl}?page={i}&pageSize={Model.PageSize}";
            if (!string.IsNullOrEmpty(Model.SearchTerm))
            {
                href += $"&search={Model.SearchTerm}";
            }

            <a data-page="@i" class="btn btn-sm mr-2 @(i == Model.Page ? "btn-active" : "")"
               href="@href">
                @i
            </a>
        }

        @* Optional skip forward indicator *@
        @if (Model.SkipForwardBackNavigation && Model.Page < totalPages - Model.PagesToDisplay + 1)
        {
            <a class="btn btn-sm btn-disabled mr-2">
                @Model.SkipForwardText
            </a>
        }

        @* "Next" page link *@
        @if (Model.Page < totalPages)
        {
            var href = $"{Model.LinkUrl}?page={Model.Page + 1}&pageSize={Model.PageSize}";
            if (!string.IsNullOrEmpty(Model.SearchTerm))
            {
                href += $"&search={Model.SearchTerm}";
            }

            <a class="btn btn-sm mr-2"
               href="@href"
               aria-label="@Model.NextPageAriaLabel">
                @Model.NextPageText
            </a>
        }

        @* "Last" page link *@
        @if (Model.FirstLastNavigation && Model.Page < totalPages)
        {
            var href = $"{Model.LinkUrl}?page={totalPages}&pageSize={Model.PageSize}";
            if (!string.IsNullOrEmpty(Model.SearchTerm))
            {
                href += $"&search={Model.SearchTerm}";
            }

            <a class="btn btn-sm"
               href="@href">
                @Model.LastPageText
            </a>
        }

        <!-- Page info text with left margin for separation -->
        <div class="text-sm text-neutral-500 ml-8">
            Page @Model.Page of @totalPages (Total items: @Model.TotalItems)
        </div>
    </div>
}
Tämä on jaettu muutamaan osaan:
  1. Sivun koon pudotus
  2. Ensimmäiset, viimeiset, seuraavat ja aiemmat linkit
  3. Ohita takaisin ja ohita etulinkit
  4. Sivun linkit
  5. Sivun infoteksti

Sivun koon lasku

Yksi asia, joka minulta puuttui alkuperäisestä lapusta, oli sivun kokoinen pudotus. Tämä on yksinkertainen valintalista, näet, että aloitan määrittelemällä fixedSteps jotka ovat vain muutamia kiinteitä askeleita, joita haluan käyttää pudotuksessa. Sitten silmukkaan nämä ja lisään ne listalle. Minulla on aina tapana valita "kaikki", joten lisään listalle kaikki kohteet, jos niitä ei ole jo olemassa.

@{
    var totalPages = (int)Math.Ceiling((double)Model.TotalItems! / (double)Model.PageSize!);
    var pageSizes = new List<int>();
    if (Model.ShowPageSize)
    {
        // Build a dynamic list of page sizes.

        // Fixed steps as a starting point.
        int[] fixedSteps = { 10, 25, 50, 75, 100, 125, 150, 200, 250, 500, 1000 };

        // Add only those fixed steps that are less than or equal to TotalItems.
        foreach (var step in fixedSteps)
        {
            if (step <= Model.TotalItems)
            {
                pageSizes.Add(step);
            }
        }

        // If TotalItems is greater than the largest fixed step,
        // add additional steps by doubling until reaching TotalItems.
        if (Model.TotalItems > fixedSteps.Last())
        {
            int next = fixedSteps.Last();
            while (next < Model.TotalItems)
            {
                next *= 2;
                // Only add if it doesn't exceed TotalItems.
                if (next < Model.TotalItems)
                {
                    pageSizes.Add(next);
                }
            }

            // Always include the actual TotalItems as the maximum option.
            if (!pageSizes.Contains(Model.TotalItems.Value))
            {
                pageSizes.Add(Model.TotalItems.Value);
            }
        }
    }
}

Sitten esitän tämän sivulle

  @if (Model.ShowPageSize)
        {
            var pagerId = Model.PagerId;
            var htmxAttributes = Model.UseHtmx
                ? $"hx-get=\"{Model.LinkUrl}\" hx-trigger=\"change\" hx-include=\"#{pagerId} [name='page'], #{pagerId} [name='search']\" hx-push-url=\"true\""
                : "";


            <!-- Preserve current page -->
            <input type="hidden" name="page" value="@Model.Page"/>
            <input type="hidden" name="search" value="@Model.SearchTerm"/>
            <input type="hidden" class="useHtmx" value="@Model.UseHtmx.ToString().ToLowerInvariant()"/>
            if (!Model.UseHtmx)
            {
                <input type="hidden" class="linkUrl" value="@Model.LinkUrl"/>
            }

            <!-- Page size select with label -->
            <div class="flex items-center mr-8">
                <label for="pageSize-@pagerId" class="text-sm text-gray-600 mr-2">Page size:</label>
                <select id="pageSize-@pagerId"
                        name="pageSize"
                        class="border rounded select select-primary select-sm pt-0 mt-0 min-w-[80px] pr-4"
                        @Html.Raw(htmxAttributes)>
                    @foreach (var option in pageSizes.ToList())
                    {
                        var optionString = option.ToString();
                        if (option == Model.PageSize)
                        {
                            <option value="@optionString" selected="selected">@optionString</option>
                        }
                        else
                        {
                            <option value="@optionString">@optionString</option>
                        }
                    }
                </select>
            </div>
        }

Voit nähdä, että käytän valinnaisesti joitakin HTMX-asetelmia siirtääkseni sivun koon palvelimelle ja päivittääkseni sivua säilyttäen samalla nykyisen sivun ja hakuparametrin (jos sellainen on).

Lisäksi jos määrität use-htmx=false Muuttujana tag-auttajassa se ei tuota näitä, mutta sen sijaan voit käyttää JS:ää HTML-auttajana päivittämään sivun kokoa.

@Html.PageSizeOnchangeSnippet()
    

Tämä on yksinkertainen skripti, joka päivittää sivun koon ja lataa sivun uudelleen (huomatkaa, että tämä ei vielä toimi Plain CSS:lle / Bootstrapille, koska minun täytyy selvittää kiinteistöjen nimet jne.).

document.addEventListener("DOMContentLoaded", function () {
    document.body.addEventListener("change", function (event) {
        const selectElement = event.target.closest("#pager-container select[name='pageSize']");
        if (!selectElement) return;

        const pagerContainer = selectElement.closest("#pager-container");
        const useHtmxInput = pagerContainer.querySelector("input.useHtmx");
        const useHtmx = useHtmxInput ? useHtmxInput.value === "true" : true; // default to true

        if (!useHtmx) {
            const pageInput = pagerContainer.querySelector("[name='page']");
            const searchInput = pagerContainer.querySelector("[name='search']");

            const page = pageInput ? pageInput.value : "1";
            const search = searchInput ? searchInput.value : "";
            const pageSize = selectElement.value;
            const linkUrl =  pagerContainer.querySelector("input.linkUrl").value ?? "";
            
            const url = new URL(linkUrl, window.location.origin);
            url.searchParams.set("page", page);
            url.searchParams.set("pageSize", pageSize);

            if (search) {
                url.searchParams.set("search", search);
            }

            window.location.href = url.toString();
        }
    });
});

HTMX-integraatio

HTMX-integraatio on aika yksinkertainen, kun HTMX ryöpyttää lapsielementtejä, voimme määritellä HTMX-parametrit vanhemmuuselementissä, ja ne tulevat periytymään.

  • hx-boost="true" - tässä käytetään näppärää hx-vahvin ominaisuus Klikkaa tapahtumaa ja lähetä pyyntö HTMX:n kautta.
  • hx-indicator="#lataus-modaali" – tässä on latausmodaali, joka näkyy pyynnön käsittelyn aikana.
  • hx-target="#user-list" - tämä on elementti, jonka vastaus vaihdetaan pois, tässä tapauksessa käyttäjäluettelo. Huomaa: Tämä sisältää tällä hetkellä Pager yksinkertaisuuden, voit tehdä tästä aktiivisempaa Alppien avulla (kuten minun aiempi artikkeli ) mutta se ei tällä kertaa ollut käytössä.
  • hx-swap="show: none"

Loading Modal

Tämä on aika yksinkertaista ja käyttää DaisyUI:tä, boksikoneita ja Tailwindiä yksinkertaisen latausmodaalin luomiseen.

<div id="loading-modal" class="modal htmx-indicator">
    <div class="modal-box flex flex-col items-center justify-center">
        <h2 class="text-lg font-semibold">Loading...</h2>
        <i class="bx bx-loader bx-spin text-3xl mt-2"></i>
    </div>
</div>

hx-indicator="#lataus-modaali" sitten täsmentää, että kun HTMX-pyyntö tehdään, se näytetään shoould-näytössä ja piilotetaan tämä modaali.

Tulevaisuus

Joten se on osa 1, on selvää, että on paljon enemmän katettavana ja teen sen tulevissa artikkeleissa, mukaan lukien näytesivusto, vaihtoehtoiset näkymät, hakutoiminto ja mukautettu CSS.

logo

©2024 Scott Galloway