xet-shelf / app.py
Megan Riley
Real chunk-level dedup stats in receipt (1KB chunk hashing on actual parquet bytes)
ae4c4fd
Raw
History Blame Contribute Delete
33 kB
import gradio as gr
import pandas as pd
import io
import uuid
import random
import hashlib
from datetime import datetime, timezone
from huggingface_hub import hf_hub_download, HfApi
import os
DATASET_REPO = "meganariley/open-shelf"
WRITE_TOKEN = os.environ.get("HF_TOKEN", os.environ.get("HF_WRITE_TOKEN", ""))
GENRE_TAGS = ["Fiction","Non-Fiction","Mystery","Sci-Fi","Fantasy","Biography","History",
"Romance","Thriller","Literary","Essays","Poetry","Graphic Novel","Self-Help","Travel"]
MOOD_TAGS = ["Page-turner","Slow burn","Dense","Funny","Devastating","Uplifting",
"Unsettling","Cozy","Challenging","Breezy","Cerebral","Emotional"]
AVATAR_COLORS = ["#C1440E","#5C7A5F","#B8860B","#78716C","#44403C","#3182C8","#7B6EDE"]
# ── Data layer ────────────────────────────────────────────────────────────────
_cache: dict = {}
def load_books() -> pd.DataFrame:
try:
path = hf_hub_download(repo_id=DATASET_REPO, repo_type="dataset", filename="books.parquet")
df = pd.read_parquet(path)
rng = random.Random(42)
df["dedup"] = [rng.randint(60, 94) for _ in range(len(df))]
return df
except Exception as e:
print(f"books load failed: {e}")
return pd.DataFrame(columns=["isbn13","title","author","year_published","goodreads_shelf",
"community_rating_count","community_rating_sum",
"genre_tags","mood_tags","dedup"])
def load_contributions() -> pd.DataFrame:
try:
path = hf_hub_download(repo_id=DATASET_REPO, repo_type="dataset", filename="contributions.parquet")
return pd.read_parquet(path)
except Exception as e:
print(f"contributions load failed: {e}")
return pd.DataFrame(columns=["contribution_id","isbn13","hf_username","shelf",
"rating","genre_tags","mood_tags","contributed_at"])
def merge_contribs(books: pd.DataFrame, contribs: pd.DataFrame) -> pd.DataFrame:
if contribs.empty:
return books
rated = (contribs[contribs["rating"] > 0]
.groupby("isbn13")
.agg(rc=("rating","count"), rs=("rating","sum"))
.reset_index())
books = books.merge(rated, on="isbn13", how="left")
orig_rc = books.get("community_rating_count", pd.Series(0, index=books.index))
orig_rs = books.get("community_rating_sum", pd.Series(0, index=books.index))
books["community_rating_count"] = books["rc"].fillna(orig_rc).fillna(0).astype(int)
books["community_rating_sum"] = books["rs"].fillna(orig_rs).fillna(0).astype(int)
books.drop(columns=["rc","rs"], errors="ignore", inplace=True)
return books
def build_leaderboard(contribs: pd.DataFrame) -> list:
if contribs.empty:
return []
lb = (contribs.groupby("hf_username")
.agg(contributions=("contribution_id","count"), joined=("contributed_at","min"))
.reset_index()
.sort_values("contributions", ascending=False)
.head(50))
return lb.to_dict("records")
def get_data(refresh=False):
if refresh or "books" not in _cache:
books = load_books()
contribs = load_contributions()
_cache["books"] = merge_contribs(books, contribs)
_cache["contribs"] = contribs
_cache["leaders"] = build_leaderboard(contribs)
return _cache["books"], _cache["contribs"], _cache["leaders"]
CHUNK_SIZE = 1024 # 1 KB chunks; Xet uses 64 KB CDC at scale
def compute_dedup(old_bytes: bytes, new_bytes: bytes) -> dict:
def chunk_hashes(data):
return [hashlib.sha256(data[i:i+CHUNK_SIZE]).hexdigest()
for i in range(0, len(data), CHUNK_SIZE)]
old_set = set(chunk_hashes(old_bytes)) if old_bytes else set()
new_list = chunk_hashes(new_bytes)
shared = sum(1 for h in new_list if h in old_set)
total = len(new_list) or 1
dedup_pct = round(shared / total * 100)
total_kb = max(1, len(new_bytes) // 1024)
saved_kb = round(total_kb * dedup_pct / 100)
return {
"dedup_pct": dedup_pct,
"total_kb": total_kb,
"saved_kb": saved_kb,
"new_kb": max(1, total_kb - saved_kb),
"total_chunks": total,
"new_chunks": total - shared,
}
def append_contribution(contrib: dict) -> dict:
_, contribs, _ = get_data()
# Serialize old state for dedup comparison
old_buf = io.BytesIO()
contribs.to_parquet(old_buf, compression="snappy", index=False)
old_bytes = old_buf.getvalue()
# Build and serialize new state
updated = pd.concat([contribs, pd.DataFrame([contrib])], ignore_index=True)
new_buf = io.BytesIO()
updated.to_parquet(new_buf, compression="snappy", index=False)
new_bytes = new_buf.getvalue()
# Real chunk-level dedup stats
stats = compute_dedup(old_bytes, new_bytes)
HfApi(token=WRITE_TOKEN).upload_file(
path_or_fileobj=io.BytesIO(new_bytes),
path_in_repo="contributions.parquet",
repo_id=DATASET_REPO,
repo_type="dataset",
commit_message=f"Add contribution by {contrib['hf_username']}",
)
_cache.clear()
return stats
# ── HTML helpers ──────────────────────────────────────────────────────────────
def _tag(text, kind):
style = ("background:rgba(193,68,14,0.08);color:#C1440E" if kind == "mood"
else "background:rgba(92,122,95,0.1);color:#5C7A5F")
return (f'<span style="font-family:JetBrains Mono,monospace;font-size:0.62rem;'
f'letter-spacing:0.05em;text-transform:uppercase;padding:0.18rem 0.45rem;'
f'border-radius:2px;{style}">{text}</span>')
def _stars(n):
return '<span style="color:#B8860B">' + "".join("β˜…" if i < n else "β˜†" for i in range(5)) + "</span>"
# ── Page sections ─────────────────────────────────────────────────────────────
HEADER_HTML = """
<div style="border-bottom:2px solid #1C1917;padding:0 2.5rem;background:#FDFAF4">
<div style="max-width:1200px;margin:0 auto;display:flex;align-items:center;padding:1rem 0;gap:2rem">
<div style="border-right:1px solid #EAE3D2;padding-right:2rem">
<div style="font-family:'Playfair Display',serif;font-size:1.4rem;font-weight:900;letter-spacing:-0.02em;line-height:1">The Open Shelf</div>
<div style="font-family:JetBrains Mono,monospace;font-size:0.68rem;letter-spacing:0.14em;text-transform:uppercase;color:#78716C;margin-top:3px">Community Book Dataset</div>
</div>
<a href="https://huggingface.co/datasets/meganariley/open-shelf" target="_blank"
style="margin-left:auto;font-family:JetBrains Mono,monospace;font-size:0.72rem;color:#78716C;text-decoration:none">
huggingface.co/datasets/open-shelf β†’
</a>
</div>
</div>"""
FOOTER_HTML = f"""
<div style="border-top:1px solid #EAE3D2;padding:1.1rem 2.5rem;font-family:JetBrains Mono,monospace;font-size:0.68rem;color:#78716C;display:flex;justify-content:space-between;align-items:center;margin-top:2rem">
<span>The Open Shelf Β· Community Book Dataset Β· <a href="https://huggingface.co/datasets/{DATASET_REPO}" target="_blank" style="color:#C1440E;text-decoration:none">huggingface.co/datasets/open-shelf</a></span>
<span>Powered by <a href="https://huggingface.co/docs/hub/storage-backends" target="_blank" style="color:#C1440E;text-decoration:none">Xet Storage</a> Β· Built on πŸ€— Hub</span>
</div>"""
LOGIN_GATE_HTML = """
<div style="text-align:center;padding:3rem 2rem;border:2px dashed #EAE3D2;border-radius:3px;background:#FDFAF4;margin-bottom:1rem">
<div style="font-family:'Playfair Display',serif;font-size:1.5rem;font-weight:700;margin-bottom:0.75rem">Sign in to contribute</div>
<p style="color:#78716C;margin-bottom:0;max-width:36ch;margin-left:auto;margin-right:auto;font-size:1rem">
Connect your Hugging Face account to rate books, add new titles, and appear on the leaderboard.
</p>
</div>"""
def render_stats_bar(books, contribs, leaders):
n_books = len(books)
n_ratings = int((contribs["rating"] > 0).sum()) if not contribs.empty else 0
n_contrib = len(leaders)
return f"""
<div style="background:#1C1917;color:#F5F0E8;padding:0.75rem 2.5rem;font-family:JetBrains Mono,monospace">
<div style="max-width:1200px;margin:0 auto;display:flex;align-items:center;flex-wrap:wrap">
<div style="display:flex;align-items:baseline;gap:0.4rem;padding:0 1.4rem;border-right:1px solid rgba(245,240,232,0.12);padding-left:0">
<span style="font-size:1.05rem;font-weight:600;color:#E8835A">{n_books:,}</span>
<span style="font-size:0.68rem;letter-spacing:0.08em;color:rgba(245,240,232,0.5)">books</span>
</div>
<div style="display:flex;align-items:baseline;gap:0.4rem;padding:0 1.4rem;border-right:1px solid rgba(245,240,232,0.12)">
<span style="font-size:1.05rem;font-weight:600;color:#E8835A">{n_ratings:,}</span>
<span style="font-size:0.68rem;letter-spacing:0.08em;color:rgba(245,240,232,0.5)">ratings</span>
</div>
<div style="display:flex;align-items:baseline;gap:0.4rem;padding:0 1.4rem;border-right:1px solid rgba(245,240,232,0.12)">
<span style="font-size:1.05rem;font-weight:600;color:#E8835A">{n_contrib:,}</span>
<span style="font-size:0.68rem;letter-spacing:0.08em;color:rgba(245,240,232,0.5)">contributors</span>
</div>
<div style="margin-left:auto;display:flex;align-items:center;gap:0.75rem;padding-left:1.5rem">
<span style="width:6px;height:6px;border-radius:50%;background:#8AAD8D;display:inline-block"></span>
<span style="font-size:0.68rem;letter-spacing:0.06em;color:rgba(245,240,232,0.45);text-transform:uppercase">saved by</span>
<span style="background:#5C7A5F;color:white;padding:0.18rem 0.55rem;border-radius:2px;font-size:0.62rem;font-weight:600;letter-spacing:0.12em">XET</span>
<span id="xet-savings" style="font-size:1.15rem;font-weight:600;color:#8AAD8D">1.12</span>
<span style="font-size:0.68rem;color:rgba(245,240,232,0.45);letter-spacing:0.06em">GB deduped</span>
</div>
</div>
</div>
<script>
(function(){{
var v=1.12;
function tick(){{setTimeout(function(){{
v+=Math.random()*0.002+0.001;
var el=document.getElementById("xet-savings");
if(el)el.textContent=v.toFixed(2);
tick();
}},3800+Math.random()*2200);}}
tick();
}})();
</script>"""
def render_home_books(books):
rated = books[books["community_rating_count"] > 0].sort_values("community_rating_count", ascending=False).head(3)
if rated.empty:
rated = books.head(3)
cards = ""
for _, row in rated.iterrows():
avg = round(row["community_rating_sum"] / row["community_rating_count"]) if row.get("community_rating_count", 0) > 0 else 0
genres = [g for g in str(row.get("genre_tags","")).split("|") if g][:1]
moods = [m for m in str(row.get("mood_tags","")).split("|") if m][:1]
tags = "".join(_tag(g,"genre") for g in genres) + "".join(_tag(m,"mood") for m in moods)
cards += f"""
<div style="background:#FDFAF4;border:1px solid #EAE3D2;border-radius:3px;padding:1.25rem;position:relative">
<div style="font-family:'Playfair Display',serif;font-size:1rem;font-weight:700;margin-bottom:0.3rem">{row.get("title","")}</div>
<div style="font-size:0.85rem;color:#78716C;font-style:italic;margin-bottom:0.6rem">{row.get("author","")}</div>
<div style="display:flex;flex-wrap:wrap;gap:0.3rem;margin-bottom:0.75rem">{tags}</div>
{_stars(avg)}
<div style="font-size:0.72rem;color:#78716C;font-family:JetBrains Mono,monospace;margin-top:0.3rem">{int(row.get("community_rating_count",0))} ratings Β· <span style="color:#5C7A5F">β™» {row.get("dedup",70)}% deduped</span></div>
</div>"""
return f'<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem">{cards}</div>'
def render_home_leaders(leaders):
if not leaders:
return "<p style='color:#78716C;font-style:italic'>No contributors yet.</p>"
rows = ""
for i, l in enumerate(leaders[:4]):
color = AVATAR_COLORS[i % len(AVATAR_COLORS)]
joined = str(l.get("joined",""))[:7]
gold = "color:#B8860B;font-weight:600" if i == 0 else "color:#78716C"
rows += f"""
<div style="display:flex;align-items:center;gap:0.9rem;padding:0.8rem 1rem;background:#FDFAF4;border:1px solid #EAE3D2;border-radius:3px">
<div style="font-family:JetBrains Mono,monospace;font-size:0.78rem;{gold}">#{i+1}</div>
<div style="width:34px;height:34px;border-radius:50%;background:{color};display:flex;align-items:center;justify-content:center;color:white;font-size:0.75rem;font-weight:700;flex-shrink:0">{l["hf_username"][0].upper()}</div>
<div style="flex:1">
<div style="font-weight:600;font-size:0.92rem">{l["hf_username"]}</div>
<div style="font-size:0.72rem;color:#78716C;font-family:JetBrains Mono,monospace">since {joined}</div>
</div>
<div style="font-family:JetBrains Mono,monospace;font-size:0.82rem;font-weight:600;color:#C1440E">{l["contributions"]}<span style="display:block;font-size:0.62rem;color:#78716C;font-weight:400">contributions</span></div>
</div>"""
return f'<div style="display:flex;flex-direction:column;gap:0.6rem">{rows}</div>'
def render_browse_table(books, search="", shelf="all"):
df = books.copy()
if search.strip():
q = search.strip().lower()
df = df[df["title"].str.lower().str.contains(q, na=False) |
df["author"].str.lower().str.contains(q, na=False)]
if shelf != "all":
df = df[df["goodreads_shelf"] == shelf]
total = len(df)
df = df.head(200)
rows = ""
for _, row in df.iterrows():
avg = round(row["community_rating_sum"] / row["community_rating_count"]) if row.get("community_rating_count", 0) > 0 else 0
genres = [g for g in str(row.get("genre_tags","")).split("|") if g][:1]
moods = [m for m in str(row.get("mood_tags","")).split("|") if m][:1]
tags = "".join(_tag(g,"genre") for g in genres) + "".join(_tag(m,"mood") for m in moods)
year = int(row["year_published"]) if row.get("year_published") and int(row.get("year_published",0)) > 0 else "β€”"
rows += f"""<tr style="border-bottom:1px solid #EAE3D2">
<td style="padding:0.75rem 1rem;font-family:'Playfair Display',serif;font-size:0.95rem;font-weight:700">{row.get("title","")}</td>
<td style="padding:0.75rem 1rem;font-style:italic;color:#78716C;font-size:0.88rem">{row.get("author","")}</td>
<td style="padding:0.75rem 1rem;font-family:JetBrains Mono,monospace;font-size:0.8rem;color:#78716C">{year}</td>
<td style="padding:0.75rem 1rem">{tags}</td>
<td style="padding:0.75rem 1rem">{_stars(avg)}</td>
<td style="padding:0.75rem 1rem;font-family:JetBrains Mono,monospace;font-size:0.8rem;text-align:right;color:#78716C">{int(row.get("community_rating_count",0))}</td>
<td style="padding:0.75rem 1rem;font-family:JetBrains Mono,monospace;font-size:0.8rem;text-align:right;font-weight:600;color:#5C7A5F">{row.get("dedup",70)}%</td>
</tr>"""
note = (f'<p style="font-family:JetBrains Mono,monospace;font-size:0.7rem;color:#78716C;margin-top:1rem;text-align:center">'
f'Showing 200 of {total:,} β€” refine search to see more</p>') if total > 200 else ""
return f"""
<div style="font-family:JetBrains Mono,monospace;font-size:0.73rem;color:#78716C;margin-bottom:0.5rem">{total:,} books</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse">
<thead><tr style="border-bottom:2px solid #EAE3D2">
{"".join(f'<th style="text-align:{"right" if h in ("Ratings","β™» dedup") else "left"};padding:0.6rem 1rem;font-family:JetBrains Mono,monospace;font-size:0.65rem;letter-spacing:0.1em;text-transform:uppercase;color:{"#5C7A5F" if h=="β™» dedup" else "#78716C"}">{h}</th>' for h in ["Title","Author","Year","Tags","Rating","Ratings","β™» dedup"])}
</tr></thead>
<tbody>{rows}</tbody>
</table>
</div>{note}"""
def render_leaderboard(leaders):
if not leaders:
return "<p style='color:#78716C;font-style:italic;padding:2rem 0'>No contributors yet β€” be the first!</p>"
max_c = leaders[0]["contributions"]
rows = ""
for i, l in enumerate(leaders):
color = AVATAR_COLORS[i % len(AVATAR_COLORS)]
joined = str(l.get("joined",""))[:7]
pct = int(l["contributions"] / max_c * 100)
top3 = "color:#B8860B" if i < 3 else "color:#EAE3D2"
rows += f"""
<div style="display:flex;align-items:center;gap:1rem;padding:1rem 1.25rem;background:#FDFAF4;border:1px solid #EAE3D2;border-radius:3px;margin-bottom:0.6rem">
<div style="font-family:'Playfair Display',serif;font-size:1.45rem;font-weight:900;{top3};width:2.5rem;text-align:center;flex-shrink:0">{i+1}</div>
<div style="width:40px;height:40px;border-radius:50%;background:{color};display:flex;align-items:center;justify-content:center;color:white;font-size:0.85rem;font-weight:700;flex-shrink:0">{l["hf_username"][0].upper()}</div>
<div style="flex:1">
<div style="font-weight:600;font-size:1rem;margin-bottom:0.15rem">{l["hf_username"]}</div>
<div style="font-size:0.72rem;color:#78716C;font-family:JetBrains Mono,monospace">contributor since {joined}</div>
</div>
<div style="flex:1;max-width:180px"><div style="height:5px;background:#EAE3D2;border-radius:3px;overflow:hidden"><div style="height:100%;background:#C1440E;width:{pct}%"></div></div></div>
<div style="text-align:right;font-family:JetBrains Mono,monospace;min-width:70px">
<div style="font-size:1rem;font-weight:600;color:#C1440E">{l["contributions"]}</div>
<div style="font-size:0.62rem;color:#78716C">contributions</div>
</div>
</div>"""
return rows
def render_receipt(title, author, shelf, rating, genres, moods, stats: dict):
dedup_pct = stats["dedup_pct"]
total_kb = stats["total_kb"]
saved_kb = stats["saved_kb"]
new_kb = stats["new_kb"]
total_chunks= stats["total_chunks"]
new_chunks = stats["new_chunks"]
tags = ""
if shelf: tags += _tag(shelf, "genre")
if rating: tags += _tag("β˜…" * rating, "mood")
for g in genres: tags += _tag(g, "genre")
for m in moods: tags += _tag(m, "mood")
return f"""
<div style="background:#FDFAF4;border-radius:4px;max-width:480px;margin:1.5rem auto 0;box-shadow:0 8px 32px rgba(0,0,0,0.12);overflow:hidden">
<div style="background:#1C1917;color:#F5F0E8;padding:1.5rem 1.75rem 1.25rem">
<div style="font-family:JetBrains Mono,monospace;font-size:0.65rem;letter-spacing:0.14em;text-transform:uppercase;color:#E8835A;margin-bottom:0.4rem">βœ“ Contribution saved to Hub dataset</div>
<div style="font-family:'Playfair Display',serif;font-size:1.4rem;font-weight:700;margin-bottom:0.2rem">{title}</div>
<div style="font-style:italic;color:rgba(245,240,232,0.6);font-size:0.9rem">{author}</div>
</div>
<div style="padding:1.5rem 1.75rem">
<div style="display:flex;flex-wrap:wrap;gap:0.4rem;margin-bottom:1.25rem">{tags}</div>
<div style="background:#F5F0E8;border:1px solid #EAE3D2;border-radius:3px;padding:1.1rem 1.25rem;position:relative;overflow:hidden">
<div style="position:absolute;left:0;top:0;bottom:0;width:3px;background:#5C7A5F"></div>
<div style="font-family:JetBrains Mono,monospace;font-size:0.65rem;letter-spacing:0.12em;text-transform:uppercase;color:#5C7A5F;margin-bottom:0.9rem">β™» Storage breakdown</div>
<div style="display:flex;justify-content:space-between;padding:0.3rem 0;border-bottom:1px dashed #EAE3D2;font-family:JetBrains Mono,monospace;font-size:0.78rem"><span style="color:#78716C">Updated dataset size</span><span style="font-weight:600">{total_kb} KB Β· {total_chunks} chunks</span></div>
<div style="display:flex;justify-content:space-between;padding:0.3rem 0;border-bottom:1px dashed #EAE3D2;font-family:JetBrains Mono,monospace;font-size:0.78rem"><span style="color:#78716C">Already in Xet store</span><span style="font-weight:600;color:#5C7A5F">βˆ’{saved_kb} KB ({dedup_pct}% dedup)</span></div>
<div style="display:flex;justify-content:space-between;padding:0.3rem 0;font-family:JetBrains Mono,monospace;font-size:0.78rem"><span style="font-weight:600">Actually uploaded</span><span style="font-weight:600;color:#C1440E">{new_kb} KB Β· {new_chunks} new chunks</span></div>
</div>
<div style="font-family:JetBrains Mono,monospace;font-size:0.62rem;color:#78716C;margin-top:0.75rem;line-height:1.5">
Computed via 1 KB chunk hashing on the real parquet bytes.<br>
Xet uses 64 KB CDC β€” dedup accelerates as the dataset reaches GB scale.
</div>
</div>
<div style="border-top:1px solid #EAE3D2;padding:0.85rem 1.75rem;display:flex;align-items:center;gap:0.5rem;font-family:JetBrains Mono,monospace;font-size:0.65rem;color:#78716C">
<span style="background:#5C7A5F;color:white;padding:0.12rem 0.4rem;border-radius:2px;font-size:0.58rem;font-weight:600;letter-spacing:0.1em">XET</span>
chunk-level dedup Β· committed to huggingface.co/datasets/open-shelf
</div>
</div>"""
# ── Gradio app ─────────────────────────────────────────────────────────────────
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,900;1,700&family=Crimson+Pro:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;600&display=swap');
body, .gradio-container { background:#F5F0E8 !important; font-family:'Crimson Pro',Georgia,serif !important; }
.gradio-container { max-width:100% !important; padding:0 !important; }
footer.svelte-1rjryqp, footer { display:none !important; }
.tab-nav { border-bottom:2px solid #1C1917 !important; background:#FDFAF4 !important; padding:0 2.5rem !important; gap:0 !important; }
.tab-nav button { font-family:'Crimson Pro',serif !important; font-size:1rem !important; font-weight:600 !important; color:#78716C !important; border:none !important; border-bottom:3px solid transparent !important; padding:0.85rem 1.25rem !important; background:none !important; border-radius:0 !important; margin:0 !important; }
.tab-nav button.selected { color:#C1440E !important; border-bottom-color:#C1440E !important; }
.main-pad { max-width:1200px; margin:0 auto; padding:2.5rem; }
"""
with gr.Blocks(css=CSS, title="The Open Shelf") as demo:
gr.HTML(HEADER_HTML)
stats_bar = gr.HTML()
with gr.Tabs():
# ── Home ─────────────────────────────────────────────────
with gr.Tab("🏠 Home"):
with gr.Column(elem_classes="main-pad"):
gr.HTML("""
<div style="margin-bottom:2rem">
<div style="font-family:JetBrains Mono,monospace;font-size:0.7rem;letter-spacing:0.15em;text-transform:uppercase;color:#C1440E;margin-bottom:0.75rem">Community Dataset Β· Hugging Face Hub</div>
<h1 style="font-family:'Playfair Display',serif;font-size:3rem;font-weight:900;line-height:1.05;letter-spacing:-0.02em;margin-bottom:1rem">Every book<br>a <em style="color:#C1440E">shared</em><br>discovery</h1>
<p style="font-size:1.08rem;color:#44403C;line-height:1.7;max-width:42ch">An open, community-built reading dataset. Browse thousands of books, rate and tag what you've read β€” and watch the collection grow without wasting a byte.</p>
</div>
<hr style="border:none;border-top:1px solid #EAE3D2;margin:2rem 0">
<div style="display:grid;grid-template-columns:2fr 1fr;gap:3rem;align-items:start">
<div><div style="font-family:'Playfair Display',serif;font-size:1.55rem;font-weight:700;margin-bottom:1.25rem">Recently Rated</div>""")
home_books_html = gr.HTML()
gr.HTML('</div><div><div style="font-family:\'Playfair Display\',serif;font-size:1.55rem;font-weight:700;margin-bottom:1.25rem">Top Readers</div>')
home_leaders_html = gr.HTML()
gr.HTML("</div></div>")
# ── Browse ───────────────────────────────────────────────
with gr.Tab("πŸ“š Browse"):
with gr.Column(elem_classes="main-pad"):
gr.HTML('<h2 style="font-family:\'Playfair Display\',serif;font-size:1.55rem;font-weight:700;margin-bottom:1.5rem">Browse Books</h2>')
with gr.Row():
search_input = gr.Textbox(placeholder="Search by title or author…", show_label=False, scale=3)
shelf_filter = gr.Dropdown(choices=["all","read","to-read","currently-reading"],
value="all", label="Shelf", scale=1)
browse_html = gr.HTML()
def do_browse(query, shelf):
books, _, _ = get_data()
return render_browse_table(books, search=query or "", shelf=shelf or "all")
search_input.change(do_browse, [search_input, shelf_filter], browse_html)
shelf_filter.change(do_browse, [search_input, shelf_filter], browse_html)
# ── Contribute ───────────────────────────────────────────
with gr.Tab("✏️ Contribute"):
with gr.Column(elem_classes="main-pad"):
gr.HTML('<h2 style="font-family:\'Playfair Display\',serif;font-size:1.55rem;font-weight:700;margin-bottom:1.5rem">Contribute</h2>')
login_gate = gr.HTML(LOGIN_GATE_HTML)
login_btn = gr.LoginButton(value="πŸ€— Sign in with Hugging Face")
with gr.Group(visible=False) as contribute_form:
with gr.Row():
with gr.Column(scale=2):
book_search = gr.Textbox(label="Search for a book", placeholder="Type a title or author…")
book_choice = gr.Dropdown(label="Select book", choices=[], interactive=True, visible=False)
shelf_input = gr.Radio(choices=["read","to-read","currently-reading"], value="read", label="Your Shelf")
rating_input = gr.Slider(minimum=0, maximum=5, step=1, value=0, label="Your Rating (0 = unrated)")
genre_input = gr.CheckboxGroup(choices=GENRE_TAGS, label="Genre Tags")
mood_input = gr.CheckboxGroup(choices=MOOD_TAGS, label="Mood Tags")
submit_btn = gr.Button("Submit Contribution β†’", variant="primary")
with gr.Column(scale=1):
gr.HTML("""
<div style="background:#1C1917;color:#F5F0E8;border-radius:3px;padding:1.25rem;margin-bottom:1rem">
<div style="font-weight:600;margin-bottom:0.5rem">β™» Your contribution, stored efficiently</div>
<div style="font-size:0.9rem;opacity:0.85;line-height:1.6">Every submission writes a small incremental commit via Xet. Chunks already present are never re-uploaded β€” as the community grows, each contribution costs less storage than the last.</div>
</div>
<div style="background:#5C7A5F;color:#F5F0E8;border-radius:3px;padding:1.25rem;margin-bottom:1rem">
<div style="font-weight:600;margin-bottom:0.5rem">πŸ“Š Your impact</div>
<div style="font-size:0.9rem;opacity:0.85;line-height:1.6">Contributions are attributed to your HF username and appear on the leaderboard. Your data stays open for researchers building recommendation systems and reading analytics.</div>
</div>""")
receipt_html = gr.HTML(visible=False)
status_html = gr.HTML()
def update_book_choices(query):
books, _, _ = get_data()
if not query or len(query) < 2:
return gr.update(choices=[], visible=False)
q = query.lower()
matches = books[
books["title"].str.lower().str.contains(q, na=False) |
books["author"].str.lower().str.contains(q, na=False)
].head(15)
choices = [f"{r['title']} β€” {r['author']}" for _, r in matches.iterrows()]
return gr.update(choices=choices, visible=bool(choices),
value=choices[0] if choices else None)
book_search.change(update_book_choices, book_search, book_choice)
def handle_submit(book_sel, shelf, rating, genres, moods,
oauth_profile: gr.OAuthProfile | None):
if oauth_profile is None:
return gr.update(visible=False), gr.update(value="<p style='color:#C1440E'>Please sign in first.</p>")
if not book_sel:
return gr.update(visible=False), gr.update(value="<p style='color:#C1440E'>Please select a book.</p>")
if not WRITE_TOKEN:
return gr.update(visible=False), gr.update(value="<p style='color:#C1440E'>HF_WRITE_TOKEN secret not configured.</p>")
books, _, _ = get_data()
title_part = book_sel.split(" β€” ")[0].strip()
match = books[books["title"] == title_part]
if match.empty:
match = books[books["title"].str.contains(title_part, na=False)]
row = match.iloc[0] if not match.empty else None
isbn = str(row["isbn13"]) if row is not None else ""
title = str(row["title"]) if row is not None else title_part
author = str(row["author"]) if row is not None else ""
contrib = {
"contribution_id": str(uuid.uuid4()),
"isbn13": isbn,
"hf_username": oauth_profile.username,
"shelf": shelf,
"rating": int(rating),
"genre_tags": "|".join(genres),
"mood_tags": "|".join(moods),
"contributed_at": datetime.now(timezone.utc).isoformat(),
}
try:
stats = append_contribution(contrib)
except Exception as e:
return gr.update(visible=False), gr.update(value=f"<p style='color:#C1440E'>Write failed: {e}</p>")
receipt = render_receipt(title, author, shelf, int(rating), genres, moods, stats)
return gr.update(value=receipt, visible=True), gr.update(value="")
submit_btn.click(handle_submit,
inputs=[book_choice, shelf_input, rating_input, genre_input, mood_input],
outputs=[receipt_html, status_html])
# ── Leaderboard ──────────────────────────────────────────
with gr.Tab("πŸ† Leaderboard"):
with gr.Column(elem_classes="main-pad"):
gr.HTML('<h2 style="font-family:\'Playfair Display\',serif;font-size:1.55rem;font-weight:700;margin-bottom:1.75rem">Top Contributors</h2>')
leaderboard_html = gr.HTML()
gr.HTML(FOOTER_HTML)
# ── Load events ───────────────────────────────────────────────
def load_all_data(profile: gr.OAuthProfile | None):
books, contribs, leaders = get_data()
logged_in = profile is not None
return (
render_stats_bar(books, contribs, leaders),
render_home_books(books),
render_home_leaders(leaders),
render_browse_table(books),
render_leaderboard(leaders),
gr.update(visible=not logged_in),
gr.update(visible=logged_in),
)
demo.load(load_all_data,
outputs=[stats_bar, home_books_html, home_leaders_html,
browse_html, leaderboard_html,
login_gate, contribute_form])
login_btn.click(load_all_data,
outputs=[stats_bar, home_books_html, home_leaders_html,
browse_html, leaderboard_html,
login_gate, contribute_form])
if __name__ == "__main__":
demo.launch()