二次裏のクローンサイト

tyosuke20xx.com/nijiura.html


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>二次裏クローン(ローカルHTML版・強化)</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>
        /* ------------------------------
           全体レイアウト / ベーススタイル
        ------------------------------ */
        body {
            background: #f2f2e9;
            color: #333;
            font-family: "YuGothic", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
            font-size: 14px;
            margin: 0;
            padding: 0;
        }
        header {
            background: #d8d8c0;
            padding: 10px;
            border-bottom: 1px solid #b0b08f;
        }
        header h1 {
            margin: 0;
            font-size: 18px;
        }
        header small {
            display: block;
            font-size: 11px;
            color: #555;
        }
        .container {
            width: 95%;
            max-width: 900px;
            margin: 10px auto 40px auto;
            background: #fff;
            border: 1px solid #ccc;
            padding: 10px 15px 20px 15px;
            box-sizing: border-box;
        }
        a {
            color: #0044cc;
            text-decoration: none;
            cursor: pointer;
        }
        a:hover {
            text-decoration: underline;
        }
        .hidden {
            display: none;
        }

        /* ------------------------------
           上部ナビ・ステータス
        ------------------------------ */
        .board-nav {
            font-size: 12px;
            padding: 5px 0 8px 0;
            border-bottom: 1px solid #ddd;
            margin-bottom: 10px;
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            align-items: center;
        }
        .board-nav .nav-left {
            flex: 1 1 auto;
        }
        .board-nav .nav-right {
            flex: 1 1 auto;
            text-align: right;
        }
        .board-nav label {
            font-size: 12px;
            margin-left: 6px;
        }
        .board-nav select,
        .board-nav input[type="text"] {
            font-size: 12px;
            padding: 2px 4px;
        }
        .board-nav input[type="checkbox"] {
            vertical-align: middle;
        }
        .stats {
            font-size: 11px;
            color: #555;
        }

        /* ------------------------------
           スレッド一覧
        ------------------------------ */
        .thread-list {
            margin-bottom: 20px;
        }
        .thread-item {
            border-bottom: 1px dotted #ccc;
            padding: 5px 0;
            display: flex;
            align-items: flex-start;
            gap: 4px;
        }
        .thread-main {
            flex: 1 1 auto;
        }
        .thread-item:last-child {
            border-bottom: none;
        }
        .thread-title {
            font-weight: bold;
        }
        .thread-category {
            font-size: 11px;
            color: #444;
            background: #f0f0d8;
            padding: 1px 4px;
            border-radius: 3px;
            margin-right: 4px;
        }
        .meta {
            font-size: 11px;
            color: #666;
        }

        /* お気に入りスター */
        .fav-toggle {
            font-size: 16px;
            cursor: pointer;
            user-select: none;
            padding: 0 2px;
        }
        .fav-true {
            color: #d89a00;
        }
        .fav-false {
            color: #ccc;
        }

        /* ------------------------------
           フォーム共通
        ------------------------------ */
        .form-block {
            margin: 20px 0;
            padding: 10px;
            background: #f7f7ee;
            border: 1px solid #ddd;
        }
        .form-block h2 {
            margin: 0 0 5px 0;
            font-size: 14px;
        }
        .form-row {
            margin-bottom: 5px;
        }
        label {
            font-size: 12px;
            display: inline-block;
            width: 70px;
        }
        input[type="text"],
        textarea,
        select {
            width: 90%;
            max-width: 500px;
            box-sizing: border-box;
            font-size: 13px;
        }
        textarea {
            height: 80px;
        }
        input[type="submit"],
        button {
            font-size: 12px;
            padding: 3px 10px;
            margin-right: 4px;
        }
        .notice {
            font-size: 11px;
            color: #999;
            margin-top: 5px;
        }

        /* ------------------------------
           レス表示
        ------------------------------ */
        .posts {
            margin-top: 10px;
        }
        .post {
            border-top: 1px dotted #ccc;
            padding: 5px 0;
        }
        .post:first-child {
            border-top: none;
        }
        .post-header {
            font-size: 12px;
            margin-bottom: 3px;
        }
        .post-body {
            font-size: 13px;
            white-space: pre-wrap;
            word-wrap: break-word;
        }
        .post-id {
            font-family: "Consolas", "Menlo", monospace;
            font-size: 11px;
            color: #666;
        }
        .post-no-link {
            font-weight: bold;
        }

        /* ------------------------------
           スレッドビューのナビ
        ------------------------------ */
        .nav-top {
            margin-bottom: 10px;
            font-size: 12px;
        }

        /* ------------------------------
           データツール用エリア
        ------------------------------ */
        #backup-text {
            width: 100%;
            max-width: 100%;
            box-sizing: border-box;
            font-size: 12px;
        }

        /* ------------------------------
           設定ブロック
        ------------------------------ */
        #settings-block label {
            width: auto;
        }
        #settings-block input[type="checkbox"] {
            width: auto;
        }
        #setting-last-name {
            width: 200px;
        }

        /* ------------------------------
           レスポンシブ調整
        ------------------------------ */
        @media (max-width: 600px) {
            label {
                display: block;
                width: auto;
                margin-bottom: 2px;
            }
            input[type="text"],
            textarea,
            select {
                width: 100%;
            }
            .board-nav {
                flex-direction: column;
                align-items: flex-start;
            }
            .board-nav .nav-right {
                text-align: left;
            }
            .thread-item {
                flex-direction: row;
            }
        }
    </style>
