<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>エクスプローラリンク集</title>
<link rel="stylesheet" href="../style.css">
<style>
/* ページ内ミニスタイル(必要なら削除可) */
.toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:12px 0}
.toolbar input[type="text"]{padding:8px;border:1px solid #e5e7eb;border-radius:8px;min-width:240px}
.toolbar button{padding:8px 10px;border:1px solid #e5e7eb;border-radius:8px;background:#fff;cursor:pointer}
.toolbar .right{margin-left:auto;display:flex;gap:8px}
.form-grid{display:grid;grid-template-columns:1fr 2fr 2fr;gap:8px;margin:12px 0}
.form-grid input, .form-grid textarea{padding:8px;border:1px solid #e5e7eb;border-radius:8px;width:100%}
.form-grid label{font-size:12px;color:#6b7280}
.list{
display:block; /* 1列にする */
margin:16px 0;
}
.card{
border:1px solid #e5e7eb;
border-radius:12px;
padding:12px;
background:#fff;
display:flex; /* 横並び */
justify-content:space-between;
align-items:center;
margin-bottom:12px; /* 各行に余白 */
width:100%; /* 横幅いっぱい */
box-sizing:border-box;
}
.card .actions{
display:flex;
gap:6px;
flex-shrink:0; /* ボタンを縮めない */
}
.card .nowrap{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .meta{font-size:12px;color:#6b7280;margin-top:6px;word-break:break-all}
.actions{display:flex;gap:6px}
.actions button{padding:6px 8px;border:1px solid #e5e7eb;border-radius:8px;background:#fff;cursor:pointer}
.dragger{cursor:grab;font-size:12px;color:#6b7280}
.badge{display:inline-block;border:1px solid #e5e7eb;border-radius:999px;padding:2px 8px;font-size:12px;margin-left:8px}
.hidden{display:none}
.muted{color:#6b7280}
.danger{border-color:#ef4444;color:#ef4444}
.ok{border-color:#10b981;color:#10b981}
@media (prefers-color-scheme: dark){
.card, .toolbar button{background:#0f172a;border-color:#243049;color:#e8ecf3}
.form-grid input,.form-grid textarea,.toolbar input[type="text"]{background:#0b1020;border-color:#243049;color:#e8ecf3}
.badge{border-color:#243049}
.meta{color:#98a2b3}
}
</style>
</head>
<body>
<div id="header-placeholder"></div>
<div class="page-wrapper">
<aside id="sidebar">
<!-- Sidebar content will be injected here by JS -->
</aside>
<div class="content-wrapper">
<div class="main">
<h1 class="hyoudai" style="margin-bottom:0;">エクスプローラリンク集</h1>
<!-- 入力フォーム -->
<div class="form">
<div class="form-grid">
<div>
<label for="name">表示名</label>
<input id="name" type="text" placeholder="例)ドキュメント(社内共有)">
</div>
<div>
<label for="path">パス(ドライブ/UNC)</label>
<input id="path" type="text" placeholder="例)D:\work\docs または \\server\share\team">
</div>
<div>
<label for="note">メモ(任意)</label>
<input id="note" type="text" placeholder="任意の補足を書けます">
</div>
</div>
<div class="toolbar">
<button id="addBtn">追加</button>
<button id="clearFormBtn" class="muted">クリア</button>
<span id="editModeBadge" class="badge hidden">編集モード</span>
<div class="right">
<input id="q" type="text" placeholder="検索(表示名・パス・メモ)">
<button id="exportBtn">エクスポート</button>
<button id="importBtn">インポート</button>
<input id="importFile" type="file" accept="application/json" class="hidden">
<button id="resetBtn" class="danger">全削除</button>
</div>
</div>
</div>
<!-- 一覧 -->
<div id="list" class="list"></div>
<p class="muted" id="emptyMsg" style="margin-top:12px;">まだ登録がありません。「表示名」「パス」を入力して「追加」を押してください。</p>
</div>
</div>
</div>
<script>
(() => {
const LS_KEY = 'explorerLinksV1';
/** @type {{id:string,name:string,path:string,note?:string,createdAt:number,updatedAt?:number}[]} */
let data = [];
let filter = '';
let dragSrcId = null;
let editingId = null;
// 初期ロード
function load() {
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) {
data = JSON.parse(raw);
} else {
// 初回のみ例データを1件(空なら)
data = [{
id: crypto.randomUUID(),
name: 'サンプル:共有ドキュメント',
path: 'D:\\◯◯◯\\テスト',
note: '初期サンプル。不要なら削除してください。',
createdAt: Date.now()
}];
save();
}
} catch(e) {
console.error(e);
data = [];
}
}
function save() {
localStorage.setItem(LS_KEY, JSON.stringify(data));
}
function createExplorerHref(p){
if (!p) return '#';
// explorer: はバックスラッシュOK。スペース等は encodeURI で軽くエスケープ(\ は維持される)
return 'explorer:' + encodeURI(p);
}
function render(){
const listEl = document.getElementById('list');
const empty = document.getElementById('emptyMsg');
listEl.innerHTML = '';
const rows = data
.filter(item => {
if (!filter) return true;
const hay = (item.name + ' ' + item.path + ' ' + (item.note||'')).toLowerCase();
return hay.includes(filter.toLowerCase());
});
empty.style.display = rows.length ? 'none' : '';
for (const item of rows) {
const card = document.createElement('div');
card.className = 'card';
card.draggable = true;
card.dataset.id = item.id;
// 左側(内容)
const left = document.createElement('div');
const title = document.createElement('div');
title.className = 'nowrap';
title.innerHTML = `<strong>${escapeHtml(item.name)}</strong>`;
const link = document.createElement('div');
link.innerHTML = `<a class="open" href="${createExplorerHref(item.path)}" title="エクスプローラで開く">${escapeHtml(item.path)}</a>`;
const meta = document.createElement('div');
meta.className = 'meta';
const created = new Date(item.createdAt);
const updated = item.updatedAt ? new Date(item.updatedAt) : null;
meta.textContent = (item.note ? (item.note + ' — ') : '') +
`登録: ${fmt(created)}` + (updated ? ` / 更新: ${fmt(updated)}` : '');
left.appendChild(title);
left.appendChild(link);
left.appendChild(meta);
// 右側(操作)
const right = document.createElement('div');
right.className = 'actions';
right.innerHTML = `
<span class="dragger" title="ドラッグで並べ替え">↕</span>
<button data-act="copy" data-id="${item.id}">コピー</button>
<button data-act="edit" data-id="${item.id}">編集</button>
<button data-act="del" data-id="${item.id}" class="danger">削除</button>
`;
card.appendChild(left);
card.appendChild(right);
// D&D
card.addEventListener('dragstart', e => {
dragSrcId = item.id;
e.dataTransfer.effectAllowed = 'move';
});
card.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
});
card.addEventListener('drop', e => {
e.preventDefault();
if (!dragSrcId) return;
const dstId = item.id;
if (dragSrcId === dstId) return;
reorder(dragSrcId, dstId);
dragSrcId = null;
});
card.addEventListener('dragend', () => dragSrcId = null);
listEl.appendChild(card);
}
}
function reorder(srcId, dstId){
const from = data.findIndex(x => x.id === srcId);
const to = data.findIndex(x => x.id === dstId);
if (from < 0 || to < 0) return;
const [moved] = data.splice(from, 1);
data.splice(to, 0, moved);
save(); render();
}
function fmt(d){
const pad = n => String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function escapeHtml(s){
return String(s ?? '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
}
// 追加 / 更新
function onSubmit(){
const name = document.getElementById('name').value.trim();
const path = document.getElementById('path').value.trim();
const note = document.getElementById('note').value.trim();
if (!name || !path){
alert('「表示名」と「パス」は必須です。');
return;
}
// パスの簡易整形:全角スペース除去、末尾バックスラッシュは残す
const normPath = path.replace(/\u3000/g,' ').replace(/[ ]+$/,'');
if (editingId){
const idx = data.findIndex(x => x.id === editingId);
if (idx >= 0){
data[idx] = { ...data[idx], name, path: normPath, note, updatedAt: Date.now() };
}
editingId = null;
document.getElementById('editModeBadge').classList.add('hidden');
document.getElementById('addBtn').textContent = '追加';
} else {
data.push({ id: crypto.randomUUID(), name, path: normPath, note, createdAt: Date.now() });
}
save(); clearForm(); render();
}
function clearForm(){
document.getElementById('name').value = '';
document.getElementById('path').value = '';
document.getElementById('note').value = '';
editingId = null;
document.getElementById('editModeBadge').classList.add('hidden');
document.getElementById('addBtn').textContent = '追加';
}
// 操作(コピー/編集/削除)
function onAction(e){
const btn = e.target.closest('button');
if (!btn) return;
const act = btn.dataset.act;
const id = btn.dataset.id;
if (!act || !id) return;
const item = data.find(x => x.id === id);
if (!item) return;
if (act === 'copy'){
const text = item.path;
if (navigator.clipboard?.writeText){
navigator.clipboard.writeText(text).then(() => {
toast('パスをコピーしました');
}).catch(() => {
fallbackCopy(text);
});
} else {
fallbackCopy(text);
}
} else if (act === 'edit'){
document.getElementById('name').value = item.name;
document.getElementById('path').value = item.path;
document.getElementById('note').value = item.note || '';
editingId = id;
document.getElementById('editModeBadge').classList.remove('hidden');
document.getElementById('addBtn').textContent = '更新';
window.scrollTo({top:0, behavior:'smooth'});
} else if (act === 'del'){
if (confirm(`「${item.name}」を削除しますか?`)){
data = data.filter(x => x.id !== id);
save(); render();
}
}
}
function fallbackCopy(text){
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); toast('パスをコピーしました'); }
catch { alert('コピーに失敗しました'); }
finally { document.body.removeChild(ta); }
}
// 検索
function onSearch(e){
filter = e.target.value || '';
render();
}
// エクスポート / インポート / 全削除
function onExport(){
const blob = new Blob([JSON.stringify(data,null,2)], {type:'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `explorer_links_${new Date().toISOString().slice(0,19).replace(/[:T]/g,'-')}.json`;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
function onImportClick(){ document.getElementById('importFile').click(); }
function onImportFile(e){
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try{
const arr = JSON.parse(reader.result);
if (!Array.isArray(arr)) throw new Error('JSONは配列ではありません');
// ざっくり検証
const mapped = arr.map(x => ({
id: x.id || crypto.randomUUID(),
name: String(x.name ?? ''),
path: String(x.path ?? ''),
note: x.note ? String(x.note) : '',
createdAt: Number(x.createdAt || Date.now()),
updatedAt: x.updatedAt ? Number(x.updatedAt) : undefined
})).filter(x => x.name && x.path);
if (!mapped.length) throw new Error('有効な項目がありません');
data = mapped;
save(); render();
toast('インポートしました');
} catch(err){
alert('インポートに失敗しました: ' + err.message);
} finally {
e.target.value = '';
}
};
reader.readAsText(file, 'utf-8');
}
function onReset(){
if (confirm('すべての登録を削除します。よろしいですか?')){
data = []; save(); render();
}
}
function toast(msg){
// 簡易トースト(必要なら既存のトーストに置換)
const div = document.createElement('div');
div.textContent = msg;
div.style.position='fixed'; div.style.bottom='20px'; div.style.left='50%';
div.style.transform='translateX(-50%)';
div.style.padding='10px 14px'; div.style.background='#111827'; div.style.color='#fff';
div.style.borderRadius='10px'; div.style.boxShadow='0 4px 14px rgba(0,0,0,.2)';
div.style.zIndex='9999'; div.style.opacity='0'; div.style.transition='opacity .2s';
document.body.appendChild(div);
requestAnimationFrame(()=>div.style.opacity='1');
setTimeout(()=>{ div.style.opacity='0'; setTimeout(()=>div.remove(), 200); }, 1500);
}
// イベント束ね
function bind(){
document.getElementById('addBtn').addEventListener('click', onSubmit);
document.getElementById('clearFormBtn').addEventListener('click', clearForm);
document.getElementById('list').addEventListener('click', onAction);
document.getElementById('q').addEventListener('input', onSearch);
document.getElementById('exportBtn').addEventListener('click', onExport);
document.getElementById('importBtn').addEventListener('click', onImportClick);
document.getElementById('importFile').addEventListener('change', onImportFile);
document.getElementById('resetBtn').addEventListener('click', onReset);
// Enter で追加
const inputs = ['name','path','note'].map(id=>document.getElementById(id));
inputs.forEach(el => el.addEventListener('keydown', e=>{
if (e.key === 'Enter'){
e.preventDefault();
onSubmit();
}
}));
}
// 起動
load(); bind(); render();
})();
</script>
<script src="../favorites-data.js"></script>
<script src="../load_header.js"></script>
</body>
</html>
目次
コメント