Shaarli_bm_theme/shaarli-pro/js/custom_views.js

1960 lines
70 KiB
JavaScript

document.addEventListener("DOMContentLoaded", function () {
// Check URL parameters for custom views
const urlParams = new URLSearchParams(window.location.search);
const searchTags = urlParams.get("searchtags");
const linkList = document.getElementById("links-list");
const container = document.querySelector(".content-container");
const startViewInitialization = function () {
if (typeof initBookmarkPaletteButtons === "function") {
initBookmarkPaletteButtons();
}
// 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 (searchTags === "todo") {
initTodoView(linkList, container);
} else if (searchTags === "note") {
initNoteView(linkList, container);
}
};
// 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();
}
});
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"
}
];
const NOTE_BACKGROUND_TAG_PREFIX = "notebg-";
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);
}
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: "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";
const colorClass = Array.from(element.classList).find((cls) => cls.startsWith("note-color-"));
if (colorClass) {
return colorClass.replace("note-color-", "") || "default";
}
return element.dataset.color || "default";
}
function getElementVisualBackground(element) {
if (!element) return "none";
const normalizedBackground = normalizeBackgroundKey(element.dataset.background || "");
return normalizedBackground || "none";
}
function refreshNoteBackgroundVisuals() {
document.querySelectorAll(".note-card, .note-modal, .link-outer").forEach((element) => {
applyNoteVisualState(element, {
color: getElementVisualColor(element),
background: getElementVisualBackground(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 = document.querySelector(".note-modal-overlay");
if (!modal || !modal.currentNote) return;
const modalCard = modal.querySelector(".note-modal");
if (modalCard) {
modal.currentNote.color = getElementVisualColor(modalCard);
modal.currentNote.background = getElementVisualBackground(modalCard);
}
const modalColorPopup = modal.querySelector("#note-modal-color-popup");
if (modalColorPopup) {
modalColorPopup.innerHTML = generateModalPaletteButtons(modal.currentNote);
}
}
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";
}
}
function applyNoteVisualState(element, note) {
if (!element || !note) return;
const resolvedColorOption = getColorOption(note.color || "default");
const color = resolvedColorOption ? resolvedColorOption.key : "default";
const colorValue = getThemeColorValue(resolvedColorOption);
const foregroundColor = getReadableForegroundForBackground(colorValue);
const normalizedBackground = normalizeBackgroundKey(note.background || "");
const background = normalizedBackground || "none";
element.classList.forEach((cls) => {
if (cls.startsWith("note-color-")) element.classList.remove(cls);
});
element.classList.add(`note-color-${color}`);
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 (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;
}
function extractNoteVisualStateFromTags(tags) {
const safeTags = Array.isArray(tags) ? tags : [];
let color = "default";
const foundColorTag = safeTags.find((t) => typeof t === "string" && t.startsWith("note-"));
if (foundColorTag) {
const candidate = foundColorTag.substring(5);
if (NOTE_COLOR_OPTIONS.some((opt) => opt.key === candidate)) {
color = 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;
}
}
return { color, background };
}
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, background } = extractNoteVisualStateFromTags(tags);
applyNoteVisualState(card, { color, background });
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>
<div class="palette-popup" id="popup-${paletteBtnId}">
${generateBookmarkPaletteButtons(cardId, editUrl, color, background)}
</div>
`;
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}`);
const palettePopup = wrapper.querySelector(`#popup-${paletteBtnId}`);
if (!paletteBtn || !palettePopup) return;
paletteBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
document.querySelectorAll(".palette-popup.open").forEach((p) => {
if (p !== palettePopup) {
p.classList.remove("open");
const parentCard = p.closest(".link-outer");
if (parentCard) parentCard.classList.remove("palette-open");
}
});
const nextOpenState = !palettePopup.classList.contains("open");
palettePopup.classList.toggle("open");
card.classList.toggle("palette-open", nextOpenState);
positionPalettePopup(palettePopup);
});
});
document.addEventListener("click", (e) => {
if (e.target.closest(".bookmark-palette")) return;
document.querySelectorAll(".palette-popup.open").forEach((p) => p.classList.remove("open"));
document.querySelectorAll(".link-outer.palette-open").forEach((el) => el.classList.remove("palette-open"));
});
}
function generateBookmarkPaletteButtons(bookmarkId, editUrl, currentColor, currentBackground) {
const color = currentColor || "default";
const background = normalizeBackgroundKey(currentBackground || "") || "none";
const colorButtons = [
`<button class="palette-btn palette-btn-default ${color === "default" ? "is-active" : ""}" title="Par défaut" onclick="setNoteColor('${bookmarkId}', 'default', '${editUrl}')"><i class="mdi mdi-format-color-reset"></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" : ""}" title="${opt.label}" onclick="setNoteColor('${bookmarkId}', '${opt.key}', '${editUrl}')" style="background-color:${swatchColor}"></button>`;
},
),
].join("");
const backgroundButtons = [
`<button class="palette-btn palette-btn-bg-none ${background === "none" ? "is-active" : ""}" title="Sans image" onclick="setNoteBackground('${bookmarkId}', 'none', '${editUrl}')"><i class="mdi mdi-image-off-outline"></i></button>`,
...getAvailableBackgroundOptionsForMode().map((bg) => {
const bgUrl = getNoteBackgroundUrl(bg.key);
return `<button class="palette-btn palette-btn-bg ${background === bg.key ? "is-active" : ""}" title="${bg.label}" onclick="setNoteBackground('${bookmarkId}', '${bg.key}', '${editUrl}')" style="background-image:url('${bgUrl}')"></button>`;
}),
].join("");
return `
<div class="palette-row palette-row-colors">${colorButtons}</div>
<div class="palette-row palette-row-backgrounds">${backgroundButtons}</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 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");
// Extract task data from existing DOM
const rawLinks = Array.from(linkList.querySelectorAll(".link-outer"));
const tasks = rawLinks.map((link) => parseTaskFromLink(link)).filter((t) => t !== null);
// Create new Layout
const wrapper = document.createElement("div");
wrapper.className = "special-view-wrapper";
// 1. Sidebar
const sidebar = document.createElement("div");
sidebar.className = "todo-sidebar";
// Extract unique groups for the sidebar
const groups = new Set();
tasks.forEach((t) => {
if (t.group) groups.add(t.group);
});
const groupsList = Array.from(groups)
.map((g) => `<div class="todo-list-item" onclick="filterTasksByGroup('${g}')"><i class="mdi mdi-label-outline"></i> ${g}</div>`)
.join("");
sidebar.innerHTML = `
<div class="sidebar-section-title" style="padding: 1rem 1rem 0.5rem; font-weight:bold; color:var(--text-light);">TÂCHES</div>
<div class="todo-list-item active" onclick="filterTasksByGroup('all')">
<i class="mdi mdi-inbox" aria-hidden="true"></i> Mes tâches
<span style="margin-left:auto; font-size:0.8rem; background:rgba(0,0,0,0.1); padding:2px 6px; border-radius:10px;" aria-label="${tasks.length} tâches">${tasks.length}</span>
</div>
${groups.size > 0 ? `<div class="sidebar-section-title" style="padding: 1rem 1rem 0.5rem; font-weight:bold; color:var(--text-light);">LISTES</div>${groupsList}` : ""}
`;
// 2. Main Content
const main = document.createElement("div");
main.className = "todo-main";
const mainHeader = document.createElement("div");
mainHeader.className = "todo-main-header";
mainHeader.innerHTML = `
<h2 id="todo-list-title">Mes tâches</h2>
<div class="todo-actions">
<!-- Sorting/Menu could go here -->
</div>
`;
const itemsContainer = document.createElement("div");
itemsContainer.className = "todo-items-container";
// Sort Tasks: Pinned items first
tasks.sort((a, b) => {
const aPinned = a.tags && a.tags.includes("shaarli-pin");
const bPinned = b.tags && b.tags.includes("shaarli-pin");
return bPinned - aPinned;
});
// Render Tasks
tasks.forEach((task) => {
itemsContainer.appendChild(renderTaskItem(task));
});
main.appendChild(mainHeader);
main.appendChild(itemsContainer);
wrapper.appendChild(sidebar);
wrapper.appendChild(main);
// Inject and Hide original
linkList.style.display = "none";
// Remove pagination/toolbar if present to clean up view
const toolbar = document.querySelector(".content-toolbar");
if (toolbar) toolbar.style.display = "none";
if (linkList.parentNode) {
linkList.parentNode.insertBefore(wrapper, linkList);
} else {
container.appendChild(wrapper);
}
// Global filter function
window.filterTasksByGroup = function (group) {
const title = document.getElementById("todo-list-title");
const items = document.querySelectorAll(".todo-item");
// Update Sidebar Active State
document.querySelectorAll(".todo-list-item").forEach((el) => el.classList.remove("active"));
if (event && event.currentTarget) event.currentTarget.classList.add("active");
if (group === "all") {
title.textContent = "Mes tâches";
items.forEach((item) => (item.style.display = "flex"));
} else {
title.textContent = group;
items.forEach((item) => {
if (item.dataset.group === group) item.style.display = "flex";
else item.style.display = "none";
});
}
};
}
function parseTaskFromLink(linkEl) {
const id = linkEl.dataset.id;
const titleEl = linkEl.querySelector(".link-title"); // "Title" normally
// For Todos, the Bookmark Title is the Task Title?
// Or is the title inside the description?
// Mental model says: "Todo: reste un bookmark privé... LinkEntity.title" is used.
const title = titleEl ? titleEl.textContent.trim() : "Task";
const descEl = linkEl.querySelector(".link-description");
const rawDesc = descEl ? descEl.innerHTML : "";
const textDesc = descEl ? descEl.textContent : "";
// Check if it's really a todo (should be if we are in ?searchtags=todo, but double check)
// We assume yes.
// Parse Metadata from Description text
// Format: 📅 **Échéance :** <Instant ISO>
// Format: 🏷️ **Groupe :** <nom>
// Format: - [ ] Subtask or Main task status?
// Status
// If [x] is found in the first few lines, maybe completed?
// User says: "Puis une checkbox markdown: - [ ] Titre"
// Wait, if the Description contains the checkbox and title, then Bookmark Title is ignored?
// Let's assume Bookmark Title is the master Display Title.
// And "Checkbox status" can be parsed from description.
let isCompleted = false;
if (textDesc.includes("[x]")) isCompleted = true;
// Due Date
let dueDate = null;
const dateMatch = textDesc.match(/Échéance\s*:\s*\*+([^*]+)\*+/); // varies by markdown regex
// Text might be "📅 **Échéance :** 2023-10-10"
// Regex: Échéance\s*:\s*(.*?)(\n|$)
const dueMatch = textDesc.match(/Échéance\s*[:]\s*(.*?)(\n|$)/);
if (dueMatch) dueDate = dueMatch[1].trim();
// Group
let group = null;
const groupMatch = textDesc.match(/Groupe\s*[:]\s*(.*?)(\n|$)/);
if (groupMatch) group = groupMatch[1].trim();
return {
id,
title,
isCompleted,
dueDate,
group,
originalUrl: linkEl.querySelector(".link-url") ? linkEl.querySelector(".link-url").textContent : "",
editUrl: linkEl.querySelector('a[href*="admin/shaare/"]') ? linkEl.querySelector('a[href*="admin/shaare/"]').href : "#",
};
}
function renderTaskItem(task) {
const el = document.createElement("div");
el.className = `todo-item ${task.isCompleted ? "completed" : ""}`;
el.dataset.group = task.group || "";
/*
We cannot easily toggle state (AJAX) without a backend API that supports partial updates efficiently.
But we can provide a link to Edit.
Or simulate it.
*/
el.innerHTML = `
<div class="todo-checkbox ${task.isCompleted ? "checked" : ""}">
${task.isCompleted ? '<i class="mdi mdi-check"></i>' : ""}
</div>
<div class="todo-content">
<div class="todo-title">${task.title}</div>
<div class="todo-meta">
${task.group ? `<span class="todo-badge">${task.group}</span>` : ""}
${task.dueDate ? `<span class="due-date ${isOverdue(task.dueDate) ? "overdue" : ""}"><i class="mdi mdi-calendar"></i> ${formatDate(task.dueDate)}</span>` : ""}
<a href="${task.editUrl}" title="Edit Task" style="margin-left:auto;"><i class="mdi mdi-pencil" style="font-size:14px;"></i></a>
</div>
</div>
`;
// Simple click handler to open edit (since we can't sync state easily)
el.querySelector(".todo-checkbox").addEventListener("click", (e) => {
e.stopPropagation();
// Open edit page or toggle visually
// window.location.href = task.editUrl;
});
return el;
}
function isOverdue(dateStr) {
try {
return new Date(dateStr) < new Date();
} catch (e) {
return false;
}
}
function formatDate(dateStr) {
try {
const d = new Date(dateStr);
return d.toLocaleDateString();
} catch (e) {
return dateStr;
}
}
/**
* Initialize the Google Keep-like view
*/
/**
* Initialize the Google Keep-like view
*/
function initNoteView(linkList, container) {
document.body.classList.add("view-notes");
// 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";
// Custom Input "Take a note..."
const inputContainer = document.createElement("div");
inputContainer.className = "note-input-container";
inputContainer.innerHTML = `
<div class="note-input-collapsed" onclick="window.location.href='?do=addlink&tags=note'">
<span class="note-input-placeholder">Créer une note...</span>
<div class="note-input-actions">
<button title="Nouvelle liste" onclick="event.stopPropagation(); window.location.href='?do=addlink&tags=note&description=- [ ] '"><i class="mdi mdi-checkbox-marked-outline"></i></button>
<button title="Nouveau dessin" onclick="event.stopPropagation(); window.location.href='?do=addlink&tags=note'"><i class="mdi mdi-brush"></i></button>
<button title="Nouvelle image" onclick="event.stopPropagation(); window.location.href='?do=addlink&tags=note'"><i class="mdi mdi-image"></i></button>
</div>
</div>
`;
topBar.appendChild(inputContainer);
// 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);
}
// Modal Container
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-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");
});
// 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();
modalColorPopup.classList.toggle("open");
positionPalettePopup(modalColorPopup);
});
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-archiver")
.then(() => {
if (modalOverlay.currentNote) {
if (!modalOverlay.currentNote.tags.includes("shaarli-archiver")) {
modalOverlay.currentNote.tags.push("shaarli-archiver");
}
}
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");
})
.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");
});
});
}
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 tags = [];
let color = "default";
let background = "none";
linkEl.querySelectorAll(".link-tag-list a").forEach((tag) => {
const t = tag.textContent.trim();
// Check for color tag
if (t.startsWith("note-")) {
const potentialColor = t.substring(5);
const knownColor = NOTE_COLOR_OPTIONS.some((opt) => opt.key === potentialColor);
if (knownColor) {
color = potentialColor;
} else {
tags.push(t);
}
} else if (t.startsWith(NOTE_BACKGROUND_TAG_PREFIX)) {
background = normalizeBackgroundKey(t.substring(NOTE_BACKGROUND_TAG_PREFIX.length)) || "none";
} else {
tags.push(t);
}
});
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, background, editUrl, deleteUrl, pinUrl, isPinned };
}
function renderNotes(container, notes, viewMode) {
container.innerHTML = "";
container.className = viewMode === "grid" ? "notes-masonry" : "notes-list-view";
const visibleNotes = notes.filter((note) => !(note.tags || []).includes("shaarli-archiver"));
// Sort: Pinned items first
visibleNotes.sort((a, b) => {
const aPinned = a.tags.includes("shaarli-pin");
const bPinned = b.tags.includes("shaarli-pin");
return bPinned - aPinned;
});
visibleNotes.forEach((note) => {
const card = document.createElement("div");
card.className = "note-card";
card.dataset.id = note.id;
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) {
const body = document.createElement("div");
body.className = "note-body";
// Start simple: use innerHTML but maybe strip big images if we used cover?
// For now, let's just dump it and style images to fit or hide if first child
body.innerHTML = note.descHtml;
inner.appendChild(body);
}
// Tags (Labels)
if (note.tags.length > 0) {
const tagContainer = document.createElement("div");
tagContainer.className = "note-tags";
note.tags.forEach((t) => {
if (t !== "note") {
const span = document.createElement("span");
span.className = "note-tag";
span.textContent = t;
tagContainer.appendChild(span);
}
});
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}`;
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 class="palette-popup" id="popup-${paletteBtnId}">
${generatePaletteButtons(note)}
</div>
</div>
<button title="Image"><i class="mdi mdi-image-outline"></i></button>
<button title="Archiver"><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>
<a href="${note.editUrl}" title="Edit"><i class="mdi mdi-pencil-outline"></i></a>
<button title="Plus" onclick="window.location.href='${note.deleteUrl}'"><i class="mdi mdi-dots-vertical"></i></button>
`;
// Palette Toggle
const paletteBtn = actions.querySelector(`#${paletteBtnId}`);
const palettePopup = actions.querySelector(`#popup-${paletteBtnId}`);
paletteBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
// Close others?
document.querySelectorAll(".palette-popup.open").forEach((p) => {
if (p !== palettePopup) p.classList.remove("open");
});
palettePopup.classList.toggle("open");
positionPalettePopup(palettePopup);
});
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 && tag !== "note");
if (visibleTags.length === 0) {
container.classList.add("is-empty");
return;
}
container.classList.remove("is-empty");
visibleTags.forEach((tag) => {
const tagEl = document.createElement("span");
tagEl.className = "note-tag";
tagEl.textContent = tag;
container.appendChild(tagEl);
});
}
function openNoteModal(note) {
const modal = document.querySelector(".note-modal-overlay");
if (!modal) return;
const modalCard = modal.querySelector(".note-modal");
const title = modal.querySelector("#note-modal-title");
const content = modal.querySelector(".note-modal-content");
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");
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 && tag !== "note");
modalCard.dataset.tags = visibleTags.join("||");
title.textContent = note.title || "Sans titre";
content.innerHTML = `<div class="note-body">${note.descHtml || ""}</div>`;
renderModalTags(tagsContainer, visibleTags);
if (editLink) {
editLink.href = note.editUrl || "#";
}
if (modalColorPopup) {
modalColorPopup.innerHTML = generateModalPaletteButtons(note);
modalColorPopup.classList.remove("open");
}
setModalPinButtonState(pinButton, !!note.isPinned);
modal.classList.add("open");
}
function generateModalPaletteButtons(note) {
const currentColor = note.color || "default";
const currentBackground = normalizeBackgroundKey(note.background || "") || "none";
const colorButtons = [
`<button class="palette-btn palette-btn-default ${currentColor === "default" ? "is-active" : ""}" title="Par défaut" onclick="setModalNoteColor('default')"><i class="mdi mdi-format-color-reset"></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} ${currentColor === opt.key ? "is-active" : ""}" title="${opt.label}" onclick="setModalNoteColor('${opt.key}')" style="background-color:${swatchColor}"></button>`;
},
),
].join("");
const backgroundButtons = [
`<button class="palette-btn palette-btn-bg-none ${currentBackground === "none" ? "is-active" : ""}" title="Sans image" onclick="setModalNoteBackground('none')"><i class="mdi mdi-image-off-outline"></i></button>`,
...getAvailableBackgroundOptionsForMode().map((bg) => {
const bgUrl = getNoteBackgroundUrl(bg.key);
return `<button class="palette-btn palette-btn-bg ${currentBackground === bg.key ? "is-active" : ""}" title="${bg.label}" onclick="setModalNoteBackground('${bg.key}')" style="background-image:url('${bgUrl}')"></button>`;
}),
].join("");
return `
<div class="palette-row palette-row-colors">${colorButtons}</div>
<div class="palette-row palette-row-backgrounds">${backgroundButtons}</div>
`;
}
window.setModalNoteColor = function (color) {
const modal = document.querySelector(".note-modal-overlay");
if (!modal || !modal.currentNote) return;
const currentNote = modal.currentNote;
setNoteColor(currentNote.id, color, currentNote.editUrl);
currentNote.color = color;
const modalCard = modal.querySelector(".note-modal");
if (modalCard) {
applyNoteVisualState(modalCard, currentNote);
}
const modalColorPopup = modal.querySelector("#note-modal-color-popup");
if (modalColorPopup) {
modalColorPopup.innerHTML = generateModalPaletteButtons(currentNote);
modalColorPopup.classList.add("open");
positionPalettePopup(modalColorPopup);
}
};
window.setModalNoteBackground = function (backgroundKey) {
const modal = document.querySelector(".note-modal-overlay");
if (!modal || !modal.currentNote) return;
const currentNote = modal.currentNote;
const normalizedBackgroundKey = backgroundKey === "none" ? "none" : normalizeBackgroundKey(backgroundKey) || "none";
setNoteBackground(currentNote.id, normalizedBackgroundKey, currentNote.editUrl);
currentNote.background = normalizedBackgroundKey;
const modalCard = modal.querySelector(".note-modal");
if (modalCard) {
applyNoteVisualState(modalCard, currentNote);
}
const modalColorPopup = modal.querySelector("#note-modal-color-popup");
if (modalColorPopup) {
modalColorPopup.innerHTML = generateModalPaletteButtons(currentNote);
modalColorPopup.classList.add("open");
positionPalettePopup(modalColorPopup);
}
};
function generatePaletteButtons(note) {
const currentColor = note.color || "default";
const currentBackground = normalizeBackgroundKey(note.background || "") || "none";
const colorButtons = [
`<button class="palette-btn palette-btn-default ${currentColor === "default" ? "is-active" : ""}" title="Par défaut" onclick="setNoteColor('${note.id}', 'default', '${note.editUrl}')"><i class="mdi mdi-format-color-reset"></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} ${currentColor === opt.key ? "is-active" : ""}" title="${opt.label}" onclick="setNoteColor('${note.id}', '${opt.key}', '${note.editUrl}')" style="background-color:${swatchColor}"></button>`;
},
),
].join("");
const backgroundButtons = [
`<button class="palette-btn palette-btn-bg-none ${currentBackground === "none" ? "is-active" : ""}" title="Sans image" onclick="setNoteBackground('${note.id}', 'none', '${note.editUrl}')"><i class="mdi mdi-image-off-outline"></i></button>`,
...getAvailableBackgroundOptionsForMode().map((bg) => {
const bgUrl = getNoteBackgroundUrl(bg.key);
return `<button class="palette-btn palette-btn-bg ${currentBackground === bg.key ? "is-active" : ""}" title="${bg.label}" onclick="setNoteBackground('${note.id}', '${bg.key}', '${note.editUrl}')" style="background-image:url('${bgUrl}')"></button>`;
}),
].join("");
return `
<div class="palette-row palette-row-colors">${colorButtons}</div>
<div class="palette-row palette-row-backgrounds">${backgroundButtons}</div>
`;
}
window.setNoteColor = function (noteId, color, editUrl) {
// 1. Visual Update (Immediate feedback)
const card = document.querySelector(`.note-card[data-id="${noteId}"]`);
if (card) {
const background = card.dataset.background || "none";
applyNoteVisualState(card, { color, background });
}
const bookmarkCard = document.querySelector(`.link-outer[data-id="${noteId}"]`);
if (bookmarkCard) {
const background = bookmarkCard.dataset.background || "none";
applyNoteVisualState(bookmarkCard, { color, background });
const palettePopup = bookmarkCard.querySelector(".bookmark-palette .palette-popup");
if (palettePopup) {
palettePopup.innerHTML = generateBookmarkPaletteButtons(noteId, editUrl, color, background);
}
}
const modal = document.querySelector(".note-modal-overlay");
if (modal && modal.currentNote && String(modal.currentNote.id) === String(noteId)) {
modal.currentNote.color = color;
const modalCard = modal.querySelector(".note-modal");
if (modalCard) {
applyNoteVisualState(modalCard, modal.currentNote);
}
const modalColorPopup = modal.querySelector("#note-modal-color-popup");
if (modalColorPopup) {
modalColorPopup.innerHTML = generateModalPaletteButtons(modal.currentNote);
}
}
// 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, input.value);
}
});
// Update Tags
let currentTags = formData.get("lf_tags") || "";
let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== "");
// Remove existing color tags
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") {
tagsArray.push(`note-${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.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";
applyNoteVisualState(card, { color, 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";
applyNoteVisualState(bookmarkCard, { color, background: normalizedBackgroundKey });
const palettePopup = bookmarkCard.querySelector(".bookmark-palette .palette-popup");
if (palettePopup) {
palettePopup.innerHTML = generateBookmarkPaletteButtons(noteId, editUrl, color, normalizedBackgroundKey);
}
}
const modal = document.querySelector(".note-modal-overlay");
if (modal && modal.currentNote && String(modal.currentNote.id) === String(noteId)) {
modal.currentNote.background = normalizedBackgroundKey;
const modalCard = modal.querySelector(".note-modal");
if (modalCard) {
applyNoteVisualState(modalCard, modal.currentNote);
}
const modalColorPopup = modal.querySelector("#note-modal-color-popup");
if (modalColorPopup) {
modalColorPopup.innerHTML = generateModalPaletteButtons(modal.currentNote);
}
}
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, input.value);
}
});
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.");
});
};
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, input.value);
}
});
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]);
}
}
// 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) {
// 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, input.value);
}
});
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");
})
.catch((err) => console.error(err));
}