index.html
<!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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
</head>
<body>
<div class="app-layout">
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-header">
<div class="header-title">
<svg class="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
<span>Pages</span>
</div>
<div class="header-actions">
<button id="bulkDeletePagesBtn" class="icon-btn hidden" title="選択したページを削除"
style="color: var(--danger-color);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20"
height="20">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
</svg>
</button>
<button id="toggleViewBtn" class="icon-btn" title="表示切り替え">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20"
height="20">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</button>
</div>
</div>
<div class="create-page-btn-container">
<button class="btn-primary" id="newPageBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新規ページ作成
</button>
</div>
<div class="page-list" id="pageList">
<!-- Pages will be loaded here -->
</div>
<div class="resizer" id="sidebarResizer"></div>
</div>
<!-- Main Content -->
<main class="main-content">
<div id="pageContent" class="page-content hidden">
<header class="page-header">
<input type="text" id="pageTitle" class="page-title-input" placeholder="ページタイトルを入力...">
<button id="exportBtn" class="export-btn" title="エクスポート">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18"
height="18">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
エクスポート
</button>
</header>
<div class="editor-area">
<textarea id="pageText" class="page-textarea" placeholder="ここにテキストを入力..."></textarea>
</div>
<div class="files-area">
<div class="area-header">
<h3>添付ファイル・URL・テキスト</h3>
<button id="bulkDeleteBtn" class="bulk-delete-btn hidden" title="選択した項目を削除">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16"
height="16" style="flex-shrink: 0;">
<polyline points="3 6 5 6 21 6"></polyline>
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
</svg>
<span id="selectedCount" style="margin-left: 4px;">0</span>
</button>
</div>
<div class="compact-drop-zone" id="dropZone">
<div class="drop-row">
<span class="drop-label-text">ファイル</span>
<label for="fileInput" class="select-file-btn">ファイルを選択する</label>
<span class="drop-hint-text">(ドラッグアンドドロップでもファイルを添付できます。)</span>
</div>
<div class="drop-row url-row">
<span class="drop-label-text">URL</span>
<input type="text" id="urlInput" class="url-input" placeholder="https://example.com">
<button id="addUrlBtn" class="add-url-btn">追加</button>
</div>
<div class="drop-row text-row">
<span class="drop-label-text">テキスト</span>
<input type="text" id="textInput" class="text-input" placeholder="メモやテキストを入力">
<button id="addTextBtn" class="add-text-btn">追加</button>
</div>
<input type="file" id="fileInput" multiple hidden>
</div>
<div class="files-list-container">
<table class="files-table">
<tbody id="fileList">
<!-- Files will be inserted here -->
</tbody>
</table>
<div id="emptyFilesState" class="empty-state">
<p>添付ファイルはありません</p>
</div>
</div>
</div>
</div>
<div id="noPageSelected" class="no-page-selected">
<div class="empty-message">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="12" y1="18" x2="12" y2="12"></line>
<line x1="9" y1="15" x2="15" y2="15"></line>
</svg>
<h3>ページを選択または作成してください</h3>
</div>
</div>
</main>
</div>
<script src="db.js"></script>
<script src="ui.js"></script>
<script src="app.js"></script>
</body>
</html>
app.js
// Main Application
console.log('app.js loaded');
document.addEventListener('DOMContentLoaded', () => {
console.log('DOMContentLoaded fired');
initApp();
});
async function initApp() {
console.log('initApp called');
try {
await initDB();
console.log('Database initialized successfully');
await loadPages();
console.log('Pages loaded');
setupEventListeners();
console.log('Event listeners set up');
initSidebarResizer();
console.log('Sidebar resizer initialized');
const pages = await getAllPages();
// Try to restore last active page from localStorage
const savedPageId = localStorage.getItem('activePageId');
if (savedPageId) {
const pageExists = pages.find(p => p.id === parseInt(savedPageId));
if (pageExists) {
selectPage(parseInt(savedPageId));
console.log('Restored last active page:', savedPageId);
} else {
// Saved page doesn't exist anymore, select first page or create new
if (pages.length > 0) {
selectPage(pages[0].id);
} else {
await createNewPage('無題のページ');
}
}
} else if (pages.length > 0) {
selectPage(pages[0].id);
} else {
const allPages = await getAllPages();
if (allPages.length === 0) {
await createNewPage('無題のページ');
}
}
console.log('App initialization complete');
} catch (error) {
console.error('Initialization failed:', error);
alert('データベースの初期化に失敗しました。');
}
}
db.js
// Database configuration
const DB_NAME = 'FileManagerDB';
const DB_VERSION = 6; // Version 6: Added text items support
let db = null;
// Initialize Database
function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = (event) => reject(event.target.error);
request.onsuccess = (event) => {
db = event.target.result;
console.log('Database initialized');
resolve(db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
const txn = event.target.transaction;
// 1. Ensure 'files' store exists
if (!db.objectStoreNames.contains('files')) {
const fileStore = db.createObjectStore('files', { keyPath: 'id', autoIncrement: true });
fileStore.createIndex('pageId', 'pageId', { unique: false });
} else {
const fileStore = txn.objectStore('files');
if (!fileStore.indexNames.contains('pageId')) {
fileStore.createIndex('pageId', 'pageId', { unique: false });
}
}
// 2. Ensure 'pages' store exists
if (!db.objectStoreNames.contains('pages')) {
const pageStore = db.createObjectStore('pages', { keyPath: 'id', autoIncrement: true });
pageStore.createIndex('updatedAt', 'updatedAt', { unique: false });
}
// 3. Ensure 'urls' store exists (Version 5)
if (!db.objectStoreNames.contains('urls')) {
const urlStore = db.createObjectStore('urls', { keyPath: 'id', autoIncrement: true });
urlStore.createIndex('pageId', 'pageId', { unique: false });
}
// 3.5. Ensure 'texts' store exists (Version 6)
if (!db.objectStoreNames.contains('texts')) {
const textStore = db.createObjectStore('texts', { keyPath: 'id', autoIncrement: true });
textStore.createIndex('pageId', 'pageId', { unique: false });
}
// 4. Data Migration (Only if upgrading from V1 where files existed but pages didn't)
if (event.oldVersion > 0 && event.oldVersion < 2) {
const fileStore = txn.objectStore('files');
fileStore.openCursor().onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
const updateData = cursor.value;
updateData.pageId = 1;
cursor.update(updateData);
cursor.continue();
}
};
const pageStore = txn.objectStore('pages');
pageStore.add({ content: '', title: '移行されたファイル', updatedAt: new Date().getTime() });
}
// 5. Data Migration for V3 -> V4 (Add 'order' field to existing files)
if (event.oldVersion > 0 && event.oldVersion < 4) {
const fileStore = txn.objectStore('files');
fileStore.openCursor().onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
const file = cursor.value;
if (file.order === undefined) {
file.order = file.timestamp || 0;
cursor.update(file);
}
cursor.continue();
}
};
}
};
});
}
// Page Operations
function getAllPages() {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['pages'], 'readonly');
const store = transaction.objectStore('pages');
const pages = [];
const request = store.openCursor(null, 'prev');
request.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
pages.push(cursor.value);
cursor.continue();
} else {
resolve(pages);
}
};
request.onerror = (e) => reject(e.target.error);
});
}
function getPage(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['pages'], 'readonly');
const store = transaction.objectStore('pages');
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = (e) => reject(e.target.error);
});
}
function addPage(title) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['pages'], 'readwrite');
const store = transaction.objectStore('pages');
const request = store.add({
title: title,
content: '',
updatedAt: new Date().getTime(),
textUpdatedAt: new Date().getTime() // Initialize with creation time
});
request.onsuccess = () => resolve(request.result);
request.onerror = (e) => reject(e.target.error);
});
}
function updatePageInDB(id, data) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['pages'], 'readwrite');
const store = transaction.objectStore('pages');
const getReq = store.get(id);
getReq.onsuccess = () => {
const page = getReq.result;
if (!page) {
reject('Page not found');
return;
}
const changes = { ...data };
changes.updatedAt = new Date().getTime();
// If content is being updated, also update textUpdatedAt
if (changes.content !== undefined) {
changes.textUpdatedAt = new Date().getTime();
}
const updatedPage = { ...page, ...changes };
const putReq = store.put(updatedPage);
putReq.onsuccess = () => resolve();
putReq.onerror = (e) => reject(e.target.error);
};
getReq.onerror = (e) => reject(e.target.error);
});
}
function deletePageFromDB(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['pages', 'files'], 'readwrite');
const pageStore = transaction.objectStore('pages');
const fileStore = transaction.objectStore('files');
const fileIndex = fileStore.index('pageId');
pageStore.delete(id);
const fileReq = fileIndex.getAllKeys(id);
fileReq.onsuccess = () => {
const keys = fileReq.result;
keys.forEach(key => fileStore.delete(key));
};
transaction.oncomplete = () => resolve();
transaction.onerror = (e) => reject(e.target.error);
});
}
// File Operations
function saveFileToDB(file, pageId) {
return new Promise(async (resolve, reject) => {
if (!pageId) {
reject('No active page selected');
return;
}
try {
// Get current max order
const currentFiles = await getFilesByPage(pageId);
const maxOrder = currentFiles.length > 0
? Math.max(...currentFiles.map(f => f.order || 0))
: 0;
const transaction = db.transaction(['files'], 'readwrite');
const store = transaction.objectStore('files');
const fileData = {
name: file.name,
type: file.type,
size: file.size,
data: file,
timestamp: new Date().getTime(),
pageId: pageId,
order: maxOrder + 100 // Increment by 100 for easier reordering
};
const request = store.add(fileData);
request.onsuccess = () => resolve(request.result);
request.onerror = (e) => reject(e.target.error);
} catch (error) {
reject(error);
}
});
}
function getFilesByPage(pageId) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['files'], 'readonly');
const store = transaction.objectStore('files');
const index = store.index('pageId');
const files = [];
const request = index.openCursor(IDBKeyRange.only(pageId));
request.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
files.push(cursor.value);
cursor.continue();
} else {
// Sort by order field, fallback to timestamp
files.sort((a, b) => {
const orderA = a.order !== undefined ? a.order : (a.timestamp || 0);
const orderB = b.order !== undefined ? b.order : (b.timestamp || 0);
return orderA - orderB;
});
resolve(files);
}
};
request.onerror = (e) => reject(e.target.error);
});
}
function deleteFileFromDB(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['files'], 'readwrite');
const store = transaction.objectStore('files');
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = (e) => reject(e.target.error);
});
}
function updateFileOrderInDB(filesToUpdate) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['files'], 'readwrite');
const store = transaction.objectStore('files');
filesToUpdate.forEach(item => {
const getReq = store.get(item.id);
getReq.onsuccess = () => {
const file = getReq.result;
if (file) {
file.order = item.order;
store.put(file);
}
};
});
transaction.oncomplete = () => resolve();
transaction.onerror = (e) => reject(e.target.error);
});
}
// URL Operations
function saveURLToDB(url, title, pageId) {
return new Promise(async (resolve, reject) => {
if (!pageId) {
reject('No active page selected');
return;
}
try {
// Get current max order from both files and URLs
const currentItems = await getItemsByPage(pageId);
const maxOrder = currentItems.length > 0
? Math.max(...currentItems.map(item => item.order || 0))
: 0;
const transaction = db.transaction(['urls'], 'readwrite');
const store = transaction.objectStore('urls');
const urlData = {
url: url,
title: title || url,
pageId: pageId,
timestamp: new Date().getTime(),
order: maxOrder + 100 // Increment by 100 for easier reordering
};
const request = store.add(urlData);
request.onsuccess = () => resolve(request.result);
request.onerror = (e) => reject(e.target.error);
} catch (error) {
reject(error);
}
});
}
function getURLsByPage(pageId) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['urls'], 'readonly');
const store = transaction.objectStore('urls');
const index = store.index('pageId');
const urls = [];
const request = index.openCursor(IDBKeyRange.only(pageId));
request.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
urls.push(cursor.value);
cursor.continue();
} else {
// Sort by order field, fallback to timestamp
urls.sort((a, b) => {
const orderA = a.order !== undefined ? a.order : (a.timestamp || 0);
const orderB = b.order !== undefined ? b.order : (b.timestamp || 0);
return orderA - orderB;
});
resolve(urls);
}
};
request.onerror = (e) => reject(e.target.error);
});
}
function deleteURLFromDB(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['urls'], 'readwrite');
const store = transaction.objectStore('urls');
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = (e) => reject(e.target.error);
});
}
function updateURLOrderInDB(urlsToUpdate) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['urls'], 'readwrite');
const store = transaction.objectStore('urls');
urlsToUpdate.forEach(item => {
const getReq = store.get(item.id);
getReq.onsuccess = () => {
const url = getReq.result;
if (url) {
url.order = item.order;
store.put(url);
}
};
});
transaction.oncomplete = () => resolve();
transaction.onerror = (e) => reject(e.target.error);
});
}
// Unified function to get both files and URLs
function getItemsByPage(pageId) {
return new Promise(async (resolve, reject) => {
try {
const files = await getFilesByPage(pageId);
const urls = await getURLsByPage(pageId);
const texts = await getTextsByPage(pageId);
// Add type identifier to each item
const filesWithType = files.map(f => ({ ...f, itemType: 'file' }));
const urlsWithType = urls.map(u => ({ ...u, itemType: 'url' }));
const textsWithType = texts.map(t => ({ ...t, itemType: 'text' }));
// Merge and sort by order
const allItems = [...filesWithType, ...urlsWithType, ...textsWithType];
allItems.sort((a, b) => {
const orderA = a.order !== undefined ? a.order : (a.timestamp || 0);
const orderB = b.order !== undefined ? b.order : (b.timestamp || 0);
return orderA - orderB;
});
resolve(allItems);
} catch (error) {
reject(error);
}
});
}
// Text Operations
function saveTextToDB(content, pageId) {
return new Promise(async (resolve, reject) => {
if (!pageId) {
reject('No active page selected');
return;
}
try {
// Get current max order from all items
const currentItems = await getItemsByPage(pageId);
const maxOrder = currentItems.length > 0
? Math.max(...currentItems.map(item => item.order || 0))
: 0;
const transaction = db.transaction(['texts'], 'readwrite');
const store = transaction.objectStore('texts');
const textData = {
content: content,
pageId: pageId,
timestamp: new Date().getTime(),
order: maxOrder + 100 // Increment by 100 for easier reordering
};
const request = store.add(textData);
request.onsuccess = () => resolve(request.result);
request.onerror = (e) => reject(e.target.error);
} catch (error) {
reject(error);
}
});
}
function getTextsByPage(pageId) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['texts'], 'readonly');
const store = transaction.objectStore('texts');
const index = store.index('pageId');
const texts = [];
const request = index.openCursor(IDBKeyRange.only(pageId));
request.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
texts.push(cursor.value);
cursor.continue();
} else {
// Sort by order field, fallback to timestamp
texts.sort((a, b) => {
const orderA = a.order !== undefined ? a.order : (a.timestamp || 0);
const orderB = b.order !== undefined ? b.order : (b.timestamp || 0);
return orderA - orderB;
});
resolve(texts);
}
};
request.onerror = (e) => reject(e.target.error);
});
}
function deleteTextFromDB(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['texts'], 'readwrite');
const store = transaction.objectStore('texts');
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = (e) => reject(e.target.error);
});
}
function updateTextOrderInDB(textsToUpdate) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['texts'], 'readwrite');
const store = transaction.objectStore('texts');
textsToUpdate.forEach(item => {
const getReq = store.get(item.id);
getReq.onsuccess = () => {
const text = getReq.result;
if (text) {
text.order = item.order;
store.put(text);
}
};
});
transaction.oncomplete = () => resolve();
transaction.onerror = (e) => reject(e.target.error);
});
}
ui.js
// UI State
let activePageId = null;
let saveTimeout = null;
// Utility function
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Setup Event Listeners
function setupEventListeners() {
console.log('Setting up event listeners...');
// Sidebar
document.getElementById('newPageBtn').addEventListener('click', () => createNewPage());
// Toggle View State
const toggleViewBtn = document.getElementById('toggleViewBtn');
const pageList = document.getElementById('pageList');
const savedMode = localStorage.getItem('pageListMode');
if (savedMode === 'detail') {
pageList.classList.add('mode-detail');
if (toggleViewBtn) toggleViewBtn.classList.add('active');
}
if (toggleViewBtn) {
toggleViewBtn.addEventListener('click', () => {
pageList.classList.toggle('mode-detail');
const isDetail = pageList.classList.contains('mode-detail');
localStorage.setItem('pageListMode', isDetail ? 'detail' : 'list');
});
}
// Main Content
document.getElementById('pageTitle').addEventListener('input', (e) => {
const newTitle = e.target.value;
document.title = newTitle || '無題のページ';
debouncedSave({ title: newTitle });
});
document.getElementById('pageText').addEventListener('input', (e) => {
debouncedSave({ content: e.target.value });
});
// document.getElementById('deletePageBtn').addEventListener('click', deleteCurrentPage);
// Drop Zone
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', handleDrop);
fileInput.addEventListener('change', handleFileSelect);
// URL Input
const urlInput = document.getElementById('urlInput');
const addUrlBtn = document.getElementById('addUrlBtn');
addUrlBtn.addEventListener('click', handleURLAdd);
urlInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleURLAdd();
}
});
// Text Input
const textInput = document.getElementById('textInput');
const addTextBtn = document.getElementById('addTextBtn');
addTextBtn.addEventListener('click', handleTextAdd);
textInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleTextAdd();
}
});
// Export button
document.getElementById('exportBtn').addEventListener('click', handleExport);
// File list delegation
document.getElementById('fileList').addEventListener('click', handleFileAction);
// Bulk Delete Listener
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
if (bulkDeleteBtn && !bulkDeleteBtn.hasAttribute('data-has-listener')) {
bulkDeleteBtn.addEventListener('click', async () => {
const checkboxes = document.querySelectorAll('.page-checkbox:checked');
if (checkboxes.length === 0) return;
if (!confirm(`${checkboxes.length}件のページを削除しますか?`)) return;
const idsToDelete = Array.from(checkboxes).map(cb => parseInt(cb.dataset.id));
try {
for (const id of idsToDelete) {
await deletePageFromDB(id);
}
if (activePageId && idsToDelete.includes(activePageId)) {
activePageId = null;
document.getElementById('pageTitle').value = '';
document.getElementById('pageText').value = '';
document.getElementById('fileList').innerHTML = '';
document.getElementById('pageContent').classList.add('hidden');
document.getElementById('noPageSelected').classList.remove('hidden');
}
await loadPages();
} catch (e) {
console.error(e);
alert('削除エラーが発生しました');
}
});
bulkDeleteBtn.setAttribute('data-has-listener', 'true');
}
}
// Page UI Logic
async function loadPages() {
const pages = await getAllPages();
pages.sort((a, b) => {
// Priority: textUpdatedAt > updatedAt > 0
const timeA = a.textUpdatedAt || a.updatedAt || 0;
const timeB = b.textUpdatedAt || b.updatedAt || 0;
return timeB - timeA;
});
const pageList = document.getElementById('pageList');
pageList.innerHTML = '';
pages.forEach(page => {
const div = document.createElement('div');
div.className = `page-item ${page.id === activePageId ? 'active' : ''}`;
// Use textUpdatedAt for display if available, otherwise fallback to updatedAt
const displayTime = page.textUpdatedAt || page.updatedAt || Date.now();
const date = new Date(displayTime);
const dateStr = `${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
div.innerHTML = `
<input type="checkbox" class="page-checkbox" data-id="${page.id}">
<div class="page-info">
<div class="page-item-title">${escapeHtml(page.title || '無題のページ')}</div>
<div class="page-item-date">${dateStr}</div>
</div>
<button class="sidebar-delete-btn" title="ページを削除">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
`;
div.dataset.id = page.id;
div.onclick = (e) => {
selectPage(page.id);
};
const checkbox = div.querySelector('.page-checkbox');
checkbox.onclick = (e) => {
e.stopPropagation();
updateBulkDeleteState();
};
const delBtn = div.querySelector('.sidebar-delete-btn');
delBtn.onclick = (e) => {
e.stopPropagation();
deletePage(page.id);
};
pageList.appendChild(div);
});
updateBulkDeleteState();
}
function updateBulkDeleteState() {
const checkboxes = document.querySelectorAll('.page-checkbox:checked');
const bulkBtn = document.getElementById('bulkDeleteBtn');
if (bulkBtn) {
if (checkboxes.length > 0) {
bulkBtn.classList.add('visible');
} else {
bulkBtn.classList.remove('visible');
}
}
}
async function deletePage(id) {
if (!confirm('このページと関連ファイルを完全に削除しますか?')) return;
try {
await deletePageFromDB(id);
if (activePageId === id) {
activePageId = null;
localStorage.removeItem('activePageId'); // Clear saved page
document.title = 'ファイルマネージャー';
document.getElementById('pageTitle').value = '';
document.getElementById('pageText').value = '';
document.getElementById('fileList').innerHTML = '';
document.getElementById('pageContent').classList.add('hidden');
document.getElementById('noPageSelected').classList.remove('hidden');
}
await loadPages();
} catch (e) {
console.error(e);
alert('削除に失敗しました');
}
}
async function createNewPage(initialTitle = '無題のページ') {
try {
const id = await addPage(initialTitle);
activePageId = id;
await loadPages();
await selectPage(id);
} catch (e) {
console.error('Error creating page', e);
}
}
async function selectPage(id) {
activePageId = id;
const page = await getPage(id);
if (!page) return;
// Save to localStorage for persistence across reloads
localStorage.setItem('activePageId', id);
document.title = page.title || '無題のページ';
document.querySelectorAll('.page-item').forEach(el => {
el.classList.toggle('active', parseInt(el.dataset.id) === id);
});
document.getElementById('pageContent').classList.remove('hidden');
document.getElementById('noPageSelected').classList.add('hidden');
document.getElementById('pageTitle').value = page.title;
document.getElementById('pageText').value = page.content;
await refreshFilesList();
}
async function deleteCurrentPage() {
if (!activePageId) return;
if (!confirm('このページと関連ファイルをすべて削除しますか?')) return;
await deletePageFromDB(activePageId);
activePageId = null;
localStorage.removeItem('activePageId'); // Clear saved page
document.getElementById('pageContent').classList.add('hidden');
document.getElementById('noPageSelected').classList.remove('hidden');
await loadPages();
}
function debouncedSave(data) {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
if (activePageId) {
await updatePageInDB(activePageId, data);
await loadPages();
}
}, 500);
}
// File UI Logic
async function handleDrop(e) {
e.preventDefault();
document.getElementById('dropZone').classList.remove('drag-over');
if (!activePageId) return;
const files = e.dataTransfer.files;
if (files.length > 0) await processFiles(files);
}
async function handleFileSelect(e) {
if (!activePageId) return;
const files = Array.from(e.target.files);
if (files.length > 0) await processFiles(files);
e.target.value = '';
}
async function processFiles(files) {
for (const file of files) {
try {
await saveFileToDB(file, activePageId);
} catch (err) {
console.error(`Error saving ${file.name}:`, err);
}
}
await refreshItemsList();
}
// URL handling
async function handleURLAdd() {
if (!activePageId) return;
const urlInput = document.getElementById('urlInput');
const url = urlInput.value.trim();
if (!url) return;
// Basic URL validation
try {
new URL(url);
} catch (e) {
alert('有効なURLを入力してください');
return;
}
try {
// Fetch page title
let title = url; // Default to URL
try {
const response = await fetch(url, {
method: 'GET',
mode: 'cors'
});
const html = await response.text();
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (titleMatch && titleMatch[1]) {
title = titleMatch[1].trim();
}
} catch (fetchError) {
// If fetch fails (CORS, network, etc.), use URL as title
console.log('Could not fetch page title, using URL:', fetchError);
}
await saveURLToDB(url, title, activePageId);
urlInput.value = '';
await refreshItemsList();
} catch (err) {
console.error('Error saving URL:', err);
alert('URLの保存に失敗しました');
}
}
// Text handling
async function handleTextAdd() {
if (!activePageId) return;
const textInput = document.getElementById('textInput');
const content = textInput.value.trim();
if (!content) return;
try {
await saveTextToDB(content, activePageId);
textInput.value = '';
await refreshItemsList();
} catch (err) {
console.error('Error saving text:', err);
alert('テキストの保存に失敗しました');
}
}
async function refreshItemsList() {
if (!activePageId) return;
const items = await getItemsByPage(activePageId);
const list = document.getElementById('fileList');
const empty = document.getElementById('emptyFilesState');
list.innerHTML = '';
if (items.length === 0) {
empty.classList.add('visible');
return;
}
empty.classList.remove('visible');
items.forEach(item => {
const row = document.createElement('tr');
row.className = 'file-row';
row.draggable = true;
row.dataset.id = item.id;
row.dataset.order = item.order;
row.dataset.type = item.itemType; // 'file', 'url', or 'text'
// Choose icon based on type
let iconSvg = '';
let displayName = '';
if (item.itemType === 'file') {
iconSvg = `
<svg style="width: 16px; height: 16px; color: var(--text-muted);" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
`;
displayName = item.name || '名称未設定';
} else if (item.itemType === 'url') {
iconSvg = `
<svg style="width: 16px; height: 16px; color: var(--primary-color);" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
`;
displayName = item.title || item.url;
} else {
// text
iconSvg = `
<svg style="width: 16px; height: 16px; color: #10b981;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="17" y1="10" x2="3" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="17" y1="18" x2="3" y2="18"></line>
</svg>
`;
displayName = item.content;
}
row.innerHTML = `
<td class="checkbox-cell">
<input type="checkbox" class="item-checkbox" data-id="${item.id}" data-type="${item.itemType}">
</td>
<td class="drag-handle-cell">
<div class="drag-handle">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
<circle cx="9" cy="5" r="1"></circle>
<circle cx="9" cy="12" r="1"></circle>
<circle cx="9" cy="19" r="1"></circle>
<circle cx="15" cy="5" r="1"></circle>
<circle cx="15" cy="12" r="1"></circle>
<circle cx="15" cy="19" r="1"></circle>
</svg>
</div>
</td>
<td>
<div class="file-name-container">
${iconSvg}
<span class="file-name-text">${escapeHtml(displayName)}</span>
</div>
</td>
<td class="text-right">
${item.itemType === 'file' ? `
<button class="action-btn download" data-id="${item.id}" data-type="file" title="ダウンロード">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
` : ''}
<button class="action-btn delete" data-id="${item.id}" data-type="${item.itemType}" title="削除">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</td>
`;
const nameContainer = row.querySelector('.file-name-container');
if (item.itemType === 'file') {
nameContainer.onclick = () => openFile(item);
} else if (item.itemType === 'url') {
nameContainer.onclick = () => openURL(item);
} else {
nameContainer.onclick = () => openText(item);
}
const downloadBtn = row.querySelector('.download');
if (downloadBtn) {
downloadBtn.onclick = (e) => {
e.stopPropagation();
downloadFile(item);
};
}
// Checkbox handler
const checkbox = row.querySelector('.item-checkbox');
checkbox.onclick = (e) => {
e.stopPropagation();
updateBulkDeleteItemState();
};
// Add drag event listeners
row.addEventListener('dragstart', handleRowDragStart);
row.addEventListener('dragover', handleRowDragOver);
row.addEventListener('drop', handleRowDrop);
row.addEventListener('dragenter', (e) => e.preventDefault());
row.addEventListener('dragend', handleRowDragEnd);
list.appendChild(row);
});
}
// Keep old function name for backward compatibility
async function refreshFilesList() {
await refreshItemsList();
}
function handleFileAction(e) {
const delBtn = e.target.closest('.delete');
if (delBtn) {
e.stopPropagation();
const id = parseInt(delBtn.dataset.id);
const type = delBtn.dataset.type;
deleteItem(id, type);
}
}
async function deleteItem(id, type) {
if (!confirm(type === 'file' ? 'ファイルを削除しますか?' : type === 'url' ? 'URLを削除しますか?' : 'テキストを削除しますか?')) return;
try {
if (type === 'file') {
await deleteFileFromDB(id);
} else if (type === 'url') {
await deleteURLFromDB(id);
} else {
await deleteTextFromDB(id);
}
await refreshItemsList();
} catch (err) {
console.error('Error deleting item:', err);
}
}
async function deleteFile(id) {
await deleteItem(id, 'file');
}
function downloadFile(fileData) {
const url = URL.createObjectURL(fileData.data);
const a = document.createElement('a');
a.href = url;
a.download = fileData.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function openFile(fileData) {
const url = URL.createObjectURL(fileData.data);
window.open(url, '_blank');
setTimeout(() => { /* keep alive */ }, 1000);
}
function openURL(urlData) {
window.open(urlData.url, '_blank');
}
// Sidebar Resizing
function initSidebarResizer() {
const sidebar = document.querySelector('.sidebar');
const resizer = document.getElementById('sidebarResizer');
if (!resizer) return;
let isResizing = false;
resizer.addEventListener('mousedown', (e) => {
isResizing = true;
sidebar.classList.add('resizing');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const newWidth = e.clientX;
if (newWidth > 180 && newWidth < 500) {
sidebar.style.width = `${newWidth}px`;
}
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
sidebar.classList.remove('resizing');
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
});
}
// Drag and Drop for File Reordering
let dragSrcRow = null;
function handleRowDragStart(e) {
dragSrcRow = this;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', this.innerHTML);
this.classList.add('dragging');
}
function handleRowDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
e.dataTransfer.dropEffect = 'move';
return false;
}
async function handleRowDrop(e) {
if (e.stopPropagation) {
e.stopPropagation();
}
if (dragSrcRow !== this) {
const list = document.getElementById('fileList');
const rows = Array.from(list.children);
let srcIndex = rows.indexOf(dragSrcRow);
let targetIndex = rows.indexOf(this);
// Reorder DOM
if (srcIndex < targetIndex) {
this.after(dragSrcRow);
} else {
this.before(dragSrcRow);
}
// Save new order to database
await saveNewItemOrder();
}
return false;
}
function handleRowDragEnd(e) {
this.classList.remove('dragging');
}
async function saveNewItemOrder() {
const list = document.getElementById('fileList');
const rows = list.querySelectorAll('tr.file-row');
const fileUpdates = [];
const urlUpdates = [];
const textUpdates = [];
rows.forEach((row, index) => {
const id = parseInt(row.dataset.id);
const type = row.dataset.type;
const newOrder = (index + 1) * 100;
if (type === 'file') {
fileUpdates.push({ id: id, order: newOrder });
} else if (type === 'url') {
urlUpdates.push({ id: id, order: newOrder });
} else {
textUpdates.push({ id: id, order: newOrder });
}
});
if (fileUpdates.length > 0) {
await updateFileOrderInDB(fileUpdates);
}
if (urlUpdates.length > 0) {
await updateURLOrderInDB(urlUpdates);
}
if (textUpdates.length > 0) {
await updateTextOrderInDB(textUpdates);
}
}
async function saveNewFileOrder() {
await saveNewItemOrder();
}
// Text operations
function openText(textData) {
// Copy text to clipboard
navigator.clipboard.writeText(textData.content).then(() => {
alert('テキストをクリップボードにコピーしました');
}).catch(() => {
// Fallback: show in alert
alert(textData.content);
});
}
// Bulk delete for items
function updateBulkDeleteItemState() {
const checkboxes = document.querySelectorAll('.item-checkbox:checked');
// For now, just log - we'll add a visible button later
console.log(`${checkboxes.length} items selected`);
}
async function handleBulkDeleteItems() {
const checkboxes = document.querySelectorAll('.item-checkbox:checked');
if (checkboxes.length === 0) return;
if (!confirm(`${checkboxes.length}件のアイテムを削除しますか?`)) return;
try {
for (const checkbox of checkboxes) {
const id = parseInt(checkbox.dataset.id);
const type = checkbox.dataset.type;
if (type === 'file') {
await deleteFileFromDB(id);
} else if (type === 'url') {
await deleteURLFromDB(id);
} else {
await deleteTextFromDB(id);
}
}
await refreshItemsList();
} catch (err) {
console.error('Error in bulk delete:', err);
alert('削除エラーが発生しました');
}
}
// Export functionality
async function handleExport() {
if (!activePageId) {
alert('ページが選択されていません');
return;
}
try {
const items = await getItemsByPage(activePageId);
const pageTitle = document.getElementById('pageTitle').value || '無題のページ';
if (items.length === 0) {
alert('エクスポートするアイテムがありません');
return;
}
// Separate items by type
const files = items.filter(item => item.itemType === 'file');
const urls = items.filter(item => item.itemType === 'url');
const texts = items.filter(item => item.itemType === 'text');
// Export files as actual files
for (const file of files) {
const url = URL.createObjectURL(file.data);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Small delay between downloads
await new Promise(resolve => setTimeout(resolve, 100));
}
// Export URLs as text file
if (urls.length > 0) {
const urlText = urls.map(item => {
return `${item.title || item.url}\n${item.url}\n`;
}).join('\n');
const blob = new Blob([urlText], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${pageTitle}_urls.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
await new Promise(resolve => setTimeout(resolve, 100));
}
// Export text items as text file
if (texts.length > 0) {
const textContent = texts.map((item, index) => {
return `--- テキスト ${index + 1} ---\n${item.content}\n`;
}).join('\n');
const blob = new Blob([textContent], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${pageTitle}_texts.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
const summary = [];
if (files.length > 0) summary.push(`${files.length}個のファイル`);
if (urls.length > 0) summary.push(`${urls.length}個のURL`);
if (texts.length > 0) summary.push(`${texts.length}個のテキスト`);
alert(`エクスポートが完了しました\n${summary.join('、')}`);
} catch (err) {
console.error('Error exporting data:', err);
alert('エクスポートに失敗しました');
}
}
style.css
:root {
--primary-color: #6366f1;
--primary-hover: #4f46e5;
--bg-color: #f8fafc;
--card-bg: #ffffff;
--text-main: #1e293b;
--text-muted: #64748b;
--border-color: #e2e8f0;
--danger-color: #ef4444;
--success-color: #10b981;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--radius: 12px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
:root {
--primary-color: #6366f1;
--primary-hover: #4f46e5;
--sidebar-bg: #f1f5f9;
--sidebar-border: #e2e8f0;
--bg-color: #ffffff;
--text-main: #1e293b;
--text-muted: #64748b;
--border-color: #e2e8f0;
--danger-color: #ef4444;
--accent-bg: #e0e7ff;
--radius: 8px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
height: 100vh;
overflow: hidden;
/* App-like feel */
}
/* Layout */
.app-layout {
display: flex;
height: 100%;
}
/* Sidebar Resizer */
.resizer {
width: 5px;
background-color: transparent;
cursor: col-resize;
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 10;
transition: background-color 0.2s;
}
.resizer:hover,
.sidebar.resizing .resizer {
background-color: var(--primary-color);
}
/* Sidebar */
.sidebar {
width: 250px;
background-color: #f1f5f9;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
position: relative;
/* Needed for resizer positioning */
min-width: 180px;
max-width: 500px;
}
/* Sidebar Header Controls */
.sidebar-header {
padding: 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
/* Spacing for controls */
font-weight: 700;
color: var(--primary-color);
font-size: 1.2rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.icon-btn:hover {
background-color: rgba(0, 0, 0, 0.05);
color: var(--text-main);
}
.header-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.bulk-delete-btn {
background: #fff;
border: 1px solid var(--border-color);
color: var(--text-muted);
cursor: pointer;
width: 50px;
height: 32px;
border-radius: 50%;
/* Circular */
display: none;
/* Hidden by default */
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.bulk-delete-btn.visible {
display: flex;
animation: popIn 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes popIn {
from {
transform: scale(0.5);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.bulk-delete-btn:hover {
color: var(--danger-color);
border-color: #fecaca;
background-color: #fef2f2;
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(220, 38, 38, 0.1);
}
.bulk-delete-btn:active {
transform: translateY(0);
}
.logo-icon {
width: 24px;
height: 24px;
color: var(--primary-color);
}
.sidebar-header h2 {
font-size: 1.125rem;
font-weight: 600;
}
.create-page-btn-container {
padding: 0 1rem 1rem;
}
.btn-primary {
width: 100%;
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.75rem;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: background 0.2s;
}
.btn-primary:hover {
background-color: var(--primary-hover);
}
.new-page-btn svg {
width: 18px;
height: 18px;
}
.page-list {
flex: 1;
overflow-y: auto;
padding: 0 0.5rem;
}
.page-item {
padding: 0.5rem 0.75rem;
margin-bottom: 0px;
border-bottom: 1px solid #f1f5f9;
cursor: pointer;
color: var(--text-main);
transition: background 0.1s;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
height: 48px;
/* Default height */
}
/* Two-line mode styles */
.page-list.mode-detail .page-item {
height: auto;
min-height: 60px;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.page-item:hover {
background-color: #fff;
}
.page-item.active {
background-color: #fff;
border-left: 4px solid var(--primary-color);
padding-left: calc(0.75rem - 4px);
/* maintain spacing */
}
/* Container for text parts to handle overflow */
.page-info {
display: flex;
flex-direction: row;
/* Horizontal layout */
align-items: center;
overflow: hidden;
flex: 1;
gap: 0.5rem;
}
.page-list.mode-detail .page-info {
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
}
.page-item-title {
font-weight: 500;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
/* Take remaining space */
width: 100%;
}
.page-item-date {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
/* Don't shrink date */
}
.page-list.mode-detail .page-item-date {
font-size: 0.75rem;
color: var(--text-muted);
}
.page-checkbox {
margin-right: 0.5rem;
cursor: pointer;
width: 16px;
height: 16px;
}
/* Delete button in sidebar (individual) */
.sidebar-delete-btn {
padding: 4px;
border-radius: 4px;
color: var(--text-muted);
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
/* Hidden by default */
transition: opacity 0.2s, background-color 0.2s;
margin-left: 0.25rem;
}
.page-item:hover .sidebar-delete-btn,
.sidebar-delete-btn:focus {
opacity: 1;
}
.sidebar-delete-btn:hover {
background-color: #ffe4e6;
color: var(--danger-color);
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #fff;
position: relative;
}
.no-page-selected {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.no-page-selected.hidden {
display: none;
}
.empty-message {
text-align: center;
}
.empty-icon {
width: 64px;
height: 64px;
color: #cbd5e1;
margin-bottom: 1rem;
}
/* Page Content View */
.page-content {
display: flex;
flex-direction: column;
height: 100%;
}
.page-content.hidden {
display: none;
}
.page-header {
padding: 0.75rem 2rem;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 1rem;
}
.page-title-input {
flex: 1;
font-size: 1.5rem;
font-weight: 600;
border: none;
outline: none;
padding: 0.5rem;
background: transparent;
color: var(--text-main);
}
.page-title-input::placeholder {
color: #cbd5e1;
}
.delete-page-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.5rem;
border-radius: 6px;
transition: all 0.2s;
}
.delete-page-btn:hover {
background-color: #fee2e2;
color: var(--danger-color);
}
.delete-page-btn svg {
width: 20px;
height: 20px;
}
/* Editor Area */
.editor-area {
height: 80px;
/* Fixed smaller height */
overflow-y: auto;
display: flex;
justify-content: center;
/* Center the editor content */
background-color: #f8fafc;
/* Slight bg distinction */
flex-shrink: 0;
border-bottom: 1px solid var(--border-color);
}
.export-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background-color 0.2s;
white-space: nowrap;
}
.export-btn:hover {
background-color: var(--primary-hover);
}
.export-btn svg {
flex-shrink: 0;
}
.page-textarea {
width: 100%;
max-width: 800px;
/* Restrict width */
height: 100%;
border: none;
resize: none;
outline: none;
font-size: 1rem;
line-height: 1.6;
color: var(--text-main);
font-family: inherit;
background-color: transparent;
padding: 1rem;
/* Add some internal padding */
}
/* Files Area */
.files-area {
flex: 1;
/* Take remaining space */
border-top: none;
display: flex;
flex-direction: column;
background-color: #fff;
min-height: 0;
/* Important for flex child scrolling */
}
.area-header {
padding: 0.75rem 2rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
background-color: #fff;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.bulk-delete-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.85rem;
background-color: var(--danger-color);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
transition: opacity 0.2s, background-color 0.2s;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.bulk-delete-btn:hover {
opacity: 0.9;
}
.bulk-delete-btn.hidden {
display: none;
}
/* Compact Drop Zone - New Style */
.compact-drop-zone {
padding: 1rem 2rem;
border-bottom: 1px solid var(--border-color);
background-color: #f1f5f9;
/* Light gray bg for visual separation */
transition: background 0.2s;
}
.compact-drop-zone.drag-over {
background-color: var(--accent-bg);
border-color: var(--primary-color);
}
.drop-row {
display: flex;
align-items: center;
gap: 1rem;
}
.drop-label-text {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-main);
}
.select-file-btn {
display: inline-block;
background-color: #fff;
border: 1px solid #ccc;
color: var(--text-main);
padding: 0.25rem 0.75rem;
font-size: 0.9rem;
border-radius: 4px;
/* Slight rounded */
cursor: pointer;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
background: linear-gradient(to bottom, #fff, #f9fafb);
}
.select-file-btn:hover {
background-color: #f3f4f6;
border-color: #b0b0b0;
}
.select-file-btn:active {
background-color: #e5e7eb;
}
.drop-hint-text {
color: var(--text-muted);
font-size: 0.85rem;
}
/* URL Input Row */
.url-row {
margin-top: 0.75rem;
}
.url-input {
flex: 1;
padding: 0.4rem 0.75rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
}
.url-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
}
.add-url-btn {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.4rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s;
}
.add-url-btn:hover {
background-color: var(--primary-hover);
}
.add-url-btn:active {
transform: translateY(1px);
}
/* Text Input Row */
.text-row {
margin-top: 0.75rem;
}
.text-input {
flex: 1;
padding: 0.4rem 0.75rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
}
.text-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
}
.add-text-btn {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.4rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s;
}
.add-text-btn:hover {
background-color: var(--primary-hover);
}
.add-text-btn:active {
transform: translateY(1px);
}
/* Files List */
.files-list-container {
flex: 1;
overflow-y: auto;
padding: 0;
}
.files-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.files-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.files-table tr:hover {
background-color: #f1f5f9;
}
.file-name-container {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.file-name-container:hover span {
color: var(--primary-color);
text-decoration: underline;
}
.rename-input {
flex: 1;
padding: 2px 4px;
font-size: 0.875rem;
border: 1px solid var(--primary-color);
border-radius: 4px;
outline: none;
min-width: 0;
}
.rename-btn {
background: none;
border: none;
cursor: pointer;
padding: 2px;
color: var(--text-muted);
border-radius: 4px;
opacity: 0;
transition: opacity 0.2s, color 0.2s;
margin-left: 0.5rem;
}
.rename-btn:hover {
color: var(--primary-color);
background-color: var(--hover-bg);
}
.file-name-container:hover .rename-btn {
opacity: 1;
}
.action-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--text-muted);
border-radius: 4px;
}
.action-btn:hover {
background-color: rgba(0, 0, 0, 0.05);
color: var(--text-main);
}
.action-btn.delete:hover {
color: var(--danger-color);
background-color: #fee2e2;
}
.empty-state {
padding: 2rem;
text-align: center;
color: var(--text-muted);
display: none;
}
.empty-state.visible {
display: block;
}
.text-right {
text-align: right;
}
/* Drag and Drop Styles */
.drag-handle-cell {
display: none;
}
.file-row {
transition: background-color 0.2s;
cursor: grab;
}
.file-row:active {
cursor: grabbing;
}
.file-row.dragging {
opacity: 0.5;
background-color: var(--hover-bg);
}
.file-row:not(.dragging):hover {
background-color: var(--hover-bg);
}
/* Checkbox cell styles */
.checkbox-cell {
width: 30px;
padding: 0.5rem 0.75rem;
text-align: center;
}
.item-checkbox {
cursor: pointer;
width: 16px;
height: 16px;
accent-color: var(--primary-color);
}
.drag-handle-cell {
width: 30px;
padding: 0.5rem 0.25rem;
cursor: grab;
}
.drag-handle {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
opacity: 0.5;
transition: opacity 0.2s;
}
.file-row:hover .drag-handle {
opacity: 1;
}
.hidden {
display: none !important;
}
favicon.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- フォルダが背景を兼ねる -->
<path d="M0 18h24l6 8h34v38H0z"
fill="#facc15"/>
<!-- 輪郭 -->
<path d="M0 18h24l6 8h34"
fill="none"
stroke="#111"
stroke-width="3"/>
<rect x="1.5" y="26" width="61" height="36" rx="6"
fill="none"
stroke="#111"
stroke-width="3"/>
<!-- スロット -->
<rect x="20" y="42" width="24" height="4" rx="2"
fill="#111"/>
</svg>