</head>
<body>
<header>
    <h1>二次裏クローン(ローカルHTML版・強化)</h1>
    <small>※ブラウザの localStorage に保存 / 本家とは一切関係ありません</small>
</header>

<div class="container" id="top">
    <!-- スレ一覧ビュー -->
    <div id="view-list">
        <div class="board-nav">
            <div class="nav-left">
                <span class="stats">
                    スレ数:<span id="stat-threads">0</span> /
                    総レス数:<span id="stat-posts">0</span>
                </span>
                <label>
                    カテゴリ
                    <select id="category-filter">
                        <option value="">全カテゴリ</option>
                        <option value="雑談">雑談</option>
                        <option value="ゲーム">ゲーム</option>
                        <option value="アニメ">アニメ</option>
                        <option value="ニュース">ニュース</option>
                        <option value="その他">その他</option>
                    </select>
                </label>
                <label>
                    <input type="checkbox" id="fav-only">
                    お気に入りのみ
                </label>
            </div>
            <div class="nav-right">
                <label>
                    並び替え
                    <select id="sort-select">
                        <option value="updated">更新順</option>
                        <option value="created">作成順</option>
                        <option value="id">スレ番号順</option>
                    </select>
                </label>
                <label>
                    検索
                    <input type="text" id="search-input" placeholder="タイトル・本文から検索">
                </label>
            </div>
        </div>

        <h2>スレッド一覧</h2>
        <div id="thread-list" class="thread-list">
            <!-- JSで一覧を描画 -->
        </div>

        <div class="form-block">
            <h2>新規スレッド作成</h2>
            <form id="new-thread-form">
                <div class="form-row">
                    <label>名前</label>
                    <input type="text" name="name" id="new-name" placeholder="名無し">
                </div>
                <div class="form-row">
                    <label>カテゴリ</label>
                    <select name="category" id="new-category">
                        <option value="雑談">雑談</option>
                        <option value="ゲーム">ゲーム</option>
                        <option value="アニメ">アニメ</option>
                        <option value="ニュース">ニュース</option>
                        <option value="その他">その他</option>
                    </select>
                </div>
                <div class="form-row">
                    <label>タイトル</label>
                    <input type="text" name="title" required>
                </div>
                <div class="form-row">
                    <label>本文</label>
                    <textarea name="body" required></textarea>
                </div>
                <div class="form-row">
                    <input type="submit" value="スレ立て">
                </div>
                <div class="notice">
                    ※超シンプルローカル実装。データはこのブラウザ内だけに保存されます。<br>
                    ※別ブラウザ・シークレットモードでは共有されません。
                </div>
            </form>
        </div>

        <!-- ローカルデータ管理ツール -->
        <div class="form-block" id="data-tools">
            <h2>ローカルデータ管理</h2>
            <p class="notice">
                このPC / ブラウザだけで使う簡易ツールです。<br>
                別環境へ移したい場合は JSON をエクスポートして保存してください。
            </p>
            <div class="form-row">
                <button type="button" id="btn-export">エクスポート</button>
                <button type="button" id="btn-import">インポート</button>
                <button type="button" id="btn-clear">全消去</button>
            </div>
            <div class="form-row">
                <textarea id="backup-text" rows="4" placeholder="エクスポートしたJSONがここに出力されます。ここに貼り付けてインポートもできます。"></textarea>
            </div>
        </div>

        <!-- 簡易設定 -->
        <div class="form-block" id="settings-block">
            <h2>ミニ設定</h2>
            <div class="form-row">
                <label>
                    <input type="checkbox" id="opt-autoname">
                    名前を記憶して自動で入れる
                </label>
            </div>
            <div class="form-row">
                <label>前回の名前</label>
                <input type="text" id="setting-last-name" placeholder="名無し">
            </div>
            <div class="form-row">
                <button type="button" id="btn-save-settings">設定を保存</button>
            </div>
            <div class="notice">
                ※名前を記憶しておくと、新規スレ・レス投稿時の名前欄に自動で反映されます。<br>
                ※これらも localStorage に保存されます。
            </div>
        </div>
    </div>

    <!-- 個別スレッドビュー -->
    <div id="view-thread" class="hidden">
        <div class="nav-top">
            <a id="back-to-list">&lt;&lt; スレ一覧に戻る</a> |
            <a href="#bottom">▼ 一番下へ</a> |
            <a href="#top">▲ ページ先頭へ</a>
        </div>

        <h2 id="thread-title"></h2>
        <div class="meta" id="thread-meta"></div>

        <div id="posts" class="posts">
            <!-- JSでレスを描画 -->
        </div>

        <div class="form-block">
            <h2>レスを書く</h2>
            <form id="reply-form">
                <div class="form-row">
                    <label>名前</label>
                    <input type="text" name="name" id="reply-name" placeholder="名無し">
                </div>
                <div class="form-row">
                    <label>本文</label>
                    <textarea name="body" id="reply-body" required></textarea>
                </div>
                <div class="form-row">
                    <input type="submit" value="レス投稿">
                </div>
                <div class="notice">
                    ※レス番号をクリックすると本文に「>>番号」が入ります。<br>
                    ※画像アップロード機能はこのHTML版には入れていません。<br>
                    ※本格運用したい場合はPHPやDB版で実装してください。
                </div>
            </form>
        </div>
    </div>
