382 lines
14 KiB
Plaintext
382 lines
14 KiB
Plaintext
@{
|
||
// Global floating "find a poem about..." widget — included once from _Layout.cshtml, so it
|
||
// appears on every page that opts into that shared layout, same guarantee the theme toggle
|
||
// has. Self-contained CSS (scoped under #ss-widget* ids) rather than added to p8.css
|
||
// deliberately - avoids needing a cache-busting version bump on that large shared file for
|
||
// every iteration of a still-settling feature, at the cost of not being in the "official"
|
||
// stylesheet yet. Reuses real site CSS variables (--card-inner, --border-gold, --brand,
|
||
// --text-muted, --bg-page) throughout for visual consistency regardless.
|
||
//
|
||
// Calls APIRoot.SemanticSearchUrl DIRECTLY from browser JS (not a server-side proxy) - see
|
||
// the comment on SemanticSearchUrl in APIRoot.cs for why it's a separate, currently-isolated
|
||
// domain. CORS is already wide open on that backend (Startup.cs: SetIsOriginAllowed(_ =>
|
||
// true)), confirmed before choosing this approach rather than guessed.
|
||
}
|
||
<style>
|
||
#ss-widget-launcher {
|
||
position: fixed;
|
||
bottom: 74px; /* stacked above the theme toggle (bottom:20px, 44px tall) with a real gap - */
|
||
left: 20px; /* not touching, so they read as two distinct controls, not one fused blob */
|
||
z-index: 999;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 50%;
|
||
background: var(--card-inner);
|
||
border: 1.5px solid var(--border-gold);
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||
cursor: pointer;
|
||
transition: all 0.25s ease;
|
||
}
|
||
|
||
#ss-widget-launcher:hover {
|
||
background: var(--card-nested);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
#ss-widget-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.35);
|
||
z-index: 998;
|
||
}
|
||
|
||
#ss-widget-panel {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
left: 20px; /* anchored on the same side as the launcher, so the panel feels connected to
|
||
where you clicked rather than popping up across the screen */
|
||
z-index: 1000;
|
||
width: 360px;
|
||
max-width: calc(100vw - 40px);
|
||
max-height: 70vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: var(--bg-page);
|
||
border: 1.5px solid var(--border-gold);
|
||
border-radius: 14px;
|
||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
||
overflow: hidden;
|
||
}
|
||
|
||
#ss-widget-panel.ss-widget-maximized {
|
||
top: 5vh;
|
||
bottom: 5vh;
|
||
left: 50%;
|
||
right: auto;
|
||
transform: translateX(-50%);
|
||
width: min(760px, 92vw);
|
||
max-height: none;
|
||
}
|
||
|
||
#ss-widget-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid var(--border-gold);
|
||
font-weight: 600;
|
||
color: var(--brand);
|
||
-webkit-text-fill-color: var(--brand);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#ss-widget-header .ss-widget-actions a {
|
||
color: var(--text-muted);
|
||
margin-inline-start: 12px;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
font-size: 1.15rem;
|
||
}
|
||
|
||
#ss-widget-form {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 10px 14px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#ss-widget-form input[type="text"],
|
||
#ss-widget-form input[type="submit"] {
|
||
box-sizing: border-box;
|
||
height: 38px;
|
||
font-size: 0.95rem;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border-gold);
|
||
}
|
||
|
||
#ss-widget-query {
|
||
flex: 1;
|
||
padding: 0 12px;
|
||
background: var(--card-inner);
|
||
color: inherit;
|
||
}
|
||
|
||
#ss-widget-form input[type="submit"] {
|
||
padding: 0 16px;
|
||
background: var(--card-inner);
|
||
color: var(--brand);
|
||
-webkit-text-fill-color: var(--brand);
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
transition: background 0.2s ease;
|
||
}
|
||
|
||
#ss-widget-form input[type="submit"]:hover {
|
||
background: var(--card-nested);
|
||
}
|
||
|
||
#ss-widget-status {
|
||
padding: 0 14px 10px;
|
||
color: var(--text-muted);
|
||
font-size: 0.9rem;
|
||
display: none;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#ss-widget-results {
|
||
overflow-y: auto;
|
||
padding: 0 14px 14px;
|
||
flex: 1;
|
||
}
|
||
|
||
.ss-widget-result {
|
||
margin-bottom: 12px;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--border-gold);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.ss-widget-result a.ss-widget-result-title {
|
||
font-weight: 600;
|
||
font-size: 0.98rem;
|
||
color: var(--brand);
|
||
-webkit-text-fill-color: var(--brand);
|
||
}
|
||
|
||
.ss-widget-verses {
|
||
margin-top: 6px;
|
||
color: var(--text-muted);
|
||
font-size: 0.88rem;
|
||
line-height: 1.9;
|
||
}
|
||
|
||
.ss-widget-couplet {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
padding: 2px 0;
|
||
}
|
||
|
||
.ss-widget-scope-note {
|
||
text-align: center;
|
||
color: var(--gold, var(--text-muted));
|
||
font-size: 0.85rem;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.ss-widget-scope-clear {
|
||
color: var(--brand);
|
||
-webkit-text-fill-color: var(--brand);
|
||
text-decoration: underline dotted;
|
||
cursor: pointer;
|
||
}
|
||
</style>
|
||
|
||
<a href="javascript:void(0);" id="ss-widget-launcher" title="جستجوی معنایی — پیدا کردن شعر با توصیف معنای آن" onclick="ssWidgetOpen()">
|
||
<svg height="24px" viewBox="0 -960 960 960" width="24px" fill="var(--gold, currentColor)">
|
||
<path d="M240-400h480v-80H240v80Zm0-120h480v-80H240v80Zm0-120h480v-80H240v80ZM80-80v-720q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H240L80-80Zm126-240h594v-480H160v525l46-45Zm-46 0v-480 480Z" />
|
||
</svg>
|
||
</a>
|
||
|
||
<div id="ss-widget-backdrop" style="display:none;" onclick="ssWidgetUnmaximize()"></div>
|
||
|
||
<div id="ss-widget-panel" style="display:none;">
|
||
<div id="ss-widget-header">
|
||
<span>جستجوی معنایی</span>
|
||
<div class="ss-widget-actions">
|
||
<a href="javascript:void(0);" id="ss-widget-maximize-btn" onclick="ssWidgetToggleMaximize()" title="بزرگنمایی">⤢</a>
|
||
<a href="javascript:void(0);" onclick="ssWidgetClose()" title="بستن">×</a>
|
||
</div>
|
||
</div>
|
||
<form id="ss-widget-form" onsubmit="return ssWidgetSubmit(event);">
|
||
<input type="text" id="ss-widget-query" placeholder="مثلاً: شعری در مورد بیوفایی دنیا" autocomplete="off" />
|
||
<input type="submit" value="جستجو" />
|
||
</form>
|
||
<div id="ss-widget-status"></div>
|
||
<div id="ss-widget-results"></div>
|
||
</div>
|
||
|
||
<script>
|
||
var ssWidgetApiRoot = "@APIRoot.SemanticSearchUrl";
|
||
var ssWidgetMaximized = false;
|
||
var ssWidgetLastQuery = ""; // the last SUBMITTED query, for the "search globally instead" link
|
||
|
||
function ssWidgetOpen() {
|
||
document.getElementById("ss-widget-launcher").style.display = "none";
|
||
document.getElementById("ss-widget-panel").style.display = "flex";
|
||
document.getElementById("ss-widget-query").focus();
|
||
}
|
||
|
||
function ssWidgetClose() {
|
||
document.getElementById("ss-widget-launcher").style.display = "inline-flex";
|
||
document.getElementById("ss-widget-panel").style.display = "none";
|
||
ssWidgetUnmaximize();
|
||
}
|
||
|
||
function ssWidgetToggleMaximize() {
|
||
if (ssWidgetMaximized) {
|
||
ssWidgetUnmaximize();
|
||
} else {
|
||
ssWidgetMaximized = true;
|
||
document.getElementById("ss-widget-panel").classList.add("ss-widget-maximized");
|
||
document.getElementById("ss-widget-backdrop").style.display = "block";
|
||
document.getElementById("ss-widget-maximize-btn").textContent = "⤡";
|
||
document.getElementById("ss-widget-maximize-btn").title = "بازگشت به حالت کوچک";
|
||
}
|
||
}
|
||
|
||
function ssWidgetUnmaximize() {
|
||
ssWidgetMaximized = false;
|
||
document.getElementById("ss-widget-panel").classList.remove("ss-widget-maximized");
|
||
document.getElementById("ss-widget-backdrop").style.display = "none";
|
||
document.getElementById("ss-widget-maximize-btn").textContent = "⤢";
|
||
document.getElementById("ss-widget-maximize-btn").title = "بزرگنمایی";
|
||
}
|
||
|
||
function ssWidgetSubmit(e) {
|
||
e.preventDefault();
|
||
var query = document.getElementById("ss-widget-query").value.trim();
|
||
if (!query) return false;
|
||
ssWidgetLastQuery = query;
|
||
ssWidgetRunSearch(query, false);
|
||
return false;
|
||
}
|
||
|
||
// "search globally instead" - the escape hatch for when auto-detection guesses wrong.
|
||
// Reuses the last SUBMITTED query, not whatever's currently sitting in the input box
|
||
// (which the user may have started editing without resubmitting).
|
||
function ssWidgetSearchGlobally() {
|
||
if (!ssWidgetLastQuery) return;
|
||
ssWidgetRunSearch(ssWidgetLastQuery, true);
|
||
}
|
||
|
||
function ssWidgetRunSearch(query, disableScopeDetection) {
|
||
var resultsEl = document.getElementById("ss-widget-results");
|
||
var statusEl = document.getElementById("ss-widget-status");
|
||
resultsEl.innerHTML = "";
|
||
statusEl.style.display = "block";
|
||
statusEl.textContent = "در حال جستجو...";
|
||
|
||
$.ajax({
|
||
type: "POST",
|
||
url: ssWidgetApiRoot + "/api/ganjoor/search/semantic",
|
||
contentType: "application/json",
|
||
data: JSON.stringify({ query: query, topK: 10, disableScopeDetection: !!disableScopeDetection }),
|
||
success: function (response) {
|
||
statusEl.style.display = "none";
|
||
ssWidgetRenderResults(response);
|
||
},
|
||
error: function (xhr) {
|
||
statusEl.style.display = "block";
|
||
if (xhr.status === 503) {
|
||
statusEl.textContent = "در حال حاضر جستجوی معنایی در دسترس نیست. لطفاً کمی بعد دوباره امتحان کنید.";
|
||
} else if (xhr.responseText) {
|
||
statusEl.textContent = "خطا: " + xhr.responseText;
|
||
} else {
|
||
statusEl.textContent = "خطایی رخ داد (کد " + xhr.status + "). لطفاً دوباره تلاش کنید.";
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function ssWidgetRenderResults(response) {
|
||
var resultsEl = document.getElementById("ss-widget-results");
|
||
var statusEl = document.getElementById("ss-widget-status");
|
||
|
||
if (!response || !response.results || response.results.length === 0) {
|
||
statusEl.style.display = "block";
|
||
statusEl.textContent = "نتیجهای پیدا نشد.";
|
||
return;
|
||
}
|
||
|
||
var html = "";
|
||
if (response.detectedPoetName || response.detectedCategoryName) {
|
||
var scopeText = [response.detectedPoetName, response.detectedCategoryName].filter(Boolean).join(" » ");
|
||
html += '<p class="ss-widget-scope-note">نتایج محدود به: ' + ssWidgetEscapeHtml(scopeText) +
|
||
' <a href="javascript:void(0);" onclick="ssWidgetSearchGlobally()" class="ss-widget-scope-clear">(جستجوی سراسری)</a></p>';
|
||
}
|
||
|
||
response.results.forEach(function (item, index) {
|
||
var rank = index + 1;
|
||
html += '<div class="ss-widget-result">';
|
||
html += '<a class="ss-widget-result-title" href="' + ssWidgetEscapeHtml(item.fullUrl) + '" onclick="ssWidgetReportClick(' +
|
||
JSON.stringify(response.logId) + ',' + JSON.stringify(item.poemId) + ',' + rank + ')">' +
|
||
ssWidgetEscapeHtml(item.fullTitle || item.title) + '</a>';
|
||
html += ssWidgetRenderVerses(item.verses);
|
||
html += '</div>';
|
||
});
|
||
|
||
resultsEl.innerHTML = html;
|
||
}
|
||
|
||
// Fire-and-forget - does not preventDefault or delay navigation in any way, just queues the
|
||
// click report in the background while the browser proceeds to the poem page normally.
|
||
// sendBeacon is specifically designed for "report something right as the page is about to
|
||
// unload" - a plain fetch can get cancelled mid-flight by the navigation; sendBeacon (or a
|
||
// keepalive fetch as fallback for older browsers) doesn't have that problem.
|
||
function ssWidgetReportClick(logId, poemId, rank) {
|
||
if (!logId) return; // the search itself failed to log - nothing to correlate a click to
|
||
|
||
var payload = JSON.stringify({ logId: logId, poemId: poemId, rank: rank });
|
||
var url = ssWidgetApiRoot + "/api/ganjoor/search/semantic/click";
|
||
|
||
if (navigator.sendBeacon) {
|
||
navigator.sendBeacon(url, new Blob([payload], { type: "application/json" }));
|
||
} else {
|
||
fetch(url, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: payload,
|
||
keepalive: true
|
||
}).catch(function () { });
|
||
}
|
||
}
|
||
|
||
// Same hemistich-pairing logic as mini-ganjoor's renderVerses (https://ganjoor.github.io/mini/)
|
||
// and SemanticSearch.cshtml - reused as-is rather than reinvented a third time.
|
||
function ssWidgetRenderVerses(verses) {
|
||
if (!verses || verses.length === 0) return "";
|
||
|
||
var out = [];
|
||
var i = 0;
|
||
while (i < verses.length) {
|
||
var v = verses[i];
|
||
if (v.position === "Right" && verses[i + 1] && verses[i + 1].position === "Left") {
|
||
out.push(
|
||
'<div class="ss-widget-couplet">' +
|
||
'<div style="flex:1;">' + ssWidgetEscapeHtml(v.text) + '</div>' +
|
||
'<div style="flex:1;">' + ssWidgetEscapeHtml(verses[i + 1].text) + '</div>' +
|
||
'</div>'
|
||
);
|
||
i += 2;
|
||
} else {
|
||
out.push('<div style="padding:2px 0;">' + ssWidgetEscapeHtml(v.text) + '</div>');
|
||
i += 1;
|
||
}
|
||
}
|
||
return '<div class="ss-widget-verses">' + out.join("") + '</div>';
|
||
}
|
||
|
||
function ssWidgetEscapeHtml(s) {
|
||
if (s == null) return "";
|
||
return String(s).replace(/[&<>"']/g, function (c) {
|
||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||
});
|
||
}
|
||
</script>
|