diff --git a/.gitignore b/.gitignore index 313ed3f..36985de 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /public /dist /repos +/pds/dist .DS_Store .config .claude diff --git a/pds/index.html b/pds/index.html new file mode 100644 index 0000000..f46e33f --- /dev/null +++ b/pds/index.html @@ -0,0 +1,12 @@ + + + + + + AT URI Browser - syui.ai + + +
+ + + \ No newline at end of file diff --git a/pds/package.json b/pds/package.json new file mode 100644 index 0000000..750a10d --- /dev/null +++ b/pds/package.json @@ -0,0 +1,27 @@ +{ + "name": "pds-browser", + "version": "0.3.4", + "description": "AT Protocol browser for ai.log", + "main": "index.js", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "license": "MIT", + "dependencies": { + "@atproto/api": "^0.13.0", + "@atproto/did": "^0.1.0", + "@atproto/lexicon": "^0.4.0", + "@atproto/syntax": "^0.3.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.0.37", + "@types/react-dom": "^18.0.11", + "@vitejs/plugin-react": "^4.0.0", + "vite": "^5.0.0" + } +} diff --git a/pds/src/App.css b/pds/src/App.css new file mode 100644 index 0000000..db4b156 --- /dev/null +++ b/pds/src/App.css @@ -0,0 +1,463 @@ +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + margin: 0; + padding: 20px; + background-color: #f5f5f5; + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + background: white; + padding: 30px; + border-radius: 10px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); +} + +h1 { + color: #333; + margin-bottom: 30px; + border-bottom: 3px solid #007acc; + padding-bottom: 10px; +} + +.test-section { + margin-bottom: 30px; + padding: 20px; + background: #f8f9fa; + border-radius: 8px; + border-left: 4px solid #007acc; +} + +.test-uris { + background: #fff; + padding: 15px; + border-radius: 5px; + border: 1px solid #ddd; + margin: 15px 0; +} + +.at-uri { + font-family: 'Monaco', 'Consolas', monospace; + background: #f4f4f4; + padding: 8px 12px; + border-radius: 4px; + margin: 10px 0; + display: block; + word-break: break-all; + cursor: pointer; + transition: background-color 0.2s; +} + +.at-uri:hover { + background: #e8e8e8; +} + +.instructions { + background: #e8f4f8; + padding: 15px; + border-radius: 5px; + margin: 15px 0; +} + +.instructions ol { + margin: 10px 0; + padding-left: 20px; +} + +.back-link { + display: inline-block; + margin-top: 20px; + color: #007acc; + text-decoration: none; + font-weight: bold; +} + +.back-link:hover { + text-decoration: underline; +} + +/* AT Browser Modal Styles */ +.at-uri-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.at-uri-modal-content { + background-color: white; + border-radius: 8px; + max-width: 800px; + max-height: 600px; + width: 90%; + height: 80%; + overflow: auto; + position: relative; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.at-uri-modal-close { + position: absolute; + top: 10px; + right: 10px; + background: none; + border: none; + font-size: 20px; + cursor: pointer; + z-index: 1001; + padding: 5px 10px; +} + +/* AT URI Link Styles */ +[data-at-uri] { + color: #1976d2; + cursor: pointer; + text-decoration: underline; +} + +[data-at-uri]:hover { + color: #1565c0; +} + +/* Handle Browser Styles */ +.handle-browser { + margin-bottom: 30px; +} + +.handle-form { + display: flex; + gap: 10px; + margin-bottom: 20px; +} + +.handle-input { + flex: 1; + padding: 12px 16px; + font-size: 16px; + border: 2px solid #ddd; + border-radius: 6px; + font-family: 'Monaco', 'Consolas', monospace; +} + +.handle-input:focus { + outline: none; + border-color: #007acc; +} + +.handle-input:disabled { + background: #f5f5f5; + cursor: not-allowed; +} + +.handle-button { + padding: 12px 24px; + font-size: 16px; + background: #007acc; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + transition: background 0.2s; +} + +.handle-button:hover:not(:disabled) { + background: #005a9e; +} + +.handle-button:disabled { + background: #ccc; + cursor: not-allowed; +} + +.error-message { + background: #fee; + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 20px; + color: #c33; + border-left: 4px solid #c33; +} + +.debug-info { + background: #f0f0f0; + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 20px; + border-left: 4px solid #666; +} + +.debug-info h3 { + margin-top: 0; + color: #333; + font-size: 14px; +} + +.debug-info pre { + background: white; + padding: 8px; + border-radius: 4px; + font-size: 12px; + overflow-x: auto; + margin: 0; +} + + +.record-item { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 12px; + background: white; + border: none; + cursor: pointer; + text-align: left; + transition: background 0.2s; + border-radius: 4px; + margin: 4px 0; +} + +.record-item:hover { + background: #e8f4f8; +} + +.record-title { + font-size: 16px; + color: #007acc; + font-weight: 500; +} + +.record-date { + color: #666; + font-size: 14px; +} + +.record-detail { + background: white; + padding: 20px; + border-radius: 8px; + border: 1px solid #ddd; +} + +.back-button { + padding: 8px 16px; + margin-bottom: 16px; + background: #f5f5f5; + border: 1px solid #ddd; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + color: #666; + transition: background 0.2s; +} + +.back-button:hover { + background: #e8e8e8; +} + +.record-detail h2 { + margin-top: 0; + color: #333; +} + +.record-meta { + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid #eee; +} + +.record-meta p { + margin: 8px 0; + color: #666; + font-size: 14px; +} + +.record-meta code { + background: #f4f4f4; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Monaco', 'Consolas', monospace; + font-size: 12px; +} + +.record-content { + line-height: 1.8; +} + +.record-content pre { + white-space: pre-wrap; + word-wrap: break-word; + font-family: inherit; + margin: 0; + color: #333; +} + +.services-list { + margin-top: 20px; + background: #f8f9fa; + padding: 20px; + border-radius: 8px; +} + +.services-list h2 { + margin-top: 0; + margin-bottom: 16px; + color: #333; + font-size: 20px; +} + +.services-list ul { + list-style: none; + padding: 0; + margin: 0; +} + +.services-list li { + border-bottom: 1px solid #ddd; +} + +.services-list li:last-child { + border-bottom: none; +} + +.service-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 16px; + background: white; + border: none; + cursor: pointer; + text-align: left; + transition: background 0.2s; + border-radius: 4px; + margin: 4px 0; +} + +.service-item:hover { + background: #e8f4f8; +} + +.service-icon { + width: 24px; + height: 24px; + border-radius: 4px; + flex-shrink: 0; +} + +.service-name { + font-size: 16px; + color: #007acc; + font-weight: 500; + font-family: 'Monaco', 'Consolas', monospace; + flex: 1; +} + +.service-count { + color: #666; + font-size: 14px; + background: #e8e8e8; + padding: 4px 12px; + border-radius: 12px; +} + +.collections-list { + margin-top: 20px; + background: #f8f9fa; + padding: 20px; + border-radius: 8px; +} + +.collections-list h2 { + margin-top: 0; + margin-bottom: 16px; + color: #333; + font-size: 20px; +} + +.collections-list ul { + list-style: none; + padding: 0; + margin: 0; +} + +.collections-list li { + border-bottom: 1px solid #ddd; +} + +.collections-list li:last-child { + border-bottom: none; +} + +.collection-item { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 16px; + background: white; + border: none; + cursor: pointer; + text-align: left; + transition: background 0.2s; + border-radius: 4px; + margin: 4px 0; +} + +.collection-item:hover { + background: #e8f4f8; +} + +.collection-name { + font-size: 16px; + color: #007acc; + font-weight: 500; + font-family: 'Monaco', 'Consolas', monospace; +} + +.collection-count { + color: #666; + font-size: 14px; + background: #e8e8e8; + padding: 4px 12px; + border-radius: 12px; +} + +.records-view { + margin-top: 20px; + background: white; + padding: 20px; + border-radius: 8px; + border: 1px solid #ddd; +} + +.records-view h2 { + margin-top: 0; + margin-bottom: 16px; + color: #333; + font-size: 20px; +} + +.records-view .records-list { + list-style: none; + padding: 0; + margin: 0; +} + +.records-view .records-list li { + border-bottom: 1px solid #eee; +} + +.records-view .records-list li:last-child { + border-bottom: none; +} \ No newline at end of file diff --git a/pds/src/App.jsx b/pds/src/App.jsx new file mode 100644 index 0000000..28e6bfe --- /dev/null +++ b/pds/src/App.jsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react' +import { AtUriBrowser } from './components/AtUriBrowser.jsx' +import { HandleBrowser } from './components/HandleBrowser.jsx' +import './App.css' + +function App() { + return ( + +
+

