Qiitaティッカー - Qiitaの記事をターミナル風に表示

Qiita APIから最新のAI関連記事を取得してターミナル風に表示。キーワード検索、ストック数でのフィルターなども可能。

<html style="max-height: 100%;"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta http-equiv="X-UA-Compatible" content="ie=edge"><style id="basic-style"></style><style id="root-style">:root{--pic-image-data:none;}</style><style id="out-style">:root { --color-qiita-green: #55c500; --color-terminal-bg: #0d1117; --color-terminal-header: #161b22; --color-text: #c9d1d9; --color-text-dim: #8b949e; --color-prompt: #58a6ff; --color-success: #3fb950; --color-warning: #d29922; --space-sm: 0.5rem; --space-md: 1rem; --line-height: 1.6; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Menlo', 'Monaco', 'Courier New', monospace; background: transparent; color: var(--color-text); min-height: 100vh; display: flex; align-items: flex-start; justify-content: center; padding: var(--space-md); } .terminal-window { width: 900px; max-width: 95vw; background: var(--color-terminal-bg); border-radius: 5px; box-shadow: 0 20px 20px rgba(0, 0, 0, 0.6); overflow: visible; display: flex; flex-direction: column; margin: 20px; } /* ターミナルヘッダー */ .terminal-header { background: var(--color-terminal-header); padding: 0.75rem 1rem; border-radius: 12px 12px 0 0; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } .terminal-dots { display: flex; gap: 0.5rem; flex: 0 0 auto; } .terminal-title { flex: 1; text-align: center; font-size: 0.875rem; font-weight: 500; color: var(--color-text-dim); } .terminal-controls { flex: 0 0 auto; } .dot { width: 12px; height: 12px; border-radius: 50%; } .dot.close { background: #ff5f57; } .dot.minimize { background: #febc2e; } .dot.maximize { background: #28c840; } .terminal-controls { display: flex; align-items: center; gap: 0.5rem; opacity: 0; pointer-events: none; transition: opacity 0.3s ease; } .terminal-window:hover .terminal-controls, .terminal-controls.show { opacity: 1; pointer-events: all; } .control-icon { width: 32px; height: 32px; border: none; background: rgba(255, 255, 255, 0.05); color: var(--color-text-dim); border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; position: relative; } .control-icon:hover { background: rgba(85, 197, 0, 0.15); color: var(--color-qiita-green); } .control-icon.active { background: rgba(85, 197, 0, 0.2); color: var(--color-qiita-green); } .control-icon svg { width: 18px; height: 18px; fill: currentColor; } .control-wrapper { position: relative; } /* ドロップダウン */ .dropdown { position: absolute; top: calc(100% + 8px); right: 0; background: var(--color-terminal-header); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 0.75rem; min-width: 280px; max-width: 90vw; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); opacity: 0; pointer-events: none; transform: translateY(-8px); transition: all 0.2s; z-index: 1000; } /* フィルターは内容が増えるため縦スクロール可能にする */ #filter-dropdown { max-height: 95vh; overflow-y: auto; } .dropdown-text { margin-bottom: 1rem; } .dropdown-text input[type="text"] { width: 100%; padding: 0.6rem 0.65rem; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.05); color: var(--color-text); border-radius: 6px; outline: none; font-size: 0.85rem; } .dropdown-text input[type="text"]:focus { border-color: var(--color-qiita-green); background: rgba(85, 197, 0, 0.08); } .dropdown.open { opacity: 1; pointer-events: all; transform: translateY(0); } .dropdown-title { font-size: 0.75rem; color: var(--color-text-dim); margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; } .dropdown-slider { margin-bottom: 0.75rem; } .dropdown-slider input[type="range"] { width: 100%; height: 4px; -webkit-appearance: none; appearance: none; background: rgba(255, 255, 255, 0.1); border-radius: 2px; outline: none; } .dropdown-slider input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; background: var(--color-qiita-green); border-radius: 50%; cursor: pointer; } .dropdown-slider input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; background: var(--color-qiita-green); border: none; border-radius: 50%; cursor: pointer; } .dropdown-value { text-align: center; font-size: 1rem; color: var(--color-qiita-green); margin-top: 0.25rem; font-weight: 600; } .dropdown-presets { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; } .preset-btn { padding: 0.5rem; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.05); color: var(--color-text); border-radius: 4px; cursor: pointer; font-size: 0.75rem; transition: all 0.2s; } .preset-btn:hover { background: rgba(85, 197, 0, 0.1); border-color: var(--color-qiita-green); } .preset-btn.active { background: var(--color-qiita-green); color: #000; border-color: var(--color-qiita-green); } /* ターミナル本体 */ .terminal-body { position: relative; /* heightはJavaScriptで動的に設定 */ } .terminal-output { width: 100%; height: 100%; padding: 1.5rem; overflow-y: auto; overflow-x: hidden; font-size: 1rem; line-height: 2; /* 行間を広げる */ } /* スクロールバーのスタイリング */ .terminal-output::-webkit-scrollbar { width: 8px; } .terminal-output::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.05); } .terminal-output::-webkit-scrollbar-thumb { background: transparent; border-radius: 4px; transition: background 0.3s ease; } /* ホバー時またはスクロール中にスクロールバーを表示 */ .terminal-window:hover .terminal-output::-webkit-scrollbar-thumb, .terminal-output.scrolling::-webkit-scrollbar-thumb { background: var(--color-qiita-green); } .terminal-output::-webkit-scrollbar-thumb:hover { background: var(--color-success); } .output-content { width: 100%; } .terminal-line { margin-bottom: 0.5rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 2; min-height: 2em; display: flex; align-items: center; } .article-item { height: 150px; /* 1記事の固定高さ */ overflow: hidden; margin-bottom: 0.5rem; padding-bottom: 1rem; will-change: transform, opacity; } .article-item.is-entering { animation: article-enter 220ms ease-out both; } @keyframes article-enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } .article-title-line { margin-top: 0.5rem; margin-bottom: 0.5rem; font-size: 1.1rem; white-space: normal; word-wrap: break-word; overflow: visible; align-items: flex-start; } .article-meta-line { margin-bottom: 0.5rem; white-space: nowrap; } .prompt { color: var(--color-prompt); margin-right: 0.25rem; font-size: 0.9em; line-height: 1; flex-shrink: 0; } .command { color: var(--color-qiita-green); line-height: 1; } /* タイピングアニメーション */ .typing-command { display: inline-block; overflow: hidden; border-right: 2px solid var(--color-qiita-green); white-space: nowrap; line-height: 1; --typing-chars: 5ch; animation: typing 1.5s steps(5) forwards, blink-caret 0.75s step-end infinite; } @keyframes typing { from { width: 0; } to { width: var(--typing-chars, 5ch); } } @keyframes blink-caret { 50% { border-color: transparent; } } /* チェックマーク */ .check-mark { color: #4ade80; font-weight: bold; margin-right: 0.5rem; } .article-title { color: var(--color-qiita-green); text-decoration: none; transition: color 0.2s; font-size: 1.1rem; font-weight: 600; display: inline-block; } .article-title:hover { color: var(--color-success); text-decoration: underline; } .meta { color: var(--color-text-dim); font-size: 0.85rem; opacity: 0.8; } .metric { font-weight: 600; margin-right: 0.25rem; line-height: 1; vertical-align: middle; } .metric-like { color: var(--color-text); } .metric-stock { color: var(--color-text); position: relative; top: -0.15em; } .meta-separator { margin: 0 0.5rem; color: var(--color-text-dim); opacity: 0.5; } .success { color: var(--color-success); } /* 選択肢リスト(next/quit) */ .choice-prompt { color: var(--color-text); font-size: 0.95rem; margin-bottom: 0.5rem; } .choice-line { margin: 0.75rem 0; display: block; } .choice-item { padding: 0.3rem 0; background: transparent; color: var(--color-text); cursor: pointer; font-size: 0.95rem; transition: color 0.15s; display: block; border: none; margin-left: 1rem; } .choice-item:hover { color: var(--color-qiita-green); } .choice-item.active { color: var(--color-qiita-green); } .choice-cursor { display: inline-block; width: 1rem; color: var(--color-qiita-green); visibility: hidden; } .choice-item.active .choice-cursor { visibility: visible; } .choice-number { color: var(--color-text-dim); margin-right: 0.25rem; } .choice-label { color: inherit; } .choice-description { display: none; } /* レスポンシブ */ @media (max-width: 768px) { .terminal-window { border-radius: 8px; } .terminal-output { font-size: 0.9rem; padding: 1rem; } .article-title { font-size: 1rem; } .meta { font-size: 0.75rem; } .terminal-title span { display: none; } .dropdown { right: 0; min-width: 240px; max-width: calc(100vw - 2rem); } } @media (max-width: 480px) { .terminal-dots { display: none; } .terminal-output { font-size: 0.85rem; } } .error-message { padding: 2rem; text-align: center; color: var(--color-warning); } .loading { padding: 2rem; text-align: center; color: var(--color-text-dim); } .loading::after { content: ''; display: inline-block; width: 1em; height: 1em; border: 2px solid var(--color-qiita-green); border-top-color: transparent; border-radius: 50%; margin-left: 0.5rem; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } </style><style id="pic-style">html { max-height: 100%; }</style><style id="vsc-protection">body { pointer-events: auto !important; opacity: 1 !important; }.vsc-controller { display: none !important; }</style></head><body id="svg-section" style="width: 800px; height: 600px; transform-origin: left top; transform: scale(1); overflow: auto; margin: 0px; padding: 0px;" data-vsc-ignore="true" class="vsc-ignore"><div class="terminal-window"> <div class="terminal-header"> <div class="terminal-dots"> <div class="dot close"></div> <div class="dot minimize"></div> <div class="dot maximize"></div> </div> <div class="terminal-title">Qiita Feed</div> <div class="terminal-controls"> <div class="control-wrapper"> <button class="control-icon" id="filter-toggle" title="フィルター"> <svg viewBox="0 0 24 24"> <path d="M6,13H18V11H6M3,6V8H21V6M10,18H14V16H10V18Z"></path> </svg> </button> <div class="dropdown" id="filter-dropdown"> <div class="dropdown-title">Keyword</div> <div class="dropdown-text"> <input type="text" id="filter-keyword" placeholder="Enterで検索" aria-label="キーワード検索"> </div> <div class="dropdown-title">Stocks Filter</div> <div class="dropdown-slider"> <input type="range" id="filter-slider" min="0" max="1000" step="5" value="0" aria-label="ストック数フィルター"> <div class="dropdown-value" id="filter-value-display">All</div> </div> <div class="dropdown-presets"> <button class="preset-btn active" data-filter="0">All</button> <button class="preset-btn" data-filter="10">10+</button> <button class="preset-btn" data-filter="50">50+</button> <button class="preset-btn" data-filter="100">100+</button> <button class="preset-btn" data-filter="500">500+</button> <button class="preset-btn" data-filter="1000">1000+</button> </div> </div> </div> <div class="control-wrapper"> <button class="control-icon" id="speed-toggle" title="速度"> <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"> <path d="M418-340q24 24 62 23.5t56-27.5l224-336-336 224q-27 18-28.5 55t22.5 61Zm62-460q59 0 113.5 16.5T696-734l-76 48q-33-17-68.5-25.5T480-720q-133 0-226.5 93.5T160-400q0 42 11.5 83t32.5 77h552q23-38 33.5-79t10.5-85q0-36-8.5-70T766-540l48-76q30 47 47.5 100T880-406q1 57-13 109t-41 99q-11 18-30 28t-40 10H204q-21 0-40-10t-30-28q-26-45-40-95.5T80-400q0-83 31.5-155.5t86-127Q252-737 325-768.5T480-800Zm7 313Z"></path> </svg> </button> <div class="dropdown" id="speed-dropdown"> <div class="dropdown-title">Speed</div> <div class="dropdown-slider"> <input type="range" id="speed-slider" min="0.5" max="10" step="0.5" value="1" aria-label="速度調整"> <div class="dropdown-value" id="speed-value-display">1.0x</div> </div> <div class="dropdown-presets"> <button class="preset-btn" data-speed="0.5">0.5x</button> <button class="preset-btn active" data-speed="1">1x</button> <button class="preset-btn" data-speed="2">2x</button> <button class="preset-btn" data-speed="4">4x</button> <button class="preset-btn" data-speed="6">6x</button> <button class="preset-btn" data-speed="10">10x</button> </div> </div> </div> <div class="control-wrapper"> <button class="control-icon" id="lines-toggle" title="表示行数"> <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"> <path d="M480-120 320-280l56-56 64 63v-414l-64 63-56-56 160-160 160 160-56 57-64-64v414l64-63 56 56-160 160Z"></path> </svg> </button> <div class="dropdown" id="lines-dropdown"> <div class="dropdown-title">Display Lines</div> <div class="dropdown-presets"> <button class="preset-btn active" data-lines="1">1</button> <button class="preset-btn" data-lines="3">3</button> <button class="preset-btn" data-lines="5">5</button> </div> </div> </div> </div> </div> <div class="terminal-body" id="terminal-body"> <div class="terminal-output"> <div class="output-content" id="output-content"></div> </div> </div> </div> <script id="js-section">// 設定 const CONFIG = { API_URL: 'https://qiita.com/api/v2/items', DATA_SOURCE: 'qiita', JSON_DATA: [], QUERY_PARAMS: { query: 'tag:AI,生成AI,AIエージェント,LLM', per_page: 50 }, SCROLL_SPEED_PX_PER_SEC: 50, UPDATE_INTERVAL: 30 * 60 * 1000, DEFAULT_LINES: 1 }; const STORAGE_KEY = 'pa-tu:qiita-terminal:v1'; // DOM要素 const outputContent = document.getElementById('output-content'); const speedToggle = document.getElementById('speed-toggle'); const filterToggle = document.getElementById('filter-toggle'); const linesToggle = document.getElementById('lines-toggle'); const speedDropdown = document.getElementById('speed-dropdown'); const filterDropdown = document.getElementById('filter-dropdown'); const linesDropdown = document.getElementById('lines-dropdown'); const speedSlider = document.getElementById('speed-slider'); const speedValueDisplay = document.getElementById('speed-value-display'); const filterSlider = document.getElementById('filter-slider'); const filterValueDisplay = document.getElementById('filter-value-display'); const filterKeywordInput = document.getElementById('filter-keyword'); // 状態 const STATE = { rafId: null, lastTs: 0, running: false, speed: 1, minStocks: 0, keyword: '', restarting: false, displayLines: CONFIG.DEFAULT_LINES, y: 0, contentHeight: 0, viewHeight: 0, articles: [], currentPage: 1, displayTimerId: null, // タイマーIDを保存 displayPaused: false, // 表示一時停止フラグ currentArticleIndex: 0, // 現在表示中の記事インデックス canPause: false // コマンド入力中は停止不可にするフラグ }; if (filterKeywordInput && typeof filterKeywordInput.value === 'string') { STATE.keyword = filterKeywordInput.value; } let keywordIsComposing = false; // 現在の表示ループ参照 let currentDisplayLoop = null; // ユーティリティ function escapeHtml(text) { const map = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'}; return String(text).replace(/[&<>"']/g, m => map[m]); } function scrollElementToTopInContainer(container, element) { if (!container || !element) return; const containerRect = container.getBoundingClientRect(); const elementRect = element.getBoundingClientRect(); container.scrollTop += (elementRect.top - containerRect.top); } function formatNumber(num) { if (!Number.isFinite(Number(num))) return '0'; return num >= 1000 ? `${(num / 1000).toFixed(1)}k` : String(num); } function getRelativeTime(dateString) { const date = new Date(dateString); if (!dateString || isNaN(date.getTime())) return ''; const diff = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000)); if (diff < 60) return '数秒前'; if (diff < 3600) return `${Math.floor(diff / 60)}分前`; if (diff < 86400) return `${Math.floor(diff / 3600)}時間前`; if (diff < 2592000) return `${Math.floor(diff / 86400)}日前`; return `${Math.floor(diff / 2592000)}ヶ月前`; } function getTypingDurationMs(commandText) { const length = Math.max(1, String(commandText || '').length); // 固定1.5sだった頃の「長いコマンド時の体感速度」に近づける // (短いコマンドほど遅く感じる問題を避ける) const cps = 15; const minMs = 180; const maxMs = 1500; // 従来の上限(長くしない) const estimated = Math.round((length / cps) * 1000); return Math.max(minMs, Math.min(maxMs, estimated)); } function buildArticleMetaHTML(article) { const time = getRelativeTime(article.createdAt); const metaParts = []; if (article.author) metaParts.push(`@${escapeHtml(article.author)}`); metaParts.push(`<span class="metric metric-like">♥</span>${formatNumber(article.likes ?? 0)}`); metaParts.push(`<span class="metric metric-stock">■</span>${formatNumber(article.stocks ?? 0)}`); if (time) metaParts.push(time); return metaParts.join('<span class="meta-separator">|</span>'); } function buildArticleLinesHTML(article) { const titleEscaped = escapeHtml(article.title); const urlEscaped = escapeHtml(article.url); const metaString = buildArticleMetaHTML(article); return `<div class="terminal-line article-title-line"> <span class="check-mark">✓</span> <a href="${urlEscaped}" target="_blank" rel="noopener" class="article-title">${titleEscaped}</a> </div> <div class="terminal-line article-meta-line"><span class="meta"> ${metaString}</span></div>`; } // データ取得 async function fetchArticles(page = 1) { try { const hasKeyword = typeof STATE.keyword === 'string' && STATE.keyword.trim() !== ''; let query = hasKeyword ? STATE.keyword.trim() : CONFIG.QUERY_PARAMS.query; if (STATE.minStocks > 0) query += ` stocks:>${STATE.minStocks}`; const url = `${CONFIG.API_URL}?query=${encodeURIComponent(query)}&per_page=${CONFIG.QUERY_PARAMS.per_page}&page=${page}`; const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); const items = await response.json(); return items.map(item => ({ title: item.title || '', url: item.url || '', author: item.user?.id || '', likes: item.likes_count || 0, stocks: item.stocks_count || 0, createdAt: item.created_at || '' })); } catch (error) { console.error('記事取得失敗:', error); return []; } } // UI制御 function updateSpeedValue() { const displayValue = `${STATE.speed.toFixed(1)}x`; if (speedValueDisplay) speedValueDisplay.textContent = displayValue; if (speedSlider && speedSlider.value !== String(STATE.speed)) { speedSlider.value = String(STATE.speed); } // プリセットボタンのactive状態を更新 document.querySelectorAll('#speed-dropdown .preset-btn').forEach(btn => { const btnSpeed = parseFloat(btn.dataset.speed); btn.classList.toggle('active', Math.abs(btnSpeed - STATE.speed) < 0.01); }); } function updateFilterValue() { const displayValue = STATE.minStocks === 0 ? 'All' : `${STATE.minStocks}+`; if (filterValueDisplay) filterValueDisplay.textContent = displayValue; if (filterSlider && filterSlider.value !== String(STATE.minStocks)) { filterSlider.value = String(STATE.minStocks); } // プリセットボタンのactive状態を更新 document.querySelectorAll('#filter-dropdown .preset-btn').forEach(btn => { const btnStocks = parseInt(btn.dataset.filter, 10); btn.classList.toggle('active', btnStocks === STATE.minStocks); }); } function updateDisplayLines() { const terminalBody = document.querySelector('.terminal-body'); const terminalOutput = document.querySelector('.terminal-output'); // 各記事は固定高さ150px const articleHeight = 150; const padding = 48; // 上下のpadding (1.5rem * 2 * 16px) const newHeight = articleHeight * STATE.displayLines + padding; const minHeight = 100; terminalBody.style.height = `${Math.max(minHeight, newHeight)}px`; // 高さ変更後に再計算 setTimeout(() => { STATE.viewHeight = terminalOutput.clientHeight; STATE.contentHeight = outputContent.scrollHeight; }, 100); // プリセットボタンのactive状態を更新 document.querySelectorAll('#lines-dropdown .preset-btn').forEach(btn => { const btnLines = parseInt(btn.dataset.lines, 10); btn.classList.toggle('active', btnLines === STATE.displayLines); }); } // localStorage function saveSettings() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ speed: STATE.speed, minStocks: STATE.minStocks, displayLines: STATE.displayLines, keyword: STATE.keyword })); } catch {} } function loadSettings() { try { const data = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); if (typeof data.speed === 'number') STATE.speed = Math.max(0.5, Math.min(10, data.speed)); if (typeof data.minStocks === 'number') STATE.minStocks = Math.max(0, data.minStocks); if (typeof data.displayLines === 'number') STATE.displayLines = Math.max(1, Math.min(5, data.displayLines)); if (typeof data.keyword === 'string') { STATE.keyword = data.keyword; if (filterKeywordInput) filterKeywordInput.value = data.keyword; } } catch {} } // イベント speedToggle.addEventListener('click', (e) => { e.stopPropagation(); speedDropdown.classList.toggle('open'); filterDropdown.classList.remove('open'); linesDropdown.classList.remove('open'); speedToggle.classList.toggle('active'); filterToggle.classList.remove('active'); linesToggle.classList.remove('active'); }); filterToggle.addEventListener('click', (e) => { e.stopPropagation(); filterDropdown.classList.toggle('open'); speedDropdown.classList.remove('open'); linesDropdown.classList.remove('open'); filterToggle.classList.toggle('active'); speedToggle.classList.remove('active'); linesToggle.classList.remove('active'); }); linesToggle.addEventListener('click', (e) => { e.stopPropagation(); linesDropdown.classList.toggle('open'); speedDropdown.classList.remove('open'); filterDropdown.classList.remove('open'); linesToggle.classList.toggle('active'); speedToggle.classList.remove('active'); filterToggle.classList.remove('active'); }); document.addEventListener('click', () => { speedDropdown.classList.remove('open'); filterDropdown.classList.remove('open'); linesDropdown.classList.remove('open'); speedToggle.classList.remove('active'); filterToggle.classList.remove('active'); linesToggle.classList.remove('active'); }); [speedDropdown, filterDropdown, linesDropdown].forEach(dd => { dd.addEventListener('click', e => e.stopPropagation()); }); speedSlider.addEventListener('input', () => { STATE.speed = parseFloat(speedSlider.value); updateSpeedValue(); saveSettings(); }); speedSlider.addEventListener('change', () => { // プルダウンを閉じる speedDropdown.classList.remove('open'); speedToggle.classList.remove('active'); }); document.querySelectorAll('#speed-dropdown .preset-btn').forEach(btn => { btn.addEventListener('click', () => { STATE.speed = parseFloat(btn.dataset.speed); updateSpeedValue(); saveSettings(); // プルダウンを閉じる speedDropdown.classList.remove('open'); speedToggle.classList.remove('active'); }); }); filterSlider.addEventListener('input', () => { STATE.minStocks = parseInt(filterSlider.value, 10); updateFilterValue(); }); filterSlider.addEventListener('change', async () => { saveSettings(); // プルダウンを閉じる filterDropdown.classList.remove('open'); filterToggle.classList.remove('active'); // 既存のタイマーをクリア if (STATE.displayTimerId) { clearTimeout(STATE.displayTimerId); STATE.displayTimerId = null; } STATE.displayPaused = true; STATE.canPause = false; await restartWithCommand(); }); if (filterKeywordInput) { filterKeywordInput.addEventListener('compositionstart', () => { keywordIsComposing = true; }); filterKeywordInput.addEventListener('compositionend', () => { keywordIsComposing = false; STATE.keyword = filterKeywordInput.value; }); filterKeywordInput.addEventListener('input', () => { STATE.keyword = filterKeywordInput.value; }); // Enterで検索(キーワードが空なら従来クエリにフォールバック) filterKeywordInput.addEventListener('keydown', async (e) => { if (e.key !== 'Enter') return; if (e.isComposing || keywordIsComposing) return; if (STATE.restarting) return; e.preventDefault(); e.stopPropagation(); // プルダウンを閉じる filterDropdown.classList.remove('open'); filterToggle.classList.remove('active'); // 既存のタイマーをクリア if (STATE.displayTimerId) { clearTimeout(STATE.displayTimerId); STATE.displayTimerId = null; } STATE.displayPaused = true; // 既存の表示を停止 STATE.canPause = false; // 新しいコマンド完了まで停止禁止 await restartWithCommand(); }); } document.querySelectorAll('#filter-dropdown .preset-btn').forEach(btn => { btn.addEventListener('click', async () => { STATE.minStocks = parseInt(btn.dataset.filter, 10); updateFilterValue(); saveSettings(); // プルダウンを閉じる filterDropdown.classList.remove('open'); filterToggle.classList.remove('active'); // 既存のタイマーをクリア if (STATE.displayTimerId) { clearTimeout(STATE.displayTimerId); STATE.displayTimerId = null; } STATE.displayPaused = true; // 既存の表示を停止 STATE.canPause = false; // 新しいコマンド完了まで停止禁止 // 現在の表示をクリアしてコマンドから再開 await restartWithCommand(); }); }); document.querySelectorAll('#lines-dropdown .preset-btn').forEach(btn => { btn.addEventListener('click', () => { STATE.displayLines = parseInt(btn.dataset.lines, 10); updateDisplayLines(); saveSettings(); // プルダウンを閉じる linesDropdown.classList.remove('open'); linesToggle.classList.remove('active'); }); }); // コントローラー表示制御 const terminalWindow = document.querySelector('.terminal-window'); const terminalControls = document.querySelector('.terminal-controls'); let controlsHideTimer = null; const terminalOutput = document.querySelector('.terminal-output'); function showControls() { if (terminalControls) { terminalControls.classList.add('show'); } if (terminalOutput) { terminalOutput.classList.add('scrolling'); } clearTimeout(controlsHideTimer); } function scheduleHideControls() { clearTimeout(controlsHideTimer); controlsHideTimer = setTimeout(() => { if (terminalControls) { terminalControls.classList.remove('show'); } if (terminalOutput) { terminalOutput.classList.remove('scrolling'); } }, 2000); } if (terminalWindow) { terminalWindow.addEventListener('mouseenter', showControls); terminalWindow.addEventListener('mouseleave', scheduleHideControls); } // ユーザーの手動スクロール操作(マウスホイール、タッチスワイプ)のみで表示 if (terminalOutput) { // マウスホイール terminalOutput.addEventListener('wheel', () => { showControls(); scheduleHideControls(); }, { passive: true }); // タッチスワイプ terminalOutput.addEventListener('touchmove', () => { showControls(); scheduleHideControls(); }, { passive: true }); } // 共通ローディング表示 function showLoadingLine() { const loadingLine = document.createElement('div'); loadingLine.className = 'terminal-line'; loadingLine.style.color = 'var(--color-text-dim)'; loadingLine.textContent = '.'; outputContent.appendChild(loadingLine); let dotCount = 1; const loadingInterval = setInterval(() => { dotCount = (dotCount % 3) + 1; loadingLine.textContent = '.'.repeat(dotCount); }, 400); return () => { clearInterval(loadingInterval); loadingLine.remove(); }; } // 共通の選択肢UI function showChoicesUI({ onNext, onQuit, marginTop = '1.5rem' }) { outputContent.innerHTML += `<div class="terminal-line article-meta-line" style="margin-top: ${marginTop};"></div>`; // 質問プロンプト追加 const promptLine = document.createElement('div'); promptLine.className = 'terminal-line choice-prompt'; promptLine.textContent = 'What would you like to do?'; outputContent.appendChild(promptLine); const choiceLine = document.createElement('div'); choiceLine.className = 'choice-line'; choiceLine.innerHTML = ` <div class="choice-item active" data-choice="next" tabindex="0"> <span class="choice-cursor">></span> <span class="choice-number">1)</span> <span class="choice-label">next 50件を見る</span> </div> <div class="choice-item" data-choice="quit" tabindex="0"> <span class="choice-cursor">></span> <span class="choice-number">2)</span> <span class="choice-label">quit 終了</span> </div>`; outputContent.appendChild(choiceLine); const terminalOutput = document.querySelector('.terminal-output'); terminalOutput.scrollTop = terminalOutput.scrollHeight; const choiceItems = choiceLine.querySelectorAll('.choice-item'); let activeIndex = 0; const updateSelection = () => { choiceItems.forEach((item, idx) => { item.classList.toggle('active', idx === activeIndex); }); }; const cleanup = () => { document.removeEventListener('keydown', handleKey); }; const handleChoice = async (choice) => { cleanup(); choiceLine.remove(); if (choice === 'next') { await onNext(); } else { await onQuit(); } }; const handleKey = (e) => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); choiceItems[activeIndex].classList.remove('active'); if (e.key === 'ArrowDown') { activeIndex = (activeIndex + 1) % choiceItems.length; } else { activeIndex = (activeIndex - 1 + choiceItems.length) % choiceItems.length; } updateSelection(); } else if (e.key === 'Enter') { e.preventDefault(); handleChoice(choiceItems[activeIndex].dataset.choice); } else if (e.key === '1') { e.preventDefault(); handleChoice('next'); } else if (e.key === '2') { e.preventDefault(); handleChoice('quit'); } }; document.addEventListener('keydown', handleKey); updateSelection(); choiceItems.forEach((item, index) => { item.addEventListener('click', () => { handleChoice(item.dataset.choice); }); item.addEventListener('mouseenter', () => { activeIndex = index; updateSelection(); }); }); } // 共通表示コントローラ function startDisplay({ articles, showChoices = true, choiceMarginTop = '1.5rem', scrollFirstToTop = false }) { if (!articles || !articles.length) { outputContent.innerHTML += '<div class="error-message">記事が見つかりません</div>'; return; } // リセット STATE.articles = articles; STATE.displayPaused = false; STATE.canPause = false; STATE.currentArticleIndex = 0; if (STATE.displayTimerId) { clearTimeout(STATE.displayTimerId); STATE.displayTimerId = null; } const terminalOutput = document.querySelector('.terminal-output'); const displayNextArticle = () => { if (STATE.displayPaused || STATE.currentArticleIndex >= STATE.articles.length) return; const article = STATE.articles[STATE.currentArticleIndex]; const articleEl = document.createElement('div'); articleEl.className = 'article-item'; articleEl.innerHTML = buildArticleLinesHTML(article); outputContent.appendChild(articleEl); const latestArticle = articleEl; // 追加をスムーズに見せる(ログ感は維持) requestAnimationFrame(() => { articleEl.classList.add('is-entering'); }); articleEl.addEventListener('animationend', () => { articleEl.classList.remove('is-entering'); }, { once: true }); // 記事が1件でも表示されたら停止を許可 STATE.canPause = true; // 初回記事はオプションで先頭揃え。それ以降は従来通り最下部へ。 if (latestArticle && scrollFirstToTop && STATE.currentArticleIndex === 0) { scrollElementToTopInContainer(terminalOutput, latestArticle); } else if (STATE.currentArticleIndex > 0 && terminalOutput) { // ターミナル内だけ、なめらかに追従 try { terminalOutput.scrollTo({ top: terminalOutput.scrollHeight, behavior: 'smooth' }); } catch { terminalOutput.scrollTop = terminalOutput.scrollHeight; } } STATE.currentArticleIndex++; // 終端: 選択肢を表示 if (STATE.currentArticleIndex >= STATE.articles.length) { if (showChoices) { STATE.displayTimerId = setTimeout(() => { showChoicesUI({ onNext: async () => { STATE.currentPage++; const cleanup = showLoadingLine(); const nextArticles = await fetchArticles(STATE.currentPage); cleanup(); outputContent.innerHTML += '<div class="terminal-line article-meta-line"></div>'; if (nextArticles && nextArticles.length > 0) { startDisplay({ articles: nextArticles, showChoices: true, choiceMarginTop }); } else { outputContent.innerHTML += '<div class="terminal-line" style="color: var(--color-warning);">No more articles found.</div>'; } }, onQuit: async () => { STATE.canPause = false; // quit後はpauseハンドラを無効化 const quitMsg = `<div class="terminal-line" style="margin-top: 2rem; color: var(--color-success);"> Thanks for reading!</div> <div class="terminal-line" style="color: var(--color-text-dim); margin-top: 0.5rem;"> Press R to reload | Press any key to stay</div>`; outputContent.innerHTML += quitMsg; terminalOutput.scrollTop = terminalOutput.scrollHeight; const handleQuitKey = (e) => { if (e.key === 'r' || e.key === 'R') { location.reload(); } document.removeEventListener('keydown', handleQuitKey); }; document.addEventListener('keydown', handleQuitKey); }, marginTop: choiceMarginTop }); }, 500); } return; } // 次の記事をスピードに応じた遅延で表示 const baseDelay = 6000; // 6秒(標準速度) const delay = baseDelay / STATE.speed; if (!STATE.displayPaused) { STATE.displayTimerId = setTimeout(displayNextArticle, delay); } }; currentDisplayLoop = { displayNextArticle }; displayNextArticle(); } // コマンドから再開する関数 async function restartWithCommand() { if (STATE.restarting) return; STATE.restarting = true; try { // 前の記事はそのまま残す(クリアしない) // 停止メッセージが残っている場合は削除 const lines = outputContent.querySelectorAll('.terminal-line'); const lastLine = lines[lines.length - 1]; if (lastLine && lastLine.textContent.includes('続行するには')) { lastLine.remove(); } // 表示制御をリセット STATE.displayPaused = false; STATE.currentArticleIndex = 0; STATE.canPause = false; if (STATE.displayTimerId) { clearTimeout(STATE.displayTimerId); STATE.displayTimerId = null; } // 空行を追加してから新しいコマンド表示(余白はクラス側に任せる) outputContent.innerHTML += '<div class="terminal-line article-meta-line"></div>'; // コマンド表示 const hasKeyword = typeof STATE.keyword === 'string' && STATE.keyword.trim() !== ''; const keywordArg = hasKeyword ? ` --keyword=${STATE.keyword}` : ''; const stocksArg = STATE.minStocks > 0 ? ` --stocks=${STATE.minStocks}` : ''; const commandText = `qiita${keywordArg}${stocksArg}`; const commandTextEscaped = escapeHtml(commandText); // タイピングアニメーション const typingLine = document.createElement('div'); typingLine.className = 'terminal-line'; const typingDurationMs = getTypingDurationMs(commandText); const typingDurationSec = (typingDurationMs / 1000).toFixed(3); typingLine.innerHTML = `<span class="prompt">$</span> <span class="typing-command command" style="--typing-chars:${commandText.length}ch; animation: typing ${typingDurationSec}s steps(${commandText.length}) forwards, blink-caret 0.75s step-end infinite;">${commandTextEscaped}</span>`; outputContent.appendChild(typingLine); // コマンド行追加後、記事と同様の位置に合わせて表示 const terminalOutput = document.querySelector('.terminal-output'); if (terminalOutput) { // レイアウト確定後にターミナル内だけスクロール(ページ全体は動かさない) requestAnimationFrame(() => { scrollElementToTopInContainer(terminalOutput, typingLine); }); } // タイピングアニメーション完了を待つ await new Promise(resolve => setTimeout(resolve, typingDurationMs + 100)); // コマンド実行完了(カーソル削除) typingLine.innerHTML = `<span class="prompt">$</span> <span class="command">${commandTextEscaped}</span>`; // ローディング表示 const cleanupLoading = showLoadingLine(); // 記事取得 const articles = await fetchArticles(); // ローディング表示を削除して空行を追加 cleanupLoading(); outputContent.innerHTML += '<div class="terminal-line article-meta-line"></div>'; if (articles && articles.length > 0) { STATE.currentPage = 1; startDisplay({ articles, showChoices: true, choiceMarginTop: '1.5rem', scrollFirstToTop: true }); } else { outputContent.innerHTML += '<div class="error-message">記事が見つかりません</div>'; } } finally { STATE.restarting = false; } } // 一時停止メッセージを削除 function removePauseMessage() { const lines = outputContent.querySelectorAll('.terminal-line'); const lastLine = lines[lines.length - 1]; if (lastLine && lastLine.textContent.includes('続行するには')) { lastLine.remove(); } } // 表示を一時停止 function pauseDisplay() { if (STATE.displayPaused || !STATE.canPause || STATE.currentArticleIndex >= STATE.articles.length) return; STATE.displayPaused = true; if (STATE.displayTimerId) { clearTimeout(STATE.displayTimerId); STATE.displayTimerId = null; } outputContent.innerHTML += '<div class="terminal-line" style="margin-top: 1rem; color: var(--color-warning);">続行するには再度クリック、または何かキーを押してください...</div>'; const terminalOutput = document.querySelector('.terminal-output'); terminalOutput.scrollTop = terminalOutput.scrollHeight; } // 表示を再開 function resumeDisplay() { if (!STATE.displayPaused) return; STATE.displayPaused = false; removePauseMessage(); if (currentDisplayLoop && typeof currentDisplayLoop.displayNextArticle === 'function') { currentDisplayLoop.displayNextArticle(); } } // 停止/再開ハンドラをセットアップ let pauseHandlersInitialized = false; function setupPauseHandlers() { if (pauseHandlersInitialized) return; const terminalOutput = document.querySelector('.terminal-output'); if (!terminalOutput) return; terminalOutput.addEventListener('click', () => { if (!STATE.canPause) return; if (!STATE.displayPaused && STATE.currentArticleIndex < STATE.articles.length) { pauseDisplay(); } else if (STATE.displayPaused) { resumeDisplay(); } }); // スクロールで中断 terminalOutput.addEventListener('wheel', () => { if (!STATE.canPause) return; if (!STATE.displayPaused && STATE.currentArticleIndex < STATE.articles.length) { pauseDisplay(); } }, { passive: true }); // キー押下で中断または再開 document.addEventListener('keydown', (e) => { // 入力欄でのキー入力は無視 if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (!STATE.canPause) return; if (!STATE.displayPaused && STATE.currentArticleIndex < STATE.articles.length) { pauseDisplay(); } else if (STATE.displayPaused) { resumeDisplay(); } }); pauseHandlersInitialized = true; } // 初期化 async function init() { try { loadSettings(); updateSpeedValue(); updateFilterValue(); updateDisplayLines(); setupPauseHandlers(); // コマンド文字列(フィルター反映) const hasKeywordInit = typeof STATE.keyword === 'string' && STATE.keyword.trim() !== ''; const keywordArgInit = hasKeywordInit ? ` --keyword=${STATE.keyword}` : ''; const stocksArgInit = STATE.minStocks > 0 ? ` --stocks=${STATE.minStocks}` : ''; const commandTextInit = `qiita${keywordArgInit}${stocksArgInit}`; const commandTextInitEscaped = escapeHtml(commandTextInit); const typingDurationMsInit = getTypingDurationMs(commandTextInit); const typingDurationSecInit = (typingDurationMsInit / 1000).toFixed(3); // タイピングアニメーション: $ qiita...(フィルター込み) outputContent.innerHTML = `<div class="terminal-line"><span class="prompt">$</span> <span class="typing-command command" style="--typing-chars:${commandTextInit.length}ch; animation: typing ${typingDurationSecInit}s steps(${commandTextInit.length}) forwards, blink-caret 0.75s step-end infinite;">${commandTextInitEscaped}</span></div>`; // タイピングアニメーション完了を待つ await new Promise(resolve => setTimeout(resolve, typingDurationMsInit + 100)); // コマンド実行完了(カーソル削除) outputContent.innerHTML = `<div class="terminal-line"><span class="prompt">$</span> <span class="command">${commandTextInitEscaped}</span></div>`; // ローディング表示 const cleanupLoading = showLoadingLine(); // 記事取得 const articles = await fetchArticles(); // ローディング表示を削除して空行を追加 cleanupLoading(); outputContent.innerHTML += '<div class="terminal-line article-meta-line"></div>'; if (articles && articles.length > 0) { STATE.currentPage = 1; startDisplay({ articles, showChoices: true, choiceMarginTop: '1.5rem' }); } else { outputContent.innerHTML += '<div class="error-message">記事が見つかりません</div>'; } } catch (error) { console.error('初期化エラー:', error); outputContent.innerHTML = '<div class="error-message">エラーが発生しました</div>'; } } init();</script></body></html>