LinuxDo Star

为 linux.do 添加帖子和评论收藏功能,支持收藏夹、管理面板、导入导出和 GitHub Gist 同步

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         LinuxDo Star
// @namespace    https://greasyfork.org/scripts/linuxdo-star-tampermonkey
// @version      1.2.0
// @description  为 linux.do 添加帖子和评论收藏功能,支持收藏夹、管理面板、导入导出和 GitHub Gist 同步
// @author       FULANmee; based on codedogQBY/LinuxDoStar
// @license      MIT
// @match        https://linux.do/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @connect      api.github.com
// ==/UserScript==

(function () {
  'use strict';

  const STORAGE_KEY = 'linuxdo_stars';
  const SYNC_CONFIG_KEY = 'linuxdo_sync_config';
  const UI_CONFIG_KEY = 'linuxdo_ui_config';
  const GIST_FILENAME = 'linuxdo-stars.json';
  const GIST_DESCRIPTION = 'LinuxDo Star Collector - Sync Data (do not delete)';

  const STAR_CLASS = 'ldsm-star-btn';
  const STAR_ACTIVE_CLASS = 'ldsm-star-active';
  const STAR_SVG = `<svg class="ldsm-star-icon fa d-icon svg-icon svg-string" width="1em" height="1em" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
    <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
  </svg><span aria-hidden="true">\u200B</span>`;

  const ICONS = ['📁', '📚', '💡', '🔥', '💼', '🎯', '🏷️', '📌', '🗂️', '💻', '⭐', '❤️', '🎨', '🔖', '📝', '🧪', '🎓', '🌐', '🛠️', '📊'];
  const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
  const ALLOWED_SYNC_STATUS = new Set(['disconnected', 'connected', 'syncing', 'synced', 'error']);
  const MAX_IMPORT_BYTES = 5 * 1024 * 1024;
  const MAX_COLLECTIONS = 300;
  const MAX_BOOKMARKS = 20000;
  const MAX_POSTS_PER_TOPIC = 800;
  const MAX_TAGS = 40;
  const MAX_GITHUB_RESPONSE_BYTES = 10 * 1024 * 1024;
  const ORDER_STEP = 1000;
  const ALL_ORDER_KEY = '__all__';
  const DRAG_MIME = 'application/x-linuxdo-star';
  const FAB_SIZE = 44;
  const FAB_MARGIN = 12;
  const FAB_DRAG_THRESHOLD = 5;
  const FAB_DOCK_THRESHOLD = 16;
  const FAB_DOCK_VISIBLE = 28;
  const FAB_DEFAULT_RIGHT = 28;
  const MANAGER_DEFAULT_WIDTH = 980;
  const MANAGER_MIN_WIDTH = 640;
  const MANAGER_MAX_WIDTH = 1280;
  const MANAGER_VIEWPORT_GAP = 24;

  const managerState = {
    store: null,
    currentView: 'all',
    sort: 'custom',
    query: '',
    expanded: new Set(),
    batchMode: false,
    selected: new Set(),
    panelOpen: false,
    dragging: null,
    ignoreNextClick: false,
  };

  let activePopup = null;
  let managerReady = false;
  let injectScheduled = false;
  let syncDebounceTimer = null;
  let routeObserverStarted = false;
  let postsObserverStarted = false;
  let lastUrl = location.href;
  let menuCommandsRegistered = false;
  let fabDragState = null;
  let suppressFabClick = false;
  let managerResizeState = null;
  let fabViewportObserverStarted = false;
  let lastFabViewportWidth = 0;
  let lastFabViewportHeight = 0;

  // ========================= Utilities =========================
  function nowIso() {
    return new Date().toISOString();
  }

  function h(value) {
    if (value === null || value === undefined) return '';
    const div = document.createElement('div');
    div.textContent = String(value);
    return div.innerHTML;
  }

  function attr(value) {
    return h(value).replace(/`/g, '&#96;');
  }

  function debounce(fn, ms) {
    let timer = null;
    return (...args) => {
      clearTimeout(timer);
      timer = setTimeout(() => fn(...args), ms);
    };
  }

  function makeId(prefix) {
    return prefix + '_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
  }

  function makeDefaultCollection() {
    return {
      id: 'default',
      name: '默认收藏夹',
      icon: '⭐',
      color: '#eab308',
      createdAt: nowIso(),
      order: 0,
    };
  }

  function isObject(value) {
    return !!value && typeof value === 'object' && !Array.isArray(value);
  }

  function safeEntries(value) {
    if (!isObject(value)) return [];
    return Object.entries(value).filter(([key]) => !DANGEROUS_KEYS.has(key));
  }

  function safeString(value, fallback = '', maxLength = 500) {
    if (value === null || value === undefined) return fallback;
    return String(value).slice(0, maxLength);
  }

  function safeInt(value, fallback = 0) {
    const number = Number.parseInt(value, 10);
    return Number.isFinite(number) && number >= 0 ? number : fallback;
  }

  function safeNumber(value, fallback = 0) {
    const number = Number(value);
    return Number.isFinite(number) && number >= 0 ? number : fallback;
  }

  function clamp(value, min, max) {
    if (max < min) return min;
    return Math.min(Math.max(value, min), max);
  }

  function safeBool(value, fallback = false) {
    return typeof value === 'boolean' ? value : fallback;
  }

  function safeIso(value) {
    const text = safeString(value, '', 64);
    if (!text) return '';
    const time = new Date(text).getTime();
    return Number.isFinite(time) ? text : '';
  }

  function safeTags(value) {
    if (!Array.isArray(value)) return [];
    const tags = [];
    for (const tag of value) {
      const text = safeString(tag, '', 60).trim();
      if (text && !tags.includes(text)) tags.push(text);
      if (tags.length >= MAX_TAGS) break;
    }
    return tags;
  }

  function safeId(value, fallback = '') {
    const text = safeString(value, fallback, 96);
    if (!text || DANGEROUS_KEYS.has(text)) return fallback;
    return /^[A-Za-z0-9_-]+$/.test(text) ? text : fallback;
  }

  function safeCollectionId(value) {
    return safeId(value, 'default') || 'default';
  }

  function safeOrderByCollection(value) {
    const result = {};
    for (const [key, order] of safeEntries(value)) {
      const id = safeCollectionId(key);
      if (!id) continue;
      result[id] = safeNumber(order, 0);
    }
    return result;
  }

  function safeTopicKey(key, bookmark) {
    if (!DANGEROUS_KEYS.has(key) && /^topic_[1-9]\d*$/.test(key)) return key;
    const topicId = safeInt(bookmark?.topicId, 0);
    return topicId > 0 ? `topic_${topicId}` : '';
  }

  function safePostKey(key, post) {
    if (!DANGEROUS_KEYS.has(key) && /^post_[1-9]\d*$/.test(key)) return key;
    const postNumber = safeInt(post?.postNumber, 0);
    return postNumber > 0 ? `post_${postNumber}` : '';
  }

  function safeLinuxDoUrl(value, fallback = '') {
    const text = safeString(value, fallback, 2048);
    if (!text) return fallback;
    try {
      const url = new URL(text, 'https://linux.do');
      if (url.protocol !== 'https:' || url.hostname !== 'linux.do') return fallback;
      return url.href;
    } catch {
      return fallback;
    }
  }

  function safeGithubApiUrl(value) {
    try {
      const url = new URL(value);
      if (url.protocol !== 'https:' || url.hostname !== 'api.github.com') return '';
      if (url.pathname !== '/user' && url.pathname !== '/gists' && !url.pathname.startsWith('/gists/')) return '';
      return url.href;
    } catch {
      return '';
    }
  }

  function normalizeSyncConfig(input) {
    const raw = isObject(input) ? input : {};
    const status = safeString(raw.status, 'disconnected', 24);
    return {
      token: safeString(raw.token, '', 256),
      gistId: safeString(raw.gistId, '', 96).replace(/[^A-Za-z0-9]/g, ''),
      lastSyncAt: safeIso(raw.lastSyncAt),
      autoSync: safeBool(raw.autoSync, true),
      status: ALLOWED_SYNC_STATUS.has(status) ? status : 'disconnected',
      username: safeString(raw.username, '', 120),
      lastError: safeString(raw.lastError, '', 500),
    };
  }

  function normalizeUiConfig(input) {
    const raw = isObject(input) ? input : {};
    const side = raw.fabSide === 'left' ? 'left' : 'right';
    const dock = raw.fabDock === 'left' || raw.fabDock === 'right' || raw.fabDock === 'free' ? raw.fabDock : 'free';
    return {
      showFab: safeBool(raw.showFab, true),
      fabDock: dock,
      fabSide: side,
      fabX: safeNumber(raw.fabX, -1),
      fabY: safeNumber(raw.fabY, -1),
      managerWidth: safeNumber(raw.managerWidth, -1),
    };
  }

  function sanitizeCollection(input, key, order) {
    const fallbackId = key === 'default' ? 'default' : safeId(key, makeId('col'));
    const id = fallbackId;
    if (id !== 'default' && input?._deleted) {
      return {
        id,
        _deleted: true,
        _deletedAt: safeIso(input._deletedAt) || nowIso(),
      };
    }
    return {
      id,
      name: safeString(input?.name, id === 'default' ? '默认收藏夹' : '未命名收藏夹', 120).trim() || '未命名收藏夹',
      icon: safeString(input?.icon, id === 'default' ? '⭐' : '📁', 8),
      color: safeString(input?.color, id === 'default' ? '#eab308' : '#71717a', 32),
      createdAt: safeIso(input?.createdAt) || nowIso(),
      updatedAt: safeIso(input?.updatedAt),
      order: safeInt(input?.order, order),
    };
  }

  function sanitizePost(input, key, topicId) {
    const postNumber = safeInt(input?.postNumber, Number(key.replace('post_', '')) || 0);
    const fallbackUrl = topicId && postNumber ? `https://linux.do/t/topic/${topicId}/${postNumber}` : '';
    if (input?._deleted) {
      return {
        _deleted: true,
        _deletedAt: safeIso(input._deletedAt) || nowIso(),
        postNumber,
      };
    }
    return {
      postNumber,
      postUrl: safeLinuxDoUrl(input?.postUrl, fallbackUrl),
      author: safeString(input?.author, '', 120),
      excerpt: safeString(input?.excerpt, '', 1000),
      starredAt: safeIso(input?.starredAt) || nowIso(),
      updatedAt: safeIso(input?.updatedAt),
      collectionId: safeCollectionId(input?.collectionId),
      tags: safeTags(input?.tags),
      note: safeString(input?.note, '', 5000),
    };
  }

  function sanitizeBookmark(input, key) {
    const topicId = safeInt(input?.topicId, Number(key.replace('topic_', '')) || 0);
    if (input?._deleted) {
      return {
        _deleted: true,
        _deletedAt: safeIso(input._deletedAt) || nowIso(),
        topicId,
      };
    }

    const topicUrl = safeLinuxDoUrl(input?.topicUrl, topicId ? `https://linux.do/t/topic/${topicId}` : '');
    const bookmark = {
      topicId,
      topicTitle: safeString(input?.topicTitle, '未知标题', 500),
      topicUrl,
      category: safeString(input?.category, '', 120),
      starredAt: safeIso(input?.starredAt) || nowIso(),
      updatedAt: safeIso(input?.updatedAt),
      starred: safeBool(input?.starred, true),
      collectionId: safeCollectionId(input?.collectionId),
      tags: safeTags(input?.tags),
      note: safeString(input?.note, '', 5000),
      orderByCollection: safeOrderByCollection(input?.orderByCollection),
      posts: {},
    };

    let postCount = 0;
    for (const [postKey, postInput] of safeEntries(input?.posts)) {
      if (postCount >= MAX_POSTS_PER_TOPIC) break;
      if (!isObject(postInput)) continue;
      const safeKey = safePostKey(postKey, postInput);
      if (!safeKey) continue;
      bookmark.posts[safeKey] = sanitizePost(postInput, safeKey, topicId);
      postCount += 1;
    }

    return bookmark;
  }

  function normalizeStore(input) {
    let store = input;
    if (typeof store === 'string') {
      try {
        store = JSON.parse(store);
      } catch {
        store = {};
      }
    }
    if (!isObject(store)) store = {};

    if (!store.collections && !store.bookmarks) {
      const oldKeys = safeEntries(store).map(([key]) => key).filter(key => key.startsWith('topic_'));
      if (oldKeys.length) {
        const migrated = { collections: { default: makeDefaultCollection() }, bookmarks: {} };
        for (const key of oldKeys) {
          migrated.bookmarks[key] = store[key];
        }
        store = migrated;
      }
    }

    const normalized = { collections: {}, bookmarks: {} };
    let collectionOrder = 0;
    for (const [key, collection] of safeEntries(store.collections)) {
      if (collectionOrder >= MAX_COLLECTIONS) break;
      if (!isObject(collection)) continue;
      const safeKey = key === 'default' ? 'default' : safeId(key, '');
      if (!safeKey) continue;
      normalized.collections[safeKey] = sanitizeCollection(collection, safeKey, collectionOrder);
      collectionOrder += 1;
    }
    if (!normalized.collections.default) normalized.collections.default = makeDefaultCollection();

    let bookmarkCount = 0;
    for (const [key, bookmark] of safeEntries(store.bookmarks)) {
      if (bookmarkCount >= MAX_BOOKMARKS) break;
      if (!isObject(bookmark)) continue;
      const safeKey = safeTopicKey(key, bookmark);
      if (!safeKey) continue;
      normalized.bookmarks[safeKey] = sanitizeBookmark(bookmark, safeKey);
      bookmarkCount += 1;
    }

    const collectionIds = new Set(aliveCollections(normalized).map(([id]) => id));
    for (const bookmark of Object.values(normalized.bookmarks)) {
      if (!bookmark || bookmark._deleted) continue;
      if (!collectionIds.has(bookmark.collectionId)) bookmark.collectionId = 'default';
      for (const post of Object.values(bookmark.posts || {})) {
        if (!post || post._deleted) continue;
        post.collectionId = bookmark.collectionId || 'default';
      }
    }

    return normalized;
  }

  function clone(data) {
    return JSON.parse(JSON.stringify(data));
  }

  function collectionSortValue(collection) {
    if (collection?.id === 'default') return -1;
    return safeNumber(collection?.order, Number.MAX_SAFE_INTEGER);
  }

  function sortedCollections(store) {
    return aliveCollections(store)
      .map(([, collection]) => collection)
      .sort((a, b) => {
        if (a.id === 'default') return -1;
        if (b.id === 'default') return 1;
        const orderDiff = collectionSortValue(a) - collectionSortValue(b);
        if (orderDiff) return orderDiff;
        return (a.name || '').localeCompare(b.name || '', 'zh-CN');
      });
  }

  function collectionItemOrder(bookmark, collectionId) {
    const id = safeCollectionId(collectionId);
    const explicit = bookmark?.orderByCollection?.[id];
    if (Number.isFinite(explicit)) return explicit;
    const time = new Date(bookmark?.starredAt || bookmark?.updatedAt || bookmark?.createdAt || 0).getTime();
    return Number.isFinite(time) ? Number.MAX_SAFE_INTEGER - time : Number.MAX_SAFE_INTEGER;
  }

  function sortBookmarksByCustomOrder(items, collectionId) {
    return items.sort((a, b) => {
      const diff = collectionItemOrder(a, collectionId) - collectionItemOrder(b, collectionId);
      if (diff) return diff;
      return new Date(b.starredAt || b.updatedAt || 0) - new Date(a.starredAt || a.updatedAt || 0);
    });
  }

  function collectionBookmarks(store, collectionId) {
    const id = safeCollectionId(collectionId);
    return aliveBookmarks(store)
      .filter(([, bookmark]) => (bookmark.collectionId || 'default') === id)
      .map(([key, bookmark]) => ({ key, ...bookmark }));
  }

  function viewBookmarks(store, viewId) {
    if (viewId === 'all' || viewId === ALL_ORDER_KEY) {
      return aliveBookmarks(store).map(([key, bookmark]) => ({ key, ...bookmark }));
    }
    return collectionBookmarks(store, viewId);
  }

  function orderScopeId(store, viewId) {
    return viewId === 'all' || viewId === ALL_ORDER_KEY ? ALL_ORDER_KEY : liveCollectionId(store, viewId);
  }

  function assignCollectionOrders(store, collectionIds) {
    const ids = collectionIds.filter(id => id && id !== 'default' && store.collections[id] && !store.collections[id]._deleted);
    const time = nowIso();
    store.collections.default = { ...makeDefaultCollection(), ...(store.collections.default || {}), id: 'default', order: 0, updatedAt: time };
    ids.forEach((id, index) => {
      store.collections[id].order = (index + 1) * ORDER_STEP;
      store.collections[id].updatedAt = time;
    });
  }

  function setTopicCollectionOrder(store, topicKey, collectionId, order) {
    const topic = store.bookmarks?.[topicKey];
    if (!topic || topic._deleted) return;
    const id = safeCollectionId(collectionId);
    if (!topic.orderByCollection) topic.orderByCollection = {};
    topic.orderByCollection[id] = safeNumber(order, 0);
  }

  function reindexCollectionItems(store, collectionId, topicKeys, time = nowIso()) {
    const id = safeCollectionId(collectionId);
    topicKeys.forEach((topicKey, index) => {
      setTopicCollectionOrder(store, topicKey, id, (index + 1) * ORDER_STEP);
      if (store.bookmarks?.[topicKey] && !store.bookmarks[topicKey]._deleted) {
        store.bookmarks[topicKey].updatedAt = time;
      }
    });
  }

  function removeTopicCollectionOrder(topic, collectionId) {
    const id = safeCollectionId(collectionId);
    if (topic?.orderByCollection) delete topic.orderByCollection[id];
  }

  function placeTopicAtCollectionTop(store, topicKey, collectionId, time = nowIso()) {
    const id = safeCollectionId(collectionId);
    const current = sortBookmarksByCustomOrder(viewBookmarks(store, id), id).filter(item => item.key !== topicKey);
    const firstOrder = current.length ? collectionItemOrder(current[0], id) : ORDER_STEP * 2;
    if (firstOrder > 1) {
      setTopicCollectionOrder(store, topicKey, id, firstOrder / 2);
      if (store.bookmarks?.[topicKey] && !store.bookmarks[topicKey]._deleted) {
        store.bookmarks[topicKey].updatedAt = time;
      }
      return;
    }
    reindexCollectionItems(store, id, [topicKey, ...current.map(item => item.key)], time);
  }

  function alivePosts(bookmark) {
    return Object.entries(bookmark?.posts || {})
      .filter(([, post]) => post && !post._deleted);
  }

  function aliveBookmarks(store) {
    return Object.entries(store?.bookmarks || {})
      .filter(([, bookmark]) => bookmark && !bookmark._deleted);
  }

  function aliveCollections(store) {
    return Object.entries(store?.collections || {})
      .filter(([, collection]) => collection && !collection._deleted);
  }

  function liveCollectionId(store, collectionId) {
    const id = safeCollectionId(collectionId);
    const collection = store?.collections?.[id];
    return collection && !collection._deleted ? id : 'default';
  }

  function countStore(store) {
    let topics = 0;
    let posts = 0;
    for (const [, bookmark] of aliveBookmarks(store)) {
      if (bookmark.starred) topics++;
      posts += alivePosts(bookmark).length;
    }
    return { topics, posts };
  }

  function formatTime(value) {
    if (!value) return '';
    const time = new Date(value).getTime();
    if (!Number.isFinite(time)) return '';
    const diff = Date.now() - time;
    if (diff < 60_000) return '刚刚';
    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}分钟前`;
    if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}小时前`;
    if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}天前`;
    const date = new Date(value);
    return `${date.getMonth() + 1}/${date.getDate()}`;
  }

  function openExternal(url) {
    const safeUrl = safeLinuxDoUrl(url);
    if (!safeUrl) {
      showToast('链接已被拦截', '⚠');
      return;
    }
    const opened = window.open(safeUrl, '_blank', 'noopener,noreferrer');
    if (opened) opened.opener = null;
  }

  function downloadText(content, filename, type = 'application/json') {
    const url = URL.createObjectURL(new Blob([content], { type }));
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 500);
  }

  function showToast(message, icon = '⭐') {
    document.querySelector('.ldsm-toast')?.remove();
    const toast = document.createElement('div');
    toast.className = 'ldsm-toast';
    toast.setAttribute('role', 'status');
    toast.setAttribute('aria-live', 'polite');
    toast.innerHTML = `<span class="ldsm-toast-icon">${h(icon)}</span><span>${h(message)}</span>`;
    document.body.appendChild(toast);
    requestAnimationFrame(() => toast.classList.add('ldsm-toast-visible'));
    setTimeout(() => {
      toast.classList.remove('ldsm-toast-visible');
      setTimeout(() => toast.remove(), 260);
    }, 1900);
  }

  function setDragPayload(event, payload) {
    managerState.dragging = payload;
    event.dataTransfer.effectAllowed = 'move';
    event.dataTransfer.setData(DRAG_MIME, JSON.stringify(payload));
    event.dataTransfer.setData('text/plain', payload.topicKey || payload.collectionId || '');
  }

  function getDragPayload(event) {
    if (managerState.dragging) return managerState.dragging;
    try {
      const raw = event.dataTransfer.getData(DRAG_MIME);
      return raw ? JSON.parse(raw) : null;
    } catch {
      return null;
    }
  }

  function getDropPosition(event, element) {
    const rect = element.getBoundingClientRect();
    return event.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
  }

  function clearDragMarks(root = document) {
    root.querySelectorAll('.ldsm-dragging, .ldsm-drag-over, .ldsm-drag-before, .ldsm-drag-after')
      .forEach(element => element.classList.remove('ldsm-dragging', 'ldsm-drag-over', 'ldsm-drag-before', 'ldsm-drag-after'));
  }

  function markDragTarget(element, position) {
    const root = element.parentElement || document;
    root.querySelectorAll('.ldsm-drag-over, .ldsm-drag-before, .ldsm-drag-after')
      .forEach(item => item.classList.remove('ldsm-drag-over', 'ldsm-drag-before', 'ldsm-drag-after'));
    element.classList.add('ldsm-drag-over', position === 'before' ? 'ldsm-drag-before' : 'ldsm-drag-after');
  }

  function reorderIds(ids, movingId, targetId, position) {
    const next = ids.filter(id => id !== movingId);
    let index = next.indexOf(targetId);
    if (index < 0) index = next.length;
    else if (position === 'after') index += 1;
    next.splice(index, 0, movingId);
    return next;
  }

  function canReorderCurrentView() {
    return managerState.sort === 'custom' && !managerState.query && !managerState.batchMode;
  }

  function suppressNextClick() {
    managerState.ignoreNextClick = true;
    setTimeout(() => {
      managerState.ignoreNextClick = false;
    }, 120);
  }

  function getUiConfig() {
    return normalizeUiConfig(GM_getValue(UI_CONFIG_KEY, {}));
  }

  function saveUiConfig(updates) {
    const next = normalizeUiConfig({ ...getUiConfig(), ...updates });
    GM_setValue(UI_CONFIG_KEY, next);
    applyUiConfig(next);
    return next;
  }

  function fabViewportWidth() {
    return document.documentElement?.clientWidth || window.innerWidth;
  }

  function fabViewportHeight() {
    return document.documentElement?.clientHeight || window.innerHeight;
  }

  function fabMaxX() {
    return Math.max(0, fabViewportWidth() - FAB_SIZE);
  }

  function fabMaxY() {
    return Math.max(FAB_MARGIN, fabViewportHeight() - FAB_SIZE - FAB_MARGIN);
  }

  function defaultFabX() {
    return clamp(fabViewportWidth() - FAB_SIZE - FAB_DEFAULT_RIGHT, 0, fabMaxX());
  }

  function defaultFabY() {
    const bottom = fabViewportWidth() <= 560 ? 74 : 82;
    return clamp(fabViewportHeight() - FAB_SIZE - bottom, FAB_MARGIN, fabMaxY());
  }

  function normalizedFabX(config) {
    const x = safeNumber(config?.fabX, -1);
    return clamp(x >= 0 ? x : defaultFabX(), 0, fabMaxX());
  }

  function normalizedFabY(config) {
    const y = safeNumber(config?.fabY, -1);
    return clamp(y >= 0 ? y : defaultFabY(), FAB_MARGIN, fabMaxY());
  }

  function placeFabAt(fab, x, y) {
    fab.style.left = `${clamp(x, 0, fabMaxX())}px`;
    fab.style.top = `${clamp(y, FAB_MARGIN, fabMaxY())}px`;
    fab.style.right = 'auto';
    fab.style.bottom = 'auto';
  }

  function applyFabPosition(fab, config = getUiConfig()) {
    const dock = config.fabDock === 'left' || config.fabDock === 'right' ? config.fabDock : 'free';
    const y = normalizedFabY(config);
    if (dock === 'left') {
      placeFabAt(fab, 0, y);
    } else if (dock === 'right') {
      placeFabAt(fab, fabMaxX(), y);
    } else {
      placeFabAt(fab, normalizedFabX(config), y);
    }
    fab.classList.toggle('ldsm-fab-docked-left', dock === 'left');
    fab.classList.toggle('ldsm-fab-docked-right', dock === 'right');
  }

  function refreshFabPosition() {
    if (fabDragState) return;
    const fab = $('#ldsmFab');
    if (fab) applyFabPosition(fab, getUiConfig());
  }

  function scheduleFabPositionRefresh(delay = 0) {
    setTimeout(() => requestAnimationFrame(refreshFabPosition), delay);
  }

  function stabilizeFabPosition() {
    [0, 50, 150, 350, 800, 1500, 3000].forEach(scheduleFabPositionRefresh);
  }

  function refreshFabPositionIfViewportChanged() {
    const width = fabViewportWidth();
    const height = fabViewportHeight();
    if (width === lastFabViewportWidth && height === lastFabViewportHeight) return;
    lastFabViewportWidth = width;
    lastFabViewportHeight = height;
    refreshFabPosition();
  }

  function startFabViewportWatcher() {
    if (fabViewportObserverStarted) return;
    fabViewportObserverStarted = true;
    lastFabViewportWidth = fabViewportWidth();
    lastFabViewportHeight = fabViewportHeight();

    if (typeof ResizeObserver === 'function') {
      const observer = new ResizeObserver(() => {
        requestAnimationFrame(refreshFabPositionIfViewportChanged);
      });
      observer.observe(document.documentElement);
      if (document.body) observer.observe(document.body);
    }

    window.visualViewport?.addEventListener?.('resize', refreshFabPositionIfViewportChanged);
    window.addEventListener('load', stabilizeFabPosition, { once: true });
    stabilizeFabPosition();
  }

  function canResizeManager() {
    return window.innerWidth > 760;
  }

  function managerMaxWidth() {
    return Math.max(320, Math.min(MANAGER_MAX_WIDTH, window.innerWidth - MANAGER_VIEWPORT_GAP));
  }

  function managerMinWidth() {
    return Math.min(MANAGER_MIN_WIDTH, managerMaxWidth());
  }

  function defaultManagerWidth() {
    return clamp(MANAGER_DEFAULT_WIDTH, managerMinWidth(), managerMaxWidth());
  }

  function normalizedManagerWidth(config) {
    const width = safeNumber(config?.managerWidth, -1);
    return clamp(width >= 0 ? width : defaultManagerWidth(), managerMinWidth(), managerMaxWidth());
  }

  function applyManagerWidth(manager, config = getUiConfig()) {
    if (!canResizeManager()) {
      manager.style.width = '100vw';
      return;
    }
    manager.style.width = `${normalizedManagerWidth(config)}px`;
  }

  function applyUiConfig(config = getUiConfig()) {
    const fab = $('#ldsmFab');
    if (fab) {
      fab.hidden = !config.showFab;
      applyFabPosition(fab, config);
    }
    const manager = $('#ldsmManager');
    if (manager) applyManagerWidth(manager, config);
    const toggle = $('#ldsmFabToggle');
    if (toggle) toggle.checked = config.showFab;
  }

  function handleFabPointerDown(event) {
    if (event.button !== undefined && event.button !== 0) return;
    const fab = event.currentTarget;
    const rect = fab.getBoundingClientRect();
    fabDragState = {
      pointerId: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      offsetX: event.clientX - rect.left,
      offsetY: event.clientY - rect.top,
      moved: false,
    };
    fab.setPointerCapture?.(event.pointerId);
  }

  function handleFabPointerMove(event) {
    const state = fabDragState;
    if (!state || state.pointerId !== event.pointerId) return;
    const distance = Math.hypot(event.clientX - state.startX, event.clientY - state.startY);
    if (!state.moved && distance < FAB_DRAG_THRESHOLD) return;
    state.moved = true;
    const fab = event.currentTarget;
    fab.classList.add('ldsm-fab-dragging');
    event.preventDefault();
    placeFabAt(fab, event.clientX - state.offsetX, event.clientY - state.offsetY);
  }

  function handleFabPointerUp(event) {
    const state = fabDragState;
    if (!state || state.pointerId !== event.pointerId) return;
    const fab = event.currentTarget;
    fab.releasePointerCapture?.(event.pointerId);
    fabDragState = null;

    if (!state.moved) return;
    event.preventDefault();
    const rect = fab.getBoundingClientRect();
    const x = clamp(rect.left, 0, fabMaxX());
    const y = clamp(rect.top, FAB_MARGIN, fabMaxY());
    const leftGap = x;
    const rightGap = fabViewportWidth() - (x + FAB_SIZE);
    const dock = leftGap <= FAB_DOCK_THRESHOLD ? 'left' : rightGap <= FAB_DOCK_THRESHOLD ? 'right' : 'free';
    fab.classList.remove('ldsm-fab-dragging');
    saveUiConfig({
      fabDock: dock,
      fabSide: dock === 'left' ? 'left' : 'right',
      fabX: dock === 'free' ? x : -1,
      fabY: y,
    });
    suppressFabClick = true;
    setTimeout(() => {
      suppressFabClick = false;
    }, 150);
  }

  function handleFabPointerCancel(event) {
    const state = fabDragState;
    if (!state || state.pointerId !== event.pointerId) return;
    event.currentTarget.classList.remove('ldsm-fab-dragging');
    fabDragState = null;
    applyUiConfig();
  }

  function handleFabClick(event) {
    if (suppressFabClick) {
      event.preventDefault();
      return;
    }
    openManager();
  }

  function handleManagerResizePointerDown(event) {
    if (event.button !== undefined && event.button !== 0) return;
    if (!canResizeManager()) return;
    const handle = event.currentTarget;
    const manager = $('#ldsmManager');
    if (!manager) return;
    const rect = manager.getBoundingClientRect();
    managerResizeState = {
      pointerId: event.pointerId,
      startX: event.clientX,
      startWidth: rect.width,
    };
    handle.setPointerCapture?.(event.pointerId);
    manager.classList.add('ldsm-manager-resizing');
    document.body.classList.add('ldsm-resize-lock');
    event.preventDefault();
  }

  function handleManagerResizePointerMove(event) {
    const state = managerResizeState;
    if (!state || state.pointerId !== event.pointerId) return;
    const manager = $('#ldsmManager');
    if (!manager) return;
    const width = clamp(state.startWidth + state.startX - event.clientX, managerMinWidth(), managerMaxWidth());
    manager.style.width = `${width}px`;
    event.preventDefault();
  }

  function finishManagerResize(event, shouldSave) {
    const state = managerResizeState;
    if (!state || state.pointerId !== event.pointerId) return;
    const handle = event.currentTarget;
    const manager = $('#ldsmManager');
    handle.releasePointerCapture?.(event.pointerId);
    managerResizeState = null;
    manager?.classList.remove('ldsm-manager-resizing');
    document.body.classList.remove('ldsm-resize-lock');
    if (shouldSave && manager && canResizeManager()) {
      saveUiConfig({ managerWidth: manager.getBoundingClientRect().width });
    } else {
      applyUiConfig();
    }
  }

  // ========================= Storage =========================
  const StarStorage = {
    async getAll() {
      const store = normalizeStore(GM_getValue(STORAGE_KEY, null));
      return clone(store);
    },

    async save(store, options = {}) {
      const normalized = normalizeStore(store);
      this.purgeDeleted(normalized);
      GM_setValue(STORAGE_KEY, clone(normalized));
      if (options.notify !== false) notifyDataChanged();
    },

    async getCollections() {
      const store = await this.getAll();
      return store.collections;
    },

    async createCollection(name, icon, color) {
      const store = await this.getAll();
      const id = makeId('col');
      const order = Math.max(0, ...sortedCollections(store).filter(col => col.id !== 'default').map(col => safeNumber(col.order, 0))) + ORDER_STEP;
      const time = nowIso();
      store.collections[id] = {
        id,
        name: name || '新收藏夹',
        icon: icon || '📁',
        color: color || '#71717a',
        createdAt: time,
        updatedAt: time,
        order,
      };
      await this.save(store);
      return id;
    },

    async reorderCollections(collectionIds) {
      const store = await this.getAll();
      assignCollectionOrders(store, collectionIds);
      await this.save(store);
    },

    async updateCollection(id, updates) {
      const store = await this.getAll();
      if (!store.collections[id] || store.collections[id]._deleted) return;
      Object.assign(store.collections[id], updates, { updatedAt: nowIso() });
      await this.save(store);
    },

    async deleteCollection(id) {
      if (id === 'default') return;
      const store = await this.getAll();
      const time = nowIso();
      store.collections[id] = {
        id,
        _deleted: true,
        _deletedAt: time,
      };
      for (const bookmark of Object.values(store.bookmarks || {})) {
        if (!bookmark || bookmark._deleted) continue;
        const wasInDeletedCollection = bookmark.collectionId === id;
        if (wasInDeletedCollection) {
          bookmark.collectionId = 'default';
          removeTopicCollectionOrder(bookmark, id);
          bookmark.updatedAt = time;
        }
        for (const post of Object.values(bookmark.posts || {})) {
          if (post && !post._deleted && (post.collectionId === id || wasInDeletedCollection)) {
            post.collectionId = 'default';
            post.updatedAt = time;
            bookmark.updatedAt = time;
          }
        }
      }
      await this.save(store);
    },

    async isTopicStarred(topicId) {
      const store = await this.getAll();
      const item = store.bookmarks[`topic_${topicId}`];
      return !!item && !item._deleted && !!item.starred;
    },

    async isPostStarred(topicId, postNumber) {
      const store = await this.getAll();
      const post = store.bookmarks[`topic_${topicId}`]?.posts?.[`post_${postNumber}`];
      return !!post && !post._deleted;
    },

    async toggleTopicStar(topicId, meta, collectionId = 'default') {
      const store = await this.getAll();
      const key = `topic_${topicId}`;
      const time = nowIso();
      const existing = store.bookmarks[key];
      const targetCollectionId = liveCollectionId(store, collectionId);

      if (existing && !existing._deleted && existing.starred) {
        existing.starred = false;
        existing.updatedAt = time;
        if (!alivePosts(existing).length) {
          store.bookmarks[key] = {
            _deleted: true,
            _deletedAt: time,
            topicId,
          };
        }
        await this.save(store);
        return false;
      }

      if (!existing || existing._deleted) {
        store.bookmarks[key] = {
          topicId,
          topicTitle: meta.title,
          topicUrl: meta.url,
          category: meta.category || '',
          starredAt: time,
          updatedAt: time,
          starred: true,
          collectionId: targetCollectionId,
          tags: meta.tags || [],
          note: '',
          posts: {},
        };
      } else {
        const previousCollectionId = liveCollectionId(store, existing.collectionId);
        existing.starred = true;
        existing.starredAt = time;
        existing.updatedAt = time;
        existing.topicTitle = meta.title || existing.topicTitle;
        existing.topicUrl = meta.url || existing.topicUrl;
        existing.category = meta.category || existing.category || '';
        existing.tags = meta.tags?.length ? meta.tags : (existing.tags || []);
        existing.collectionId = targetCollectionId || liveCollectionId(store, existing.collectionId);
        if (previousCollectionId !== targetCollectionId) removeTopicCollectionOrder(existing, previousCollectionId);
        for (const post of Object.values(existing.posts || {})) {
          if (!post || post._deleted) continue;
          post.collectionId = existing.collectionId;
          post.updatedAt = time;
        }
      }

      placeTopicAtCollectionTop(store, key, targetCollectionId, time);
      placeTopicAtCollectionTop(store, key, ALL_ORDER_KEY, time);
      await this.save(store);
      return true;
    },

    async togglePostStar(topicId, postNumber, topicMeta, postMeta, collectionId = 'default') {
      const store = await this.getAll();
      const topicKey = `topic_${topicId}`;
      const postKey = `post_${postNumber}`;
      const time = nowIso();
      const targetCollectionId = liveCollectionId(store, collectionId);

      if (!store.bookmarks[topicKey] || store.bookmarks[topicKey]._deleted) {
        store.bookmarks[topicKey] = {
          topicId,
          topicTitle: topicMeta.title,
          topicUrl: topicMeta.url,
          category: topicMeta.category || '',
          starredAt: time,
          updatedAt: time,
          starred: true,
          collectionId: targetCollectionId,
          tags: topicMeta.tags || [],
          note: '',
          posts: {},
        };
      }

      const topic = store.bookmarks[topicKey];
      if (!topic.posts) topic.posts = {};
      const previousCollectionId = liveCollectionId(store, topic.collectionId);

      if (topic.posts[postKey] && !topic.posts[postKey]._deleted) {
        topic.posts[postKey] = {
          _deleted: true,
          _deletedAt: time,
          postNumber,
        };
        topic.updatedAt = time;
        if (!topic.starred && !alivePosts(topic).length) {
          store.bookmarks[topicKey] = {
            _deleted: true,
            _deletedAt: time,
            topicId,
          };
        }
        await this.save(store);
        return false;
      }

      topic.posts[postKey] = {
        postNumber,
        postUrl: postMeta.url,
        author: postMeta.author,
        excerpt: postMeta.excerpt,
        starredAt: time,
        updatedAt: time,
        collectionId: targetCollectionId,
        tags: [],
        note: '',
      };
      topic.starred = true;
      topic.updatedAt = time;
      topic.topicTitle = topicMeta.title || topic.topicTitle;
      topic.topicUrl = topicMeta.url || topic.topicUrl;
      topic.collectionId = targetCollectionId;
      if (previousCollectionId !== targetCollectionId) removeTopicCollectionOrder(topic, previousCollectionId);
      for (const post of Object.values(topic.posts || {})) {
        if (!post || post._deleted) continue;
        post.collectionId = targetCollectionId;
        post.updatedAt = time;
      }
      placeTopicAtCollectionTop(store, topicKey, targetCollectionId, time);
      placeTopicAtCollectionTop(store, topicKey, ALL_ORDER_KEY, time);
      await this.save(store);
      return true;
    },

    async moveToCollection(topicKey, collectionId, postKey) {
      const store = await this.getAll();
      const time = nowIso();
      const targetCollectionId = liveCollectionId(store, collectionId);
      const topic = store.bookmarks[topicKey];
      if (topic && !topic._deleted) {
        const previousCollectionId = liveCollectionId(store, topic.collectionId);
        topic.collectionId = targetCollectionId;
        topic.updatedAt = time;
        for (const post of Object.values(topic.posts || {})) {
          if (!post || post._deleted) continue;
          post.collectionId = targetCollectionId;
          post.updatedAt = time;
        }
        if (previousCollectionId !== targetCollectionId) removeTopicCollectionOrder(topic, previousCollectionId);
        placeTopicAtCollectionTop(store, topicKey, targetCollectionId, time);
      }
      await this.save(store);
    },

    async reorderTopics(collectionId, topicKeys) {
      const store = await this.getAll();
      const id = orderScopeId(store, collectionId);
      const sourceItems = viewBookmarks(store, id);
      const allowed = new Set(sourceItems.map(item => item.key));
      const ordered = topicKeys.filter(key => allowed.has(key));
      const missing = sortBookmarksByCustomOrder(sourceItems, id)
        .map(item => item.key)
        .filter(key => !ordered.includes(key));
      reindexCollectionItems(store, id, [...ordered, ...missing]);
      await this.save(store);
    },

    async softDeleteTopic(topicKey) {
      const store = await this.getAll();
      if (store.bookmarks[topicKey]) {
        store.bookmarks[topicKey] = {
          _deleted: true,
          _deletedAt: nowIso(),
          topicId: store.bookmarks[topicKey].topicId,
        };
      }
      await this.save(store);
    },

    async softDeletePost(topicKey, postKey) {
      const store = await this.getAll();
      const topic = store.bookmarks[topicKey];
      if (topic?.posts?.[postKey]) {
        topic.posts[postKey] = {
          _deleted: true,
          _deletedAt: nowIso(),
          postNumber: topic.posts[postKey].postNumber,
        };
        topic.updatedAt = nowIso();
        if (!topic.starred && !alivePosts(topic).length) {
          store.bookmarks[topicKey] = {
            _deleted: true,
            _deletedAt: nowIso(),
            topicId: topic.topicId,
          };
        }
      }
      await this.save(store);
    },

    purgeDeleted(store) {
      const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
      for (const [key, bookmark] of Object.entries(store.bookmarks || {})) {
        if (bookmark?._deleted && new Date(bookmark._deletedAt || 0).getTime() < cutoff) {
          delete store.bookmarks[key];
          continue;
        }
        for (const [postKey, post] of Object.entries(bookmark?.posts || {})) {
          if (post?._deleted && new Date(post._deletedAt || 0).getTime() < cutoff) {
            delete bookmark.posts[postKey];
          }
        }
      }
      for (const [id, collection] of Object.entries(store.collections || {})) {
        if (id !== 'default' && collection?._deleted && new Date(collection._deletedAt || 0).getTime() < cutoff) {
          delete store.collections[id];
        }
      }
    },
  };

  function notifyDataChanged() {
    refreshFabCount();
    refreshPageStarStates();
    if (isManagerOpen()) reloadManager();
    scheduleAutoSync();
  }

  // ========================= GitHub Gist Sync =========================
  const SyncManager = {
    async getConfig() {
      return normalizeSyncConfig(GM_getValue(SYNC_CONFIG_KEY, {}));
    },

    async saveConfig(config) {
      const normalized = normalizeSyncConfig(config);
      if (!normalized.lastError) delete normalized.lastError;
      if (!normalized.username) delete normalized.username;
      GM_setValue(SYNC_CONFIG_KEY, normalized);
      if (isManagerOpen()) renderSyncStatus();
    },

    async updateStatus(status, error) {
      const config = await this.getConfig();
      config.status = status;
      if (error) config.lastError = error;
      else delete config.lastError;
      await this.saveConfig(config);
    },

    async apiRequest(token, url, options = {}) {
      const safeUrl = safeGithubApiUrl(url);
      const method = safeString(options.method || 'GET', 'GET', 8).toUpperCase();
      if (!safeUrl) throw new Error('GitHub API 地址不被允许');
      if (!['GET', 'POST', 'PATCH'].includes(method)) throw new Error('GitHub API 方法不被允许');

      return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method,
          url: safeUrl,
          headers: {
            ...(options.headers || {}),
            Authorization: `Bearer ${token}`,
            Accept: 'application/vnd.github+json',
            'X-GitHub-Api-Version': '2022-11-28',
          },
          data: options.body,
          responseType: 'text',
          timeout: 20_000,
          onload: response => {
            const body = response.responseText || '';
            if (body.length > MAX_GITHUB_RESPONSE_BYTES) {
              reject(new Error('GitHub API 响应过大'));
              return;
            }
            if (response.status < 200 || response.status >= 300) {
              if (response.status === 401) reject(new Error('Token 无效或已过期'));
              else if (response.status === 404) reject(new Error('Gist 不存在'));
              else reject(new Error(`GitHub API 错误 (${response.status}): ${body.slice(0, 160)}`));
              return;
            }
            if (!body) {
              resolve(null);
              return;
            }
            try {
              resolve(JSON.parse(body));
            } catch {
              resolve(body);
            }
          },
          onerror: () => reject(new Error('无法连接 GitHub API')),
          ontimeout: () => reject(new Error('连接 GitHub API 超时')),
        });
      });
    },

    async validateToken(token) {
      try {
        const user = await this.apiRequest(token, 'https://api.github.com/user');
        return { ok: true, username: user.login, avatar: user.avatar_url };
      } catch (error) {
        return { ok: false, message: error.message };
      }
    },

    async findOrCreateGist(token) {
      const gists = await this.apiRequest(token, 'https://api.github.com/gists?per_page=100');
      for (const gist of gists || []) {
        if (gist.files?.[GIST_FILENAME]) return gist.id;
      }

      const created = await this.apiRequest(token, 'https://api.github.com/gists', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          description: GIST_DESCRIPTION,
          public: false,
          files: {
            [GIST_FILENAME]: {
              content: JSON.stringify({ collections: {}, bookmarks: {} }, null, 2),
            },
          },
        }),
      });
      return created.id;
    },

    async readGist(token, gistId) {
      const gist = await this.apiRequest(token, `https://api.github.com/gists/${gistId}`);
      const file = gist.files?.[GIST_FILENAME];
      if (!file) throw new Error('Gist 中未找到数据文件');
      if (file.truncated) throw new Error('Gist 数据文件过大,GitHub API 返回内容已截断');
      try {
        return normalizeStore(JSON.parse(file.content || '{}'));
      } catch {
        return normalizeStore({});
      }
    },

    async writeGist(token, gistId, data) {
      await this.apiRequest(token, `https://api.github.com/gists/${gistId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          files: {
            [GIST_FILENAME]: {
              content: JSON.stringify(data, null, 2),
            },
          },
        }),
      });
    },

    async connect(token) {
      const cleanToken = safeString(token, '', 256).trim();
      if (!cleanToken) return { ok: false, message: 'Token 不能为空' };

      const validation = await this.validateToken(cleanToken);
      if (!validation.ok) return { ok: false, message: validation.message };

      const gistId = await this.findOrCreateGist(cleanToken);
      await this.saveConfig({
        token: cleanToken,
        gistId,
        lastSyncAt: '',
        autoSync: true,
        status: 'connected',
        username: validation.username,
      });

      const result = await this.sync();
      return { ok: result.ok, message: result.ok ? `已连接为 @${validation.username}` : result.message, gistId };
    },

    async disconnect() {
      await this.saveConfig({
        token: '',
        gistId: '',
        lastSyncAt: '',
        autoSync: false,
        status: 'disconnected',
      });
      return { ok: true, message: '已断开同步' };
    },

    async sync() {
      const config = await this.getConfig();
      if (!config.token || !config.gistId) return { ok: false, message: '未配置同步' };

      try {
        await this.updateStatus('syncing');
        const remote = await this.readGist(config.token, config.gistId);
        const local = await StarStorage.getAll();
        const merged = this.merge(local, remote);
        await StarStorage.save(merged, { notify: false });
        await this.writeGist(config.token, config.gistId, merged);
        config.lastSyncAt = nowIso();
        config.status = 'synced';
        delete config.lastError;
        await this.saveConfig(config);
        refreshFabCount();
        refreshPageStarStates();
        if (isManagerOpen()) await reloadManager();
        return { ok: true, message: '同步成功', merged };
      } catch (error) {
        await this.updateStatus('error', error.message);
        return { ok: false, message: error.message };
      }
    },

    merge(localInput, remoteInput) {
      const local = normalizeStore(localInput);
      const remote = normalizeStore(remoteInput);
      const result = { collections: {}, bookmarks: {} };

      const allCollections = new Set([
        ...Object.keys(local.collections || {}),
        ...Object.keys(remote.collections || {}),
      ]);
      for (const id of allCollections) {
        const left = local.collections?.[id];
        const right = remote.collections?.[id];
        if (!left) result.collections[id] = right;
        else if (!right) result.collections[id] = left;
        else if (left._deleted || right._deleted) result.collections[id] = resolveDeletion(left, right);
        else result.collections[id] = newer(left, right);
      }
      if (!result.collections.default) result.collections.default = makeDefaultCollection();

      const allTopics = new Set([
        ...Object.keys(local.bookmarks || {}),
        ...Object.keys(remote.bookmarks || {}),
      ]);
      for (const key of allTopics) {
        const left = local.bookmarks?.[key];
        const right = remote.bookmarks?.[key];
        if (!left && !right) continue;
        if (!left) {
          result.bookmarks[key] = right;
          continue;
        }
        if (!right) {
          result.bookmarks[key] = left;
          continue;
        }

        if (left._deleted || right._deleted) {
          result.bookmarks[key] = resolveDeletion(left, right);
          continue;
        }

        const merged = { ...newer(left, right), posts: {} };
        const allPosts = new Set([
          ...Object.keys(left.posts || {}),
          ...Object.keys(right.posts || {}),
        ]);
        for (const postKey of allPosts) {
          const lp = left.posts?.[postKey];
          const rp = right.posts?.[postKey];
          if (!lp && !rp) continue;
          if (!lp) merged.posts[postKey] = rp;
          else if (!rp) merged.posts[postKey] = lp;
          else if (lp._deleted || rp._deleted) merged.posts[postKey] = resolveDeletion(lp, rp);
          else merged.posts[postKey] = newer(lp, rp);
        }
        result.bookmarks[key] = merged;
      }

      StarStorage.purgeDeleted(result);
      return normalizeStore(result);

      function newer(a, b) {
        const ta = new Date(a.updatedAt || a.starredAt || a.createdAt || a._deletedAt || 0).getTime();
        const tb = new Date(b.updatedAt || b.starredAt || b.createdAt || b._deletedAt || 0).getTime();
        return ta >= tb ? { ...a } : { ...b };
      }

      function resolveDeletion(a, b) {
        if (a._deleted && b._deleted) return newer(a, b);
        if (a._deleted) {
          const deleteTime = new Date(a._deletedAt || 0).getTime();
          const liveTime = new Date(b.updatedAt || b.starredAt || b.createdAt || 0).getTime();
          return deleteTime >= liveTime ? { ...a } : { ...b };
        }
        const deleteTime = new Date(b._deletedAt || 0).getTime();
        const liveTime = new Date(a.updatedAt || a.starredAt || a.createdAt || 0).getTime();
        return deleteTime >= liveTime ? { ...b } : { ...a };
      }
    },
  };

  function scheduleAutoSync() {
    clearTimeout(syncDebounceTimer);
    syncDebounceTimer = setTimeout(async () => {
      const config = await SyncManager.getConfig();
      if (config.token && config.gistId && config.autoSync) {
        const result = await SyncManager.sync();
        if (!result.ok) showToast(result.message, '⚠');
      }
    }, 30_000);
  }

  setInterval(async () => {
    const config = await SyncManager.getConfig();
    if (config.token && config.gistId && config.autoSync) {
      await SyncManager.sync();
    }
  }, 30 * 60 * 1000);

  // ========================= Topic DOM =========================
  function getTopicId() {
    const match = location.pathname.match(/\/t\/[^/]+\/(\d+)/);
    return match ? Number.parseInt(match[1], 10) : null;
  }

  function getTopicSlug() {
    const match = location.pathname.match(/\/t\/([^/]+)\/\d+/);
    return match ? match[1] : 'topic';
  }

  function getTopicTitle() {
    for (const selector of ['#topic-title h1 a', '.fancy-title', '#topic-title h1', 'h1 .fancy-title']) {
      const element = document.querySelector(selector);
      const text = element?.textContent?.trim();
      if (text) return text;
    }
    return document.title.split(' - ')[0]?.trim() || '未知标题';
  }

  function getTopicCategory() {
    for (const selector of ['.topic-category .badge-category__name', '.category-name', '.d-breadcrumbs .badge-category span']) {
      const element = document.querySelector(selector);
      const text = element?.textContent?.trim();
      if (text) return text;
    }
    return '';
  }

  function getTopicTags() {
    const tags = [];
    document.querySelectorAll('.discourse-tags .discourse-tag, .topic-header-extra .discourse-tag, #topic-title .discourse-tag, .tag-list .discourse-tag')
      .forEach(element => {
        const tag = element.textContent.trim();
        if (tag && !tags.includes(tag)) tags.push(tag);
      });
    return tags;
  }

  function getTopicMeta() {
    const id = getTopicId();
    return {
      title: getTopicTitle(),
      url: `https://linux.do/t/${getTopicSlug()}/${id}`,
      category: getTopicCategory(),
      tags: getTopicTags(),
    };
  }

  function isTopicPage() {
    return /\/t\/[^/]+\/\d+/.test(location.pathname);
  }

  function getPostNumber(article) {
    const topicPost = article.closest('.topic-post');
    let postNumber = topicPost?.dataset?.postNumber || article.dataset.postNumber || '';
    if (!postNumber && topicPost?.id) {
      const match = topicPost.id.match(/post_(\d+)/);
      if (match) postNumber = match[1];
    }
    if (!postNumber) {
      const link = article.querySelector('a.post-date, .post-number a, a[data-post-number]');
      const href = link?.getAttribute('href') || '';
      const match = href.match(/\/(\d+)$/);
      if (match) postNumber = match[1];
    }
    if (!postNumber) {
      const posts = Array.from(document.querySelectorAll('.topic-post'));
      const index = posts.indexOf(topicPost);
      if (index >= 0) postNumber = String(index + 1);
    }
    return postNumber ? String(postNumber) : '';
  }

  function getPostMeta(article, topicId, postNumber) {
    const authorElement = article.querySelector('.username a, .names .username, .first a, .topic-meta-data .username');
    const contentElement = article.querySelector('.cooked');
    const author = authorElement?.textContent?.trim() || '';
    const excerpt = contentElement?.textContent?.trim().replace(/\s+/g, ' ').slice(0, 180) || '';
    return {
      url: `https://linux.do/t/${getTopicSlug()}/${topicId}/${postNumber}`,
      author,
      excerpt,
    };
  }

  function createStarButton({ isActive, ariaLabel, onDirectClick, onHoverPick, topicId, postNumber }) {
    const button = document.createElement('button');
    button.className = `btn no-text btn-icon ${STAR_CLASS} btn-flat${isActive ? ` ${STAR_ACTIVE_CLASS}` : ''}`;
    button.innerHTML = STAR_SVG;
    button.setAttribute('aria-label', ariaLabel);
    button.setAttribute('title', ariaLabel);
    button.setAttribute('type', 'button');
    if (topicId) button.dataset.topicId = String(topicId);
    if (postNumber) button.dataset.postNumber = String(postNumber);

    let hoverTimer = null;

    button.addEventListener('click', async event => {
      event.preventDefault();
      event.stopPropagation();
      clearTimeout(hoverTimer);

      if (activePopup) {
        closeCollectionPicker();
        return;
      }

      try {
        const newState = await onDirectClick();
        button.classList.toggle(STAR_ACTIVE_CLASS, newState);
        button.setAttribute('title', newState ? '取消收藏' : '收藏');
        button.setAttribute('aria-label', newState ? '取消收藏' : '收藏');
        if (newState) {
          button.classList.add('ldsm-star-just-activated');
          setTimeout(() => button.classList.remove('ldsm-star-just-activated'), 420);
        }
        showToast(newState ? '已收藏' : '已取消收藏', newState ? '⭐' : '☆');
      } catch (error) {
        showToast(error.message || '操作失败', '⚠');
      }
    });

    button.addEventListener('mouseenter', () => {
      hoverTimer = setTimeout(() => onHoverPick(button), 500);
    });
    button.addEventListener('mouseleave', () => clearTimeout(hoverTimer));

    return button;
  }

  async function injectStars() {
    const topicId = getTopicId();
    if (!topicId) return;
    const topicMeta = getTopicMeta();
    const articles = document.querySelectorAll('.topic-post article[data-post-id]:not([data-ldsm-star-injected])');

    for (const article of articles) {
      article.setAttribute('data-ldsm-star-injected', 'true');
      const postNumber = getPostNumber(article);
      if (!postNumber) continue;

      const postMeta = getPostMeta(article, topicId, postNumber);
      const isPostStarred = await StarStorage.isPostStarred(topicId, Number.parseInt(postNumber, 10));
      const actionBar =
        article.querySelector('.post-action-menu__row') ||
        article.querySelector('nav.post-controls .actions') ||
        article.querySelector('.post-menu-area .actions') ||
        article.querySelector('.post-controls .actions');
      if (!actionBar || actionBar.querySelector(`.${STAR_CLASS}`)) continue;

      const starButton = createStarButton({
        isActive: isPostStarred,
        ariaLabel: isPostStarred ? '取消收藏评论' : '收藏评论',
        topicId,
        postNumber,
        onDirectClick: async () => {
          const state = await StarStorage.togglePostStar(topicId, Number.parseInt(postNumber, 10), topicMeta, postMeta, 'default');
          await refreshPageStarStates();
          return state;
        },
        onHoverPick: button => showCollectionPicker(button, { topicId, postNumber, topicMeta, postMeta }),
      });

      const first = actionBar.firstElementChild;
      if (first) actionBar.insertBefore(starButton, first);
      else actionBar.appendChild(starButton);
    }

    injectTopicStar(topicId, topicMeta);
  }

  async function injectTopicStar(topicId, topicMeta) {
    const existing = document.querySelector('.ldsm-star-topic-btn');
    if (existing && existing.parentNode && document.contains(existing)) return;
    existing?.remove();

    const titleElement =
      document.querySelector('#topic-title h1') ||
      document.querySelector('.title-wrapper h1') ||
      document.querySelector('#topic-title .fancy-title');
    if (!titleElement) return;

    const isStarred = await StarStorage.isTopicStarred(topicId);
    const starButton = createStarButton({
      isActive: isStarred,
      ariaLabel: isStarred ? '取消收藏帖子' : '收藏帖子',
      topicId,
      onDirectClick: () => StarStorage.toggleTopicStar(topicId, topicMeta, 'default'),
      onHoverPick: button => showCollectionPicker(button, { topicId, postNumber: null, topicMeta, postMeta: null }),
    });
    starButton.classList.add('ldsm-star-topic-btn');
    starButton.classList.remove('btn', 'no-text', 'btn-icon', 'btn-flat');
    titleElement.style.display = 'inline-flex';
    titleElement.style.alignItems = 'center';
    titleElement.style.gap = '6px';
    titleElement.appendChild(starButton);
  }

  async function refreshPageStarStates() {
    const topicId = getTopicId();
    if (!topicId) return;
    const topicButton = document.querySelector('.ldsm-star-topic-btn');
    if (topicButton) {
      const isStarred = await StarStorage.isTopicStarred(topicId);
      topicButton.classList.toggle(STAR_ACTIVE_CLASS, isStarred);
      topicButton.setAttribute('title', isStarred ? '取消收藏帖子' : '收藏帖子');
    }

    const buttons = document.querySelectorAll(`.${STAR_CLASS}[data-post-number]`);
    for (const button of buttons) {
      const postNumber = Number.parseInt(button.dataset.postNumber, 10);
      if (!postNumber) continue;
      const isPostStarred = await StarStorage.isPostStarred(topicId, postNumber);
      button.classList.toggle(STAR_ACTIVE_CLASS, isPostStarred);
      button.setAttribute('title', isPostStarred ? '取消收藏评论' : '收藏评论');
    }
  }

  function closeCollectionPicker() {
    activePopup?.remove();
    activePopup = null;
    document.removeEventListener('click', onDocumentClickForPicker, true);
  }

  function onDocumentClickForPicker(event) {
    if (activePopup && !activePopup.contains(event.target) && !event.target.closest(`.${STAR_CLASS}`)) {
      closeCollectionPicker();
    }
  }

  async function showCollectionPicker(button, { topicId, postNumber, topicMeta, postMeta }) {
    closeCollectionPicker();
    const store = await StarStorage.getAll();
    let collections = sortedCollections(store);
    const topicKey = `topic_${topicId}`;
    const currentCollectionId = postNumber
      ? store.bookmarks[topicKey]?.posts?.[`post_${postNumber}`]?.collectionId
      : store.bookmarks[topicKey]?.collectionId;

    const popup = document.createElement('div');
    popup.className = 'ldsm-picker';
    popup.innerHTML = `
      <div class="ldsm-picker-header">
        <span>收藏到</span>
        <span>${collections.length} 个收藏夹</span>
      </div>
      <div class="ldsm-picker-search-wrap">
        <input class="ldsm-picker-search" placeholder="搜索收藏夹" type="text">
      </div>
      <div class="ldsm-picker-list"></div>
      <button class="ldsm-picker-item ldsm-picker-new" data-action="new-collection" type="button">
        <span class="ldsm-picker-icon">+</span>
        <span class="ldsm-picker-name">新建收藏夹</span>
      </button>
    `;

    let pickerFilter = '';
    const renderList = (filter = '') => {
      const list = popup.querySelector('.ldsm-picker-list');
      const query = filter.toLowerCase().trim();
      const filtered = query
        ? collections.filter(col => col.name.toLowerCase().includes(query) || (col.icon || '').includes(query))
        : collections;

      if (!filtered.length) {
        list.innerHTML = '<div class="ldsm-picker-empty">无匹配结果</div>';
        return;
      }

      list.innerHTML = filtered.map(col => `
        <button class="ldsm-picker-item${col.id === currentCollectionId ? ' active' : ''}" data-cid="${attr(col.id)}" type="button" ${!query && col.id !== 'default' ? 'draggable="true"' : ''}>
          <span class="ldsm-picker-icon">${h(col.icon || '📁')}</span>
          <span class="ldsm-picker-name">${h(col.name)}</span>
          ${col.id === currentCollectionId ? '<span class="ldsm-picker-check">✓</span>' : ''}
        </button>
      `).join('');
    };

    renderList();
    popup.querySelector('.ldsm-picker-search').addEventListener('input', event => {
      pickerFilter = event.target.value;
      renderList(pickerFilter);
    });
    popup.querySelector('.ldsm-picker-search').addEventListener('keydown', event => {
      if (event.key === 'Enter') popup.querySelector('.ldsm-picker-list .ldsm-picker-item')?.click();
    });
    const pickerList = popup.querySelector('.ldsm-picker-list');
    pickerList.addEventListener('dragstart', event => {
      if (pickerFilter.trim()) return;
      const item = event.target.closest('.ldsm-picker-item[data-cid]');
      if (!item || item.dataset.cid === 'default') return;
      setDragPayload(event, { type: 'picker-collection', collectionId: item.dataset.cid });
      item.classList.add('ldsm-dragging');
    });
    pickerList.addEventListener('dragover', event => {
      const payload = getDragPayload(event);
      if (!payload || payload.type !== 'picker-collection' || pickerFilter.trim()) return;
      const item = event.target.closest('.ldsm-picker-item[data-cid]');
      if (!item || item.dataset.cid === 'default' || item.dataset.cid === payload.collectionId) return;
      event.preventDefault();
      event.dataTransfer.dropEffect = 'move';
      markDragTarget(item, getDropPosition(event, item));
    });
    pickerList.addEventListener('dragleave', handleDragLeave);
    pickerList.addEventListener('drop', async event => {
      const payload = getDragPayload(event);
      const item = event.target.closest('.ldsm-picker-item[data-cid]');
      if (!payload || payload.type !== 'picker-collection' || !item || item.dataset.cid === 'default' || item.dataset.cid === payload.collectionId) return;
      event.preventDefault();
      const position = getDropPosition(event, item);
      const ids = sortedCollections(await StarStorage.getAll()).map(col => col.id).filter(id => id !== 'default');
      await StarStorage.reorderCollections(reorderIds(ids, payload.collectionId, item.dataset.cid, position));
      collections = sortedCollections(await StarStorage.getAll());
      clearDragMarks(popup);
      renderList(pickerFilter);
      if (isManagerOpen()) await reloadManager();
      suppressNextClick();
    });
    pickerList.addEventListener('dragend', handleDragEnd);

    const rect = button.getBoundingClientRect();
    popup.style.top = `${Math.min(window.innerHeight - 16, rect.bottom + 6)}px`;
    popup.style.left = `${Math.max(8, Math.min(window.innerWidth - 280, rect.left - 92))}px`;

    popup.addEventListener('click', async event => {
      event.stopPropagation();
      if (managerState.ignoreNextClick) {
        event.preventDefault();
        return;
      }
      const newButton = event.target.closest('[data-action="new-collection"]');
      if (newButton) {
        newButton.outerHTML = `
          <div class="ldsm-picker-input-row">
            <input class="ldsm-picker-input" placeholder="收藏夹名称" autofocus>
            <button class="ldsm-picker-input-ok" type="button">✓</button>
          </div>
        `;
        const input = popup.querySelector('.ldsm-picker-input');
        const ok = popup.querySelector('.ldsm-picker-input-ok');
        input?.focus();

        const createAndSave = async () => {
          const name = input.value.trim();
          if (!name) return;
          const id = await StarStorage.createCollection(name, ICONS[aliveCollections(store).length % ICONS.length], '#71717a');
          closeCollectionPicker();
          if (postNumber) await StarStorage.togglePostStar(topicId, Number.parseInt(postNumber, 10), topicMeta, postMeta, id);
          else await StarStorage.toggleTopicStar(topicId, topicMeta, id);
          showToast(`已收藏到「${name}」`);
        };
        ok.addEventListener('click', createAndSave);
        input.addEventListener('keydown', event => {
          if (event.key === 'Enter') createAndSave();
        });
        return;
      }

      const item = event.target.closest('.ldsm-picker-item[data-cid]');
      if (!item) return;
      const collectionId = item.dataset.cid;
      closeCollectionPicker();
      const isAlreadyStarred = postNumber
        ? await StarStorage.isPostStarred(topicId, Number.parseInt(postNumber, 10))
        : await StarStorage.isTopicStarred(topicId);

      if (isAlreadyStarred) {
        await StarStorage.moveToCollection(topicKey, collectionId, postNumber ? `post_${postNumber}` : null);
        const col = (await StarStorage.getAll()).collections[collectionId];
        showToast(`已移动到「${col?.name || '收藏夹'}」`);
      } else {
        if (postNumber) await StarStorage.togglePostStar(topicId, Number.parseInt(postNumber, 10), topicMeta, postMeta, collectionId);
        else await StarStorage.toggleTopicStar(topicId, topicMeta, collectionId);
        const col = (await StarStorage.getAll()).collections[collectionId];
        showToast(`已收藏到「${col?.name || '收藏夹'}」`);
      }
      await refreshPageStarStates();
    });

    document.body.appendChild(popup);
    activePopup = popup;
    setTimeout(() => document.addEventListener('click', onDocumentClickForPicker, true), 10);
  }

  function scheduleInject() {
    if (injectScheduled) return;
    injectScheduled = true;
    requestAnimationFrame(() => {
      injectScheduled = false;
      injectStars();
    });
  }

  function checkForMissingStars() {
    const actionBars = document.querySelectorAll(
      '.topic-post article[data-post-id] .post-action-menu__row, .topic-post article[data-post-id] nav.post-controls .actions, .topic-post article[data-post-id] .post-controls .actions'
    );
    for (const bar of actionBars) {
      if (!bar.querySelector(`.${STAR_CLASS}`)) {
        const article = bar.closest('article[data-post-id]');
        article?.removeAttribute('data-ldsm-star-injected');
      }
    }

    const hasTitleStar = !!document.querySelector('.ldsm-star-topic-btn');
    const hasTitle = !!document.querySelector('#topic-title h1, #topic-title .fancy-title, .title-wrapper h1');
    const needsInject = (hasTitle && !hasTitleStar) || !!document.querySelector('.topic-post article[data-post-id]:not([data-ldsm-star-injected])');
    if (needsInject) injectStars();
  }

  function observePosts() {
    if (postsObserverStarted) return;
    postsObserverStarted = true;
    const observer = new MutationObserver(scheduleInject);
    observer.observe(document.querySelector('#main-outlet') || document.body, {
      childList: true,
      subtree: true,
    });

    let scrollTimer = null;
    const onScroll = () => {
      if (scrollTimer) return;
      scrollTimer = setTimeout(() => {
        scrollTimer = null;
        checkForMissingStars();
      }, 300);
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    document.querySelector('#main-outlet')?.addEventListener('scroll', onScroll, { passive: true });
    setInterval(checkForMissingStars, 5000);
  }

  function onRouteChange() {
    if (location.href === lastUrl) return;
    lastUrl = location.href;
    closeCollectionPicker();
    if (!isTopicPage()) {
      document.querySelectorAll(`.${STAR_CLASS}, .ldsm-star-topic-btn`).forEach(element => element.remove());
      return;
    }
    setTimeout(() => {
      document.querySelectorAll('[data-ldsm-star-injected]').forEach(element => element.removeAttribute('data-ldsm-star-injected'));
      document.querySelectorAll(`.${STAR_CLASS}, .ldsm-star-topic-btn`).forEach(element => element.remove());
      waitAndInject();
    }, 800);
  }

  function watchRouteChanges() {
    if (routeObserverStarted) return;
    routeObserverStarted = true;
    const originalPushState = history.pushState;
    const originalReplaceState = history.replaceState;
    history.pushState = function (...args) {
      originalPushState.apply(this, args);
      onRouteChange();
    };
    history.replaceState = function (...args) {
      originalReplaceState.apply(this, args);
      onRouteChange();
    };
    window.addEventListener('popstate', onRouteChange);
    const title = document.querySelector('title');
    if (title) new MutationObserver(onRouteChange).observe(title, { childList: true });
    setInterval(onRouteChange, 1000);
  }

  function waitAndInject() {
    const timer = setInterval(() => {
      if (document.querySelector('.topic-post article[data-post-id]')) {
        clearInterval(timer);
        injectStars();
        observePosts();
      }
    }, 300);
    setTimeout(() => clearInterval(timer), 15_000);
  }

  // ========================= Manager UI =========================
  function ensureManager() {
    if (managerReady) return;
    managerReady = true;

    const root = document.createElement('div');
    root.id = 'ldsm-root';
    root.innerHTML = `
      <button class="ldsm-fab" id="ldsmFab" type="button" title="收藏管理">
        <span class="ldsm-fab-star">★</span>
        <span class="ldsm-fab-count" id="ldsmFabCount"></span>
      </button>
      <div class="ldsm-shade" id="ldsmShade"></div>
      <aside class="ldsm-manager" id="ldsmManager" aria-label="LinuxDo Star 收藏管理">
        <div class="ldsm-manager-resizer" id="ldsmManagerResizer" title="拖拽调整宽度" aria-hidden="true"></div>
        <aside class="ldsm-sidebar">
          <div class="ldsm-sidebar-head">
            <span class="ldsm-logo">★</span>
            <span class="ldsm-logo-text">收藏管理</span>
            <button class="ldsm-icon-btn ldsm-mobile-close" id="ldsmCloseMobile" type="button" title="关闭">${svgX()}</button>
          </div>
          <nav class="ldsm-nav" id="ldsmNav"></nav>
          <div class="ldsm-sidebar-mid">
            <button class="ldsm-btn ldsm-btn-outline ldsm-btn-full" id="ldsmNewCollection" type="button">新建收藏夹</button>
            <button class="ldsm-btn ldsm-btn-outline ldsm-btn-full ldsm-sync-button" id="ldsmSyncButton" type="button">
              ${svgSync()}<span id="ldsmSyncText">同步设置</span>
            </button>
            <label class="ldsm-check-label ldsm-sidebar-toggle"><input type="checkbox" id="ldsmFabToggle"> 悬浮入口</label>
            <div class="ldsm-total" id="ldsmTotal"></div>
          </div>
          <div class="ldsm-sidebar-foot">
            <button class="ldsm-btn ldsm-btn-outline" id="ldsmImport" type="button">导入</button>
            <button class="ldsm-btn ldsm-btn-outline" id="ldsmExport" type="button">导出</button>
            <button class="ldsm-btn ldsm-btn-danger" id="ldsmClear" type="button">清空</button>
          </div>
        </aside>
        <main class="ldsm-main">
          <div class="ldsm-toolbar">
            <div class="ldsm-search">
              ${svgSearch()}<input id="ldsmSearchInput" placeholder="搜索标题、作者、内容">
            </div>
            <div class="ldsm-toolbar-right">
              <div class="ldsm-batch-bar" id="ldsmBatchBar">
                <span id="ldsmBatchCount">已选 0 项</span>
                <button class="ldsm-btn ldsm-btn-danger" id="ldsmBatchDelete" type="button">批量删除</button>
                <button class="ldsm-btn ldsm-btn-outline" id="ldsmBatchCancel" type="button">取消</button>
              </div>
              <label class="ldsm-check-label"><input type="checkbox" id="ldsmBatchMode"> 多选</label>
              <select class="ldsm-select" id="ldsmSort">
                <option value="custom">自定义排序</option>
                <option value="newest">最新收藏</option>
                <option value="oldest">最早收藏</option>
                <option value="title">按标题</option>
              </select>
              <button class="ldsm-icon-btn ldsm-close" id="ldsmClose" type="button" title="关闭">${svgX()}</button>
            </div>
          </div>
          <div class="ldsm-content" id="ldsmContent"></div>
        </main>
        <div class="ldsm-subshade" id="ldsmSubshade"></div>
        <aside class="ldsm-panel" id="ldsmPanel">
          <div class="ldsm-panel-head">
            <h2 id="ldsmPanelTitle">详情</h2>
            <button class="ldsm-icon-btn" id="ldsmPanelClose" type="button" title="关闭">${svgX()}</button>
          </div>
          <div class="ldsm-panel-body" id="ldsmPanelBody"></div>
        </aside>
      </aside>
      <input type="file" id="ldsmImportFile" accept=".json" hidden>
    `;
    document.body.appendChild(root);

    $('#ldsmFab').addEventListener('pointerdown', handleFabPointerDown);
    $('#ldsmFab').addEventListener('pointermove', handleFabPointerMove);
    $('#ldsmFab').addEventListener('pointerup', handleFabPointerUp);
    $('#ldsmFab').addEventListener('pointercancel', handleFabPointerCancel);
    $('#ldsmFab').addEventListener('click', handleFabClick);
    $('#ldsmManagerResizer').addEventListener('pointerdown', handleManagerResizePointerDown);
    $('#ldsmManagerResizer').addEventListener('pointermove', handleManagerResizePointerMove);
    $('#ldsmManagerResizer').addEventListener('pointerup', event => finishManagerResize(event, true));
    $('#ldsmManagerResizer').addEventListener('pointercancel', event => finishManagerResize(event, false));
    $('#ldsmShade').addEventListener('click', closeManager);
    $('#ldsmClose').addEventListener('click', closeManager);
    $('#ldsmCloseMobile').addEventListener('click', closeManager);
    $('#ldsmPanelClose').addEventListener('click', closePanel);
    $('#ldsmSubshade').addEventListener('click', closePanel);
    $('#ldsmSearchInput').addEventListener('input', debounce(event => {
      managerState.query = event.target.value.toLowerCase().trim();
      renderManager();
    }, 120));
    $('#ldsmSort').addEventListener('change', event => {
      managerState.sort = event.target.value;
      renderManager();
    });
    $('#ldsmBatchMode').addEventListener('change', event => {
      managerState.batchMode = event.target.checked;
      managerState.selected.clear();
      updateBatchBar();
      renderManager();
    });
    $('#ldsmBatchCancel').addEventListener('click', () => {
      managerState.batchMode = false;
      managerState.selected.clear();
      $('#ldsmBatchMode').checked = false;
      updateBatchBar();
      renderManager();
    });
    $('#ldsmBatchDelete').addEventListener('click', batchDelete);
    $('#ldsmNav').addEventListener('click', handleNavClick);
    $('#ldsmNav').addEventListener('dragstart', handleNavDragStart);
    $('#ldsmNav').addEventListener('dragover', handleNavDragOver);
    $('#ldsmNav').addEventListener('dragleave', handleDragLeave);
    $('#ldsmNav').addEventListener('drop', handleNavDrop);
    $('#ldsmNav').addEventListener('dragend', handleDragEnd);
    $('#ldsmContent').addEventListener('click', handleContentClick);
    $('#ldsmContent').addEventListener('dragstart', handleContentDragStart);
    $('#ldsmContent').addEventListener('dragover', handleContentDragOver);
    $('#ldsmContent').addEventListener('dragleave', handleDragLeave);
    $('#ldsmContent').addEventListener('drop', handleContentDrop);
    $('#ldsmContent').addEventListener('dragend', handleDragEnd);
    $('#ldsmNewCollection').addEventListener('click', createCollectionFromManager);
    $('#ldsmSyncButton').addEventListener('click', openSyncPanel);
    $('#ldsmFabToggle').addEventListener('change', event => {
      saveUiConfig({ showFab: event.target.checked });
      showToast(event.target.checked ? '已显示悬浮入口' : '已隐藏悬浮入口');
    });
    $('#ldsmExport').addEventListener('click', exportStore);
    $('#ldsmImport').addEventListener('click', () => $('#ldsmImportFile').click());
    $('#ldsmImportFile').addEventListener('change', importStore);
    $('#ldsmClear').addEventListener('click', clearStore);
    document.addEventListener('keydown', event => {
      if (event.key !== 'Escape') return;
      if (managerState.panelOpen) closePanel();
      else if (isManagerOpen()) closeManager();
    });
    window.addEventListener('resize', debounce(() => {
      applyUiConfig();
      refreshFabPositionIfViewportChanged();
    }, 120));
    applyUiConfig();
    startFabViewportWatcher();
  }

  function $(selector) {
    return document.querySelector(selector);
  }

  function isManagerOpen() {
    return !!document.querySelector('#ldsmManager.ldsm-open');
  }

  async function openManager() {
    ensureManager();
    $('#ldsmManager').classList.add('ldsm-open');
    $('#ldsmShade').classList.add('ldsm-open');
    document.body.classList.add('ldsm-body-lock');
    await reloadManager();
  }

  function closeManager() {
    closePanel();
    $('#ldsmManager')?.classList.remove('ldsm-open');
    $('#ldsmShade')?.classList.remove('ldsm-open');
    document.body.classList.remove('ldsm-body-lock');
  }

  async function reloadManager() {
    if (!managerReady) return;
    managerState.store = await StarStorage.getAll();
    renderNav();
    renderManager();
    renderSyncStatus();
  }

  async function refreshFabCount() {
    ensureManager();
    const store = await StarStorage.getAll();
    const count = countStore(store);
    const badge = $('#ldsmFabCount');
    if (!badge) return;
    const total = count.topics + count.posts;
    badge.textContent = total ? String(total) : '';
    badge.style.display = total ? 'inline-flex' : 'none';
  }

  function renderNav() {
    const store = managerState.store;
    if (!store) return;
    const counts = { all: 0 };
    for (const [, bookmark] of aliveBookmarks(store)) {
      const cid = bookmark.collectionId || 'default';
      counts[cid] = (counts[cid] || 0) + 1;
      counts.all++;
    }

    const postCount = aliveBookmarks(store).reduce((total, [, bookmark]) => total + alivePosts(bookmark).length, 0);
    $('#ldsmTotal').textContent = `${counts.all || 0} 帖 · ${postCount} 评`;

    const collections = sortedCollections(store);
    $('#ldsmNav').innerHTML = `
      <button class="ldsm-nav-item${managerState.currentView === 'all' ? ' active' : ''}" data-view="all" type="button">
        ${svgHome()}<span>全部</span><span class="ldsm-nav-count">${counts.all || 0}</span>
      </button>
      ${collections.map(col => `
        <button class="ldsm-nav-item${managerState.currentView === col.id ? ' active' : ''}" data-view="${attr(col.id)}" type="button" ${col.id !== 'default' ? 'draggable="true"' : ''}>
          <span class="ldsm-nav-icon">${h(col.icon || '📁')}</span>
          <span class="ldsm-nav-name">${h(col.name)}</span>
          <span class="ldsm-nav-count">${counts[col.id] || 0}</span>
          ${col.id !== 'default' ? `<span class="ldsm-nav-edit" data-act="edit-col" data-cid="${attr(col.id)}">...</span>` : ''}
        </button>
      `).join('')}
    `;
  }

  function renderManager() {
    const store = managerState.store;
    if (!store) return;
    const content = $('#ldsmContent');
    const query = managerState.query;
    let items = [];

    for (const [key, bookmark] of aliveBookmarks(store)) {
      if (managerState.currentView !== 'all' && (bookmark.collectionId || 'default') !== managerState.currentView) continue;
      let match = !query;
      if (query) {
        if (bookmark.topicTitle?.toLowerCase().includes(query)) match = true;
        if ((bookmark.tags || []).some(tag => tag.toLowerCase().includes(query))) match = true;
        if (bookmark.note?.toLowerCase().includes(query)) match = true;
        if (alivePosts(bookmark).some(([, post]) => post.author?.toLowerCase().includes(query) || post.excerpt?.toLowerCase().includes(query))) match = true;
      }
      if (match) items.push({ key, ...bookmark });
    }

    if (managerState.sort === 'custom') {
      if (managerState.currentView === 'all') {
        sortBookmarksByCustomOrder(items, ALL_ORDER_KEY);
      } else {
        sortBookmarksByCustomOrder(items, managerState.currentView);
      }
    } else {
      items.sort((a, b) => {
        if (managerState.sort === 'oldest') return new Date(a.starredAt || a.updatedAt || 0) - new Date(b.starredAt || b.updatedAt || 0);
        if (managerState.sort === 'title') return (a.topicTitle || '').localeCompare(b.topicTitle || '', 'zh-CN');
        return new Date(b.starredAt || b.updatedAt || 0) - new Date(a.starredAt || a.updatedAt || 0);
      });
    }

    if (!items.length) {
      content.innerHTML = `
        <div class="ldsm-empty">
          ${STAR_SVG}
          <h3>${query ? '无匹配' : '暂无收藏'}</h3>
          <p>在帖子页点击星标开始收藏</p>
        </div>
      `;
      return;
    }

    content.innerHTML = items.map(item => renderTopicCard(item, store)).join('');
  }

  function renderTopicCard(item, store) {
    const posts = alivePosts(item)
      .map(([postKey, post]) => ({ postKey, ...post }))
      .sort((a, b) => new Date(b.starredAt || 0) - new Date(a.starredAt || 0));
    const isOpen = managerState.expanded.has(item.key);
    const collection = store.collections[item.collectionId] || store.collections.default;
    const sortable = canReorderCurrentView();

    return `
      <div class="ldsm-card${isOpen ? ' open' : ''}${sortable ? ' ldsm-card-sortable' : ''}" data-key="${attr(item.key)}" ${!managerState.batchMode ? 'draggable="true"' : ''}>
        <div class="ldsm-card-head">
          ${sortable ? '<span class="ldsm-drag-handle" draggable="true" title="拖拽排序" aria-label="拖拽排序">⋮⋮</span>' : ''}
          ${managerState.batchMode
            ? `<input type="checkbox" class="ldsm-card-check" data-key="${attr(item.key)}" ${managerState.selected.has(item.key) ? 'checked' : ''}>`
            : svgChevron()}
          <span class="ldsm-card-star">★</span>
          <div class="ldsm-card-body">
            <div class="ldsm-card-title"><a href="${attr(item.topicUrl)}" target="_blank" rel="noopener">${h(item.topicTitle || '未知标题')}</a></div>
            <div class="ldsm-card-meta">
              ${item.category ? `<span class="ldsm-tag">${h(item.category)}</span>` : ''}
              ${managerState.currentView === 'all' ? `<span class="ldsm-tag">${h(collection?.name || '默认收藏夹')}</span>` : ''}
              ${(item.tags || []).slice(0, 3).map(tag => `<span class="ldsm-tag ldsm-tag-note">${h(tag)}</span>`).join('')}
              ${(item.tags || []).length > 3 ? `<span class="ldsm-tag">+${item.tags.length - 3}</span>` : ''}
              ${item.note ? '<span class="ldsm-tag ldsm-tag-note">备注</span>' : ''}
              <span class="ldsm-time">${formatTime(item.starredAt || item.updatedAt)}</span>
              ${posts.length ? `<span class="ldsm-tag ldsm-comment-count">${posts.length} 评论</span>` : ''}
            </div>
          </div>
          <div class="ldsm-card-actions">
            <button class="ldsm-icon-btn" data-act="detail-t" data-key="${attr(item.key)}" type="button" title="详情">${svgInfo()}</button>
            <button class="ldsm-icon-btn" data-act="move" data-tkey="${attr(item.key)}" type="button" title="移动">${svgFolder()}</button>
            <button class="ldsm-icon-btn ldsm-danger-hover" data-act="del-t" data-key="${attr(item.key)}" type="button" title="删除">${svgX()}</button>
          </div>
        </div>
        ${posts.length ? `<div class="ldsm-card-posts">
          ${posts.map(post => `
            <div class="ldsm-post-row" data-url="${attr(post.postUrl)}" data-tkey="${attr(item.key)}" ${!managerState.batchMode ? 'draggable="true"' : ''}>
              <span class="ldsm-post-num">#${h(post.postNumber)}</span>
              <div class="ldsm-post-info">
                <span class="ldsm-post-author">@${h(post.author || '?')}</span>
                <div class="ldsm-post-excerpt">${h(post.excerpt || '')}</div>
                ${post.note ? `<div class="ldsm-post-note">${h(post.note)}</div>` : ''}
                <div class="ldsm-post-time">${formatTime(post.starredAt || post.updatedAt)}</div>
              </div>
              <div class="ldsm-post-actions">
                <button class="ldsm-icon-btn" data-act="detail-p" data-tkey="${attr(item.key)}" data-pkey="${attr(post.postKey)}" type="button" title="详情">${svgInfo()}</button>
                <button class="ldsm-icon-btn" data-act="move" data-tkey="${attr(item.key)}" data-pkey="${attr(post.postKey)}" type="button" title="移动">${svgFolder()}</button>
                <button class="ldsm-icon-btn ldsm-danger-hover" data-act="del-p" data-tkey="${attr(item.key)}" data-pkey="${attr(post.postKey)}" type="button" title="删除">${svgX()}</button>
              </div>
            </div>
          `).join('')}
        </div>` : ''}
      </div>
    `;
  }

  function handleNavClick(event) {
    if (managerState.ignoreNextClick) {
      event.preventDefault();
      return;
    }
    const edit = event.target.closest('[data-act="edit-col"]');
    if (edit) {
      event.stopPropagation();
      openCollectionPanel(edit.dataset.cid);
      return;
    }

    const item = event.target.closest('.ldsm-nav-item');
    if (!item) return;
    managerState.currentView = item.dataset.view;
    renderNav();
    renderManager();
  }

  function handleNavDragStart(event) {
    const item = event.target.closest('.ldsm-nav-item[data-view]');
    if (!item || item.dataset.view === 'all' || item.dataset.view === 'default' || event.target.closest('[data-act]')) return;
    setDragPayload(event, { type: 'manager-collection', collectionId: item.dataset.view });
    item.classList.add('ldsm-dragging');
  }

  function handleNavDragOver(event) {
    const payload = getDragPayload(event);
    const item = event.target.closest('.ldsm-nav-item[data-view]');
    if (!payload || !item || item.dataset.view === 'all') return;

    if (payload.type === 'manager-collection') {
      if (item.dataset.view === 'default' || item.dataset.view === payload.collectionId) return;
      event.preventDefault();
      event.dataTransfer.dropEffect = 'move';
      markDragTarget(item, getDropPosition(event, item));
      return;
    }

    if (payload.type === 'topic') {
      event.preventDefault();
      event.dataTransfer.dropEffect = 'move';
      item.classList.add('ldsm-drag-over');
    }
  }

  async function handleNavDrop(event) {
    const payload = getDragPayload(event);
    const item = event.target.closest('.ldsm-nav-item[data-view]');
    if (!payload || !item || item.dataset.view === 'all') return;
    event.preventDefault();

    if (payload.type === 'manager-collection') {
      if (item.dataset.view === 'default' || item.dataset.view === payload.collectionId) return;
      const position = getDropPosition(event, item);
      const ids = sortedCollections(await StarStorage.getAll()).map(col => col.id).filter(id => id !== 'default');
      await StarStorage.reorderCollections(reorderIds(ids, payload.collectionId, item.dataset.view, position));
      showToast('已调整收藏夹顺序');
    } else if (payload.type === 'topic') {
      await StarStorage.moveToCollection(payload.topicKey, item.dataset.view, null);
      showToast('已移动到收藏夹');
    }

    clearDragMarks();
    await reloadManager();
    suppressNextClick();
  }

  async function handleContentClick(event) {
    if (managerState.ignoreNextClick) {
      event.preventDefault();
      return;
    }
    if (event.target.closest('.ldsm-drag-handle')) {
      event.preventDefault();
      return;
    }
    const checkbox = event.target.closest('.ldsm-card-check');
    if (checkbox) {
      const key = checkbox.dataset.key;
      if (checkbox.checked) managerState.selected.add(key);
      else managerState.selected.delete(key);
      updateBatchBar();
      return;
    }

    const action = event.target.closest('[data-act]');
    if (action) {
      event.preventDefault();
      event.stopPropagation();
      const act = action.dataset.act;
      if (act === 'del-t') await confirmThen(`删除「${managerState.store.bookmarks[action.dataset.key]?.topicTitle || '该帖子'}」?`, () => StarStorage.softDeleteTopic(action.dataset.key));
      if (act === 'del-p') await confirmThen('删除这条评论收藏?', () => StarStorage.softDeletePost(action.dataset.tkey, action.dataset.pkey));
      if (act === 'detail-t') openTopicPanel(action.dataset.key);
      if (act === 'detail-p') openPostPanel(action.dataset.tkey, action.dataset.pkey);
      if (act === 'move') openMovePanel(action.dataset.tkey, action.dataset.pkey || null);
      return;
    }

    const postRow = event.target.closest('.ldsm-post-row');
    if (postRow?.dataset.url) {
      openExternal(postRow.dataset.url);
      return;
    }

    const head = event.target.closest('.ldsm-card-head');
    if (!head || event.target.closest('a')) return;
    const card = head.closest('.ldsm-card');
    const key = card.dataset.key;
    if (managerState.batchMode) {
      const input = card.querySelector('.ldsm-card-check');
      input.checked = !input.checked;
      if (input.checked) managerState.selected.add(key);
      else managerState.selected.delete(key);
      updateBatchBar();
      return;
    }
    card.classList.toggle('open');
    if (managerState.expanded.has(key)) managerState.expanded.delete(key);
    else managerState.expanded.add(key);
  }

  function handleContentDragStart(event) {
    if (managerState.batchMode) return;
    const handle = event.target.closest('.ldsm-drag-handle');
    if (!handle && event.target.closest('[data-act], a, input, textarea, select, button')) return;
    const row = event.target.closest('.ldsm-post-row[data-tkey]');
    const card = event.target.closest('.ldsm-card[data-key]');
    const topicKey = row?.dataset.tkey || card?.dataset.key;
    const dragElement = handle ? card : (row || card);
    if (!topicKey || !dragElement) return;
    setDragPayload(event, {
      type: 'topic',
      topicKey,
      sourceCollectionId: managerState.currentView,
    });
    dragElement.classList.add('ldsm-dragging');
  }

  function handleContentDragOver(event) {
    const payload = getDragPayload(event);
    if (!payload || payload.type !== 'topic' || !canReorderCurrentView()) return;
    const card = event.target.closest('.ldsm-card[data-key]');
    if (!card || card.dataset.key === payload.topicKey) return;
    event.preventDefault();
    event.dataTransfer.dropEffect = 'move';
    markDragTarget(card, getDropPosition(event, card));
  }

  async function handleContentDrop(event) {
    const payload = getDragPayload(event);
    if (!payload || payload.type !== 'topic' || !canReorderCurrentView()) return;
    const card = event.target.closest('.ldsm-card[data-key]');
    if (!card || card.dataset.key === payload.topicKey) return;
    event.preventDefault();
    const ids = Array.from($('#ldsmContent').querySelectorAll('.ldsm-card[data-key]')).map(item => item.dataset.key);
    const nextIds = reorderIds(ids, payload.topicKey, card.dataset.key, getDropPosition(event, card));
    await StarStorage.reorderTopics(managerState.currentView, nextIds);
    clearDragMarks();
    await reloadManager();
    showToast('已调整收藏顺序');
    suppressNextClick();
  }

  function handleDragLeave(event) {
    const target = event.target.closest?.('.ldsm-nav-item, .ldsm-card, .ldsm-picker-item');
    if (!target || target.contains(event.relatedTarget)) return;
    target.classList.remove('ldsm-drag-over', 'ldsm-drag-before', 'ldsm-drag-after');
  }

  function handleDragEnd() {
    clearDragMarks();
    managerState.dragging = null;
  }

  async function createCollectionFromManager() {
    openPanel('新建收藏夹', `
      <div class="ldsm-field">
        <label>名称</label>
        <input class="ldsm-input" id="ldsmColName" placeholder="收藏夹名称">
      </div>
      <div class="ldsm-field">
        <label>图标</label>
        <div class="ldsm-icon-grid" id="ldsmIconGrid">
          ${ICONS.map((icon, index) => `<button class="ldsm-icon-opt${index === 0 ? ' active' : ''}" data-icon="${attr(icon)}" type="button">${h(icon)}</button>`).join('')}
        </div>
      </div>
      <button class="ldsm-btn ldsm-btn-primary" id="ldsmSaveCollection" type="button">保存</button>
    `);
    let icon = ICONS[0];
    $('#ldsmIconGrid').addEventListener('click', event => {
      const item = event.target.closest('.ldsm-icon-opt');
      if (!item) return;
      $('#ldsmIconGrid').querySelectorAll('.ldsm-icon-opt').forEach(el => el.classList.remove('active'));
      item.classList.add('active');
      icon = item.dataset.icon;
    });
    $('#ldsmSaveCollection').addEventListener('click', async () => {
      const name = $('#ldsmColName').value.trim();
      if (!name) return;
      await StarStorage.createCollection(name, icon);
      closePanel();
      showToast('已新建收藏夹');
    });
    $('#ldsmColName').focus();
  }

  function openCollectionPanel(collectionId) {
    const collection = managerState.store.collections[collectionId];
    if (!collection) return;
    openPanel('编辑收藏夹', `
      <div class="ldsm-field">
        <label>名称</label>
        <input class="ldsm-input" id="ldsmColName" value="${attr(collection.name)}">
      </div>
      <div class="ldsm-field">
        <label>图标</label>
        <div class="ldsm-icon-grid" id="ldsmIconGrid">
          ${ICONS.map(icon => `<button class="ldsm-icon-opt${icon === collection.icon ? ' active' : ''}" data-icon="${attr(icon)}" type="button">${h(icon)}</button>`).join('')}
        </div>
      </div>
      <div class="ldsm-action-row">
        <button class="ldsm-btn ldsm-btn-primary" id="ldsmSaveCollection" type="button">保存</button>
        <button class="ldsm-btn ldsm-btn-danger" id="ldsmDeleteCollection" type="button">删除收藏夹</button>
      </div>
    `);
    let icon = collection.icon || '📁';
    $('#ldsmIconGrid').addEventListener('click', event => {
      const item = event.target.closest('.ldsm-icon-opt');
      if (!item) return;
      $('#ldsmIconGrid').querySelectorAll('.ldsm-icon-opt').forEach(el => el.classList.remove('active'));
      item.classList.add('active');
      icon = item.dataset.icon;
    });
    $('#ldsmSaveCollection').addEventListener('click', async () => {
      const name = $('#ldsmColName').value.trim();
      if (!name) return;
      await StarStorage.updateCollection(collectionId, { name, icon });
      closePanel();
      showToast('已保存收藏夹');
    });
    $('#ldsmDeleteCollection').addEventListener('click', async () => {
      await confirmThen('删除该收藏夹?其中收藏会移到默认收藏夹。', async () => {
        await StarStorage.deleteCollection(collectionId);
        if (managerState.currentView === collectionId) managerState.currentView = 'all';
        closePanel();
      });
    });
  }

  function openTopicPanel(key) {
    const topic = managerState.store.bookmarks[key];
    if (!topic) return;
    const collection = managerState.store.collections[topic.collectionId] || managerState.store.collections.default;
    openPanel('帖子详情', `
      <div class="ldsm-field"><label>标题</label><div class="ldsm-field-value"><a href="${attr(topic.topicUrl)}" target="_blank" rel="noopener">${h(topic.topicTitle)}</a></div></div>
      <div class="ldsm-field"><label>收藏夹</label><div class="ldsm-field-value">${h(collection?.name || '默认收藏夹')}</div></div>
      <div class="ldsm-field"><label>分类</label><div class="ldsm-field-value">${h(topic.category || '-')}</div></div>
      <div class="ldsm-field"><label>收藏时间</label><div class="ldsm-field-value">${topic.starredAt ? h(new Date(topic.starredAt).toLocaleString('zh-CN')) : '-'}</div></div>
      <div class="ldsm-field"><label>标签</label>${renderTagEditor(topic.tags || [])}</div>
      <div class="ldsm-field"><label>备注</label><textarea class="ldsm-textarea" id="ldsmNoteInput" placeholder="写点备注">${h(topic.note || '')}</textarea></div>
      <button class="ldsm-btn ldsm-btn-primary" id="ldsmSaveDetail" type="button">保存</button>
    `);
    bindTagAndNoteEditor(async tags => {
      const store = await StarStorage.getAll();
      const target = store.bookmarks[key];
      if (!target) return;
      target.tags = tags;
      target.note = $('#ldsmNoteInput').value.trim();
      target.updatedAt = nowIso();
      await StarStorage.save(store);
      closePanel();
      showToast('已保存');
    });
  }

  function openPostPanel(topicKey, postKey) {
    const topic = managerState.store.bookmarks[topicKey];
    const post = topic?.posts?.[postKey];
    if (!topic || !post) return;
    openPanel(`#${post.postNumber} 评论详情`, `
      <div class="ldsm-field"><label>帖子</label><div class="ldsm-field-value"><a href="${attr(topic.topicUrl)}" target="_blank" rel="noopener">${h(topic.topicTitle)}</a></div></div>
      <div class="ldsm-field"><label>作者</label><div class="ldsm-field-value">@${h(post.author || '?')}</div></div>
      <div class="ldsm-field"><label>内容</label><div class="ldsm-field-value">${h(post.excerpt || '')}</div></div>
      <div class="ldsm-field"><label>链接</label><div class="ldsm-field-value"><a href="${attr(post.postUrl)}" target="_blank" rel="noopener">打开原文</a></div></div>
      <div class="ldsm-field"><label>收藏时间</label><div class="ldsm-field-value">${post.starredAt ? h(new Date(post.starredAt).toLocaleString('zh-CN')) : '-'}</div></div>
      <div class="ldsm-field"><label>标签</label>${renderTagEditor(post.tags || [])}</div>
      <div class="ldsm-field"><label>备注</label><textarea class="ldsm-textarea" id="ldsmNoteInput" placeholder="写点备注">${h(post.note || '')}</textarea></div>
      <button class="ldsm-btn ldsm-btn-primary" id="ldsmSaveDetail" type="button">保存</button>
    `);
    bindTagAndNoteEditor(async tags => {
      const store = await StarStorage.getAll();
      const target = store.bookmarks[topicKey]?.posts?.[postKey];
      if (!target) return;
      target.tags = tags;
      target.note = $('#ldsmNoteInput').value.trim();
      target.updatedAt = nowIso();
      store.bookmarks[topicKey].updatedAt = nowIso();
      await StarStorage.save(store);
      closePanel();
      showToast('已保存');
    });
  }

  function renderTagEditor(tags) {
    return `
      <div class="ldsm-tag-editor" id="ldsmTagEditor">
        ${tags.map(tag => `<span class="ldsm-tag-pill" data-tag="${attr(tag)}">${h(tag)}<button type="button" title="移除">×</button></span>`).join('')}
        <input id="ldsmTagInput" placeholder="回车添加">
      </div>
    `;
  }

  function bindTagAndNoteEditor(onSave) {
    let tags = Array.from($('#ldsmTagEditor').querySelectorAll('.ldsm-tag-pill')).map(tag => tag.dataset.tag);
    const render = () => {
      $('#ldsmTagEditor').innerHTML = `
        ${tags.map(tag => `<span class="ldsm-tag-pill" data-tag="${attr(tag)}">${h(tag)}<button type="button" title="移除">×</button></span>`).join('')}
        <input id="ldsmTagInput" placeholder="回车添加">
      `;
      bind();
    };
    const bind = () => {
      $('#ldsmTagEditor').querySelectorAll('.ldsm-tag-pill button').forEach(button => {
        button.addEventListener('click', event => {
          const tag = event.target.closest('.ldsm-tag-pill').dataset.tag;
          tags = tags.filter(item => item !== tag);
          render();
        });
      });
      $('#ldsmTagInput').addEventListener('keydown', event => {
        if (event.key !== 'Enter') return;
        event.preventDefault();
        const value = event.target.value.trim();
        if (value && !tags.includes(value)) {
          tags.push(value);
          render();
        }
      });
    };
    bind();
    $('#ldsmSaveDetail').addEventListener('click', () => onSave(tags));
  }

  function openMovePanel(topicKey, postKey) {
    const collections = sortedCollections(managerState.store);
    const current = postKey
      ? managerState.store.bookmarks[topicKey]?.posts?.[postKey]?.collectionId
      : managerState.store.bookmarks[topicKey]?.collectionId;
    openPanel('移动到收藏夹', `
      <div class="ldsm-move-list">
        ${collections.map(col => `
          <button class="ldsm-move-item${col.id === (current || 'default') ? ' active' : ''}" data-cid="${attr(col.id)}" type="button">
            <span>${h(col.icon || '📁')}</span><span>${h(col.name)}</span>
            ${col.id === (current || 'default') ? '<span class="ldsm-move-check">✓</span>' : ''}
          </button>
        `).join('')}
      </div>
    `);
    const panelBody = $('#ldsmPanelBody');
    const onMoveClick = async event => {
      const item = event.target.closest('.ldsm-move-item');
      if (!item) return;
      panelBody.removeEventListener('click', onMoveClick);
      await StarStorage.moveToCollection(topicKey, item.dataset.cid, postKey || null);
      closePanel();
      showToast('已移动');
    };
    panelBody.addEventListener('click', onMoveClick);
  }

  async function exportStore() {
    const store = await StarStorage.getAll();
    const total = aliveBookmarks(store).length;
    if (!total) {
      showToast('暂无收藏可导出', '☆');
      return;
    }
    downloadText(
      JSON.stringify({ exportedAt: nowIso(), v: '1.0-tampermonkey', data: store }, null, 2),
      `linuxdo-stars-${new Date().toISOString().slice(0, 10)}.json`
    );
  }

  async function importStore(event) {
    const file = event.target.files?.[0];
    if (!file) return;
    if (file.size > MAX_IMPORT_BYTES) {
      showToast('导入文件过大', '⚠');
      event.target.value = '';
      return;
    }
    try {
      const json = JSON.parse(await file.text());
      const imported = normalizeStore(json.data || json);
      const current = await StarStorage.getAll();
      const merged = SyncManager.merge(current, imported);
      await StarStorage.save(merged);
      showToast('导入成功');
    } catch {
      showToast('文件格式错误', '⚠');
    } finally {
      event.target.value = '';
    }
  }

  async function clearStore() {
    await confirmThen('清空全部收藏?该操作会生成同步删除记录。', async () => {
      const store = await StarStorage.getAll();
      const time = nowIso();
      for (const [key, bookmark] of Object.entries(store.bookmarks)) {
        store.bookmarks[key] = {
          _deleted: true,
          _deletedAt: time,
          topicId: bookmark.topicId,
        };
      }
      await StarStorage.save(store);
      showToast('已清空收藏');
    });
  }

  async function batchDelete() {
    if (!managerState.selected.size) return;
    await confirmThen(`删除选中的 ${managerState.selected.size} 个帖子?`, async () => {
      for (const key of managerState.selected) await StarStorage.softDeleteTopic(key);
      managerState.selected.clear();
      managerState.batchMode = false;
      $('#ldsmBatchMode').checked = false;
      updateBatchBar();
      showToast('已删除');
    });
  }

  function updateBatchBar() {
    const bar = $('#ldsmBatchBar');
    if (!bar) return;
    bar.classList.toggle('visible', managerState.batchMode);
    $('#ldsmBatchCount').textContent = `已选 ${managerState.selected.size} 项`;
  }

  async function confirmThen(message, onYes) {
    if (!window.confirm(message)) return;
    await onYes();
  }

  async function openSyncPanel() {
    const config = await SyncManager.getConfig();
    const connected = !!(config.token && config.gistId);

    if (!connected) {
      openPanel('同步设置', `
        <div class="ldsm-sync-field">
          <label>GitHub Personal Access Token</label>
          <input class="ldsm-input ldsm-mono" id="ldsmTokenInput" type="password" placeholder="ghp_xxxxxxxxxxxx">
          <div class="ldsm-help">
            需要 gist 权限的 Token。<a href="https://github.com/settings/tokens/new?scopes=gist&description=LinuxDo+Star+Sync" target="_blank" rel="noopener">创建 Token</a>
          </div>
        </div>
        <button class="ldsm-btn ldsm-btn-primary" id="ldsmConnectSync" type="button">连接 GitHub</button>
        <div id="ldsmSyncMessage"></div>
      `);
      $('#ldsmConnectSync').addEventListener('click', async () => {
        const token = $('#ldsmTokenInput').value.trim();
        if (!token) return;
        const button = $('#ldsmConnectSync');
        button.disabled = true;
        button.textContent = '连接中...';
        const result = await SyncManager.connect(token);
        if (result.ok) {
          showToast('同步已连接');
          await reloadManager();
          openSyncPanel();
        } else {
          $('#ldsmSyncMessage').innerHTML = `<div class="ldsm-sync-error">${h(result.message)}</div>`;
          button.disabled = false;
          button.textContent = '连接 GitHub';
        }
      });
      return;
    }

    openPanel('同步设置', `
      <div class="ldsm-sync-field"><label>状态</label><div class="ldsm-sync-value"><span class="ldsm-sync-dot ${attr(config.status || 'connected')}"></span>${config.username ? `@${h(config.username)}` : '已连接'}</div></div>
      <div class="ldsm-sync-field"><label>Gist ID</label><div class="ldsm-sync-value ldsm-mono"><a href="https://gist.github.com/${attr(config.gistId)}" target="_blank" rel="noopener">${h(config.gistId)}</a></div></div>
      <div class="ldsm-sync-field"><label>上次同步</label><div class="ldsm-sync-value">${config.lastSyncAt ? h(new Date(config.lastSyncAt).toLocaleString('zh-CN')) : '从未'}</div></div>
      ${config.lastError ? `<div class="ldsm-sync-error">上次错误:${h(config.lastError)}</div>` : ''}
      <label class="ldsm-check-label ldsm-sync-toggle"><input type="checkbox" id="ldsmAutoSync" ${config.autoSync ? 'checked' : ''}> 自动同步</label>
      <div class="ldsm-action-row">
        <button class="ldsm-btn ldsm-btn-primary" id="ldsmSyncNow" type="button">${svgSync()}立即同步</button>
        <button class="ldsm-btn ldsm-btn-danger" id="ldsmDisconnectSync" type="button">断开连接</button>
      </div>
      <div id="ldsmSyncMessage"></div>
    `);

    $('#ldsmAutoSync').addEventListener('change', async event => {
      const next = await SyncManager.getConfig();
      next.autoSync = event.target.checked;
      await SyncManager.saveConfig(next);
      showToast(next.autoSync ? '已开启自动同步' : '已关闭自动同步');
    });
    $('#ldsmSyncNow').addEventListener('click', async () => {
      const button = $('#ldsmSyncNow');
      button.disabled = true;
      button.textContent = '同步中...';
      const result = await SyncManager.sync();
      if (result.ok) {
        $('#ldsmSyncMessage').innerHTML = '<div class="ldsm-sync-ok">同步成功</div>';
        showToast('同步成功');
      } else {
        $('#ldsmSyncMessage').innerHTML = `<div class="ldsm-sync-error">${h(result.message)}</div>`;
      }
      button.disabled = false;
      button.innerHTML = `${svgSync()}立即同步`;
    });
    $('#ldsmDisconnectSync').addEventListener('click', async () => {
      await SyncManager.disconnect();
      showToast('已断开同步');
      openSyncPanel();
    });
  }

  async function renderSyncStatus() {
    if (!managerReady) return;
    const config = await SyncManager.getConfig();
    const button = $('#ldsmSyncButton');
    const text = $('#ldsmSyncText');
    if (!button || !text) return;
    button.classList.toggle('syncing', config.status === 'syncing');
    if (!config.token || !config.gistId) text.textContent = '同步设置';
    else if (config.status === 'syncing') text.textContent = '同步中';
    else if (config.status === 'error') text.textContent = '同步失败';
    else text.textContent = '已同步';
  }

  function openPanel(title, bodyHtml) {
    ensureManager();
    $('#ldsmPanelTitle').textContent = title;
    const oldBody = $('#ldsmPanelBody');
    const body = oldBody.cloneNode(false);
    oldBody.replaceWith(body);
    body.innerHTML = bodyHtml;
    $('#ldsmPanel').classList.add('open');
    $('#ldsmSubshade').classList.add('open');
    managerState.panelOpen = true;
  }

  function closePanel() {
    $('#ldsmPanel')?.classList.remove('open');
    $('#ldsmSubshade')?.classList.remove('open');
    managerState.panelOpen = false;
  }

  // ========================= Icons =========================
  function svgX() {
    return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
  }

  function svgChevron() {
    return '<svg class="ldsm-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>';
  }

  function svgSearch() {
    return '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path d="M16 16l5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
  }

  function svgHome() {
    return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>';
  }

  function svgFolder() {
    return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>';
  }

  function svgInfo() {
    return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>';
  }

  function svgSync() {
    return '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>';
  }

  // ========================= Styles =========================
  function addStyles() {
    const css = `
      .ldsm-body-lock { overflow: hidden !important; }
      .${STAR_CLASS} { position: relative; cursor: pointer; }
      .${STAR_CLASS} * { pointer-events: none; }
      .${STAR_CLASS} .ldsm-star-icon { width: 1em; height: 1em; transition: transform 200ms ease; }
      .${STAR_CLASS} .ldsm-star-icon path { fill: none; stroke: var(--primary-medium, #919191); stroke-width: 1.5; stroke-linejoin: round; transition: fill 200ms ease, stroke 200ms ease; }
      .${STAR_CLASS}:hover .ldsm-star-icon path { stroke: #eab308; fill: rgba(234, 179, 8, .12); }
      .${STAR_CLASS}.${STAR_ACTIVE_CLASS} .ldsm-star-icon path { fill: #eab308; stroke: #eab308; }
      .${STAR_CLASS}.${STAR_ACTIVE_CLASS}:hover .ldsm-star-icon path { fill: #ca8a04; stroke: #ca8a04; }
      .ldsm-star-topic-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; margin-left: 6px; vertical-align: middle; background: transparent; border: 0; border-radius: 4px; cursor: pointer; padding: 0; transition: background 150ms ease; }
      .ldsm-star-topic-btn:hover { background: rgba(234, 179, 8, .12); }
      .ldsm-star-topic-btn .ldsm-star-icon { width: 20px; height: 20px; }
      @keyframes ldsm-star-pop { 0% { transform: scale(1); } 40% { transform: scale(1.35); } 70% { transform: scale(.9); } 100% { transform: scale(1); } }
      .ldsm-star-just-activated .ldsm-star-icon { animation: ldsm-star-pop 350ms cubic-bezier(.175,.885,.32,1.275); }

      .ldsm-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(16px); z-index: 999999; display: flex; align-items: center; gap: 8px; padding: 10px 18px; border-radius: 8px; background: #18181b; color: #fff; box-shadow: 0 10px 30px rgba(0,0,0,.22); font-size: 14px; font-weight: 500; opacity: 0; pointer-events: none; transition: opacity 220ms ease, transform 220ms ease; }
      .ldsm-toast-visible { opacity: 1; transform: translateX(-50%) translateY(0); }
      .ldsm-toast-icon { font-size: 16px; }

      .ldsm-picker { position: fixed; z-index: 999998; min-width: 220px; max-width: 270px; padding: 5px; border: 1px solid #e4e4e7; border-radius: 8px; background: #fff; color: #09090b; box-shadow: 0 12px 28px rgba(0,0,0,.15); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; }
      .ldsm-picker-header { display: flex; justify-content: space-between; padding: 6px 9px 3px; color: #71717a; font-size: 11px; font-weight: 600; }
      .ldsm-picker-search-wrap { padding: 4px 5px; }
      .ldsm-picker-search, .ldsm-picker-input { width: 100%; height: 29px; padding: 0 8px; border: 1px solid #e4e4e7; border-radius: 5px; background: #fff; color: #09090b; outline: none; font-size: 12px; }
      .ldsm-picker-search:focus, .ldsm-picker-input:focus { border-color: #a1a1aa; box-shadow: 0 0 0 2px rgba(0,0,0,.03); }
      .ldsm-picker-list { max-height: 220px; overflow-y: auto; padding: 2px 0; }
      .ldsm-picker-empty { padding: 12px 10px; text-align: center; color: #a1a1aa; font-size: 12px; }
      .ldsm-picker-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 9px; border: 0; background: transparent; border-radius: 5px; color: #09090b; cursor: pointer; text-align: left; }
      .ldsm-picker-item:hover, .ldsm-picker-item.active { background: #f4f4f5; }
      .ldsm-picker-item[draggable="true"], .ldsm-nav-item[draggable="true"], .ldsm-card[draggable="true"], .ldsm-post-row[draggable="true"] { cursor: grab; }
      .ldsm-picker-item[draggable="true"]:active, .ldsm-nav-item[draggable="true"]:active, .ldsm-card[draggable="true"]:active, .ldsm-post-row[draggable="true"]:active, .ldsm-drag-handle:active { cursor: grabbing; }
      .ldsm-dragging { opacity: .48; }
      .ldsm-drag-over { outline: 1px solid #93c5fd; background: #eff6ff !important; }
      .ldsm-drag-before { box-shadow: inset 0 2px 0 #2563eb; }
      .ldsm-drag-after { box-shadow: inset 0 -2px 0 #2563eb; }
      .ldsm-picker-icon { width: 18px; text-align: center; flex: 0 0 18px; }
      .ldsm-picker-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      .ldsm-picker-check { color: #16a34a; font-weight: 700; }
      .ldsm-picker-new { margin-top: 2px; border-top: 1px solid #f4f4f5; color: #71717a; }
      .ldsm-picker-input-row { display: flex; gap: 4px; padding: 5px; }
      .ldsm-picker-input-ok { flex: 0 0 30px; height: 29px; border: 0; border-radius: 5px; background: #18181b; color: #fff; cursor: pointer; }

      #ldsm-root, #ldsm-root * { box-sizing: border-box; }
      .ldsm-resize-lock, .ldsm-resize-lock * { cursor: ew-resize !important; user-select: none !important; }
      .ldsm-fab { position: fixed; right: 18px; bottom: 82px; z-index: 999990; width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #e4e4e7; border-radius: 999px; background: #fff; color: #ca8a04; box-shadow: 0 8px 24px rgba(0,0,0,.16); cursor: grab; touch-action: none; user-select: none; transition: transform 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 160ms ease; will-change: transform; }
      .ldsm-fab[hidden] { display: none !important; }
      .ldsm-fab:hover { background: #fefce8; border-color: #fde68a; }
      .ldsm-fab.ldsm-fab-docked-left:not(.ldsm-fab-dragging):not(:hover):not(:focus-visible) { transform: translateX(-${FAB_SIZE - FAB_DOCK_VISIBLE}px); }
      .ldsm-fab.ldsm-fab-docked-right:not(.ldsm-fab-dragging):not(:hover):not(:focus-visible) { transform: translateX(${FAB_SIZE - FAB_DOCK_VISIBLE}px); }
      .ldsm-fab.ldsm-fab-dragging { cursor: grabbing; transform: none !important; box-shadow: 0 12px 30px rgba(0,0,0,.2); }
      .ldsm-fab-star { font-size: 22px; line-height: 1; }
      .ldsm-fab-count { position: absolute; top: -5px; right: -5px; min-width: 18px; height: 18px; display: none; align-items: center; justify-content: center; padding: 0 5px; border-radius: 999px; background: #18181b; color: #fff; font-size: 10px; font-weight: 700; }
      .ldsm-shade, .ldsm-subshade { position: fixed; inset: 0; background: rgba(0,0,0,.28); opacity: 0; pointer-events: none; transition: opacity 180ms ease; }
      .ldsm-shade { z-index: 999991; }
      .ldsm-subshade { z-index: 999995; background: rgba(0,0,0,.18); }
      .ldsm-shade.ldsm-open, .ldsm-subshade.open { opacity: 1; pointer-events: auto; }
      .ldsm-manager { position: fixed; top: 0; right: 0; z-index: 999992; width: min(980px, 96vw); height: 100vh; display: flex; background: #fff; color: #09090b; border-left: 1px solid #e4e4e7; box-shadow: -8px 0 28px rgba(0,0,0,.12); transform: translateX(104%); transition: transform 220ms cubic-bezier(.16,1,.3,1); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.5; }
      .ldsm-manager.ldsm-open { transform: translateX(0); }
      .ldsm-manager-resizer { position: absolute; left: -5px; top: 0; bottom: 0; z-index: 3; width: 10px; cursor: ew-resize; touch-action: none; }
      .ldsm-manager-resizer::before { content: ""; position: absolute; left: 4px; top: 0; bottom: 0; width: 2px; background: transparent; transition: background 140ms ease, box-shadow 140ms ease; }
      .ldsm-manager-resizer:hover::before, .ldsm-manager.ldsm-manager-resizing .ldsm-manager-resizer::before { background: #2563eb; box-shadow: 0 0 0 2px rgba(37, 99, 235, .12); }
      .ldsm-sidebar { width: 224px; flex: 0 0 224px; display: flex; flex-direction: column; border-right: 1px solid #e4e4e7; background: #fafafa; min-height: 0; }
      .ldsm-sidebar-head { height: 49px; display: flex; align-items: center; gap: 8px; padding: 0 14px; border-bottom: 1px solid #e4e4e7; }
      .ldsm-logo { width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; background: #fefce8; color: #ca8a04; font-size: 14px; }
      .ldsm-logo-text { font-weight: 650; font-size: 14px; flex: 1; }
      .ldsm-nav { flex: 1; min-height: 0; overflow-y: auto; padding: 7px; display: flex; flex-direction: column; gap: 2px; }
      .ldsm-nav-item { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 32px; padding: 6px 9px; border: 0; border-radius: 6px; background: transparent; color: #71717a; cursor: pointer; text-align: left; font-size: 13px; font-weight: 500; }
      .ldsm-nav-item:hover, .ldsm-nav-item.active { background: #f4f4f5; color: #09090b; }
      .ldsm-nav-item.ldsm-drag-over { color: #1d4ed8; }
      .ldsm-nav-item svg { width: 16px; height: 16px; flex: 0 0 16px; }
      .ldsm-nav-icon { width: 16px; text-align: center; }
      .ldsm-nav-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      .ldsm-nav-count { margin-left: auto; min-width: 18px; padding: 0 5px; border-radius: 999px; background: #e4e4e7; color: #71717a; text-align: center; font-size: 10px; font-weight: 700; }
      .ldsm-nav-edit { opacity: 0; padding: 0 4px; border-radius: 4px; color: #a1a1aa; }
      .ldsm-nav-item:hover .ldsm-nav-edit { opacity: 1; }
      .ldsm-nav-edit:hover { background: #e4e4e7; color: #09090b; }
      .ldsm-sidebar-mid { padding: 9px 10px; border-top: 1px solid #e4e4e7; display: flex; flex-direction: column; gap: 6px; }
      .ldsm-sidebar-toggle { justify-content: center; padding-top: 2px; }
      .ldsm-sidebar-foot { padding: 10px; display: flex; gap: 6px; border-top: 1px solid #e4e4e7; flex-wrap: wrap; }
      .ldsm-total { font-size: 11px; color: #a1a1aa; text-align: center; }

      .ldsm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: #fff; }
      .ldsm-toolbar { min-height: 49px; display: flex; align-items: center; gap: 12px; padding: 8px 16px; border-bottom: 1px solid #e4e4e7; }
      .ldsm-search { position: relative; flex: 1; max-width: 390px; min-width: 180px; }
      .ldsm-search svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: #a1a1aa; pointer-events: none; }
      .ldsm-search input, .ldsm-input, .ldsm-select, .ldsm-textarea { width: 100%; border: 1px solid #e4e4e7; border-radius: 6px; background: #fff; color: #09090b; outline: none; font: inherit; }
      .ldsm-search input { height: 34px; padding: 0 10px 0 32px; font-size: 13px; }
      .ldsm-input, .ldsm-select { height: 34px; padding: 0 10px; font-size: 13px; }
      .ldsm-textarea { min-height: 78px; padding: 8px 10px; resize: vertical; font-size: 13px; }
      .ldsm-search input:focus, .ldsm-input:focus, .ldsm-select:focus, .ldsm-textarea:focus { border-color: #a1a1aa; box-shadow: 0 0 0 2px rgba(0,0,0,.03); }
      .ldsm-toolbar-right { display: flex; align-items: center; gap: 9px; margin-left: auto; }
      .ldsm-content { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 16px; background: #fff; }
      .ldsm-content::-webkit-scrollbar, .ldsm-nav::-webkit-scrollbar, .ldsm-panel-body::-webkit-scrollbar { width: 6px; }
      .ldsm-content::-webkit-scrollbar-thumb, .ldsm-nav::-webkit-scrollbar-thumb, .ldsm-panel-body::-webkit-scrollbar-thumb { background: #d4d4d8; border-radius: 999px; }

      .ldsm-btn { min-height: 30px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 0 10px; border: 1px solid #e4e4e7; border-radius: 6px; background: #fff; color: #09090b; cursor: pointer; font-size: 12px; font-weight: 550; white-space: nowrap; }
      .ldsm-btn:hover { background: #f4f4f5; }
      .ldsm-btn:disabled { opacity: .6; cursor: not-allowed; }
      .ldsm-btn svg { width: 14px; height: 14px; }
      .ldsm-btn-full { width: 100%; }
      .ldsm-btn-primary { background: #18181b; color: #fff; border-color: #18181b; }
      .ldsm-btn-primary:hover { background: #27272a; }
      .ldsm-btn-danger { color: #dc2626; border-color: #fecaca; }
      .ldsm-btn-danger:hover { background: #fef2f2; color: #b91c1c; }
      .ldsm-icon-btn { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; background: transparent; color: #71717a; cursor: pointer; flex: 0 0 28px; }
      .ldsm-icon-btn:hover { background: #f4f4f5; color: #09090b; }
      .ldsm-icon-btn svg { width: 15px; height: 15px; pointer-events: none; }
      .ldsm-danger-hover:hover { background: #fef2f2; color: #dc2626; }
      .ldsm-mobile-close { display: none; margin-left: auto; }
      .ldsm-sync-button.syncing svg { animation: ldsm-spin 1s linear infinite; }
      @keyframes ldsm-spin { to { transform: rotate(360deg); } }

      .ldsm-check-label { display: inline-flex; align-items: center; gap: 5px; color: #71717a; font-size: 12px; white-space: nowrap; user-select: none; }
      .ldsm-check-label input { width: 14px; height: 14px; accent-color: #18181b; }
      .ldsm-batch-bar { display: none; align-items: center; gap: 7px; padding: 4px 8px; border: 1px solid #fecaca; border-radius: 6px; background: #fef2f2; color: #dc2626; font-size: 12px; }
      .ldsm-batch-bar.visible { display: flex; }

      .ldsm-card { position: relative; border: 1px solid #e4e4e7; border-radius: 8px; overflow: hidden; margin-bottom: 8px; background: #fff; }
      .ldsm-card.ldsm-drag-over > .ldsm-card-head { background: #eff6ff !important; }
      .ldsm-card.ldsm-drag-before::before, .ldsm-card.ldsm-drag-after::after { content: ""; position: absolute; left: 10px; right: 10px; z-index: 3; height: 3px; border-radius: 999px; background: #2563eb; box-shadow: 0 0 0 2px rgba(37, 99, 235, .12); pointer-events: none; }
      .ldsm-card.ldsm-drag-before::before { top: 0; }
      .ldsm-card.ldsm-drag-after::after { bottom: 0; }
      .ldsm-card-head { display: flex; align-items: center; gap: 10px; padding: 10px 13px; background: #fafafa; cursor: pointer; }
      .ldsm-card-head:hover { background: #f4f4f5; }
      .ldsm-drag-handle { width: 18px; height: 28px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 18px; border-radius: 5px; color: #a1a1aa; cursor: grab; font-size: 13px; font-weight: 700; line-height: 1; user-select: none; }
      .ldsm-drag-handle:hover { background: #e4e4e7; color: #52525b; }
      .ldsm-chevron { width: 14px; height: 14px; flex: 0 0 14px; color: #a1a1aa; transition: transform 150ms ease; }
      .ldsm-card.open .ldsm-chevron { transform: rotate(90deg); }
      .ldsm-card-check { width: 16px; height: 16px; accent-color: #18181b; flex: 0 0 16px; }
      .ldsm-card-star { color: #eab308; font-size: 15px; flex: 0 0 15px; line-height: 1; }
      .ldsm-card-body { flex: 1; min-width: 0; }
      .ldsm-card-title { font-size: 13px; font-weight: 600; color: #09090b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      .ldsm-card-title a { color: inherit; text-decoration: none; }
      .ldsm-card-title a:visited { color: inherit; }
      .ldsm-card-title a:hover { color: #2563eb; text-decoration: underline; }
      .ldsm-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
      .ldsm-tag { display: inline-flex; align-items: center; height: 18px; padding: 0 5px; border-radius: 4px; background: #f4f4f5; color: #71717a; font-size: 10px; font-weight: 600; }
      .ldsm-tag-note { background: #fefce8; color: #a16207; }
      .ldsm-comment-count { background: #eff6ff; color: #2563eb; }
      .ldsm-time { color: #a1a1aa; font-size: 11px; }
      .ldsm-card-actions, .ldsm-post-actions { display: flex; gap: 2px; flex: 0 0 auto; }
      .ldsm-card-posts { display: none; }
      .ldsm-card.open .ldsm-card-posts { display: block; }
      .ldsm-post-row { display: flex; align-items: flex-start; gap: 8px; padding: 9px 13px 9px 40px; border-top: 1px solid #f4f4f5; cursor: pointer; }
      .ldsm-post-row:hover { background: #fafafa; }
      .ldsm-post-num { min-width: 30px; color: #a1a1aa; font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; }
      .ldsm-post-info { flex: 1; min-width: 0; }
      .ldsm-post-author { color: #09090b; font-size: 12px; font-weight: 650; }
      .ldsm-post-excerpt { margin-top: 2px; color: #71717a; font-size: 12px; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
      .ldsm-post-note { margin-top: 4px; padding: 4px 6px; border-radius: 4px; background: #fefce8; color: #a16207; font-size: 11px; line-height: 1.35; }
      .ldsm-post-time { margin-top: 3px; color: #a1a1aa; font-size: 11px; }
      .ldsm-post-actions { opacity: 0; transition: opacity 90ms ease; }
      .ldsm-post-row:hover .ldsm-post-actions { opacity: 1; }

      .ldsm-empty { min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; color: #a1a1aa; }
      .ldsm-empty .ldsm-star-icon { width: 44px; height: 44px; margin-bottom: 10px; }
      .ldsm-empty .ldsm-star-icon path { fill: none; stroke: #d4d4d8; stroke-width: 1.2; }
      .ldsm-empty h3 { margin: 0; font-size: 14px; font-weight: 600; color: #71717a; }
      .ldsm-empty p { margin: 5px 0 0; font-size: 13px; color: #a1a1aa; }

      .ldsm-panel { position: absolute; top: 0; right: 0; z-index: 999996; width: 410px; max-width: 92vw; height: 100%; display: flex; flex-direction: column; background: #fff; border-left: 1px solid #e4e4e7; box-shadow: -6px 0 18px rgba(0,0,0,.08); transform: translateX(104%); transition: transform 200ms cubic-bezier(.16,1,.3,1); }
      .ldsm-panel.open { transform: translateX(0); }
      .ldsm-panel-head { height: 50px; display: flex; align-items: center; justify-content: space-between; padding: 0 15px; border-bottom: 1px solid #e4e4e7; }
      .ldsm-panel-head h2 { margin: 0; font-size: 14px; font-weight: 650; color: #09090b; }
      .ldsm-panel-body { flex: 1; overflow-y: auto; padding: 16px; }
      .ldsm-field, .ldsm-sync-field { margin-bottom: 14px; }
      .ldsm-field label, .ldsm-sync-field label { display: block; margin-bottom: 5px; color: #71717a; font-size: 12px; font-weight: 600; }
      .ldsm-field-value, .ldsm-sync-value { color: #09090b; font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
      .ldsm-field-value a, .ldsm-sync-value a, .ldsm-help a { color: #2563eb; text-decoration: none; }
      .ldsm-field-value a:hover, .ldsm-sync-value a:hover, .ldsm-help a:hover { text-decoration: underline; }
      .ldsm-action-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
      .ldsm-icon-grid { display: flex; flex-wrap: wrap; gap: 5px; }
      .ldsm-icon-opt { width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #e4e4e7; border-radius: 6px; background: #fff; cursor: pointer; font-size: 16px; }
      .ldsm-icon-opt:hover, .ldsm-icon-opt.active { background: #f4f4f5; border-color: #18181b; box-shadow: 0 0 0 1px #18181b; }
      .ldsm-tag-editor { display: flex; flex-wrap: wrap; gap: 5px; min-height: 36px; padding: 6px 8px; border: 1px solid #e4e4e7; border-radius: 6px; background: #fff; }
      .ldsm-tag-editor:focus-within { border-color: #a1a1aa; box-shadow: 0 0 0 2px rgba(0,0,0,.03); }
      .ldsm-tag-editor input { flex: 1; min-width: 90px; border: 0; outline: 0; font: inherit; font-size: 12px; }
      .ldsm-tag-pill { display: inline-flex; align-items: center; gap: 4px; height: 22px; padding: 0 6px; border: 1px solid #e4e4e7; border-radius: 5px; background: #f4f4f5; color: #09090b; font-size: 11px; }
      .ldsm-tag-pill button { border: 0; background: transparent; color: #a1a1aa; cursor: pointer; padding: 0; line-height: 1; }
      .ldsm-tag-pill button:hover { color: #dc2626; }
      .ldsm-move-list { display: flex; flex-direction: column; gap: 5px; }
      .ldsm-move-item { min-height: 36px; display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid #e4e4e7; border-radius: 6px; background: #fff; color: #09090b; cursor: pointer; font-size: 13px; }
      .ldsm-move-item:hover, .ldsm-move-item.active { background: #f4f4f5; border-color: #18181b; }
      .ldsm-move-check { margin-left: auto; color: #16a34a; font-weight: 800; }
      .ldsm-help { margin-top: 7px; color: #71717a; font-size: 12px; line-height: 1.45; }
      .ldsm-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
      .ldsm-sync-dot { display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: #d4d4d8; vertical-align: middle; }
      .ldsm-sync-dot.connected, .ldsm-sync-dot.synced { background: #22c55e; }
      .ldsm-sync-dot.syncing { background: #eab308; }
      .ldsm-sync-dot.error { background: #ef4444; }
      .ldsm-sync-toggle { margin: 8px 0 4px; color: #09090b; }
      .ldsm-sync-error { margin-top: 8px; color: #dc2626; font-size: 12px; line-height: 1.45; }
      .ldsm-sync-ok { margin-top: 8px; color: #16a34a; font-size: 12px; }

      @media (max-width: 760px) {
        .ldsm-manager { width: 100vw; }
        .ldsm-manager-resizer { display: none; }
        .ldsm-sidebar { position: absolute; z-index: 999994; width: 210px; height: 100%; }
        .ldsm-main { margin-left: 210px; }
        .ldsm-toolbar { flex-wrap: wrap; align-items: stretch; }
        .ldsm-search { max-width: none; width: 100%; flex: 1 1 100%; }
        .ldsm-toolbar-right { width: 100%; justify-content: flex-end; flex-wrap: wrap; }
        .ldsm-card-head { align-items: flex-start; }
        .ldsm-card-actions { flex-direction: column; }
        .ldsm-post-row { padding-left: 18px; }
        .ldsm-post-actions { opacity: 1; }
      }

      @media (max-width: 560px) {
        .ldsm-sidebar { display: none; }
        .ldsm-main { margin-left: 0; }
        .ldsm-mobile-close { display: inline-flex; }
        .ldsm-fab { right: 14px; bottom: 74px; }
      }

      @media (prefers-reduced-motion: reduce) {
        .ldsm-manager, .ldsm-panel, .ldsm-shade, .ldsm-subshade, .ldsm-toast, .${STAR_CLASS} .ldsm-star-icon, .${STAR_CLASS} .ldsm-star-icon path { transition-duration: 0ms !important; animation-duration: 0ms !important; }
      }
    `;

    if (typeof GM_addStyle === 'function') GM_addStyle(css);
    else {
      const style = document.createElement('style');
      style.textContent = css;
      document.head.appendChild(style);
    }
  }

  function registerMenuCommands() {
    if (menuCommandsRegistered || typeof GM_registerMenuCommand !== 'function') return;
    menuCommandsRegistered = true;
    GM_registerMenuCommand('打开 LinuxDo Star 收藏管理', () => {
      if (!managerReady) {
        addStyles();
        ensureManager();
      }
      openManager();
    });
    GM_registerMenuCommand('立即同步 LinuxDo Star', async () => {
      const result = await SyncManager.sync();
      showToast(result.message, result.ok ? '⭐' : '⚠');
    });
  }

  // ========================= Init =========================
  function init() {
    registerMenuCommands();
    addStyles();
    ensureManager();
    refreshFabCount();
    watchRouteChanges();
    if (isTopicPage()) waitAndInject();
  }

  registerMenuCommands();
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init, { once: true });
  } else {
    init();
  }
})();