' + esc(k==='other' ? 'その他(SNSを混ぜてOK)' : SNS[k].name) + ''; }).join('');
function sync(){
var k = sel.value;
var prev = root.querySelector('[data-sns-preview="' + selId + '"]');
if(prev) prev.innerHTML = snsBadge(k, true);
var suggest = k==='other' ? '' : SNS[k].name;
if(!name.value.trim() || name.value===auto){ name.value = suggest; }
auto = suggest;
name.placeholder = k==='other' ? '例:YouTube' : '例:' + SNS[k].name + '本垢';
}
sel.addEventListener('change', sync);
return { reset:function(k){ sel.value = SNS[k] ? k : 'x'; name.value=''; auto=''; sync(); } };
}
var startForm = setupSnsSelect('mft-start-sns', 'mft-start-name');
var newForm = setupSnsSelect('mft-new-sns', 'mft-new-name');
function createList(sns, name){
name = String(name||'').trim().slice(0,20);
if(!name){ msg('リスト名を入力してください','err'); return false; }
state.L.push(newList(name, sns, 7)); state.c = state.L.length - 1;
save(); return true;
}
/* ---------- 追加 ---------- */
function addItem(){
var list = cur(), urlEl = $('mft-url'), memoEl = $('mft-memo'), dateEl = $('mft-date');
var n = normalize(urlEl.value);
if(!n){ msg('URLの形式を確認してください(例:' + SNS[list.p].ph + ')', 'err'); urlEl.focus(); return; }
// SNS指定のリストには、そのSNSのURLしか登録できない(「その他」は何でもOK)
if(list.p!=='other' && SNS[list.p].host!==n.host){
var k = HOST_TO_SNS[n.host], other = k ? SNS[k].name : (HOST_NAME[n.host] || n.host);
var actions = [];
if(k){
state.L.forEach(function(l, i){
if(l.p===k && actions.length<3) actions.push({ label:'「' + l.n + '」に追加する', fn:function(){ state.c = i; save(); clearMsg(); render(); addItem(); } });
});
if(!actions.length) actions.push({ label:'+ ' + other + '用のリストを作る', fn:function(){ clearMsg(); showPanel('new'); newForm.reset(k); $('mft-new-name').focus(); } });
}else{
state.L.forEach(function(l, i){
if(l.p==='other' && actions.length<3) actions.push({ label:'「' + l.n + '」に追加する', fn:function(){ state.c = i; save(); clearMsg(); render(); addItem(); } });
});
if(!actions.length) actions.push({ label:'+「その他」のリストを作る', fn:function(){ clearMsg(); showPanel('new'); newForm.reset('other'); $('mft-new-name').focus(); } });
}
msg('「' + list.n + '」は ' + SNS[list.p].name + ' 専用のリストです。' + other + ' のURLは登録できません', 'err', actions);
return;
}
var dup = list.t.filter(function(it){ return normalize(it.u).key===n.key; })[0];
if(dup){
var where = dup.s==='m' ? '相互' : (dup.s==='u' ? '解除済み' : 'フォロー待ち');
msg('「' + list.n + '」に登録済みです(' + where + '・' + fmt(dup.d) + 'にフォロー)', 'err');
return;
}
var d = (isDate(dateEl.value) && toUTC(dateEl.value) <= toUTC(today())) ? dateEl.value : today();
var it = { id:nid(), u:n.url, d:d, s:'w' };
var memo = memoEl.value.trim().slice(0, MEMO_MAX); if(memo) it.n = memo;
list.t.push(it);
save();
urlEl.value=''; memoEl.value=''; dateEl.value=today();
msg('「' + n.label + '」を「' + list.n + '」のフォロー待ちに追加しました', 'ok');
setTab('w');
urlEl.focus();
}
/* ---------- 描画 ---------- */
function rowHTML(it, list){
var n = normalize(it.u);
var cls = 'mft-item', meta, badge, act;
var days = daysSince(it.d);
if(it.s==='w'){
var over = isOver(it, list);
if(over) cls += ' is-over';
meta = '' + fmt(it.d) + '・' + days + '日' + (it.k ? '' : (over ? ' 超過' : '(あと' + (list.d - days) + '日)'));
badge = '';
act = '';
}else if(it.s==='m'){
meta = '' + fmt(it.d) + '→' + (it.m ? fmt(it.m) : '');
badge = '';
act = '';
}else{
cls += ' is-unf';
var remain = Math.max(UNF_KEEP_DAYS - daysSince(it.x), 1);
meta = '' + fmt(it.x) + '解除・あと' + remain + '日';
badge = '解除済';
act = '';
}
// 「その他」リスト、またはリストのSNSと違うURLのときだけSNSバッジを表示
var showBadge = list.p==='other' || SNS[list.p].host!==n.host;
var panel = '';
if(openId===it.id){
var armed = delItemId===it.id;
panel = '' +
'' + fmt(it.d) + 'にフォロー' +
(it.s==='m' && it.m ? '・' + fmt(it.m) + 'に相互' : '') + (it.s==='u' ? '・' + fmt(it.x) + 'に解除' : '') + '' +
'' +
'' +
'' +
'
';
}
return '' +
'' +
'
' +
'
' +
(showBadge ? hostBadge(n.host) : '') +
'' + esc(n.label) + '' +
(it.n ? '' : '') +
'' +
'
' + meta + '' +
badge + act +
'
' +
'
' + panel + '';
}
function renderListSelect(){
$('mft-list-sel').innerHTML = state.L.map(function(l, i){
var w = 0, o = 0;
l.t.forEach(function(it){ if(it.s==='w') w++; if(isOver(it,l)) o++; });
// リスト名にSNS名が入っていなければ[X]のように補足する
var tag = (l.p==='other' || l.n.toLowerCase().indexOf(SNS[l.p].name.toLowerCase())>=0) ? '' : '[' + SNS[l.p].name + ']';
return '';
}).join('');
$('mft-cur-badge').innerHTML = snsBadge(cur().p, true);
}
// リスト管理パネル(開くのは常に1つだけ)
function showPanel(name){
openPanel = name || null;
each('[data-pnl]', function(p){ p.hidden = p.getAttribute('data-pnl')!==openPanel; });
each('[data-panel]', function(b){ b.classList.toggle('is-active', b.getAttribute('data-panel')===openPanel); });
if(!openPanel || !cur()) return;
var list = cur();
if(openPanel==='new'){ newForm.reset(); $('mft-new-name').focus(); }
if(openPanel==='rename'){ $('mft-rename').value = list.n; $('mft-rename').focus(); $('mft-rename').select(); }
if(openPanel==='del'){
$('mft-del-text').textContent = '「' + list.n + '」(' + list.t.length + '件)を削除します。元に戻せないので、必要なら先にバックアップを保存してください。';
}
}
function render(){
purge();
var hasList = state.L.length > 0;
$('mft-start').hidden = hasList;
$('mft-main').hidden = !hasList;
if(!hasList) return;
if(state.c >= state.L.length) state.c = 0;
var list = cur();
renderListSelect();
$('mft-days').value = list.d;
$('mft-url').placeholder = SNS[list.p].ph;
var counts = { w:0, m:0, u:0, all:list.t.length }, over = 0;
list.t.forEach(function(it){ counts[it.s]++; if(isOver(it,list)) over++; });
$('mft-s-wait').textContent = counts.w;
$('mft-s-over').textContent = over;
$('mft-s-mutual').textContent = counts.m;
each('[data-count]', function(el){ el.textContent = '(' + counts[el.getAttribute('data-count')] + ')'; });
$('mft-over-only').classList.toggle('is-on', overOnly);
$('mft-over-only').style.display = (tab==='w' || tab==='all') ? '' : 'none';
var q = $('mft-search').value.trim().toLowerCase();
var rows = list.t.filter(function(it){
if(tab!=='all' && it.s!==tab) return false;
if(overOnly && (tab==='w'||tab==='all') && !isOver(it,list)) return false;
if(q && (it.u + ' ' + (it.n||'')).toLowerCase().indexOf(q) < 0) return false;
return true;
});
rows.sort(function(a,b){
if(tab==='w'){ // キープは下へ、それ以外は新しい順(最近フォローした相手が上)
if(!!a.k !== !!b.k) return a.k ? 1 : -1;
return a.d > b.d ? -1 : a.d < b.d ? 1 : 0;
}
var ka = tab==='m' ? (a.m||a.d) : tab==='u' ? (a.x||'') : a.d;
var kb = tab==='m' ? (b.m||b.d) : tab==='u' ? (b.x||'') : b.d;
return ka > kb ? -1 : ka < kb ? 1 : 0;
});
var ul = $('mft-list');
if(!rows.length){
var empty = (q || overOnly) ? '該当するアカウントはありません' :
{ w:'フォロー待ちのアカウントはありません', m:'相互フォローのアカウントはまだありません',
u:'解除済みのアカウントはありません', all:'まだ登録がありません' }[tab];
if(!list.t.length) empty = 'まだ登録がありません。フォローしたら、相手のURLを上の欄に貼って「追加」してください';
ul.innerHTML = '' + empty + '';
}else{
var html = rows.slice(0, shown).map(function(it){ return rowHTML(it, list); }).join('');
if(rows.length > shown){
html += '';
}
ul.innerHTML = html;
}
var note = $('mft-backup-note'), total = totalCount(state);
if(!storageOK){
note.className = 'mft-note is-warn';
note.textContent = 'ブラウザへの自動保存ができませんでした。閉じる前に必ずバックアップを保存してください';
}else if(total && (!state.b || daysSince(state.b) >= 7)){
note.className = 'mft-note is-warn';
note.textContent = state.b ? '最終バックアップ:' + fmt(state.b) + '(1週間以上前です。そろそろ保存しておきましょう)' : 'まだバックアップがありません。念のため保存しておきましょう';
}else{
note.className = 'mft-note';
note.textContent = (state.b ? '最終バックアップ:' + fmt(state.b) + ' ' : '') + 'すべてのリストをまとめて1ファイルに保存します';
}
}
function setTab(t){
tab = t; shown = PAGE; openId = null;
each('.mft-tab', function(el){ el.classList.toggle('is-active', el.getAttribute('data-tab')===t); });
render();
}
/* ---------- はじめての画面 ---------- */
$('mft-start-btn').addEventListener('click', function(){
var name = $('mft-start-name').value;
if(createList($('mft-start-sns').value, name)){
setTab('w');
msg('リスト「' + name.trim() + '」を作成しました。フォローした相手のURLを貼って追加しましょう','ok');
$('mft-url').focus();
}
});
onEnter($('mft-start-name'), function(){ $('mft-start-btn').click(); });
/* ---------- リスト管理 ---------- */
root.querySelector('.mft-bar-btns').addEventListener('click', function(e){
var b = e.target.closest('[data-panel]'); if(!b) return;
var name = b.getAttribute('data-panel');
showPanel(openPanel===name ? null : name);
});
each('[data-panel-close]', function(b){ b.addEventListener('click', function(){ showPanel(null); }); });
$('mft-new-btn').addEventListener('click', function(){
var name = $('mft-new-name').value;
if(createList($('mft-new-sns').value, name)){
showPanel(null); setTab('w');
msg('リスト「' + name.trim() + '」を作成しました','ok');
}
});
onEnter($('mft-new-name'), function(){ $('mft-new-btn').click(); });
$('mft-rename-btn').addEventListener('click', function(){
var name = $('mft-rename').value.trim().slice(0,20);
if(!name){ msg('新しい名前を入力してください','err'); return; }
cur().n = name; save(); showPanel(null); render();
msg('リスト名を「' + name + '」に変更しました','ok');
});
onEnter($('mft-rename'), function(){ $('mft-rename-btn').click(); });
$('mft-del-btn').addEventListener('click', function(){
var list = cur();
state.L.splice(state.c, 1); state.c = 0; save(); showPanel(null); setTab('w');
if(state.L.length){ msg('リスト「' + list.n + '」を削除しました','ok'); }
else { startForm.reset(); msg('リスト「' + list.n + '」を削除しました。新しいリストを作りましょう','ok'); }
});
$('mft-list-sel').addEventListener('change', function(){
state.c = parseInt(this.value,10) || 0; save(); showPanel(null); setTab('w');
});
$('mft-days').addEventListener('change', function(){
var d = parseInt(this.value,10);
cur().d = (d>=1 && d<=365) ? d : 7;
save(); render();
});
/* ---------- 行の操作 ---------- */
function saveMemo(it, li){
var mi = li.querySelector('[data-memo-input]');
var v = mi ? mi.value.trim().slice(0, MEMO_MAX) : '';
if(v) it.n = v; else delete it.n;
save(); msg('メモを保存しました', 'ok'); render();
}
$('mft-list').addEventListener('click', function(e){
if(e.target.closest('[data-more-list]')){ shown += PAGE; render(); return; }
var btn = e.target.closest('[data-act]'); if(!btn) return;
var li = btn.closest('[data-id]'); if(!li) return;
var it = findItem(li.getAttribute('data-id')); if(!it) return;
var act = btn.getAttribute('data-act');
var label = normalize(it.u).label;
if(act!=='delete') delItemId = null;
switch(act){
case 'mutual': it.s='m'; it.m=today(); delete it.x; msg('「'+label+'」と相互フォローになりました 🎉','ok'); break;
case 'unmutual': it.s='w'; delete it.m; msg('「'+label+'」をフォロー待ちに戻しました','ok'); break;
case 'unfollow': it.s='u'; it.x=today(); msg('「'+label+'」を解除済みにしました(3日間は取り消せます)','ok'); break;
case 'restore': it.s='w'; delete it.x; msg('「'+label+'」の解除を取り消しました','ok'); break;
case 'keep':
if(it.k){ delete it.k; msg('「'+label+'」のキープを外しました','ok'); }
else { it.k = 1; msg('「'+label+'」をキープしました(解除の目安の対象外)','ok'); }
break;
case 'more': openId = (openId===it.id ? null : it.id); render(); return;
case 'memo-save': saveMemo(it, li); return;
case 'delete':
if(delItemId===it.id){
cur().t = cur().t.filter(function(x){ return x.id!==it.id; });
delItemId = null; openId = null; clearTimeout(delTimer);
msg('「'+label+'」を削除しました','ok');
}else{
delItemId = it.id; clearTimeout(delTimer);
delTimer = setTimeout(function(){ delItemId=null; render(); }, 3000);
render(); return;
}
break;
}
save(); render();
});
$('mft-list').addEventListener('keydown', function(e){
if(e.target.hasAttribute && e.target.hasAttribute('data-memo-input') && e.key==='Enter' && !e.isComposing){
var li = e.target.closest('[data-id]'); var it = li && findItem(li.getAttribute('data-id'));
if(it) saveMemo(it, li);
}
});
/* ---------- 追加・タブ・絞り込み ---------- */
root.querySelector('.mft-tabs').addEventListener('click', function(e){
var t = e.target.closest('[data-tab]'); if(t) setTab(t.getAttribute('data-tab'));
});
$('mft-add').addEventListener('click', addItem);
onEnter($('mft-url'), addItem);
onEnter($('mft-memo'), addItem);
$('mft-search').addEventListener('input', function(){ shown = PAGE; render(); });
$('mft-over-only').addEventListener('click', function(){ overOnly = !overOnly; shown = PAGE; render(); });
/* ---------- バックアップ(.txt) ---------- */
$('mft-export').addEventListener('click', function(){
state.b = today();
var total = totalCount(state);
var body = '# FOLLOG(相互フォロー管理ツール)バックアップ(' + today() + ' 保存・' + state.L.length + 'リスト・' + total + '件)\n' +
'# このファイルを「バックアップから復元」で読み込むと元の状態に戻せます。中身は編集しないでください。\n' +
JSON.stringify(serialize(state));
var blob = new Blob([body], { type:'text/plain;charset=utf-8' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'follog_' + today().replace(/-/g,'') + '.txt';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(function(){ URL.revokeObjectURL(a.href); }, 1000);
save(); render();
msg('バックアップを保存しました(' + state.L.length + 'リスト・' + total + '件)','ok');
});
each('[data-import]', function(b){ b.addEventListener('click', function(){ $('mft-file').click(); }); });
$('mft-file').addEventListener('change', function(){
var file = this.files && this.files[0], input = this;
if(!file) return;
var reader = new FileReader();
reader.onload = function(){
var text = String(reader.result||''), data = null, i = text.indexOf('{');
try{ data = sanitize(JSON.parse(i>=0 ? text.slice(i) : text)); }catch(e){ data = null; }
input.value = '';
if(!data || !data.L.length){ msg('読み込めませんでした。このツールで保存したバックアップファイルを選んでください','err'); return; }
var n = totalCount(data), m = totalCount(state);
if(state.L.length && !window.confirm('バックアップ(' + data.L.length + 'リスト・' + n + '件)で、今のデータ(' + state.L.length + 'リスト・' + m + '件)を置き換えます。よろしいですか?')) return;
state = data; save(); showPanel(null); setTab('w');
msg('バックアップから復元しました(' + data.L.length + 'リスト・' + n + '件)','ok');
};
reader.onerror = function(){ input.value=''; msg('ファイルを読み込めませんでした','err'); };
reader.readAsText(file, 'utf-8');
});
/* ---------- 初期化 ---------- */
$('mft-date').value = today();
$('mft-date').max = today();
startForm.reset();
render();
setInterval(render, 60 * 60 * 1000); // 開きっぱなしでも日付の経過を反映
}
if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', init); }else{ init(); }
})();