#421 geo tag suggestion

This commit is contained in:
Hamid Reza Mohammadi 2026-09-27 21:58:43 +03:30
parent 1351679a21
commit 14461c362f
6 changed files with 243 additions and 39 deletions

View File

@ -12,6 +12,10 @@
<h1>@ViewData["Title"]</h1>
</div>
<script>
// full existing-location catalog ({id, name, latitude, longitude}[]), used by the
// search-as-you-type location picker on each geo tag - see setupLocationAutocomplete in bk.js
var allGeoLocations = @Html.Raw(Model.AllLocationsJson);
async function checkAllNotCorrect() {
var ok = await upConfirm('همه نادرست؟');
if (!ok) return;
@ -40,10 +44,13 @@
}
function toggleGeoTagNewLocationFields(geoIndex) {
var select = document.getElementById('geotag' + geoIndex + '_location_select');
var hidden = document.getElementById('geotag' + geoIndex + '_location_select');
var fields = document.getElementById('geotag' + geoIndex + '_new_location_fields');
if (!select || !fields) return;
fields.style.display = (select.value === '0') ? '' : 'none';
if (!hidden || !fields) return;
// '' (nothing picked yet / cleared while typing) and '0' (the old select's "no change /
// suggest as new location" option) both mean "not linked to an existing location"
var linkedToExisting = hidden.value && hidden.value !== '0';
fields.style.display = linkedToExisting ? 'none' : '';
}
function moderatePoemCorrections(verseCount, correctionId, geoTagCount) {
@ -912,22 +919,24 @@ else
<p class="up-hint">
اگر کاربر در نام مکان یا مختصات آن اشتباه کرده اما پیشنهادش در اصل ارزش تأیید دارد، به‌جای رد کردن می‌توانید همین‌جا آن را اصلاح کنید:
</p>
<label for="geotag@(geoIndex)_location_select">اتصال به مکان موجود در فهرست (در صورت وجود اشتباه در تشخیص مکان):</label>
<select id="geotag@(geoIndex)_location_select" name="geotag@(geoIndex)_location_select" onchange="toggleGeoTagNewLocationFields(@geoIndex)">
<option value="0">-- بدون تغییر / پیشنهاد به‌عنوان مکان جدید --</option>
@foreach (var loc in (Model.Locations ?? new List<GanjoorGeoLocation>()))
{
if (loc.Id == 0) continue;
if (geoTag.LocationId == loc.Id)
{
<option value="@loc.Id" selected="selected">@loc.Name (@loc.Latitude, @loc.Longitude)</option>
}
else
{
<option value="@loc.Id">@loc.Name (@loc.Latitude, @loc.Longitude)</option>
}
}
</select>
<label for="geotag@(geoIndex)_location_search">اتصال به مکان موجود در فهرست (در صورت وجود اشتباه در تشخیص مکان):</label>
<div class="up-autocomplete-wrap">
<input type="text" id="geotag@(geoIndex)_location_search" autocomplete="off" placeholder="نام مکان را تایپ کنید..."
value="@(geoTag.LocationId != null ? (Model.Locations ?? new List<GanjoorGeoLocation>()).Where(l => l.Id == geoTag.LocationId).Select(l => l.Name).FirstOrDefault() : null)" />
<input type="hidden" id="geotag@(geoIndex)_location_select" name="geotag@(geoIndex)_location_select" value="@(geoTag.LocationId ?? 0)" />
<div id="geotag@(geoIndex)_location_results" class="up-autocomplete-results" style="display:none;"></div>
</div>
<p class="up-hint">برای اتصال به یک مکان موجود، نام آن را تایپ کنید و از فهرست انتخاب کنید؛ برای رها کردن اتصال و پیشنهاد به‌عنوان مکان جدید، این فیلد را خالی کنید.</p>
<script>
setupLocationAutocomplete(
document.getElementById('geotag@(geoIndex)_location_search'),
document.getElementById('geotag@(geoIndex)_location_select'),
document.getElementById('geotag@(geoIndex)_location_results'),
allGeoLocations,
function (loc) { toggleGeoTagNewLocationFields(@geoIndex); },
function (typedText) { toggleGeoTagNewLocationFields(@geoIndex); }
);
</script>
@if (geoTag.LocationId == null)
{
<div id="geotag@(geoIndex)_new_location_fields">
@ -946,7 +955,7 @@ else
<tr>
<td colspan="2">
<div class="up-radio-option">
<input type="radio" id="geotag@(geoIndex)_review_result_Approved" name="geotag@(geoIndex)_review_result" class="radio-approved" value="@RMuseum.Models.Ganjoor.CorrectionReviewResult.Approved">
<input type="radio" id="geotag@(geoIndex)_review_result_Approved" name="geotag@(geoIndex)_review_result" class="radio-approved" value="@RMuseum.Models.Ganjoor.CorrectionReviewResult.Approved" checked="checked">
<label for="geotag@(geoIndex)_review_result_Approved">@RMuseum.Services.Implementation.CorrectionReviewResultConvertor.GetString(RMuseum.Models.Ganjoor.CorrectionReviewResult.Approved)</label>
</div>
<div class="up-radio-option">
@ -958,7 +967,7 @@ else
<label for="geotag@(geoIndex)_review_result_RejectedBecauseUnnecessaryChange">تغییر سلیقه‌ای یا بی دلیل است</label>
</div>
<div class="up-radio-option">
<input type="radio" id="geotag@(geoIndex)_review_result_Rejected" name="geotag@(geoIndex)_review_result" class="radio-rejected" value="@RMuseum.Models.Ganjoor.CorrectionReviewResult.Rejected" checked="checked">
<input type="radio" id="geotag@(geoIndex)_review_result_Rejected" name="geotag@(geoIndex)_review_result" class="radio-rejected" value="@RMuseum.Models.Ganjoor.CorrectionReviewResult.Rejected">
<label for="geotag@(geoIndex)_review_result_Rejected">@RMuseum.Services.Implementation.CorrectionReviewResultConvertor.GetString(RMuseum.Models.Ganjoor.CorrectionReviewResult.Rejected)</label>
</div>
<input type="text" id="geotag@(geoIndex)_review_note" name="geotag@(geoIndex)_review_note" value="" size="50" />

View File

@ -68,6 +68,17 @@ namespace GanjooRazor.Areas.Admin.Pages
/// </summary>
public List<GanjoorGeoLocation> Locations { get; set; }
/// <summary>
/// camelCase JSON of Locations (id/name/latitude/longitude only), used client-side for the
/// search-as-you-type location picker on each geo tag (see setupLocationAutocomplete in bk.js) -
/// same shape/purpose as Editor.cshtml.cs's AllLocationsJson
/// </summary>
public string AllLocationsJson =>
JsonConvert.SerializeObject(
(Locations ?? new List<GanjoorGeoLocation>()).Select(l => new { l.Id, l.Name, l.Latitude, l.Longitude }),
new JsonSerializerSettings { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() }
);
/// <summary>
/// groups verses into couplets - same logic as SuggestQuoted.cshtml.cs's/Editor.cshtml.cs's GetCouplets,
/// duplicated here rather than shared, so this page doesn't take on a cross-file dependency on those

View File

@ -126,6 +126,42 @@
var allGeoLocations = @Html.Raw(Model.AllLocationsJson);
$(document).ready(function () {
renderPendingGeoDateTags();
setupLocationAutocomplete(
document.getElementById('geotag-location-search'),
document.getElementById('geotag-location-select'),
document.getElementById('geotag-location-results'),
allGeoLocations,
function (loc) {
// picking an existing location (or clearing the field) means whatever was
// typed into the "new location" panel no longer applies to this suggestion
var widget = document.getElementById('new-location-widget');
if (loc != null && widget.style.display !== 'none') {
toggleNewLocationWidget();
}
},
function (typedText) {
var widget = document.getElementById('new-location-widget');
if (widget.style.display === 'none' || widget.style.display === '') {
toggleNewLocationWidget();
}
document.getElementById('geotag-new-location-name').value = typedText;
}
);
var directAddLocationSearch = document.getElementById('location-search');
if (directAddLocationSearch != null) {
// admin-only direct-add row (bypasses the suggestion/moderation flow entirely) -
// no "new location" concept here, so an unmatched search just has nothing to pick
setupLocationAutocomplete(
directAddLocationSearch,
document.getElementById('location'),
document.getElementById('location-results'),
allGeoLocations,
null,
function (typedText) {
upToast('این مکان در فهرست نیست. برای افزودن مکان جدید از «مکان‌ها» در پایین این جدول استفاده کنید.', 'error');
}
);
}
});
var geoDateTagMap = null;
var geoDateTagMarker = null;
@ -268,7 +304,7 @@
var s = geoDateTagSuggestions[i];
var status = s.markForDelete ? '🗑️ درخواست حذف' : '➕ پیشنهاد جدید';
var coupletText = (s.coupletIndex != null && geoDateTagCoupletTexts[s.coupletIndex]) ? geoDateTagCoupletTexts[s.coupletIndex] : '— کل شعر —';
var locationText = s.markForDelete ? '' : (s.locationId ? $('#geotag-location-select option[value="' + s.locationId + '"]').text() : (s.suggestedLocationName || ''));
var locationText = s.markForDelete ? '' : (s.locationId ? ((allGeoLocations.filter(function (l) { return l.id === s.locationId; })[0] || {}).name || ('مکان #' + s.locationId)) : (s.suggestedLocationName || ''));
html += '<tr><td>' + status + '</td><td>' + coupletText + '</td><td>' + locationText + '</td><td>' + (s.lunarYear || '') + '</td><td>' + (s.lunarMonth || '') + '</td><td>' + (s.lunarDay || '') + '</td>' +
'<td><a role="button" class="up-btn up-btn--sm up-btn--ghost" onclick="removeGeoDateTagSuggestion(' + i + ')">✕ حذف از فهرست</a></td></tr>';
}
@ -354,6 +390,8 @@
function resetGeoDateTagForm() {
$('#geotag-couplet-select').val('');
$('#geotag-location-select').val('');
$('#geotag-location-search').val('');
$('#geotag-location-results').hide().empty();
$('#geotag-new-location-name').val('');
$('#geotag-new-location-lat').val('');
$('#geotag-new-location-lng').val('');
@ -2001,14 +2039,13 @@ else
</div>
<div class="up-field">
<label for="geotag-location-select">مکان</label>
<select id="geotag-location-select">
<option value="">— انتخاب نشده —</option>
@foreach (var location in Model.Locations)
{
<option value="@location.Id">@location.Name</option>
}
</select>
<label for="geotag-location-search">مکان</label>
<div class="up-autocomplete-wrap">
<input type="text" id="geotag-location-search" autocomplete="off" placeholder="نام مکان را تایپ کنید..." />
<input type="hidden" id="geotag-location-select" />
<div id="geotag-location-results" class="up-autocomplete-results" style="display:none;"></div>
</div>
<p class="up-hint">شروع به تایپ کنید تا فهرست مکان‌های موجود بر اساس نام فیلتر شود؛ اگر مکان مورد نظر پیدا نشد، گزینهٔ تعریف آن به‌عنوان مکان جدید نمایش داده می‌شود.</p>
<a role="button" class="up-btn up-btn--sm up-btn--ghost" onclick="toggleNewLocationWidget()">📍 مکان جدید پیشنهاد بده</a>
</div>
@ -2203,14 +2240,11 @@ else
}
<tr>
<td>
<select id="location">
@{
foreach (var location in Model.Locations)
{
<option value="@location.Id">@location.Name</option>
}
}
</select>
<div class="up-autocomplete-wrap">
<input type="text" id="location-search" autocomplete="off" placeholder="نام مکان را تایپ کنید..." />
<input type="hidden" id="location" />
<div id="location-results" class="up-autocomplete-results" style="display:none;"></div>
</div>
</td>
<td>
<input type="text" id="year" />

View File

@ -218,6 +218,11 @@ namespace GanjooRazor.Areas.User.Pages
FatalError = "";
CanEdit = Request.Cookies["CanEdit"] == "True";
// set before any of the early "API call failed" returns below, so the view - which
// reads Model.Couplets unconditionally near the top of its script block, before the
// FatalError check further down - never sees a null and throws ArgumentNullException
// out of Couplets.ToDictionary(...) on a failed request
Couplets = Array.Empty<Tuple<int, string>>();
ShowAdminOps = CanEdit && Request.Query["admin"] == "1";
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))

