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 (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
Error: {error}
+
+ URI: {uri}
+
+
+ デバッグ情報: このAT URIは有効ではないか、レコードが存在しません。
+
+
+ )
+ }
+
+ if (!record) {
+ return (
+
+ )
+ }
+
+ const atUri = parseAtUri(uri)
+
+ return (
+
+
+
AT URI Record
+
+ {uri}
+
+
+ DID: {atUri.hostname} | Collection: {atUri.collection} | RKey: {atUri.rkey}
+
+
+
+
+
+ )
+}
\ 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 (
+
+
+
+ {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() {