<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verse - 次世代ソーシャルネットワーク</title>
<!-- SVG ファビコンを指定 -->
<link rel="shortcut icon" href="favicon.ico">
<!-- PNG版 -->
<link rel="icon" type="image/png" href="favicon.png">
<!-- SVG版 -->
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.0/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/rita@3.0.0/dist/rita.min.js"></script>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.glass-effect {
background: rgba(255,255,255,0.25);
backdrop-filter: blur(10px);
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.18);
}
.card-hover { transition: all 0.3s ease; }
.card-hover:hover {
transform: translateY(-5px);
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04);
}
.gradient-text {
background: linear-gradient(45deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.timeline-post {
background: white;
border-radius: 15px;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
transition: all 0.3s ease;
border-left: 4px solid #667eea;
}
.timeline-post:hover {
transform: translateX(5px);
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);
}
.profile-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
border: 4px solid white;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.btn-primary {
background: linear-gradient(45deg, #667eea, #764ba2);
border: none;
transition: all 0.3s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 7px 14px rgba(0,0,0,0.18);
}
.section-divider {
height: 3px;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 2px;
margin: 2rem 0;
}
.username-badge {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
padding: 0.2rem 0.5rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
display: inline-block;
margin-left: 0.5rem;
}
.share-menu {
position: absolute;
z-index: 50;
min-width: 180px;
right: 0;
top: 110%;
background: white;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.13);
}
.share-menu button {
width: 100%;
text-align: left;
padding: 10px 20px;
border: none;
background: none;
cursor: pointer;
font-size: 0.95rem;
}
.share-menu button:hover { background: #f0f4ff; }
.status-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
}
.status-active {
background-color: #10b981;
animation: pulse 2s infinite;
}
.status-inactive { background-color: #6b7280; }
.log-container {
max-height: 150px;
overflow-y: auto;
background: rgba(255,255,255,0.1);
border-radius: 5px;
padding: 10px;
margin-top: 10px;
font-size: 0.8rem;
font-family: monospace;
}
.rss-item {
background: rgba(255,255,255,0.1);
border-radius: 5px;
padding: 8px;
margin: 5px 0;
font-size: 0.8rem;
}
.error-message {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
padding: 8px;
border-radius: 5px;
margin: 5px 0;
}
.success-message {
color: #10b981;
background: rgba(16, 185, 129, 0.1);
padding: 8px;
border-radius: 5px;
margin: 5px 0;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.dark .glass-effect {
background: rgba(0,0,0,0.3);
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.1);
}
.dark .timeline-post {
background: #374151;
color: #f9fafb;
}
@media print {
body { background: white !important; -webkit-print-color-adjust: exact; }
.glass-effect { background: white !important; backdrop-filter: none !important; border: 1px solid #e5e7eb !important; }
}
</style>
</head>
<body>
<header class="glass-effect mx-4 mt-4 p-6">
<div class="text-center">
<h1 class="text-4xl font-bold text-white mb-2">
<i class="fas fa-comments mr-3"></i>Verse
<span class="text-sm opacity-75 ml-2">v2.0 完全修正版</span>
</h1>
<p class="text-white text-lg opacity-90">次世代ソーシャルネットワーク</p>
<div class="mt-4 flex justify-center items-center space-x-4">
<div class="flex items-center">
<img id="header-profile-icon" class="profile-avatar" src="https://via.placeholder.com/100" alt="プロフィール">
<div class="ml-3 text-white">
<div class="font-semibold text-lg" id="header-username">未設定</div>
<div class="text-sm opacity-75">ユーザー</div>
</div>
</div>
<button onclick="toggleDarkMode()" class="btn-primary px-4 py-2 rounded-full text-white">
<i class="fas fa-moon mr-2"></i>ダークモード
</button>
<button onclick="showSystemStatus()" class="btn-primary px-4 py-2 rounded-full text-white">
<i class="fas fa-info-circle mr-2"></i>ステータス
</button>
</div>
</div>
</header>
<div class="max-w-6xl mx-auto px-4 py-6">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左側カラム -->
<div class="lg:col-span-1 space-y-6">
<!-- プロフィール -->
<div class="glass-effect p-6 card-hover">
<h3 class="text-2xl font-bold gradient-text mb-4">
<i class="fas fa-user-circle mr-2"></i>プロフィール
</h3>
<div class="text-center mb-6">
<img id="profile-icon" class="profile-avatar mx-auto mb-4" src="https://via.placeholder.com/100" alt="プロフィール">
<input type="file" id="profile-upload" accept="image/*" onchange="uploadProfileIcon(event)" class="hidden">
<button onclick="document.getElementById('profile-upload').click()" class="btn-primary px-4 py-2 rounded-full text-white">
<i class="fas fa-camera mr-2"></i>画像変更
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-white font-semibold mb-2">ユーザー名</label>
<input type="text" id="username" class="w-full p-3 border rounded-lg bg-white bg-opacity-90" placeholder="ユーザー名を入力してください" maxlength="20">
</div>
<div>
<label class="block text-white font-semibold mb-2">自己紹介</label>
<textarea id="self-intro" class="w-full p-3 border rounded-lg bg-white bg-opacity-90" rows="4" placeholder="自己紹介を入力してください"></textarea>
</div>
<button onclick="saveProfile()" class="btn-primary w-full py-2 rounded-lg text-white">
<i class="fas fa-save mr-2"></i>プロフィール保存
</button>
<div class="p-3 bg-white bg-opacity-80 rounded-lg">
<h5 class="font-semibold text-gray-800 mb-2">プレビュー:</h5>
<div class="text-gray-700">
<div class="font-semibold mb-1" id="username-preview">未設定</div>
<div id="self-intro-preview" class="text-sm whitespace-pre-line min-h-8">まだ自己紹介がありません</div>
</div>
</div>
</div>
</div>
<!-- BOT機能 -->
<div class="glass-effect p-6 card-hover">
<h3 class="text-xl font-bold text-white mb-4">
<i class="fas fa-robot mr-2"></i>BOT機能
<span class="status-indicator" id="bot-status"></span>
<span id="bot-status-text" class="text-xs opacity-75">停止中</span>
</h3>
<div class="space-y-4">
<div>
<textarea id="botContent" class="w-full p-3 border rounded-lg bg-white bg-opacity-90" rows="3" placeholder="BOT投稿内容"></textarea>
<button onclick="postBotMessage()" class="btn-primary w-full mt-2 py-2 rounded-lg text-white">
<i class="fas fa-robot mr-2"></i>BOT投稿
</button>
</div>
<div>
<input type="number" id="botIntervalSec" class="w-full p-2 border rounded-lg bg-white bg-opacity-90" placeholder="マルコフ自動投稿間隔(秒)" min="10" max="3600" value="60">
<div class="flex space-x-2 mt-2">
<button onclick="postMarkovBot()" class="btn-primary flex-1 py-2 rounded-lg text-white">
<i class="fas fa-dice mr-2"></i>マルコフ生成
</button>
<button onclick="startBotAutoPost()" class="btn-primary flex-1 py-2 rounded-lg text-white">
<i class="fas fa-play mr-2"></i>自動開始
</button>
<button onclick="stopBotAutoPost()" class="bg-red-500 hover:bg-red-600 flex-1 py-2 rounded-lg text-white">
<i class="fas fa-stop mr-2"></i>停止
</button>
</div>
</div>
<div class="text-white text-xs opacity-75">
<i class="fas fa-info-circle mr-1"></i>マルコフ連鎖では過去の投稿からランダムな文章を生成します
</div>
<div id="bot-log" class="log-container text-white text-xs"></div>
</div>
</div>
<!-- RSS機能 -->
<div class="glass-effect p-6 card-hover">
<h3 class="text-xl font-bold text-white mb-4">
<i class="fas fa-rss mr-2"></i>RSS機能
<span class="status-indicator" id="rss-status"></span>
<span id="rss-status-text" class="text-xs opacity-75">停止中</span>
</h3>
<div class="space-y-4">
<div>
<input type="text" id="feedUrl" class="w-full p-3 border rounded-lg bg-white bg-opacity-90" placeholder="RSS URL">
<button onclick="registerFeedUrl()" class="btn-primary w-full mt-2 py-2 rounded-lg text-white">
<i class="fas fa-plus mr-2"></i>フィード登録
</button>
</div>
<div>
<input type="number" id="feedIntervalSec" class="w-full p-2 border rounded-lg bg-white bg-opacity-90" placeholder="RSS自動取得間隔(秒)" min="60" max="3600" value="180">
<div class="flex space-x-2 mt-2">
<button onclick="fetchAllFeeds()" class="btn-primary flex-1 py-2 rounded-lg text-white">
<i class="fas fa-download mr-2"></i>手動取得
</button>
<button onclick="startRSSAutoPost()" class="btn-primary flex-1 py-2 rounded-lg text-white">
<i class="fas fa-play mr-2"></i>自動開始
</button>
<button onclick="stopRSSAutoPost()" class="bg-red-500 hover:bg-red-600 flex-1 py-2 rounded-lg text-white">
<i class="fas fa-stop mr-2"></i>停止
</button>
</div>
</div>
<div class="bg-white bg-opacity-80 rounded-lg p-3">
<div class="flex justify-between items-center mb-2">
<h5 class="font-semibold text-gray-800">登録済みフィード:</h5>
<button onclick="addDefaultFeeds()" class="text-xs bg-blue-500 text-white px-2 py-1 rounded">
デフォルト追加
</button>
</div>
<div id="feed-list" class="text-sm text-gray-700"></div>
</div>
<div id="rss-log" class="log-container text-white text-xs"></div>
</div>
</div>
</div>
<!-- 右側カラム -->
<div class="lg:col-span-2 space-y-6">
<!-- 新規投稿 -->
<div class="glass-effect p-6 card-hover">
<h3 class="text-2xl font-bold gradient-text mb-4">
<i class="fas fa-edit mr-2"></i>新規投稿
</h3>
<div>
<textarea id="postContent" class="w-full p-4 border rounded-lg bg-white bg-opacity-90" rows="4" placeholder="今何を考えていますか?" maxlength="500"></textarea>
<div class="mt-4 flex justify-between items-center">
<div class="text-white text-sm opacity-75">
<i class="fas fa-info-circle mr-1"></i>あなたの思いを共有しましょう
<span id="char-count" class="ml-2">(0/500)</span>
</div>
<button onclick="createUserPost()" class="btn-primary px-6 py-3 rounded-lg text-white font-semibold">
<i class="fas fa-paper-plane mr-2"></i>投稿する
</button>
</div>
</div>
</div>
<div class="section-divider"></div>
<!-- タイムライン -->
<div class="glass-effect p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-2xl font-bold text-white">
<i class="fas fa-stream mr-2"></i>タイムライン
<span id="post-count" class="text-sm font-normal opacity-75 ml-2">(0件の投稿)</span>
</h3>
<div class="flex space-x-2">
<button onclick="clearAllPosts()" class="bg-red-500 hover:bg-red-600 px-3 py-1 rounded text-white text-sm">
<i class="fas fa-trash mr-1"></i>全削除
</button>
<button onclick="exportData()" class="bg-green-500 hover:bg-green-600 px-3 py-1 rounded text-white text-sm">
<i class="fas fa-download mr-1"></i>エクスポート
</button>
</div>
</div>
<div id="timeline" class="space-y-4"></div>
<div id="empty-timeline" class="text-center py-12 text-white opacity-75">
<i class="fas fa-comments text-4xl mb-4"></i>
<p class="text-lg">まだ投稿がありません</p>
<p class="text-sm">最初の投稿をして、タイムラインを始めましょう!</p>
</div>
</div>
</div>
</div>
</div>
<footer class="glass-effect mx-4 mb-4 p-4 text-center">
<p class="text-white opacity-75">
<i class="fas fa-copyright mr-2"></i>2025 Verse - 次世代ソーシャルネットワーク v2.0
<span class="ml-4">
<i class="fas fa-bug mr-1"></i>RSS自動投稿完全修正版
</span>
</p>
</footer>
<script>
// === 設定とグローバル変数 ===
const DEFAULT_FEEDS = [
"https://feeds.feedburner.com/hatena/b/hotentry",
"https://gigazine.net/news/rss_2.0/",
"https://rss.cnn.com/rss/edition.rss",
"https://feeds.bbci.co.uk/news/rss.xml",
"https://www.reddit.com/r/worldnews/.rss"
];
const RSS_APIS = [
'https://api.rss2json.com/v1/api.json',
'https://cors-anywhere.herokuapp.com/',
'https://api.allorigins.win/get'
];
// グローバル状態
let posts = JSON.parse(localStorage.getItem('verse_posts') || '[]');
let feedUrls = JSON.parse(localStorage.getItem('verse_feeds') || '[]');
let profile = JSON.parse(localStorage.getItem('verse_profile') || '{}');
let processedItems = new Set(JSON.parse(localStorage.getItem('verse_processed') || '[]'));
// タイマーとステータス
let botInterval = null;
let rssInterval = null;
let isDarkMode = localStorage.getItem('verse_darkMode') === 'true';
let markovChain = null;
let isInitialized = false;
// 統計情報
let stats = {
totalPosts: 0,
rssSuccess: 0,
rssErrors: 0,
botPosts: 0,
lastRSSUpdate: null,
lastBotPost: null
};
// === 初期化処理 ===
function initializeApp() {
if (isInitialized) return;
// プロフィール初期化
if (!profile.icon) profile.icon = 'https://via.placeholder.com/100';
if (!profile.username) profile.username = 'ゲストユーザー';
if (!profile.selfIntro) profile.selfIntro = '';
// デフォルトフィード設定
if (feedUrls.length === 0) {
feedUrls = [...DEFAULT_FEEDS];
saveData();
addLog('rss-log', 'デフォルトRSSフィードを登録しました', 'success');
}
// UI初期化
updateAllUI();
updateStatusIndicators();
// ダークモード適用
if (isDarkMode) {
document.body.classList.add('dark');
document.body.style.background = 'linear-gradient(135deg, #1a202c 0%, #2d3748 100%)';
}
isInitialized = true;
addLog('rss-log', 'アプリケーション初期化完了', 'success');
addLog('bot-log', 'BOT機能初期化完了', 'success');
// 初回RSS取得(遅延実行)
setTimeout(() => {
addLog('rss-log', '初回RSS取得を開始します...', 'info');
fetchAllFeeds();
}, 3000);
}
// === データ管理 ===
function saveData() {
try {
localStorage.setItem('verse_posts', JSON.stringify(posts));
localStorage.setItem('verse_feeds', JSON.stringify(feedUrls));
localStorage.setItem('verse_profile', JSON.stringify(profile));
localStorage.setItem('verse_processed', JSON.stringify([...processedItems]));
localStorage.setItem('verse_darkMode', isDarkMode);
stats.totalPosts = posts.length;
} catch (error) {
console.error('データ保存エラー:', error);
addLog('rss-log', `データ保存エラー: ${error.message}`, 'error');
}
}
function clearAllPosts() {
if (confirm('すべての投稿を削除してもよろしいですか?\nこの操作は元に戻せません。')) {
posts = [];
processedItems.clear();
saveData();
renderTimeline();
addLog('rss-log', 'すべての投稿を削除しました', 'info');
addLog('bot-log', 'すべての投稿を削除しました', 'info');
}
}
function exportData() {
const exportData = {
posts: posts,
profile: profile,
feedUrls: feedUrls,
stats: stats,
exportDate: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `verse_export_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
addLog('rss-log', 'データをエクスポートしました', 'success');
}
// === ログ機能 ===
function addLog(elementId, message, type = 'info') {
const logElement = document.getElementById(elementId);
if (!logElement) return;
const timestamp = new Date().toLocaleTimeString('ja-JP');
const logEntry = document.createElement('div');
const typeClass = {
'error': 'error-message',
'success': 'success-message',
'info': 'text-white opacity-75'
}[type] || 'text-white opacity-75';
logEntry.className = typeClass;
logEntry.innerHTML = `<span class="opacity-75">[${timestamp}]</span> ${message}`;
logElement.appendChild(logEntry);
logElement.scrollTop = logElement.scrollHeight;
// ログ数制限
while (logElement.children.length > 50) {
logElement.removeChild(logElement.firstChild);
}
}
// === ステータス管理 ===
function updateStatusIndicators() {
const botIndicator = document.getElementById('bot-status');
const rssIndicator = document.getElementById('rss-status');
const botStatusText = document.getElementById('bot-status-text');
const rssStatusText = document.getElementById('rss-status-text');
if (botIndicator && botStatusText) {
const isActive = botInterval !== null;
botIndicator.className = `status-indicator ${isActive ? 'status-active' : 'status-inactive'}`;
botStatusText.textContent = isActive ? '動作中' : '停止中';
}
if (rssIndicator && rssStatusText) {
const isActive = rssInterval !== null;
rssIndicator.className = `status-indicator ${isActive ? 'status-active' : 'status-inactive'}`;
rssStatusText.textContent = isActive ? '動作中' : '停止中';
}
}
function showSystemStatus() {
const statusInfo = `
=== Verse システムステータス ===
総投稿数: ${stats.totalPosts}
RSS成功: ${stats.rssSuccess}
RSSエラー: ${stats.rssErrors}
BOT投稿数: ${stats.botPosts}
最後のRSS更新: ${stats.lastRSSUpdate || 'なし'}
最後のBOT投稿: ${stats.lastBotPost || 'なし'}
RSS自動取得: ${rssInterval ? '動作中' : '停止中'}
BOT自動投稿: ${botInterval ? '動作中' : '停止中'}
登録フィード数: ${feedUrls.length}
処理済みアイテム: ${processedItems.size}
`;
alert(statusInfo);
}
// === 投稿管理 ===
function createPost(content, type = 'user', username = null, icon = null) {
if (!content || !content.trim()) {
addLog('rss-log', '空のコンテンツは投稿できません', 'error');
return false;
}
// RSS重複チェック
if (type === 'feed') {
const urlMatch = content.match(/href="([^"]+)"/);
const url = urlMatch ? urlMatch[1] : null;
if (url && processedItems.has(url)) {
addLog('rss-log', `重複記事をスキップ: ${url.substring(0, 50)}...`, 'info');
return false;
}
if (url) {
processedItems.add(url);
}
}
const post = {
id: Date.now() + Math.random(),
content: content.trim(),
likes: 0,
timestamp: new Date().toLocaleString('ja-JP'),
type: type,
username: username || profile.username,
icon: icon || profile.icon
};
posts.unshift(post);
// 統計更新
if (type === 'bot' || type === 'markov') {
stats.botPosts++;
stats.lastBotPost = post.timestamp;
}
if (type === 'feed') {
stats.rssSuccess++;
stats.lastRSSUpdate = post.timestamp;
}
saveData();
renderTimeline();
return true;
}
function createUserPost() {
const content = document.getElementById('postContent').value.trim();
if (content) {
if (createPost(content, 'user')) {
document.getElementById('postContent').value = '';
updateCharCount();
addLog('bot-log', 'ユーザー投稿を作成しました', 'success');
}
}
}
function likePost(id) {
const post = posts.find(p => p.id === id);
if (post) {
post.likes++;
saveData();
renderTimeline();
}
}
function deletePost(id) {
if (confirm('この投稿を削除しますか?')) {
const post = posts.find(p => p.id === id);
if (post && post.type === 'feed') {
const urlMatch = post.content.match(/href="([^"]+)"/);
if (urlMatch) {
processedItems.delete(urlMatch[1]);
}
}
posts = posts.filter(p => p.id !== id);
saveData();
renderTimeline();
}
}
// === タイムライン表示 ===
function renderTimeline() {
const timeline = document.getElementById('timeline');
const emptyTimeline = document.getElementById('empty-timeline');
const postCount = document.getElementById('post-count');
if (posts.length === 0) {
timeline.innerHTML = '';
emptyTimeline.style.display = 'block';
postCount.textContent = '(0件の投稿)';
return;
}
emptyTimeline.style.display = 'none';
postCount.textContent = `(${posts.length}件の投稿)`;
timeline.innerHTML = posts.map(post => {
const typeInfo = getPostTypeInfo(post.type);
return `
<div class="timeline-post p-6">
<div class="flex justify-between items-start mb-4">
<div class="flex items-center space-x-3">
<img src="${post.icon}" alt="アバター" class="w-10 h-10 rounded-full object-cover">
<div>
<div class="flex items-center">
<span class="font-semibold text-gray-800 dark:text-white">${post.username}</span>
<span class="username-badge">${typeInfo.badge}</span>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">${post.timestamp}</div>
</div>
</div>
</div>
<div class="text-gray-800 dark:text-gray-200 mb-4 leading-relaxed">${post.content}</div>
<div class="flex items-center space-x-4 pt-4 border-t border-gray-100 dark:border-gray-600">
<button onclick="likePost(${post.id})" class="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-red-500 transition-colors">
<i class="fas fa-heart"></i>
<span>${post.likes}</span>
</button>
<div class="relative">
<button onclick="toggleShareMenu(${post.id})" class="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 transition-colors">
<i class="fas fa-share"></i>
<span>シェア</span>
</button>
<div id="share-menu-${post.id}" class="share-menu hidden">
<button onclick="shareToX(${post.id})"><i class="fab fa-x-twitter text-blue-400 mr-2"></i>Xでシェア</button>
<button onclick="shareToLine(${post.id})"><i class="fab fa-line text-green-400 mr-2"></i>LINEでシェア</button>
<button onclick="copyPost(${post.id})"><i class="fas fa-copy mr-2"></i>コピー</button>
</div>
</div>
<button onclick="deletePost(${post.id})" class="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-red-500 transition-colors ml-auto">
<i class="fas fa-trash"></i>
<span>削除</span>
</button>
</div>
</div>
`;
}).join('');
}
function getPostTypeInfo(type) {
const typeMap = {
'bot': { badge: '🤖 BOT' },
'markov': { badge: '🎲 MarkovBOT' },
'feed': { badge: '📡 RSS Feed' },
'user': { badge: '👤 ユーザー' }
};
return typeMap[type] || typeMap['user'];
}
// === シェア機能 ===
function getPostContentText(id) {
const post = posts.find(p => p.id === id);
if (!post) return '';
const tempDiv = document.createElement('div');
tempDiv.innerHTML = post.content;
return tempDiv.textContent || tempDiv.innerText || '';
}
function toggleShareMenu(id) {
document.querySelectorAll('[id^="share-menu-"]').forEach(el => el.classList.add('hidden'));
const menu = document.getElementById('share-menu-' + id);
if (menu) {
menu.classList.toggle('hidden');
setTimeout(() => {
const closeMenu = (e) => {
if (!menu.contains(e.target)) {
menu.classList.add('hidden');
document.removeEventListener('mousedown', closeMenu);
}
};
document.addEventListener('mousedown', closeMenu);
}, 100);
}
}
function shareToX(id) {
const text = encodeURIComponent(getPostContentText(id));
const url = encodeURIComponent(location.href);
window.open(`https://twitter.com/intent/tweet?text=${text}&url=${url}`, '_blank');
}
function shareToLine(id) {
const text = encodeURIComponent(getPostContentText(id) + ' ' + location.href);
window.open(`https://social-plugins.line.me/lineit/share?url=${text}`, '_blank');
}
function copyPost(id) {
const text = getPostContentText(id);
navigator.clipboard.writeText(text).then(() => {
alert('投稿内容をクリップボードにコピーしました!');
}).catch(() => {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert('投稿内容をコピーしました!');
});
}
// === BOT機能 ===
function postBotMessage() {
const content = document.getElementById('botContent').value.trim();
if (content) {
if (createPost(content, 'bot', 'BOT')) {
document.getElementById('botContent').value = '';
addLog('bot-log', `BOT投稿: "${content.substring(0, 30)}..."`, 'success');
}
}
}
// === マルコフ連鎖機能(強化版) ===
function initializeMarkovChain() {
try {
if (typeof RiTa === 'undefined') {
addLog('bot-log', 'RiTaライブラリが未読み込みです', 'error');
return false;
}
markovChain = new RiTa.Markov(3);
const userPosts = posts.filter(p =>
(p.type === 'user' || p.type === 'bot') &&
p.content.length > 20 &&
!p.content.includes('<div') &&
!p.content.includes('href=')
);
if (userPosts.length < 5) {
addLog('bot-log', `学習データ不足 (${userPosts.length}件) - 5件以上必要`, 'error');
return false;
}
const textData = userPosts
.map(p => cleanTextForMarkov(p.content))
.filter(text => text.length > 15)
.join('\n');
if (textData.length < 200) {
addLog('bot-log', 'テキストデータが不十分です', 'error');
return false;
}
markovChain.addText(textData);
addLog('bot-log', `マルコフ連鎖学習完了 (${userPosts.length}件の投稿を学習)`, 'success');
return true;
} catch (error) {
addLog('bot-log', `マルコフ初期化エラー: ${error.message}`, 'error');
return false;
}
}
function cleanTextForMarkov(text) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = text;
let cleanText = tempDiv.textContent || tempDiv.innerText || '';
cleanText = cleanText
.replace(/https?:\/\/[^\s]+/g, '')
.replace(/[^\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF\u3400-\u4DBFa-zA-Z0-9\s!?。、.,!?\n]/g, '')
.replace(/\s+/g, ' ')
.replace(/[。!?]{2,}/g, '。')
.trim();
return cleanText;
}
function generateMarkovText() {
try {
if (!initializeMarkovChain()) {
const fallbackMessages = [
"今日はいい天気ですね!どのように過ごされていますか?",
"最近面白いニュースありましたか?",
"新しいアイデアが浮かんできました。",
"皆さんはどう思いますか?",
"今日も一日頑張りましょう!"
];
return fallbackMessages[Math.floor(Math.random() * fallbackMessages.length)];
}
let bestText = '';
const maxAttempts = 15;
for (let i = 0; i < maxAttempts; i++) {
try {
const options = {
maxLength: 120,
minLength: 20,
count: Math.floor(Math.random() * 2) + 1
};
const sentences = markovChain.generate(options);
if (sentences && sentences.length > 0) {
let generatedText = sentences.join(' ').trim();
// 文章の自然な終端処理
if (generatedText.length > 150) {
const endMarkers = ['。', '!', '?'];
let bestEnd = -1;
endMarkers.forEach(marker => {
const pos = generatedText.lastIndexOf(marker, 130);
if (pos > 30) bestEnd = Math.max(bestEnd, pos);
});
if (bestEnd > -1) {
generatedText = generatedText.substring(0, bestEnd + 1);
}
}
// 品質チェック
if (generatedText.length >= 20 &&
generatedText.length <= 200 &&
!generatedText.includes('undefined') &&
generatedText.length > bestText.length) {
bestText = generatedText;
}
}
} catch (genError) {
continue;
}
}
if (!bestText || bestText.length < 10) {
bestText = "新しいアイデアについて考えています。皆さんの意見も聞きたいですね。";
}
return bestText;
} catch (error) {
addLog('bot-log', `マルコフ生成エラー: ${error.message}`, 'error');
return "マルコフ連鎖で自然な文章を生成しています。";
}
}
function postMarkovBot() {
const content = generateMarkovText();
if (createPost(content, 'markov', 'MarkovBOT')) {
addLog('bot-log', `マルコフ投稿: "${content.substring(0, 40)}..."`, 'success');
}
}
function startBotAutoPost() {
const interval = parseInt(document.getElementById('botIntervalSec').value) || 60;
if (interval < 10) {
alert('間隔は10秒以上で設定してください。');
return;
}
stopBotAutoPost();
// 初回投稿
setTimeout(postMarkovBot, 2000);
botInterval = setInterval(() => {
postMarkovBot();
}, interval * 1000);
updateStatusIndicators();
addLog('bot-log', `マルコフBOT自動投稿開始 (${interval}秒間隔)`, 'success');
}
function stopBotAutoPost() {
if (botInterval) {
clearInterval(botInterval);
botInterval = null;
updateStatusIndicators();
addLog('bot-log', 'マルコフBOT自動投稿を停止しました', 'info');
}
}
// === RSS機能(完全修正版) ===
function addDefaultFeeds() {
let addedCount = 0;
DEFAULT_FEEDS.forEach(url => {
if (!feedUrls.includes(url)) {
feedUrls.push(url);
addedCount++;
}
});
if (addedCount > 0) {
saveData();
renderFeedList();
addLog('rss-log', `${addedCount}件のデフォルトフィードを追加しました`, 'success');
} else {
addLog('rss-log', 'すべてのデフォルトフィードは既に登録されています', 'info');
}
}
function registerFeedUrl() {
const url = document.getElementById('feedUrl').value.trim();
if (!url) {
alert('URLを入力してください。');
return;
}
if (!url.startsWith('http')) {
alert('有効なURLを入力してください(http://またはhttps://で開始)。');
return;
}
if (feedUrls.includes(url)) {
alert('このフィードは既に登録されています。');
return;
}
feedUrls.push(url);
saveData();
renderFeedList();
document.getElementById('feedUrl').value = '';
addLog('rss-log', `フィード登録: ${url}`, 'success');
}
function removeFeedUrl(index) {
if (confirm('このフィードを削除しますか?')) {
const removedUrl = feedUrls[index];
feedUrls.splice(index, 1);
saveData();
renderFeedList();
addLog('rss-log', `フィード削除: ${removedUrl}`, 'info');
}
}
function renderFeedList() {
const feedList = document.getElementById('feed-list');
if (feedUrls.length === 0) {
feedList.innerHTML = '<div class="text-gray-500">登録されたフィードはありません</div>';
return;
}
feedList.innerHTML = feedUrls.map((url, index) => `
<div class="rss-item flex items-center justify-between">
<span class="text-xs truncate flex-1" title="${url}">${url}</span>
<button onclick="removeFeedUrl(${index})" class="text-red-500 hover:text-red-700 ml-2 text-xs">
<i class="fas fa-times"></i>
</button>
</div>
`).join('');
}
// 改良されたRSS取得機能
async function fetchAllFeeds() {
if (feedUrls.length === 0) {
addLog('rss-log', 'RSSフィードが登録されていません', 'error');
return;
}
addLog('rss-log', `RSS取得開始 (${feedUrls.length}件のフィード)`, 'info');
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < feedUrls.length; i++) {
const url = feedUrls[i];
try {
addLog('rss-log', `[${i + 1}/${feedUrls.length}] 取得中: ${url.substring(0, 50)}...`, 'info');
const success = await fetchSingleFeed(url);
if (success) {
successCount++;
addLog('rss-log', `✓ 成功: ${url.substring(0, 50)}...`, 'success');
} else {
errorCount++;
addLog('rss-log', `✗ 失敗: ${url.substring(0, 50)}...`, 'error');
}
} catch (error) {
errorCount++;
addLog('rss-log', `✗ エラー: ${error.message}`, 'error');
}
// レート制限対策(フィード間に待機時間)
if (i < feedUrls.length - 1) {
await sleep(2000);
}
}
stats.rssSuccess += successCount;
stats.rssErrors += errorCount;
const resultMsg = `RSS取得完了: 成功 ${successCount}件 / エラー ${errorCount}件`;
addLog('rss-log', resultMsg, successCount > 0 ? 'success' : 'error');
if (successCount > 0) {
saveData();
}
}
async function fetchSingleFeed(feedUrl) {
const apis = [
// API 1: rss2json
async () => {
const response = await fetchWithTimeout(
`https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(feedUrl)}&count=5`,
10000
);
const data = await response.json();
if (data.status !== 'ok') {
throw new Error(data.message || 'RSS API error');
}
return data.items || [];
},
// API 2: allorigins
async () => {
const response = await fetchWithTimeout(
`https://api.allorigins.win/get?url=${encodeURIComponent(feedUrl)}`,
10000
);
const data = await response.json();
if (!data.contents) {
throw new Error('No contents returned');
}
return parseRSSContent(data.contents);
}
];
for (const api of apis) {
try {
const items = await api();
return await processRSSItems(items, feedUrl);
} catch (error) {
continue;
}
}
return false;
}
async function processRSSItems(items, feedUrl) {
if (!items || items.length === 0) {
return false;
}
let processedCount = 0;
for (const item of items.slice(0, 3)) { // 最大3件まで
if (!item.title || !item.link) continue;
// 重複チェック
if (processedItems.has(item.link)) {
continue;
}
const cleanTitle = cleanHTML(item.title).substring(0, 100);
const cleanDescription = item.description
? cleanHTML(item.description).substring(0, 150)
: '';
const content = createRSSPostContent(cleanTitle, cleanDescription, item.link, feedUrl);
if (createPost(content, 'feed', 'RSS Feed')) {
processedCount++;
processedItems.add(item.link);
// 投稿間の間隔
if (processedCount < 3) {
await sleep(1000);
}
}
}
return processedCount > 0;
}
function createRSSPostContent(title, description, link, feedUrl) {
const feedDomain = new URL(feedUrl).hostname;
return `
<div class="border-l-4 border-blue-500 pl-4 bg-gray-50 dark:bg-gray-700 rounded-r-lg p-3">
<h4 class="font-semibold mb-2 text-gray-900 dark:text-white">${title}</h4>
${description ? `<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">${description}${description.length >= 150 ? '...' : ''}</p>` : ''}
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-globe mr-1"></i>${feedDomain}
</span>
<a href="${link}" target="_blank" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm font-medium">
<i class="fas fa-external-link-alt mr-1"></i>記事を読む
</a>
</div>
</div>
`;
}
function parseRSSContent(xmlContent) {
// 簡易XMLパース(実装を簡略化)
const items = [];
const parser = new DOMParser();
const doc = parser.parseFromString(xmlContent, 'text/xml');
const rssItems = doc.querySelectorAll('item');
rssItems.forEach(item => {
const title = item.querySelector('title')?.textContent;
const link = item.querySelector('link')?.textContent;
const description = item.querySelector('description')?.textContent;
if (title && link) {
items.push({ title, link, description });
}
});
return items;
}
function cleanHTML(text) {
if (!text) return '';
const tempDiv = document.createElement('div');
tempDiv.innerHTML = text;
return (tempDiv.textContent || tempDiv.innerText || '').trim();
}
async function fetchWithTimeout(url, timeout = 10000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
'Accept': 'application/json',
'User-Agent': 'Verse RSS Reader 2.0'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
} finally {
clearTimeout(timeoutId);
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function startRSSAutoPost() {
const interval = parseInt(document.getElementById('feedIntervalSec').value) || 180;
if (interval < 60) {
alert('間隔は60秒以上で設定してください。');
return;
}
stopRSSAutoPost();
// 初回実行(少し遅延)
setTimeout(() => {
addLog('rss-log', '自動RSS取得の初回実行を開始...', 'info');
fetchAllFeeds();
}, 5000);
rssInterval = setInterval(() => {
addLog('rss-log', '定期RSS取得を実行中...', 'info');
fetchAllFeeds();
}, interval * 1000);
updateStatusIndicators();
addLog('rss-log', `RSS自動取得開始 (${interval}秒間隔)`, 'success');
}
function stopRSSAutoPost() {
if (rssInterval) {
clearInterval(rssInterval);
rssInterval = null;
updateStatusIndicators();
addLog('rss-log', 'RSS自動取得を停止しました', 'info');
}
}
// === プロフィール機能 ===
function uploadProfileIcon(event) {
const file = event.target.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
alert('画像サイズは5MB以下にしてください。');
return;
}
const reader = new FileReader();
reader.onload = function(e) {
profile.icon = e.target.result;
updateAllUI();
saveData();
alert('プロフィール画像を更新しました!');
};
reader.readAsDataURL(file);
}
function saveProfile() {
const username = document.getElementById('username').value.trim();
const selfIntro = document.getElementById('self-intro').value.trim();
if (username && username.length > 20) {
alert('ユーザー名は20文字以内で入力してください。');
return;
}
profile.username = username || 'ゲストユーザー';
profile.selfIntro = selfIntro;
updateAllUI();
saveData();
alert('プロフィールを保存しました!');
}
// === UI更新 ===
function updateAllUI() {
// プロフィール画像更新
const profileIcon = document.getElementById('profile-icon');
const headerIcon = document.getElementById('header-profile-icon');
if (profileIcon) profileIcon.src = profile.icon;
if (headerIcon) headerIcon.src = profile.icon;
// ユーザー名更新
const usernameElements = ['username-preview', 'header-username'];
usernameElements.forEach(id => {
const element = document.getElementById(id);
if (element) element.textContent = profile.username;
});
// 自己紹介更新
const selfIntroPreview = document.getElementById('self-intro-preview');
if (selfIntroPreview) {
selfIntroPreview.textContent = profile.selfIntro || 'まだ自己紹介がありません';
}
// タイムライン再描画
renderTimeline();
renderFeedList();
}
function updateCharCount() {
const postContent = document.getElementById('postContent');
const charCount = document.getElementById('char-count');
if (postContent && charCount) {
const length = postContent.value.length;
charCount.textContent = `(${length}/500)`;
charCount.style.color = length > 450 ? '#ef4444' : '';
}
}
function toggleDarkMode() {
isDarkMode = !isDarkMode;
if (isDarkMode) {
document.body.classList.add('dark');
document.body.style.background = 'linear-gradient(135deg, #1a202c 0%, #2d3748 100%)';
} else {
document.body.classList.remove('dark');
document.body.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
}
saveData();
}
// === イベントリスナー ===
document.addEventListener('DOMContentLoaded', function() {
// 文字数カウンター
const postContent = document.getElementById('postContent');
if (postContent) {
postContent.addEventListener('input', updateCharCount);
}
// プロフィール入力のリアルタイム更新
const usernameInput = document.getElementById('username');
const selfIntroInput = document.getElementById('self-intro');
if (usernameInput) {
usernameInput.addEventListener('input', function() {
const username = this.value.trim() || 'ゲストユーザー';
document.getElementById('username-preview').textContent = username;
document.getElementById('header-username').textContent = username;
});
}
if (selfIntroInput) {
selfIntroInput.addEventListener('input', function() {
const selfIntro = this.value.trim() || 'まだ自己紹介がありません';
document.getElementById('self-intro-preview').textContent = selfIntro;
});
}
});
// === 初期化実行 ===
window.addEventListener('load', function() {
// アプリ初期化
initializeApp();
// プロフィール情報を入力フィールドに設定
document.getElementById('username').value = profile.username || '';
document.getElementById('self-intro').value = profile.selfIntro || '';
// ページ終了時のクリーンアップ
window.addEventListener('beforeunload', function() {
stopBotAutoPost();
stopRSSAutoPost();
saveData();
});
});
</script>
</body>
</html>
カテゴリー: WEBサービス
DeepLinkVR
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verse - 次世代ソーシャルネットワーク VR対応</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/rita/1.3.63/rita-full.min.js"></script>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 0; background: #f0f0f0; }
header, .footer { background: linear-gradient(45deg, #6a11cb, #2575fc); color: white; text-align: center; padding: 20px; }
.nav-container { background: #2575fc; display: flex; justify-content: center; padding: 10px; position: sticky; top: 0; }
.nav-menu a { color: white; text-decoration: none; margin: 0 10px; }
.content { max-width: 900px; margin: 20px auto; background: white; padding: 20px; border-radius: 8px; }
.cta-button { background: #2575fc; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; }
.profile-icon { width: 80px; height: 80px; border-radius: 50%; object-fit: cover; }
.dark-mode { background: #1e1e1e; color: #ddd; }
.timeline { margin-top: 20px; }
.timeline-post { border: 1px solid #ddd; border-radius: 8px; padding: 10px; margin-bottom: 10px; background: #fff; }
canvas { border: 1px solid #ccc; }
</style>
<script>
let posts = JSON.parse(localStorage.getItem('posts') || '[]');
function saveData() {
localStorage.setItem('posts', JSON.stringify(posts));
}
function createPost(content) {
posts.unshift({ content: content, likes: 0 });
saveData();
renderTimeline();
}
function renderTimeline() {
const container = document.getElementById('timeline');
container.innerHTML = '';
posts.forEach((post, index) => {
container.innerHTML += `<div class='timeline-post'><p>${post.content}</p></div>`;
});
}
function toggleVR() {
if (!navigator.xr) {
alert('WebXRがサポートされていません。');
return;
}
navigator.xr.requestSession('immersive-vr').then(session => {
const vrCanvas = document.createElement('canvas');
vrCanvas.width = window.innerWidth;
vrCanvas.height = window.innerHeight;
vrCanvas.style.width = '100vw';
vrCanvas.style.height = '100vh';
vrCanvas.style.position = 'fixed';
vrCanvas.style.top = 0;
vrCanvas.style.left = 0;
vrCanvas.style.background = 'black';
document.body.appendChild(vrCanvas);
const ctx = vrCanvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.font = '30px Arial';
ctx.fillText('VRモード:仮想空間に没入中', 50, 100);
drawVREnvironment(ctx);
loadUnityAsset(ctx);
loadUnrealAsset(ctx);
session.end().then(() => {
document.body.removeChild(vrCanvas);
});
}).catch(err => {
alert('VRセッションの開始に失敗しました。');
console.error(err);
});
}
function drawVREnvironment(ctx) {
ctx.fillStyle = '#0f0';
ctx.beginPath();
ctx.arc(200, 200, 50, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = '#f00';
ctx.fillRect(300, 150, 100, 100);
ctx.fillStyle = 'cyan';
ctx.font = '20px Arial';
ctx.fillText('仮想オブジェクト: 球体と立方体', 50, 300);
}
function loadUnityAsset(ctx) {
ctx.fillStyle = 'yellow';
ctx.font = '20px Arial';
ctx.fillText('Unityアセット読み込み: キャラクター', 50, 350);
}
function loadUnrealAsset(ctx) {
ctx.fillStyle = 'orange';
ctx.font = '20px Arial';
ctx.fillText('Unreal Engineアセット読み込み: シーン', 50, 400);
}
function toggleDarkMode() {
document.body.classList.toggle('dark-mode');
}
window.onload = renderTimeline;
</script>
</head>
<body>
<header>
<h1>Verse VR SNS</h1>
<button class="cta-button" onclick="toggleVR()">🎮 VRモード</button>
<button class="cta-button" onclick="toggleDarkMode()">🌙 ダークモード</button>
</header>
<div class="nav-container">
<div class="nav-menu">
<a href="#" onclick="renderTimeline()">ホーム</a>
</div>
</div>
<div class="content">
<h2>新規投稿</h2>
<textarea id="postContent" class="form-control" placeholder="いま何してる?"></textarea><br>
<button class="cta-button" onclick="createPost(document.getElementById('postContent').value)">投稿</button>
<section id="timeline" class="timeline"></section>
</div>
<div class="footer">© 2025 Verse VR SNS</div>
</body>
</html>
Verse.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verse - 次世代ソーシャルネットワーク</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/rita/1.3.63/rita-full.min.js"></script>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 0; background: #f0f0f0; }
header, .footer { background: linear-gradient(45deg, #6a11cb, #2575fc); color: white; text-align: center; padding: 20px; }
.nav-container { background: #2575fc; display: flex; justify-content: center; padding: 10px; position: sticky; top: 0; }
.nav-menu a { color: white; text-decoration: none; margin: 0 10px; }
.content { max-width: 900px; margin: 20px auto; background: white; padding: 20px; border-radius: 8px; }
.cta-button { background: #2575fc; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; }
.profile-icon { width: 80px; height: 80px; border-radius: 50%; object-fit: cover; }
.dark-mode { background: #1e1e1e; color: #ddd; }
.timeline { margin-top: 20px; }
.timeline-post { border: 1px solid #ddd; border-radius: 8px; padding: 10px; margin-bottom: 10px; background: #fff; }
.post-controls { display: flex; gap: 10px; }
.search-box { width: 100%; margin-bottom: 10px; }
</style>
<script>
let posts = JSON.parse(localStorage.getItem('posts') || '[]');
let feedUrls = JSON.parse(localStorage.getItem('feedUrls') || '[]');
let botInterval = null;
let feedInterval = null;
function saveData() {
localStorage.setItem('posts', JSON.stringify(posts));
localStorage.setItem('feedUrls', JSON.stringify(feedUrls));
}
function createPost(content) {
posts.unshift({ content: content, likes: 0 });
saveData();
renderTimeline();
}
function createUserPost() {
const content = document.getElementById('postContent').value.trim();
if(content) {
createPost(content);
document.getElementById('postContent').value = '';
}
}
function likePost(index) {
posts[index].likes++;
saveData();
renderTimeline();
}
function deletePost(index) {
posts.splice(index, 1);
saveData();
renderTimeline();
}
function searchPosts() {
const query = document.getElementById('searchInput').value.trim().toLowerCase();
renderTimeline(query);
}
function renderTimeline(filter = '') {
const container = document.getElementById('timeline');
container.innerHTML = '';
posts.filter(post => post.content.toLowerCase().includes(filter)).forEach((post, index) => {
const postDiv = document.createElement('div');
postDiv.className = 'timeline-post';
postDiv.innerHTML = `
<p>${post.content}</p>
<div class='post-controls'>
<button class='btn btn-sm btn-primary' onclick='likePost(${index})'>❤️ いいね (${post.likes})</button>
<button class='btn btn-sm btn-danger' onclick='deletePost(${index})'>🗑️ 削除</button>
</div>
`;
container.appendChild(postDiv);
});
}
function toggleDarkMode() {
document.body.classList.toggle('dark-mode');
}
function uploadProfileIcon(event) {
const file = event.target.files[0];
if(file) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('profile-icon').src = e.target.result;
showTimeline();
};
reader.readAsDataURL(file);
}
}
function showTimeline() {
document.getElementById('profile-section').style.display = 'none';
document.getElementById('post-section').style.display = 'block';
document.getElementById('timeline').style.display = 'block';
}
function showProfile() {
document.getElementById('profile-section').style.display = 'block';
document.getElementById('post-section').style.display = 'none';
document.getElementById('timeline').style.display = 'none';
}
function postBotMessage() {
const content = document.getElementById('botContent').value.trim();
if(content) {
createPost(`🤖 BOT: ${content}`);
document.getElementById('botContent').value = '';
}
}
function generateMarkovText() {
const combinedText = posts.map(p => p.content).join(' ');
if(combinedText.length < 20) return "BOTの投稿データが不足しています。";
const markov = new RiTa.Markov(3);
markov.addText(combinedText);
const sentences = markov.generate(1);
return sentences[0] ? sentences[0] : "自然な文章の生成に失敗しました。";
}
function postMarkovBot() {
const text = generateMarkovText();
createPost(`🤖 MarkovBOT: ${text}`);
}
function startBotAutoPost(intervalSec) {
if(botInterval) clearInterval(botInterval);
botInterval = setInterval(postMarkovBot, intervalSec * 1000);
alert(`マルコフ連鎖BOTの自動投稿間隔を${intervalSec}秒に設定しました。`);
}
function registerFeedUrl() {
const url = document.getElementById('feedUrl').value.trim();
if(url) {
feedUrls.push(url);
saveData();
alert('RSSフィードURLを登録しました。');
document.getElementById('feedUrl').value = '';
}
}
async function fetchAllFeeds() {
for(const url of feedUrls) {
try {
const response = await fetch(`https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(url)}`);
const data = await response.json();
data.items.forEach(item => {
createPost(`📡 FEED: ${item.title} - ${item.link}`);
});
} catch (error) {
console.error('RSSフィード取得エラー:', error);
}
}
}
function startFeedAutoPost(intervalSec) {
if(feedInterval) clearInterval(feedInterval);
feedInterval = setInterval(fetchAllFeeds, intervalSec * 1000);
alert(`RSSフィードの自動投稿間隔を${intervalSec}秒に設定しました。`);
}
window.onload = function() {
renderTimeline();
};
</script>
</head>
<body>
<header>
<h1>Verse</h1>
<button class="cta-button" onclick="toggleDarkMode()">🌙 ダークモード切替</button>
</header>
<div class="nav-container">
<div class="nav-menu">
<a href="#" onclick="showTimeline()">ホーム</a>
<a href="#" onclick="showProfile()">プロフィール</a>
</div>
</div>
<div class="content">
<section id="profile-section" style="display:none;">
<h2>プロフィール</h2>
<img id="profile-icon" src="https://via.placeholder.com/80" alt="プロフィールアイコン" class="profile-icon"><br><br>
<input type="file" accept="image/*" onchange="uploadProfileIcon(event)">
</section>
<section id="post-section">
<h2>新規投稿</h2>
<textarea id="postContent" class="form-control" placeholder="いま何してる?"></textarea><br>
<button class="cta-button" onclick="createUserPost()">投稿</button>
<h2>BOT投稿登録</h2>
<textarea id="botContent" class="form-control" placeholder="BOTに投稿させたい内容"></textarea><br>
<button class="cta-button" onclick="postBotMessage()">BOT投稿登録</button><br><br>
<input type="number" id="botIntervalSec" placeholder="BOTの自動投稿間隔(秒)">
<button class="cta-button" onclick="startBotAutoPost(parseInt(document.getElementById('botIntervalSec').value))">マルコフ連鎖 自動投稿開始</button><br><br>
<h2>RSSフィード登録</h2>
<input type="text" id="feedUrl" class="form-control" placeholder="RSSフィードのURL"><br>
<button class="cta-button" onclick="registerFeedUrl()">フィード登録</button><br><br>
<input type="number" id="feedIntervalSec" placeholder="RSS自動投稿間隔(秒)">
<button class="cta-button" onclick="startFeedAutoPost(parseInt(document.getElementById('feedIntervalSec').value))">RSS 自動投稿開始</button>
</section>
<input type="text" id="searchInput" class="form-control search-box" placeholder="投稿を検索..." onkeyup="searchPosts()">
<section id="timeline" class="timeline"></section>
</div>
<div class="footer">© 2025 Verse - 新しいつながりを創造する</div>
</body>
</html>
匿名相談・共感プラットフォーム
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>匿名相談・共感プラットフォーム</title>
<style>
body { font-family: 'Segoe UI', sans-serif; background: #eef1f5; margin: 0; padding: 20px; }
header { text-align: center; padding: 20px; background: #4a90e2; color: white; border-radius: 8px; }
.container { max-width: 800px; margin: 20px auto; }
.card { background: white; border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); padding: 15px; margin-bottom: 20px; }
textarea, input[type="text"] { width: 100%; padding: 10px; margin: 5px 0; border: 1px solid #ccc; border-radius: 5px; }
button { background: #4a90e2; color: white; border: none; padding: 10px 15px; border-radius: 5px; cursor: pointer; margin-right: 5px; }
button:hover { background: #357ab8; }
.comment { background: #f5f5f5; border-left: 3px solid #4a90e2; padding: 8px; margin-top: 8px; border-radius: 5px; }
.meta { font-size: 0.85em; color: #555; }
.tag { display: inline-block; background: #ddd; border-radius: 5px; padding: 2px 8px; margin-right: 5px; font-size: 0.8em; }
footer { text-align: center; font-size: 0.8em; color: #888; margin-top: 50px; }
.search-bar { margin-bottom: 20px; }
.delete-btn { background: #e94e4e; }
.delete-btn:hover { background: #b73939; }
.edit-btn { background: #f0ad4e; }
.edit-btn:hover { background: #d98c00; }
.copy-btn { background: #6cc070; }
.copy-btn:hover { background: #4e9f50; }
</style>
</head>
<body>
<header>
<h1>匿名相談・共感プラットフォーム</h1>
<p>悩みや相談を匿名で投稿し、共感やコメントをもらおう</p>
</header>
<div class="container">
<div class="card">
<h3>新しい相談を投稿</h3>
<input type="text" id="postTags" placeholder="タグ(カンマ区切り)">
<textarea id="postText" placeholder="あなたの悩みや相談を書いてください..."></textarea>
<button onclick="addPost()">投稿する</button>
</div>
<div class="card search-bar">
<input type="text" id="searchInput" placeholder="投稿検索..." oninput="searchPosts()">
</div>
<div id="postsContainer"></div>
</div>
<footer>
© 2025 匿名相談・共感プラットフォーム
</footer>
<script>
let posts = JSON.parse(localStorage.getItem('posts')) || [];
function addPost() {
const text = document.getElementById('postText').value.trim();
const tags = document.getElementById('postTags').value.split(',').map(tag => tag.trim()).filter(tag => tag);
if (text) {
posts.unshift({ text, tags, empathy: 0, comments: [], date: new Date().toLocaleString() });
document.getElementById('postText').value = '';
document.getElementById('postTags').value = '';
saveAndRender();
}
}
function addEmpathy(index) {
posts[index].empathy++;
saveAndRender();
}
function addComment(index, commentId) {
const input = document.getElementById(commentId);
const commentText = input.value.trim();
if (commentText) {
posts[index].comments.push({ text: commentText, date: new Date().toLocaleString() });
input.value = '';
saveAndRender();
}
}
function editPost(index) {
const newText = prompt('投稿内容を編集してください:', posts[index].text);
if (newText !== null) {
posts[index].text = newText;
saveAndRender();
}
}
function deletePost(index) {
if (confirm('この投稿を削除しますか?')) {
posts.splice(index, 1);
saveAndRender();
}
}
function copyPost(index) {
navigator.clipboard.writeText(posts[index].text)
.then(() => alert('投稿内容をコピーしました'))
.catch(() => alert('コピーに失敗しました'));
}
function saveAndRender() {
localStorage.setItem('posts', JSON.stringify(posts));
renderPosts();
}
function renderPosts(filteredPosts = posts) {
const container = document.getElementById('postsContainer');
container.innerHTML = '';
filteredPosts.forEach((post, index) => {
let postHtml = `
<div class="card">
<p>${post.text}</p>
<div>${post.tags.map(tag => `<span class='tag'>#${tag}</span>`).join(' ')}</div>
<p class="meta">投稿日: ${post.date}</p>
<button onclick="addEmpathy(${index})">共感 (${post.empathy})</button>
<button class="copy-btn" onclick="copyPost(${index})">コピー</button>
<button class="edit-btn" onclick="editPost(${index})">編集</button>
<button class="delete-btn" onclick="deletePost(${index})">削除</button>
<h4>コメント</h4>
<input type="text" id="commentInput${index}" placeholder="コメントを書く...">
<button onclick="addComment(${index}, 'commentInput${index}')">送信</button>
`;
post.comments.forEach(comment => {
postHtml += `<div class="comment">${comment.text}<div class="meta">${comment.date}</div></div>`;
});
postHtml += '</div>';
container.innerHTML += postHtml;
});
}
function searchPosts() {
const query = document.getElementById('searchInput').value.toLowerCase();
const filtered = posts.filter(post =>
post.text.toLowerCase().includes(query) ||
post.tags.some(tag => tag.toLowerCase().includes(query))
);
renderPosts(filtered);
}
renderPosts();
</script>
</body>
</html>
AIイラストプロンプトメーカー
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>AIイラストプロンプトメーカー</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {
--primary: #6c63ff;
--bg: #f2f2f2;
--text: #333;
--card: white;
}
body {
margin: 0;
font-family: 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
}
header {
background: var(--primary);
color: white;
padding: 20px;
text-align: center;
font-size: 1.8em;
}
.container {
max-width: 1000px;
margin: 30px auto;
background: var(--card);
padding: 30px;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
font-weight: bold;
margin-bottom: 5px;
}
select, button, textarea, input[type="file"] {
width: 100%;
padding: 10px;
font-size: 1em;
border-radius: 8px;
border: 1px solid #ccc;
box-sizing: border-box;
}
.output {
background: #fafafa;
padding: 15px;
border-radius: 10px;
margin-top: 20px;
white-space: pre-wrap;
}
button {
background: var(--primary);
color: white;
border: none;
margin-top: 15px;
cursor: pointer;
}
button:hover {
background: #574fd9;
}
.image-preview {
margin-top: 20px;
text-align: center;
}
.image-preview img {
max-width: 100%;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.gallery {
margin-top: 40px;
}
.gallery img {
max-width: 100%;
margin: 10px 0;
border-radius: 12px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
@media (max-width: 600px) {
.container {
padding: 20px;
margin: 10px;
}
}
</style>
</head>
<body>
<header>🎨 AIイラストプロンプトメーカー</header>
<div class="container">
<div class="form-group">
<label>キャラクター</label>
<select id="subject">
<option>狐の少女</option>
<option>魔法少女</option>
<option>サイバーパンクの戦士</option>
<option>猫耳の少年</option>
<option>吸血鬼の姫</option>
<option>天使の戦士</option>
<option>竜騎士</option>
<option>宇宙探検家</option>
<option>妖精</option>
<option>獣人の女王</option>
<option>機械生命体</option>
<option>デジタル妖精</option>
<option>スチームパンクの発明家</option>
<option>氷の精霊</option>
<option>砂漠の王女</option>
<option>未来の警察官</option>
<option>忍者少女</option>
<option>異世界の商人</option>
<option>獣耳魔法使い</option>
<option>雷の神</option>
<option>闇の王子</option>
<option>炎の踊り子</option>
<option>時間を操る司書</option>
<option>ポストアポカリプスの旅人</option>
<option>音楽を操る精霊</option>
<option>風の精霊使い</option>
<option>森の守護者</option>
<option>未来の芸術家</option>
<option>魔界の王女</option>
<option>電脳世界のハッカー</option>
<option>サーカス団の団長</option>
<option>時計仕掛けの人形</option>
<option>図書館の魔導師</option>
<option>宇宙アイドル</option>
<option>夢の案内人</option>
<option>四季を司る女神</option>
<option>時間旅行者</option>
<option>戦場の傭兵</option>
<option>星を読む預言者</option>
<option>電脳巫女</option>
<option>古代の王</option>
<option>未来の料理人</option>
<option>天空の案内人</option>
<option>人魚の王女</option>
<option>炎を纏う戦士</option>
<option>異界の騎士</option>
<option>霧の中の影</option>
<option>雷獣の化身</option>
<option>植物を操る錬金術師</option>
<option>おとぎ話の語り部</option>
<option>重力を操る少女</option>
<option>古の預言者</option>
<option>空を旅する郵便屋</option>
<option>眠りを司る精霊</option>
<option>お祭りの踊り子</option>
<option>砂嵐の遊牧民</option>
<option>泡の海の守護者</option>
<option>魔法道具職人</option>
<option>氷と炎の二重人格者</option>
<option>デジタル世界の探偵</option>
<option>孤独な塔の詩人</option>
<option>空飛ぶ書斎の管理人</option>
<option>鏡の中の分身</option>
<option>古城に棲む亡霊</option>
<option>地下世界の旅人</option>
<option>異星文明の観測者</option>
<option>時間停止の魔術師</option>
<option>夢の記録者</option>
<option>霧の海の漁師</option>
<option>重力反転の案内人</option>
<option>時空の管理者</option>
<option>流星に乗る観測者</option>
<option>伝説の召喚士</option>
<option>影を操る使者</option>
<option>魔法にかけられた人形</option>
<option>未来都市のDJ</option>
<option>空想世界の画家</option>
<option>炎の道化師</option>
<option>廃墟に住む猫型ロボット</option>
<option>雲の牧場の飼育員</option>
<option>月光に踊る騎士</option>
<option>泡でできた人間</option>
<option>時の狭間に生きる者</option>
</select>
</div>
<div class="form-group">
<label>衣装</label>
<select id="clothing">
<option>着物</option>
<option>ゴスロリ</option>
<option>セーラー服</option>
<option>鎧</option>
<option>メイド服</option>
<option>学生服</option>
<option>忍者装束</option>
<option>水着</option>
<option>アイドル衣装</option>
<option>宇宙服</option>
<option>ウェディングドレス</option>
<option>スーツ</option>
<option>チャイナドレス</option>
<option>パーカーとジーンズ</option>
<option>ボロボロの服</option>
<option>軍服</option>
<option>ドレスアーマー</option>
<option>モダンファッション</option>
<option>魔導士のローブ</option>
<option>未来的スーツ</option>
<option>フリルのついたロングドレス</option>
<option>サイバースーツ</option>
<option>狩人の装束</option>
<option>伝統的な王族の衣装</option>
<option>修道女の服</option>
<option>ポンチョスタイル</option>
<option>レザージャケット</option>
<option>花柄のワンピース</option>
<option>ホログラムドレス</option>
<option>羽付きの礼装</option>
<option>ロリータドレス</option>
<option>海賊風コート</option>
<option>スポーツユニフォーム</option>
<option>カウガールスタイル</option>
<option>研究者の白衣</option>
<option>錬金術師のローブ</option>
</select>
</div>
<div class="form-group">
<label>シチュエーション</label>
<select id="scene">
<option>桜の下</option>
<option>未来都市</option>
<option>夕焼けの海辺</option>
<option>廃墟の寺院</option>
<option>暗い森</option>
<option>宇宙船の中</option>
<option>雪山</option>
<option>草原</option>
<option>古代遺跡</option>
<option>空中庭園</option>
<option>星空の下</option>
<option>雨の街角</option>
<option>異世界の市場</option>
<option>火山地帯</option>
<option>地下の書庫</option>
<option>空港の滑走路</option>
<option>無重力空間</option>
<option>深海の遺跡</option>
<option>サーカス会場</option>
<option>魔法学園の中庭</option>
<option>闘技場</option>
<option>王宮のバルコニー</option>
<option>雲の上</option>
<option>ネオン輝く夜の街</option>
<option>幽霊船の甲板</option>
<option>滝の裏の洞窟</option>
</select>
</div>
<div class="form-group">
<label>表情</label>
<select id="emotion">
<option>微笑んでいる</option>
<option>驚いている</option>
<option>泣いている</option>
<option>真剣な表情</option>
<option>照れている</option>
<option>怒っている</option>
<option>眠そう</option>
<option>無表情</option>
<option>ウィンクしている</option>
<option>笑いながら泣いている</option>
<option>楽しそうに笑っている</option>
<option>不機嫌そうな顔</option>
<option>恥ずかしそうに俯いている</option>
<option>驚愕している</option>
<option>勝ち誇っている</option>
<option>安心している</option>
<option>寂しげな表情</option>
<option>苦悩している</option>
</select>
</div>
<div class="form-group">
<label>スタイル</label>
<select id="style">
<option>アニメ調</option>
<option>リアル調</option>
<option>水彩風</option>
<option>ピクセルアート</option>
<option>モノクロスケッチ</option>
<option>デフォルメ</option>
<option>シネマティック</option>
<option>油絵風</option>
<option>幻想的</option>
<option>ミッドセンチュリー</option>
<option>ドット絵</option>
<option>イラスト風3D</option>
<option>和風アート</option>
<option>ポップアート</option>
<option>ネオンアート</option>
<option>墨絵風</option>
<option>ミニマリスト</option>
<option>ダークファンタジー</option>
</select>
</div>
<div class="form-group">
<label>ライティング</label>
<select id="lighting">
<option>夕日</option>
<option>月明かり</option>
<option>逆光</option>
<option>スポットライト</option>
<option>柔らかい光</option>
<option>ネオンライト</option>
<option>キャンドルライト</option>
<option>青白い光</option>
<option>モノクローム</option>
<option>ファンタジー風</option>
<option>雷光</option>
<option>神秘的な光</option>
<option>焚き火の明かり</option>
<option>都市の夜明かり</option>
<option>レーザーライト</option>
<option>朝焼け</option>
<option>逆光のシルエット</option>
</select>
</div>
<div class="form-group">
<label>構図</label>
<select id="aspect">
<option>全身</option>
<option>バストアップ</option>
<option>クローズアップ</option>
<option>後ろ姿</option>
<option>斜め上から</option>
<option>ローアングル</option>
<option>ハイアングル</option>
<option>俯瞰図</option>
<option>対面</option>
<option>ポートレート</option>
<option>シルエット</option>
<option>鏡越しの視点</option>
<option>一部だけ見せる</option>
<option>肩越し視点</option>
<option>手元のアップ</option>
<option>足元のアップ</option>
<option>寝転んだ構図</option>
<option>振り返った構図</option>
</select>
</div>
<button onclick="generatePrompt()">✨ プロンプト生成</button>
<button onclick="copyPrompt()">📋 コピー</button>
<div class="output" id="japaneseOutput"></div>
<div class="output" id="englishOutput"></div>
<div class="output"><strong>🚫 Negative Prompt:</strong><br><span id="negativePrompt"></span></div>
<div class="form-group">
<label>🎨 画像アップロード(作品ギャラリー)</label>
<input type="file" accept="image/*" onchange="previewUpload(event)">
</div>
<div class="gallery" id="gallery"></div>
</div>
<script>
function generatePrompt() {
const subject = document.getElementById("subject").value;
const clothing = document.getElementById("clothing").value;
const scene = document.getElementById("scene").value;
const emotion = document.getElementById("emotion").value;
const style = document.getElementById("style").value;
const lighting = document.getElementById("lighting").value;
const aspect = document.getElementById("aspect").value;
const ja = `「${scene}」で「${clothing}」を着た「${subject}」が「${emotion}」表情をしている。「${style}」「${lighting}」で「${aspect}」構図。`;
const en = `${translate(subject)}, wearing ${translate(clothing)}, ${translate(scene)}, ${translate(emotion)}, ${translate(style)}, ${translate(lighting)}, ${translate(aspect)}, masterpiece, best quality`;
document.getElementById("japaneseOutput").textContent = "📝 日本語説明:\n" + ja;
document.getElementById("englishOutput").textContent = "🧩 English Tags:\n" + en;
document.getElementById("negativePrompt").textContent = "low quality, bad anatomy, blurry, extra limbs, deformed, watermark, text, signature";
}
function copyPrompt() {
const ja = document.getElementById("japaneseOutput").textContent;
const en = document.getElementById("englishOutput").textContent;
const neg = document.getElementById("negativePrompt").textContent;
const full = `${ja}\n\n${en}\n\nNegative Prompt:\n${neg}`;
navigator.clipboard.writeText(full).then(() => alert("プロンプトをコピーしました!"));
}
function previewUpload(event) {
const files = event.target.files;
const gallery = document.getElementById("gallery");
for (let i = 0; i < files.length; i++) {
const reader = new FileReader();
reader.onload = function(e) {
const img = document.createElement("img");
img.src = e.target.result;
gallery.appendChild(img);
}
reader.readAsDataURL(files[i]);
}
}
function translate(text) {
const dict = {
"狐の少女": "fox girl", "魔法少女": "magical girl", "サイバーパンクの戦士": "cyberpunk warrior",
"猫耳の少年": "catboy", "吸血鬼の姫": "vampire princess",
"着物": "kimono", "ゴスロリ": "gothic lolita", "セーラー服": "sailor uniform", "鎧": "armor", "メイド服": "maid outfit",
"桜の下": "under cherry blossoms", "未来都市": "in futuristic city", "夕焼けの海辺": "on sunset beach", "廃墟の寺院": "in ruined temple", "暗い森": "in dark forest",
"微笑んでいる": "smiling", "驚いている": "surprised", "泣いている": "crying", "真剣な表情": "serious", "照れている": "blushing",
"アニメ調": "anime style", "リアル調": "realistic", "水彩風": "watercolor", "ピクセルアート": "pixel art", "モノクロスケッチ": "monochrome sketch",
"夕日": "sunset lighting", "月明かり": "moonlight", "逆光": "backlight", "スポットライト": "spotlight", "柔らかい光": "soft light",
"全身": "full body", "バストアップ": "bust-up", "クローズアップ": "close-up"
};
return dict[text] || text;
}
</script>
</body>
</html>
Googleの人気記事を拾ってくるサイト
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Googleニュース 人気記事アグリゲーター</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<style>
body { background: #f8f9fa; padding-top: 20px; font-family: 'Arial', sans-serif; transition: background .3s, color .3s; }
.dark-mode { background: #2c2c2c; color: #f1f1f1; }
#controls { margin-bottom: 20px; }
.card { border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); margin-bottom: 15px; transition: transform .2s, background .3s, color .3s; }
.dark-mode .card { background: #3a3a3a; color: #f1f1f1; }
.card:hover { transform: translateY(-3px); }
.card-title { font-size: 1.25rem; margin-bottom: .5rem; }
.meta { font-size: 0.85rem; color: #6c757d; margin-bottom: .5rem; }
.dark-mode .meta { color: #ccc; }
.thumbnail { width: 100%; height: auto; border-top-left-radius: 10px; border-top-right-radius: 10px; }
#loading { display: none; font-size: 2rem; text-align: center; margin-top: 40px; }
#error { display: none; color: red; text-align: center; margin-top: 20px; }
@media (min-width: 768px) {
#articles .col-md-6 { flex: 0 0 50%; max-width: 50%; }
}
</style>
</head>
<body>
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>Googleニュース 人気記事</h1>
<button id="darkModeToggle" class="btn btn-outline-secondary">ダークモード</button>
</div>
<div id="controls" class="d-flex flex-wrap justify-content-between align-items-end">
<div class="form-group mb-2 mr-2">
<label for="categorySelect">カテゴリ:</label>
<select id="categorySelect" class="form-control">
<option value="WORLD">世界</option>
<option value="BUSINESS">ビジネス</option>
<option value="TECHNOLOGY">テクノロジー</option>
<option value="ENTERTAINMENT">エンタメ</option>
<option value="SPORTS">スポーツ</option>
<option value="SCIENCE">科学</option>
<option value="HEALTH">健康</option>
</select>
</div>
<div class="form-group mb-2 mr-2 flex-grow-1">
<label for="searchInput">キーワード:</label>
<input id="searchInput" type="text" class="form-control" placeholder="タイトルで絞り込み">
</div>
<div class="form-group mb-2 mr-2">
<label for="maxItems">表示数:</label>
<input id="maxItems" type="number" class="form-control" value="20" min="1" max="100">
</div>
<button id="refreshBtn" class="btn btn-primary mb-2">更新</button>
</div>
<div id="loading"><i class="fas fa-spinner fa-spin"></i> 読み込み中...</div>
<div id="error"></div>
<div id="articles" class="row"></div>
</div>
<!-- FontAwesome & jQuery & Bootstrap JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/js/all.min.js"></script>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.bundle.min.js"></script>
<script>
const controls = {
category: document.getElementById('categorySelect'),
search: document.getElementById('searchInput'),
maxItems: document.getElementById('maxItems'),
refresh: document.getElementById('refreshBtn'),
darkToggle: document.getElementById('darkModeToggle')
};
const articlesContainer = document.getElementById('articles');
const loading = document.getElementById('loading');
const errorMsg = document.getElementById('error');
const body = document.body;
function getFeedUrl(topic) {
return `https://news.google.com/rss/headlines/section/topic/${topic}?hl=ja&gl=JP&ceid=JP:ja`;
}
async function fetchArticles() {
articlesContainer.innerHTML = '';
errorMsg.style.display = 'none';
loading.style.display = 'block';
const topic = controls.category.value;
const apiUrl = `https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(getFeedUrl(topic))}`;
try {
const res = await fetch(apiUrl);
const data = await res.json();
if (data.status !== 'ok') throw new Error('取得失敗');
let items = data.items.map(item => ({
title: item.title,
link: item.link,
date: new Date(item.pubDate),
thumbnail: item.thumbnail || ''
}));
const kw = controls.search.value.trim();
if (kw) items = items.filter(i => i.title.includes(kw));
items = items.slice(0, parseInt(controls.maxItems.value) || items.length);
render(items);
} catch (e) {
console.error(e);
errorMsg.textContent = '記事の取得に失敗しました。';
errorMsg.style.display = 'block';
} finally {
loading.style.display = 'none';
}
}
function render(items) {
if (!items.length) {
articlesContainer.innerHTML = '<p class="text-center w-100">該当する記事がありません。</p>';
return;
}
items.forEach(i => {
const col = document.createElement('div'); col.className = 'col-12 col-md-6';
const thumb = i.thumbnail ? `<img src="${i.thumbnail}" class="thumbnail" alt="サムネイル">` : '';
col.innerHTML = `
<div class="card">
${thumb}
<div class="card-body">
<h2 class="card-title"><a href="${i.link}" target="_blank">${i.title}</a></h2>
<p class="meta">公開: ${i.date.toLocaleString()}</p>
</div>
</div>`;
articlesContainer.appendChild(col);
});
}
controls.refresh.addEventListener('click', fetchArticles);
controls.darkToggle.addEventListener('click', () => {
body.classList.toggle('dark-mode');
});
document.addEventListener('DOMContentLoaded', fetchArticles);
</script>
</body>
</html>
ゲームBGM自動生成サービス.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>🎮 ゲームBGM自動生成サービス</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #121212;
--card: #1e1e2e;
--text: #ffffff;
--accent: #ffd700;
--shadow: rgba(0, 0, 0, 0.3);
--button: #ffd700;
}
[data-theme='light'] {
--bg: #f5f5f5;
--card: #ffffff;
--text: #111;
--accent: #ff9800;
--shadow: rgba(0, 0, 0, 0.1);
--button: #ff9800;
}
body {
margin: 0;
font-family: 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
transition: 0.3s;
padding: 1rem;
}
header {
text-align: center;
margin-bottom: 1rem;
}
h1 {
color: var(--accent);
font-size: 2rem;
}
.container {
max-width: 600px;
margin: auto;
background: var(--card);
border-radius: 12px;
box-shadow: 0 0 10px var(--shadow);
padding: 1.5rem;
}
label {
font-weight: bold;
display: block;
margin-top: 1rem;
}
select, button {
width: 100%;
padding: 0.6rem;
margin-top: 0.5rem;
border-radius: 8px;
border: none;
font-size: 1rem;
}
button {
background: var(--button);
color: #000;
font-weight: bold;
cursor: pointer;
transition: 0.3s;
}
button:disabled {
background: #999;
cursor: not-allowed;
}
button:hover:enabled {
opacity: 0.85;
}
.desc, .msg {
margin-top: 1rem;
font-size: 0.9rem;
color: #ccc;
}
.result, .history {
margin-top: 2rem;
}
audio {
width: 100%;
margin-top: 0.5rem;
}
.visualizer {
width: 100%;
height: 4px;
background: linear-gradient(90deg, #ffd700, #ff9800);
animation: pulse 2s infinite linear;
opacity: 0;
}
.playing .visualizer {
opacity: 1;
}
@keyframes pulse {
0% { transform: scaleX(1); }
50% { transform: scaleX(1.05); }
100% { transform: scaleX(1); }
}
.toggle-theme {
text-align: right;
margin-bottom: 0.5rem;
}
.accordion {
background: transparent;
color: var(--accent);
border: none;
font-weight: bold;
cursor: pointer;
margin-top: 1rem;
width: 100%;
text-align: left;
font-size: 1rem;
}
.accordion-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.accordion.open + .accordion-content {
max-height: 600px;
}
</style>
</head>
<body>
<header>
<h1>🎮 ゲームBGM自動生成</h1>
</header>
<div class="toggle-theme">
<button onclick="toggleTheme()">🌓 テーマ切替</button>
</div>
<div class="container">
<label for="genre">🎼 ジャンル</label>
<select id="genre" onchange="updateDescription()">
<option value="fantasy">ファンタジー</option>
<option value="cyberpunk">サイバーパンク</option>
<option value="horror">ホラー</option>
<option value="symphonic">シンフォニック</option>
</select>
<label for="mood">🎭 雰囲気</label>
<select id="mood" onchange="updateDescription()">
<option value="mysterious">神秘的</option>
<option value="sad">悲しい</option>
<option value="heroic">勇ましい</option>
<option value="fun">楽しい</option>
</select>
<div class="desc" id="descText">選択内容に応じてBGMを生成します。</div>
<button id="generateBtn" onclick="generateBGM()">🎶 BGMを生成する</button>
<div class="msg" id="msg"></div>
<div class="result" id="result" style="display:none;">
<h3>🎧 再生中のBGM</h3>
<audio controls id="player" onplay="startVisualizer()" onpause="stopVisualizer()"></audio>
<div class="visualizer" id="visualizer"></div>
</div>
<button class="accordion" onclick="toggleAccordion(this)">📜 再生履歴</button>
<div class="accordion-content" id="historyList"></div>
</div>
<script>
const descMap = {
fantasy: "魔法の世界、冒険と伝説の音楽",
cyberpunk: "近未来都市とテクノ感の融合",
horror: "不安と恐怖を煽るBGM",
symphonic: "壮大で感動的なオーケストラ風",
mysterious: "謎解き、探検にぴったり",
sad: "涙や喪失感を表現する旋律",
heroic: "勇ましさ、戦い、勝利の象徴",
fun: "軽快で明るいリズム"
};
const history = [];
const maxHistory = 5;
function updateDescription() {
const g = document.getElementById('genre').value;
const m = document.getElementById('mood').value;
document.getElementById('descText').textContent = `🧠 ${descMap[g]} × ${descMap[m]}`;
}
function generateBGM() {
const genre = document.getElementById('genre').value;
const mood = document.getElementById('mood').value;
const btn = document.getElementById('generateBtn');
const player = document.getElementById('player');
const msg = document.getElementById('msg');
const result = document.getElementById('result');
btn.disabled = true;
msg.textContent = "⏳ BGMを生成中...";
const url = `https://example.com/bgm/${genre}_${mood}_${Math.floor(Math.random()*3)+1}.mp3`;
setTimeout(() => {
msg.textContent = "✅ BGM生成完了!再生できます。";
player.src = url;
result.style.display = 'block';
// 保存履歴
history.unshift({ genre, mood, url });
if (history.length > maxHistory) history.pop();
renderHistory();
btn.disabled = false;
}, 1500);
}
function renderHistory() {
const list = document.getElementById('historyList');
list.innerHTML = "";
history.forEach(item => {
const div = document.createElement('div');
div.innerHTML = `<strong>${item.genre} × ${item.mood}</strong><br><audio controls src="${item.url}"></audio><hr>`;
list.appendChild(div);
});
}
function toggleTheme() {
const theme = document.documentElement.getAttribute("data-theme");
document.documentElement.setAttribute("data-theme", theme === "light" ? "dark" : "light");
}
function toggleAccordion(el) {
el.classList.toggle('open');
}
function startVisualizer() {
document.getElementById('visualizer').style.opacity = '1';
}
function stopVisualizer() {
document.getElementById('visualizer').style.opacity = '0';
}
// 初期設定
document.documentElement.setAttribute("data-theme", "dark");
updateDescription();
</script>
</body>
</html>
Z
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Z – 次世代ソーシャルネットワーク</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/aframe/1.5.0/aframe.min.js"></script>
<style>
:root{
--primary:#1DA1F2;--background:#fff;--text:#000;--border:#E1E8ED;--card:#F7F9F9;--danger:#E0245E;
}
[data-theme="dark"]{--background:#15202B;--text:#fff;--border:#38444D;--card:#192734}
*{box-sizing:border-box;margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
body{background:var(--background);color:var(--text);min-height:100vh;transition:.3s}
.hidden{display:none}
.wrapper{max-width:640px;margin-inline:auto;padding:20px}
.timeline{margin-top:2rem}
.timeline-item{background:var(--card);border-radius:12px;padding:1rem;margin-bottom:1rem;box-shadow:0 2px 6px rgba(0,0,0,.05)}
.timeline-item h3{margin:0 0 .5rem;font-size:1.1rem}
.timeline-item p{margin:0;white-space:pre-wrap;line-height:1.4}
.timeline-item small{display:block;margin-top:.5rem;font-size:.75rem;color:var(--border)}
.auth-box{background:var(--card);border-radius:12px;padding:1.5rem;margin-bottom:2rem}
.auth-box input{padding:.75rem;border:1px solid var(--border);border-radius:8px;width:100%;margin-bottom:.5rem}
.auth-box button{background:var(--primary);color:white;border:none;border-radius:8px;padding:.75rem;margin-top:.5rem;width:100%;cursor:pointer;font-weight:bold}
.profile-edit{background:var(--card);padding:1rem;border-radius:12px;margin-bottom:2rem}
.profile-edit h3{margin-bottom:.75rem}
.profile-edit input{width:100%;margin:.5rem 0;padding:.5rem;border:1px solid var(--border);border-radius:8px}
.follow-btn{background:#ccc;padding:.3rem .8rem;border-radius:8px;border:none;cursor:pointer;font-size:.85rem;margin-top:.5rem}
img.upload-preview{max-width:100px;border-radius:8px;margin-top:.5rem}
</style>
</head>
<body>
<div class="wrapper">
<div id="authBox" class="auth-box">
<h2>ログイン / 登録</h2>
<input type="email" id="email" placeholder="メールアドレス">
<input type="tel" id="phone" placeholder="電話番号">
<input type="password" id="password" placeholder="パスワード">
<input type="text" id="username" placeholder="ユーザー名">
<button onclick="loginOrRegister()">ログイン / 登録</button>
</div>
<div id="mainBox" class="hidden">
<h1 style="font-size:1.5rem;margin-bottom:1rem">Zタイムライン</h1>
<div style="margin-bottom:1rem">ようこそ、<span id="userEmail"></span> さん!</div>
<div class="profile-edit">
<h3>プロフィール編集</h3>
<input type="text" id="editName" placeholder="表示名を編集">
<input type="text" id="editBio" placeholder="自己紹介を編集">
<button onclick="saveProfile()">プロフィール保存</button>
</div>
<form id="timelineForm" style="display:flex;flex-direction:column;gap:.75rem;margin-bottom:2rem">
<input id="timelineTitle" type="text" placeholder="タイトル" required>
<textarea id="timelineContent" placeholder="投稿内容" required style="min-height:100px"></textarea>
<input type="file" id="imageUpload" accept="image/*">
<img id="preview" class="upload-preview hidden">
<button type="submit">タイムラインに投稿</button>
</form>
<section id="timelineList" class="timeline"></section>
<button onclick="logout()">ログアウト</button>
</div>
</div>
<div id="vrScene" class="hidden" style="position:fixed;inset:0;z-index:9999"></div>
<button id="vrBtn" style="position:fixed;bottom:20px;right:20px;width:56px;height:56px;border-radius:50%;background:var(--primary);color:#fff;border:none;font-size:1.3rem;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.25)" onclick="enterVR()"><i class="fa-brands fa-vr-cardboard"></i></button>
<script>
let timeline = JSON.parse(localStorage.getItem('z_timeline')||'[]');
let feeds = JSON.parse(localStorage.getItem('z_feeds')||'[]');
let currentUser = JSON.parse(localStorage.getItem('z_user')||'null');
const authBox = document.getElementById('authBox');
const mainBox = document.getElementById('mainBox');
const timelineForm = document.getElementById('timelineForm');
const timelineList = document.getElementById('timelineList');
const userEmailSpan = document.getElementById('userEmail');
const previewImg = document.getElementById('preview');
const imageUpload = document.getElementById('imageUpload');
function loginOrRegister(){
const email = document.getElementById('email').value.trim();
const phone = document.getElementById('phone').value.trim();
const pass = document.getElementById('password').value;
const name = document.getElementById('username').value.trim();
if(!email || !pass || !name){ alert('メール、パスワード、ユーザー名を入力してください'); return; }
currentUser = {email, phone, name, bio:"", followers:[], following:[]};
localStorage.setItem('z_user', JSON.stringify(currentUser));
authBox.classList.add('hidden');
mainBox.classList.remove('hidden');
userEmailSpan.textContent = email;
renderTimeline();
}
function logout(){ localStorage.removeItem('z_user'); location.reload(); }
function saveProfile(){
const name = document.getElementById('editName').value;
const bio = document.getElementById('editBio').value;
if(name) currentUser.name = name;
if(bio) currentUser.bio = bio;
localStorage.setItem('z_user', JSON.stringify(currentUser));
alert('プロフィールを保存しました');
}
function renderTimeline(){
if(!timeline.length){ timelineList.innerHTML = '<p style="color:var(--border)">投稿がまだありません</p>'; return; }
timelineList.innerHTML = timeline.map((t, index)=>{
return `<div class="timeline-item">
<h3>${t.title}</h3>
<p>${t.content}</p>
${t.image ? `<img src="${t.image}" style="max-width:100%;margin-top:.5rem;border-radius:8px">` : ''}
<small>${new Date(t.created).toLocaleString()}</small>
<button onclick="followUser('${t.email}')" class="follow-btn">フォロー</button>
<button onclick="deletePost(${index})" style="margin-top:.5rem;padding:.3rem .6rem;border:none;background:#eee;border-radius:6px;font-size:.8rem;cursor:pointer">削除</button>
</div>`;
}).join('');
}
function followUser(email){
if(!currentUser.following.includes(email)){
currentUser.following.push(email);
localStorage.setItem('z_user', JSON.stringify(currentUser));
alert(`${email} をフォローしました`);
}
}
function deletePost(index){
if(confirm('この投稿を削除しますか?')){
timeline.splice(index,1);
localStorage.setItem('z_timeline', JSON.stringify(timeline));
renderTimeline();
}
}
timelineForm.addEventListener('submit',e=>{
e.preventDefault();
const title = document.getElementById('timelineTitle').value.trim();
const content = document.getElementById('timelineContent').value.trim();
const file = imageUpload.files[0];
if(!title || !content) return;
const newPost = {title, content, image:null, created:new Date().toISOString(), email: currentUser.email};
if(file){
const reader = new FileReader();
reader.onload = ()=>{
newPost.image = reader.result;
timeline.unshift(newPost);
localStorage.setItem('z_timeline', JSON.stringify(timeline));
renderTimeline();
};
reader.readAsDataURL(file);
} else {
timeline.unshift(newPost);
localStorage.setItem('z_timeline', JSON.stringify(timeline));
renderTimeline();
}
timelineForm.reset();
previewImg.classList.add('hidden');
});
imageUpload.addEventListener('change',()=>{
const file = imageUpload.files[0];
if(file){
const reader = new FileReader();
reader.onload = ()=>{
previewImg.src = reader.result;
previewImg.classList.remove('hidden');
};
reader.readAsDataURL(file);
}
});
function botAutoPost(){
const phrases = ['こんにちは!', '今日も頑張ろう!', 'Zへようこそ!'];
const msg = phrases[Math.floor(Math.random()*phrases.length)];
timeline.unshift({title:'BOT投稿', content:msg, image:null, created:new Date().toISOString(), email:'bot@z.jp'});
localStorage.setItem('z_timeline', JSON.stringify(timeline));
renderTimeline();
}
setInterval(botAutoPost, 60000);
function fetchFeed(url){
fetch(`https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(url)}`)
.then(res=>res.json())
.then(data=>{
if(!data.items) return;
data.items.slice(0,3).forEach(item=>{
timeline.unshift({title:item.title, content:item.link, image:null, created:new Date().toISOString(), email:data.feed.title});
});
localStorage.setItem('z_timeline', JSON.stringify(timeline));
renderTimeline();
}).catch(e=>console.error('feed error',e));
}
feeds.forEach(fetchFeed);
function enterVR(){
document.getElementById('vrScene').innerHTML = `
<a-scene embedded>
<a-sky color="#ECECEC"></a-sky>
${timeline.slice(0,10).map((p,i)=>`<a-entity text="value:${p.title}: ${p.content.replace(/\n/g,' ')};wrapCount:30" position="0 ${3-i*1.5} -3"></a-entity>`).join('')}
<a-camera position="0 1.6 0"></a-camera>
</a-scene>`;
document.getElementById('vrScene').classList.remove('hidden');
document.getElementById('vrBtn').classList.add('hidden');
}
document.addEventListener('keydown',e=>{
if(e.key==='Escape' && !document.getElementById('vrScene').classList.contains('hidden')){
document.getElementById('vrScene').classList.add('hidden');
document.getElementById('vrBtn').classList.remove('hidden');
document.getElementById('vrScene').innerHTML='';
}
});
if(currentUser){
authBox.classList.add('hidden');
mainBox.classList.remove('hidden');
userEmailSpan.textContent = currentUser.email;
renderTimeline();
}
</script>
</body>
</html>
小説投稿サイト
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>小説投稿サイト</title>
<style>
body {
font-family: sans-serif;
padding: 20px;
max-width: 800px;
margin: auto;
background: #f2f2f2;
}
h1 {
text-align: center;
}
form {
background: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 30px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
input, textarea {
width: 100%;
margin-bottom: 10px;
padding: 10px;
border-radius: 5px;
border: 1px solid #ccc;
}
button {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
.post {
background: white;
padding: 15px;
border-left: 5px solid #007bff;
margin-bottom: 20px;
border-radius: 5px;
}
.post h2 {
margin: 0 0 10px;
}
.meta {
color: gray;
font-size: 0.9em;
margin-bottom: 10px;
}
.delete-btn {
background-color: #dc3545;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
float: right;
cursor: pointer;
}
</style>
</head>
<body>
<h1>小説投稿サイト</h1>
<form id="novelForm">
<input type="text" id="author" placeholder="著者名" required>
<input type="text" id="title" placeholder="タイトル" required>
<textarea id="content" rows="8" placeholder="本文" required></textarea>
<button type="submit">投稿する</button>
</form>
<div id="postList"></div>
<script>
const form = document.getElementById('novelForm');
const postList = document.getElementById('postList');
let posts = JSON.parse(localStorage.getItem('novels')) || [];
function saveAndRender() {
localStorage.setItem('novels', JSON.stringify(posts));
renderPosts();
}
function renderPosts() {
postList.innerHTML = '';
[...posts].reverse().forEach((post, index) => {
const div = document.createElement('div');
div.className = 'post';
div.innerHTML = `
<button class="delete-btn" onclick="deletePost(${index})">削除</button>
<h2>${post.title}</h2>
<div class="meta">著者: ${post.author} | 投稿日: ${post.date}</div>
<p>${post.content.replace(/\n/g, '<br>')}</p>
`;
postList.appendChild(div);
});
}
form.addEventListener('submit', e => {
e.preventDefault();
const title = document.getElementById('title').value;
const content = document.getElementById('content').value;
const author = document.getElementById('author').value;
const date = new Date().toLocaleString();
posts.push({ title, content, author, date });
form.reset();
saveAndRender();
});
window.deletePost = function(index) {
posts.splice(posts.length - 1 - index, 1); // reverseしてるため
saveAndRender();
}
renderPosts();
</script>
</body>
</html>
Twitter風サイト
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Twitter風サイト(高度拡張版)</title>
<style>
/* 全体設定 */
body {
margin: 0;
padding: 0;
font-family: sans-serif;
background-color: #f5f8fa;
}
header {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 50px;
background-color: #1da1f2;
display: flex;
align-items: center;
padding: 0 20px;
color: #fff;
font-size: 20px;
font-weight: bold;
box-sizing: border-box;
z-index: 10;
}
/* レイアウト用コンテナ */
.container {
display: flex;
width: 100%;
max-width: 1200px;
margin: 60px auto 0; /* ヘッダー分だけ下に余白をとる */
box-sizing: border-box;
}
/* 左サイドバー(ナビゲーション) */
.sidebar {
width: 20%;
max-width: 200px;
padding: 10px;
box-sizing: border-box;
}
.nav-item {
margin: 10px 0;
font-size: 18px;
}
.nav-item a {
text-decoration: none;
color: #1da1f2;
cursor: pointer;
}
.profile-settings {
margin-top: 20px;
padding: 10px;
background-color: #fff;
border: 1px solid #e6ecf0;
border-radius: 5px;
}
.profile-settings input {
width: 100%;
margin-bottom: 5px;
font-size: 14px;
padding: 5px;
box-sizing: border-box;
}
.profile-settings button {
border: none;
background-color: #1da1f2;
color: #fff;
font-size: 14px;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
}
/* メインタイムライン部分 */
.feed {
width: 60%;
padding: 10px;
box-sizing: border-box;
}
.tweet-box {
background-color: #fff;
border: 1px solid #e6ecf0;
border-radius: 5px;
padding: 10px;
margin-bottom: 20px;
}
.tweet-box textarea {
width: 100%;
border: none;
resize: none;
font-size: 16px;
outline: none;
box-sizing: border-box;
}
.tweet-stats {
display: flex;
justify-content: space-between;
margin-top: 5px;
font-size: 14px;
}
.tweet-stats .char-count {
color: #657786;
}
.tweet-stats .error {
color: red;
}
.tweet-box .attach-label {
display: inline-block;
margin-top: 5px;
font-size: 14px;
color: #657786;
}
.tweet-box button {
margin-top: 10px;
padding: 8px 16px;
border: none;
background-color: #1da1f2;
color: #fff;
font-size: 16px;
border-radius: 5px;
cursor: pointer;
}
.tweet {
background-color: #fff;
border: 1px solid #e6ecf0;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
}
.tweet-header {
display: flex;
align-items: center;
margin-bottom: 5px;
flex-wrap: wrap;
}
.tweet-header img.user-icon {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 10px;
}
.tweet-header .name {
font-weight: bold;
margin-right: 5px;
}
.tweet-header .username {
color: #657786;
font-size: 14px;
margin-right: 5px;
}
.tweet-time {
font-size: 12px;
color: #657786;
}
.tweet-content {
font-size: 16px;
margin: 10px 0;
white-space: pre-wrap; /* 改行を保持 */
}
.tweet-content img.attached-image {
max-width: 100%;
display: block;
margin-top: 5px;
border: 1px solid #ccc;
}
.tweet-footer {
display: flex;
justify-content: flex-start;
gap: 15px;
margin-top: 10px;
flex-wrap: wrap;
}
.tweet-footer button {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: #657786;
display: flex;
align-items: center;
gap: 5px;
}
.tweet .replies-container {
margin-top: 10px;
border-left: 2px solid #e6ecf0;
padding-left: 10px;
}
/* リプライの更なる階層は少しずつ左にずらす */
.nested-reply {
margin-left: 20px;
}
/* 右サイドバー(ウィジェット) */
.widgets {
width: 20%;
max-width: 250px;
padding: 10px;
box-sizing: border-box;
}
.search-box {
background-color: #fff;
border-radius: 20px;
padding: 8px 15px;
margin-bottom: 20px;
border: 1px solid #e6ecf0;
display: flex;
align-items: center;
}
.search-box input {
border: none;
outline: none;
width: 100%;
font-size: 16px;
}
.trends {
background-color: #fff;
border: 1px solid #e6ecf0;
border-radius: 5px;
padding: 10px;
}
.trends h3 {
margin-top: 0;
}
.trend-item {
margin-bottom: 10px;
font-size: 14px;
}
/* リンク風のテキストデザイン */
.hashtag,
.mention {
color: #1da1f2;
text-decoration: none;
cursor: pointer;
}
.hashtag:hover,
.mention:hover {
text-decoration: underline;
}
/* 折りたたみ表示のボタン */
.toggle-replies-btn {
background: none;
color: #1da1f2;
border: none;
cursor: pointer;
font-size: 14px;
margin-top: 5px;
padding: 0;
}
</style>
</head>
<body>
<!-- ヘッダー -->
<header>
Twitter風サイト(高度拡張版)
</header>
<!-- メインコンテンツを左右に分けるコンテナ -->
<div class="container">
<!-- 左サイドバー -->
<aside class="sidebar">
<div class="nav-item"><a href="#">ホーム</a></div>
<div class="nav-item"><a href="#">通知</a></div>
<div class="nav-item"><a href="#">設定</a></div>
<!-- 簡易プロフィール設定 -->
<div class="profile-settings">
<label for="displayName">名前</label>
<input type="text" id="displayName" placeholder="あなたの表示名">
<label for="userName">ユーザー名(@なしで)</label>
<input type="text" id="userName" placeholder="myAccount">
<button id="saveProfileBtn">保存</button>
</div>
</aside>
<!-- タイムライン部分 -->
<main class="feed">
<!-- 新規ツイート入力フォーム -->
<div class="tweet-box">
<textarea rows="3" placeholder="いまどうしてる? (140文字まで)"></textarea>
<div class="tweet-stats">
<span class="char-count">0 / 140</span>
<span class="error"></span>
</div>
<label class="attach-label">
画像を添付:
<input type="file" class="attach-input" accept="image/*">
</label>
<button class="tweet-submit-btn">ツイート</button>
</div>
</main>
<!-- 右サイドバー -->
<aside class="widgets">
<!-- 検索ボックス -->
<div class="search-box">
<input type="text" placeholder="キーワード検索">
</div>
<!-- トレンド表示 -->
<div class="trends">
<h3>今どうしてる?</h3>
<div class="trend-item">#春の訪れ</div>
<div class="trend-item">#お花見</div>
<div class="trend-item">#新年度</div>
</div>
</aside>
</div>
<script>
// =======================
// 定数・変数設定
// =======================
const TWEET_MAX_LENGTH = 140;
const feedContainer = document.querySelector('.feed');
const tweetTextarea = document.querySelector('.tweet-box textarea');
const tweetButton = document.querySelector('.tweet-submit-btn');
const charCountEl = document.querySelector('.char-count');
const errorEl = document.querySelector('.error');
const attachInput = document.querySelector('.attach-input');
const displayNameInput = document.getElementById('displayName');
const userNameInput = document.getElementById('userName');
const saveProfileBtn = document.getElementById('saveProfileBtn');
// LocalStorageからツイート一覧を読み込み(なければ空配列)
let tweets = JSON.parse(localStorage.getItem('tweets-advanced') || '[]');
// ユーザープロフィール情報をLocalStorageから読み込み
let userProfile = JSON.parse(localStorage.getItem('userProfile-advanced') || '{}');
let currentName = userProfile.displayName || 'あなた';
let currentUserName = userProfile.userName || 'myAccount';
// フォームに反映
displayNameInput.value = currentName;
userNameInput.value = currentUserName;
// =======================
// 画像ファイル取得用
// =======================
let attachedImageBase64 = null;
attachInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if(!file) {
attachedImageBase64 = null;
return;
}
const reader = new FileReader();
reader.onload = () => {
attachedImageBase64 = reader.result; // base64データ
};
reader.readAsDataURL(file);
});
// =======================
// スレッド実装のためのツイート構造
// =======================
// tweet = {
// id: string (一意のID),
// name: string,
// userName: string,
// content: string,
// time: number (Date.now()),
// likes: number,
// retweets: number,
// image: string (base64) | null,
// replies: array of same structure
// }
// =======================
// ユーティリティ
// =======================
function escapeHtml(str) {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// ハッシュタグ/メンションのリンク化
function linkify(text) {
let escaped = escapeHtml(text);
escaped = escaped.replace(/#(\w+)/g, `<a href="#" class="hashtag">#$1</a>`);
escaped = escaped.replace(/@(\w+)/g, `<a href="#" class="mention">@$1</a>`);
return escaped;
}
// ツイートをLocalStorageに保存
function updateLocalStorage() {
localStorage.setItem('tweets-advanced', JSON.stringify(tweets));
}
// 親ツイートまたはリプライ先を検索するための再帰関数
function findTweetById(tweetArray, tweetId) {
for (const tw of tweetArray) {
if (tw.id === tweetId) {
return tw;
}
const childFound = findTweetById(tw.replies, tweetId);
if (childFound) {
return childFound;
}
}
return null;
}
// 新しいツイートを作成 & tweets配列に登録
// parentIdが指定されたら、そのツイートのrepliesに追加する
function createNewTweet(content, parentId = null, imageBase64 = null) {
const newTweet = {
id: 'tw-' + Date.now() + '-' + Math.floor(Math.random() * 10000),
name: currentName,
userName: currentUserName,
content: content,
time: Date.now(),
likes: 0,
retweets: 0,
image: imageBase64,
replies: []
};
if (parentId) {
const parentTweet = findTweetById(tweets, parentId);
if (parentTweet) {
parentTweet.replies.unshift(newTweet);
}
} else {
tweets.unshift(newTweet);
}
updateLocalStorage();
renderTweets();
}
// =======================
// ツイート描画
// =======================
// ツイート1件を生成するDOM要素を返す(返信分も含め再帰的に生成)
// depth: スレッドの深さに応じて左マージンなどを調整したいときに利用
function createTweetElement(tweet, depth = 0) {
const tweetDiv = document.createElement('div');
tweetDiv.classList.add('tweet');
if (depth >= 1) {
// 2段目以降のリプライならclassで左にずらす
tweetDiv.classList.add('nested-reply');
}
// 日付文字列
const timeString = new Date(tweet.time).toLocaleString('ja-JP', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
// 本文のリンク化
const contentHtml = linkify(tweet.content);
// 画像がある場合
const imageHtml = tweet.image
? `<img src="${tweet.image}" alt="Attached Image" class="attached-image" />`
: '';
// 自分のツイートなら削除ボタンを表示
const isMyTweet = (tweet.name === currentName && tweet.userName === currentUserName);
const deleteBtnHtml = isMyTweet
? `<button class="delete-btn">削除</button>`
: '';
tweetDiv.innerHTML = `
<div class="tweet-header">
<img src="https://via.placeholder.com/40" alt="User Icon" class="user-icon" />
<span class="name">${escapeHtml(tweet.name)}</span>
<span class="username">@${escapeHtml(tweet.userName)}</span>
<span class="tweet-time">- ${timeString}</span>
</div>
<div class="tweet-content">
${contentHtml}
${imageHtml}
</div>
<div class="tweet-footer">
<button class="like-btn">
<span>いいね</span>
<span class="like-count">${tweet.likes}</span>
</button>
<button class="retweet-btn">
<span>リツイート</span>
<span class="retweet-count">${tweet.retweets}</span>
</button>
<button class="reply-btn">返信</button>
${deleteBtnHtml}
</div>
`;
// ---------- 返信フォーム & スレッド表示 ----------
// 返信コンテナ(折りたたみ対象)
const repliesContainer = document.createElement('div');
repliesContainer.classList.add('replies-container');
// 返信がある場合、表示/非表示を切り替えるボタンを設置
if (tweet.replies && tweet.replies.length > 0) {
const toggleRepliesBtn = document.createElement('button');
toggleRepliesBtn.classList.add('toggle-replies-btn');
toggleRepliesBtn.textContent = `返信を表示 (${tweet.replies.length})`;
tweetDiv.appendChild(toggleRepliesBtn);
// 折りたたみ状態管理
let isRepliesOpen = false;
toggleRepliesBtn.addEventListener('click', () => {
isRepliesOpen = !isRepliesOpen;
toggleRepliesBtn.textContent = isRepliesOpen
? `返信を非表示`
: `返信を表示 (${tweet.replies.length})`;
repliesContainer.style.display = isRepliesOpen ? 'block' : 'none';
});
}
// 返信フォーム
const replyForm = document.createElement('div');
replyForm.style.marginTop = '5px';
replyForm.innerHTML = `
<textarea rows="2" placeholder="返信を入力..." style="width: 100%; font-size:14px;"></textarea>
<button class="reply-submit-btn" style="margin-top:5px;">返信を投稿</button>
`;
replyForm.style.display = 'none'; // デフォルトは非表示
tweetDiv.appendChild(replyForm);
// スレッド(返信)の再帰描画
tweet.replies.forEach(replyTweet => {
const replyEl = createTweetElement(replyTweet, depth + 1);
repliesContainer.appendChild(replyEl);
});
repliesContainer.style.display = 'none'; // 最初は折りたたみ
tweetDiv.appendChild(repliesContainer);
// ========== 各種ボタンイベント ==========
// いいね
const likeBtn = tweetDiv.querySelector('.like-btn');
const likeCountEl = tweetDiv.querySelector('.like-count');
likeBtn.addEventListener('click', () => {
tweet.likes++;
updateLocalStorage();
likeCountEl.textContent = tweet.likes;
});
// リツイート
const retweetBtn = tweetDiv.querySelector('.retweet-btn');
const retweetCountEl = tweetDiv.querySelector('.retweet-count');
retweetBtn.addEventListener('click', () => {
tweet.retweets++;
updateLocalStorage();
retweetCountEl.textContent = tweet.retweets;
});
// 返信ボタン -> フォーム表示/非表示
const replyBtn = tweetDiv.querySelector('.reply-btn');
replyBtn.addEventListener('click', () => {
replyForm.style.display = (replyForm.style.display === 'none') ? 'block' : 'none';
});
// 返信投稿
const replySubmitBtn = replyForm.querySelector('.reply-submit-btn');
const replyTextarea = replyForm.querySelector('textarea');
replySubmitBtn.addEventListener('click', () => {
const replyText = replyTextarea.value.trim();
if (replyText === '' || replyText.length > TWEET_MAX_LENGTH) {
return;
}
// 新規リプライ作成
createNewTweet(replyText, tweet.id);
replyTextarea.value = '';
});
// 削除
if (isMyTweet) {
const deleteBtn = tweetDiv.querySelector('.delete-btn');
deleteBtn.addEventListener('click', () => {
// 再帰的に探して削除
removeTweetById(tweets, tweet.id);
updateLocalStorage();
renderTweets();
});
}
return tweetDiv;
}
// ツイート削除(再帰)
function removeTweetById(tweetArray, tweetId) {
for (let i = 0; i < tweetArray.length; i++) {
if (tweetArray[i].id === tweetId) {
tweetArray.splice(i, 1);
return true;
}
if (removeTweetById(tweetArray[i].replies, tweetId)) {
return true;
}
}
return false;
}
// 画面上のツイート一覧を再描画
function renderTweets() {
// まず既存ツイートを全削除
const oldTweets = feedContainer.querySelectorAll('.tweet');
oldTweets.forEach(t => t.remove());
// 上から順にツイートを追加
tweets.forEach(tweet => {
const tweetEl = createTweetElement(tweet);
feedContainer.appendChild(tweetEl);
});
}
// ======================
// イベントリスナー
// ======================
window.addEventListener('DOMContentLoaded', () => {
renderTweets();
updateCharCount();
});
// ツイート文字数カウント
tweetTextarea.addEventListener('input', updateCharCount);
function updateCharCount() {
const length = tweetTextarea.value.length;
charCountEl.textContent = `${length} / ${TWEET_MAX_LENGTH}`;
if (length > TWEET_MAX_LENGTH) {
errorEl.textContent = '文字数オーバーです!';
tweetButton.disabled = true;
} else {
errorEl.textContent = '';
tweetButton.disabled = false;
}
}
// ツイート投稿
tweetButton.addEventListener('click', () => {
const text = tweetTextarea.value.trim();
if (text === '' || text.length > TWEET_MAX_LENGTH) {
return;
}
createNewTweet(text, null, attachedImageBase64);
tweetTextarea.value = '';
attachedImageBase64 = null;
attachInput.value = ''; // ファイル選択をクリア
updateCharCount();
});
// プロフィール情報の保存
saveProfileBtn.addEventListener('click', () => {
currentName = displayNameInput.value.trim() || 'あなた';
currentUserName = userNameInput.value.trim() || 'myAccount';
userProfile = {
displayName: currentName,
userName: currentUserName
};
localStorage.setItem('userProfile-advanced', JSON.stringify(userProfile));
alert('プロフィールを保存しました!\n' +
`名前:${currentName}\nユーザー名:@${currentUserName}`);
renderTweets(); // 表示名を変えて再描画
});
</script>
</body>
</html>