View File

@ -612,6 +612,51 @@ body.up-modal-open {
box-shadow: 0 0 0 3px rgba(51, 0, 0, 0.12);
}
/* ---------- location search-as-you-type (see setupLocationAutocomplete in bk.js) ---------- */
.up-autocomplete-wrap {
position: relative;
}
.up-autocomplete-results {
position: absolute;
z-index: 50;
top: calc(100% + 2px);
right: 0;
left: 0;
max-height: 260px;
overflow-y: auto;
background: var(--up-surface);
border: 1px solid var(--up-border);
border-radius: var(--up-radius-sm);
box-shadow: var(--up-shadow-sm);
}
.up-autocomplete-item {
padding: 8px 12px;
font-size: 0.9rem;
cursor: pointer;
border-bottom: 1px solid var(--up-border);
}
.up-autocomplete-item:last-child {
border-bottom: none;
}
.up-autocomplete-item:hover {
background: var(--up-bg);
}
.up-autocomplete-item--empty,
.up-autocomplete-item--more {
color: var(--up-text-muted);
cursor: default;
}
.up-autocomplete-item--empty {
cursor: pointer;
}
/* ---------- editable surfaces (contenteditable divs standing in for inputs) ----------
Needs a strong, unmistakable affordance since it isn't a native form control -
nothing else on the page should look like this. */

