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 `
+
+ ${config.icon}
+
+ ${config.name}
+ @${username}
+
+
+ `
+}
+
+// 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 `
-
No links found.
+
+ ${editForm}
+
No links found.
`
}
- 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 `
-
- ${config.icon}
-
- ${config.name}
- @${username}
-
-
- `
- }).join('')
+ const items = links.map(link => renderLinkItem(link)).join('')
return `
-
${items}
+
+ ${editForm}
+
${items}
`
}
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;
+ }
+}