1632 lines
59 KiB
TypeScript
1632 lines
59 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import { OAuthCallback } from './components/OAuthCallback';
|
||
import { AIChat } from './components/AIChat';
|
||
import { authService, User } from './services/auth';
|
||
import { atprotoOAuthService } from './services/atproto-oauth';
|
||
import { appConfig, getCollectionNames } from './config/app';
|
||
import { getProfileForUser, detectPdsFromHandle, getApiUrlForUser, verifyPdsDetection, getNetworkConfigFromPdsEndpoint, getNetworkConfig } from './utils/pds-detection';
|
||
import { isValidDid } from './utils/validation';
|
||
import './App.css';
|
||
|
||
function App() {
|
||
// Handle OAuth callback detection
|
||
if (window.location.search.includes('code=') || window.location.search.includes('state=')) {
|
||
const urlInfo = `OAuth callback detected!\n\nURL: ${window.location.href}\nSearch: ${window.location.search}`;
|
||
alert(urlInfo);
|
||
}
|
||
|
||
const [user, setUser] = useState<User | null>(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [comments, setComments] = useState<any[]>([]);
|
||
const [commentText, setCommentText] = useState('');
|
||
const [isPosting, setIsPosting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [handleInput, setHandleInput] = useState('');
|
||
const [userListInput, setUserListInput] = useState('');
|
||
const [isPostingUserList, setIsPostingUserList] = useState(false);
|
||
const [userListRecords, setUserListRecords] = useState<any[]>([]);
|
||
const [showJsonFor, setShowJsonFor] = useState<string | null>(null);
|
||
const [activeTab, setActiveTab] = useState<'comments' | 'ai-chat' | 'lang-en' | 'ai-comment'>('comments');
|
||
const [aiChatHistory, setAiChatHistory] = useState<any[]>([]);
|
||
const [langEnRecords, setLangEnRecords] = useState<any[]>([]);
|
||
const [aiCommentRecords, setAiCommentRecords] = useState<any[]>([]);
|
||
const [aiProfile, setAiProfile] = useState<any>(null);
|
||
|
||
const [adminDid, setAdminDid] = useState<string | null>(null);
|
||
const [aiDid, setAiDid] = useState<string | null>(null);
|
||
|
||
// ハンドルからDIDを解決する関数
|
||
const resolveHandleToDid = async (handle: string): Promise<string | null> => {
|
||
try {
|
||
const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(handle));
|
||
return profile?.did || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
// 管理者とAIのDIDを解決
|
||
const resolveAdminAndAiDids = async () => {
|
||
const [resolvedAdminDid, resolvedAiDid] = await Promise.all([
|
||
resolveHandleToDid(appConfig.adminHandle),
|
||
resolveHandleToDid(appConfig.aiHandle)
|
||
]);
|
||
|
||
setAdminDid(resolvedAdminDid || appConfig.adminDid);
|
||
setAiDid(resolvedAiDid || appConfig.aiDid);
|
||
};
|
||
|
||
resolveAdminAndAiDids();
|
||
|
||
// Setup Jetstream WebSocket for real-time comments (optional)
|
||
const setupJetstream = () => {
|
||
try {
|
||
const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe');
|
||
|
||
const collections = getCollectionNames(appConfig.collections.base);
|
||
ws.onopen = () => {
|
||
ws.send(JSON.stringify({
|
||
wantedCollections: [collections.comment]
|
||
}));
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.collection === collections.comment && data.commit?.operation === 'create') {
|
||
// Optionally reload comments
|
||
// loadAllComments(window.location.href);
|
||
}
|
||
} catch (err) {
|
||
// Ignore parsing errors
|
||
}
|
||
};
|
||
|
||
ws.onerror = (err) => {
|
||
// Ignore Jetstream errors
|
||
};
|
||
|
||
return ws;
|
||
} catch (err) {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// Jetstream + Cache example (disabled for now)
|
||
// const jetstream = setupJetstream();
|
||
|
||
// キャッシュからコメント読み込み
|
||
const loadCachedComments = () => {
|
||
const cached = localStorage.getItem('cached_comments_' + window.location.pathname);
|
||
if (cached) {
|
||
const { comments: cachedComments, timestamp } = JSON.parse(cached);
|
||
// 5分以内のキャッシュなら使用
|
||
if (Date.now() - timestamp < 5 * 60 * 1000) {
|
||
setComments(cachedComments);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// DID解決が完了してからコメントとチャット履歴を読み込む
|
||
const loadDataAfterDidResolution = () => {
|
||
// キャッシュがなければ、ATProtoから取得(認証状態に関係なく)
|
||
if (!loadCachedComments()) {
|
||
loadAllComments(); // URLフィルタリングを無効にして全コメント表示
|
||
}
|
||
|
||
// Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示)
|
||
loadAiChatHistory();
|
||
|
||
// Load AI generated content (lang:en and AI comments)
|
||
loadAIGeneratedContent();
|
||
};
|
||
|
||
// Wait for DID resolution before loading data
|
||
if (adminDid && aiDid) {
|
||
loadDataAfterDidResolution();
|
||
} else {
|
||
// Wait a bit and try again
|
||
setTimeout(() => {
|
||
if (adminDid && aiDid) {
|
||
loadDataAfterDidResolution();
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
// Load AI profile from handle
|
||
const loadAiProfile = async () => {
|
||
try {
|
||
// Use VITE_AI_HANDLE to detect PDS and get profile
|
||
const handle = appConfig.aiHandle;
|
||
if (!handle) {
|
||
throw new Error('No AI handle configured');
|
||
}
|
||
|
||
// Detect PDS: Use VITE_ATPROTO_PDS if handle matches admin/ai handles
|
||
let pds;
|
||
if (handle === appConfig.adminHandle || handle === appConfig.aiHandle) {
|
||
// Use configured PDS for admin/ai handles
|
||
pds = appConfig.atprotoPds || 'syu.is';
|
||
} else {
|
||
// Use handle-based detection for other handles
|
||
pds = detectPdsFromHandle(handle);
|
||
}
|
||
|
||
const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
|
||
const apiEndpoint = config.bskyApi;
|
||
|
||
|
||
// Get profile from appropriate bsky API
|
||
const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
|
||
if (profileResponse.ok) {
|
||
const profileData = await profileResponse.json();
|
||
setAiProfile({
|
||
did: profileData.did || appConfig.aiDid,
|
||
handle: profileData.handle || handle,
|
||
displayName: profileData.displayName || appConfig.aiDisplayName || 'ai',
|
||
avatar: profileData.avatar || generatePlaceholderAvatar(handle),
|
||
description: profileData.description || appConfig.aiDescription || ''
|
||
});
|
||
} else {
|
||
// Fallback to config values
|
||
setAiProfile({
|
||
did: appConfig.aiDid,
|
||
handle: handle,
|
||
displayName: appConfig.aiDisplayName || 'ai',
|
||
avatar: generatePlaceholderAvatar(handle),
|
||
description: appConfig.aiDescription || ''
|
||
});
|
||
}
|
||
} catch (err) {
|
||
// Failed to load AI profile
|
||
// Fallback to config values
|
||
setAiProfile({
|
||
did: appConfig.aiDid,
|
||
handle: appConfig.aiHandle,
|
||
displayName: appConfig.aiDisplayName || 'ai',
|
||
avatar: generatePlaceholderAvatar(appConfig.aiHandle || 'ai'),
|
||
description: appConfig.aiDescription || ''
|
||
});
|
||
}
|
||
};
|
||
loadAiProfile();
|
||
|
||
// Handle popstate events for mock OAuth flow
|
||
const handlePopState = () => {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const isOAuthCallback = urlParams.has('code') && urlParams.has('state');
|
||
|
||
if (isOAuthCallback) {
|
||
// Force re-render to handle OAuth callback
|
||
window.location.reload();
|
||
}
|
||
};
|
||
|
||
window.addEventListener('popstate', handlePopState);
|
||
|
||
// Check if this is an OAuth callback
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const isOAuthCallback = urlParams.has('code') && urlParams.has('state');
|
||
|
||
if (isOAuthCallback) {
|
||
return; // Let OAuthCallback component handle this
|
||
}
|
||
|
||
// Check existing sessions
|
||
const checkAuth = async () => {
|
||
// First check OAuth session using official BrowserOAuthClient
|
||
const oauthResult = await atprotoOAuthService.checkSession();
|
||
|
||
if (oauthResult) {
|
||
// Ensure handle is not DID
|
||
const handle = oauthResult.handle !== oauthResult.did ? oauthResult.handle : oauthResult.handle;
|
||
|
||
// Note: appConfig.allowedHandles is used for PDS detection, not access control
|
||
|
||
// Get user profile including avatar
|
||
const userProfile = await getUserProfile(oauthResult.did, handle);
|
||
setUser(userProfile);
|
||
|
||
// Load all comments for display (this will be the default view)
|
||
// Temporarily disable URL filtering to see all comments
|
||
loadAllComments();
|
||
|
||
// Load AI chat history
|
||
loadAiChatHistory();
|
||
|
||
// Load user list records if admin
|
||
if (userProfile.did === adminDid) {
|
||
loadUserListRecords();
|
||
}
|
||
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
// Fallback to legacy auth
|
||
const verifiedUser = await authService.verify();
|
||
if (verifiedUser) {
|
||
// Check if handle is allowed
|
||
if (appConfig.allowedHandles.length > 0 && !appConfig.allowedHandles.includes(verifiedUser.handle)) {
|
||
// Handle not in allowed list
|
||
setError(`Access denied: ${verifiedUser.handle} is not authorized for this application.`);
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
setUser(verifiedUser);
|
||
|
||
// Load all comments for display (this will be the default view)
|
||
// Temporarily disable URL filtering to see all comments
|
||
loadAllComments();
|
||
|
||
// Load user list records if admin
|
||
if (verifiedUser.did === adminDid) {
|
||
loadUserListRecords();
|
||
}
|
||
}
|
||
setIsLoading(false);
|
||
|
||
// 認証状態に関係なく、コメントを読み込む
|
||
loadAllComments();
|
||
};
|
||
|
||
checkAuth();
|
||
|
||
// Load AI generated content (public)
|
||
loadAIGeneratedContent();
|
||
|
||
return () => {
|
||
window.removeEventListener('popstate', handlePopState);
|
||
};
|
||
}, []);
|
||
|
||
// DID解決完了時にデータを再読み込み
|
||
useEffect(() => {
|
||
if (adminDid && aiDid) {
|
||
loadAllComments();
|
||
loadAiChatHistory();
|
||
loadAIGeneratedContent();
|
||
}
|
||
}, [adminDid, aiDid]);
|
||
|
||
const getUserProfile = async (did: string, handle: string): Promise<User> => {
|
||
try {
|
||
const agent = atprotoOAuthService.getAgent();
|
||
if (agent) {
|
||
const profile = await agent.getProfile({ actor: handle });
|
||
return {
|
||
did: did,
|
||
handle: handle,
|
||
avatar: profile.data.avatar,
|
||
displayName: profile.data.displayName || handle
|
||
};
|
||
}
|
||
} catch (error) {
|
||
// Failed to get user profile
|
||
}
|
||
|
||
// Fallback to basic user info
|
||
return {
|
||
did: did,
|
||
handle: handle,
|
||
avatar: generatePlaceholderAvatar(handle),
|
||
displayName: handle
|
||
};
|
||
};
|
||
|
||
const generatePlaceholderAvatar = (handle: string): string => {
|
||
const initial = handle ? handle.charAt(0).toUpperCase() : 'U';
|
||
const svg = `<svg width="48" height="48" xmlns="http://www.w3.org/2000/svg">
|
||
<rect width="48" height="48" fill="#1185fe"/>
|
||
<text x="24" y="32" font-family="Arial, sans-serif" font-size="20" font-weight="bold" fill="white" text-anchor="middle">${initial}</text>
|
||
</svg>`;
|
||
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
||
};
|
||
|
||
const loadAiChatHistory = async () => {
|
||
try {
|
||
// Load all chat records from users in admin's user list
|
||
const currentAdminDid = adminDid || appConfig.adminDid;
|
||
|
||
// Don't proceed if we don't have a valid DID
|
||
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
|
||
return;
|
||
}
|
||
|
||
// Resolve admin's actual PDS from their DID
|
||
let adminPdsEndpoint;
|
||
try {
|
||
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
|
||
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
||
adminPdsEndpoint = config.pdsApi;
|
||
} catch {
|
||
// Fallback to configured PDS
|
||
const adminConfig = getNetworkConfig(appConfig.atprotoPds);
|
||
adminPdsEndpoint = adminConfig.pdsApi;
|
||
}
|
||
|
||
const collections = getCollectionNames(appConfig.collections.base);
|
||
|
||
const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`);
|
||
|
||
if (!userListResponse.ok) {
|
||
setAiChatHistory([]);
|
||
return;
|
||
}
|
||
|
||
const userListData = await userListResponse.json();
|
||
const userRecords = userListData.records || [];
|
||
|
||
// Extract unique DIDs from user records (including admin DID for their own chats)
|
||
const allUserDids = [];
|
||
userRecords.forEach(record => {
|
||
if (record.value.users && Array.isArray(record.value.users)) {
|
||
record.value.users.forEach(user => {
|
||
if (user.did) {
|
||
allUserDids.push(user.did);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// Always include admin DID to check admin's own chats
|
||
allUserDids.push(currentAdminDid);
|
||
|
||
const userDids = [...new Set(allUserDids)];
|
||
|
||
// Load chat records from all registered users (including admin) using per-user PDS detection
|
||
const allChatRecords = [];
|
||
for (const userDid of userDids) {
|
||
try {
|
||
// Use per-user PDS detection for each user's chat records
|
||
let userPdsEndpoint;
|
||
try {
|
||
// Validate DID format before making API calls
|
||
if (!userDid || !userDid.startsWith('did:')) {
|
||
continue;
|
||
}
|
||
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(userDid));
|
||
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
||
userPdsEndpoint = config.pdsApi;
|
||
} catch {
|
||
userPdsEndpoint = atprotoApi; // Fallback
|
||
}
|
||
|
||
const chatResponse = await fetch(`${userPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(userDid)}&collection=${encodeURIComponent(collections.chat)}&limit=100`);
|
||
|
||
if (chatResponse.ok) {
|
||
const chatData = await chatResponse.json();
|
||
const records = chatData.records || [];
|
||
allChatRecords.push(...records);
|
||
} else if (chatResponse.status === 400) {
|
||
// Skip 400 errors (repo not found, etc)
|
||
continue;
|
||
}
|
||
} catch (err) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Filter for page-specific content if on a post page
|
||
let filteredRecords = allChatRecords;
|
||
if (appConfig.rkey) {
|
||
// On post page: show only chats for this specific post
|
||
filteredRecords = allChatRecords.filter(record => {
|
||
const recordRkey = record.value.post?.url ? new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '') : '';
|
||
return recordRkey === appConfig.rkey;
|
||
});
|
||
} else {
|
||
// On top page: show latest 3 records from all pages
|
||
filteredRecords = allChatRecords.slice(0, 3);
|
||
}
|
||
|
||
// Filter out old records with invalid AI profile data (temporary fix for migration)
|
||
const validRecords = filteredRecords.filter(record => {
|
||
if (record.value.type === 'answer') {
|
||
// This is an AI answer - check if it has valid AI profile
|
||
return record.value.author?.handle &&
|
||
record.value.author?.handle !== 'ai-assistant' &&
|
||
record.value.author?.displayName !== 'AI Assistant';
|
||
}
|
||
return true; // Keep all questions
|
||
});
|
||
|
||
// Sort by creation time
|
||
const sortedRecords = validRecords.sort((a, b) =>
|
||
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
|
||
);
|
||
|
||
setAiChatHistory(sortedRecords);
|
||
} catch (err) {
|
||
setAiChatHistory([]);
|
||
}
|
||
};
|
||
|
||
// Load AI generated content from admin DID
|
||
const loadAIGeneratedContent = async () => {
|
||
try {
|
||
const currentAdminDid = adminDid || appConfig.adminDid;
|
||
|
||
// Don't proceed if we don't have a valid DID
|
||
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
|
||
return;
|
||
}
|
||
|
||
// Resolve admin's actual PDS from their DID
|
||
let atprotoApi;
|
||
try {
|
||
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
|
||
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
||
atprotoApi = config.pdsApi;
|
||
} catch {
|
||
// Fallback to configured PDS
|
||
const adminConfig = getNetworkConfig(appConfig.atprotoPds);
|
||
atprotoApi = adminConfig.pdsApi;
|
||
}
|
||
const collections = getCollectionNames(appConfig.collections.base);
|
||
|
||
// Load lang:en records
|
||
const langResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`);
|
||
if (langResponse.ok) {
|
||
const langData = await langResponse.json();
|
||
const langRecords = langData.records || [];
|
||
|
||
// Filter by current page rkey if on post page
|
||
const filteredLangRecords = appConfig.rkey
|
||
? langRecords.filter(record => {
|
||
// Compare rkey only (last part of path)
|
||
const recordRkey = record.value.post?.url ? new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '') : '';
|
||
return recordRkey === appConfig.rkey;
|
||
})
|
||
: langRecords.slice(0, 3); // Top page: latest 3
|
||
|
||
setLangEnRecords(filteredLangRecords);
|
||
}
|
||
|
||
// Load AI comment records from admin account (not AI account)
|
||
if (!currentAdminDid) {
|
||
console.warn('No Admin DID available, skipping AI comment loading');
|
||
setAiCommentRecords([]);
|
||
return;
|
||
}
|
||
|
||
const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`);
|
||
if (commentResponse.ok) {
|
||
const commentData = await commentResponse.json();
|
||
const commentRecords = commentData.records || [];
|
||
|
||
// Filter by current page rkey if on post page
|
||
const filteredCommentRecords = appConfig.rkey
|
||
? commentRecords.filter(record => {
|
||
// Compare rkey only (last part of path)
|
||
const recordRkey = record.value.post?.url ? new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '') : '';
|
||
return recordRkey === appConfig.rkey;
|
||
})
|
||
: commentRecords.slice(0, 3); // Top page: latest 3
|
||
|
||
setAiCommentRecords(filteredCommentRecords);
|
||
}
|
||
} catch (err) {
|
||
// Ignore errors
|
||
}
|
||
};
|
||
|
||
const loadUserComments = async (did: string) => {
|
||
try {
|
||
const agent = atprotoOAuthService.getAgent();
|
||
if (!agent) {
|
||
return;
|
||
}
|
||
|
||
// Get comments from current user
|
||
const response = await agent.api.com.atproto.repo.listRecords({
|
||
repo: did,
|
||
collection: getCollectionNames(appConfig.collections.base).comment,
|
||
limit: 100,
|
||
});
|
||
const userComments = response.data.records || [];
|
||
|
||
// Enhance comments with fresh profile information
|
||
const enhancedComments = await Promise.all(
|
||
userComments.map(async (record) => {
|
||
if (record.value.author?.handle) {
|
||
try {
|
||
// Use existing PDS detection logic
|
||
const handle = record.value.author.handle;
|
||
const pds = detectPdsFromHandle(handle);
|
||
const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
|
||
const apiEndpoint = config.bskyApi;
|
||
|
||
const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
|
||
if (profileResponse.ok) {
|
||
const profileData = await profileResponse.json();
|
||
|
||
// Determine correct web URL based on avatar source
|
||
let webUrl = config.webUrl; // Default from handle-based detection
|
||
if (profileData.avatar && profileData.avatar.includes('cdn.bsky.app')) {
|
||
webUrl = 'https://bsky.app'; // Override to Bluesky if avatar is from Bluesky
|
||
}
|
||
|
||
return {
|
||
...record,
|
||
value: {
|
||
...record.value,
|
||
author: {
|
||
...record.value.author,
|
||
avatar: profileData.avatar,
|
||
displayName: profileData.displayName || handle,
|
||
_pdsEndpoint: `https://${pds}`, // Store PDS info for later use
|
||
_webUrl: webUrl, // Store corrected web URL for profile links
|
||
}
|
||
}
|
||
};
|
||
} else {
|
||
// If profile fetch fails, still add PDS info for links
|
||
return {
|
||
...record,
|
||
value: {
|
||
...record.value,
|
||
author: {
|
||
...record.value.author,
|
||
_pdsEndpoint: `https://${pds}`,
|
||
_webUrl: config.webUrl,
|
||
}
|
||
}
|
||
};
|
||
}
|
||
} catch (err) {
|
||
// Ignore enhancement errors, use existing data
|
||
}
|
||
}
|
||
return record;
|
||
})
|
||
);
|
||
|
||
setComments(enhancedComments);
|
||
} catch (err) {
|
||
// Ignore load errors
|
||
setComments([]);
|
||
}
|
||
};
|
||
|
||
// JSONからユーザーリストを取得
|
||
const loadUsersFromRecord = async () => {
|
||
try {
|
||
// 管理者のユーザーリストを取得 using proper PDS detection
|
||
const currentAdminDid = adminDid || appConfig.adminDid;
|
||
|
||
// Use per-user PDS detection for admin's records
|
||
let adminPdsEndpoint;
|
||
try {
|
||
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
|
||
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
||
adminPdsEndpoint = config.pdsApi;
|
||
} catch {
|
||
adminPdsEndpoint = 'https://bsky.social'; // Fallback
|
||
}
|
||
|
||
const userCollectionUrl = `${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`;
|
||
|
||
const response = await fetch(userCollectionUrl);
|
||
|
||
|
||
if (!response.ok) {
|
||
return getDefaultUsers();
|
||
}
|
||
|
||
const data = await response.json();
|
||
const userRecords = data.records || [];
|
||
|
||
if (userRecords.length === 0) {
|
||
const defaultUsers = getDefaultUsers();
|
||
return defaultUsers;
|
||
}
|
||
|
||
// レコードからユーザーリストを構築し、プレースホルダーDIDを実際のDIDに解決
|
||
const allUsers = [];
|
||
for (const record of userRecords) {
|
||
if (record.value.users) {
|
||
// プレースホルダーDIDを実際のDIDに解決
|
||
const resolvedUsers = await Promise.all(
|
||
record.value.users.map(async (user) => {
|
||
if (user.did && user.did.includes('-placeholder')) {
|
||
// Resolving placeholder DID using proper PDS detection
|
||
try {
|
||
const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(user.handle));
|
||
if (profile && profile.did) {
|
||
// Resolved DID
|
||
return {
|
||
...user,
|
||
did: profile.did
|
||
};
|
||
}
|
||
} catch (err) {
|
||
// Failed to resolve DID
|
||
}
|
||
}
|
||
return user;
|
||
})
|
||
);
|
||
allUsers.push(...resolvedUsers);
|
||
}
|
||
}
|
||
|
||
return allUsers;
|
||
} catch (err) {
|
||
// Failed to load users from records, using defaults
|
||
return getDefaultUsers();
|
||
}
|
||
};
|
||
|
||
// ユーザーリスト一覧を読み込み
|
||
const loadUserListRecords = async () => {
|
||
try {
|
||
// Loading user list records using proper PDS detection
|
||
const currentAdminDid = adminDid || appConfig.adminDid;
|
||
|
||
// Use per-user PDS detection for admin's records
|
||
let adminPdsEndpoint;
|
||
try {
|
||
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
|
||
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
||
adminPdsEndpoint = config.pdsApi;
|
||
} catch {
|
||
adminPdsEndpoint = 'https://bsky.social'; // Fallback
|
||
}
|
||
|
||
const response = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`);
|
||
|
||
if (!response.ok) {
|
||
// Failed to fetch user list records
|
||
setUserListRecords([]);
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
const records = data.records || [];
|
||
|
||
// 新しい順にソート
|
||
const sortedRecords = records.sort((a, b) =>
|
||
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
|
||
);
|
||
|
||
// Loaded user list records
|
||
setUserListRecords(sortedRecords);
|
||
} catch (err) {
|
||
// Failed to load user list records
|
||
setUserListRecords([]);
|
||
}
|
||
};
|
||
|
||
const getDefaultUsers = () => {
|
||
const currentAdminDid = adminDid || appConfig.adminDid;
|
||
const defaultUsers = [
|
||
// Default admin user
|
||
{ did: currentAdminDid, handle: appConfig.adminHandle, pds: 'https://syu.is' },
|
||
];
|
||
|
||
// 現在ログインしているユーザーも追加(重複チェック)
|
||
if (user && user.did && user.handle && !defaultUsers.find(u => u.did === user.did)) {
|
||
// Detect PDS based on handle
|
||
const userPds = user.handle.endsWith('.syu.is') ? 'https://syu.is' :
|
||
user.handle.endsWith('.syui.ai') ? 'https://syu.is' :
|
||
'https://bsky.social';
|
||
|
||
defaultUsers.push({
|
||
did: user.did,
|
||
handle: user.handle,
|
||
pds: userPds
|
||
});
|
||
}
|
||
|
||
return defaultUsers;
|
||
};
|
||
|
||
// 新しい関数: 全ユーザーからコメントを収集
|
||
const loadAllComments = async (pageUrl?: string) => {
|
||
try {
|
||
|
||
// ユーザーリストを動的に取得
|
||
const knownUsers = await loadUsersFromRecord();
|
||
|
||
const allComments = [];
|
||
|
||
// 各ユーザーからコメントを収集
|
||
for (const user of knownUsers) {
|
||
try {
|
||
|
||
// Use per-user PDS detection for repo operations
|
||
let pdsEndpoint;
|
||
try {
|
||
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(user.did));
|
||
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
||
pdsEndpoint = config.pdsApi;
|
||
} catch {
|
||
// Fallback to user.pds if PDS detection fails
|
||
pdsEndpoint = user.pds;
|
||
}
|
||
|
||
const collections = getCollectionNames(appConfig.collections.base);
|
||
const response = await fetch(`${pdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(user.did)}&collection=${encodeURIComponent(collections.comment)}&limit=100`);
|
||
|
||
if (!response.ok) {
|
||
continue;
|
||
}
|
||
|
||
const data = await response.json();
|
||
const userRecords = data.records || [];
|
||
|
||
// Flatten comments from new array format
|
||
const userComments = [];
|
||
for (const record of userRecords) {
|
||
if (record.value.comments && Array.isArray(record.value.comments)) {
|
||
// New format: array of comments
|
||
for (const comment of record.value.comments) {
|
||
userComments.push({
|
||
...record,
|
||
value: comment,
|
||
originalRecord: record // Keep reference to original record
|
||
});
|
||
}
|
||
} else if (record.value.text) {
|
||
// Old format: single comment
|
||
userComments.push(record);
|
||
}
|
||
}
|
||
|
||
|
||
// ページpathでフィルタリング(指定された場合)
|
||
const filteredComments = pageUrl && appConfig.rkey
|
||
? userComments.filter(record => {
|
||
try {
|
||
// Compare rkey only (last part of path)
|
||
const recordRkey = record.value.url ? new URL(record.value.url).pathname.split('/').pop() : '';
|
||
return recordRkey === appConfig.rkey;
|
||
} catch (err) {
|
||
return false;
|
||
}
|
||
})
|
||
: userComments;
|
||
|
||
allComments.push(...filteredComments);
|
||
} catch (err) {
|
||
}
|
||
}
|
||
|
||
// 時間順にソート(新しい順)
|
||
const sortedComments = allComments.sort((a, b) =>
|
||
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
|
||
);
|
||
|
||
// プロフィール情報で拡張(認証なしでも取得可能)
|
||
const enhancedComments = await Promise.all(
|
||
sortedComments.map(async (record) => {
|
||
if (!record.value.author?.avatar && record.value.author?.handle) {
|
||
try {
|
||
// Use per-user PDS detection for profile fetching
|
||
const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(record.value.author.handle));
|
||
|
||
if (profile) {
|
||
// Determine network config based on profile data
|
||
let webUrl = 'https://bsky.app'; // Default to Bluesky
|
||
if (profile.avatar && profile.avatar.includes('cdn.bsky.app')) {
|
||
webUrl = 'https://bsky.app';
|
||
} else if (profile.avatar && profile.avatar.includes('bsky.syu.is')) {
|
||
webUrl = 'https://web.syu.is';
|
||
}
|
||
|
||
return {
|
||
...record,
|
||
value: {
|
||
...record.value,
|
||
author: {
|
||
...record.value.author,
|
||
avatar: profile.avatar,
|
||
displayName: profile.displayName || record.value.author.handle,
|
||
_webUrl: webUrl, // Store network config for profile URL generation
|
||
}
|
||
}
|
||
};
|
||
}
|
||
} catch (err) {
|
||
// Ignore enhancement errors
|
||
}
|
||
}
|
||
return record;
|
||
})
|
||
);
|
||
|
||
|
||
// デバッグ情報を追加
|
||
|
||
setComments(enhancedComments);
|
||
|
||
// キャッシュに保存(5分間有効)
|
||
if (pageUrl) {
|
||
const cacheKey = 'cached_comments_' + new URL(pageUrl).pathname;
|
||
const cacheData = {
|
||
comments: enhancedComments,
|
||
timestamp: Date.now()
|
||
};
|
||
localStorage.setItem(cacheKey, JSON.stringify(cacheData));
|
||
}
|
||
} catch (err) {
|
||
setComments([]);
|
||
}
|
||
};
|
||
|
||
|
||
const handlePostComment = async () => {
|
||
if (!user || !commentText.trim()) {
|
||
return;
|
||
}
|
||
|
||
setIsPosting(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const agent = atprotoOAuthService.getAgent();
|
||
if (!agent) {
|
||
throw new Error('No agent available');
|
||
}
|
||
|
||
// Create comment record with post-specific rkey
|
||
const now = new Date();
|
||
// Use post rkey if on post page, otherwise use timestamp-based rkey
|
||
const rkey = appConfig.rkey || now.toISOString().replace(/[:.]/g, '-');
|
||
|
||
const newComment = {
|
||
text: commentText,
|
||
url: window.location.href,
|
||
createdAt: now.toISOString(),
|
||
author: {
|
||
did: user.did,
|
||
handle: user.handle,
|
||
avatar: user.avatar,
|
||
displayName: user.displayName || user.handle,
|
||
},
|
||
};
|
||
|
||
// Check if record with this rkey already exists
|
||
let existingComments = [];
|
||
try {
|
||
const existingResponse = await agent.api.com.atproto.repo.getRecord({
|
||
repo: user.did,
|
||
collection: getCollectionNames(appConfig.collections.base).comment,
|
||
rkey: rkey,
|
||
});
|
||
|
||
// Handle both old single comment format and new array format
|
||
if (existingResponse.data.value.comments) {
|
||
// New format: array of comments
|
||
existingComments = existingResponse.data.value.comments;
|
||
} else if (existingResponse.data.value.text) {
|
||
// Old format: single comment, convert to array
|
||
existingComments = [{
|
||
text: existingResponse.data.value.text,
|
||
url: existingResponse.data.value.url,
|
||
createdAt: existingResponse.data.value.createdAt,
|
||
author: existingResponse.data.value.author,
|
||
}];
|
||
}
|
||
} catch (err) {
|
||
// Record doesn't exist yet, that's fine
|
||
}
|
||
|
||
// Add new comment to the array
|
||
existingComments.push(newComment);
|
||
|
||
// Create the record with comments array
|
||
const record = {
|
||
$type: getCollectionNames(appConfig.collections.base).comment,
|
||
comments: existingComments,
|
||
url: window.location.href,
|
||
createdAt: now.toISOString(), // Latest update time
|
||
};
|
||
|
||
// Post to ATProto with rkey
|
||
const response = await agent.api.com.atproto.repo.putRecord({
|
||
repo: user.did,
|
||
collection: getCollectionNames(appConfig.collections.base).comment,
|
||
rkey: rkey,
|
||
record: record,
|
||
});
|
||
|
||
|
||
// Clear form and reload all comments
|
||
setCommentText('');
|
||
await loadAllComments(window.location.href);
|
||
} catch (err: any) {
|
||
setError('コメントの投稿に失敗しました: ' + err.message);
|
||
} finally {
|
||
setIsPosting(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteComment = async (uri: string) => {
|
||
if (!user) {
|
||
alert('ログインが必要です');
|
||
return;
|
||
}
|
||
|
||
if (!confirm('このコメントを削除しますか?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const agent = atprotoOAuthService.getAgent();
|
||
if (!agent) {
|
||
throw new Error('No agent available');
|
||
}
|
||
|
||
// Extract rkey from URI: at://did:plc:xxx/ai.syui.log/rkey
|
||
const uriParts = uri.split('/');
|
||
const rkey = uriParts[uriParts.length - 1];
|
||
|
||
|
||
// Delete the record
|
||
await agent.api.com.atproto.repo.deleteRecord({
|
||
repo: user.did,
|
||
collection: getCollectionNames(appConfig.collections.base).comment,
|
||
rkey: rkey,
|
||
});
|
||
|
||
|
||
// Reload all comments to reflect the deletion
|
||
await loadAllComments(window.location.href);
|
||
|
||
} catch (err: any) {
|
||
alert('コメントの削除に失敗しました: ' + err.message);
|
||
}
|
||
};
|
||
|
||
const handleLogout = async () => {
|
||
// Logout from both services
|
||
await authService.logout();
|
||
atprotoOAuthService.logout();
|
||
setUser(null);
|
||
setComments([]);
|
||
};
|
||
|
||
// 管理者チェック
|
||
const isAdmin = (user: User | null): boolean => {
|
||
return user?.did === adminDid || user?.did === appConfig.adminDid;
|
||
};
|
||
|
||
// ユーザーリスト投稿
|
||
const handlePostUserList = async () => {
|
||
if (!user || !userListInput.trim()) {
|
||
return;
|
||
}
|
||
|
||
if (!isAdmin(user)) {
|
||
alert('管理者のみがユーザーリストを更新できます');
|
||
return;
|
||
}
|
||
|
||
setIsPostingUserList(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const agent = atprotoOAuthService.getAgent();
|
||
if (!agent) {
|
||
throw new Error('No agent available');
|
||
}
|
||
|
||
// ユーザーリストをパース
|
||
const userHandles = userListInput
|
||
.split(',')
|
||
.map(handle => handle.trim())
|
||
.filter(handle => handle.length > 0);
|
||
|
||
// ユーザーリストを各PDS用に分類し、実際のDIDを解決
|
||
const users = await Promise.all(userHandles.map(async (handle) => {
|
||
const pds = handle.endsWith('.syu.is') ? 'https://syu.is' : 'https://bsky.social';
|
||
|
||
// 実際のDIDを解決
|
||
let resolvedDid = `did:plc:${handle.replace(/\./g, '-')}-placeholder`; // フォールバック
|
||
|
||
try {
|
||
// Public APIでプロフィールを取得してDIDを解決
|
||
const profileResponse = await fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
|
||
if (profileResponse.ok) {
|
||
const profileData = await profileResponse.json();
|
||
if (profileData.did) {
|
||
resolvedDid = profileData.did;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
}
|
||
|
||
return {
|
||
handle: handle,
|
||
pds: pds,
|
||
did: resolvedDid
|
||
};
|
||
}));
|
||
|
||
// Create user list record with ISO datetime rkey
|
||
const now = new Date();
|
||
const rkey = now.toISOString().replace(/[:.]/g, '-');
|
||
|
||
const record = {
|
||
$type: getCollectionNames(appConfig.collections.base).user,
|
||
users: users,
|
||
createdAt: now.toISOString(),
|
||
updatedBy: {
|
||
did: user.did,
|
||
handle: user.handle,
|
||
},
|
||
};
|
||
|
||
// Post to ATProto with rkey
|
||
const response = await agent.api.com.atproto.repo.putRecord({
|
||
repo: user.did,
|
||
collection: getCollectionNames(appConfig.collections.base).user,
|
||
rkey: rkey,
|
||
record: record,
|
||
});
|
||
|
||
|
||
// Clear form and reload user list records
|
||
setUserListInput('');
|
||
loadUserListRecords();
|
||
alert('ユーザーリストが更新されました');
|
||
} catch (err: any) {
|
||
setError('ユーザーリストの投稿に失敗しました: ' + err.message);
|
||
} finally {
|
||
setIsPostingUserList(false);
|
||
}
|
||
};
|
||
|
||
// ユーザーリスト削除
|
||
const handleDeleteUserList = async (uri: string) => {
|
||
if (!user || !isAdmin(user)) {
|
||
alert('管理者のみがユーザーリストを削除できます');
|
||
return;
|
||
}
|
||
|
||
if (!confirm('このユーザーリストを削除しますか?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const agent = atprotoOAuthService.getAgent();
|
||
if (!agent) {
|
||
throw new Error('No agent available');
|
||
}
|
||
|
||
// Extract rkey from URI
|
||
const uriParts = uri.split('/');
|
||
const rkey = uriParts[uriParts.length - 1];
|
||
|
||
|
||
// Delete the record
|
||
await agent.api.com.atproto.repo.deleteRecord({
|
||
repo: user.did,
|
||
collection: getCollectionNames(appConfig.collections.base).user,
|
||
rkey: rkey,
|
||
});
|
||
|
||
loadUserListRecords();
|
||
alert('ユーザーリストが削除されました');
|
||
|
||
} catch (err: any) {
|
||
alert('ユーザーリストの削除に失敗しました: ' + err.message);
|
||
}
|
||
};
|
||
|
||
// JSON表示のトグル
|
||
const toggleJsonDisplay = (uri: string) => {
|
||
if (showJsonFor === uri) {
|
||
setShowJsonFor(null);
|
||
} else {
|
||
setShowJsonFor(uri);
|
||
}
|
||
};
|
||
|
||
// OAuth実行関数
|
||
const executeOAuth = async () => {
|
||
if (!handleInput.trim()) {
|
||
alert('Please enter your Bluesky handle first');
|
||
return;
|
||
}
|
||
try {
|
||
await atprotoOAuthService.initiateOAuthFlow(handleInput);
|
||
} catch (err) {
|
||
alert('認証の開始に失敗しました。再度お試しください。');
|
||
}
|
||
};
|
||
|
||
// ユーザーハンドルからプロフィールURLを生成
|
||
const generateProfileUrl = (author: any): string => {
|
||
// Check if this is admin/AI handle that should use configured PDS
|
||
if (author.handle === appConfig.adminHandle || author.handle === appConfig.aiHandle) {
|
||
const config = getNetworkConfig(appConfig.atprotoPds);
|
||
return `${config.webUrl}/profile/${author.did}`;
|
||
}
|
||
|
||
// For ai.syu.is handle, also use configured PDS
|
||
if (author.handle === 'ai.syu.is') {
|
||
const config = getNetworkConfig(appConfig.atprotoPds);
|
||
return `${config.webUrl}/profile/${author.did}`;
|
||
}
|
||
|
||
// For other users, detect network based on avatar URL or stored network info
|
||
if (author.avatar && author.avatar.includes('cdn.bsky.app')) {
|
||
// User has Bluesky avatar, use Bluesky web interface
|
||
return `https://bsky.app/profile/${author.did}`;
|
||
}
|
||
|
||
// Check if we have stored network config from profile fetching
|
||
if (author._webUrl) {
|
||
return `${author._webUrl}/profile/${author.did}`;
|
||
}
|
||
|
||
// Fallback: Get PDS from handle for other users
|
||
const pds = detectPdsFromHandle(author.handle);
|
||
const config = getNetworkConfig(pds);
|
||
|
||
// Use DID for profile URL
|
||
return `${config.webUrl}/profile/${author.did}`;
|
||
};
|
||
|
||
// Rkey-based comment filtering
|
||
// If on post page (/posts/xxx.html), only show comments with rkey=xxx
|
||
const shouldShowComment = (record: any): boolean => {
|
||
// If not on a post page, show all comments
|
||
if (!appConfig.rkey) {
|
||
return true;
|
||
}
|
||
|
||
// Extract rkey from comment URI: at://did:plc:xxx/collection/rkey
|
||
// Handle both original records and flattened records from new array format
|
||
const uri = record.uri || record.originalRecord?.uri;
|
||
if (!uri) {
|
||
return false;
|
||
}
|
||
|
||
const uriParts = uri.split('/');
|
||
const commentRkey = uriParts[uriParts.length - 1];
|
||
|
||
// Show comment only if rkey matches current post
|
||
return commentRkey === appConfig.rkey;
|
||
};
|
||
|
||
// OAuth callback is now handled by React Router in main.tsx
|
||
|
||
// Unified rendering function for AI content
|
||
const renderAIContent = (record: any, index: number, className: string) => {
|
||
// Handle both new format (record.value.$type) and old format compatibility
|
||
const value = record.value;
|
||
const isNewFormat = value.$type && value.post && value.author;
|
||
|
||
// Extract content based on format
|
||
const contentText = isNewFormat ? value.text : (value.content || value.body || '');
|
||
// Use the author from the record if available, otherwise fall back to AI profile
|
||
const authorInfo = value.author || aiProfile;
|
||
|
||
const postInfo = isNewFormat ? value.post : null;
|
||
const contentType = value.type || 'unknown';
|
||
const createdAt = value.createdAt || value.generated_at || '';
|
||
|
||
return (
|
||
<div key={index} className={className}>
|
||
<div className="comment-header">
|
||
<img
|
||
src={authorInfo?.avatar || generatePlaceholderAvatar('AI')}
|
||
alt="AI Avatar"
|
||
className="comment-avatar"
|
||
/>
|
||
<div className="comment-author-info">
|
||
<span className="comment-author">
|
||
{authorInfo?.displayName || 'ai'}
|
||
</span>
|
||
<a
|
||
href={generateProfileUrl({
|
||
handle: authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle,
|
||
did: authorInfo?.did || aiProfile?.did || appConfig.aiDid
|
||
})}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="comment-handle"
|
||
>
|
||
@{authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle}
|
||
</a>
|
||
</div>
|
||
<span className="comment-date">
|
||
{new Date(createdAt).toLocaleString()}
|
||
</span>
|
||
<div className="comment-actions">
|
||
<button
|
||
onClick={() => toggleJsonDisplay(record.uri)}
|
||
className="json-button"
|
||
title="Show/Hide JSON"
|
||
>
|
||
{showJsonFor === record.uri ? 'Hide' : 'JSON'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="comment-meta">
|
||
{(postInfo?.url || value.post_url) && (
|
||
<small>
|
||
<a href={postInfo?.url || value.post_url}>
|
||
{postInfo?.url || value.post_url}
|
||
</a>
|
||
</small>
|
||
)}
|
||
</div>
|
||
|
||
{/* JSON Display */}
|
||
{showJsonFor === record.uri && (
|
||
<div className="json-display">
|
||
<h5>JSON Record:</h5>
|
||
<pre className="json-content">
|
||
{JSON.stringify(record, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
|
||
<div className="comment-content">
|
||
{contentText?.split('\n').map((line: string, index: number) => (
|
||
<React.Fragment key={index}>
|
||
{line}
|
||
{index < contentText.split('\n').length - 1 && <br />}
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const getTypeLabel = (collectionType: string, contentType: string) => {
|
||
if (!collectionType) return contentType;
|
||
|
||
const collections = getCollectionNames(appConfig.collections.base);
|
||
|
||
if (collectionType === collections.chat) {
|
||
return contentType === 'question' ? '質問' : '回答';
|
||
}
|
||
if (collectionType === collections.chatLang) {
|
||
return `翻訳: ${contentType.toUpperCase()}`;
|
||
}
|
||
if (collectionType === collections.chatComment) {
|
||
return `AI ${contentType}`;
|
||
}
|
||
return contentType;
|
||
};
|
||
|
||
return (
|
||
<div className="app">
|
||
|
||
<main className="app-main">
|
||
<section className="comment-section">
|
||
{/* Authentication Section */}
|
||
{!user ? (
|
||
<div className="auth-section search-bar-layout">
|
||
<input
|
||
type="text"
|
||
id="handle-input"
|
||
name="handle"
|
||
placeholder="user.bsky.social"
|
||
className="handle-input"
|
||
value={handleInput}
|
||
onChange={(e) => setHandleInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
executeOAuth();
|
||
}
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={executeOAuth}
|
||
className="atproto-button"
|
||
>
|
||
<i class="fab fa-bluesky"></i>
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="user-section">
|
||
<div className="user-info">
|
||
<div className="user-profile">
|
||
<img
|
||
src={user.avatar || generatePlaceholderAvatar(user.handle)}
|
||
alt="User Avatar"
|
||
className="user-avatar"
|
||
/>
|
||
<div className="user-details">
|
||
<h3>{user.displayName || user.handle}</h3>
|
||
<p className="user-handle">@{user.handle}</p>
|
||
<p className="user-did">{user.did}</p>
|
||
</div>
|
||
</div>
|
||
<button onClick={handleLogout} className="logout-button">
|
||
Logout
|
||
</button>
|
||
</div>
|
||
|
||
{/* Admin Section - User Management */}
|
||
{isAdmin(user) && (
|
||
<div className="admin-section">
|
||
<h3>管理者機能 - ユーザーリスト管理</h3>
|
||
|
||
{/* User List Form */}
|
||
<div className="user-list-form">
|
||
<textarea
|
||
id="user-list-input"
|
||
name="userList"
|
||
value={userListInput}
|
||
onChange={(e) => setUserListInput(e.target.value)}
|
||
placeholder="ユーザーハンドルをカンマ区切りで入力 例: syui.ai, ai.syui.ai, user.bsky.social"
|
||
rows={3}
|
||
disabled={isPostingUserList}
|
||
/>
|
||
<div className="form-actions">
|
||
<span className="admin-hint">カンマ区切りでハンドルを入力してください</span>
|
||
<button
|
||
onClick={handlePostUserList}
|
||
disabled={isPostingUserList || !userListInput.trim()}
|
||
className="post-button"
|
||
>
|
||
{isPostingUserList ? 'Posting...' : 'Post User List'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* User List Records */}
|
||
<div className="user-list-records">
|
||
<h4>ユーザーリスト一覧 ({userListRecords.length}件)</h4>
|
||
{userListRecords.length === 0 ? (
|
||
<p className="no-user-lists">ユーザーリストが見つかりません</p>
|
||
) : (
|
||
userListRecords.map((record, index) => (
|
||
<div key={index} className="user-list-item">
|
||
<div className="user-list-header">
|
||
<span className="user-list-date">
|
||
{new Date(record.value.createdAt).toLocaleString()}
|
||
</span>
|
||
<div className="user-list-actions">
|
||
<button
|
||
onClick={() => toggleJsonDisplay(record.uri)}
|
||
className="json-button"
|
||
title="Show/Hide JSON"
|
||
>
|
||
{showJsonFor === record.uri ? 'Hide JSON' : 'Show JSON'}
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteUserList(record.uri)}
|
||
className="delete-button"
|
||
title="Delete user list"
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="user-list-content">
|
||
<div className="user-handles">
|
||
{record.value.users && record.value.users.map((user, userIndex) => (
|
||
<span key={userIndex} className="user-handle-tag">
|
||
{user.handle}
|
||
<small className="pds-info">({new URL(user.pds).hostname})</small>
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="user-list-meta">
|
||
<small>URI: {record.uri}</small>
|
||
<br />
|
||
<small>Updated by: {record.value.updatedBy?.handle || 'unknown'}</small>
|
||
</div>
|
||
|
||
{/* JSON Display */}
|
||
{showJsonFor === record.uri && (
|
||
<div className="json-display">
|
||
<h5>JSON Record:</h5>
|
||
<pre className="json-content">
|
||
{JSON.stringify(record, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab Navigation */}
|
||
<div className="tab-navigation">
|
||
<button
|
||
className={`tab-button ${activeTab === 'comments' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('comments')}
|
||
>
|
||
comment ({comments.filter(shouldShowComment).length})
|
||
</button>
|
||
<button
|
||
className={`tab-button ${activeTab === 'ai-chat' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('ai-chat')}
|
||
>
|
||
chat ({aiChatHistory.length})
|
||
</button>
|
||
<button
|
||
className={`tab-button ${activeTab === 'lang-en' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('lang-en')}
|
||
>
|
||
en ({langEnRecords.length})
|
||
</button>
|
||
<button
|
||
className={`tab-button ${activeTab === 'ai-comment' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('ai-comment')}
|
||
>
|
||
feedback ({aiCommentRecords.length})
|
||
</button>
|
||
</div>
|
||
|
||
{/* Comments List */}
|
||
{activeTab === 'comments' && (
|
||
<div className="comments-list">
|
||
{comments.filter(shouldShowComment).length === 0 ? (
|
||
<p className="no-comments">
|
||
{appConfig.rkey ? `No comments for this post yet` : `No comments yet`}
|
||
</p>
|
||
) : (
|
||
comments.filter(shouldShowComment).map((record, index) => (
|
||
<div key={index} className="comment-item">
|
||
<div className="comment-header">
|
||
<img
|
||
src={record.value.author?.avatar || generatePlaceholderAvatar(record.value.author?.handle || 'unknown')}
|
||
alt="User Avatar"
|
||
className="comment-avatar"
|
||
/>
|
||
<div className="comment-author-info">
|
||
<span className="comment-author">
|
||
{record.value.author?.displayName || record.value.author?.handle || 'unknown'}
|
||
</span>
|
||
<a
|
||
href={generateProfileUrl(record.value.author)}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="comment-handle"
|
||
>
|
||
@{record.value.author?.handle || 'unknown'}
|
||
</a>
|
||
</div>
|
||
<span className="comment-date">
|
||
{new Date(record.value.createdAt).toLocaleString()}
|
||
</span>
|
||
<div className="comment-actions">
|
||
<button
|
||
onClick={() => toggleJsonDisplay(record.uri)}
|
||
className="json-button"
|
||
title="Show/Hide JSON"
|
||
>
|
||
{showJsonFor === record.uri ? 'Hide' : 'JSON'}
|
||
</button>
|
||
{/* Show delete button only for current user's comments */}
|
||
{user && record.value.author?.did === user.did && (
|
||
<button
|
||
onClick={() => handleDeleteComment(record.uri)}
|
||
className="delete-button"
|
||
title="Delete comment"
|
||
>
|
||
Delete
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="comment-meta">
|
||
{record.value.url && (
|
||
<small><a href={record.value.url}>{record.value.url}</a></small>
|
||
)}
|
||
</div>
|
||
|
||
{/* JSON Display */}
|
||
{showJsonFor === record.uri && (
|
||
<div className="json-display">
|
||
<h5>JSON Record:</h5>
|
||
<pre className="json-content">
|
||
{JSON.stringify(record, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
|
||
<div className="comment-content">
|
||
{record.value.text?.split('\n').map((line: string, index: number) => (
|
||
<React.Fragment key={index}>
|
||
{line}
|
||
{index < record.value.text.split('\n').length - 1 && <br />}
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* AI Chat History List */}
|
||
{activeTab === 'ai-chat' && (
|
||
<div className="comments-list">
|
||
{aiChatHistory.length === 0 ? (
|
||
<p className="no-chat">No AI conversations yet. Start chatting with Ask AI!</p>
|
||
) : (
|
||
aiChatHistory.map((record, index) =>
|
||
renderAIContent(record, index, 'comment-item')
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Lang: EN List */}
|
||
{activeTab === 'lang-en' && (
|
||
<div className="comments-list">
|
||
{langEnRecords.length === 0 ? (
|
||
<p className="no-content">No EN translations yet</p>
|
||
) : (
|
||
langEnRecords.map((record, index) =>
|
||
renderAIContent(record, index, 'lang-item')
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* AI Comment List */}
|
||
{activeTab === 'ai-comment' && (
|
||
<div className="comments-list">
|
||
{aiCommentRecords.length === 0 ? (
|
||
<p className="no-content">No AI comments yet</p>
|
||
) : (
|
||
aiCommentRecords.map((record, index) =>
|
||
renderAIContent(record, index, 'comment-item')
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Comment Form - Only show on post pages when Comments tab is active */}
|
||
{user && appConfig.rkey && activeTab === 'comments' && (
|
||
<div className="comment-form">
|
||
<textarea
|
||
id="comment-text"
|
||
name="commentText"
|
||
value={commentText}
|
||
onChange={(e) => setCommentText(e.target.value)}
|
||
placeholder="Write your comment..."
|
||
rows={4}
|
||
disabled={isPosting}
|
||
/>
|
||
<div className="form-actions">
|
||
<span className="char-count">{commentText.length} / 1000</span>
|
||
<button
|
||
onClick={handlePostComment}
|
||
disabled={isPosting || !commentText.trim() || commentText.length > 1000}
|
||
className="post-button"
|
||
>
|
||
{isPosting ? 'Posting...' : 'Post Comment'}
|
||
</button>
|
||
</div>
|
||
{error && <p className="error">{error}</p>}
|
||
</div>
|
||
)}
|
||
|
||
</section>
|
||
</main>
|
||
|
||
{/* AI Chat Component - handles all AI functionality */}
|
||
<AIChat user={user} isEnabled={appConfig.aiEnabled && appConfig.aiAskAi} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|