Shaarli_bm_theme/shaarli-pro/js/custom_views.js

6104 lines
222 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');
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');
const hasArchiveActiveMenu = !!document.querySelector('.sidebar-link[aria-label="Archive"].active, .header-nav-link[aria-label="Archive"].active, .sidebar-link[href*="searchtags=shaarli-archive"].active');
const domChipTags = Array.from(document.querySelectorAll('.search-tag-chip')).map(el => (el.textContent || "").trim().toLowerCase());
const isNoteView = activeTags.includes("note") || activeTags.includes("shaarli-note") || hasNoteActiveMenu || domChipTags.includes("note") || domChipTags.includes("shaarli-note");
const isTodoView = activeTags.includes("shaarli-todo") || hasTodoActiveMenu || domChipTags.includes("shaarli-todo");
const isArchiveView = activeTags.includes("shaarli-archive") || hasArchiveActiveMenu || domChipTags.includes("shaarli-archive");
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const escapeAttr = (value) =>
String(value)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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(/&lt;u&gt;([\s\S]*?)&lt;\/u&gt;/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 = `![${alt}](https://)`;
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 {
console.log("[custom_views] Initializing Todo view");
initTodoView(linkList, container);
} catch (err) {
console.error("[custom_views] Erreur lors de l'initialisation de la vue Todo:", err);
restoreDefaultListView();
}
} else if (isNoteView) {
try {
console.log("[custom_views] Initializing Note view");
// 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 {
console.log("[custom_views] Initializing Archive view");
// 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: "Par défaut",
light: "#ffffff",
dark: "#20293A"
},
{
key: "red",
label: "Rouge",
light: "#f28b82",
dark: "#9c2116"
},
{
key: "orange",
label: "Orange",
light: "#fbbc04",
dark: "#9c7a16"
},
{
key: "yellow",
label: "Jaune",
light: "#fff475",
dark: "#9c9116"
},
{
key: "green",
label: "Vert",
light: "#ccff90",
dark: "#5e9c16"
},
{
key: "teal",
label: "Menthe",
light: "#a7ffeb",
dark: "#169c7d"
},
{
key: "blue",
label: "Bleu clair",
light: "#cbf0f8",
dark: "#16849c"
},
{
key: "darkblue",
label: "Bleu",
light: "#aecbfa",
dark: "#16499c"
},
{
key: "purple",
label: "Violet",
light: "#d7aefb",
dark: "#5d169c"
},
{
key: "pink",
label: "Rose",
light: "#fdcfe8",
dark: "#9c165f"
},
{
key: "brown",
label: "Beige",
light: "#e6c9a8",
dark: "#9c5d16"
},
{
key: "grey",
label: "Gris",
light: "#e8eaed",
dark: "#4e5764"
},
{
key: "custom",
label: "Personnalisé",
light: "#custom",
dark: "#custom"
}
];
const NOTE_FILTER_OPTIONS = [
{ key: "none", label: "Aucun", icon: "mdi-close-circle-outline" },
{ key: "glass", label: "Glass", icon: "mdi-blur" },
{ key: "vignette", label: "Vignette", icon: "mdi-vignette" },
{ key: "lined", label: "Ligné", icon: "mdi-format-align-justify" },
{ key: "grid", label: "Quadrillé", icon: "mdi-grid" },
{ key: "noise", label: "Noise", icon: "mdi-grain" },
{ key: "dots", label: "Points", icon: "mdi-dots-grid" },
{ key: "stripes", label: "Rayures", icon: "mdi-slash-forward" },
];
const NOTE_FONT_COLOR_OPTIONS = [
{ key: "auto", label: "Auto", value: "auto" },
{ key: "light", label: "Clair", value: "#f5f7fb" },
{ key: "dark", label: "Sombre", value: "#202124" },
{ key: "white", label: "Blanc", value: "#ffffff" },
{ key: "black", label: "Noir", value: "#000000" },
{ key: "custom", label: "Personnalisé", 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();
if (!t) return false;
if (t === "note") return true;
if (t === "shaarli-note") return true;
if (t === "shaarli-todo") return true;
if (t === "shaarli-pin") 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 = "Supprimer";
btn.innerHTML = "&times;";
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;
});
}
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("Erreur lors de la suppression du 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") {
console.info("[custom_views] loaded", window.__shaarliCustomViewsVersion);
}
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: "Café", 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: "Forêt", 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: "Légumes", 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: "Océan", 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").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="Modifier"], 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;
refreshNoteBackgroundVisuals();
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);
document.addEventListener("click", (e) => {
const p = document.getElementById("shaarli-bg-studio");
if (!p || !p.classList.contains("open")) return;
if (e.target.closest("#shaarli-bg-studio")) return;
const anchor = p.__anchorEl;
if (anchor && (e.target === anchor || e.target.closest(`#${anchor.id}`))) return;
closeBackgroundStudioPanel();
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
const p = document.getElementById("shaarli-bg-studio");
if (!p || !p.classList.contains("open")) return;
closeBackgroundStudioPanel();
});
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);
} else {
setNoteColor(entityId, key, editUrl);
}
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);
} else {
setNoteBackground(entityId, key, editUrl);
}
return;
}
if (action === "set-filter") {
const filterKey = actionBtn.dataset.filter || "none";
if (mode === "draft") {
const normalized = normalizeFilterKey(filterKey || "") || "none";
panelEl.dataset.filter = normalized;
applyDraft({ filter: normalized });
renderBackgroundStudioPanel(panelEl);
} else if (mode === "modal") {
setModalNoteFilter(filterKey);
} else {
setNoteFilter(entityId, filterKey, editUrl);
}
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.filter = "none";
panelEl.dataset.background = "none";
panelEl.dataset.fontColor = "auto";
applyDraft({ color: "default", filter: "none", background: "none", fontColor: "auto" });
renderBackgroundStudioPanel(panelEl);
} else if (mode === "modal") {
setModalNoteColor("default");
setModalNoteFilter("none");
} else {
setNoteColor(entityId, "default", editUrl);
setNoteFilter(entityId, "none", editUrl);
}
return;
}
if (action === "reset-font-color") {
if (mode === "draft") {
panelEl.dataset.fontColor = "auto";
applyDraft({ fontColor: "auto" });
renderBackgroundStudioPanel(panelEl);
} else if (mode === "modal") {
setModalNoteFontColor("auto");
} else {
setNoteFontColor(entityId, "auto", editUrl);
}
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);
} else {
setNoteFontColor(entityId, fontColorKey, editUrl);
}
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 || "Mes images & couleurs";
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: "Couleur unie",
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 || "Mes images & couleurs";
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="Couleur personnalisée" aria-label="Couleur personnalisée" 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="Couleur de texte personnalisée" aria-label="Couleur de texte personnalisée" 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="Fermer" aria-label="Fermer"><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="Réinitialiser" aria-label="Réinitialiser"><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="Réinitialiser" aria-label="Réinitialiser"><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="Rechercher mes images… (filtrer par couleur ou filtre)" value="${panel.dataset.query || ""}" />
<button class="bg-studio-clear" type="button" data-bg-studio-action="set-query" title="Effacer" aria-label="Effacer" 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}`);
}
if (colorValue) {
element.style.backgroundColor = colorValue;
element.style.borderColor = "transparent";
} else {
element.style.removeProperty("background-color");
}
if (foregroundColor) {
element.style.setProperty("--note-card-fg", foregroundColor);
}
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="Modifier"], 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="Couleur" aria-label="Couleur" 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*="Épingler"], a[title*="Épingler"]'))[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: "Mes images & couleurs",
});
});
});
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="Par défaut" aria-label="Par défaut" 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">Créer une tâche...</span>
<div class="note-input-actions">
<button type="button" class="todo-open-draft-btn" title="Créer une liste"><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="Titre de la liste" />
<div class="todo-draft-list" role="list"></div>
<button type="button" class="todo-draft-add-btn"><i class="mdi mdi-plus"></i> Élément de liste</button>
<div class="note-input-expanded-actions">
<div class="note-input-actions-left">
<button type="button" class="note-input-palette-btn" title="Couleur"><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="Marquer comme terminée">
<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="Élément de liste" value="${escapeHtml(item.text || "")}" />
<button type="button" class="todo-item-delete" title="Supprimer"><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("Erreur lors de la création de la tâche.");
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: "Mes images & couleurs",
});
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="Vue grille"><i class="mdi mdi-view-dashboard-outline"></i></button>
<button class="icon-btn" id="btn-view-list" title="Vue liste"><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="Épingler">
<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> Élément de liste</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="Couleur"><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="Modifier"><i class="mdi mdi-pencil-outline"></i></a>
<button type="button" id="todo-modal-delete" title="Supprimer"><i class="mdi mdi-dots-vertical"></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: "Mes images & couleurs",
});
});
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", () => {
const modalCard = modalOverlay.querySelector(".note-modal");
const deleteUrl = modalCard.dataset.deleteUrl;
if (deleteUrl && deleteUrl !== "#") {
window.location.href = deleteUrl;
}
});
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 : "";
const formUrl = `${basePath}/admin/shaare?post=&tags=shaarli-note`;
const { action, baseData } = await fetchShaareFormBaseData(formUrl);
const formData = new URLSearchParams(baseData.toString());
formData.set("lf_url", "");
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 : "";
const formUrl = `${basePath}/admin/shaare?post=http%3A%2F%2Fshaarli-todo&tags=shaarli-todo`;
const { action, baseData } = await fetchShaareFormBaseData(formUrl);
const formData = new URLSearchParams(baseData.toString());
formData.set("lf_url", "http://shaarli-todo");
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("shaarli-todo")) tagsArray.push("shaarli-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;
if (!todo._todoDebugLogged && rawMarkdown && String(rawMarkdown).trim() && (!parsed.items || parsed.items.length === 0)) {
todo._todoDebugLogged = true;
console.warn("Todo parsed 0 items from lf_description; sample follows.", {
id: todo && todo.id,
url: todo && todo.editUrl,
sample: String(rawMarkdown).slice(0, 240),
});
}
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 paletteBtnId = `palette-${todo.id}`;
actions.innerHTML = `
<div style="position:relative;">
<button title="Couleur" id="${paletteBtnId}"><i class="mdi mdi-palette-outline"></i></button>
</div>
<div class="spacer"></div>
<a href="${todo.pinUrl}" title="${todo.isPinned ? "Unpin" : "Pin"}" class="${todo.isPinned ? "active" : ""}"><i class="mdi mdi-pin${todo.isPinned ? "" : "-outline"}"></i></a>
<a href="${todo.editUrl}" title="Edit"><i class="mdi mdi-pencil-outline"></i></a>
<button title="Plus" onclick="window.location.href='${todo.deleteUrl}'"><i class="mdi mdi-dots-vertical"></i></button>
`;
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: "Mes images & couleurs",
});
});
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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="Déplacer"><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="Supprimer"><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 || "Sans titre";
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("Erreur lors de la sauvegarde de la tâche. Veuillez rafraîchir la 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">Créer une note...</span>
<div class="note-input-actions note-input-type-actions">
<button type="button" class="note-create-note-btn" title="Créer une 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="Titre de la liste" />
<div class="todo-draft-list" role="list"></div>
<button type="button" class="todo-draft-add-btn"><i class="mdi mdi-plus"></i> Élément de liste</button>
<div class="note-input-expanded-actions">
<div class="note-input-actions-left">
<button type="button" class="note-input-palette-btn" title="Couleur"><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="Marquer comme terminée">
<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="Élément de liste" value="${escapeHtml(item.text || "")}" />
<button type="button" class="todo-item-delete" title="Supprimer"><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("Erreur lors de la création de la note liste.");
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: "Mes images & couleurs",
});
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="Titre" />
<textarea class="note-input-description-source" rows="8" placeholder="Écrivez votre note en Markdown..."></textarea>
<div class="note-formatting-bar" aria-hidden="true">
<button type="button" class="note-format-btn" data-note-format="h1" title="Titre 1">H1</button>
<button type="button" class="note-format-btn" data-note-format="h2" title="Titre 2">H2</button>
<button type="button" class="note-format-btn" data-note-format="h3" title="Titre 3">H3</button>
<button type="button" class="note-format-btn" data-note-format="p" title="Texte">Aa</button>
<span class="note-format-sep" aria-hidden="true"></span>
<button type="button" class="note-format-btn" data-note-format="bold" title="Gras"><b>B</b></button>
<button type="button" class="note-format-btn" data-note-format="italic" title="Italique"><i>I</i></button>
<button type="button" class="note-format-btn" data-note-format="underline" title="Souligné"><u>U</u></button>
<button type="button" class="note-format-btn" data-note-format="strike" title="Barré"><s>S</s></button>
<button type="button" class="note-format-btn" data-note-format="code" title="Code inline"><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="Liste à puces"><i class="mdi mdi-format-list-bulleted"></i></button>
<button type="button" class="note-format-btn" data-note-format="ol" title="Liste numérotée"><i class="mdi mdi-format-list-numbered"></i></button>
<button type="button" class="note-format-btn" data-note-format="todo" title="Checklist"><i class="mdi mdi-format-list-checks"></i></button>
<button type="button" class="note-format-btn" data-note-format="quote" title="Citation"><i class="mdi mdi-format-quote-open"></i></button>
<button type="button" class="note-format-btn" data-note-format="link" title="Lien"><i class="mdi mdi-link-variant"></i></button>
<button type="button" class="note-format-btn" data-note-format="image" title="Image"><i class="mdi mdi-image-outline"></i></button>
<button type="button" class="note-format-btn" data-note-format="codeblock" title="Bloc de code"><i class="mdi mdi-code-braces"></i></button>
<button type="button" class="note-format-btn" data-note-format="clear" title="Effacer la mise en forme">X</button>
</div>
<div class="note-input-expanded-actions">
<div class="note-input-actions-left">
<button type="button" class="note-input-format-btn" title="Options de mise en forme"><i class="mdi mdi-format-text"></i></button>
<button type="button" class="note-input-palette-btn" title="Couleur"><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("Erreur lors de la création de la 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: "Mes images & couleurs",
});
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="Vue grille"><i class="mdi mdi-view-dashboard-outline"></i></button>
<button class="icon-btn" id="btn-view-list" title="Vue liste"><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="Titre" />
<button type="button" class="note-modal-pin-toggle" id="note-modal-pin" title="Épingler">
<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="Cliquer pour modifier"></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="Titre 1">H1</button>
<button type="button" class="note-format-btn" data-note-format="h2" title="Titre 2">H2</button>
<button type="button" class="note-format-btn" data-note-format="h3" title="Titre 3">H3</button>
<button type="button" class="note-format-btn" data-note-format="p" title="Texte">Aa</button>
<span class="note-format-sep" aria-hidden="true"></span>
<button type="button" class="note-format-btn" data-note-format="bold" title="Gras"><b>B</b></button>
<button type="button" class="note-format-btn" data-note-format="italic" title="Italique"><i>I</i></button>
<button type="button" class="note-format-btn" data-note-format="underline" title="Souligné"><u>U</u></button>
<button type="button" class="note-format-btn" data-note-format="strike" title="Barré"><s>S</s></button>
<button type="button" class="note-format-btn" data-note-format="code" title="Code inline"><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="Liste à puces"><i class="mdi mdi-format-list-bulleted"></i></button>
<button type="button" class="note-format-btn" data-note-format="ol" title="Liste numérotée"><i class="mdi mdi-format-list-numbered"></i></button>
<button type="button" class="note-format-btn" data-note-format="todo" title="Checklist"><i class="mdi mdi-format-list-checks"></i></button>
<button type="button" class="note-format-btn" data-note-format="quote" title="Citation"><i class="mdi mdi-format-quote-open"></i></button>
<button type="button" class="note-format-btn" data-note-format="link" title="Lien"><i class="mdi mdi-link-variant"></i></button>
<button type="button" class="note-format-btn" data-note-format="image" title="Image"><i class="mdi mdi-image-outline"></i></button>
<button type="button" class="note-format-btn" data-note-format="codeblock" title="Bloc de code"><i class="mdi mdi-code-braces"></i></button>
<button type="button" class="note-format-btn" data-note-format="clear" title="Effacer la mise en forme">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="Couleur"><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="Options de mise en forme"><i class="mdi mdi-format-text"></i></button>
<button type="button" title="Rappel"><i class="mdi mdi-bell-outline"></i></button>
<button type="button" title="Collaborateur"><i class="mdi mdi-account-plus-outline"></i></button>
<button type="button" title="Image"><i class="mdi mdi-image-outline"></i></button>
<button type="button" id="note-modal-archive" title="Archiver"><i class="mdi mdi-archive-arrow-down-outline"></i></button>
<a href="#" id="note-modal-edit" title="Modifier"><i class="mdi mdi-pencil-outline"></i></a>
<button type="button" id="note-modal-delete" title="Supprimer"><i class="mdi mdi-dots-vertical"></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("Erreur lors de la sauvegarde de la 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: "Mes images & couleurs",
});
});
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", () => {
const modalCard = modalOverlay.querySelector(".note-modal");
const deleteUrl = modalCard.dataset.deleteUrl;
if (deleteUrl && deleteUrl !== "#") {
window.location.href = deleteUrl;
}
});
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("Erreur lors de l'archivage de la 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">Notes archivées</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="Vue grille"><i class="mdi mdi-view-dashboard-outline"></i></button>
<button class="icon-btn" id="btn-view-list" title="Vue liste"><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 || []).includes("shaarli-archive"));
// 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="Épingler">
<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="Couleur"><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="Rappel"><i class="mdi mdi-bell-outline"></i></button>
<button type="button" title="Collaborateur"><i class="mdi mdi-account-plus-outline"></i></button>
<button type="button" title="Image"><i class="mdi mdi-image-outline"></i></button>
<button type="button" id="note-modal-unarchive" title="Désarchiver"><i class="mdi mdi-archive-arrow-up-outline"></i></button>
<a href="#" id="note-modal-edit" title="Modifier"><i class="mdi mdi-pencil-outline"></i></a>
<button type="button" id="note-modal-delete" title="Supprimer"><i class="mdi mdi-dots-vertical"></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: "Mes images & couleurs",
});
});
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", () => {
const modalCard = modalOverlay.querySelector(".note-modal");
const deleteUrl = modalCard.dataset.deleteUrl;
if (deleteUrl && deleteUrl !== "#") {
window.location.href = deleteUrl;
}
});
// 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("Erreur lors du désarchivage de la 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 || []).includes("shaarli-archive"));
} else {
// In normal notes mode: hide archived notes
visibleNotes = notes.filter((note) => !(note.tags || []).includes("shaarli-archive"));
}
// 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";
// Palette Button Logic
const paletteBtnId = `palette-${note.id}`;
const archiveBtnId = `archive-${note.id}`;
actions.innerHTML = `
<button title="Rappel"><i class="mdi mdi-bell-outline"></i></button>
<button title="Collaborateur"><i class="mdi mdi-account-plus-outline"></i></button>
<div style="position:relative;">
<button title="Couleur" id="${paletteBtnId}"><i class="mdi mdi-palette-outline"></i></button>
</div>
<button title="Image"><i class="mdi mdi-image-outline"></i></button>
<button title="Archiver" id="${archiveBtnId}"><i class="mdi mdi-archive-arrow-down-outline"></i></button>
<div class="spacer"></div>
<!-- Real Actions -->
<a href="${note.pinUrl}" title="${note.isPinned ? "Unpin" : "Pin"}" class="${note.isPinned ? "active" : ""}"><i class="mdi mdi-pin${note.isPinned ? "" : "-outline"}"></i></a>
<button type="button" class="note-open-editor-btn" title="Modifier"><i class="mdi mdi-pencil-outline"></i></button>
<button title="Plus" onclick="window.location.href='${note.deleteUrl}'"><i class="mdi mdi-dots-vertical"></i></button>
`;
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: "Mes images & couleurs",
});
});
// 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();
console.log(`Note ${note.id} archived`);
})
.catch((err) => {
console.error("Error archiving note:", err);
alert("Erreur lors de l'archivage de la 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 ? "Désépingler" : "Épingler";
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) {
console.log(`Color ${color} saved for note ${noteId}`);
} else {
throw new Error("Failed to save color");
}
})
.catch((err) => {
console.error("Error saving note color:", err);
alert("Erreur lors de la sauvegarde de la couleur. Veuillez rafraîchir la 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";
applyNoteVisualState(card, { color, filter: normalizedFilterKey });
}
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";
applyNoteVisualState(bookmarkCard, { color, filter: normalizedFilterKey });
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("Erreur lors de la sauvegarde du filtre. Veuillez rafraîchir la 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";
applyNoteVisualState(card, { color, filter, background: normalizedBackgroundKey });
}
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";
applyNoteVisualState(bookmarkCard, { color, filter, background: normalizedBackgroundKey });
}
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("Erreur lors de la sauvegarde du fond. Veuillez rafraîchir la 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"], .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) {
console.log("Pin toggled successfully");
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" : "Couleur personnalisée"}</div>
<button class="color-picker-close" type="button" data-color-picker-action="close" title="Fermer" aria-label="Fermer"><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);
}
// Position the color picker panel
positionColorPickerPanel(panel, mode);
panel.style.display = "block";
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();
// 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 + panel.offsetWidth > window.innerWidth - viewportPadding) {
// Position to the left instead
left = bgRect.left - panel.offsetWidth - 10;
}
// Ensure it stays within viewport
if (left < viewportPadding) left = viewportPadding;
if (top + panel.offsetHeight > window.innerHeight - viewportPadding) {
top = window.innerHeight - viewportPadding - panel.offsetHeight;
}
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 left = Math.max(viewportPadding, (window.innerWidth - panelRect.width) / 2);
const top = Math.max(viewportPadding, (window.innerHeight - panelRect.height) / 2);
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.borderColor = "transparent";
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) {
console.log(`Font color ${fontColorKey} saved for note ${noteId}`);
} 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.borderColor = "transparent";
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");
console.log(`Custom color ${color} saved for note ${entity.id}`);
})
.catch((err) => {
console.error("Error saving custom color:", err);
});
}