diff --git a/src/JobsMedical.Web/Pages/Shared/_JobCard.cshtml b/src/JobsMedical.Web/Pages/Shared/_JobCard.cshtml index 631a09d..304b1e0 100644 --- a/src/JobsMedical.Web/Pages/Shared/_JobCard.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_JobCard.cshtml @@ -34,6 +34,11 @@ {
📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما
} + @{ var snip = JobsMedical.Web.Services.SearchHighlight.Snippet(Model.Description, q); } + @if (snip.Value.Length > 0) + { +
@snip
+ }
@salary جزئیات diff --git a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml index 7c543f5..5534fd6 100644 --- a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml @@ -259,8 +259,10 @@ .then(function (items) { if (!items || !items.length) { hide(); return; } var html = items.map(function (it) { + var sub = it.sub ? '' + hi(it.sub, q) + '' : ''; return '' + esc(it.type) + - '' + hi(it.label, q) + ''; + '' + hi(it.label, q) + + '' + sub + ''; }).join(''); html += 'همه نتایج برای «' + esc(q) + '» ←'; box.innerHTML = html; diff --git a/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml b/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml index e79ec14..ed9b696 100644 --- a/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml @@ -35,6 +35,11 @@ }
📅 @JalaliDate.WeekDayName(Model.Date)، @JalaliDate.ToLongDate(Model.Date)
🕐 @JalaliDate.Time(Model.StartTime) تا @JalaliDate.Time(Model.EndTime)
+ @{ var snip = JobsMedical.Web.Services.SearchHighlight.Snippet(Model.Description, q); } + @if (snip.Value.Length > 0) + { +
@snip
+ }
@JalaliDate.PayLabel(Model.PayType, Model.PayAmount, Model.SharePercent) diff --git a/src/JobsMedical.Web/Pages/Shared/_TalentCard.cshtml b/src/JobsMedical.Web/Pages/Shared/_TalentCard.cshtml index 1fec820..2bd4b3d 100644 --- a/src/JobsMedical.Web/Pages/Shared/_TalentCard.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_TalentCard.cshtml @@ -40,6 +40,11 @@ }
📍 @Model.City?.Name@(area is not null ? "، " + area : "")
+ @{ var snip = JobsMedical.Web.Services.SearchHighlight.Snippet(Model.Description ?? Model.Tags, q); } + @if (snip.Value.Length > 0) + { +
@snip
+ } @if (tags.Count > 0) {
diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs index 560baeb..2c183e2 100644 --- a/src/JobsMedical.Web/Program.cs +++ b/src/JobsMedical.Web/Program.cs @@ -373,17 +373,17 @@ app.MapGet("/search/suggest", async (string? q, AppDbContext db) => .Where(s => s.Status == ShiftStatus.Open && s.Date >= today && (EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Role.Name, like) || EF.Functions.ILike(s.SpecialtyRequired, like))) .OrderByDescending(s => s.CreatedAt).Take(5) - .Select(s => new SuggestItem("شیفت", s.Role.Name + " — " + s.Facility.Name, "/Shifts/Details/" + s.Id)).ToListAsync(); + .Select(s => new SuggestItem("شیفت", s.Role.Name + " — " + s.Facility.Name, "/Shifts/Details/" + s.Id, s.Facility.City.Name + " · " + s.SpecialtyRequired)).ToListAsync(); var jobs = await db.JobOpenings .Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut && (EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like) || EF.Functions.ILike(j.Role.Name, like))) .OrderByDescending(j => j.CreatedAt).Take(5) - .Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id)).ToListAsync(); + .Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id, j.Facility.Name + " · " + j.Facility.City.Name)).ToListAsync(); var talent = await db.TalentListings .Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut && (EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like) || EF.Functions.ILike(t.PersonName ?? "", like))) .OrderByDescending(t => t.CreatedAt).Take(5) - .Select(t => new SuggestItem("آماده‌به‌کار", (t.PersonName ?? t.Role.Name) + " — " + t.City.Name, "/Talent/Details/" + t.Id)).ToListAsync(); + .Select(t => new SuggestItem("آماده‌به‌کار", (t.PersonName ?? t.Role.Name) + " — " + t.City.Name, "/Talent/Details/" + t.Id, t.Tags)).ToListAsync(); // round-robin merge so all three types appear, capped at 5 var merged = new List(); @@ -398,5 +398,6 @@ app.MapGet("/search/suggest", async (string? q, AppDbContext db) => app.Run(); -/// One typeahead suggestion row (lowercase props → camelCase JSON for the client). -public record SuggestItem(string type, string label, string url); +/// One typeahead suggestion row (lowercase props → camelCase JSON for the client). +/// sub is the matched-context line (tags/city/specialty) shown highlighted under the label. +public record SuggestItem(string type, string label, string url, string? sub = null); diff --git a/src/JobsMedical.Web/Services/SearchHighlight.cs b/src/JobsMedical.Web/Services/SearchHighlight.cs index fe7f331..07d5c03 100644 --- a/src/JobsMedical.Web/Services/SearchHighlight.cs +++ b/src/JobsMedical.Web/Services/SearchHighlight.cs @@ -24,4 +24,33 @@ public static class SearchHighlight var marked = Regex.Replace(encoded, pattern, m => $"{m.Value}", RegexOptions.IgnoreCase); return new HtmlString(marked); } + + /// + /// Elasticsearch-style highlight fragment: finds the first matching term in , + /// returns a window of ± chars around it with the terms marked and ellipses + /// at the cut points. Empty when nothing matches (so callers can hide the line). + /// + public static HtmlString Snippet(string? text, string? query, int radius = 70) + { + if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(query)) return HtmlString.Empty; + var terms = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(t => t.Length >= 2).ToList(); + if (terms.Count == 0) return HtmlString.Empty; + + var flat = Regex.Replace(text, @"\s+", " ").Trim(); + int idx = -1, matchLen = 0; + foreach (var t in terms) + { + var i = flat.IndexOf(t, StringComparison.OrdinalIgnoreCase); + if (i >= 0 && (idx < 0 || i < idx)) { idx = i; matchLen = t.Length; } + } + if (idx < 0) return HtmlString.Empty; + + var start = Math.Max(0, idx - radius); + var end = Math.Min(flat.Length, idx + matchLen + radius); + var slice = flat.Substring(start, end - start).Trim(); + var prefix = start > 0 ? "…" : ""; + var suffix = end < flat.Length ? "…" : ""; + return new HtmlString(prefix + Mark(slice, query).Value + suffix); + } } diff --git a/src/JobsMedical.Web/wwwroot/css/site.css b/src/JobsMedical.Web/wwwroot/css/site.css index 4f6283d..e621f53 100644 --- a/src/JobsMedical.Web/wwwroot/css/site.css +++ b/src/JobsMedical.Web/wwwroot/css/site.css @@ -294,7 +294,12 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px; .nav-search-results a:hover { background: var(--primary-soft); } .nav-search-results .ns-type { flex: 0 0 auto; font-size: 11px; font-weight: 700; color: var(--primary-dark); background: var(--primary-soft); border-radius: 6px; padding: 2px 7px; } -.nav-search-results .ns-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-search-results .ns-text { flex: 1; display: flex; flex-direction: column; min-width: 0; } +.nav-search-results .ns-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-search-results .ns-sub { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* ES-style matched snippet shown under a search-result card */ +.search-snippet { font-size: 12.5px; color: var(--muted); line-height: 1.6; margin: 4px 0 2px; + background: var(--bg); border-inline-start: 3px solid var(--primary-soft); padding: 5px 9px; border-radius: 6px; } .nav-search-results .ns-all { font-weight: 700; color: var(--primary-dark); justify-content: center; } /* Big search box on the /Search page head */ .search-hero { display: flex; gap: 8px; max-width: 640px; margin: 6px 0 4px; }