fix at.link edit

This commit is contained in:
2026-01-22 22:10:10 +09:00
parent ca6bb5319c
commit c2044c5bd8
4 changed files with 380 additions and 24 deletions

View File

@@ -1,4 +1,4 @@
import type { LinkCollection } from '../lib/api'
import type { LinkCollection, LinkItem } from '../lib/api'
// Service configurations
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
function buildUrl(service: string, username: string): string {
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)
}
// 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
export function renderLinkPage(data: LinkCollection | null, _handle: string): string {
if (!data || !data.links || data.links.length === 0) {
export function renderLinkPage(data: LinkCollection | null, handle: string, isOwner = false): string {
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 `
<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>
`
}
const items = data.links.map(link => {
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('')
const items = links.map(link => renderLinkItem(link)).join('')
return `
<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>
`
}

View File

@@ -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
export async function saveMigratedCardData(
user: {

View File

@@ -3,7 +3,7 @@ import './styles/card.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 { 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 { renderHeader } from './components/header'
import { renderProfile } from './components/profile'
@@ -258,7 +258,7 @@ async function render(route: Route): Promise<void> {
} else if (route.type === 'link') {
// Link page
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>`
} else if (route.type === 'chat') {
@@ -406,6 +406,11 @@ async function render(route: Route): Promise<void> {
setupChatEdit(chatCollection, handle)
}
// Setup link edit
if (route.type === 'link' && isOwner) {
setupLinkEdit()
}
// Setup validate button for record detail
if (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
render(parseRoute())

View File

@@ -2648,6 +2648,18 @@ button.tab {
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 {
text-align: center;
color: #888;
@@ -2738,3 +2750,146 @@ button.tab {
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;
}
}