add translate
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal 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
6
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
dist
|
dist
|
||||||
.claude
|
repos
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
repos
|
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
.claude
|
||||||
|
.env
|
||||||
|
/rust/target
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
|
|
||||||
"cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e",
|
"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```",
|
"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
13
rust/Cargo.toml
Normal 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
44
rust/src/auth.rs
Normal 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(())
|
||||||
|
}
|
||||||
66
rust/src/commands/login.rs
Normal file
66
rust/src/commands/login.rs
Normal 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
4
rust/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod login;
|
||||||
|
pub mod refresh;
|
||||||
|
pub mod sync;
|
||||||
|
pub mod translate;
|
||||||
75
rust/src/commands/refresh.rs
Normal file
75
rust/src/commands/refresh.rs
Normal 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
117
rust/src/commands/sync.rs
Normal 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(())
|
||||||
|
}
|
||||||
184
rust/src/commands/translate.rs
Normal file
184
rust/src/commands/translate.rs
Normal 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
112
rust/src/main.rs
Normal 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(())
|
||||||
|
}
|
||||||
@@ -114,6 +114,7 @@ async function listRecordsFromApi(did: string, collection: string, pdsUrl: strin
|
|||||||
title: r.value.title as string || 'Untitled',
|
title: r.value.title as string || 'Untitled',
|
||||||
content: r.value.content as string || '',
|
content: r.value.content as string || '',
|
||||||
createdAt: r.value.createdAt as string || new Date().toISOString(),
|
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())
|
})).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 {
|
function generatePostListHtml(posts: BlogPost[]): string {
|
||||||
if (posts.length === 0) {
|
if (posts.length === 0) {
|
||||||
return '<p class="no-posts">No posts yet</p>'
|
return '<p class="no-posts">No posts yet</p>'
|
||||||
@@ -409,7 +435,10 @@ function generatePostListHtml(posts: BlogPost[]): string {
|
|||||||
</li>
|
</li>
|
||||||
`
|
`
|
||||||
}).join('')
|
}).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
|
// 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 {
|
function generatePostDetailHtml(post: BlogPost, handle: string, collection: string, network: string, siteUrl?: string): string {
|
||||||
const rkey = post.uri.split('/').pop() || ''
|
const rkey = post.uri.split('/').pop() || ''
|
||||||
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
|
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
|
// Use siteUrl from config, or construct from handle
|
||||||
const baseSiteUrl = siteUrl || `https://${handle}`
|
const baseSiteUrl = siteUrl || `https://${handle}`
|
||||||
const postUrl = `${baseSiteUrl}/post/${rkey}/`
|
const postUrl = `${baseSiteUrl}/post/${rkey}/`
|
||||||
@@ -439,8 +476,14 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
|
|||||||
const searchQuery = basePath + rkeyPrefix
|
const searchQuery = basePath + rkeyPrefix
|
||||||
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}`
|
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 `
|
return `
|
||||||
<article class="post-detail">
|
<div class="content-header">${generateLangSelectorHtml()}</div>
|
||||||
|
<article class="post-detail"${dataAttrs}>
|
||||||
<header class="post-header">
|
<header class="post-header">
|
||||||
<h1 class="post-title">${escapeHtml(post.title)}</h1>
|
<h1 class="post-title">${escapeHtml(post.title)}</h1>
|
||||||
<div class="post-meta">
|
<div class="post-meta">
|
||||||
@@ -448,7 +491,7 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
|
|||||||
<a href="${jsonUrl}" class="json-btn">json</a>
|
<a href="${jsonUrl}" class="json-btn">json</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="post-content">${content}</div>
|
<div class="post-content">${displayContent}</div>
|
||||||
</article>
|
</article>
|
||||||
<div class="discussion-section">
|
<div class="discussion-section">
|
||||||
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
|
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
|
||||||
|
|||||||
@@ -270,9 +270,15 @@ export async function searchPostsForUrl(url: string): Promise<any[]> {
|
|||||||
const searchPromises = endpoints.flatMap(endpoint =>
|
const searchPromises = endpoints.flatMap(endpoint =>
|
||||||
searchQueries.map(async query => {
|
searchQueries.map(async query => {
|
||||||
try {
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 5000) // 5s timeout
|
||||||
|
|
||||||
const res = await fetch(
|
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 []
|
if (!res.ok) return []
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
// Filter posts that actually link to the target URL
|
// 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(/\/$/, ''))
|
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
|
// Silently fail for CORS/network/timeout errors
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -98,7 +98,11 @@ export async function restoreSession(): Promise<AuthSession | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Session restore error:', 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
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
132
src/main.ts
132
src/main.ts
@@ -15,6 +15,7 @@ let authSession: AuthSession | null = null
|
|||||||
let config: AppConfig
|
let config: AppConfig
|
||||||
let networks: Networks = {}
|
let networks: Networks = {}
|
||||||
let browserNetwork: string = '' // Network for AT Browser
|
let browserNetwork: string = '' // Network for AT Browser
|
||||||
|
let currentLang: string = 'en' // Default language for translations
|
||||||
|
|
||||||
// Browser state
|
// Browser state
|
||||||
let browserMode = false
|
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 {
|
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string {
|
||||||
let tabs = `
|
let tabs = `
|
||||||
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a>
|
<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)
|
// JSON button click (on post detail page)
|
||||||
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
|
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
|
||||||
if (jsonBtn) {
|
if (jsonBtn) {
|
||||||
@@ -604,6 +705,34 @@ async function render(): Promise<void> {
|
|||||||
// For blog top page, check for new posts from API and merge
|
// For blog top page, check for new posts from API and merge
|
||||||
if (route.type === 'blog') {
|
if (route.type === 'blog') {
|
||||||
refreshPostListFromAPI()
|
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
|
return // Skip content re-rendering
|
||||||
@@ -712,6 +841,9 @@ async function init(): Promise<void> {
|
|||||||
// Initialize browser network from localStorage or default to config.network
|
// Initialize browser network from localStorage or default to config.network
|
||||||
browserNetwork = localStorage.getItem('browserNetwork') || 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
|
// Set network config based on selected browser network
|
||||||
const selectedNetworkConfig = networks[browserNetwork]
|
const selectedNetworkConfig = networks[browserNetwork]
|
||||||
if (selectedNetworkConfig) {
|
if (selectedNetworkConfig) {
|
||||||
|
|||||||
@@ -961,6 +961,125 @@ body {
|
|||||||
color: transparent;
|
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 */
|
/* AT Browser */
|
||||||
.server-info {
|
.server-info {
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export interface BlogPost {
|
|||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
translations?: {
|
||||||
|
[lang: string]: {
|
||||||
|
content: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkConfig {
|
export interface NetworkConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user