change web design
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -3,10 +3,12 @@ from flask import Flask
|
||||
from config import Config
|
||||
from routes.transcriptionRoute import transcription_bp
|
||||
from routes.scraperZLibraryRoute import scraperZLibraryRoute
|
||||
from routes.headerRoute import header_bp
|
||||
|
||||
def create_app():
|
||||
"""Application factory function"""
|
||||
app = Flask(__name__, template_folder='views')
|
||||
# static_folder=None → tắt hoàn toàn thư mục /static mặc định của Flask
|
||||
app = Flask(__name__, template_folder='views', static_folder=None)
|
||||
|
||||
# Load configuration
|
||||
app.config.from_object(Config)
|
||||
@@ -18,6 +20,7 @@ def create_app():
|
||||
Config.init_app()
|
||||
|
||||
# Register blueprints
|
||||
app.register_blueprint(header_bp)
|
||||
app.register_blueprint(transcription_bp)
|
||||
app.register_blueprint(scraperZLibraryRoute, url_prefix='/scraperZLibrary')
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# routes/headerRoute.py
|
||||
from flask import Blueprint
|
||||
|
||||
# Blueprint để serve headerLoader.js từ views/header/
|
||||
# → URL: /assets/header/headerLoader.js
|
||||
header_bp = Blueprint(
|
||||
'header',
|
||||
__name__,
|
||||
static_folder='../views/header',
|
||||
static_url_path='/assets/header',
|
||||
)
|
||||
@@ -2,8 +2,15 @@
|
||||
from flask import Blueprint
|
||||
from controllers.scraperZLibraryController import ScraperController
|
||||
|
||||
# Create blueprint
|
||||
scraperZLibraryRoute = Blueprint('scraperZLibrary', __name__)
|
||||
# Blueprint với static_folder trỏ vào views/scraperZLibraryPages
|
||||
# → Flask serve CSS/JS qua URL /scraperZLibrary/assets/scraper/...
|
||||
# Lưu ý: static_url_path chỉ cần phần sau /scraperZLibrary/ (url_prefix đã được thêm khi register)
|
||||
scraperZLibraryRoute = Blueprint(
|
||||
'scraperZLibrary',
|
||||
__name__,
|
||||
static_folder='../views/scraperZLibraryPages',
|
||||
static_url_path='/assets/scraper',
|
||||
)
|
||||
|
||||
# Define routes
|
||||
scraperZLibraryRoute.route('/', methods=['GET'])(ScraperController.index)
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
from flask import Blueprint
|
||||
from controllers.transcriptionController import TranscriptionController
|
||||
|
||||
# Create blueprint
|
||||
transcription_bp = Blueprint('transcription', __name__)
|
||||
# Blueprint với static_folder trỏ vào views/transcriptionPages
|
||||
# → Flask serve CSS/JS qua URL /assets/transcription/...
|
||||
transcription_bp = Blueprint(
|
||||
'transcription',
|
||||
__name__,
|
||||
static_folder='../views/transcriptionPages',
|
||||
static_url_path='/assets/transcription',
|
||||
)
|
||||
|
||||
# Define routes
|
||||
transcription_bp.route('/', methods=['GET'])(TranscriptionController.index)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<!-- static/header/header.html -->
|
||||
<header class="app-header">
|
||||
<nav class="header-nav">
|
||||
<div class="nav-container">
|
||||
<ul class="nav-links">
|
||||
<li>
|
||||
<a href="/" class="nav-link" data-nav="transcription">📝 Phiên Âm</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/scraperZLibrary" class="nav-link" data-nav="scraper"
|
||||
>📚 Z-Library Scraper</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -1,187 +0,0 @@
|
||||
// static/header/headerLoader.js
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
headerPath: "/static/header/header.html",
|
||||
insertPosition: "afterbegin",
|
||||
containerSelector: "body",
|
||||
activeClass: "active",
|
||||
version: "1.0",
|
||||
};
|
||||
|
||||
// Styles
|
||||
const styles = `
|
||||
<style id="header-component-styles">
|
||||
.app-header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #111;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
color: #444;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
padding: 8px 0;
|
||||
transition: all 0.15s ease;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #111;
|
||||
border-bottom-color: #ccc;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: #111;
|
||||
border-bottom-color: #111;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Ensure content doesn't hide behind fixed header */
|
||||
body {
|
||||
padding-top: 64px !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 600px) {
|
||||
.nav-container {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
padding: 16px 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: 100px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Load header function
|
||||
async function loadHeader() {
|
||||
try {
|
||||
// Fetch header HTML
|
||||
const response = await fetch(`${CONFIG.headerPath}?v=${CONFIG.version}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load header: ${response.status}`);
|
||||
}
|
||||
|
||||
const headerHTML = await response.text();
|
||||
|
||||
// Inject styles if not already present
|
||||
if (!document.getElementById("header-component-styles")) {
|
||||
document.head.insertAdjacentHTML("beforeend", styles);
|
||||
}
|
||||
|
||||
// Insert header
|
||||
const container = document.querySelector(CONFIG.containerSelector);
|
||||
container.insertAdjacentHTML(CONFIG.insertPosition, headerHTML);
|
||||
|
||||
// Highlight active link
|
||||
highlightActiveLink();
|
||||
|
||||
console.log("✅ Header component loaded v" + CONFIG.version);
|
||||
} catch (error) {
|
||||
console.error("❌ Error loading header:", error);
|
||||
createFallbackHeader();
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight current page link
|
||||
function highlightActiveLink() {
|
||||
const currentPath = window.location.pathname;
|
||||
const links = document.querySelectorAll(".nav-link");
|
||||
|
||||
links.forEach((link) => {
|
||||
const href = link.getAttribute("href");
|
||||
|
||||
// Check if current path matches
|
||||
if (
|
||||
currentPath === href ||
|
||||
(href !== "/" && currentPath.startsWith(href))
|
||||
) {
|
||||
link.classList.add(CONFIG.activeClass);
|
||||
}
|
||||
|
||||
// Special case for root
|
||||
if (currentPath === "/" && href === "/transcription") {
|
||||
link.classList.add(CONFIG.activeClass);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback header in case of error
|
||||
function createFallbackHeader() {
|
||||
const fallback = document.createElement("div");
|
||||
fallback.style.cssText = `
|
||||
padding: 15px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
`;
|
||||
fallback.innerHTML = `
|
||||
<div style="max-width: 1200px; margin: 0 auto; display: flex; gap: 30px;">
|
||||
<a href="/" style="color: #111; text-decoration: none;">📝 Phiên Âm</a>
|
||||
<a href="/scraperZLibrary" style="color: #111; text-decoration: none;">📚 Z-Library Scraper</a>
|
||||
</div>
|
||||
`;
|
||||
document.body.insertAdjacentElement("afterbegin", fallback);
|
||||
document.body.style.paddingTop = "50px";
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", loadHeader);
|
||||
} else {
|
||||
loadHeader();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,234 @@
|
||||
// views/header/headerLoader.js
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
insertPosition: "afterbegin",
|
||||
containerSelector: "body",
|
||||
activeClass: "active",
|
||||
version: "1.1",
|
||||
};
|
||||
|
||||
// Header HTML inlined — không cần fetch file nữa
|
||||
const headerHTML = `
|
||||
<header class="app-header">
|
||||
<nav class="header-nav">
|
||||
<div class="nav-container">
|
||||
<!-- Left: Brand -->
|
||||
<div class="nav-left">
|
||||
<a href="/" class="nav-brand">KYLONG.</a>
|
||||
</div>
|
||||
|
||||
<!-- Center: Navigation -->
|
||||
<div class="nav-center">
|
||||
<ul class="nav-links">
|
||||
<li>
|
||||
<a href="/" class="nav-link" data-nav="transcription">Phiên Âm</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/scraperZLibrary" class="nav-link" data-nav="scraper">Z-Library</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Right: Utility -->
|
||||
<div class="nav-right">
|
||||
<span class="status-badge"><span class="dot"></span> Online</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>`;
|
||||
|
||||
const styles = `
|
||||
<style id="header-component-styles">
|
||||
@import url('https://db.onlinewebfonts.com/c/5ac3fe7c6abd2f62067f266d89671492?family=HelveticaNowDisplay-Medium');
|
||||
@import url('https://db.onlinewebfonts.com/c/1aa3377e489837a26d019bba501e779d?family=HelveticaNowDisplayW01-Rg');
|
||||
|
||||
.app-header {
|
||||
background: rgba(244, 243, 239, 0.85);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border-bottom: 1px solid rgba(10,10,10,0.06);
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* top-edge glint */
|
||||
.app-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%, rgba(255,255,255,0.6) 50%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
height: 54px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nav-left { display: flex; align-items: center; justify-content: flex-start; }
|
||||
.nav-center { display: flex; align-items: center; justify-content: center; }
|
||||
.nav-right { display: flex; align-items: center; justify-content: flex-end; }
|
||||
|
||||
.nav-brand {
|
||||
font-family: 'HelveticaNowDisplay-Medium', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0a0a0a;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
background: rgba(10,10,10,0.04);
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(10,10,10,0.05);
|
||||
box-shadow: inset 0 1px 3px rgba(10,10,10,0.02);
|
||||
}
|
||||
|
||||
.nav-links li {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'HelveticaNowDisplayW01-Rg', 'Helvetica Neue', Arial, sans-serif;
|
||||
text-decoration: none;
|
||||
color: rgba(10,10,10,0.50);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
padding: 6px 18px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid transparent;
|
||||
transition: background .18s, color .18s, border-color .18s, box-shadow .18s;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: #0a0a0a;
|
||||
background: #ffffff;
|
||||
border-color: rgba(10,10,10,0.08);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05), inset 0 1px 0 rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(10,10,10,0.45);
|
||||
}
|
||||
|
||||
.status-badge .dot {
|
||||
width: 6px; height: 6px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: 54px !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.nav-container {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
height: 48px;
|
||||
gap: 10px;
|
||||
}
|
||||
.nav-center { order: 3; grid-column: 1 / -1; margin-top: -8px; margin-bottom: 8px; }
|
||||
.nav-right { grid-column: 3; }
|
||||
.nav-brand { font-size: 12px; }
|
||||
.nav-link { font-size: 12px; padding: 5px 14px; }
|
||||
body { padding-top: 90px !important; }
|
||||
.app-header { padding-bottom: 8px; }
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Inject header directly (no fetch needed)
|
||||
function loadHeader() {
|
||||
// Inject styles if not already present
|
||||
if (!document.getElementById("header-component-styles")) {
|
||||
document.head.insertAdjacentHTML("beforeend", styles);
|
||||
}
|
||||
|
||||
// Insert header HTML inline
|
||||
const container = document.querySelector(CONFIG.containerSelector);
|
||||
container.insertAdjacentHTML(CONFIG.insertPosition, headerHTML);
|
||||
|
||||
// Highlight active link
|
||||
highlightActiveLink();
|
||||
|
||||
console.log("✅ Header component loaded v" + CONFIG.version);
|
||||
}
|
||||
|
||||
// Highlight current page link
|
||||
function highlightActiveLink() {
|
||||
const currentPath = window.location.pathname;
|
||||
const links = document.querySelectorAll(".nav-link");
|
||||
|
||||
links.forEach((link) => {
|
||||
const href = link.getAttribute("href");
|
||||
|
||||
// Check if current path matches
|
||||
if (
|
||||
currentPath === href ||
|
||||
(href !== "/" && currentPath.startsWith(href))
|
||||
) {
|
||||
link.classList.add(CONFIG.activeClass);
|
||||
}
|
||||
|
||||
// Special case for root
|
||||
if (currentPath === "/" && href === "/transcription") {
|
||||
link.classList.add(CONFIG.activeClass);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", loadHeader);
|
||||
} else {
|
||||
loadHeader();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,436 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
scraperZLibraryMain.css · Mainframe Design System v3
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
--font-heading: 'HelveticaNowDisplay-Medium','Helvetica Neue',Arial,sans-serif;
|
||||
--font-body: 'HelveticaNowDisplayW01-Rg','Helvetica Neue',Arial,sans-serif;
|
||||
--ink: #0a0a0a;
|
||||
--ink-70: rgba(10,10,10,0.70);
|
||||
--ink-45: rgba(10,10,10,0.45);
|
||||
--ink-20: rgba(10,10,10,0.20);
|
||||
--ink-10: rgba(10,10,10,0.10);
|
||||
--ink-06: rgba(10,10,10,0.06);
|
||||
--ink-03: rgba(10,10,10,0.03);
|
||||
--surface: #f4f3ef;
|
||||
--surface-2: #eeecea;
|
||||
--card: #ffffff;
|
||||
--radius-pill: 9999px;
|
||||
--radius-xl: 24px;
|
||||
--radius-lg: 18px;
|
||||
--radius-md: 12px;
|
||||
--shadow-xs: 0 1px 2px rgba(0,0,0,0.06);
|
||||
--shadow-sm: 0 2px 10px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 6px 28px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.05);
|
||||
--shadow-inset: inset 0 1px 0 rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity:0; transform:translateY(14px); }
|
||||
to { opacity:1; transform:none; }
|
||||
}
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
|
||||
*,*::before,*::after { margin:0; padding:0; box-sizing:border-box; }
|
||||
|
||||
html {
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--surface);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='0.028'/%3E%3C/svg%3E");
|
||||
padding: 0 28px 100px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
/* ─── Layout ─────────────────────────────────────────────── */
|
||||
.container {
|
||||
max-width: 1260px;
|
||||
margin: 0 auto;
|
||||
animation: fade-up .65s cubic-bezier(.22,1,.36,1) backwards;
|
||||
animation-delay: .04s;
|
||||
}
|
||||
|
||||
/* ─── Page Header ────────────────────────────────────────── */
|
||||
.header {
|
||||
padding: 42px 0 28px;
|
||||
border-bottom: 1px solid var(--ink-10);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Eyebrow */
|
||||
.header::before {
|
||||
content: '✦ Z-Library Search';
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-45);
|
||||
margin-bottom: 14px;
|
||||
padding: 6px 14px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-pill);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-family: var(--font-heading);
|
||||
font-size: clamp(30px, 5.5vw, 50px);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 1.0;
|
||||
color: var(--ink);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
color: var(--ink-45);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ─── Search Section ─────────────────────────────────────── */
|
||||
.search-section {
|
||||
padding: 26px 0;
|
||||
border-bottom: 1px solid var(--ink-06);
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-group { flex:1; min-width:200px; }
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .1em;
|
||||
color: var(--ink-45);
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 11px 18px;
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-body);
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow-xs), var(--shadow-inset);
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ink-45);
|
||||
box-shadow: 0 0 0 3px rgba(10,10,10,.07), var(--shadow-inset);
|
||||
}
|
||||
.form-group input::placeholder { color: var(--ink-20); }
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--ink-45);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* ─── Checkbox pills ─────────────────────────────────────── */
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink-70);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 6px 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-xs), var(--shadow-inset);
|
||||
user-select: none;
|
||||
transition: background .18s, color .18s, border-color .18s, box-shadow .18s;
|
||||
}
|
||||
.checkbox-group label:hover {
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
border-color: var(--ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
accent-color: var(--ink);
|
||||
width: 13px; height: 13px; cursor: pointer;
|
||||
}
|
||||
|
||||
/* ─── Buttons ────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 9px 22px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
letter-spacing: -0.01em;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--ink-10);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow-xs), var(--shadow-inset);
|
||||
margin: 0 5px 5px 0;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background .2s, color .2s, border-color .2s, box-shadow .2s,
|
||||
transform .18s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
border-color: var(--ink);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.btn:active:not(:disabled) { transform:scale(.97) translateY(0); box-shadow:var(--shadow-xs); }
|
||||
.btn:disabled { opacity:.35; cursor:not-allowed; }
|
||||
|
||||
.btn-primary { background:var(--ink); color:#fff; border-color:var(--ink); box-shadow:var(--shadow-sm); }
|
||||
.btn-primary:hover:not(:disabled) { background:#1a1a1a; }
|
||||
|
||||
.btn-download { background:var(--ink); color:#fff; border-color:var(--ink); box-shadow:var(--shadow-sm); }
|
||||
.btn-download:hover:not(:disabled) { background:#1a1a1a; }
|
||||
|
||||
/* ─── Results Section ────────────────────────────────────── */
|
||||
.results-section { padding: 26px 0; }
|
||||
|
||||
/* ─── Stats Bar — horizontal card row ───────────────────── */
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-xl);
|
||||
margin-bottom: 26px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm), var(--shadow-inset);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 22px 26px;
|
||||
border-right: 1px solid var(--ink-06);
|
||||
position: relative;
|
||||
}
|
||||
.stat-item:last-child { border-right: none; }
|
||||
.stat-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 26px; right: 26px;
|
||||
height: 1.5px;
|
||||
background: linear-gradient(90deg, transparent 0%, var(--ink-10) 50%, transparent 100%);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .1em;
|
||||
color: var(--ink-45);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.stat-value {
|
||||
font-family: var(--font-heading);
|
||||
font-size: clamp(24px, 3.5vw, 34px);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.05em;
|
||||
color: var(--ink);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
/* ─── Books Grid ─────────────────────────────────────────── */
|
||||
.books-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--ink-10);
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
margin-top: 18px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.book-card {
|
||||
background: var(--card);
|
||||
padding: 22px 20px 18px;
|
||||
position: relative;
|
||||
transition: background .18s;
|
||||
}
|
||||
.book-card:hover { background: #fdfcf9; }
|
||||
.book-card.selected {
|
||||
background: rgba(10,10,10,0.025);
|
||||
box-shadow: inset 3px 0 0 var(--ink);
|
||||
}
|
||||
|
||||
.book-checkbox {
|
||||
position: absolute;
|
||||
top: 18px; right: 18px;
|
||||
width: 15px; height: 15px;
|
||||
accent-color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.book-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
padding-right: 26px;
|
||||
}
|
||||
.book-authors {
|
||||
font-size: 12px;
|
||||
color: var(--ink-45);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.book-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px 10px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--ink-06);
|
||||
}
|
||||
.detail-item { font-size: 11px; }
|
||||
.detail-label {
|
||||
color: var(--ink-45);
|
||||
text-transform: uppercase;
|
||||
font-size: 9.5px;
|
||||
letter-spacing: .07em;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.detail-value { color: var(--ink-70); }
|
||||
|
||||
.book-link {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-color: var(--ink-20);
|
||||
transition: text-decoration-color .18s;
|
||||
}
|
||||
.book-link:hover { text-decoration-color: var(--ink); }
|
||||
|
||||
/* ─── Loading ─────────────────────────────────────────────── */
|
||||
.loading { text-align:center; padding:80px 20px; display:none; }
|
||||
.spinner {
|
||||
width:26px; height:26px;
|
||||
border:1.5px solid var(--ink-10);
|
||||
border-top-color: var(--ink);
|
||||
border-radius:50%;
|
||||
animation:spin .7s linear infinite;
|
||||
margin:0 auto 16px;
|
||||
}
|
||||
.loading p { font-size:13px; color:var(--ink-45); }
|
||||
|
||||
/* ─── Misc ───────────────────────────────────────────────── */
|
||||
.progress-container { margin-top:12px; }
|
||||
.progress-text { font-size:11px; color:var(--ink-45); margin-top:5px; }
|
||||
|
||||
.error-message {
|
||||
background:var(--card);
|
||||
border:1px solid var(--ink-10);
|
||||
color:var(--ink-70);
|
||||
padding:12px 18px;
|
||||
border-radius:var(--radius-md);
|
||||
margin:14px 0;
|
||||
font-size:13px;
|
||||
display:none;
|
||||
box-shadow:var(--shadow-xs);
|
||||
}
|
||||
|
||||
.empty-state { text-align:center; padding:80px 24px; color:var(--ink-45); }
|
||||
.empty-state h3 {
|
||||
font-family:var(--font-heading);
|
||||
font-size:20px;
|
||||
font-weight:500;
|
||||
letter-spacing:-0.04em;
|
||||
color:var(--ink);
|
||||
margin-bottom:8px;
|
||||
}
|
||||
.empty-state p { font-size:14px; }
|
||||
|
||||
.download-section { display:flex; gap:6px; flex-wrap:wrap; margin:18px 0; }
|
||||
|
||||
.selection-bar {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
padding:12px 0;
|
||||
border-bottom:1px solid var(--ink-06);
|
||||
margin-bottom:14px;
|
||||
flex-wrap:wrap;
|
||||
gap:10px;
|
||||
}
|
||||
.selection-controls { display:flex; align-items:center; gap:10px; }
|
||||
.selection-controls label {
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
font-size:13px;
|
||||
color:var(--ink);
|
||||
cursor:pointer;
|
||||
}
|
||||
.selection-controls label input { accent-color:var(--ink); }
|
||||
.selection-info { font-size:12px; color:var(--ink-45); }
|
||||
|
||||
.stats-detail { margin-top:30px; padding-top:22px; border-top:1px solid var(--ink-10); }
|
||||
.stats-detail h3 {
|
||||
font-size:10px;
|
||||
font-weight:700;
|
||||
letter-spacing:.1em;
|
||||
text-transform:uppercase;
|
||||
color:var(--ink-45);
|
||||
margin-bottom:16px;
|
||||
}
|
||||
|
||||
hr { border:none; border-top:1px solid var(--ink-06); margin:14px 0; }
|
||||
|
||||
.load-more-container { margin-top:28px; text-align:center; }
|
||||
.load-more-btn { width:100%; padding:13px; font-size:13px; }
|
||||
|
||||
#resultCountHeader {
|
||||
font-size:10px;
|
||||
font-weight:700;
|
||||
letter-spacing:.1em;
|
||||
text-transform:uppercase;
|
||||
color:var(--ink-45);
|
||||
margin:20px 0 14px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,753 @@
|
||||
let currentResults = null;
|
||||
let searchInProgress = false;
|
||||
let downloadInProgress = false;
|
||||
let loadMoreInProgress = false;
|
||||
let selectedBooks = new Set();
|
||||
let currentQuery = "";
|
||||
let currentPage = 1;
|
||||
let totalPagesAvailable = 0;
|
||||
let headlessMode = true;
|
||||
let totalBooksCount = 0;
|
||||
|
||||
async function searchBooks(resetResults = true) {
|
||||
if (searchInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = document.getElementById("query").value.trim();
|
||||
const headless = document.getElementById("headless").checked;
|
||||
|
||||
headlessMode = headless;
|
||||
currentQuery = query;
|
||||
|
||||
if (!query) {
|
||||
showError("Vui lòng nhập từ khóa tìm kiếm");
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.length < 2) {
|
||||
showError("Từ khóa phải có ít nhất 2 ký tự");
|
||||
return;
|
||||
}
|
||||
|
||||
searchInProgress = true;
|
||||
|
||||
if (resetResults) {
|
||||
selectedBooks.clear();
|
||||
currentPage = 1;
|
||||
currentResults = null;
|
||||
}
|
||||
|
||||
document.getElementById("loadingIndicator").style.display = "block";
|
||||
document.getElementById("loadingMessage").textContent = resetResults
|
||||
? "Đang tìm kiếm sách..."
|
||||
: `Đang tải trang ${currentPage + 1}...`;
|
||||
document.getElementById("errorMessage").style.display = "none";
|
||||
|
||||
if (resetResults) {
|
||||
document.getElementById("resultsContent").innerHTML = "";
|
||||
}
|
||||
|
||||
document.getElementById("progressText").textContent =
|
||||
"Đang khởi tạo...";
|
||||
|
||||
const searchBtn = document.getElementById("searchBtn");
|
||||
searchBtn.disabled = true;
|
||||
searchBtn.textContent = resetResults ? "Đang tìm..." : "Đang tải...";
|
||||
|
||||
try {
|
||||
const nextPage = resetResults ? 1 : currentPage + 1;
|
||||
|
||||
const response = await fetch("/scraperZLibrary/api/search", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: query,
|
||||
page: nextPage,
|
||||
headless: headless,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (resetResults) {
|
||||
currentResults = data;
|
||||
currentPage = data.current_page || 1;
|
||||
totalPagesAvailable = data.total_pages || 1;
|
||||
totalBooksCount = data.total_books_count || data.books.length;
|
||||
displayResults(data);
|
||||
} else {
|
||||
// Merge new books with existing results
|
||||
if (currentResults) {
|
||||
currentResults.books = [...currentResults.books, ...data.books];
|
||||
currentResults.total_count = currentResults.books.length;
|
||||
currentPage = data.current_page;
|
||||
totalPagesAvailable = data.total_pages;
|
||||
totalBooksCount = data.total_books_count || totalBooksCount;
|
||||
appendBooksToGrid(
|
||||
data.books,
|
||||
currentResults.books.length - data.books.length,
|
||||
);
|
||||
updateStatsBar(currentResults);
|
||||
updateResultCount();
|
||||
updateSelectionInfo();
|
||||
} else {
|
||||
currentResults = data;
|
||||
currentPage = data.current_page || 1;
|
||||
totalPagesAvailable = data.total_pages || 1;
|
||||
totalBooksCount = data.total_books_count || data.books.length;
|
||||
displayResults(data);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showError(data.error || "Có lỗi xảy ra khi tìm kiếm");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Search error:", error);
|
||||
showError("Không thể kết nối đến máy chủ.");
|
||||
} finally {
|
||||
searchInProgress = false;
|
||||
loadMoreInProgress = false;
|
||||
document.getElementById("loadingIndicator").style.display = "none";
|
||||
searchBtn.disabled = false;
|
||||
searchBtn.textContent = "Tìm kiếm";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreBooks() {
|
||||
if (loadMoreInProgress || searchInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPage >= totalPagesAvailable) {
|
||||
alert("Đã tải hết tất cả các trang!");
|
||||
return;
|
||||
}
|
||||
|
||||
loadMoreInProgress = true;
|
||||
await searchBooks(false);
|
||||
}
|
||||
|
||||
function appendBooksToGrid(newBooks, startIndex) {
|
||||
const booksGrid = document.querySelector(".books-grid");
|
||||
if (!booksGrid) return;
|
||||
|
||||
let html = "";
|
||||
newBooks.forEach((book, idx) => {
|
||||
const index = startIndex + idx;
|
||||
const bookId = `book-${index}`;
|
||||
html += `
|
||||
<div class="book-card" id="${bookId}">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="book-checkbox"
|
||||
data-book-index="${index}"
|
||||
onchange="toggleBookSelection(${index}, this.checked)"
|
||||
>
|
||||
<div class="book-title">${index + 1}. ${escapeHtml(book.title)}</div>
|
||||
<div class="book-authors">${escapeHtml(book.authors.join(", ") || "Không rõ")}</div>
|
||||
|
||||
<div class="book-details">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">NXB</div>
|
||||
<div class="detail-value">${escapeHtml(book.publisher)}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Năm</div>
|
||||
<div class="detail-value">${escapeHtml(book.year)}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Số trang</div>
|
||||
<div class="detail-value">${escapeHtml(book.pages)}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Ngôn ngữ</div>
|
||||
<div class="detail-value">${escapeHtml(book.language)}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Định dạng</div>
|
||||
<div class="detail-value">${escapeHtml(book.file)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
book.link !== "N/A"
|
||||
? `<a href="${escapeHtml(book.link)}" target="_blank" class="book-link" rel="noopener noreferrer">Xem sách →</a>`
|
||||
: '<span style="color: #999; font-size: 0.85em;">Không có liên kết</span>'
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
booksGrid.insertAdjacentHTML("beforeend", html);
|
||||
|
||||
// Update load more button
|
||||
updateLoadMoreButton();
|
||||
}
|
||||
|
||||
function updateResultCount() {
|
||||
const resultHeader = document.querySelector("#resultCountHeader");
|
||||
if (resultHeader) {
|
||||
resultHeader.textContent = `Kết quả (${currentResults.books.length} sách đã tải / ${totalBooksCount} tổng cộng)`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatsBar(data) {
|
||||
const statsBar = document.querySelector(".stats-bar");
|
||||
if (statsBar) {
|
||||
statsBar.innerHTML = `
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Tổng số sách</div>
|
||||
<div class="stat-value">${totalBooksCount}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Từ khóa</div>
|
||||
<div class="stat-value" style="font-size: 1.5em;">"${escapeHtml(data.query)}"</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Trang hiện tại</div>
|
||||
<div class="stat-value" style="font-size: 1.5em;">${currentPage} / ${totalPagesAvailable}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateLoadMoreButton() {
|
||||
const loadMoreContainer = document.getElementById("loadMoreContainer");
|
||||
if (loadMoreContainer) {
|
||||
if (currentPage >= totalPagesAvailable) {
|
||||
loadMoreContainer.innerHTML = `
|
||||
<button class="btn btn-secondary load-more-btn" disabled>
|
||||
✅ Đã tải hết ${totalPagesAvailable} trang (${currentResults.books.length} sách)
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
loadMoreContainer.innerHTML = `
|
||||
<button class="btn btn-secondary load-more-btn" onclick="loadMoreBooks()">
|
||||
📄 Tải thêm trang ${currentPage + 1} / ${totalPagesAvailable} (mỗi trang 50 sách)
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(data) {
|
||||
const resultsDiv = document.getElementById("resultsContent");
|
||||
|
||||
if (!data.books || data.books.length === 0) {
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div style="font-size: 3em; margin-bottom: 20px; font-weight: 300;">🔍</div>
|
||||
<h3>Không tìm thấy sách</h3>
|
||||
<p>Thử từ khóa khác hoặc kiểm tra lại chính tả</p>
|
||||
<p style="margin-top: 10px; font-size: 0.9em;">Từ khóa: "${escapeHtml(data.query)}"</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
currentPage = data.current_page || 1;
|
||||
totalPagesAvailable = data.total_pages || 1;
|
||||
totalBooksCount = data.total_books_count || data.books.length;
|
||||
|
||||
let html = `
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Tổng số sách</div>
|
||||
<div class="stat-value">${totalBooksCount}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Từ khóa</div>
|
||||
<div class="stat-value" style="font-size: 1.5em;">"${escapeHtml(data.query)}"</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Trang hiện tại</div>
|
||||
<div class="stat-value" style="font-size: 1.5em;">${currentPage} / ${totalPagesAvailable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="download-section">
|
||||
<button class="btn btn-secondary" onclick="downloadResults('txt')">
|
||||
↓ Tải TXT
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="downloadResults('json')">
|
||||
↓ Tải JSON
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="copyResultsToClipboard()">
|
||||
📋 Sao chép
|
||||
</button>
|
||||
<button class="btn btn-download" onclick="downloadSelectedBooks()">
|
||||
📦 Tải sách đã chọn
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="selection-bar">
|
||||
<div class="selection-controls">
|
||||
<label>
|
||||
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll(this.checked)">
|
||||
Chọn tất cả sách đã tải
|
||||
</label>
|
||||
<button class="btn btn-secondary" style="padding: 6px 16px; font-size: 12px;" onclick="clearSelection()">
|
||||
Bỏ chọn
|
||||
</button>
|
||||
</div>
|
||||
<div class="selection-info" id="selectionInfo">
|
||||
Đã chọn 0 sách
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 id="resultCountHeader" style="font-weight: 400; margin: 24px 0 16px 0; text-transform: uppercase; font-size: 0.9em; letter-spacing: 0.5px;">
|
||||
Kết quả (${data.books.length} sách đã tải / ${totalBooksCount} tổng cộng)
|
||||
</h3>
|
||||
<div class="books-grid">
|
||||
`;
|
||||
|
||||
data.books.forEach((book, index) => {
|
||||
const bookId = `book-${index}`;
|
||||
html += `
|
||||
<div class="book-card" id="${bookId}">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="book-checkbox"
|
||||
data-book-index="${index}"
|
||||
onchange="toggleBookSelection(${index}, this.checked)"
|
||||
>
|
||||
<div class="book-title">${index + 1}. ${escapeHtml(book.title)}</div>
|
||||
<div class="book-authors">${escapeHtml(book.authors.join(", ") || "Không rõ")}</div>
|
||||
|
||||
<div class="book-details">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">NXB</div>
|
||||
<div class="detail-value">${escapeHtml(book.publisher)}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Năm</div>
|
||||
<div class="detail-value">${escapeHtml(book.year)}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Số trang</div>
|
||||
<div class="detail-value">${escapeHtml(book.pages)}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Ngôn ngữ</div>
|
||||
<div class="detail-value">${escapeHtml(book.language)}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Định dạng</div>
|
||||
<div class="detail-value">${escapeHtml(book.file)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
book.link !== "N/A"
|
||||
? `<a href="${escapeHtml(book.link)}" target="_blank" class="book-link" rel="noopener noreferrer">Xem sách →</a>`
|
||||
: '<span style="color: #999; font-size: 0.85em;">Không có liên kết</span>'
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += "</div>";
|
||||
|
||||
// Add load more button
|
||||
html += `<div id="loadMoreContainer" class="load-more-container">`;
|
||||
|
||||
if (currentPage < totalPagesAvailable) {
|
||||
html += `
|
||||
<button class="btn btn-secondary load-more-btn" onclick="loadMoreBooks()">
|
||||
📄 Tải thêm trang ${currentPage + 1} / ${totalPagesAvailable} (mỗi trang 50 sách)
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
html += `
|
||||
<button class="btn btn-secondary load-more-btn" disabled>
|
||||
✅ Đã tải hết ${totalPagesAvailable} trang (${data.books.length} sách)
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
// Add statistics if available
|
||||
if (data.statistics && Object.keys(data.statistics).length > 0) {
|
||||
html += '<div class="stats-detail">';
|
||||
html += "<h3>Thống kê</h3>";
|
||||
html += '<div class="stats-bar" style="border-bottom: none;">';
|
||||
|
||||
const stats = data.statistics;
|
||||
|
||||
if (stats.languages && Object.keys(stats.languages).length > 0) {
|
||||
const langHtml = Object.entries(stats.languages)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([lang, count]) => {
|
||||
const percentage = ((count / totalBooksCount) * 100).toFixed(1);
|
||||
return `<div style="margin-bottom: 4px;">${escapeHtml(lang)}: ${count} (${percentage}%)</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
html += `
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Ngôn ngữ phổ biến</div>
|
||||
<div class="stat-value" style="font-size: 0.9em; font-weight: 400;">${langHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (stats.formats && Object.keys(stats.formats).length > 0) {
|
||||
const formatHtml = Object.entries(stats.formats)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([fmt, count]) => {
|
||||
const percentage = ((count / totalBooksCount) * 100).toFixed(1);
|
||||
return `<div style="margin-bottom: 4px;">${escapeHtml(fmt)}: ${count} (${percentage}%)</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
html += `
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Định dạng phổ biến</div>
|
||||
<div class="stat-value" style="font-size: 0.9em; font-weight: 400;">${formatHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (stats.years && Object.keys(stats.years).length > 0) {
|
||||
const yearHtml = Object.entries(stats.years)
|
||||
.sort((a, b) => parseInt(b[0]) - parseInt(a[0]))
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
([year, count]) =>
|
||||
`<div style="margin-bottom: 4px;">${escapeHtml(year)}: ${count} sách</div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
html += `
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Năm xuất bản gần đây</div>
|
||||
<div class="stat-value" style="font-size: 0.9em; font-weight: 400;">${yearHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += "</div>";
|
||||
html += "</div>";
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = html;
|
||||
updateSelectionInfo();
|
||||
resultsDiv.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
|
||||
function toggleBookSelection(index, checked) {
|
||||
if (checked) {
|
||||
selectedBooks.add(index);
|
||||
const card = document.getElementById(`book-${index}`);
|
||||
if (card) card.classList.add("selected");
|
||||
} else {
|
||||
selectedBooks.delete(index);
|
||||
const card = document.getElementById(`book-${index}`);
|
||||
if (card) card.classList.remove("selected");
|
||||
}
|
||||
updateSelectionInfo();
|
||||
updateSelectAllCheckbox();
|
||||
}
|
||||
|
||||
function toggleSelectAll(checked) {
|
||||
if (!currentResults || !currentResults.books) return;
|
||||
|
||||
if (checked) {
|
||||
currentResults.books.forEach((_, index) => {
|
||||
selectedBooks.add(index);
|
||||
const card = document.getElementById(`book-${index}`);
|
||||
if (card) card.classList.add("selected");
|
||||
const checkbox = document.querySelector(
|
||||
`[data-book-index="${index}"]`,
|
||||
);
|
||||
if (checkbox) checkbox.checked = true;
|
||||
});
|
||||
} else {
|
||||
selectedBooks.clear();
|
||||
document
|
||||
.querySelectorAll(".book-card")
|
||||
.forEach((card) => card.classList.remove("selected"));
|
||||
document
|
||||
.querySelectorAll(".book-checkbox")
|
||||
.forEach((cb) => (cb.checked = false));
|
||||
}
|
||||
updateSelectionInfo();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedBooks.clear();
|
||||
document
|
||||
.querySelectorAll(".book-card")
|
||||
.forEach((card) => card.classList.remove("selected"));
|
||||
document
|
||||
.querySelectorAll(".book-checkbox")
|
||||
.forEach((cb) => (cb.checked = false));
|
||||
const selectAllCheckbox = document.getElementById("selectAllCheckbox");
|
||||
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
||||
updateSelectionInfo();
|
||||
}
|
||||
|
||||
function updateSelectionInfo() {
|
||||
const count = selectedBooks.size;
|
||||
const infoElement = document.getElementById("selectionInfo");
|
||||
if (infoElement) {
|
||||
infoElement.textContent = `Đã chọn ${count} / ${currentResults?.books.length || 0} sách`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectAllCheckbox() {
|
||||
if (!currentResults || !currentResults.books) return;
|
||||
const selectAll = document.getElementById("selectAllCheckbox");
|
||||
if (selectAll) {
|
||||
selectAll.checked =
|
||||
selectedBooks.size === currentResults.books.length &&
|
||||
currentResults.books.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSelectedBooks() {
|
||||
if (downloadInProgress) {
|
||||
alert("Đang tải sách, vui lòng đợi...");
|
||||
return;
|
||||
}
|
||||
if (!currentResults || !currentResults.books) {
|
||||
alert("Không có sách để tải");
|
||||
return;
|
||||
}
|
||||
if (selectedBooks.size === 0) {
|
||||
alert("Vui lòng chọn ít nhất một cuốn sách");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedBooksList = Array.from(selectedBooks)
|
||||
.sort((a, b) => a - b)
|
||||
.map((index) => currentResults.books[index]);
|
||||
|
||||
downloadInProgress = true;
|
||||
const headless = document.getElementById("headless").checked;
|
||||
|
||||
// Show progress UI
|
||||
document.getElementById("loadingIndicator").style.display = "block";
|
||||
document.getElementById("loadingMessage").textContent =
|
||||
`Đang tải ${selectedBooksList.length} sách...`;
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
try {
|
||||
// POST to get the SSE stream
|
||||
const response = await fetch("/scraperZLibrary/api/download/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
books: selectedBooksList,
|
||||
query: currentResults.query,
|
||||
headless: headless,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Không thể bắt đầu tải xuống");
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n\n");
|
||||
buffer = lines.pop(); // keep incomplete chunk
|
||||
|
||||
for (const chunk of lines) {
|
||||
const line = chunk.replace(/^data:\s*/, "").trim();
|
||||
if (!line) continue;
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "progress") {
|
||||
document.getElementById("loadingMessage").textContent =
|
||||
`[${event.index}/${event.total}] Đang tải: ${event.title.substring(0, 50)}...`;
|
||||
document.getElementById("progressText").textContent =
|
||||
`Đã xong: ${successCount} ✓ Lỗi: ${failedCount} ✗`;
|
||||
} else if (event.type === "ready") {
|
||||
successCount++;
|
||||
document.getElementById("progressText").textContent =
|
||||
`Đã xong: ${successCount} ✓ Lỗi: ${failedCount} ✗`;
|
||||
|
||||
// Trigger instant download via hidden <a>
|
||||
const a = document.createElement("a");
|
||||
a.href = `/scraperZLibrary/api/download/file/${event.token}`;
|
||||
a.download = event.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
// Small gap so the browser doesn't block rapid-fire downloads
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
} else if (event.type === "error") {
|
||||
failedCount++;
|
||||
console.warn(`Failed: ${event.title} — ${event.message}`);
|
||||
document.getElementById("progressText").textContent =
|
||||
`Đã xong: ${successCount} ✓ Lỗi: ${failedCount} ✗`;
|
||||
} else if (event.type === "done") {
|
||||
document.getElementById("loadingMessage").textContent =
|
||||
`Hoàn tất! ${event.success} thành công, ${event.failed} thất bại`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stream download error:", error);
|
||||
alert("Không thể tải sách: " + error.message);
|
||||
} finally {
|
||||
downloadInProgress = false;
|
||||
setTimeout(() => {
|
||||
document.getElementById("loadingIndicator").style.display = "none";
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const errorDiv = document.getElementById("errorMessage");
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.display = "block";
|
||||
|
||||
document.getElementById("resultsContent").innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div style="font-size: 3em; margin-bottom: 20px; font-weight: 300;">✕</div>
|
||||
<h3>Tìm kiếm thất bại</h3>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
<p style="margin-top: 20px; font-size: 0.9em;">Vui lòng thử lại với từ khóa khác.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
errorDiv.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
|
||||
function downloadResults(format) {
|
||||
if (!currentResults) {
|
||||
alert("Không có kết quả để tải xuống");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/scraperZLibrary/api/download/${format}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(currentResults),
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Tải xuống thất bại");
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `zlib_${currentResults.query.replace(/\s+/g, "_")}_ket_qua.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Download error:", error);
|
||||
alert("Không thể tải file: " + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
function copyResultsToClipboard() {
|
||||
if (!currentResults) {
|
||||
alert("Không có kết quả để sao chép");
|
||||
return;
|
||||
}
|
||||
|
||||
let summary = `KẾT QUẢ TÌM KIẾM Z-LIBRARY\n`;
|
||||
summary += `========================\n`;
|
||||
summary += `Từ khóa: ${currentResults.query}\n`;
|
||||
summary += `Tổng số sách: ${totalBooksCount}\n`;
|
||||
summary += `Đã tải: ${currentResults.books.length} sách\n`;
|
||||
summary += `========================\n\n`;
|
||||
|
||||
currentResults.books.slice(0, 20).forEach((book, index) => {
|
||||
summary += `${index + 1}. ${book.title}\n`;
|
||||
summary += ` Tác giả: ${book.authors.join(", ")}\n`;
|
||||
summary += ` Năm: ${book.year} | Ngôn ngữ: ${book.language} | Định dạng: ${book.file}\n`;
|
||||
summary += ` Link: ${book.link}\n\n`;
|
||||
});
|
||||
|
||||
if (currentResults.books.length > 20) {
|
||||
summary += `... và ${currentResults.books.length - 20} sách khác\n`;
|
||||
}
|
||||
|
||||
navigator.clipboard
|
||||
.writeText(summary)
|
||||
.then(() => {
|
||||
alert("Đã sao chép tóm tắt!");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to copy:", err);
|
||||
alert("Không thể sao chép");
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (typeof text !== "string") return text;
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
document
|
||||
.getElementById("query")
|
||||
.addEventListener("keypress", function (e) {
|
||||
if (e.key === "Enter") {
|
||||
searchBooks(true);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
document.getElementById("query").focus();
|
||||
}
|
||||
});
|
||||
|
||||
const progressMessages = [
|
||||
"Đang khởi tạo...",
|
||||
"Đang kết nối Z-Library...",
|
||||
"Đang lấy dữ liệu sách...",
|
||||
"Đang xử lý kết quả...",
|
||||
"Sắp xong...",
|
||||
];
|
||||
|
||||
let messageIndex = 0;
|
||||
const progressInterval = setInterval(() => {
|
||||
if (searchInProgress || downloadInProgress) {
|
||||
messageIndex = (messageIndex + 1) % progressMessages.length;
|
||||
const progressElement = document.getElementById("progressText");
|
||||
if (progressElement) {
|
||||
progressElement.textContent = progressMessages[messageIndex];
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
clearInterval(progressInterval);
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
transcriptionMain.css · Mainframe Design System v3
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
--font-heading: 'HelveticaNowDisplay-Medium','Helvetica Neue',Arial,sans-serif;
|
||||
--font-body: 'HelveticaNowDisplayW01-Rg','Helvetica Neue',Arial,sans-serif;
|
||||
--ink: #0a0a0a;
|
||||
--ink-70: rgba(10,10,10,0.70);
|
||||
--ink-45: rgba(10,10,10,0.45);
|
||||
--ink-20: rgba(10,10,10,0.20);
|
||||
--ink-10: rgba(10,10,10,0.10);
|
||||
--ink-05: rgba(10,10,10,0.05);
|
||||
--surface: #f4f3ef;
|
||||
--surface-2: #eeecea;
|
||||
--card: #ffffff;
|
||||
--radius-pill: 9999px;
|
||||
--radius-xl: 24px;
|
||||
--radius-lg: 18px;
|
||||
--radius-md: 12px;
|
||||
--shadow-xs: 0 1px 2px rgba(0,0,0,0.06);
|
||||
--shadow-sm: 0 2px 10px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 6px 28px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.05);
|
||||
--shadow-inset: inset 0 1px 0 rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
/* ─── Animations ─────────────────────────────────────────── */
|
||||
@keyframes fade-up {
|
||||
from { opacity:0; transform:translateY(16px); }
|
||||
to { opacity:1; transform:none; }
|
||||
}
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
|
||||
/* ─── Base ───────────────────────────────────────────────── */
|
||||
*,*::before,*::after { margin:0; padding:0; box-sizing:border-box; }
|
||||
|
||||
html {
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--surface);
|
||||
padding: 0 20px 100px;
|
||||
font-family: var(--font-body);
|
||||
/* subtle paper grain */
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='0.028'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* ─── Page Layout ────────────────────────────────────────── */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding-top: 48px;
|
||||
}
|
||||
|
||||
/* ─── Upload View ────────────────────────────────────────── */
|
||||
.upload-view {
|
||||
animation: fade-up .7s cubic-bezier(.22,1,.36,1) backwards;
|
||||
animation-delay: .05s;
|
||||
}
|
||||
|
||||
/* ── Section label above heading ── */
|
||||
.upload-view::before {
|
||||
content: '✦ Powered by Whisper AI';
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-45);
|
||||
margin-bottom: 14px;
|
||||
padding: 6px 14px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-pill);
|
||||
box-shadow: var(--shadow-xs);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ─── Page Heading ───────────────────────────────────────── */
|
||||
.upload-view h1 {
|
||||
font-family: var(--font-heading);
|
||||
font-size: clamp(32px, 6vw, 52px);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1.0;
|
||||
color: var(--ink);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ─── Subtitle ───────────────────────────────────────────── */
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--ink-45);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 36px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* ─── Drop Zone ──────────────────────────────────────────── */
|
||||
.upload-area {
|
||||
background: var(--card);
|
||||
border: 1.5px dashed var(--ink-20);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm), var(--shadow-inset);
|
||||
transition:
|
||||
border-color .25s ease,
|
||||
box-shadow .25s ease,
|
||||
transform .25s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
|
||||
/* Dashed border via gradient trick for rounded corners */
|
||||
.upload-area::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: radial-gradient(ellipse 70% 55% at 50% -10%,
|
||||
rgba(10,10,10,0.04) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Inner content wrapper */
|
||||
.upload-area > :not(.file-input) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Actual click target padding */
|
||||
.upload-area {
|
||||
padding: 52px 32px 44px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-area:hover,
|
||||
.upload-area.dragover {
|
||||
border-color: var(--ink-45);
|
||||
box-shadow: var(--shadow-md), var(--shadow-inset);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px; height: 44px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--ink-10);
|
||||
box-shadow: var(--shadow-xs);
|
||||
margin-bottom: 16px;
|
||||
font-size: 18px;
|
||||
color: var(--ink-45);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.upload-area h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 6px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.upload-area p {
|
||||
font-size: 12px;
|
||||
color: var(--ink-45);
|
||||
line-height: 1.55;
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.file-input { display:none; }
|
||||
|
||||
/* ─── Controls Card ──────────────────────────────────────── */
|
||||
/* Wrap language + model in a subtle grouped container */
|
||||
.language-select,
|
||||
.model-select {
|
||||
animation: fade-up .7s cubic-bezier(.22,1,.36,1) backwards;
|
||||
}
|
||||
|
||||
/* ─── Language Pills ─────────────────────────────────────── */
|
||||
.language-select {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
animation-delay: .18s;
|
||||
padding: 14px 16px;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--ink-10);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.language-select::before {
|
||||
content: 'Ngôn ngữ';
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-45);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.language-select label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink-70);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 5px 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-xs);
|
||||
transition: background .18s, color .18s, border-color .18s, box-shadow .18s;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.language-select label:hover {
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
border-color: var(--ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.language-select input[type="radio"] {
|
||||
accent-color: var(--ink);
|
||||
width: 13px; height: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ─── Model Row ──────────────────────────────────────────── */
|
||||
.model-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 22px;
|
||||
animation-delay: .24s;
|
||||
padding: 12px 16px;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--ink-10);
|
||||
box-shadow: var(--shadow-xs);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-45);
|
||||
}
|
||||
|
||||
.model-select select {
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--ink-10);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
box-shadow: var(--shadow-xs);
|
||||
transition: border-color .18s, box-shadow .18s;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.model-select select:focus {
|
||||
outline: none;
|
||||
border-color: var(--ink-45);
|
||||
box-shadow: 0 0 0 3px rgba(10,10,10,.07);
|
||||
}
|
||||
|
||||
.model-select select option { background:#fff; color:#000; }
|
||||
|
||||
/* ─── File Info ──────────────────────────────────────────── */
|
||||
.file-info {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-45);
|
||||
line-height: 1.8;
|
||||
word-break: break-all;
|
||||
display: none;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
/* ─── Buttons ────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 9px 24px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
letter-spacing: -0.01em;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--ink-10);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow-xs), var(--shadow-inset);
|
||||
margin: 0 5px 5px 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
background .2s ease,
|
||||
color .2s ease,
|
||||
border-color .2s ease,
|
||||
box-shadow .2s ease,
|
||||
transform .18s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
border-color: var(--ink);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(.97) translateY(0);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.btn:disabled { opacity:.35; cursor:not-allowed; }
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border-color: var(--ink-20);
|
||||
}
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
border-color: var(--ink);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ─── Progress Card ──────────────────────────────────────── */
|
||||
.progress-container {
|
||||
margin-top: 18px;
|
||||
display: none;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 22px 24px 20px;
|
||||
box-shadow: var(--shadow-sm), var(--shadow-inset);
|
||||
animation: fade-up .5s cubic-bezier(.22,1,.36,1) backwards;
|
||||
}
|
||||
|
||||
.progress-container h3 {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-45);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width:100%; height:2px;
|
||||
background: var(--ink-05);
|
||||
border-radius:99px;
|
||||
overflow:hidden;
|
||||
margin-bottom:6px;
|
||||
}
|
||||
.progress-fill {
|
||||
height:100%;
|
||||
background: var(--ink);
|
||||
width:0%;
|
||||
border-radius:99px;
|
||||
transition: width .45s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
.upload-progress-bar {
|
||||
width:100%; height:2px;
|
||||
background: var(--ink-05);
|
||||
border-radius:99px;
|
||||
overflow:hidden;
|
||||
margin:10px 0 4px;
|
||||
}
|
||||
.upload-progress-fill {
|
||||
height:100%;
|
||||
background: var(--ink-45);
|
||||
width:0%;
|
||||
border-radius:99px;
|
||||
transition: width .45s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
.progress-text {
|
||||
font-size:11px;
|
||||
color:var(--ink-45);
|
||||
margin-top:6px;
|
||||
letter-spacing:.01em;
|
||||
}
|
||||
|
||||
/* ─── Result ─────────────────────────────────────────────── */
|
||||
.result-area {
|
||||
margin-top:22px;
|
||||
display:none;
|
||||
animation: fade-up .5s cubic-bezier(.22,1,.36,1) backwards;
|
||||
}
|
||||
.result-label {
|
||||
font-size:10px;
|
||||
font-weight:700;
|
||||
letter-spacing:.1em;
|
||||
text-transform:uppercase;
|
||||
color:var(--ink-45);
|
||||
margin-bottom:10px;
|
||||
}
|
||||
.result-preview {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 20px;
|
||||
font-size:14px;
|
||||
color:var(--ink-70);
|
||||
line-height:1.75;
|
||||
margin-bottom:14px;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
/* ─── Transcript View ────────────────────────────────────── */
|
||||
.transcript-view { display:none; }
|
||||
|
||||
.transcript-view h1 {
|
||||
font-family: var(--font-heading);
|
||||
font-size: clamp(26px, 4.5vw, 40px);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.05em;
|
||||
color: var(--ink);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 12px;
|
||||
color: var(--ink-45);
|
||||
margin-bottom: 22px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--ink-10);
|
||||
line-height: 1.9;
|
||||
}
|
||||
.meta strong { color: var(--ink); font-weight: 500; }
|
||||
|
||||
.transcript-content {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 26px 28px;
|
||||
font-size:15px;
|
||||
line-height:1.9;
|
||||
white-space:pre-wrap;
|
||||
word-wrap:break-word;
|
||||
max-height:580px;
|
||||
overflow-y:auto;
|
||||
color:var(--ink-70);
|
||||
box-shadow: var(--shadow-sm), var(--shadow-inset);
|
||||
scrollbar-width:thin;
|
||||
scrollbar-color: var(--ink-10) transparent;
|
||||
}
|
||||
.transcript-content::-webkit-scrollbar { width:4px; }
|
||||
.transcript-content::-webkit-scrollbar-track { background:transparent; }
|
||||
.transcript-content::-webkit-scrollbar-thumb { background:var(--ink-10); border-radius:99px; }
|
||||
|
||||
.nav-buttons { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:22px; }
|
||||
@@ -5,240 +5,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Phiên Âm</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
color: #111;
|
||||
}
|
||||
.container {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
padding: 48px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.3px;
|
||||
margin-bottom: 6px;
|
||||
color: #111;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #777;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.upload-view {
|
||||
display: block;
|
||||
}
|
||||
.transcript-view {
|
||||
display: none;
|
||||
}
|
||||
.upload-area {
|
||||
border: 1.5px dashed #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background 0.2s;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.upload-area:hover,
|
||||
.upload-area.dragover {
|
||||
border-color: #111;
|
||||
background: #fafafa;
|
||||
}
|
||||
.upload-icon {
|
||||
font-size: 22px;
|
||||
margin-bottom: 14px;
|
||||
display: block;
|
||||
}
|
||||
.upload-area h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: #222;
|
||||
}
|
||||
.upload-area p {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
.language-select {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
color: #444;
|
||||
}
|
||||
.language-select label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.language-select input[type="radio"] {
|
||||
accent-color: #111;
|
||||
}
|
||||
.model-select {
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
color: #444;
|
||||
}
|
||||
.model-select select {
|
||||
margin-left: 10px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin: 4px 4px 4px 0;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.btn:hover {
|
||||
background: #333;
|
||||
}
|
||||
.btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-outline {
|
||||
background: #fff;
|
||||
color: #111;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.btn-outline:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #999;
|
||||
}
|
||||
.file-info {
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #eee;
|
||||
padding: 14px 16px;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
line-height: 1.8;
|
||||
word-break: break-all;
|
||||
display: none;
|
||||
}
|
||||
.progress-container {
|
||||
margin-top: 28px;
|
||||
display: none;
|
||||
}
|
||||
.progress-container h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #444;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: #eee;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #111;
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.upload-progress-bar {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.upload-progress-fill {
|
||||
height: 100%;
|
||||
background: #4caf50;
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.progress-text {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
.result-area {
|
||||
margin-top: 28px;
|
||||
display: none;
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 24px;
|
||||
}
|
||||
.result-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #111;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.result-preview {
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 3px;
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.meta {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.transcript-content {
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 3px;
|
||||
padding: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
max-height: 560px;
|
||||
overflow-y: auto;
|
||||
color: #333;
|
||||
}
|
||||
.nav-buttons {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://db.onlinewebfonts.com/c/5ac3fe7c6abd2f62067f266d89671492?family=HelveticaNowDisplay-Medium" />
|
||||
<link rel="stylesheet" href="https://db.onlinewebfonts.com/c/1aa3377e489837a26d019bba501e779d?family=HelveticaNowDisplayW01-Rg" />
|
||||
<link rel="stylesheet" href="/assets/transcription/transcriptionMain.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -336,294 +105,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if (document.querySelector(".upload-view").style.display !== "none") {
|
||||
const uploadArea = document.getElementById("uploadArea");
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
const uploadBtn = document.getElementById("uploadBtn");
|
||||
const fileInfo = document.getElementById("fileInfo");
|
||||
const progressContainer = document.getElementById("progressContainer");
|
||||
const progressFill = document.getElementById("progressFill");
|
||||
const uploadProgressFill =
|
||||
document.getElementById("uploadProgressFill");
|
||||
const progressText = document.getElementById("progressText");
|
||||
const resultArea = document.getElementById("resultArea");
|
||||
const transcriptPreview = document.getElementById("transcriptPreview");
|
||||
let selectedFile = null;
|
||||
|
||||
uploadArea.addEventListener("click", () => fileInput.click());
|
||||
uploadArea.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add("dragover");
|
||||
});
|
||||
uploadArea.addEventListener("dragleave", () =>
|
||||
uploadArea.classList.remove("dragover"),
|
||||
);
|
||||
uploadArea.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove("dragover");
|
||||
if (e.dataTransfer.files.length > 0)
|
||||
handleFileSelect(e.dataTransfer.files[0]);
|
||||
});
|
||||
fileInput.addEventListener("change", (e) => {
|
||||
if (e.target.files.length > 0) handleFileSelect(e.target.files[0]);
|
||||
});
|
||||
|
||||
function handleFileSelect(file) {
|
||||
selectedFile = file;
|
||||
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||
fileInfo.style.display = "block";
|
||||
fileInfo.innerHTML = `<strong>Tệp đã chọn:</strong> ${file.name}<br><strong>Kích thước:</strong> ${sizeInMB} MB`;
|
||||
uploadBtn.style.display = "inline-block";
|
||||
}
|
||||
|
||||
uploadBtn.addEventListener("click", async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
const language = document.querySelector(
|
||||
'input[name="language"]:checked',
|
||||
).value;
|
||||
const model = document.getElementById("modelSelect").value;
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
progressContainer.style.display = "block";
|
||||
resultArea.style.display = "none";
|
||||
|
||||
try {
|
||||
await uploadFileInChunks(selectedFile, language, model);
|
||||
} catch (error) {
|
||||
alert("Tải lên thất bại: " + error.message);
|
||||
uploadBtn.disabled = false;
|
||||
progressContainer.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
async function uploadFileInChunks(file, language, model) {
|
||||
const CHUNK_SIZE = 3 * 60 * 1000; // 3 minutes in milliseconds
|
||||
let jobId = null;
|
||||
|
||||
// Create audio context to get duration
|
||||
const audioContext = new (
|
||||
window.AudioContext || window.webkitAudioContext
|
||||
)();
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
const duration = audioBuffer.duration * 1000; // Duration in milliseconds
|
||||
|
||||
const totalChunks = Math.ceil(duration / CHUNK_SIZE);
|
||||
progressText.textContent = `Đang chuẩn bị tải lên ${totalChunks} phần...`;
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, duration);
|
||||
|
||||
progressText.textContent = `Đang tải lên phần ${i + 1}/${totalChunks}...`;
|
||||
|
||||
// Extract chunk from audio
|
||||
const chunkBlob = await extractAudioChunk(
|
||||
audioBuffer,
|
||||
start,
|
||||
end,
|
||||
file.type,
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("chunk", chunkBlob, `chunk_${i}.mp3`);
|
||||
formData.append("chunk_index", i);
|
||||
formData.append("total_chunks", totalChunks);
|
||||
formData.append("filename", file.name);
|
||||
formData.append("language", language);
|
||||
formData.append("model", model);
|
||||
|
||||
if (jobId) {
|
||||
formData.append("job_id", jobId);
|
||||
}
|
||||
|
||||
const response = await fetch("/upload-chunk", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Upload failed");
|
||||
}
|
||||
|
||||
jobId = data.job_id;
|
||||
|
||||
// Update upload progress
|
||||
const uploadProgress = ((i + 1) / totalChunks) * 100;
|
||||
uploadProgressFill.style.width = uploadProgress + "%";
|
||||
|
||||
if (data.status === "processing") {
|
||||
progressText.textContent =
|
||||
"Tất cả các phần đã được tải lên! Đang bắt đầu phiên âm...";
|
||||
startPolling(jobId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function extractAudioChunk(
|
||||
audioBuffer,
|
||||
startTime,
|
||||
endTime,
|
||||
mimeType,
|
||||
) {
|
||||
const sampleRate = audioBuffer.sampleRate;
|
||||
const startSample = Math.floor((startTime * sampleRate) / 1000);
|
||||
const endSample = Math.floor((endTime * sampleRate) / 1000);
|
||||
const frameCount = endSample - startSample;
|
||||
|
||||
// Create new audio buffer for chunk
|
||||
const chunkBuffer = new AudioContext().createBuffer(
|
||||
audioBuffer.numberOfChannels,
|
||||
frameCount,
|
||||
sampleRate,
|
||||
);
|
||||
|
||||
// Copy data
|
||||
for (
|
||||
let channel = 0;
|
||||
channel < audioBuffer.numberOfChannels;
|
||||
channel++
|
||||
) {
|
||||
const channelData = audioBuffer.getChannelData(channel);
|
||||
const chunkData = chunkBuffer.getChannelData(channel);
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
chunkData[i] = channelData[startSample + i];
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to WAV first, then let server handle conversion
|
||||
const wavBlob = audioBufferToWav(chunkBuffer);
|
||||
|
||||
// If it's already an audio file, return as is, otherwise convert
|
||||
if (mimeType.includes("audio")) {
|
||||
return wavBlob;
|
||||
} else {
|
||||
// For video files, we'll send the audio portion
|
||||
return wavBlob;
|
||||
}
|
||||
}
|
||||
|
||||
function audioBufferToWav(buffer) {
|
||||
const numChannels = buffer.numberOfChannels;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
const format = 1; // PCM
|
||||
const bitDepth = 16;
|
||||
|
||||
let resultBuffer;
|
||||
|
||||
if (numChannels === 2) {
|
||||
resultBuffer = interleave(
|
||||
buffer.getChannelData(0),
|
||||
buffer.getChannelData(1),
|
||||
);
|
||||
} else {
|
||||
resultBuffer = buffer.getChannelData(0);
|
||||
}
|
||||
|
||||
const dataLength = resultBuffer.length * 2;
|
||||
const bufferLength = 44 + dataLength;
|
||||
const arrayBuffer = new ArrayBuffer(bufferLength);
|
||||
const view = new DataView(arrayBuffer);
|
||||
|
||||
// Write WAV header
|
||||
writeString(view, 0, "RIFF");
|
||||
view.setUint32(4, 36 + dataLength, true);
|
||||
writeString(view, 8, "WAVE");
|
||||
writeString(view, 12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, format, true);
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 4, true);
|
||||
view.setUint16(32, numChannels * 2, true);
|
||||
view.setUint16(34, bitDepth, true);
|
||||
writeString(view, 36, "data");
|
||||
view.setUint32(40, dataLength, true);
|
||||
|
||||
// Write audio data
|
||||
const offset = 44;
|
||||
for (let i = 0; i < resultBuffer.length; i++) {
|
||||
const sample = Math.max(-1, Math.min(1, resultBuffer[i]));
|
||||
view.setInt16(
|
||||
offset + i * 2,
|
||||
sample < 0 ? sample * 0x8000 : sample * 0x7fff,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
return new Blob([arrayBuffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function writeString(view, offset, string) {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
function interleave(left, right) {
|
||||
const length = left.length + right.length;
|
||||
const result = new Float32Array(length);
|
||||
|
||||
let inputIndex = 0;
|
||||
for (let i = 0; i < length; i += 2) {
|
||||
result[i] = left[inputIndex];
|
||||
result[i + 1] = right[inputIndex];
|
||||
inputIndex++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function startPolling(jobId) {
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const data = await (await fetch(`/status/${jobId}`)).json();
|
||||
progressFill.style.width = data.progress + "%";
|
||||
|
||||
if (data.status === "processing") {
|
||||
progressText.textContent = data.current_chunk
|
||||
? `Đang phiên âm đoạn ${data.current_chunk}...`
|
||||
: `Đang xử lý: ${data.progress}%`;
|
||||
} else if (data.status === "completed") {
|
||||
clearInterval(pollInterval);
|
||||
progressText.textContent = "Hoàn tất!";
|
||||
showResult(jobId);
|
||||
} else if (data.status === "error") {
|
||||
clearInterval(pollInterval);
|
||||
progressText.textContent = "Lỗi: " + data.error;
|
||||
uploadBtn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Lỗi kiểm tra:", e);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function showResult(jobId) {
|
||||
try {
|
||||
const html = await (await fetch(`/view/${jobId}`)).text();
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const content =
|
||||
doc.querySelector(".transcript-content")?.textContent ||
|
||||
"Đã tải bản phiên âm";
|
||||
transcriptPreview.innerHTML = `<pre style="white-space:pre-wrap;word-wrap:break-word;margin:0;font-family:inherit">${content.substring(0, 300)}...</pre>`;
|
||||
resultArea.style.display = "block";
|
||||
uploadBtn.disabled = false;
|
||||
document.getElementById("viewFullBtn").onclick = () =>
|
||||
window.open(`/view/${jobId}`, "_blank");
|
||||
document.getElementById("downloadBtn").onclick = () =>
|
||||
(window.location.href = `/transcript/${jobId}`);
|
||||
} catch (e) {
|
||||
console.error("Lỗi hiển thị:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="/static/header/headerLoader.js?v=1.0"></script>
|
||||
<script src="/assets/transcription/transcriptionMain.js"></script>
|
||||
<script src="/assets/header/headerLoader.js?v=1.1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
if (document.querySelector(".upload-view").style.display !== "none") {
|
||||
const uploadArea = document.getElementById("uploadArea");
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
const uploadBtn = document.getElementById("uploadBtn");
|
||||
const fileInfo = document.getElementById("fileInfo");
|
||||
const progressContainer = document.getElementById("progressContainer");
|
||||
const progressFill = document.getElementById("progressFill");
|
||||
const uploadProgressFill =
|
||||
document.getElementById("uploadProgressFill");
|
||||
const progressText = document.getElementById("progressText");
|
||||
const resultArea = document.getElementById("resultArea");
|
||||
const transcriptPreview = document.getElementById("transcriptPreview");
|
||||
let selectedFile = null;
|
||||
|
||||
uploadArea.addEventListener("click", () => fileInput.click());
|
||||
uploadArea.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add("dragover");
|
||||
});
|
||||
uploadArea.addEventListener("dragleave", () =>
|
||||
uploadArea.classList.remove("dragover"),
|
||||
);
|
||||
uploadArea.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove("dragover");
|
||||
if (e.dataTransfer.files.length > 0)
|
||||
handleFileSelect(e.dataTransfer.files[0]);
|
||||
});
|
||||
fileInput.addEventListener("change", (e) => {
|
||||
if (e.target.files.length > 0) handleFileSelect(e.target.files[0]);
|
||||
});
|
||||
|
||||
function handleFileSelect(file) {
|
||||
selectedFile = file;
|
||||
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||
fileInfo.style.display = "block";
|
||||
fileInfo.innerHTML = `<strong>Tệp đã chọn:</strong> ${file.name}<br><strong>Kích thước:</strong> ${sizeInMB} MB`;
|
||||
uploadBtn.style.display = "inline-block";
|
||||
}
|
||||
|
||||
uploadBtn.addEventListener("click", async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
const language = document.querySelector(
|
||||
'input[name="language"]:checked',
|
||||
).value;
|
||||
const model = document.getElementById("modelSelect").value;
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
progressContainer.style.display = "block";
|
||||
resultArea.style.display = "none";
|
||||
|
||||
try {
|
||||
await uploadFileInChunks(selectedFile, language, model);
|
||||
} catch (error) {
|
||||
alert("Tải lên thất bại: " + error.message);
|
||||
uploadBtn.disabled = false;
|
||||
progressContainer.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
async function uploadFileInChunks(file, language, model) {
|
||||
const CHUNK_SIZE = 3 * 60 * 1000; // 3 minutes in milliseconds
|
||||
let jobId = null;
|
||||
|
||||
// Create audio context to get duration
|
||||
const audioContext = new (
|
||||
window.AudioContext || window.webkitAudioContext
|
||||
)();
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
const duration = audioBuffer.duration * 1000; // Duration in milliseconds
|
||||
|
||||
const totalChunks = Math.ceil(duration / CHUNK_SIZE);
|
||||
progressText.textContent = `Đang chuẩn bị tải lên ${totalChunks} phần...`;
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, duration);
|
||||
|
||||
progressText.textContent = `Đang tải lên phần ${i + 1}/${totalChunks}...`;
|
||||
|
||||
// Extract chunk from audio
|
||||
const chunkBlob = await extractAudioChunk(
|
||||
audioBuffer,
|
||||
start,
|
||||
end,
|
||||
file.type,
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("chunk", chunkBlob, `chunk_${i}.mp3`);
|
||||
formData.append("chunk_index", i);
|
||||
formData.append("total_chunks", totalChunks);
|
||||
formData.append("filename", file.name);
|
||||
formData.append("language", language);
|
||||
formData.append("model", model);
|
||||
|
||||
if (jobId) {
|
||||
formData.append("job_id", jobId);
|
||||
}
|
||||
|
||||
const response = await fetch("/upload-chunk", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Upload failed");
|
||||
}
|
||||
|
||||
jobId = data.job_id;
|
||||
|
||||
// Update upload progress
|
||||
const uploadProgress = ((i + 1) / totalChunks) * 100;
|
||||
uploadProgressFill.style.width = uploadProgress + "%";
|
||||
|
||||
if (data.status === "processing") {
|
||||
progressText.textContent =
|
||||
"Tất cả các phần đã được tải lên! Đang bắt đầu phiên âm...";
|
||||
startPolling(jobId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function extractAudioChunk(
|
||||
audioBuffer,
|
||||
startTime,
|
||||
endTime,
|
||||
mimeType,
|
||||
) {
|
||||
const sampleRate = audioBuffer.sampleRate;
|
||||
const startSample = Math.floor((startTime * sampleRate) / 1000);
|
||||
const endSample = Math.floor((endTime * sampleRate) / 1000);
|
||||
const frameCount = endSample - startSample;
|
||||
|
||||
// Create new audio buffer for chunk
|
||||
const chunkBuffer = new AudioContext().createBuffer(
|
||||
audioBuffer.numberOfChannels,
|
||||
frameCount,
|
||||
sampleRate,
|
||||
);
|
||||
|
||||
// Copy data
|
||||
for (
|
||||
let channel = 0;
|
||||
channel < audioBuffer.numberOfChannels;
|
||||
channel++
|
||||
) {
|
||||
const channelData = audioBuffer.getChannelData(channel);
|
||||
const chunkData = chunkBuffer.getChannelData(channel);
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
chunkData[i] = channelData[startSample + i];
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to WAV first, then let server handle conversion
|
||||
const wavBlob = audioBufferToWav(chunkBuffer);
|
||||
|
||||
// If it's already an audio file, return as is, otherwise convert
|
||||
if (mimeType.includes("audio")) {
|
||||
return wavBlob;
|
||||
} else {
|
||||
// For video files, we'll send the audio portion
|
||||
return wavBlob;
|
||||
}
|
||||
}
|
||||
|
||||
function audioBufferToWav(buffer) {
|
||||
const numChannels = buffer.numberOfChannels;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
const format = 1; // PCM
|
||||
const bitDepth = 16;
|
||||
|
||||
let resultBuffer;
|
||||
|
||||
if (numChannels === 2) {
|
||||
resultBuffer = interleave(
|
||||
buffer.getChannelData(0),
|
||||
buffer.getChannelData(1),
|
||||
);
|
||||
} else {
|
||||
resultBuffer = buffer.getChannelData(0);
|
||||
}
|
||||
|
||||
const dataLength = resultBuffer.length * 2;
|
||||
const bufferLength = 44 + dataLength;
|
||||
const arrayBuffer = new ArrayBuffer(bufferLength);
|
||||
const view = new DataView(arrayBuffer);
|
||||
|
||||
// Write WAV header
|
||||
writeString(view, 0, "RIFF");
|
||||
view.setUint32(4, 36 + dataLength, true);
|
||||
writeString(view, 8, "WAVE");
|
||||
writeString(view, 12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, format, true);
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 4, true);
|
||||
view.setUint16(32, numChannels * 2, true);
|
||||
view.setUint16(34, bitDepth, true);
|
||||
writeString(view, 36, "data");
|
||||
view.setUint32(40, dataLength, true);
|
||||
|
||||
// Write audio data
|
||||
const offset = 44;
|
||||
for (let i = 0; i < resultBuffer.length; i++) {
|
||||
const sample = Math.max(-1, Math.min(1, resultBuffer[i]));
|
||||
view.setInt16(
|
||||
offset + i * 2,
|
||||
sample < 0 ? sample * 0x8000 : sample * 0x7fff,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
return new Blob([arrayBuffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function writeString(view, offset, string) {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
function interleave(left, right) {
|
||||
const length = left.length + right.length;
|
||||
const result = new Float32Array(length);
|
||||
|
||||
let inputIndex = 0;
|
||||
for (let i = 0; i < length; i += 2) {
|
||||
result[i] = left[inputIndex];
|
||||
result[i + 1] = right[inputIndex];
|
||||
inputIndex++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function startPolling(jobId) {
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const data = await (await fetch(`/status/${jobId}`)).json();
|
||||
progressFill.style.width = data.progress + "%";
|
||||
|
||||
if (data.status === "processing") {
|
||||
progressText.textContent = data.current_chunk
|
||||
? `Đang phiên âm đoạn ${data.current_chunk}...`
|
||||
: `Đang xử lý: ${data.progress}%`;
|
||||
} else if (data.status === "completed") {
|
||||
clearInterval(pollInterval);
|
||||
progressText.textContent = "Hoàn tất!";
|
||||
showResult(jobId);
|
||||
} else if (data.status === "error") {
|
||||
clearInterval(pollInterval);
|
||||
progressText.textContent = "Lỗi: " + data.error;
|
||||
uploadBtn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Lỗi kiểm tra:", e);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function showResult(jobId) {
|
||||
try {
|
||||
const html = await (await fetch(`/view/${jobId}`)).text();
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const content =
|
||||
doc.querySelector(".transcript-content")?.textContent ||
|
||||
"Đã tải bản phiên âm";
|
||||
transcriptPreview.innerHTML = `<pre style="white-space:pre-wrap;word-wrap:break-word;margin:0;font-family:inherit">${content.substring(0, 300)}...</pre>`;
|
||||
resultArea.style.display = "block";
|
||||
uploadBtn.disabled = false;
|
||||
document.getElementById("viewFullBtn").onclick = () =>
|
||||
window.open(`/view/${jobId}`, "_blank");
|
||||
document.getElementById("downloadBtn").onclick = () =>
|
||||
(window.location.href = `/transcript/${jobId}`);
|
||||
} catch (e) {
|
||||
console.error("Lỗi hiển thị:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
transcriptionReview.css · Mainframe Design System v3
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
--font-heading: 'HelveticaNowDisplay-Medium','Helvetica Neue',Arial,sans-serif;
|
||||
--font-body: 'HelveticaNowDisplayW01-Rg','Helvetica Neue',Arial,sans-serif;
|
||||
--ink: #0a0a0a;
|
||||
--ink-70: rgba(10,10,10,0.70);
|
||||
--ink-45: rgba(10,10,10,0.45);
|
||||
--ink-20: rgba(10,10,10,0.20);
|
||||
--ink-10: rgba(10,10,10,0.10);
|
||||
--ink-05: rgba(10,10,10,0.05);
|
||||
--surface: #f4f3ef;
|
||||
--card: #ffffff;
|
||||
--radius-pill: 9999px;
|
||||
--radius-xl: 24px;
|
||||
--radius-lg: 18px;
|
||||
--shadow-xs: 0 1px 2px rgba(0,0,0,0.06);
|
||||
--shadow-sm: 0 2px 10px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 6px 28px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.05);
|
||||
--shadow-inset: inset 0 1px 0 rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity:0; transform:translateY(14px); }
|
||||
to { opacity:1; transform:none; }
|
||||
}
|
||||
|
||||
*,*::before,*::after { margin:0; padding:0; box-sizing:border-box; }
|
||||
|
||||
html {
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--surface);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='0.028'/%3E%3C/svg%3E");
|
||||
padding: 0 20px 100px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding-top: 48px;
|
||||
animation: fade-up .7s cubic-bezier(.22,1,.36,1) backwards;
|
||||
animation-delay: .05s;
|
||||
}
|
||||
|
||||
/* ─── Eyebrow label above h1 ─────────────────────────────── */
|
||||
.container::before {
|
||||
content: '✦ Kết quả phiên âm';
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-45);
|
||||
margin-bottom: 14px;
|
||||
padding: 6px 14px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-pill);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-heading);
|
||||
font-size: clamp(28px, 5vw, 42px);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1.0;
|
||||
color: var(--ink);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 12px;
|
||||
color: var(--ink-45);
|
||||
line-height: 2;
|
||||
margin-bottom: 24px;
|
||||
padding: 14px 18px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.meta strong { color: var(--ink); font-weight: 500; }
|
||||
|
||||
/* Nav row */
|
||||
div[style*="margin-bottom"] {
|
||||
display: flex !important;
|
||||
gap: 6px !important;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 22px !important;
|
||||
}
|
||||
|
||||
/* ─── Buttons ────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 9px 24px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
letter-spacing: -0.01em;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--ink-10);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow-xs), var(--shadow-inset);
|
||||
margin: 0 5px 5px 0;
|
||||
transition: background .2s, color .2s, border-color .2s, box-shadow .2s,
|
||||
transform .18s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
border-color: var(--ink);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.btn:active:not(:disabled) { transform: scale(.97); box-shadow: var(--shadow-xs); }
|
||||
.btn:disabled { opacity:.36; cursor:not-allowed; }
|
||||
|
||||
/* ─── Transcript body ────────────────────────────────────── */
|
||||
.transcript {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--ink-10);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 28px 30px;
|
||||
font-size: 15px;
|
||||
line-height: 1.95;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
color: var(--ink-70);
|
||||
box-shadow: var(--shadow-sm), var(--shadow-inset);
|
||||
animation: fade-up .7s cubic-bezier(.22,1,.36,1) backwards;
|
||||
animation-delay: .18s;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--ink-10) transparent;
|
||||
}
|
||||
.transcript::-webkit-scrollbar { width:4px; }
|
||||
.transcript::-webkit-scrollbar-track { background:transparent; }
|
||||
.transcript::-webkit-scrollbar-thumb { background:var(--ink-10); border-radius:99px; }
|
||||
@@ -5,60 +5,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Transcript - {{ filename }}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.transcript {
|
||||
background: #f9f9f9;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #000000;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #000000;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://db.onlinewebfonts.com/c/5ac3fe7c6abd2f62067f266d89671492?family=HelveticaNowDisplay-Medium" />
|
||||
<link rel="stylesheet" href="https://db.onlinewebfonts.com/c/1aa3377e489837a26d019bba501e779d?family=HelveticaNowDisplayW01-Rg" />
|
||||
<link rel="stylesheet" href="/assets/transcription/transcriptionReview.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -75,5 +24,6 @@
|
||||
|
||||
<div class="transcript">{{ content }}</div>
|
||||
</div>
|
||||
<script src="/assets/transcription/transcriptionReview.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// transcriptionReview.js
|
||||
// Custom JavaScript for Transcription Review
|
||||
console.log("Transcription Review initialized.");
|
||||
Reference in New Issue
Block a user