2026-06-03 05:08:00 +00:00

200 lines
10 KiB
JavaScript

// TaskList — FilterBar + LocalTaskListItem recreation
const SORT_OPTIONS = [
{ value: 'updated', label: '新しい順' },
{ value: 'status', label: 'ステータス順' },
{ value: 'title', label: 'タイトル順' },
];
function SortMenu({ sort, onSort }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
}, []);
const current = SORT_OPTIONS.find(o => o.value === sort) || SORT_OPTIONS[0];
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={() => setOpen(v => !v)}
title={`並び順: ${current.label}`}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, border: 'none', background: open ? '#eff6ff' : 'transparent',
color: open ? '#1d4ed8' : '#64748b', borderRadius: 8, cursor: 'pointer',
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h13M3 12h9M3 18h5M17 8V4m0 0l-3 3m3-3l3 3"/></svg>
</button>
{open && (
<div style={{
position: 'absolute', right: 0, top: 'calc(100% + 6px)', zIndex: 10,
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
minWidth: 160, padding: 4,
}}>
{SORT_OPTIONS.map(o => (
<button key={o.value} onClick={() => { onSort(o.value); setOpen(false); }} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '6px 10px', borderRadius: 8, border: 'none',
background: sort === o.value ? '#eff6ff' : 'transparent',
color: sort === o.value ? '#1d4ed8' : '#334155',
fontSize: 12, fontWeight: sort === o.value ? 700 : 500, cursor: 'pointer',
fontFamily: 'inherit', textAlign: 'left',
}}>
{o.label}
{sort === o.value && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4L19 7"/></svg>}
</button>
))}
</div>
)}
</div>
);
}
function FilterBar({ status, onStatus, search, onSearch, sort, onSort, counts, total }) {
const columns = ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled'];
const chipStyle = (active) => ({
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
border: '1px solid ' + (active ? '#2563eb' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
color: active ? '#1d4ed8' : '#64748b',
fontFamily: 'inherit',
});
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 6, background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 12, padding: '4px 6px 4px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<IconSearch width={14} height={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
<input value={search} onChange={(e) => onSearch(e.target.value)} placeholder="検索..."
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0, padding: '4px 0' }} />
<div style={{ width: 1, height: 18, background: '#e2e8f0', flexShrink: 0 }} />
<SortMenu sort={sort} onSort={onSort} />
</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
<button style={chipStyle(status === 'all')} onClick={() => onStatus('all')}>
All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{total}</span>
</button>
{columns.map(s => (
<button key={s} style={chipStyle(status === s)} onClick={() => onStatus(s)}>
{STATUS_LABELS[s]} <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts[s] || 0}</span>
</button>
))}
</div>
</div>
);
}
function TaskItem({ task, active, onClick }) {
return (
<button onClick={onClick} style={{
width: '100%', textAlign: 'left', padding: '10px 12px', borderRadius: 12,
border: '1px solid ' + (active ? '#3b82f6' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
cursor: 'pointer', transition: 'background .15s', fontFamily: 'inherit',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
#{task.id} {task.title}
</div>
<StatusBadge status={task.status} small />
</div>
<div style={{ marginTop: 2, fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.body.length > 60 ? task.body.slice(0, 60) + '…' : task.body}
</div>
<div style={{ marginTop: 2, fontSize: 10, color: '#94a3b8' }}>{relativeTime(task.updatedAt)}</div>
</button>
);
}
function TaskList({ tasks, activeId, onSelect, filters, setFilters, onOpenCreate, loading, error, onRetry }) {
const counts = {};
for (const s of ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled']) {
counts[s] = tasks.filter(t => t.status === s).length;
}
const running = counts.running || 0;
const waiting = (counts.waiting_human || 0) + (counts.waiting_subtasks || 0);
const failed = counts.failed || 0;
const filtered = tasks
.filter(t => filters.status === 'all' || t.status === filters.status)
.filter(t => !filters.search || (t.title + t.body).toLowerCase().includes(filters.search.toLowerCase()))
.sort((a, b) => filters.sort === 'title' ? a.title.localeCompare(b.title) : b.updatedAt - a.updatedAt);
const hasSearch = !!filters.search || filters.status !== 'all';
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<button onClick={onOpenCreate} style={{
width: '100%', padding: '10px 14px', marginBottom: 10,
background: '#2563eb', color: '#fff', borderRadius: 12,
fontSize: 13, fontWeight: 700, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', gap: 6, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
transition: 'background .15s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = '#1d4ed8'}
onMouseLeave={(e) => e.currentTarget.style.background = '#2563eb'}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
新しい依頼
</button>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
fontSize: 11, color: '#64748b', padding: '0 2px 10px',
}}>
<span><b style={{ color: '#334155', fontWeight: 700 }}>{tasks.length}</b> </span>
<span style={{ color: '#cbd5e1' }}>·</span>
<span><b style={{ color: '#16a34a', fontWeight: 700 }}>{running}</b> </span>
<span><b style={{ color: '#d97706', fontWeight: 700 }}>{waiting}</b> </span>
{failed > 0 && <span><b style={{ color: '#dc2626', fontWeight: 700 }}>{failed}</b> </span>}
</div>
<FilterBar
status={filters.status} onStatus={(s) => setFilters(f => ({ ...f, status: s }))}
search={filters.search} onSearch={(q) => setFilters(f => ({ ...f, search: q }))}
sort={filters.sort} onSort={(s) => setFilters(f => ({ ...f, sort: s }))}
counts={counts} total={tasks.length}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
{loading && <SkeletonList count={6} />}
{!loading && error && (
<ErrorState
title="タスクの読み込みに失敗"
hint={error}
onRetry={onRetry}
compact
/>
)}
{!loading && !error && filtered.map(t => <TaskItem key={t.id} task={t} active={activeId === t.id} onClick={() => onSelect(t.id)} />)}
{!loading && !error && filtered.length === 0 && (
hasSearch ? (
<EmptyState
compact
icon={<IconSearch width={18} height={18} />}
title="該当するタスクはありません"
hint="検索ワードやステータスフィルタを変えてみてください。"
action={
<button onClick={() => setFilters(f => ({ ...f, search: '', status: 'all' }))} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 700,
background: '#fff', border: '1px solid #e2e8f0', color: '#334155',
cursor: 'pointer', fontFamily: 'inherit',
}}>フィルタをクリア</button>
}
/>
) : (
<EmptyState
compact
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 12h6M9 16h6M9 8h6M5 21h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>}
title="まだ依頼がありません"
hint="左上の「新しい依頼」から最初のタスクを作成できます。"
/>
)
)}
</div>
</div>
);
}
window.TaskList = TaskList;