tejasashinde's picture
Corrected logic and UI
6f97a9b
Raw
History Blame Contribute Delete
26.5 kB
from __future__ import annotations
import base64
import html
import json
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any
from backend.spaces_index import filter_public_spaces, get_category_distribution
from backend.storage import get_recent_feedback
CARD_DECORATIONS = ["compass", "camera", "lantern", "boot", "pine", "flower", "peak", "map"]
CARD_DECORATION_MARKS = {
"compass": "🧭",
"camera": "📷",
"lantern": "🏮",
"boot": "🥾",
"pine": "🌲",
"flower": "🌸",
"peak": "🏔",
"map": "🗺",
}
def safe(text: Any) -> str:
return html.escape("" if text is None else str(text))
PROJECT_ROOT = Path(__file__).resolve().parent.parent
BANNER_PATH = PROJECT_ROOT / "banner.png"
BADGE_PATH = PROJECT_ROOT / "hackathon-badge.png"
def _image_data_uri(path: Path) -> str:
data = path.read_bytes()
encoded = base64.b64encode(data).decode("ascii")
return f"data:image/png;base64,{encoded}"
BANNER_SRC = _image_data_uri(BANNER_PATH)
BADGE_SRC = _image_data_uri(BADGE_PATH)
def _js_literal(value: Any) -> str:
return html.escape(json.dumps(value, ensure_ascii=False), quote=True)
def _tags_text(tags: list[str] | None) -> str:
tags = [str(tag).strip() for tag in (tags or []) if str(tag).strip()]
return ", ".join(tags[:7])
def _group_feedback() -> dict[str, list[dict]]:
grouped: dict[str, list[dict]] = defaultdict(list)
for row in get_recent_feedback(limit=2000):
repo_id = str(row.get("repo_id", "")).strip()
if repo_id:
grouped[repo_id].append(row)
return grouped
def _human_datetime(value: str | None) -> str:
if not value:
return "Just now"
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except Exception:
return str(value)
local = parsed.astimezone()
return local.strftime("%b %d, %Y at %I:%M %p").replace(" 0", " ")
COMMENTS_MODAL_HTML = """
<div id="bsqf-comments-modal" class="comment-modal-overlay" aria-hidden="true">
<div class="comment-modal">
<div class="comment-modal-header">
<div>
<div class="comment-modal-kicker">Community notes</div>
<h3 id="bsqf-comments-title">Comments</h3>
</div>
<button class="comment-modal-close" type="button" onclick="window.bsqfCloseComments()">Close</button>
</div>
<div id="bsqf-comments-body" class="comment-modal-body"></div>
</div>
</div>
"""
COMMENTS_MODAL_SCRIPT = """
<script>
(function () {
const OWNER_SESSION_KEY = 'bsqf_owner_session_id';
function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, function (m) {
return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]);
});
}
function formatCommentDate(value) {
if (!value) return '';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
const now = new Date();
const diffMs = now.getTime() - parsed.getTime();
const diffDays = Math.floor(diffMs / 86400000);
const timePart = parsed.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
if (diffDays <= 0) return 'Today, ' + timePart;
if (diffDays === 1) return 'Yesterday, ' + timePart;
if (diffDays < 7) {
return parsed.toLocaleDateString([], { weekday: 'long' }) + ', ' + timePart;
}
return parsed.toLocaleDateString([], {
month: 'short',
day: 'numeric',
year: 'numeric'
}) + ' at ' + timePart;
}
function getOwnerSessionId() {
try {
let current = window.localStorage.getItem(OWNER_SESSION_KEY);
if (!current) {
current = (window.crypto && window.crypto.randomUUID)
? window.crypto.randomUUID()
: 'bsqf-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
window.localStorage.setItem(OWNER_SESSION_KEY, current);
}
return current;
} catch (error) {
if (!window.__bsqfAnonymousSessionId) {
window.__bsqfAnonymousSessionId = (window.crypto && window.crypto.randomUUID)
? window.crypto.randomUUID()
: 'bsqf-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
}
return window.__bsqfAnonymousSessionId;
}
}
async function callGradioApi(apiName, args) {
const response = await fetch('/gradio_api/run/' + encodeURIComponent(apiName), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
data: Array.isArray(args) ? args : [],
simple_format: true
})
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload && (payload.error || payload.detail) ? (payload.error || payload.detail) : 'Request failed.');
}
const result = payload && Array.isArray(payload.data) ? payload.data[0] : null;
if (!result || typeof result !== 'object') {
throw new Error('Unexpected response from the server.');
}
return result;
}
async function loadComments(repoId) {
const ownerSessionId = getOwnerSessionId();
const payload = await callGradioApi('comments_get', [repoId, ownerSessionId]);
if (!payload.ok) {
throw new Error(payload && (payload.error || payload.detail) ? (payload.error || payload.detail) : 'Could not load comments.');
}
updateCommentCount(repoId, Number(payload.count || 0));
return payload;
}
function bodyScrollLock(locked) {
document.documentElement.classList.toggle('bsqf-modal-open', locked);
document.body.classList.toggle('bsqf-modal-open', locked);
}
function renderCommentActions(row) {
if (!row || !row.can_edit) return '';
const rawCommentId = String(row.id || '');
const rawRepoId = String(row.repo_id || '');
const commentId = escapeHtml(JSON.stringify(rawCommentId));
const repoId = escapeHtml(JSON.stringify(rawRepoId));
const text = escapeHtml(row.answer || '');
return '' +
'<div class="comment-actions">' +
'<button class="comment-action-button" type="button" onclick="window.bsqfBeginCommentEdit(' + repoId + ', ' + commentId + ')">Edit</button>' +
'<button class="comment-action-button comment-action-button--danger" type="button" onclick="window.bsqfDeleteComment(' + repoId + ', ' + commentId + ')">Delete</button>' +
'</div>' +
'<div class="comment-edit-shell" data-comment-edit-shell="' + escapeHtml(rawCommentId) + '" hidden>' +
'<textarea class="comment-edit-input" data-comment-edit-input="' + escapeHtml(rawCommentId) + '">' + text + '</textarea>' +
'<div class="comment-edit-actions">' +
'<button class="comment-action-button" type="button" onclick="window.bsqfCancelCommentEdit(' + commentId + ')">Cancel</button>' +
'<button class="comment-action-button comment-action-button--primary" type="button" onclick="window.bsqfSaveCommentEdit(' + repoId + ', ' + commentId + ')">Save</button>' +
'</div>' +
'</div>';
}
function renderCommentsHtml(comments) {
if (!Array.isArray(comments) || !comments.length) {
return '<div class="comment-empty">No comments yet. Be the first to leave a note for this Space.</div>';
}
const items = comments.map(function (row) {
const commentId = row && row.id != null ? String(row.id) : '';
const answer = row && row.answer ? escapeHtml(row.answer) : '';
const createdAt = row && row.created_at ? formatCommentDate(row.created_at) : '';
const sourceQuery = row && row.source_query ? '<span class="comment-source">Query: ' + escapeHtml(row.source_query) + '</span>' : '';
const ownClass = row && row.can_edit ? ' comment-item--own' : '';
const meta = [
createdAt ? '<span class="comment-date">' + createdAt + '</span>' : '',
sourceQuery
].filter(Boolean).join('');
return '<li class="comment-item' + ownClass + '" data-comment-id="' + escapeHtml(commentId) + '">' +
'<div class="comment-answer">' + answer + '</div>' +
(meta ? '<div class="comment-meta">' + meta + '</div>' : '') +
renderCommentActions(row) +
'</li>';
}).join('');
return '<ul class="comment-list">' + items + '</ul>';
}
function updateCommentCount(repoId, count) {
document.querySelectorAll('[data-comment-count-for="' + repoId + '"]').forEach(function (node) {
node.textContent = String(count || 0);
});
}
function showCommentToast(repoId, message, variant) {
const toast = document.querySelector('[data-comment-status-for="' + repoId + '"]');
if (!toast) return;
if (toast._bsqfTimer) {
window.clearTimeout(toast._bsqfTimer);
toast._bsqfTimer = null;
}
toast.classList.remove('is-visible', 'is-error', 'is-success');
toast.classList.add(variant === 'error' ? 'is-error' : 'is-success');
toast.innerHTML = '<span class="card-comment-toast__icon">' + (variant === 'error' ? '!' : '✓') + '</span><span class="card-comment-toast__text">' + escapeHtml(message) + '</span>';
void toast.offsetWidth;
toast.classList.add('is-visible');
toast._bsqfTimer = window.setTimeout(function () {
toast.classList.remove('is-visible');
window.setTimeout(function () {
toast.classList.remove('is-error', 'is-success');
toast.innerHTML = '';
}, 180);
}, 5000);
}
window.bsqfCloseComments = function () {
const modal = document.getElementById('bsqf-comments-modal');
if (!modal) return;
modal.classList.remove('is-open');
modal.setAttribute('aria-hidden', 'true');
bodyScrollLock(false);
};
window.bsqfOpenComments = async function (title, repoId) {
const modal = document.getElementById('bsqf-comments-modal');
const titleNode = document.getElementById('bsqf-comments-title');
const body = document.getElementById('bsqf-comments-body');
if (!modal || !titleNode || !body) return;
window.bsqfActiveCommentsRepoId = repoId || '';
titleNode.textContent = title || 'Comments';
body.innerHTML = '<div class="comment-empty">Loading comments...</div>';
modal.classList.add('is-open');
modal.setAttribute('aria-hidden', 'false');
bodyScrollLock(true);
try {
const payload = await loadComments(repoId);
body.innerHTML = renderCommentsHtml(payload.comments || []);
} catch (error) {
body.innerHTML = '<div class="comment-empty">' + escapeHtml(error && error.message ? error.message : 'Could not load comments.') + '</div>';
}
};
window.bsqfSubmitComment = async function (repoId, sourceQuery) {
const textarea = document.querySelector('[data-comment-input-for="' + repoId + '"]');
const statusNode = document.querySelector('[data-comment-status-for="' + repoId + '"]');
if (!textarea || !statusNode) return;
const answer = String(textarea.value || '').trim();
if (!answer) {
showCommentToast(repoId, 'Write a quick comment first.', 'error');
return;
}
try {
const payload = await callGradioApi('comments_save', [repoId, answer, sourceQuery || '', getOwnerSessionId()]);
if (!payload.ok) {
throw new Error(payload && (payload.error || payload.detail) ? (payload.error || payload.detail) : 'Could not save comment.');
}
textarea.value = '';
window.bsqfSyncCommentButton(repoId);
updateCommentCount(repoId, Number(payload.count || 0));
showCommentToast(repoId, 'Comment saved', 'success');
if (window.bsqfActiveCommentsRepoId === repoId) {
const body = document.getElementById('bsqf-comments-body');
if (body) body.innerHTML = renderCommentsHtml(payload.comments || []);
}
} catch (error) {
showCommentToast(repoId, error && error.message ? error.message : 'Could not save comment.', 'error');
}
};
window.bsqfSyncCommentButton = function (repoId) {
const textarea = document.querySelector('[data-comment-input-for="' + repoId + '"]');
const button = document.querySelector('[data-comment-send-for="' + repoId + '"]');
if (!textarea || !button) return;
const hasText = String(textarea.value || '').trim().length > 0;
button.disabled = !hasText;
button.setAttribute('aria-disabled', hasText ? 'false' : 'true');
};
window.bsqfBeginCommentEdit = function (repoId, commentId) {
const shell = document.querySelector('[data-comment-edit-shell="' + commentId + '"]');
const input = document.querySelector('[data-comment-edit-input="' + commentId + '"]');
const item = document.querySelector('[data-comment-id="' + commentId + '"]');
if (!shell || !input || !item) return;
item.classList.add('is-editing');
shell.hidden = false;
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
};
window.bsqfCancelCommentEdit = function (commentId) {
const shell = document.querySelector('[data-comment-edit-shell="' + commentId + '"]');
const item = document.querySelector('[data-comment-id="' + commentId + '"]');
if (!shell || !item) return;
item.classList.remove('is-editing');
shell.hidden = true;
};
window.bsqfSaveCommentEdit = async function (repoId, commentId) {
const input = document.querySelector('[data-comment-edit-input="' + commentId + '"]');
if (!input) return;
const answer = String(input.value || '').trim();
if (!answer) {
showCommentToast(repoId, 'Write a quick comment first.', 'error');
return;
}
try {
const payload = await callGradioApi('comments_update', [repoId, commentId, answer, getOwnerSessionId()]);
if (!payload.ok) {
throw new Error(payload && (payload.error || payload.detail) ? (payload.error || payload.detail) : 'Could not update comment.');
}
updateCommentCount(repoId, Number(payload.count || 0));
showCommentToast(repoId, 'Comment updated', 'success');
if (window.bsqfActiveCommentsRepoId === repoId) {
const body = document.getElementById('bsqf-comments-body');
if (body) body.innerHTML = renderCommentsHtml(payload.comments || []);
}
} catch (error) {
showCommentToast(repoId, error && error.message ? error.message : 'Could not update comment.', 'error');
}
};
window.bsqfDeleteComment = async function (repoId, commentId) {
const confirmed = window.confirm('Delete your comment from this Space?');
if (!confirmed) return;
try {
const payload = await callGradioApi('comments_delete', [repoId, commentId, getOwnerSessionId()]);
if (!payload.ok) {
throw new Error(payload && (payload.error || payload.detail) ? (payload.error || payload.detail) : 'Could not delete comment.');
}
updateCommentCount(repoId, Number(payload.count || 0));
showCommentToast(repoId, 'Comment deleted', 'success');
if (window.bsqfActiveCommentsRepoId === repoId) {
const body = document.getElementById('bsqf-comments-body');
if (body) body.innerHTML = renderCommentsHtml(payload.comments || []);
}
} catch (error) {
showCommentToast(repoId, error && error.message ? error.message : 'Could not delete comment.', 'error');
}
};
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') window.bsqfCloseComments();
});
})();
</script>
"""
FIELD_GUIDE_SHELL_SCRIPT = """
<script>
(function () {
function dispatchInput(node) {
if (!node) return;
node.dispatchEvent(new Event('input', { bubbles: true }));
node.dispatchEvent(new Event('change', { bubbles: true }));
}
function setTextboxValue(selector, value) {
const node = document.querySelector(selector);
if (!node) return false;
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
if (setter) setter.call(node, value);
else node.value = value;
dispatchInput(node);
return true;
}
function clickButton(selector) {
const button = document.querySelector(selector);
if (button) button.click();
}
function applyPrompt(query, liked) {
setTextboxValue('#search-query textarea', query || '');
setTextboxValue('#liked-text textarea', liked || '');
clickButton('#recommend-btn button');
const target = document.querySelector('.search-panels');
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
window.bsqfDisableRecommendButton = function () {
const button = document.querySelector('#recommend-btn button');
if (!button) return;
button.disabled = true;
button.setAttribute('aria-busy', 'true');
button.classList.add('is-processing');
};
window.bsqfEnableRecommendButton = function () {
const button = document.querySelector('#recommend-btn button');
if (!button) return;
button.disabled = false;
button.removeAttribute('aria-busy');
button.classList.remove('is-processing');
};
window.bsqfApplyPrompt = applyPrompt;
document.addEventListener('click', function (event) {
const trigger = event.target.closest('[data-bsqf-fill-query]');
if (!trigger) return;
event.preventDefault();
applyPrompt(
trigger.getAttribute('data-bsqf-fill-query') || '',
trigger.getAttribute('data-bsqf-fill-liked') || ''
);
});
})();
</script>
"""
def render_page_shell(stats: dict, *, active_category: str = "All", active_query: str = "") -> str:
return f"""
<section class="camp-hero-shell">
<div class="camp-hero-art-shell" aria-label="Hackathon Space Recommender hero illustration">
<img id="camp-hero-image" class="camp-hero-art" src="{BANNER_SRC}" alt="Hackathon Space Recommender banner illustration" />
</div>
</section>
"""
def render_footer_shell() -> str:
return """
<footer class="camp-footer">
<div class="camp-footer__trail"></div>
<div class="camp-seal">
<img id="camp-badge-image" class="camp-seal__image" src="{badge_src}" alt="Build Small Hackathon badge" />
</div>
</footer>
""".format(badge_src=BADGE_SRC)
def _meta_button(text: str, *, css_class: str = "", attrs: str = "", tag: str = "button") -> str:
class_attr = f"card-action {css_class}".strip()
if tag == "a":
return f'<a class="{class_attr}" {attrs}>{text}</a>'
return f'<button class="{class_attr}" type="button" {attrs}>{text}</button>'
def render_space_card(
space: dict,
comments_by_repo: dict[str, list[dict]] | None = None,
*,
show_reason: bool = True,
enable_inline_comment: bool = False,
source_query: str = "",
tone_index: int = 0,
) -> str:
comments_by_repo = comments_by_repo or {}
repo_id = str(space.get("id", "")).strip()
comments = comments_by_repo.get(repo_id, [])
comment_count = len(comments)
reason = str(space.get("reason", "") or "").strip()
category = str(space.get("category", "Other") or "Other").strip()
track = str(space.get("track", "") or "").strip()
title = str(space.get("name", "") or "").strip()
likes = int(space.get("likes", 0) or 0)
tag_text = _tags_text(space.get("tags"))
deco_key = CARD_DECORATIONS[tone_index % len(CARD_DECORATIONS)]
deco_mark = CARD_DECORATION_MARKS[deco_key]
title_js = _js_literal(title)
repo_id_js = _js_literal(repo_id)
source_query_js = _js_literal(source_query)
open_url = safe(space.get("url", ""))
reason_html = ""
if show_reason and reason:
reason_html = f'<div class="card-why"><span class="card-why__label">Why it fits</span><p>{safe(reason)}</p></div>'
inline_comment_html = ""
if enable_inline_comment and repo_id:
inline_comment_html = f"""
<div class="card-comment-box">
<label class="card-comment-label" for="comment-box-{safe(repo_id)}">What did you like best about the space?</label>
<textarea
id="comment-box-{safe(repo_id)}"
class="card-comment-input"
data-comment-input-for="{safe(repo_id)}"
placeholder="What stood out to you about this Space?"
rows="2"
oninput="window.bsqfSyncCommentButton({repo_id_js})"
onchange="window.bsqfSyncCommentButton({repo_id_js})"
></textarea>
<div class="card-comment-help"><small>Not sure what to write? Open the Space and give it a quick try.</small></div>
<div class="card-comment-actions">
<button
class="card-comment-send"
data-comment-send-for="{safe(repo_id)}"
type="button"
disabled
aria-disabled="true"
onclick="window.bsqfSubmitComment({repo_id_js}, {source_query_js})"
>
Send comment
</button>
<span class="card-comment-status" data-comment-status-for="{safe(repo_id)}" aria-live="polite" aria-atomic="true"></span>
</div>
</div>
"""
category_chip = f'<span class="card-badge card-badge--category">{safe(category)}</span>' if category else ""
track_chip = f'<span class="card-badge card-badge--track">{safe(track)}</span>' if track else ""
comment_button = _meta_button(
f'💬 <span data-comment-count-for="{safe(repo_id)}">{comment_count}</span>',
css_class="card-action--count",
attrs=f'onclick="window.bsqfOpenComments({title_js}, {repo_id_js})"',
)
like_chip = f'<span class="card-like">♥ {likes}</span>'
open_button = _meta_button("Open Space ↗", css_class="card-action--open", attrs=f'href="{open_url}" target="_blank" rel="noreferrer"', tag="a")
return f"""
<article class="space-card notebook-card notebook-card--{safe(deco_key)}" data-deco="{safe(deco_mark)}" data-bsqf-search-text="{safe(title)}">
<div class="card-top">
<div class="card-chip-row">
{category_chip}
{track_chip}
</div>
<div class="card-meta-actions">
{like_chip}
{comment_button}
{open_button}
</div>
</div>
<div class="card-title-block">
<h2><strong>{safe(title)}</strong></h2>
</div>
{reason_html}
{inline_comment_html}
<div class="card-tags">{safe(tag_text)}</div>
</article>
"""
def render_recommendation_cards(results: list[dict], *, show_reason: bool = False, source_query: str = "") -> str:
results = filter_public_spaces(results)
comments_by_repo = _group_feedback()
if not results:
return """
<div class="results-shell" data-bsqf-results-shell>
<div class="bsqf-filter-empty" data-bsqf-filter-empty hidden>No spaces match this filter.</div>
<div class="results-grid">
<div class="empty-state-card">
<h3>No matching spaces found.</h3>
<p>Try to modify your search query</p>
</div>
</div>
</div>
"""
cards = "\n".join(
render_space_card(
result,
comments_by_repo,
show_reason=show_reason,
enable_inline_comment=True,
source_query=source_query,
tone_index=index,
)
for index, result in enumerate(results)
)
return f"""
<div class="results-shell" data-bsqf-results-shell>
<div class="bsqf-filter-empty" data-bsqf-filter-empty hidden>No spaces match this filter.</div>
<div class="results-grid">{cards}</div>
</div>
"""
def render_category_section(spaces: list[dict], category: str) -> str:
spaces = filter_public_spaces(spaces)
comments_by_repo = _group_feedback()
if not spaces:
return """
<div class="results-shell" data-bsqf-results-shell>
<div class="bsqf-filter-empty" data-bsqf-filter-empty hidden>No spaces match this filter.</div>
<div class="results-grid">
<div class="empty-state-card">
<h3>No Spaces in this category</h3>
<p>Nothing in the current index matches this category yet.</p>
</div>
</div>
</div>
"""
cards = "\n".join(
render_space_card(
space,
comments_by_repo,
show_reason=False,
enable_inline_comment=False,
tone_index=index,
)
for index, space in enumerate(spaces)
)
return f"""
<div class="results-shell" data-bsqf-results-shell>
<div class="bsqf-filter-empty" data-bsqf-filter-empty hidden>No spaces match this filter.</div>
<div class="results-grid results-grid--browse">{cards}</div>
</div>
"""