|
|
|
@@ -0,0 +1,405 @@
|
|
|
|
|
import streamlit as st
|
|
|
|
|
import streamlit.components.v1 as components
|
|
|
|
|
import requests
|
|
|
|
|
import os
|
|
|
|
|
from collections import defaultdict
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
st.set_page_config(page_title="Crunchyroll Watchlist", page_icon="🍊", layout="wide")
|
|
|
|
|
|
|
|
|
|
st.markdown("""
|
|
|
|
|
<style>
|
|
|
|
|
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:wght@300;400;500&display=swap');
|
|
|
|
|
html, body, [class*="css"] { font-family: 'DM Sans', sans-serif; background-color: #0d0d0d; color: #f0ede8; }
|
|
|
|
|
#MainMenu, footer, header { visibility: hidden; }
|
|
|
|
|
.block-container { padding: 2rem 2.5rem 4rem; max-width: 1400px; }
|
|
|
|
|
.hero { display: flex; align-items: flex-end; gap: 1.2rem; margin-bottom: 2.5rem; border-bottom: 1px solid #2a2a2a; padding-bottom: 1.5rem; }
|
|
|
|
|
.hero-logo { font-family: 'Bebas Neue', sans-serif; font-size: 3.2rem; letter-spacing: 0.04em; color: #f47521; line-height: 1; }
|
|
|
|
|
.hero-sub { font-size: 0.85rem; color: #666; font-weight: 300; padding-bottom: 0.4rem; }
|
|
|
|
|
.cat-header { display: flex; align-items: center; gap: 0.75rem; margin: 2rem 0 0.5rem; }
|
|
|
|
|
.cat-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.4rem; letter-spacing: 0.06em; color: #f47521; }
|
|
|
|
|
.cat-count { background: #1e1e1e; border: 1px solid #2e2e2e; color: #888; font-size: 0.7rem; font-weight: 500; padding: 0.15rem 0.55rem; border-radius: 20px; }
|
|
|
|
|
.cat-line { flex: 1; height: 1px; background: #1e1e1e; }
|
|
|
|
|
.stTextInput input { background: #141414 !important; border: 1px solid #2a2a2a !important; color: #f0ede8 !important; border-radius: 6px !important; }
|
|
|
|
|
.stTextInput input:focus { border-color: #f47521 !important; box-shadow: 0 0 0 2px rgba(244,117,33,0.15) !important; }
|
|
|
|
|
.stButton > button { background: #f47521 !important; color: #0d0d0d !important; font-family: 'Bebas Neue', sans-serif !important; font-size: 1rem !important; letter-spacing: 0.08em !important; border: none !important; border-radius: 6px !important; padding: 0.45rem 1.8rem !important; }
|
|
|
|
|
.stButton > button:hover { opacity: 0.85 !important; }
|
|
|
|
|
.cache-pill { display: inline-flex; align-items: center; gap: 0.4rem; background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 20px; padding: 0.25rem 0.75rem; font-size: 0.72rem; color: #555; margin-bottom: 1.5rem; }
|
|
|
|
|
.cache-dot { width: 6px; height: 6px; border-radius: 50%; background: #3a3; display: inline-block; }
|
|
|
|
|
.stats-row { display: flex; gap: 1rem; margin-bottom: 2rem; flex-wrap: wrap; }
|
|
|
|
|
.stat-box { background: #141414; border: 1px solid #222; border-radius: 8px; padding: 0.75rem 1.25rem; min-width: 120px; text-align: center; }
|
|
|
|
|
.stat-num { font-family: 'Bebas Neue', sans-serif; font-size: 2rem; color: #f47521; line-height: 1; }
|
|
|
|
|
.stat-label { font-size: 0.68rem; color: #555; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 0.2rem; }
|
|
|
|
|
</style>
|
|
|
|
|
""", unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
# ── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
|
AUTH_URL = "https://www.crunchyroll.com"
|
|
|
|
|
API_URL = "https://beta-api.crunchyroll.com"
|
|
|
|
|
CR_CLIENT_ID = "t-kdgp2h8c3jub8fn0fq"
|
|
|
|
|
CR_CLIENT_SECRET = "yfLDfMfrYvKXh4JXS1LEI2cCqu1v5Wan"
|
|
|
|
|
HEADERS = {"User-Agent": "Crunchyroll/3.0.0 Android/5.1.1 okhttp/3.12.1"}
|
|
|
|
|
CATEGORY_ORDER = ["Continue", "Up Next", "Start Watching", "Watch Again"]
|
|
|
|
|
CATEGORY_ICONS = {
|
|
|
|
|
"Continue": "▶",
|
|
|
|
|
"Up Next": "⏭",
|
|
|
|
|
"Start Watching":"✦",
|
|
|
|
|
"Watch Again": "↺",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ── iframe HTML template ─────────────────────────────────────────────────────
|
|
|
|
|
# Built as a plain string (no f-string) to avoid brace-escaping headaches.
|
|
|
|
|
IFRAME_TEMPLATE = """\
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
|
|
|
|
|
<style>
|
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
|
body { background: transparent; font-family: 'DM Sans', sans-serif; overflow: hidden; }
|
|
|
|
|
|
|
|
|
|
.outer { position: relative; }
|
|
|
|
|
|
|
|
|
|
/* soft edge fades to hint there is more content */
|
|
|
|
|
.outer::before, .outer::after {
|
|
|
|
|
content: '';
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0; bottom: 16px;
|
|
|
|
|
width: 56px;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
z-index: 2;
|
|
|
|
|
}
|
|
|
|
|
.outer::before { left: 0; background: linear-gradient(to right, #0d0d0d, transparent); }
|
|
|
|
|
.outer::after { right: 0; background: linear-gradient(to left, #0d0d0d, transparent); }
|
|
|
|
|
|
|
|
|
|
.row {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
padding: 4px 2px 16px;
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
scroll-snap-type: x mandatory;
|
|
|
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
|
scroll-behavior: smooth;
|
|
|
|
|
}
|
|
|
|
|
.row::-webkit-scrollbar { height: 4px; }
|
|
|
|
|
.row::-webkit-scrollbar-track { background: #1a1a1a; border-radius: 2px; }
|
|
|
|
|
.row::-webkit-scrollbar-thumb { background: #f47521; border-radius: 2px; }
|
|
|
|
|
|
|
|
|
|
/* prev / next arrow buttons */
|
|
|
|
|
.arrow {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 50%;
|
|
|
|
|
transform: translateY(calc(-50% - 8px));
|
|
|
|
|
z-index: 3;
|
|
|
|
|
width: 36px; height: 36px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
background: rgba(20,20,20,0.92);
|
|
|
|
|
border: 1px solid #3a3a3a;
|
|
|
|
|
color: #f0ede8;
|
|
|
|
|
font-size: 1.1rem;
|
|
|
|
|
line-height: 1;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
display: flex; align-items: center; justify-content: center;
|
|
|
|
|
transition: background 0.15s, border-color 0.15s, opacity 0.2s;
|
|
|
|
|
opacity: 0;
|
|
|
|
|
user-select: none;
|
|
|
|
|
}
|
|
|
|
|
.outer:hover .arrow { opacity: 0.75; }
|
|
|
|
|
.arrow:hover { background: #f47521 !important; border-color: #f47521 !important; color: #0d0d0d !important; opacity: 1 !important; }
|
|
|
|
|
.arrow.prev { left: 6px; }
|
|
|
|
|
.arrow.next { right: 6px; }
|
|
|
|
|
.arrow.hidden { opacity: 0 !important; pointer-events: none; }
|
|
|
|
|
|
|
|
|
|
/* cards */
|
|
|
|
|
.card {
|
|
|
|
|
flex: 0 0 240px;
|
|
|
|
|
background: #141414;
|
|
|
|
|
border: 1px solid #2a2a2a;
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
scroll-snap-align: start;
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
display: block;
|
|
|
|
|
transition: border-color 0.15s, transform 0.15s;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
.card:hover { border-color: #f47521; transform: translateY(-2px); }
|
|
|
|
|
.thumb-wrap { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #1e1e1e; overflow: hidden; }
|
|
|
|
|
.thumb-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
|
|
|
.badge {
|
|
|
|
|
position: absolute; top: 7px; right: 7px;
|
|
|
|
|
background: rgba(0,0,0,0.78); border: 1px solid rgba(255,255,255,0.15);
|
|
|
|
|
color: #ccc; font-size: 0.6rem; font-weight: 600;
|
|
|
|
|
padding: 2px 6px; border-radius: 4px; letter-spacing: 0.05em; text-transform: uppercase;
|
|
|
|
|
}
|
|
|
|
|
.prog-track { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: rgba(255,255,255,0.1); }
|
|
|
|
|
.prog-fill { height: 100%; background: #f47521; border-radius: 0 2px 2px 0; }
|
|
|
|
|
.info { padding: 10px 12px 12px; }
|
|
|
|
|
.series {
|
|
|
|
|
font-size: 0.7rem; color: #f47521; font-weight: 500;
|
|
|
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
|
|
|
margin-bottom: 3px; letter-spacing: 0.02em;
|
|
|
|
|
}
|
|
|
|
|
.ep-title {
|
|
|
|
|
font-size: 0.85rem; font-weight: 500; color: #f0ede8;
|
|
|
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
}
|
|
|
|
|
.meta { font-size: 0.67rem; color: #666; font-weight: 300; }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div class="outer">
|
|
|
|
|
<button class="arrow prev hidden" id="prev">‹</button>
|
|
|
|
|
<div class="row" id="row">
|
|
|
|
|
CARDS_PLACEHOLDER
|
|
|
|
|
</div>
|
|
|
|
|
<button class="arrow next" id="next">›</button>
|
|
|
|
|
</div>
|
|
|
|
|
<script>
|
|
|
|
|
(function() {
|
|
|
|
|
var row = document.getElementById('row');
|
|
|
|
|
var prev = document.getElementById('prev');
|
|
|
|
|
var next = document.getElementById('next');
|
|
|
|
|
var STEP = 252 * 3;
|
|
|
|
|
|
|
|
|
|
function update() {
|
|
|
|
|
var atStart = row.scrollLeft <= 4;
|
|
|
|
|
var atEnd = row.scrollLeft + row.clientWidth >= row.scrollWidth - 4;
|
|
|
|
|
prev.classList.toggle('hidden', atStart);
|
|
|
|
|
next.classList.toggle('hidden', atEnd);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
prev.addEventListener('click', function() { row.scrollBy({ left: -STEP, behavior: 'smooth' }); });
|
|
|
|
|
next.addEventListener('click', function() { row.scrollBy({ left: STEP, behavior: 'smooth' }); });
|
|
|
|
|
row.addEventListener('scroll', update, { passive: true });
|
|
|
|
|
window.addEventListener('resize', update);
|
|
|
|
|
update();
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
# ── API helpers (cached) ─────────────────────────────────────────────────────
|
|
|
|
|
@st.cache_data(ttl=300, show_spinner=False)
|
|
|
|
|
def fetch_token(email, password):
|
|
|
|
|
r = requests.post(
|
|
|
|
|
AUTH_URL + "/auth/v1/token",
|
|
|
|
|
data={"username": email, "password": password, "grant_type": "password", "scope": "offline_access"},
|
|
|
|
|
headers=HEADERS, auth=(CR_CLIENT_ID, CR_CLIENT_SECRET), timeout=10,
|
|
|
|
|
)
|
|
|
|
|
r.raise_for_status()
|
|
|
|
|
return r.json()["access_token"]
|
|
|
|
|
|
|
|
|
|
@st.cache_data(ttl=300, show_spinner=False)
|
|
|
|
|
def fetch_account_id(token):
|
|
|
|
|
r = requests.get(API_URL + "/accounts/v1/me",
|
|
|
|
|
headers={**HEADERS, "Authorization": "Bearer " + token}, timeout=10)
|
|
|
|
|
r.raise_for_status()
|
|
|
|
|
return r.json()["account_id"]
|
|
|
|
|
|
|
|
|
|
@st.cache_data(ttl=300, show_spinner=False)
|
|
|
|
|
def fetch_watchlist(token, account_id, n=200):
|
|
|
|
|
r = requests.get(
|
|
|
|
|
API_URL + "/content/v2/discover/" + account_id + "/watchlist",
|
|
|
|
|
params={"locale": "en-US", "n": n},
|
|
|
|
|
headers={**HEADERS, "Authorization": "Bearer " + token}, timeout=10,
|
|
|
|
|
)
|
|
|
|
|
r.raise_for_status()
|
|
|
|
|
return r.json().get("data", [])
|
|
|
|
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
def categorise(item):
|
|
|
|
|
if item.get("never_watched"): return "Start Watching"
|
|
|
|
|
if item.get("fully_watched"): return "Watch Again"
|
|
|
|
|
if item.get("playhead", 0) > 0: return "Continue"
|
|
|
|
|
return "Up Next"
|
|
|
|
|
|
|
|
|
|
def get_thumbnail(panel):
|
|
|
|
|
try:
|
|
|
|
|
sizes = panel["images"]["thumbnail"][0]
|
|
|
|
|
for s in sizes:
|
|
|
|
|
if s.get("width") == 640:
|
|
|
|
|
return s["source"]
|
|
|
|
|
return sizes[-1]["source"]
|
|
|
|
|
except (KeyError, IndexError):
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
def esc(s):
|
|
|
|
|
return str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
|
|
|
|
|
|
|
|
def build_card(item):
|
|
|
|
|
panel = item.get("panel", {})
|
|
|
|
|
meta = panel.get("episode_metadata", {})
|
|
|
|
|
series = meta.get("series_title", panel.get("title", "Unknown"))
|
|
|
|
|
ep_num = meta.get("episode_number", "")
|
|
|
|
|
season = meta.get("season_number", "")
|
|
|
|
|
ep_title = panel.get("title", "")
|
|
|
|
|
duration = meta.get("duration_ms", 0) // 1000
|
|
|
|
|
playhead = item.get("playhead", 0)
|
|
|
|
|
thumb = get_thumbnail(panel)
|
|
|
|
|
ep_label = "S{}E{}".format(season, ep_num) if season and ep_num else ""
|
|
|
|
|
|
|
|
|
|
ep_id = panel.get("id", "")
|
|
|
|
|
ep_slug = panel.get("slug_title", "")
|
|
|
|
|
url = "https://www.crunchyroll.com/watch/{}/{}".format(ep_id, ep_slug) if ep_id else "#"
|
|
|
|
|
|
|
|
|
|
prog = ""
|
|
|
|
|
if playhead > 0 and not item.get("fully_watched") and duration:
|
|
|
|
|
pct = min(int(playhead / duration * 100), 100)
|
|
|
|
|
prog = '<div class="prog-track"><div class="prog-fill" style="width:{}%"></div></div>'.format(pct)
|
|
|
|
|
|
|
|
|
|
img = '<img src="{}" alt="">'.format(esc(thumb)) if thumb else '<div style="width:100%;height:100%;background:#1e1e1e;"></div>'
|
|
|
|
|
badge = '<span class="badge">{}</span>'.format(esc(ep_label)) if ep_label else ""
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
'<a class="card" href="{}" target="_blank" rel="noopener noreferrer">'
|
|
|
|
|
'<div class="thumb-wrap">{}{}{}</div>'
|
|
|
|
|
'<div class="info">'
|
|
|
|
|
'<div class="series">{}</div>'
|
|
|
|
|
'<div class="ep-title">{}</div>'
|
|
|
|
|
'<div class="meta">{}</div>'
|
|
|
|
|
'</div>'
|
|
|
|
|
'</a>'
|
|
|
|
|
).format(esc(url), img, badge, prog, esc(series), esc(ep_title), esc(ep_label))
|
|
|
|
|
|
|
|
|
|
def render_row(entries):
|
|
|
|
|
cards_html = "".join(build_card(i) for i in entries)
|
|
|
|
|
html = IFRAME_TEMPLATE.replace("CARDS_PLACEHOLDER", cards_html)
|
|
|
|
|
components.html(html, height=255, scrolling=False)
|
|
|
|
|
|
|
|
|
|
# ── Session state ────────────────────────────────────────────────────────────
|
|
|
|
|
for k in ("logged_in", "token", "account_id", "watchlist", "fetched_at"):
|
|
|
|
|
st.session_state.setdefault(k, None)
|
|
|
|
|
st.session_state.setdefault("logged_in", False)
|
|
|
|
|
|
|
|
|
|
# ── Auto-login ───────────────────────────────────────────────────────────────
|
|
|
|
|
if not st.session_state.logged_in:
|
|
|
|
|
env_email = os.environ.get("CR_EMAIL")
|
|
|
|
|
env_pass = os.environ.get("CR_PASSWORD")
|
|
|
|
|
if env_email and env_pass:
|
|
|
|
|
try:
|
|
|
|
|
tok = fetch_token(env_email, env_pass)
|
|
|
|
|
aid = fetch_account_id(tok)
|
|
|
|
|
wl = fetch_watchlist(tok, aid)
|
|
|
|
|
st.session_state.update(
|
|
|
|
|
logged_in=True, token=tok, account_id=aid,
|
|
|
|
|
watchlist=wl, fetched_at=datetime.now()
|
|
|
|
|
)
|
|
|
|
|
st.rerun()
|
|
|
|
|
except Exception:
|
|
|
|
|
# Fallback to manual login if auto-login fails
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# ── Header ───────────────────────────────────────────────────────────────────
|
|
|
|
|
st.markdown("""
|
|
|
|
|
<div class="hero">
|
|
|
|
|
<div class="hero-logo">🍊 Watchlist</div>
|
|
|
|
|
<div class="hero-sub">Crunchyroll — personal queue dashboard</div>
|
|
|
|
|
</div>
|
|
|
|
|
""", unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
# ── Login ────────────────────────────────────────────────────────────────────
|
|
|
|
|
if not st.session_state.logged_in:
|
|
|
|
|
_, mid, _ = st.columns([1, 1.2, 1])
|
|
|
|
|
with mid:
|
|
|
|
|
st.markdown("#### Sign in to Crunchyroll")
|
|
|
|
|
email = st.text_input("Email", key="email_input")
|
|
|
|
|
password = st.text_input("Password", key="pass_input", type="password")
|
|
|
|
|
if st.button("Load Watchlist"):
|
|
|
|
|
if not email or not password:
|
|
|
|
|
st.error("Please enter your email and password.")
|
|
|
|
|
else:
|
|
|
|
|
with st.spinner("Signing in..."):
|
|
|
|
|
try:
|
|
|
|
|
tok = fetch_token(email, password)
|
|
|
|
|
aid = fetch_account_id(tok)
|
|
|
|
|
wl = fetch_watchlist(tok, aid)
|
|
|
|
|
st.session_state.update(
|
|
|
|
|
logged_in=True, token=tok, account_id=aid,
|
|
|
|
|
watchlist=wl, fetched_at=datetime.now()
|
|
|
|
|
)
|
|
|
|
|
st.rerun()
|
|
|
|
|
except requests.HTTPError as e:
|
|
|
|
|
st.error("Login failed: {}".format(e))
|
|
|
|
|
except Exception as e:
|
|
|
|
|
st.error("Error: {}".format(e))
|
|
|
|
|
|
|
|
|
|
# ── Dashboard ────────────────────────────────────────────────────────────────
|
|
|
|
|
else:
|
|
|
|
|
watchlist = st.session_state.watchlist
|
|
|
|
|
fetched_at = st.session_state.fetched_at
|
|
|
|
|
|
|
|
|
|
# Toolbar
|
|
|
|
|
c1, c2 = st.columns([6, 1])
|
|
|
|
|
with c1:
|
|
|
|
|
age = int((datetime.now() - fetched_at).total_seconds()) if fetched_at else 0
|
|
|
|
|
astr = "{}s ago".format(age) if age < 60 else "{}m ago".format(age // 60)
|
|
|
|
|
st.markdown(
|
|
|
|
|
'<div class="cache-pill"><span class="cache-dot"></span>'
|
|
|
|
|
'Cached · fetched {} · refreshes every 5 min</div>'.format(astr),
|
|
|
|
|
unsafe_allow_html=True,
|
|
|
|
|
)
|
|
|
|
|
with c2:
|
|
|
|
|
if st.button("Refresh"):
|
|
|
|
|
fetch_token.clear(); fetch_account_id.clear(); fetch_watchlist.clear()
|
|
|
|
|
with st.spinner("Refreshing..."):
|
|
|
|
|
try:
|
|
|
|
|
tok = fetch_token(
|
|
|
|
|
st.session_state.get("email_input", ""),
|
|
|
|
|
st.session_state.get("pass_input", ""),
|
|
|
|
|
)
|
|
|
|
|
wl = fetch_watchlist(tok, st.session_state.account_id)
|
|
|
|
|
st.session_state.update(watchlist=wl, fetched_at=datetime.now())
|
|
|
|
|
st.rerun()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
st.error("Refresh failed: {}".format(e))
|
|
|
|
|
|
|
|
|
|
# Bucket items
|
|
|
|
|
buckets = defaultdict(list)
|
|
|
|
|
for item in watchlist:
|
|
|
|
|
buckets[categorise(item)].append(item)
|
|
|
|
|
|
|
|
|
|
# Stats
|
|
|
|
|
stat_boxes = ""
|
|
|
|
|
for c in CATEGORY_ORDER:
|
|
|
|
|
n = len(buckets.get(c, []))
|
|
|
|
|
stat_boxes += (
|
|
|
|
|
'<div class="stat-box">'
|
|
|
|
|
'<div class="stat-num">{}</div>'
|
|
|
|
|
'<div class="stat-label">{}</div>'
|
|
|
|
|
'</div>'
|
|
|
|
|
).format(n, c)
|
|
|
|
|
stat_boxes += (
|
|
|
|
|
'<div class="stat-box">'
|
|
|
|
|
'<div class="stat-num">{}</div>'
|
|
|
|
|
'<div class="stat-label">Total</div>'
|
|
|
|
|
'</div>'
|
|
|
|
|
).format(len(watchlist))
|
|
|
|
|
st.markdown('<div class="stats-row">{}</div>'.format(stat_boxes), unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
# Category rows
|
|
|
|
|
for cat in CATEGORY_ORDER:
|
|
|
|
|
entries = buckets.get(cat, [])
|
|
|
|
|
if not entries:
|
|
|
|
|
continue
|
|
|
|
|
icon = CATEGORY_ICONS[cat]
|
|
|
|
|
st.markdown(
|
|
|
|
|
'<div class="cat-header">'
|
|
|
|
|
'<span class="cat-title">{} {}</span>'
|
|
|
|
|
'<span class="cat-count">{}</span>'
|
|
|
|
|
'<div class="cat-line"></div>'
|
|
|
|
|
'</div>'.format(icon, cat, len(entries)),
|
|
|
|
|
unsafe_allow_html=True,
|
|
|
|
|
)
|
|
|
|
|
render_row(entries)
|
|
|
|
|
|
|
|
|
|
# Sign out
|
|
|
|
|
st.markdown("<br>", unsafe_allow_html=True)
|
|
|
|
|
if st.button("Sign out"):
|
|
|
|
|
for k in ("logged_in", "token", "account_id", "watchlist", "fetched_at"):
|
|
|
|
|
st.session_state[k] = None
|
|
|
|
|
st.session_state.logged_in = False
|
|
|
|
|
fetch_token.clear(); fetch_account_id.clear(); fetch_watchlist.clear()
|
|
|
|
|
st.rerun()
|