</div>

<div id="bottom"></div>

<script>
(function() {
    "use strict";

    const STORAGE_KEY = "nijiura_clone_threads_v1";
    const SETTINGS_KEY = "nijiura_clone_settings_v1";

    // --------------------
    // データ構造
    // --------------------
    // threads = [
    //   {
    //     id: number,
    //     title: string,
    //     category: string,
    //     favorite: boolean,
    //     createdAt: string,
    //     updatedAt: string,
    //     posts: [
    //       { id, name, body, createdAt, uid }
    //     ]
    //   }, ...
    // ]
    //
    // settings = {
    //   autoName: boolean,
    //   lastName: string
    // }

    let threads = loadThreads();
    let settings = loadSettings();
    let currentThreadId = null;
    let currentSort = "updated"; // updated / created / id

    const viewList = document.getElementById("view-list");
    const viewThread = document.getElementById("view-thread");

    const threadListEl = document.getElementById("thread-list");
    const threadTitleEl = document.getElementById("thread-title");
    const threadMetaEl = document.getElementById("thread-meta");
    const postsEl = document.getElementById("posts");

    const newThreadForm = document.getElementById("new-thread-form");
    const replyForm = document.getElementById("reply-form");
    const backToListBtn = document.getElementById("back-to-list");

    const searchInput = document.getElementById("search-input");
    const sortSelect = document.getElementById("sort-select");
    const categoryFilter = document.getElementById("category-filter");
    const favOnlyCheckbox = document.getElementById("fav-only");

    const statThreadsEl = document.getElementById("stat-threads");
    const statPostsEl = document.getElementById("stat-posts");

    const btnExport = document.getElementById("btn-export");
    const btnImport = document.getElementById("btn-import");
    const btnClear = document.getElementById("btn-clear");
    const backupText = document.getElementById("backup-text");

    const optAutoname = document.getElementById("opt-autoname");
    const settingLastName = document.getElementById("setting-last-name");
    const btnSaveSettings = document.getElementById("btn-save-settings");

    const newNameInput = document.getElementById("new-name");
    const newCategorySelect = document.getElementById("new-category");
    const replyNameInput = document.getElementById("reply-name");
    const replyBodyTextarea = document.getElementById("reply-body");

    // 既存データにカテゴリ・お気に入り・UIDがなければ補完
    migrateThreads();

    // 初期ソート&描画
    sortThreadsByUpdated();
    applySettingsToUI();
    renderThreadList();
    showListView();

    // --------------------
    // イベント: スレ立て
    // --------------------
    newThreadForm.addEventListener("submit", function(e) {
        e.preventDefault();
        const formData = new FormData(newThreadForm);
        let name = (formData.get("name") || "").toString().trim();
        const category = (formData.get("category") || "雑談").toString();
        const title = (formData.get("title") || "").toString().trim();
        const body = (formData.get("body") || "").toString().trim();

        if (!name) {
            name = "名無し";
        }

        if (!title || !body) {
            alert("タイトルと本文は必須です。");
            return;
        }

        const now = getNowStr();
        const newId = getNewThreadId();

        const newThread = {
            id: newId,
            title: title,
            category: category || "雑談",
            favorite: false,
            createdAt: now,
            updatedAt: now,
            posts: [
                {
                    id: 1,
                    name: name,
                    body: body,
                    createdAt: now,
                    uid: generateUid(newId, 1, name, body, now)
                }
            ]
        };

        threads.push(newThread);
        sortThreadsByUpdated();
        saveThreads(threads);

        // 名前を設定に反映
        updateLastNameSetting(name);

        newThreadForm.reset();
        currentSort = "updated";
        sortSelect.value = "updated";

        openThread(newId);
    });

    // --------------------
    // イベント: レス投稿
    // --------------------
    replyForm.addEventListener("submit", function(e) {
        e.preventDefault();
        if (currentThreadId === null) return;

        const formData = new FormData(replyForm);
        let name = (formData.get("name") || "").toString().trim();
        const body = (formData.get("body") || "").toString().trim();

        if (!name) {
            name = "名無し";
        }
        if (!body) {
            alert("本文は必須です。");
            return;
        }

        const thread = threads.find(t => t.id === currentThreadId);
        if (!thread) {
            alert("スレッドが見つかりません。");
            return;
        }

        const now = getNowStr();
        const newPostId = getNewPostId(thread);

        thread.posts.push({
            id: newPostId,
            name: name,
            body: body,
            createdAt: now,
            uid: generateUid(thread.id, newPostId, name, body, now)
        });
        thread.updatedAt = now;

        sortThreadsByUpdated();
        saveThreads(threads);

        // 名前を設定に反映
        updateLastNameSetting(name);

        replyForm.reset();

        renderThread(thread);
        renderThreadList(); // 更新日時が変わるので一覧も更新
    });

    // --------------------
    // イベント: 一覧に戻る
    // --------------------
    backToListBtn.addEventListener("click", function() {
        currentThreadId = null;
        showListView();
    });

    // --------------------
    // イベント: 並び替え&検索&フィルタ
    // --------------------
    sortSelect.addEventListener("change", function() {
        currentSort = this.value;
        renderThreadList();
    });

    searchInput.addEventListener("input", function() {
        renderThreadList();
    });

    categoryFilter.addEventListener("change", function() {
        renderThreadList();
    });

    favOnlyCheckbox.addEventListener("change", function() {
        renderThreadList();
    });

    // --------------------
    // イベント: データツール
    // --------------------
    btnExport.addEventListener("click", function() {
        try {
            const json = JSON.stringify(threads, null, 2);
            backupText.value = json;
            alert("現在のデータをJSONとして出力しました。必要ならコピーして保存してください。");
        } catch (e) {
            console.warn("export failed:", e);
            alert("エクスポートに失敗しました。");
        }
    });

    btnImport.addEventListener("click", function() {
        const text = backupText.value.trim();
        if (!text) {
            alert("インポートするJSONが入力されていません。");
            return;
        }
        if (!confirm("テキストエリアのJSONで現在のデータを上書きします。よろしいですか?")) {
            return;
        }
        try {
            const data = JSON.parse(text);
            if (!Array.isArray(data)) {
                alert("JSONの形式が不正です。(配列ではありません)");
                return;
            }
            threads = data;
            migrateThreads(); // 新フィールドを補完
            sortThreadsByUpdated();
            saveThreads(threads);
            currentThreadId = null;
            showListView();
            alert("インポートに成功しました。");
        } catch (e) {
            console.warn("import failed:", e);
            alert("JSONの解析に失敗しました。形式が正しいか確認してください。");
        }
    });

    btnClear.addEventListener("click", function() {
        if (!confirm("本当に全データを削除しますか?(取り消しできません)")) {
            return;
        }
        try {
            localStorage.removeItem(STORAGE_KEY);
        } catch (e) {
            console.warn("clear failed:", e);
        }
        threads = [];
        currentThreadId = null;
        renderThreadList();
        showListView();
        alert("全データを削除しました。");
    });

    // --------------------
    // イベント: 設定
    // --------------------
    btnSaveSettings.addEventListener("click", function() {
        const autoName = !!optAutoname.checked;
        const lastName = (settingLastName.value || "").toString().trim() || "名無し";
        settings.autoName = autoName;
        settings.lastName = lastName;
        saveSettings(settings);
        applySettingsToUI();
        alert("設定を保存しました。");
    });

    // --------------------
    // イベント: レス番クリック(>>アンカー挿入)
    // --------------------
    postsEl.addEventListener("click", function(e) {
        const target = e.target;
        if (target && target.classList.contains("post-no-link")) {
            e.preventDefault();
            const no = target.getAttribute("data-no");
            if (!no) return;
            insertAnchorToReply(">>" + no + "\n");
        }
    });

    // --------------------
    // ビュー切替
    // --------------------
    function showListView() {
        viewList.classList.remove("hidden");
        viewThread.classList.add("hidden");
        renderThreadList();
    }

    function showThreadView() {
        viewList.classList.add("hidden");
        viewThread.classList.remove("hidden");
    }

    // --------------------
    // レンダリング: 一覧
    // --------------------
    function renderThreadList() {
        updateStats();

        if (!threads.length) {
            threadListEl.innerHTML = "<p>まだスレッドはありません。</p>";
            return;
        }

        const query = (searchInput.value || "").toString().trim();
        const filterCategory = (categoryFilter.value || "").toString();
        const favOnly = !!favOnlyCheckbox.checked;

        let list = threads.slice();

        // 検索
        if (query) {
            list = list.filter(function(t) {
                const q = query;
                if (t.title && t.title.indexOf(q) !== -1) return true;
                if (Array.isArray(t.posts)) {
                    return t.posts.some(function(p) {
                        return p.body && p.body.indexOf(q) !== -1;
                    });
                }
                return false;
            });
        }

        // カテゴリフィルタ
        if (filterCategory) {
            list = list.filter(function(t) {
                return (t.category || "雑談") === filterCategory;
            });
        }

        // お気に入りのみ
        if (favOnly) {
            list = list.filter(function(t) {
                return !!t.favorite;
            });
        }

        // 並び替え
        if (currentSort === "created") {
            list.sort(function(a, b) {
                if (a.createdAt < b.createdAt) return 1;
                if (a.createdAt > b.createdAt) return -1;
                return 0;
            });
        } else if (currentSort === "id") {
            list.sort(function(a, b) {
                return b.id - a.id; // 新しい番号が上
            });
        } else {
            // 更新順は threads 自体を sortThreadsByUpdated で管理しているのでそのまま
            // ただしフィルタ・検索後も順序を維持するだけ
            list.sort(function(a, b) {
                if (a.updatedAt < b.updatedAt) return 1;
                if (a.updatedAt > b.updatedAt) return -1;
                return 0;
            });
        }

        if (!list.length) {
            threadListEl.innerHTML = "<p>条件に一致するスレッドはありません。</p>";
            return;
        }

        let html = "";
        list.forEach(function(t) {
            const cat = t.category || "雑談";
            const fav = !!t.favorite;
            const favClass = fav ? "fav-true" : "fav-false";
            const favSymbol = fav ? "★" : "☆";

            html += `
                <div class="thread-item">
                    <span class="fav-toggle ${favClass}" data-thread-id="${t.id}" title="お気に入り切り替え">${favSymbol}</span>
                    <div class="thread-main">
                        <span class="thread-title">
                            <span class="thread-category">[${escapeHtml(cat)}]</span>
                            <a data-thread-id="${t.id}" class="thread-link">
                                ${escapeHtml(t.title)}
                            </a>
                        </span><br>
                        <span class="meta">
                            No.${t.id} / 作成:${escapeHtml(t.createdAt)} /
                            最終更新:${escapeHtml(t.updatedAt)} /
                            レス:${t.posts ? t.posts.length : 0}
                        </span>
                    </div>
                </div>
            `;
        });
        threadListEl.innerHTML = html;

        // スレッドリンクイベント
        const links = threadListEl.querySelectorAll(".thread-link");
        links.forEach(function(link) {
            link.addEventListener("click", function() {
                const id = parseInt(this.getAttribute("data-thread-id"), 10);
                openThread(id);
            });
        });

        // お気に入り切り替えイベント
        const favToggles = threadListEl.querySelectorAll(".fav-toggle");
        favToggles.forEach(function(btn) {
            btn.addEventListener("click", function() {
                const id = parseInt(this.getAttribute("data-thread-id"), 10);
                toggleFavorite(id, this);
            });
        });
    }

    // --------------------
    // レンダリング: 個別スレ
    // --------------------
    function renderThread(thread) {
        currentThreadId = thread.id;

        const cat = thread.category || "雑談";
        threadTitleEl.textContent = "[" + cat + "] " + thread.title;
        threadMetaEl.textContent =
            "スレ番号:" + thread.id +
            " 作成:" + thread.createdAt +
            " 最終更新:" + thread.updatedAt +
            " レス数:" + (thread.posts ? thread.posts.length : 0);

        if (!thread.posts || !thread.posts.length) {
            postsEl.innerHTML = "<p>まだレスはありません。</p>";
            return;
        }

        let html = "";
        thread.posts.forEach(function(p) {
            const uid = p.uid || generateUid(thread.id, p.id, p.name, p.body, p.createdAt);
            html += `
                <div class="post">
                    <div class="post-header">
                        <a href="#" class="post-no-link" data-no="${p.id}">No.${p.id}</a>
                        名前:${escapeHtml(p.name)} 
                        投稿日:${escapeHtml(p.createdAt)} 
                        ID:<span class="post-id">${escapeHtml(uid)}</span>
                    </div>
                    <div class="post-body">
                        ${escapeHtml(p.body).replace(/\n/g, "<br>")}
                    </div>
                </div>
            `;
        });
        postsEl.innerHTML = html;

        // 自動名前反映
        applyAutoNameToReply();
    }

    function openThread(id) {
        const thread = threads.find(t => t.id === id);
        if (!thread) {
            alert("スレッドが見つかりません。");
            return;
        }
        renderThread(thread);
        showThreadView();
    }

    // --------------------
    // お気に入り切り替え
    // --------------------
    function toggleFavorite(threadId, element) {
        const thread = threads.find(t => t.id === threadId);
        if (!thread) return;
        thread.favorite = !thread.favorite;
        saveThreads(threads);

        const fav = !!thread.favorite;
        element.textContent = fav ? "★" : "☆";
        element.classList.toggle("fav-true", fav);
        element.classList.toggle("fav-false", !fav);
    }

    // --------------------
    // ストレージ操作
    // --------------------
    function loadThreads() {
        try {
            const raw = localStorage.getItem(STORAGE_KEY);
            if (!raw) return [];
            const data = JSON.parse(raw);
            if (!Array.isArray(data)) return [];
            return data;
        } catch (e) {
            console.warn("failed to load threads:", e);
            return [];
        }
    }

    function saveThreads(data) {
        try {
            localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
        } catch (e) {
            console.warn("failed to save threads:", e);
            alert("保存に失敗しました。localStorage容量オーバーの可能性があります。");
        }
    }

    function loadSettings() {
        try {
            const raw = localStorage.getItem(SETTINGS_KEY);
            if (!raw) {
                return {
                    autoName: false,
                    lastName: "名無し"
                };
            }
            const data = JSON.parse(raw);
            if (!data || typeof data !== "object") {
                return {
                    autoName: false,
                    lastName: "名無し"
                };
            }
            if (typeof data.autoName !== "boolean") {
                data.autoName = false;
            }
            if (typeof data.lastName !== "string") {
                data.lastName = "名無し";
            }
            return data;
        } catch (e) {
            console.warn("failed to load settings:", e);
            return {
                autoName: false,
                lastName: "名無し"
            };
        }
    }

    function saveSettings(data) {
        try {
            localStorage.setItem(SETTINGS_KEY, JSON.stringify(data));
        } catch (e) {
            console.warn("failed to save settings:", e);
        }
    }

    // --------------------
    // 統計更新
    // --------------------
    function updateStats() {
        statThreadsEl.textContent = threads.length;
        let totalPosts = 0;
        threads.forEach(function(t) {
            if (Array.isArray(t.posts)) {
                totalPosts += t.posts.length;
            }
        });
        statPostsEl.textContent = totalPosts;
    }

    // --------------------
    // ID・日付・エスケープ
    // --------------------
    function getNewThreadId() {
        if (!threads.length) return 1;
        const ids = threads.map(function(t) { return t.id; });
        return Math.max.apply(null, ids) + 1;
    }

    function getNewPostId(thread) {
        if (!thread.posts || !thread.posts.length) return 1;
        const ids = thread.posts.map(function(p) { return p.id; });
        return Math.max.apply(null, ids) + 1;
    }

    function getNowStr() {
        const d = new Date();
        const y = d.getFullYear();
        const m = ("0" + (d.getMonth() + 1)).slice(-2);
        const day = ("0" + d.getDate()).slice(-2);
        const h = ("0" + d.getHours()).slice(-2);
        const min = ("0" + d.getMinutes()).slice(-2);
        const s = ("0" + d.getSeconds()).slice(-2);
        return y + "/" + m + "/" + day + " " + h + ":" + min + ":" + s;
    }

    function escapeHtml(str) {
        return String(str)
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&#39;");
    }

    // 簡易ハッシュでUIDを生成(8桁16進)
    function generateUid(threadId, postId, name, body, createdAt) {
        const src = String(threadId) + ":" + String(postId) + ":" +
                    String(name) + ":" + String(body) + ":" + String(createdAt);
        let hash = 0;
        for (let i = 0; i < src.length; i++) {
            hash = ((hash << 5) - hash) + src.charCodeAt(i);
            hash |= 0; // 32bit
        }
        // 符号を外す
        if (hash < 0) {
            hash = ~hash + 1;
        }
        let hex = hash.toString(16);
        if (hex.length < 8) {
            hex = ("00000000" + hex).slice(-8);
        } else if (hex.length > 8) {
            hex = hex.slice(-8);
        }
        return hex;
    }

    // --------------------
    // ソート
    // --------------------
    function sortThreadsByUpdated() {
        threads.sort(function(a, b) {
            if (a.updatedAt < b.updatedAt) return 1;
            if (a.updatedAt > b.updatedAt) return -1;
            return 0;
        });
    }

    // --------------------
    // 設定適用
    // --------------------
    function applySettingsToUI() {
        optAutoname.checked = !!settings.autoName;
        settingLastName.value = settings.lastName || "名無し";

        if (settings.autoName) {
            if (newNameInput) newNameInput.value = settings.lastName || "名無し";
            if (replyNameInput) replyNameInput.value = settings.lastName || "名無し";
        }
    }

    function applyAutoNameToReply() {
        if (settings.autoName) {
            replyNameInput.value = settings.lastName || "名無し";
        }
    }

    function updateLastNameSetting(name) {
        settings.lastName = name || "名無し";
        if (settings.autoName) {
            // UIへ即時反映
            settingLastName.value = settings.lastName;
            if (newNameInput) newNameInput.value = settings.lastName;
            if (replyNameInput) replyNameInput.value = settings.lastName;
        }
        saveSettings(settings);
    }

    // --------------------
    // レス番アンカー挿入
    // --------------------
    function insertAnchorToReply(text) {
        replyBodyTextarea.focus();
        const start = replyBodyTextarea.selectionStart;
        const end = replyBodyTextarea.selectionEnd;
        const value = replyBodyTextarea.value;
        replyBodyTextarea.value = value.slice(0, start) + text + value.slice(end);
        // キャレット位置を挿入したテキストの後ろに
        const pos = start + text.length;
        replyBodyTextarea.selectionStart = replyBodyTextarea.selectionEnd = pos;
    }

    // --------------------
    // 既存データのマイグレーション
    // --------------------
    function migrateThreads() {
        threads.forEach(function(t) {
            if (!t.category) {
                t.category = "雑談";
            }
            if (typeof t.favorite !== "boolean") {
                t.favorite = false;
            }
            if (!Array.isArray(t.posts)) {
                t.posts = [];
            }
            t.posts.forEach(function(p) {
                if (!p.uid) {
                    p.uid = generateUid(t.id, p.id, p.name || "名無し", p.body || "", p.createdAt || "");
                }
            });
        });
        saveThreads(threads);
    }
})();
</script>
</body>
</html>

投稿者: chosuke

趣味はゲームやアニメや漫画などです

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です