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 ` + + + + + ` + }).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); + } +}