Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <meta name="color-scheme" content="dark" /> | |
| <title>AI Article Summarizer · Qwen + Kokoro</title> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" /> | |
| <style> | |
| :root{ | |
| --bg-0:#0a0e16; --bg-1:#0e1420; --bg-2:#111927; | |
| --glass: rgba(255,255,255,.05); | |
| --glass-border: rgba(255,255,255,.1); | |
| --muted: #9aa4bf; --text: #e7ecf8; --text-dim: #b8c2d9; | |
| --accent-1:#6d6aff; --accent-2:#7b5cff; --accent-3:#00d4ff; | |
| --ok:#21d19f; --warn:#ffb84d; --err:#ff6b6b; | |
| --ring: 0 0 0 1px rgba(255,255,255,.08), 0 0 0 4px rgba(124, 58, 237, .15); | |
| --shadow: 0 25px 70px rgba(0,0,0,.5), 0 10px 25px rgba(0,0,0,.4); | |
| --shadow-lg: 0 35px 90px rgba(0,0,0,.6), 0 15px 35px rgba(0,0,0,.5); | |
| --radius-xl:24px; --radius-lg:18px; --radius-md:14px; --radius-sm:10px; | |
| --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); | |
| --transition-slow: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| *{box-sizing:border-box} | |
| html,body{height:100%; scroll-behavior: smooth} | |
| body{ | |
| margin:0; | |
| font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial; | |
| color:var(--text); font-weight: 400; line-height: 1.6; | |
| background: | |
| radial-gradient(1400px 700px at -15% -15%, rgba(109,106,255,.2), transparent 55%), | |
| radial-gradient(1000px 600px at 115% -15%, rgba(0,212,255,.16), transparent 58%), | |
| radial-gradient(1400px 1000px at 50% 115%, rgba(123,92,255,.16), transparent 65%), | |
| linear-gradient(180deg, var(--bg-0) 0%, var(--bg-1) 40%, var(--bg-2) 100%); | |
| background-attachment: fixed; | |
| overflow-y:auto; overflow-x: hidden; | |
| } | |
| /* Enhanced progress bar */ | |
| .bar{ | |
| position:fixed; inset:0 0 auto 0; height:3px; z-index:9999; | |
| background: linear-gradient(90deg, var(--accent-3), var(--accent-2), var(--accent-1)); | |
| background-size:300% 100%; transform:scaleX(0); transform-origin:left; | |
| box-shadow:0 0 20px rgba(0,212,255,.5), 0 0 40px rgba(123,92,255,.3); | |
| transition:transform .25s cubic-bezier(0.4, 0, 0.2, 1); | |
| animation:bar-move 2.5s linear infinite; | |
| } | |
| @keyframes bar-move{0%{background-position:0 0}100%{background-position:300% 0}} | |
| /* Floating particles animation */ | |
| .particles{ | |
| position: fixed; inset: 0; pointer-events: none; z-index: 1; | |
| background-image: | |
| radial-gradient(2px 2px at 20px 30px, rgba(255,255,255,.1), transparent), | |
| radial-gradient(2px 2px at 40px 70px, rgba(109,106,255,.1), transparent), | |
| radial-gradient(1px 1px at 90px 40px, rgba(0,212,255,.1), transparent), | |
| radial-gradient(1px 1px at 130px 80px, rgba(123,92,255,.1), transparent); | |
| background-repeat: repeat; | |
| background-size: 200px 100px; | |
| animation: float 20s linear infinite; | |
| } | |
| @keyframes float{0%{transform:translateY(0px)}100%{transform:translateY(-100px)}} | |
| .wrap{max-width:1100px; margin:80px auto; padding:0 28px; position: relative; z-index: 2} | |
| /* Enhanced hero section */ | |
| .hero{ | |
| display:flex; flex-direction:column; align-items:center; gap:18px; | |
| margin-bottom:36px; text-align:center; animation: fadeInUp 0.8s ease-out; | |
| } | |
| @keyframes fadeInUp{0%{opacity:0;transform:translateY(30px)}100%{opacity:1;transform:translateY(0)}} | |
| .hero-badge{ | |
| display:inline-flex; align-items:center; gap:12px; padding:10px 16px; border-radius:999px; | |
| background:linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.03)); | |
| border:1px solid var(--glass-border); backdrop-filter: blur(12px); | |
| box-shadow: var(--shadow); transition: var(--transition); | |
| animation: pulse 2s ease-in-out infinite; | |
| } | |
| @keyframes pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.02)}} | |
| .dot{ | |
| width:10px;height:10px;border-radius:50%; background:var(--warn); | |
| box-shadow:0 0 0 8px rgba(255,184,77,.15); transition: var(--transition); | |
| animation: dotPulse 1.5s ease-in-out infinite; | |
| } | |
| .dot.ready{ | |
| background:var(--ok); box-shadow:0 0 0 8px rgba(33,209,159,.15); | |
| animation: dotReady 0.5s ease-out; | |
| } | |
| @keyframes dotPulse{0%,100%{opacity:1}50%{opacity:0.6}} | |
| @keyframes dotReady{0%{transform:scale(0.8)}100%{transform:scale(1)}} | |
| .hero h1{ | |
| font-size: clamp(32px, 5.5vw, 52px); margin:0; font-weight:800; | |
| letter-spacing:-.03em; line-height:1.05; animation: fadeInUp 0.8s 0.2s both; | |
| } | |
| .grad-text{ | |
| background: linear-gradient(135deg, #f8faff, #d4e0ff 25%, #a8d8ff 50%, #c8b8ff 75%, #e8d4ff); | |
| -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; | |
| background-size: 200% 200%; animation: gradientShift 4s ease-in-out infinite; | |
| } | |
| @keyframes gradientShift{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}} | |
| .hero p{ | |
| margin:0; color:var(--text-dim); font-size:16px; font-weight: 300; | |
| animation: fadeInUp 0.8s 0.4s both; | |
| } | |
| /* Enhanced glass panel */ | |
| .panel{ | |
| position:relative; | |
| background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03)); | |
| border:1px solid var(--glass-border); border-radius: var(--radius-xl); | |
| padding:32px; box-shadow: var(--shadow-lg); overflow:hidden; | |
| backdrop-filter: blur(20px); animation: fadeInUp 0.8s 0.6s both; | |
| } | |
| .panel::before{ | |
| content:""; position:absolute; inset:-1px; border-radius:inherit; padding:1px; | |
| background:linear-gradient(135deg, rgba(175,134,255,.4) 0%, rgba(0,212,255,.2) 50%, rgba(175,134,255,.4) 100%); | |
| -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); | |
| -webkit-mask-composite:xor; mask-composite: exclude; pointer-events:none; | |
| opacity:.9; animation: borderGlow 3s ease-in-out infinite; | |
| } | |
| @keyframes borderGlow{0%,100%{opacity:.9}50%{opacity:.6}} | |
| /* Enhanced segmented control */ | |
| .seg{ | |
| display:inline-flex; padding:8px; | |
| background:rgba(0,0,0,.4); border:1px solid rgba(255,255,255,.12); | |
| border-radius:999px; gap:8px; backdrop-filter: blur(8px); | |
| box-shadow: inset 0 2px 8px rgba(0,0,0,.3); | |
| } | |
| .seg button{ | |
| border:0; border-radius:999px; padding:12px 18px; color:var(--text); | |
| background:transparent; cursor:pointer; font-weight:600; font-size:14px; | |
| transition: var(--transition); position: relative; overflow: hidden; | |
| } | |
| .seg button::before{ | |
| content: ''; position: absolute; inset: 0; border-radius: inherit; | |
| background: linear-gradient(135deg, #7b5cff 0%, #00d4ff 100%); | |
| opacity: 0; transition: var(--transition); | |
| } | |
| .seg button.active{color:#0a0e16; font-weight: 700} | |
| .seg button.active::before{opacity: 1} | |
| .seg button span{position: relative; z-index: 1} | |
| /* Enhanced form elements */ | |
| .grid{display:grid; grid-template-columns:1fr auto; gap:16px; align-items:center} | |
| .input, .textarea{ | |
| width:100%; | |
| background:linear-gradient(180deg, rgba(0,0,0,.4), rgba(0,0,0,.3)); | |
| border:1px solid rgba(255,255,255,.15); | |
| border-radius:18px; padding:16px 20px; color:var(--text); font-size:16px; | |
| outline:none; transition: var(--transition); backdrop-filter: blur(8px); | |
| box-shadow: inset 0 2px 8px rgba(0,0,0,.2); | |
| } | |
| .input::placeholder, .textarea::placeholder{color:#7f8aad; font-weight: 300} | |
| .input:focus, .textarea:focus{ | |
| border-color:rgba(0,212,255,.6); box-shadow: var(--ring), inset 0 2px 8px rgba(0,0,0,.2); | |
| transform: translateY(-1px); | |
| } | |
| .textarea{min-height:180px; resize:vertical; line-height: 1.6} | |
| .hint{color:var(--muted); font-size:13px; margin-top:8px; font-weight: 300} | |
| /* Enhanced buttons */ | |
| .btn{ | |
| position:relative; display:inline-flex; align-items:center; justify-content:center; gap:12px; | |
| padding:16px 24px; border-radius:18px; border:1px solid rgba(255,255,255,.15); | |
| color:#0a0e16; font-weight:700; letter-spacing:.01em; font-size: 15px; | |
| background: linear-gradient(135deg, #7b5cff 0%, #00d4ff 100%); | |
| box-shadow: 0 12px 35px rgba(0,212,255,.4), inset 0 1px 0 rgba(255,255,255,.2); | |
| cursor:pointer; user-select:none; transition: var(--transition); overflow: hidden; | |
| } | |
| .btn::before{ | |
| content: ''; position: absolute; inset: 0; border-radius: inherit; | |
| background: linear-gradient(135deg, #8a6bff 0%, #10e4ff 100%); | |
| opacity: 0; transition: var(--transition); | |
| } | |
| .btn:hover{transform: translateY(-2px); box-shadow: 0 15px 45px rgba(0,212,255,.5), inset 0 1px 0 rgba(255,255,255,.2)} | |
| .btn:hover::before{opacity: 1} | |
| .btn:active{transform: translateY(-1px)} | |
| .btn:disabled{opacity:.6; cursor:not-allowed; filter:grayscale(.3); transform: none} | |
| .btn span{position: relative; z-index: 1} | |
| /* Enhanced switch */ | |
| .row{display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-top:18px} | |
| .switch{ | |
| display:inline-flex; align-items:center; gap:14px; cursor:pointer; user-select:none; | |
| padding:12px 16px; border-radius:999px; | |
| background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.1); | |
| transition: var(--transition); backdrop-filter: blur(8px); | |
| } | |
| .switch:hover{background:rgba(255,255,255,.08); transform: translateY(-1px)} | |
| .switch .track{ | |
| width:48px; height:26px; background:rgba(255,255,255,.15); border-radius:999px; | |
| position:relative; transition: var(--transition); box-shadow: inset 0 2px 6px rgba(0,0,0,.3); | |
| } | |
| .switch .thumb{ | |
| width:20px; height:20px; border-radius:50%; background:white; position:absolute; top:3px; left:3px; | |
| box-shadow:0 4px 18px rgba(0,0,0,.5), 0 2px 8px rgba(0,0,0,.3); | |
| transition: var(--transition); | |
| } | |
| .switch input{display:none} | |
| .switch input:checked + .track{ | |
| background:linear-gradient(90deg, #00d4ff, #7b5cff); | |
| box-shadow: 0 0 20px rgba(0,212,255,.3), inset 0 2px 6px rgba(0,0,0,.2); | |
| } | |
| .switch input:checked + .track .thumb{ | |
| left:25px; background:#0a0e16; transform:scale(1.1); | |
| box-shadow:0 6px 20px rgba(0,0,0,.6), 0 2px 8px rgba(0,0,0,.4); | |
| } | |
| /* Enhanced collapsible section */ | |
| .collapse{ | |
| overflow:hidden; max-height:0; opacity:0; transform: translateY(-8px); | |
| transition: max-height .4s cubic-bezier(0.4, 0, 0.2, 1), | |
| opacity .3s ease, transform .3s ease; | |
| } | |
| .collapse.open{max-height:600px; opacity:1; transform:none} | |
| /* Enhanced voice grid */ | |
| .voices{ | |
| display:grid; gap:14px; margin-top:16px; | |
| grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); | |
| } | |
| .voice{ | |
| position:relative; padding:16px; border-radius:16px; | |
| background:linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02)); | |
| border:1px solid rgba(255,255,255,.1); | |
| transition: var(--transition); cursor:pointer; overflow: hidden; | |
| backdrop-filter: blur(8px); | |
| } | |
| .voice::before{ | |
| content: ''; position: absolute; inset: 0; border-radius: inherit; | |
| background: linear-gradient(135deg, rgba(0,212,255,.1), rgba(123,92,255,.1)); | |
| opacity: 0; transition: var(--transition); | |
| } | |
| .voice:hover{ | |
| transform: translateY(-3px); | |
| box-shadow: var(--shadow); | |
| border-color: rgba(0,212,255,.3); | |
| } | |
| .voice:hover::before{opacity: 1} | |
| .voice.selected{ | |
| background:linear-gradient(180deg, rgba(0,212,255,.1), rgba(123,92,255,.08)); | |
| border-color: rgba(123,92,255,.6); | |
| box-shadow: 0 0 30px rgba(123,92,255,.2); | |
| } | |
| .voice.selected::before{opacity: 1} | |
| .voice .content{position: relative; z-index: 1} | |
| .voice .name{font-weight:700; letter-spacing:.01em; margin-bottom: 8px} | |
| .voice .meta{ | |
| color:var(--muted); font-size:13px; display:flex; gap:12px; align-items:center; | |
| flex-wrap: wrap; | |
| } | |
| .voice .badge{ | |
| font-size:11px; padding:4px 10px; border-radius:999px; | |
| border:1px solid rgba(255,255,255,.15); | |
| background:rgba(255,255,255,.06); font-weight: 500; | |
| } | |
| /* Enhanced results section */ | |
| .results{margin-top:24px} | |
| .chips{display:flex; flex-wrap:wrap; gap:12px} | |
| .chip{ | |
| font-size:13px; color:#d4e0ff; padding:10px 14px; border-radius:999px; | |
| border:1px solid rgba(255,255,255,.1); | |
| background:linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02)); | |
| backdrop-filter: blur(8px); font-weight: 500; | |
| } | |
| .toolbar{display:flex; gap:12px; flex-wrap:wrap; margin-top:16px} | |
| .tbtn{ | |
| display:inline-flex; align-items:center; gap:10px; padding:10px 16px; | |
| border-radius:12px; | |
| background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03)); | |
| border:1px solid rgba(255,255,255,.12); color:var(--text); cursor:pointer; | |
| font-size:14px; transition: var(--transition); text-decoration: none; | |
| backdrop-filter: blur(8px); font-weight: 500; | |
| } | |
| .tbtn:hover{ | |
| background:linear-gradient(180deg, rgba(255,255,255,.1), rgba(255,255,255,.06)); | |
| transform: translateY(-1px); | |
| box-shadow: 0 8px 25px rgba(0,0,0,.2); | |
| } | |
| .tbtn:active{transform: translateY(0)} | |
| /* Enhanced summary display */ | |
| .summary{ | |
| margin-top:18px; | |
| background:linear-gradient(180deg, rgba(0,0,0,.4), rgba(0,0,0,.3)); | |
| border:1px solid rgba(255,255,255,.12); border-radius:18px; padding:24px; | |
| line-height:1.8; font-size:16px; white-space:pre-wrap; min-height:140px; | |
| backdrop-filter: blur(8px); box-shadow: inset 0 2px 8px rgba(0,0,0,.2); | |
| } | |
| /* Enhanced skeleton loading */ | |
| .skeleton{ | |
| position:relative; overflow:hidden; | |
| background:linear-gradient(90deg, rgba(255,255,255,.04), rgba(255,255,255,.08), rgba(255,255,255,.04)); | |
| background-size: 200% 100%; border-radius:12px; | |
| animation: skeletonShimmer 1.5s ease-in-out infinite; | |
| } | |
| @keyframes skeletonShimmer{0%{background-position:-200% 0}100%{background-position:200% 0}} | |
| /* Enhanced messages */ | |
| .msg{ | |
| margin-top:18px; padding:16px 20px; border-radius:16px; | |
| border:1px solid rgba(255,255,255,.1); display:none; font-size:14px; | |
| backdrop-filter: blur(8px); font-weight: 500; | |
| } | |
| .msg.err{ | |
| display:block; color:#ffd8d8; | |
| background:linear-gradient(180deg, rgba(255,107,107,.1), rgba(255,107,107,.05)); | |
| border-color: rgba(255,107,107,.3); | |
| } | |
| .msg.ok{ | |
| display:block; color:#d9fff4; | |
| background:linear-gradient(180deg, rgba(33,209,159,.1), rgba(33,209,159,.05)); | |
| border-color: rgba(33,209,159,.3); | |
| } | |
| /* Enhanced audio section */ | |
| .audio{ | |
| margin-top:18px; padding:20px; | |
| background:linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02)); | |
| border:1px solid rgba(255,255,255,.1); border-radius:18px; | |
| backdrop-filter: blur(8px); | |
| } | |
| audio{ | |
| width:100%; height:44px; outline:none; border-radius: 12px; | |
| background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.1); | |
| } | |
| .foot{ | |
| margin-top:20px; text-align:center; color:#7f8aad; font-size:13px; | |
| font-weight: 300; opacity: 0.8; | |
| } | |
| /* Keyboard shortcuts tooltip */ | |
| .shortcuts{ | |
| position: fixed; bottom: 20px; right: 20px; z-index: 1000; | |
| background: rgba(0,0,0,.8); border: 1px solid rgba(255,255,255,.1); | |
| border-radius: 12px; padding: 12px 16px; color: var(--text-dim); | |
| font-size: 12px; backdrop-filter: blur(12px); | |
| opacity: 0; transform: translateY(20px); transition: var(--transition); | |
| pointer-events: none; | |
| } | |
| .shortcuts.show{opacity: 1; transform: translateY(0); pointer-events: auto} | |
| .shortcuts kbd{ | |
| background: rgba(255,255,255,.1); padding: 2px 6px; border-radius: 4px; | |
| font-family: monospace; font-size: 11px; margin: 0 2px; | |
| } | |
| /* Responsive design */ | |
| @media (max-width:768px){ | |
| .wrap{margin: 60px auto; padding: 0 20px} | |
| .panel{padding: 24px} | |
| .grid{grid-template-columns:1fr} | |
| .btn{width:100%; justify-content: center} | |
| .voices{grid-template-columns: 1fr} | |
| .hero h1{font-size: clamp(28px, 8vw, 40px)} | |
| .particles{display: none} /* Reduce animations on mobile */ | |
| } | |
| @media (max-width:480px){ | |
| .wrap{margin: 40px auto; padding: 0 16px} | |
| .panel{padding: 20px} | |
| .hero{gap: 14px; margin-bottom: 24px} | |
| .row{gap: 12px} | |
| .chips{gap: 8px} | |
| .toolbar{gap: 8px} | |
| } | |
| /* Dark mode enhancements */ | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --bg-0: #080c14; | |
| --bg-1: #0c1218; | |
| --bg-2: #0f1825; | |
| } | |
| } | |
| /* Reduced motion support */ | |
| @media (prefers-reduced-motion: reduce) { | |
| *, *::before, *::after { | |
| animation-duration: 0.01ms ; | |
| animation-iteration-count: 1 ; | |
| transition-duration: 0.01ms ; | |
| } | |
| .particles{display: none} | |
| } | |
| /* High contrast mode */ | |
| @media (prefers-contrast: high) { | |
| :root { | |
| --glass-border: rgba(255,255,255,.3); | |
| --text: #ffffff; | |
| --muted: #cccccc; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="particles"></div> | |
| <div class="bar" id="bar"></div> | |
| <div class="wrap"> | |
| <header class="hero"> | |
| <div class="hero-badge" id="statusBadge"> | |
| <span class="dot" id="statusDot"></span> | |
| <span id="statusText">Loading AI models…</span> | |
| </div> | |
| <h1><span class="grad-text">AI Article Summarizer</span></h1> | |
| <p>Qwen3-0.6B summarization · Kokoro neural TTS · smooth, private, fast</p> | |
| </header> | |
| <section class="panel"> | |
| <!-- Mode switch --> | |
| <div class="row" style="justify-content:center; margin-bottom:16px"> | |
| <div class="seg" role="tablist" aria-label="Input mode"> | |
| <button id="modeUrlBtn" class="active" role="tab" aria-selected="true"> | |
| <span>🔗 URL</span> | |
| </button> | |
| <button id="modeTextBtn" role="tab" aria-selected="false"> | |
| <span>📝 Paste Text</span> | |
| </button> | |
| </div> | |
| </div> | |
| <form id="summarizerForm" autocomplete="on"> | |
| <!-- URL mode --> | |
| <div id="urlMode" class="grid"> | |
| <input id="articleUrl" class="input" type="url" inputmode="url" | |
| placeholder="Paste an article URL (https://…)" | |
| aria-label="Article URL" /> | |
| <button id="submitBtn" class="btn" type="submit"> | |
| <span>✨ Summarize</span> | |
| </button> | |
| </div> | |
| <!-- Text mode --> | |
| <div id="textMode" style="display:none; margin-top:16px"> | |
| <textarea id="articleText" class="textarea" | |
| placeholder="Paste the article text here…" | |
| aria-label="Article text"></textarea> | |
| <div class="hint"> | |
| <span id="charCount">0</span> characters | |
| <span style="margin-left: 12px; opacity: 0.7"> | |
| 💡 Tip: Press <kbd>Ctrl+Enter</kbd> to submit | |
| </span> | |
| </div> | |
| <div style="margin-top:16px"> | |
| <button id="submitBtnText" class="btn" type="submit"> | |
| <span>✨ Summarize Text</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="row"> | |
| <label class="switch" title="Generate audio with Kokoro TTS"> | |
| <input id="generateAudio" type="checkbox" /> | |
| <span class="track"><span class="thumb"></span></span> | |
| <span>🎵 Text-to-Speech</span> | |
| </label> | |
| <span class="chip">🧠 Qwen3-0.6B</span> | |
| <span class="chip">🎤 Kokoro TTS</span> | |
| <span class="chip">🔒 Private</span> | |
| </div> | |
| <div id="voiceSection" class="collapse" aria-hidden="true"> | |
| <div class="voices" id="voiceGrid"></div> | |
| </div> | |
| </form> | |
| <!-- Enhanced loading skeleton --> | |
| <div id="loadingSection" style="display:none; margin-top:24px"> | |
| <div style="margin-bottom: 16px"> | |
| <div class="skeleton" style="height:20px; width:45%; margin-bottom:12px"></div> | |
| <div class="skeleton" style="height:16px; width:92%; margin-bottom:10px"></div> | |
| <div class="skeleton" style="height:16px; width:88%; margin-bottom:10px"></div> | |
| <div class="skeleton" style="height:16px; width:90%; margin-bottom:10px"></div> | |
| <div class="skeleton" style="height:16px; width:65%"></div> | |
| </div> | |
| <div style="color: var(--muted); font-size: 14px; text-align: center; margin-top: 16px"> | |
| 🤖 AI is processing your content... | |
| </div> | |
| </div> | |
| <!-- Results --> | |
| <div id="resultSection" class="results" style="display:none"> | |
| <div class="chips" id="stats"></div> | |
| <div class="toolbar"> | |
| <button class="tbtn" id="copyBtn" type="button" title="Copy summary to clipboard"> | |
| 📋 Copy summary | |
| </button> | |
| <button class="tbtn" id="shareBtn" type="button" title="Share summary"> | |
| 🔗 Share | |
| </button> | |
| <a class="tbtn" id="downloadAudioBtn" href="#" download style="display:none" title="Download audio file"> | |
| ⬇️ Download audio | |
| </a> | |
| </div> | |
| <div id="summaryContent" class="summary" role="region" aria-label="Article summary"></div> | |
| <div id="audioSection" class="audio" style="display:none"> | |
| <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px"> | |
| <strong>🎧 Audio Playback</strong> | |
| <span id="duration" style="color:var(--muted); font-size:13px"></span> | |
| </div> | |
| <audio id="audioPlayer" controls preload="none" aria-label="Summary audio"></audio> | |
| </div> | |
| </div> | |
| <div id="errorMessage" class="msg err" role="alert"></div> | |
| <div id="successMessage" class="msg ok" role="status"></div> | |
| </section> | |
| <p class="foot"> | |
| 💡 Tip: Enable TTS and pick your favorite voice. We'll remember your choice. | |
| <br>Press <kbd>?</kbd> for keyboard shortcuts. | |
| </p> | |
| </div> | |
| <!-- Keyboard shortcuts tooltip --> | |
| <div class="shortcuts" id="shortcutsTooltip"> | |
| <div style="margin-bottom: 8px; font-weight: 600">⌨️ Keyboard Shortcuts</div> | |
| <div><kbd>Ctrl</kbd> + <kbd>Enter</kbd> Submit form</div> | |
| <div><kbd>Tab</kbd> Switch input mode</div> | |
| <div><kbd>Space</kbd> Toggle TTS</div> | |
| <div><kbd>C</kbd> Copy summary</div> | |
| <div><kbd>?</kbd> Show/hide shortcuts</div> | |
| </div> | |
| <script> | |
| // ---------------- Enhanced State Management ---------------- | |
| let modelsReady = false; | |
| let selectedVoice = localStorage.getItem("voiceId") || "af_heart"; | |
| let inputMode = localStorage.getItem("inputMode") || "url"; | |
| let isProcessing = false; | |
| const bar = document.getElementById("bar"); | |
| const shortcutsTooltip = document.getElementById("shortcutsTooltip"); | |
| // --------------- Enhanced Utilities -------------- | |
| const $ = (sel) => document.querySelector(sel); | |
| const $$ = (sel) => document.querySelectorAll(sel); | |
| function showBar(active) { | |
| bar.style.transform = active ? "scaleX(1)" : "scaleX(0)"; | |
| } | |
| function setStatus(ready, error){ | |
| const dot = $("#statusDot"); | |
| const text = $("#statusText"); | |
| const badge = $("#statusBadge"); | |
| if (error){ | |
| dot.classList.remove("ready"); | |
| text.textContent = "⚠️ " + error; | |
| badge.style.borderColor = "rgba(255,107,107,.45)"; | |
| badge.style.background = "linear-gradient(180deg, rgba(255,107,107,.1), rgba(255,107,107,.05))"; | |
| return; | |
| } | |
| if (ready){ | |
| dot.classList.add("ready"); | |
| text.textContent = "✅ Models ready"; | |
| badge.style.borderColor = "rgba(33,209,159,.45)"; | |
| badge.style.background = "linear-gradient(180deg, rgba(33,209,159,.1), rgba(33,209,159,.05))"; | |
| } else { | |
| dot.classList.remove("ready"); | |
| text.textContent = "⏳ Loading AI models…"; | |
| badge.style.borderColor = "rgba(255,184,77,.45)"; | |
| badge.style.background = "linear-gradient(180deg, rgba(255,184,77,.1), rgba(255,184,77,.05))"; | |
| } | |
| } | |
| function chip(text, icon = "") { | |
| const span = document.createElement("span"); | |
| span.className="chip"; | |
| span.textContent = icon + text; | |
| return span; | |
| } | |
| function fmt(x){ return new Intl.NumberFormat().format(x); } | |
| function showMessage(text, type = "ok", duration = 3000) { | |
| const msgEl = type === "ok" ? $("#successMessage") : $("#errorMessage"); | |
| msgEl.textContent = text; | |
| msgEl.style.display = "block"; | |
| if (duration > 0) { | |
| setTimeout(() => msgEl.style.display = "none", duration); | |
| } | |
| } | |
| // ------------- Enhanced Model Status Poll --------- | |
| async function checkModelStatus(){ | |
| try{ | |
| const res = await fetch("/status"); | |
| const s = await res.json(); | |
| modelsReady = !!s.loaded; | |
| setStatus(modelsReady, s.error || null); | |
| if (!modelsReady && !s.error) { | |
| setTimeout(checkModelStatus, 2000); | |
| } | |
| if (modelsReady) { | |
| await loadVoices(); | |
| showMessage("🎉 AI models loaded successfully!", "ok", 2000); | |
| } | |
| }catch(e){ | |
| console.warn("Status check failed:", e); | |
| setTimeout(checkModelStatus, 3000); | |
| } | |
| } | |
| // ------------- Enhanced Voice Loading ------------- | |
| async function loadVoices(){ | |
| try{ | |
| const res = await fetch("/voices"); | |
| const voices = await res.json(); | |
| const grid = $("#voiceGrid"); | |
| grid.innerHTML = ""; | |
| voices.forEach(v=>{ | |
| const el = document.createElement("div"); | |
| el.className = "voice" + (v.id === selectedVoice ? " selected":""); | |
| el.dataset.voice = v.id; | |
| el.innerHTML = ` | |
| <div class="content"> | |
| <div class="name">${v.name}</div> | |
| <div class="meta"> | |
| <span class="badge">Grade ${v.grade}</span> | |
| <span>${v.description || ""}</span> | |
| </div> | |
| </div>`; | |
| el.addEventListener("click", ()=>{ | |
| $$(".voice").forEach(x=>x.classList.remove("selected")); | |
| el.classList.add("selected"); | |
| selectedVoice = v.id; | |
| localStorage.setItem("voiceId", selectedVoice); | |
| // Haptic feedback if available | |
| if (navigator.vibrate) navigator.vibrate(50); | |
| }); | |
| grid.appendChild(el); | |
| }); | |
| }catch(e){ | |
| console.warn("Voice loading failed:", e); | |
| } | |
| } | |
| // ------------- Enhanced Collapsible Voices -------- | |
| const generateAudio = $("#generateAudio"); | |
| const voiceSection = $("#voiceSection"); | |
| function toggleVoices(open){ | |
| voiceSection.classList.toggle("open", !!open); | |
| voiceSection.setAttribute("aria-hidden", open ? "false" : "true"); | |
| localStorage.setItem("ttsEnabled", open); | |
| } | |
| generateAudio.addEventListener("change", e=> { | |
| toggleVoices(e.target.checked); | |
| if (navigator.vibrate) navigator.vibrate(30); | |
| }); | |
| // Restore TTS preference | |
| const ttsEnabled = localStorage.getItem("ttsEnabled") === "true"; | |
| generateAudio.checked = ttsEnabled; | |
| toggleVoices(ttsEnabled); | |
| // ------------- Enhanced Mode Switching ------------ | |
| const urlMode = $("#urlMode"); | |
| const textMode = $("#textMode"); | |
| const modeUrlBtn = $("#modeUrlBtn"); | |
| const modeTextBtn = $("#modeTextBtn"); | |
| const urlInput = $("#articleUrl"); | |
| const textArea = $("#articleText"); | |
| const charCount = $("#charCount"); | |
| function setMode(m){ | |
| inputMode = m; | |
| localStorage.setItem("inputMode", m); | |
| if (m === "url"){ | |
| urlMode.style.display = "grid"; | |
| textMode.style.display = "none"; | |
| modeUrlBtn.classList.add("active"); | |
| modeTextBtn.classList.remove("active"); | |
| modeUrlBtn.setAttribute("aria-selected", "true"); | |
| modeTextBtn.setAttribute("aria-selected", "false"); | |
| setTimeout(() => urlInput.focus(), 100); | |
| } else { | |
| urlMode.style.display = "none"; | |
| textMode.style.display = "block"; | |
| modeTextBtn.classList.add("active"); | |
| modeUrlBtn.classList.remove("active"); | |
| modeTextBtn.setAttribute("aria-selected", "true"); | |
| modeUrlBtn.setAttribute("aria-selected", "false"); | |
| setTimeout(() => textArea.focus(), 100); | |
| } | |
| } | |
| modeUrlBtn.addEventListener("click", ()=> setMode("url")); | |
| modeTextBtn.addEventListener("click", ()=> setMode("text")); | |
| textArea.addEventListener("input", ()=> { | |
| charCount.textContent = (textArea.value || "").length; | |
| }); | |
| // ------------- Enhanced Form Submit ---------------- | |
| const form = $("#summarizerForm"); | |
| const loading = $("#loadingSection"); | |
| const result = $("#resultSection"); | |
| const errorBox = $("#errorMessage"); | |
| const okBox = $("#successMessage"); | |
| const submitBtn = $("#submitBtn"); | |
| const submitBtnText = $("#submitBtnText"); | |
| form.addEventListener("submit", async (e)=>{ | |
| e.preventDefault(); | |
| if (isProcessing) return; | |
| errorBox.style.display="none"; | |
| okBox.style.display="none"; | |
| if (!modelsReady){ | |
| showMessage("Please wait for the AI models to finish loading.", "err"); | |
| return; | |
| } | |
| const url = (urlInput.value || "").trim(); | |
| const text = (textArea.value || "").trim(); | |
| if (!text && !url){ | |
| showMessage("Please paste text or provide a valid URL.", "err"); | |
| return; | |
| } | |
| if (inputMode === "url" && !url){ | |
| showMessage("Please provide a valid URL or switch to Paste Text.", "err"); | |
| return; | |
| } | |
| if (inputMode === "text" && !text){ | |
| showMessage("Please paste the article text or switch to URL.", "err"); | |
| return; | |
| } | |
| isProcessing = true; | |
| if (submitBtn) submitBtn.disabled = true; | |
| if (submitBtnText) submitBtnText.disabled = true; | |
| showBar(true); | |
| loading.style.display = "block"; | |
| result.style.display = "none"; | |
| try{ | |
| const res = await fetch("/process", { | |
| method: "POST", | |
| headers: {"Content-Type":"application/json"}, | |
| body: JSON.stringify({ | |
| url, text, | |
| generate_audio: generateAudio.checked, | |
| voice: selectedVoice | |
| }) | |
| }); | |
| const data = await res.json(); | |
| loading.style.display = "none"; | |
| isProcessing = false; | |
| if (submitBtn) submitBtn.disabled = false; | |
| if (submitBtnText) submitBtnText.disabled = false; | |
| showBar(false); | |
| if (!data.success){ | |
| showMessage(data.error || "Something went wrong.", "err"); | |
| return; | |
| } | |
| renderResult(data); | |
| showMessage("✨ Summary generated successfully!", "ok", 2000); | |
| }catch(err){ | |
| loading.style.display="none"; | |
| isProcessing = false; | |
| if (submitBtn) submitBtn.disabled = false; | |
| if (submitBtnText) submitBtnText.disabled = false; | |
| showBar(false); | |
| showMessage("Network error: " + (err?.message || err), "err"); | |
| } | |
| }); | |
| // ------------- Enhanced Results Rendering ------------- | |
| const stats = $("#stats"); | |
| const summaryEl = $("#summaryContent"); | |
| const audioWrap = $("#audioSection"); | |
| const audioEl = $("#audioPlayer"); | |
| const dlBtn = $("#downloadAudioBtn"); | |
| const durationLabel = $("#duration"); | |
| const copyBtn = $("#copyBtn"); | |
| const shareBtn = $("#shareBtn"); | |
| function renderResult(r){ | |
| stats.innerHTML = ""; | |
| stats.appendChild(chip(`📄 ${fmt(r.article_length)} → ${fmt(r.summary_length)} chars`)); | |
| stats.appendChild(chip(`📉 ${r.compression_ratio}% compression`)); | |
| stats.appendChild(chip(`🕒 ${r.timestamp}`)); | |
| summaryEl.textContent = r.summary || ""; | |
| result.style.display = "block"; | |
| // Smooth scroll to results | |
| setTimeout(() => { | |
| result.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| }, 100); | |
| // Handle audio display - show if audio was requested and generated | |
| if (r.audio_file && r.audio_file.trim()) { | |
| audioEl.src = r.audio_file; | |
| audioWrap.style.display = "block"; | |
| durationLabel.textContent = `${r.audio_duration || 0}s`; | |
| dlBtn.style.display = "inline-flex"; | |
| dlBtn.href = r.audio_file; | |
| dlBtn.download = r.audio_file.split("/").pop() || "summary.wav"; | |
| console.log("Audio section displayed:", r.audio_file); | |
| } else if (generateAudio.checked && !r.audio_file) { | |
| // Show message if audio was requested but failed | |
| audioWrap.style.display = "block"; | |
| audioWrap.innerHTML = ` | |
| <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px"> | |
| <strong>⚠️ Audio Generation</strong> | |
| </div> | |
| <div style="color: var(--warn); font-size: 14px;"> | |
| ${r.audio_error || "Audio generation failed. Please try again."} | |
| </div> | |
| `; | |
| dlBtn.style.display = "none"; | |
| console.log("Audio error displayed:", r.audio_error); | |
| } else { | |
| audioWrap.style.display = "none"; | |
| dlBtn.style.display = "none"; | |
| console.log("Audio section hidden - no audio requested or generated"); | |
| } | |
| } | |
| // ------------- Enhanced Copy & Share ------------- | |
| copyBtn.addEventListener("click", async ()=>{ | |
| try{ | |
| await navigator.clipboard.writeText(summaryEl.textContent || ""); | |
| copyBtn.innerHTML = "✅ Copied"; | |
| setTimeout(()=> copyBtn.innerHTML = "📋 Copy summary", 1200); | |
| if (navigator.vibrate) navigator.vibrate(50); | |
| }catch(e){ | |
| // Fallback for older browsers | |
| const textArea = document.createElement('textarea'); | |
| textArea.value = summaryEl.textContent || ""; | |
| document.body.appendChild(textArea); | |
| textArea.select(); | |
| document.execCommand('copy'); | |
| document.body.removeChild(textArea); | |
| copyBtn.innerHTML = "✅ Copied"; | |
| setTimeout(()=> copyBtn.innerHTML = "📋 Copy summary", 1200); | |
| } | |
| }); | |
| shareBtn.addEventListener("click", async ()=>{ | |
| const summary = summaryEl.textContent || ""; | |
| const shareData = { | |
| title: 'AI Article Summary', | |
| text: summary, | |
| url: window.location.href | |
| }; | |
| try { | |
| if (navigator.share) { | |
| await navigator.share(shareData); | |
| } else { | |
| // Fallback: copy to clipboard | |
| await navigator.clipboard.writeText(`AI Article Summary:\n\n${summary}\n\nGenerated at: ${window.location.href}`); | |
| shareBtn.innerHTML = "✅ Link copied"; | |
| setTimeout(()=> shareBtn.innerHTML = "🔗 Share", 1200); | |
| } | |
| } catch (e) { | |
| console.warn('Sharing failed:', e); | |
| } | |
| }); | |
| // ------------- Enhanced Keyboard Shortcuts ------------- | |
| let shortcutsVisible = false; | |
| function toggleShortcuts() { | |
| shortcutsVisible = !shortcutsVisible; | |
| shortcutsTooltip.classList.toggle('show', shortcutsVisible); | |
| } | |
| document.addEventListener("keydown", (e) => { | |
| // Ignore if user is typing in input fields | |
| if (e.target.matches('input, textarea')) { | |
| // Allow Ctrl+Enter in textarea | |
| if (e.ctrlKey && e.key === 'Enter') { | |
| e.preventDefault(); | |
| form.dispatchEvent(new Event('submit')); | |
| } | |
| return; | |
| } | |
| switch(e.key.toLowerCase()) { | |
| case '?': | |
| e.preventDefault(); | |
| toggleShortcuts(); | |
| break; | |
| case 'tab': | |
| e.preventDefault(); | |
| setMode(inputMode === "url" ? "text" : "url"); | |
| break; | |
| case ' ': | |
| e.preventDefault(); | |
| generateAudio.checked = !generateAudio.checked; | |
| generateAudio.dispatchEvent(new Event('change')); | |
| break; | |
| case 'c': | |
| if (summaryEl.textContent) { | |
| e.preventDefault(); | |
| copyBtn.click(); | |
| } | |
| break; | |
| case 'escape': | |
| if (shortcutsVisible) { | |
| e.preventDefault(); | |
| toggleShortcuts(); | |
| } | |
| break; | |
| } | |
| }); | |
| // ------------- Enhanced Auto-paste Detection ------------- | |
| window.addEventListener("paste", (e)=>{ | |
| if (inputMode === "url" && document.activeElement !== urlInput && !urlInput.value){ | |
| const text = (e.clipboardData || window.clipboardData).getData("text"); | |
| if (text?.match(/^https?:\/\//)) { | |
| urlInput.value = text; | |
| urlInput.focus(); | |
| showMessage("📎 URL pasted automatically", "ok", 1500); | |
| } | |
| } | |
| }); | |
| // ------------- Enhanced Initialization ------------- | |
| document.addEventListener("DOMContentLoaded", ()=>{ | |
| // Initialize status check | |
| checkModelStatus(); | |
| // Restore preferences | |
| if (localStorage.getItem("voiceId")) { | |
| selectedVoice = localStorage.getItem("voiceId"); | |
| } | |
| // Set initial mode | |
| setMode(inputMode); | |
| charCount.textContent = "0"; | |
| // Add loading animation to buttons | |
| [submitBtn, submitBtnText].forEach(btn => { | |
| if (btn) { | |
| btn.addEventListener('click', () => { | |
| if (!btn.disabled) { | |
| btn.style.transform = 'scale(0.98)'; | |
| setTimeout(() => btn.style.transform = '', 100); | |
| } | |
| }); | |
| } | |
| }); | |
| // Enhanced focus management | |
| const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; | |
| const modal = document.querySelector('.panel'); | |
| const firstFocusableElement = modal.querySelectorAll(focusableElements)[0]; | |
| const focusableContent = modal.querySelectorAll(focusableElements); | |
| const lastFocusableElement = focusableContent[focusableContent.length - 1]; | |
| document.addEventListener('keydown', function(e) { | |
| if (e.key === 'Tab') { | |
| if (e.shiftKey) { | |
| if (document.activeElement === firstFocusableElement) { | |
| lastFocusableElement.focus(); | |
| e.preventDefault(); | |
| } | |
| } else { | |
| if (document.activeElement === lastFocusableElement) { | |
| firstFocusableElement.focus(); | |
| e.preventDefault(); | |
| } | |
| } | |
| } | |
| }); | |
| // Performance optimization: Intersection Observer for animations | |
| if ('IntersectionObserver' in window) { | |
| const observer = new IntersectionObserver((entries) => { | |
| entries.forEach(entry => { | |
| if (entry.isIntersecting) { | |
| entry.target.style.animationPlayState = 'running'; | |
| } | |
| }); | |
| }); | |
| document.querySelectorAll('.hero, .panel').forEach(el => { | |
| observer.observe(el); | |
| }); | |
| } | |
| // Service Worker registration for offline support | |
| if ('serviceWorker' in navigator) { | |
| navigator.serviceWorker.register('/sw.js').catch(() => { | |
| // Silently fail if no service worker | |
| }); | |
| } | |
| }); | |
| // ------------- Performance Monitoring ------------- | |
| if ('performance' in window) { | |
| window.addEventListener('load', () => { | |
| setTimeout(() => { | |
| const perfData = performance.getEntriesByType('navigation')[0]; | |
| if (perfData && perfData.loadEventEnd > 0) { | |
| console.log(`Page loaded in ${Math.round(perfData.loadEventEnd)}ms`); | |
| } | |
| }, 0); | |
| }); | |
| } | |
| // ------------- Error Boundary ------------- | |
| window.addEventListener('error', (e) => { | |
| console.error('Global error:', e.error); | |
| showMessage('An unexpected error occurred. Please refresh the page.', 'err'); | |
| }); | |
| window.addEventListener('unhandledrejection', (e) => { | |
| console.error('Unhandled promise rejection:', e.reason); | |
| showMessage('A network error occurred. Please try again.', 'err'); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |