diff --git a/public/config.json b/public/config.json index 1c93226..acf5041 100644 --- a/public/config.json +++ b/public/config.json @@ -2,5 +2,6 @@ "title": "ailog", "handle": "syui.ai", "collection": "ai.syui.log.post", - "network": "bsky.social" + "network": "bsky.social", + "color": "#0066cc" } diff --git a/src/components/atbrowser.ts b/src/components/atbrowser.ts index c950e1e..1502140 100644 --- a/src/components/atbrowser.ts +++ b/src/components/atbrowser.ts @@ -23,24 +23,69 @@ function escapeHtml(str: string): string { .replace(/"/g, '"') } -async function renderCollections(did: string, handle: string): Promise { +async function renderServices(did: string, handle: string): Promise { const collections = await describeRepo(did) if (collections.length === 0) { return '

No collections found

' } - const items = collections.map(col => { - const service = getServiceInfo(col) - const favicon = service ? `` : '' - const serviceName = service ? `${service.name}` : '' + // Group by service domain + const serviceMap = new Map() + 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 ` +
  • + + + ${info.name} + ${info.count} + +
  • + ` + }).join('') + + return ` +
    +

    Services

    +
      ${items}
    +
    + ` +} + +async function renderCollections(did: string, handle: string, serviceDomain: string): Promise { + 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 '

    No collections found

    ' + } + + // Get favicon from first collection + const firstInfo = getServiceInfo(filtered[0]) + const favicon = firstInfo ? `` : '' + + const items = filtered.map(col => { return `
  • - ${favicon} ${col} - ${serviceName}
  • ` @@ -48,7 +93,7 @@ async function renderCollections(did: string, handle: string): Promise { return `
    -

    Collections

    +

    ${favicon}${serviceDomain}

      ${items}
    ` @@ -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 { container.innerHTML = '

    Loading...

    ' @@ -135,10 +181,16 @@ export async function mountAtBrowser( nav = `← Back` content = await renderRecordDetail(did, handle, collection, rkey, canDelete) } else if (collection) { - nav = `← Collections` + // Get service from collection for back link + const info = getServiceInfo(collection) + const backService = info ? info.domain : '' + nav = `← ${info?.name || 'Back'}` content = await renderRecordList(did, handle, collection) + } else if (service) { + nav = `← Services` + content = await renderCollections(did, handle, service) } else { - content = await renderCollections(did, handle) + content = await renderServices(did, handle) } container.innerHTML = nav + content diff --git a/src/components/services.ts b/src/components/services.ts new file mode 100644 index 0000000..0086ae2 --- /dev/null +++ b/src/components/services.ts @@ -0,0 +1,42 @@ +import { describeRepo, getServiceInfo, resolveHandle } from '../lib/api.js' + +export async function renderServices(handle: string): Promise { + 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() + + 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 ` + + + ${info.name} + + ` + }).join('') + + return `
    ${items}
    ` +} + +export async function mountServices(container: HTMLElement, handle: string): Promise { + const html = await renderServices(handle) + container.innerHTML = html +} diff --git a/src/lib/api.ts b/src/lib/api.ts index c7bda89..c59f2a3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -178,16 +178,16 @@ export async function fetchLexicon(nsid: string): Promise { } // Known service mappings for collections -const SERVICE_MAP: Record = { - '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 = { + '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` } diff --git a/src/main.ts b/src/main.ts index 743f23a..87856fb 100644 --- a/src/main.ts +++ b/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 { // 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 { 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 { 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 { 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) { diff --git a/src/styles/main.css b/src/styles/main.css index 311e1d1..cd059fe 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -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 { diff --git a/src/types.ts b/src/types.ts index 75a8975..578ad7a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,7 @@ export interface AppConfig { handle: string collection: string network: string + color?: string } export type Networks = Record