add service top

This commit is contained in:
2026-01-15 18:02:41 +09:00
parent 31cc050444
commit 9f693cbb0b
7 changed files with 242 additions and 34 deletions

View File

@@ -2,5 +2,6 @@
"title": "ailog", "title": "ailog",
"handle": "syui.ai", "handle": "syui.ai",
"collection": "ai.syui.log.post", "collection": "ai.syui.log.post",
"network": "bsky.social" "network": "bsky.social",
"color": "#0066cc"
} }

View File

@@ -23,24 +23,69 @@ function escapeHtml(str: string): string {
.replace(/"/g, '"') .replace(/"/g, '"')
} }
async function renderCollections(did: string, handle: string): Promise<string> { async function renderServices(did: string, handle: string): Promise<string> {
const collections = await describeRepo(did) const collections = await describeRepo(did)
if (collections.length === 0) { if (collections.length === 0) {
return '<p class="no-data">No collections found</p>' return '<p class="no-data">No collections found</p>'
} }
const items = collections.map(col => { // Group by service domain
const service = getServiceInfo(col) const serviceMap = new Map<string, { name: string; favicon: string; count: number }>()
const favicon = service ? `<img src="${service.favicon}" class="collection-favicon" alt="" onerror="this.style.display='none'">` : ''
const serviceName = service ? `<span class="collection-service">${service.name}</span>` : ''
for (const col of collections) {
const info = getServiceInfo(col)
if (info) {
const key = info.domain
if (!serviceMap.has(key)) {
serviceMap.set(key, { name: info.name, favicon: info.favicon, count: 0 })
}
serviceMap.get(key)!.count++
}
}
const items = Array.from(serviceMap.entries()).map(([domain, info]) => {
return `
<li class="service-list-item">
<a href="?mode=browser&handle=${handle}&service=${encodeURIComponent(domain)}" class="service-list-link">
<img src="${info.favicon}" class="service-list-favicon" alt="" onerror="this.style.display='none'">
<span class="service-list-name">${info.name}</span>
<span class="service-list-count">${info.count}</span>
</a>
</li>
`
}).join('')
return `
<div class="services-list">
<h3>Services</h3>
<ul class="service-list">${items}</ul>
</div>
`
}
async function renderCollections(did: string, handle: string, serviceDomain: string): Promise<string> {
const collections = await describeRepo(did)
// Filter by service domain
const filtered = collections.filter(col => {
const info = getServiceInfo(col)
return info && info.domain === serviceDomain
})
if (filtered.length === 0) {
return '<p class="no-data">No collections found</p>'
}
// Get favicon from first collection
const firstInfo = getServiceInfo(filtered[0])
const favicon = firstInfo ? `<img src="${firstInfo.favicon}" class="collection-header-favicon" alt="" onerror="this.style.display='none'">` : ''
const items = filtered.map(col => {
return ` return `
<li class="collection-item"> <li class="collection-item">
<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(col)}" class="collection-link"> <a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(col)}" class="collection-link">
${favicon}
<span class="collection-nsid">${col}</span> <span class="collection-nsid">${col}</span>
${serviceName}
</a> </a>
</li> </li>
` `
@@ -48,7 +93,7 @@ async function renderCollections(did: string, handle: string): Promise<string> {
return ` return `
<div class="collections"> <div class="collections">
<h3>Collections</h3> <h3 class="collection-header">${favicon}<span>${serviceDomain}</span></h3>
<ul class="collection-list">${items}</ul> <ul class="collection-list">${items}</ul>
</div> </div>
` `
@@ -120,6 +165,7 @@ export async function mountAtBrowser(
handle: string, handle: string,
collection: string | null, collection: string | null,
rkey: string | null, rkey: string | null,
service: string | null = null,
loginDid: string | null = null loginDid: string | null = null
): Promise<void> { ): Promise<void> {
container.innerHTML = '<p class="loading">Loading...</p>' container.innerHTML = '<p class="loading">Loading...</p>'
@@ -135,10 +181,16 @@ export async function mountAtBrowser(
nav = `<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(collection)}" class="back-link">← Back</a>` nav = `<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(collection)}" class="back-link">← Back</a>`
content = await renderRecordDetail(did, handle, collection, rkey, canDelete) content = await renderRecordDetail(did, handle, collection, rkey, canDelete)
} else if (collection) { } else if (collection) {
nav = `<a href="?mode=browser&handle=${handle}" class="back-link">← Collections</a>` // Get service from collection for back link
const info = getServiceInfo(collection)
const backService = info ? info.domain : ''
nav = `<a href="?mode=browser&handle=${handle}&service=${encodeURIComponent(backService)}" class="back-link">← ${info?.name || 'Back'}</a>`
content = await renderRecordList(did, handle, collection) content = await renderRecordList(did, handle, collection)
} else if (service) {
nav = `<a href="?mode=browser&handle=${handle}" class="back-link">← Services</a>`
content = await renderCollections(did, handle, service)
} else { } else {
content = await renderCollections(did, handle) content = await renderServices(did, handle)
} }
container.innerHTML = nav + content container.innerHTML = nav + content

View File

@@ -0,0 +1,42 @@
import { describeRepo, getServiceInfo, resolveHandle } from '../lib/api.js'
export async function renderServices(handle: string): Promise<string> {
const did = handle.startsWith('did:') ? handle : await resolveHandle(handle)
const collections = await describeRepo(did)
if (collections.length === 0) {
return ''
}
// Group by service
const serviceMap = new Map<string, { name: string; favicon: string; collections: string[] }>()
for (const col of collections) {
const info = getServiceInfo(col)
if (info) {
const key = info.domain
if (!serviceMap.has(key)) {
serviceMap.set(key, { name: info.name, favicon: info.favicon, collections: [] })
}
serviceMap.get(key)!.collections.push(col)
}
}
const items = Array.from(serviceMap.entries()).map(([domain, info]) => {
const url = `?mode=browser&handle=${handle}&service=${encodeURIComponent(domain)}`
return `
<a href="${url}" class="service-item" title="${info.collections.join(', ')}">
<img src="${info.favicon}" class="service-favicon" alt="" onerror="this.style.display='none'">
<span class="service-name">${info.name}</span>
</a>
`
}).join('')
return `<div class="services">${items}</div>`
}
export async function mountServices(container: HTMLElement, handle: string): Promise<void> {
const html = await renderServices(handle)
container.innerHTML = html
}

View File

@@ -178,16 +178,16 @@ export async function fetchLexicon(nsid: string): Promise<any | null> {
} }
// Known service mappings for collections // Known service mappings for collections
const SERVICE_MAP: Record<string, { name: string; domain: string; icon?: string }> = { const SERVICE_MAP: Record<string, { domain: string; icon?: string }> = {
'app.bsky': { name: 'Bluesky', domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' }, 'app.bsky': { domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' },
'ai.syui': { name: 'syui.ai', domain: 'syui.ai' }, 'chat.bsky': { domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' },
'com.whtwnd': { name: 'WhiteWind', domain: 'whtwnd.com' }, 'ai.syui': { domain: 'syui.ai' },
'fyi.unravel.frontpage': { name: 'Frontpage', domain: 'frontpage.fyi' }, 'com.whtwnd': { domain: 'whtwnd.com' },
'com.shinolabs.pinksea': { name: 'PinkSea', domain: 'pinksea.art' }, 'fyi.unravel.frontpage': { domain: 'frontpage.fyi' },
'blue.linkat': { name: 'Linkat', domain: 'linkat.blue' }, 'com.shinolabs.pinksea': { domain: 'pinksea.art' },
'sh.tangled': { name: 'Tangled', domain: 'tangled.sh' }, 'blue.linkat': { domain: 'linkat.blue' },
'pub.leaflet': { name: 'Leaflet', domain: 'leaflet.pub' }, 'sh.tangled': { domain: 'tangled.sh' },
'chat.bsky': { name: 'Bluesky Chat', domain: 'bsky.app' }, 'pub.leaflet': { domain: 'leaflet.pub' },
} }
export function getServiceInfo(collection: string): { name: string; domain: string; favicon: string } | null { export function getServiceInfo(collection: string): { name: string; domain: string; favicon: string } | null {
@@ -195,7 +195,7 @@ export function getServiceInfo(collection: string): { name: string; domain: stri
for (const [prefix, info] of Object.entries(SERVICE_MAP)) { for (const [prefix, info] of Object.entries(SERVICE_MAP)) {
if (collection.startsWith(prefix)) { if (collection.startsWith(prefix)) {
return { return {
name: info.name, name: info.domain,
domain: info.domain, domain: info.domain,
favicon: info.icon || `https://www.google.com/s2/favicons?domain=${info.domain}&sz=32` favicon: info.icon || `https://www.google.com/s2/favicons?domain=${info.domain}&sz=32`
} }

View File

@@ -1,4 +1,5 @@
import { getProfile, listRecords, getRecord, setNetworkConfig } from './lib/api.js' import { getProfile, listRecords, getRecord, setNetworkConfig } from './lib/api.js'
import { renderServices } from './components/services.js'
import { login, logout, restoreSession, handleOAuthCallback, setAuthNetworkConfig, type AuthSession } from './lib/auth.js' import { login, logout, restoreSession, handleOAuthCallback, setAuthNetworkConfig, type AuthSession } from './lib/auth.js'
import { mountProfile } from './components/profile.js' import { mountProfile } from './components/profile.js'
import { mountPostList, mountPostDetail } from './components/posts.js' import { mountPostList, mountPostDetail } from './components/posts.js'
@@ -52,6 +53,11 @@ async function init(): Promise<void> {
// Set page title // Set page title
document.title = config.title || 'ailog' document.title = config.title || 'ailog'
// Set theme color
if (config.color) {
document.documentElement.style.setProperty('--btn-color', config.color)
}
// Set network config // Set network config
const networkConfig = networks[config.network] const networkConfig = networks[config.network]
if (networkConfig) { if (networkConfig) {
@@ -72,6 +78,7 @@ async function init(): Promise<void> {
const mode = params.get('mode') const mode = params.get('mode')
const rkey = params.get('rkey') const rkey = params.get('rkey')
const collection = params.get('collection') const collection = params.get('collection')
const service = params.get('service')
const handle = params.get('handle') || config.handle const handle = params.get('handle') || config.handle
const profileEl = document.getElementById('profile') const profileEl = document.getElementById('profile')
@@ -126,7 +133,7 @@ async function init(): Promise<void> {
if (mode === 'browser') { if (mode === 'browser') {
profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn) profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn)
const loginDid = authSession?.did || null const loginDid = authSession?.did || null
await mountAtBrowser(contentEl, handle, collection, rkey, loginDid) await mountAtBrowser(contentEl, handle, collection, rkey, service, loginDid)
return return
} }
@@ -139,6 +146,10 @@ async function init(): Promise<void> {
profileEl.appendChild(profileContentEl) profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile) mountProfile(profileContentEl, profile)
// Add services
const servicesHtml = await renderServices(handle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
if (rkey) { if (rkey) {
const post = await getRecord(profile.did, config.collection, rkey) const post = await getRecord(profile.did, config.collection, rkey)
if (post) { if (post) {

View File

@@ -1,3 +1,7 @@
:root {
--btn-color: #0066cc;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@@ -26,6 +30,16 @@ body {
.profile { .profile {
background: #1a1a1a; background: #1a1a1a;
} }
.services {
border-color: #333;
}
.service-item {
background: #2a2a2a;
color: #e0e0e0;
}
.service-item:hover {
background: #333;
}
.post-item { .post-item {
border-color: #333; border-color: #333;
} }
@@ -78,9 +92,9 @@ body {
} }
.header-btn.at-btn { .header-btn.at-btn {
background: #0066cc; background: var(--btn-color);
color: #fff; color: #fff;
border-color: #0066cc; border-color: var(--btn-color);
} }
.header-btn.at-btn:hover { .header-btn.at-btn:hover {
@@ -92,9 +106,9 @@ body {
} }
.header-btn.user-btn { .header-btn.user-btn {
background: #0066cc; background: var(--btn-color);
color: #fff; color: #fff;
border-color: #0066cc; border-color: var(--btn-color);
} }
/* Post Form */ /* Post Form */
@@ -144,7 +158,7 @@ body {
.post-form-btn { .post-form-btn {
padding: 10px 24px; padding: 10px 24px;
background: #0066cc; background: var(--btn-color);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
@@ -211,6 +225,42 @@ body {
color: #444; color: #444;
} }
/* Services */
.services {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #eee;
}
.service-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #f5f5f5;
border-radius: 20px;
text-decoration: none;
color: #333;
font-size: 13px;
transition: background 0.2s;
}
.service-item:hover {
background: #e8e8e8;
}
.service-favicon {
width: 16px;
height: 16px;
}
.service-name {
font-weight: 500;
}
/* Post List */ /* Post List */
.post-list { .post-list {
list-style: none; list-style: none;
@@ -392,7 +442,7 @@ body {
} }
.back-link { .back-link {
color: #0066cc; color: var(--btn-color);
text-decoration: none; text-decoration: none;
} }
@@ -448,17 +498,19 @@ body {
} }
.tab.active { .tab.active {
background: #0066cc; background: var(--btn-color);
color: #fff; color: #fff;
} }
/* AT Browser */ /* AT Browser */
.services-list,
.collections, .collections,
.records, .records,
.record-detail { .record-detail {
padding: 16px 0; padding: 16px 0;
} }
.services-list h3,
.collections h3, .collections h3,
.records h3, .records h3,
.record-detail h3 { .record-detail h3 {
@@ -466,6 +518,53 @@ body {
margin-bottom: 12px; margin-bottom: 12px;
} }
.service-list {
list-style: none;
}
.service-list-item {
border-bottom: 1px solid #eee;
}
.service-list-link {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 8px;
text-decoration: none;
color: inherit;
}
.service-list-link:hover {
background: #f9f9f9;
}
.service-list-favicon {
width: 24px;
height: 24px;
}
.service-list-name {
flex: 1;
font-weight: 500;
}
.service-list-count {
font-size: 13px;
color: #888;
}
.collection-header {
display: flex;
align-items: center;
gap: 8px;
}
.collection-header-favicon {
width: 24px;
height: 24px;
}
.collection-list, .collection-list,
.record-list { .record-list {
list-style: none; list-style: none;
@@ -515,7 +614,7 @@ body {
} }
.record-rkey { .record-rkey {
color: #0066cc; color: var(--btn-color);
min-width: 120px; min-width: 120px;
} }
@@ -622,8 +721,8 @@ body {
} }
.header-btn.at-btn, .header-btn.at-btn,
.header-btn.user-btn { .header-btn.user-btn {
background: #0066cc; background: var(--btn-color);
border-color: #0066cc; border-color: var(--btn-color);
color: #fff; color: #fff;
} }
.post-form-title, .post-form-title,
@@ -650,12 +749,14 @@ body {
background: #333; background: #333;
} }
.tab.active { .tab.active {
background: #0066cc; background: var(--btn-color);
} }
.service-list-link:hover,
.collection-link:hover, .collection-link:hover,
.record-link:hover { .record-link:hover {
background: #1a1a1a; background: #1a1a1a;
} }
.service-list-item,
.collection-item, .collection-item,
.record-item, .record-item,
.record-header { .record-header {

View File

@@ -25,6 +25,7 @@ export interface AppConfig {
handle: string handle: string
collection: string collection: string
network: string network: string
color?: string
} }
export type Networks = Record<string, NetworkConfig> export type Networks = Record<string, NetworkConfig>