Back to "Φίλτρο Μπαρ Progress (Και το εύρος ημερομηνίας Shenanigans)"

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 HTMX JavaScript

Φίλτρο Μπαρ Progress (Και το εύρος ημερομηνίας Shenanigans)

Monday, 10 November 2025

Εισαγωγή

Κατά τη διάρκεια των τελευταίων ημερών έχω σφυροκοπήσει μακριά στο νέο blog filter bar: επιλογή γλώσσας, παραγγελία, και ένα range picker που προσπαθεί πολύ σκληρά να είναι έξυπνος (μερικές φορές πάρα πολύ έξυπνος).

Αυτή η ανάρτηση περπατάει μέσα από ό, τι έφτιαξα, γιατί μερικά πράγματα πήγαν στραβά (ραντεβού δεν συμπεριφέρονται, τονίζει δεν αναζωογονητικό), και πώς τα έφτιαξα.

Αρκετός πηγαίος κώδικας και μερικά διαγράμματα γοργόνας για να δείξει τη ροή.

Θυμηθείτε αυτό το site είναι ένα έργο σε εξέλιξη, αυτό το είδος των πραγμάτων θα συμβεί όταν θα φάτε-σας-own-dogfood!

  • Τι Άλλαξε;
  • Εδώ είναι το υψηλό επίπεδο: Πρόσθεσα μια σωστή γραμμή φίλτρου στο ευρετήριο blog που περιλαμβάνει:
  • Γλώσσα επιλέξτε ότι διατηρεί τα τρέχοντα φίλτρα σας και επαναζητεί τη λίστα μέσω HTMX
  • Ταξινόμηση παραγγελίας επιλέξτε (ημερομηνία/τίτλος asc/desc) που διατηρεί επίσης το πλαίσιο
  • Διακόπτης εύρους ημερομηνίας (Flatpickr σε λειτουργία εύρους) με στιγμιότυπα ημέρας διακομιστή

Η κατάσταση URL διατηρείται πάντα σε συγχρονισμό (πίσω/πίσω έργα, εργασίες βαθιών συνδέσμων)

  • Ο διακομιστής caching τώρα ποικίλλει σωστά από τις παραμέτρους φίλτρουΝέα σε αυτή την ενημέρωση (The Big Fix!)
  • Φίλτρα ορατότητας: Posts can now be hidden, scheduled for future publish, or pinned to the top/blog/date-rangeΤελικό όριο εύρους ημερομηνίας
  • : ΝέαAPI επιστρέφει min/max ημερομηνίες για λογικά όρια picker<clear-param>Σαφής βοηθός ετικέτας param

: Εύκολος τρόπος για να καθαρίσετε τα φίλτρα χρησιμοποιώντας

βοηθός ετικετών με Alpine.js

Κατά μήκος του τρόπου που είχα μερικά σφάλματα: εύρος ημερομηνίας που χάνονται στην αλλαγή γλώσσας / παραγγελίας, το ημερολόγιο τονίζει δεν αναζωογονητικό σε swap, και ένα διασκεδαστικό ένα όπου σκοτεινό στυλ λειτουργίας έκανε το ημερολόγιο να φαίνεται σπασμένο, επειδή Flatpickr δεν επανασχεδιάστηκε.

<div id="filters" hx-target="#content">
  <select id="languageSelect">…</select>
  <select id="orderSelect">
    <option value="date_desc">Newest first</option>
    <option value="date_asc">Oldest first</option>
    <option value="title_asc">Title A–Z</option>
    <option value="title_desc">Title Z–A</option>
  </select>
  <input id="dateRange" type="text" placeholder="YYYY-MM-DD → YYYY-MM-DD" />
  <button id="clearDateFilter">Clear</button>
  <div id="filterSummary"></div>
</div>

<div id="content"><!-- HTMX swaps blog list here --></div>

Η γραμμή φίλτρου στο DOM

Αυτή είναι η σκληρή δομή που περιμένει το σενάριο:Mostlylucid/src/js/blog-index.jsΠελάτης: HTMX + Flatpickr καλωδίωση

  • Ο πυρήνας της συμπεριφοράς ζει σε