AT Protocol Browser

+ + + +
+

AT URI について

+

AT URIは、AT Protocolで使用される統一リソース識別子です。この形式により、分散ソーシャルネットワーク上のコンテンツを一意に識別できます。

+ +

対応PDS環境

+
    +
  • bsky.social - メインのBlueskyネットワーク
  • +
  • syu.is - 独立したPDS環境
  • +
  • plc.directory + plc.syu.is - DID解決
  • +
+
+ + ← ブログに戻る +
+
+ ) +} + +export default App \ No newline at end of file diff --git a/pds/src/components/AtUriBrowser.jsx b/pds/src/components/AtUriBrowser.jsx new file mode 100644 index 0000000..2836050 --- /dev/null +++ b/pds/src/components/AtUriBrowser.jsx @@ -0,0 +1,75 @@ +/* + * AT URI Browser Component + * Copyright (c) 2025 ai.log + * MIT License + */ + +import React, { useState, useEffect } from 'react' +import { AtUriModal } from './AtUriModal.jsx' +import { isAtUri } from '../lib/atproto.js' + +export function AtUriBrowser({ children }) { + const [modalUri, setModalUri] = useState(null) + + useEffect(() => { + const handleAtUriClick = (e) => { + const target = e.target + + // Check if clicked element has at-uri data attribute + if (target.dataset.atUri) { + e.preventDefault() + setModalUri(target.dataset.atUri) + return + } + + // Check if clicked element contains at-uri text + const text = target.textContent + if (text && isAtUri(text)) { + e.preventDefault() + setModalUri(text) + return + } + + // Check if parent element has at-uri + const parent = target.parentElement + if (parent && parent.dataset.atUri) { + e.preventDefault() + setModalUri(parent.dataset.atUri) + return + } + } + + document.addEventListener('click', handleAtUriClick) + + return () => { + document.removeEventListener('click', handleAtUriClick) + } + }, []) + + const handleAtUriClick = (uri) => { + setModalUri(uri) + } + + const handleCloseModal = () => { + setModalUri(null) + } + + return ( + <> + {children} + + + ) +} + +// Utility function to wrap at-uri text with clickable spans +export const wrapAtUris = (text) => { + const atUriRegex = /at:\/\/[^\s]+/g + return text.replace(atUriRegex, (match) => { + return `${match}` + }) +} \ No newline at end of file diff --git a/pds/src/components/AtUriJson.jsx b/pds/src/components/AtUriJson.jsx new file mode 100644 index 0000000..280b829 --- /dev/null +++ b/pds/src/components/AtUriJson.jsx @@ -0,0 +1,130 @@ +/* + * Based on frontpage/atproto-browser + * Copyright (c) 2025 The Frontpage Authors + * MIT License + */ + +import React from 'react' +import { isDid } from '@atproto/did' +import { parseAtUri, isAtUri } from '../lib/atproto.js' + +const JSONString = ({ data, onAtUriClick }) => { + const handleClick = (uri) => { + if (onAtUriClick) { + onAtUriClick(uri) + } + } + + return ( +
+      {isAtUri(data) ? (
+        <>
+          "
+           handleClick(data)}
+            style={{ 
+              color: 'blue', 
+              cursor: 'pointer',
+              textDecoration: 'underline'
+            }}
+          >
+            {data}
+          
+          "
+        
+      ) : isDid(data) ? (
+        <>
+          "
+           handleClick(`at://${data}`)}
+            style={{ 
+              color: 'blue', 
+              cursor: 'pointer',
+              textDecoration: 'underline'
+            }}
+          >
+            {data}
+          
+          "
+        
+      ) : URL.canParse(data) ? (
+        <>
+          "
+          
+            {data}
+          
+          "
+        
+      ) : (
+        `"${data}"`
+      )}
+    
+ ) +} + +const JSONValue = ({ data, onAtUriClick }) => { + if (data === null) { + return
null
+ } + + if (typeof data === 'string') { + return + } + + if (typeof data === 'number') { + return
{data}
+ } + + if (typeof data === 'boolean') { + return
{data.toString()}
+ } + + if (Array.isArray(data)) { + return ( +
+ [ + {data.map((item, index) => ( +
+ + {index < data.length - 1 && ','} +
+ ))} + ] +
+ ) + } + + if (typeof data === 'object') { + return ( +
+ {'{'} + {Object.entries(data).map(([key, value], index, entries) => ( +
+ "{key}": + {index < entries.length - 1 && ','} +
+ ))} + {'}'} +
+ ) + } + + return
{String(data)}
+} + +export default function AtUriJson({ data, onAtUriClick }) { + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/pds/src/components/AtUriModal.jsx b/pds/src/components/AtUriModal.jsx new file mode 100644 index 0000000..21e12af --- /dev/null +++ b/pds/src/components/AtUriModal.jsx @@ -0,0 +1,80 @@ +/* + * AT URI Modal Component + * Copyright (c) 2025 ai.log + * MIT License + */ + +import React, { useEffect } from 'react' +import AtUriViewer from './AtUriViewer.jsx' + +export function AtUriModal({ uri, onClose, onAtUriClick }) { + useEffect(() => { + const handleEscape = (e) => { + if (e.key === 'Escape') { + onClose() + } + } + + const handleClickOutside = (e) => { + if (e.target.classList.contains('at-uri-modal-overlay')) { + onClose() + } + } + + document.addEventListener('keydown', handleEscape) + document.addEventListener('click', handleClickOutside) + + return () => { + document.removeEventListener('keydown', handleEscape) + document.removeEventListener('click', handleClickOutside) + } + }, [onClose]) + + if (!uri) return null + + return ( +
+
+ + + +
+
+ ) +} \ No newline at end of file diff --git a/pds/src/components/AtUriViewer.jsx b/pds/src/components/AtUriViewer.jsx new file mode 100644 index 0000000..d5e8bec --- /dev/null +++ b/pds/src/components/AtUriViewer.jsx @@ -0,0 +1,103 @@ +/* + * Based on frontpage/atproto-browser + * Copyright (c) 2025 The Frontpage Authors + * MIT License + */ + +import React, { useState, useEffect } from 'react' +import { parseAtUri, getRecord } from '../lib/atproto.js' +import AtUriJson from './AtUriJson.jsx' + +export default function AtUriViewer({ uri, onAtUriClick }) { + const [record, setRecord] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const loadRecord = async () => { + if (!uri) return + + setLoading(true) + setError(null) + + try { + const atUri = parseAtUri(uri) + if (!atUri) { + throw new Error('Invalid AT URI') + } + + + const result = await getRecord(atUri.hostname, atUri.collection, atUri.rkey) + + + if (!result.success) { + throw new Error(result.error) + } + + setRecord(result.data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + loadRecord() + }, [uri]) + + if (loading) { + return ( +
+
Loading...
+
+ ) + } + + if (error) { + return ( +
+
Error: {error}
+
+ URI: {uri} +
+
+ デバッグ情報: このAT URIは有効ではないか、レコードが存在しません。 +
+
+ ) + } + + if (!record) { + return ( +
+
No record found
+
+ ) + } + + const atUri = parseAtUri(uri) + + return ( +
+
+

AT URI Record

+
+ {uri} +
+
+ DID: {atUri.hostname} | Collection: {atUri.collection} | RKey: {atUri.rkey} +
+
+ +
+

Record Data

+ +
+
+ ) +} \ No newline at end of file diff --git a/pds/src/components/HandleBrowser.jsx b/pds/src/components/HandleBrowser.jsx new file mode 100644 index 0000000..b43e936 --- /dev/null +++ b/pds/src/components/HandleBrowser.jsx @@ -0,0 +1,229 @@ +import React, { useState } from 'react' +import { listAllCollections } from '../lib/atproto.js' + +const SERVICE_ICONS = { + 'app.bsky': 'https://bsky.app/favicon.ico', + 'chat.bsky': 'https://bsky.app/favicon.ico', + 'ai.syui': 'https://syui.ai/favicon.ico', + 'tools.ozone': 'https://ozone.tools/favicon.ico', + 'com.atproto': 'https://atproto.com/favicon.ico' +} + +const groupCollectionsByService = (collections) => { + const services = {} + + collections.forEach(col => { + const parts = col.collection.split('.') + const service = parts.slice(0, 2).join('.') + + if (!services[service]) { + services[service] = [] + } + services[service].push(col) + }) + + return services +} + +export function HandleBrowser() { + const [handle, setHandle] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [collections, setCollections] = useState([]) + const [services, setServices] = useState({}) + const [expandedService, setExpandedService] = useState(null) + const [expandedCollection, setExpandedCollection] = useState(null) + const [selectedRecord, setSelectedRecord] = useState(null) + const [debugInfo, setDebugInfo] = useState(null) + + const handleSubmit = async (e) => { + e.preventDefault() + + if (!handle) return + + setLoading(true) + setError(null) + setCollections([]) + setServices({}) + setExpandedService(null) + setExpandedCollection(null) + setSelectedRecord(null) + setDebugInfo(null) + + try { + const result = await listAllCollections(handle) + + const totalRecords = result.collections?.reduce((sum, c) => sum + c.records.length, 0) || 0 + + setDebugInfo({ + handle, + success: result.success, + pdsUrl: result.pdsUrl, + collectionCount: result.collections?.length || 0, + totalRecords + }) + + if (!result.success) { + throw new Error(result.error) + } + + if (result.collections.length === 0) { + setError('No collections found for this handle') + } else { + setCollections(result.collections) + const grouped = groupCollectionsByService(result.collections) + setServices(grouped) + } + } catch (err) { + setError(`Failed to load: ${err.message}`) + } finally { + setLoading(false) + } + } + + const handleServiceClick = (service) => { + setExpandedService(service) + setExpandedCollection(null) + setSelectedRecord(null) + } + + const handleBackToServices = () => { + setExpandedService(null) + setExpandedCollection(null) + setSelectedRecord(null) + } + + const handleCollectionClick = (collection) => { + setExpandedCollection(collection) + setSelectedRecord(null) + } + + const handleBackToCollections = () => { + setExpandedCollection(null) + setSelectedRecord(null) + } + + const handleRecordClick = (record) => { + setSelectedRecord(record) + } + + const handleBackToRecords = () => { + setSelectedRecord(null) + } + + return ( +
+
+ setHandle(e.target.value)} + className="handle-input" + disabled={loading} + /> + +
+ + {error && ( +
+

Error: {error}

+
+ )} + + {debugInfo && ( +
+

Debug Info

+
{JSON.stringify(debugInfo, null, 2)}
+
+ )} + + {selectedRecord ? ( +
+ +

{selectedRecord.uri.split('/').pop()}

+
+

URI: {selectedRecord.uri}

+ {selectedRecord.value.createdAt && ( +

Created: {new Date(selectedRecord.value.createdAt).toLocaleString()}

+ )} +
+
+
{JSON.stringify(selectedRecord.value, null, 2)}
+
+
+ ) : expandedCollection ? ( +
+ +

{expandedCollection.collection} ({expandedCollection.records.length})

+
    + {expandedCollection.records.map((record) => { + const rkey = record.uri.split('/').pop() + return ( +
  • + +
  • + ) + })} +
+
+ ) : expandedService ? ( +
+ +

{expandedService} ({services[expandedService].length})

+
    + {services[expandedService].map((collectionGroup) => ( +
  • + +
  • + ))} +
+
+ ) : Object.keys(services).length > 0 ? ( +
+

Services ({Object.keys(services).length})

+
    + {Object.keys(services).map((service) => { + const totalRecords = services[service].reduce((sum, col) => sum + col.records.length, 0) + return ( +
  • + +
  • + ) + })} +
+
+ ) : null} +
+ ) +} diff --git a/pds/src/config.js b/pds/src/config.js new file mode 100644 index 0000000..244aeb9 --- /dev/null +++ b/pds/src/config.js @@ -0,0 +1,33 @@ +/* + * AT Protocol Configuration for syu.is environment + */ + +export const AT_PROTOCOL_CONFIG = { + // Primary PDS environment (syu.is) + primary: { + pds: 'https://syu.is', + plc: 'https://plc.syu.is', + bsky: 'https://bsky.syu.is', + web: 'https://web.syu.is' + }, + + // Fallback PDS environment (bsky.social) + fallback: { + pds: 'https://bsky.social', + plc: 'https://plc.directory', + bsky: 'https://public.api.bsky.app', + web: 'https://bsky.app' + } +} + +export const getPDSConfig = (pds) => { + // Map PDS URL to appropriate config + if (pds.includes('syu.is')) { + return AT_PROTOCOL_CONFIG.primary + } else if (pds.includes('bsky.social')) { + return AT_PROTOCOL_CONFIG.fallback + } + + // Default to primary for unknown PDS + return AT_PROTOCOL_CONFIG.primary +} \ No newline at end of file diff --git a/pds/src/index.js b/pds/src/index.js new file mode 100644 index 0000000..2e82f25 --- /dev/null +++ b/pds/src/index.js @@ -0,0 +1,9 @@ +/* + * Based on frontpage/atproto-browser + * Copyright (c) 2025 The Frontpage Authors + * MIT License + */ + +export { AtUriBrowser } from './components/AtUriBrowser.jsx' +export { AtUriModal } from './components/AtUriModal.jsx' +export { default as AtUriViewer } from './components/AtUriViewer.jsx' \ No newline at end of file diff --git a/pds/src/lib/atproto.js b/pds/src/lib/atproto.js new file mode 100644 index 0000000..201c4a9 --- /dev/null +++ b/pds/src/lib/atproto.js @@ -0,0 +1,251 @@ +/* + * Based on frontpage/atproto-browser + * Copyright (c) 2025 The Frontpage Authors + * MIT License + */ + +import { AtpBaseClient } from '@atproto/api' +import { AtUri } from '@atproto/syntax' +import { isDid } from '@atproto/did' +import { AT_PROTOCOL_CONFIG } from '../config.js' + +// Identity resolution cache +const identityCache = new Map() + +// Create AT Protocol client +export const createAtpClient = (pds) => { + return new AtpBaseClient({ + service: pds.startsWith('http') ? pds : `https://${pds}` + }) +} + +// Resolve identity (DID/Handle) +export const resolveIdentity = async (identifier) => { + if (identityCache.has(identifier)) { + return identityCache.get(identifier) + } + + try { + let did = identifier + + // If it's a handle, resolve to DID + if (!isDid(identifier)) { + // Try syu.is first, then fallback to bsky.social + let resolved = false + + try { + const client = createAtpClient(AT_PROTOCOL_CONFIG.primary.pds) + const response = await client.com.atproto.repo.describeRepo({ repo: identifier }) + did = response.data.did + resolved = true + } catch (error) { + } + + if (!resolved) { + try { + const client = createAtpClient(AT_PROTOCOL_CONFIG.fallback.pds) + const response = await client.com.atproto.repo.describeRepo({ repo: identifier }) + did = response.data.did + } catch (error) { + throw new Error(`Failed to resolve handle: ${identifier}`) + } + } + } + + // Get DID document to find PDS + // Try plc.syu.is first, then fallback to plc.directory + let didDoc = null + let plcResponse = null + + try { + plcResponse = await fetch(`${AT_PROTOCOL_CONFIG.primary.plc}/${did}`) + if (plcResponse.ok) { + didDoc = await plcResponse.json() + } + } catch (error) { + } + + // If plc.syu.is fails, try plc.directory + if (!didDoc) { + try { + plcResponse = await fetch(`${AT_PROTOCOL_CONFIG.fallback.plc}/${did}`) + if (plcResponse.ok) { + didDoc = await plcResponse.json() + } + } catch (error) { + } + } + + if (!didDoc) { + throw new Error(`Failed to resolve DID document from any PLC server`) + } + + // Find PDS service endpoint + const pdsService = didDoc.service?.find(service => + service.type === 'AtprotoPersonalDataServer' || + service.id === '#atproto_pds' + ) + + if (!pdsService) { + throw new Error('No PDS service found in DID document') + } + + const result = { + success: true, + didDocument: didDoc, + pdsUrl: pdsService.serviceEndpoint + } + + identityCache.set(identifier, result) + return result + } catch (error) { + const result = { + success: false, + error: error.message + } + identityCache.set(identifier, result) + return result + } +} + +// Get record from AT Protocol +export const getRecord = async (did, collection, rkey) => { + try { + const identityResult = await resolveIdentity(did) + + if (!identityResult.success) { + return { success: false, error: identityResult.error } + } + + const pdsUrl = identityResult.pdsUrl + + const client = createAtpClient(pdsUrl) + + const response = await client.com.atproto.repo.getRecord({ + repo: did, + collection, + rkey + }) + + return { + success: true, + data: response.data, + pdsUrl + } + } catch (error) { + return { + success: false, + error: error.message + } + } +} + +// Parse AT URI +export const parseAtUri = (uri) => { + try { + return new AtUri(uri) + } catch (error) { + return null + } +} + +// Check if string is AT URI +export const isAtUri = (str) => { + return str.startsWith('at://') && str.split(' ').length === 1 +} + +// List records from AT Protocol +export const listRecords = async (identifier, collection) => { + try { + const identityResult = await resolveIdentity(identifier) + + if (!identityResult.success) { + return { success: false, error: identityResult.error } + } + + const did = identityResult.didDocument.id + const pdsUrl = identityResult.pdsUrl + + const client = createAtpClient(pdsUrl) + + const response = await client.com.atproto.repo.listRecords({ + repo: did, + collection, + limit: 100 + }) + + return { + success: true, + records: response.data.records || [], + pdsUrl + } + } catch (error) { + return { + success: false, + error: error.message + } + } +} + +// List all collections for a user +export const listAllCollections = async (identifier) => { + try { + const identityResult = await resolveIdentity(identifier) + + if (!identityResult.success) { + return { success: false, error: identityResult.error } + } + + const did = identityResult.didDocument.id + const pdsUrl = identityResult.pdsUrl + + const client = createAtpClient(pdsUrl) + + // Get collections list from describeRepo + const repoDesc = await client.com.atproto.repo.describeRepo({ + repo: did + }) + + const collections = repoDesc.data.collections || [] + + if (collections.length === 0) { + return { + success: true, + collections: [], + pdsUrl + } + } + + const allRecords = [] + + for (const collection of collections) { + try { + const response = await client.com.atproto.repo.listRecords({ + repo: did, + collection, + limit: 100 + }) + + if (response.data.records && response.data.records.length > 0) { + allRecords.push({ + collection, + records: response.data.records + }) + } + } catch (err) { + // Collection doesn't exist or is empty, skip + } + } + + return { + success: true, + collections: allRecords, + pdsUrl + } + } catch (error) { + return { + success: false, + error: error.message + } + } +} \ No newline at end of file diff --git a/pds/src/main.jsx b/pds/src/main.jsx new file mode 100644 index 0000000..6161d18 --- /dev/null +++ b/pds/src/main.jsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) \ No newline at end of file diff --git a/pds/vite.config.js b/pds/vite.config.js new file mode 100644 index 0000000..e21c23d --- /dev/null +++ b/pds/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + base: '/pds/', + define: { + 'process.env.NODE_ENV': JSON.stringify('production') + } +}) \ No newline at end of file diff --git a/src/build.rs b/src/build.rs index ccab69b..2ef7a87 100644 --- a/src/build.rs +++ b/src/build.rs @@ -138,7 +138,7 @@ pub async fn execute() -> Result<()> { async fn build_browser() -> Result<()> { use std::process::Command; - let browser_dir = "./repos/log/pds"; + let browser_dir = "./pds"; // Check if pds directory exists if !std::path::Path::new(browser_dir).exists() {