add translate

This commit is contained in:
2026-01-16 11:59:21 +09:00
parent 2533720014
commit b8922f38be
17 changed files with 1062 additions and 20 deletions

View File

@@ -243,8 +243,10 @@ const SERVICE_MAP: Record<string, { domain: string; icon?: string }> = {
// Search Bluesky posts mentioning a URL
export async function searchPostsForUrl(url: string): Promise<any[]> {
// Search ALL endpoints and merge results (different networks have different indexes)
const endpoints = [getBsky(), ...FALLBACK_ENDPOINTS.filter(e => e !== getBsky())]
// Only use current network's endpoint - don't cross-search other networks
// This avoids CORS issues with public.api.bsky.app when using different PDS
const currentBsky = getBsky()
const endpoints = [currentBsky]
// Extract search-friendly patterns from URL
// e.g., "https://syui.ai/post/abc123/" -> ["syui.ai/post/abc123", "syui.ai/post"]
@@ -270,9 +272,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 +290,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,7 +98,11 @@ export async function restoreSession(): Promise<AuthSession | null> {
}
}
} 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
}

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,101 @@ 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 640 640" width="20" height="20" fill="currentColor"><path d="M192 64C209.7 64 224 78.3 224 96L224 128L352 128C369.7 128 384 142.3 384 160C384 177.7 369.7 192 352 192L342.4 192L334 215.1C317.6 260.3 292.9 301.6 261.8 337.1C276 345.9 290.8 353.7 306.2 360.6L356.6 383L418.8 243C423.9 231.4 435.4 224 448 224C460.6 224 472.1 231.4 477.2 243L605.2 531C612.4 547.2 605.1 566.1 589 573.2C572.9 580.3 553.9 573.1 546.8 557L526.8 512L369.3 512L349.3 557C342.1 573.2 323.2 580.4 307.1 573.2C291 566 283.7 547.1 290.9 531L330.7 441.5L280.3 419.1C257.3 408.9 235.3 396.7 214.5 382.7C193.2 399.9 169.9 414.9 145 427.4L110.3 444.6C94.5 452.5 75.3 446.1 67.4 430.3C59.5 414.5 65.9 395.3 81.7 387.4L116.2 370.1C132.5 361.9 148 352.4 162.6 341.8C148.8 329.1 135.8 315.4 123.7 300.9L113.6 288.7C102.3 275.1 104.1 254.9 117.7 243.6C131.3 232.3 151.5 234.1 162.8 247.7L173 259.9C184.5 273.8 197.1 286.7 210.4 298.6C237.9 268.2 259.6 232.5 273.9 193.2L274.4 192L64.1 192C46.3 192 32 177.7 32 160C32 142.3 46.3 128 64 128L160 128L160 96C160 78.3 174.3 64 192 64zM448 334.8L397.7 448L498.3 448L448 334.8z"/></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')
const titleEl = document.getElementById('post-detail-title')
// Get translation data from script tag
const scriptEl = document.getElementById('translation-data')
if (!scriptEl) return
try {
const data = JSON.parse(scriptEl.textContent || '{}')
// Apply content translation
if (contentEl) {
if (currentLang === 'en' && data.translated) {
contentEl.innerHTML = data.translated
} else if (data.original) {
contentEl.innerHTML = data.original
}
}
// Apply title translation
if (titleEl) {
if (currentLang === 'en' && data.translatedTitle) {
titleEl.textContent = data.translatedTitle
} else if (data.originalTitle) {
titleEl.textContent = data.originalTitle
}
}
} catch {
// Invalid JSON, ignore
}
}
function applyTitleTranslations(): void {
// Get title translations from script tag
const scriptEl = document.getElementById('title-translations')
if (!scriptEl) return
try {
const translations = JSON.parse(scriptEl.textContent || '{}') as Record<string, { original: string; translated: string }>
// Update each post title
document.querySelectorAll('.post-title[data-rkey]').forEach(el => {
const rkey = (el as HTMLElement).dataset.rkey
if (rkey && translations[rkey]) {
const { original, translated } = translations[rkey]
el.textContent = currentLang === 'en' ? translated : original
}
})
} catch {
// Invalid JSON, ignore
}
}
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 +563,45 @@ 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()
applyTitleTranslations()
}
// 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 +739,32 @@ 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 translationScript = document.getElementById('translation-data')
if (translationScript) {
applyTranslation()
}
}
// For blog index page, sync lang selector state and apply title translations
if (route.type === 'blog') {
updateLangSelector()
applyTitleTranslations()
}
return // Skip content re-rendering
@@ -712,6 +873,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 {