add at.link

This commit is contained in:
2026-01-22 21:57:22 +09:00
parent cefe7981ee
commit ca6bb5319c
10 changed files with 296 additions and 5 deletions

View File

@@ -0,0 +1,62 @@
import type { LinkCollection } from '../lib/api'
// Service configurations
const serviceConfig = {
github: {
name: 'GitHub',
urlTemplate: 'https://github.com/{username}',
icon: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>`,
},
x: {
name: 'X',
urlTemplate: 'https://x.com/{username}',
icon: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>`,
},
youtube: {
name: 'YouTube',
urlTemplate: 'https://youtube.com/@{username}',
icon: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>`,
},
}
// 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 `
<div class="link-container">
<p class="link-empty">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('')
return `
<div class="link-container">
<div class="link-grid">${items}</div>
</div>
`
}

View File

@@ -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
}

View File

@@ -652,3 +652,49 @@ export async function getRse(did: string): Promise<RseCollection | null> {
}
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<LinkCollection | null> {
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
}

View File

@@ -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)

View File

@@ -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<void> {
html += `<div id="content">${renderRsePage(rseData, handle)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'link') {
// Link page
const links = await getLinks(did)
html += `<div id="content">${renderLinkPage(links, handle)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'chat') {
// Chat list page - show threads started by this user
if (!config.bot) {

View File

@@ -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);
}
}