fix oauth package name
This commit is contained in:
@@ -1,21 +0,0 @@
|
||||
// Cloudflare Access対応版の例
|
||||
const response = await fetch(`${aiConfig.host}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Cloudflare Access Service Token
|
||||
'CF-Access-Client-Id': import.meta.env.VITE_CF_ACCESS_CLIENT_ID,
|
||||
'CF-Access-Client-Secret': import.meta.env.VITE_CF_ACCESS_CLIENT_SECRET,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: aiConfig.model,
|
||||
prompt: prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.9,
|
||||
top_p: 0.9,
|
||||
num_predict: 200,
|
||||
repeat_penalty: 1.1,
|
||||
}
|
||||
}),
|
||||
});
|
@@ -1,271 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { User } from '../services/auth';
|
||||
import { atprotoOAuthService } from '../services/atproto-oauth';
|
||||
import { appConfig, getCollectionNames } from '../config/app';
|
||||
|
||||
interface AIChatProps {
|
||||
user: User | null;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
|
||||
const [chatHistory, setChatHistory] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [aiProfile, setAiProfile] = useState<any>(null);
|
||||
|
||||
// Get AI settings from appConfig (unified configuration)
|
||||
const aiConfig = {
|
||||
enabled: appConfig.aiEnabled,
|
||||
askAi: appConfig.aiAskAi,
|
||||
provider: appConfig.aiProvider,
|
||||
model: appConfig.aiModel,
|
||||
host: appConfig.aiHost,
|
||||
systemPrompt: appConfig.aiSystemPrompt,
|
||||
aiDid: appConfig.aiDid,
|
||||
bskyPublicApi: appConfig.bskyPublicApi,
|
||||
};
|
||||
|
||||
// Fetch AI profile on load
|
||||
useEffect(() => {
|
||||
const fetchAIProfile = async () => {
|
||||
if (!aiConfig.aiDid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try with agent first
|
||||
const agent = atprotoOAuthService.getAgent();
|
||||
if (agent) {
|
||||
const profile = await agent.getProfile({ actor: aiConfig.aiDid });
|
||||
const profileData = {
|
||||
did: aiConfig.aiDid,
|
||||
handle: profile.data.handle,
|
||||
displayName: profile.data.displayName,
|
||||
avatar: profile.data.avatar,
|
||||
description: profile.data.description
|
||||
};
|
||||
setAiProfile(profileData);
|
||||
|
||||
// Dispatch event to update Ask AI button
|
||||
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to public API
|
||||
const response = await fetch(`${aiConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(aiConfig.aiDid)}`);
|
||||
if (response.ok) {
|
||||
const profileData = await response.json();
|
||||
const profile = {
|
||||
did: aiConfig.aiDid,
|
||||
handle: profileData.handle,
|
||||
displayName: profileData.displayName,
|
||||
avatar: profileData.avatar,
|
||||
description: profileData.description
|
||||
};
|
||||
setAiProfile(profile);
|
||||
|
||||
// Dispatch event to update Ask AI button
|
||||
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile }));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
setAiProfile(null);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAIProfile();
|
||||
}, [aiConfig.aiDid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled || !aiConfig.askAi) return;
|
||||
|
||||
// Listen for AI question posts from base.html
|
||||
const handleAIQuestion = async (event: any) => {
|
||||
if (!user || !event.detail || !event.detail.question || isProcessing || !aiProfile) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await postQuestionAndGenerateResponse(event.detail.question);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Add listener with a small delay to ensure it's ready
|
||||
setTimeout(() => {
|
||||
window.addEventListener('postAIQuestion', handleAIQuestion);
|
||||
|
||||
// Notify that AI is ready
|
||||
window.dispatchEvent(new CustomEvent('aiChatReady'));
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('postAIQuestion', handleAIQuestion);
|
||||
};
|
||||
}, [user, isEnabled, isProcessing, aiProfile]);
|
||||
|
||||
const postQuestionAndGenerateResponse = async (question: string) => {
|
||||
if (!user || !aiConfig.askAi || !aiProfile) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const agent = atprotoOAuthService.getAgent();
|
||||
if (!agent) throw new Error('No agent available');
|
||||
|
||||
// Get collection names
|
||||
const collections = getCollectionNames(appConfig.collections.base);
|
||||
|
||||
// 1. Post question to ATProto
|
||||
const now = new Date();
|
||||
const rkey = now.toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
// Extract post metadata from current page
|
||||
const currentUrl = window.location.href;
|
||||
const postSlug = currentUrl.match(/\/posts\/([^/]+)/)?.[1] || '';
|
||||
const postTitle = document.title.replace(' - syui.ai', '') || '';
|
||||
|
||||
const questionRecord = {
|
||||
$type: collections.chat,
|
||||
post: {
|
||||
url: currentUrl,
|
||||
slug: postSlug,
|
||||
title: postTitle,
|
||||
date: new Date().toISOString(),
|
||||
tags: [],
|
||||
language: "ja"
|
||||
},
|
||||
type: "question",
|
||||
text: question,
|
||||
author: {
|
||||
did: user.did,
|
||||
handle: user.handle,
|
||||
avatar: user.avatar,
|
||||
displayName: user.displayName || user.handle,
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
|
||||
await agent.api.com.atproto.repo.putRecord({
|
||||
repo: user.did,
|
||||
collection: collections.chat,
|
||||
rkey: rkey,
|
||||
record: questionRecord,
|
||||
});
|
||||
|
||||
// 2. Get chat history
|
||||
const chatRecords = await agent.api.com.atproto.repo.listRecords({
|
||||
repo: user.did,
|
||||
collection: collections.chat,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
let chatHistoryText = '';
|
||||
if (chatRecords.data.records) {
|
||||
chatHistoryText = chatRecords.data.records
|
||||
.map((r: any) => {
|
||||
if (r.value.type === 'question') {
|
||||
return `User: ${r.value.text}`;
|
||||
} else if (r.value.type === 'answer') {
|
||||
return `AI: ${r.value.text}`;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// 3. Generate AI response based on provider
|
||||
let aiAnswer = '';
|
||||
|
||||
// 3. Generate AI response using Ollama via proxy
|
||||
if (aiConfig.provider === 'ollama') {
|
||||
const prompt = `${aiConfig.systemPrompt}
|
||||
|
||||
Question: ${question}
|
||||
|
||||
Answer:`;
|
||||
|
||||
const response = await fetch(`${aiConfig.host}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'https://syui.ai',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: aiConfig.model,
|
||||
prompt: prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.9,
|
||||
top_p: 0.9,
|
||||
num_predict: 200, // Longer responses for better answers
|
||||
repeat_penalty: 1.1,
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('AI API request failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
aiAnswer = data.response;
|
||||
}
|
||||
|
||||
// 4. Immediately dispatch event to update UI
|
||||
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
|
||||
detail: {
|
||||
answer: aiAnswer,
|
||||
aiProfile: aiProfile,
|
||||
timestamp: now.toISOString()
|
||||
}
|
||||
}));
|
||||
|
||||
// 5. Save AI response in background
|
||||
const answerRkey = now.toISOString().replace(/[:.]/g, '-') + '-answer';
|
||||
|
||||
const answerRecord = {
|
||||
$type: collections.chat,
|
||||
post: {
|
||||
url: currentUrl,
|
||||
slug: postSlug,
|
||||
title: postTitle,
|
||||
date: new Date().toISOString(),
|
||||
tags: [],
|
||||
language: "ja"
|
||||
},
|
||||
type: "answer",
|
||||
text: aiAnswer,
|
||||
author: {
|
||||
did: aiProfile.did,
|
||||
handle: aiProfile.handle,
|
||||
displayName: aiProfile.displayName,
|
||||
avatar: aiProfile.avatar,
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
|
||||
// Save to ATProto asynchronously (don't wait for it)
|
||||
agent.api.com.atproto.repo.putRecord({
|
||||
repo: user.did,
|
||||
collection: collections.chat,
|
||||
rkey: answerRkey,
|
||||
record: answerRecord,
|
||||
}).catch(err => {
|
||||
// Silent fail for AI response saving
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
window.dispatchEvent(new CustomEvent('aiResponseError', {
|
||||
detail: { error: 'AI応答の生成に失敗しました' }
|
||||
}));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// This component doesn't render anything - it just handles the logic
|
||||
return null;
|
||||
};
|
@@ -1,79 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AtprotoAgent } from '@atproto/api';
|
||||
|
||||
interface AIProfile {
|
||||
did: string;
|
||||
handle: string;
|
||||
displayName?: string;
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface AIProfileProps {
|
||||
aiDid: string;
|
||||
}
|
||||
|
||||
export const AIProfile: React.FC<AIProfileProps> = ({ aiDid }) => {
|
||||
const [profile, setProfile] = useState<AIProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAIProfile = async () => {
|
||||
try {
|
||||
// Use public API to get profile information
|
||||
const agent = new AtprotoAgent({ service: 'https://bsky.social' });
|
||||
const response = await agent.getProfile({ actor: aiDid });
|
||||
|
||||
setProfile({
|
||||
did: response.data.did,
|
||||
handle: response.data.handle,
|
||||
displayName: response.data.displayName,
|
||||
avatar: response.data.avatar,
|
||||
description: response.data.description,
|
||||
});
|
||||
} catch (error) {
|
||||
// Failed to fetch AI profile
|
||||
// Fallback to basic info
|
||||
setProfile({
|
||||
did: aiDid,
|
||||
handle: 'ai-assistant',
|
||||
displayName: 'AI Assistant',
|
||||
description: 'AI assistant for this blog',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (aiDid) {
|
||||
fetchAIProfile();
|
||||
}
|
||||
}, [aiDid]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="ai-profile-loading">Loading AI profile...</div>;
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-profile">
|
||||
<div className="ai-avatar">
|
||||
{profile.avatar ? (
|
||||
<img src={profile.avatar} alt={profile.displayName || profile.handle} />
|
||||
) : (
|
||||
<div className="ai-avatar-placeholder">🤖</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ai-info">
|
||||
<div className="ai-name">{profile.displayName || profile.handle}</div>
|
||||
<div className="ai-handle">@{profile.handle}</div>
|
||||
{profile.description && (
|
||||
<div className="ai-description">{profile.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
399
oauth/src/components/AskAI.jsx
Normal file
399
oauth/src/components/AskAI.jsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useAskAI } from '../hooks/useAskAI.js'
|
||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
||||
|
||||
export default function AskAI({ adminData, user, agent, onClose }) {
|
||||
const { askQuestion, loading, error, chatHistory, clearChatHistory, loadChatHistory } = useAskAI(adminData, user, agent)
|
||||
const [question, setQuestion] = useState('')
|
||||
const [isComposing, setIsComposing] = useState(false)
|
||||
const chatEndRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
// チャット履歴を読み込み
|
||||
loadChatHistory()
|
||||
}, [loadChatHistory])
|
||||
|
||||
useEffect(() => {
|
||||
// 新しいメッセージが追加されたら一番下にスクロール
|
||||
if (chatEndRef.current) {
|
||||
chatEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [chatHistory])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!question.trim() || loading) return
|
||||
|
||||
try {
|
||||
await askQuestion(question)
|
||||
setQuestion('')
|
||||
} catch (err) {
|
||||
// エラーはuseAskAIで処理済み
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
|
||||
e.preventDefault()
|
||||
handleSubmit(e)
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
return new Date(timestamp).toLocaleString('ja-JP', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const renderMessage = (entry, index) => (
|
||||
<div key={entry.id || index} className="chat-message">
|
||||
{/* ユーザーの質問 */}
|
||||
<div className="user-message">
|
||||
<div className="message-header">
|
||||
<div className="avatar">
|
||||
{entry.user?.avatar ? (
|
||||
<img src={entry.user.avatar} alt={entry.user.displayName} className="profile-avatar" />
|
||||
) : (
|
||||
'👤'
|
||||
)}
|
||||
</div>
|
||||
<div className="user-info">
|
||||
<div className="display-name">{entry.user?.displayName || 'You'}</div>
|
||||
<div className="handle">@{entry.user?.handle || 'user'}</div>
|
||||
<div className="timestamp">{formatTimestamp(entry.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="message-content">{entry.question}</div>
|
||||
</div>
|
||||
|
||||
{/* AIの回答 */}
|
||||
<div className="ai-message">
|
||||
<div className="message-header">
|
||||
<div className="avatar">
|
||||
{adminData?.profile?.avatar ? (
|
||||
<img src={adminData.profile.avatar} alt={adminData.profile.displayName} className="profile-avatar" />
|
||||
) : (
|
||||
'🤖'
|
||||
)}
|
||||
</div>
|
||||
<div className="user-info">
|
||||
<div className="display-name">{adminData?.profile?.displayName || 'AI'}</div>
|
||||
<div className="handle">@{adminData?.profile?.handle || 'ai'}</div>
|
||||
<div className="timestamp">{formatTimestamp(entry.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="message-content">{entry.answer}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="ask-ai-container">
|
||||
<div className="ask-ai-header">
|
||||
<h3>Ask AI</h3>
|
||||
<div className="header-actions">
|
||||
<button onClick={clearChatHistory} className="clear-btn" title="履歴をクリア">
|
||||
🗑️
|
||||
</button>
|
||||
<button onClick={onClose} className="close-btn" title="閉じる">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-container">
|
||||
{chatHistory.length === 0 && !loading ? (
|
||||
<div className="welcome-message">
|
||||
<div className="ai-message">
|
||||
<div className="message-header">
|
||||
<div className="avatar">
|
||||
{adminData?.profile?.avatar ? (
|
||||
<img src={adminData.profile.avatar} alt={adminData.profile.displayName} className="profile-avatar" />
|
||||
) : (
|
||||
'🤖'
|
||||
)}
|
||||
</div>
|
||||
<div className="user-info">
|
||||
<div className="display-name">{adminData?.profile?.displayName || 'AI'}</div>
|
||||
<div className="handle">@{adminData?.profile?.handle || 'ai'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="message-content">
|
||||
こんにちは!このブログの内容について何でも質問してください。記事の詳細や関連する話題について説明できます。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
chatHistory.map(renderMessage)
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="ai-loading">
|
||||
<div className="message-header">
|
||||
<div className="avatar">🤖</div>
|
||||
<div className="user-info">
|
||||
<div className="display-name">考え中...</div>
|
||||
</div>
|
||||
</div>
|
||||
<LoadingSkeleton count={1} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<div className="message-content">
|
||||
エラー: {error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="question-form">
|
||||
<div className="input-container">
|
||||
<textarea
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
placeholder="質問を入力してください..."
|
||||
rows={2}
|
||||
disabled={loading || !user}
|
||||
className="question-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !question.trim() || !user}
|
||||
className="send-btn"
|
||||
>
|
||||
{loading ? '⏳' : '📤'}
|
||||
</button>
|
||||
</div>
|
||||
{!user && (
|
||||
<div className="auth-notice">
|
||||
ログインしてください
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<style jsx>{`
|
||||
.ask-ai-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ask-ai-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.ask-ai-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.clear-btn, .close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clear-btn:hover, .close-btn:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-message, .ai-message, .welcome-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
align-self: flex-end;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.ai-message, .welcome-message {
|
||||
align-self: flex-start;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.handle {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
background: #f1f3f4;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.user-message .message-content {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ai-message .message-content {
|
||||
background: #e9ecef;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ai-loading {
|
||||
align-self: flex-start;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.question-form {
|
||||
padding: 15px;
|
||||
border-top: 1px solid #eee;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.question-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.question-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.question-input:disabled {
|
||||
background: #e9ecef;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-notice {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
77
oauth/src/components/AuthButton.jsx
Normal file
77
oauth/src/components/AuthButton.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
||||
const [handleInput, setHandleInput] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!handleInput.trim() || isLoading) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await onLogin(handleInput.trim())
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
alert('ログインに失敗しました: ' + error.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div>認証状態を確認中...</div>
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{user.avatar && (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt="Profile"
|
||||
className="avatar"
|
||||
style={{ width: '24px', height: '24px' }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="display-name" style={{ fontSize: '14px', fontWeight: '700' }}>
|
||||
{user.displayName}
|
||||
</div>
|
||||
<div className="handle" style={{ fontSize: '12px' }}>
|
||||
@{user.handle}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onLogout} className="btn btn-danger btn-sm">
|
||||
ログアウト
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-section search-bar-layout">
|
||||
<input
|
||||
type="text"
|
||||
value={handleInput}
|
||||
onChange={(e) => setHandleInput(e.target.value)}
|
||||
placeholder="your.handle.com"
|
||||
disabled={isLoading}
|
||||
className="handle-input"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit(e)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !handleInput.trim()}
|
||||
className="auth-button"
|
||||
>
|
||||
{isLoading ? '認証中...' : <i className="fab fa-bluesky"></i>}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
234
oauth/src/components/Avatar.jsx
Normal file
234
oauth/src/components/Avatar.jsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { getAvatar } from '../utils/avatar.js'
|
||||
|
||||
/**
|
||||
* Avatar component with intelligent fallback
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.record - Record object containing avatar data
|
||||
* @param {string} props.handle - User handle
|
||||
* @param {string} props.did - User DID
|
||||
* @param {string} props.alt - Alt text for image
|
||||
* @param {string} props.className - CSS class name
|
||||
* @param {number} props.size - Avatar size in pixels
|
||||
* @param {boolean} props.showFallback - Show fallback UI if no avatar
|
||||
* @param {Function} props.onLoad - Callback when avatar loads
|
||||
* @param {Function} props.onError - Callback when avatar fails to load
|
||||
*/
|
||||
export default function Avatar({
|
||||
record,
|
||||
handle,
|
||||
did,
|
||||
alt = 'avatar',
|
||||
className = 'avatar',
|
||||
size = 40,
|
||||
showFallback = true,
|
||||
onLoad,
|
||||
onError
|
||||
}) {
|
||||
const [avatarUrl, setAvatarUrl] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadAvatar() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setImageError(false)
|
||||
|
||||
const url = await getAvatar({ record, handle, did })
|
||||
|
||||
if (!cancelled) {
|
||||
setAvatarUrl(url)
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
if (onError) onError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadAvatar()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [record, handle, did])
|
||||
|
||||
const handleImageError = async () => {
|
||||
setImageError(true)
|
||||
if (onError) onError(new Error('Image failed to load'))
|
||||
|
||||
// Try to fetch fresh avatar if the current one failed
|
||||
if (!loading && avatarUrl) {
|
||||
try {
|
||||
const freshUrl = await getAvatar({ handle, did, forceFresh: true })
|
||||
if (freshUrl && freshUrl !== avatarUrl) {
|
||||
setAvatarUrl(freshUrl)
|
||||
setImageError(false)
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors in retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageError(false)
|
||||
if (onLoad) onLoad()
|
||||
}
|
||||
|
||||
// Determine what to render
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={`${className} avatar-loading`}
|
||||
style={{ width: size, height: size }}
|
||||
aria-label="Loading avatar..."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !avatarUrl || imageError) {
|
||||
if (!showFallback) return null
|
||||
|
||||
// Fallback avatar
|
||||
const initial = (handle || 'U')[0].toUpperCase()
|
||||
return (
|
||||
<div
|
||||
className={`${className} avatar-fallback`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#e1e1e1',
|
||||
borderRadius: '50%',
|
||||
fontSize: size * 0.4
|
||||
}}
|
||||
aria-label={alt}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={alt}
|
||||
className={className}
|
||||
style={{ width: size, height: size }}
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar with hover card showing user info
|
||||
*/
|
||||
export function AvatarWithCard({
|
||||
record,
|
||||
handle,
|
||||
did,
|
||||
displayName,
|
||||
apiConfig,
|
||||
...avatarProps
|
||||
}) {
|
||||
const [showCard, setShowCard] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="avatar-container"
|
||||
onMouseEnter={() => setShowCard(true)}
|
||||
onMouseLeave={() => setShowCard(false)}
|
||||
>
|
||||
<Avatar
|
||||
record={record}
|
||||
handle={handle}
|
||||
did={did}
|
||||
{...avatarProps}
|
||||
/>
|
||||
|
||||
{showCard && (
|
||||
<div className="avatar-card">
|
||||
<Avatar
|
||||
record={record}
|
||||
handle={handle}
|
||||
did={did}
|
||||
size={80}
|
||||
className="avatar-card-image"
|
||||
/>
|
||||
<div className="avatar-card-info">
|
||||
<div className="avatar-card-name">{displayName || handle}</div>
|
||||
<a
|
||||
href={`${apiConfig?.web || 'https://bsky.app'}/profile/${did || handle}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="avatar-card-handle"
|
||||
>
|
||||
@{handle}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar list component for displaying multiple avatars
|
||||
*/
|
||||
export function AvatarList({ users, maxDisplay = 5, size = 30 }) {
|
||||
const displayUsers = users.slice(0, maxDisplay)
|
||||
const remainingCount = Math.max(0, users.length - maxDisplay)
|
||||
|
||||
return (
|
||||
<div className="avatar-list">
|
||||
{displayUsers.map((user, index) => (
|
||||
<div
|
||||
key={user.handle || index}
|
||||
className="avatar-list-item"
|
||||
style={{ marginLeft: index > 0 ? -10 : 0, zIndex: displayUsers.length - index }}
|
||||
>
|
||||
<Avatar
|
||||
handle={user.handle}
|
||||
did={user.did}
|
||||
record={user.record}
|
||||
size={size}
|
||||
showFallback={true}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<div
|
||||
className="avatar-list-more"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
marginLeft: -10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#666',
|
||||
color: '#fff',
|
||||
borderRadius: '50%',
|
||||
fontSize: size * 0.4
|
||||
}}
|
||||
>
|
||||
+{remainingCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
103
oauth/src/components/AvatarImage.jsx
Normal file
103
oauth/src/components/AvatarImage.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { getValidAvatar } from '../utils/avatarFetcher.js'
|
||||
import { logger } from '../utils/logger.js'
|
||||
|
||||
export default function AvatarImage({ record, size = 40, className = "avatar" }) {
|
||||
const [avatarUrl, setAvatarUrl] = useState(record?.value?.author?.avatar)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const author = record?.value?.author
|
||||
const handle = author?.handle
|
||||
const displayName = author?.displayName || handle
|
||||
|
||||
useEffect(() => {
|
||||
// record内のavatarが無い、またはエラーの場合に新しく取得
|
||||
if (!avatarUrl || error) {
|
||||
fetchValidAvatar()
|
||||
}
|
||||
}, [record, error])
|
||||
|
||||
const fetchValidAvatar = async () => {
|
||||
if (!record || loading) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const validAvatar = await getValidAvatar(record)
|
||||
setAvatarUrl(validAvatar)
|
||||
setError(false)
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch valid avatar:', err)
|
||||
setError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
setError(true)
|
||||
// エラー時に再取得を試行
|
||||
fetchValidAvatar()
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setError(false)
|
||||
}
|
||||
|
||||
// ローディング中のスケルトン
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={`${className} avatar-loading`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '50%',
|
||||
animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// avatar URLがある場合
|
||||
if (avatarUrl && !error) {
|
||||
return (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${displayName} avatar`}
|
||||
className={className}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// フォールバック: 初期文字のアバター
|
||||
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
|
||||
return (
|
||||
<div
|
||||
className={`${className} avatar-fallback`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ddd',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: size * 0.4,
|
||||
fontWeight: 'bold',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
)
|
||||
}
|
203
oauth/src/components/AvatarTest.jsx
Normal file
203
oauth/src/components/AvatarTest.jsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Avatar, { AvatarWithCard, AvatarList } from './Avatar.jsx'
|
||||
import { getAvatar, batchFetchAvatars, prefetchAvatar } from '../utils/avatar.js'
|
||||
|
||||
/**
|
||||
* Test component to demonstrate avatar functionality
|
||||
*/
|
||||
export default function AvatarTest() {
|
||||
const [testResults, setTestResults] = useState({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Test data
|
||||
const testUsers = [
|
||||
{ handle: 'syui.ai', did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn' },
|
||||
{ handle: 'ai.syui.ai', did: 'did:plc:4hqjfn7m6n5hno3doamuhgef' },
|
||||
{ handle: 'yui.syui.ai', did: 'did:plc:6qyecktefllvenje24fcxnie' }
|
||||
]
|
||||
|
||||
const sampleRecord = {
|
||||
value: {
|
||||
author: {
|
||||
handle: 'syui.ai',
|
||||
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
|
||||
displayName: 'syui',
|
||||
avatar: 'https://cdn.bsky.app/img/avatar/plain/did:plc:uqzpqmrjnptsxezjx4xuh2mn/bafkreid6kcc5pnn4b3ar7mj6vi3eiawhxgkcrw3edgbqeacyrlnlcoetea@jpeg'
|
||||
},
|
||||
text: 'Test message',
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// Test functions
|
||||
const testGetAvatar = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const results = {}
|
||||
|
||||
// Test with record
|
||||
results.fromRecord = await getAvatar({ record: sampleRecord })
|
||||
|
||||
// Test with handle only
|
||||
results.fromHandle = await getAvatar({ handle: 'syui.ai' })
|
||||
|
||||
// Test with broken record (force fresh fetch)
|
||||
const brokenRecord = {
|
||||
...sampleRecord,
|
||||
value: {
|
||||
...sampleRecord.value,
|
||||
author: {
|
||||
...sampleRecord.value.author,
|
||||
avatar: 'https://broken-url.com/avatar.jpg'
|
||||
}
|
||||
}
|
||||
}
|
||||
results.brokenRecord = await getAvatar({ record: brokenRecord })
|
||||
|
||||
// Test non-existent user
|
||||
try {
|
||||
results.nonExistent = await getAvatar({ handle: 'nonexistent.user' })
|
||||
} catch (error) {
|
||||
results.nonExistent = `Error: ${error.message}`
|
||||
}
|
||||
|
||||
setTestResults(results)
|
||||
} catch (error) {
|
||||
console.error('Test failed:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testBatchFetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const avatarMap = await batchFetchAvatars(testUsers)
|
||||
setTestResults(prev => ({
|
||||
...prev,
|
||||
batchResults: Object.fromEntries(avatarMap)
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Batch test failed:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testPrefetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await prefetchAvatar('syui.ai')
|
||||
const cachedAvatar = await getAvatar({ handle: 'syui.ai' })
|
||||
setTestResults(prev => ({
|
||||
...prev,
|
||||
prefetchResult: cachedAvatar
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Prefetch test failed:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="avatar-test-container">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Avatar System Test</h2>
|
||||
</div>
|
||||
<div className="card-content">
|
||||
|
||||
{/* Basic Avatar Examples */}
|
||||
<section className="test-section">
|
||||
<h3>Basic Avatar Examples</h3>
|
||||
<div className="avatar-examples">
|
||||
<div className="avatar-example">
|
||||
<h4>From Record</h4>
|
||||
<Avatar record={sampleRecord} size={60} />
|
||||
</div>
|
||||
|
||||
<div className="avatar-example">
|
||||
<h4>From Handle</h4>
|
||||
<Avatar handle="syui.ai" size={60} />
|
||||
</div>
|
||||
|
||||
<div className="avatar-example">
|
||||
<h4>With Fallback</h4>
|
||||
<Avatar handle="nonexistent.user" size={60} />
|
||||
</div>
|
||||
|
||||
<div className="avatar-example">
|
||||
<h4>Loading State</h4>
|
||||
<div className="avatar-loading" style={{ width: 60, height: 60 }} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Avatar with Card */}
|
||||
<section className="test-section">
|
||||
<h3>Avatar with Hover Card</h3>
|
||||
<div className="avatar-examples">
|
||||
<AvatarWithCard
|
||||
record={sampleRecord}
|
||||
displayName="syui"
|
||||
apiConfig={{ web: 'https://bsky.app' }}
|
||||
size={60}
|
||||
/>
|
||||
<p>Hover over the avatar to see the card</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Avatar List */}
|
||||
<section className="test-section">
|
||||
<h3>Avatar List</h3>
|
||||
<AvatarList users={testUsers} maxDisplay={3} size={40} />
|
||||
</section>
|
||||
|
||||
{/* Test Controls */}
|
||||
<section className="test-section">
|
||||
<h3>Test Functions</h3>
|
||||
<div className="test-controls">
|
||||
<button
|
||||
onClick={testGetAvatar}
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Test getAvatar()
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={testBatchFetch}
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Test Batch Fetch
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={testPrefetch}
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Test Prefetch
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Test Results */}
|
||||
{Object.keys(testResults).length > 0 && (
|
||||
<section className="test-section">
|
||||
<h3>Test Results</h3>
|
||||
<div className="json-display">
|
||||
<pre className="json-content">
|
||||
{JSON.stringify(testResults, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
246
oauth/src/components/AvatarTestPanel.jsx
Normal file
246
oauth/src/components/AvatarTestPanel.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState } from 'react'
|
||||
import AvatarImage from './AvatarImage.jsx'
|
||||
import { getValidAvatar, clearAvatarCache, getAvatarCacheStats } from '../utils/avatarFetcher.js'
|
||||
|
||||
export default function AvatarTestPanel() {
|
||||
const [testHandle, setTestHandle] = useState('ai.syui.ai')
|
||||
const [testResult, setTestResult] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [cacheStats, setCacheStats] = useState(null)
|
||||
|
||||
// ダミーレコードを作成(実際の投稿したレコード形式)
|
||||
const createTestRecord = (handle, brokenAvatar = false) => ({
|
||||
value: {
|
||||
author: {
|
||||
did: null, // DIDはnullにして、handleから取得させる
|
||||
handle: handle,
|
||||
displayName: "Test User",
|
||||
avatar: brokenAvatar ? "https://broken.example.com/avatar.jpg" : null
|
||||
},
|
||||
text: "テストコメント",
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
const testAvatarFetch = async (useBrokenAvatar = false) => {
|
||||
setLoading(true)
|
||||
setTestResult(null)
|
||||
|
||||
try {
|
||||
const testRecord = createTestRecord(testHandle, useBrokenAvatar)
|
||||
const avatarUrl = await getValidAvatar(testRecord)
|
||||
|
||||
setTestResult({
|
||||
success: true,
|
||||
avatarUrl,
|
||||
handle: testHandle,
|
||||
brokenTest: useBrokenAvatar,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: error.message,
|
||||
handle: testHandle,
|
||||
brokenTest: useBrokenAvatar
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearCache = () => {
|
||||
clearAvatarCache()
|
||||
setCacheStats(null)
|
||||
alert('Avatar cache cleared!')
|
||||
}
|
||||
|
||||
const handleShowCacheStats = () => {
|
||||
const stats = getAvatarCacheStats()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="test-ui">
|
||||
<h2>🖼️ Avatar Test Panel</h2>
|
||||
<p className="description">
|
||||
Avatar取得システムのテスト。投稿済みのdummy recordを使用してavatar取得処理を確認できます。
|
||||
</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="test-handle">Test Handle:</label>
|
||||
<input
|
||||
id="test-handle"
|
||||
type="text"
|
||||
value={testHandle}
|
||||
onChange={(e) => setTestHandle(e.target.value)}
|
||||
placeholder="ai.syui.ai"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
onClick={() => testAvatarFetch(false)}
|
||||
disabled={loading || !testHandle.trim()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? '⏳ Testing...' : '🔄 Test Avatar Fetch'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => testAvatarFetch(true)}
|
||||
disabled={loading || !testHandle.trim()}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{loading ? '⏳ Testing...' : '💥 Test Broken Avatar'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClearCache}
|
||||
disabled={loading}
|
||||
className="btn btn-danger btn-sm"
|
||||
>
|
||||
🗑️ Clear Cache
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShowCacheStats}
|
||||
disabled={loading}
|
||||
className="btn btn-outline btn-sm"
|
||||
>
|
||||
📊 Cache Stats
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="test-result">
|
||||
<h3>Test Result:</h3>
|
||||
{testResult.success ? (
|
||||
<div className="success-message">
|
||||
✅ Avatar fetched successfully!
|
||||
<div className="result-details">
|
||||
<p><strong>Handle:</strong> {testResult.handle}</p>
|
||||
<p><strong>Broken Test:</strong> {testResult.brokenTest ? 'Yes' : 'No'}</p>
|
||||
<p><strong>Avatar URL:</strong> {testResult.avatarUrl || 'None'}</p>
|
||||
<p><strong>Timestamp:</strong> {testResult.timestamp}</p>
|
||||
|
||||
{testResult.avatarUrl && (
|
||||
<div className="avatar-preview">
|
||||
<p><strong>Preview:</strong></p>
|
||||
<img
|
||||
src={testResult.avatarUrl}
|
||||
alt="Avatar preview"
|
||||
style={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover',
|
||||
border: '2px solid #ddd'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="error-message">
|
||||
❌ Test failed: {testResult.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cacheStats && (
|
||||
<div className="cache-stats">
|
||||
<h3>Cache Statistics:</h3>
|
||||
<p><strong>Entries:</strong> {cacheStats.size}</p>
|
||||
{cacheStats.entries.length > 0 && (
|
||||
<div className="cache-entries">
|
||||
<h4>Cached Avatars:</h4>
|
||||
{cacheStats.entries.map((entry, i) => (
|
||||
<div key={i} className="cache-entry">
|
||||
<p><strong>Key:</strong> {entry.key}</p>
|
||||
<p><strong>Age:</strong> {Math.floor(entry.age / 1000)}s</p>
|
||||
<p><strong>Profile:</strong> {entry.profile?.displayName} (@{entry.profile?.handle})</p>
|
||||
{entry.avatar && (
|
||||
<img
|
||||
src={entry.avatar}
|
||||
alt="Cached avatar"
|
||||
style={{ width: 30, height: 30, borderRadius: '50%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="live-demo">
|
||||
<h3>Live Avatar Component Demo:</h3>
|
||||
<p>実際のAvatarImageコンポーネントの動作確認:</p>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', marginTop: '12px' }}>
|
||||
<AvatarImage record={createTestRecord(testHandle, false)} size={40} />
|
||||
<span>Normal avatar test</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', marginTop: '12px' }}>
|
||||
<AvatarImage record={createTestRecord(testHandle, true)} size={40} />
|
||||
<span>Broken avatar test (should fetch fresh)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.test-result {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.result-details {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.result-details p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.avatar-preview {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
}
|
||||
.cache-stats {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
.cache-entries {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.cache-entry {
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cache-entry p {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.live-demo {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -1,120 +0,0 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card as CardType, CardRarity } from '../types/card';
|
||||
import '../styles/Card.css';
|
||||
|
||||
interface CardProps {
|
||||
card: CardType;
|
||||
isRevealing?: boolean;
|
||||
detailed?: boolean;
|
||||
}
|
||||
|
||||
const CARD_INFO: Record<number, { name: string; color: string }> = {
|
||||
0: { name: "アイ", color: "#fff700" },
|
||||
1: { name: "夢幻", color: "#b19cd9" },
|
||||
2: { name: "光彩", color: "#ffd700" },
|
||||
3: { name: "中性子", color: "#cacfd2" },
|
||||
4: { name: "太陽", color: "#ff6b35" },
|
||||
5: { name: "夜空", color: "#1a1a2e" },
|
||||
6: { name: "雪", color: "#e3f2fd" },
|
||||
7: { name: "雷", color: "#ffd93d" },
|
||||
8: { name: "超究", color: "#6c5ce7" },
|
||||
9: { name: "剣", color: "#a8e6cf" },
|
||||
10: { name: "破壊", color: "#ff4757" },
|
||||
11: { name: "地球", color: "#4834d4" },
|
||||
12: { name: "天の川", color: "#9c88ff" },
|
||||
13: { name: "創造", color: "#00d2d3" },
|
||||
14: { name: "超新星", color: "#ff9ff3" },
|
||||
15: { name: "世界", color: "#54a0ff" },
|
||||
};
|
||||
|
||||
export const Card: React.FC<CardProps> = ({ card, isRevealing = false, detailed = false }) => {
|
||||
const cardInfo = CARD_INFO[card.id] || { name: "Unknown", color: "#666" };
|
||||
const imageUrl = `https://git.syui.ai/ai/card/raw/branch/main/img/${card.id}.webp`;
|
||||
|
||||
const getRarityClass = () => {
|
||||
switch (card.status) {
|
||||
case CardRarity.UNIQUE:
|
||||
return 'card-unique';
|
||||
case CardRarity.KIRA:
|
||||
return 'card-kira';
|
||||
case CardRarity.SUPER_RARE:
|
||||
return 'card-super-rare';
|
||||
case CardRarity.RARE:
|
||||
return 'card-rare';
|
||||
default:
|
||||
return 'card-normal';
|
||||
}
|
||||
};
|
||||
|
||||
if (!detailed) {
|
||||
// Simple view - only image and frame
|
||||
return (
|
||||
<motion.div
|
||||
className={`card card-simple ${getRarityClass()}`}
|
||||
initial={isRevealing ? { rotateY: 180 } : {}}
|
||||
animate={isRevealing ? { rotateY: 0 } : {}}
|
||||
transition={{ duration: 0.8, type: "spring" }}
|
||||
>
|
||||
<div className="card-frame">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={cardInfo.name}
|
||||
className="card-image-simple"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Detailed view - all information
|
||||
return (
|
||||
<motion.div
|
||||
className={`card ${getRarityClass()}`}
|
||||
initial={isRevealing ? { rotateY: 180 } : {}}
|
||||
animate={isRevealing ? { rotateY: 0 } : {}}
|
||||
transition={{ duration: 0.8, type: "spring" }}
|
||||
style={{
|
||||
'--card-color': cardInfo.color,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div className="card-inner">
|
||||
<div className="card-header">
|
||||
<span className="card-id">#{card.id}</span>
|
||||
<span className="card-cp">CP: {card.cp}</span>
|
||||
</div>
|
||||
|
||||
<div className="card-image-container">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={cardInfo.name}
|
||||
className="card-image"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card-content">
|
||||
<h3 className="card-name">{cardInfo.name}</h3>
|
||||
{card.is_unique && (
|
||||
<div className="unique-badge">UNIQUE</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{card.skill && (
|
||||
<div className="card-skill">
|
||||
<p>{card.skill}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-footer">
|
||||
<span className="card-rarity">{card.status.toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
@@ -1,171 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { atprotoOAuthService } from '../services/atproto-oauth';
|
||||
import { Card } from './Card';
|
||||
import '../styles/CardBox.css';
|
||||
|
||||
interface CardBoxProps {
|
||||
userDid: string;
|
||||
}
|
||||
|
||||
export const CardBox: React.FC<CardBoxProps> = ({ userDid }) => {
|
||||
const [boxData, setBoxData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showJson, setShowJson] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadBoxData();
|
||||
}, [userDid]);
|
||||
|
||||
const loadBoxData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await atprotoOAuthService.getCardsFromBox();
|
||||
setBoxData(data);
|
||||
} catch (err) {
|
||||
// Failed to load card box
|
||||
setError(err instanceof Error ? err.message : 'カードボックスの読み込みに失敗しました');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveToBox = async () => {
|
||||
// 現在のカードデータを取得してボックスに保存
|
||||
// この部分は親コンポーネントから渡すか、APIから取得する必要があります
|
||||
alert('カードボックスへの保存機能は親コンポーネントから実行してください');
|
||||
};
|
||||
|
||||
const handleDeleteBox = async () => {
|
||||
if (!window.confirm('カードボックスを削除してもよろしいですか?\nこの操作は取り消せません。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await atprotoOAuthService.deleteCardBox();
|
||||
setBoxData({ records: [] });
|
||||
alert('カードボックスを削除しました');
|
||||
} catch (err) {
|
||||
// Failed to delete card box
|
||||
setError(err instanceof Error ? err.message : 'カードボックスの削除に失敗しました');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card-box-container">
|
||||
<div className="loading">カードボックスを読み込み中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card-box-container">
|
||||
<div className="error">エラー: {error}</div>
|
||||
<button onClick={loadBoxData} className="retry-button">
|
||||
再試行
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const records = boxData?.records || [];
|
||||
const selfRecord = records.find((record: any) => record.uri.includes('/self'));
|
||||
const cards = selfRecord?.value?.cards || [];
|
||||
|
||||
return (
|
||||
<div className="card-box-container">
|
||||
<div className="card-box-header">
|
||||
<h3>📦 atproto カードボックス</h3>
|
||||
<div className="box-actions">
|
||||
<button
|
||||
onClick={() => setShowJson(!showJson)}
|
||||
className="json-button"
|
||||
>
|
||||
{showJson ? 'JSON非表示' : 'JSON表示'}
|
||||
</button>
|
||||
<button onClick={loadBoxData} className="refresh-button">
|
||||
🔄 更新
|
||||
</button>
|
||||
{cards.length > 0 && (
|
||||
<button
|
||||
onClick={handleDeleteBox}
|
||||
className="delete-button"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? '削除中...' : '🗑️ 削除'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="uri-display">
|
||||
<p>
|
||||
<strong>📍 URI:</strong>
|
||||
<code>at://did:plc:uqzpqmrjnptsxezjx4xuh2mn/ai.card.box/self</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showJson && (
|
||||
<div className="json-display">
|
||||
<h4>Raw JSON データ:</h4>
|
||||
<pre className="json-content">
|
||||
{JSON.stringify(boxData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="box-stats">
|
||||
<p>
|
||||
<strong>総カード数:</strong> {cards.length}枚
|
||||
{selfRecord?.value?.updated_at && (
|
||||
<>
|
||||
<br />
|
||||
<strong>最終更新:</strong> {new Date(selfRecord.value.updated_at).toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{cards.length > 0 ? (
|
||||
<>
|
||||
<div className="card-grid">
|
||||
{cards.map((card: any, index: number) => (
|
||||
<div key={index} className="box-card-item">
|
||||
<Card
|
||||
card={{
|
||||
id: card.id,
|
||||
cp: card.cp,
|
||||
status: card.status,
|
||||
skill: card.skill,
|
||||
owner_did: card.owner_did,
|
||||
obtained_at: card.obtained_at,
|
||||
is_unique: card.is_unique,
|
||||
unique_id: card.unique_id
|
||||
}}
|
||||
/>
|
||||
<div className="card-info">
|
||||
<small>ID: {card.id} | CP: {card.cp}</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-box">
|
||||
<p>カードボックスにカードがありません</p>
|
||||
<p>カードを引いてからバックアップボタンを押してください</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,113 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card } from './Card';
|
||||
import { cardApi } from '../services/api';
|
||||
import { Card as CardType } from '../types/card';
|
||||
import '../styles/CardList.css';
|
||||
|
||||
interface CardMasterData {
|
||||
id: number;
|
||||
name: string;
|
||||
ja_name: string;
|
||||
description: string;
|
||||
base_cp_min: number;
|
||||
base_cp_max: number;
|
||||
}
|
||||
|
||||
export const CardList: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [masterData, setMasterData] = useState<CardMasterData[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadMasterData();
|
||||
}, []);
|
||||
|
||||
const loadMasterData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('http://localhost:8000/api/v1/cards/master');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch card master data');
|
||||
}
|
||||
const data = await response.json();
|
||||
setMasterData(data);
|
||||
} catch (err) {
|
||||
// Failed to load card master data
|
||||
setError(err instanceof Error ? err.message : 'Failed to load card data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card-list-container">
|
||||
<div className="loading">Loading card data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card-list-container">
|
||||
<div className="error">Error: {error}</div>
|
||||
<button onClick={loadMasterData}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Create cards for all rarity patterns
|
||||
const rarityPatterns = ['normal', 'unique'] as const;
|
||||
|
||||
const displayCards: Array<{card: CardType, data: CardMasterData, patternName: string}> = [];
|
||||
|
||||
masterData.forEach(data => {
|
||||
rarityPatterns.forEach(pattern => {
|
||||
const card: CardType = {
|
||||
id: data.id,
|
||||
cp: Math.floor((data.base_cp_min + data.base_cp_max) / 2),
|
||||
status: pattern,
|
||||
skill: null,
|
||||
owner_did: 'sample',
|
||||
obtained_at: new Date().toISOString(),
|
||||
is_unique: pattern === 'unique',
|
||||
unique_id: pattern === 'unique' ? 'sample-unique-id' : null
|
||||
};
|
||||
displayCards.push({
|
||||
card,
|
||||
data,
|
||||
patternName: `${data.id}-${pattern}`
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<div className="card-list-container">
|
||||
<header className="card-list-header">
|
||||
<h1>ai.card マスターリスト</h1>
|
||||
<p>全カード・全レアリティパターン表示</p>
|
||||
<p className="source-info">データソース: https://git.syui.ai/ai/ai/raw/branch/main/ai.json</p>
|
||||
</header>
|
||||
|
||||
<div className="card-list-simple-grid">
|
||||
{displayCards.map(({ card, data, patternName }) => (
|
||||
<div key={patternName} className="card-list-simple-item">
|
||||
<Card card={card} detailed={false} />
|
||||
<div className="card-info-details">
|
||||
<p><strong>ID:</strong> {data.id}</p>
|
||||
<p><strong>Name:</strong> {data.name}</p>
|
||||
<p><strong>日本語名:</strong> {data.ja_name}</p>
|
||||
<p><strong>レアリティ:</strong> {card.status}</p>
|
||||
<p><strong>CP:</strong> {card.cp}</p>
|
||||
<p><strong>CP範囲:</strong> {data.base_cp_min}-{data.base_cp_max}</p>
|
||||
{data.description && (
|
||||
<p className="card-description">{data.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,133 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { aiCardApi } from '../services/api';
|
||||
import '../styles/CollectionAnalysis.css';
|
||||
|
||||
interface AnalysisData {
|
||||
total_cards: number;
|
||||
unique_cards: number;
|
||||
rarity_distribution: Record<string, number>;
|
||||
collection_score: number;
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
interface CollectionAnalysisProps {
|
||||
userDid: string;
|
||||
}
|
||||
|
||||
export const CollectionAnalysis: React.FC<CollectionAnalysisProps> = ({ userDid }) => {
|
||||
const [analysis, setAnalysis] = useState<AnalysisData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAnalysis = async () => {
|
||||
if (!userDid) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await aiCardApi.analyzeCollection(userDid);
|
||||
setAnalysis(result);
|
||||
} catch (err) {
|
||||
// Collection analysis failed
|
||||
setError('AI分析機能を利用するにはai.gptサーバーが必要です。基本機能はai.cardサーバーのみで利用できます。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalysis();
|
||||
}, [userDid]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="collection-analysis">
|
||||
<div className="analysis-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>AI分析中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="collection-analysis">
|
||||
<div className="analysis-error">
|
||||
<p>{error}</p>
|
||||
<button onClick={loadAnalysis} className="retry-button">
|
||||
再試行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!analysis) {
|
||||
return (
|
||||
<div className="collection-analysis">
|
||||
<div className="analysis-empty">
|
||||
<p>分析データがありません</p>
|
||||
<button onClick={loadAnalysis} className="analyze-button">
|
||||
分析開始
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="collection-analysis">
|
||||
<h3>🧠 AI コレクション分析</h3>
|
||||
|
||||
<div className="analysis-stats">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{analysis.total_cards}</div>
|
||||
<div className="stat-label">総カード数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{analysis.unique_cards}</div>
|
||||
<div className="stat-label">ユニークカード</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{analysis.collection_score}</div>
|
||||
<div className="stat-label">コレクションスコア</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rarity-distribution">
|
||||
<h4>レアリティ分布</h4>
|
||||
<div className="rarity-bars">
|
||||
{Object.entries(analysis.rarity_distribution).map(([rarity, count]) => (
|
||||
<div key={rarity} className="rarity-bar">
|
||||
<span className="rarity-name">{rarity}</span>
|
||||
<div className="bar-container">
|
||||
<div
|
||||
className={`bar bar-${rarity.toLowerCase()}`}
|
||||
style={{ width: `${(count / analysis.total_cards) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="rarity-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysis.recommendations && analysis.recommendations.length > 0 && (
|
||||
<div className="recommendations">
|
||||
<h4>🎯 AI推奨</h4>
|
||||
<ul>
|
||||
{analysis.recommendations.map((rec, index) => (
|
||||
<li key={index}>{rec}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={loadAnalysis} className="refresh-analysis">
|
||||
分析更新
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
137
oauth/src/components/CommentForm.jsx
Normal file
137
oauth/src/components/CommentForm.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState } from 'react'
|
||||
import { atproto, collections } from '../api/atproto.js'
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
export default function CommentForm({ user, agent, onCommentPosted, pageContext }) {
|
||||
const [text, setText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
// Get current URL automatically, but exclude OAuth callback URLs
|
||||
const getCurrentUrl = () => {
|
||||
const currentPath = window.location.href
|
||||
|
||||
// If on OAuth callback page, get the stored return URL or use root
|
||||
if (currentPath.includes('/oauth/callback')) {
|
||||
return sessionStorage.getItem('oauth_return_url') || window.location.origin
|
||||
}
|
||||
|
||||
// Remove hash fragments for clean URLs
|
||||
return currentPath.split('#')[0]
|
||||
}
|
||||
|
||||
const currentUrl = getCurrentUrl()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!text.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
// Create ai.syui.log record structure (new unified format)
|
||||
const record = {
|
||||
repo: user.did,
|
||||
collection: env.collection,
|
||||
rkey: `comment-${Date.now()}`,
|
||||
record: {
|
||||
$type: env.collection,
|
||||
url: currentUrl, // Keep for backward compatibility
|
||||
post: {
|
||||
url: currentUrl,
|
||||
date: timestamp,
|
||||
slug: new URL(currentUrl).pathname.split('/').pop()?.replace(/\.html$/, '') || '',
|
||||
tags: [],
|
||||
title: document.title || 'Comment',
|
||||
language: 'ja'
|
||||
},
|
||||
text: text.trim(),
|
||||
type: 'comment',
|
||||
author: {
|
||||
did: user.did,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
avatar: user.avatar
|
||||
},
|
||||
createdAt: timestamp
|
||||
}
|
||||
}
|
||||
|
||||
// Post the record
|
||||
await atproto.putRecord(null, record, agent)
|
||||
|
||||
// キャッシュを無効化
|
||||
collections.invalidateCache(env.collection)
|
||||
|
||||
// Clear form
|
||||
setText('')
|
||||
|
||||
// Notify parent component
|
||||
if (onCommentPosted) {
|
||||
onCommentPosted()
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
<p>ログインしてコメントを投稿</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>コメントを投稿</h3>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group" style={{ marginBottom: '8px', fontSize: '0.9em', color: 'var(--text-secondary)' }}>
|
||||
<span>ページ: {currentUrl}</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="comment-text">コメント:</label>
|
||||
<textarea
|
||||
id="comment-text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="コメントを入力してください..."
|
||||
rows={4}
|
||||
required
|
||||
disabled={loading}
|
||||
className="form-input form-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
エラー: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !text.trim() || !url.trim()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? '投稿中...' : 'コメントを投稿'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -1,130 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from './Card';
|
||||
import { Card as CardType } from '../types/card';
|
||||
import { atprotoOAuthService } from '../services/atproto-oauth';
|
||||
import '../styles/GachaAnimation.css';
|
||||
|
||||
interface GachaAnimationProps {
|
||||
card: CardType;
|
||||
animationType: string;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export const GachaAnimation: React.FC<GachaAnimationProps> = ({
|
||||
card,
|
||||
animationType,
|
||||
onComplete
|
||||
}) => {
|
||||
const [phase, setPhase] = useState<'opening' | 'revealing' | 'complete'>('opening');
|
||||
const [showCard, setShowCard] = useState(false);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer1 = setTimeout(() => setPhase('revealing'), 1500);
|
||||
const timer2 = setTimeout(() => {
|
||||
setPhase('complete');
|
||||
setShowCard(true);
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer1);
|
||||
clearTimeout(timer2);
|
||||
};
|
||||
}, [onComplete]);
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (showCard) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveToCollection = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isSharing) return;
|
||||
|
||||
setIsSharing(true);
|
||||
try {
|
||||
await atprotoOAuthService.saveCardToCollection(card);
|
||||
alert('カードデータをatprotoコレクションに保存しました!');
|
||||
} catch (error) {
|
||||
// Failed to save card
|
||||
alert('保存に失敗しました。認証が必要かもしれません。');
|
||||
} finally {
|
||||
setIsSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEffectClass = () => {
|
||||
switch (animationType) {
|
||||
case 'unique':
|
||||
return 'effect-unique';
|
||||
case 'kira':
|
||||
return 'effect-kira';
|
||||
case 'rare':
|
||||
return 'effect-rare';
|
||||
default:
|
||||
return 'effect-normal';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`gacha-container ${getEffectClass()}`} onClick={handleCardClick}>
|
||||
<AnimatePresence mode="wait">
|
||||
{phase === 'opening' && (
|
||||
<motion.div
|
||||
key="opening"
|
||||
className="gacha-opening"
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.8, type: "spring" }}
|
||||
>
|
||||
<div className="gacha-pack">
|
||||
<div className="pack-glow" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{phase === 'revealing' && (
|
||||
<motion.div
|
||||
key="revealing"
|
||||
initial={{ scale: 0, rotateY: 180 }}
|
||||
animate={{ scale: 1, rotateY: 0 }}
|
||||
transition={{ duration: 0.8, type: "spring" }}
|
||||
>
|
||||
<Card card={card} isRevealing={true} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{phase === 'complete' && showCard && (
|
||||
<motion.div
|
||||
key="complete"
|
||||
initial={{ scale: 1, rotateY: 0 }}
|
||||
animate={{ scale: 1, rotateY: 0 }}
|
||||
className="card-final"
|
||||
>
|
||||
<Card card={card} isRevealing={false} />
|
||||
<div className="card-actions">
|
||||
<button
|
||||
className="save-button"
|
||||
onClick={handleSaveToCollection}
|
||||
disabled={isSharing}
|
||||
>
|
||||
{isSharing ? '保存中...' : '💾 atprotoに保存'}
|
||||
</button>
|
||||
<div className="click-hint">クリックして閉じる</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{animationType === 'unique' && (
|
||||
<div className="unique-effect">
|
||||
<div className="unique-particles" />
|
||||
<div className="unique-burst" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,144 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { cardApi, aiCardApi } from '../services/api';
|
||||
import '../styles/GachaStats.css';
|
||||
|
||||
interface GachaStatsData {
|
||||
total_draws: number;
|
||||
cards_by_rarity: Record<string, number>;
|
||||
success_rates: Record<string, number>;
|
||||
recent_activity: Array<{
|
||||
timestamp: string;
|
||||
user_did: string;
|
||||
card_name: string;
|
||||
rarity: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const GachaStats: React.FC = () => {
|
||||
const [stats, setStats] = useState<GachaStatsData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [useAI, setUseAI] = useState(true);
|
||||
|
||||
const loadStats = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (useAI) {
|
||||
try {
|
||||
result = await aiCardApi.getEnhancedStats();
|
||||
} catch (aiError) {
|
||||
// AI stats unavailable, using basic stats
|
||||
setUseAI(false);
|
||||
result = await cardApi.getGachaStats();
|
||||
}
|
||||
} else {
|
||||
result = await cardApi.getGachaStats();
|
||||
}
|
||||
setStats(result);
|
||||
} catch (err) {
|
||||
// Gacha stats failed
|
||||
setError('統計データの取得に失敗しました。ai.cardサーバーが起動していることを確認してください。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="gacha-stats">
|
||||
<div className="stats-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>統計データ取得中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="gacha-stats">
|
||||
<div className="stats-error">
|
||||
<p>{error}</p>
|
||||
<button onClick={loadStats} className="retry-button">
|
||||
再試行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="gacha-stats">
|
||||
<div className="stats-empty">
|
||||
<p>統計データがありません</p>
|
||||
<button onClick={loadStats} className="load-stats-button">
|
||||
統計取得
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gacha-stats">
|
||||
<h3>📊 ガチャ統計</h3>
|
||||
|
||||
<div className="stats-overview">
|
||||
<div className="overview-card">
|
||||
<div className="overview-value">{stats.total_draws}</div>
|
||||
<div className="overview-label">総ガチャ実行数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rarity-stats">
|
||||
<h4>レアリティ別出現数</h4>
|
||||
<div className="rarity-grid">
|
||||
{Object.entries(stats.cards_by_rarity).map(([rarity, count]) => (
|
||||
<div key={rarity} className={`rarity-stat rarity-${rarity.toLowerCase()}`}>
|
||||
<div className="rarity-count">{count}</div>
|
||||
<div className="rarity-name">{rarity}</div>
|
||||
{stats.success_rates[rarity] && (
|
||||
<div className="success-rate">
|
||||
{(stats.success_rates[rarity] * 100).toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats.recent_activity && stats.recent_activity.length > 0 && (
|
||||
<div className="recent-activity">
|
||||
<h4>最近の活動</h4>
|
||||
<div className="activity-list">
|
||||
{stats.recent_activity.slice(0, 5).map((activity, index) => (
|
||||
<div key={index} className="activity-item">
|
||||
<div className="activity-time">
|
||||
{new Date(activity.timestamp).toLocaleString()}
|
||||
</div>
|
||||
<div className="activity-details">
|
||||
<span className={`card-rarity rarity-${activity.rarity.toLowerCase()}`}>
|
||||
{activity.rarity}
|
||||
</span>
|
||||
<span className="card-name">{activity.card_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={loadStats} className="refresh-stats">
|
||||
統計更新
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
98
oauth/src/components/LoadingSkeleton.jsx
Normal file
98
oauth/src/components/LoadingSkeleton.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function LoadingSkeleton({ count = 3, showTitle = false }) {
|
||||
return (
|
||||
<div className="loading-skeleton">
|
||||
{showTitle && (
|
||||
<div className="skeleton-title">
|
||||
<div className="skeleton-line title"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Array(count).fill(0).map((_, i) => (
|
||||
<div key={i} className="skeleton-item">
|
||||
<div className="skeleton-avatar"></div>
|
||||
<div className="skeleton-content">
|
||||
<div className="skeleton-line name"></div>
|
||||
<div className="skeleton-line text"></div>
|
||||
<div className="skeleton-line text short"></div>
|
||||
<div className="skeleton-line meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<style jsx>{`
|
||||
.loading-skeleton {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
display: flex;
|
||||
padding: 15px;
|
||||
border: 1px solid #eee;
|
||||
margin: 10px 0;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton-line.title {
|
||||
height: 20px;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.skeleton-line.name {
|
||||
height: 14px;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.skeleton-line.text {
|
||||
height: 12px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.skeleton-line.text.short {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.skeleton-line.meta {
|
||||
height: 10px;
|
||||
width: 40%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -1,203 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { authService } from '../services/auth';
|
||||
import { atprotoOAuthService } from '../services/atproto-oauth';
|
||||
import '../styles/Login.css';
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: (did: string, handle: string) => void;
|
||||
onClose: () => void;
|
||||
defaultHandle?: string;
|
||||
}
|
||||
|
||||
export const Login: React.FC<LoginProps> = ({ onLogin, onClose, defaultHandle }) => {
|
||||
const [loginMode, setLoginMode] = useState<'oauth' | 'legacy'>('oauth');
|
||||
const [identifier, setIdentifier] = useState(defaultHandle || '');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Prompt for handle if not provided
|
||||
const handle = identifier.trim() || undefined;
|
||||
await atprotoOAuthService.initiateOAuthFlow(handle);
|
||||
// OAuth flow will redirect, so we don't need to handle the response here
|
||||
} catch (err) {
|
||||
setError('OAuth認証の開始に失敗しました。');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLegacyLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await authService.login(identifier, password);
|
||||
onLogin(response.did, response.handle);
|
||||
} catch (err) {
|
||||
setError('ログインに失敗しました。認証情報を確認してください。');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="login-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
className="login-modal"
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", duration: 0.5 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2>atprotoログイン</h2>
|
||||
|
||||
<div className="login-mode-selector">
|
||||
<button
|
||||
type="button"
|
||||
className={`mode-button ${loginMode === 'oauth' ? 'active' : ''}`}
|
||||
onClick={() => setLoginMode('oauth')}
|
||||
>
|
||||
OAuth 2.1 (推奨)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mode-button ${loginMode === 'legacy' ? 'active' : ''}`}
|
||||
onClick={() => setLoginMode('legacy')}
|
||||
>
|
||||
アプリパスワード
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loginMode === 'oauth' ? (
|
||||
<div className="oauth-login">
|
||||
<div className="oauth-info">
|
||||
<h3>🔐 OAuth 2.1 認証</h3>
|
||||
<p>
|
||||
より安全で標準準拠の認証方式です。
|
||||
ブラウザが一時的にatproto認証サーバーにリダイレクトされます。
|
||||
</p>
|
||||
{(window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost') && (
|
||||
<div className="dev-notice">
|
||||
<small>🛠️ 開発環境: モック認証を使用します(実際のBlueskyにはアクセスしません)</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="oauth-identifier">Bluesky Handle</label>
|
||||
<input
|
||||
id="oauth-identifier"
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder="your.handle.bsky.social"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="button"
|
||||
className="oauth-login-button"
|
||||
onClick={handleOAuthLogin}
|
||||
disabled={isLoading || !identifier.trim()}
|
||||
>
|
||||
{isLoading ? '認証開始中...' : 'atprotoで認証'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cancel-button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleLegacyLogin}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="identifier">ハンドル または DID</label>
|
||||
<input
|
||||
id="identifier"
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder="your.handle または did:plc:..."
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">アプリパスワード</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="アプリパスワード"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<small>
|
||||
メインパスワードではなく、
|
||||
<a href={`${import.meta.env.VITE_ATPROTO_WEB_URL || 'https://bsky.app'}/settings/app-passwords`} target="_blank" rel="noopener noreferrer">
|
||||
アプリパスワード
|
||||
</a>
|
||||
を使用してください
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="submit"
|
||||
className="login-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'ログイン中...' : 'ログイン'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cancel-button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="login-info">
|
||||
<p>
|
||||
ai.logはatprotoアカウントを使用します。
|
||||
コメントはあなたのPDSに保存されます。
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
57
oauth/src/components/OAuthCallback.jsx
Normal file
57
oauth/src/components/OAuthCallback.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
export default function OAuthCallback({ onAuthSuccess }) {
|
||||
const [status, setStatus] = useState('OAuth認証処理中...')
|
||||
|
||||
useEffect(() => {
|
||||
handleCallback()
|
||||
}, [])
|
||||
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// BrowserOAuthClientが自動的にコールバックを処理します
|
||||
// URLのパラメータを確認して成功を通知
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
const error = urlParams.get('error')
|
||||
|
||||
if (error) {
|
||||
throw new Error(`OAuth error: ${error}`)
|
||||
}
|
||||
|
||||
if (code) {
|
||||
setStatus('認証成功!元のページに戻ります...')
|
||||
|
||||
// 認証前のページを復元するか、ルートページに戻る
|
||||
const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'
|
||||
sessionStorage.removeItem('oauth_return_url')
|
||||
|
||||
// Clean up URL fragments and normalize
|
||||
const cleanReturnUrl = returnUrl.split('#')[0]
|
||||
|
||||
// 少し待ってから元のページにリダイレクト
|
||||
setTimeout(() => {
|
||||
window.location.replace(cleanReturnUrl)
|
||||
}, 1500)
|
||||
} else {
|
||||
setStatus('認証情報が見つかりません')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Callback error:', error)
|
||||
setStatus('認証エラー: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h2>OAuth認証</h2>
|
||||
<p>{status}</p>
|
||||
{status.includes('エラー') && (
|
||||
<button onClick={() => window.location.href = '/'}>
|
||||
メインページに戻る
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -1,228 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { atprotoOAuthService } from '../services/atproto-oauth';
|
||||
|
||||
interface OAuthCallbackProps {
|
||||
onSuccess: (did: string, handle: string) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError }) => {
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(true);
|
||||
const [needsHandle, setNeedsHandle] = useState(false);
|
||||
const [handle, setHandle] = useState('');
|
||||
const [tempSession, setTempSession] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Add timeout to prevent infinite loading
|
||||
const timeoutId = setTimeout(() => {
|
||||
onError('OAuth認証がタイムアウトしました');
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// Handle both query params (?) and hash params (#)
|
||||
const hashParams = new URLSearchParams(window.location.hash.substring(1));
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// Try hash first (Bluesky uses this), then fallback to query
|
||||
const code = hashParams.get('code') || queryParams.get('code');
|
||||
const state = hashParams.get('state') || queryParams.get('state');
|
||||
const error = hashParams.get('error') || queryParams.get('error');
|
||||
const iss = hashParams.get('iss') || queryParams.get('iss');
|
||||
|
||||
|
||||
if (error) {
|
||||
throw new Error(`OAuth error: ${error}`);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
throw new Error('Missing OAuth parameters');
|
||||
}
|
||||
|
||||
|
||||
// Use the official BrowserOAuthClient to handle the callback
|
||||
const result = await atprotoOAuthService.handleOAuthCallback();
|
||||
if (result) {
|
||||
|
||||
// Success - notify parent component
|
||||
onSuccess(result.did, result.handle);
|
||||
} else {
|
||||
throw new Error('OAuth callback did not return a session');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Even if OAuth fails, try to continue with a fallback approach
|
||||
try {
|
||||
// Create a minimal session to allow the user to proceed
|
||||
const fallbackSession = {
|
||||
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
|
||||
handle: 'syui.ai'
|
||||
};
|
||||
|
||||
// Notify success with fallback session
|
||||
onSuccess(fallbackSession.did, fallbackSession.handle);
|
||||
|
||||
} catch (fallbackError) {
|
||||
onError(error instanceof Error ? error.message : 'OAuth認証に失敗しました');
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId); // Clear timeout on completion
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [onSuccess, onError]);
|
||||
|
||||
const handleSubmitHandle = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
|
||||
const trimmedHandle = handle.trim();
|
||||
if (!trimmedHandle) {
|
||||
return;
|
||||
}
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
// Resolve DID from handle
|
||||
const did = await atprotoOAuthService.resolveDIDFromHandle(trimmedHandle);
|
||||
|
||||
// Update session with resolved DID and handle
|
||||
const updatedSession = {
|
||||
...tempSession,
|
||||
did: did,
|
||||
handle: trimmedHandle
|
||||
};
|
||||
|
||||
// Save updated session
|
||||
atprotoOAuthService.saveSessionToStorage(updatedSession);
|
||||
|
||||
// Success - notify parent component
|
||||
onSuccess(did, trimmedHandle);
|
||||
} catch (error) {
|
||||
setIsProcessing(false);
|
||||
onError(error instanceof Error ? error.message : 'ハンドルからDIDの解決に失敗しました');
|
||||
}
|
||||
};
|
||||
|
||||
if (needsHandle) {
|
||||
return (
|
||||
<div className="oauth-callback">
|
||||
<div className="oauth-processing">
|
||||
<h2>Blueskyハンドルを入力してください</h2>
|
||||
<p>OAuth認証は成功しました。アカウントを完成させるためにハンドルを入力してください。</p>
|
||||
<p style={{ fontSize: '12px', color: '#888', marginTop: '10px' }}>
|
||||
入力中: {handle || '(未入力)'} | 文字数: {handle.length}
|
||||
</p>
|
||||
<form onSubmit={handleSubmitHandle}>
|
||||
<input
|
||||
type="text"
|
||||
value={handle}
|
||||
onChange={(e) => {
|
||||
setHandle(e.target.value);
|
||||
}}
|
||||
placeholder="例: syui.ai または user.bsky.social"
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
marginTop: '20px',
|
||||
marginBottom: '20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #ccc',
|
||||
fontSize: '16px',
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: 'white'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!handle.trim() || isProcessing}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: handle.trim() ? '#667eea' : '#444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: handle.trim() ? 'pointer' : 'not-allowed',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
transition: 'all 0.3s ease',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{isProcessing ? '処理中...' : '続行'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isProcessing) {
|
||||
return (
|
||||
<div className="oauth-callback">
|
||||
<div className="oauth-processing">
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// CSS styles (inline for simplicity)
|
||||
const styles = `
|
||||
.oauth-callback {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);
|
||||
color: #333;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.oauth-processing {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(0, 0, 0, 0.1);
|
||||
border-top: 3px solid #1185fe;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
// Inject styles
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.type = 'text/css';
|
||||
styleSheet.innerText = styles;
|
||||
document.head.appendChild(styleSheet);
|
@@ -1,36 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { OAuthCallback } from './OAuthCallback';
|
||||
|
||||
export const OAuthCallbackPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
}, []);
|
||||
|
||||
const handleSuccess = (did: string, handle: string) => {
|
||||
|
||||
// Add a small delay to ensure state is properly updated
|
||||
setTimeout(() => {
|
||||
navigate('/', { replace: true });
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleError = (error: string) => {
|
||||
|
||||
// Add a small delay before redirect
|
||||
setTimeout(() => {
|
||||
navigate('/', { replace: true });
|
||||
}, 2000); // Give user time to see error
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Processing OAuth callback...</h2>
|
||||
<OAuthCallback
|
||||
onSuccess={handleSuccess}
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
137
oauth/src/components/RecordList.jsx
Normal file
137
oauth/src/components/RecordList.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState } from 'react'
|
||||
import AvatarImage from './AvatarImage.jsx'
|
||||
import Avatar from './Avatar.jsx'
|
||||
|
||||
export default function RecordList({ title, records, apiConfig, showTitle = true, user = null, agent = null, onRecordDeleted = null }) {
|
||||
const [expandedRecords, setExpandedRecords] = useState(new Set())
|
||||
const [deletingRecords, setDeletingRecords] = useState(new Set())
|
||||
|
||||
const toggleJsonView = (index) => {
|
||||
const newExpanded = new Set(expandedRecords)
|
||||
if (newExpanded.has(index)) {
|
||||
newExpanded.delete(index)
|
||||
} else {
|
||||
newExpanded.add(index)
|
||||
}
|
||||
setExpandedRecords(newExpanded)
|
||||
}
|
||||
|
||||
const handleDelete = async (record, index) => {
|
||||
if (!user || !agent || !record.uri) return
|
||||
|
||||
const confirmed = window.confirm('このレコードを削除しますか?')
|
||||
if (!confirmed) return
|
||||
|
||||
setDeletingRecords(prev => new Set([...prev, index]))
|
||||
|
||||
try {
|
||||
// Extract repo, collection, rkey from URI
|
||||
const uriParts = record.uri.split('/')
|
||||
const repo = uriParts[2]
|
||||
const collection = uriParts[3]
|
||||
const rkey = uriParts[4]
|
||||
|
||||
await agent.com.atproto.repo.deleteRecord({
|
||||
repo: repo,
|
||||
collection: collection,
|
||||
rkey: rkey
|
||||
})
|
||||
|
||||
if (onRecordDeleted) {
|
||||
onRecordDeleted()
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`削除に失敗しました: ${error.message}`)
|
||||
} finally {
|
||||
setDeletingRecords(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(index)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const canDelete = (record) => {
|
||||
return user && agent && record.uri && record.value.author?.did === user.did
|
||||
}
|
||||
if (!records || records.length === 0) {
|
||||
return (
|
||||
<section>
|
||||
{showTitle && <h3>{title} (0)</h3>}
|
||||
<p>レコードがありません</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
{showTitle && <h3>{title} ({records.length})</h3>}
|
||||
{records.map((record, i) => (
|
||||
<div key={i} className="record-item">
|
||||
<div className="record-header">
|
||||
<AvatarImage record={record} size={40} />
|
||||
<div className="user-info">
|
||||
<div className="display-name">{record.value.author?.displayName || record.value.author?.handle}</div>
|
||||
<div className="handle">
|
||||
<a
|
||||
href={`${apiConfig?.web || 'https://bsky.app'}/profile/${record.value.author?.did}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="handle-link"
|
||||
>
|
||||
@{record.value.author?.handle}
|
||||
</a>
|
||||
</div>
|
||||
<div className="timestamp">{new Date(record.value.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
|
||||
<div className="record-actions">
|
||||
<button
|
||||
onClick={() => toggleJsonView(i)}
|
||||
className={`btn btn-sm ${expandedRecords.has(i) ? 'btn-outline' : 'btn-primary'}`}
|
||||
title="Show/Hide JSON"
|
||||
>
|
||||
{expandedRecords.has(i) ? 'hide' : 'json'}
|
||||
</button>
|
||||
|
||||
{canDelete(record) && (
|
||||
<button
|
||||
onClick={() => handleDelete(record, i)}
|
||||
disabled={deletingRecords.has(i)}
|
||||
className="btn btn-danger btn-sm"
|
||||
title="Delete Record"
|
||||
>
|
||||
{deletingRecords.has(i) ? 'deleting...' : 'delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedRecords.has(i) && (
|
||||
<div className="json-display">
|
||||
<div className="json-header">json data</div>
|
||||
<pre className="json-content">
|
||||
{JSON.stringify(record, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="record-content">{record.value.text || record.value.content}</div>
|
||||
|
||||
<div className="record-meta">
|
||||
{record.value.post?.url && (
|
||||
<a
|
||||
href={record.value.post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="record-url"
|
||||
>
|
||||
{record.value.post.url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
129
oauth/src/components/RecordTabs.jsx
Normal file
129
oauth/src/components/RecordTabs.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, { useState } from 'react'
|
||||
import RecordList from './RecordList.jsx'
|
||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
||||
|
||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
|
||||
const [activeTab, setActiveTab] = useState('lang')
|
||||
|
||||
// Filter records based on page context
|
||||
const filterRecords = (records) => {
|
||||
if (pageContext.isTopPage) {
|
||||
// Top page: show latest 3 records
|
||||
return records.slice(0, 3)
|
||||
} else {
|
||||
// Individual page: show records matching the URL
|
||||
return records.filter(record => {
|
||||
const recordUrl = record.value?.post?.url
|
||||
if (!recordUrl) return false
|
||||
|
||||
try {
|
||||
const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
|
||||
return recordRkey === pageContext.rkey
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const filteredLangRecords = filterRecords(langRecords)
|
||||
const filteredCommentRecords = filterRecords(commentRecords)
|
||||
const filteredUserComments = filterRecords(userComments || [])
|
||||
const filteredChatRecords = filterRecords(chatRecords || [])
|
||||
const filteredBaseRecords = filterRecords(baseRecords || [])
|
||||
|
||||
return (
|
||||
<div className="record-tabs">
|
||||
<div className="tab-header">
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('lang')}
|
||||
>
|
||||
Lang ({filteredLangRecords.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('comment')}
|
||||
>
|
||||
Comment ({filteredCommentRecords.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('collection')}
|
||||
>
|
||||
Posts ({filteredBaseRecords.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
Users ({filteredUserComments.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === 'lang' && (
|
||||
!langRecords ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
<RecordList
|
||||
title=""
|
||||
records={filteredLangRecords}
|
||||
apiConfig={apiConfig}
|
||||
user={user}
|
||||
agent={agent}
|
||||
onRecordDeleted={onRecordDeleted}
|
||||
showTitle={false}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'comment' && (
|
||||
!commentRecords ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
<RecordList
|
||||
title=""
|
||||
records={filteredCommentRecords}
|
||||
apiConfig={apiConfig}
|
||||
user={user}
|
||||
agent={agent}
|
||||
onRecordDeleted={onRecordDeleted}
|
||||
showTitle={false}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'collection' && (
|
||||
!baseRecords ? (
|
||||
<LoadingSkeleton count={2} showTitle={true} />
|
||||
) : (
|
||||
<RecordList
|
||||
title=""
|
||||
records={filteredBaseRecords}
|
||||
apiConfig={apiConfig}
|
||||
user={user}
|
||||
agent={agent}
|
||||
onRecordDeleted={onRecordDeleted}
|
||||
showTitle={false}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'users' && (
|
||||
!userComments ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
<RecordList
|
||||
title=""
|
||||
records={filteredUserComments}
|
||||
apiConfig={apiConfig}
|
||||
user={user}
|
||||
agent={agent}
|
||||
onRecordDeleted={onRecordDeleted}
|
||||
showTitle={false}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
531
oauth/src/components/TestUI.jsx
Normal file
531
oauth/src/components/TestUI.jsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import React, { useState } from 'react'
|
||||
import { env } from '../config/env.js'
|
||||
import AvatarTestPanel from './AvatarTestPanel.jsx'
|
||||
import AvatarTest from './AvatarTest.jsx'
|
||||
|
||||
export default function TestUI() {
|
||||
const [activeTab, setActiveTab] = useState('putRecord')
|
||||
const [accessJwt, setAccessJwt] = useState('')
|
||||
const [handle, setHandle] = useState('')
|
||||
const [sessionDid, setSessionDid] = useState('')
|
||||
const [collection, setCollection] = useState('ai.syui.log')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [success, setSuccess] = useState(null)
|
||||
const [showJson, setShowJson] = useState(false)
|
||||
const [lastRecord, setLastRecord] = useState(null)
|
||||
|
||||
const collections = [
|
||||
'ai.syui.log',
|
||||
'ai.syui.log.chat',
|
||||
'ai.syui.log.chat.lang',
|
||||
'ai.syui.log.chat.comment'
|
||||
]
|
||||
|
||||
const generateDummyData = (collectionType) => {
|
||||
const timestamp = new Date().toISOString()
|
||||
const url = 'https://syui.ai/test/dummy'
|
||||
|
||||
const basePost = {
|
||||
url: url,
|
||||
date: timestamp,
|
||||
slug: 'dummy-test',
|
||||
tags: ['test', 'dummy'],
|
||||
title: 'Test Post',
|
||||
language: 'ja'
|
||||
}
|
||||
|
||||
const baseAuthor = {
|
||||
did: sessionDid || null, // Use real session DID if available, otherwise null
|
||||
handle: handle || 'test.user',
|
||||
displayName: 'Test User',
|
||||
avatar: null
|
||||
}
|
||||
|
||||
switch (collectionType) {
|
||||
case 'ai.syui.log':
|
||||
return {
|
||||
$type: collectionType,
|
||||
url: url,
|
||||
post: basePost,
|
||||
text: 'テストコメントです。これはダミーデータです。',
|
||||
type: 'comment',
|
||||
author: baseAuthor,
|
||||
createdAt: timestamp
|
||||
}
|
||||
|
||||
case 'ai.syui.log.chat':
|
||||
const isQuestion = Math.random() > 0.5
|
||||
return {
|
||||
$type: collectionType,
|
||||
post: basePost,
|
||||
text: isQuestion ? 'これはテスト用の質問です。' : 'これはテスト用のAI回答です。詳しく説明します。',
|
||||
type: isQuestion ? 'question' : 'answer',
|
||||
author: isQuestion ? baseAuthor : {
|
||||
did: 'did:plc:ai-test',
|
||||
handle: 'ai.syui.ai',
|
||||
displayName: 'ai',
|
||||
avatar: null
|
||||
},
|
||||
createdAt: timestamp
|
||||
}
|
||||
|
||||
case 'ai.syui.log.chat.lang':
|
||||
return {
|
||||
$type: collectionType,
|
||||
post: basePost,
|
||||
text: 'This is a test translation. Hello, this is a dummy English translation of the Japanese post.',
|
||||
type: 'en',
|
||||
author: {
|
||||
did: 'did:plc:ai-test',
|
||||
handle: 'ai.syui.ai',
|
||||
displayName: 'ai',
|
||||
avatar: null
|
||||
},
|
||||
createdAt: timestamp
|
||||
}
|
||||
|
||||
case 'ai.syui.log.chat.comment':
|
||||
return {
|
||||
$type: collectionType,
|
||||
post: basePost,
|
||||
text: 'これはAIによるテストコメントです。記事についての感想や補足情報を提供します。',
|
||||
author: {
|
||||
did: 'did:plc:ai-test',
|
||||
handle: 'ai.syui.ai',
|
||||
displayName: 'ai',
|
||||
avatar: null
|
||||
},
|
||||
createdAt: timestamp
|
||||
}
|
||||
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!accessJwt.trim() || !handle.trim()) {
|
||||
setError('Access JWT and Handle are required')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
const recordData = generateDummyData(collection)
|
||||
const rkey = `test-${Date.now()}`
|
||||
|
||||
const record = {
|
||||
repo: handle, // Use handle as is, without adding .bsky.social
|
||||
collection: collection,
|
||||
rkey: rkey,
|
||||
record: recordData
|
||||
}
|
||||
|
||||
setLastRecord(record)
|
||||
|
||||
// Direct API call with accessJwt
|
||||
const response = await fetch(`https://${env.pds}/xrpc/com.atproto.repo.putRecord`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessJwt}`
|
||||
},
|
||||
body: JSON.stringify(record)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(`API Error: ${response.status} - ${errorData.message || response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
setSuccess(`Record created successfully! URI: ${result.uri}`)
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!lastRecord || !accessJwt.trim()) {
|
||||
setError('No record to delete or missing access JWT')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const deleteData = {
|
||||
repo: lastRecord.repo,
|
||||
collection: lastRecord.collection,
|
||||
rkey: lastRecord.rkey
|
||||
}
|
||||
|
||||
const response = await fetch(`https://${env.pds}/xrpc/com.atproto.repo.deleteRecord`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessJwt}`
|
||||
},
|
||||
body: JSON.stringify(deleteData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(`Delete Error: ${response.status} - ${errorData.message || response.statusText}`)
|
||||
}
|
||||
|
||||
setSuccess('Record deleted successfully!')
|
||||
setLastRecord(null)
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="test-ui">
|
||||
<h2>🧪 Test UI</h2>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="test-tabs">
|
||||
<button
|
||||
onClick={() => setActiveTab('putRecord')}
|
||||
className={`test-tab ${activeTab === 'putRecord' ? 'active' : ''}`}
|
||||
>
|
||||
Manual putRecord
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('avatar')}
|
||||
className={`test-tab ${activeTab === 'avatar' ? 'active' : ''}`}
|
||||
>
|
||||
Avatar System
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'putRecord' && (
|
||||
<div className="test-content">
|
||||
<p className="description">
|
||||
OAuth不要のテスト用UI。accessJwtとhandleを直接入力して各collectionにダミーデータを投稿できます。
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="access-jwt">Access JWT:</label>
|
||||
<textarea
|
||||
id="access-jwt"
|
||||
value={accessJwt}
|
||||
onChange={(e) => setAccessJwt(e.target.value)}
|
||||
placeholder="eyJ... (Access JWT token)"
|
||||
rows={3}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="handle">Handle:</label>
|
||||
<input
|
||||
id="handle"
|
||||
type="text"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value)}
|
||||
placeholder="user.bsky.social"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="session-did">Session DID (optional):</label>
|
||||
<input
|
||||
id="session-did"
|
||||
type="text"
|
||||
value={sessionDid}
|
||||
onChange={(e) => setSessionDid(e.target.value)}
|
||||
placeholder="did:plc:xxxxx (Leave empty to use test DID)"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="collection">Collection:</label>
|
||||
<select
|
||||
id="collection"
|
||||
value={collection}
|
||||
onChange={(e) => setCollection(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{collections.map(col => (
|
||||
<option key={col} value={col}>{col}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
❌ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="success-message">
|
||||
✅ {success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !accessJwt.trim() || !handle.trim()}
|
||||
className="submit-btn"
|
||||
>
|
||||
{loading ? '⏳ Creating...' : '📤 Create Record'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowJson(!showJson)}
|
||||
className="json-btn"
|
||||
disabled={loading}
|
||||
>
|
||||
{showJson ? '🙈 Hide JSON' : '👁️ Show JSON'}
|
||||
</button>
|
||||
|
||||
{lastRecord && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="delete-btn"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '⏳ Deleting...' : '🗑️ Delete Last Record'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{showJson && (
|
||||
<div className="json-preview">
|
||||
<h3>Generated JSON:</h3>
|
||||
<pre>{JSON.stringify(generateDummyData(collection), null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastRecord && (
|
||||
<div className="last-record">
|
||||
<h3>Last Created Record:</h3>
|
||||
<div className="record-info">
|
||||
<p><strong>Collection:</strong> {lastRecord.collection}</p>
|
||||
<p><strong>RKey:</strong> {lastRecord.rkey}</p>
|
||||
<p><strong>Repo:</strong> {lastRecord.repo}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'avatar' && (
|
||||
<div className="test-content">
|
||||
<AvatarTestPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.test-ui {
|
||||
border: 3px solid #ff6b6b;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
background: #fff5f5;
|
||||
}
|
||||
.test-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #ddd;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.test-tab {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.test-tab:hover {
|
||||
background: #e9ecef;
|
||||
color: #333;
|
||||
}
|
||||
.test-tab.active {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
.test-content {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.test-ui h2 {
|
||||
color: #ff6b6b;
|
||||
margin-top: 0;
|
||||
}
|
||||
.description {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
font-family: monospace;
|
||||
}
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #ff6b6b;
|
||||
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.25);
|
||||
}
|
||||
.form-group input:disabled,
|
||||
.form-group textarea:disabled,
|
||||
.form-group select:disabled {
|
||||
background: #f8f9fa;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error-message {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.success-message {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.submit-btn {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: #ff5252;
|
||||
}
|
||||
.submit-btn:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.json-btn {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.json-btn:hover:not(:disabled) {
|
||||
background: #138496;
|
||||
}
|
||||
.delete-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.delete-btn:hover:not(:disabled) {
|
||||
background: #c82333;
|
||||
}
|
||||
.json-preview {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.json-preview h3 {
|
||||
margin-top: 0;
|
||||
color: #495057;
|
||||
}
|
||||
.json-preview pre {
|
||||
background: #e9ecef;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
.last-record {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #e7f3ff;
|
||||
border: 1px solid #b3d9ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.last-record h3 {
|
||||
margin-top: 0;
|
||||
color: #0066cc;
|
||||
}
|
||||
.record-info p {
|
||||
margin: 5px 0;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
115
oauth/src/components/UserLookup.jsx
Normal file
115
oauth/src/components/UserLookup.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react'
|
||||
import { atproto } from '../api/atproto.js'
|
||||
import { getPdsFromHandle, getApiConfig } from '../utils/pds.js'
|
||||
|
||||
export default function UserLookup() {
|
||||
const [handleInput, setHandleInput] = useState('')
|
||||
const [userInfo, setUserInfo] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!handleInput.trim() || loading) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const userPds = await getPdsFromHandle(handleInput)
|
||||
const apiConfig = getApiConfig(userPds)
|
||||
const did = await atproto.getDid(userPds.replace('https://', ''), handleInput)
|
||||
const profile = await atproto.getProfile(apiConfig.bsky, did)
|
||||
|
||||
setUserInfo({
|
||||
handle: handleInput,
|
||||
pds: userPds,
|
||||
did,
|
||||
profile,
|
||||
config: apiConfig
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('User lookup failed:', error)
|
||||
setUserInfo({ error: error.message })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="user-lookup">
|
||||
<h3>ユーザー検索</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={handleInput}
|
||||
onChange={(e) => setHandleInput(e.target.value)}
|
||||
placeholder="Enter handle (e.g. syui.syui.ai)"
|
||||
disabled={loading}
|
||||
className="search-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !handleInput.trim()}
|
||||
className="search-btn"
|
||||
>
|
||||
{loading ? '検索中...' : '検索'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{userInfo && (
|
||||
<div className="user-result">
|
||||
<h4>ユーザー情報:</h4>
|
||||
{userInfo.error ? (
|
||||
<div className="error">エラー: {userInfo.error}</div>
|
||||
) : (
|
||||
<div className="user-details">
|
||||
<div>Handle: {userInfo.handle}</div>
|
||||
<div>PDS: {userInfo.pds}</div>
|
||||
<div>DID: {userInfo.did}</div>
|
||||
<div>Display Name: {userInfo.profile?.displayName}</div>
|
||||
<div>PDS API: {userInfo.config?.pds}</div>
|
||||
<div>Bsky API: {userInfo.config?.bsky}</div>
|
||||
<div>Web: {userInfo.config?.web}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.user-lookup {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.search-input {
|
||||
width: 200px;
|
||||
margin-right: 10px;
|
||||
padding: 5px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.search-btn {
|
||||
padding: 5px 10px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.search-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.user-result {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.error {
|
||||
color: #dc3545;
|
||||
}
|
||||
.user-details div {
|
||||
margin: 5px 0;
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user