add translate

This commit is contained in:
2026-01-16 12:40:01 +09:00
parent 7d7130b23c
commit e2fdf135ea
17 changed files with 947 additions and 11 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
AILOG_DIR=~/ai/log
TRANSLATE_URL=http://127.0.0.1:1234/v1
TRANSLATE_MODEL=plamo-2-translate

6
.gitignore vendored
View File

@@ -1,6 +1,8 @@
dist
.claude
repos
node_modules
package-lock.json
repos
CLAUDE.md
.claude
.env
/rust/target

View File

@@ -1,7 +1,12 @@
{
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
"cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e",
"title": "ailogを作り直した",
"content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```",
"createdAt": "2026-01-15T13:59:52.367Z"
"createdAt": "2026-01-15T13:59:52.367Z",
"title": "ailogを作り直した",
"translations": {
"en": {
"content": "## About ailog\n\nA site generator that integrates with atproto.\n\n## Using ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on the at-browser foundation\n2. Logs in via atproto oAuth\n3. Allows posting articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local sources)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```"
}
},
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s"
}

13
rust/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "ailog"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dotenvy = "0.15"
dirs = "5"

44
rust/src/auth.rs Normal file
View File

@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthConfig {
pub handle: String,
pub did: String,
pub access_jwt: String,
pub refresh_jwt: String,
pub pds: String,
}
pub fn config_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("syui")
.join("ai")
.join("log")
}
pub fn config_path() -> PathBuf {
config_dir().join("config.json")
}
pub fn load_config() -> Option<AuthConfig> {
let path = config_path();
if path.exists() {
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
} else {
None
}
}
pub fn save_config(config: &AuthConfig) -> Result<(), Box<dyn std::error::Error>> {
let dir = config_dir();
fs::create_dir_all(&dir)?;
let path = config_path();
let content = serde_json::to_string_pretty(config)?;
fs::write(&path, content)?;
println!("Config saved to: {}", path.display());
Ok(())
}

View File

@@ -0,0 +1,66 @@
use crate::auth::{save_config, AuthConfig};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
struct CreateSessionRequest {
identifier: String,
password: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateSessionResponse {
did: String,
handle: String,
access_jwt: String,
refresh_jwt: String,
}
pub async fn run(handle: &str, password: &str, server: &str) -> Result<(), Box<dyn std::error::Error>> {
// Add https:// if no protocol specified
let server = if server.starts_with("http://") || server.starts_with("https://") {
server.to_string()
} else {
format!("https://{}", server)
};
println!("Logging in as {} to {}", handle, server);
let client = reqwest::Client::new();
let url = format!("{}/xrpc/com.atproto.server.createSession", server);
let req = CreateSessionRequest {
identifier: handle.to_string(),
password: password.to_string(),
};
let res = client
.post(&url)
.json(&req)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await?;
return Err(format!("Login failed ({}): {}", status, body).into());
}
let session: CreateSessionResponse = res.json().await?;
let config = AuthConfig {
handle: session.handle.clone(),
did: session.did.clone(),
access_jwt: session.access_jwt,
refresh_jwt: session.refresh_jwt,
pds: server.to_string(),
};
save_config(&config)?;
println!("Logged in successfully!");
println!("DID: {}", session.did);
println!("Handle: {}", session.handle);
Ok(())
}

4
rust/src/commands/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod login;
pub mod refresh;
pub mod sync;
pub mod translate;

View File

