diff --git a/lexicons/ai.syui.at.link.json b/lexicons/ai.syui.at.link.json
new file mode 100644
index 0000000..5d8807d
--- /dev/null
+++ b/lexicons/ai.syui.at.link.json
@@ -0,0 +1,48 @@
+{
+ "lexicon": 1,
+ "id": "ai.syui.at.link",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Record containing links to external service profiles.",
+ "key": "literal:self",
+ "record": {
+ "type": "object",
+ "required": ["links", "createdAt"],
+ "properties": {
+ "links": {
+ "type": "array",
+ "items": { "type": "ref", "ref": "#linkItem" },
+ "description": "Array of external service links."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Client-declared timestamp when this record was created."
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Client-declared timestamp when this record was last updated."
+ }
+ }
+ }
+ },
+ "linkItem": {
+ "type": "object",
+ "required": ["service", "username"],
+ "properties": {
+ "service": {
+ "type": "string",
+ "knownValues": ["github", "youtube", "x"],
+ "description": "Service identifier."
+ },
+ "username": {
+ "type": "string",
+ "maxLength": 300,
+ "description": "Username or ID on the service."
+ }
+ }
+ }
+ }
+}
diff --git a/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.at.link/self.json b/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.at.link/self.json
new file mode 100644
index 0000000..cd373c2
--- /dev/null
+++ b/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.at.link/self.json
@@ -0,0 +1,11 @@
+{
+ "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.at.link/self",
+ "cid": "",
+ "value": {
+ "$type": "ai.syui.at.link",
+ "links": [
+ { "service": "github", "username": "syui" }
+ ],
+ "createdAt": "2026-01-22T12:00:00.000Z"
+ }
+}
diff --git a/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/index.json b/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/index.json
index 87ca58a..e289962 100644
--- a/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/index.json
+++ b/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/index.json
@@ -3,13 +3,11 @@
"z42mx3edarpnb",
"y2qobgxho6jte",
"wwgwt2ycq3tx5",
- "wigv2qnon7pmg",
"vr72pvlhuxnf5",
"tg7crfsupxz7h",
"sv26xtnwgjsds",
"sqzphb67ymv4i",
"snju64fbt4a3n",
- "smrgeplyw5wmr",
"s55utv52t3rf6",
"qbuquaswgxo36",
"q57mb4gebtj2o",
@@ -36,5 +34,7 @@
"3ucggdsyhth6h",
"3kwayvs5zrtng",
"3gaf4ckp5be5j",
- "27xox352hir2g"
+ "27xox352hir2g",
+ "wigv2qnon7pmg",
+ "smrgeplyw5wmr"
]
\ No newline at end of file
diff --git a/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/describe.json b/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/describe.json
index afd8c16..f731694 100644
--- a/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/describe.json
+++ b/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/describe.json
@@ -1,5 +1,6 @@
{
"collections": [
+ "ai.syui.at.link",
"ai.syui.card.old",
"ai.syui.card.user",
"ai.syui.log.chat",
diff --git a/src/web/components/link.ts b/src/web/components/link.ts
new file mode 100644
index 0000000..cbe8789
--- /dev/null
+++ b/src/web/components/link.ts
@@ -0,0 +1,62 @@
+import type { LinkCollection } from '../lib/api'
+
+// Service configurations
+const serviceConfig = {
+ github: {
+ name: 'GitHub',
+ urlTemplate: 'https://github.com/{username}',
+ icon: ``,
+ },
+ x: {
+ name: 'X',
+ urlTemplate: 'https://x.com/{username}',
+ icon: ``,
+ },
+ youtube: {
+ name: 'YouTube',
+ urlTemplate: 'https://youtube.com/@{username}',
+ icon: ``,
+ },
+}
+
+// Build URL from service and username
+function buildUrl(service: string, username: string): string {
+ const config = serviceConfig[service as keyof typeof serviceConfig]
+ if (!config) return '#'
+ return config.urlTemplate.replace('{username}', username)
+}
+
+// Render link page
+export function renderLinkPage(data: LinkCollection | null, _handle: string): string {
+ if (!data || !data.links || data.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 `
+
+ ${config.icon}
+
+ ${config.name}
+ @${username}
+
+
+ `
+ }).join('')
+
+ return `
+
+ `
+}
diff --git a/src/web/components/profile.ts b/src/web/components/profile.ts
index 42984d4..5703433 100644
--- a/src/web/components/profile.ts
+++ b/src/web/components/profile.ts
@@ -42,6 +42,16 @@ export function getServiceLinks(handle: string, collections: string[]): ServiceL
})
}
+ // Link
+ if (collections.includes('ai.syui.at.link')) {
+ services.push({
+ name: 'Link',
+ icon: '/service/ai.syui.at.png',
+ url: `/@${handle}/at/link`,
+ collection: 'ai.syui.at.link'
+ })
+ }
+
return services
}
diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts
index d851031..a64fe7d 100644
--- a/src/web/lib/api.ts
+++ b/src/web/lib/api.ts
@@ -652,3 +652,49 @@ export async function getRse(did: string): Promise {
}
return null
}
+
+// Link item type
+export interface LinkItem {
+ service: 'github' | 'youtube' | 'x'
+ username: string
+}
+
+// Link collection type
+export interface LinkCollection {
+ links: LinkItem[]
+ createdAt: string
+ updatedAt?: string
+}
+
+// Get user's links (ai.syui.at.link)
+export async function getLinks(did: string): Promise {
+ const collection = 'ai.syui.at.link'
+
+ // Try local first
+ try {
+ const res = await fetch(`/content/${did}/${collection}/self.json`)
+ if (res.ok && isJsonResponse(res)) {
+ const record = await res.json()
+ return record.value as LinkCollection
+ }
+ } catch {
+ // Try remote
+ }
+
+ // Remote fallback
+ const pds = await getPds(did)
+ if (!pds) return null
+
+ try {
+ const host = pds.replace('https://', '')
+ const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=self`
+ const res = await fetch(url)
+ if (res.ok) {
+ const record = await res.json()
+ return record.value as LinkCollection
+ }
+ } catch {
+ // Failed
+ }
+ return null
+}
diff --git a/src/web/lib/router.ts b/src/web/lib/router.ts
index 2525a74..1031034 100644
--- a/src/web/lib/router.ts
+++ b/src/web/lib/router.ts
@@ -1,5 +1,5 @@
export interface Route {
- type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread' | 'chat-edit' | 'card' | 'card-old' | 'rse'
+ type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread' | 'chat-edit' | 'card' | 'card-old' | 'rse' | 'link'
handle?: string
rkey?: string
service?: string
@@ -69,6 +69,12 @@ export function parseRoute(): Route {
return { type: 'rse', handle: rseMatch[1] }
}
+ // Link page: /@handle/at/link
+ const linkMatch = path.match(/^\/@([^/]+)\/at\/link\/?$/)
+ if (linkMatch) {
+ return { type: 'link', handle: linkMatch[1] }
+ }
+
// Chat edit: /@handle/at/chat/{rkey}/edit
const chatEditMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/edit$/)
if (chatEditMatch) {
@@ -125,6 +131,8 @@ export function navigate(route: Route): void {
path = `/@${route.handle}/at/chat/${route.rkey}`
} else if (route.type === 'chat-edit' && route.handle && route.rkey) {
path = `/@${route.handle}/at/chat/${route.rkey}/edit`
+ } else if (route.type === 'link' && route.handle) {
+ path = `/@${route.handle}/at/link`
}
window.history.pushState({}, '', path)
diff --git a/src/web/main.ts b/src/web/main.ts
index 2471404..c11b69b 100644
--- a/src/web/main.ts
+++ b/src/web/main.ts
@@ -1,7 +1,7 @@
import './styles/main.css'
import './styles/card.css'
import './styles/card-migrate.css'
-import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages, getCards, getRse } 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 { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost, updateChat } from './lib/auth'
import { validateRecord } from './lib/lexicon'
@@ -15,6 +15,7 @@ import { renderFooter } from './components/footer'
import { renderChatListPage, renderChatThreadPage, renderChatEditForm } from './components/chat'
import { renderCardPage } from './components/card'
import { renderRsePage } from './components/rse'
+import { renderLinkPage } from './components/link'
import { checkMigrationStatus, renderMigrationPage, setupMigrationButton } from './components/card-migrate'
import { showLoading, hideLoading } from './components/loading'
@@ -254,6 +255,12 @@ async function render(route: Route): Promise {
html += `${renderRsePage(rseData, handle)}
`
html += ``
+ } else if (route.type === 'link') {
+ // Link page
+ const links = await getLinks(did)
+ html += `${renderLinkPage(links, handle)}
`
+ html += ``
+
} else if (route.type === 'chat') {
// Chat list page - show threads started by this user
if (!config.bot) {
diff --git a/src/web/styles/main.css b/src/web/styles/main.css
index cee9c24..f291668 100644
--- a/src/web/styles/main.css
+++ b/src/web/styles/main.css
@@ -2640,3 +2640,101 @@ button.tab {
color: #999;
}
}
+
+/* ==================== Link Page ==================== */
+.link-container {
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+.link-empty {
+ text-align: center;
+ color: #888;
+ padding: 40px 20px;
+}
+
+.link-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.link-item {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 20px 24px;
+ border-radius: 12px;
+ text-decoration: none;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.link-item:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
+}
+
+.link-icon {
+ width: 48px;
+ height: 48px;
+ flex-shrink: 0;
+}
+
+.link-icon svg {
+ width: 100%;
+ height: 100%;
+}
+
+.link-info {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.link-service {
+ font-size: 1.1rem;
+ font-weight: 600;
+}
+
+.link-username {
+ font-size: 0.95rem;
+ opacity: 0.8;
+}
+
+/* GitHub */
+.link-github {
+ background: linear-gradient(135deg, #24292e 0%, #1a1e22 100%);
+ color: #fff;
+}
+
+.link-github:hover {
+ background: linear-gradient(135deg, #2d3339 0%, #24292e 100%);
+}
+
+/* X (Twitter) */
+.link-x {
+ background: linear-gradient(135deg, #000000 0%, #14171a 100%);
+ color: #fff;
+}
+
+.link-x:hover {
+ background: linear-gradient(135deg, #1a1a1a 0%, #000000 100%);
+}
+
+/* YouTube */
+.link-youtube {
+ background: linear-gradient(135deg, #ff0000 0%, #cc0000 100%);
+ color: #fff;
+}
+
+.link-youtube:hover {
+ background: linear-gradient(135deg, #ff1a1a 0%, #ff0000 100%);
+}
+
+/* Dark mode adjustments */
+@media (prefers-color-scheme: dark) {
+ .link-item:hover {
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
+ }
+}