fix oauth
This commit is contained in:
205
src/web/components/browser.ts
Normal file
205
src/web/components/browser.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// AT-Browser: Server info and collection hierarchy
|
||||
|
||||
// Group collections by service domain
|
||||
function groupCollectionsByService(collections: string[]): Map<string, string[]> {
|
||||
const groups = new Map<string, string[]>()
|
||||
|
||||
for (const collection of collections) {
|
||||
// Extract service from collection (e.g., "app.bsky.feed.post" -> "bsky.app")
|
||||
const parts = collection.split('.')
|
||||
let service: string
|
||||
|
||||
if (parts.length >= 2) {
|
||||
// Reverse first two parts: app.bsky -> bsky.app, ai.syui -> syui.ai
|
||||
service = `${parts[1]}.${parts[0]}`
|
||||
} else {
|
||||
service = collection
|
||||
}
|
||||
|
||||
if (!groups.has(service)) {
|
||||
groups.set(service, [])
|
||||
}
|
||||
groups.get(service)!.push(collection)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// Get favicon URL for service
|
||||
function getFaviconUrl(service: string): string {
|
||||
return `https://www.google.com/s2/favicons?domain=${service}&sz=32`
|
||||
}
|
||||
|
||||
// Render compact collection buttons for user page (horizontal)
|
||||
export function renderCollectionButtons(collections: string[], handle: string): string {
|
||||
if (collections.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const groups = groupCollectionsByService(collections)
|
||||
|
||||
const buttons = Array.from(groups.keys()).map(service => {
|
||||
const favicon = getFaviconUrl(service)
|
||||
return `
|
||||
<a href="/@${handle}/at/service/${encodeURIComponent(service)}" class="collection-btn" title="${service}">
|
||||
<img src="${favicon}" alt="" class="collection-btn-icon" onerror="this.style.display='none'">
|
||||
<span>${service}</span>
|
||||
</a>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `<div class="collection-buttons">${buttons}</div>`
|
||||
}
|
||||
|
||||
// Render server info section (for AT-Browser)
|
||||
export function renderServerInfo(did: string, pds: string | null): string {
|
||||
return `
|
||||
<div class="server-info">
|
||||
<h3>Server</h3>
|
||||
<dl class="server-details">
|
||||
<div class="server-row">
|
||||
<dt>PDS</dt>
|
||||
<dd>${pds || 'Unknown'}</dd>
|
||||
</div>
|
||||
<div class="server-row">
|
||||
<dt>DID</dt>
|
||||
<dd>${did}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Render service list (grouped collections) for AT-Browser
|
||||
export function renderServiceList(collections: string[], handle: string): string {
|
||||
if (collections.length === 0) {
|
||||
return '<p class="no-collections">No collections found.</p>'
|
||||
}
|
||||
|
||||
const groups = groupCollectionsByService(collections)
|
||||
|
||||
const items = Array.from(groups.entries()).map(([service, cols]) => {
|
||||
const favicon = getFaviconUrl(service)
|
||||
const count = cols.length
|
||||
|
||||
return `
|
||||
<li class="service-list-item">
|
||||
<a href="/@${handle}/at/service/${encodeURIComponent(service)}" class="service-list-link">
|
||||
<img src="${favicon}" alt="" class="service-list-favicon" onerror="this.style.display='none'">
|
||||
<span class="service-list-name">${service}</span>
|
||||
<span class="service-list-count">${count}</span>
|
||||
</a>
|
||||
</li>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<div class="services-list">
|
||||
<h3>Collections</h3>
|
||||
<ul class="service-list">${items}</ul>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Render collections for a specific service
|
||||
export function renderCollectionList(
|
||||
collections: string[],
|
||||
handle: string,
|
||||
service: string
|
||||
): string {
|
||||
const favicon = getFaviconUrl(service)
|
||||
|
||||
const items = collections.map(collection => {
|
||||
return `
|
||||
<li class="collection-item">
|
||||
<a href="/@${handle}/at/collection/${collection}" class="collection-link">
|
||||
<span class="collection-nsid">${collection}</span>
|
||||
</a>
|
||||
</li>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<div class="collections">
|
||||
<h3 class="collection-header">
|
||||
<img src="${favicon}" alt="" class="collection-header-favicon" onerror="this.style.display='none'">
|
||||
${service}
|
||||
</h3>
|
||||
<ul class="collection-list">${items}</ul>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Render records list
|
||||
export function renderRecordList(
|
||||
records: { uri: string; cid: string; value: unknown }[],
|
||||
handle: string,
|
||||
collection: string
|
||||
): string {
|
||||
if (records.length === 0) {
|
||||
return '<p class="no-records">No records found.</p>'
|
||||
}
|
||||
|
||||
const items = records.map(record => {
|
||||
const rkey = record.uri.split('/').pop() || ''
|
||||
const value = record.value as Record<string, unknown>
|
||||
const preview = getRecordPreview(value)
|
||||
|
||||
return `
|
||||
<li class="record-item">
|
||||
<a href="/@${handle}/at/collection/${collection}/${rkey}" class="record-link">
|
||||
<span class="record-rkey">${rkey}</span>
|
||||
<span class="record-preview">${escapeHtml(preview)}</span>
|
||||
</a>
|
||||
</li>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<div class="records">
|
||||
<h3>${collection}</h3>
|
||||
<p class="record-count">${records.length} records</p>
|
||||
<ul class="record-list">${items}</ul>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Render single record detail
|
||||
export function renderRecordDetail(
|
||||
record: { uri: string; cid: string; value: unknown },
|
||||
collection: string
|
||||
): string {
|
||||
return `
|
||||
<article class="record-detail">
|
||||
<header class="record-header">
|
||||
<h3>${collection}</h3>
|
||||
<p class="record-uri">URI: ${record.uri}</p>
|
||||
<p class="record-cid">CID: ${record.cid}</p>
|
||||
</header>
|
||||
<div class="json-view">
|
||||
<pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre>
|
||||
</div>
|
||||
</article>
|
||||
`
|
||||
}
|
||||
|
||||
// Get preview text from record value
|
||||
function getRecordPreview(value: Record<string, unknown>): string {
|
||||
if (typeof value.text === 'string') return value.text.slice(0, 60)
|
||||
if (typeof value.title === 'string') return value.title
|
||||
if (typeof value.name === 'string') return value.name
|
||||
if (typeof value.displayName === 'string') return value.displayName
|
||||
if (typeof value.handle === 'string') return value.handle
|
||||
if (typeof value.subject === 'string') return value.subject
|
||||
if (typeof value.description === 'string') return value.description.slice(0, 60)
|
||||
return ''
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
17
src/web/components/footer.ts
Normal file
17
src/web/components/footer.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function renderFooter(handle: string): string {
|
||||
// Extract username from handle: {username}.{name}.{domain} -> username
|
||||
const username = handle.split('.')[0] || handle
|
||||
|
||||
return `
|
||||
<footer id="footer" class="footer">
|
||||
<div class="license">
|
||||
<a href="https://git.syui.ai/ai/log" target="_blank" rel="noopener">
|
||||
<img src="/ai.svg" alt="ai" class="license-icon">
|
||||
</a>
|
||||
</div>
|
||||
<div class="footer-content">
|
||||
<span class="footer-copy">© ${username}</span>
|
||||
</div>
|
||||
</footer>
|
||||
`
|
||||
}
|
||||
45
src/web/components/header.ts
Normal file
45
src/web/components/header.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { isLoggedIn, getLoggedInDid } from '../lib/auth'
|
||||
|
||||
export function renderHeader(currentHandle: string): string {
|
||||
const loggedIn = isLoggedIn()
|
||||
const did = getLoggedInDid()
|
||||
|
||||
const loginBtn = loggedIn
|
||||
? `<button type="button" class="header-btn user-btn" id="logout-btn" title="Logout (${did?.slice(0, 20)}...)">✓</button>`
|
||||
: `<button type="button" class="header-btn login-btn" id="login-btn" title="Login">↗</button>`
|
||||
|
||||
return `
|
||||
<header id="header">
|
||||
<form class="header-form" id="header-form">
|
||||
<input
|
||||
type="text"
|
||||
class="header-input"
|
||||
id="header-input"
|
||||
placeholder="handle (e.g., syui.ai)"
|
||||
value="${currentHandle}"
|
||||
>
|
||||
<button type="submit" class="header-btn at-btn" title="Browse">@</button>
|
||||
${loginBtn}
|
||||
</form>
|
||||
</header>
|
||||
`
|
||||
}
|
||||
|
||||
export function mountHeader(
|
||||
container: HTMLElement,
|
||||
currentHandle: string,
|
||||
onBrowse: (handle: string) => void
|
||||
): void {
|
||||
container.innerHTML = renderHeader(currentHandle)
|
||||
|
||||
const form = document.getElementById('header-form') as HTMLFormElement
|
||||
const input = document.getElementById('header-input') as HTMLInputElement
|
||||
|
||||
form?.addEventListener('submit', (e) => {
|
||||
e.preventDefault()
|
||||
const handle = input.value.trim()
|
||||
if (handle) {
|
||||
onBrowse(handle)
|
||||
}
|
||||
})
|
||||
}
|
||||
80
src/web/components/mode-tabs.ts
Normal file
80
src/web/components/mode-tabs.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { getNetworks } from '../lib/api'
|
||||
|
||||
let currentNetwork = 'bsky.social'
|
||||
|
||||
export function getCurrentNetwork(): string {
|
||||
return currentNetwork
|
||||
}
|
||||
|
||||
export function setCurrentNetwork(network: string): void {
|
||||
currentNetwork = network
|
||||
}
|
||||
|
||||
export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' = 'blog'): string {
|
||||
let tabs = `
|
||||
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">Blog</a>
|
||||
<a href="/@${handle}/at" class="tab ${activeTab === 'browser' ? 'active' : ''}">Browser</a>
|
||||
`
|
||||
|
||||
tabs += `
|
||||
<div class="pds-selector" id="pds-selector">
|
||||
<button type="button" class="tab" id="pds-tab">PDS</button>
|
||||
<div class="pds-dropdown" id="pds-dropdown"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
return `<div class="mode-tabs">${tabs}</div>`
|
||||
}
|
||||
|
||||
export async function setupModeTabs(onNetworkChange: (network: string) => void): Promise<void> {
|
||||
const pdsTab = document.getElementById('pds-tab')
|
||||
const dropdown = document.getElementById('pds-dropdown')
|
||||
|
||||
if (!pdsTab || !dropdown) return
|
||||
|
||||
// Load networks
|
||||
const networks = await getNetworks()
|
||||
|
||||
// Build options
|
||||
const optionsHtml = Object.keys(networks).map(key => {
|
||||
const isSelected = key === currentNetwork
|
||||
return `
|
||||
<div class="pds-option ${isSelected ? 'selected' : ''}" data-network="${key}">
|
||||
<span class="pds-name">${key}</span>
|
||||
<span class="pds-check">✓</span>
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
dropdown.innerHTML = optionsHtml
|
||||
|
||||
// Toggle dropdown
|
||||
pdsTab.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
dropdown.classList.toggle('show')
|
||||
})
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', () => {
|
||||
dropdown.classList.remove('show')
|
||||
})
|
||||
|
||||
// Handle option selection
|
||||
dropdown.querySelectorAll('.pds-option').forEach(opt => {
|
||||
opt.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
const network = (opt as HTMLElement).dataset.network || ''
|
||||
|
||||
currentNetwork = network
|
||||
|
||||
// Update UI
|
||||
dropdown.querySelectorAll('.pds-option').forEach(o => {
|
||||
o.classList.remove('selected')
|
||||
})
|
||||
opt.classList.add('selected')
|
||||
dropdown.classList.remove('show')
|
||||
|
||||
onNetworkChange(network)
|
||||
})
|
||||
})
|
||||
}
|
||||
91
src/web/components/pds-selector.ts
Normal file
91
src/web/components/pds-selector.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { getNetworks } from '../lib/api'
|
||||
|
||||
let currentPds: string | null = null
|
||||
|
||||
export function getCurrentPds(): string | null {
|
||||
return currentPds
|
||||
}
|
||||
|
||||
export function setCurrentPds(pds: string): void {
|
||||
currentPds = pds
|
||||
}
|
||||
|
||||
export function renderPdsSelector(): string {
|
||||
return `
|
||||
<div class="pds-selector">
|
||||
<button class="pds-trigger" id="pds-trigger">
|
||||
<span>pds</span>
|
||||
</button>
|
||||
<div class="pds-dropdown" id="pds-dropdown" style="display: none;">
|
||||
<div class="pds-dropdown-content" id="pds-dropdown-content">
|
||||
<!-- Options loaded dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export async function setupPdsSelector(onSelect: (pds: string, domain: string) => void): Promise<void> {
|
||||
const trigger = document.getElementById('pds-trigger')
|
||||
const dropdown = document.getElementById('pds-dropdown')
|
||||
const content = document.getElementById('pds-dropdown-content')
|
||||
|
||||
if (!trigger || !dropdown || !content) return
|
||||
|
||||
// Load networks and build options
|
||||
const networks = await getNetworks()
|
||||
const firstDomain = Object.keys(networks)[0]
|
||||
|
||||
// Set default
|
||||
if (!currentPds && firstDomain) {
|
||||
currentPds = networks[firstDomain].bsky
|
||||
}
|
||||
|
||||
const optionsHtml = Object.entries(networks).map(([domain, network]) => {
|
||||
const isSelected = currentPds === network.bsky
|
||||
return `
|
||||
<button class="pds-option ${isSelected ? 'selected' : ''}" data-pds="${network.bsky}" data-domain="${domain}">
|
||||
<span class="pds-option-name">${domain}</span>
|
||||
<span class="pds-option-check">${isSelected ? '●' : '○'}</span>
|
||||
</button>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
content.innerHTML = optionsHtml
|
||||
|
||||
// Toggle dropdown
|
||||
trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
const isVisible = dropdown.style.display !== 'none'
|
||||
dropdown.style.display = isVisible ? 'none' : 'block'
|
||||
})
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', () => {
|
||||
dropdown.style.display = 'none'
|
||||
})
|
||||
|
||||
// Handle option selection
|
||||
content.querySelectorAll('.pds-option').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
const pds = btn.getAttribute('data-pds') || ''
|
||||
const domain = btn.getAttribute('data-domain') || ''
|
||||
|
||||
currentPds = pds
|
||||
|
||||
// Update UI
|
||||
content.querySelectorAll('.pds-option').forEach(b => {
|
||||
b.classList.remove('selected')
|
||||
const check = b.querySelector('.pds-option-check')
|
||||
if (check) check.textContent = '○'
|
||||
})
|
||||
btn.classList.add('selected')
|
||||
const check = btn.querySelector('.pds-option-check')
|
||||
if (check) check.textContent = '●'
|
||||
|
||||
dropdown.style.display = 'none'
|
||||
onSelect(pds, domain)
|
||||
})
|
||||
})
|
||||
}
|
||||
69
src/web/components/postform.ts
Normal file
69
src/web/components/postform.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { createPost } from '../lib/auth'
|
||||
|
||||
export function renderPostForm(collection: string): string {
|
||||
return `
|
||||
<div class="post-form-container">
|
||||
<form class="post-form" id="post-form">
|
||||
<input
|
||||
type="text"
|
||||
class="post-form-title"
|
||||
id="post-title"
|
||||
placeholder="Title"
|
||||
required
|
||||
>
|
||||
<textarea
|
||||
class="post-form-body"
|
||||
id="post-body"
|
||||
placeholder="Content (markdown)"
|
||||
rows="6"
|
||||
required
|
||||
></textarea>
|
||||
<div class="post-form-footer">
|
||||
<span class="post-form-collection">${collection}</span>
|
||||
<button type="submit" class="post-form-btn" id="post-submit">Post</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="post-status" class="post-status"></div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export function setupPostForm(collection: string, onSuccess: () => void): void {
|
||||
const form = document.getElementById('post-form') as HTMLFormElement
|
||||
const titleInput = document.getElementById('post-title') as HTMLInputElement
|
||||
const bodyInput = document.getElementById('post-body') as HTMLTextAreaElement
|
||||
const submitBtn = document.getElementById('post-submit') as HTMLButtonElement
|
||||
const statusEl = document.getElementById('post-status') as HTMLDivElement
|
||||
|
||||
if (!form) return
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
const title = titleInput.value.trim()
|
||||
const body = bodyInput.value.trim()
|
||||
|
||||
if (!title || !body) return
|
||||
|
||||
submitBtn.disabled = true
|
||||
submitBtn.textContent = 'Posting...'
|
||||
statusEl.innerHTML = ''
|
||||
|
||||
try {
|
||||
const result = await createPost(collection, title, body)
|
||||
if (result) {
|
||||
statusEl.innerHTML = `<span class="post-success">Posted!</span>`
|
||||
titleInput.value = ''
|
||||
bodyInput.value = ''
|
||||
setTimeout(() => {
|
||||
onSuccess()
|
||||
}, 1000)
|
||||
}
|
||||
} catch (err) {
|
||||
statusEl.innerHTML = `<span class="post-error">Error: ${err}</span>`
|
||||
} finally {
|
||||
submitBtn.disabled = false
|
||||
submitBtn.textContent = 'Post'
|
||||
}
|
||||
})
|
||||
}
|
||||
63
src/web/components/posts.ts
Normal file
63
src/web/components/posts.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Post } from '../types'
|
||||
import { renderMarkdown } from '../lib/markdown'
|
||||
|
||||
// Render post list
|
||||
export function renderPostList(posts: Post[], handle: string): string {
|
||||
if (posts.length === 0) {
|
||||
return '<p class="no-posts">No posts yet.</p>'
|
||||
}
|
||||
|
||||
const items = posts.map(post => {
|
||||
const rkey = post.uri.split('/').pop() || ''
|
||||
const date = new Date(post.value.createdAt).toLocaleDateString('ja-JP')
|
||||
|
||||
return `
|
||||
<article class="post-item">
|
||||
<a href="/@${handle}/${rkey}" class="post-link">
|
||||
<h2 class="post-title">${escapeHtml(post.value.title)}</h2>
|
||||
<time class="post-date">${date}</time>
|
||||
</a>
|
||||
</article>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `<div class="post-list">${items}</div>`
|
||||
}
|
||||
|
||||
// Render single post detail
|
||||
export function renderPostDetail(post: Post, handle: string, collection: string): string {
|
||||
const rkey = post.uri.split('/').pop() || ''
|
||||
const date = new Date(post.value.createdAt).toLocaleDateString('ja-JP')
|
||||
const content = renderMarkdown(post.value.content)
|
||||
const jsonUrl = `/@${handle}/at/collection/${collection}/${rkey}`
|
||||
|
||||
return `
|
||||
<article class="post-detail">
|
||||
<header class="post-header">
|
||||
<h1 class="post-title">${escapeHtml(post.value.title)}</h1>
|
||||
<div class="post-meta">
|
||||
<time class="post-date">${date}</time>
|
||||
<a href="${jsonUrl}" class="json-btn">json</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="post-content">${content}</div>
|
||||
</article>
|
||||
`
|
||||
}
|
||||
|
||||
export function mountPostList(container: HTMLElement, html: string): void {
|
||||
container.innerHTML = html
|
||||
}
|
||||
|
||||
export function mountPostDetail(container: HTMLElement, html: string): void {
|
||||
container.innerHTML = html
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
55
src/web/components/profile.ts
Normal file
55
src/web/components/profile.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Profile } from '../types'
|
||||
import { getAvatarUrl } from '../lib/api'
|
||||
|
||||
export async function renderProfile(
|
||||
did: string,
|
||||
profile: Profile,
|
||||
handle: string,
|
||||
webUrl?: string
|
||||
): Promise<string> {
|
||||
const avatarUrl = await getAvatarUrl(did, profile)
|
||||
const displayName = profile.value.displayName || handle || 'Unknown'
|
||||
const description = profile.value.description || ''
|
||||
|
||||
// Build profile link (e.g., https://bsky.app/profile/did:plc:xxx)
|
||||
const profileLink = webUrl ? `${webUrl}/profile/${did}` : null
|
||||
|
||||
const handleHtml = profileLink
|
||||
? `<a href="${profileLink}" class="profile-handle-link" target="_blank" rel="noopener">@${escapeHtml(handle)}</a>`
|
||||
: `<span>@${escapeHtml(handle)}</span>`
|
||||
|
||||
const avatarHtml = avatarUrl
|
||||
? `<a href="/"><img class="profile-avatar" src="${avatarUrl}" alt="${displayName}"></a>`
|
||||
: `<a href="/"><div class="profile-avatar-placeholder"></div></a>`
|
||||
|
||||
return `
|
||||
<div class="profile">
|
||||
<div class="profile-row">
|
||||
${avatarHtml}
|
||||
<div class="profile-meta">
|
||||
<span class="profile-name">${escapeHtml(displayName)}</span>
|
||||
<span class="profile-handle">${handleHtml}</span>
|
||||
</div>
|
||||
</div>
|
||||
${description ? `
|
||||
<div class="profile-row">
|
||||
<div class="profile-avatar-spacer"></div>
|
||||
<p class="profile-description">${escapeHtml(description)}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export function mountProfile(container: HTMLElement, html: string): void {
|
||||
container.innerHTML = html
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
Reference in New Issue
Block a user