CI / build (push) Failing after 6m2s
Introduce a Node build system for minified CSS/JS with content hashes, add PWA support, i18n helpers, accessibility improvements, and more translatable strings. Move theme configuration from bookmarks to localStorage, add Playwright tests, and set up CI workflows.
6083 lines
230 KiB
JavaScript
6083 lines
230 KiB
JavaScript
document.addEventListener("DOMContentLoaded", function () {
|
|
// Check URL parameters for custom views
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const searchTagsRaw = urlParams.get("searchtags") || urlParams.get("searchTags") || "";
|
|
|
|
// Also check URL path for tag format (e.g., /tag/note)
|
|
const urlPath = window.location.pathname;
|
|
const pathMatch = urlPath.match(/\/tag\/(.+)$/);
|
|
const pathTagRaw = pathMatch ? decodeURIComponent(pathMatch[1]) : "";
|
|
|
|
// Parse all active tags to safely detect the view
|
|
const activeTags = (searchTagsRaw + " " + pathTagRaw)
|
|
.toLowerCase()
|
|
.split(/[\s,]+/)
|
|
.filter((t) => t);
|
|
|
|
// Foolproof detection using sidebar active state and DOM rendered tags
|
|
const hasNoteActiveMenu = !!document.querySelector('.sidebar-link[aria-label="Notes"].active, .header-nav-link[aria-label="Notes"].active, .sidebar-link[href*="searchtags=note"].active, .sidebar-link[href*="searchtags=shaarli-note"].active') ||
|
|
!!document.querySelector('.sidebar-link[aria-label="' + window.ShaarliProI18n.t('Notes') + '"].active, .header-nav-link[aria-label="' + window.ShaarliProI18n.t('Notes') + '"].active');
|
|
const hasTodoActiveMenu = !!document.querySelector('.sidebar-link[aria-label="Mes tâches"].active, .header-nav-link[aria-label="Mes tâches"].active, .sidebar-link[href*="searchtags=shaarli-todo"].active, .sidebar-link[href*="searchtags=todo"].active') ||
|
|
!!document.querySelector('.sidebar-link[aria-label="' + window.ShaarliProI18n.t('My tasks') + '"].active, .header-nav-link[aria-label="' + window.ShaarliProI18n.t('My tasks') + '"].active');
|
|
const hasArchiveActiveMenu = !!document.querySelector('.sidebar-link[aria-label="Archive"].active, .header-nav-link[aria-label="Archive"].active, .sidebar-link[href*="searchtags=shaarli-archive"].active, .sidebar-link[href*="searchtags=shaarli-archiver"].active') ||
|
|
!!document.querySelector('.sidebar-link[aria-label="' + window.ShaarliProI18n.t('Archive') + '"].active, .header-nav-link[aria-label="' + window.ShaarliProI18n.t('Archive') + '"].active');
|
|
|
|
const domChipTags = Array.from(document.querySelectorAll(".search-tag-chip")).map((el) => (el.textContent || "").trim().toLowerCase());
|
|
|
|
const isArchiveTag = (tag) => tag === "shaarli-archive" || tag === "shaarli-archiver";
|
|
const isNoteTag = (tag) => tag === "note" || tag === "shaarli-note" || tag === "#note";
|
|
const isTodoTag = (tag) => tag === "todo" || tag === "shaarli-todo" || tag === "#todo";
|
|
|
|
const isNoteView = activeTags.some(isNoteTag) || hasNoteActiveMenu || domChipTags.some(isNoteTag);
|
|
const isTodoView = activeTags.some(isTodoTag) || hasTodoActiveMenu || domChipTags.some(isTodoTag);
|
|
const isArchiveView = activeTags.some(isArchiveTag) || hasArchiveActiveMenu || domChipTags.some(isArchiveTag);
|
|
|
|
const linkList = document.getElementById("links-list");
|
|
const container = document.querySelector(".content-container");
|
|
|
|
const showErrorBanner = (msg, err) => {
|
|
const banner = document.createElement("div");
|
|
banner.style.cssText = "background: #ffebee; color: #c62828; padding: 16px; margin: 16px; border-radius: 8px; border: 1px solid #ef9a9a; z-index: 9999; position: relative;";
|
|
banner.innerHTML = `<strong>Erreur Custom Views:</strong> ${msg}<br><pre style="margin-top:8px; font-size: 12px; white-space: pre-wrap; word-break: break-all;">${err ? err.stack || err : ""}</pre>`;
|
|
if (container) container.prepend(banner);
|
|
else document.body.prepend(banner);
|
|
};
|
|
|
|
const restoreDefaultListView = () => {
|
|
try {
|
|
document.body.classList.remove("view-todo", "view-notes", "view-archive");
|
|
} catch (e) {
|
|
// noop
|
|
}
|
|
|
|
const toolbar = document.querySelector(".content-toolbar");
|
|
if (toolbar) toolbar.style.display = "";
|
|
|
|
const list = document.getElementById("links-list");
|
|
if (list) list.style.display = "";
|
|
|
|
document.querySelectorAll(".notes-wrapper.todo-wrapper").forEach((el) => el.remove());
|
|
};
|
|
|
|
// Rendu Markdown étendu pour les cartes Notes/Tâches
|
|
function renderMarkdown(markdown) {
|
|
if (!markdown) return "";
|
|
|
|
const normalizeNewlines = String(markdown).replace(/\r\n?/g, "\n");
|
|
const escape = (value) => String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
|
|
const escapeAttr = (value) => String(value).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
|
|
const sanitizeUrl = (rawUrl) => {
|
|
const url = String(rawUrl || "").trim();
|
|
if (!url) return "";
|
|
if (/^(https?:|mailto:|\/)/i.test(url)) return url;
|
|
return "";
|
|
};
|
|
|
|
const renderInline = (input) => {
|
|
let out = escape(input || "");
|
|
|
|
const codeTokens = [];
|
|
out = out.replace(/`([^`\n]+)`/g, (_, code) => {
|
|
const token = `__MD_CODE_${codeTokens.length}__`;
|
|
codeTokens.push(`<code>${code}</code>`);
|
|
return token;
|
|
});
|
|
|
|
out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, rawUrl) => {
|
|
const url = sanitizeUrl(rawUrl);
|
|
if (!url) return _;
|
|
return `<img src="${escapeAttr(url)}" alt="${escapeAttr(alt)}" loading="lazy" />`;
|
|
});
|
|
|
|
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, rawUrl) => {
|
|
const url = sanitizeUrl(rawUrl);
|
|
if (!url) return _;
|
|
return `<a href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
|
});
|
|
|
|
out = out.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
out = out.replace(/__(.+?)__/g, "<strong>$1</strong>");
|
|
out = out.replace(/~~(.+?)~~/g, "<del>$1</del>");
|
|
out = out.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
|
out = out.replace(/_(.+?)_/g, "<em>$1</em>");
|
|
out = out.replace(/<u>([\s\S]*?)<\/u>/g, "<u>$1</u>");
|
|
|
|
out = out.replace(/__MD_CODE_(\d+)__/g, (_, idx) => codeTokens[Number(idx)] || "");
|
|
return out;
|
|
};
|
|
|
|
const lines = normalizeNewlines.split("\n");
|
|
const html = [];
|
|
let inUl = false;
|
|
let inOl = false;
|
|
let inTodo = false;
|
|
let inCode = false;
|
|
let codeLang = "";
|
|
let codeLines = [];
|
|
|
|
const closeLists = () => {
|
|
if (inUl) {
|
|
html.push("</ul>");
|
|
inUl = false;
|
|
}
|
|
if (inOl) {
|
|
html.push("</ol>");
|
|
inOl = false;
|
|
}
|
|
if (inTodo) {
|
|
html.push("</ul>");
|
|
inTodo = false;
|
|
}
|
|
};
|
|
|
|
lines.forEach((line) => {
|
|
const trimmed = line.trim();
|
|
|
|
const codeFenceMatch = trimmed.match(/^```\s*([\w-]+)?\s*$/);
|
|
if (codeFenceMatch) {
|
|
closeLists();
|
|
if (!inCode) {
|
|
inCode = true;
|
|
codeLang = codeFenceMatch[1] || "";
|
|
codeLines = [];
|
|
} else {
|
|
const langAttr = codeLang ? ` data-lang="${escapeAttr(codeLang)}"` : "";
|
|
html.push(`<pre class="md-code-block"><code${langAttr}>${escape(codeLines.join("\n"))}</code></pre>`);
|
|
inCode = false;
|
|
codeLang = "";
|
|
codeLines = [];
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (inCode) {
|
|
codeLines.push(line);
|
|
return;
|
|
}
|
|
|
|
if (!trimmed) {
|
|
closeLists();
|
|
html.push('<div class="md-spacer"></div>');
|
|
return;
|
|
}
|
|
|
|
const headingMatch = line.match(/^\s*(#{1,6})\s+(.+)$/);
|
|
if (headingMatch) {
|
|
closeLists();
|
|
const level = headingMatch[1].length;
|
|
html.push(`<h${level}>${renderInline(headingMatch[2])}</h${level}>`);
|
|
return;
|
|
}
|
|
|
|
const quoteMatch = line.match(/^\s*>\s?(.*)$/);
|
|
if (quoteMatch) {
|
|
closeLists();
|
|
html.push(`<blockquote>${renderInline(quoteMatch[1])}</blockquote>`);
|
|
return;
|
|
}
|
|
|
|
const todoMatch = line.match(/^\s*[-*]\s+\[( |x|X)\]\s+(.+)$/);
|
|
if (todoMatch) {
|
|
if (inUl) {
|
|
html.push("</ul>");
|
|
inUl = false;
|
|
}
|
|
if (inOl) {
|
|
html.push("</ol>");
|
|
inOl = false;
|
|
}
|
|
if (!inTodo) {
|
|
html.push('<ul class="md-todo-list">');
|
|
inTodo = true;
|
|
}
|
|
const checked = String(todoMatch[1]).toLowerCase() === "x";
|
|
html.push(`<li class="md-todo-item${checked ? " is-checked" : ""}">` + `<span class="md-todo-box" aria-hidden="true"><i class="mdi mdi-check"></i></span>` + `<span class="md-todo-text">${renderInline(todoMatch[2])}</span>` + "</li>");
|
|
return;
|
|
}
|
|
|
|
const olMatch = line.match(/^\s*\d+\.\s+(.+)$/);
|
|
if (olMatch) {
|
|
if (inUl) {
|
|
html.push("</ul>");
|
|
inUl = false;
|
|
}
|
|
if (inTodo) {
|
|
html.push("</ul>");
|
|
inTodo = false;
|
|
}
|
|
if (!inOl) {
|
|
html.push("<ol>");
|
|
inOl = true;
|
|
}
|
|
html.push(`<li>${renderInline(olMatch[1])}</li>`);
|
|
return;
|
|
}
|
|
|
|
const ulMatch = line.match(/^\s*[-*+]\s+(.+)$/);
|
|
if (ulMatch) {
|
|
if (inOl) {
|
|
html.push("</ol>");
|
|
inOl = false;
|
|
}
|
|
if (inTodo) {
|
|
html.push("</ul>");
|
|
inTodo = false;
|
|
}
|
|
if (!inUl) {
|
|
html.push("<ul>");
|
|
inUl = true;
|
|
}
|
|
html.push(`<li>${renderInline(ulMatch[1])}</li>`);
|
|
return;
|
|
}
|
|
|
|
closeLists();
|
|
html.push(`<p>${renderInline(line)}</p>`);
|
|
});
|
|
|
|
if (inCode) {
|
|
const langAttr = codeLang ? ` data-lang="${escapeAttr(codeLang)}"` : "";
|
|
html.push(`<pre class="md-code-block"><code${langAttr}>${escape(codeLines.join("\n"))}</code></pre>`);
|
|
}
|
|
|
|
closeLists();
|
|
return html.join("");
|
|
}
|
|
|
|
function applyKeepNoteFormatting(textarea, format) {
|
|
if (!textarea || !format) return;
|
|
const start = textarea.selectionStart || 0;
|
|
const end = textarea.selectionEnd || 0;
|
|
const value = String(textarea.value || "");
|
|
const selected = value.slice(start, end);
|
|
|
|
const replaceRange = (from, to, replacement, caretMode = "end") => {
|
|
const next = value.slice(0, from) + replacement + value.slice(to);
|
|
textarea.value = next;
|
|
textarea.focus({ preventScroll: true });
|
|
if (caretMode === "select") {
|
|
textarea.setSelectionRange(from, from + replacement.length);
|
|
} else {
|
|
const cursor = from + replacement.length;
|
|
textarea.setSelectionRange(cursor, cursor);
|
|
}
|
|
};
|
|
|
|
const wrapSelection = (prefix, suffix) => {
|
|
const body = selected || "";
|
|
const replacement = `${prefix}${body}${suffix}`;
|
|
const next = value.slice(0, start) + replacement + value.slice(end);
|
|
textarea.value = next;
|
|
const selStart = start + prefix.length;
|
|
const selEnd = selStart + body.length;
|
|
textarea.focus({ preventScroll: true });
|
|
textarea.setSelectionRange(selStart, selEnd);
|
|
};
|
|
|
|
const lineRangeForSelection = () => {
|
|
const before = value.slice(0, start);
|
|
const after = value.slice(end);
|
|
const lineStart = before.lastIndexOf("\n") + 1;
|
|
const nextNl = after.indexOf("\n");
|
|
const lineEnd = nextNl === -1 ? value.length : end + nextNl;
|
|
return { lineStart, lineEnd };
|
|
};
|
|
|
|
const mapLines = (mapper) => {
|
|
const { lineStart, lineEnd } = lineRangeForSelection();
|
|
const block = value.slice(lineStart, lineEnd);
|
|
const lines = block.split(/\r?\n/);
|
|
const nextLines = lines.map(mapper);
|
|
const replacement = nextLines.join("\n");
|
|
const next = value.slice(0, lineStart) + replacement + value.slice(lineEnd);
|
|
textarea.value = next;
|
|
textarea.focus({ preventScroll: true });
|
|
textarea.setSelectionRange(lineStart, lineStart + replacement.length);
|
|
};
|
|
|
|
if (format === "bold") return wrapSelection("**", "**");
|
|
if (format === "italic") return wrapSelection("*", "*");
|
|
if (format === "underline") return wrapSelection("<u>", "</u>");
|
|
if (format === "strike") return wrapSelection("~~", "~~");
|
|
if (format === "code") return wrapSelection("`", "`");
|
|
|
|
if (format === "link") {
|
|
const label = selected || "Texte du lien";
|
|
const replacement = `[${label}](https://)`;
|
|
replaceRange(start, end, replacement);
|
|
const cursor = start + replacement.length - 1;
|
|
textarea.setSelectionRange(cursor, cursor);
|
|
return;
|
|
}
|
|
|
|
if (format === "image") {
|
|
const alt = selected || "Description image";
|
|
const replacement = ``;
|
|
replaceRange(start, end, replacement);
|
|
const cursor = start + replacement.length - 1;
|
|
textarea.setSelectionRange(cursor, cursor);
|
|
return;
|
|
}
|
|
|
|
if (format === "quote") {
|
|
return mapLines((ln) => (ln.startsWith("> ") ? ln : `> ${ln}`));
|
|
}
|
|
|
|
if (format === "ul") {
|
|
return mapLines((ln) => {
|
|
const trimmed = ln.trim();
|
|
if (!trimmed) return "- ";
|
|
if (/^[-*+]\s+/.test(trimmed)) return ln;
|
|
return `- ${trimmed}`;
|
|
});
|
|
}
|
|
|
|
if (format === "ol") {
|
|
return mapLines((ln, idx) => {
|
|
const trimmed = ln.trim();
|
|
if (!trimmed) return `${idx + 1}. `;
|
|
if (/^\d+\.\s+/.test(trimmed)) return ln;
|
|
return `${idx + 1}. ${trimmed}`;
|
|
});
|
|
}
|
|
|
|
if (format === "todo") {
|
|
return mapLines((ln) => {
|
|
const trimmed = ln.trim();
|
|
if (!trimmed) return "- [ ] ";
|
|
if (/^[-*]\s+\[( |x|X)\]\s+/.test(trimmed)) return ln;
|
|
return `- [ ] ${trimmed}`;
|
|
});
|
|
}
|
|
|
|
if (format === "codeblock") {
|
|
const body = selected || "code";
|
|
const replacement = `\n\`\`\`\n${body}\n\`\`\`\n`;
|
|
replaceRange(start, end, replacement, "select");
|
|
return;
|
|
}
|
|
|
|
if (format === "h1" || format === "h2" || format === "h3" || format === "p") {
|
|
const prefix = format === "h1" ? "# " : format === "h2" ? "## " : format === "h3" ? "### " : "";
|
|
return mapLines((ln) => {
|
|
const stripped = ln.replace(/^\s*(#{1,6}\s+)/, "");
|
|
return prefix ? prefix + stripped : stripped;
|
|
});
|
|
}
|
|
|
|
if (format === "clear") {
|
|
if (!selected) {
|
|
return mapLines((ln) =>
|
|
ln
|
|
.replace(/^\s*(#{1,6}\s+)/, "")
|
|
.replace(/^\s*>\s?/, "")
|
|
.replace(/^\s*[-*+]\s+\[( |x|X)\]\s+/, "")
|
|
.replace(/^\s*[-*+]\s+/, "")
|
|
.replace(/^\s*\d+\.\s+/, "")
|
|
.replace(/\*\*(.*?)\*\*/g, "$1")
|
|
.replace(/__(.*?)__/g, "$1")
|
|
.replace(/~~(.*?)~~/g, "$1")
|
|
.replace(/\*(.*?)\*/g, "$1")
|
|
.replace(/_(.*?)_/g, "$1")
|
|
.replace(/`(.*?)`/g, "$1")
|
|
.replace(/<u>(.*?)<\/u>/g, "$1"),
|
|
);
|
|
}
|
|
const cleaned = selected
|
|
.replace(/\*\*(.*?)\*\*/g, "$1")
|
|
.replace(/__(.*?)__/g, "$1")
|
|
.replace(/~~(.*?)~~/g, "$1")
|
|
.replace(/\*(.*?)\*/g, "$1")
|
|
.replace(/_(.*?)_/g, "$1")
|
|
.replace(/`(.*?)`/g, "$1")
|
|
.replace(/<u>(.*?)<\/u>/g, "$1");
|
|
replaceRange(start, end, cleaned);
|
|
}
|
|
}
|
|
|
|
window.renderMarkdown = renderMarkdown;
|
|
window.applyKeepNoteFormatting = applyKeepNoteFormatting;
|
|
|
|
const startViewInitialization = function () {
|
|
if (typeof initBookmarkPaletteButtons === "function") {
|
|
initBookmarkPaletteButtons();
|
|
}
|
|
|
|
if (typeof organizePinnedBookmarks === "function") {
|
|
window.requestAnimationFrame(organizePinnedBookmarks);
|
|
}
|
|
|
|
// Always init Pinned Items logic (sorting and listeners)
|
|
// This function is defined at the end of the file
|
|
if (typeof initPinnedItems === "function") {
|
|
initPinnedItems();
|
|
}
|
|
|
|
if (!linkList || !container) return;
|
|
|
|
if (isTodoView) {
|
|
try {
|
|
initTodoView(linkList, container);
|
|
} catch (err) {
|
|
console.error("[custom_views] Erreur lors de l'initialisation de la vue Todo:", err);
|
|
restoreDefaultListView();
|
|
}
|
|
} else if (isNoteView) {
|
|
try {
|
|
// Pour la vue notes, parser les notes AVANT de supprimer les tags techniques
|
|
// afin que les propriétés visuelles (couleur, fond, etc.) soient correctement extraites
|
|
initNoteView(linkList, container);
|
|
// Puis supprimer les tags techniques de l'affichage
|
|
if (typeof initTagDisplayAndRemoval === "function") {
|
|
initTagDisplayAndRemoval();
|
|
}
|
|
} catch (err) {
|
|
console.error("[custom_views] Erreur lors de l'initialisation de la vue Note:", err);
|
|
restoreDefaultListView();
|
|
}
|
|
} else if (isArchiveView) {
|
|
try {
|
|
// Vue Archive - similaire à Notes mais pour les notes archivées
|
|
initArchiveView(linkList, container);
|
|
// Puis supprimer les tags techniques de l'affichage
|
|
if (typeof initTagDisplayAndRemoval === "function") {
|
|
initTagDisplayAndRemoval();
|
|
}
|
|
} catch (err) {
|
|
console.error("[custom_views] Erreur lors de l'initialisation de la vue Archive:", err);
|
|
restoreDefaultListView();
|
|
}
|
|
} else {
|
|
// Vue standard : supprimer les tags techniques
|
|
if (typeof initTagDisplayAndRemoval === "function") {
|
|
initTagDisplayAndRemoval();
|
|
}
|
|
}
|
|
};
|
|
|
|
// Charger les backgrounds dynamiquement, puis initialiser les vues
|
|
initThemeModeBackgroundSync();
|
|
|
|
if (typeof loadBackgroundOptions === "function") {
|
|
loadBackgroundOptions()
|
|
.then(() => {
|
|
startViewInitialization();
|
|
})
|
|
.catch((err) => {
|
|
console.error("Erreur lors du chargement des backgrounds:", err);
|
|
// Initialiser quand même même si le chargement échoue
|
|
startViewInitialization();
|
|
});
|
|
} else {
|
|
startViewInitialization();
|
|
}
|
|
});
|
|
|
|
function renderMarkdown(markdown) {
|
|
if (typeof window !== "undefined" && typeof window.renderMarkdown === "function") {
|
|
return window.renderMarkdown(markdown);
|
|
}
|
|
return String(markdown || "").replace(/\n/g, "<br>");
|
|
}
|
|
|
|
function applyKeepNoteFormatting(textarea, format) {
|
|
if (typeof window !== "undefined" && typeof window.applyKeepNoteFormatting === "function") {
|
|
return window.applyKeepNoteFormatting(textarea, format);
|
|
}
|
|
}
|
|
|
|
const NOTE_COLOR_OPTIONS = [
|
|
{ key: "default", label: window.ShaarliProI18n.t("Default"), light: "#f8fafc", dark: "#20293A" },
|
|
{ key: "red", label: window.ShaarliProI18n.t("Red"), light: "#f28b82", dark: "#9c2116" },
|
|
{ key: "orange", label: window.ShaarliProI18n.t("Orange"), light: "#fbbc04", dark: "#9c7a16" },
|
|
{ key: "yellow", label: window.ShaarliProI18n.t("Yellow"), light: "#fff475", dark: "#9c9116" },
|
|
{ key: "green", label: window.ShaarliProI18n.t("Green"), light: "#ccff90", dark: "#5e9c16" },
|
|
{ key: "teal", label: window.ShaarliProI18n.t("Mint"), light: "#a7ffeb", dark: "#169c7d" },
|
|
{ key: "blue", label: window.ShaarliProI18n.t("Light blue"), light: "#cbf0f8", dark: "#16849c" },
|
|
{ key: "darkblue", label: window.ShaarliProI18n.t("Blue"), light: "#aecbfa", dark: "#16499c" },
|
|
{ key: "purple", label: window.ShaarliProI18n.t("Purple"), light: "#d7aefb", dark: "#5d169c" },
|
|
{ key: "pink", label: window.ShaarliProI18n.t("Pink"), light: "#fdcfe8", dark: "#9c165f" },
|
|
{ key: "brown", label: window.ShaarliProI18n.t("Beige"), light: "#e6c9a8", dark: "#9c5d16" },
|
|
{ key: "grey", label: window.ShaarliProI18n.t("Gray"), light: "#e8eaed", dark: "#4e5764" },
|
|
{ key: "custom", label: window.ShaarliProI18n.t("Custom"), light: "#custom", dark: "#custom" },
|
|
];
|
|
|
|
const NOTE_FILTER_OPTIONS = [
|
|
{ key: "none", label: window.ShaarliProI18n.t("None"), icon: "mdi-close-circle-outline" },
|
|
{ key: "glass", label: window.ShaarliProI18n.t("Glass"), icon: "mdi-blur" },
|
|
{ key: "vignette", label: window.ShaarliProI18n.t("Vignette"), icon: "mdi-vignette" },
|
|
{ key: "lined", label: window.ShaarliProI18n.t("Lined"), icon: "mdi-format-align-justify" },
|
|
{ key: "grid", label: window.ShaarliProI18n.t("Grid"), icon: "mdi-grid" },
|
|
{ key: "noise", label: window.ShaarliProI18n.t("Noise"), icon: "mdi-grain" },
|
|
{ key: "dots", label: window.ShaarliProI18n.t("Dots"), icon: "mdi-dots-grid" },
|
|
{ key: "stripes", label: window.ShaarliProI18n.t("Stripes"), icon: "mdi-slash-forward" },
|
|
];
|
|
|
|
const NOTE_FONT_COLOR_OPTIONS = [
|
|
{ key: "auto", label: window.ShaarliProI18n.t("Auto"), value: "auto" },
|
|
{ key: "light", label: window.ShaarliProI18n.t("Light"), value: "#f5f7fb" },
|
|
{ key: "dark", label: window.ShaarliProI18n.t("Dark"), value: "#202124" },
|
|
{ key: "white", label: window.ShaarliProI18n.t("White"), value: "#ffffff" },
|
|
{ key: "black", label: window.ShaarliProI18n.t("Black"), value: "#000000" },
|
|
{ key: "custom", label: window.ShaarliProI18n.t("Custom"), value: "custom" },
|
|
];
|
|
|
|
const NOTE_FONT_COLOR_TAG_PREFIX = "font-";
|
|
const NOTE_COLOR_TAG_PREFIX = "note-color-";
|
|
|
|
const NOTE_FILTER_TAG_PREFIX = "notefilter-";
|
|
const NOTE_BACKGROUND_TAG_PREFIX = "notebg-";
|
|
|
|
function isTechnicalTag(tag) {
|
|
if (typeof tag !== "string") return false;
|
|
const t = tag.trim().toLowerCase();
|
|
if (!t) return false;
|
|
|
|
if (t === "note" || t === "#note") return true;
|
|
if (t === "shaarli-note") return true;
|
|
if (t === "todo" || t === "#todo") return true;
|
|
if (t === "shaarli-todo") return true;
|
|
if (t === "shaarli-pin") return true;
|
|
if (t === "brain-dump") return true;
|
|
if (t.startsWith(NOTE_FONT_COLOR_TAG_PREFIX)) return true;
|
|
if (t.startsWith(NOTE_COLOR_TAG_PREFIX)) return true;
|
|
if (t.startsWith(NOTE_FILTER_TAG_PREFIX)) return true;
|
|
if (t.startsWith(NOTE_BACKGROUND_TAG_PREFIX)) return true;
|
|
if (t.startsWith("notefilter-")) return true;
|
|
if (t.startsWith("notebg-")) return true;
|
|
if (t.startsWith("note-color-")) return true;
|
|
|
|
// Legacy note color tags: note-<color>
|
|
if (t.startsWith("note-")) {
|
|
const candidate = t.substring(5);
|
|
if (NOTE_COLOR_OPTIONS.some((opt) => opt.key === candidate)) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function createTagPill({ tag, onRemoveClass = "tag-remove-btn", tagClass = "tag-pill" }) {
|
|
const wrapper = document.createElement("span");
|
|
wrapper.className = tagClass;
|
|
wrapper.dataset.tag = tag;
|
|
|
|
const text = document.createElement("span");
|
|
text.className = `${tagClass}-text`;
|
|
text.textContent = tag;
|
|
wrapper.appendChild(text);
|
|
|
|
const canRemove = arguments[0] && Object.prototype.hasOwnProperty.call(arguments[0], "canRemove") ? !!arguments[0].canRemove : true;
|
|
if (!canRemove) return wrapper;
|
|
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = onRemoveClass;
|
|
btn.dataset.tag = tag;
|
|
btn.setAttribute("aria-label", `Supprimer le tag ${tag}`);
|
|
btn.title = window.ShaarliProI18n.t("Delete");
|
|
btn.innerHTML = "×";
|
|
wrapper.appendChild(btn);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
function removeTagFromEntity(editUrl, tag) {
|
|
return fetch(editUrl)
|
|
.then((response) => response.text())
|
|
.then((html) => {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const form = doc.querySelector('form[name="linkform"]');
|
|
if (!form) throw new Error("Could not find edit form");
|
|
|
|
const formData = new URLSearchParams();
|
|
const inputs = form.querySelectorAll("input, textarea");
|
|
inputs.forEach((input) => {
|
|
if (input.type === "checkbox") {
|
|
if (input.checked) formData.append(input.name, input.value || "on");
|
|
} else if (input.name) {
|
|
formData.append(input.name, getFormFieldValue(input));
|
|
}
|
|
});
|
|
|
|
let currentTags = formData.get("lf_tags") || "";
|
|
let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== "");
|
|
tagsArray = tagsArray.filter((t) => t !== tag);
|
|
|
|
formData.set("lf_tags", tagsArray.join(" "));
|
|
formData.append("save_edit", "1");
|
|
|
|
return fetch(form.action, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: formData.toString(),
|
|
});
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) throw new Error("Failed to update tags");
|
|
return response;
|
|
});
|
|
}
|
|
|
|
function deleteEntitySilently(deleteUrl) {
|
|
if (!deleteUrl || deleteUrl === "#") return Promise.reject("Invalid delete URL");
|
|
|
|
return fetch(deleteUrl).then((response) => {
|
|
if (!response.ok) throw new Error("Delete request failed");
|
|
return response;
|
|
});
|
|
}
|
|
|
|
let tagDisplayRemovalInitialized = false;
|
|
function initTagDisplayAndRemoval() {
|
|
if (tagDisplayRemovalInitialized) return;
|
|
|
|
// Filter technical tags on bookmarks list (standard view).
|
|
// Keep shaarli-pin in DOM (hidden) so initPinnedItems can still detect it.
|
|
document.querySelectorAll(".link-tag-list .link-tag").forEach((el) => {
|
|
const tag = (el.dataset.tag || el.textContent || "").trim();
|
|
if (!tag) return;
|
|
if (tag === "shaarli-pin") {
|
|
el.classList.add("is-tech-tag");
|
|
return;
|
|
}
|
|
if (isTechnicalTag(tag)) {
|
|
el.classList.add("is-tech-tag");
|
|
}
|
|
});
|
|
|
|
// Delegate remove button clicks (bookmarks + notes + modal)
|
|
document.addEventListener("click", function (e) {
|
|
const btn = e.target.closest(".tag-remove-btn, .note-tag-remove-btn");
|
|
if (!btn) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const tag = (btn.dataset.tag || "").trim();
|
|
if (!tag) return;
|
|
|
|
const tagEl = btn.closest(".link-tag, .note-tag");
|
|
const card = btn.closest(".link-outer, .note-card, .note-modal");
|
|
|
|
let editUrl = "";
|
|
if (card && card.classList.contains("note-card")) {
|
|
editUrl = card.dataset.editUrl || "";
|
|
} else if (card && card.classList.contains("note-modal")) {
|
|
editUrl = card.dataset.editUrl || "";
|
|
} else {
|
|
const actionsEl = card ? card.querySelector(".link-actions") : null;
|
|
const editLink = actionsEl ? actionsEl.querySelector('a[href*="admin/shaare"]') : null;
|
|
editUrl = editLink ? editLink.href : "";
|
|
}
|
|
|
|
if (!editUrl) return;
|
|
|
|
removeTagFromEntity(editUrl, tag)
|
|
.then(() => {
|
|
if (tagEl) tagEl.remove();
|
|
|
|
// Sync note datasets + modal tag list if applicable
|
|
if (card && card.classList.contains("note-card")) {
|
|
let tags = (card.dataset.tags || "").split("||").filter((t) => t);
|
|
tags = tags.filter((t) => t !== tag);
|
|
card.dataset.tags = tags.join("||");
|
|
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (modal && entity && String(entity.id) === String(card.dataset.id)) {
|
|
entity.tags = (entity.tags || []).filter((t) => t !== tag);
|
|
const tagsContainer = modal.querySelector(".note-modal-tags");
|
|
renderModalTags(tagsContainer, entity.tags);
|
|
}
|
|
}
|
|
|
|
if (card && card.classList.contains("note-modal")) {
|
|
let tags = (card.dataset.tags || "").split("||").filter((t) => t);
|
|
tags = tags.filter((t) => t !== tag);
|
|
card.dataset.tags = tags.join("||");
|
|
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (modal && entity) {
|
|
entity.tags = (entity.tags || []).filter((t) => t !== tag);
|
|
const tagsContainer = modal.querySelector(".note-modal-tags");
|
|
renderModalTags(tagsContainer, entity.tags);
|
|
}
|
|
|
|
// Also update corresponding card if visible
|
|
const entityId = card.dataset.noteId || card.dataset.todoId || (entity && entity.id ? String(entity.id) : "");
|
|
const entityCard = entityId ? document.querySelector(`.note-card[data-id="${entityId}"]`) : null;
|
|
if (entityCard) {
|
|
const pill = entityCard.querySelector(`.note-tag[data-tag="${CSS.escape(tag)}"]`);
|
|
if (pill) pill.remove();
|
|
let entityTags = (entityCard.dataset.tags || "").split("||").filter((t) => t);
|
|
entityTags = entityTags.filter((t) => t !== tag);
|
|
entityCard.dataset.tags = entityTags.join("||");
|
|
}
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error("Error removing tag:", err);
|
|
alert(window.ShaarliProI18n.t("Error removing tag."));
|
|
});
|
|
});
|
|
|
|
tagDisplayRemovalInitialized = true;
|
|
}
|
|
|
|
function getOpenModalOverlay() {
|
|
return document.querySelector(".note-modal-overlay.open");
|
|
}
|
|
|
|
function getModalCurrentEntity(modal) {
|
|
if (!modal) return null;
|
|
return modal.currentTodo || modal.currentNote || null;
|
|
}
|
|
|
|
function resolveThemeAssetBasePath() {
|
|
const cssLink = Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find((link) => link.href && link.href.includes("/custom_views.css"));
|
|
if (cssLink && cssLink.href) {
|
|
const cssUrl = new URL(cssLink.href, window.location.origin);
|
|
const cssPath = cssUrl.pathname.replace(/\/css\/custom_views\.css$/, "");
|
|
if (cssPath) return cssPath;
|
|
}
|
|
|
|
const jsScript = Array.from(document.querySelectorAll("script[src]")).find((script) => script.src && script.src.includes("/custom_views.js"));
|
|
if (jsScript && jsScript.src) {
|
|
const jsUrl = new URL(jsScript.src, window.location.origin);
|
|
const jsPath = jsUrl.pathname.replace(/\/js\/custom_views\.js$/, "");
|
|
if (jsPath) return jsPath;
|
|
}
|
|
|
|
if (window.shaarli && typeof window.shaarli.assetPath === "string" && window.shaarli.assetPath.trim() !== "") {
|
|
return window.shaarli.assetPath.replace(/\/$/, "");
|
|
}
|
|
|
|
return "/tpl/shaarli-pro";
|
|
}
|
|
|
|
const NOTE_BACKGROUND_ASSET_ROOT = `${resolveThemeAssetBasePath().replace(/\/$/, "")}/img`;
|
|
|
|
// Liste des backgrounds - chargée dynamiquement depuis le serveur
|
|
let NOTE_BACKGROUND_OPTIONS = [];
|
|
let isBackgroundOptionsLoaded = false;
|
|
let backgroundOptionsLoadPromise = null;
|
|
|
|
window.__shaarliCustomViewsVersion = "1.0.5-inline-bg-manifest";
|
|
if (typeof console !== "undefined" && console && typeof console.info === "function") {
|
|
}
|
|
|
|
function organizePinnedBookmarks() {
|
|
const linksList = document.getElementById("links-list");
|
|
if (!linksList) return;
|
|
|
|
const pinnedHead = linksList.querySelector(".pinned-section-head");
|
|
const normalHead = linksList.querySelector(".normal-section-head");
|
|
const compactHead = linksList.querySelector(".compact-table-head");
|
|
if (!pinnedHead || !normalHead || !compactHead) return;
|
|
|
|
const allBookmarks = Array.from(linksList.getElementsByClassName("link-outer"));
|
|
if (!allBookmarks.length) return;
|
|
|
|
const isPinnedBookmark = (bookmark) => {
|
|
if (bookmark.classList.contains("is-sticky") || bookmark.classList.contains("is-pinned-tag")) {
|
|
return true;
|
|
}
|
|
|
|
const tags = bookmark.querySelectorAll(".link-tag-list a, .link-tag-list .link-tag-link, .note-tag");
|
|
for (const tag of tags) {
|
|
const value = (tag.textContent || "").trim().toLowerCase();
|
|
if (value === "shaarli-pin") {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const pinnedBookmarks = allBookmarks.filter((bookmark) => isPinnedBookmark(bookmark));
|
|
const normalBookmarks = allBookmarks.filter((bookmark) => !isPinnedBookmark(bookmark));
|
|
|
|
linksList.classList.toggle("has-pinned", pinnedBookmarks.length > 0);
|
|
linksList.classList.toggle("has-normal", normalBookmarks.length > 0);
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
fragment.appendChild(pinnedHead);
|
|
pinnedBookmarks.forEach((bookmark) => fragment.appendChild(bookmark));
|
|
|
|
fragment.appendChild(normalHead);
|
|
fragment.appendChild(compactHead);
|
|
normalBookmarks.forEach((bookmark) => fragment.appendChild(bookmark));
|
|
|
|
linksList.replaceChildren(fragment);
|
|
}
|
|
|
|
function initPinnedBookmarksSeparation() {
|
|
const linksList = document.getElementById("links-list");
|
|
if (!linksList) return;
|
|
|
|
organizePinnedBookmarks();
|
|
|
|
const viewButtons = ["view-grid-btn", "view-list-btn", "view-compact-btn"];
|
|
viewButtons.forEach((id) => {
|
|
const button = document.getElementById(id);
|
|
if (!button) return;
|
|
button.addEventListener("click", () => {
|
|
window.requestAnimationFrame(organizePinnedBookmarks);
|
|
});
|
|
});
|
|
|
|
linksList.addEventListener("click", (event) => {
|
|
const pinLink = event.target.closest('a[href*="/pin?token="]');
|
|
if (!pinLink) return;
|
|
|
|
window.setTimeout(() => {
|
|
window.location.reload();
|
|
}, 500);
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", initPinnedBookmarksSeparation);
|
|
|
|
const NOTE_BACKGROUND_MANIFEST_INLINE = [
|
|
{ key: "cafe", label: window.ShaarliProI18n.t("Coffee"), files: { light: "bg-light-cafe.jpg", dark: "bg-dark-cafe.jpg" } },
|
|
{ key: "codes", label: "Codes", files: { light: "bg-light-codes.jpg", dark: "bg-dark-codes.jpg" } },
|
|
{ key: "dune", label: "Dune", files: { light: "bg-light-dune.jpg", dark: "bg-dark-dune.jpg" } },
|
|
{ key: "feuille-ligne", label: "Feuille Ligne", files: { light: "bg-light-feuilleLigne.jpg", dark: "bg-dark-feuilleLigne.jpg" } },
|
|
{ key: "feuille-quadrillage", label: "Feuille Quadrillage", files: { light: "bg-light-feuilleQuadrille.jpg", dark: "bg-dark-feuilleQuadrille.jpg" } },
|
|
{ key: "fleurs", label: "Fleurs", files: { light: "bg-light-fleurs.jpg", dark: "bg-dark-fleurs.jpg" } },
|
|
{ key: "foret", label: window.ShaarliProI18n.t("Forest"), files: { light: "bg-light-foret.jpg", dark: "bg-dark-foret.jpg" } },
|
|
{ key: "grid", label: "Grid", files: { light: "bg-light-grid.jpg", dark: "bg-dark-grid.jpg" } },
|
|
{ key: "journal", label: "Journal", files: { light: "bg-light-journal.jpg", dark: "bg-dark-journal.jpg" } },
|
|
{ key: "lecture", label: "Lecture", files: { light: "bg-light-lecture.jpg", dark: "bg-dark-lecture.jpg" } },
|
|
{ key: "legumes", label: window.ShaarliProI18n.t("Vegetables"), files: { light: "bg-light-legumes.jpg", dark: "bg-dark-legumes.jpg" } },
|
|
{ key: "montagnes", label: "Montagnes", files: { light: "bg-light-montagnes.jpg", dark: "bg-dark-montagnes.jpg" } },
|
|
{ key: "ocean", label: window.ShaarliProI18n.t("Ocean"), files: { light: "bg-light-ocean.jpg", dark: "bg-dark-ocean.jpg" } },
|
|
{ key: "radio", label: "Radio", files: { light: "bg-light-radio.jpg", dark: "bg-dark-radio.jpg" } },
|
|
{ key: "sports", label: "Sports", files: { light: "bg-light-sports.jpg", dark: "bg-dark-sports.jpg" } },
|
|
{ key: "vague1", label: "Vague 1", files: { light: "bg-light-vague1.jpg", dark: "bg-dark-vague1.jpg" } },
|
|
{ key: "vague2", label: "Vague 2", files: { light: "bg-light-vague2.jpg", dark: "bg-dark-vague2.jpg" } },
|
|
{ key: "ville", label: "Ville", files: { light: "bg-light-ville.jpg", dark: "bg-dark-ville.jpg" } },
|
|
{ key: "voyage", label: "Voyage", files: { light: "bg-light-voyage.jpg", dark: "bg-dark-voyage.jpg" } },
|
|
];
|
|
|
|
/**
|
|
* Charge la liste des backgrounds depuis le endpoint PHP
|
|
* @returns {Promise<Array>} Liste des backgrounds
|
|
*/
|
|
function loadBackgroundOptions() {
|
|
if (isBackgroundOptionsLoaded) {
|
|
return Promise.resolve(NOTE_BACKGROUND_OPTIONS);
|
|
}
|
|
|
|
if (backgroundOptionsLoadPromise) {
|
|
return backgroundOptionsLoadPromise;
|
|
}
|
|
|
|
NOTE_BACKGROUND_OPTIONS = normalizeDynamicBackgroundOptions(NOTE_BACKGROUND_MANIFEST_INLINE);
|
|
isBackgroundOptionsLoaded = true;
|
|
backgroundOptionsLoadPromise = Promise.resolve(NOTE_BACKGROUND_OPTIONS);
|
|
return backgroundOptionsLoadPromise;
|
|
}
|
|
|
|
function normalizeBackgroundKey(key) {
|
|
if (typeof key !== "string") return "";
|
|
|
|
return key
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\s_]+/g, "-")
|
|
.replace(/[^a-z0-9-]/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
}
|
|
|
|
function formatBackgroundLabel(rawLabel, fallbackKey) {
|
|
const base = (typeof rawLabel === "string" && rawLabel.trim() !== "" ? rawLabel : fallbackKey)
|
|
.replace(/[_-]+/g, " ")
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
|
|
return base
|
|
.split(" ")
|
|
.filter((part) => part !== "")
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
function getCurrentThemeMode() {
|
|
return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
|
}
|
|
|
|
function getColorOption(colorKey) {
|
|
const normalizedKey = typeof colorKey === "string" ? colorKey.trim().toLowerCase() : "";
|
|
|
|
return NOTE_COLOR_OPTIONS.find((opt) => opt.key === normalizedKey) || NOTE_COLOR_OPTIONS.find((opt) => opt.key === "default") || null;
|
|
}
|
|
|
|
function getThemeColorValue(option, mode = getCurrentThemeMode()) {
|
|
if (!option || typeof option !== "object") return "";
|
|
|
|
const safeMode = mode === "dark" ? "dark" : "light";
|
|
const preferred = typeof option[safeMode] === "string" ? option[safeMode].trim() : "";
|
|
const fallbackLight = typeof option.light === "string" ? option.light.trim() : "";
|
|
const fallbackDark = typeof option.dark === "string" ? option.dark.trim() : "";
|
|
|
|
return preferred || fallbackLight || fallbackDark;
|
|
}
|
|
|
|
function parseHexColor(colorValue) {
|
|
if (typeof colorValue !== "string") return null;
|
|
|
|
const safeValue = colorValue.trim();
|
|
const shortHexMatch = /^#([0-9a-f]{3})$/i.exec(safeValue);
|
|
if (shortHexMatch) {
|
|
return {
|
|
r: parseInt(shortHexMatch[1].charAt(0).repeat(2), 16),
|
|
g: parseInt(shortHexMatch[1].charAt(1).repeat(2), 16),
|
|
b: parseInt(shortHexMatch[1].charAt(2).repeat(2), 16),
|
|
};
|
|
}
|
|
|
|
const longHexMatch = /^#([0-9a-f]{6})$/i.exec(safeValue);
|
|
if (longHexMatch) {
|
|
return {
|
|
r: parseInt(longHexMatch[1].slice(0, 2), 16),
|
|
g: parseInt(longHexMatch[1].slice(2, 4), 16),
|
|
b: parseInt(longHexMatch[1].slice(4, 6), 16),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getReadableForegroundForBackground(colorValue, mode = getCurrentThemeMode()) {
|
|
const rgb = parseHexColor(colorValue);
|
|
if (!rgb) {
|
|
return mode === "dark" ? "#e8eaed" : "#202124";
|
|
}
|
|
|
|
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
|
return luminance > 0.56 ? "#202124" : "#f5f7fb";
|
|
}
|
|
|
|
function normalizeDynamicBackgroundOptions(rawOptions) {
|
|
const rawList = Array.isArray(rawOptions) ? rawOptions : rawOptions && Array.isArray(rawOptions.backgrounds) ? rawOptions.backgrounds : [];
|
|
|
|
const mergedByKey = {};
|
|
|
|
rawList.forEach((option) => {
|
|
if (!option || typeof option !== "object") return;
|
|
|
|
const key = normalizeBackgroundKey(typeof option.key === "string" ? option.key : option.label || "");
|
|
if (!key) return;
|
|
|
|
const files = option.files && typeof option.files === "object" ? option.files : {};
|
|
const lightFile = typeof files.light === "string" ? files.light.trim() : "";
|
|
const darkFile = typeof files.dark === "string" ? files.dark.trim() : "";
|
|
if (!lightFile && !darkFile) return;
|
|
|
|
if (!mergedByKey[key]) {
|
|
mergedByKey[key] = {
|
|
key,
|
|
label: formatBackgroundLabel(option.label || "", key),
|
|
paths: {
|
|
light: "",
|
|
dark: "",
|
|
},
|
|
};
|
|
}
|
|
|
|
if (lightFile) {
|
|
mergedByKey[key].paths.light = `note-bg-light/${lightFile}`;
|
|
}
|
|
if (darkFile) {
|
|
mergedByKey[key].paths.dark = `note-bg-dark/${darkFile}`;
|
|
}
|
|
});
|
|
|
|
return Object.values(mergedByKey).sort((a, b) => a.label.localeCompare(b.label, "fr", { sensitivity: "base" }));
|
|
}
|
|
|
|
function getAvailableBackgroundOptionsForMode(mode = getCurrentThemeMode()) {
|
|
const safeMode = mode === "dark" ? "dark" : "light";
|
|
return NOTE_BACKGROUND_OPTIONS.filter((option) => option.paths && option.paths[safeMode]);
|
|
}
|
|
|
|
function getNoteBackgroundUrl(backgroundKey, mode = getCurrentThemeMode()) {
|
|
const normalizedKey = normalizeBackgroundKey(backgroundKey);
|
|
if (!normalizedKey) return "";
|
|
|
|
const found = NOTE_BACKGROUND_OPTIONS.find((bg) => bg.key === normalizedKey);
|
|
if (!found || !found.paths) return "";
|
|
|
|
const safeMode = mode === "dark" ? "dark" : "light";
|
|
const selectedPath = found.paths[safeMode] || "";
|
|
|
|
if (!selectedPath) return "";
|
|
return `${NOTE_BACKGROUND_ASSET_ROOT}/${selectedPath}`;
|
|
}
|
|
|
|
function getElementVisualColor(element) {
|
|
if (!element) return "default";
|
|
|
|
if (element.dataset.color === "custom" && element.dataset.customColor) {
|
|
return `custom:${element.dataset.customColor}`;
|
|
}
|
|
|
|
const colorClass = Array.from(element.classList).find((cls) => cls.startsWith("note-color-"));
|
|
if (colorClass) return colorClass.replace("note-color-", "");
|
|
|
|
return element.dataset.color || "default";
|
|
}
|
|
|
|
function getElementVisualBackground(element) {
|
|
if (!element) return "none";
|
|
|
|
const normalizedBackground = normalizeBackgroundKey(element.dataset.background || "");
|
|
return normalizedBackground || "none";
|
|
}
|
|
|
|
function normalizeFilterKey(key) {
|
|
if (typeof key !== "string") return "none";
|
|
const normalized = key.trim().toLowerCase();
|
|
if (NOTE_FILTER_OPTIONS.some((opt) => opt.key === normalized)) {
|
|
return normalized;
|
|
}
|
|
return "none";
|
|
}
|
|
|
|
function getElementVisualFilter(element) {
|
|
if (!element) return "none";
|
|
return normalizeFilterKey(element.dataset.filter || "");
|
|
}
|
|
|
|
function getElementVisualFontColor(element) {
|
|
if (!element) return "auto";
|
|
return element.dataset.fontColor || "auto";
|
|
}
|
|
|
|
function refreshNoteFilterVisuals() {
|
|
document.querySelectorAll(".note-card, .note-modal, .link-outer, .note-input-container").forEach((element) => {
|
|
applyNoteVisualState(element, {
|
|
color: getElementVisualColor(element),
|
|
filter: getElementVisualFilter(element),
|
|
background: getElementVisualBackground(element),
|
|
fontColor: getElementVisualFontColor(element),
|
|
});
|
|
});
|
|
}
|
|
|
|
function refreshBackgroundPalettes() {
|
|
document.querySelectorAll(".note-card").forEach((card) => {
|
|
const palettePopup = card.querySelector(".note-hover-actions .palette-popup");
|
|
if (!palettePopup) return;
|
|
|
|
const noteId = card.dataset.id || "";
|
|
if (!noteId) return;
|
|
|
|
const editLink = card.querySelector('.note-hover-actions a[href*="/admin/shaare/"], .note-hover-actions a[href*="do=editlink"], .note-hover-actions a[title="Edit"]');
|
|
|
|
palettePopup.innerHTML = generatePaletteButtons({
|
|
id: noteId,
|
|
editUrl: editLink && editLink.href ? editLink.href : "#",
|
|
color: getElementVisualColor(card),
|
|
background: getElementVisualBackground(card),
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll(".link-outer").forEach((card) => {
|
|
const palettePopup = card.querySelector(".bookmark-palette .palette-popup");
|
|
if (!palettePopup) return;
|
|
|
|
const noteId = card.dataset.id || card.getAttribute("data-id") || card.id;
|
|
if (!noteId) return;
|
|
|
|
const actions = card.querySelector(".link-actions");
|
|
const editLink = actions ? actions.querySelector('a[title=window.ShaarliProI18n.t("Edit")], a[aria-label="Modifier ce bookmark"], a[href*="/admin/shaare/"]') : null;
|
|
|
|
palettePopup.innerHTML = generateBookmarkPaletteButtons(noteId, editLink && editLink.href ? editLink.href : "#", getElementVisualColor(card), getElementVisualBackground(card));
|
|
});
|
|
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (!modal || !entity) return;
|
|
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
if (modalCard) {
|
|
entity.color = getElementVisualColor(modalCard);
|
|
entity.background = getElementVisualBackground(modalCard);
|
|
}
|
|
|
|
const modalColorPopup = modal.querySelector(".note-modal-palette");
|
|
if (modalColorPopup) {
|
|
modalColorPopup.innerHTML = generateModalPaletteButtons(entity);
|
|
}
|
|
}
|
|
|
|
function parseBackgroundManifestPayload(rawPayload) {
|
|
const payload = typeof rawPayload === "string" ? rawPayload.trim() : "";
|
|
if (!payload) return [];
|
|
|
|
try {
|
|
return JSON.parse(payload);
|
|
} catch (parseError) {
|
|
const firstArrayBracket = payload.indexOf("[");
|
|
const lastArrayBracket = payload.lastIndexOf("]");
|
|
if (firstArrayBracket === -1 || lastArrayBracket === -1 || lastArrayBracket <= firstArrayBracket) {
|
|
throw parseError;
|
|
}
|
|
|
|
const jsonSlice = payload.slice(firstArrayBracket, lastArrayBracket + 1);
|
|
return JSON.parse(jsonSlice);
|
|
}
|
|
}
|
|
|
|
let isThemeModeBackgroundSyncInitialized = false;
|
|
|
|
function initThemeModeBackgroundSync() {
|
|
if (isThemeModeBackgroundSyncInitialized) return;
|
|
|
|
const root = document.documentElement;
|
|
if (!root) return;
|
|
|
|
let lastTheme = getCurrentThemeMode();
|
|
const observer = new MutationObserver((mutations) => {
|
|
const themeChanged = mutations.some((mutation) => mutation.attributeName === "data-theme");
|
|
if (!themeChanged) return;
|
|
|
|
const nextTheme = getCurrentThemeMode();
|
|
if (nextTheme === lastTheme) return;
|
|
|
|
lastTheme = nextTheme;
|
|
refreshNoteFilterVisuals();
|
|
refreshBackgroundPalettes();
|
|
});
|
|
|
|
observer.observe(root, { attributes: true, attributeFilter: ["data-theme"] });
|
|
isThemeModeBackgroundSyncInitialized = true;
|
|
}
|
|
|
|
function positionPalettePopup(popup) {
|
|
if (!popup || !popup.classList.contains("open")) return;
|
|
|
|
popup.classList.remove("open-down");
|
|
popup.style.left = "";
|
|
popup.style.right = "";
|
|
|
|
const viewportPadding = 8;
|
|
const upRect = popup.getBoundingClientRect();
|
|
if (upRect.top < viewportPadding) {
|
|
popup.classList.add("open-down");
|
|
|
|
const downRect = popup.getBoundingClientRect();
|
|
if (downRect.bottom > window.innerHeight - viewportPadding) {
|
|
popup.classList.remove("open-down");
|
|
}
|
|
}
|
|
|
|
let rect = popup.getBoundingClientRect();
|
|
if (rect.right > window.innerWidth - viewportPadding) {
|
|
popup.style.left = "auto";
|
|
popup.style.right = "0";
|
|
rect = popup.getBoundingClientRect();
|
|
}
|
|
|
|
if (rect.left < viewportPadding) {
|
|
popup.style.left = "0";
|
|
popup.style.right = "auto";
|
|
}
|
|
}
|
|
|
|
let backgroundStudioPanelInitialized = false;
|
|
|
|
function ensureBackgroundStudioPanel() {
|
|
if (backgroundStudioPanelInitialized) return;
|
|
if (document.getElementById("shaarli-bg-studio")) {
|
|
backgroundStudioPanelInitialized = true;
|
|
return;
|
|
}
|
|
|
|
const panel = document.createElement("div");
|
|
panel.id = "shaarli-bg-studio";
|
|
panel.className = "bg-studio-panel";
|
|
panel.setAttribute("role", "dialog");
|
|
panel.setAttribute("aria-modal", "false");
|
|
panel.setAttribute("aria-hidden", "true");
|
|
panel.style.display = "none";
|
|
document.body.appendChild(panel);
|
|
|
|
panel.addEventListener("click", (e) => {
|
|
const actionBtn = e.target.closest("button[data-bg-studio-action]");
|
|
if (!actionBtn) return;
|
|
|
|
const panelEl = document.getElementById("shaarli-bg-studio");
|
|
if (!panelEl) return;
|
|
|
|
const mode = panelEl.dataset.mode || "entity";
|
|
const entityId = panelEl.dataset.entityId || "";
|
|
const editUrl = panelEl.dataset.editUrl || "";
|
|
|
|
const applyDraft = (next) => {
|
|
if (typeof panelEl.__draftApply === "function") {
|
|
panelEl.__draftApply(next);
|
|
}
|
|
};
|
|
|
|
const action = actionBtn.dataset.bgStudioAction;
|
|
if (action === "close") {
|
|
closeBackgroundStudioPanel();
|
|
return;
|
|
}
|
|
|
|
if (action === "set-color") {
|
|
const key = actionBtn.dataset.colorKey || "default";
|
|
if (mode === "draft") {
|
|
panelEl.dataset.color = key;
|
|
applyDraft({ color: key });
|
|
renderBackgroundStudioPanel(panelEl);
|
|
} else if (mode === "modal") {
|
|
setModalNoteColor(key);
|
|
panelEl.dataset.color = key;
|
|
renderBackgroundStudioPanel(panelEl);
|
|
} else {
|
|
setNoteColor(entityId, key, editUrl);
|
|
panelEl.dataset.color = key;
|
|
renderBackgroundStudioPanel(panelEl);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (action === "set-background") {
|
|
const key = actionBtn.dataset.bgKey || "none";
|
|
if (mode === "draft") {
|
|
panelEl.dataset.background = key;
|
|
applyDraft({ background: key });
|
|
renderBackgroundStudioPanel(panelEl);
|
|
} else if (mode === "modal") {
|
|
setModalNoteBackground(key);
|
|
panelEl.dataset.background = key;
|
|
renderBackgroundStudioPanel(panelEl);
|
|
} else {
|
|
setNoteBackground(entityId, key, editUrl);
|
|
panelEl.dataset.background = key;
|
|
renderBackgroundStudioPanel(panelEl);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (action === "set-filter") {
|
|
const filterKey = actionBtn.dataset.filter || "none";
|
|
const normalizedFilter = normalizeFilterKey(filterKey || "") || "none";
|
|
if (mode === "draft") {
|
|
panelEl.dataset.filter = normalizedFilter;
|
|
applyDraft({ filter: normalizedFilter });
|
|
renderBackgroundStudioPanel(panelEl);
|
|
} else if (mode === "modal") {
|
|
setModalNoteFilter(filterKey);
|
|
panelEl.dataset.filter = normalizedFilter;
|
|
renderBackgroundStudioPanel(panelEl);
|
|
} else {
|
|
setNoteFilter(entityId, filterKey, editUrl);
|
|
panelEl.dataset.filter = normalizedFilter;
|
|
renderBackgroundStudioPanel(panelEl);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (action === "set-query") {
|
|
panelEl.dataset.query = "";
|
|
renderBackgroundStudioPanel(panelEl);
|
|
const input = panelEl.querySelector(".bg-studio-search-input");
|
|
if (input) input.focus();
|
|
return;
|
|
}
|
|
|
|
if (action === "set-defaults") {
|
|
if (mode === "draft") {
|
|
panelEl.dataset.color = "default";
|
|
panelEl.dataset.background = "none";
|
|
applyDraft({ color: "default", background: "none" });
|
|
renderBackgroundStudioPanel(panelEl);
|
|
} else if (mode === "modal") {
|
|
setModalNoteColor("default");
|
|
panelEl.dataset.color = "default";
|
|
panelEl.dataset.background = "none";
|
|
renderBackgroundStudioPanel(panelEl);
|
|
} else {
|
|
setNoteColor(entityId, "default", editUrl);
|
|
panelEl.dataset.color = "default";
|
|
panelEl.dataset.background = "none";
|
|
renderBackgroundStudioPanel(panelEl);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (action === "reset-font-color") {
|
|
if (mode === "draft") {
|
|
panelEl.dataset.fontColor = "auto";
|
|
applyDraft({ fontColor: "auto" });
|
|
renderBackgroundStudioPanel(panelEl);
|
|
} else if (mode === "modal") {
|
|
setModalNoteFontColor("auto");
|
|
panelEl.dataset.fontColor = "auto";
|
|
renderBackgroundStudioPanel(panelEl);
|
|
} else {
|
|
setNoteFontColor(entityId, "auto", editUrl);
|
|
panelEl.dataset.fontColor = "auto";
|
|
renderBackgroundStudioPanel(panelEl);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (action === "open-color-picker") {
|
|
openColorPickerPanel({ mode, entityId, editUrl, type: "background" });
|
|
return;
|
|
}
|
|
|
|
if (action === "open-font-color-picker") {
|
|
openColorPickerPanel({ mode, entityId, editUrl, type: "font" });
|
|
return;
|
|
}
|
|
|
|
if (action === "set-font-color") {
|
|
const fontColorKey = actionBtn.dataset.fontColorKey || "auto";
|
|
if (mode === "draft") {
|
|
panelEl.dataset.fontColor = fontColorKey;
|
|
applyDraft({ fontColor: fontColorKey });
|
|
renderBackgroundStudioPanel(panelEl);
|
|
} else if (mode === "modal") {
|
|
setModalNoteFontColor(fontColorKey);
|
|
panelEl.dataset.fontColor = fontColorKey;
|
|
renderBackgroundStudioPanel(panelEl);
|
|
} else {
|
|
setNoteFontColor(entityId, fontColorKey, editUrl);
|
|
panelEl.dataset.fontColor = fontColorKey;
|
|
renderBackgroundStudioPanel(panelEl);
|
|
}
|
|
return;
|
|
}
|
|
});
|
|
|
|
panel.addEventListener("keydown", (e) => {
|
|
const targetBtn = e.target && e.target.closest ? e.target.closest("button") : null;
|
|
if (!targetBtn) return;
|
|
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
if (targetBtn.hasAttribute("data-bg-studio-action") || targetBtn.classList.contains("bg-studio-thumb") || targetBtn.classList.contains("bg-studio-swatch")) {
|
|
e.preventDefault();
|
|
targetBtn.click();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const moveFocus = (buttons, nextIndex) => {
|
|
if (!buttons || buttons.length === 0) return;
|
|
const idx = Math.max(0, Math.min(buttons.length - 1, nextIndex));
|
|
const el = buttons[idx];
|
|
if (el && typeof el.focus === "function") el.focus();
|
|
};
|
|
|
|
if (targetBtn.classList.contains("bg-studio-thumb")) {
|
|
const buttons = Array.from(panel.querySelectorAll(".bg-studio-gallery .bg-studio-thumb"));
|
|
const currentIndex = buttons.indexOf(targetBtn);
|
|
if (currentIndex === -1) return;
|
|
|
|
const colSize = 2;
|
|
if (e.key === "ArrowRight") {
|
|
e.preventDefault();
|
|
moveFocus(buttons, currentIndex + colSize);
|
|
} else if (e.key === "ArrowLeft") {
|
|
e.preventDefault();
|
|
moveFocus(buttons, currentIndex - colSize);
|
|
} else if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
moveFocus(buttons, currentIndex + 1);
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
moveFocus(buttons, currentIndex - 1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (targetBtn.classList.contains("bg-studio-swatch")) {
|
|
const buttons = Array.from(panel.querySelectorAll(".bg-studio-swatches .bg-studio-swatch"));
|
|
const currentIndex = buttons.indexOf(targetBtn);
|
|
if (currentIndex === -1) return;
|
|
|
|
if (e.key === "ArrowRight") {
|
|
e.preventDefault();
|
|
moveFocus(buttons, currentIndex + 1);
|
|
} else if (e.key === "ArrowLeft") {
|
|
e.preventDefault();
|
|
moveFocus(buttons, currentIndex - 1);
|
|
}
|
|
return;
|
|
}
|
|
});
|
|
|
|
panel.addEventListener("input", (e) => {
|
|
const input = e.target && e.target.matches(".bg-studio-search-input") ? e.target : null;
|
|
if (!input) return;
|
|
|
|
const panelEl = document.getElementById("shaarli-bg-studio");
|
|
if (!panelEl) return;
|
|
panelEl.dataset.query = input.value || "";
|
|
|
|
window.clearTimeout(panelEl.__searchTimer);
|
|
panelEl.__searchTimer = window.setTimeout(() => {
|
|
renderBackgroundStudioPanel(panelEl);
|
|
}, 150);
|
|
});
|
|
|
|
backgroundStudioPanelInitialized = true;
|
|
}
|
|
|
|
function closeBackgroundStudioPanel() {
|
|
const panel = document.getElementById("shaarli-bg-studio");
|
|
if (!panel) return;
|
|
panel.classList.remove("open");
|
|
panel.style.display = "none";
|
|
panel.setAttribute("aria-hidden", "true");
|
|
panel.__anchorEl = null;
|
|
}
|
|
|
|
function openBackgroundStudioPanel({ anchorEl, mode, entityId, editUrl, currentColor, currentFilter, currentBackground, currentFontColor, title }) {
|
|
ensureBackgroundStudioPanel();
|
|
const panel = document.getElementById("shaarli-bg-studio");
|
|
if (!panel) return;
|
|
|
|
panel.dataset.mode = mode || "entity";
|
|
panel.dataset.entityId = entityId || "";
|
|
panel.dataset.editUrl = editUrl || "";
|
|
panel.dataset.color = currentColor || "default";
|
|
panel.dataset.filter = normalizeFilterKey(currentFilter || "") || "none";
|
|
panel.dataset.background = normalizeBackgroundKey(currentBackground || "") || "none";
|
|
panel.dataset.fontColor = currentFontColor || panel.dataset.fontColor || "auto";
|
|
panel.dataset.query = panel.dataset.query || "";
|
|
panel.dataset.title = title || window.ShaarliProI18n.t("My images & colors");
|
|
panel.__anchorEl = anchorEl || null;
|
|
|
|
renderBackgroundStudioPanel(panel);
|
|
|
|
panel.style.display = "block";
|
|
panel.classList.add("open");
|
|
panel.setAttribute("aria-hidden", "false");
|
|
|
|
positionBackgroundStudioPanel(panel, anchorEl);
|
|
|
|
window.requestAnimationFrame(() => {
|
|
const input = panel.querySelector(".bg-studio-search-input");
|
|
if (input && typeof input.focus === "function") {
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
});
|
|
}
|
|
|
|
function positionBackgroundStudioPanel(panel, anchorEl) {
|
|
if (!panel) return;
|
|
|
|
const viewportPadding = 12;
|
|
const rect = anchorEl && anchorEl.getBoundingClientRect ? anchorEl.getBoundingClientRect() : null;
|
|
|
|
panel.style.left = "";
|
|
panel.style.top = "";
|
|
panel.style.right = "";
|
|
panel.style.bottom = "";
|
|
|
|
const panelRect = panel.getBoundingClientRect();
|
|
const preferredTop = rect ? rect.top - panelRect.height - 10 : viewportPadding;
|
|
const preferredLeft = rect ? rect.left : viewportPadding;
|
|
|
|
let top = preferredTop;
|
|
if (top < viewportPadding) {
|
|
top = rect ? rect.bottom + 10 : viewportPadding;
|
|
}
|
|
|
|
let left = preferredLeft;
|
|
if (left + panelRect.width > window.innerWidth - viewportPadding) {
|
|
left = window.innerWidth - viewportPadding - panelRect.width;
|
|
}
|
|
if (left < viewportPadding) left = viewportPadding;
|
|
|
|
if (top + panelRect.height > window.innerHeight - viewportPadding) {
|
|
top = window.innerHeight - viewportPadding - panelRect.height;
|
|
}
|
|
if (top < viewportPadding) top = viewportPadding;
|
|
|
|
panel.style.left = `${Math.round(left)}px`;
|
|
panel.style.top = `${Math.round(top)}px`;
|
|
}
|
|
|
|
function normalizeSearchText(value) {
|
|
return String(value || "")
|
|
.toLowerCase()
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.trim();
|
|
}
|
|
|
|
function categorizeBackgroundKey(key, label) {
|
|
const hay = normalizeSearchText(`${key || ""} ${label || ""}`);
|
|
|
|
if (!key || key === "none") return "solid";
|
|
if (hay.includes("degrade") || hay.includes("gradient")) return "gradient";
|
|
if (hay.includes("grid") || hay.includes("quadrill") || hay.includes("lign") || hay.includes("feuille")) return "grid";
|
|
if (hay.includes("damier") || hay.includes("checker") || hay.includes("texture")) return "texture";
|
|
|
|
return "image";
|
|
}
|
|
|
|
function getBackgroundStudioItems() {
|
|
const colors = NOTE_COLOR_OPTIONS.map((opt) => ({
|
|
type: "color",
|
|
key: opt.key,
|
|
label: opt.label,
|
|
color: getThemeColorValue(opt),
|
|
keywords: [opt.key, opt.label],
|
|
}));
|
|
|
|
const backgrounds = [
|
|
{
|
|
type: "background",
|
|
category: "solid",
|
|
key: "none",
|
|
label: window.ShaarliProI18n.t("Solid color"),
|
|
keywords: ["none", "sans image", "solid", "uni"],
|
|
},
|
|
...getAvailableBackgroundOptionsForMode().map((bg) => ({
|
|
type: "background",
|
|
category: categorizeBackgroundKey(bg.key, bg.label),
|
|
key: bg.key,
|
|
label: bg.label,
|
|
url: getNoteBackgroundUrl(bg.key),
|
|
keywords: [bg.key, bg.label, "image", "background"],
|
|
})),
|
|
];
|
|
|
|
return { colors, backgrounds };
|
|
}
|
|
|
|
function renderBackgroundStudioPanel(panel) {
|
|
if (!panel) return;
|
|
|
|
const activeEl = document.activeElement;
|
|
const wasSearchFocused = !!(activeEl && panel.contains(activeEl) && activeEl.classList && activeEl.classList.contains("bg-studio-search-input"));
|
|
const caretStart = wasSearchFocused && typeof activeEl.selectionStart === "number" ? activeEl.selectionStart : null;
|
|
const caretEnd = wasSearchFocused && typeof activeEl.selectionEnd === "number" ? activeEl.selectionEnd : null;
|
|
|
|
const title = panel.dataset.title || window.ShaarliProI18n.t("My images & colors");
|
|
const color = panel.dataset.color || "default";
|
|
const isCustomColor = typeof color === "string" && color.startsWith("custom:");
|
|
const currentFilter = panel.dataset.filter || "none";
|
|
const currentFontColor = panel.dataset.fontColor || "auto";
|
|
const query = normalizeSearchText(panel.dataset.query || "");
|
|
|
|
const { colors, backgrounds } = getBackgroundStudioItems();
|
|
|
|
// Filter backgrounds based on search query
|
|
const filteredBackgrounds = backgrounds.filter((b) => {
|
|
if (!query) return true;
|
|
const hay = normalizeSearchText([b.label, b.key, "image", "background"].join(" "));
|
|
return hay.includes(query);
|
|
});
|
|
|
|
// Generate gallery HTML for backgrounds
|
|
const galleryHtml = filteredBackgrounds
|
|
.map((item) => {
|
|
const isActive = panel.dataset.background === item.key;
|
|
const common = `class="bg-studio-thumb ${isActive ? "is-active" : ""}" type="button"`;
|
|
const thumb =
|
|
item.key === "none"
|
|
? `<button ${common} data-bg-studio-action="set-background" data-bg-key="none" title="${item.label}" aria-label="${item.label}" aria-pressed="${isActive}"><span class="bg-studio-thumb-solid"></span></button>`
|
|
: `<button ${common} data-bg-studio-action="set-background" data-bg-key="${item.key}" title="${item.label}" aria-label="${item.label}" aria-pressed="${isActive}" style="background-image:url('${item.url || ""}')"></button>`;
|
|
|
|
return `<div class="bg-studio-tile">${thumb}<div class="bg-studio-thumb-label">${item.label}</div></div>`;
|
|
})
|
|
.join("");
|
|
|
|
// Colors row with custom color picker button
|
|
const colorsRowHtml =
|
|
colors
|
|
.filter((c) => c.key !== "default" && c.key !== "custom")
|
|
.map((c) => {
|
|
const isActive = color === c.key;
|
|
const hex = c.color || "";
|
|
return `<button class="bg-studio-swatch ${isActive ? "is-active" : ""}" type="button" data-bg-studio-action="set-color" data-color-key="${c.key}" title="${hex}" aria-label="${c.label}" aria-pressed="${isActive}" style="background-color:${hex}"></button>`;
|
|
})
|
|
.join("") +
|
|
// Add custom color button (opens color picker)
|
|
`<button class="bg-studio-swatch bg-studio-swatch-custom ${isCustomColor ? "is-active" : ""}" type="button" data-bg-studio-action="open-color-picker" title="${window.ShaarliProI18n.t("Custom color")}" aria-label="${window.ShaarliProI18n.t("Custom color")}" aria-pressed="${isCustomColor}">
|
|
<i class="mdi mdi-plus" aria-hidden="true"></i>
|
|
</button>`;
|
|
|
|
// Font colors row
|
|
const isCustomFontColor = typeof currentFontColor === "string" && currentFontColor.startsWith("custom:");
|
|
const fontColorsRowHtml =
|
|
NOTE_FONT_COLOR_OPTIONS.filter((c) => c.key !== "custom")
|
|
.map((c) => {
|
|
const isActive = currentFontColor === c.key;
|
|
const value = c.value || "auto";
|
|
const style = value === "auto" ? "background: linear-gradient(135deg, #f5f7fb 50%, #202124 50%);" : `background-color: ${value};`;
|
|
return `<button class="bg-studio-swatch ${isActive ? "is-active" : ""}" type="button" data-bg-studio-action="set-font-color" data-font-color-key="${c.key}" title="${c.label}" aria-label="${c.label}" aria-pressed="${isActive}" style="${style}"></button>`;
|
|
})
|
|
.join("") +
|
|
// Add custom font color button
|
|
`<button class="bg-studio-swatch bg-studio-swatch-custom ${isCustomFontColor ? "is-active" : ""}" type="button" data-bg-studio-action="open-font-color-picker" title="${window.ShaarliProI18n.t("Custom font color")}" aria-label="${window.ShaarliProI18n.t("Custom font color")}" aria-pressed="${isCustomFontColor}">
|
|
<i class="mdi mdi-plus" aria-hidden="true"></i>
|
|
</button>`;
|
|
|
|
const filterBtn = (key, icon, label) => {
|
|
const active = currentFilter === key;
|
|
return `<button class="bg-studio-filter-btn ${active ? "is-active" : ""}" type="button" data-bg-studio-action="set-filter" data-filter="${key}" title="${label}" aria-label="${label}" aria-pressed="${active}"><i class="mdi ${icon}" aria-hidden="true"></i></button>`;
|
|
};
|
|
|
|
const filtersHtml = NOTE_FILTER_OPTIONS.map((f) => filterBtn(f.key, f.icon, f.label)).join("");
|
|
|
|
const showClear = !!(panel.dataset.query || "").trim();
|
|
|
|
panel.innerHTML = `
|
|
<div class="bg-studio-header">
|
|
<div class="bg-studio-title">${title}</div>
|
|
<button class="bg-studio-close" type="button" data-bg-studio-action="close" title=window.ShaarliProI18n.t("Close") aria-label=window.ShaarliProI18n.t("Close")><i class="mdi mdi-close" aria-hidden="true"></i></button>
|
|
</div>
|
|
<div class="bg-studio-gallery" role="list">${galleryHtml}</div>
|
|
<div class="bg-studio-rows">
|
|
<div class="bg-studio-row">
|
|
<div class="bg-studio-row-heading">
|
|
<div class="bg-studio-row-label">Colors:</div>
|
|
<button type="button" class="bg-studio-reset" data-bg-studio-action="set-defaults" title=window.ShaarliProI18n.t("Reset") aria-label=window.ShaarliProI18n.t("Reset")><i class="mdi mdi-refresh" aria-hidden="true"></i></button>
|
|
</div>
|
|
<div class="bg-studio-swatches" role="list">${colorsRowHtml}</div>
|
|
</div>
|
|
<div class="bg-studio-row">
|
|
<div class="bg-studio-row-heading">
|
|
<div class="bg-studio-row-label">Font:</div>
|
|
<button type="button" class="bg-studio-reset" data-bg-studio-action="reset-font-color" title=window.ShaarliProI18n.t("Reset") aria-label=window.ShaarliProI18n.t("Reset")><i class="mdi mdi-refresh" aria-hidden="true"></i></button>
|
|
</div>
|
|
<div class="bg-studio-swatches bg-studio-swatches-font" role="list">${fontColorsRowHtml}</div>
|
|
</div>
|
|
<div class="bg-studio-row">
|
|
<div class="bg-studio-row-label">Filtres:</div>
|
|
<div class="bg-studio-filters">${filtersHtml}</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-studio-search">
|
|
<i class="mdi mdi-magnify" aria-hidden="true"></i>
|
|
<input class="bg-studio-search-input" type="search" placeholder=window.ShaarliProI18n.t("Search my images… (filter by color or filter)") value="${panel.dataset.query || ""}" />
|
|
<button class="bg-studio-clear" type="button" data-bg-studio-action="set-query" title=window.ShaarliProI18n.t("Clear search") aria-label=window.ShaarliProI18n.t("Clear search") style="${showClear ? "" : "display:none"}"><i class="mdi mdi-close-circle-outline" aria-hidden="true"></i></button>
|
|
</div>
|
|
`;
|
|
|
|
if (wasSearchFocused) {
|
|
const input = panel.querySelector(".bg-studio-search-input");
|
|
if (input && typeof input.focus === "function") {
|
|
input.focus({ preventScroll: true });
|
|
if (typeof input.setSelectionRange === "function") {
|
|
const len = (input.value || "").length;
|
|
const start = typeof caretStart === "number" ? Math.max(0, Math.min(len, caretStart)) : len;
|
|
const end = typeof caretEnd === "number" ? Math.max(0, Math.min(len, caretEnd)) : len;
|
|
try {
|
|
input.setSelectionRange(start, end);
|
|
} catch (e) {
|
|
// Ignore selection errors for non-text inputs or unsupported browsers.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyNoteVisualState(element, note) {
|
|
if (!element || !note) return;
|
|
|
|
const rawColor = note.color || "default";
|
|
const isCustomColor = typeof rawColor === "string" && rawColor.startsWith("custom:");
|
|
const customHex = isCustomColor ? rawColor.substring(7) : "";
|
|
|
|
const resolvedColorOption = isCustomColor ? null : getColorOption(rawColor);
|
|
const color = isCustomColor ? "custom" : resolvedColorOption ? resolvedColorOption.key : "default";
|
|
const colorValue = isCustomColor ? customHex : getThemeColorValue(resolvedColorOption);
|
|
const foregroundColor = getReadableForegroundForBackground(colorValue);
|
|
const filter = normalizeFilterKey(note.filter || "none");
|
|
const normalizedBackground = normalizeBackgroundKey(note.background || "");
|
|
const background = normalizedBackground || "none";
|
|
const fontColor = note.fontColor || "auto";
|
|
|
|
Array.from(element.classList).forEach((cls) => {
|
|
if (cls.startsWith("note-color-")) element.classList.remove(cls);
|
|
if (cls.startsWith("note-filter-")) element.classList.remove(cls);
|
|
});
|
|
element.classList.add(`note-color-${color}`);
|
|
if (filter !== "none") {
|
|
element.classList.add(`note-filter-${filter}`);
|
|
}
|
|
|
|
// For the "default" color we intentionally DO NOT set an inline
|
|
// background-color. That lets the CSS token --tile-bg (which follows
|
|
// the active theme's --header-bg) drive the tile color. A hard-coded
|
|
// inline style would win over the theme CSS (Shaarli pro theme bug).
|
|
if (colorValue && color !== "default") {
|
|
element.style.backgroundColor = colorValue;
|
|
element.style.removeProperty("border-color");
|
|
} else {
|
|
element.style.removeProperty("background-color");
|
|
}
|
|
|
|
if (foregroundColor && color !== "default") {
|
|
element.style.setProperty("--note-card-fg", foregroundColor);
|
|
} else {
|
|
element.style.removeProperty("--note-card-fg");
|
|
}
|
|
|
|
if (fontColor && fontColor !== "auto") {
|
|
let fontValue = null;
|
|
if (typeof fontColor === "string" && fontColor.startsWith("custom:")) {
|
|
fontValue = fontColor.substring(7);
|
|
} else {
|
|
const opt = NOTE_FONT_COLOR_OPTIONS.find((o) => o.key === fontColor);
|
|
if (opt && opt.value && opt.value !== "auto") fontValue = opt.value;
|
|
}
|
|
if (fontValue) {
|
|
element.style.setProperty("--note-card-fg", fontValue);
|
|
}
|
|
}
|
|
|
|
if (background && background !== "none") {
|
|
const bgUrl = getNoteBackgroundUrl(background);
|
|
if (bgUrl) {
|
|
element.classList.add("note-has-bg");
|
|
element.style.setProperty("--note-bg-image", `url('${bgUrl}')`);
|
|
element.dataset.background = background;
|
|
} else {
|
|
element.classList.remove("note-has-bg");
|
|
element.style.removeProperty("--note-bg-image");
|
|
element.dataset.background = "none";
|
|
}
|
|
} else {
|
|
element.classList.remove("note-has-bg");
|
|
element.style.removeProperty("--note-bg-image");
|
|
element.dataset.background = "none";
|
|
}
|
|
|
|
element.dataset.color = color;
|
|
if (isCustomColor && customHex) {
|
|
element.dataset.customColor = customHex;
|
|
} else {
|
|
element.dataset.customColor = "";
|
|
}
|
|
element.dataset.filter = filter;
|
|
element.dataset.fontColor = fontColor;
|
|
}
|
|
|
|
function extractNoteVisualStateFromTags(tags) {
|
|
const safeTags = Array.isArray(tags) ? tags : [];
|
|
|
|
let color = "default";
|
|
const foundColorTag = safeTags.find((t) => typeof t === "string" && t.startsWith(NOTE_COLOR_TAG_PREFIX));
|
|
if (foundColorTag) {
|
|
const candidate = foundColorTag.substring(NOTE_COLOR_TAG_PREFIX.length);
|
|
if (/^[0-9A-Fa-f]{6}$/.test(candidate)) {
|
|
color = `custom:#${candidate.toUpperCase()}`;
|
|
} else if (NOTE_COLOR_OPTIONS.some((opt) => opt.key === candidate)) {
|
|
color = candidate;
|
|
}
|
|
} else {
|
|
// Backward compat: legacy note-custom-<hex>
|
|
const legacyCustomTag = safeTags.find((t) => typeof t === "string" && t.startsWith("note-custom-"));
|
|
if (legacyCustomTag) {
|
|
const candidate = legacyCustomTag.substring("note-custom-".length);
|
|
if (/^[0-9A-Fa-f]{6}$/.test(candidate)) {
|
|
color = `custom:#${candidate.toUpperCase()}`;
|
|
}
|
|
}
|
|
|
|
// Backward compat: legacy note-<color>
|
|
const legacyColorTag = safeTags.find((t) => typeof t === "string" && t.startsWith("note-"));
|
|
if (legacyColorTag) {
|
|
const candidate = legacyColorTag.substring(5);
|
|
if (NOTE_COLOR_OPTIONS.some((opt) => opt.key === candidate)) {
|
|
color = candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
let filter = "none";
|
|
const foundFilterTag = safeTags.find((t) => typeof t === "string" && t.startsWith(NOTE_FILTER_TAG_PREFIX));
|
|
if (foundFilterTag) {
|
|
const candidate = normalizeFilterKey(foundFilterTag.substring(NOTE_FILTER_TAG_PREFIX.length));
|
|
if (candidate && candidate !== "none") {
|
|
filter = candidate;
|
|
}
|
|
}
|
|
|
|
let background = "none";
|
|
const foundBgTag = safeTags.find((t) => typeof t === "string" && t.startsWith(NOTE_BACKGROUND_TAG_PREFIX));
|
|
if (foundBgTag) {
|
|
const candidate = normalizeBackgroundKey(foundBgTag.substring(NOTE_BACKGROUND_TAG_PREFIX.length));
|
|
if (candidate) {
|
|
background = candidate;
|
|
}
|
|
}
|
|
|
|
let fontColor = "auto";
|
|
const foundFontTag = safeTags.find((t) => typeof t === "string" && t.startsWith(NOTE_FONT_COLOR_TAG_PREFIX));
|
|
if (foundFontTag) {
|
|
const raw = foundFontTag.substring(NOTE_FONT_COLOR_TAG_PREFIX.length);
|
|
const hex = raw.startsWith("#") ? raw : `#${raw}`;
|
|
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
|
|
fontColor = `custom:${hex.toUpperCase()}`;
|
|
}
|
|
}
|
|
|
|
return { color, filter, background, fontColor };
|
|
}
|
|
|
|
function initBookmarkPaletteButtons() {
|
|
const linkCards = Array.from(document.querySelectorAll(".link-outer"));
|
|
if (linkCards.length === 0) return;
|
|
|
|
linkCards.forEach((card) => {
|
|
const cardId = card.dataset.id || card.getAttribute("data-id") || card.id;
|
|
if (!cardId) return;
|
|
|
|
const tags = Array.from(card.querySelectorAll(".link-tag a")).map((a) => (a.textContent || "").trim());
|
|
const { color, filter, background, fontColor } = extractNoteVisualStateFromTags(tags);
|
|
applyNoteVisualState(card, { color, filter, background, fontColor });
|
|
|
|
const actions = card.querySelector(".link-actions");
|
|
if (!actions) return;
|
|
if (actions.querySelector(".bookmark-palette")) return;
|
|
|
|
const editLink = actions.querySelector('a[title=window.ShaarliProI18n.t("Edit")], a[aria-label="Modifier ce bookmark"], a[href*="/admin/shaare/"]');
|
|
if (!editLink || !editLink.href) return;
|
|
const editUrl = editLink.href;
|
|
|
|
const paletteBtnId = `bookmark-palette-${cardId}`;
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "bookmark-palette";
|
|
wrapper.style.position = "relative";
|
|
wrapper.innerHTML = `
|
|
<button type="button" title=window.ShaarliProI18n.t("Color") aria-label=window.ShaarliProI18n.t("Color") id="${paletteBtnId}"><i class="mdi mdi-palette-outline" aria-hidden="true"></i></button>
|
|
`;
|
|
|
|
const pinLink = Array.from(actions.querySelectorAll('a[href*="/pin"], a[aria-label*=window.ShaarliProI18n.t("Pin")], a[title*=window.ShaarliProI18n.t("Pin")]'))[0];
|
|
if (pinLink && pinLink.parentNode === actions) {
|
|
actions.insertBefore(wrapper, pinLink);
|
|
} else if (editLink && editLink.parentNode === actions) {
|
|
actions.insertBefore(wrapper, editLink.nextSibling);
|
|
} else {
|
|
actions.appendChild(wrapper);
|
|
}
|
|
|
|
const paletteBtn = wrapper.querySelector(`#${paletteBtnId}`);
|
|
|
|
if (!paletteBtn) return;
|
|
|
|
paletteBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openBackgroundStudioPanel({
|
|
anchorEl: paletteBtn,
|
|
mode: "entity",
|
|
entityId: cardId,
|
|
editUrl,
|
|
currentColor: getElementVisualColor(card),
|
|
currentFilter: getElementVisualFilter(card),
|
|
currentBackground: getElementVisualBackground(card),
|
|
currentFontColor: getElementVisualFontColor(card),
|
|
title: window.ShaarliProI18n.t("My images & colors"),
|
|
});
|
|
});
|
|
});
|
|
|
|
document.addEventListener("click", (e) => {
|
|
if (e.target.closest(".bookmark-palette")) return;
|
|
if (e.target.closest("#shaarli-bg-studio")) return;
|
|
closeBackgroundStudioPanel();
|
|
});
|
|
}
|
|
|
|
function generateBookmarkPaletteButtons(bookmarkId, editUrl, currentColor, currentFilter) {
|
|
return generateUnifiedPaletteMenu({
|
|
entityId: bookmarkId,
|
|
editUrl,
|
|
currentColor,
|
|
currentFilter,
|
|
mode: "entity",
|
|
});
|
|
}
|
|
|
|
function generateUnifiedPaletteMenu({ entityId, editUrl, currentColor, currentFilter, mode }) {
|
|
const color = currentColor || "default";
|
|
const filter = normalizeFilterKey(currentFilter || "") || "none";
|
|
|
|
const onColorClick = mode === "modal" ? (c) => `setModalNoteColor('${c}')` : (c) => `setNoteColor('${entityId}', '${c}', '${editUrl}')`;
|
|
|
|
const onFilterClick = mode === "modal" ? (k) => `setModalNoteFilter('${k}')` : (k) => `setNoteFilter('${entityId}', '${k}', '${editUrl}')`;
|
|
|
|
const colorButtons = [
|
|
`<button class="palette-btn palette-btn-default ${color === "default" ? "is-active" : ""}" type="button" title="${window.ShaarliProI18n.t("Default")}" aria-label="${window.ShaarliProI18n.t("Default")}" aria-pressed="${color === "default"}" onclick="${onColorClick("default")}"><i class="mdi mdi-format-color-reset" aria-hidden="true"></i></button>`,
|
|
...NOTE_COLOR_OPTIONS.filter((opt) => opt.key !== "default").map((opt) => {
|
|
const swatchColor = getThemeColorValue(opt);
|
|
return `<button class="palette-btn note-color-${opt.key} ${color === opt.key ? "is-active" : ""}" type="button" title="${opt.label}" aria-label="${opt.label}" aria-pressed="${color === opt.key}" onclick="${onColorClick(opt.key)}" style="background-color:${swatchColor}"></button>`;
|
|
}),
|
|
].join("");
|
|
|
|
const filterButtons = [
|
|
`<button class="palette-btn palette-btn-filter-none ${filter === "none" ? "is-active" : ""}" type="button" title="Aucun" aria-label="Aucun" aria-pressed="${filter === "none"}" onclick="${onFilterClick("none")}"><i class="mdi mdi-close-circle-outline" aria-hidden="true"></i></button>`,
|
|
...NOTE_FILTER_OPTIONS.filter((opt) => opt.key !== "none").map((opt) => {
|
|
return `<button class="palette-btn palette-btn-filter ${filter === opt.key ? "is-active" : ""}" type="button" title="${opt.label}" aria-label="${opt.label}" aria-pressed="${filter === opt.key}" onclick="${onFilterClick(opt.key)}"><i class="mdi ${opt.icon}" aria-hidden="true"></i></button>`;
|
|
}),
|
|
].join("");
|
|
|
|
return `
|
|
<div class="palette-section">
|
|
<div class="palette-section-title">Couleurs</div>
|
|
<div class="palette-row palette-row-colors">${colorButtons}</div>
|
|
</div>
|
|
<div class="palette-divider"></div>
|
|
<div class="palette-section">
|
|
<div class="palette-section-title">Filtres</div>
|
|
<div class="palette-row palette-row-filters">${filterButtons}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function syncNoteFromCardElement(note, card) {
|
|
if (!note || !card) return;
|
|
|
|
const colorClass = Array.from(card.classList).find((cls) => cls.startsWith("note-color-"));
|
|
if (colorClass) {
|
|
note.color = colorClass.replace("note-color-", "") || "default";
|
|
}
|
|
|
|
const filter = card.dataset.filter;
|
|
note.filter = filter && filter !== "none" ? filter : "none";
|
|
|
|
const background = card.dataset.background;
|
|
note.background = background && background !== "none" ? background : "none";
|
|
}
|
|
|
|
/**
|
|
* Initialize the Google Tasks-like view
|
|
*/
|
|
function initTodoView(linkList, container) {
|
|
document.body.classList.add("view-todo", "view-notes");
|
|
|
|
const toolbar = document.querySelector(".content-toolbar");
|
|
if (toolbar) toolbar.style.display = "none";
|
|
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "notes-wrapper todo-wrapper";
|
|
|
|
const topBar = document.createElement("div");
|
|
topBar.className = "notes-top-bar";
|
|
|
|
const inputContainer = document.createElement("div");
|
|
inputContainer.className = "note-input-container todo-input-container";
|
|
|
|
const renderTodoInputCollapsed = () => {
|
|
inputContainer.classList.remove("is-editing", "todo-draft-mode", "is-enhanced");
|
|
applyNoteVisualState(inputContainer, { color: "default", filter: "none", background: "none", fontColor: "auto" });
|
|
inputContainer.innerHTML = `
|
|
<div class="note-input-collapsed">
|
|
<span class="note-input-placeholder">${window.ShaarliProI18n.t("Create a task...")}</span>
|
|
<div class="note-input-actions">
|
|
<button type="button" class="todo-open-draft-btn" title=window.ShaarliProI18n.t("Create a list")><i class="mdi mdi-check-circle-outline"></i></button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const collapsed = inputContainer.querySelector(".note-input-collapsed");
|
|
const openBtn = inputContainer.querySelector(".todo-open-draft-btn");
|
|
|
|
const openTodoDraftEditor = () => {
|
|
inputContainer.classList.add("is-editing", "todo-draft-mode");
|
|
inputContainer.innerHTML = `
|
|
<div class="note-input-expanded todo-draft-expanded">
|
|
<input type="text" class="note-input-title todo-draft-title" placeholder=window.ShaarliProI18n.t("List title") autocomplete="off" />
|
|
<div class="todo-draft-list" role="list"></div>
|
|
<button type="button" class="todo-draft-add-btn"><i class="mdi mdi-plus"></i> ${window.ShaarliProI18n.t("Add list item")}</button>
|
|
<div class="note-input-expanded-actions">
|
|
<div class="note-input-actions-left">
|
|
<button type="button" class="note-input-palette-btn" title=window.ShaarliProI18n.t("Color")><i class="mdi mdi-palette-outline"></i></button>
|
|
</div>
|
|
<button type="button" class="note-input-close-btn">Fermer</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const titleInput = inputContainer.querySelector(".todo-draft-title");
|
|
const listEl = inputContainer.querySelector(".todo-draft-list");
|
|
const addBtn = inputContainer.querySelector(".todo-draft-add-btn");
|
|
const closeBtn = inputContainer.querySelector(".note-input-close-btn");
|
|
const paletteBtn = inputContainer.querySelector(".note-input-palette-btn");
|
|
|
|
let hasChanges = false;
|
|
let isSaving = false;
|
|
let draftVisual = { color: "default", filter: "none", background: "none", fontColor: "auto" };
|
|
applyNoteVisualState(inputContainer, draftVisual);
|
|
|
|
const buildDraftRow = (item = { text: "", checked: false }, focusInput = false) => {
|
|
if (!listEl) return null;
|
|
const row = document.createElement("div");
|
|
row.className = "todo-draft-row";
|
|
row.innerHTML = `
|
|
<label class="todo-checklist-box" title=window.ShaarliProI18n.t("Mark as completed")>
|
|
<input type="checkbox" class="todo-item-checkbox" ${item.checked ? "checked" : ""} />
|
|
<span class="todo-checklist-box-ui"></span>
|
|
</label>
|
|
<input type="text" class="todo-item-text" placeholder=window.ShaarliProI18n.t("List item") value="${escapeHtml(item.text || "")}" />
|
|
<button type="button" class="todo-item-delete" title=window.ShaarliProI18n.t("Delete")><i class="mdi mdi-close"></i></button>
|
|
`;
|
|
listEl.appendChild(row);
|
|
|
|
const checkbox = row.querySelector(".todo-item-checkbox");
|
|
const textInput = row.querySelector(".todo-item-text");
|
|
const deleteBtn = row.querySelector(".todo-item-delete");
|
|
|
|
const syncCheckedState = () => {
|
|
row.classList.toggle("is-checked", !!(checkbox && checkbox.checked));
|
|
};
|
|
syncCheckedState();
|
|
|
|
checkbox?.addEventListener("change", () => {
|
|
hasChanges = true;
|
|
syncCheckedState();
|
|
});
|
|
|
|
textInput?.addEventListener("input", () => {
|
|
hasChanges = true;
|
|
});
|
|
|
|
deleteBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
hasChanges = true;
|
|
row.remove();
|
|
if (!listEl.querySelector(".todo-draft-row")) {
|
|
buildDraftRow({ text: "", checked: false }, true);
|
|
}
|
|
});
|
|
|
|
if (focusInput && textInput) {
|
|
textInput.focus({ preventScroll: true });
|
|
}
|
|
|
|
return row;
|
|
};
|
|
|
|
const collectDraftItems = () => {
|
|
if (!listEl) return [];
|
|
return Array.from(listEl.querySelectorAll(".todo-draft-row"))
|
|
.map((row) => {
|
|
const checkbox = row.querySelector(".todo-item-checkbox");
|
|
const textInput = row.querySelector(".todo-item-text");
|
|
const text = textInput && typeof textInput.value === "string" ? textInput.value.trim() : "";
|
|
return { checked: !!(checkbox && checkbox.checked), text };
|
|
})
|
|
.filter((it) => it.text);
|
|
};
|
|
|
|
buildDraftRow({ text: "", checked: false }, false);
|
|
|
|
addBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
hasChanges = true;
|
|
buildDraftRow({ text: "", checked: false }, true);
|
|
});
|
|
|
|
titleInput?.addEventListener("input", () => {
|
|
hasChanges = true;
|
|
});
|
|
|
|
const saveIfNeededAndClose = async () => {
|
|
if (isSaving) return;
|
|
const title = titleInput && typeof titleInput.value === "string" ? titleInput.value.trim() : "";
|
|
const items = collectDraftItems();
|
|
|
|
if (!title && items.length === 0) {
|
|
document.removeEventListener("pointerdown", onOutsidePointerDown, true);
|
|
renderTodoInputCollapsed();
|
|
return;
|
|
}
|
|
|
|
if (!hasChanges) {
|
|
document.removeEventListener("pointerdown", onOutsidePointerDown, true);
|
|
renderTodoInputCollapsed();
|
|
return;
|
|
}
|
|
|
|
isSaving = true;
|
|
try {
|
|
await createNewTodoViaForm({ title, items, visual: draftVisual });
|
|
window.location.reload();
|
|
} catch (err) {
|
|
console.error("Error creating todo:", err);
|
|
alert(window.ShaarliProI18n.t("Error creating todo."));
|
|
isSaving = false;
|
|
}
|
|
};
|
|
|
|
closeBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
saveIfNeededAndClose();
|
|
});
|
|
|
|
const onOutsidePointerDown = (e) => {
|
|
if (e.target && e.target.closest && e.target.closest("#shaarli-bg-studio")) return;
|
|
if (e.target && e.target.closest && e.target.closest(".bg-studio-panel")) return;
|
|
if (inputContainer.contains(e.target)) return;
|
|
saveIfNeededAndClose();
|
|
};
|
|
document.addEventListener("pointerdown", onOutsidePointerDown, true);
|
|
|
|
paletteBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openBackgroundStudioPanel({
|
|
anchorEl: paletteBtn,
|
|
mode: "draft",
|
|
entityId: "draft-new-todo",
|
|
editUrl: "",
|
|
currentColor: draftVisual.color || "default",
|
|
currentFilter: draftVisual.filter || "none",
|
|
currentBackground: draftVisual.background || "none",
|
|
currentFontColor: draftVisual.fontColor || "auto",
|
|
title: window.ShaarliProI18n.t("My images & colors"),
|
|
});
|
|
|
|
const panel = document.getElementById("shaarli-bg-studio");
|
|
if (panel) {
|
|
panel.__draftApply = (next) => {
|
|
draftVisual = { ...draftVisual, ...(next || {}) };
|
|
applyNoteVisualState(inputContainer, draftVisual);
|
|
};
|
|
}
|
|
});
|
|
|
|
titleInput?.focus({ preventScroll: true });
|
|
};
|
|
|
|
collapsed?.addEventListener("click", (e) => {
|
|
if (e.target.closest("button")) return;
|
|
openTodoDraftEditor();
|
|
});
|
|
openBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openTodoDraftEditor();
|
|
});
|
|
};
|
|
|
|
renderTodoInputCollapsed();
|
|
topBar.appendChild(inputContainer);
|
|
|
|
const tools = document.createElement("div");
|
|
tools.className = "notes-tools";
|
|
tools.innerHTML = `
|
|
<button class="icon-btn active" id="btn-view-grid" title=window.ShaarliProI18n.t("Grid view")><i class="mdi mdi-view-dashboard-outline"></i></button>
|
|
<button class="icon-btn" id="btn-view-list" title=window.ShaarliProI18n.t("List view")><i class="mdi mdi-view-agenda-outline"></i></button>
|
|
`;
|
|
topBar.appendChild(tools);
|
|
wrapper.appendChild(topBar);
|
|
|
|
const contentArea = document.createElement("div");
|
|
contentArea.className = "notes-content-area";
|
|
|
|
const links = Array.from(linkList.querySelectorAll(".link-outer"));
|
|
const todos = links.map((link) => parseTodoFromLink(link)).filter((t) => t);
|
|
|
|
wrapper.appendChild(contentArea);
|
|
|
|
linkList.style.display = "none";
|
|
if (linkList.parentNode) {
|
|
linkList.parentNode.insertBefore(wrapper, linkList);
|
|
} else {
|
|
container.appendChild(wrapper);
|
|
}
|
|
|
|
renderTodos(contentArea, todos, "grid");
|
|
|
|
const modalOverlay = document.createElement("div");
|
|
modalOverlay.className = "note-modal-overlay todo-modal-overlay";
|
|
modalOverlay.innerHTML = `
|
|
<div class="note-modal note-color-default todo-modal">
|
|
<div class="note-modal-header">
|
|
<h2 class="note-title" id="todo-modal-title"></h2>
|
|
<button type="button" class="note-modal-pin-toggle" id="todo-modal-pin" title=window.ShaarliProI18n.t("Pin")>
|
|
<i class="mdi mdi-pin-outline"></i>
|
|
</button>
|
|
</div>
|
|
<div class="note-modal-content">
|
|
<div class="todo-checklist" id="todo-modal-checklist"></div>
|
|
<button type="button" class="todo-add-item-btn" id="todo-modal-add-item"><i class="mdi mdi-plus"></i> ${window.ShaarliProI18n.t("Add list item")}</button>
|
|
</div>
|
|
<div class="note-modal-tags is-empty" id="todo-modal-tags"></div>
|
|
<div class="note-modal-actions">
|
|
<div class="note-modal-actions-left">
|
|
<div class="note-modal-color-picker">
|
|
<button type="button" id="todo-modal-color-btn" title=window.ShaarliProI18n.t("Color")><i class="mdi mdi-palette-outline"></i></button>
|
|
<div class="palette-popup note-modal-palette" id="todo-modal-color-popup"></div>
|
|
</div>
|
|
<a href="#" id="todo-modal-edit" title=window.ShaarliProI18n.t("Edit")><i class="mdi mdi-pencil-outline"></i></a>
|
|
<button type="button" id="todo-modal-delete" title=window.ShaarliProI18n.t("Delete")><i class="mdi mdi-delete-outline"></i></button>
|
|
</div>
|
|
<button type="button" class="note-modal-close-btn" id="todo-modal-close">Fermer</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modalOverlay);
|
|
|
|
const btnGrid = wrapper.querySelector("#btn-view-grid");
|
|
const btnList = wrapper.querySelector("#btn-view-list");
|
|
|
|
btnGrid.addEventListener("click", () => {
|
|
btnGrid.classList.add("active");
|
|
btnList.classList.remove("active");
|
|
renderTodos(contentArea, todos, "grid");
|
|
});
|
|
|
|
btnList.addEventListener("click", () => {
|
|
btnList.classList.add("active");
|
|
btnGrid.classList.remove("active");
|
|
renderTodos(contentArea, todos, "list");
|
|
});
|
|
|
|
modalOverlay.querySelector("#todo-modal-close").addEventListener("click", () => {
|
|
modalOverlay.classList.remove("open");
|
|
});
|
|
modalOverlay.addEventListener("click", (e) => {
|
|
if (e.target === modalOverlay) modalOverlay.classList.remove("open");
|
|
});
|
|
|
|
const modalPinBtn = modalOverlay.querySelector("#todo-modal-pin");
|
|
const modalColorBtn = modalOverlay.querySelector("#todo-modal-color-btn");
|
|
const modalColorPopup = modalOverlay.querySelector("#todo-modal-color-popup");
|
|
|
|
modalColorBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const modalCard = modalOverlay.querySelector(".note-modal");
|
|
openBackgroundStudioPanel({
|
|
anchorEl: modalColorBtn,
|
|
mode: "modal",
|
|
entityId: modalCard ? modalCard.dataset.todoId || "" : "",
|
|
editUrl: modalCard ? modalCard.dataset.editUrl || "" : "",
|
|
currentColor: modalCard ? getElementVisualColor(modalCard) : "default",
|
|
currentFilter: modalCard ? getElementVisualFilter(modalCard) : "none",
|
|
currentBackground: modalCard ? getElementVisualBackground(modalCard) : "none",
|
|
currentFontColor: modalCard ? getElementVisualFontColor(modalCard) : "auto",
|
|
title: window.ShaarliProI18n.t("My images & colors"),
|
|
});
|
|
});
|
|
|
|
modalPinBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const modalCard = modalOverlay.querySelector(".note-modal");
|
|
const todoId = modalCard.dataset.todoId;
|
|
const editUrl = modalCard.dataset.editUrl;
|
|
if (!todoId || !editUrl) return;
|
|
|
|
togglePinTag(todoId, editUrl, modalPinBtn);
|
|
|
|
const isPinned = modalPinBtn.classList.contains("active");
|
|
let tags = (modalCard.dataset.tags || "").split("||").filter((t) => t);
|
|
if (isPinned) {
|
|
if (!tags.includes("shaarli-pin")) tags.push("shaarli-pin");
|
|
} else {
|
|
tags = tags.filter((t) => t !== "shaarli-pin");
|
|
}
|
|
|
|
modalCard.dataset.tags = tags.join("||");
|
|
renderModalTags(modalOverlay.querySelector("#todo-modal-tags"), tags);
|
|
|
|
if (modalOverlay.currentTodo) {
|
|
modalOverlay.currentTodo.isPinned = isPinned;
|
|
modalOverlay.currentTodo.tags = tags;
|
|
}
|
|
});
|
|
|
|
modalOverlay.querySelector("#todo-modal-delete").addEventListener("click", () => {
|
|
if (!confirm(window.ShaarliProI18n.t("Delete this task?"))) return;
|
|
const modalCard = modalOverlay.querySelector(".note-modal");
|
|
const deleteUrl = modalCard.dataset.deleteUrl;
|
|
const todoId = modalCard.dataset.todoId;
|
|
if (deleteUrl && deleteUrl !== "#") {
|
|
deleteEntitySilently(deleteUrl)
|
|
.then(() => {
|
|
window.location.href = "/?searchtags=todo";
|
|
})
|
|
.catch((err) => {
|
|
console.error("Delete failed:", err);
|
|
alert(window.ShaarliProI18n.t("Error deleting."));
|
|
});
|
|
}
|
|
});
|
|
|
|
modalOverlay.addEventListener("click", (e) => {
|
|
if (!e.target.closest(".note-modal-color-picker")) {
|
|
modalColorPopup.classList.remove("open");
|
|
}
|
|
});
|
|
|
|
modalOverlay.querySelector("#todo-modal-add-item").addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!modalOverlay.currentTodo) return;
|
|
addTodoItem(modalOverlay, modalOverlay.currentTodo);
|
|
});
|
|
|
|
if (typeof initTagDisplayAndRemoval === "function") {
|
|
initTagDisplayAndRemoval();
|
|
}
|
|
}
|
|
|
|
function parseTodoFromLink(linkEl) {
|
|
if (!linkEl) return null;
|
|
|
|
const id = linkEl.dataset.id;
|
|
const titleEl = linkEl.querySelector(".link-title");
|
|
const title = titleEl ? titleEl.textContent.trim() : "";
|
|
|
|
const descEl = linkEl.querySelector(".link-description");
|
|
const descHtml = descEl ? descEl.innerHTML : "";
|
|
const descText = descEl ? descEl.textContent : "";
|
|
|
|
const rawTags = [];
|
|
linkEl.querySelectorAll(".link-tag-list a").forEach((tag) => {
|
|
const t = (tag.textContent || "").trim();
|
|
if (t) rawTags.push(t);
|
|
});
|
|
|
|
const { color, filter, background, fontColor } = extractNoteVisualStateFromTags(rawTags);
|
|
|
|
const tags = rawTags.filter((t) => {
|
|
if (t.startsWith(NOTE_COLOR_TAG_PREFIX)) return false;
|
|
if (t.startsWith("note-custom-")) return false;
|
|
if (t.startsWith(NOTE_FILTER_TAG_PREFIX)) return false;
|
|
if (t.startsWith(NOTE_BACKGROUND_TAG_PREFIX)) return false;
|
|
if (t.startsWith(NOTE_FONT_COLOR_TAG_PREFIX)) return false;
|
|
return true;
|
|
});
|
|
|
|
const actionsEl = linkEl.querySelector(".link-actions");
|
|
const editUrl = actionsEl && actionsEl.querySelector('a[href*="admin/shaare"]') ? actionsEl.querySelector('a[href*="admin/shaare"]').href : "#";
|
|
const deleteUrl = actionsEl && actionsEl.querySelector('a[href*="delete"]') ? actionsEl.querySelector('a[href*="delete"]').href : "#";
|
|
const pinUrl = actionsEl && actionsEl.querySelector('a[href*="pin"]') ? actionsEl.querySelector('a[href*="pin"]').href : "#";
|
|
const isPinned = tags.includes("shaarli-pin");
|
|
|
|
const items = extractChecklistItemsFromDescription(descEl, descText);
|
|
const parsed = parseTodoMarkdown(descText);
|
|
|
|
return {
|
|
id,
|
|
title,
|
|
descHtml,
|
|
descText,
|
|
items,
|
|
_todoHeaderLines: parsed.headerLines,
|
|
_todoFooterLines: parsed.footerLines,
|
|
tags,
|
|
color,
|
|
filter,
|
|
background,
|
|
fontColor,
|
|
editUrl,
|
|
deleteUrl,
|
|
pinUrl,
|
|
isPinned,
|
|
};
|
|
}
|
|
|
|
function extractChecklistItemsFromDescription(descEl, descText) {
|
|
const items = [];
|
|
|
|
if (descEl) {
|
|
const checkboxEls = Array.from(descEl.querySelectorAll('input[type="checkbox"]'));
|
|
if (checkboxEls.length > 0) {
|
|
checkboxEls.forEach((cb) => {
|
|
const li = cb.closest("li") || cb.parentElement;
|
|
let text = "";
|
|
if (li) {
|
|
const clone = li.cloneNode(true);
|
|
clone.querySelectorAll('input[type="checkbox"]').forEach((i) => i.remove());
|
|
text = (clone.textContent || "").trim();
|
|
}
|
|
items.push({ checked: !!cb.checked, text });
|
|
});
|
|
return items;
|
|
}
|
|
}
|
|
|
|
const lines = String(descText || "").split(/\r?\n/);
|
|
const fallbackSource = descEl ? descEl.innerHTML : lines.join("\n");
|
|
const parsed = parseTodoMarkdown(fallbackSource);
|
|
return parsed.items;
|
|
}
|
|
|
|
function getFormFieldValue(input) {
|
|
if (!input) return "";
|
|
const tag = (input.tagName || "").toUpperCase();
|
|
if (tag === "TEXTAREA") {
|
|
const v = typeof input.value === "string" ? input.value : "";
|
|
if (v && v.trim() !== "") return v;
|
|
const t = typeof input.textContent === "string" ? input.textContent : "";
|
|
return t;
|
|
}
|
|
return input.value;
|
|
}
|
|
|
|
async function fetchShaareFormBaseData(url) {
|
|
const response = await fetch(url, { credentials: "same-origin" });
|
|
const html = await response.text();
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const form = doc.querySelector('form[name="linkform"]');
|
|
if (!form) throw new Error("Could not find edit form");
|
|
|
|
const baseData = new URLSearchParams();
|
|
const inputs = form.querySelectorAll("input, textarea");
|
|
inputs.forEach((input) => {
|
|
if (input.type === "checkbox") {
|
|
if (input.checked) baseData.append(input.name, input.value || "on");
|
|
} else if (input.name) {
|
|
baseData.append(input.name, getFormFieldValue(input));
|
|
}
|
|
});
|
|
|
|
return { action: form.action, baseData };
|
|
}
|
|
|
|
async function createNewNoteViaForm({ title, markdown, visual = null }) {
|
|
const basePath = typeof shaarli !== "undefined" && shaarli.basePath ? shaarli.basePath : "";
|
|
// Generate Android note URL
|
|
const noteUrl = window.ShaarItRules ? window.ShaarItRules.generateNoteUrl() : "";
|
|
const formUrl = `${basePath}/admin/shaare?post=${encodeURIComponent(noteUrl)}&tags=note`;
|
|
const { action, baseData } = await fetchShaareFormBaseData(formUrl);
|
|
|
|
const formData = new URLSearchParams(baseData.toString());
|
|
formData.set("lf_url", noteUrl);
|
|
formData.set("lf_title", title || "");
|
|
formData.set("lf_description", markdown || "");
|
|
|
|
const currentTags = (formData.get("lf_tags") || "").trim();
|
|
let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== "");
|
|
|
|
// Ensure note tags exist (legacy + normalized) and todo tag is absent
|
|
tagsArray = tagsArray.filter((t) => t !== "shaarli-todo");
|
|
if (!tagsArray.includes("note")) tagsArray.push("note");
|
|
if (!tagsArray.includes("shaarli-note")) tagsArray.push("shaarli-note");
|
|
|
|
// Apply optional visual tags (note-color-*, notefilter-*, notebg-*, font-*)
|
|
if (visual) {
|
|
const colorKey = visual.color || "default";
|
|
const filterKey = normalizeFilterKey(visual.filter || "") || "none";
|
|
const bgKey = normalizeBackgroundKey(visual.background || "") || "none";
|
|
const fontColorKey = visual.fontColor || "auto";
|
|
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith(NOTE_COLOR_TAG_PREFIX));
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith(NOTE_FILTER_TAG_PREFIX));
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith(NOTE_BACKGROUND_TAG_PREFIX));
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith(NOTE_FONT_COLOR_TAG_PREFIX));
|
|
|
|
if (colorKey && colorKey !== "default") {
|
|
if (typeof colorKey === "string" && colorKey.startsWith("custom:")) {
|
|
const clean = colorKey.substring(7).replace("#", "");
|
|
if (clean) tagsArray.push(`${NOTE_COLOR_TAG_PREFIX}${clean}`);
|
|
} else {
|
|
tagsArray.push(`${NOTE_COLOR_TAG_PREFIX}${colorKey}`);
|
|
}
|
|
}
|
|
|
|
if (filterKey && filterKey !== "none") {
|
|
tagsArray.push(`${NOTE_FILTER_TAG_PREFIX}${filterKey}`);
|
|
}
|
|
|
|
if (bgKey && bgKey !== "none") {
|
|
tagsArray.push(`${NOTE_BACKGROUND_TAG_PREFIX}${bgKey}`);
|
|
}
|
|
|
|
if (fontColorKey && fontColorKey !== "auto") {
|
|
if (typeof fontColorKey === "string" && fontColorKey.startsWith("custom:")) {
|
|
const clean = fontColorKey.substring(7).replace("#", "");
|
|
if (clean) tagsArray.push(`${NOTE_FONT_COLOR_TAG_PREFIX}${clean}`);
|
|
} else {
|
|
const option = NOTE_FONT_COLOR_OPTIONS.find((opt) => opt.key === fontColorKey);
|
|
if (option && option.value && option.value !== "auto") {
|
|
tagsArray.push(`${NOTE_FONT_COLOR_TAG_PREFIX}${option.value.substring(1)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
formData.set("lf_tags", tagsArray.join(" "));
|
|
|
|
if (!formData.get("returnurl")) {
|
|
formData.set("returnurl", window.location.href);
|
|
}
|
|
formData.append("save_edit", "1");
|
|
|
|
const response = await fetch(action, {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: formData.toString(),
|
|
});
|
|
|
|
if (!response.ok) throw new Error(`Failed to create note (status ${response.status})`);
|
|
}
|
|
|
|
async function createNewTodoViaForm({ title, items = [], visual = null }) {
|
|
const basePath = typeof shaarli !== "undefined" && shaarli.basePath ? shaarli.basePath : "";
|
|
// Generate Android todo URL
|
|
const todoUrl = window.ShaarItRules ? window.ShaarItRules.generateTodoUrl() : "";
|
|
const formUrl = `${basePath}/admin/shaare?post=${encodeURIComponent(todoUrl)}&tags=todo`;
|
|
const { action, baseData } = await fetchShaareFormBaseData(formUrl);
|
|
|
|
const formData = new URLSearchParams(baseData.toString());
|
|
formData.set("lf_url", todoUrl);
|
|
formData.set("lf_title", title || "");
|
|
|
|
const normalizedItems = (Array.isArray(items) ? items : []).map((it) => ({ checked: !!(it && it.checked), text: String((it && it.text) || "").trim() })).filter((it) => it.text);
|
|
const markdown = normalizedItems.map((it) => `- [${it.checked ? "x" : " "}] ${it.text}`).join("\n");
|
|
formData.set("lf_description", markdown);
|
|
|
|
const currentTags = (formData.get("lf_tags") || "").trim();
|
|
let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== "");
|
|
tagsArray = tagsArray.filter((t) => t !== "note" && t !== "shaarli-note");
|
|
if (!tagsArray.includes("todo")) tagsArray.push("todo");
|
|
|
|
if (visual) {
|
|
const colorKey = visual.color || "default";
|
|
const filterKey = normalizeFilterKey(visual.filter || "") || "none";
|
|
const bgKey = normalizeBackgroundKey(visual.background || "") || "none";
|
|
const fontColorKey = visual.fontColor || "auto";
|
|
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith(NOTE_COLOR_TAG_PREFIX));
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith(NOTE_FILTER_TAG_PREFIX));
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith(NOTE_BACKGROUND_TAG_PREFIX));
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith(NOTE_FONT_COLOR_TAG_PREFIX));
|
|
|
|
if (colorKey && colorKey !== "default") {
|
|
if (typeof colorKey === "string" && colorKey.startsWith("custom:")) {
|
|
const clean = colorKey.substring(7).replace("#", "");
|
|
if (clean) tagsArray.push(`${NOTE_COLOR_TAG_PREFIX}${clean}`);
|
|
} else {
|
|
tagsArray.push(`${NOTE_COLOR_TAG_PREFIX}${colorKey}`);
|
|
}
|
|
}
|
|
|
|
if (filterKey && filterKey !== "none") {
|
|
tagsArray.push(`${NOTE_FILTER_TAG_PREFIX}${filterKey}`);
|
|
}
|
|
|
|
if (bgKey && bgKey !== "none") {
|
|
tagsArray.push(`${NOTE_BACKGROUND_TAG_PREFIX}${bgKey}`);
|
|
}
|
|
|
|
if (fontColorKey && fontColorKey !== "auto") {
|
|
if (typeof fontColorKey === "string" && fontColorKey.startsWith("custom:")) {
|
|
const clean = fontColorKey.substring(7).replace("#", "");
|
|
if (clean) tagsArray.push(`${NOTE_FONT_COLOR_TAG_PREFIX}${clean}`);
|
|
} else {
|
|
const option = NOTE_FONT_COLOR_OPTIONS.find((opt) => opt.key === fontColorKey);
|
|
if (option && option.value && option.value !== "auto") {
|
|
tagsArray.push(`${NOTE_FONT_COLOR_TAG_PREFIX}${option.value.substring(1)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
formData.set("lf_tags", tagsArray.join(" "));
|
|
|
|
if (!formData.get("returnurl")) {
|
|
formData.set("returnurl", window.location.href);
|
|
}
|
|
formData.append("save_edit", "1");
|
|
|
|
const response = await fetch(action, {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: formData.toString(),
|
|
});
|
|
|
|
if (!response.ok) throw new Error(`Failed to create todo (status ${response.status})`);
|
|
}
|
|
|
|
async function hydrateNoteFromEditForm(note) {
|
|
if (!note || !note.editUrl || note.editUrl === "#") return;
|
|
if (note._noteHydrated) return;
|
|
if (note._noteHydratePromise) return note._noteHydratePromise;
|
|
|
|
note._noteHydratePromise = (async () => {
|
|
try {
|
|
const { action, baseData } = await fetchShaareFormBaseData(note.editUrl);
|
|
note._noteEditAction = action;
|
|
note._noteEditBaseData = baseData;
|
|
note._noteMarkdown = baseData.get("lf_description") || "";
|
|
note._noteTitle = baseData.get("lf_title") || "";
|
|
note._noteHydrated = true;
|
|
} catch (e) {
|
|
console.error("Error hydrating note edit form:", e);
|
|
} finally {
|
|
note._noteHydratePromise = null;
|
|
}
|
|
})();
|
|
|
|
return note._noteHydratePromise;
|
|
}
|
|
|
|
async function persistNoteChanges(note, { title, markdown }, retryCount = 0) {
|
|
if (!note || !note.editUrl || note.editUrl === "#") return;
|
|
|
|
const refreshEditForm = async () => {
|
|
const { action, baseData } = await fetchShaareFormBaseData(note.editUrl);
|
|
note._noteEditAction = action;
|
|
note._noteEditBaseData = baseData;
|
|
};
|
|
|
|
if (!note._noteEditAction || !note._noteEditBaseData || !(note._noteEditBaseData.get && note._noteEditBaseData.get("token"))) {
|
|
await refreshEditForm();
|
|
}
|
|
|
|
const formData = new URLSearchParams(note._noteEditBaseData.toString());
|
|
formData.set("lf_title", title || "");
|
|
formData.set("lf_description", markdown || "");
|
|
|
|
const currentTags = (formData.get("lf_tags") || "").trim();
|
|
let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== "");
|
|
tagsArray = tagsArray.filter((t) => t !== "shaarli-todo");
|
|
if (!tagsArray.includes("note")) tagsArray.push("note");
|
|
if (!tagsArray.includes("shaarli-note")) tagsArray.push("shaarli-note");
|
|
formData.set("lf_tags", tagsArray.join(" "));
|
|
|
|
formData.append("save_edit", "1");
|
|
|
|
const response = await fetch(note._noteEditAction, {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: formData.toString(),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 403 && retryCount < 1) {
|
|
note._noteEditAction = "";
|
|
note._noteEditBaseData = null;
|
|
await refreshEditForm();
|
|
return persistNoteChanges(note, { title, markdown }, retryCount + 1);
|
|
}
|
|
throw new Error("Failed to save note");
|
|
}
|
|
}
|
|
|
|
async function hydrateTodoFromEditForm(todo) {
|
|
if (!todo || !todo.editUrl || todo.editUrl === "#") return;
|
|
if (todo._todoHydrated) return;
|
|
if (todo._todoHydratePromise) return todo._todoHydratePromise;
|
|
|
|
todo._todoHydratePromise = (async () => {
|
|
try {
|
|
const response = await fetch(todo.editUrl, { credentials: "same-origin" });
|
|
if (!response.ok) {
|
|
console.error("Todo hydration failed (edit form fetch not ok).", { id: todo && todo.id, url: todo && todo.editUrl, status: response && response.status });
|
|
return;
|
|
}
|
|
const html = await response.text();
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const form = doc.querySelector('form[name="linkform"]');
|
|
if (!form) {
|
|
console.error("Todo hydration failed (edit form not found).", { id: todo && todo.id, url: todo && todo.editUrl, status: response && response.status });
|
|
return;
|
|
}
|
|
|
|
const formData = new URLSearchParams();
|
|
const inputs = form.querySelectorAll("input, textarea");
|
|
inputs.forEach((input) => {
|
|
if (input.type === "checkbox") {
|
|
if (input.checked) formData.append(input.name, input.value || "on");
|
|
} else if (input.name) {
|
|
formData.append(input.name, getFormFieldValue(input));
|
|
}
|
|
});
|
|
|
|
todo._todoEditAction = form.action;
|
|
todo._todoEditBaseData = formData;
|
|
|
|
const rawMarkdown = formData.get("lf_description") || "";
|
|
const parsed = parseTodoMarkdown(rawMarkdown);
|
|
todo._todoHeaderLines = parsed.headerLines;
|
|
todo._todoFooterLines = parsed.footerLines;
|
|
todo.items = parsed.items;
|
|
|
|
updateTodoCardPreview(todo);
|
|
todo._todoHydrated = true;
|
|
} catch (e) {
|
|
console.error("Error hydrating todo markdown:", e);
|
|
} finally {
|
|
todo._todoHydratePromise = null;
|
|
}
|
|
})();
|
|
|
|
return todo._todoHydratePromise;
|
|
}
|
|
|
|
function renderTodos(container, todos, viewMode) {
|
|
if (!container) return;
|
|
container.innerHTML = "";
|
|
container.className = viewMode === "grid" ? "notes-masonry" : "notes-list-view";
|
|
|
|
const visibleTodos = (todos || []).slice();
|
|
visibleTodos.sort((a, b) => {
|
|
const aPinned = (a.tags || []).includes("shaarli-pin");
|
|
const bPinned = (b.tags || []).includes("shaarli-pin");
|
|
return bPinned - aPinned;
|
|
});
|
|
|
|
visibleTodos.forEach((todo) => {
|
|
const card = document.createElement("div");
|
|
card.className = "note-card todo-card";
|
|
card.dataset.id = todo.id;
|
|
card.dataset.editUrl = todo.editUrl || "";
|
|
card.dataset.tags = (todo.tags || []).filter((t) => t).join("||");
|
|
todo._todoCardEl = card;
|
|
applyNoteVisualState(card, todo);
|
|
if (viewMode === "list") card.classList.add("list-mode");
|
|
|
|
card.addEventListener("click", (e) => {
|
|
if (e.target.closest("button") || e.target.closest("a") || e.target.closest(".note-hover-actions")) return;
|
|
syncNoteFromCardElement(todo, card);
|
|
openTodoModal(todo);
|
|
});
|
|
|
|
const inner = document.createElement("div");
|
|
inner.className = "note-inner";
|
|
|
|
if (todo.title) {
|
|
const h3 = document.createElement("h3");
|
|
h3.className = "note-title";
|
|
h3.textContent = todo.title;
|
|
inner.appendChild(h3);
|
|
}
|
|
|
|
const body = document.createElement("div");
|
|
body.className = "note-body todo-checklist-preview-wrap";
|
|
body.innerHTML = buildTodoPreviewHtml(todo.items || []);
|
|
inner.appendChild(body);
|
|
|
|
if ((todo.tags || []).length > 0) {
|
|
const tagContainer = document.createElement("div");
|
|
tagContainer.className = "note-tags";
|
|
todo.tags.forEach((t) => {
|
|
if (isTechnicalTag(t)) return;
|
|
const pill = createTagPill({ tag: t, onRemoveClass: "note-tag-remove-btn", tagClass: "note-tag", canRemove: !!todo.editUrl && todo.editUrl !== "#" });
|
|
tagContainer.appendChild(pill);
|
|
});
|
|
inner.appendChild(tagContainer);
|
|
}
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "note-hover-actions";
|
|
|
|
const pinCorner = document.createElement("a");
|
|
pinCorner.href = todo.pinUrl;
|
|
pinCorner.title = todo.isPinned ? "Unpin" : "Pin";
|
|
pinCorner.className = `note-pin-corner ${todo.isPinned ? "active" : ""}`;
|
|
pinCorner.innerHTML = `<i class="mdi mdi-pin${todo.isPinned ? "" : "-outline"}"></i>`;
|
|
inner.appendChild(pinCorner);
|
|
|
|
const paletteBtnId = `palette-${todo.id}`;
|
|
|
|
actions.innerHTML = `
|
|
<div style="position:relative;">
|
|
<button title=window.ShaarliProI18n.t("Color") id="${paletteBtnId}"><i class="mdi mdi-palette-outline"></i></button>
|
|
</div>
|
|
<div class="spacer"></div>
|
|
<a href="${todo.editUrl}" title=window.ShaarliProI18n.t("Edit")><i class="mdi mdi-pencil-outline"></i></a>
|
|
<button title=window.ShaarliProI18n.t("Delete") class="todo-delete-btn"><i class="mdi mdi-delete-outline"></i></button>
|
|
`;
|
|
|
|
const deleteBtn = actions.querySelector(".todo-delete-btn");
|
|
deleteBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!confirm(window.ShaarliProI18n.t("Delete this task?"))) return;
|
|
deleteEntitySilently(todo.deleteUrl)
|
|
.then(() => {
|
|
window.location.href = "/?searchtags=todo";
|
|
})
|
|
.catch((err) => {
|
|
console.error("Delete failed:", err);
|
|
alert(window.ShaarliProI18n.t("Error deleting."));
|
|
});
|
|
});
|
|
|
|
const paletteBtn = actions.querySelector(`#${paletteBtnId}`);
|
|
paletteBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openBackgroundStudioPanel({
|
|
anchorEl: paletteBtn,
|
|
mode: "entity",
|
|
entityId: todo.id,
|
|
editUrl: todo.editUrl,
|
|
currentColor: getElementVisualColor(card),
|
|
currentFilter: getElementVisualFilter(card),
|
|
currentBackground: getElementVisualBackground(card),
|
|
title: window.ShaarliProI18n.t("My images & colors"),
|
|
});
|
|
});
|
|
|
|
inner.appendChild(actions);
|
|
card.appendChild(inner);
|
|
container.appendChild(card);
|
|
|
|
hydrateTodoFromEditForm(todo);
|
|
});
|
|
}
|
|
|
|
function buildTodoPreviewHtml(items) {
|
|
const safeItems = Array.isArray(items) ? items : [];
|
|
const maxItems = 8;
|
|
const visible = safeItems.slice(0, maxItems);
|
|
const hasMore = safeItems.length > maxItems;
|
|
|
|
const rows = visible
|
|
.map((it) => {
|
|
const checked = !!it.checked;
|
|
const text = escapeHtml(String(it.text || ""));
|
|
return `<li class="todo-checklist-preview-item${checked ? " is-checked" : ""}"><span class="todo-checklist-preview-box"><i class="mdi mdi-check"></i></span><span class="todo-checklist-preview-text">${text}</span></li>`;
|
|
})
|
|
.join("");
|
|
|
|
return `<ul class="todo-checklist-preview">${rows}${hasMore ? '<li class="todo-checklist-preview-more">...</li>' : ""}</ul>`;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\"/g, """).replace(/'/g, "'");
|
|
}
|
|
|
|
function parseTodoMarkdown(markdown) {
|
|
const raw = String(markdown || "");
|
|
// Minimal normalization for parsing lines
|
|
const normalized = raw.replace(/<br\s*\/?\s*>/gi, "\n").replace(/<\/(p|div|li|h\d)\s*>/gi, "\n");
|
|
|
|
let text = normalized;
|
|
try {
|
|
const tmp = document.createElement("div");
|
|
tmp.innerHTML = normalized;
|
|
const checkboxEls = Array.from(tmp.querySelectorAll('input[type="checkbox"]'));
|
|
if (checkboxEls.length > 0) {
|
|
const items = [];
|
|
checkboxEls.forEach((cb) => {
|
|
const li = cb.closest("li") || cb.parentElement;
|
|
let itemText = "";
|
|
if (li) {
|
|
const clone = li.cloneNode(true);
|
|
clone.querySelectorAll('input[type="checkbox"]').forEach((i) => i.remove());
|
|
itemText = (clone.textContent || "").trim();
|
|
}
|
|
items.push({ checked: !!cb.checked, text: itemText });
|
|
});
|
|
return { headerLines: [], footerLines: [], items };
|
|
}
|
|
|
|
const listItemEls = Array.from(tmp.querySelectorAll("li"));
|
|
if (listItemEls.length > 0) {
|
|
const items = listItemEls.map((li) => ({ checked: false, text: (li.textContent || "").trim() })).filter((it) => it.text);
|
|
if (items.length > 0) {
|
|
return { headerLines: [], footerLines: [], items };
|
|
}
|
|
}
|
|
text = tmp.textContent || normalized;
|
|
} catch (e) {
|
|
text = normalized;
|
|
}
|
|
|
|
const lines = text.split(/\r?\n/);
|
|
const taskRegexes = [/^\s*[-*+]\s*\[([ xX])\]\s*(.*)$/, /^\s*\[([ xX])\]\s*(.*)$/, /^\s*☐\s*(.*)$/, /^\s*☑\s*(.*)$/];
|
|
|
|
let firstTaskIndex = -1;
|
|
let lastTaskIndex = -1;
|
|
const items = [];
|
|
|
|
lines.forEach((line, idx) => {
|
|
let matched = false;
|
|
|
|
for (let i = 0; i < taskRegexes.length; i++) {
|
|
const rx = taskRegexes[i];
|
|
const m = line.match(rx);
|
|
if (!m) continue;
|
|
|
|
matched = true;
|
|
if (firstTaskIndex === -1) firstTaskIndex = idx;
|
|
lastTaskIndex = idx;
|
|
|
|
if (i === 2) {
|
|
items.push({ checked: false, text: (m[1] || "").trim() });
|
|
} else if (i === 3) {
|
|
items.push({ checked: true, text: (m[1] || "").trim() });
|
|
} else {
|
|
items.push({ checked: String(m[1] || "").toLowerCase() === "x", text: (m[2] || "").trim() });
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (!matched) return;
|
|
});
|
|
|
|
const headerLines = firstTaskIndex === -1 ? lines.slice() : lines.slice(0, firstTaskIndex);
|
|
const footerLines = lastTaskIndex === -1 ? [] : lines.slice(lastTaskIndex + 1);
|
|
|
|
return { headerLines, footerLines, items };
|
|
}
|
|
|
|
function buildTodoMarkdown(todo) {
|
|
const headerLines = Array.isArray(todo._todoHeaderLines) ? todo._todoHeaderLines : [];
|
|
const footerLines = Array.isArray(todo._todoFooterLines) ? todo._todoFooterLines : [];
|
|
const items = Array.isArray(todo.items) ? todo.items : [];
|
|
const taskLines = items.map((it) => `- [${it.checked ? "x" : " "}] ${String(it.text || "").trim()}`);
|
|
|
|
const out = [];
|
|
headerLines.forEach((l) => out.push(l));
|
|
taskLines.forEach((l) => out.push(l));
|
|
footerLines.forEach((l) => out.push(l));
|
|
|
|
return out.join("\n").replace(/\s+$/g, "");
|
|
}
|
|
|
|
function renderTodoModalChecklist(modal, todo) {
|
|
const list = modal.querySelector("#todo-modal-checklist");
|
|
if (!list) return;
|
|
|
|
list.innerHTML = "";
|
|
const items = Array.isArray(todo.items) ? todo.items : [];
|
|
|
|
items.forEach((item, idx) => {
|
|
const row = document.createElement("div");
|
|
row.className = "todo-checklist-row";
|
|
row.dataset.index = String(idx);
|
|
row.setAttribute("draggable", "true");
|
|
row.classList.toggle("is-checked", !!item.checked);
|
|
row.innerHTML = `
|
|
<button type="button" class="todo-drag-handle" aria-label=window.ShaarliProI18n.t("Move")><i class="mdi mdi-dots-grid"></i></button>
|
|
<label class="todo-checklist-box">
|
|
<input type="checkbox" class="todo-item-checkbox" ${item.checked ? "checked" : ""}>
|
|
<span class="todo-checklist-box-ui"></span>
|
|
</label>
|
|
<input type="text" class="todo-item-text" value="${escapeHtml(item.text || "")}" placeholder="">
|
|
<button type="button" class="todo-item-delete" aria-label=window.ShaarliProI18n.t("Delete")><i class="mdi mdi-close"></i></button>
|
|
`;
|
|
|
|
const cb = row.querySelector(".todo-item-checkbox");
|
|
const textInput = row.querySelector(".todo-item-text");
|
|
const delBtn = row.querySelector(".todo-item-delete");
|
|
|
|
cb.addEventListener("change", () => {
|
|
todo.items[idx].checked = cb.checked;
|
|
scheduleTodoSave(modal, todo);
|
|
row.classList.toggle("is-checked", cb.checked);
|
|
});
|
|
|
|
textInput.addEventListener("input", () => {
|
|
todo.items[idx].text = textInput.value;
|
|
scheduleTodoSave(modal, todo);
|
|
});
|
|
|
|
delBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
todo.items.splice(idx, 1);
|
|
renderTodoModalChecklist(modal, todo);
|
|
scheduleTodoSave(modal, todo);
|
|
});
|
|
|
|
row.addEventListener("dragstart", (e) => {
|
|
row.classList.add("is-dragging");
|
|
e.dataTransfer.effectAllowed = "move";
|
|
e.dataTransfer.setData("text/plain", String(idx));
|
|
});
|
|
row.addEventListener("dragend", () => {
|
|
row.classList.remove("is-dragging");
|
|
});
|
|
|
|
list.appendChild(row);
|
|
});
|
|
|
|
if (!list.dataset.dndInit) {
|
|
list.addEventListener(
|
|
"dragover",
|
|
(e) => {
|
|
e.preventDefault();
|
|
const dragging = list.querySelector(".todo-checklist-row.is-dragging");
|
|
if (!dragging) return;
|
|
|
|
const afterElement = getDragAfterElement(list, e.clientY);
|
|
if (afterElement == null) {
|
|
list.appendChild(dragging);
|
|
} else {
|
|
list.insertBefore(dragging, afterElement);
|
|
}
|
|
},
|
|
{ passive: false },
|
|
);
|
|
|
|
list.addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
const current = modal && modal.currentTodo ? modal.currentTodo : null;
|
|
if (!current) return;
|
|
const rows = Array.from(list.querySelectorAll(".todo-checklist-row"));
|
|
const newItems = [];
|
|
rows.forEach((r) => {
|
|
const oldIndex = Number(r.dataset.index);
|
|
if (!Number.isNaN(oldIndex) && current.items && current.items[oldIndex]) {
|
|
newItems.push(current.items[oldIndex]);
|
|
}
|
|
});
|
|
current.items = newItems;
|
|
renderTodoModalChecklist(modal, current);
|
|
scheduleTodoSave(modal, current);
|
|
});
|
|
|
|
list.dataset.dndInit = "1";
|
|
}
|
|
}
|
|
|
|
function getDragAfterElement(container, y) {
|
|
const draggableElements = [...container.querySelectorAll(".todo-checklist-row:not(.is-dragging)")];
|
|
|
|
return draggableElements.reduce(
|
|
(closest, child) => {
|
|
const box = child.getBoundingClientRect();
|
|
const offset = y - box.top - box.height / 2;
|
|
if (offset < 0 && offset > closest.offset) {
|
|
return { offset, element: child };
|
|
}
|
|
return closest;
|
|
},
|
|
{ offset: Number.NEGATIVE_INFINITY, element: null },
|
|
).element;
|
|
}
|
|
|
|
async function openTodoModal(todo) {
|
|
const modal = document.querySelector(".todo-modal-overlay");
|
|
if (!modal) return;
|
|
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
const title = modal.querySelector("#todo-modal-title");
|
|
const tagsContainer = modal.querySelector("#todo-modal-tags");
|
|
const editLink = modal.querySelector("#todo-modal-edit");
|
|
const pinButton = modal.querySelector("#todo-modal-pin");
|
|
const modalColorPopup = modal.querySelector("#todo-modal-color-popup");
|
|
|
|
modal.currentTodo = todo;
|
|
|
|
modalCard.className = "note-modal todo-modal";
|
|
applyNoteVisualState(modalCard, todo);
|
|
modalCard.dataset.todoId = todo.id || "";
|
|
modalCard.dataset.editUrl = todo.editUrl || "";
|
|
modalCard.dataset.deleteUrl = todo.deleteUrl || "";
|
|
modalCard.dataset.background = todo.background || "none";
|
|
|
|
const visibleTags = (todo.tags || []).filter((tag) => tag && !isTechnicalTag(tag));
|
|
modalCard.dataset.tags = visibleTags.join("||");
|
|
|
|
title.textContent = todo.title || window.ShaarliProI18n.t("Untitled");
|
|
renderModalTags(tagsContainer, visibleTags);
|
|
|
|
if (editLink) {
|
|
editLink.href = todo.editUrl || "#";
|
|
}
|
|
|
|
if (modalColorPopup) {
|
|
modalColorPopup.innerHTML = generateUnifiedPaletteMenu({
|
|
entityId: todo && todo.id ? todo.id : "",
|
|
editUrl: todo && todo.editUrl ? todo.editUrl : "",
|
|
currentColor: todo && todo.color ? todo.color : "default",
|
|
currentFilter: todo && todo.filter ? todo.filter : "none",
|
|
mode: "modal",
|
|
});
|
|
modalColorPopup.classList.remove("open");
|
|
}
|
|
|
|
setModalPinButtonState(pinButton, !!todo.isPinned);
|
|
|
|
modal.classList.add("open");
|
|
renderTodoModalChecklist(modal, todo);
|
|
|
|
const loadKey = `${todo.id}-${Date.now()}`;
|
|
modal._todoLoadKey = loadKey;
|
|
|
|
if (todo.editUrl && todo.editUrl !== "#") {
|
|
try {
|
|
const response = await fetch(todo.editUrl, { credentials: "same-origin" });
|
|
if (!response.ok) {
|
|
console.error("Todo modal load failed (edit form fetch not ok).", { id: todo && todo.id, url: todo && todo.editUrl, status: response && response.status });
|
|
return;
|
|
}
|
|
const html = await response.text();
|
|
if (modal._todoLoadKey !== loadKey) return;
|
|
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const form = doc.querySelector('form[name="linkform"]');
|
|
if (!form) {
|
|
console.error("Todo modal load failed (edit form not found).", { id: todo && todo.id, url: todo && todo.editUrl, status: response && response.status });
|
|
return;
|
|
}
|
|
|
|
const formData = new URLSearchParams();
|
|
const inputs = form.querySelectorAll("input, textarea");
|
|
inputs.forEach((input) => {
|
|
if (input.type === "checkbox") {
|
|
if (input.checked) formData.append(input.name, input.value || "on");
|
|
} else if (input.name) {
|
|
formData.append(input.name, getFormFieldValue(input));
|
|
}
|
|
});
|
|
|
|
todo._todoEditAction = form.action;
|
|
todo._todoEditBaseData = formData;
|
|
|
|
const rawMarkdown = formData.get("lf_description") || "";
|
|
const parsed = parseTodoMarkdown(rawMarkdown);
|
|
todo._todoHeaderLines = parsed.headerLines;
|
|
todo._todoFooterLines = parsed.footerLines;
|
|
todo.items = parsed.items;
|
|
|
|
renderTodoModalChecklist(modal, todo);
|
|
} catch (e) {
|
|
console.error("Error loading todo markdown:", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
function addTodoItem(modal, todo) {
|
|
if (!todo.items) todo.items = [];
|
|
todo.items.push({ checked: false, text: "" });
|
|
renderTodoModalChecklist(modal, todo);
|
|
const inputs = modal.querySelectorAll(".todo-item-text");
|
|
const last = inputs && inputs.length ? inputs[inputs.length - 1] : null;
|
|
if (last) last.focus();
|
|
scheduleTodoSave(modal, todo);
|
|
}
|
|
|
|
function scheduleTodoSave(modal, todo) {
|
|
if (!modal || !todo) return;
|
|
if (modal._todoSaveTimer) {
|
|
clearTimeout(modal._todoSaveTimer);
|
|
}
|
|
|
|
updateTodoCardPreview(todo);
|
|
|
|
modal._todoSaveTimer = setTimeout(() => {
|
|
persistTodoChanges(todo).catch((err) => {
|
|
console.error("Error saving todo:", err);
|
|
alert(window.ShaarliProI18n.t("Error saving todo.") + " " + window.ShaarliProI18n.t("Please refresh the page."));
|
|
});
|
|
}, 600);
|
|
}
|
|
|
|
function updateTodoCardPreview(todo) {
|
|
if (!todo || !todo.id) return;
|
|
let card = todo._todoCardEl && todo._todoCardEl.isConnected ? todo._todoCardEl : null;
|
|
if (!card) {
|
|
card = document.querySelector(`.note-card.todo-card[data-id="${String(todo.id)}"]`);
|
|
}
|
|
if (!card) {
|
|
const all = Array.from(document.querySelectorAll(".note-card.todo-card"));
|
|
card = all.find((c) => String(c.dataset.id || "") === String(todo.id)) || null;
|
|
}
|
|
if (!card) return;
|
|
const wrap = card.querySelector(".todo-checklist-preview-wrap");
|
|
if (!wrap) return;
|
|
wrap.innerHTML = buildTodoPreviewHtml(todo.items || []);
|
|
}
|
|
|
|
async function persistTodoChanges(todo, retryCount = 0) {
|
|
if (!todo || !todo.editUrl || todo.editUrl === "#") return;
|
|
|
|
const refreshEditForm = async () => {
|
|
const response = await fetch(todo.editUrl, { credentials: "same-origin" });
|
|
const html = await response.text();
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const form = doc.querySelector('form[name="linkform"]');
|
|
if (!form) throw new Error("Could not find edit form");
|
|
|
|
const baseData = new URLSearchParams();
|
|
const inputs = form.querySelectorAll("input, textarea");
|
|
inputs.forEach((input) => {
|
|
if (input.type === "checkbox") {
|
|
if (input.checked) baseData.append(input.name, input.value || "on");
|
|
} else if (input.name) {
|
|
baseData.append(input.name, getFormFieldValue(input));
|
|
}
|
|
});
|
|
|
|
todo._todoEditAction = form.action;
|
|
todo._todoEditBaseData = baseData;
|
|
|
|
const rawMarkdown = baseData.get("lf_description") || "";
|
|
const parsed = parseTodoMarkdown(rawMarkdown);
|
|
todo._todoHeaderLines = parsed.headerLines;
|
|
todo._todoFooterLines = parsed.footerLines;
|
|
};
|
|
|
|
if (!todo._todoEditAction || !todo._todoEditBaseData || !(todo._todoEditBaseData.get && todo._todoEditBaseData.get("token"))) {
|
|
await refreshEditForm();
|
|
}
|
|
|
|
const description = buildTodoMarkdown(todo);
|
|
const formData = new URLSearchParams(todo._todoEditBaseData.toString());
|
|
formData.set("lf_description", description);
|
|
|
|
const currentTags = (formData.get("lf_tags") || "").trim();
|
|
let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== "");
|
|
tagsArray = tagsArray.filter((t) => t !== "note" && t !== "shaarli-note");
|
|
if (!tagsArray.includes("shaarli-todo")) tagsArray.push("shaarli-todo");
|
|
formData.set("lf_tags", tagsArray.join(" "));
|
|
|
|
formData.append("save_edit", "1");
|
|
|
|
const response = await fetch(todo._todoEditAction, {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: formData.toString(),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 403 && retryCount < 1) {
|
|
todo._todoEditAction = "";
|
|
todo._todoEditBaseData = null;
|
|
await refreshEditForm();
|
|
return persistTodoChanges(todo, retryCount + 1);
|
|
}
|
|
throw new Error("Failed to save todo");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the Google Keep-like view
|
|
*/
|
|
|
|
/**
|
|
* Initialize the Google Keep-like view
|
|
*/
|
|
function initNoteView(linkList, container) {
|
|
document.body.classList.add("view-notes");
|
|
|
|
const basePath = typeof shaarli !== "undefined" && shaarli.basePath ? shaarli.basePath : "";
|
|
|
|
// Hide standard toolbar
|
|
const toolbar = document.querySelector(".content-toolbar");
|
|
if (toolbar) toolbar.style.display = "none";
|
|
|
|
// 1. Create Layout Wrapper
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "notes-wrapper";
|
|
|
|
// 2. Create Search/Input Area (Top)
|
|
const topBar = document.createElement("div");
|
|
topBar.className = "notes-top-bar";
|
|
|
|
const topBarInner = document.createElement("div");
|
|
topBarInner.className = "notes-top-bar-inner";
|
|
|
|
// Custom Input "Take a note..."
|
|
const inputContainer = document.createElement("div");
|
|
inputContainer.className = "note-input-container";
|
|
const renderNoteInputCollapsed = () => {
|
|
inputContainer.classList.remove("is-editing", "is-enhanced", "todo-draft-mode");
|
|
applyNoteVisualState(inputContainer, { color: "default", filter: "none", background: "none", fontColor: "auto" });
|
|
inputContainer.innerHTML = `
|
|
<div class="note-input-collapsed">
|
|
<span class="note-input-placeholder">${window.ShaarliProI18n.t("Create a note...")}</span>
|
|
<div class="note-input-actions note-input-type-actions">
|
|
<button type="button" class="note-create-note-btn" title=window.ShaarliProI18n.t("Create a note")><i class="mdi mdi-note-outline"></i></button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const collapsed = inputContainer.querySelector(".note-input-collapsed");
|
|
const noteBtn = inputContainer.querySelector(".note-create-note-btn");
|
|
|
|
const openEditor = (mode = "note") => {
|
|
if (mode === "list") {
|
|
inputContainer.classList.add("is-editing", "todo-draft-mode");
|
|
inputContainer.innerHTML = `
|
|
<div class="note-input-expanded todo-draft-expanded">
|
|
<input type="text" class="note-input-title todo-draft-title" placeholder=window.ShaarliProI18n.t("List title") autocomplete="off" />
|
|
<div class="todo-draft-list" role="list"></div>
|
|
<button type="button" class="todo-draft-add-btn"><i class="mdi mdi-plus"></i> ${window.ShaarliProI18n.t("Add list item")}</button>
|
|
<div class="note-input-expanded-actions">
|
|
<div class="note-input-actions-left">
|
|
<button type="button" class="note-input-palette-btn" title=window.ShaarliProI18n.t("Color")><i class="mdi mdi-palette-outline"></i></button>
|
|
</div>
|
|
<button type="button" class="note-input-close-btn">Fermer</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const titleInput = inputContainer.querySelector(".todo-draft-title");
|
|
const listEl = inputContainer.querySelector(".todo-draft-list");
|
|
const addBtn = inputContainer.querySelector(".todo-draft-add-btn");
|
|
const closeBtn = inputContainer.querySelector(".note-input-close-btn");
|
|
const paletteBtn = inputContainer.querySelector(".note-input-palette-btn");
|
|
|
|
let hasChanges = false;
|
|
let isSaving = false;
|
|
let draftVisual = { color: "default", filter: "none", background: "none", fontColor: "auto" };
|
|
applyNoteVisualState(inputContainer, draftVisual);
|
|
|
|
const buildDraftRow = (item = { text: "", checked: false }, focusInput = false) => {
|
|
if (!listEl) return null;
|
|
const row = document.createElement("div");
|
|
row.className = "todo-draft-row";
|
|
row.innerHTML = `
|
|
<label class="todo-checklist-box" title=window.ShaarliProI18n.t("Mark as completed")>
|
|
<input type="checkbox" class="todo-item-checkbox" ${item.checked ? "checked" : ""} />
|
|
<span class="todo-checklist-box-ui"></span>
|
|
</label>
|
|
<input type="text" class="todo-item-text" placeholder=window.ShaarliProI18n.t("List item") value="${escapeHtml(item.text || "")}" />
|
|
<button type="button" class="todo-item-delete" title=window.ShaarliProI18n.t("Delete")><i class="mdi mdi-close"></i></button>
|
|
`;
|
|
listEl.appendChild(row);
|
|
|
|
const checkbox = row.querySelector(".todo-item-checkbox");
|
|
const textInput = row.querySelector(".todo-item-text");
|
|
const deleteBtn = row.querySelector(".todo-item-delete");
|
|
|
|
const syncCheckedState = () => {
|
|
row.classList.toggle("is-checked", !!(checkbox && checkbox.checked));
|
|
};
|
|
syncCheckedState();
|
|
|
|
checkbox?.addEventListener("change", () => {
|
|
hasChanges = true;
|
|
syncCheckedState();
|
|
});
|
|
textInput?.addEventListener("input", () => {
|
|
hasChanges = true;
|
|
});
|
|
deleteBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
hasChanges = true;
|
|
row.remove();
|
|
if (!listEl.querySelector(".todo-draft-row")) {
|
|
buildDraftRow({ text: "", checked: false }, true);
|
|
}
|
|
});
|
|
|
|
if (focusInput && textInput) {
|
|
textInput.focus({ preventScroll: true });
|
|
}
|
|
|
|
return row;
|
|
};
|
|
|
|
const collectDraftItems = () => {
|
|
if (!listEl) return [];
|
|
return Array.from(listEl.querySelectorAll(".todo-draft-row"))
|
|
.map((row) => {
|
|
const checkbox = row.querySelector(".todo-item-checkbox");
|
|
const textInput = row.querySelector(".todo-item-text");
|
|
const text = textInput && typeof textInput.value === "string" ? textInput.value.trim() : "";
|
|
return { checked: !!(checkbox && checkbox.checked), text };
|
|
})
|
|
.filter((it) => it.text);
|
|
};
|
|
|
|
buildDraftRow({ text: "", checked: false }, false);
|
|
|
|
addBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
hasChanges = true;
|
|
buildDraftRow({ text: "", checked: false }, true);
|
|
});
|
|
|
|
titleInput?.addEventListener("input", () => {
|
|
hasChanges = true;
|
|
});
|
|
|
|
const saveIfNeededAndClose = async () => {
|
|
if (isSaving) return;
|
|
const title = titleInput && typeof titleInput.value === "string" ? titleInput.value.trim() : "";
|
|
const items = collectDraftItems();
|
|
|
|
if (!title && items.length === 0) {
|
|
document.removeEventListener("pointerdown", onOutsidePointerDown, true);
|
|
renderNoteInputCollapsed();
|
|
return;
|
|
}
|
|
|
|
if (!hasChanges) {
|
|
document.removeEventListener("pointerdown", onOutsidePointerDown, true);
|
|
renderNoteInputCollapsed();
|
|
return;
|
|
}
|
|
|
|
isSaving = true;
|
|
try {
|
|
const markdown = items.map((it) => `- [${it.checked ? "x" : " "}] ${it.text}`).join("\n");
|
|
await createNewNoteViaForm({ title, markdown, visual: draftVisual });
|
|
window.location.reload();
|
|
} catch (err) {
|
|
console.error("Error creating checklist note:", err);
|
|
alert(window.ShaarliProI18n.t("Error creating checklist note."));
|
|
isSaving = false;
|
|
}
|
|
};
|
|
|
|
closeBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
saveIfNeededAndClose();
|
|
});
|
|
|
|
const onOutsidePointerDown = (e) => {
|
|
if (e.target && e.target.closest && e.target.closest("#shaarli-bg-studio")) return;
|
|
if (e.target && e.target.closest && e.target.closest(".bg-studio-panel")) return;
|
|
if (inputContainer.contains(e.target)) return;
|
|
saveIfNeededAndClose();
|
|
};
|
|
document.addEventListener("pointerdown", onOutsidePointerDown, true);
|
|
|
|
paletteBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openBackgroundStudioPanel({
|
|
anchorEl: paletteBtn,
|
|
mode: "draft",
|
|
entityId: "draft-new-todo-from-note",
|
|
editUrl: "",
|
|
currentColor: draftVisual.color || "default",
|
|
currentFilter: draftVisual.filter || "none",
|
|
currentBackground: draftVisual.background || "none",
|
|
currentFontColor: draftVisual.fontColor || "auto",
|
|
title: window.ShaarliProI18n.t("My images & colors"),
|
|
});
|
|
|
|
const panel = document.getElementById("shaarli-bg-studio");
|
|
if (panel) {
|
|
panel.__draftApply = (next) => {
|
|
draftVisual = { ...draftVisual, ...(next || {}) };
|
|
applyNoteVisualState(inputContainer, draftVisual);
|
|
};
|
|
}
|
|
});
|
|
|
|
titleInput?.focus({ preventScroll: true });
|
|
return;
|
|
}
|
|
|
|
inputContainer.classList.add("is-editing");
|
|
inputContainer.innerHTML = `
|
|
<div class="note-input-expanded">
|
|
<input type="text" class="note-input-title" placeholder=window.ShaarliProI18n.t("Title") />
|
|
<textarea class="note-input-description-source" rows="8" placeholder=window.ShaarliProI18n.t("Write your note in Markdown...")></textarea>
|
|
<div class="note-formatting-bar" aria-hidden="true">
|
|
<button type="button" class="note-format-btn" data-note-format="h1" title=window.ShaarliProI18n.t("Heading 1")>H1</button>
|
|
<button type="button" class="note-format-btn" data-note-format="h2" title=window.ShaarliProI18n.t("Heading 2")>H2</button>
|
|
<button type="button" class="note-format-btn" data-note-format="h3" title=window.ShaarliProI18n.t("Heading 3")>H3</button>
|
|
<button type="button" class="note-format-btn" data-note-format="p" title=window.ShaarliProI18n.t("Text")>Aa</button>
|
|
<span class="note-format-sep" aria-hidden="true"></span>
|
|
<button type="button" class="note-format-btn" data-note-format="bold" title=window.ShaarliProI18n.t("Bold")><b>B</b></button>
|
|
<button type="button" class="note-format-btn" data-note-format="italic" title=window.ShaarliProI18n.t("Italic")><i>I</i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="underline" title=window.ShaarliProI18n.t("Underline")><u>U</u></button>
|
|
<button type="button" class="note-format-btn" data-note-format="strike" title=window.ShaarliProI18n.t("Strikethrough")><s>S</s></button>
|
|
<button type="button" class="note-format-btn" data-note-format="code" title=window.ShaarliProI18n.t("Inline code")><i class="mdi mdi-code-tags"></i></button>
|
|
<span class="note-format-sep" aria-hidden="true"></span>
|
|
<button type="button" class="note-format-btn" data-note-format="ul" title=window.ShaarliProI18n.t("Bulleted list")><i class="mdi mdi-format-list-bulleted"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="ol" title=window.ShaarliProI18n.t("Numbered list")><i class="mdi mdi-format-list-numbered"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="todo" title=window.ShaarliProI18n.t("Checklist")><i class="mdi mdi-format-list-checks"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="quote" title=window.ShaarliProI18n.t("Quote")><i class="mdi mdi-format-quote-open"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="link" title=window.ShaarliProI18n.t("Link")><i class="mdi mdi-link-variant"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="image" title=window.ShaarliProI18n.t("Image")><i class="mdi mdi-image-outline"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="codeblock" title=window.ShaarliProI18n.t("Code block")><i class="mdi mdi-code-braces"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="clear" title=window.ShaarliProI18n.t("Clear formatting")>X</button>
|
|
</div>
|
|
<div class="note-input-expanded-actions">
|
|
<div class="note-input-actions-left">
|
|
<button type="button" class="note-input-format-btn" title=window.ShaarliProI18n.t("Formatting options")><i class="mdi mdi-format-text"></i></button>
|
|
<button type="button" class="note-input-palette-btn" title=window.ShaarliProI18n.t("Color")><i class="mdi mdi-palette-outline"></i></button>
|
|
</div>
|
|
<button type="button" class="note-input-close-btn">Fermer</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const titleInput = inputContainer.querySelector(".note-input-title");
|
|
const source = inputContainer.querySelector(".note-input-description-source");
|
|
const closeBtn = inputContainer.querySelector(".note-input-close-btn");
|
|
const paletteBtn = inputContainer.querySelector(".note-input-palette-btn");
|
|
const formatToggleBtn = inputContainer.querySelector(".note-input-format-btn");
|
|
const formattingBar = inputContainer.querySelector(".note-formatting-bar");
|
|
|
|
let hasChanges = false;
|
|
let isSaving = false;
|
|
let draftVisual = { color: "default", filter: "none", background: "none", fontColor: "auto" };
|
|
applyNoteVisualState(inputContainer, draftVisual);
|
|
|
|
const setFormattingVisible = (visible) => {
|
|
if (!formattingBar) return;
|
|
if (visible) {
|
|
formattingBar.classList.add("open");
|
|
formattingBar.setAttribute("aria-hidden", "false");
|
|
inputContainer.classList.add("show-formatting");
|
|
} else {
|
|
formattingBar.classList.remove("open");
|
|
formattingBar.setAttribute("aria-hidden", "true");
|
|
inputContainer.classList.remove("show-formatting");
|
|
}
|
|
};
|
|
|
|
formatToggleBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const isOpen = formattingBar && formattingBar.classList.contains("open");
|
|
setFormattingVisible(!isOpen);
|
|
});
|
|
|
|
inputContainer.querySelectorAll(".note-format-btn").forEach((btnEl) => {
|
|
btnEl.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!source) return;
|
|
applyKeepNoteFormatting(source, btnEl.dataset.noteFormat || "");
|
|
hasChanges = true;
|
|
});
|
|
});
|
|
|
|
titleInput?.addEventListener("input", () => {
|
|
hasChanges = true;
|
|
});
|
|
source?.addEventListener("input", () => {
|
|
hasChanges = true;
|
|
});
|
|
|
|
const getValues = () => {
|
|
const t = titleInput && typeof titleInput.value === "string" ? titleInput.value.trim() : "";
|
|
const md = source && typeof source.value === "string" ? source.value.trim() : "";
|
|
return { title: t, markdown: md };
|
|
};
|
|
|
|
const saveIfNeededAndClose = async () => {
|
|
if (isSaving) return;
|
|
const { title, markdown } = getValues();
|
|
if (!title && !markdown) {
|
|
document.removeEventListener("pointerdown", onOutsidePointerDown, true);
|
|
renderNoteInputCollapsed();
|
|
return;
|
|
}
|
|
|
|
if (!hasChanges) {
|
|
document.removeEventListener("pointerdown", onOutsidePointerDown, true);
|
|
renderNoteInputCollapsed();
|
|
return;
|
|
}
|
|
|
|
isSaving = true;
|
|
try {
|
|
await createNewNoteViaForm({ title, markdown, visual: draftVisual });
|
|
window.location.reload();
|
|
} catch (err) {
|
|
console.error("Error creating note:", err);
|
|
alert(window.ShaarliProI18n.t("Error creating note."));
|
|
isSaving = false;
|
|
}
|
|
};
|
|
|
|
closeBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
saveIfNeededAndClose();
|
|
});
|
|
|
|
const onOutsidePointerDown = (e) => {
|
|
if (e.target && e.target.closest && e.target.closest("#shaarli-bg-studio")) return;
|
|
if (e.target && e.target.closest && e.target.closest(".bg-studio-panel")) return;
|
|
if (e.target && e.target.closest && e.target.closest(".note-formatting-bar")) return;
|
|
if (inputContainer.contains(e.target)) return;
|
|
saveIfNeededAndClose();
|
|
};
|
|
document.addEventListener("pointerdown", onOutsidePointerDown, true);
|
|
|
|
paletteBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openBackgroundStudioPanel({
|
|
anchorEl: paletteBtn,
|
|
mode: "draft",
|
|
entityId: "draft-new-note",
|
|
editUrl: "",
|
|
currentColor: draftVisual.color || "default",
|
|
currentFilter: draftVisual.filter || "none",
|
|
currentBackground: draftVisual.background || "none",
|
|
currentFontColor: draftVisual.fontColor || "auto",
|
|
title: window.ShaarliProI18n.t("My images & colors"),
|
|
});
|
|
|
|
const panel = document.getElementById("shaarli-bg-studio");
|
|
if (panel) {
|
|
panel.__draftApply = (next) => {
|
|
draftVisual = { ...draftVisual, ...(next || {}) };
|
|
applyNoteVisualState(inputContainer, draftVisual);
|
|
};
|
|
}
|
|
});
|
|
|
|
titleInput?.focus({ preventScroll: true });
|
|
};
|
|
|
|
collapsed?.addEventListener("click", (e) => {
|
|
if (e.target.closest("button")) return;
|
|
openEditor("note");
|
|
});
|
|
noteBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openEditor("note");
|
|
});
|
|
};
|
|
|
|
renderNoteInputCollapsed();
|
|
topBarInner.appendChild(inputContainer);
|
|
topBar.appendChild(topBarInner);
|
|
|
|
// View Toggle and other tools
|
|
const tools = document.createElement("div");
|
|
tools.className = "notes-tools";
|
|
tools.innerHTML = `
|
|
<button class="icon-btn active" id="btn-view-grid" title=window.ShaarliProI18n.t("Grid view")><i class="mdi mdi-view-dashboard-outline"></i></button>
|
|
<button class="icon-btn" id="btn-view-list" title=window.ShaarliProI18n.t("List view")><i class="mdi mdi-view-agenda-outline"></i></button>
|
|
`;
|
|
topBar.appendChild(tools);
|
|
|
|
wrapper.appendChild(topBar);
|
|
|
|
// 3. Content Area
|
|
const contentArea = document.createElement("div");
|
|
contentArea.className = "notes-content-area";
|
|
|
|
const links = Array.from(linkList.querySelectorAll(".link-outer"));
|
|
const notes = links.map((link) => parseNoteFromLink(link));
|
|
|
|
// Initial Render (Grid)
|
|
renderNotes(contentArea, notes, "grid");
|
|
|
|
wrapper.appendChild(contentArea);
|
|
|
|
// Replace original list
|
|
linkList.style.display = "none";
|
|
if (linkList.parentNode) {
|
|
linkList.parentNode.insertBefore(wrapper, linkList);
|
|
} else {
|
|
container.appendChild(wrapper);
|
|
}
|
|
|
|
// Check for hash parameter to auto-open a specific note
|
|
const hash = window.location.hash;
|
|
if (hash && hash.startsWith("#open-note-")) {
|
|
const noteId = hash.replace("#open-note-", "");
|
|
const targetNote = notes.find((n) => String(n.id) === String(noteId));
|
|
if (targetNote) {
|
|
// Clear the hash to avoid reopening on refresh
|
|
history.replaceState(null, null, window.location.pathname + window.location.search);
|
|
// Open the note modal after a short delay to ensure DOM is ready
|
|
setTimeout(() => {
|
|
openNoteModal(targetNote);
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
// Modal Container
|
|
const modalOverlay = document.createElement("div");
|
|
modalOverlay.className = "note-modal-overlay";
|
|
modalOverlay.innerHTML = `
|
|
<div class="note-modal" role="dialog" aria-modal="true">
|
|
<div class="note-modal-header">
|
|
<input type="text" class="note-title note-modal-title-input" id="note-modal-title" placeholder=window.ShaarliProI18n.t("Title") />
|
|
<button type="button" class="note-modal-pin-toggle" id="note-modal-pin" title=window.ShaarliProI18n.t("Pin")>
|
|
<i class="mdi mdi-pin-outline"></i>
|
|
</button>
|
|
</div>
|
|
<div class="note-modal-content">
|
|
<div class="note-modal-description-preview note-body" id="note-modal-description-preview" role="button" tabindex="0" aria-label=window.ShaarliProI18n.t("Click to edit")></div>
|
|
<textarea class="note-modal-description-source" id="note-modal-description-source" rows="8"></textarea>
|
|
<div class="note-formatting-bar" id="note-modal-formatting" aria-hidden="true">
|
|
<button type="button" class="note-format-btn" data-note-format="h1" title=window.ShaarliProI18n.t("Heading 1")>H1</button>
|
|
<button type="button" class="note-format-btn" data-note-format="h2" title=window.ShaarliProI18n.t("Heading 2")>H2</button>
|
|
<button type="button" class="note-format-btn" data-note-format="h3" title=window.ShaarliProI18n.t("Heading 3")>H3</button>
|
|
<button type="button" class="note-format-btn" data-note-format="p" title=window.ShaarliProI18n.t("Text")>Aa</button>
|
|
<span class="note-format-sep" aria-hidden="true"></span>
|
|
<button type="button" class="note-format-btn" data-note-format="bold" title=window.ShaarliProI18n.t("Bold")><b>B</b></button>
|
|
<button type="button" class="note-format-btn" data-note-format="italic" title=window.ShaarliProI18n.t("Italic")><i>I</i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="underline" title=window.ShaarliProI18n.t("Underline")><u>U</u></button>
|
|
<button type="button" class="note-format-btn" data-note-format="strike" title=window.ShaarliProI18n.t("Strikethrough")><s>S</s></button>
|
|
<button type="button" class="note-format-btn" data-note-format="code" title=window.ShaarliProI18n.t("Inline code")><i class="mdi mdi-code-tags"></i></button>
|
|
<span class="note-format-sep" aria-hidden="true"></span>
|
|
<button type="button" class="note-format-btn" data-note-format="ul" title=window.ShaarliProI18n.t("Bulleted list")><i class="mdi mdi-format-list-bulleted"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="ol" title=window.ShaarliProI18n.t("Numbered list")><i class="mdi mdi-format-list-numbered"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="todo" title=window.ShaarliProI18n.t("Checklist")><i class="mdi mdi-format-list-checks"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="quote" title=window.ShaarliProI18n.t("Quote")><i class="mdi mdi-format-quote-open"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="link" title=window.ShaarliProI18n.t("Link")><i class="mdi mdi-link-variant"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="image" title=window.ShaarliProI18n.t("Image")><i class="mdi mdi-image-outline"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="codeblock" title=window.ShaarliProI18n.t("Code block")><i class="mdi mdi-code-braces"></i></button>
|
|
<button type="button" class="note-format-btn" data-note-format="clear" title=window.ShaarliProI18n.t("Clear formatting")>X</button>
|
|
</div>
|
|
</div>
|
|
<div class="note-modal-tags is-empty" id="note-modal-tags"></div>
|
|
<div class="note-modal-actions">
|
|
<div class="note-modal-actions-left">
|
|
<div class="note-modal-color-picker">
|
|
<button type="button" id="note-modal-color-btn" title=window.ShaarliProI18n.t("Color")><i class="mdi mdi-palette-outline"></i></button>
|
|
<div class="palette-popup note-modal-palette" id="note-modal-color-popup"></div>
|
|
</div>
|
|
<button type="button" id="note-modal-format-btn" title=window.ShaarliProI18n.t("Formatting options")><i class="mdi mdi-format-text"></i></button>
|
|
<button type="button" title=window.ShaarliProI18n.t("Reminder")><i class="mdi mdi-bell-outline"></i></button>
|
|
<button type="button" title=window.ShaarliProI18n.t("Collaborator")><i class="mdi mdi-account-plus-outline"></i></button>
|
|
<button type="button" title=window.ShaarliProI18n.t("Image")><i class="mdi mdi-image-outline"></i></button>
|
|
<button type="button" id="note-modal-archive" title=window.ShaarliProI18n.t("Archive")><i class="mdi mdi-archive-arrow-down-outline"></i></button>
|
|
<a href="#" id="note-modal-edit" title=window.ShaarliProI18n.t("Edit")><i class="mdi mdi-pencil-outline"></i></a>
|
|
<button type="button" id="note-modal-delete" title=window.ShaarliProI18n.t("Delete")><i class="mdi mdi-delete-outline"></i></button>
|
|
</div>
|
|
<button type="button" class="note-modal-close-btn" id="note-modal-close">Fermer</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modalOverlay);
|
|
|
|
// Event Listeners for Toggles
|
|
const btnGrid = wrapper.querySelector("#btn-view-grid");
|
|
const btnList = wrapper.querySelector("#btn-view-list");
|
|
|
|
btnGrid.addEventListener("click", () => {
|
|
btnGrid.classList.add("active");
|
|
btnList.classList.remove("active");
|
|
renderNotes(contentArea, notes, "grid");
|
|
});
|
|
|
|
btnList.addEventListener("click", () => {
|
|
btnList.classList.add("active");
|
|
btnGrid.classList.remove("active");
|
|
renderNotes(contentArea, notes, "list");
|
|
});
|
|
|
|
const saveAndCloseModal = async () => {
|
|
try {
|
|
await saveOpenNoteEditorModal(modalOverlay);
|
|
modalOverlay.classList.remove("open");
|
|
document.body.classList.remove("note-modal-open");
|
|
} catch (e) {
|
|
console.error("Error saving note from modal:", e);
|
|
alert(window.ShaarliProI18n.t("Error saving note."));
|
|
}
|
|
};
|
|
|
|
modalOverlay.querySelector("#note-modal-close").addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
saveAndCloseModal();
|
|
});
|
|
modalOverlay.addEventListener("click", (e) => {
|
|
if (e.target === modalOverlay) saveAndCloseModal();
|
|
});
|
|
|
|
modalOverlay.querySelector("#note-modal-edit").addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const titleInput = modalOverlay.querySelector("#note-modal-title");
|
|
titleInput?.focus({ preventScroll: true });
|
|
});
|
|
|
|
const modalPinBtn = modalOverlay.querySelector("#note-modal-pin");
|
|
const modalColorBtn = modalOverlay.querySelector("#note-modal-color-btn");
|
|
const modalColorPopup = modalOverlay.querySelector("#note-modal-color-popup");
|
|
|
|
modalColorBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const modalCard = modalOverlay.querySelector(".note-modal");
|
|
openBackgroundStudioPanel({
|
|
anchorEl: modalColorBtn,
|
|
mode: "modal",
|
|
entityId: modalCard ? modalCard.dataset.noteId || "" : "",
|
|
editUrl: modalCard ? modalCard.dataset.editUrl || "" : "",
|
|
currentColor: modalCard ? getElementVisualColor(modalCard) : "default",
|
|
currentFilter: modalCard ? getElementVisualFilter(modalCard) : "none",
|
|
currentBackground: modalCard ? getElementVisualBackground(modalCard) : "none",
|
|
currentFontColor: modalCard ? getElementVisualFontColor(modalCard) : "auto",
|
|
title: window.ShaarliProI18n.t("My images & colors"),
|
|
});
|
|
});
|
|
|
|
modalPinBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const modalCard = modalOverlay.querySelector(".note-modal");
|
|
const noteId = modalCard.dataset.noteId;
|
|
const editUrl = modalCard.dataset.editUrl;
|
|
|
|
if (!noteId || !editUrl) return;
|
|
|
|
togglePinTag(noteId, editUrl, modalPinBtn);
|
|
|
|
const isPinned = modalPinBtn.classList.contains("active");
|
|
let tags = (modalCard.dataset.tags || "").split("||").filter((t) => t);
|
|
|
|
if (isPinned) {
|
|
if (!tags.includes("shaarli-pin")) tags.push("shaarli-pin");
|
|
} else {
|
|
tags = tags.filter((t) => t !== "shaarli-pin");
|
|
}
|
|
|
|
modalCard.dataset.tags = tags.join("||");
|
|
renderModalTags(modalOverlay.querySelector("#note-modal-tags"), tags);
|
|
|
|
if (modalOverlay.currentNote) {
|
|
modalOverlay.currentNote.isPinned = isPinned;
|
|
modalOverlay.currentNote.tags = tags;
|
|
}
|
|
});
|
|
|
|
modalOverlay.querySelector("#note-modal-delete").addEventListener("click", () => {
|
|
if (!confirm(window.ShaarliProI18n.t("Delete this note?"))) return;
|
|
const modalCard = modalOverlay.querySelector(".note-modal");
|
|
const deleteUrl = modalCard.dataset.deleteUrl;
|
|
const noteId = modalCard.dataset.noteId;
|
|
if (deleteUrl && deleteUrl !== "#") {
|
|
deleteEntitySilently(deleteUrl)
|
|
.then(() => {
|
|
window.location.href = "/?searchtags=note";
|
|
})
|
|
.catch((err) => {
|
|
console.error("Delete failed:", err);
|
|
alert(window.ShaarliProI18n.t("Error deleting."));
|
|
});
|
|
}
|
|
});
|
|
|
|
modalOverlay.querySelector("#note-modal-archive").addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
|
|
const modalCard = modalOverlay.querySelector(".note-modal");
|
|
const noteId = modalCard.dataset.noteId;
|
|
const editUrl = modalCard.dataset.editUrl;
|
|
if (!noteId || !editUrl) return;
|
|
|
|
addTagToNote(editUrl, "shaarli-archive")
|
|
.then(() => {
|
|
if (modalOverlay.currentNote) {
|
|
if (!modalOverlay.currentNote.tags.includes("shaarli-archive")) {
|
|
modalOverlay.currentNote.tags.push("shaarli-archive");
|
|
}
|
|
}
|
|
|
|
const noteCard = document.querySelector(`.note-card[data-id="${noteId}"]`);
|
|
if (noteCard) noteCard.remove();
|
|
|
|
const index = notes.findIndex((n) => String(n.id) === String(noteId));
|
|
if (index > -1) notes.splice(index, 1);
|
|
|
|
modalOverlay.classList.remove("open");
|
|
document.body.classList.remove("note-modal-open");
|
|
})
|
|
.catch((err) => {
|
|
console.error("Error archiving note:", err);
|
|
alert(window.ShaarliProI18n.t("Error archiving note."));
|
|
});
|
|
});
|
|
|
|
modalOverlay.addEventListener("click", (e) => {
|
|
if (!e.target.closest(".note-modal-color-picker")) {
|
|
modalColorPopup.classList.remove("open");
|
|
}
|
|
});
|
|
|
|
document.addEventListener("click", (e) => {
|
|
if (!e.target.closest(".note-hover-actions .palette-popup") || e.target.closest('.note-hover-actions [id^="palette-"]')) {
|
|
return;
|
|
}
|
|
|
|
document.querySelectorAll(".note-hover-actions .palette-popup.open").forEach((p) => {
|
|
p.classList.remove("open");
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initialize the Archive view (similar to Notes but for archived notes)
|
|
*/
|
|
function initArchiveView(linkList, container) {
|
|
document.body.classList.add("view-notes", "view-archive");
|
|
|
|
// Hide standard toolbar
|
|
const toolbar = document.querySelector(".content-toolbar");
|
|
if (toolbar) toolbar.style.display = "none";
|
|
|
|
// 1. Create Layout Wrapper
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "notes-wrapper archive-wrapper";
|
|
|
|
// 2. Create Title Area (Top)
|
|
const topBar = document.createElement("div");
|
|
topBar.className = "notes-top-bar archive-top-bar";
|
|
|
|
// Title for archive view
|
|
const titleContainer = document.createElement("div");
|
|
titleContainer.className = "archive-title-container";
|
|
titleContainer.innerHTML = `
|
|
<h1 class="archive-title"><i class="mdi mdi-archive-arrow-down-outline"></i> Archive</h1>
|
|
<p class="archive-subtitle">${window.ShaarliProI18n.t("Archived notes")}</p>
|
|
`;
|
|
topBar.appendChild(titleContainer);
|
|
|
|
// View Toggle and other tools
|
|
const tools = document.createElement("div");
|
|
tools.className = "notes-tools";
|
|
tools.innerHTML = `
|
|
<button class="icon-btn active" id="btn-view-grid" title=window.ShaarliProI18n.t("Grid view")><i class="mdi mdi-view-dashboard-outline"></i></button>
|
|
<button class="icon-btn" id="btn-view-list" title=window.ShaarliProI18n.t("List view")><i class="mdi mdi-view-agenda-outline"></i></button>
|
|
`;
|
|
topBar.appendChild(tools);
|
|
|
|
wrapper.appendChild(topBar);
|
|
|
|
// 3. Content Area
|
|
const contentArea = document.createElement("div");
|
|
contentArea.className = "notes-content-area";
|
|
|
|
const links = Array.from(linkList.querySelectorAll(".link-outer"));
|
|
const notes = links.map((link) => parseNoteFromLink(link));
|
|
|
|
// Filter only archived notes
|
|
const archivedNotes = notes.filter((note) => (note.tags || []).some((t) => t === "shaarli-archive" || t === "shaarli-archiver"));
|
|
|
|
// Initial Render (Grid)
|
|
renderNotes(contentArea, archivedNotes, "grid", true); // true = archive mode
|
|
|
|
wrapper.appendChild(contentArea);
|
|
|
|
// Replace original list
|
|
linkList.style.display = "none";
|
|
if (linkList.parentNode) {
|
|
linkList.parentNode.insertBefore(wrapper, linkList);
|
|
} else {
|
|
container.appendChild(wrapper);
|
|
}
|
|
|
|
// Modal Container (reuse the same modal as notes)
|
|
const modalOverlay = document.createElement("div");
|
|
modalOverlay.className = "note-modal-overlay";
|
|
modalOverlay.innerHTML = `
|
|
<div class="note-modal note-color-default">
|
|
<div class="note-modal-header">
|
|
<h2 class="note-title" id="note-modal-title"></h2>
|
|
<button type="button" class="note-modal-pin-toggle" id="note-modal-pin" title=window.ShaarliProI18n.t("Pin")>
|
|
<i class="mdi mdi-pin-outline"></i>
|
|
</button>
|
|
</div>
|
|
<div class="note-modal-content"></div>
|
|
<div class="note-modal-tags is-empty" id="note-modal-tags"></div>
|
|
<div class="note-modal-actions">
|
|
<div class="note-modal-actions-left">
|
|
<div class="note-modal-color-picker">
|
|
<button type="button" id="note-modal-color-btn" title=window.ShaarliProI18n.t("Color")><i class="mdi mdi-palette-outline"></i></button>
|
|
<div class="palette-popup note-modal-palette" id="note-modal-color-popup"></div>
|
|
</div>
|
|
<button type="button" title=window.ShaarliProI18n.t("Reminder")><i class="mdi mdi-bell-outline"></i></button>
|
|
<button type="button" title=window.ShaarliProI18n.t("Collaborator")><i class="mdi mdi-account-plus-outline"></i></button>
|
|
<button type="button" title=window.ShaarliProI18n.t("Image")><i class="mdi mdi-image-outline"></i></button>
|
|
<button type="button" id="note-modal-unarchive" title=window.ShaarliProI18n.t("Unarchive")><i class="mdi mdi-archive-arrow-up-outline"></i></button>
|
|
<a href="#" id="note-modal-edit" title=window.ShaarliProI18n.t("Edit")><i class="mdi mdi-pencil-outline"></i></a>
|
|
<button type="button" id="note-modal-delete" title=window.ShaarliProI18n.t("Delete")><i class="mdi mdi-delete-outline"></i></button>
|
|
</div>
|
|
<button type="button" class="note-modal-close-btn" id="note-modal-close">Fermer</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modalOverlay);
|
|
|
|
// Event Listeners for Toggles
|
|
const btnGrid = wrapper.querySelector("#btn-view-grid");
|
|
const btnList = wrapper.querySelector("#btn-view-list");
|
|
|
|
btnGrid.addEventListener("click", () => {
|
|
btnGrid.classList.add("active");
|
|
btnList.classList.remove("active");
|
|
renderNotes(contentArea, archivedNotes, "grid", true);
|
|
});
|
|
|
|
btnList.addEventListener("click", () => {
|
|
btnList.classList.add("active");
|
|
btnGrid.classList.remove("active");
|
|
renderNotes(contentArea, archivedNotes, "list", true);
|
|
});
|
|
|
|
// Close Modal
|
|
modalOverlay.querySelector("#note-modal-close").addEventListener("click", () => {
|
|
modalOverlay.classList.remove("open");
|
|
});
|
|
modalOverlay.addEventListener("click", (e) => {
|
|
if (e.target === modalOverlay) modalOverlay.classList.remove("open");
|
|
});
|
|
|
|
const modalPinBtn = modalOverlay.querySelector("#note-modal-pin");
|
|
const modalColorBtn = modalOverlay.querySelector("#note-modal-color-btn");
|
|
const modalColorPopup = modalOverlay.querySelector("#note-modal-color-popup");
|
|
|
|
modalColorBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const modalCard = modalOverlay.querySelector(".note-modal");
|
|
openBackgroundStudioPanel({
|
|
anchorEl: modalColorBtn,
|
|
mode: "modal",
|
|
entityId: modalCard ? modalCard.dataset.noteId || "" : "",
|
|
editUrl: modalCard ? modalCard.dataset.editUrl || "" : "",
|
|
currentColor: modalCard ? getElementVisualColor(modalCard) : "default",
|
|
currentFilter: modalCard ? getElementVisualFilter(modalCard) : "none",
|
|
currentBackground: modalCard ? getElementVisualBackground(modalCard) : "none",
|
|
currentFontColor: modalCard ? getElementVisualFontColor(modalCard) : "auto",
|
|
title: window.ShaarliProI18n.t("My images & colors"),
|
|
});
|
|
});
|
|
|
|
modalPinBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const modalCard = modalOverlay.querySelector(".note-modal");
|
|
const noteId = modalCard.dataset.noteId;
|
|
const editUrl = modalCard.dataset.editUrl;
|
|
|
|
if (!noteId || !editUrl) return;
|
|
|
|
togglePinTag(noteId, editUrl, modalPinBtn);
|
|
|
|
const isPinned = modalPinBtn.classList.contains("active");
|
|
let tags = (modalCard.dataset.tags || "").split("||").filter((t) => t);
|
|
|
|
if (isPinned) {
|
|
if (!tags.includes("shaarli-pin")) tags.push("shaarli-pin");
|
|
} else {
|
|
tags = tags.filter((t) => t !== "shaarli-pin");
|
|
}
|
|
|
|
modalCard.dataset.tags = tags.join("||");
|
|
renderModalTags(modalOverlay.querySelector("#note-modal-tags"), tags);
|
|
|
|
if (modalOverlay.currentNote) {
|
|
modalOverlay.currentNote.isPinned = isPinned;
|
|
modalOverlay.currentNote.tags = tags;
|
|
}
|
|
});
|
|
|
|
modalOverlay.querySelector("#note-modal-delete").addEventListener("click", () => {
|
|
if (!confirm(window.ShaarliProI18n.t("Delete this note?"))) return;
|
|
const modalCard = modalOverlay.querySelector(".note-modal");
|
|
const deleteUrl = modalCard.dataset.deleteUrl;
|
|
const noteId = modalCard.dataset.noteId;
|
|
if (deleteUrl && deleteUrl !== "#") {
|
|
deleteEntitySilently(deleteUrl)
|
|
.then(() => {
|
|
window.location.href = "/?searchtags=shaarli-archive";
|
|
})
|
|
.catch((err) => {
|
|
console.error("Delete failed:", err);
|
|
alert(window.ShaarliProI18n.t("Error deleting."));
|
|
});
|
|
}
|
|
});
|
|
|
|
// Unarchive button in modal
|
|
modalOverlay.querySelector("#note-modal-unarchive").addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
|
|
const modalCard = modalOverlay.querySelector(".note-modal");
|
|
const noteId = modalCard.dataset.noteId;
|
|
const editUrl = modalCard.dataset.editUrl;
|
|
if (!noteId || !editUrl) return;
|
|
|
|
removeTagFromEntity(editUrl, "shaarli-archive")
|
|
.then(() => {
|
|
if (modalOverlay.currentNote) {
|
|
modalOverlay.currentNote.tags = (modalOverlay.currentNote.tags || []).filter((t) => t !== "shaarli-archive");
|
|
}
|
|
|
|
const noteCard = document.querySelector(`.note-card[data-id="${noteId}"]`);
|
|
if (noteCard) noteCard.remove();
|
|
|
|
const index = archivedNotes.findIndex((n) => String(n.id) === String(noteId));
|
|
if (index > -1) archivedNotes.splice(index, 1);
|
|
|
|
modalOverlay.classList.remove("open");
|
|
})
|
|
.catch((err) => {
|
|
console.error("Error unarchiving note:", err);
|
|
alert(window.ShaarliProI18n.t("Error unarchiving note."));
|
|
});
|
|
});
|
|
|
|
modalOverlay.addEventListener("click", (e) => {
|
|
if (!e.target.closest(".note-modal-color-picker")) {
|
|
modalColorPopup.classList.remove("open");
|
|
}
|
|
});
|
|
|
|
document.addEventListener("click", (e) => {
|
|
if (!e.target.closest(".note-hover-actions .palette-popup") || e.target.closest('.note-hover-actions [id^="palette-"]')) {
|
|
return;
|
|
}
|
|
|
|
document.querySelectorAll(".note-hover-actions .palette-popup.open").forEach((p) => {
|
|
p.classList.remove("open");
|
|
});
|
|
});
|
|
}
|
|
|
|
function parseNoteFromLink(linkEl) {
|
|
const id = linkEl.dataset.id;
|
|
const titleEl = linkEl.querySelector(".link-title");
|
|
const title = titleEl ? titleEl.textContent.trim() : "";
|
|
|
|
const descEl = linkEl.querySelector(".link-description");
|
|
const descHtml = descEl ? descEl.innerHTML : "";
|
|
const descText = descEl ? descEl.textContent : "";
|
|
|
|
// Extract Image from Description (First image as cover)
|
|
let coverImage = null;
|
|
if (descEl) {
|
|
const img = descEl.querySelector("img");
|
|
if (img) {
|
|
coverImage = img.src;
|
|
// Optionally remove img from body text if it's purely a cover
|
|
// But usually we keep it or hide it via CSS if we construct a custom card
|
|
}
|
|
}
|
|
|
|
const urlEl = linkEl.querySelector(".link-url");
|
|
const url = urlEl ? urlEl.textContent.trim() : "";
|
|
|
|
const rawTags = [];
|
|
linkEl.querySelectorAll(".link-tag-list a").forEach((tag) => {
|
|
const t = (tag.textContent || "").trim();
|
|
if (t) rawTags.push(t);
|
|
});
|
|
|
|
const { color, filter, background, fontColor } = extractNoteVisualStateFromTags(rawTags);
|
|
|
|
const isLegacyNoteColorTag = (t) => {
|
|
if (typeof t !== "string") return false;
|
|
if (!t.startsWith("note-")) return false;
|
|
const candidate = t.substring(5);
|
|
return NOTE_COLOR_OPTIONS.some((opt) => opt.key === candidate);
|
|
};
|
|
|
|
const tags = rawTags.filter((t) => {
|
|
if (t.startsWith(NOTE_COLOR_TAG_PREFIX)) return false;
|
|
if (t.startsWith("note-custom-")) return false;
|
|
if (isLegacyNoteColorTag(t)) return false;
|
|
if (t.startsWith(NOTE_FILTER_TAG_PREFIX)) return false;
|
|
if (t.startsWith(NOTE_BACKGROUND_TAG_PREFIX)) return false;
|
|
if (t.startsWith(NOTE_FONT_COLOR_TAG_PREFIX)) return false;
|
|
return true;
|
|
});
|
|
|
|
const isPinnedByTag = tags.includes("shaarli-pin");
|
|
|
|
const actionsEl = linkEl.querySelector(".link-actions");
|
|
const editUrl = actionsEl && actionsEl.querySelector('a[href*="admin/shaare"]') ? actionsEl.querySelector('a[href*="admin/shaare"]').href : "#";
|
|
const deleteUrl = actionsEl && actionsEl.querySelector('a[href*="delete"]') ? actionsEl.querySelector('a[href*="delete"]').href : "#";
|
|
const pinUrl = actionsEl && actionsEl.querySelector('a[href*="pin"]') ? actionsEl.querySelector('a[href*="pin"]').href : "#";
|
|
|
|
// User requested "availability of the tag 'shaarli-pin' as the main source"
|
|
const isPinned = tags.includes("shaarli-pin");
|
|
|
|
return { id, title, descHtml, descText, coverImage, url, tags, color, filter, background, fontColor, editUrl, deleteUrl, pinUrl, isPinned };
|
|
}
|
|
|
|
function renderNotes(container, notes, viewMode, isArchiveMode = false) {
|
|
container.innerHTML = "";
|
|
container.className = viewMode === "grid" ? "notes-masonry" : "notes-list-view";
|
|
|
|
// Filter notes based on archive mode
|
|
let visibleNotes;
|
|
if (isArchiveMode) {
|
|
// In archive mode: show only notes with shaarli-archive tag
|
|
visibleNotes = notes.filter((note) => (note.tags || []).some((t) => t === "shaarli-archive" || t === "shaarli-archiver"));
|
|
} else {
|
|
// In normal notes mode: hide archived notes
|
|
visibleNotes = notes.filter((note) => !(note.tags || []).some((t) => t === "shaarli-archive" || t === "shaarli-archiver"));
|
|
}
|
|
|
|
// Sort: Pinned items first
|
|
visibleNotes.sort((a, b) => {
|
|
const aPinned = a.tags.includes("shaarli-pin");
|
|
const bPinned = b.tags.includes("shaarli-pin");
|
|
return bPinned - aPinned;
|
|
});
|
|
|
|
visibleNotes.forEach((note) => {
|
|
const card = document.createElement("div");
|
|
card.className = "note-card";
|
|
card.dataset.id = note.id;
|
|
card.dataset.editUrl = note.editUrl || "";
|
|
card.dataset.tags = (note.tags || []).filter((t) => t).join("||");
|
|
applyNoteVisualState(card, note);
|
|
if (viewMode === "list") card.classList.add("list-mode");
|
|
|
|
// Main Click to Open Modal
|
|
card.addEventListener("click", (e) => {
|
|
// Prevent if clicking buttons
|
|
if (e.target.closest("button") || e.target.closest("a") || e.target.closest(".note-hover-actions")) return;
|
|
syncNoteFromCardElement(note, card);
|
|
openNoteModal(note);
|
|
});
|
|
|
|
// Cover Image
|
|
if (note.coverImage && viewMode === "grid") {
|
|
// Show cover mainly in grid
|
|
const imgContainer = document.createElement("div");
|
|
imgContainer.className = "note-cover";
|
|
imgContainer.innerHTML = `<img src="${note.coverImage}" alt="Cover">`;
|
|
card.appendChild(imgContainer);
|
|
}
|
|
|
|
// Inner Content
|
|
const inner = document.createElement("div");
|
|
inner.className = "note-inner";
|
|
|
|
// Title
|
|
if (note.title) {
|
|
const h3 = document.createElement("h3");
|
|
h3.className = "note-title";
|
|
h3.textContent = note.title;
|
|
inner.appendChild(h3);
|
|
}
|
|
|
|
// Body (truncated in grid, maybe?)
|
|
if (note.descHtml || note.descText || note._noteMarkdown) {
|
|
const body = document.createElement("div");
|
|
body.className = "note-body";
|
|
|
|
if (note._noteMarkdown !== undefined) {
|
|
body.innerHTML = renderMarkdown(note._noteMarkdown);
|
|
} else {
|
|
const textToRender = note.descText || "";
|
|
if (textToRender.trim().startsWith('<div class="markdown">') || textToRender.trim().startsWith("<")) {
|
|
body.innerHTML = textToRender;
|
|
} else {
|
|
body.innerHTML = renderMarkdown(textToRender);
|
|
}
|
|
}
|
|
|
|
inner.appendChild(body);
|
|
}
|
|
|
|
// Tags (Labels)
|
|
if (note.tags.length > 0) {
|
|
const tagContainer = document.createElement("div");
|
|
tagContainer.className = "note-tags";
|
|
note.tags.forEach((t) => {
|
|
if (isTechnicalTag(t)) return;
|
|
const pill = createTagPill({ tag: t, onRemoveClass: "note-tag-remove-btn", tagClass: "note-tag", canRemove: !!note.editUrl && note.editUrl !== "#" });
|
|
tagContainer.appendChild(pill);
|
|
});
|
|
inner.appendChild(tagContainer);
|
|
}
|
|
|
|
// Hover Actions (Keep style: at bottom, visible on hover)
|
|
const actions = document.createElement("div");
|
|
actions.className = "note-hover-actions";
|
|
|
|
const pinCorner = document.createElement("a");
|
|
pinCorner.href = note.pinUrl;
|
|
pinCorner.title = note.isPinned ? "Unpin" : "Pin";
|
|
pinCorner.className = `note-pin-corner ${note.isPinned ? "active" : ""}`;
|
|
pinCorner.innerHTML = `<i class="mdi mdi-pin${note.isPinned ? "" : "-outline"}"></i>`;
|
|
inner.appendChild(pinCorner);
|
|
|
|
// Palette Button Logic
|
|
const paletteBtnId = `palette-${note.id}`;
|
|
const archiveBtnId = `archive-${note.id}`;
|
|
|
|
actions.innerHTML = `
|
|
<button title=window.ShaarliProI18n.t("Reminder")><i class="mdi mdi-bell-outline"></i></button>
|
|
<button title=window.ShaarliProI18n.t("Collaborator")><i class="mdi mdi-account-plus-outline"></i></button>
|
|
<div style="position:relative;">
|
|
<button title=window.ShaarliProI18n.t("Color") id="${paletteBtnId}"><i class="mdi mdi-palette-outline"></i></button>
|
|
</div>
|
|
<button title=window.ShaarliProI18n.t("Image")><i class="mdi mdi-image-outline"></i></button>
|
|
<button title=window.ShaarliProI18n.t("Archive") id="${archiveBtnId}"><i class="mdi mdi-archive-arrow-down-outline"></i></button>
|
|
<div class="spacer"></div>
|
|
<!-- Real Actions -->
|
|
<button type="button" class="note-open-editor-btn" title=window.ShaarliProI18n.t("Edit")><i class="mdi mdi-pencil-outline"></i></button>
|
|
<button title=window.ShaarliProI18n.t("Delete") class="note-delete-btn"><i class="mdi mdi-delete-outline"></i></button>
|
|
`;
|
|
|
|
const deleteBtn = actions.querySelector(".note-delete-btn");
|
|
deleteBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!confirm(window.ShaarliProI18n.t("Delete this note?"))) return;
|
|
deleteEntitySilently(note.deleteUrl)
|
|
.then(() => {
|
|
window.location.href = isArchiveMode ? "/?searchtags=shaarli-archive" : "/?searchtags=note";
|
|
})
|
|
.catch((err) => {
|
|
console.error("Delete failed:", err);
|
|
alert(window.ShaarliProI18n.t("Error deleting."));
|
|
});
|
|
});
|
|
|
|
const openEditorBtn = actions.querySelector(".note-open-editor-btn");
|
|
openEditorBtn?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
syncNoteFromCardElement(note, card);
|
|
openNoteModal(note);
|
|
});
|
|
|
|
// Palette Toggle
|
|
const paletteBtn = actions.querySelector(`#${paletteBtnId}`);
|
|
paletteBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openBackgroundStudioPanel({
|
|
anchorEl: paletteBtn,
|
|
mode: "entity",
|
|
entityId: note.id,
|
|
editUrl: note.editUrl,
|
|
currentColor: getElementVisualColor(card),
|
|
currentFilter: getElementVisualFilter(card),
|
|
currentBackground: getElementVisualBackground(card),
|
|
title: window.ShaarliProI18n.t("My images & colors"),
|
|
});
|
|
});
|
|
|
|
// Archive button handler
|
|
const archiveBtn = actions.querySelector(`#${archiveBtnId}`);
|
|
archiveBtn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
if (!note.editUrl || note.editUrl === "#") return;
|
|
|
|
addTagToNote(note.editUrl, "shaarli-archive")
|
|
.then(() => {
|
|
// Remove the card from the view
|
|
card.remove();
|
|
})
|
|
.catch((err) => {
|
|
console.error("Error archiving note:", err);
|
|
alert(window.ShaarliProI18n.t("Error archiving note."));
|
|
});
|
|
});
|
|
|
|
inner.appendChild(actions);
|
|
card.appendChild(inner);
|
|
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function setModalPinButtonState(button, isPinned) {
|
|
if (!button) return;
|
|
|
|
button.classList.toggle("active", isPinned);
|
|
button.title = isPinned ? window.ShaarliProI18n.t("Unpin") : window.ShaarliProI18n.t("Pin");
|
|
|
|
const icon = button.querySelector("i");
|
|
if (icon) {
|
|
icon.className = `mdi ${isPinned ? "mdi-pin" : "mdi-pin-outline"}`;
|
|
}
|
|
}
|
|
|
|
function renderModalTags(container, tags) {
|
|
if (!container) return;
|
|
|
|
container.innerHTML = "";
|
|
const visibleTags = (tags || []).filter((tag) => tag && !isTechnicalTag(tag));
|
|
|
|
const modal = container.closest(".note-modal-overlay") || getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
const canRemove = !!(entity && entity.editUrl && entity.editUrl !== "#");
|
|
|
|
if (visibleTags.length === 0) {
|
|
container.classList.add("is-empty");
|
|
return;
|
|
}
|
|
|
|
container.classList.remove("is-empty");
|
|
visibleTags.forEach((tag) => {
|
|
const pill = createTagPill({ tag, onRemoveClass: "note-tag-remove-btn", tagClass: "note-tag", canRemove });
|
|
container.appendChild(pill);
|
|
});
|
|
}
|
|
|
|
function openNoteModal(note) {
|
|
const modal = document.querySelector(".note-modal-overlay");
|
|
if (!modal) return;
|
|
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
const titleInput = modal.querySelector("#note-modal-title");
|
|
const descriptionSource = modal.querySelector("#note-modal-description-source");
|
|
const descriptionPreview = modal.querySelector("#note-modal-description-preview");
|
|
const tagsContainer = modal.querySelector("#note-modal-tags");
|
|
const editLink = modal.querySelector("#note-modal-edit");
|
|
const pinButton = modal.querySelector("#note-modal-pin");
|
|
const modalColorPopup = modal.querySelector("#note-modal-color-popup");
|
|
const formatToggleBtn = modal.querySelector("#note-modal-format-btn");
|
|
const formattingBar = modal.querySelector("#note-modal-formatting");
|
|
|
|
const setDescriptionEditMode = (isEditing) => {
|
|
if (!descriptionSource || !descriptionPreview) return;
|
|
if (isEditing) {
|
|
modal.classList.add("note-modal-editing");
|
|
descriptionPreview.setAttribute("aria-hidden", "true");
|
|
descriptionSource.removeAttribute("aria-hidden");
|
|
} else {
|
|
modal.classList.remove("note-modal-editing");
|
|
descriptionPreview.setAttribute("aria-hidden", "false");
|
|
descriptionSource.setAttribute("aria-hidden", "true");
|
|
}
|
|
};
|
|
|
|
modal.currentNote = note;
|
|
|
|
modalCard.className = "note-modal";
|
|
applyNoteVisualState(modalCard, note);
|
|
modalCard.dataset.noteId = note.id || "";
|
|
modalCard.dataset.editUrl = note.editUrl || "";
|
|
modalCard.dataset.deleteUrl = note.deleteUrl || "";
|
|
modalCard.dataset.background = note.background || "none";
|
|
|
|
const visibleTags = (note.tags || []).filter((tag) => tag && !isTechnicalTag(tag));
|
|
modalCard.dataset.tags = visibleTags.join("||");
|
|
|
|
if (titleInput) titleInput.value = note.title || "";
|
|
if (descriptionSource) descriptionSource.value = (note._noteMarkdown || note.descText || "").trim();
|
|
if (descriptionPreview) {
|
|
descriptionPreview.innerHTML = renderMarkdown((note._noteMarkdown || note.descText || "").trim());
|
|
}
|
|
setDescriptionEditMode(false);
|
|
renderModalTags(tagsContainer, visibleTags);
|
|
|
|
if (editLink) {
|
|
editLink.href = note.editUrl || "#";
|
|
}
|
|
|
|
if (modalColorPopup) {
|
|
modalColorPopup.innerHTML = generateModalPaletteButtons(note);
|
|
modalColorPopup.classList.remove("open");
|
|
}
|
|
|
|
setModalPinButtonState(pinButton, !!note.isPinned);
|
|
|
|
if (!modal._noteEditorState) {
|
|
modal._noteEditorState = { hasChanges: false, isSaving: false, lastSavedTitle: "", lastSavedMarkdown: "" };
|
|
}
|
|
|
|
const state = modal._noteEditorState;
|
|
state.hasChanges = false;
|
|
state.lastSavedTitle = titleInput && typeof titleInput.value === "string" ? titleInput.value.trim() : "";
|
|
state.lastSavedMarkdown = descriptionSource && typeof descriptionSource.value === "string" ? descriptionSource.value.trim() : "";
|
|
|
|
if (!modal._noteEditorBound) {
|
|
modal._noteEditorBound = true;
|
|
titleInput?.addEventListener("input", () => {
|
|
if (modal._noteEditorState) modal._noteEditorState.hasChanges = true;
|
|
});
|
|
descriptionSource?.addEventListener("input", () => {
|
|
if (modal._noteEditorState) modal._noteEditorState.hasChanges = true;
|
|
if (descriptionPreview) {
|
|
descriptionPreview.innerHTML = renderMarkdown(descriptionSource.value || "");
|
|
}
|
|
});
|
|
|
|
descriptionPreview?.addEventListener("click", () => {
|
|
setDescriptionEditMode(true);
|
|
descriptionSource?.focus({ preventScroll: true });
|
|
});
|
|
descriptionPreview?.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
setDescriptionEditMode(true);
|
|
descriptionSource?.focus({ preventScroll: true });
|
|
}
|
|
});
|
|
|
|
descriptionSource?.addEventListener("blur", () => {
|
|
setDescriptionEditMode(false);
|
|
});
|
|
|
|
const setFormattingVisible = (visible) => {
|
|
const fb = modal.querySelector("#note-modal-formatting");
|
|
if (!fb) return;
|
|
if (visible) {
|
|
fb.classList.add("open");
|
|
fb.setAttribute("aria-hidden", "false");
|
|
modal.classList.add("show-formatting");
|
|
} else {
|
|
fb.classList.remove("open");
|
|
fb.setAttribute("aria-hidden", "true");
|
|
modal.classList.remove("show-formatting");
|
|
}
|
|
};
|
|
|
|
modal._setNoteModalFormattingVisible = setFormattingVisible;
|
|
|
|
const ftb = modal.querySelector("#note-modal-format-btn");
|
|
ftb?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const fb = modal.querySelector("#note-modal-formatting");
|
|
const isOpen = fb && fb.classList.contains("open");
|
|
setFormattingVisible(!isOpen);
|
|
});
|
|
|
|
modal.querySelectorAll(".note-format-btn").forEach((btnEl) => {
|
|
btnEl.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const src = modal.querySelector("#note-modal-description-source");
|
|
if (!src) return;
|
|
setDescriptionEditMode(true);
|
|
applyKeepNoteFormatting(src, btnEl.dataset.noteFormat || "");
|
|
if (modal._noteEditorState) modal._noteEditorState.hasChanges = true;
|
|
});
|
|
});
|
|
}
|
|
|
|
if (typeof modal._setNoteModalFormattingVisible === "function") {
|
|
modal._setNoteModalFormattingVisible(false);
|
|
}
|
|
|
|
hydrateNoteFromEditForm(note).then(() => {
|
|
if (modal.currentNote !== note) return;
|
|
if (titleInput && note._noteTitle) {
|
|
titleInput.value = note._noteTitle;
|
|
}
|
|
if (descriptionSource) {
|
|
descriptionSource.value = (note._noteMarkdown || "").trim();
|
|
}
|
|
if (descriptionPreview) {
|
|
descriptionPreview.innerHTML = renderMarkdown(descriptionSource ? descriptionSource.value : "");
|
|
}
|
|
setDescriptionEditMode(false);
|
|
state.lastSavedTitle = titleInput && typeof titleInput.value === "string" ? titleInput.value.trim() : "";
|
|
state.lastSavedMarkdown = descriptionSource && typeof descriptionSource.value === "string" ? descriptionSource.value.trim() : "";
|
|
});
|
|
|
|
modal.classList.add("open");
|
|
document.body.classList.add("note-modal-open");
|
|
titleInput?.focus({ preventScroll: true });
|
|
}
|
|
|
|
async function saveOpenNoteEditorModal(modal) {
|
|
if (!modal || !modal.currentNote) return;
|
|
const note = modal.currentNote;
|
|
|
|
const titleInput = modal.querySelector("#note-modal-title");
|
|
const descriptionSource = modal.querySelector("#note-modal-description-source");
|
|
|
|
const title = titleInput && typeof titleInput.value === "string" ? titleInput.value.trim() : "";
|
|
const markdown = descriptionSource && typeof descriptionSource.value === "string" ? descriptionSource.value.trim() : "";
|
|
const state = modal._noteEditorState || { hasChanges: false, isSaving: false, lastSavedTitle: "", lastSavedMarkdown: "" };
|
|
|
|
if (!title && !markdown) {
|
|
return;
|
|
}
|
|
|
|
const changed = state.hasChanges || title !== (state.lastSavedTitle || "") || markdown !== (state.lastSavedMarkdown || "");
|
|
|
|
if (!changed || state.isSaving) return;
|
|
|
|
state.isSaving = true;
|
|
modal._noteEditorState = state;
|
|
|
|
await hydrateNoteFromEditForm(note);
|
|
await persistNoteChanges(note, { title, markdown });
|
|
|
|
note.title = title;
|
|
note._noteTitle = title;
|
|
note._noteMarkdown = markdown;
|
|
note.descText = markdown;
|
|
|
|
const card = document.querySelector(`.note-card[data-id="${CSS.escape(String(note.id))}"]`);
|
|
if (card) {
|
|
const titleEl = card.querySelector(".note-inner .note-title");
|
|
if (titleEl) titleEl.textContent = title;
|
|
|
|
const bodyEl = card.querySelector(".note-inner .note-body");
|
|
if (bodyEl) {
|
|
bodyEl.innerHTML = renderMarkdown(markdown);
|
|
note.descHtml = bodyEl.innerHTML;
|
|
}
|
|
}
|
|
|
|
state.lastSavedTitle = title;
|
|
state.lastSavedMarkdown = markdown;
|
|
state.hasChanges = false;
|
|
state.isSaving = false;
|
|
}
|
|
|
|
function generateModalPaletteButtons(note) {
|
|
return generateUnifiedPaletteMenu({
|
|
entityId: note && note.id ? note.id : "",
|
|
editUrl: note && note.editUrl ? note.editUrl : "",
|
|
currentColor: note && note.color ? note.color : "default",
|
|
currentFilter: note && note.filter ? note.filter : "none",
|
|
mode: "modal",
|
|
});
|
|
}
|
|
|
|
window.setModalNoteColor = function (color) {
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (!modal || !entity) return;
|
|
|
|
setNoteColor(entity.id, color, entity.editUrl);
|
|
|
|
entity.color = color;
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
if (modalCard) {
|
|
applyNoteVisualState(modalCard, entity);
|
|
}
|
|
|
|
const modalColorPopup = modal.querySelector(".note-modal-palette");
|
|
if (modalColorPopup) {
|
|
modalColorPopup.innerHTML = generateModalPaletteButtons(entity);
|
|
modalColorPopup.classList.add("open");
|
|
positionPalettePopup(modalColorPopup);
|
|
}
|
|
};
|
|
|
|
window.setModalNoteFontColor = function (fontColorKey) {
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (!modal || !entity) return;
|
|
|
|
setNoteFontColor(entity.id, fontColorKey, entity.editUrl);
|
|
|
|
entity.fontColor = fontColorKey;
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
if (modalCard) {
|
|
applyNoteVisualState(modalCard, entity);
|
|
}
|
|
|
|
const modalColorPopup = modal.querySelector(".note-modal-palette");
|
|
if (modalColorPopup) {
|
|
modalColorPopup.innerHTML = generateModalPaletteButtons(entity);
|
|
modalColorPopup.classList.add("open");
|
|
positionPalettePopup(modalColorPopup);
|
|
}
|
|
};
|
|
|
|
window.setModalNoteFilter = function (filterKey) {
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (!modal || !entity) return;
|
|
|
|
const normalizedFilterKey = normalizeFilterKey(filterKey) || "none";
|
|
setNoteFilter(entity.id, normalizedFilterKey, entity.editUrl);
|
|
|
|
entity.filter = normalizedFilterKey;
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
if (modalCard) {
|
|
applyNoteVisualState(modalCard, entity);
|
|
}
|
|
|
|
const modalColorPopup = modal.querySelector(".note-modal-palette");
|
|
if (modalColorPopup) {
|
|
modalColorPopup.innerHTML = generateModalPaletteButtons(entity);
|
|
modalColorPopup.classList.add("open");
|
|
positionPalettePopup(modalColorPopup);
|
|
}
|
|
};
|
|
|
|
function generatePaletteButtons(note) {
|
|
return generateUnifiedPaletteMenu({
|
|
entityId: note && note.id ? note.id : "",
|
|
editUrl: note && note.editUrl ? note.editUrl : "",
|
|
currentColor: note && note.color ? note.color : "default",
|
|
currentFilter: note && note.filter ? note.filter : "none",
|
|
mode: "entity",
|
|
});
|
|
}
|
|
|
|
window.setNoteColor = function (noteId, color, editUrl) {
|
|
// 1. Visual Update (Immediate feedback)
|
|
setNoteColorVisual(noteId, color);
|
|
|
|
const bookmarkCard = document.querySelector(`.link-outer[data-id="${noteId}"]`);
|
|
if (bookmarkCard) {
|
|
const palettePopup = bookmarkCard.querySelector(".bookmark-palette .palette-popup");
|
|
if (palettePopup) {
|
|
const currentColor = getElementVisualColor(bookmarkCard);
|
|
const currentFilter = getElementVisualFilter(bookmarkCard);
|
|
palettePopup.innerHTML = generateBookmarkPaletteButtons(noteId, editUrl, currentColor, currentFilter);
|
|
}
|
|
}
|
|
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (modal && entity && String(entity.id) === String(noteId)) {
|
|
entity.color = color;
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
if (modalCard) {
|
|
applyNoteVisualState(modalCard, entity);
|
|
}
|
|
const modalColorPopup = modal.querySelector(".note-modal-palette");
|
|
if (modalColorPopup) {
|
|
modalColorPopup.innerHTML = generateModalPaletteButtons(entity);
|
|
}
|
|
}
|
|
|
|
// 2. Persistence via AJAX Form Submission
|
|
fetch(editUrl)
|
|
.then((response) => response.text())
|
|
.then((html) => {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const form = doc.querySelector('form[name="linkform"]');
|
|
|
|
if (!form) throw new Error("Could not find edit form");
|
|
|
|
// Extract all necessary fields
|
|
const formData = new URLSearchParams();
|
|
const inputs = form.querySelectorAll("input, textarea");
|
|
|
|
inputs.forEach((input) => {
|
|
if (input.type === "checkbox") {
|
|
if (input.checked) formData.append(input.name, input.value || "on");
|
|
} else if (input.name) {
|
|
formData.append(input.name, getFormFieldValue(input));
|
|
}
|
|
});
|
|
|
|
// Update Tags
|
|
let currentTags = formData.get("lf_tags") || "";
|
|
let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== "");
|
|
|
|
// Remove existing color tags (only one note-color-* allowed)
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith(NOTE_COLOR_TAG_PREFIX));
|
|
|
|
// Backward compat cleanup: remove legacy note-custom-<hex>
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith("note-custom-"));
|
|
|
|
// Backward compat cleanup: remove legacy note-<color>
|
|
tagsArray = tagsArray.filter((t) => {
|
|
if (!t.startsWith("note-")) return true;
|
|
const colorKey = t.substring(5);
|
|
return !NOTE_COLOR_OPTIONS.some((opt) => opt.key === colorKey);
|
|
});
|
|
|
|
// Add new color tag (unless default)
|
|
if (color !== "default") {
|
|
if (typeof color === "string" && color.startsWith("custom:")) {
|
|
const cleanColor = color.substring(7).replace("#", "");
|
|
tagsArray.push(`${NOTE_COLOR_TAG_PREFIX}${cleanColor}`);
|
|
} else {
|
|
tagsArray.push(`${NOTE_COLOR_TAG_PREFIX}${color}`);
|
|
}
|
|
}
|
|
|
|
formData.set("lf_tags", tagsArray.join(" "));
|
|
formData.append("save_edit", "1"); // Trigger save action
|
|
|
|
// POST back to Shaarli
|
|
return fetch(form.action, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: formData.toString(),
|
|
});
|
|
})
|
|
.then((response) => {
|
|
if (response.ok) {
|
|
} else {
|
|
throw new Error("Failed to save color");
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error("Error saving note color:", err);
|
|
alert(window.ShaarliProI18n.t("Failed to save color.") + " " + window.ShaarliProI18n.t("Please refresh the page."));
|
|
});
|
|
};
|
|
|
|
window.setNoteFilter = function (noteId, filterKey, editUrl) {
|
|
const normalizedFilterKey = normalizeFilterKey(filterKey) || "none";
|
|
|
|
const card = document.querySelector(`.note-card[data-id="${noteId}"]`);
|
|
if (card) {
|
|
const colorClass = Array.from(card.classList).find((cls) => cls.startsWith("note-color-"));
|
|
const color = colorClass ? colorClass.replace("note-color-", "") : card.dataset.color || "default";
|
|
const background = getElementVisualBackground(card);
|
|
const fontColor = getElementVisualFontColor(card);
|
|
applyNoteVisualState(card, { color, filter: normalizedFilterKey, background, fontColor });
|
|
}
|
|
|
|
const bookmarkCard = document.querySelector(`.link-outer[data-id="${noteId}"]`);
|
|
if (bookmarkCard) {
|
|
const colorClass = Array.from(bookmarkCard.classList).find((cls) => cls.startsWith("note-color-"));
|
|
const color = colorClass ? colorClass.replace("note-color-", "") : bookmarkCard.dataset.color || "default";
|
|
const background = getElementVisualBackground(bookmarkCard);
|
|
const fontColor = getElementVisualFontColor(bookmarkCard);
|
|
applyNoteVisualState(bookmarkCard, { color, filter: normalizedFilterKey, background, fontColor });
|
|
const palettePopup = bookmarkCard.querySelector(".bookmark-palette .palette-popup");
|
|
if (palettePopup) {
|
|
palettePopup.innerHTML = generateBookmarkPaletteButtons(noteId, editUrl, color, normalizedFilterKey);
|
|
}
|
|
}
|
|
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (modal && entity && String(entity.id) === String(noteId)) {
|
|
entity.filter = normalizedFilterKey;
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
if (modalCard) {
|
|
applyNoteVisualState(modalCard, entity);
|
|
}
|
|
const modalColorPopup = modal.querySelector(".note-modal-palette");
|
|
if (modalColorPopup) {
|
|
modalColorPopup.innerHTML = generateModalPaletteButtons(entity);
|
|
}
|
|
}
|
|
|
|
fetch(editUrl)
|
|
.then((response) => response.text())
|
|
.then((html) => {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const form = doc.querySelector('form[name="linkform"]');
|
|
|
|
if (!form) throw new Error("Could not find edit form");
|
|
|
|
const formData = new URLSearchParams();
|
|
const inputs = form.querySelectorAll("input, textarea");
|
|
|
|
inputs.forEach((input) => {
|
|
if (input.type === "checkbox") {
|
|
if (input.checked) formData.append(input.name, input.value || "on");
|
|
} else if (input.name) {
|
|
formData.append(input.name, getFormFieldValue(input));
|
|
}
|
|
});
|
|
|
|
let currentTags = formData.get("lf_tags") || "";
|
|
let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== "");
|
|
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith(NOTE_FILTER_TAG_PREFIX));
|
|
if (normalizedFilterKey && normalizedFilterKey !== "none") {
|
|
tagsArray.push(`${NOTE_FILTER_TAG_PREFIX}${normalizedFilterKey}`);
|
|
}
|
|
|
|
formData.set("lf_tags", tagsArray.join(" "));
|
|
formData.append("save_edit", "1");
|
|
|
|
return fetch(form.action, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: formData.toString(),
|
|
});
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error("Failed to save filter");
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error("Error saving note filter:", err);
|
|
alert(window.ShaarliProI18n.t("Failed to save filter.") + " " + window.ShaarliProI18n.t("Please refresh the page."));
|
|
});
|
|
};
|
|
|
|
window.setNoteBackground = function (noteId, backgroundKey, editUrl) {
|
|
const normalizedBackgroundKey = backgroundKey === "none" ? "none" : normalizeBackgroundKey(backgroundKey) || "none";
|
|
|
|
const card = document.querySelector(`.note-card[data-id="${noteId}"]`);
|
|
if (card) {
|
|
const colorClass = Array.from(card.classList).find((cls) => cls.startsWith("note-color-"));
|
|
const color = colorClass ? colorClass.replace("note-color-", "") : card.dataset.color || "default";
|
|
const filter = card.dataset.filter || "none";
|
|
const fontColor = getElementVisualFontColor(card);
|
|
applyNoteVisualState(card, { color, filter, background: normalizedBackgroundKey, fontColor });
|
|
}
|
|
|
|
const bookmarkCard = document.querySelector(`.link-outer[data-id="${noteId}"]`);
|
|
if (bookmarkCard) {
|
|
const colorClass = Array.from(bookmarkCard.classList).find((cls) => cls.startsWith("note-color-"));
|
|
const color = colorClass ? colorClass.replace("note-color-", "") : bookmarkCard.dataset.color || "default";
|
|
const filter = bookmarkCard.dataset.filter || "none";
|
|
const fontColor = getElementVisualFontColor(bookmarkCard);
|
|
applyNoteVisualState(bookmarkCard, { color, filter, background: normalizedBackgroundKey, fontColor });
|
|
}
|
|
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (modal && entity && String(entity.id) === String(noteId)) {
|
|
entity.background = normalizedBackgroundKey;
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
if (modalCard) {
|
|
applyNoteVisualState(modalCard, entity);
|
|
}
|
|
const modalColorPopup = modal.querySelector(".note-modal-palette");
|
|
if (modalColorPopup) {
|
|
modalColorPopup.innerHTML = generateModalPaletteButtons(entity);
|
|
}
|
|
}
|
|
|
|
fetch(editUrl)
|
|
.then((response) => response.text())
|
|
.then((html) => {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const form = doc.querySelector('form[name="linkform"]');
|
|
|
|
if (!form) throw new Error("Could not find edit form");
|
|
|
|
const formData = new URLSearchParams();
|
|
const inputs = form.querySelectorAll("input, textarea");
|
|
|
|
inputs.forEach((input) => {
|
|
if (input.type === "checkbox") {
|
|
if (input.checked) formData.append(input.name, input.value || "on");
|
|
} else if (input.name) {
|
|
formData.append(input.name, getFormFieldValue(input));
|
|
}
|
|
});
|
|
|
|
let currentTags = formData.get("lf_tags") || "";
|
|
let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== "");
|
|
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith(NOTE_BACKGROUND_TAG_PREFIX));
|
|
if (normalizedBackgroundKey && normalizedBackgroundKey !== "none") {
|
|
tagsArray.push(`${NOTE_BACKGROUND_TAG_PREFIX}${normalizedBackgroundKey}`);
|
|
}
|
|
|
|
formData.set("lf_tags", tagsArray.join(" "));
|
|
formData.append("save_edit", "1");
|
|
|
|
return fetch(form.action, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: formData.toString(),
|
|
});
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error("Failed to save background");
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error("Error saving note background:", err);
|
|
alert(window.ShaarliProI18n.t("Failed to save background.") + " " + window.ShaarliProI18n.t("Please refresh the page."));
|
|
});
|
|
};
|
|
|
|
window.setModalNoteBackground = function (backgroundKey) {
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (!modal || !entity) return;
|
|
|
|
const normalizedBackgroundKey = backgroundKey === "none" ? "none" : normalizeBackgroundKey(backgroundKey) || "none";
|
|
setNoteBackground(entity.id, normalizedBackgroundKey, entity.editUrl);
|
|
|
|
entity.background = normalizedBackgroundKey;
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
if (modalCard) {
|
|
applyNoteVisualState(modalCard, entity);
|
|
}
|
|
|
|
const modalColorPopup = modal.querySelector(".note-modal-palette");
|
|
if (modalColorPopup) {
|
|
modalColorPopup.innerHTML = generateModalPaletteButtons(entity);
|
|
modalColorPopup.classList.add("open");
|
|
positionPalettePopup(modalColorPopup);
|
|
}
|
|
};
|
|
|
|
function addTagToNote(editUrl, tag) {
|
|
return fetch(editUrl)
|
|
.then((response) => response.text())
|
|
.then((html) => {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const form = doc.querySelector('form[name="linkform"]');
|
|
if (!form) throw new Error("Could not find edit form");
|
|
|
|
const formData = new URLSearchParams();
|
|
const inputs = form.querySelectorAll("input, textarea");
|
|
|
|
inputs.forEach((input) => {
|
|
if (input.type === "checkbox") {
|
|
if (input.checked) formData.append(input.name, input.value || "on");
|
|
} else if (input.name) {
|
|
formData.append(input.name, getFormFieldValue(input));
|
|
}
|
|
});
|
|
|
|
let currentTags = formData.get("lf_tags") || "";
|
|
let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== "");
|
|
|
|
if (!tagsArray.includes(tag)) tagsArray.push(tag);
|
|
|
|
formData.set("lf_tags", tagsArray.join(" "));
|
|
formData.append("save_edit", "1");
|
|
|
|
return fetch(form.action, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: formData.toString(),
|
|
});
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) throw new Error("Failed to update tags");
|
|
return response;
|
|
});
|
|
}
|
|
|
|
/* ==========================================================
|
|
PINNED ITEMS LOGIC (Tag: shaarli-pin)
|
|
========================================================== */
|
|
function initPinnedItems() {
|
|
const container = document.querySelector(".links-list, .notes-masonry, .notes-list-view");
|
|
if (!container) return; // Exit if no container found (e.g. empty page or other view)
|
|
|
|
const items = Array.from(container.children);
|
|
const pinnedItems = [];
|
|
|
|
items.forEach((item) => {
|
|
// Support both Standard Link items and Note items
|
|
if (item.classList.contains("link-outer") || item.classList.contains("note-card")) {
|
|
let isPinned = false;
|
|
|
|
// 1. Check for Tag 'shaarli-pin'
|
|
// In note-card, we might need to check dataset or re-parse tags if not visible?
|
|
// But usually renderNotes puts tags in DOM or we rely on data attribute if we saved it?
|
|
// Let's rely on finding the tag text in the DOM for consistency with Standard View.
|
|
// For Note View, tags are in .note-tags > .note-tag
|
|
// For Standard View, tags are in .link-tag-list > a
|
|
|
|
const itemHtml = item.innerHTML; // Simple search in content (quick & dirty but effective for tag presence)
|
|
// Better: Select text content of tags specifically to avoid false positives in description
|
|
const tagElements = item.querySelectorAll(".link-tag-list a, .note-tag");
|
|
for (let t of tagElements) {
|
|
if (t.textContent.trim() === "shaarli-pin") {
|
|
isPinned = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 2. Enforce Visual State based on Tag Presence
|
|
const pinBtnIcon = item.querySelector(".mdi-pin-outline, .mdi-pin");
|
|
const titleArea = item.querySelector(".link-title, .note-title");
|
|
const titleIcon = titleArea ? titleArea.querySelector("i.mdi-pin") : null;
|
|
|
|
if (isPinned) {
|
|
// It IS Pinned: Ensure UI reflects this
|
|
pinnedItems.push(item);
|
|
item.classList.add("is-pinned-tag");
|
|
|
|
// Button -> Filled Pin
|
|
if (pinBtnIcon) {
|
|
pinBtnIcon.classList.remove("mdi-pin-outline");
|
|
pinBtnIcon.classList.add("mdi-pin");
|
|
if (pinBtnIcon.parentElement) pinBtnIcon.parentElement.classList.add("active");
|
|
}
|
|
|
|
// Title -> Add Icon if missing
|
|
if (titleArea && !titleIcon) {
|
|
const newIcon = document.createElement("i");
|
|
newIcon.className = "mdi mdi-pin";
|
|
newIcon.style.color = "var(--primary)";
|
|
newIcon.style.marginRight = "8px";
|
|
titleArea.prepend(newIcon);
|
|
}
|
|
} else {
|
|
// It is NOT Pinned: Ensure UI reflects this (Clean up native sticky or mismatches)
|
|
item.classList.remove("is-pinned-tag");
|
|
|
|
// Button -> Outline Pin
|
|
if (pinBtnIcon) {
|
|
pinBtnIcon.classList.remove("mdi-pin");
|
|
pinBtnIcon.classList.add("mdi-pin-outline");
|
|
if (pinBtnIcon.parentElement) pinBtnIcon.parentElement.classList.remove("active");
|
|
}
|
|
|
|
// Title -> Remove Icon if present
|
|
if (titleIcon) {
|
|
titleIcon.remove();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 3. Move Pinned Items to Top (Only for standard list, renderNotes already sorts itself)
|
|
if (container.classList.contains("links-list")) {
|
|
for (let i = pinnedItems.length - 1; i >= 0; i--) {
|
|
container.prepend(pinnedItems[i]);
|
|
}
|
|
}
|
|
|
|
if (typeof organizePinnedBookmarks === "function") {
|
|
window.requestAnimationFrame(organizePinnedBookmarks);
|
|
}
|
|
|
|
// 4. Click Listener for all Pin Buttons (Event Delegation)
|
|
// Avoid adding multiple listeners if init called multiple times?
|
|
// We'll rely on one global listener on document, but here we add it inside this function which is called once on load.
|
|
// To be safe, let's remove old one if we could, but anonymous function makes it hard.
|
|
// Better: Allow this to run, but ensure we don't duplicate.
|
|
// Since initPinnedItems is called on DOMContentLoaded, it runs once.
|
|
|
|
// Note: We already have the listener attached in previous version.
|
|
// We will just keep the listener logic here in the replacement.
|
|
document.addEventListener(
|
|
"click",
|
|
function (e) {
|
|
const btn = e.target.closest('a[href*="do=pin"], .note-hover-actions a[href*="pin"], .note-pin-corner[href*="pin"], .link-actions a[href*="pin"]');
|
|
if (btn) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const card = btn.closest(".link-outer, .note-card");
|
|
const id = card ? card.dataset.id : null;
|
|
|
|
// Re-derive edit URL if needed
|
|
let editUrl = btn.href.replace("do=pin", "do=editlink").replace("pin", "editlink");
|
|
if (card) {
|
|
const editBtn = card.querySelector('a[href*="edit_link"], a[href*="admin/shaare"]');
|
|
if (editBtn) editUrl = editBtn.href;
|
|
}
|
|
|
|
if (id && editUrl) {
|
|
togglePinTag(id, editUrl, btn);
|
|
}
|
|
}
|
|
},
|
|
{ once: false },
|
|
); // Listener is permanent
|
|
}
|
|
|
|
function togglePinTag(id, editUrl, btn) {
|
|
const icon = btn.querySelector("i");
|
|
let isPinning = false;
|
|
|
|
if (icon) {
|
|
if (icon.classList.contains("mdi-pin-outline")) {
|
|
icon.classList.remove("mdi-pin-outline");
|
|
icon.classList.add("mdi-pin");
|
|
btn.classList.add("active");
|
|
isPinning = true;
|
|
} else {
|
|
icon.classList.remove("mdi-pin");
|
|
icon.classList.add("mdi-pin-outline");
|
|
btn.classList.remove("active");
|
|
isPinning = false;
|
|
}
|
|
}
|
|
|
|
// Update Title Icon (The one "devant le titre")
|
|
const card = btn.closest(".link-outer, .note-card");
|
|
if (card) {
|
|
card.classList.toggle("is-pinned-tag", isPinning);
|
|
|
|
// Update Title Icon
|
|
const titleArea = card.querySelector(".link-title, .note-title");
|
|
if (titleArea) {
|
|
let titleIcon = titleArea.querySelector("i.mdi-pin");
|
|
if (isPinning) {
|
|
if (!titleIcon) {
|
|
const newIcon = document.createElement("i");
|
|
newIcon.className = "mdi mdi-pin";
|
|
newIcon.style.color = "var(--primary)";
|
|
newIcon.style.marginRight = "8px";
|
|
titleArea.prepend(newIcon);
|
|
}
|
|
} else {
|
|
if (titleIcon) titleIcon.remove();
|
|
}
|
|
}
|
|
|
|
// Update Tag List Visualization
|
|
// We need to handle both Note Cards (.note-tags) and Standard Links (.link-tag-list)
|
|
let tagContainer = card.querySelector(".note-tags");
|
|
if (!tagContainer) tagContainer = card.querySelector(".link-tag-list");
|
|
|
|
if (tagContainer) {
|
|
// Check if tag exists already
|
|
let existingTagElement = null;
|
|
|
|
// Search in children
|
|
// Notes: .note-tag
|
|
// Links: .label-tag > a OR just a
|
|
const allCandidates = tagContainer.querySelectorAll("*");
|
|
for (let el of allCandidates) {
|
|
if (el.textContent.trim() === "shaarli-pin") {
|
|
// We found the text.
|
|
// If note, it's the span.note-tag
|
|
if (el.classList.contains("note-tag")) {
|
|
existingTagElement = el;
|
|
break;
|
|
}
|
|
// If link, we want the anchor or its wrapper
|
|
if (el.tagName === "A") {
|
|
// Check if wrapped in .label-tag
|
|
if (el.parentElement.classList.contains("label-tag")) {
|
|
existingTagElement = el.parentElement;
|
|
} else {
|
|
existingTagElement = el;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isPinning) {
|
|
if (!existingTagElement) {
|
|
if (card.classList.contains("note-card")) {
|
|
// Add Note Tag
|
|
const span = document.createElement("span");
|
|
span.className = "note-tag";
|
|
span.textContent = "shaarli-pin";
|
|
tagContainer.appendChild(span);
|
|
} else {
|
|
// Add Link Tag (Standard View)
|
|
// Structure: <span class="link-tag"><a ...>shaarli-pin</a></span>
|
|
const wrapper = document.createElement("span");
|
|
wrapper.className = "link-tag";
|
|
|
|
const link = document.createElement("a");
|
|
link.href = "?searchtags=shaarli-pin";
|
|
link.textContent = "shaarli-pin";
|
|
|
|
wrapper.appendChild(link);
|
|
|
|
// Append space first for separation if there are other tags
|
|
if (tagContainer.children.length > 0) {
|
|
tagContainer.appendChild(document.createTextNode(" "));
|
|
}
|
|
tagContainer.appendChild(wrapper);
|
|
}
|
|
}
|
|
} else {
|
|
if (existingTagElement) {
|
|
// Remove the element
|
|
const prev = existingTagElement.previousSibling;
|
|
existingTagElement.remove();
|
|
// Clean up trailing space/text if it was the last one or between tags
|
|
if (prev && prev.nodeType === Node.TEXT_NODE && !prev.textContent.trim()) {
|
|
prev.remove();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fetch(editUrl)
|
|
.then((response) => response.text())
|
|
.then((html) => {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const form = doc.querySelector('form[name="linkform"]');
|
|
if (!form) throw new Error("Could not find edit form");
|
|
|
|
const formData = new URLSearchParams();
|
|
const inputs = form.querySelectorAll("input, textarea");
|
|
inputs.forEach((input) => {
|
|
if (input.type === "checkbox") {
|
|
if (input.checked) formData.append(input.name, input.value || "on");
|
|
} else if (input.name) {
|
|
formData.append(input.name, getFormFieldValue(input));
|
|
}
|
|
});
|
|
|
|
let currentTags = formData.get("lf_tags") || "";
|
|
let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== "");
|
|
const pinTag = "shaarli-pin";
|
|
|
|
if (isPinning) {
|
|
if (!tagsArray.includes(pinTag)) tagsArray.push(pinTag);
|
|
} else {
|
|
tagsArray = tagsArray.filter((t) => t !== pinTag);
|
|
}
|
|
|
|
formData.set("lf_tags", tagsArray.join(" "));
|
|
formData.append("save_edit", "1");
|
|
|
|
return fetch(form.action, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: formData.toString(),
|
|
});
|
|
})
|
|
.then((res) => {
|
|
if (res.ok) {
|
|
if (typeof organizePinnedBookmarks === "function") {
|
|
window.requestAnimationFrame(organizePinnedBookmarks);
|
|
}
|
|
}
|
|
})
|
|
.catch((err) => console.error(err));
|
|
}
|
|
|
|
/* ==========================================================
|
|
COLOR PICKER PANEL
|
|
========================================================== */
|
|
let colorPickerPanelInitialized = false;
|
|
|
|
function ensureColorPickerPanel() {
|
|
if (colorPickerPanelInitialized) return;
|
|
if (document.getElementById("shaarli-color-picker")) {
|
|
colorPickerPanelInitialized = true;
|
|
return;
|
|
}
|
|
|
|
const panel = document.createElement("div");
|
|
panel.id = "shaarli-color-picker";
|
|
panel.className = "color-picker-panel";
|
|
panel.setAttribute("role", "dialog");
|
|
panel.setAttribute("aria-modal", "false");
|
|
panel.setAttribute("aria-hidden", "true");
|
|
panel.style.display = "none";
|
|
document.body.appendChild(panel);
|
|
|
|
document.addEventListener("click", (e) => {
|
|
const p = document.getElementById("shaarli-color-picker");
|
|
if (!p || !p.classList.contains("open")) return;
|
|
if (e.target.closest("#shaarli-color-picker")) return;
|
|
if (e.target.closest("#shaarli-bg-studio")) return;
|
|
closeColorPickerPanel();
|
|
});
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key !== "Escape") return;
|
|
const p = document.getElementById("shaarli-color-picker");
|
|
if (!p || !p.classList.contains("open")) return;
|
|
closeColorPickerPanel();
|
|
});
|
|
|
|
colorPickerPanelInitialized = true;
|
|
}
|
|
|
|
function closeColorPickerPanel() {
|
|
const panel = document.getElementById("shaarli-color-picker");
|
|
if (!panel) return;
|
|
|
|
const type = panel.dataset.type || "background";
|
|
const skipRestore = panel.dataset.skipRestore === "1";
|
|
const mode = panel.dataset.mode || "entity";
|
|
const entityId = panel.dataset.entityId || "";
|
|
|
|
if (!skipRestore && mode === "draft") {
|
|
panel.dataset.skipRestore = "0";
|
|
panel.classList.remove("open");
|
|
panel.style.display = "none";
|
|
panel.setAttribute("aria-hidden", "true");
|
|
return;
|
|
}
|
|
if (!skipRestore && type === "font") {
|
|
const prevFontColorKey = panel.dataset.prevFontColorKey || "auto";
|
|
if (mode === "modal") {
|
|
setModalNoteFontColorVisual(prevFontColorKey);
|
|
} else {
|
|
setNoteFontColorVisual(entityId, prevFontColorKey);
|
|
}
|
|
}
|
|
|
|
if (!skipRestore && type === "background") {
|
|
const prevColorKey = panel.dataset.prevColorKey || "default";
|
|
if (mode === "modal") {
|
|
setModalNoteColorVisual(prevColorKey);
|
|
} else {
|
|
setNoteColorVisual(entityId, prevColorKey);
|
|
}
|
|
}
|
|
|
|
panel.dataset.skipRestore = "0";
|
|
panel.classList.remove("open");
|
|
panel.style.display = "none";
|
|
panel.setAttribute("aria-hidden", "true");
|
|
}
|
|
|
|
function openColorPickerPanel({ mode, entityId, editUrl, type }) {
|
|
ensureColorPickerPanel();
|
|
const panel = document.getElementById("shaarli-color-picker");
|
|
if (!panel) return;
|
|
|
|
panel.dataset.mode = mode || "entity";
|
|
panel.dataset.entityId = entityId || "";
|
|
panel.dataset.editUrl = editUrl || "";
|
|
panel.dataset.type = type || "background";
|
|
|
|
const isDark = getCurrentThemeMode() === "dark";
|
|
const defaultColor = isDark ? "#20293A" : "#ffffff";
|
|
|
|
if ((type || "background") === "font") {
|
|
let prevFontColorKey = "auto";
|
|
if ((mode || "entity") === "draft") {
|
|
const bgPanel = document.getElementById("shaarli-bg-studio");
|
|
prevFontColorKey = bgPanel ? bgPanel.dataset.fontColor || "auto" : "auto";
|
|
} else if ((mode || "entity") === "modal") {
|
|
const modal = getOpenModalOverlay();
|
|
const modalCard = modal ? modal.querySelector(".note-modal") : null;
|
|
prevFontColorKey = modalCard && modalCard.dataset.fontColor ? modalCard.dataset.fontColor : "auto";
|
|
} else {
|
|
const noteCard = document.querySelector(`.note-card[data-id="${entityId}"]`);
|
|
const bookmarkCard = document.querySelector(`.link-outer[data-id="${entityId}"]`);
|
|
prevFontColorKey = noteCard && noteCard.dataset.fontColor ? noteCard.dataset.fontColor : bookmarkCard && bookmarkCard.dataset.fontColor ? bookmarkCard.dataset.fontColor : "auto";
|
|
}
|
|
panel.dataset.prevFontColorKey = prevFontColorKey;
|
|
} else {
|
|
panel.dataset.prevFontColorKey = "";
|
|
}
|
|
|
|
if ((type || "background") === "background") {
|
|
let prevColorKey = "default";
|
|
if ((mode || "entity") === "draft") {
|
|
const bgPanel = document.getElementById("shaarli-bg-studio");
|
|
if (bgPanel) {
|
|
prevColorKey = bgPanel.dataset.color || "default";
|
|
}
|
|
} else if ((mode || "entity") === "modal") {
|
|
const modal = getOpenModalOverlay();
|
|
const modalCard = modal ? modal.querySelector(".note-modal") : null;
|
|
if (modalCard) {
|
|
const isCustom = modalCard.dataset.color === "custom";
|
|
const cc = modalCard.dataset.customColor || "";
|
|
prevColorKey = isCustom && cc ? `custom:${cc}` : getElementVisualColor(modalCard);
|
|
}
|
|
} else {
|
|
const noteCard = document.querySelector(`.note-card[data-id="${entityId}"]`);
|
|
const bookmarkCard = document.querySelector(`.link-outer[data-id="${entityId}"]`);
|
|
const el = noteCard || bookmarkCard;
|
|
if (el) {
|
|
const isCustom = el.dataset.color === "custom";
|
|
const cc = el.dataset.customColor || "";
|
|
prevColorKey = isCustom && cc ? `custom:${cc}` : getElementVisualColor(el);
|
|
}
|
|
}
|
|
panel.dataset.prevColorKey = prevColorKey;
|
|
} else {
|
|
panel.dataset.prevColorKey = "";
|
|
}
|
|
|
|
panel.innerHTML = `
|
|
<div class="color-picker-header">
|
|
<div class="color-picker-title">${type === "font" ? "Couleur du texte" : window.ShaarliProI18n.t("Custom color")}</div>
|
|
<button class="color-picker-close" type="button" data-color-picker-action="close" title=window.ShaarliProI18n.t("Close") aria-label=window.ShaarliProI18n.t("Close")><i class="mdi mdi-close" aria-hidden="true"></i></button>
|
|
</div>
|
|
<div class="color-picker-body">
|
|
<div class="color-picker-gradient">
|
|
<div class="color-picker-gradient-inner" id="color-gradient">
|
|
<div class="color-picker-cursor" id="color-cursor"></div>
|
|
</div>
|
|
</div>
|
|
<div class="color-picker-hue">
|
|
<input type="range" min="0" max="360" value="210" class="color-picker-hue-slider" id="hue-slider">
|
|
</div>
|
|
<div class="color-picker-preview-row">
|
|
<div class="color-picker-preview" id="color-preview"></div>
|
|
<div class="color-picker-inputs">
|
|
<div class="color-picker-input-group">
|
|
<label>Hex</label>
|
|
<input type="text" id="hex-input" value="${defaultColor}" maxlength="7">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="color-picker-footer">
|
|
<button type="button" class="color-picker-btn color-picker-btn-secondary" data-color-picker-action="close">Annuler</button>
|
|
<button type="button" class="color-picker-btn color-picker-btn-primary" data-color-picker-action="apply">Appliquer</button>
|
|
</div>
|
|
`;
|
|
|
|
// Initialize color picker state
|
|
let currentHue = 210;
|
|
let currentSaturation = 50;
|
|
let currentValue = 50;
|
|
let currentHex = defaultColor;
|
|
|
|
const gradient = panel.querySelector("#color-gradient");
|
|
const cursor = panel.querySelector("#color-cursor");
|
|
const hueSlider = panel.querySelector("#hue-slider");
|
|
const hexInput = panel.querySelector("#hex-input");
|
|
const preview = panel.querySelector("#color-preview");
|
|
|
|
function hsvToHex(h, s, v) {
|
|
const c = v * s;
|
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
const m = v - c;
|
|
let r, g, b;
|
|
|
|
if (h < 60) {
|
|
r = c;
|
|
g = x;
|
|
b = 0;
|
|
} else if (h < 120) {
|
|
r = x;
|
|
g = c;
|
|
b = 0;
|
|
} else if (h < 180) {
|
|
r = 0;
|
|
g = c;
|
|
b = x;
|
|
} else if (h < 240) {
|
|
r = 0;
|
|
g = x;
|
|
b = c;
|
|
} else if (h < 300) {
|
|
r = x;
|
|
g = 0;
|
|
b = c;
|
|
} else {
|
|
r = c;
|
|
g = 0;
|
|
b = x;
|
|
}
|
|
|
|
const toHex = (n) =>
|
|
Math.round((n + m) * 255)
|
|
.toString(16)
|
|
.padStart(2, "0");
|
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
|
}
|
|
|
|
function hexToHsv(hex) {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
if (!result) return { h: 0, s: 0, v: 0 };
|
|
|
|
const r = parseInt(result[1], 16) / 255;
|
|
const g = parseInt(result[2], 16) / 255;
|
|
const b = parseInt(result[3], 16) / 255;
|
|
|
|
const max = Math.max(r, g, b);
|
|
const min = Math.min(r, g, b);
|
|
const d = max - min;
|
|
|
|
let h = 0;
|
|
if (d !== 0) {
|
|
if (max === r) h = (((g - b) / d + 6) % 6) * 60;
|
|
else if (max === g) h = ((b - r) / d + 2) * 60;
|
|
else h = ((r - g) / d + 4) * 60;
|
|
}
|
|
|
|
const s = max === 0 ? 0 : d / max;
|
|
const v = max;
|
|
|
|
return { h, s, v };
|
|
}
|
|
|
|
function updateColor() {
|
|
currentHex = hsvToHex(currentHue, currentSaturation / 100, currentValue / 100);
|
|
gradient.style.background = `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${currentHue}, 100%, 50%))`;
|
|
cursor.style.left = `${currentSaturation}%`;
|
|
cursor.style.top = `${100 - currentValue}%`;
|
|
preview.style.backgroundColor = currentHex;
|
|
hexInput.value = currentHex;
|
|
|
|
if ((panel.dataset.type || "background") === "font") {
|
|
const previewKey = `custom:${currentHex}`;
|
|
const pMode = panel.dataset.mode || "entity";
|
|
const pEntityId = panel.dataset.entityId || "";
|
|
if (pMode === "draft") {
|
|
const bgPanel = document.getElementById("shaarli-bg-studio");
|
|
if (bgPanel && typeof bgPanel.__draftApply === "function") {
|
|
bgPanel.__draftApply({ fontColor: previewKey });
|
|
}
|
|
} else if (pMode === "modal") {
|
|
setModalNoteFontColorVisual(previewKey);
|
|
} else {
|
|
setNoteFontColorVisual(pEntityId, previewKey);
|
|
}
|
|
}
|
|
|
|
if ((panel.dataset.type || "background") === "background") {
|
|
const previewKey = `custom:${currentHex}`;
|
|
const pMode = panel.dataset.mode || "entity";
|
|
const pEntityId = panel.dataset.entityId || "";
|
|
if (pMode === "draft") {
|
|
const bgPanel = document.getElementById("shaarli-bg-studio");
|
|
if (bgPanel && typeof bgPanel.__draftApply === "function") {
|
|
bgPanel.__draftApply({ color: previewKey });
|
|
}
|
|
} else if (pMode === "modal") {
|
|
setModalNoteColorVisual(previewKey);
|
|
} else {
|
|
setNoteColorVisual(pEntityId, previewKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
function setFromHex(hex) {
|
|
const hsv = hexToHsv(hex);
|
|
currentHue = hsv.h;
|
|
currentSaturation = hsv.s * 100;
|
|
currentValue = hsv.v * 100;
|
|
hueSlider.value = currentHue;
|
|
updateColor();
|
|
}
|
|
|
|
// Event listeners
|
|
let isDragging = false;
|
|
|
|
gradient.addEventListener("mousedown", (e) => {
|
|
isDragging = true;
|
|
const rect = gradient.getBoundingClientRect();
|
|
currentSaturation = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
|
|
currentValue = Math.max(0, Math.min(100, 100 - ((e.clientY - rect.top) / rect.height) * 100));
|
|
updateColor();
|
|
});
|
|
|
|
document.addEventListener("mousemove", (e) => {
|
|
if (!isDragging) return;
|
|
const rect = gradient.getBoundingClientRect();
|
|
currentSaturation = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
|
|
currentValue = Math.max(0, Math.min(100, 100 - ((e.clientY - rect.top) / rect.height) * 100));
|
|
updateColor();
|
|
});
|
|
|
|
document.addEventListener("mouseup", () => {
|
|
isDragging = false;
|
|
});
|
|
|
|
hueSlider.addEventListener("input", (e) => {
|
|
currentHue = parseInt(e.target.value);
|
|
updateColor();
|
|
});
|
|
|
|
hexInput.addEventListener("input", (e) => {
|
|
const hex = e.target.value;
|
|
if (/^#[0-9A-F]{6}$/i.test(hex)) {
|
|
setFromHex(hex);
|
|
}
|
|
});
|
|
|
|
// Initialize
|
|
if ((panel.dataset.type || "background") === "font") {
|
|
const prevFontColorKey = panel.dataset.prevFontColorKey || "auto";
|
|
let initialHex = defaultColor;
|
|
if (typeof prevFontColorKey === "string" && prevFontColorKey.startsWith("custom:")) {
|
|
initialHex = prevFontColorKey.substring(7);
|
|
} else if (prevFontColorKey !== "auto") {
|
|
const opt = NOTE_FONT_COLOR_OPTIONS.find((o) => o.key === prevFontColorKey);
|
|
if (opt && opt.value && opt.value !== "auto") initialHex = opt.value;
|
|
}
|
|
setFromHex(initialHex);
|
|
} else if ((panel.dataset.type || "background") === "background") {
|
|
const prevColorKey = panel.dataset.prevColorKey || "default";
|
|
let initialHex = defaultColor;
|
|
if (typeof prevColorKey === "string" && prevColorKey.startsWith("custom:")) {
|
|
initialHex = prevColorKey.substring(7);
|
|
}
|
|
setFromHex(initialHex);
|
|
} else {
|
|
setFromHex(defaultColor);
|
|
}
|
|
|
|
panel.style.display = "block";
|
|
panel.style.visibility = "hidden";
|
|
// Position the color picker panel
|
|
positionColorPickerPanel(panel, mode);
|
|
panel.style.visibility = "visible";
|
|
panel.classList.add("open");
|
|
panel.setAttribute("aria-hidden", "false");
|
|
}
|
|
|
|
function positionColorPickerPanel(panel, mode) {
|
|
if (!panel) return;
|
|
|
|
const bgPanel = document.getElementById("shaarli-bg-studio");
|
|
const viewportPadding = 12;
|
|
|
|
// Default position if bgPanel is not available
|
|
let anchorRect = null;
|
|
|
|
if (bgPanel && bgPanel.classList.contains("open")) {
|
|
const bgRect = bgPanel.getBoundingClientRect();
|
|
const panelRect = panel.getBoundingClientRect();
|
|
const panelWidth = Math.max(1, panelRect.width);
|
|
const panelHeight = Math.max(1, panelRect.height);
|
|
// Position to the right of the bg-studio panel
|
|
let left = bgRect.right + 10;
|
|
let top = bgRect.top;
|
|
|
|
// Check if it fits on the right
|
|
if (left + panelWidth > window.innerWidth - viewportPadding) {
|
|
// Position to the left instead
|
|
left = bgRect.left - panelWidth - 10;
|
|
}
|
|
|
|
// Ensure it stays within viewport
|
|
if (left < viewportPadding) left = viewportPadding;
|
|
if (left + panelWidth > window.innerWidth - viewportPadding) {
|
|
left = window.innerWidth - viewportPadding - panelWidth;
|
|
}
|
|
if (top + panelHeight > window.innerHeight - viewportPadding) {
|
|
top = window.innerHeight - viewportPadding - panelHeight;
|
|
}
|
|
if (top < viewportPadding) top = viewportPadding;
|
|
|
|
panel.style.left = `${Math.round(left)}px`;
|
|
panel.style.top = `${Math.round(top)}px`;
|
|
panel.style.right = "";
|
|
panel.style.bottom = "";
|
|
} else {
|
|
// Center in viewport if bg panel not available
|
|
const panelRect = panel.getBoundingClientRect();
|
|
const clampedWidth = Math.max(1, panelRect.width);
|
|
const clampedHeight = Math.max(1, panelRect.height);
|
|
const left = Math.max(viewportPadding, Math.min((window.innerWidth - clampedWidth) / 2, window.innerWidth - viewportPadding - clampedWidth));
|
|
const top = Math.max(viewportPadding, Math.min((window.innerHeight - clampedHeight) / 2, window.innerHeight - viewportPadding - clampedHeight));
|
|
panel.style.left = `${Math.round(left)}px`;
|
|
panel.style.top = `${Math.round(top)}px`;
|
|
}
|
|
}
|
|
|
|
// Handle color picker actions
|
|
document.addEventListener("click", (e) => {
|
|
const actionBtn = e.target.closest("[data-color-picker-action]");
|
|
if (!actionBtn) return;
|
|
|
|
const panel = document.getElementById("shaarli-color-picker");
|
|
if (!panel) return;
|
|
|
|
const action = actionBtn.dataset.colorPickerAction;
|
|
|
|
if (action === "close") {
|
|
closeColorPickerPanel();
|
|
return;
|
|
}
|
|
|
|
if (action === "apply") {
|
|
const mode = panel.dataset.mode || "entity";
|
|
const entityId = panel.dataset.entityId || "";
|
|
const editUrl = panel.dataset.editUrl || "";
|
|
const type = panel.dataset.type || "background";
|
|
const hexInput = panel.querySelector("#hex-input");
|
|
const color = hexInput ? hexInput.value : "#ffffff";
|
|
|
|
if (mode === "draft") {
|
|
const bgPanel = document.getElementById("shaarli-bg-studio");
|
|
if (bgPanel && typeof bgPanel.__draftApply === "function") {
|
|
if (type === "font") {
|
|
bgPanel.dataset.fontColor = `custom:${color}`;
|
|
bgPanel.__draftApply({ fontColor: `custom:${color}` });
|
|
} else {
|
|
bgPanel.dataset.color = `custom:${color}`;
|
|
bgPanel.__draftApply({ color: `custom:${color}` });
|
|
}
|
|
renderBackgroundStudioPanel(bgPanel);
|
|
}
|
|
|
|
panel.dataset.skipRestore = "1";
|
|
closeColorPickerPanel();
|
|
return;
|
|
}
|
|
|
|
if (type === "font") {
|
|
if (mode === "modal") {
|
|
setModalCustomFontColor(color);
|
|
} else {
|
|
setNoteFontColor(entityId, `custom:${color}`, editUrl);
|
|
}
|
|
} else {
|
|
if (mode === "modal") {
|
|
setModalCustomNoteColor(color);
|
|
} else {
|
|
setNoteColor(entityId, `custom:${color}`, editUrl);
|
|
}
|
|
}
|
|
|
|
panel.dataset.skipRestore = "1";
|
|
closeColorPickerPanel();
|
|
}
|
|
});
|
|
|
|
function setNoteFontColorVisual(noteId, fontColorKey) {
|
|
const card = document.querySelector(`.note-card[data-id="${noteId}"]`);
|
|
const bookmarkCard = document.querySelector(`.link-outer[data-id="${noteId}"]`);
|
|
|
|
let colorValue = "auto";
|
|
if (typeof fontColorKey === "string" && fontColorKey.startsWith("custom:")) {
|
|
colorValue = fontColorKey.substring(7);
|
|
} else if (fontColorKey !== "auto") {
|
|
const option = NOTE_FONT_COLOR_OPTIONS.find((opt) => opt.key === fontColorKey);
|
|
colorValue = option ? option.value : "auto";
|
|
}
|
|
|
|
const applyTo = (element) => {
|
|
if (!element) return;
|
|
if (colorValue === "auto") element.style.removeProperty("--note-card-fg");
|
|
else element.style.setProperty("--note-card-fg", colorValue);
|
|
element.dataset.fontColor = fontColorKey;
|
|
};
|
|
|
|
applyTo(card);
|
|
applyTo(bookmarkCard);
|
|
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (modal && entity && String(entity.id) === String(noteId)) {
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
applyTo(modalCard);
|
|
}
|
|
}
|
|
|
|
function setModalNoteFontColorVisual(fontColorKey) {
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (!modal || !entity) return;
|
|
setNoteFontColorVisual(entity.id, fontColorKey);
|
|
}
|
|
|
|
function setNoteColorVisual(noteId, colorKey) {
|
|
const card = document.querySelector(`.note-card[data-id="${noteId}"]`);
|
|
const bookmarkCard = document.querySelector(`.link-outer[data-id="${noteId}"]`);
|
|
|
|
const applyTo = (element) => {
|
|
if (!element) return;
|
|
const currentState = {
|
|
color: getElementVisualColor(element),
|
|
filter: getElementVisualFilter(element),
|
|
background: getElementVisualBackground(element),
|
|
fontColor: getElementVisualFontColor(element),
|
|
};
|
|
|
|
if (typeof colorKey === "string" && colorKey.startsWith("custom:")) {
|
|
const hex = colorKey.substring(7);
|
|
Array.from(element.classList).forEach((cls) => {
|
|
if (cls.startsWith("note-color-")) element.classList.remove(cls);
|
|
});
|
|
element.classList.add("note-color-custom");
|
|
element.style.backgroundColor = hex;
|
|
element.style.removeProperty("border-color");
|
|
element.dataset.color = "custom";
|
|
element.dataset.customColor = hex;
|
|
const fg = getReadableForegroundForBackground(hex);
|
|
if (fg) element.style.setProperty("--note-card-fg", fg);
|
|
return;
|
|
}
|
|
|
|
element.dataset.customColor = "";
|
|
applyNoteVisualState(element, { ...currentState, color: colorKey });
|
|
};
|
|
|
|
applyTo(card);
|
|
applyTo(bookmarkCard);
|
|
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (modal && entity && String(entity.id) === String(noteId)) {
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
applyTo(modalCard);
|
|
}
|
|
}
|
|
|
|
function setModalNoteColorVisual(colorKey) {
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (!modal || !entity) return;
|
|
setNoteColorVisual(entity.id, colorKey);
|
|
}
|
|
/* ==========================================================
|
|
FONT COLOR FUNCTIONS
|
|
========================================================== */
|
|
function setNoteFontColor(noteId, fontColorKey, editUrl) {
|
|
// 1. Visual Update (Immediate feedback)
|
|
setNoteFontColorVisual(noteId, fontColorKey);
|
|
|
|
// 2. Persistence via AJAX Form Submission
|
|
fetch(editUrl)
|
|
.then((response) => response.text())
|
|
.then((html) => {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const form = doc.querySelector('form[name="linkform"]');
|
|
|
|
if (!form) throw new Error("Could not find edit form");
|
|
|
|
const formData = new URLSearchParams();
|
|
const inputs = form.querySelectorAll("input, textarea");
|
|
|
|
inputs.forEach((input) => {
|
|
if (input.type === "checkbox") {
|
|
if (input.checked) formData.append(input.name, input.value || "on");
|
|
} else if (input.name) {
|
|
formData.append(input.name, getFormFieldValue(input));
|
|
}
|
|
});
|
|
|
|
let currentTags = formData.get("lf_tags") || "";
|
|
let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== "");
|
|
|
|
// Remove existing font color tags
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith(NOTE_FONT_COLOR_TAG_PREFIX));
|
|
|
|
// Add new font color tag (unless auto)
|
|
if (fontColorKey !== "auto") {
|
|
if (fontColorKey.startsWith("custom:")) {
|
|
tagsArray.push(`${NOTE_FONT_COLOR_TAG_PREFIX}${fontColorKey.substring(7).replace("#", "")}`);
|
|
} else {
|
|
const option = NOTE_FONT_COLOR_OPTIONS.find((opt) => opt.key === fontColorKey);
|
|
if (option && option.value !== "auto") {
|
|
tagsArray.push(`${NOTE_FONT_COLOR_TAG_PREFIX}${option.value.substring(1)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
formData.set("lf_tags", tagsArray.join(" "));
|
|
formData.append("save_edit", "1");
|
|
|
|
return fetch(form.action, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: formData.toString(),
|
|
});
|
|
})
|
|
.then((response) => {
|
|
if (response.ok) {
|
|
} else {
|
|
throw new Error("Failed to save font color");
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error("Error saving note font color:", err);
|
|
});
|
|
}
|
|
|
|
function setModalNoteFontColor(fontColorKey) {
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (!modal || !entity) return;
|
|
|
|
setNoteFontColor(entity.id, fontColorKey, entity.editUrl);
|
|
|
|
entity.fontColor = fontColorKey;
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
if (modalCard) {
|
|
let colorValue = "auto";
|
|
if (String(fontColorKey || "").startsWith("custom:")) {
|
|
colorValue = String(fontColorKey).substring(7);
|
|
} else if (fontColorKey !== "auto") {
|
|
const option = NOTE_FONT_COLOR_OPTIONS.find((opt) => opt.key === fontColorKey);
|
|
colorValue = option ? option.value : "auto";
|
|
}
|
|
|
|
if (colorValue === "auto") {
|
|
modalCard.style.removeProperty("--note-card-fg");
|
|
} else {
|
|
modalCard.style.setProperty("--note-card-fg", colorValue);
|
|
}
|
|
modalCard.dataset.fontColor = fontColorKey;
|
|
}
|
|
}
|
|
|
|
function setModalCustomFontColor(color) {
|
|
setModalNoteFontColor(`custom:${color}`);
|
|
}
|
|
|
|
/* ==========================================================
|
|
CUSTOM NOTE COLOR FUNCTIONS
|
|
========================================================== */
|
|
function setModalCustomNoteColor(color) {
|
|
const modal = getOpenModalOverlay();
|
|
const entity = getModalCurrentEntity(modal);
|
|
if (!modal || !entity || !entity.editUrl) return;
|
|
|
|
// Apply custom color visually
|
|
const modalCard = modal.querySelector(".note-modal");
|
|
if (modalCard) {
|
|
modalCard.style.backgroundColor = color;
|
|
modalCard.style.removeProperty("border-color");
|
|
modalCard.dataset.color = "custom";
|
|
modalCard.dataset.customColor = color;
|
|
}
|
|
|
|
// Save via AJAX
|
|
fetch(entity.editUrl)
|
|
.then((response) => response.text())
|
|
.then((html) => {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const form = doc.querySelector('form[name="linkform"]');
|
|
|
|
if (!form) throw new Error("Could not find edit form");
|
|
|
|
const formData = new URLSearchParams();
|
|
const inputs = form.querySelectorAll("input, textarea");
|
|
|
|
inputs.forEach((input) => {
|
|
if (input.type === "checkbox") {
|
|
if (input.checked) formData.append(input.name, input.value || "on");
|
|
} else if (input.name) {
|
|
formData.append(input.name, getFormFieldValue(input));
|
|
}
|
|
});
|
|
|
|
let currentTags = formData.get("lf_tags") || "";
|
|
let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== "");
|
|
|
|
// Remove existing color tags (only one note-color-* allowed)
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith(NOTE_COLOR_TAG_PREFIX));
|
|
|
|
// Backward compat cleanup: remove legacy note-custom-<hex>
|
|
tagsArray = tagsArray.filter((t) => !t.startsWith("note-custom-"));
|
|
|
|
// Backward compat cleanup: remove legacy note-<color>
|
|
tagsArray = tagsArray.filter((t) => {
|
|
if (!t.startsWith("note-")) return true;
|
|
const colorKey = t.substring(5);
|
|
return !NOTE_COLOR_OPTIONS.some((opt) => opt.key === colorKey);
|
|
});
|
|
|
|
// Add custom color tag
|
|
const cleanColor = color.replace("#", "");
|
|
tagsArray.push(`${NOTE_COLOR_TAG_PREFIX}${cleanColor}`);
|
|
|
|
formData.set("lf_tags", tagsArray.join(" "));
|
|
formData.append("save_edit", "1");
|
|
|
|
return fetch(form.action, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: formData.toString(),
|
|
});
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) throw new Error("Failed to save custom color");
|
|
})
|
|
.catch((err) => {
|
|
console.error("Error saving custom color:", err);
|
|
});
|
|
}
|