test blog profile

This commit is contained in:
2025-06-25 20:17:31 +09:00
parent 7791399314
commit 73b5982b36
8 changed files with 526 additions and 4 deletions

View File

@@ -200,7 +200,7 @@ a.view-markdown:any-link {
/* Main Content */ /* Main Content */
.main-content { .main-content {
grid-area: main; grid-area: main;
max-width: 1000px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 0px; padding: 0px;
width: 100%; width: 100%;
@@ -969,6 +969,41 @@ article.article-content {
.question-form { .question-form {
padding: 12px !important; padding: 12px !important;
} }
}
/* Profile Display Styles */
.profile-avatar-fallback {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--theme-color);
color: var(--white);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
}
.admin-badge {
background: var(--theme-color);
color: var(--white);
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
font-weight: 500;
margin-left: 8px;
}
.loading-message, .error-message, .no-profiles {
text-align: center;
padding: 20px;
color: var(--dark-gray);
}
.error-message {
color: #d32f2f;
}
.input-container { .input-container {
flex-direction: column !important; flex-direction: column !important;

View File

@@ -37,10 +37,74 @@ function checkAuthenticationStatus() {
document.getElementById('aiQuestion').focus(); document.getElementById('aiQuestion').focus();
}, 50); }, 50);
} else { } else {
// User not authenticated - show auth message // User not authenticated - show profiles instead of auth message
document.getElementById('authCheck').style.display = 'block'; document.getElementById('authCheck').style.display = 'none';
document.getElementById('chatForm').style.display = 'none'; document.getElementById('chatForm').style.display = 'none';
document.getElementById('chatHistory').style.display = 'none'; document.getElementById('chatHistory').style.display = 'block';
loadAndShowProfiles();
}
}
// Load and display profiles from ai.syui.log.profile collection
async function loadAndShowProfiles() {
const chatHistory = document.getElementById('chatHistory');
chatHistory.innerHTML = '<div class="loading-message">Loading profiles...</div>';
try {
const ADMIN_HANDLE = 'ai.syui.ai';
const OAUTH_COLLECTION = 'ai.syui.log';
const ATPROTO_PDS = 'syu.is';
const response = await fetch(`https://${ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${ADMIN_HANDLE}&collection=${OAUTH_COLLECTION}.profile&limit=100`);
if (!response.ok) {
throw new Error('Failed to fetch profiles');
}
const data = await response.json();
const profiles = (data.records || []).sort((a, b) => {
if (a.value.type === 'admin' && b.value.type !== 'admin') return -1;
if (a.value.type !== 'admin' && b.value.type === 'admin') return 1;
return 0;
});
// Clear loading message
chatHistory.innerHTML = '';
// Display profiles using the same format as chat
profiles.forEach(profile => {
const profileDiv = document.createElement('div');
profileDiv.className = 'chat-message ai-message comment-style';
const avatarElement = profile.value.author.avatar
? `<img src="${profile.value.author.avatar}" alt="${profile.value.author.displayName || profile.value.author.handle}" class="profile-avatar">`
: `<div class="profile-avatar-fallback">${(profile.value.author.displayName || profile.value.author.handle || '?').charAt(0).toUpperCase()}</div>`;
const adminBadge = profile.value.type === 'admin'
? '<span class="admin-badge">Admin</span>'
: '';
profileDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${profile.value.author.displayName || profile.value.author.handle} ${adminBadge}</div>
<div class="handle">@${profile.value.author.handle}</div>
<div class="timestamp">${new Date(profile.value.createdAt).toLocaleString()}</div>
</div>
</div>
<div class="message-content">${profile.value.text}</div>
`;
chatHistory.appendChild(profileDiv);
});
if (profiles.length === 0) {
chatHistory.innerHTML = '<div class="no-profiles">No profiles available</div>';
}
} catch (error) {
console.error('Error loading profiles:', error);
chatHistory.innerHTML = '<div class="error-message">Failed to load profiles. Please try again later.</div>';
} }
} }

View File

@@ -34,6 +34,96 @@ body {
background: var(--background); background: var(--background);
} }
/* Profile Form Styles */
.profile-form-container {
background: var(--background-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.profile-form-container h3 {
margin: 0 0 16px 0;
color: var(--text);
}
.profile-form .form-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.profile-form .form-group {
flex: 1;
}
.profile-form .form-group label {
display: block;
margin-bottom: 6px;
font-weight: 600;
color: var(--text);
}
.profile-form .form-group input,
.profile-form .form-group select,
.profile-form .form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
.profile-form .form-group input:focus,
.profile-form .form-group select:focus,
.profile-form .form-group textarea:focus {
outline: none;
border-color: var(--primary);
}
.profile-form .form-group textarea {
resize: vertical;
min-height: 80px;
}
.profile-form .submit-btn {
background: var(--primary);
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
}
.profile-form .submit-btn:hover:not(:disabled) {
background: var(--primary-hover);
}
.profile-form .submit-btn:disabled {
background: var(--text-secondary);
cursor: not-allowed;
}
/* Profile Record List Styles */
.profile-record-list .record-item.admin {
border-left: 4px solid var(--primary);
}
.profile-record-list .admin-badge {
background: var(--primary);
color: white;
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
font-weight: 500;
margin-left: 8px;
}
/* Header */ /* Header */
.oauth-app-header { .oauth-app-header {
background: var(--background); background: var(--background);

View File

@@ -7,6 +7,7 @@ import { usePageContext } from './hooks/usePageContext.js'
import AuthButton from './components/AuthButton.jsx' import AuthButton from './components/AuthButton.jsx'
import RecordTabs from './components/RecordTabs.jsx' import RecordTabs from './components/RecordTabs.jsx'
import CommentForm from './components/CommentForm.jsx' import CommentForm from './components/CommentForm.jsx'
import ProfileForm from './components/ProfileForm.jsx'
import AskAI from './components/AskAI.jsx' import AskAI from './components/AskAI.jsx'
import TestUI from './components/TestUI.jsx' import TestUI from './components/TestUI.jsx'
import OAuthCallback from './components/OAuthCallback.jsx' import OAuthCallback from './components/OAuthCallback.jsx'
@@ -428,6 +429,20 @@ Answer:`
</div> </div>
)} )}
{user && (
<div className="profile-form">
<ProfileForm
user={user}
agent={agent}
apiConfig={adminData.apiConfig}
onProfilePosted={() => {
refreshAdminData?.()
refreshUserData?.()
}}
/>
</div>
)}
<RecordTabs <RecordTabs
langRecords={langRecords} langRecords={langRecords}
commentRecords={commentRecords} commentRecords={commentRecords}