@@ -0,0 +1,75 @@
use crate::auth::{load_config, save_config, AuthConfig};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RefreshSessionResponse {
did: String,
handle: String,
access_jwt: String,
refresh_jwt: String,
}
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let config = load_config().ok_or("Not logged in. Run 'ailog l' first.")?;
println!("Refreshing session for {}", config.handle);
let client = reqwest::Client::new();
let url = format!("{}/xrpc/com.atproto.server.refreshSession", config.pds);
let res = client
.post(&url)
.header("Authorization", format!("Bearer {}", config.refresh_jwt))
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await?;
return Err(format!("Refresh failed ({}): {}", status, body).into());
}
let session: RefreshSessionResponse = res.json().await?;
let new_config = AuthConfig {
handle: session.handle.clone(),
did: session.did.clone(),
access_jwt: session.access_jwt,
refresh_jwt: session.refresh_jwt,
pds: config.pds,
};
save_config(&new_config)?;
println!("Session refreshed successfully!");
println!("DID: {}", session.did);
Ok(())
}
/// Refresh token if needed and return valid access token
pub async fn get_valid_token() -> Result<String, Box<dyn std::error::Error>> {
let config = load_config().ok_or("Not logged in. Run 'ailog l' first.")?;
// Try to use current token, if it fails, refresh
let client = reqwest::Client::new();
let test_url = format!("{}/xrpc/com.atproto.server.getSession", config.pds);
let res = client
.get(&test_url)
.header("Authorization", format!("Bearer {}", config.access_jwt))
.send()
.await?;
if res.status().is_success() {
return Ok(config.access_jwt);
}
// Token expired, refresh it
println!("Token expired, refreshing...");
run().await?;
let new_config = load_config().ok_or("Failed to load refreshed config")?;
Ok(new_config.access_jwt)
}

117
rust/src/commands/sync.rs Normal file
View File

@@ -0,0 +1,117 @@
use crate::auth::load_config;
use crate::commands::refresh::get_valid_token;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct PutRecordRequest {
repo: String,
collection: String,
rkey: String,
record: serde_json::Value,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct PutRecordResponse {
uri: String,
cid: String,
}
pub async fn run(input: &Path, collection: &str) -> Result<(), Box<dyn std::error::Error>> {
let config = load_config().ok_or("Not logged in. Run 'ailog l' first.")?;
if input.is_dir() {
sync_folder(input, collection).await
} else {
sync_file(input, collection, &config.did, &config.pds).await
}
}
async fn sync_file(
input: &Path,
collection: &str,
did: &str,
pds: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Extract rkey from filename (e.g., "3abc123.json" -> "3abc123")
let rkey = input
.file_stem()
.and_then(|s| s.to_str())
.ok_or("Invalid filename")?;
println!("Syncing {} -> {}/{}", input.display(), collection, rkey);
// Read and parse JSON
let content = fs::read_to_string(input)?;
let record: serde_json::Value = serde_json::from_str(&content)?;
// Get valid token (auto-refresh if needed)
let token = get_valid_token().await?;
let client = reqwest::Client::new();
let url = format!("{}/xrpc/com.atproto.repo.putRecord", pds);
let req = PutRecordRequest {
repo: did.to_string(),
collection: collection.to_string(),
rkey: rkey.to_string(),
record,
};
let res = client
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.json(&req)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await?;
return Err(format!("Put failed ({}): {}", status, body).into());
}
let result: PutRecordResponse = res.json().await?;
println!(" OK: {}", result.uri);
Ok(())
}
async fn sync_folder(dir: &Path, collection: &str) -> Result<(), Box<dyn std::error::Error>> {
let config = load_config().ok_or("Not logged in. Run 'ailog l' first.")?;
let mut files: Vec<_> = fs::read_dir(dir)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
.collect();
files.sort_by_key(|e| e.path());
println!("Syncing {} files from {}", files.len(), dir.display());
let mut success = 0;
let mut failed = 0;
for entry in files {
let path = entry.path();
match sync_file(&path, collection, &config.did, &config.pds).await {
Ok(_) => success += 1,
Err(e) => {
eprintln!(" ERROR {}: {}", path.display(), e);
failed += 1;
}
}
}
println!("\nDone: {} success, {} failed", success, failed);
Ok(())
}

View File

