Jump to content
Main menu
Main menu
move to sidebar
hide
Navigation
Main page
Recent changes
Random page
freem
Search
Search
Appearance
Create account
Log in
Personal tools
Create account
Log in
Pages for logged out editors
learn more
Contributions
Talk
Editing
Openai/6926aad9-faf0-8007-bcb8-9e64cd1f86b1
(section)
Add languages
Page
Discussion
English
Read
Edit
Edit source
View history
Tools
Tools
move to sidebar
hide
Actions
Read
Edit
Edit source
View history
General
What links here
Related changes
Special pages
Page information
Appearance
move to sidebar
hide
Warning:
You are not logged in. Your IP address will be publicly visible if you make any edits. If you
log in
or
create an account
, your edits will be attributed to your username, along with other benefits.
Anti-spam check. Do
not
fill this in!
=== Assistant: じゃあ、要件そのまま満たす「フロントエンド完結メモアプリ」のサンプルを丸ごと1ファイルで置きますね。 === じゃあ、要件そのまま満たす「フロントエンド完結メモアプリ」のサンプルを丸ごと1ファイルで置きますね。 index.htmlとして保存 → ブラウザで開くだけで動きます。 * 完全フロントエンド * localStorageに保存 * 左ペイン:メモ一覧 & CRUD * 右ペイン:メモ編集 * 入力と同時に自動保存(少しデバウンス) * メモ名 = 先頭行を流用(空なら「無題メモ」) * 内部的には機械的IDでKV保存 ==== <syntaxhighlight lang="html"><!DOCTYPE html> ==== <html lang="ja"> <head> <meta charset="UTF-8" /> <title>ブラウザ完結メモアプリ</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <style> :root { --border-color: #ddd; --accent-color: #2563eb; --bg-sidebar: #f5f5f5; --bg-active: #e0edff; --text-muted: #666; --radius: 8px; --shadow: 0 8px 16px rgba(0,0,0,0.06); } * { box-sizing: border-box; } body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f3f4f6; color: #111827; height: 100vh; display: flex; align-items: stretch; justify-content: center; } .app-shell { margin: 16px; background: white; border-radius: var(--radius); box-shadow: var(--shadow); display: flex; flex: 1; max-width: 1200px; min-height: 0; } .sidebar { width: 280px; min-width: 220px; max-width: 360px; border-right: 1px solid var(--border-color); background: var(--bg-sidebar); display: flex; flex-direction: column; } .sidebar-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border-color); } .sidebar-title { font-size: 15px; font-weight: 600; } .new-btn { border: none; border-radius: 999px; padding: 6px 12px; font-size: 13px; cursor: pointer; background: var(--accent-color); color: white; display: inline-flex; align-items: center; gap: 4px; } .new-btn span { font-size: 16px; line-height: 1; transform: translateY(-1px); } .memo-list { flex: 1; overflow-y: auto; padding: 8px; } .memo-item { background: white; border-radius: 6px; padding: 8px 10px; margin-bottom: 6px; cursor: pointer; display: flex; align-items: center; gap: 8px; border: 1px solid transparent; transition: background 0.1s ease, border-color 0.1s ease, transform 0.05s ease; } .memo-item:hover { background: #eef2ff; transform: translateY(-1px); } .memo-item.active { background: var(--bg-active); border-color: var(--accent-color); } .memo-main { flex: 1; min-width: 0; } .memo-title { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .memo-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } .memo-delete { border: none; background: transparent; cursor: pointer; padding: 4px; border-radius: 999px; font-size: 14px; color: #9ca3af; flex-shrink: 0; } .memo-delete:hover { background: #fee2e2; color: #b91c1c; } .editor-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; } .editor-header { padding: 10px 16px; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; justify-content: space-between; gap: 8px; } .editor-title { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .editor-meta { font-size: 11px; color: var(--text-muted); } .editor-body { flex: 1; display: flex; flex-direction: column; min-height: 0; } .editor-textarea { flex: 1; padding: 12px 16px; border: none; resize: none; font-family: inherit; font-size: 14px; line-height: 1.6; outline: none; min-height: 0; } .editor-textarea::placeholder { color: #9ca3af; } .status-bar { font-size: 11px; color: var(--text-muted); padding: 4px 10px 8px 16px; border-top: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; } @media (max-width: 768px) { .app-shell { flex-direction: column; } .sidebar { width: 100%; max-width: none; border-right: none; border-bottom: 1px solid var(--border-color); } } </style> </head> <body> <div class="app-shell"> <!-- 左ペイン:メモ一覧 --> <aside class="sidebar"> <div class="sidebar-header"> <div class="sidebar-title">メモ一覧</div> <button class="new-btn" id="newMemoBtn"><span>+</span>新規</button> </div> <div class="memo-list" id="memoList"></div> </aside> <!-- 右ペイン:エディタ --> <main class="editor-pane"> <div class="editor-header"> <div class="editor-title" id="currentTitle">メモが選択されていません</div> <div class="editor-meta" id="currentMeta"></div> </div> <div class="editor-body"> <textarea id="editor" class="editor-textarea" placeholder="メモを書き始めてください…(自動保存されます)" ></textarea> <div class="status-bar"> <span id="statusText">準備完了</span> <span id="charCount">0 文字</span> </div> </div> </main> </div> <script> (function () { const STORAGE_KEY = "memoApp.v1"; /** * state: * memos: { [id]: { id, content, updatedAt } } * order: string[] (表示順) * currentId: string | null */ let state = { memos: {}, order: [], currentId: null, }; const memoListEl = document.getElementById("memoList"); const editorEl = document.getElementById("editor"); const newMemoBtn = document.getElementById("newMemoBtn"); const currentTitleEl = document.getElementById("currentTitle"); const currentMetaEl = document.getElementById("currentMeta"); const statusTextEl = document.getElementById("statusText"); const charCountEl = document.getElementById("charCount"); let saveTimer = null; let isLoading = true; // ---- ストレージ関連 ---- function loadFromStorage() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return; state.memos = parsed.memos || {}; state.order = Array.isArray(parsed.order) ? parsed.order : []; state.currentId = parsed.currentId || null; } catch (e) { console.error("Failed to load memos:", e); } } function saveToStorage() { const payload = { memos: state.memos, order: state.order, currentId: state.currentId, }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch (e) { console.error("Failed to save memos:", e); setStatus("保存に失敗しました(容量オーバーかもしれません)"); } } // ---- ユーティリティ ---- function createId() { return ( "m_" + Date.now().toString(36) + "_" + Math.random().toString(16).slice(2) ); } function getTitleFromContent(content) { const firstLine = (content || "").split(/\r?\n/)[0].trim(); return firstLine || "無題メモ"; } function formatDate(ts) { const d = new Date(ts); const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); const hh = String(d.getHours()).padStart(2, "0"); const mi = String(d.getMinutes()).padStart(2, "0"); return <code>${yyyy}/${mm}/${dd} ${hh}:${mi}</code>; } function setStatus(text) { statusTextEl.textContent = text; } function updateCharCount() { charCountEl.textContent = <code>${editorEl.value.length} 文字</code>; } // ---- CRUD ---- function createMemo() { const id = createId(); const now = Date.now(); const memo = { id, content: "", updatedAt: now, }; state.memos[id] = memo; state.order.unshift(id); // 新しいメモを先頭に state.currentId = id; saveToStorage(); renderList(); renderCurrentMemo(); editorEl.focus(); } function deleteMemo(id) { if (!state.memos[id]) return; const ok = confirm("このメモを削除しますか?"); if (!ok) return; delete state.memos[id]; state.order = state.order.filter((mId) => mId !== id); if (state.currentId === id) { state.currentId = state.order[0] || null; } if (!state.currentId && state.order.length === 0) { // メモが一つもない場合は空のメモを1つ作る createMemo(); return; } saveToStorage(); renderList(); renderCurrentMemo(); } function selectMemo(id) { if (!state.memos[id]) return; state.currentId = id; saveToStorage(); renderList(); renderCurrentMemo(); } function updateCurrentMemoContent(content) { const id = state.currentId; if (!id) return; const memo = state.memos[id]; if (!memo) return; memo.content = content; memo.updatedAt = Date.now(); // 表示順を前に持ってくる(最近編集したものを上にしたい場合) const idx = state.order.indexOf(id); if (idx > 0) { state.order.splice(idx, 1); state.order.unshift(id); } // 自動保存(デバウンス) if (saveTimer) clearTimeout(saveTimer); setStatus("保存中…"); saveTimer = setTimeout(() => { saveToStorage(); renderList(); renderCurrentMetaOnly(); setStatus("保存しました"); }, 500); } // ---- 描画 ---- function renderList() { memoListEl.innerHTML = ""; if (state.order.length === 0) { const emptyEl = document.createElement("div"); emptyEl.style.padding = "12px"; emptyEl.style.fontSize = "13px"; emptyEl.style.color = "#9ca3af"; emptyEl.textContent = "メモがありません。「新規」ボタンから作成できます。"; memoListEl.appendChild(emptyEl); return; } state.order.forEach((id) => { const memo = state.memos[id]; if (!memo) return; const itemEl = document.createElement("div"); itemEl.className = "memo-item" + (id === state.currentId ? " active" : ""); itemEl.addEventListener("click", () => selectMemo(id)); const mainEl = document.createElement("div"); mainEl.className = "memo-main"; const titleEl = document.createElement("div"); titleEl.className = "memo-title"; titleEl.textContent = getTitleFromContent(memo.content); const metaEl = document.createElement("div"); metaEl.className = "memo-meta"; metaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>; mainEl.appendChild(titleEl); mainEl.appendChild(metaEl); const deleteBtn = document.createElement("button"); deleteBtn.className = "memo-delete"; deleteBtn.innerHTML = "✕"; deleteBtn.title = "削除"; deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); deleteMemo(id); }); itemEl.appendChild(mainEl); itemEl.appendChild(deleteBtn); memoListEl.appendChild(itemEl); }); } function renderCurrentMetaOnly() { const id = state.currentId; if (!id || !state.memos[id]) { currentMetaEl.textContent = ""; return; } const memo = state.memos[id]; currentTitleEl.textContent = getTitleFromContent(memo.content); currentMetaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>; } function renderCurrentMemo() { const id = state.currentId; if (!id || !state.memos[id]) { currentTitleEl.textContent = "メモが選択されていません"; currentMetaEl.textContent = ""; editorEl.value = ""; editorEl.disabled = true; updateCharCount(); return; } const memo = state.memos[id]; editorEl.disabled = false; editorEl.value = memo.content; currentTitleEl.textContent = getTitleFromContent(memo.content); currentMetaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>; updateCharCount(); } // ---- イベント ---- newMemoBtn.addEventListener("click", () => { createMemo(); }); editorEl.addEventListener("input", () => { if (isLoading) return; updateCharCount(); updateCurrentMemoContent(editorEl.value); renderCurrentMetaOnly(); // タイトルも更新 }); window.addEventListener("beforeunload", () => { // 念のため即保存 saveToStorage(); }); // ---- 初期化 ---- function init() { loadFromStorage(); // メモが全くなければ1つ作る if (Object.keys(state.memos).length === 0 || state.order.length === 0) { state.memos = {}; state.order = []; state.currentId = null; createMemo(); } else { // currentIdがおかしい場合は先頭を選択 if (!state.currentId || !state.memos[state.currentId]) { state.currentId = state.order[0] || null; } } renderList(); renderCurrentMemo(); isLoading = false; setStatus("自動保存が有効です"); } init(); })(); </script> </body> </html> </syntaxhighlight> ==== - データ構造(localStorage) ``<code>js { memos: { [id]: { id, content, updatedAt } }, order: [id1, id2, ...], // 表示順 currentId: "m_..." } </code>`` ==== * タイトル - getTitleFromContent で「先頭行 or 無題メモ」を生成 - 編集のたびにタイトルも再計算して左ペインに反映 * 自動保存 - textarea の input で updateCurrentMemoContent - 500ms デバウンスして localStorage に保存 - 同時にメモ一覧の最上部に移動(最近編集したものが上に来る) * CRUD - 新規: createMemo() で空メモ作成+選択 - 削除: 左ペインの「✕」ボタンで confirm → 削除 - 一覧クリックで選択 - 更新は編集+自動保存 もし「メモをタグでフィルタしたい」「Markdownプレビューを付けたい」「IndexedDBにしたい」みたいな拡張があれば、それ前提で次のバージョンも一緒に考えましょう。
Summary:
Please note that all contributions to freem are considered to be released under the Creative Commons Attribution-ShareAlike 4.0 (see
Freem:Copyrights
for details). If you do not want your writing to be edited mercilessly and redistributed at will, then do not submit it here.
You are also promising us that you wrote this yourself, or copied it from a public domain or similar free resource.
Do not submit copyrighted work without permission!
Cancel
Editing help
(opens in new window)