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",
|
"z42mx3edarpnb",
|
||||||
"y2qobgxho6jte",
|
"y2qobgxho6jte",
|
||||||
"wwgwt2ycq3tx5",
|
"wwgwt2ycq3tx5",
|
||||||
"wigv2qnon7pmg",
|
|
||||||
"vr72pvlhuxnf5",
|
"vr72pvlhuxnf5",
|
||||||
"tg7crfsupxz7h",
|
"tg7crfsupxz7h",
|
||||||
"sv26xtnwgjsds",
|
"sv26xtnwgjsds",
|
||||||
"sqzphb67ymv4i",
|
"sqzphb67ymv4i",
|
||||||
"snju64fbt4a3n",
|
"snju64fbt4a3n",
|
||||||
"smrgeplyw5wmr",
|
|
||||||
"s55utv52t3rf6",
|
"s55utv52t3rf6",
|
||||||
"qbuquaswgxo36",
|
"qbuquaswgxo36",
|
||||||
"q57mb4gebtj2o",
|
"q57mb4gebtj2o",
|
||||||
@@ -36,5 +34,7 @@
|
|||||||
"3ucggdsyhth6h",
|
"3ucggdsyhth6h",
|
||||||
"3kwayvs5zrtng",
|
"3kwayvs5zrtng",
|
||||||
"3gaf4ckp5be5j",
|
"3gaf4ckp5be5j",
|
||||||
"27xox352hir2g"
|
"27xox352hir2g",
|
||||||
|
"wigv2qnon7pmg",
|
||||||
|
"smrgeplyw5wmr"
|
||||||
]
|
]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"collections": [
|
"collections": [
|
||||||
|
"ai.syui.at.link",
|
||||||
"ai.syui.card.old",
|
"ai.syui.card.old",
|
||||||
"ai.syui.card.user",
|
"ai.syui.card.user",
|
||||||
"ai.syui.log.chat",
|
"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
|
return services
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -652,3 +652,49 @@ export async function getRse(did: string): Promise<RseCollection | null> {
|
|||||||
}
|
}
|
||||||
return 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 {
|
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
|
handle?: string
|
||||||
rkey?: string
|
rkey?: string
|
||||||
service?: string
|
service?: string
|
||||||
@@ -69,6 +69,12 @@ export function parseRoute(): Route {
|
|||||||
return { type: 'rse', handle: rseMatch[1] }
|
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
|
// Chat edit: /@handle/at/chat/{rkey}/edit
|
||||||
const chatEditMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/edit$/)
|
const chatEditMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/edit$/)
|
||||||
if (chatEditMatch) {
|
if (chatEditMatch) {
|
||||||
@@ -125,6 +131,8 @@ export function navigate(route: Route): void {
|
|||||||
path = `/@${route.handle}/at/chat/${route.rkey}`
|
path = `/@${route.handle}/at/chat/${route.rkey}`
|
||||||
} else if (route.type === 'chat-edit' && route.handle && route.rkey) {
|
} else if (route.type === 'chat-edit' && route.handle && route.rkey) {
|
||||||
path = `/@${route.handle}/at/chat/${route.rkey}/edit`
|
path = `/@${route.handle}/at/chat/${route.rkey}/edit`
|
||||||
|
} else if (route.type === 'link' && route.handle) {
|
||||||
|
path = `/@${route.handle}/at/link`
|
||||||
}
|
}
|
||||||
|
|
||||||
window.history.pushState({}, '', path)
|
window.history.pushState({}, '', path)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import './styles/main.css'
|
import './styles/main.css'
|
||||||
import './styles/card.css'
|
import './styles/card.css'
|
||||||
import './styles/card-migrate.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 { 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 } from './lib/auth'
|
||||||
import { validateRecord } from './lib/lexicon'
|
import { validateRecord } from './lib/lexicon'
|
||||||
@@ -15,6 +15,7 @@ import { renderFooter } from './components/footer'
|
|||||||
import { renderChatListPage, renderChatThreadPage, renderChatEditForm } from './components/chat'
|
import { renderChatListPage, renderChatThreadPage, renderChatEditForm } from './components/chat'
|
||||||
import { renderCardPage } from './components/card'
|
import { renderCardPage } from './components/card'
|
||||||
import { renderRsePage } from './components/rse'
|
import { renderRsePage } from './components/rse'
|
||||||
|
import { renderLinkPage } from './components/link'
|
||||||
import { checkMigrationStatus, renderMigrationPage, setupMigrationButton } from './components/card-migrate'
|
import { checkMigrationStatus, renderMigrationPage, setupMigrationButton } from './components/card-migrate'
|
||||||
import { showLoading, hideLoading } from './components/loading'
|
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 += `<div id="content">${renderRsePage(rseData, handle)}</div>`
|
||||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
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') {
|
} else if (route.type === 'chat') {
|
||||||
// Chat list page - show threads started by this user
|
// Chat list page - show threads started by this user
|
||||||
if (!config.bot) {
|
if (!config.bot) {
|
||||||
|
|||||||
@@ -2640,3 +2640,101 @@ button.tab {
|
|||||||
color: #999;
|
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