function applyNavigation(u){
  const target = document.querySelector('#content');
  try{ window.history.pushState({}, '', u.toString()); }catch{}
  if(window.htmx && target){
    window.htmx.ajax('GET', u.toString(), {
      target: '#content',
      swap: 'outerHTML show:none',
      headers: {'pagerequest': 'true'}
    });
  } else {
    window.location.href = u.toString();
  }
}
const url = new URL(window.location.href);
const existingStart = url.searchParams.get('startDate');
const existingEnd   = url.searchParams.get('endDate');
const existingLang  = url.searchParams.get('language') || 'en';
const existingOrderBy = (url.searchParams.get('orderBy') || 'date').toLowerCase();
const existingOrderDir = (url.searchParams.get('orderDir') || 'desc').toLowerCase();

langSelect.value = existingLang;
orderSelect.value = `${existingOrderBy}_${existingOrderDir}`;
updateSummary();
  • Μερικά σημαντικά κομμάτια:
async function fetchMonth(year, month, language){
  const res = await fetch(`/blog/calendar-days?year=${year}&month=${month}&language=${encodeURIComponent(language||'en')}`);
  if(!res.ok) return new Set();
  const j = await res.json();
  return new Set(j.dates || []);
}

function formatYMD(d){ return d.toISOString().substring(0,10); }

let highlightDates = new Set();
const fp = window.flatpickr(input, {
  mode: 'range',
  dateFormat: 'Y-m-d',
  defaultDate: [existingStart, existingEnd].filter(Boolean),
  onDayCreate: function(_dObj,_dStr,fpInstance,dayElem){
    const ymd = formatYMD(dayElem.dateObj);
    if(highlightDates.has(ymd)){
      dayElem.classList.add('has-post');
      dayElem.style.background = 'rgba(76,175,80,0.35)';
      dayElem.style.borderRadius = '6px';
    }
  },
  onMonthChange: async function(_sd,_ds,fpInstance){
    highlightDates = await fetchMonth(fpInstance.currentYear, fpInstance.currentMonth+1, langSelect.value);
    fpInstance.redraw();
  },
  onOpen: async function(_sd,_ds,fpInstance){
    highlightDates = await fetchMonth(fpInstance.currentYear, fpInstance.currentMonth+1, langSelect.value);
    fpInstance.redraw();
  },
  onChange: function(selectedDates){
    if(selectedDates.length === 2){
      const [start,end] = selectedDates;
      const u = new URL(window.location.href);
      u.searchParams.set('startDate', formatYMD(start));
      u.searchParams.set('endDate', formatYMD(end));
      u.searchParams.set('page','1');
      u.searchParams.set('language', langSelect.value);
      const [ob,od] = orderSelect.value.split('_');
      u.searchParams.set('orderBy', ob);
      u.searchParams.set('orderDir', od);
      updateSummary();
      applyNavigation(u);
    }
  }
});
  • Διατήρηση URL και πλοήγηση HTMX
langSelect.addEventListener('change', async function(){
  const u = new URL(window.location.href);
  u.searchParams.set('language', langSelect.value);
  u.searchParams.set('page', '1');
  const [ob,od] = (orderSelect.value||'date_desc').split('_');
  u.searchParams.set('orderBy', ob);
  u.searchParams.set('orderDir', od);
  if(input._flatpickr && input._flatpickr.selectedDates.length===2){
    const [s,e] = input._flatpickr.selectedDates;
    u.searchParams.set('startDate', formatYMD(s));
    u.searchParams.set('endDate', formatYMD(e));
  }
  // refresh calendar highlights for new language
  if(input._flatpickr){
    const fp = input._flatpickr;
    highlightDates = await fetchMonth(fp.currentYear, fp.currentMonth+1, langSelect.value);
    fp.redraw();
  }
  updateSummary();
  applyNavigation(u);
});
  • Αρχικοποίηση, προεπιλογή από URL, και διατήρηση της περίληψης UI σε συγχρονισμό
