Previously, all chat messages in the 'chat' tab were showing with AI account info regardless of the actual author. This fix ensures the author information from the record is used, only falling back to AI profile when no author is present. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1637 lines
59 KiB
TypeScript
1637 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;
|
||
|
||
// Check if handle is allowed
|
||
if (appConfig.allowedHandles.length > 0 && !appConfig.allowedHandles.includes(handle)) {
|
||
// Handle not in allowed list
|
||
setError(`Access denied: ${handle} is not authorized for this application.`);
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
// 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;
|