AI generated initial commit

This commit is contained in:
2025-02-12 07:25:09 +00:00
commit 84f79dc5f9
32 changed files with 2188 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Virtual Environment
venv/
.env
# Build artifacts
*.egg-info/
build/
dist/
# Test artifacts
.pytest_cache/
__pycache__/
*.pyc
run/
+10
View File
@@ -0,0 +1,10 @@
# Be my anchor
Make you a podcast like audio fomr articles you'd like to read but want to listen instead.
## How to run it
```
make setup
python -m src.server.app
```
+25
View File
@@ -0,0 +1,25 @@
download-webdriver:
curl -L https://storage.googleapis.com/chrome-for-testing-public/132.0.6834.110/linux64/chrome-linux64.zip -o chrome-linux64.zip
curl -L https://storage.googleapis.com/chrome-for-testing-public/132.0.6834.110/linux64/chromedriver-linux64.zip -o chromedriver-linux64.zip
prepare-webdriver: download-webdriver
mkdir -p run/webdriver/chrome
unzip chrome-linux64.zip -d run/webdriver/chrome
rm -f chrome-linux64.zip
mkdir -p run/webdriver/chromedriver
unzip -j chromedriver-linux64.zip -d run/webdriver/chromedriver
rm -f chromedriver-linux64.zip
setup: prepare-webdriver
python3 -m venv venv
pip install -e .
clean-webdriver:
rm -rf run/webdriver
clean: clean-webdriver
rm -rf run
rm -rf venv
all: setup
+106
View File
@@ -0,0 +1,106 @@
[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "be-my-anchor"
version = "0.1.0"
authors = [
{name="Pengzhan Hao", email="me@pengzhan.dev"},
]
description = "Web content to podcast converter"
readme = "README.md"
requires-python = ">=3.11"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
[project.dependencies]
annotated-types==0.7.0
anyio==4.8.0
attrs==25.1.0
beautifulsoup4==4.12.3
blinker==1.9.0
cachetools==5.5.1
certifi==2024.12.14
chardet==5.2.0
charset-normalizer==3.4.1
click==8.1.8
cssselect==1.2.0
distro==1.9.0
feedfinder2==0.0.4
feedparser==6.0.11
filelock==3.17.0
Flask==3.1.0
google-ai-generativelanguage==0.6.15
google-api-core==2.24.0
google-api-python-client==2.159.0
google-auth==2.38.0
google-auth-httplib2==0.2.0
google-cloud-texttospeech==2.24.0
google-generativeai==0.8.4
googleapis-common-protos==1.66.0
greenlet==3.1.1
grpcio==1.70.0
grpcio-status==1.70.0
h11==0.14.0
html2text==2024.2.26
httpcore==1.0.7
httplib2==0.22.0
httpx==0.28.1
idna==3.10
itsdangerous==2.2.0
jieba3k==0.35.1
Jinja2==3.1.5
jiter==0.8.2
joblib==1.4.2
lxml==5.3.0
lxml_html_clean==0.4.1
MarkupSafe==3.0.2
newspaper3k==0.2.8
nltk==3.9.1
openai==1.60.1
outcome==1.3.0.post0
pillow==11.1.0
pip-chill==1.0.3
playwright==1.49.1
proto-plus==1.25.0
protobuf==5.29.3
pyasn1==0.6.1
pyasn1_modules==0.4.1
pydantic==2.10.6
pydantic_core==2.27.2
pyee==12.0.0
pyfranc==0.2.3
pyparsing==3.2.1
PySocks==1.7.1
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
PyYAML==6.0.2
readability-lxml==0.8.1
regex==2024.11.6
requests==2.32.3
requests-file==2.1.0
rsa==4.9
selenium==4.28.1
sgmllib3k==1.0.0
six==1.17.0
sniffio==1.3.1
sortedcontainers==2.4.0
soupsieve==2.6
tinysegmenter==0.3
tldextract==5.1.3
tqdm==4.67.1
trio==0.28.0
trio-websocket==0.11.1
typing_extensions==4.12.2
uritemplate==4.1.1
urllib3==2.3.0
websocket-client==1.8.0
Werkzeug==3.1.3
wsproto==1.2.0
[project.optional-dependencies]
dev = ["pytest", "black"]
+15
View File
@@ -0,0 +1,15 @@
flask
google-cloud-texttospeech
google-generativeai
grpcio-status
html2text
lxml-html-clean
newspaper3k
openai
pip-chill
playwright
pyfranc
pysocks
python-dotenv
readability-lxml
selenium
+5
View File
@@ -0,0 +1,5 @@
-r requirements.txt
pytest>=7.0
black>=23.0
isort>=5.0
pip-chill>=1.0
View File
+266
View File
@@ -0,0 +1,266 @@
"""
Web Content Extractor Module
Provides functionalities to fetch web content using headless Chrome,
sanitize HTML, and save results with comprehensive logging and error handling.
"""
import logging
import os
import time
import random
import string
from typing import Optional
from lxml.html.clean import Cleaner
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from bs4 import BeautifulSoup
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler()]
)
logger = logging.getLogger(__name__)
# Configuration constants
DEFAULT_CHROME_PATH = "run/webdriver/chrome"
DEFAULT_WEB_DRIVER_PATH = "run/webdriver/chromedriver"
DEFAULT_WAIT_TIME = 10
DEFAULT_OUTPUT_DIR = "run/tmp"
CHROME_OPTIONS = [
"--headless",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-extensions",
"--disable-gpu",
"window-size=1920,1080"
]
CLEANER_CONFIG = {
'page_structure': True,
'meta': True,
'embedded': True,
'links': True,
'style': True,
'processing_instructions': True,
'inline_style': True,
'scripts': True,
'javascript': True,
'comments': True,
'frames': True,
'forms': True,
'annoying_tags': True,
'remove_unknown_tags': True,
'safe_attrs_only': True,
'safe_attrs': frozenset(['src', 'color', 'href', 'title', 'class', 'name', 'id']),
'remove_tags': ('span', 'font', 'div')
}
ALLOWED_TAGS = {
'img': ['src', 'alt', 'width', 'height'],
'a': ['href', 'title'],
'video': ['src', 'controls', 'width', 'height'],
'source': ['src', 'type']
}
UNWANTED_TAGS = [
'script', 'style', 'nav', 'footer', 'header', 'aside',
'form', 'iframe', 'noscript', 'svg', 'canvas', 'applet',
'object', 'embed'
]
def configure_chrome_driver() -> webdriver.Chrome:
"""Configure and return a Chrome WebDriver instance."""
try:
chrome_options = Options()
for option in CHROME_OPTIONS:
chrome_options.add_argument(option)
chrome_path = os.path.join(
DEFAULT_CHROME_PATH, 'chrome-linux64', 'chrome'
)
chrome_options.binary_location = chrome_path
driver_path = os.path.join(
DEFAULT_WEB_DRIVER_PATH, 'chromedriver'
)
chrome_service = Service(driver_path)
logger.info("Initializing Chrome WebDriver")
return webdriver.Chrome(
service=chrome_service,
options=chrome_options
)
except Exception as e:
logger.error("Chrome driver configuration failed")
raise RuntimeError("WebDriver initialization failed") from e
def extract_html_with_chromedriver(
url: str,
wait_time: int = DEFAULT_WAIT_TIME
) -> Optional[str]:
"""
Extract HTML content from a URL using headless Chrome.
Args:
url: Target URL to scrape
wait_time: Maximum wait time for page load (seconds)
Returns:
str: Raw HTML content or None if extraction fails
"""
driver = None
try:
driver = configure_chrome_driver()
logger.info(f"Fetching URL: {url}")
driver.get(url)
WebDriverWait(driver, wait_time).until(
EC.presence_of_element_located((By.TAG_NAME, 'body'))
)
logger.debug("Page load completed successfully")
return driver.page_source
except Exception as e:
logger.error(f"Failed to extract HTML: {str(e)}")
return None
finally:
if driver:
logger.debug("Closing WebDriver instance")
driver.quit()
def sanitize_html(dirty_html: str) -> str:
"""
Sanitize HTML content using lxml cleaner.
Args:
dirty_html: Raw HTML input
Returns:
str: Sanitized HTML output
"""
try:
logger.info("Sanitizing HTML content")
cleaner = Cleaner(**CLEANER_CONFIG)
return cleaner.clean_html(dirty_html)
except Exception as e:
logger.error(f"HTML sanitization failed: {str(e)}")
raise
def extract_content(html_content: str) -> Optional[str]:
"""
Extract and clean main content from HTML.
Args:
html_content: Raw HTML input
Returns:
str: Cleaned HTML content or None if processing fails
"""
try:
if not html_content:
logger.warning("Empty HTML content received")
return None
logger.info("Processing HTML content")
soup = BeautifulSoup(html_content, "html.parser")
# Remove unwanted tags
for tag in soup.find_all(UNWANTED_TAGS):
tag.decompose()
# Clean tag attributes
for tag in soup.find_all():
allowed = ALLOWED_TAGS.get(tag.name, [])
attrs = list(tag.attrs.keys())
for attr in attrs:
if attr not in allowed:
del tag[attr]
# Remove empty tags
for tag in soup.find_all():
if not tag.get_text(strip=True):
tag.decompose()
return str(soup)
except Exception as e:
logger.error(f"Content extraction failed: {str(e)}")
return None
def save_html_to_file(
html_content: str,
output_dir: str = DEFAULT_OUTPUT_DIR
) -> Optional[str]:
"""
Save HTML content to timestamped file.
Args:
html_content: Content to save
output_dir: Target directory path
Returns:
str: Path to saved file or None if save fails
"""
try:
os.makedirs(output_dir, exist_ok=True)
timestamp = time.strftime("%Y%m%d%H%M%S")
rand_str = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
filename = f"output_{timestamp}_{rand_str}.html"
filepath = os.path.join(output_dir, filename)
logger.info(f"Saving output to: {filepath}")
with open(filepath, "w", encoding="utf-8") as f:
f.write(html_content)
return filepath
except Exception as e:
logger.error(f"File save failed: {str(e)}")
return None
def get_content_from_url(url: str) -> Optional[str]:
"""
Main pipeline: Fetch URL -> Extract Content -> Sanitize HTML
Args:
url: Target URL to process
Returns:
str: Processed HTML content or None if any step fails
"""
try:
raw_html = extract_html_with_chromedriver(url)
if not raw_html:
return None
sanitized = sanitize_html(raw_html)
return extract_content(sanitized)
except Exception as e:
logger.error(f"Processing pipeline failed: {str(e)}")
return None
if __name__ == "__main__":
target_url = "https://www.cncf.io/blog/2025/01/22/kubernetes-in-2025-are-you-ready-for-these-top-5-trends-and-predictions/"
if (result := get_content_from_url(target_url)) is not None:
file_path = save_html_to_file(result)
if file_path:
logger.info(f"Successfully saved output to: {file_path}")
else:
logger.error("Failed to save output file")
else:
logger.error("Content extraction failed for target URL")
View File
+192
View File
@@ -0,0 +1,192 @@
"""
LLM-based Podcast Script Converter
将HTML内容转换为播客风格的稿件,支持多种LLM模型接口。
"""
import os
import logging
from abc import ABC, abstractmethod
from typing import Optional, Dict, Literal
import html2text
# 第三方库导入(需要额外安装)
try:
from openai import OpenAI # OpenAI官方库
import google.generativeai as genai # Google Gemini
except ImportError:
pass # 运行时检查
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class LLMConverter(ABC):
"""LLM转换器抽象基类"""
@abstractmethod
def convert_to_podcast(self, html_content: str) -> Optional[str]:
"""将HTML内容转换为播客稿件"""
pass
class OpenAIConverter(LLMConverter):
"""ChatGPT转换器实现"""
def __init__(self, model: str = "gpt-4-turbo", temperature: float = 0.7):
self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
self.model = model
self.temperature = temperature
def convert_to_podcast(self, html_content: str) -> Optional[str]:
try:
# 将HTML转换为Markdown格式
markdown_content = html2text.html2text(html_content)
response = self.client.chat.completions.create(
model=self.model,
temperature=self.temperature,
messages=[
{
"role": "system",
"content": """你是一位专业播客编剧,需要将技术文章转换为生动有趣的播客稿件。要求:
1. 使用口语化表达,避免专业术语
2. 结构清晰,包含引言、主体和总结
3. 突出文章的核心观点和技术亮点
4. 适当添加过渡语和互动提示
5. 总长度控制在1500字左右"""
},
{
"role": "user",
"content": markdown_content
}
]
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"OpenAI转换失败: {str(e)}")
return None
class GeminiConverter(LLMConverter):
"""Google Gemini转换器实现"""
def __init__(self, model: str = "gemini-pro", temperature: float = 0.7):
genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
self.model = genai.GenerativeModel(model)
self.temperature = temperature
def convert_to_podcast(self, html_content: str) -> Optional[str]:
try:
markdown_content = html2text.html2text(html_content)
response = self.model.generate_content(
f"[播客稿件转换要求同OpenAI版本]\n\n{markdown_content}",
generation_config=genai.types.GenerationConfig(
temperature=self.temperature
)
)
return response.text
except Exception as e:
logger.error(f"Gemini转换失败: {str(e)}")
return None
class DeepSeekConverter(LLMConverter):
"""深度求索转换器实现"""
def __init__(self, model: str = "deepseek-chat", temperature: float = 0.7):
self.client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com/v1"
)
self.model = model
self.temperature = temperature
def convert_to_podcast(self, html_content: str) -> Optional[str]:
try:
markdown_content = html2text.html2text(html_content)
response = self.client.chat.completions.create(
model=self.model,
temperature=self.temperature,
messages=[
{"role": "system", "content": "[播客稿件转换要求同OpenAI版本]"},
{"role": "user", "content": markdown_content}
]
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"DeepSeek转换失败: {str(e)}")
return None
class PodcastConverter:
"""播客稿件转换管理器"""
def __init__(
self,
provider: Literal["openai", "gemini", "deepseek"] = "openai",
model: Optional[str] = None,
temperature: float = 0.7
):
self.provider = provider
self.model = model
self.temperature = temperature
self.converter = self._init_converter()
def _init_converter(self) -> LLMConverter:
"""初始化指定的LLM转换器"""
try:
if self.provider == "openai":
return OpenAIConverter(
model=self.model or "gpt-4-turbo",
temperature=self.temperature
)
elif self.provider == "gemini":
return GeminiConverter(
model=self.model or "gemini-pro",
temperature=self.temperature
)
elif self.provider == "deepseek":
return DeepSeekConverter(
model=self.model or "deepseek-chat",
temperature=self.temperature
)
else:
raise ValueError("不支持的LLM提供商")
except Exception as e:
logger.error(f"转换器初始化失败: {str(e)}")
raise
def convert(self, html_content: str) -> Optional[str]:
"""执行转换流程"""
if not html_content:
logger.warning("输入内容为空")
return None
logger.info(f"开始转换,使用模型: {self.provider}/{self.model}")
return self.converter.convert_to_podcast(html_content)
# 使用示例
if __name__ == "__main__":
# 测试用HTML内容(实际应从文件或网络获取)
sample_html = """
<html>
<body>
<h1>人工智能的未来发展</h1>
<p>近年来,AI技术取得了突破性进展...</p>
<ul>
<li>自然语言处理的进步</li>
<li>计算机视觉的应用扩展</li>
<li>伦理问题的重要性</li>
</ul>
</body>
</html>
"""
# 使用OpenAI转换
openai_converter = PodcastConverter(provider="openai")
result = openai_converter.convert(sample_html)
if result:
print("转换结果示例:")
print(result[:500] + "...") # 打印前500字符
else:
print("转换失败")
View File
+87
View File
@@ -0,0 +1,87 @@
from flask import Flask, request, render_template, redirect, url_for
from ..modules.html_extractor.extract import get_content_from_url
from flask_dance.contrib.google import make_google_blueprint, google
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-here'
app.config['PREFIX'] = '/proxy/5000'
app.config['GOOGLE_OAUTH_CLIENT_ID'] = 'your-client-id'
app.config['GOOGLE_OAUTH_CLIENT_SECRET'] = 'your-client-secret'
@app.route("/")
def index():
print("index!")
return render_template("index.html", prefix=app.config['PREFIX'],)
@app.route("/page")
def article_page():
url = request.args.get("url")
if not url:
return redirect(url_for('index'))
try:
content = get_content_from_url(url)
return render_template('page.html',
prefix=app.config['PREFIX'],
url=url,
summary=content,
audio_url="/static/audio/sample.mp3")
except Exception as e:
app.logger.error(f"Error processing {url}: {str(e)}")
return render_template('error.html',
error_message="Failed to process the URL"), 500
@app.route("/login", methods=['GET', 'POST'])
def login():
if request.method == 'POST':
# Add authentication logic here
return redirect(url_for('index'))
return render_template("login.html", prefix=app.config['PREFIX'])
@app.route("/register", methods=['GET', 'POST'])
def register():
if request.method == 'POST':
# Add registration logic here
return redirect(url_for('login'))
return render_template("register.html", prefix=app.config['PREFIX'])
@app.errorhandler(404)
def page_not_found(e):
return render_template('error.html', prefix=app.config['PREFIX'],
error_message="Page not found"), 404
@app.errorhandler(500)
def internal_error(e):
return render_template('error.html', prefix=app.config['PREFIX'],
error_message="Internal server error"), 500
google_bp = make_google_blueprint(
scope=["profile", "email"],
redirect_to="auth_callback"
)
app.register_blueprint(google_bp, url_prefix="/auth")
@app.route("/auth/callback")
def auth_callback():
if not google.authorized:
return redirect(url_for("login"))
resp = google.get("/oauth2/v2/userinfo")
if resp.ok:
user_info = resp.json()
# Handle user authentication logic here
return redirect(url_for("index"))
return redirect(url_for("login"))
@app.route("/auth/google")
def google_auth():
return redirect(url_for("google.login"))
if __name__ == "__main__":
app.run(port=5000, debug=True)
+818
View File
@@ -0,0 +1,818 @@
:root {
--primary: #6366f1;
--surface: #f8fafc;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
touch-action: manipulation;
}
body {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
font-family: system-ui, sans-serif;
background: #f8fafc;
}
/* 头部样式 */
header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 12px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
z-index: 2000;
}
.branding {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 0 12px;
}
.brand-logo {
width: 32px;
height: 32px;
object-fit: contain;
}
.site-name {
font-weight: 600;
color: #1e293b;
font-size: 1.2rem;
white-space: nowrap;
}
.nav-group {
display: flex;
gap: 8px;
justify-self: end;
/* 右侧菜单保持右对齐 */
}
.history-button {
padding: 8px;
border: none;
background: none;
cursor: pointer;
justify-self: start;
}
.history-button img {
width: 32px;
height: 32px;
}
/* 菜单系统 */
.menu {
position: relative;
}
.menu-button {
padding: 8px;
border: none;
background: none;
cursor: pointer;
}
.menu-button img {
width: 32px;
height: 32px;
}
.menu-dropdown {
position: absolute;
right: 0;
top: 100%;
z-index: 100;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: none;
min-width: 160px;
}
.menu-dropdown.active {
display: block;
}
.menu-item {
padding: 12px 16px;
color: #334155;
text-decoration: none;
display: block;
border-bottom: 1px solid #f1f5f9;
}
/* 主容器设置 */
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 20px;
overflow: hidden;
}
/* 图片容器 */
.image-container {
position: relative;
width: min(60vw, 300px);
height: min(60vw, 300px);
/* 明确设置高度 */
margin: 0 auto 20px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 90;
}
/* 图片绝对居中方案 */
.image-container img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
/* 文字内容容器 */
.text-content {
width: 80%;
max-width: 800px;
text-align: center;
}
/* 主容器设置 */
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 20px;
overflow: hidden;
}
/* 图片容器 */
.image-container {
position: relative;
width: min(60vw, 300px);
height: min(60vw, 300px);
/* 明确设置高度 */
margin: 0 auto 20px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 图片绝对居中方案 */
.image-container img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
/* 文字内容容器 */
.text-content {
width: 80%;
max-width: 800px;
text-align: center;
}
.url {
font-size: 1.1em;
color: #3b82f6;
margin-bottom: 12px;
word-break: break-all;
}
.summary {
line-height: 1.5;
color: #64748b;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
/* 默认显示4行 */
overflow: hidden;
text-overflow: ellipsis;
--lines: 4;
/* 默认显示行数 */
--mobile-lines: 3;
/* 移动端行数 */
}
/* 响应式调整 */
@media (max-width: 768px) {
.text-content {
width: 90%;
/* 小屏幕更宽 */
}
.summary {
-webkit-line-clamp: 3;
/* 减少显示行数 */
}
}
/* 播放控制栏 */
footer {
display: flex;
align-items: center;
padding: 16px;
gap: 4px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
z-index: 2000;
}
.progress-bar {
flex-grow: 1;
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary);
width: 0%;
transition: width 0.1s linear;
}
.control-button {
padding: 8px;
border: none;
background: none;
cursor: pointer;
}
.control-button img {
width: 40px;
height: 40px;
}
/* 历史记录面板样式 */
.history-panel {
position: fixed;
top: -100%;
left: 0;
right: 0;
height: 100vh;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(30px);
z-index: 2000;
transition: top 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
padding: 60px 16px 16px;
}
.history-panel.active {
top: 0;
}
/* 关闭按钮位置调整 */
.history-close {
position: absolute;
top: 12px;
left: 12px;
/* 改为左上角 */
padding: 8px;
background: none;
border: none;
cursor: pointer;
}
.history-close img {
width: 32px;
height: 32px;
}
/* 滚动容器 */
.history-content {
height: calc(100vh - 100px);
overflow-y: auto;
padding: 0 8px;
}
/* 历史记录项样式 */
.history-item {
padding: 12px;
margin: 8px 0;
background: rgba(0, 0, 0, 0.03);
border-radius: 8px;
transition: all 0.2s;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
}
.history-item:hover {
background: rgba(0, 0, 0, 0.05);
}
/* 滚动条美化 */
.history-content::-webkit-scrollbar {
width: 6px;
}
.history-content::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 3px;
}
.history-content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.search-form {
display: flex;
align-items: center;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.search-input {
flex-grow: 1;
border: none;
padding: 15px;
font-size: 16px;
outline: none;
color: #333;
}
.search-button {
background-color: #007bff;
/* Blue button color */
color: #fff;
border: none;
padding: 15px 20px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.search-button:hover {
background-color: #0069d9;
}
/* Optional: Add a magnifying glass icon */
.search-input::placeholder {
color: #999;
}
/* Responsive adjustments (optional) */
@media (max-width: 768px) {
.search-form {
width: 100%;
}
}
/* Add to main.css */
/* Auth System */
.auth-main {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
min-height: calc(100dvh - 120px);
}
.auth-container {
background: white;
padding: 2.5rem;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 420px;
margin: 0 auto;
}
.auth-title {
text-align: center;
color: #1e293b;
font-size: 1.5rem;
margin-bottom: 2rem;
}
.input-group {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.input-group input {
width: 100%;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
.input-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.primary-button {
width: 100%;
padding: 14px;
background-color: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.primary-button:hover {
opacity: 0.9;
}
.oauth-divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 1.5rem 0;
color: #64748b;
}
.oauth-divider::before,
.oauth-divider::after {
content: '';
flex: 1;
height: 1px;
background: #e2e8f0;
}
.google-login {
width: 100%;
padding: 12px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: background 0.2s;
}
.google-login:hover {
background: #f8fafc;
}
.google-login img {
width: 20px;
height: 20px;
}
.auth-links {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
text-align: center;
}
.auth-links a {
color: var(--primary);
text-decoration: none;
font-size: 0.9rem;
}
.auth-links a:hover {
text-decoration: underline;
}
/* 统一搜索页面样式 */
.search-main {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100dvh - 120px);
padding: 2rem;
}
.search-container {
width: 100%;
max-width: 720px;
text-align: center;
}
.search-title {
font-size: 2rem;
color: #1e293b;
margin-bottom: 2rem;
}
.search-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.search-input {
padding: 16px 24px;
border: 2px solid #e2e8f0;
border-radius: 12px;
font-size: 1.1rem;
transition: all 0.3s ease;
}
.search-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
/* 统一按钮悬停效果 */
.primary-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
/* 历史面板动画优化 */
.history-panel {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform: translateY(-100%);
}
.history-panel.active {
transform: translateY(0);
}
/* 新增CSS样式 */
.search-textarea:focus {
outline: none;
box-shadow: 0 0 0 2px var(--primary);
}
.search-icon-button {
position: absolute;
right: 16px;
bottom: 16px;
background: var(--primary);
border: none;
border-radius: 12px;
padding: 12px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
}
.search-icon-button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.search-icon-button img {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
}
.search-options {
margin-top: 1.5rem;
display: flex;
gap: 1.5rem;
justify-content: center;
flex-wrap: wrap;
}
.option-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #475569;
}
.voice-selection select {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: white;
font-size: 0.95rem;
}
/* 加载动画 */
.loading-indicator {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 16px 24px;
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
align-items: center;
gap: 12px;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 3px solid #e2e8f0;
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 验证状态提示 */
.invalid-input {
border-color: #ef4444 !important;
background: #fef2f2;
}
.validation-error {
color: #ef4444;
font-size: 0.9rem;
margin-top: 4px;
display: none;
}
/* 新增/修改的CSS */
.search-group {
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.search-textarea {
width: 100%;
padding: 20px;
border: none;
resize: none;
font-size: 1.1rem;
line-height: 1.6;
min-height: 150px;
background: #fff;
}
.search-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
.advanced-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: var(--primary);
cursor: pointer;
padding: 8px 12px;
border-radius: 8px;
transition: all 0.2s;
}
.advanced-toggle:hover {
background: rgba(99, 102, 241, 0.1);
}
.advanced-toggle img {
width: 14px;
height: 14px;
transition: transform 0.2s;
}
.search-icon-button {
background: var(--primary);
border: none;
border-radius: 12px;
padding: 10px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
color: white;
transition: all 0.2s;
}
.search-icon-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.search-icon-button img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
/* 高级选项面板 */
.advanced-options {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
background: #fff;
border-radius: 0 0 16px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.advanced-options.active {
max-height: 400px; /* 根据内容调整 */
}
.option-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
padding: 20px;
}
.option-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-group label {
font-size: 0.9rem;
color: #475569;
font-weight: 500;
}
.option-group select,
.option-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: white;
}
/* 开关样式 */
.switch {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.slider {
position: relative;
width: 40px;
height: 20px;
background: #e2e8f0;
border-radius: 10px;
transition: 0.4s;
}
.slider:before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background: white;
border-radius: 50%;
transition: 0.4s;
}
input:checked + .slider {
background: var(--primary);
}
input:checked + .slider:before {
transform: translateX(20px);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+29
View File
@@ -0,0 +1,29 @@
// history.js
const HistoryManager = {
MAX_ITEMS: 20,
init: function() {
if (!localStorage.history) {
localStorage.history = JSON.stringify([]);
}
},
add: function(item) {
const history = JSON.parse(localStorage.history);
history.unshift({
timestamp: new Date().toISOString(),
...item
});
localStorage.history = JSON.stringify(history.slice(0, this.MAX_ITEMS));
},
render: function(container) {
const history = JSON.parse(localStorage.history);
container.innerHTML = history.map(item => `
<div class="history-item">
<time>${new Date(item.timestamp).toLocaleString()}</time>
<p>${item.title}</p>
</div>
`).join('');
}
};
+216
View File
@@ -0,0 +1,216 @@
// static/js/shared.js
const App = {
init: function () {
this.bindMenuToggle();
this.bindGlobalClick();
this.initHistoryPanel();
this.initAdvancedOptions();
this.checkLoginStatus();
this.loadSettings();
},
// 菜单切换功能
bindMenuToggle: function () {
document.querySelectorAll('.menu-button').forEach(button => {
button.addEventListener('click', (e) => {
e.stopPropagation();
const menu = e.currentTarget.closest('.menu').querySelector('.menu-dropdown');
menu.classList.toggle('active');
});
});
},
// 全局点击处理
bindGlobalClick: function () {
document.addEventListener('click', (e) => {
// 关闭所有菜单
document.querySelectorAll('.menu-dropdown.active').forEach(menu => {
if (!menu.contains(e.target)) {
menu.classList.remove('active');
}
});
// 关闭高级选项
const advancedPanel = document.getElementById('advancedOptions');
if (advancedPanel &&
!e.target.closest('.advanced-options') &&
!e.target.closest('.advanced-toggle')) {
advancedPanel.classList.remove('active');
const toggleImg = document.querySelector('.advanced-toggle img');
if (toggleImg) toggleImg.style.transform = 'rotate(0deg)';
}
});
},
// 历史记录面板
initHistoryPanel: function () {
const historyButton = document.querySelector('.history-button');
const historyPanel = document.getElementById('historyPanel');
if (historyButton && historyPanel) {
historyButton.addEventListener('click', () => {
historyPanel.classList.toggle('active');
});
// 触摸滑动处理
let touchStartY = 0;
historyPanel.addEventListener('touchstart', e => {
touchStartY = e.touches[0].clientY;
}, false);
historyPanel.addEventListener('touchmove', e => {
const touchY = e.touches[0].clientY;
if (touchY - touchStartY > 50) {
historyPanel.classList.remove('active');
}
}, false);
}
},
// 高级选项功能
initAdvancedOptions: function () {
const toggleButton = document.querySelector('.advanced-toggle');
if (toggleButton) {
toggleButton.addEventListener('click', () => {
const optionsPanel = document.getElementById('advancedOptions');
optionsPanel.classList.toggle('active');
const img = toggleButton.querySelector('img');
img.style.transform = optionsPanel.classList.contains('active') ?
'rotate(180deg)' : 'rotate(0deg)';
});
}
},
// 登录状态检查
checkLoginStatus: function () {
const loginItem = document.getElementById('loginItem');
if (loginItem) {
// 实际应通过API检查登录状态
const isLoggedIn = false;
if (isLoggedIn) {
loginItem.textContent = 'Profile';
loginItem.href = '/profile';
}
}
},
// 设置管理
loadSettings: function () {
if (document.querySelector('[name="voice"]')) {
const settings = JSON.parse(localStorage.getItem('userSettings')) || {};
document.querySelector('[name="voice"]').value = settings.voice || 'male';
document.querySelector('[name="style"]').value = settings.style || 'news';
document.querySelector('[name="experimental"]').checked = settings.experimental || false;
}
},
saveSettings: function () {
if (document.querySelector('[name="voice"]')) {
const settings = {
voice: document.querySelector('[name="voice"]').value,
style: document.querySelector('[name="style"]').value,
experimental: document.querySelector('[name="experimental"]').checked
};
localStorage.setItem('userSettings', JSON.stringify(settings));
}
},
// 通用表单验证
validateRegistration: function (form) {
const password = form.querySelector('#password').value;
const confirmPassword = form.querySelector('#confirmPassword').value;
if (password !== confirmPassword) {
alert('Passwords do not match!');
return false;
}
return true;
},
// 播放器控制
initAudioPlayer: function () {
const audio = document.getElementById('audioPlayer');
if (!audio) return;
// 设置有效音频源
if (window.audioSource) {
audio.src = window.audioSource;
}
const progress = document.querySelector('.progress-fill');
const playButton = document.getElementById('playButton');
// 安全更新播放按钮状态
const updatePlayButton = () => {
if (!audio.paused && !isFinite(audio.duration)) {
return; // 防止无效状态
}
const iconPath = audio.paused ? 'play.png' : 'pause.png';
playButton.querySelector('img').src =
`${window.prefix}/static/icons/${iconPath}`;
};
// 音频元数据加载完成后再启用控制
audio.addEventListener('loadedmetadata', () => {
// 初始化进度条
progress.style.width = '0%';
// 绑定控制事件
document.querySelectorAll('#playButton').forEach(btn => {
btn.addEventListener('click', () => {
audio.paused ? audio.play().catch(console.error) : audio.pause();
});
});
document.querySelectorAll('.jump-back').forEach(btn => {
btn.addEventListener('click', () => this.jumpAudio(-15, audio));
});
document.querySelectorAll('.jump-forward').forEach(btn => {
btn.addEventListener('click', () => this.jumpAudio(15, audio));
});
});
// 错误处理
audio.addEventListener('error', (e) => {
console.error('Audio error:', e.target.error);
alert('Error loading audio file');
});
},
jumpAudio: function (seconds, audio) {
try {
if (!isFinite(audio.duration) || audio.readyState < 2) {
console.warn('Audio not ready');
return;
}
const newTime = Math.max(0, Math.min(
audio.currentTime + seconds,
audio.duration
));
audio.currentTime = newTime;
} catch (error) {
console.error('Jump error:', error);
}
}
};
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
App.init();
App.initAudioPlayer();
// 注册表单验证
const regForm = document.querySelector('.auth-form');
if (regForm) {
regForm.addEventListener('submit', (e) => {
if (!App.validateRegistration(regForm)) {
e.preventDefault();
}
});
}
});
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Error - Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<!-- 左侧历史按钮 -->
<button class="history-button" onclick="toggleHistory()">
<img src="/proxy/5000/static/icons/clock.png" alt="History">
</button>
<!-- 中间Logo和标题 -->
<div class="branding">
<img src="/proxy/5000/static/logo.png" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<!-- 右侧菜单组 -->
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="/proxy/5000/static/icons/people.png" alt="Menu">
</button>
<!-- 菜单下拉内容 -->
<div class="menu-dropdown" id="mainMenu">
<a href="/" class="menu-item">返回主页</a>
<a href="/report" class="menu-item">报告问题</a>
<a href="/login" class="menu-item" id="loginItem">登录</a>
</div>
</div>
</div>
</header>
<!-- 历史记录面板结构 -->
<div class="history-panel" id="historyPanel">
<button class="history-close" onclick="toggleHistory()">
<img src="/proxy/5000/static/icons/play-pause.png" alt="Close">
</button>
<div class="history-content">
<div class="history-item">
<span class="timestamp">10:30</span>
<span class="title">文章标题示例 1</span>
</div>
<!-- 更多历史记录项... -->
<div class="history-item">
<span class="timestamp">11:45</span>
<span class="title">文章标题示例 20</span>
</div>
</div>
</div>
<main>
<div class="error-container">
<h2>Oops! Something went wrong</h2>
<p class="error-message">{{ error_message }}</p>
<a href="/" class="primary-button">Return Home</a>
</div>
</main>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
<!-- ./src/server/templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<button class="history-button" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/login" class="menu-item">Login</a>
<a href="{{ prefix }}/register" class="menu-item">Register</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
</div>
</div>
</div>
</header>
<!-- index.html 修改后的搜索部分 -->
<main class="search-main">
<div class="search-container">
<h1 class="search-title">Convert Content to Podcast</h1>
<form class="search-form" action="{{ prefix }}/page" method="get">
<!-- 搜索输入区 -->
<div class="search-group">
<textarea name="content" class="search-textarea" placeholder="Paste your text or enter URL..."
rows="4" required></textarea>
<div class="search-actions">
<button type="button" class="advanced-toggle" onclick="toggleAdvancedOptions()">
Advanced Options
<img src="{{ prefix }}/static/icons/chevron-down.svg" alt="▼">
</button>
<button type="submit" class="search-icon-button">
<img src="{{ prefix }}/static/icons/convert.svg" alt="Convert">
</button>
</div>
</div>
<!-- 高级选项下拉 -->
<div class="advanced-options" id="advancedOptions">
<div class="option-grid">
<div class="option-group">
<label>Voice Gender</label>
<select name="voice">
<option value="male">Male</option>
<option value="female">Female</option>
<option value="neutral">Neutral</option>
</select>
</div>
<div class="option-group">
<label>Style</label>
<select name="style">
<option value="news">News Report</option>
<option value="blog">Casual Blog</option>
<option value="story">Story Telling</option>
<option value="academic">Academic</option>
</select>
</div>
<div class="option-group">
<label>Custom Prompt</label>
<input type="text" name="prompt" placeholder="Additional instructions...">
</div>
<div class="option-group">
<label class="switch">
<input type="checkbox" name="experimental">
<span class="slider"></span>
<span>Experimental Mode</span>
</label>
</div>
</div>
</div>
</form>
</div>
</main>
<!-- 在body末尾添加 -->
<div class="loading-indicator">
<div class="loading-spinner"></div>
<span>Processing...</span>
</div>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Login - Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<button class="history-button" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/" class="menu-item">Home</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
<a href="{{ prefix }}/register" class="menu-item">Register</a>
</div>
</div>
</div>
</header>
<main class="auth-main">
<div class="auth-container">
<h2 class="auth-title">Welcome Back</h2>
<form class="auth-form" method="POST" action="/login">
<div class="input-group">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
</div>
<button type="submit" class="primary-button">Login</button>
<div class="oauth-divider">
<span>or continue with</span>
</div>
<button type="button" class="google-login" onclick="loginWithGoogle()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/google.png') }}" alt="Google">
Continue with Google
</button>
</form>
<div class="auth-links">
<a href="{{ prefix }}/register">Create new account</a>
<a href="{{ prefix }}/forgot-password">Forgot password?</a>
</div>
</div>
</main>
</body>
</html>
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Be my Anchor - {{ url }} </title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script>
// 定义全局路径前缀
window.prefix = "{{ prefix }}";
// 初始化音频源
window.audioSource = "{{ prefix }}/{{ url_for('static', filename='test.mp3') }}";
</script>
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<!-- 左侧历史按钮 -->
<button class="history-button" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<!-- 中间Logo和标题 -->
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<!-- 右侧菜单组 -->
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<!-- 菜单下拉内容 -->
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/" class="menu-item">Home</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
<a href="{{ prefix }}/login" class="menu-item" id="loginItem">Login</a>
</div>
</div>
</div>
</header>
<!-- 历史记录面板结构 -->
<div class="history-panel" id="historyPanel">
<button class="history-close" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/play-pause.png') }}" alt="Close">
</button>
<div class="history-content">
<div class="history-item">
<span class="timestamp">10:30</span>
<span class="title">文章标题示例 1</span>
</div>
<!-- 更多历史记录项... -->
<div class="history-item">
<span class="timestamp">11:45</span>
<span class="title">文章标题示例 20</span>
</div>
</div>
</div>
<main>
<div class="image-container">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Cover">
</div>
<!-- 独立文字容器 -->
<div class="text-content">
<div class="url">{{url}}</div>
<div class="summary">{{summary}}</div>
</div>
</main>
<!-- page.html 修改后的按钮部分 -->
<footer>
<button class="control-button jump-back">
<img src="{{ prefix }}/static/icons/back.png" alt="-15s">
</button>
<button class="control-button" id="playButton">
<img src="{{ prefix }}/static/icons/play.png" alt="Play/Pause">
</button>
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<button class="control-button jump-forward">
<img src="{{ prefix }}/static/icons/fast.png" alt="+15s">
</button>
</footer>
<audio id="audioPlayer" src="{{ prefix }}/{{ url_for('static', filename='test.mp3') }}"></audio>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Register - Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<button class="history-button" onclick="handleHistoryButton()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/" class="menu-item">Home</a>
<a href="{{ prefix }}/login" class="menu-item">Login</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
</div>
</div>
</div>
</header>
<main class="auth-main">
<div class="auth-container">
<h2 class="auth-title">Create Account</h2>
<form class="auth-form" onsubmit="return validateRegistration(event)">
<div class="input-group">
<input type="text" id="username" placeholder="Username" required>
<input type="email" id="email" placeholder="Email" required>
<input type="password" id="password" placeholder="Password" required>
<input type="password" id="confirmPassword" placeholder="Confirm Password" required>
</div>
<button type="submit" class="primary-button">Register</button>
<div class="auth-links">
<span>Already have an account? <a href="{{ prefix }}/login">Login here</a></span>
</div>
</form>
</div>
</main>
</body>
</html>