initial commit - removed own defaults
This commit is contained in:
697
app.js
Normal file
697
app.js
Normal file
@@ -0,0 +1,697 @@
|
||||
const MAX_OUTPUT_LENGTH = 8192;
|
||||
|
||||
const form = document.getElementById("noteForm");
|
||||
const outputEl = document.getElementById("output");
|
||||
const previewCard = document.getElementById("previewCard");
|
||||
const copyBtn = document.getElementById("copyBtn");
|
||||
const charCountEl = document.getElementById("charCount");
|
||||
const charWarningEl = document.getElementById("charWarning");
|
||||
|
||||
const previewShell = document.getElementById("previewShell");
|
||||
const themeDarkBtn = document.getElementById("themeDarkBtn");
|
||||
const themeLightBtn = document.getElementById("themeLightBtn");
|
||||
|
||||
const iconModeEl = document.getElementById("iconMode");
|
||||
const iconUrlWrap = document.getElementById("iconUrlWrap");
|
||||
const iconUploadWrap = document.getElementById("iconUploadWrap");
|
||||
const iconEmbedWrap = document.getElementById("iconEmbedWrap");
|
||||
const iconSelfhstWrap = document.getElementById("iconSelfhstWrap");
|
||||
const iconUrlEl = document.getElementById("iconUrl");
|
||||
const iconEmbedSvgEl = document.getElementById("iconEmbedSvg");
|
||||
const iconUploadEl = document.getElementById("iconUpload");
|
||||
const iconScaleEl = document.getElementById("iconScale");
|
||||
const iconScaleValueEl = document.getElementById("iconScaleValue");
|
||||
const iconStatusEl = document.getElementById("iconStatus");
|
||||
|
||||
const configLocationsEl = document.getElementById("configLocations");
|
||||
const addConfigBtn = document.getElementById("addConfigBtn");
|
||||
|
||||
let activeTheme = "dark";
|
||||
let iconResolvedSrc = "";
|
||||
let uploadSvgText = "";
|
||||
const externalSvgCache = new Map();
|
||||
let prepareToken = 0;
|
||||
|
||||
const rowConfigs = [
|
||||
{ prefix: "title", defaultAlign: "center", defaultTag: "h2", bold: false, italic: false, strong: false, code: false },
|
||||
{ prefix: "fqdn", defaultAlign: "center", defaultTag: "h3", bold: false, italic: false, strong: false, code: false },
|
||||
{ prefix: "network", defaultAlign: "center", defaultTag: "h3", bold: false, italic: false, strong: false, code: false },
|
||||
{ prefix: "config", defaultAlign: "center", defaultTag: "h3", bold: false, italic: false, strong: false, code: true },
|
||||
{ prefix: "custom", defaultAlign: "left", defaultTag: "none", bold: false, italic: false, strong: false, code: false },
|
||||
];
|
||||
|
||||
function getEl(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function isSvgUrl(url) {
|
||||
return /\.svg($|[?#])/i.test(url);
|
||||
}
|
||||
|
||||
function isRasterUrl(url) {
|
||||
return /\.(png|jpe?g|webp)($|[?#])/i.test(url);
|
||||
}
|
||||
|
||||
function readTextFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error("Could not read file."));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
function encodeSvgDataUrl(svgText) {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText)}`;
|
||||
}
|
||||
|
||||
function parsePositiveFloat(value) {
|
||||
const numeric = Number.parseFloat(String(value || "").replace(/[^0-9.]/g, ""));
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
||||
}
|
||||
|
||||
function getSvgDimensions(svgEl) {
|
||||
const viewBox = svgEl.getAttribute("viewBox");
|
||||
if (viewBox) {
|
||||
const parts = viewBox
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map((item) => Number.parseFloat(item));
|
||||
if (parts.length === 4 && Number.isFinite(parts[2]) && Number.isFinite(parts[3]) && parts[2] > 0 && parts[3] > 0) {
|
||||
return { width: parts[2], height: parts[3] };
|
||||
}
|
||||
}
|
||||
|
||||
const width = parsePositiveFloat(svgEl.getAttribute("width"));
|
||||
const height = parsePositiveFloat(svgEl.getAttribute("height"));
|
||||
if (width && height) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
return { width: 1, height: 1 };
|
||||
}
|
||||
|
||||
function resizeSvg(svgText, targetWidth) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svgText, "image/svg+xml");
|
||||
const svg = doc.documentElement;
|
||||
|
||||
if (!svg || svg.nodeName.toLowerCase() !== "svg") {
|
||||
throw new Error("Not a valid SVG.");
|
||||
}
|
||||
|
||||
const { width, height } = getSvgDimensions(svg);
|
||||
const ratio = height / width;
|
||||
const normalizedWidth = Number.parseInt(String(targetWidth), 10) || 110;
|
||||
const normalizedHeight = Math.max(1, Math.round(normalizedWidth * ratio));
|
||||
|
||||
svg.setAttribute("width", String(normalizedWidth));
|
||||
svg.setAttribute("height", String(normalizedHeight));
|
||||
|
||||
return new XMLSerializer().serializeToString(doc);
|
||||
}
|
||||
|
||||
async function getExternalSvgText(url) {
|
||||
if (externalSvgCache.has(url)) {
|
||||
return externalSvgCache.get(url);
|
||||
}
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
externalSvgCache.set(url, text);
|
||||
return text;
|
||||
}
|
||||
|
||||
function setIconStatus(text, isError = false) {
|
||||
iconStatusEl.textContent = text;
|
||||
iconStatusEl.classList.toggle("error", isError);
|
||||
}
|
||||
|
||||
function getIconAlign() {
|
||||
const checkedAlign = form.querySelector('input[name="iconAlign"]:checked');
|
||||
return checkedAlign ? checkedAlign.value : "center";
|
||||
}
|
||||
|
||||
function styleToolbarHtml(prefix, defaults) {
|
||||
const headingOptions = ["h1", "h2", "h3", "h4", "h5"];
|
||||
const alignOptions = ["left", "center", "right"];
|
||||
|
||||
const align = alignOptions
|
||||
.map((value) => {
|
||||
const short = value === "left" ? "L" : value === "center" ? "C" : "R";
|
||||
const checked = defaults.defaultAlign === value ? "checked" : "";
|
||||
return `<label class="tool-chip" title="${value.toUpperCase()} alignment"><input type="radio" name="${prefix}Align" value="${value}" ${checked} /><span>${short}</span></label>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const heading = headingOptions
|
||||
.map((tag) => {
|
||||
const label = tag.toUpperCase();
|
||||
const title = `${tag.toUpperCase()} heading`;
|
||||
const checked = defaults.defaultTag === tag ? "checked" : "";
|
||||
return `<label class="tool-chip" title="${title}"><input type="checkbox" name="${prefix}Heading" value="${tag}" ${checked} /><span>${label}</span></label>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const toggles = [
|
||||
{ key: "Italic", label: "I", title: "Italic", checked: defaults.italic },
|
||||
{ key: "Bold", label: "B", title: "Bold", checked: defaults.bold },
|
||||
{ key: "Strong", label: "S", title: "Strong", checked: defaults.strong },
|
||||
{ key: "Code", label: "C", title: "Code", checked: defaults.code },
|
||||
]
|
||||
.map((item) => {
|
||||
const checked = item.checked ? "checked" : "";
|
||||
return `<label class="tool-chip" title="${item.title}"><input id="${prefix}${item.key}" type="checkbox" ${checked} /><span>${item.label}</span></label>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="tool-set">
|
||||
<span class="tool-title">Align</span>
|
||||
<div class="tool-group">${align}</div>
|
||||
</div>
|
||||
<div class="tool-set">
|
||||
<span class="tool-title">Text Style</span>
|
||||
<div class="tool-group">${heading}${toggles}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function mountStyleToolbars() {
|
||||
for (const config of rowConfigs) {
|
||||
const holder = document.querySelector(`.style-tools[data-prefix="${config.prefix}"]`);
|
||||
if (!holder) {
|
||||
continue;
|
||||
}
|
||||
holder.innerHTML = styleToolbarHtml(config.prefix, config);
|
||||
}
|
||||
}
|
||||
|
||||
function bindStyleConflicts() {
|
||||
for (const { prefix } of rowConfigs) {
|
||||
const bold = getEl(`${prefix}Bold`);
|
||||
const strong = getEl(`${prefix}Strong`);
|
||||
const headingToggles = form.querySelectorAll(`input[name="${prefix}Heading"]`);
|
||||
if (!bold || !strong) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const headingToggle of headingToggles) {
|
||||
headingToggle.addEventListener("change", () => {
|
||||
if (headingToggle.checked) {
|
||||
for (const other of headingToggles) {
|
||||
if (other !== headingToggle) {
|
||||
other.checked = false;
|
||||
}
|
||||
}
|
||||
bold.checked = false;
|
||||
strong.checked = false;
|
||||
}
|
||||
renderOutput();
|
||||
});
|
||||
}
|
||||
|
||||
bold.addEventListener("change", () => {
|
||||
if (bold.checked && strong.checked) {
|
||||
strong.checked = false;
|
||||
}
|
||||
if (bold.checked) {
|
||||
for (const headingToggle of headingToggles) {
|
||||
headingToggle.checked = false;
|
||||
}
|
||||
}
|
||||
renderOutput();
|
||||
});
|
||||
|
||||
strong.addEventListener("change", () => {
|
||||
if (strong.checked && bold.checked) {
|
||||
bold.checked = false;
|
||||
}
|
||||
if (strong.checked) {
|
||||
for (const headingToggle of headingToggles) {
|
||||
headingToggle.checked = false;
|
||||
}
|
||||
}
|
||||
renderOutput();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getFormat(prefix) {
|
||||
const checkedAlign = form.querySelector(`input[name="${prefix}Align"]:checked`);
|
||||
const checkedHeading = form.querySelector(`input[name="${prefix}Heading"]:checked`);
|
||||
return {
|
||||
align: checkedAlign ? checkedAlign.value : "center",
|
||||
tag: checkedHeading ? checkedHeading.value : "none",
|
||||
bold: getEl(`${prefix}Bold`).checked,
|
||||
italic: getEl(`${prefix}Italic`).checked,
|
||||
strong: getEl(`${prefix}Strong`).checked,
|
||||
code: getEl(`${prefix}Code`).checked,
|
||||
};
|
||||
}
|
||||
|
||||
function textToHtml(value, keepLineBreaks = false) {
|
||||
const escaped = escapeHtml(value);
|
||||
if (!keepLineBreaks) {
|
||||
return escaped;
|
||||
}
|
||||
return escaped.replaceAll("\n", "<br />");
|
||||
}
|
||||
|
||||
function wrapTextForHeading(textHtml, format) {
|
||||
let value = textHtml;
|
||||
if (format.code) {
|
||||
value = `<code>${value}</code>`;
|
||||
}
|
||||
if (format.italic) {
|
||||
value = `<i>${value}</i>`;
|
||||
}
|
||||
if (format.strong) {
|
||||
value = `<strong>${value}</strong>`;
|
||||
} else if (format.bold) {
|
||||
value = `<b>${value}</b>`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function wrapTextForPlain(textHtml, format) {
|
||||
let value = textHtml;
|
||||
if (format.strong) {
|
||||
value = `<strong>${value}</strong>`;
|
||||
} else if (format.bold) {
|
||||
value = `<b>${value}</b>`;
|
||||
}
|
||||
if (format.italic) {
|
||||
value = `<i>${value}</i>`;
|
||||
}
|
||||
if (format.code) {
|
||||
value = `<code>${value}</code>`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildRowDiv({ align, contentHtml }) {
|
||||
const safeAlign = ["left", "center", "right"].includes(align) ? align : "center";
|
||||
return ` <div align="${safeAlign}">${contentHtml}</div>`;
|
||||
}
|
||||
|
||||
function buildTextRow({ align, icon, textHtml, format }) {
|
||||
const iconHtml = icon ? `${escapeHtml(icon)} ` : "";
|
||||
if (format.tag !== "none") {
|
||||
const textOnly = wrapTextForHeading(textHtml, format);
|
||||
return buildRowDiv({ align, contentHtml: `<${format.tag}>${iconHtml}${textOnly}</${format.tag}>` });
|
||||
}
|
||||
|
||||
const textOnly = wrapTextForPlain(textHtml, format);
|
||||
return buildRowDiv({ align, contentHtml: `${iconHtml}${textOnly}` });
|
||||
}
|
||||
|
||||
function getOrderedRowKeys() {
|
||||
return Array.from(form.querySelectorAll("fieldset[data-row-key]"))
|
||||
.map((fieldset) => fieldset.getAttribute("data-row-key"))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function moveRow(rowKey, direction) {
|
||||
const fieldsets = Array.from(form.querySelectorAll("fieldset[data-row-key]"));
|
||||
const index = fieldsets.findIndex((fieldset) => fieldset.getAttribute("data-row-key") === rowKey);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (targetIndex < 0 || targetIndex >= fieldsets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = fieldsets[index];
|
||||
const target = fieldsets[targetIndex];
|
||||
if (direction === "up") {
|
||||
form.insertBefore(current, target);
|
||||
} else {
|
||||
form.insertBefore(target, current);
|
||||
}
|
||||
}
|
||||
|
||||
function getConfigLocationValues() {
|
||||
return Array.from(configLocationsEl.querySelectorAll("input[data-config-location]"))
|
||||
.map((input) => input.value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildNoteHtml() {
|
||||
const byKey = {};
|
||||
const lines = ["<div>"];
|
||||
|
||||
if (iconResolvedSrc) {
|
||||
byKey.icon = [buildRowDiv({ align: getIconAlign(), contentHtml: `<img src="${escapeHtml(iconResolvedSrc)}" alt="App icon" />` })];
|
||||
}
|
||||
|
||||
const titleText = getEl("titleText").value.trim();
|
||||
if (titleText) {
|
||||
const format = getFormat("title");
|
||||
byKey.title = [buildTextRow({ align: format.align, icon: getEl("titleEmoji").value, textHtml: textToHtml(titleText), format })];
|
||||
}
|
||||
|
||||
const fqdnLabel = getEl("fqdnLabel").value.trim();
|
||||
if (fqdnLabel) {
|
||||
const format = getFormat("fqdn");
|
||||
const fqdnUrl = getEl("fqdnUrl").value.trim();
|
||||
const label = textToHtml(fqdnLabel);
|
||||
const linked = fqdnUrl
|
||||
? `<a href="${escapeHtml(fqdnUrl)}" target="_blank" rel="noopener noreferrer">${label}</a>`
|
||||
: label;
|
||||
byKey.fqdn = [buildTextRow({ align: format.align, icon: getEl("fqdnEmoji").value, textHtml: linked, format })];
|
||||
}
|
||||
|
||||
const networkText = getEl("networkText").value.trim();
|
||||
if (networkText) {
|
||||
const format = getFormat("network");
|
||||
byKey.network = [buildTextRow({ align: format.align, icon: getEl("networkEmoji").value, textHtml: textToHtml(networkText), format })];
|
||||
}
|
||||
|
||||
const configLocations = getConfigLocationValues();
|
||||
if (configLocations.length > 0) {
|
||||
const format = getFormat("config");
|
||||
byKey.config = configLocations.map((location) =>
|
||||
buildTextRow({ align: format.align, icon: getEl("configEmoji").value, textHtml: textToHtml(location), format })
|
||||
);
|
||||
}
|
||||
|
||||
const customText = getEl("customText").value.trim();
|
||||
if (customText) {
|
||||
const format = getFormat("custom");
|
||||
byKey.custom = [buildTextRow({ align: format.align, icon: "", textHtml: textToHtml(customText, true), format })];
|
||||
}
|
||||
|
||||
for (const key of getOrderedRowKeys()) {
|
||||
const section = byKey[key];
|
||||
if (!section) {
|
||||
continue;
|
||||
}
|
||||
lines.push(...section);
|
||||
}
|
||||
|
||||
lines.push("</div>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function updateLengthState(noteHtml) {
|
||||
const len = noteHtml.length;
|
||||
charCountEl.textContent = `${len} / ${MAX_OUTPUT_LENGTH}`;
|
||||
|
||||
if (len > MAX_OUTPUT_LENGTH) {
|
||||
charWarningEl.textContent = `Too long by ${len - MAX_OUTPUT_LENGTH} characters.`;
|
||||
copyBtn.disabled = true;
|
||||
} else {
|
||||
charWarningEl.textContent = "";
|
||||
copyBtn.disabled = len === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderOutput() {
|
||||
iconScaleValueEl.textContent = `${iconScaleEl.value} px`;
|
||||
const noteHtml = buildNoteHtml();
|
||||
outputEl.value = noteHtml;
|
||||
previewCard.innerHTML = noteHtml;
|
||||
updateLengthState(noteHtml);
|
||||
}
|
||||
|
||||
function iconCanUseScale() {
|
||||
if (iconModeEl.value === "upload") {
|
||||
return Boolean(uploadSvgText);
|
||||
}
|
||||
|
||||
if (iconModeEl.value === "external") {
|
||||
const url = iconUrlEl.value.trim();
|
||||
return isSvgUrl(url) && iconEmbedSvgEl.checked;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function updateIconControls() {
|
||||
const mode = iconModeEl.value;
|
||||
iconUrlWrap.classList.toggle("hidden", mode !== "external");
|
||||
iconSelfhstWrap.classList.toggle("hidden", mode !== "external");
|
||||
iconEmbedWrap.classList.toggle("hidden", mode !== "external");
|
||||
iconUploadWrap.classList.toggle("hidden", mode !== "upload");
|
||||
|
||||
const url = iconUrlEl.value.trim();
|
||||
const rasterLink = mode === "external" && isRasterUrl(url);
|
||||
|
||||
if (rasterLink) {
|
||||
iconEmbedSvgEl.checked = false;
|
||||
iconEmbedSvgEl.disabled = true;
|
||||
} else {
|
||||
iconEmbedSvgEl.disabled = false;
|
||||
}
|
||||
|
||||
iconScaleEl.disabled = !iconCanUseScale();
|
||||
}
|
||||
|
||||
async function prepareIcon() {
|
||||
const token = ++prepareToken;
|
||||
updateIconControls();
|
||||
|
||||
const mode = iconModeEl.value;
|
||||
if (mode === "none") {
|
||||
iconResolvedSrc = "";
|
||||
setIconStatus("");
|
||||
renderOutput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "upload") {
|
||||
if (!uploadSvgText) {
|
||||
iconResolvedSrc = "";
|
||||
setIconStatus("Upload an SVG to embed the icon.");
|
||||
renderOutput();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resized = resizeSvg(uploadSvgText, iconScaleEl.value);
|
||||
if (token !== prepareToken) {
|
||||
return;
|
||||
}
|
||||
iconResolvedSrc = encodeSvgDataUrl(resized);
|
||||
setIconStatus(`Uploaded SVG embedded at ${iconScaleEl.value}px width.`);
|
||||
updateIconControls();
|
||||
renderOutput();
|
||||
return;
|
||||
} catch {
|
||||
iconResolvedSrc = "";
|
||||
setIconStatus("Could not process uploaded SVG.", true);
|
||||
renderOutput();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const url = iconUrlEl.value.trim();
|
||||
if (!url) {
|
||||
iconResolvedSrc = "";
|
||||
setIconStatus("Add an external image URL.");
|
||||
renderOutput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRasterUrl(url)) {
|
||||
iconResolvedSrc = url;
|
||||
setIconStatus("Raster image detected: link-only mode (no scaling). Use CDN-sized assets.");
|
||||
updateIconControls();
|
||||
renderOutput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSvgUrl(url)) {
|
||||
iconResolvedSrc = url;
|
||||
setIconStatus("Unknown extension: using direct link.");
|
||||
updateIconControls();
|
||||
renderOutput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!iconEmbedSvgEl.checked) {
|
||||
iconResolvedSrc = url;
|
||||
setIconStatus("SVG link mode enabled. Scaling is disabled until embedding is enabled.");
|
||||
updateIconControls();
|
||||
renderOutput();
|
||||
return;
|
||||
}
|
||||
|
||||
setIconStatus("Preparing embedded SVG...");
|
||||
try {
|
||||
const svgText = await getExternalSvgText(url);
|
||||
const resized = resizeSvg(svgText, iconScaleEl.value);
|
||||
if (token !== prepareToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
iconResolvedSrc = encodeSvgDataUrl(resized);
|
||||
setIconStatus(`External SVG embedded at ${iconScaleEl.value}px width.`);
|
||||
updateIconControls();
|
||||
renderOutput();
|
||||
} catch {
|
||||
if (token !== prepareToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
iconResolvedSrc = url;
|
||||
setIconStatus("Embedding failed. Falling back to direct SVG link.", true);
|
||||
updateIconControls();
|
||||
renderOutput();
|
||||
}
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
activeTheme = theme === "light" ? "light" : "dark";
|
||||
previewShell.classList.toggle("dark", activeTheme === "dark");
|
||||
previewShell.classList.toggle("light", activeTheme === "light");
|
||||
themeDarkBtn.classList.toggle("active", activeTheme === "dark");
|
||||
themeLightBtn.classList.toggle("active", activeTheme === "light");
|
||||
}
|
||||
|
||||
function createConfigLocationInput(initialValue = "") {
|
||||
const row = document.createElement("div");
|
||||
row.className = "stack-row";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.placeholder = "/ETC/APP/CONFIG.YML";
|
||||
input.value = initialValue;
|
||||
input.setAttribute("data-config-location", "1");
|
||||
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "ghost";
|
||||
remove.textContent = "Remove";
|
||||
remove.addEventListener("click", () => {
|
||||
row.remove();
|
||||
renderOutput();
|
||||
});
|
||||
|
||||
row.append(input, remove);
|
||||
input.addEventListener("input", renderOutput);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function onIconUploadChange(event) {
|
||||
const [file] = event.target.files;
|
||||
if (!file) {
|
||||
uploadSvgText = "";
|
||||
await prepareIcon();
|
||||
return;
|
||||
}
|
||||
|
||||
const byType = file.type === "image/svg+xml";
|
||||
const byName = /\.svg$/i.test(file.name);
|
||||
if (!byType && !byName) {
|
||||
iconUploadEl.value = "";
|
||||
uploadSvgText = "";
|
||||
setIconStatus("Only SVG upload is allowed. Use a CDN link for PNG/JPG/WEBP.", true);
|
||||
await prepareIcon();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
uploadSvgText = await readTextFile(file);
|
||||
await prepareIcon();
|
||||
} catch {
|
||||
uploadSvgText = "";
|
||||
setIconStatus("Could not read uploaded SVG.", true);
|
||||
await prepareIcon();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyOutput() {
|
||||
if (copyBtn.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(outputEl.value);
|
||||
copyBtn.textContent = "Copied";
|
||||
setTimeout(() => {
|
||||
copyBtn.textContent = "Copy HTML";
|
||||
}, 1200);
|
||||
} catch {
|
||||
copyBtn.textContent = "Clipboard blocked";
|
||||
setTimeout(() => {
|
||||
copyBtn.textContent = "Copy HTML";
|
||||
}, 1400);
|
||||
}
|
||||
}
|
||||
|
||||
function bootstrap() {
|
||||
mountStyleToolbars();
|
||||
bindStyleConflicts();
|
||||
|
||||
configLocationsEl.append(createConfigLocationInput("/etc/termix"));
|
||||
|
||||
addConfigBtn.addEventListener("click", () => {
|
||||
configLocationsEl.append(createConfigLocationInput(""));
|
||||
renderOutput();
|
||||
});
|
||||
|
||||
form.addEventListener("input", () => {
|
||||
renderOutput();
|
||||
});
|
||||
form.addEventListener("click", (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const moveBtn = target.closest(".row-move");
|
||||
if (moveBtn instanceof HTMLElement) {
|
||||
const rowKey = moveBtn.getAttribute("data-row-key");
|
||||
const direction = moveBtn.getAttribute("data-direction");
|
||||
if (rowKey && (direction === "up" || direction === "down")) {
|
||||
moveRow(rowKey, direction);
|
||||
renderOutput();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const clearBtn = target.closest(".icon-clear");
|
||||
if (!clearBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputId = clearBtn.getAttribute("data-target");
|
||||
const input = inputId ? getEl(inputId) : null;
|
||||
if (input) {
|
||||
input.value = "";
|
||||
renderOutput();
|
||||
}
|
||||
});
|
||||
|
||||
iconModeEl.addEventListener("change", prepareIcon);
|
||||
iconUrlEl.addEventListener("input", prepareIcon);
|
||||
iconEmbedSvgEl.addEventListener("change", prepareIcon);
|
||||
iconScaleEl.addEventListener("input", prepareIcon);
|
||||
iconUploadEl.addEventListener("change", onIconUploadChange);
|
||||
|
||||
themeDarkBtn.addEventListener("click", () => setTheme("dark"));
|
||||
themeLightBtn.addEventListener("click", () => setTheme("light"));
|
||||
|
||||
copyBtn.addEventListener("click", copyOutput);
|
||||
|
||||
setTheme("dark");
|
||||
prepareIcon();
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
12
favicon.svg
Normal file
12
favicon.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="PVE NoteBuddy">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#60c3ff"/>
|
||||
<stop offset="100%" stop-color="#83e7ba"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="56" height="56" rx="14" fill="#0f1b30" stroke="url(#bg)" stroke-width="4"/>
|
||||
<path d="M20 18h18a8 8 0 0 1 8 8v20h-8V26H20z" fill="url(#bg)"/>
|
||||
<path d="M24 34h14" stroke="#0f1b30" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M24 42h10" stroke="#0f1b30" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 632 B |
219
index.html
Normal file
219
index.html
Normal file
@@ -0,0 +1,219 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PVE NoteBuddy</title>
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="layout">
|
||||
<section class="panel panel-form">
|
||||
<h1>🗒️ PVE NoteBuddy</h1>
|
||||
<p class="subtitle">Generate Proxmox Notes using HTML-safe blocks with per-row layout and formatting controls.</p>
|
||||
|
||||
<form id="noteForm">
|
||||
<fieldset class="group" data-row-key="icon">
|
||||
<legend>Icon</legend>
|
||||
|
||||
<div class="icon-top-controls">
|
||||
<div class="tool-set">
|
||||
<span class="tool-title">Align</span>
|
||||
<div class="tool-group">
|
||||
<label class="tool-chip" title="LEFT alignment"><input type="radio" name="iconAlign" value="left" /><span>L</span></label>
|
||||
<label class="tool-chip" title="CENTER alignment"><input type="radio" name="iconAlign" value="center" checked /><span>C</span></label>
|
||||
<label class="tool-chip" title="RIGHT alignment"><input type="radio" name="iconAlign" value="right" /><span>R</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-reorder">
|
||||
<button type="button" class="row-move" data-row-key="icon" data-direction="up" title="Move up">↑</button>
|
||||
<button type="button" class="row-move" data-row-key="icon" data-direction="down" title="Move down">↓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<select id="iconMode">
|
||||
<option value="external">External link</option>
|
||||
<option value="upload">File upload (SVG only)</option>
|
||||
<option value="none">No image</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label id="iconUrlWrap">
|
||||
<input id="iconUrl" type="url" value="https://cdn.jsdelivr.net/gh/selfhst/icons@main/svg/termix.svg" placeholder="EXTERNAL IMAGE URL"/>
|
||||
</label>
|
||||
|
||||
|
||||
|
||||
<label id="iconUploadWrap" class="hidden">
|
||||
<input id="iconUpload" type="file" accept=".svg,image/svg+xml" />
|
||||
</label>
|
||||
|
||||
<div id="iconEmbedWrap" class="scale-row">
|
||||
<label width="50px" class="inline-check" title="Embed SVG to enable scaling">
|
||||
<input id="iconEmbedSvg" type="checkbox" checked />
|
||||
Embed SVG <div class="icon-help">
|
||||
<span class="info-wrap" aria-label="Icon format details">
|
||||
<span class="info-icon">i</span>
|
||||
<span class="tooltip">Only embedded SVG can be scaled. PNG/JPG/WEBP must already be the correct size on the CDN.</span>
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<span><div class="icon-help" id="iconSelfhstWrap">
|
||||
<a href="https://selfh.st/icons/" target="_blank" rel="noopener noreferrer">Browse selfh.st icons</a>
|
||||
</div></span>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;"><span id="iconScaleValue" class="scale-value">100 px</span></div>
|
||||
<input id="iconScale" type="range" min="32" max="320" step="2" value="110" />
|
||||
|
||||
|
||||
|
||||
<p id="iconStatus" class="status"></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="group" data-row-key="title">
|
||||
<legend>Name</legend>
|
||||
<div class="row-grid row-controls">
|
||||
<label class="icon-field">
|
||||
EMOJI
|
||||
<span class="icon-input-wrap">
|
||||
<input id="titleEmoji" type="text" value="" maxlength="8" />
|
||||
<button type="button" class="icon-clear" data-target="titleEmoji" title="Clear icon">X</button>
|
||||
</span>
|
||||
</label>
|
||||
<div class="style-tools" data-prefix="title"></div>
|
||||
<div class="row-reorder">
|
||||
<button type="button" class="row-move" data-row-key="title" data-direction="up" title="Move up">↑</button>
|
||||
<button type="button" class="row-move" data-row-key="title" data-direction="down" title="Move down">↓</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-grid row-fields">
|
||||
<label><input id="titleText" type="text" value="TERMIX LXC" placeholder="NAME" /></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="group" data-row-key="fqdn">
|
||||
<legend>Host</legend>
|
||||
<div class="row-grid row-controls">
|
||||
<label class="icon-field">
|
||||
EMOJI
|
||||
<span class="icon-input-wrap">
|
||||
<input id="fqdnEmoji" type="text" value="🌐" maxlength="8" />
|
||||
<button type="button" class="icon-clear" data-target="fqdnEmoji" title="Clear icon">X</button>
|
||||
</span>
|
||||
</label>
|
||||
<div class="style-tools" data-prefix="fqdn"></div>
|
||||
<div class="row-reorder">
|
||||
<button type="button" class="row-move" data-row-key="fqdn" data-direction="up" title="Move up">↑</button>
|
||||
<button type="button" class="row-move" data-row-key="fqdn" data-direction="down" title="Move down">↓</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-grid row-fields">
|
||||
<label><input id="fqdnLabel" type="text" value="termix.homel4b.local" placeholder="HOST" /></label>
|
||||
<label><input id="fqdnUrl" type="url" value="https://termix.site/" placeholder="HOST URL" /></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="group" data-row-key="network">
|
||||
<legend>Network</legend>
|
||||
<div class="row-grid row-controls">
|
||||
<label class="icon-field">
|
||||
EMOJI
|
||||
<span class="icon-input-wrap">
|
||||
<input id="networkEmoji" type="text" value="🖥️" maxlength="8" />
|
||||
<button type="button" class="icon-clear" data-target="networkEmoji" title="Clear icon">X</button>
|
||||
</span>
|
||||
</label>
|
||||
<div class="style-tools" data-prefix="network"></div>
|
||||
<div class="row-reorder">
|
||||
<button type="button" class="row-move" data-row-key="network" data-direction="up" title="Move up">↑</button>
|
||||
<button type="button" class="row-move" data-row-key="network" data-direction="down" title="Move down">↓</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-grid row-fields">
|
||||
<label><input id="networkText" type="text" value="10.10.20.80:8443" placeholder="NETWORK ADDRESS" /></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="group" data-row-key="config">
|
||||
<legend>Config File Location</legend>
|
||||
<div class="row-grid row-controls">
|
||||
<label class="icon-field">
|
||||
EMOJI
|
||||
<span class="icon-input-wrap">
|
||||
<input id="configEmoji" type="text" value="📁" maxlength="8" />
|
||||
<button type="button" class="icon-clear" data-target="configEmoji" title="Clear icon">X</button>
|
||||
</span>
|
||||
</label>
|
||||
<div class="style-tools" data-prefix="config"></div>
|
||||
<div class="row-reorder">
|
||||
<button type="button" class="row-move" data-row-key="config" data-direction="up" title="Move up">↑</button>
|
||||
<button type="button" class="row-move" data-row-key="config" data-direction="down" title="Move down">↓</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="configLocations" class="stack"></div>
|
||||
<button id="addConfigBtn" type="button" class="ghost">+ ADD</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="group" data-row-key="custom">
|
||||
<legend>Additional Notes</legend>
|
||||
<div class="row-grid row-controls no-icon">
|
||||
<div class="style-tools" data-prefix="custom"></div>
|
||||
<div class="row-reorder">
|
||||
<button type="button" class="row-move" data-row-key="custom" data-direction="up" title="Move up">↑</button>
|
||||
<button type="button" class="row-move" data-row-key="custom" data-direction="down" title="Move down">↓</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-grid row-fields">
|
||||
<textarea id="customText" class="span-2" rows="4" placeholder="Any additional note...">https://community-scripts.github.io/ProxmoxVE/scripts?id=termix</textarea>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-preview">
|
||||
<div class="preview-wrap">
|
||||
<div class="preview-head">
|
||||
<h2>Preview</h2>
|
||||
<div class="mode-toggle" role="group" aria-label="Preview theme">
|
||||
<button id="themeDarkBtn" type="button" class="toggle active">Dark</button>
|
||||
<button id="themeLightBtn" type="button" class="toggle">Light</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="previewShell" class="preview-shell dark">
|
||||
<div class="preview-title">Notes</div>
|
||||
<div class="preview-body">
|
||||
<div id="previewCard" class="preview-card"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-meta">
|
||||
<span id="charCount">0 / 8192</span>
|
||||
<span id="charWarning" class="warning"></span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="copyBtn" type="button">Copy HTML</button>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Output
|
||||
<textarea id="output" rows="16" readonly></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="./app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
663
styles.css
Normal file
663
styles.css
Normal file
@@ -0,0 +1,663 @@
|
||||
:root {
|
||||
--bg: #0b0e14;
|
||||
--bg-grad-a: #16202f;
|
||||
--bg-grad-b: #0a1310;
|
||||
--panel: #111826;
|
||||
--line: #2a3347;
|
||||
--text: #e7ecf7;
|
||||
--muted: #9eabc2;
|
||||
--accent: #60c3ff;
|
||||
--accent-2: #83e7ba;
|
||||
--warning: #ff8585;
|
||||
--ui-size: 14px;
|
||||
--ui-size-sm: 12px;
|
||||
--ui-line: 1.35;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Outfit", "IBM Plex Sans", system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 8% 12%, rgba(96, 195, 255, 0.2), transparent 42%),
|
||||
radial-gradient(circle at 88% 4%, rgba(131, 231, 186, 0.18), transparent 45%),
|
||||
linear-gradient(170deg, var(--bg) 0%, var(--bg-grad-a) 55%, var(--bg-grad-b) 100%);
|
||||
}
|
||||
|
||||
.layout {
|
||||
width: min(1360px, 100% - 2rem);
|
||||
margin: 1rem auto;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(650px, 1fr) minmax(340px, 534px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, rgba(17, 24, 38, 0.95), rgba(11, 17, 29, 0.95));
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.45rem 0 1rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#noteForm {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.group {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
margin: 0;
|
||||
background: rgba(15, 22, 35, 0.85);
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0 0.35rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
color: var(--muted);
|
||||
font-size: var(--ui-size);
|
||||
line-height: var(--ui-line);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
font-size: var(--ui-size);
|
||||
line-height: var(--ui-line);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.58rem 0.65rem;
|
||||
background: #0e1525;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid rgba(96, 195, 255, 0.45);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.row-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.row-grid > label {
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
|
||||
.row-grid > label:has(textarea) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.row-grid > textarea {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.row-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 140px minmax(0, 1fr) 84px;
|
||||
gap: 0.7rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.row-controls .style-tools {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.row-controls.no-icon {
|
||||
grid-template-columns: minmax(0, 1fr) 84px;
|
||||
}
|
||||
|
||||
.row-fields {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.icon-top-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 84px;
|
||||
gap: 0.7rem;
|
||||
align-items: start;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.icon-field {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.icon-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.icon-input-wrap input {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.icon-clear {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2f4b77;
|
||||
background: #0f1b30;
|
||||
color: #a9cbf5;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.icon-clear:hover {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
.row-reorder {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.row-move {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
min-width: 34px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2f4b77;
|
||||
background: #0f1b30;
|
||||
color: #a9cbf5;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.row-move:hover {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
.inline-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.inline-check input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.inline-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.inline-help input {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.style-tools {
|
||||
margin-top: 0.6rem;
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(0, 1fr);
|
||||
gap: 0.7rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.tool-set {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tool-title {
|
||||
font-size: var(--ui-size);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #91a4c2;
|
||||
}
|
||||
|
||||
.tool-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tool-chip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 42px;
|
||||
height: 32px;
|
||||
padding: 0 0.55rem;
|
||||
border-radius: 9px;
|
||||
border: 1px solid #334360;
|
||||
background: #0e172a;
|
||||
color: #c3d3ea;
|
||||
font-size: var(--ui-size-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tool-chip input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-chip:has(input:checked) {
|
||||
background: linear-gradient(130deg, #63c2ff, #8ce5c4);
|
||||
color: #052132;
|
||||
border-color: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scale-row {
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
#iconMode {
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
#iconUrlWrap,
|
||||
#iconUploadWrap {
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.scale-value {
|
||||
font-size: var(--ui-size);
|
||||
color: #c1d8f5;
|
||||
}
|
||||
|
||||
.scale-label {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin: 0.55rem 0;
|
||||
}
|
||||
|
||||
.stack-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 0.58rem 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: #04121e;
|
||||
background: linear-gradient(125deg, var(--accent), var(--accent-2));
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: #0d1730;
|
||||
color: #9dccff;
|
||||
border-color: #28406f;
|
||||
}
|
||||
|
||||
.icon-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.icon-help a {
|
||||
color: #87d0ff;
|
||||
}
|
||||
|
||||
.info-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid #456087;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #a8cfff;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: -4px;
|
||||
min-width: 220px;
|
||||
padding: 0.48rem 0.56rem;
|
||||
border: 1px solid #3e4d69;
|
||||
border-radius: 8px;
|
||||
background: #0a101c;
|
||||
color: #c2cee3;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 0.14s ease, transform 0.14s ease;
|
||||
}
|
||||
|
||||
.info-wrap:hover .tooltip,
|
||||
.info-wrap:focus-within .tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.status {
|
||||
min-height: 1.1rem;
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--ui-size-sm);
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: #ff9b9b;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.panel-preview {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.preview-wrap {
|
||||
width: 100%;
|
||||
flex: 0 1 auto;
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
background: #111a2a;
|
||||
color: #b6c9e6;
|
||||
border: 1px solid #2f405e;
|
||||
padding: 0.35rem 0.62rem;
|
||||
}
|
||||
|
||||
.toggle.active {
|
||||
color: #072031;
|
||||
border-color: transparent;
|
||||
background: linear-gradient(120deg, var(--accent), var(--accent-2));
|
||||
}
|
||||
|
||||
.preview-shell {
|
||||
width: 100%;
|
||||
min-width: 340px;
|
||||
}
|
||||
|
||||
.preview-shell.dark {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.preview-shell.light {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
min-height: 35px;
|
||||
padding: 9px 9px 10px;
|
||||
font-family: helvetica, arial, verdana, sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 300;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.preview-shell.dark .preview-title {
|
||||
color: #4db5ff;
|
||||
background: #333333;
|
||||
}
|
||||
|
||||
.preview-shell.light .preview-title {
|
||||
color: #157fcc;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
border: 1px solid #cfcfcf;
|
||||
height: 313px;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
font-family: helvetica, arial, verdana, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
line-height: 18.2px;
|
||||
}
|
||||
|
||||
.preview-shell.dark .preview-card {
|
||||
background: #262626;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
.preview-shell.light .preview-card {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.preview-shell.dark .preview-body {
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.preview-shell.light .preview-body {
|
||||
border-color: #cfcfcf;
|
||||
}
|
||||
|
||||
.preview-card img {
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preview-shell.dark .preview-card a {
|
||||
color: #4db5ff;
|
||||
}
|
||||
|
||||
.preview-shell.light .preview-card a {
|
||||
color: #157fcc;
|
||||
}
|
||||
|
||||
.preview-card p {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.preview-card :is(h1, h2, h3, h4, h5, h6) {
|
||||
margin-top: 0.9em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.preview-card h1 {
|
||||
font-size: 22.75px;
|
||||
font-weight: 700;
|
||||
line-height: 31.85px;
|
||||
}
|
||||
|
||||
.preview-card h2 {
|
||||
font-size: 19.5px;
|
||||
font-weight: 700;
|
||||
line-height: 27.3px;
|
||||
}
|
||||
|
||||
.preview-card h3 {
|
||||
font-size: 16.25px;
|
||||
font-weight: 700;
|
||||
line-height: 22.75px;
|
||||
}
|
||||
|
||||
.preview-card h4 {
|
||||
font-size: 14.3px;
|
||||
font-weight: 700;
|
||||
line-height: 20.02px;
|
||||
}
|
||||
|
||||
.preview-card h5 {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 18.2px;
|
||||
}
|
||||
|
||||
.preview-card b {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18.2px;
|
||||
}
|
||||
|
||||
.preview-card i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.preview-card strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 18.2px;
|
||||
}
|
||||
|
||||
.preview-card code {
|
||||
white-space: pre;
|
||||
padding: 1px;
|
||||
font-family: "IBM Plex Mono", Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.preview-shell.dark .preview-card code {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
.preview-shell.light .preview-card code {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.output-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
margin: 0.7rem 0 0.35rem;
|
||||
font-size: var(--ui-size);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.style-tools {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.row-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.row-controls.no-icon {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.icon-top-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.preview-wrap {
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.preview-shell {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user