const obs = new MutationObserver(() => {
  const dr = root.querySelector('#dateRange');
  const fp = dr && dr._flatpickr;
  if(fp && typeof fp.redraw === 'function') fp.redraw();
});
obs.observe(document.documentElement, { attributes:true, attributeFilter:['class'] });

Εύρος flatpickr με στιγμιότυπα ημέρας server

Διατηρήστε τις ημερομηνίες κατά την αλλαγή γλώσσας ή παραγγελίας

  • Re-draw ημερολόγιο όταν το θέμα taggles (σκοτεινό / φως) έτσι Flatpickr repaints*
  • Εξυπηρετητής: τελικά σημεία και cachingcalendar-daysΤρία τελικά σημεία τροφοδοτούν τη σελίδα:
  • Το ίδιο το ευρετήριο, το οποίο δέχεται σελίδα, σελίδαΜέγεθος, startDate/endDate, γλώσσα, και παραγγελίαdate-rangeΑ
// GET /blog
[HttpGet]
[ResponseCache(Duration = 300, VaryByHeader = "hx-request",
  VaryByQueryKeys = new[] { "page", "pageSize", nameof(startDate), nameof(endDate), nameof(language), nameof(orderBy), nameof(orderDir) },
  Location = ResponseCacheLocation.Any)]
[OutputCache(Duration = 3600, VaryByHeaderNames = new[] { "hx-request" },
  VaryByQueryKeys = new[] { nameof(page), nameof(pageSize), nameof(startDate), nameof(endDate), nameof(language), nameof(orderBy), nameof(orderDir) })]
public async Task<IActionResult> Index(int page = 1, int pageSize = 20, DateTime? startDate = null, DateTime? endDate = null,
  string language = MarkdownBaseService.EnglishLanguage, string orderBy = "date", string orderDir = "desc")
{
    var posts = await blogViewService.GetPagedPosts(page, pageSize, language: language, startDate: startDate, endDate: endDate);
    posts.LinkUrl = Url.Action("Index", "Blog", new { startDate, endDate, language, orderBy, orderDir });
    if (Request.IsHtmx()) return PartialView("_BlogSummaryList", posts);
    return View("Index", posts);
}

// GET /blog/calendar-days?year=2025&month=11&language=en
[HttpGet("calendar-days")]
[ResponseCache(Duration = 300, VaryByHeader = "hx-request", VaryByQueryKeys = new[] { nameof(year), nameof(month), nameof(language) }, Location = ResponseCacheLocation.Any)]
[OutputCache(Duration = 1800, VaryByHeaderNames = new[] { "hx-request" }, VaryByQueryKeys = new[] { nameof(year), nameof(month), nameof(language) })]
public async Task<IActionResult> CalendarDays(int year, int month, string language = MarkdownBaseService.EnglishLanguage)
{
    if (year < 2000 || month < 1 || month > 12) return BadRequest("Invalid year or month");
    var start = new DateTime(year, month, 1);
    var end = start.AddMonths(1).AddDays(-1);
    var posts = await blogViewService.GetPostsForRange(start, end, language: language);
    if (posts is null) return Json("");
    var dates = posts.Select(p => p.PublishedDate.Date).Distinct().OrderBy(d => d).Select(d => d.ToString("yyyy-MM-dd")).ToList();
    return Json(new { dates });
}

// GET /blog/date-range?language=en
[HttpGet("date-range")]
[ResponseCache(Duration = 3600, VaryByHeader = "hx-request", VaryByQueryKeys = new[] { nameof(language) }, Location = ResponseCacheLocation.Any)]
[OutputCache(Duration = 7200, VaryByHeaderNames = new[] { "hx-request" }, VaryByQueryKeys = new[] { nameof(language) })]
public async Task<IActionResult> DateRange(string language = MarkdownBaseService.EnglishLanguage)
{
    var allPosts = await blogViewService.GetAllPosts();
    if (allPosts is null || !allPosts.Any())
    {
        return Json(new
        {
            minDate = DateTime.UtcNow.AddYears(-1).ToString("yyyy-MM-dd"),
            maxDate = DateTime.UtcNow.ToString("yyyy-MM-dd")
        });
    }

    var posts = allPosts;
    if (!string.IsNullOrEmpty(language) && language != MarkdownBaseService.EnglishLanguage)
    {
        posts = allPosts.Where(p => p.Language.Equals(language, StringComparison.OrdinalIgnoreCase)).ToList();
    }

    if (!posts.Any()) posts = allPosts;

    var minDate = posts.Min(p => p.PublishedDate.Date);
    var maxDate = posts.Max(p => p.PublishedDate.Date);

    return Json(new
    {
        minDate = minDate.ToString("yyyy-MM-dd"),
        maxDate = maxDate.ToString("yyyy-MM-dd")
    });
}