View File

@ -2498,4 +2498,104 @@ function load7DaysVisits(url) {
}
},
});
}
}
// Turns a plain text <input> into a "search as you type" picker over a small
// in-memory location catalog ({id, name, latitude, longitude}[]), instead of a
// long <select> the user has to scroll/search through by hand. If nothing in
// the catalog matches what's typed, the dropdown offers to define it as a new
// location instead - see onNotFound.
// searchInput - the visible text <input> the user types a location name into
// hiddenInput - an existing/hidden <input> that ends up holding the selected
// location's Id as a string (cleared, i.e. '', whenever the
// text doesn't currently match a selected location) - callers
// that used to read a <select>'s .val() can keep doing so
// unchanged as long as this element keeps the same id
// resultsBox - an empty container element (positioned by .up-autocomplete-results)
// right after searchInput, used to render the dropdown
// locations - array of {id, name, latitude, longitude}
// onSelect(loc) - called with the chosen location object, or null when the
// field is cleared/typed into (so any "selected" state a
// caller is tracking elsewhere - like a new-location panel
// that should hide - can be kept in sync)
// onNotFound(text) - called with the trimmed typed text when it matches no
// location in the catalog and the user clicks the "define
// as new location" row
function setupLocationAutocomplete(searchInput, hiddenInput, resultsBox, locations, onSelect, onNotFound) {
var MAX_RESULTS = 12;
function hide() {
resultsBox.style.display = 'none';
resultsBox.innerHTML = '';
}
function selectLocation(loc) {
searchInput.value = loc.name;
hiddenInput.value = String(loc.id);
hide();
if (onSelect) onSelect(loc);
}
function render(typedText) {
var typedLower = typedText.toLowerCase();
var matches = (locations || []).filter(function (l) {
return l.name && l.name.toLowerCase().indexOf(typedLower) !== -1;
});
if (matches.length == 0) {
var emptyRow = document.createElement('div');
emptyRow.className = 'up-autocomplete-item up-autocomplete-item--empty';
emptyRow.textContent = '➕ «' + typedText + '» در فهرست پیدا نشد؛ به‌عنوان مکان جدید تعریف شود';
emptyRow.addEventListener('click', function () {
hide();
if (onNotFound) onNotFound(typedText);
});
resultsBox.innerHTML = '';
resultsBox.appendChild(emptyRow);
resultsBox.style.display = 'block';
return;
}
resultsBox.innerHTML = '';
for (var i = 0; i < Math.min(matches.length, MAX_RESULTS); i++) {
var loc = matches[i];
var row = document.createElement('div');
row.className = 'up-autocomplete-item';
row.textContent = loc.name + ' (' + loc.latitude + ', ' + loc.longitude + ')';
row.addEventListener('click', function (l) {
return function () { selectLocation(l); };
}(loc));
resultsBox.appendChild(row);
}
if (matches.length > MAX_RESULTS) {
var moreRow = document.createElement('div');
moreRow.className = 'up-autocomplete-item up-autocomplete-item--more';
moreRow.textContent = '… و ' + (matches.length - MAX_RESULTS) + ' مورد دیگر (برای محدودکردن نتایج بیشتر تایپ کنید)';
resultsBox.appendChild(moreRow);
}
resultsBox.style.display = 'block';
}
searchInput.addEventListener('input', function () {
hiddenInput.value = ''; // typing invalidates whatever was previously selected
var typed = searchInput.value.trim();
if (typed == '') {
hide();
if (onSelect) onSelect(null);
return;
}
render(typed);
});
searchInput.addEventListener('focus', function () {
var typed = searchInput.value.trim();
if (typed != '' && hiddenInput.value == '') {
render(typed); // re-show suggestions for text that hasn't resolved to a pick yet
}
});
document.addEventListener('click', function (e) {
if (e.target !== searchInput && !resultsBox.contains(e.target)) {
hide();
}
});
}