| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>Sentiment Analysis</title> |
| <style> |
| :root { |
| color-scheme: light; |
| --bg: #f6f7f4; |
| --panel: #ffffff; |
| --ink: #18211f; |
| --muted: #64706c; |
| --line: #d9ded9; |
| --accent: #087f74; |
| --accent-strong: #045e56; |
| --positive: #16825d; |
| --negative: #b54343; |
| --shadow: 0 18px 45px rgba(27, 39, 35, 0.1); |
| } |
| |
| * { |
| box-sizing: border-box; |
| } |
| |
| body { |
| margin: 0; |
| min-height: 100vh; |
| background: |
| linear-gradient(135deg, rgba(8, 127, 116, 0.08), transparent 42%), |
| var(--bg); |
| color: var(--ink); |
| font-family: |
| Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, |
| "Segoe UI", sans-serif; |
| } |
| |
| main { |
| width: min(1120px, calc(100% - 32px)); |
| margin: 0 auto; |
| padding: 42px 0; |
| } |
| |
| header { |
| display: grid; |
| gap: 10px; |
| margin-bottom: 28px; |
| } |
| |
| h1 { |
| margin: 0; |
| font-size: clamp(2.15rem, 5vw, 4.4rem); |
| line-height: 0.95; |
| letter-spacing: 0; |
| } |
| |
| header p { |
| max-width: 720px; |
| margin: 0; |
| color: var(--muted); |
| font-size: 1.05rem; |
| line-height: 1.6; |
| } |
| |
| .workspace { |
| display: grid; |
| grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); |
| gap: 18px; |
| align-items: stretch; |
| } |
| |
| .panel { |
| background: var(--panel); |
| border: 1px solid var(--line); |
| border-radius: 8px; |
| box-shadow: var(--shadow); |
| } |
| |
| .input-panel, |
| .result-panel { |
| padding: 20px; |
| } |
| |
| label { |
| display: block; |
| margin-bottom: 10px; |
| color: #26312e; |
| font-weight: 700; |
| } |
| |
| textarea { |
| width: 100%; |
| min-height: 230px; |
| resize: vertical; |
| border: 1px solid #c7cec9; |
| border-radius: 8px; |
| padding: 16px; |
| color: var(--ink); |
| font: inherit; |
| line-height: 1.5; |
| outline: none; |
| background: #fbfcfb; |
| } |
| |
| textarea:focus { |
| border-color: var(--accent); |
| box-shadow: 0 0 0 3px rgba(8, 127, 116, 0.16); |
| } |
| |
| .actions { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 10px; |
| margin-top: 14px; |
| } |
| |
| button { |
| min-height: 42px; |
| border: 1px solid transparent; |
| border-radius: 8px; |
| padding: 0 16px; |
| cursor: pointer; |
| font: inherit; |
| font-weight: 700; |
| } |
| |
| .primary { |
| background: var(--accent); |
| color: white; |
| } |
| |
| .primary:hover { |
| background: var(--accent-strong); |
| } |
| |
| .secondary { |
| background: #eef2ef; |
| border-color: #d4dbd6; |
| color: #25312e; |
| } |
| |
| .secondary:hover { |
| background: #e4eae6; |
| } |
| |
| .examples { |
| display: grid; |
| gap: 8px; |
| margin-top: 18px; |
| } |
| |
| .example-row { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 8px; |
| } |
| |
| .example { |
| min-height: 34px; |
| border-color: #cdd6d1; |
| background: #fbfcfb; |
| color: #33413d; |
| font-size: 0.92rem; |
| font-weight: 650; |
| } |
| |
| .result-panel { |
| display: grid; |
| gap: 18px; |
| align-content: start; |
| } |
| |
| .status { |
| min-height: 24px; |
| color: var(--muted); |
| font-weight: 650; |
| } |
| |
| .verdict { |
| display: grid; |
| gap: 8px; |
| padding: 18px; |
| border-radius: 8px; |
| background: #f4f7f5; |
| border: 1px solid #dce3de; |
| } |
| |
| .verdict span { |
| color: var(--muted); |
| font-size: 0.9rem; |
| font-weight: 700; |
| text-transform: uppercase; |
| } |
| |
| .verdict strong { |
| font-size: 2rem; |
| line-height: 1.05; |
| } |
| |
| .scores { |
| display: grid; |
| gap: 14px; |
| } |
| |
| .score-row { |
| display: grid; |
| gap: 7px; |
| } |
| |
| .score-head { |
| display: flex; |
| justify-content: space-between; |
| gap: 16px; |
| color: #2a3834; |
| font-weight: 700; |
| } |
| |
| .track { |
| height: 12px; |
| overflow: hidden; |
| border-radius: 999px; |
| background: #edf1ee; |
| } |
| |
| .bar { |
| width: 0; |
| height: 100%; |
| border-radius: inherit; |
| transition: width 240ms ease; |
| } |
| |
| .bar.positive { |
| background: var(--positive); |
| } |
| |
| .bar.negative { |
| background: var(--negative); |
| } |
| |
| .model-note { |
| margin: 0; |
| color: var(--muted); |
| font-size: 0.92rem; |
| line-height: 1.5; |
| } |
| |
| @media (max-width: 820px) { |
| main { |
| width: min(100% - 24px, 640px); |
| padding: 26px 0; |
| } |
| |
| .workspace { |
| grid-template-columns: 1fr; |
| } |
| |
| textarea { |
| min-height: 190px; |
| } |
| } |
| </style> |
| </head> |
| <body> |
| <main> |
| <header> |
| <h1>Sentiment Analysis</h1> |
| <p> |
| Analyze a review, message, comment, or social post with a small |
| Hugging Face sentiment model that runs directly in your browser. |
| </p> |
| </header> |
|
|
| <section class="workspace" aria-label="Sentiment analysis workspace"> |
| <div class="panel input-panel"> |
| <label for="text-input">Text</label> |
| <textarea |
| id="text-input" |
| placeholder="Type a review, message, comment, or social post..." |
| ></textarea> |
|
|
| <div class="actions"> |
| <button class="primary" id="analyze-button">Analyze</button> |
| <button class="secondary" id="clear-button">Clear</button> |
| </div> |
|
|
| <div class="examples"> |
| <label>Examples</label> |
| <div class="example-row"> |
| <button class="example" data-example="The product arrived early and works better than I expected."> |
| Positive review |
| </button> |
| <button class="example" data-example="The update made the app slow and frustrating to use."> |
| Negative feedback |
| </button> |
| <button class="example" data-example="The movie was okay, but the ending felt rushed."> |
| Mixed comment |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
| <aside class="panel result-panel" aria-label="Results"> |
| <div class="status" id="status">Model is ready to load.</div> |
|
|
| <div class="verdict"> |
| <span>Prediction</span> |
| <strong id="verdict">Waiting</strong> |
| </div> |
|
|
| <div class="scores"> |
| <div class="score-row"> |
| <div class="score-head"> |
| <span>Positive</span> |
| <span id="positive-score">0%</span> |
| </div> |
| <div class="track"> |
| <div class="bar positive" id="positive-bar"></div> |
| </div> |
| </div> |
|
|
| <div class="score-row"> |
| <div class="score-head"> |
| <span>Negative</span> |
| <span id="negative-score">0%</span> |
| </div> |
| <div class="track"> |
| <div class="bar negative" id="negative-bar"></div> |
| </div> |
| </div> |
| </div> |
|
|
| <p class="model-note"> |
| Model: Xenova/distilbert-base-uncased-finetuned-sst-2-english. |
| The first run downloads the model once, then your browser caches it. |
| </p> |
| </aside> |
| </section> |
| </main> |
|
|
| <script type="module"> |
| import { env, pipeline } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2"; |
| |
| env.allowLocalModels = false; |
| |
| const modelId = "Xenova/distilbert-base-uncased-finetuned-sst-2-english"; |
| const textInput = document.querySelector("#text-input"); |
| const analyzeButton = document.querySelector("#analyze-button"); |
| const clearButton = document.querySelector("#clear-button"); |
| const statusEl = document.querySelector("#status"); |
| const verdictEl = document.querySelector("#verdict"); |
| const positiveScoreEl = document.querySelector("#positive-score"); |
| const negativeScoreEl = document.querySelector("#negative-score"); |
| const positiveBarEl = document.querySelector("#positive-bar"); |
| const negativeBarEl = document.querySelector("#negative-bar"); |
| |
| let classifierPromise; |
| |
| function setStatus(message) { |
| statusEl.textContent = message; |
| } |
| |
| function setBusy(isBusy) { |
| analyzeButton.disabled = isBusy; |
| clearButton.disabled = isBusy; |
| analyzeButton.textContent = isBusy ? "Analyzing..." : "Analyze"; |
| } |
| |
| function updateScores(positive, negative) { |
| const positivePct = Math.round(positive * 100); |
| const negativePct = Math.round(negative * 100); |
| positiveScoreEl.textContent = `${positivePct}%`; |
| negativeScoreEl.textContent = `${negativePct}%`; |
| positiveBarEl.style.width = `${positivePct}%`; |
| negativeBarEl.style.width = `${negativePct}%`; |
| verdictEl.textContent = |
| positive >= negative ? "Positive" : "Negative"; |
| } |
| |
| async function getClassifier() { |
| if (!classifierPromise) { |
| setStatus("Loading model..."); |
| classifierPromise = pipeline("sentiment-analysis", modelId); |
| } |
| return classifierPromise; |
| } |
| |
| function normalizeResults(output) { |
| const rows = Array.isArray(output?.[0]) ? output[0] : output; |
| const scores = { positive: 0, negative: 0 }; |
| for (const row of rows) { |
| const label = String(row.label || "").toLowerCase(); |
| if (label.includes("positive")) scores.positive = row.score; |
| if (label.includes("negative")) scores.negative = row.score; |
| } |
| if (!scores.positive && !scores.negative && rows[0]) { |
| const first = rows[0]; |
| if (String(first.label).toLowerCase().includes("positive")) { |
| scores.positive = first.score; |
| scores.negative = 1 - first.score; |
| } else { |
| scores.negative = first.score; |
| scores.positive = 1 - first.score; |
| } |
| } |
| return scores; |
| } |
| |
| async function analyze() { |
| const text = textInput.value.trim(); |
| if (!text) { |
| setStatus("Enter text first."); |
| verdictEl.textContent = "Waiting"; |
| updateScores(0, 0); |
| return; |
| } |
| |
| setBusy(true); |
| try { |
| const classifier = await getClassifier(); |
| setStatus("Running sentiment analysis..."); |
| const output = await classifier(text, { topk: null }); |
| const scores = normalizeResults(output); |
| updateScores(scores.positive, scores.negative); |
| setStatus("Analysis complete."); |
| } catch (error) { |
| console.error(error); |
| setStatus("Model could not load. Refresh and try again."); |
| } finally { |
| setBusy(false); |
| } |
| } |
| |
| analyzeButton.addEventListener("click", analyze); |
| textInput.addEventListener("keydown", (event) => { |
| if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { |
| analyze(); |
| } |
| }); |
| |
| clearButton.addEventListener("click", () => { |
| textInput.value = ""; |
| verdictEl.textContent = "Waiting"; |
| updateScores(0, 0); |
| setStatus("Model is ready to load."); |
| textInput.focus(); |
| }); |
| |
| document.querySelectorAll("[data-example]").forEach((button) => { |
| button.addEventListener("click", () => { |
| textInput.value = button.dataset.example; |
| analyze(); |
| }); |
| }); |
| </script> |
| </body> |
| </html> |
|
|