INIT
This commit is contained in:
commit
86547731c8
5 files changed
+1278
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
+617
@@ -0,0 +1,617 @@
|
||||
/* =========================================================
|
||||
Learn & Prep — Spanish B1 News Podcast
|
||||
========================================================= */
|
||||
|
||||
:root {
|
||||
--ink: #0e1116;
|
||||
--ink-soft: #2b3038;
|
||||
--muted: #6b7280;
|
||||
--bg: #fbf8f4;
|
||||
--card: #ffffff;
|
||||
--line: #e9e2d9;
|
||||
--accent: #ff5c39;
|
||||
--accent-2: #ffb703;
|
||||
--tg: #229ed9;
|
||||
--tg-dark: #1b86ba;
|
||||
|
||||
--radius: 18px;
|
||||
--radius-lg: 24px;
|
||||
--shadow: 0 10px 30px rgba(16, 20, 28, 0.07);
|
||||
--shadow-hover: 0 22px 45px rgba(16, 20, 28, 0.14);
|
||||
--shadow-lg: 0 40px 90px rgba(8, 10, 14, 0.35);
|
||||
|
||||
--maxw: 1120px;
|
||||
--font-display: "Fraunces", Georgia, "Times New Roman", serif;
|
||||
--font-body: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* Ensure the [hidden] attribute always wins over component display rules */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 { margin: 0; line-height: 1.15; }
|
||||
p { margin: 0; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font-family: inherit; }
|
||||
img { max-width: 100%; display: block; }
|
||||
|
||||
.container {
|
||||
width: min(100% - 40px, var(--maxw));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.icon { width: 20px; height: 20px; flex: none; }
|
||||
.icon--sm { width: 16px; height: 16px; }
|
||||
|
||||
/* ---------------- Language switcher ---------------- */
|
||||
.lang-switch {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(14, 17, 22, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.lang-switch button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 7px 13px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.lang-switch button:hover { color: #fff; }
|
||||
|
||||
.lang-switch button.is-active {
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---------------- Hero ---------------- */
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 94vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.hero__bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("../assets/hero.jpg");
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transform: scale(1.04);
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
.hero__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background:
|
||||
linear-gradient(100deg, rgba(8, 10, 14, 0.94) 0%, rgba(8, 10, 14, 0.82) 40%, rgba(8, 10, 14, 0.42) 100%),
|
||||
radial-gradient(120% 90% at 90% 10%, rgba(255, 92, 57, 0.22), transparent 55%);
|
||||
}
|
||||
|
||||
.hero__inner {
|
||||
position: relative;
|
||||
padding: 130px 0 100px;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: #ffd9a8;
|
||||
background: rgba(255, 183, 3, 0.14);
|
||||
border: 1px solid rgba(255, 183, 3, 0.35);
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: clamp(2.15rem, 5.4vw, 4rem);
|
||||
letter-spacing: -0.015em;
|
||||
margin-bottom: 20px;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.hero__subtitle {
|
||||
font-size: clamp(1rem, 1.6vw, 1.18rem);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
max-width: 620px;
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 15px 26px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 0.98rem;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:active { transform: translateY(1px); }
|
||||
|
||||
.btn--primary {
|
||||
background: var(--tg);
|
||||
color: #fff;
|
||||
box-shadow: 0 14px 30px rgba(34, 158, 217, 0.38);
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
background: var(--tg-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 40px rgba(34, 158, 217, 0.45);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.btn--ghost:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.hero__badges {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 26px;
|
||||
}
|
||||
|
||||
.hero__badges li {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
font-size: 0.92rem;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.hero__badges li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
box-shadow: 0 0 0 4px rgba(255, 183, 3, 0.14);
|
||||
}
|
||||
|
||||
/* ---------------- Archive ---------------- */
|
||||
.archive {
|
||||
padding: 96px 0 110px;
|
||||
background:
|
||||
radial-gradient(80% 60% at 100% 0%, rgba(255, 183, 3, 0.1), transparent 60%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
max-width: 640px;
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: clamp(1.8rem, 3.6vw, 2.6rem);
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-head p {
|
||||
color: var(--muted);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
max-width: 560px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.search__icon {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
padding: 16px 20px 16px 52px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--shadow);
|
||||
outline: none;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.search input::placeholder { color: #9aa1ac; }
|
||||
|
||||
.search input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(255, 92, 57, 0.14), var(--shadow);
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: inline-block;
|
||||
margin: 4px 0 12px;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.85rem;
|
||||
color: #8a5a00;
|
||||
background: rgba(255, 183, 3, 0.16);
|
||||
border: 1px solid rgba(255, 183, 3, 0.4);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
padding: 24px 24px 22px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, var(--accent), var(--accent-2));
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus-visible {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-hover);
|
||||
border-color: #ded4c7;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card:hover::before,
|
||||
.card:focus-visible::before { opacity: 1; }
|
||||
|
||||
.card__date {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card__topic {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.12rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card__intro {
|
||||
font-size: 0.92rem;
|
||||
color: var(--muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card__cta {
|
||||
margin-top: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--tg);
|
||||
}
|
||||
|
||||
.card__cta svg { width: 15px; height: 15px; transition: transform 0.2s ease; }
|
||||
.card:hover .card__cta svg { transform: translateX(4px); }
|
||||
|
||||
.state {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.state--error { color: #b42318; }
|
||||
|
||||
/* ---------------- Footer ---------------- */
|
||||
.footer {
|
||||
background: var(--ink);
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
padding: 34px 0;
|
||||
}
|
||||
|
||||
.footer__inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.footer__brand {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ---------------- Modal ---------------- */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(8, 10, 14, 0.62);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
animation: fade 0.2s ease;
|
||||
}
|
||||
|
||||
.modal__panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(760px, 100%);
|
||||
max-height: 88vh;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
animation: pop 0.24s cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.modal__close svg { width: 18px; height: 18px; }
|
||||
.modal__close:hover { background: #f4efe8; transform: rotate(90deg); }
|
||||
|
||||
.modal__head {
|
||||
padding: 30px 32px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #fffdfa, #fff);
|
||||
}
|
||||
|
||||
.modal__date {
|
||||
display: inline-block;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.25rem, 2.6vw, 1.7rem);
|
||||
font-weight: 600;
|
||||
padding-right: 40px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.modal__body {
|
||||
padding: 24px 32px 28px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.modal__section + .modal__section { margin-top: 30px; }
|
||||
|
||||
.modal__section h4 {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.transcript p {
|
||||
margin-bottom: 1em;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.78;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.transcript p:last-child { margin-bottom: 0; }
|
||||
|
||||
.transcript strong {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
background: linear-gradient(180deg, transparent 58%, rgba(255, 183, 3, 0.42) 58%);
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.transcript em { color: var(--ink-soft); font-style: italic; }
|
||||
|
||||
.sources a {
|
||||
display: block;
|
||||
padding: 9px 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--tg);
|
||||
border-bottom: 1px dashed var(--line);
|
||||
transition: color 0.16s ease, padding-left 0.16s ease;
|
||||
}
|
||||
|
||||
.sources a:last-child { border-bottom: 0; }
|
||||
.sources a:hover { color: var(--tg-dark); padding-left: 5px; }
|
||||
|
||||
.modal__foot {
|
||||
padding: 18px 32px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: #fffdfa;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modal__loading {
|
||||
padding: 30px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes pop {
|
||||
from { opacity: 0; transform: translateY(16px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* ---------------- Responsive ---------------- */
|
||||
@media (max-width: 720px) {
|
||||
.hero { min-height: 88vh; }
|
||||
.hero__inner { padding: 120px 0 80px; }
|
||||
.hero__actions .btn { flex: 1 1 auto; justify-content: center; }
|
||||
.archive { padding: 72px 0 84px; }
|
||||
.cards { grid-template-columns: 1fr; }
|
||||
.modal { padding: 0; }
|
||||
.modal__panel {
|
||||
width: 100%;
|
||||
max-height: 100vh;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
.modal__head { padding: 26px 20px 18px; }
|
||||
.modal__body { padding: 20px; }
|
||||
.modal__foot { padding: 16px 20px; }
|
||||
.modal__foot .btn { width: 100%; justify-content: center; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Learn & Prep — Spanish B1 News Podcast</title>
|
||||
<meta name="description" content="Learn Spanish at B1 level with a daily 10-minute podcast built from today's news. Three key words, the news and a clear summary — delivered to your Telegram." />
|
||||
<meta name="theme-color" content="#0e1116" />
|
||||
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='22' fill='%230e1116'/%3E%3Cpath d='M50 24a10 10 0 0 1 10 10v16a10 10 0 0 1-20 0V34a10 10 0 0 1 10-10z' fill='%23ff5c39'/%3E%3Cpath d='M32 48a18 18 0 0 0 36 0' fill='none' stroke='%23ffb703' stroke-width='6' stroke-linecap='round'/%3E%3Cpath d='M50 66v12' stroke='%23ffb703' stroke-width='6' stroke-linecap='round'/%3E%3C/svg%3E" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Language switcher (not a navigation menu) -->
|
||||
<div class="lang-switch" role="group" aria-label="Language / Sprache / Idioma">
|
||||
<button type="button" data-lang="en" class="is-active">EN</button>
|
||||
<button type="button" data-lang="de">DE</button>
|
||||
<button type="button" data-lang="es">ES</button>
|
||||
</div>
|
||||
|
||||
<!-- Hero -->
|
||||
<header class="hero">
|
||||
<div class="hero__bg" aria-hidden="true"></div>
|
||||
<div class="hero__overlay" aria-hidden="true"></div>
|
||||
|
||||
<div class="hero__inner container">
|
||||
<span class="eyebrow" data-i18n="hero.eyebrow"></span>
|
||||
<h1 class="hero__title" data-i18n="hero.title"></h1>
|
||||
<p class="hero__subtitle" data-i18n="hero.subtitle"></p>
|
||||
|
||||
<div class="hero__actions">
|
||||
<a class="btn btn--primary" id="ctaTelegram" href="https://t.me/Podcast_Learn_and_Prep_bot" target="_blank" rel="noopener">
|
||||
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M9.78 18.65l.28-4.23 7.68-6.92c.34-.31-.07-.46-.52-.19L7.74 13.3 3.64 12c-.88-.25-.89-.86.2-1.3l15.97-6.16c.73-.33 1.43.18 1.15 1.3l-2.72 12.81c-.19.91-.74 1.13-1.5.71L12.6 16.3l-1.99 1.93c-.23.23-.42.42-.83.42z"/></svg>
|
||||
<span data-i18n="hero.cta"></span>
|
||||
</a>
|
||||
<a class="btn btn--ghost" href="#archive">
|
||||
<span data-i18n="hero.ctaSecondary"></span>
|
||||
<svg class="icon icon--sm" viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12l7 7 7-7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul class="hero__badges">
|
||||
<li data-i18n="hero.badge1"></li>
|
||||
<li data-i18n="hero.badge2"></li>
|
||||
<li data-i18n="hero.badge3"></li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Archive -->
|
||||
<section id="archive" class="archive">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<h2 data-i18n="archive.title"></h2>
|
||||
<p data-i18n="archive.subtitle"></p>
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<svg class="search__icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.35-4.35"/></svg>
|
||||
<input id="search" type="search" autocomplete="off" data-i18n-placeholder="archive.search" />
|
||||
</div>
|
||||
|
||||
<p class="notice" id="notice" hidden></p>
|
||||
|
||||
<div class="meta-row">
|
||||
<span id="count" class="count"></span>
|
||||
</div>
|
||||
|
||||
<div id="list" class="cards" aria-live="polite"></div>
|
||||
<div id="state" class="state" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container footer__inner">
|
||||
<span class="footer__brand">Learn & Prep</span>
|
||||
<span class="footer__text" data-i18n="footer.text"></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Episode modal -->
|
||||
<div class="modal" id="modal" hidden>
|
||||
<div class="modal__backdrop" data-close></div>
|
||||
<div class="modal__panel" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<button type="button" class="modal__close" data-close aria-label="Close">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="modal__head">
|
||||
<span class="modal__date" id="modalDate"></span>
|
||||
<h3 class="modal__title" id="modalTitle"></h3>
|
||||
</div>
|
||||
|
||||
<div class="modal__body" id="modalBody"></div>
|
||||
|
||||
<div class="modal__foot">
|
||||
<a class="btn btn--primary" id="modalDownload" href="#" download>
|
||||
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"/></svg>
|
||||
<span data-i18n="modal.download"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,526 @@
|
||||
/* =========================================================
|
||||
Learn & Prep — Spanish B1 News Podcast
|
||||
Front-end logic: i18n, archive list, search, episode modal
|
||||
========================================================= */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Configuration
|
||||
--------------------------------------------------------- */
|
||||
const CONFIG = {
|
||||
// List of episodes: returns [{ data: [{ topic, date, text_intro }] }]
|
||||
listUrl: "https://n8n.odoo4projects.com/webhook/podcast",
|
||||
// Episode detail: same webhook, date used as id
|
||||
detailUrl: "https://n8n.odoo4projects.com/webhook/podcast",
|
||||
// Audio download endpoint
|
||||
audioUrl: "https://n8n.odoo4projects.com/webhook/podcast/audio",
|
||||
// Query parameter used to pass the episode date
|
||||
dateParam: "date",
|
||||
telegramUrl: "https://t.me/Podcast_Learn_and_Prep_bot",
|
||||
requestTimeout: 12000,
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Translations
|
||||
--------------------------------------------------------- */
|
||||
const I18N = {
|
||||
en: {
|
||||
"meta.title": "Learn & Prep — Spanish B1 News Podcast",
|
||||
"hero.eyebrow": "Spanish B1 · Daily news podcast",
|
||||
"hero.title": "Learn Spanish with today's news — in 10 minutes a day",
|
||||
"hero.subtitle":
|
||||
"Every day we turn the latest headlines into a short, easy-to-follow Spanish podcast at B1 level. Three key words, the news and a clear summary — delivered straight to your Telegram inbox.",
|
||||
"hero.cta": "Start on Telegram",
|
||||
"hero.ctaSecondary": "Browse the archive",
|
||||
"hero.badge1": "10 minutes a day",
|
||||
"hero.badge2": "B1 level Spanish",
|
||||
"hero.badge3": "3 new words per episode",
|
||||
"archive.title": "Podcast archive",
|
||||
"archive.subtitle": "Every episode, searchable by topic, keyword or date.",
|
||||
"archive.search": "Search by topic, keyword or date…",
|
||||
"archive.count": "{n} episodes",
|
||||
"archive.countOne": "1 episode",
|
||||
"archive.empty": "No episodes match your search.",
|
||||
"archive.loading": "Loading episodes…",
|
||||
"archive.error": "Could not load episodes. Please try again later.",
|
||||
"archive.demo": "Live feed unavailable — showing sample data.",
|
||||
"card.open": "Open episode",
|
||||
"modal.loading": "Loading episode…",
|
||||
"modal.error": "Could not load this episode. Please try again later.",
|
||||
"modal.transcript": "Transcript",
|
||||
"modal.sources": "Sources",
|
||||
"modal.download": "Download audio",
|
||||
"footer.text": "Daily Spanish B1 news podcast · Delivered via Telegram.",
|
||||
},
|
||||
de: {
|
||||
"meta.title": "Learn & Prep — Spanisch B1 Nachrichten-Podcast",
|
||||
"hero.eyebrow": "Spanisch B1 · Täglicher Nachrichten-Podcast",
|
||||
"hero.title": "Spanisch lernen mit den Nachrichten von heute — in 10 Minuten pro Tag",
|
||||
"hero.subtitle":
|
||||
"Jeden Tag verwandeln wir die aktuellen Schlagzeilen in einen kurzen, leicht verständlichen Spanisch-Podcast auf B1-Niveau. Drei wichtige Vokabeln, die Nachrichten und eine klare Zusammenfassung — direkt in dein Telegram-Postfach.",
|
||||
"hero.cta": "Auf Telegram starten",
|
||||
"hero.ctaSecondary": "Zum Archiv",
|
||||
"hero.badge1": "10 Minuten pro Tag",
|
||||
"hero.badge2": "Spanisch Niveau B1",
|
||||
"hero.badge3": "3 neue Wörter pro Folge",
|
||||
"archive.title": "Podcast-Archiv",
|
||||
"archive.subtitle": "Jede Folge, durchsuchbar nach Thema, Stichwort oder Datum.",
|
||||
"archive.search": "Nach Thema, Stichwort oder Datum suchen…",
|
||||
"archive.count": "{n} Folgen",
|
||||
"archive.countOne": "1 Folge",
|
||||
"archive.empty": "Keine Folgen passen zu deiner Suche.",
|
||||
"archive.loading": "Folgen werden geladen…",
|
||||
"archive.error": "Folgen konnten nicht geladen werden. Bitte später erneut versuchen.",
|
||||
"archive.demo": "Live-Feed nicht verfügbar — Beispieldaten werden angezeigt.",
|
||||
"card.open": "Folge öffnen",
|
||||
"modal.loading": "Folge wird geladen…",
|
||||
"modal.error": "Diese Folge konnte nicht geladen werden. Bitte später erneut versuchen.",
|
||||
"modal.transcript": "Transkript",
|
||||
"modal.sources": "Quellen",
|
||||
"modal.download": "Audio herunterladen",
|
||||
"footer.text": "Täglicher Spanisch-B1-Nachrichten-Podcast · Geliefert über Telegram.",
|
||||
},
|
||||
es: {
|
||||
"meta.title": "Learn & Prep — Podcast de noticias en español B1",
|
||||
"hero.eyebrow": "Español B1 · Podcast diario de noticias",
|
||||
"hero.title": "Aprende español con las noticias de hoy — en 10 minutos al día",
|
||||
"hero.subtitle":
|
||||
"Cada día convertimos los titulares más recientes en un podcast corto y fácil de seguir, en español de nivel B1. Tres palabras clave, las noticias y un resumen claro — directo a tu Telegram.",
|
||||
"hero.cta": "Empezar en Telegram",
|
||||
"hero.ctaSecondary": "Ver el archivo",
|
||||
"hero.badge1": "10 minutos al día",
|
||||
"hero.badge2": "Español nivel B1",
|
||||
"hero.badge3": "3 palabras nuevas por episodio",
|
||||
"archive.title": "Archivo del podcast",
|
||||
"archive.subtitle": "Todos los episodios, con búsqueda por tema, palabra clave o fecha.",
|
||||
"archive.search": "Busca por tema, palabra clave o fecha…",
|
||||
"archive.count": "{n} episodios",
|
||||
"archive.countOne": "1 episodio",
|
||||
"archive.empty": "Ningún episodio coincide con tu búsqueda.",
|
||||
"archive.loading": "Cargando episodios…",
|
||||
"archive.error": "No se pudieron cargar los episodios. Inténtalo más tarde.",
|
||||
"archive.demo": "Feed no disponible — mostrando datos de ejemplo.",
|
||||
"card.open": "Abrir episodio",
|
||||
"modal.loading": "Cargando episodio…",
|
||||
"modal.error": "No se pudo cargar este episodio. Inténtalo más tarde.",
|
||||
"modal.transcript": "Transcripción",
|
||||
"modal.sources": "Fuentes",
|
||||
"modal.download": "Descargar audio",
|
||||
"footer.text": "Podcast diario de noticias en español B1 · Enviado por Telegram.",
|
||||
},
|
||||
};
|
||||
|
||||
const LOCALES = { en: "en-GB", de: "de-DE", es: "es-ES" };
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Sample data (used only when the live feed is unreachable,
|
||||
so the page stays usable while the workflow is offline)
|
||||
--------------------------------------------------------- */
|
||||
const SAMPLE_LIST = [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
topic:
|
||||
"1. Paraguay siembra primera soja CRISPR comercial sin regulación de OMG en el mundo. \n2. Escasez crítica de insulina y biofármacos en hospitales públicos de Paraguay.",
|
||||
date: "2026-09-25",
|
||||
text_intro:
|
||||
"Hoy en Mbokayaty, la temperatura estará entre 22 y 32 grados Celsius. No se esperan lluvias. \n\nHoy ",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const SAMPLE_DETAIL = [
|
||||
{
|
||||
podcast: "Learn_and_Prep",
|
||||
topic:
|
||||
"1. Paraguay siembra primera soja CRISPR comercial sin regulación de OMG en el mundo. \n2. Escasez crítica de insulina y biofármacos en hospitales públicos de Paraguay.",
|
||||
text:
|
||||
"Hoy en Mbokayaty, la temperatura estará entre 22 y 32 grados Celsius. No se esperan lluvias. \n\nHoy hablaremos de dos temas muy importantes para el futuro de Paraguay. Primero, de una innovación agrícola que está llamando la atención del mundo: la siembra de soja modificada con una tecnología llamada CRISPR, y cómo Paraguay se convirtió en el primer país en hacerlo sin aplicar las reglas habituales para organismos modificados genéticamente. Segundo, de una crisis silenciosa pero grave: la escasez de medicamentos biofarmacéuticos, como la insulina y tratamientos contra el cáncer, que está afectando directamente a miles de pacientes.\n\nAntes de entrar en los detalles, aprenderemos tres palabras clave del primer tema: **biotecnología**, **edición genética** y **trazabilidad**. \n\n*biodiversidad* no es la palabra que buscamos hoy. Vamos con las tres correctas: \n\nPrimera palabra: **biotecnología**. Es el uso de organismos vivos o sus componentes para crear productos útiles, como medicamentos o cultivos mejorados. Por ejemplo: *La biotecnología ayuda a desarrollar semillas que resisten mejor la sequía*. \n\nSegunda palabra: **edición genética**. Es una técnica que permite cambiar partes específicas del ADN de un organismo, como usar tijeras muy pequeñas para corregir errores en un libro. Por ejemplo: *Con la edición genética, los científicos pueden mejorar una planta sin introducir genes de otras especies*. \n\nTercera palabra: **trazabilidad**. Es la capacidad de seguir el camino de un producto desde su origen hasta el consumidor final. Por ejemplo: *Gracias a la trazabilidad, los compradores pueden saber si un grano vino de un cultivo con tecnología CRISPR o convencional*. \n\nAhora, pasamos al primer tema: **Paraguay registra récord de siembra de soja transgénica CRISPR: primer cultivo comercial del mundo sin regulación previa**. \n\nEste año, Paraguay comenzó a sembrar más de 250.000 hectáreas de soja modificada con la tecnología CRISPR-Cas9. ¿Por qué es especial esto? Porque Paraguay la autorizó sin considerarla un organismo modificado genéticamente (OMG), tal como lo hacen Argentina y Brasil. La razón es que esta soja —llamada *HRR2*— no tiene ADN de otras especies. En lugar de eso, se usó CRISPR, una herramienta molecular que edita el propio ADN de la planta, imitando mutaciones que podrían ocurrir naturalmente. \n\nLa empresa responsable es *Bioceres Holdings*, con sede en Argentina pero con operaciones clave en Paraguay. El Ministerio de Agricultura y Ganadería (MAG) dio su autorización en abril de 2024, sin pasar por el proceso habitual de evaluación ambiental ni por la Comisión Nacional de Biotecnología (CONABIO). Según el MAG, esta variedad ya se está cultivando en 6,8% del total de soja paraguaya —que en la última campaña llegó a 3,7 millones de hectáreas—. \n\n¿Qué ventajas tiene? Según datos oficiales, esta soja rinde 12% más que la convencional: supera los 3.400 kilogramos por hectárea. Además, usa un 20% menos de fertilizantes nitrogenados, lo cual es bueno para el suelo y para reducir emisiones de gases de efecto invernadero. Ramón Ríos, presidente de la Asociación Paraguaya de Soja, dijo que esta tecnología “permite adaptarse al cambio climático sin depender de importadores de semillas patentadas”. \n\nParaguay ya está exportando esta semilla a Colombia, Brasil y Perú, bajo licencias no exclusivas. Además, hay otros cultivos en desarrollo: arroz resistente a sequía y maíz biofortificado, ambos hechos con CRISPR por la Universidad Nacional de Asunción y el INTA de Argentina. El Ministerio de Ciencia y Tecnología asegura que Paraguay podría convertirse en un *hub* de biotecnología agrícola para América Latina. Incluso empresas globales como Corteva Agriscience y Bayer han mostrado interés en instalar laboratorios de evaluación en el país. \n\nSin embargo, la decisión no está libre de críticas. La Unión Europea advirtió que, al no exigir regulaciones estrictas, algunos países “diluyen los estándares de bioseguridad”. Greenpeace pidió al gobierno paraguayo que “revierta esta política antes de que se extienda a cultivos alimentarios para humanos”. También surgen preguntas éticas y ecológicas: ¿qué pasa si una planta CRISPR se cruza con una variedad silvestre? ¿Cómo controlar su diseminación si no hay etiquetado obligatorio? \n\nEl MAG responde que, según la ley paraguaya, los productos no regulados como OMG **no requieren etiquetado**, como ocurre en Estados Unidos, Japón y Argentina. Pero para tranquilizar a compradores, ha implementado un sistema voluntario de trazabilidad usando blockchain, desarrollado por la startup local *AgriTrace*. \n\nAun así, hay desafíos importantes. Los pequeños agricultores no tienen acceso fácil a estas semillas mejoradas, porque son más costosas y requieren acuerdos de licencia. También existe preocupación por la depLine truncated
|
||||
links:
|
||||
"<a href=https://www.abc.com.py/edicion-digital/agricultura/paraguay-lidera-en-america-latina-la-siembra-de-soja-crispr-sin-regulacion-de-omg-1591002.html>ABC Color: Paraguay lidera en América Latina la siembra de soja CRISPR sin regulación de OMG</a><br>\n<a href=https://hojaelectronica.com.py/mag-autoriza-250-mil-ha-de-soja-crispr-omg-o-no/>HojaElectronica: MAG autoriza 250 mil ha de soja CRISPR: ¿OMG o no?</a><br>\n<a href=https://www.clarin.com/field/agricultura/argentina-paraguay-puerta-cultivos-crispr-sin-regulacion-omg_0_1584721221.html>Clarín: Argentina y Paraguay abren la puerta a los cultivos CRISPR sin regulación de OMG</a><br>\n<a href=https://www.reuters.com/world/americas/paraguay-becomes-first-country-commercially-plant-crispr-soybean-without-gmo-rules-2024-06-05/>Reuters: Paraguay becomes first country to commercially plant CRISPR soybean without GMO rules</a><br>\n<a href=https://www.ultimahora.com/escasez-de-insulina-y-farmacos-oncologicos-ponen-en-alerta-al-sistema-de-salud-paraguayo-n557102.html>Última Hora: Escasez de insulina y fármacos oncológicos pone en alerta al sistema de salud paraguayo</a><br>\n<a href=https://www.mspbs.gov.py/boletin-medicamentos-mayo-2024/>MSPBS: Boletín de Disponibilidad de Medicamentos – Mayo 2024</a><br>\n<a href=https://www.lanacion.com.ar/mundo/mercosur-busca-mecanismo-conjunto-para-biofarmacos-tras-crisis-en-paraguay-y-uruguay-nid28062024/>La Nación (Chile): MERCOSUR busca mecanismo conjunto para biofármacos tras crisis en Paraguay y Uruguay</a><br>\n<a href=https://www.bloomberg.com/news/articles/2024-06-03/paraguay-s-drug-shortages-signal-deeper-gaps-in-health-infrastructure>Bloomberg: Paraguay’s Drug Shortages Signal Deeper Gaps in Health Infrastructure</a><br>",
|
||||
date: "2026-09-25",
|
||||
id: 2,
|
||||
},
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
DOM references
|
||||
--------------------------------------------------------- */
|
||||
const el = {
|
||||
list: document.getElementById("list"),
|
||||
state: document.getElementById("state"),
|
||||
count: document.getElementById("count"),
|
||||
search: document.getElementById("search"),
|
||||
notice: document.getElementById("notice"),
|
||||
modal: document.getElementById("modal"),
|
||||
modalDate: document.getElementById("modalDate"),
|
||||
modalTitle: document.getElementById("modalTitle"),
|
||||
modalBody: document.getElementById("modalBody"),
|
||||
modalDownload: document.getElementById("modalDownload"),
|
||||
langButtons: Array.from(document.querySelectorAll(".lang-switch button")),
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
State
|
||||
--------------------------------------------------------- */
|
||||
let lang = "en";
|
||||
let episodes = [];
|
||||
let usingDemoData = false;
|
||||
let currentEpisode = null;
|
||||
let lastFocused = null;
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Helpers
|
||||
--------------------------------------------------------- */
|
||||
function t(key) {
|
||||
const dict = I18N[lang] || I18N.en;
|
||||
return dict[key] || I18N.en[key] || key;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, function (c) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr + "T00:00:00");
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
try {
|
||||
return d.toLocaleDateString(LOCALES[lang] || "en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch (e) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
// Renders the light markdown used by the backend (**bold**, *italic*, blank-line paragraphs)
|
||||
function renderRichText(text) {
|
||||
if (!text) return "";
|
||||
const escaped = escapeHtml(text);
|
||||
const withBold = escaped.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
const withItalic = withBold.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
||||
return withItalic
|
||||
.split(/\n{2,}/)
|
||||
.map(function (p) {
|
||||
return "<p>" + p.replace(/\n/g, "<br>") + "</p>";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(function () {
|
||||
controller.abort();
|
||||
}, CONFIG.requestTimeout);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// The list endpoint returns [{ data: [ ...episodes ] }]
|
||||
function normalizeList(payload) {
|
||||
if (!payload) return [];
|
||||
const arr = Array.isArray(payload) ? payload : [payload];
|
||||
const out = [];
|
||||
arr.forEach(function (item) {
|
||||
if (item && Array.isArray(item.data)) out.push.apply(out, item.data);
|
||||
else if (item && typeof item === "object") out.push(item);
|
||||
});
|
||||
return out.filter(function (e) {
|
||||
return e && (e.date || e.topic);
|
||||
});
|
||||
}
|
||||
|
||||
// The detail endpoint returns [{ podcast, topic, text, links, date, ... }]
|
||||
function normalizeDetail(payload) {
|
||||
if (!payload) return null;
|
||||
const arr = Array.isArray(payload) ? payload : [payload];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item = arr[i];
|
||||
if (item && Array.isArray(item.data) && item.data.length) return item.data[0];
|
||||
if (item && typeof item === "object" && (item.text || item.topic)) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detailUrl(date) {
|
||||
return CONFIG.detailUrl + "?" + CONFIG.dateParam + "=" + encodeURIComponent(date);
|
||||
}
|
||||
|
||||
function audioUrl(date) {
|
||||
return CONFIG.audioUrl + "?" + CONFIG.dateParam + "=" + encodeURIComponent(date);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
i18n application
|
||||
--------------------------------------------------------- */
|
||||
function applyI18n() {
|
||||
document.documentElement.lang = lang;
|
||||
document.title = t("meta.title");
|
||||
|
||||
document.querySelectorAll("[data-i18n]").forEach(function (node) {
|
||||
node.textContent = t(node.getAttribute("data-i18n"));
|
||||
});
|
||||
document.querySelectorAll("[data-i18n-placeholder]").forEach(function (node) {
|
||||
node.setAttribute("placeholder", t(node.getAttribute("data-i18n-placeholder")));
|
||||
});
|
||||
|
||||
el.langButtons.forEach(function (btn) {
|
||||
btn.classList.toggle("is-active", btn.getAttribute("data-lang") === lang);
|
||||
});
|
||||
}
|
||||
|
||||
function setLang(next) {
|
||||
if (!I18N[next] || next === lang) return;
|
||||
lang = next;
|
||||
try {
|
||||
localStorage.setItem("lp-lang", lang);
|
||||
} catch (e) {
|
||||
/* storage unavailable — ignore */
|
||||
}
|
||||
applyI18n();
|
||||
renderList();
|
||||
if (currentEpisode) renderModal(currentEpisode);
|
||||
}
|
||||
|
||||
function detectLang() {
|
||||
let saved = null;
|
||||
try {
|
||||
saved = localStorage.getItem("lp-lang");
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
if (saved && I18N[saved]) return saved;
|
||||
const nav = (navigator.language || "en").slice(0, 2).toLowerCase();
|
||||
return I18N[nav] ? nav : "en";
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Archive list
|
||||
--------------------------------------------------------- */
|
||||
function setState(message, isError) {
|
||||
if (!message) {
|
||||
el.state.hidden = true;
|
||||
el.state.textContent = "";
|
||||
el.state.classList.remove("state--error");
|
||||
return;
|
||||
}
|
||||
el.state.hidden = false;
|
||||
el.state.textContent = message;
|
||||
el.state.classList.toggle("state--error", !!isError);
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const query = (el.search.value || "").trim().toLowerCase();
|
||||
|
||||
const filtered = episodes
|
||||
.slice()
|
||||
.sort(function (a, b) {
|
||||
return String(b.date || "").localeCompare(String(a.date || ""));
|
||||
})
|
||||
.filter(function (ep) {
|
||||
if (!query) return true;
|
||||
const haystack = [ep.topic, ep.text_intro, ep.date]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.indexOf(query) !== -1;
|
||||
});
|
||||
|
||||
el.count.textContent =
|
||||
filtered.length === 1
|
||||
? t("archive.countOne")
|
||||
: t("archive.count").replace("{n}", String(filtered.length));
|
||||
|
||||
if (!filtered.length) {
|
||||
el.list.innerHTML = "";
|
||||
setState(t("archive.empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
setState("");
|
||||
el.list.innerHTML = filtered
|
||||
.map(function (ep) {
|
||||
const date = escapeHtml(ep.date || "");
|
||||
const topic = escapeHtml((ep.topic || "").trim());
|
||||
const intro = escapeHtml((ep.text_intro || "").trim());
|
||||
return (
|
||||
'<button type="button" class="card" data-date="' +
|
||||
date +
|
||||
'">' +
|
||||
'<span class="card__date">' +
|
||||
escapeHtml(formatDate(ep.date)) +
|
||||
"</span>" +
|
||||
'<span class="card__topic">' +
|
||||
topic +
|
||||
"</span>" +
|
||||
(intro ? '<span class="card__intro">' + intro + "</span>" : "") +
|
||||
'<span class="card__cta">' +
|
||||
escapeHtml(t("card.open")) +
|
||||
'<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M13 6l6 6-6 6"/></svg>' +
|
||||
"</span>" +
|
||||
"</button>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function loadEpisodes() {
|
||||
setState(t("archive.loading"));
|
||||
el.count.textContent = "";
|
||||
try {
|
||||
const payload = await fetchJson(CONFIG.listUrl);
|
||||
const list = normalizeList(payload);
|
||||
if (!list.length) throw new Error("empty");
|
||||
episodes = list;
|
||||
usingDemoData = false;
|
||||
el.notice.hidden = true;
|
||||
} catch (e) {
|
||||
episodes = normalizeList(SAMPLE_LIST);
|
||||
usingDemoData = true;
|
||||
el.notice.textContent = t("archive.demo");
|
||||
el.notice.hidden = false;
|
||||
}
|
||||
renderList();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Modal
|
||||
--------------------------------------------------------- */
|
||||
function section(title, innerHtml) {
|
||||
return (
|
||||
'<div class="modal__section"><h4>' +
|
||||
escapeHtml(title) +
|
||||
"</h4>" +
|
||||
innerHtml +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
function renderModal(ep) {
|
||||
currentEpisode = ep;
|
||||
|
||||
el.modalDate.textContent = formatDate(ep.date);
|
||||
el.modalTitle.textContent = (ep.topic || "").trim();
|
||||
|
||||
const parts = [];
|
||||
if (ep.text) {
|
||||
parts.push(section(t("modal.transcript"), '<div class="transcript">' + renderRichText(ep.text) + "</div>"));
|
||||
}
|
||||
if (ep.links) {
|
||||
parts.push(section(t("modal.sources"), '<div class="sources">' + ep.links + "</div>"));
|
||||
}
|
||||
el.modalBody.innerHTML = parts.join("") || '<div class="state">' + escapeHtml(t("modal.error")) + "</div>";
|
||||
|
||||
// Harden the backend-provided source links
|
||||
el.modalBody.querySelectorAll(".sources a").forEach(function (a) {
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
el.modalDownload.href = audioUrl(ep.date);
|
||||
el.modalDownload.setAttribute("download", "learn-and-prep-" + (ep.date || "episode") + ".mp3");
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
lastFocused = document.activeElement;
|
||||
el.modal.hidden = false;
|
||||
document.body.style.overflow = "hidden";
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
const closeBtn = el.modal.querySelector(".modal__close");
|
||||
if (closeBtn) closeBtn.focus();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
el.modal.hidden = true;
|
||||
document.body.style.overflow = "";
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
currentEpisode = null;
|
||||
if (lastFocused && typeof lastFocused.focus === "function") lastFocused.focus();
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === "Escape") closeModal();
|
||||
}
|
||||
|
||||
async function openEpisode(date) {
|
||||
openModal();
|
||||
el.modalDate.textContent = formatDate(date);
|
||||
el.modalTitle.textContent = "";
|
||||
el.modalBody.innerHTML = '<div class="modal__loading">' + escapeHtml(t("modal.loading")) + "</div>";
|
||||
el.modalDownload.href = audioUrl(date);
|
||||
|
||||
try {
|
||||
const payload = await fetchJson(detailUrl(date));
|
||||
const ep = normalizeDetail(payload);
|
||||
if (!ep) throw new Error("empty");
|
||||
renderModal(ep);
|
||||
} catch (e) {
|
||||
const sample = SAMPLE_DETAIL.filter(function (d) {
|
||||
return d.date === date;
|
||||
})[0];
|
||||
if (sample) {
|
||||
renderModal(sample);
|
||||
} else {
|
||||
el.modalBody.innerHTML = '<div class="state state--error">' + escapeHtml(t("modal.error")) + "</div>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Events
|
||||
--------------------------------------------------------- */
|
||||
function bindEvents() {
|
||||
el.langButtons.forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
setLang(btn.getAttribute("data-lang"));
|
||||
});
|
||||
});
|
||||
|
||||
el.search.addEventListener("input", renderList);
|
||||
|
||||
el.list.addEventListener("click", function (e) {
|
||||
const card = e.target.closest(".card");
|
||||
if (card) openEpisode(card.getAttribute("data-date"));
|
||||
});
|
||||
|
||||
el.modal.addEventListener("click", function (e) {
|
||||
if (e.target.closest("[data-close]")) closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Init
|
||||
--------------------------------------------------------- */
|
||||
function init() {
|
||||
lang = detectLang();
|
||||
applyI18n();
|
||||
|
||||
const cta = document.getElementById("ctaTelegram");
|
||||
if (cta) cta.href = CONFIG.telegramUrl;
|
||||
|
||||
bindEvents();
|
||||
loadEpisodes();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
# Simple static file server with live reload
|
||||
# Requires Node.js and live-server
|
||||
|
||||
# Check if live-server is installed
|
||||
if ! command -v live-server &> /dev/null
|
||||
then
|
||||
echo "Installing live-server..."
|
||||
npm install -g live-server
|
||||
fi
|
||||
|
||||
# Serve the current directory with auto-reload
|
||||
echo "Starting live-server on http://localhost:8080 ..."
|
||||
live-server .
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user