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 = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''};
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>