@@ -0,0 +1,184 @@
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::Path;
#[derive(Debug, Serialize)]
struct ChatMessage {
role: String,
content: String,
}
#[derive(Debug, Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
}
#[derive(Debug, Deserialize)]
struct ChatChoice {
message: ChatMessageResponse,
}
#[derive(Debug, Deserialize)]
struct ChatMessageResponse {
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatResponse {
choices: Vec<ChatChoice>,
}
pub async fn run(input: &Path, from: &str, to: &str) -> Result<(), Box<dyn std::error::Error>> {
if input.is_dir() {
translate_folder(input, from, to).await
} else {
translate_file(input, from, to).await
}
}
async fn translate_file(input: &Path, from: &str, to: &str) -> Result<(), Box<dyn std::error::Error>> {
let translate_url = env::var("TRANSLATE_URL")
.unwrap_or_else(|_| "http://127.0.0.1:1234/v1".to_string());
let model = env::var("TRANSLATE_MODEL")
.unwrap_or_else(|_| "plamo-2-translate".to_string());
println!("Translating: {}", input.display());
// Read input JSON
let content = fs::read_to_string(input)?;
let mut record: serde_json::Value = serde_json::from_str(&content)?;
// Check if already translated
if record.get("translations")
.and_then(|t| t.get(to))
.is_some()
{
println!(" Skipped (already has {} translation)", to);
return Ok(());
}
// Get text to translate
let text = record.get("content")
.and_then(|v| v.as_str())
.ok_or("No 'content' field in JSON")?;
// Build plamo-2-translate prompt
let from_lang = lang_name(from);
let to_lang = lang_name(to);
let system_content = "<|plamo:op|>dataset\ntranslation".to_string();
let user_content = format!(
"<|plamo:op|>input lang={}\n{}\n<|plamo:op|>output lang={}",
from_lang, text, to_lang
);
let req = ChatRequest {
model,
messages: vec![
ChatMessage { role: "system".to_string(), content: system_content },
ChatMessage { role: "user".to_string(), content: user_content },
],
};
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", translate_url);
let res = client
.post(&url)
.json(&req)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await?;
return Err(format!("Translation failed ({}): {}", status, body).into());
}
let chat_res: ChatResponse = res.json().await?;
let translated = chat_res.choices
.first()
.map(|c| c.message.content.trim().to_string())
.ok_or("No translation result")?;
// Add translation to record
let translations = record
.as_object_mut()
.ok_or("Invalid JSON")?
.entry("translations")
.or_insert_with(|| serde_json::json!({}));
translations
.as_object_mut()
.ok_or("Invalid translations field")?
.insert(to.to_string(), serde_json::json!({
"content": translated
}));
// Write back
let output = serde_json::to_string_pretty(&record)?;
fs::write(input, output)?;
println!(" OK");
Ok(())
}
async fn translate_folder(dir: &Path, from: &str, to: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut files: Vec<_> = fs::read_dir(dir)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
.collect();
files.sort_by_key(|e| e.path());
println!("Translating {} files ({} -> {})", files.len(), from, to);
let mut success = 0;
let mut skipped = 0;
let mut failed = 0;
for entry in files {
let path = entry.path();
match translate_file(&path, from, to).await {
Ok(_) => {
// Check if it was actually translated or skipped
let content = fs::read_to_string(&path)?;
let record: serde_json::Value = serde_json::from_str(&content)?;
if record.get("translations").and_then(|t| t.get(to)).is_some() {
success += 1;
} else {
skipped += 1;
}
}
Err(e) => {
eprintln!(" ERROR {}: {}", path.display(), e);
failed += 1;
}
}
}
println!("\nDone: {} translated, {} skipped, {} failed", success, skipped, failed);
Ok(())
}
fn lang_name(code: &str) -> &str {
match code {
"ja" => "Japanese",
"en" => "English",
"zh" => "Chinese",
"ko" => "Korean",
"fr" => "French",
"de" => "German",
"es" => "Spanish",
_ => code,
}
}

112
rust/src/main.rs Normal file
View File