τελικό σημείο που επιστρέφει το σύνολο των ημερών σε ένα δεδομένο μήνα που έχουν θέσεις (για στιγμές)LinkUrlΑ

public class BasePagingModel<T> : Interfaces.IPagingModel<T> where T : class
{
    public int Page { get; set; }
    public int TotalItems { get; set; } = 0;
    public int PageSize { get; set; }
    public ViewType ViewType { get; set; } = ViewType.TailwindAndDaisy;
    public string LinkUrl { get; set; }
    public List<T> Data { get; set; }
}

τελικό σημείο που επιστρέφει τις ημερομηνίες min/max σε όλες τις θέσεις (language-aware)

sequenceDiagram
  participant U as User
  participant FP as Flatpickr
  participant JS as blog-index.js
  participant HT as HTMX
  participant C as BlogController

  U->>FP: Selects 2025-11-01 → 2025-11-10
  FP-->>JS: onChange([start,end])
  JS->>JS: Update URLSearchParams (startDate, endDate, language, order)
  JS->>HT: htmx.ajax('GET', /blog?...)
  HT->>C: GET /blog with query
  C-->>HT: Partial _BlogSummaryList
  HT-->>U: Swap #content
  JS->>FP: (after swap) ensure highlights + redraw

Ένα γρήγορο στην άκρη: το μοντέλο paging έχει

flowchart TD
  A[Open calendar / Month change] --> B[Fetch /blog/calendar-days]
  B -->|JSON dates: yyyy-mm-dd array| C[Set highlightDates]
  C --> D[flatpickr redraw]
  D --> E[onDayCreate adds .has-post]

έτσι ώστε η επικόλληση να διατηρεί το τρέχον πλαίσιο φίλτρου όταν αποδίδεται server-side.

Ακολουθία: πώς ρέει μια αλλαγή φίλτρου

Ροή: ανταύγειες ημερολογίουBlogPostEntity:

public class BlogPostEntity
{
    // ... existing fields ...

    public bool IsPinned { get; set; }
    public bool IsHidden { get; set; }
    public DateTimeOffset? ScheduledPublishDate { get; set; }
}

Νέα Χαρακτηριστικά: Ορατότητα και Έλεγχος ΠεριεχομένουBlogServiceΦίλτρα μετά την ορατότητα

// Filter out hidden posts and posts scheduled for the future
var now = DateTimeOffset.UtcNow;
postQuery = postQuery.Where(x =>
    !x.IsHidden &&
    (x.ScheduledPublishDate == null || x.ScheduledPublishDate <= now));

// For page 1, prioritize pinned posts
var isFirstPage = page == null || page.Value == 1;
if (isFirstPage)
{
    postQuery = postQuery.OrderByDescending(x => x.IsPinned)
                         .ThenByDescending(x => x.PublishedDate.DateTime);
}

Η μεγαλύτερη βελτίωση σε αυτή την ενημέρωση είναι η σωστή διαχείριση μετά την προβολή.

  • Προστέθηκαν τρία νέα πεδία (IsHidden = trueΗ
  • **τώρα φίλτρα θέσεις κατάλληλα:**Αυτό σημαίνει:ScheduledPublishDate
  • Κρυφές θέσεις (IsPinned = true) never appear in lists

Προγραμματισμένες θέσεις

Εμφανίζονται μόνο μετά από

[HtmlTargetElement("clear-param")]
public class ClearParamTagHelper : TagHelper
{
    [HtmlAttributeName("name")]
    public string? Name { get; set; }

    [HtmlAttributeName("all")]
    public bool All { get; set; } = false;

    [HtmlAttributeName("exclude")]
    public string Exclude { get; set; } = "";

    // ... styling and Alpine.js integration ...
}

Καρφιτσωμένες θέσεις

<!-- Clear a specific parameter -->
<clear-param name="startDate">Clear Date</clear-param>

<!-- Clear all parameters except language -->
<clear-param all="true" exclude="language">Clear All Filters</clear-param>

) πάντα εμφανίζονται πρώτη στη σελίδα 1, ιδανικό για ανακοινώσεις ή χαρακτήρισε περιεχόμενοwindow.queryParamClearerΚαθαρίστε τον βοηθό ετικετών Param

