From c2044c5bd81f9af4c8814018052208aa21b3d088 Mon Sep 17 00:00:00 2001 From: syui Date: Thu, 22 Jan 2026 22:10:10 +0900 Subject: [PATCH] fix at.link edit --- src/web/components/link.ts | 111 ++++++++++++++++++++------ src/web/lib/auth.ts | 30 +++++++ src/web/main.ts | 108 +++++++++++++++++++++++++- src/web/styles/main.css | 155 +++++++++++++++++++++++++++++++++++++ 4 files changed, 380 insertions(+), 24 deletions(-) diff --git a/src/web/components/link.ts b/src/web/components/link.ts index cbe8789..ad2f61a 100644 --- a/src/web/components/link.ts +++ b/src/web/components/link.ts @@ -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 ` + + + + + ` +} + +// Render edit form for a link +function renderLinkEditItem(link: LinkItem, index: number): string { + const serviceOptions = availableServices.map(s => + `` + ).join('') + + return ` + + ` +} + // 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 ? `` : '' + + // Edit form (hidden by default) + const editItems = links.map((link, i) => renderLinkEditItem(link, i)).join('') + const newServiceOptions = availableServices.map(s => + `` + ).join('') + + const editForm = isOwner ? ` + + ` : '' + + if (links.length === 0) { return ` ` } - 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 ` - - - - - ` - }).join('') + const items = links.map(link => renderLinkItem(link)).join('') return ` ` } diff --git a/src/web/lib/auth.ts b/src/web/lib/auth.ts index f1be2d8..e5cc762 100644 --- a/src/web/lib/auth.ts +++ b/src/web/lib/auth.ts @@ -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: { diff --git a/src/web/main.ts b/src/web/main.ts index c11b69b..2d299ef 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -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 { } else if (route.type === 'link') { // Link page const links = await getLinks(did) - html += `
${renderLinkPage(links, handle)}
` + html += `
${renderLinkPage(links, handle, isOwner)}
` html += `` } else if (route.type === 'chat') { @@ -406,6 +406,11 @@ async function render(route: Route): Promise { 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 = { github: 'GitHub', x: 'X', youtube: 'YouTube' } + const options = ['github', 'x', 'youtube'].map(s => + `` + ).join('') + + const newItem = document.createElement('div') + newItem.className = 'link-edit-item' + newItem.dataset.index = String(linkIndex++) + newItem.innerHTML = ` + + + + ` + 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 = 'Saved!' + + // Refresh page + setTimeout(() => { + render(parseRoute()) + }, 1000) + } catch (err) { + console.error('Update failed:', err) + if (statusEl) statusEl.innerHTML = `Error: ${err}` + saveBtn.textContent = 'Save' + ;(saveBtn as HTMLButtonElement).disabled = false + } + }) +} + // Initial render render(parseRoute()) diff --git a/src/web/styles/main.css b/src/web/styles/main.css index f291668..c7efa9e 100644 --- a/src/web/styles/main.css +++ b/src/web/styles/main.css @@ -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; + } +}