@@ -0,0 +1,112 @@
use clap::{Parser, Subcommand};
use std::path::PathBuf;
mod auth;
mod commands;
fn load_env() {
// Try AILOG_DIR env var first (may be set externally)
if let Ok(ailog_dir) = std::env::var("AILOG_DIR") {
let env_path = PathBuf::from(&ailog_dir).join(".env");
if env_path.exists() {
let _ = dotenvy::from_path(&env_path);
return;
}
}
// Try current directory
if dotenvy::dotenv().is_ok() {
return;
}
// Try executable directory
if let Ok(exe_path) = std::env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
let env_path = exe_dir.join(".env");
if env_path.exists() {
let _ = dotenvy::from_path(&env_path);
}
}
}
}
pub fn get_ailog_dir() -> PathBuf {
std::env::var("AILOG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
}
#[derive(Parser)]
#[command(name = "ailog")]
#[command(about = "AT Protocol blog tool")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Login to PDS (createSession)
#[command(name = "l")]
Login {
/// Handle (e.g., user.bsky.social)
handle: String,
/// Password
#[arg(short, long)]
password: String,
/// PDS server (e.g., bsky.social)
#[arg(short, long, default_value = "bsky.social")]
server: String,
},
/// Refresh session token
#[command(name = "r")]
Refresh,
/// Translate content (file or folder)
#[command(name = "t")]
Translate {
/// Input JSON file or folder containing *.json
input: PathBuf,
/// Source language
#[arg(short, long, default_value = "ja")]
from: String,
/// Target language
#[arg(short = 'l', long, default_value = "en")]
to: String,
},
/// Sync records to PDS (putRecord)
#[command(name = "s")]
Sync {
/// Input JSON file or folder containing *.json
input: PathBuf,
/// Collection NSID (e.g., ai.syui.log.post)
#[arg(short, long)]
collection: String,
},
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
load_env();
let cli = Cli::parse();
match cli.command {
Commands::Login { handle, password, server } => {
commands::login::run(&handle, &password, &server).await?;
}
Commands::Refresh => {
commands::refresh::run().await?;
}
Commands::Translate { input, from, to } => {
commands::translate::run(&input, &from, &to).await?;
}
Commands::Sync { input, collection } => {
commands::sync::run(&input, &collection).await?;
}
}
Ok(())
}

View File

