403 lines
14 KiB
HTML
403 lines
14 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<title>Article View</title>
|
|
|
|
<style>
|
|
:root{
|
|
/* future per-paragraph background color */
|
|
--card-bg: #ffffff;
|
|
}
|
|
|
|
/* ─────────── Global title bar ─────────── */
|
|
.navbar{
|
|
position:fixed;
|
|
top:0; left:0; right:0;
|
|
height:3rem;
|
|
background:#0d47a1;
|
|
color:#fff;
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:space-between;
|
|
padding:0 1rem;
|
|
box-shadow:0 1px 4px rgba(0,0,0,.15);
|
|
z-index:100;
|
|
}
|
|
.navbar .brand{
|
|
font-size:1.15rem;
|
|
font-weight:700;
|
|
color:#fff;
|
|
text-decoration:none;
|
|
}
|
|
.navbar .nav-link{
|
|
color:#fff;
|
|
text-decoration:none;
|
|
margin-left:1rem;
|
|
font-size:.95rem;
|
|
}
|
|
.navbar .nav-link:hover{
|
|
text-decoration:underline;
|
|
}
|
|
|
|
body{
|
|
font-family: Arial, sans-serif;
|
|
margin:0;
|
|
padding: 4rem 1rem 2rem;
|
|
background:#f6f8fa;
|
|
color:#222;
|
|
}
|
|
|
|
h1{
|
|
text-align:center;
|
|
margin:1.5rem 0 1rem;
|
|
font-size:1.6rem;
|
|
}
|
|
|
|
a,
|
|
a:visited,
|
|
a:hover,
|
|
a:active,
|
|
a:focus {
|
|
/*text-decoration: none;*/
|
|
color: inherit; /* keep the original text color */
|
|
}
|
|
|
|
/* TOPICS GRID ------------------------------------------------------- */
|
|
#topics{
|
|
display:grid;
|
|
grid-template-columns:repeat(auto-fill,minmax(220px,1fr));
|
|
gap:.75rem;
|
|
max-width:1200px;
|
|
margin:0 auto 2rem;
|
|
list-style:none;
|
|
padding:0;
|
|
}
|
|
.topic-chip{
|
|
background:#e8eef6;
|
|
border-radius:4px;
|
|
padding:.6rem .8rem;
|
|
font-size:.9rem;
|
|
line-height:1.3;
|
|
}
|
|
|
|
/* SUMMARY BLOB ------------------------------------------------------ */
|
|
#summary{
|
|
background:#fff8e6;
|
|
border-left:5px solid #f5c147;
|
|
border-radius:4px;
|
|
padding:1rem 1.2rem;
|
|
max-width:900px;
|
|
margin:0 auto 2rem;
|
|
font-size:1rem;
|
|
}
|
|
|
|
/* PARAGRAPH CARDS --------------------------------------------------- */
|
|
#paragraphs{
|
|
display:flex;
|
|
flex-direction:column;
|
|
gap:1rem;
|
|
max-width:900px;
|
|
margin:0 auto;
|
|
}
|
|
/* make each paragraph a little flex-box so we can place
|
|
the coloured badge on the right-hand side */
|
|
.paragraph-card{
|
|
display:flex;
|
|
justify-content:space-between;
|
|
align-items:flex-start;
|
|
gap:.75rem;
|
|
background:var(--card-bg);
|
|
border-radius:6px;
|
|
padding:1rem 1.2rem;
|
|
box-shadow:0 1px 3px rgba(0,0,0,.08);
|
|
transition:box-shadow .15s ease;
|
|
}
|
|
.paragraph-card:hover{
|
|
box-shadow:0 3px 8px rgba(0,0,0,.14);
|
|
}
|
|
.paragraph-card--active{
|
|
outline:3px solid #1565c0; /* blue focus ring */
|
|
outline-offset:2px;
|
|
box-shadow:0 0 6px rgba(21,101,192,.35); /* soft glow */
|
|
}
|
|
|
|
/* ─────────── Relevance badge ─────────── */
|
|
.relevance-badge{
|
|
min-width:72px;
|
|
font-size:.75rem;
|
|
font-weight:600;
|
|
line-height:1.4;
|
|
color:#fff;
|
|
padding:.25rem .4rem;
|
|
border-radius:4px;
|
|
text-align:center;
|
|
user-select:none;
|
|
flex-shrink:0;
|
|
}
|
|
.relevance-low {background:#d32f2f;} /* red */
|
|
.relevance-med {background:#f57c00;} /* amber */
|
|
.relevance-high {background:#388e3c;} /* green */
|
|
.error{
|
|
color:#c00;
|
|
text-align:center;
|
|
margin-top:2rem;
|
|
}
|
|
/* ─────────── Floating navigation arrows ─────────── */
|
|
.nav-arrow{
|
|
position:fixed;
|
|
top:50%;
|
|
transform:translateY(-50%);
|
|
width:42px;
|
|
height:42px;
|
|
border:none;
|
|
border-radius:50%;
|
|
background:#0d47a1;
|
|
color:#fff;
|
|
font-size:1.35rem;
|
|
line-height:1;
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:center;
|
|
cursor:pointer;
|
|
box-shadow:0 2px 6px rgba(0,0,0,.25);
|
|
z-index:110;
|
|
transition:background .2s ease;
|
|
}
|
|
.nav-arrow:hover{
|
|
background:#1565c0;
|
|
}
|
|
.nav-arrow:disabled{
|
|
opacity:.35;
|
|
cursor:default;
|
|
}
|
|
.nav-arrow--left {left:.75rem;}
|
|
.nav-arrow--right{right:.75rem;}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<header class="navbar">
|
|
<a href="/news/" class="brand">Newsulizer</a>
|
|
<nav>
|
|
<a href="/news/" class="nav-link">Home</a>
|
|
<a href="/news/browse" class="nav-link">Browse</a>
|
|
<a href="/news/search" class="nav-link">Search</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<div style="display: flex; justify-content: center;">
|
|
<h1 id="title"><b></b></h1>
|
|
</div>
|
|
|
|
|
|
<div style="display: flex; justify-content: center;">
|
|
<div style="display: block; padding-bottom: 1rem;">
|
|
<div style="display: flex; justify-content: center;">
|
|
<h2>Article Paragraphs</h2>
|
|
</div>
|
|
<p>Extracted from the original article</p>
|
|
</div>
|
|
</div>
|
|
|
|
<section id="paragraphs" aria-label="Article paragraphs"></section>
|
|
|
|
<div style="display: flex; justify-content: center;">
|
|
<h2>Article Topics (AI Generated)</h2>
|
|
</div>
|
|
<ul id="topics" aria-label="Article topics"></ul>
|
|
|
|
<div style="display: flex; justify-content: center;">
|
|
<h2>Article Summary (AI Generated)</h2>
|
|
</div>
|
|
<section id="summary" hidden></section>
|
|
|
|
<p id="error" class="error" hidden>Unable to load article. Please try again later.</p>
|
|
|
|
<script type="text/javascript">
|
|
(function main(){
|
|
const qs = new URLSearchParams(window.location.search);
|
|
const url = qs.get('url');
|
|
const API = '/news/api/view_article?url=' + encodeURIComponent(url ?? '');
|
|
|
|
const elTopics = document.getElementById('topics');
|
|
const elSummary = document.getElementById('summary');
|
|
const elParagraphs = document.getElementById('paragraphs');
|
|
const elError = document.getElementById('error');
|
|
const elTitle = document.getElementById('title');
|
|
|
|
if(!url){
|
|
elError.hidden = false;
|
|
elError.textContent = '`url` query parameter missing.';
|
|
return;
|
|
}
|
|
|
|
fetch(API)
|
|
.then(r => {
|
|
if(!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
return r.json();
|
|
})
|
|
.then(renderArticle)
|
|
.catch(err => {
|
|
console.error(err);
|
|
elError.hidden = false;
|
|
});
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
function renderArticle(data){
|
|
elTitle.innerHTML = "<a href='" + url + "'>\"" + data.title + "\"</a>";
|
|
|
|
/* 1. TOPICS -------------------------------------------------- */
|
|
if(Array.isArray(data.topics)){
|
|
data.topics.forEach(topic => {
|
|
const li = document.createElement('li');
|
|
li.className = 'topic-chip';
|
|
li.textContent = topic;
|
|
elTopics.appendChild(li);
|
|
});
|
|
}
|
|
|
|
/* 2. SUMMARY ------------------------------------------------- */
|
|
if(typeof data.summary === 'string' && data.summary.trim() !== ''){
|
|
elSummary.textContent = data.summary.trim();
|
|
elSummary.hidden = false;
|
|
}
|
|
|
|
/* 3. PARAGRAPHS --------------------------------------------- */
|
|
Object.entries(data.paragraphs ?? {}).forEach(([pid, pData]) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'paragraph-card';
|
|
|
|
/* main paragraph text */
|
|
const p = document.createElement('p');
|
|
p.textContent = pData.text;
|
|
p.style.margin = 0;
|
|
card.appendChild(p);
|
|
|
|
/* relevance badge --------------------------------------- */
|
|
const badge = document.createElement('span');
|
|
badge.classList.add('relevance-badge');
|
|
|
|
/* fall-back to 0 if the API did not supply a number */
|
|
const summary_rating = Number.parseFloat(pData.summary_rating ?? 0) / 100;
|
|
const ratingsArr = pData.topic_ratings ?? [];
|
|
const totalTrue = ratingsArr.reduce(
|
|
(sum, r) => sum + (r.rating ? 1 : 0),
|
|
0
|
|
);
|
|
const topic_ratings = totalTrue / ratingsArr.length;
|
|
const rating = (summary_rating + topic_ratings) / 2;
|
|
|
|
let cssClass = 'relevance-low';
|
|
if (rating >= 0.66) cssClass = 'relevance-high';
|
|
else if (rating >= 0.33) cssClass = 'relevance-med';
|
|
badge.classList.add(cssClass);
|
|
|
|
/* display as percentage for clarity */
|
|
const pct = Math.round(rating * 100);
|
|
badge.textContent = pct + '% relevant';
|
|
card.appendChild(badge);
|
|
|
|
card.dataset.summaryRating = rating; /* keep numeric value for sorting */
|
|
|
|
elParagraphs.appendChild(card);
|
|
});
|
|
|
|
setupRelevanceNavigation();
|
|
}
|
|
|
|
/* ───────────────── Floating-arrow navigation ───────────────── */
|
|
let sortedCards = []; // cards sorted by relevance
|
|
let currentIdx = -1; // index of the active card
|
|
let previousIdx = -1;
|
|
let arrowPrev, arrowNext; // will be assigned in setup
|
|
|
|
function setupRelevanceNavigation(){
|
|
const cards = Array.from(document.querySelectorAll('.paragraph-card'));
|
|
if(!cards.length) return;
|
|
|
|
sortedCards = cards.sort(
|
|
(a,b) => parseFloat(b.dataset.summaryRating) - parseFloat(a.dataset.summaryRating)
|
|
);
|
|
|
|
/* grab the arrow buttons so helpers can reach them */
|
|
arrowPrev = document.getElementById('arrowPrev');
|
|
arrowNext = document.getElementById('arrowNext');
|
|
|
|
arrowPrev.addEventListener('click', () => goTo(currentIdx - 1));
|
|
arrowNext.addEventListener('click', () => goTo(currentIdx + 1));
|
|
|
|
/* start on the most-relevant paragraph */
|
|
// goTo(0, /*smooth*/false);
|
|
updateArrowState();
|
|
}
|
|
|
|
function goTo(idx, smooth = true, fromScroll = false){
|
|
if (idx < 0)
|
|
idx = sortedCards.length - 1;
|
|
else if (idx >= sortedCards.length)
|
|
idx = 0;
|
|
// if(idx < 0 || idx >= sortedCards.length) return;
|
|
|
|
currentIdx = idx;
|
|
|
|
/* only scroll the viewport when the user clicked an arrow */
|
|
if(!fromScroll){
|
|
sortedCards[currentIdx].scrollIntoView({
|
|
behavior: smooth ? 'smooth' : 'auto',
|
|
block: 'center'
|
|
});
|
|
}
|
|
|
|
updateArrowState();
|
|
highlightActive();
|
|
}
|
|
|
|
function updateArrowState(){
|
|
if (currentIdx < 0) {
|
|
arrowPrev.disabled = false;
|
|
arrowNext.disabled = false;
|
|
return;
|
|
}
|
|
arrowPrev.disabled = currentIdx === 0;
|
|
arrowNext.disabled = currentIdx === sortedCards.length - 1;
|
|
}
|
|
|
|
function highlightActive(){
|
|
if (currentIdx < 0) return;
|
|
if(previousIdx !== -1){
|
|
sortedCards[previousIdx].classList.remove('paragraph-card--active');
|
|
}
|
|
sortedCards[currentIdx].classList.add('paragraph-card--active');
|
|
previousIdx = currentIdx;
|
|
}
|
|
|
|
document.addEventListener('keydown', evt => {
|
|
/* ignore key presses while the user is typing in inputs / textareas */
|
|
const tag = (evt.target.tagName || '').toLowerCase();
|
|
if (tag === 'input' || tag === 'textarea' || evt.target.isContentEditable) {
|
|
return;
|
|
}
|
|
|
|
if (evt.key === 'ArrowLeft') {
|
|
/* same as clicking the left arrow */
|
|
if (typeof arrowPrev !== 'undefined' && !arrowPrev.disabled) {
|
|
evt.preventDefault();
|
|
arrowPrev.click();
|
|
}
|
|
}
|
|
else if (evt.key === 'ArrowRight') {
|
|
/* same as clicking the right arrow */
|
|
if (typeof arrowNext !== 'undefined' && !arrowNext.disabled) {
|
|
evt.preventDefault();
|
|
arrowNext.click();
|
|
}
|
|
}
|
|
});
|
|
|
|
})();
|
|
</script>
|
|
<button id="arrowPrev" class="nav-arrow nav-arrow--left" aria-label="Previous paragraph" disabled>←</button>
|
|
<button id="arrowNext" class="nav-arrow nav-arrow--right" aria-label="Next paragraph" disabled>→</button>
|
|
</body>
|
|
</html> |