add at.link
This commit is contained in:
48
lexicons/ai.syui.at.link.json
Normal file
48
lexicons/ai.syui.at.link.json
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"collections": [
|
||||
"ai.syui.at.link",
|
||||
"ai.syui.card.old",
|
||||
"ai.syui.card.user",
|
||||
"ai.syui.log.chat",
|
||||
|
||||
62
src/web/components/link.ts
Normal file
62
src/web/components/link.ts
Normal 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>
|
||||
`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user