@@ -114,6 +114,7 @@ async function listRecordsFromApi(did: string, collection: string, pdsUrl: strin
title: r.value.title as string || 'Untitled',
content: r.value.content as string || '',
createdAt: r.value.createdAt as string || new Date().toISOString(),
translations: r.value.translations as BlogPost['translations'] || undefined,
})).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
}
@@ -394,6 +395,31 @@ function generateTabsHtml(activeTab: 'blog' | 'browser', handle: string): string
`
}
function generateLangSelectorHtml(): string {
const langIcon = `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>`
return `
<div class="lang-selector" id="lang-selector">
<button type="button" class="lang-btn" id="lang-btn" title="Language">
${langIcon}
</button>
<div class="lang-dropdown" id="lang-dropdown">
<div class="lang-option" data-lang="ja">
<span class="lang-name">日本語</span>
<span class="lang-check">✓</span>
</div>
<div class="lang-option selected" data-lang="en">
<span class="lang-name">English</span>
<span class="lang-check">✓</span>
</div>
</div>
</div>
`
}
function generatePostListHtml(posts: BlogPost[]): string {
if (posts.length === 0) {
return '<p class="no-posts">No posts yet</p>'
@@ -409,7 +435,10 @@ function generatePostListHtml(posts: BlogPost[]): string {
</li>
`
}).join('')
return `<ul class="post-list">${items}</ul>`
return `
<div class="content-header">${generateLangSelectorHtml()}</div>
<ul class="post-list">${items}</ul>
`
}
// Map network to app URL for discussion links
@@ -423,7 +452,15 @@ function getAppUrl(network: string): string {
function generatePostDetailHtml(post: BlogPost, handle: string, collection: string, network: string, siteUrl?: string): string {
const rkey = post.uri.split('/').pop() || ''
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
const content = marked.parse(post.content) as string
const originalContent = marked.parse(post.content) as string
// Check for English translation
const hasTranslation = post.translations?.en?.content
const translatedContent = hasTranslation ? marked.parse(post.translations!.en.content) as string : ''
// Default to English if translation exists
const displayContent = hasTranslation ? translatedContent : originalContent
// Use siteUrl from config, or construct from handle
const baseSiteUrl = siteUrl || `https://${handle}`
const postUrl = `${baseSiteUrl}/post/${rkey}/`
@@ -439,8 +476,14 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
const searchQuery = basePath + rkeyPrefix
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}`
// Store original and translated content as data attributes for JS switching
const dataAttrs = hasTranslation
? ` data-original-content="${escapeHtml(originalContent)}" data-translated-content="${escapeHtml(translatedContent)}"`
: ''
return `
<article class="post-detail">
<div class="content-header">${generateLangSelectorHtml()}</div>
<article class="post-detail"${dataAttrs}>
<header class="post-header">
<h1 class="post-title">${escapeHtml(post.title)}</h1>
<div class="post-meta">
@@ -448,7 +491,7 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
<a href="${jsonUrl}" class="json-btn">json</a>
</div>
</header>
<div class="post-content">${content}</div>
<div class="post-content">${displayContent}</div>
</article>
<div class="discussion-section">
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">

View File

@@ -270,9 +270,15 @@ export async function searchPostsForUrl(url: string): Promise<any[]> {
const searchPromises = endpoints.flatMap(endpoint =>
searchQueries.map(async query => {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000) // 5s timeout
const res = await fetch(
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`,
{ signal: controller.signal }
)
clearTimeout(timeoutId)
if (!res.ok) return []
const data = await res.json()
// Filter posts that actually link to the target URL
@@ -282,6 +288,7 @@ export async function searchPostsForUrl(url: string): Promise<any[]> {
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
})
} catch {
// Silently fail for CORS/network/timeout errors
return []
}
})

View File

@@ -98,8 +98,12 @@ export async function restoreSession(): Promise<AuthSession | null> {
}
}
} catch (err) {
// Silently fail for CORS/network errors - don't spam console
// Only log if it's not a network error
if (err instanceof Error && !err.message.includes('NetworkError') && !err.message.includes('CORS')) {
console.error('Session restore error:', err)
}
}
return null
}

View File

@@ -15,6 +15,7 @@ let authSession: AuthSession | null = null
let config: AppConfig
let networks: Networks = {}
let browserNetwork: string = '' // Network for AT Browser
let currentLang: string = 'en' // Default language for translations
// Browser state
let browserMode = false
@@ -131,6 +132,68 @@ function updatePdsSelector(): void {
})
}
function renderLangSelector(): string {
const langs = [
{ code: 'ja', name: '日本語' },
{ code: 'en', name: 'English' },
]
const options = langs.map(lang => {
const isSelected = lang.code === currentLang
return `<div class="lang-option ${isSelected ? 'selected' : ''}" data-lang="${lang.code}">
<span class="lang-name">${lang.name}</span>
<span class="lang-check">✓</span>
</div>`
}).join('')
const langIcon = `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>`
return `
<div class="lang-selector" id="lang-selector">
<button type="button" class="lang-btn" id="lang-btn" title="Language">
${langIcon}
</button>
<div class="lang-dropdown" id="lang-dropdown">
${options}
</div>
</div>
`
}
function updateLangSelector(): void {
const dropdown = document.getElementById('lang-dropdown')
if (!dropdown) return
const options = dropdown.querySelectorAll('.lang-option')
options.forEach(opt => {
const el = opt as HTMLElement
const lang = el.dataset.lang
const isSelected = lang === currentLang
el.classList.toggle('selected', isSelected)
})
}
function applyTranslation(): void {
const contentEl = document.querySelector('.post-content')
if (!contentEl) return
// Get original and translated content from data attributes
const postDetail = document.querySelector('.post-detail')
if (!postDetail) return
const originalContent = (postDetail as HTMLElement).dataset.originalContent
const translatedContent = (postDetail as HTMLElement).dataset.translatedContent
if (currentLang === 'en' && translatedContent) {
contentEl.innerHTML = translatedContent
} else if (originalContent) {
contentEl.innerHTML = originalContent
}
}
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string {
let tabs = `
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a>
@@ -467,6 +530,44 @@ function setupEventHandlers(): void {
}
}
// Lang button - toggle dropdown
if (target.id === 'lang-btn' || target.closest('#lang-btn')) {
e.preventDefault()
e.stopPropagation()
const dropdown = document.getElementById('lang-dropdown')
if (dropdown) {
dropdown.classList.toggle('show')
}
return
}
// Lang option selection
const langOption = target.closest('.lang-option') as HTMLElement
if (langOption) {
e.preventDefault()
const selectedLang = langOption.dataset.lang
if (selectedLang && selectedLang !== currentLang) {
currentLang = selectedLang
localStorage.setItem('preferredLang', selectedLang)
updateLangSelector()
applyTranslation()
}
// Close dropdown
const dropdown = document.getElementById('lang-dropdown')
if (dropdown) {
dropdown.classList.remove('show')
}
return
}
// Close lang dropdown when clicking outside
if (!target.closest('#lang-selector')) {
const dropdown = document.getElementById('lang-dropdown')
if (dropdown) {
dropdown.classList.remove('show')
}
}
// JSON button click (on post detail page)
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
if (jsonBtn) {
@@ -604,6 +705,34 @@ async function render(): Promise<void> {
// For blog top page, check for new posts from API and merge
if (route.type === 'blog') {
refreshPostListFromAPI()
// Add lang selector above post list
const postList = contentEl?.querySelector('.post-list')
if (postList && !document.getElementById('lang-selector')) {
const contentHeader = document.createElement('div')
contentHeader.className = 'content-header'
contentHeader.innerHTML = renderLangSelector()
postList.parentNode?.insertBefore(contentHeader, postList)
}
}
// For post detail page, sync lang selector state and apply translation
if (route.type === 'post') {
// Update lang selector to match current language
updateLangSelector()
// Apply translation based on current language preference
const postDetail = contentEl?.querySelector('.post-detail') as HTMLElement
if (postDetail) {
const hasTranslation = postDetail.dataset.translatedContent
if (hasTranslation) {
applyTranslation()
}
}
}
// For blog index page, sync lang selector state
if (route.type === 'blog') {
updateLangSelector()
}
return // Skip content re-rendering
@@ -712,6 +841,9 @@ async function init(): Promise<void> {
// Initialize browser network from localStorage or default to config.network
browserNetwork = localStorage.getItem('browserNetwork') || config.network
// Initialize language preference from localStorage (default: en)
currentLang = localStorage.getItem('preferredLang') || 'en'
// Set network config based on selected browser network
const selectedNetworkConfig = networks[browserNetwork]
if (selectedNetworkConfig) {

View File

@@ -961,6 +961,125 @@ body {
color: transparent;
}
/* Language Selector */
.lang-selector {
position: relative;
}
.lang-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 6px;
cursor: pointer;
color: #666;
font-size: 18px;
}
.lang-btn:hover {
background: #e0e0e0;
}
.lang-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 140px;
z-index: 100;
overflow: hidden;
}
.lang-dropdown.show {
display: block;
}
.lang-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
}
.lang-option:hover {
background: #f5f5f5;
}
.lang-option.selected {
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4ff 100%);
}
.lang-name {
color: #333;
font-weight: 500;
}
.lang-check {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #ccc;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
transition: all 0.2s;
}
.lang-option.selected .lang-check {
background: var(--btn-color);
border-color: var(--btn-color);
color: #fff;
}
.lang-option:not(.selected) .lang-check {
color: transparent;
}
/* Content Header (above post list) */
.content-header {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 8px;
}
@media (prefers-color-scheme: dark) {
.lang-btn {
background: #2a2a2a;
border-color: #333;
color: #888;
}
.lang-btn:hover {
background: #333;
}
.lang-dropdown {
background: #1a1a1a;
border-color: #333;
}
.lang-option:hover {
background: #2a2a2a;
}
.lang-option.selected {
background: linear-gradient(135deg, #1a2a3a 0%, #1a3040 100%);
}
.lang-name {
color: #e0e0e0;
}
}
/* AT Browser */
.server-info {
padding: 16px 0;

View File

@@ -13,6 +13,12 @@ export interface BlogPost {
title: string
content: string
createdAt: string
translations?: {
[lang: string]: {
content: string
title?: string
}
}
}
export interface NetworkConfig {