add translate
This commit is contained in:
@@ -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 []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
164
src/main.ts
164
src/main.ts
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,6 +13,12 @@ export interface BlogPost {
|
||||
title: string
|
||||
content: string
|
||||
createdAt: string
|
||||
translations?: {
|
||||
[lang: string]: {
|
||||
content: string
|
||||
title?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface NetworkConfig {
|
||||
|
||||
Reference in New Issue
Block a user