add service top
This commit is contained in:
@@ -2,5 +2,6 @@
|
||||
"title": "ailog",
|
||||
"handle": "syui.ai",
|
||||
"collection": "ai.syui.log.post",
|
||||
"network": "bsky.social"
|
||||
"network": "bsky.social",
|
||||
"color": "#0066cc"
|
||||
}
|
||||
|
||||
@@ -23,24 +23,69 @@ function escapeHtml(str: string): string {
|
||||
.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)
|
||||
|
||||
if (collections.length === 0) {
|
||||
return '<p class="no-data">No collections found</p>'
|
||||
}
|
||||
|
||||
const items = collections.map(col => {
|
||||
const service = getServiceInfo(col)
|
||||
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>` : ''
|
||||
// Group by service domain
|
||||
const serviceMap = new Map<string, { name: string; favicon: string; count: number }>()
|
||||
|
||||
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 `
|
||||
<li class="collection-item">
|
||||
<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(col)}" class="collection-link">
|
||||
${favicon}
|
||||
<span class="collection-nsid">${col}</span>
|
||||
${serviceName}
|
||||
</a>
|
||||
</li>
|
||||
`
|
||||
@@ -48,7 +93,7 @@ async function renderCollections(did: string, handle: string): Promise<string> {
|
||||
|
||||
return `
|
||||
<div class="collections">
|
||||
<h3>Collections</h3>
|
||||
<h3 class="collection-header">${favicon}<span>${serviceDomain}</span></h3>
|
||||
<ul class="collection-list">${items}</ul>
|
||||
</div>
|
||||
`
|
||||
@@ -120,6 +165,7 @@ export async function mountAtBrowser(
|
||||
handle: string,
|
||||
collection: string | null,
|
||||
rkey: string | null,
|
||||
service: string | null = null,
|
||||
loginDid: string | null = null
|
||||
): Promise<void> {
|
||||
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>`
|
||||
content = await renderRecordDetail(did, handle, collection, rkey, canDelete)
|
||||
} 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)
|
||||
} else if (service) {
|
||||
nav = `<a href="?mode=browser&handle=${handle}" class="back-link">← Services</a>`
|
||||
content = await renderCollections(did, handle, service)
|
||||
} else {
|
||||
content = await renderCollections(did, handle)
|
||||
content = await renderServices(did, handle)
|
||||
}
|
||||
|
||||
container.innerHTML = nav + content
|
||||
|
||||
42
src/components/services.ts
Normal file
42
src/components/services.ts
Normal 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
|
||||
}
|
||||
@@ -178,16 +178,16 @@ export async function fetchLexicon(nsid: string): Promise<any | null> {
|
||||
}
|
||||
|
||||
// Known service mappings for collections
|
||||
const SERVICE_MAP: Record<string, { name: string; domain: string; icon?: string }> = {
|
||||
'app.bsky': { name: 'Bluesky', domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' },
|
||||
'ai.syui': { name: 'syui.ai', domain: 'syui.ai' },
|
||||
'com.whtwnd': { name: 'WhiteWind', domain: 'whtwnd.com' },
|
||||
'fyi.unravel.frontpage': { name: 'Frontpage', domain: 'frontpage.fyi' },
|
||||
'com.shinolabs.pinksea': { name: 'PinkSea', domain: 'pinksea.art' },
|
||||
'blue.linkat': { name: 'Linkat', domain: 'linkat.blue' },
|
||||
'sh.tangled': { name: 'Tangled', domain: 'tangled.sh' },
|
||||
'pub.leaflet': { name: 'Leaflet', domain: 'leaflet.pub' },
|
||||
'chat.bsky': { name: 'Bluesky Chat', domain: 'bsky.app' },
|
||||
const SERVICE_MAP: Record<string, { domain: string; icon?: string }> = {
|
||||
'app.bsky': { domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' },
|
||||
'chat.bsky': { domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' },
|
||||
'ai.syui': { domain: 'syui.ai' },
|
||||
'com.whtwnd': { domain: 'whtwnd.com' },
|
||||
'fyi.unravel.frontpage': { domain: 'frontpage.fyi' },
|
||||
'com.shinolabs.pinksea': { domain: 'pinksea.art' },
|
||||
'blue.linkat': { domain: 'linkat.blue' },
|
||||
'sh.tangled': { domain: 'tangled.sh' },
|
||||
'pub.leaflet': { domain: 'leaflet.pub' },
|
||||
}
|
||||
|
||||
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)) {
|
||||
if (collection.startsWith(prefix)) {
|
||||
return {
|
||||
name: info.name,
|
||||
name: info.domain,
|
||||
domain: info.domain,
|
||||
favicon: info.icon || `https://www.google.com/s2/favicons?domain=${info.domain}&sz=32`
|
||||
}
|
||||
|
||||
13
src/main.ts
13
src/main.ts
@@ -1,4 +1,5 @@
|
||||
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 { mountProfile } from './components/profile.js'
|
||||
import { mountPostList, mountPostDetail } from './components/posts.js'
|
||||
@@ -52,6 +53,11 @@ async function init(): Promise<void> {
|
||||
// Set page title
|
||||
document.title = config.title || 'ailog'
|
||||
|
||||
// Set theme color
|
||||
if (config.color) {
|
||||
document.documentElement.style.setProperty('--btn-color', config.color)
|
||||
}
|
||||
|
||||
// Set network config
|
||||
const networkConfig = networks[config.network]
|
||||
if (networkConfig) {
|
||||
@@ -72,6 +78,7 @@ async function init(): Promise<void> {
|
||||
const mode = params.get('mode')
|
||||
const rkey = params.get('rkey')
|
||||
const collection = params.get('collection')
|
||||
const service = params.get('service')
|
||||
const handle = params.get('handle') || config.handle
|
||||
|
||||
const profileEl = document.getElementById('profile')
|
||||
@@ -126,7 +133,7 @@ async function init(): Promise<void> {
|
||||
if (mode === 'browser') {
|
||||
profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn)
|
||||
const loginDid = authSession?.did || null
|
||||
await mountAtBrowser(contentEl, handle, collection, rkey, loginDid)
|
||||
await mountAtBrowser(contentEl, handle, collection, rkey, service, loginDid)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,6 +146,10 @@ async function init(): Promise<void> {
|
||||
profileEl.appendChild(profileContentEl)
|
||||
mountProfile(profileContentEl, profile)
|
||||
|
||||
// Add services
|
||||
const servicesHtml = await renderServices(handle)
|
||||
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
|
||||
|
||||
if (rkey) {
|
||||
const post = await getRecord(profile.did, config.collection, rkey)
|
||||
if (post) {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
:root {
|
||||
--btn-color: #0066cc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -26,6 +30,16 @@ body {
|
||||
.profile {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.services {
|
||||
border-color: #333;
|
||||
}
|
||||
.service-item {
|
||||
background: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.service-item:hover {
|
||||
background: #333;
|
||||
}
|
||||
.post-item {
|
||||
border-color: #333;
|
||||
}
|
||||
@@ -78,9 +92,9 @@ body {
|
||||
}
|
||||
|
||||
.header-btn.at-btn {
|
||||
background: #0066cc;
|
||||
background: var(--btn-color);
|
||||
color: #fff;
|
||||
border-color: #0066cc;
|
||||
border-color: var(--btn-color);
|
||||
}
|
||||
|
||||
.header-btn.at-btn:hover {
|
||||
@@ -92,9 +106,9 @@ body {
|
||||
}
|
||||
|
||||
.header-btn.user-btn {
|
||||
background: #0066cc;
|
||||
background: var(--btn-color);
|
||||
color: #fff;
|
||||
border-color: #0066cc;
|
||||
border-color: var(--btn-color);
|
||||
}
|
||||
|
||||
/* Post Form */
|
||||
@@ -144,7 +158,7 @@ body {
|
||||
|
||||
.post-form-btn {
|
||||
padding: 10px 24px;
|
||||
background: #0066cc;
|
||||
background: var(--btn-color);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
@@ -211,6 +225,42 @@ body {
|
||||
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 {
|
||||
list-style: none;
|
||||
@@ -392,7 +442,7 @@ body {
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: #0066cc;
|
||||
color: var(--btn-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -448,17 +498,19 @@ body {
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #0066cc;
|
||||
background: var(--btn-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* AT Browser */
|
||||
.services-list,
|
||||
.collections,
|
||||
.records,
|
||||
.record-detail {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.services-list h3,
|
||||
.collections h3,
|
||||
.records h3,
|
||||
.record-detail h3 {
|
||||
@@ -466,6 +518,53 @@ body {
|
||||
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,
|
||||
.record-list {
|
||||
list-style: none;
|
||||
@@ -515,7 +614,7 @@ body {
|
||||
}
|
||||
|
||||
.record-rkey {
|
||||
color: #0066cc;
|
||||
color: var(--btn-color);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
@@ -622,8 +721,8 @@ body {
|
||||
}
|
||||
.header-btn.at-btn,
|
||||
.header-btn.user-btn {
|
||||
background: #0066cc;
|
||||
border-color: #0066cc;
|
||||
background: var(--btn-color);
|
||||
border-color: var(--btn-color);
|
||||
color: #fff;
|
||||
}
|
||||
.post-form-title,
|
||||
@@ -650,12 +749,14 @@ body {
|
||||
background: #333;
|
||||
}
|
||||
.tab.active {
|
||||
background: #0066cc;
|
||||
background: var(--btn-color);
|
||||
}
|
||||
.service-list-link:hover,
|
||||
.collection-link:hover,
|
||||
.record-link:hover {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.service-list-item,
|
||||
.collection-item,
|
||||
.record-item,
|
||||
.record-header {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface AppConfig {
|
||||
handle: string
|
||||
collection: string
|
||||
network: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export type Networks = Record<string, NetworkConfig>
|
||||
|
||||
Reference in New Issue
Block a user