fix at.link edit
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import type { LinkCollection } from '../lib/api'
|
import type { LinkCollection, LinkItem } from '../lib/api'
|
||||||
|
|
||||||
// Service configurations
|
// Service configurations
|
||||||
const serviceConfig = {
|
const serviceConfig = {
|
||||||
@@ -19,6 +19,9 @@ const serviceConfig = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Available services for dropdown
|
||||||
|
export const availableServices = ['github', 'x', 'youtube'] as const
|
||||||
|
|
||||||
// Build URL from service and username
|
// Build URL from service and username
|
||||||
function buildUrl(service: string, username: string): string {
|
function buildUrl(service: string, username: string): string {
|
||||||
const config = serviceConfig[service as keyof typeof serviceConfig]
|
const config = serviceConfig[service as keyof typeof serviceConfig]
|
||||||
@@ -26,37 +29,101 @@ function buildUrl(service: string, username: string): string {
|
|||||||
return config.urlTemplate.replace('{username}', username)
|
return config.urlTemplate.replace('{username}', username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render link item for display
|
||||||
|
function renderLinkItem(link: LinkItem): string {
|
||||||
|
const { service, username } = link
|
||||||
|
const config = serviceConfig[service as keyof typeof serviceConfig]
|
||||||
|
if (!config) return ''
|
||||||
|
|
||||||
|
const url = buildUrl(service, username)
|
||||||
|
|
||||||
|
return `
|
||||||
|
<a href="${url}" class="link-item link-${service}" target="_blank" rel="noopener noreferrer">
|
||||||
|
<div class="link-icon">${config.icon}</div>
|
||||||
|
<div class="link-info">
|
||||||
|
<span class="link-service">${config.name}</span>
|
||||||
|
<span class="link-username">@${username}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render edit form for a link
|
||||||
|
function renderLinkEditItem(link: LinkItem, index: number): string {
|
||||||
|
const serviceOptions = availableServices.map(s =>
|
||||||
|
`<option value="${s}" ${s === link.service ? 'selected' : ''}>${serviceConfig[s].name}</option>`
|
||||||
|
).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="link-edit-item" data-index="${index}">
|
||||||
|
<select class="link-edit-service" data-index="${index}">
|
||||||
|
${serviceOptions}
|
||||||
|
</select>
|
||||||
|
<input type="text" class="link-edit-username" data-index="${index}" value="${link.username}" placeholder="username">
|
||||||
|
<button type="button" class="link-edit-remove" data-index="${index}">×</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
// Render link page
|
// Render link page
|
||||||
export function renderLinkPage(data: LinkCollection | null, _handle: string): string {
|
export function renderLinkPage(data: LinkCollection | null, handle: string, isOwner = false): string {
|
||||||
if (!data || !data.links || data.links.length === 0) {
|
const jsonUrl = `/@${handle}/at/collection/ai.syui.at.link/self`
|
||||||
|
const links = data?.links || []
|
||||||
|
const editBtn = isOwner ? `<button id="link-edit-btn" class="edit-btn">edit</button>` : ''
|
||||||
|
|
||||||
|
// Edit form (hidden by default)
|
||||||
|
const editItems = links.map((link, i) => renderLinkEditItem(link, i)).join('')
|
||||||
|
const newServiceOptions = availableServices.map(s =>
|
||||||
|
`<option value="${s}">${serviceConfig[s].name}</option>`
|
||||||
|
).join('')
|
||||||
|
|
||||||
|
const editForm = isOwner ? `
|
||||||
|
<div id="link-edit-form" class="link-edit-form" style="display: none;">
|
||||||
|
<div id="link-edit-list">${editItems}</div>
|
||||||
|
<div class="link-edit-add">
|
||||||
|
<select id="link-add-service">
|
||||||
|
${newServiceOptions}
|
||||||
|
</select>
|
||||||
|
<input type="text" id="link-add-username" placeholder="username">
|
||||||
|
<button type="button" id="link-add-btn">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="link-edit-actions">
|
||||||
|
<button type="button" id="link-edit-cancel">Cancel</button>
|
||||||
|
<button type="button" id="link-edit-save">Save</button>
|
||||||
|
</div>
|
||||||
|
<div id="link-edit-status"></div>
|
||||||
|
</div>
|
||||||
|
` : ''
|
||||||
|
|
||||||
|
if (links.length === 0) {
|
||||||
return `
|
return `
|
||||||
<div class="link-container">
|
<div class="link-container">
|
||||||
<p class="link-empty">No links found.</p>
|
<div class="link-header">
|
||||||
|
<h2>Links</h2>
|
||||||
|
<div class="link-header-actions">
|
||||||
|
<a href="${jsonUrl}" class="json-btn">json</a>
|
||||||
|
${editBtn}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${editForm}
|
||||||
|
<p class="link-empty" id="link-display">No links found.</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = data.links.map(link => {
|
const items = links.map(link => renderLinkItem(link)).join('')
|
||||||
const { service, username } = link
|
|
||||||
const config = serviceConfig[service as keyof typeof serviceConfig]
|
|
||||||
if (!config) return ''
|
|
||||||
|
|
||||||
const url = buildUrl(service, username)
|
|
||||||
|
|
||||||
return `
|
|
||||||
<a href="${url}" class="link-item link-${service}" target="_blank" rel="noopener noreferrer">
|
|
||||||
<div class="link-icon">${config.icon}</div>
|
|
||||||
<div class="link-info">
|
|
||||||
<span class="link-service">${config.name}</span>
|
|
||||||
<span class="link-username">@${username}</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
`
|
|
||||||
}).join('')
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="link-container">
|
<div class="link-container">
|
||||||
<div class="link-grid">${items}</div>
|
<div class="link-header">
|
||||||
|
<h2>Links</h2>
|
||||||
|
<div class="link-header-actions">
|
||||||
|
<a href="${jsonUrl}" class="json-btn">json</a>
|
||||||
|
${editBtn}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${editForm}
|
||||||
|
<div class="link-grid" id="link-display">${items}</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,6 +343,36 @@ export async function updateChat(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update links (ai.syui.at.link)
|
||||||
|
export async function updateLinks(
|
||||||
|
links: { service: string; username: string }[]
|
||||||
|
): Promise<{ uri: string; cid: string } | null> {
|
||||||
|
if (!agent) return null
|
||||||
|
|
||||||
|
const collection = 'ai.syui.at.link'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = {
|
||||||
|
$type: collection,
|
||||||
|
links,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await agent.com.atproto.repo.putRecord({
|
||||||
|
repo: agent.assertDid,
|
||||||
|
collection,
|
||||||
|
rkey: 'self',
|
||||||
|
record,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { uri: result.data.uri, cid: result.data.cid }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update links error:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save migrated card data to ai.syui.card.old
|
// Save migrated card data to ai.syui.card.old
|
||||||
export async function saveMigratedCardData(
|
export async function saveMigratedCardData(
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
108
src/web/main.ts
108
src/web/main.ts
@@ -3,7 +3,7 @@ import './styles/card.css'
|
|||||||
import './styles/card-migrate.css'
|
import './styles/card-migrate.css'
|
||||||
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages, getCards, getRse, getLinks } from './lib/api'
|
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages, getCards, getRse, getLinks } from './lib/api'
|
||||||
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
|
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
|
||||||
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost, updateChat } from './lib/auth'
|
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost, updateChat, updateLinks } from './lib/auth'
|
||||||
import { validateRecord } from './lib/lexicon'
|
import { validateRecord } from './lib/lexicon'
|
||||||
import { renderHeader } from './components/header'
|
import { renderHeader } from './components/header'
|
||||||
import { renderProfile } from './components/profile'
|
import { renderProfile } from './components/profile'
|
||||||
@@ -258,7 +258,7 @@ async function render(route: Route): Promise<void> {
|
|||||||
} else if (route.type === 'link') {
|
} else if (route.type === 'link') {
|
||||||
// Link page
|
// Link page
|
||||||
const links = await getLinks(did)
|
const links = await getLinks(did)
|
||||||
html += `<div id="content">${renderLinkPage(links, handle)}</div>`
|
html += `<div id="content">${renderLinkPage(links, handle, isOwner)}</div>`
|
||||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||||
|
|
||||||
} else if (route.type === 'chat') {
|
} else if (route.type === 'chat') {
|
||||||
@@ -406,6 +406,11 @@ async function render(route: Route): Promise<void> {
|
|||||||
setupChatEdit(chatCollection, handle)
|
setupChatEdit(chatCollection, handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup link edit
|
||||||
|
if (route.type === 'link' && isOwner) {
|
||||||
|
setupLinkEdit()
|
||||||
|
}
|
||||||
|
|
||||||
// Setup validate button for record detail
|
// Setup validate button for record detail
|
||||||
if (currentRecord) {
|
if (currentRecord) {
|
||||||
setupValidateButton(currentRecord)
|
setupValidateButton(currentRecord)
|
||||||
@@ -645,6 +650,105 @@ function setupChatEdit(collection: string, handle: string): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup link edit
|
||||||
|
function setupLinkEdit(): void {
|
||||||
|
const editBtn = document.getElementById('link-edit-btn')
|
||||||
|
const editForm = document.getElementById('link-edit-form')
|
||||||
|
const linkDisplay = document.getElementById('link-display')
|
||||||
|
const editList = document.getElementById('link-edit-list')
|
||||||
|
const cancelBtn = document.getElementById('link-edit-cancel')
|
||||||
|
const saveBtn = document.getElementById('link-edit-save')
|
||||||
|
const addBtn = document.getElementById('link-add-btn')
|
||||||
|
const addService = document.getElementById('link-add-service') as HTMLSelectElement
|
||||||
|
const addUsername = document.getElementById('link-add-username') as HTMLInputElement
|
||||||
|
const statusEl = document.getElementById('link-edit-status')
|
||||||
|
|
||||||
|
if (!editBtn || !editForm || !editList) return
|
||||||
|
|
||||||
|
let linkIndex = editList.querySelectorAll('.link-edit-item').length
|
||||||
|
|
||||||
|
// Show edit form
|
||||||
|
editBtn.addEventListener('click', () => {
|
||||||
|
if (linkDisplay) linkDisplay.style.display = 'none'
|
||||||
|
editForm.style.display = 'block'
|
||||||
|
editBtn.style.display = 'none'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cancel edit
|
||||||
|
cancelBtn?.addEventListener('click', () => {
|
||||||
|
editForm.style.display = 'none'
|
||||||
|
if (linkDisplay) linkDisplay.style.display = ''
|
||||||
|
editBtn.style.display = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add new link
|
||||||
|
addBtn?.addEventListener('click', () => {
|
||||||
|
const service = addService.value
|
||||||
|
const username = addUsername.value.trim()
|
||||||
|
if (!username) return
|
||||||
|
|
||||||
|
const serviceNames: Record<string, string> = { github: 'GitHub', x: 'X', youtube: 'YouTube' }
|
||||||
|
const options = ['github', 'x', 'youtube'].map(s =>
|
||||||
|
`<option value="${s}" ${s === service ? 'selected' : ''}>${serviceNames[s]}</option>`
|
||||||
|
).join('')
|
||||||
|
|
||||||
|
const newItem = document.createElement('div')
|
||||||
|
newItem.className = 'link-edit-item'
|
||||||
|
newItem.dataset.index = String(linkIndex++)
|
||||||
|
newItem.innerHTML = `
|
||||||
|
<select class="link-edit-service" data-index="${newItem.dataset.index}">
|
||||||
|
${options}
|
||||||
|
</select>
|
||||||
|
<input type="text" class="link-edit-username" data-index="${newItem.dataset.index}" value="${username}" placeholder="username">
|
||||||
|
<button type="button" class="link-edit-remove" data-index="${newItem.dataset.index}">×</button>
|
||||||
|
`
|
||||||
|
editList.appendChild(newItem)
|
||||||
|
addUsername.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove link (event delegation)
|
||||||
|
editList.addEventListener('click', (e) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target.classList.contains('link-edit-remove')) {
|
||||||
|
const item = target.closest('.link-edit-item')
|
||||||
|
item?.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save links
|
||||||
|
saveBtn?.addEventListener('click', async () => {
|
||||||
|
const items = editList.querySelectorAll('.link-edit-item')
|
||||||
|
const links: { service: string; username: string }[] = []
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const service = (item.querySelector('.link-edit-service') as HTMLSelectElement)?.value
|
||||||
|
const username = (item.querySelector('.link-edit-username') as HTMLInputElement)?.value.trim()
|
||||||
|
if (service && username) {
|
||||||
|
links.push({ service, username })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
saveBtn.textContent = 'Saving...'
|
||||||
|
;(saveBtn as HTMLButtonElement).disabled = true
|
||||||
|
|
||||||
|
await updateLinks(links)
|
||||||
|
|
||||||
|
if (statusEl) statusEl.innerHTML = '<span class="link-edit-success">Saved!</span>'
|
||||||
|
|
||||||
|
// Refresh page
|
||||||
|
setTimeout(() => {
|
||||||
|
render(parseRoute())
|
||||||
|
}, 1000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update failed:', err)
|
||||||
|
if (statusEl) statusEl.innerHTML = `<span class="link-edit-error">Error: ${err}</span>`
|
||||||
|
saveBtn.textContent = 'Save'
|
||||||
|
;(saveBtn as HTMLButtonElement).disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Initial render
|
// Initial render
|
||||||
render(parseRoute())
|
render(parseRoute())
|
||||||
|
|
||||||
|
|||||||
@@ -2648,6 +2648,18 @@ button.tab {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.link-empty {
|
.link-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #888;
|
color: #888;
|
||||||
@@ -2738,3 +2750,146 @@ button.tab {
|
|||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Link Header Actions */
|
||||||
|
.link-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link Edit Form */
|
||||||
|
.link-edit-form {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-edit-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-edit-service {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-edit-username {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-edit-remove {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-edit-remove:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-edit-add {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-edit-add select,
|
||||||
|
.link-edit-add input {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-edit-add input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#link-add-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--btn-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#link-add-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-edit-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#link-edit-cancel {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #888;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#link-edit-save {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--btn-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#link-edit-save:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-edit-success {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-edit-error {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.link-edit-form {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-edit-service,
|
||||||
|
.link-edit-username,
|
||||||
|
.link-edit-add select,
|
||||||
|
.link-edit-add input {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-color: #444;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-edit-add {
|
||||||
|
border-top-color: #444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user