**Ένας νέος βοηθός ετικέτας ASP.NET Core καθιστά εύκολο να καθαρίσει παραμέτρους ερώτημα:**Χρήση σε θέα Razor:

Ο βοηθός ετικέτας ενσωματώνει με Alpine.js

  • συστατικό για τη διαχείριση των πράξεων χειραγώγησης URL και ανταλλαγής HTMX.

    • ΣημείωσηstartDate/endDate.
    • : Τόσο το paging tag helper όσο και η παράμετρος ερώτημα σαφέστερη αξίζει βαθύτερη εξερεύνηση.new URL(window.location.href)Θα καλύψω τα πλήρη στοιχεία εφαρμογής, συμπεριλαμβανομένων των συστατικών Alpine.js και tag helper μοτίβα, σε μια μελλοντική δημοσίευση για την κατασκευή επαναχρησιμοποίητων συστατικών HTMX / Alpine.language/orderΖουζούνια που χτύπησα (και οι διορθώσεις)
  • Χαμένο εύρος ημερομηνίας για αλλαγή γλώσσας/παραγγελίας

    • Αιτία: Έφτιαξα το νέο URL από το μηδέν, όχι από το τρέχον; έχασε
    • Επιδιόρθωση: Ξεκινήστε πάντα με
  • και να αλλάξει μόνο τα μέρη που άλλαξαν; ή να διαβάσετε ημερομηνίες από Flatpickr αν υπάρχουν.

    • Δείτε το
    • οι χειριστές αλλαγής ανωτέρω.<html class>Οι στιγμές του ημερολογίου δεν αναζωογονήθηκαν μετά από ανταλλαγή HTMXfp.redraw().
  • Αιτία: Flatpickr περίπτωση ζούσε στο παλιό DOM και πέθανε σε ανταλλαγή.

    • Επιδιορθώστε: Σε init, καταστρέψτε οποιαδήποτε υπάρχουσα περίπτωση, αναδημιουργήστε, στη συνέχεια προφορτώστε στιγμιότυπα για τον ορατό μήνα.LinkUrlΕπίσης re-hook μετά τις πράξεις ανταλλαγής (βλέπε παρακάτω).
    • Η σκοτεινή λειτουργία έκανε το ημερολόγιο να φαίνεται σπασμένο.posts.LinkUrl = Url.Action("Index","Blog", new { startDate, endDate, language, orderBy, orderDir })Αιτία: οι τάξεις στυλ άλλαξαν αλλά Flatpickr dint redraw ζωγραφισμένες ημέρες.

Επιδιόρθωση: Παρατηρήστε

αλλαγές και κλήση

(function(){
  function initFromRoot(root){ /* …the big function shown above… */ }
  if(window.htmx){
    document.body.addEventListener('htmx:afterSwap', function(ev){
      const tgt = ev.detail.target;
      // re-init if the content swapped includes the filters/content area
      if(tgt && (tgt.id === 'content' || tgt.querySelector?.('#filters'))){
        initFromRoot(document);
      }
    });
  }
  // also run on first load
  initFromRoot(document);
})();

Οι σύνδεσμοι pagination droped filter context

  • Αιτία:
  • δεν είχε οριστεί να συμπεριλάβει το ερώτημα σε ορισμένες περιπτώσεις.
  • Επιδιόρθωση: Στον διακομιστή, σετ

Έτσι ο βομβητής συνθέτει σωστά URL.

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.