View File

@@ -179,6 +179,16 @@ export const collections = {
return data return data
}, },
async getProfiles(pds, repo, collection, limit = 100) {
const cacheKey = dataCache.generateKey('profiles', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.profile`, limit)
dataCache.set(cacheKey, data)
return data
},
// 投稿後にキャッシュを無効化 // 投稿後にキャッシュを無効化
invalidateCache(collection) { invalidateCache(collection) {
dataCache.invalidatePattern(collection) dataCache.invalidatePattern(collection)

View File

@@ -0,0 +1,160 @@
import React, { useState } from 'react'
import { atproto } from '../api/atproto.js'
import { collections } from '../api/atproto.js'
const ProfileForm = ({ user, agent, apiConfig, onProfilePosted }) => {
const [text, setText] = useState('')
const [type, setType] = useState('user')
const [handle, setHandle] = useState('')
const [rkey, setRkey] = useState('')
const [posting, setPosting] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
if (!text.trim() || !handle.trim() || !rkey.trim()) {
setError('すべてのフィールドを入力してください')
return
}
setPosting(true)
setError('')
try {
// Get handle information
let authorData
try {
const handleDid = await atproto.getDid(apiConfig.pds, handle)
authorData = await atproto.getProfile(apiConfig.bsky, handleDid)
} catch (err) {
throw new Error('ハンドルが見つかりません')
}
// Create record
const record = {
repo: user.did,
collection: `${apiConfig.collection}.profile`,
rkey: rkey,
record: {
$type: `${apiConfig.collection}.profile`,
text: text,
type: type,
author: {
did: authorData.did,
handle: authorData.handle,
displayName: authorData.displayName || authorData.handle,
avatar: authorData.avatar || null
},
createdAt: new Date().toISOString(),
post: {
url: window.location.origin,
date: new Date().toISOString(),
slug: '',
tags: [],
title: 'Profile',
language: 'ja'
}
}
}
await atproto.putRecord(apiConfig.pds, record, agent)
// Invalidate cache and refresh
collections.invalidateCache(`${apiConfig.collection}.profile`)
// Reset form
setText('')
setType('user')
setHandle('')
setRkey('')
if (onProfilePosted) {
onProfilePosted()
}
} catch (err) {
console.error('Failed to create profile:', err)
setError(err.message || 'プロフィールの作成に失敗しました')
} finally {
setPosting(false)
}
}
if (!user) {
return null
}
return (
<div className="profile-form-container">
<h3>プロフィール投稿</h3>
{error && (
<div className="error-message">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="profile-form">
<div className="form-row">
<div className="form-group">
<label htmlFor="handle">ハンドル</label>
<input
type="text"
id="handle"
value={handle}
onChange={(e) => setHandle(e.target.value)}
placeholder="例: syui.ai"
required
/>
</div>
<div className="form-group">
<label htmlFor="rkey">Rkey</label>
<input
type="text"
id="rkey"
value={rkey}
onChange={(e) => setRkey(e.target.value)}
placeholder="例: syui"
required
/>
</div>
</div>
<div className="form-group">
<label htmlFor="type">タイプ</label>
<select
id="type"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="form-group">
<label htmlFor="text">プロフィールテキスト</label>
<textarea
id="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="プロフィールの説明を入力してください"
rows={4}
required
/>
</div>
<button
type="submit"
disabled={posting || !text.trim() || !handle.trim() || !rkey.trim()}
className="submit-btn"
>
{posting ? '投稿中...' : '投稿'}
</button>
</form>
</div>
)
}
export default ProfileForm

View File

@@ -0,0 +1,133 @@
import React, { useState, useEffect } from 'react'
import { collections } from '../api/atproto.js'
import AvatarImage from './AvatarImage.jsx'
import LoadingSkeleton from './LoadingSkeleton.jsx'
const ProfileRecordList = ({ apiConfig, user, agent, onRecordDeleted }) => {
const [profiles, setProfiles] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
if (apiConfig?.admin && apiConfig?.collection) {
fetchProfiles()
}
}, [apiConfig])
const fetchProfiles = async () => {
try {
setLoading(true)
setError(null)
const adminProfiles = await collections.getProfiles(
apiConfig.pds,
apiConfig.admin,
apiConfig.collection
)
// Sort profiles: admin type first, then user type
const sortedProfiles = adminProfiles.sort((a, b) => {
if (a.value.type === 'admin' && b.value.type !== 'admin') return -1
if (a.value.type !== 'admin' && b.value.type === 'admin') return 1
return 0
})
setProfiles(sortedProfiles)
} catch (err) {
console.error('Failed to fetch profiles:', err)
setError('プロフィールの読み込みに失敗しました')
} finally {
setLoading(false)
}
}
const handleDelete = async (uri) => {
if (!user || !agent) return
if (!confirm('このプロフィールを削除しますか?')) return
try {
const rkey = uri.split('/').pop()
await agent.api.com.atproto.repo.deleteRecord({
repo: user.did,
collection: `${apiConfig.collection}.profile`,
rkey: rkey
})
// Invalidate cache and refresh
collections.invalidateCache(`${apiConfig.collection}.profile`)
await fetchProfiles()
if (onRecordDeleted) {
onRecordDeleted()
}
} catch (err) {
console.error('Failed to delete profile:', err)
setError('プロフィールの削除に失敗しました')
}
}
if (loading) {
return <LoadingSkeleton count={3} showTitle={true} />
}
if (error) {
return (
<div className="error-state">
<p>{error}</p>
<button onClick={fetchProfiles} className="retry-btn">再試行</button>
</div>
)
}
if (profiles.length === 0) {
return (
<div className="empty-state">
<p>プロフィールがありません</p>
</div>
)
}
return (
<div className="record-list profile-record-list">
{profiles.map((profile) => (
<div key={profile.uri} className={`record-item comment-style ${profile.value.type}`}>
<div className="message-header">
<div className="avatar">
<AvatarImage
src={profile.value.author.avatar}
alt={profile.value.author.displayName || profile.value.author.handle}
size={40}
/>
</div>
<div className="user-info">
<div className="display-name">
{profile.value.author.displayName || profile.value.author.handle}
{profile.value.type === 'admin' && (
<span className="admin-badge">Admin</span>
)}
</div>
<div className="handle">@{profile.value.author.handle}</div>
<div className="timestamp">
{new Date(profile.value.createdAt).toLocaleString()}
</div>
</div>
{user && (
<div className="record-actions">
<button
onClick={() => handleDelete(profile.uri)}
className="delete-btn"
title="削除"
>
×
</button>
</div>
)}
</div>
<div className="message-content">{profile.value.text}</div>
</div>
))}
</div>
)
}
export default ProfileRecordList

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import RecordList from './RecordList.jsx' import RecordList from './RecordList.jsx'
import ChatRecordList from './ChatRecordList.jsx' import ChatRecordList from './ChatRecordList.jsx'
import ProfileRecordList from './ProfileRecordList.jsx'
import LoadingSkeleton from './LoadingSkeleton.jsx' import LoadingSkeleton from './LoadingSkeleton.jsx'
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) { export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
@@ -60,6 +61,12 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
> >
comment ({filteredUserComments.length}) comment ({filteredUserComments.length})
</button> </button>
<button
className={`tab-btn ${activeTab === 'profiles' ? 'active' : ''}`}
onClick={() => setActiveTab('profiles')}
>
profiles
</button>
</div> </div>
<div className="tab-content"> <div className="tab-content">
@@ -121,6 +128,14 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
/> />
) )
)} )}
{activeTab === 'profiles' && (
<ProfileRecordList
apiConfig={apiConfig}
user={user}
agent={agent}
onRecordDeleted={onRecordDeleted}
/>
)}
</div> </div>
</div> </div>