mirror of
https://github.com/MasterAcnolo/Freedom-Loader.git
synced 2026-11-13 15:09:20 +01:00
feat: implement changelog feature with UI modal and caching
This commit is contained in:
177
app/changelogManager.js
Normal file
177
app/changelogManager.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
const { app } = require("electron");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const https = require("https");
|
||||||
|
const config = require("../config");
|
||||||
|
const { logger } = require("../server/logger");
|
||||||
|
|
||||||
|
/** Maximum age of the cached release list before it must be refreshed. */
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Filename used to persist changelog data in the Electron user data folder. */
|
||||||
|
const CACHE_FILE_NAME = "changelog-cache.json";
|
||||||
|
|
||||||
|
/** GitHub API endpoint containing the application's published releases. */
|
||||||
|
const RELEASES_URL = "https://api.github.com/repos/MasterAcnolo/Freedom-Loader/releases";
|
||||||
|
|
||||||
|
/** Returns the path of the local changelog cache file. */
|
||||||
|
function getCachePath() {
|
||||||
|
return path.join(app.getPath("userData"), CACHE_FILE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the numeric major, minor, and patch parts from a version string.
|
||||||
|
*
|
||||||
|
* @param {string} version - Version string or release tag
|
||||||
|
* @returns {number[]|null} Parsed version parts, or null when invalid
|
||||||
|
*/
|
||||||
|
function normalizeVersion(version) {
|
||||||
|
const match = String(version || "").match(/(\d+)\.(\d+)\.(\d+)/);
|
||||||
|
return match ? match.slice(1).map(Number) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two semantic version strings.
|
||||||
|
*
|
||||||
|
* @param {string} left - First version to compare
|
||||||
|
* @param {string} right - Second version to compare
|
||||||
|
* @returns {number} Negative, zero, or positive comparison result
|
||||||
|
*/
|
||||||
|
function compareVersions(left, right) {
|
||||||
|
const leftParts = normalizeVersion(left) || [0, 0, 0];
|
||||||
|
const rightParts = normalizeVersion(right) || [0, 0, 0];
|
||||||
|
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
if (leftParts[index] !== rightParts[index]) {
|
||||||
|
return leftParts[index] - rightParts[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads and parses the cached changelog, if one is available. */
|
||||||
|
function readCache() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(getCachePath(), "utf8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persists the fetched releases together with their cache timestamp. */
|
||||||
|
function writeCache(releases) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
getCachePath(),
|
||||||
|
JSON.stringify({ cachedAt: Date.now(), releases }, null, 2),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Unable to cache changelog: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches published releases from GitHub and maps them to the UI data shape.
|
||||||
|
*
|
||||||
|
* @returns {Promise<object[]>} Published releases
|
||||||
|
*/
|
||||||
|
function fetchReleases() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = https.get(
|
||||||
|
RELEASES_URL,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
"User-Agent": "Freedom-Loader",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
let body = "";
|
||||||
|
response.setEncoding("utf8");
|
||||||
|
response.on("data", (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
response.on("end", () => {
|
||||||
|
// Do not attempt to parse an error response as a release list.
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(new Error(`GitHub returned HTTP ${response.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Draft releases are not visible to users and should not appear in the changelog.
|
||||||
|
const releases = JSON.parse(body)
|
||||||
|
.filter((release) => !release.draft)
|
||||||
|
.map((release) => ({
|
||||||
|
version: release.tag_name,
|
||||||
|
name: release.name || release.tag_name,
|
||||||
|
date: release.published_at || release.created_at,
|
||||||
|
body: release.body || "No changelog available.",
|
||||||
|
url: release.html_url,
|
||||||
|
}));
|
||||||
|
resolve(releases);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
request.setTimeout(10000, () => {
|
||||||
|
request.destroy(new Error("Changelog request timed out"));
|
||||||
|
});
|
||||||
|
request.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns cached or freshly fetched releases and identifies unread versions.
|
||||||
|
*
|
||||||
|
* @returns {Promise<object>} Changelog data for the renderer
|
||||||
|
*/
|
||||||
|
async function getChangelog() {
|
||||||
|
const cache = readCache();
|
||||||
|
let releases = cache?.releases || [];
|
||||||
|
const cacheIsFresh = cache && cache.releases?.length > 0 && Date.now() - cache.cachedAt < CACHE_TTL_MS;
|
||||||
|
|
||||||
|
// Refresh stale or missing data, while keeping the last cache as a fallback.
|
||||||
|
if (!cacheIsFresh) {
|
||||||
|
try {
|
||||||
|
releases = await fetchReleases();
|
||||||
|
writeCache(releases);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Unable to retrieve changelog: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSeenVersion = cache?.lastSeenVersion || null;
|
||||||
|
|
||||||
|
// Without a previous version, all available releases are considered unread.
|
||||||
|
const unreadReleases = lastSeenVersion
|
||||||
|
? releases.filter((release) => compareVersions(release.version, lastSeenVersion) > 0)
|
||||||
|
: releases;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// An empty release list means that the fetch failed or returned no releases.
|
||||||
|
error: releases.length === 0,
|
||||||
|
currentVersion: config.version,
|
||||||
|
lastSeenVersion,
|
||||||
|
hasUnread: unreadReleases.length > 0,
|
||||||
|
releases,
|
||||||
|
unreadReleases,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks the current application version as seen in the local cache. */
|
||||||
|
function markChangelogSeen() {
|
||||||
|
const cache = readCache() || { releases: [] };
|
||||||
|
cache.lastSeenVersion = config.version;
|
||||||
|
cache.cachedAt = cache.cachedAt || 0 ;
|
||||||
|
fs.mkdirSync(path.dirname(getCachePath()), { recursive: true });
|
||||||
|
fs.writeFileSync(getCachePath(), JSON.stringify(cache, null, 2), "utf8");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getChangelog, markChangelogSeen };
|
||||||
@@ -21,6 +21,7 @@ const { userThemesPath } = require("../server/helpers/path.helpers");
|
|||||||
const { createSystemTray, destroyTray } = require("./tray");
|
const { createSystemTray, destroyTray } = require("./tray");
|
||||||
const {sendReport, checkReportService, getReportServiceStatus} = require("./sendReport");
|
const {sendReport, checkReportService, getReportServiceStatus} = require("./sendReport");
|
||||||
const { isUpdateAvailable, downloadUpdate, installUpdate } = require("./autoUpdater");
|
const { isUpdateAvailable, downloadUpdate, installUpdate } = require("./autoUpdater");
|
||||||
|
const { getChangelog, markChangelogSeen } = require("./changelogManager");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security whitelist for feature flags that can be modified at runtime.
|
* Security whitelist for feature flags that can be modified at runtime.
|
||||||
@@ -71,6 +72,16 @@ function registerIpcHandlers(getMainWindow) {
|
|||||||
*/
|
*/
|
||||||
ipcMain.handle("version", () => config.version);
|
ipcMain.handle("version", () => config.version);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the changelog from cache or GitHub API.
|
||||||
|
*/
|
||||||
|
ipcMain.handle("get-changelog", () => getChangelog());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks all changelog entries as seen and updates cache.
|
||||||
|
*/
|
||||||
|
ipcMain.handle("mark-changelog-seen", () => markChangelogSeen());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns runtime feature configuration object.
|
* Returns runtime feature configuration object.
|
||||||
*/
|
*/
|
||||||
|
|||||||
10
preload.js
10
preload.js
@@ -69,6 +69,16 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
*/
|
*/
|
||||||
getVersion: () => ipcRenderer.invoke("version"),
|
getVersion: () => ipcRenderer.invoke("version"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the changelog data
|
||||||
|
*/
|
||||||
|
getChangelog: () => ipcRenderer.invoke("get-changelog"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the current changelog version as seen by the user.
|
||||||
|
*/
|
||||||
|
markChangelogSeen: () => ipcRenderer.invoke("mark-changelog-seen"),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a download path before using it for file operations.
|
* Validates a download path before using it for file operations.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<button id="website-btn" title="Website">Website</button>
|
<button id="website-btn" title="Website">Website</button>
|
||||||
<button id="wiki-btn" title="Wiki">Wiki</button>
|
<button id="wiki-btn" title="Wiki">Wiki</button>
|
||||||
<button id="workshop-btn" title="Workshop">Theme Workshop</button>
|
<button id="workshop-btn" title="Workshop">Theme Workshop</button>
|
||||||
|
<button id="changelog-btn" title="Changelog">Changelog</button>
|
||||||
<!-- <button id="theme-btn" title="Theme">Theme</button> -->
|
<!-- <button id="theme-btn" title="Theme">Theme</button> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="window-controls">
|
<div class="window-controls">
|
||||||
@@ -303,6 +304,21 @@
|
|||||||
<!-- Toast Container -->
|
<!-- Toast Container -->
|
||||||
<div id="toast-container"></div>
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
<div class="changelog-overlay" id="changelog-overlay" hidden>
|
||||||
|
<section class="changelog-modal" role="dialog" aria-modal="true" aria-labelledby="changelog-title">
|
||||||
|
<div class="changelog-header">
|
||||||
|
<div>
|
||||||
|
<span class="changelog-eyebrow">Freedom Loader</span>
|
||||||
|
<h2 id="changelog-title">What's new</h2>
|
||||||
|
</div>
|
||||||
|
<button class="changelog-close" id="changelog-close" type="button" title="Close changelog" aria-label="Close changelog">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="changelog-list" id="changelog-list">
|
||||||
|
<p class="changelog-loading">Loading changelog...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 id="reference"> Because Why Not ? </h2>
|
<h2 id="reference"> Because Why Not ? </h2>
|
||||||
|
|
||||||
<div id="updateAvailable" style="display:none">
|
<div id="updateAvailable" style="display:none">
|
||||||
@@ -343,6 +359,7 @@
|
|||||||
<script src="script/customthemes.js"></script>
|
<script src="script/customthemes.js"></script>
|
||||||
<script src="script/reportBug.js"></script>
|
<script src="script/reportBug.js"></script>
|
||||||
<script src="script/autoUpdate.js"></script>
|
<script src="script/autoUpdate.js"></script>
|
||||||
|
<script type="module" src="script/changelog.js"></script>
|
||||||
<script type="module" src="script/topbar.js"></script>
|
<script type="module" src="script/topbar.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
277
public/script/changelog.js
Normal file
277
public/script/changelog.js
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
/** DOM elements used by the changelog modal. */
|
||||||
|
const UI = {
|
||||||
|
overlay: document.getElementById("changelog-overlay"),
|
||||||
|
list: document.getElementById("changelog-list"),
|
||||||
|
closeBtn: document.getElementById("changelog-close"),
|
||||||
|
openBtn: document.getElementById("changelog-btn")
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Changelog payload loaded from the Electron main process. */
|
||||||
|
let changelogData = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an ISO release date for the current locale.
|
||||||
|
*
|
||||||
|
* @param {string} dateString - Release date returned by GitHub
|
||||||
|
* @returns {string} Localized date or an empty string
|
||||||
|
*/
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return "";
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(new Date(dateString));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses inline Markdown and appends safe DOM nodes to a parent element.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} parent - Element receiving the parsed content
|
||||||
|
* @param {string} value - Inline Markdown text
|
||||||
|
*/
|
||||||
|
function parseInline(parent, value) {
|
||||||
|
const tokenPattern = /(\*\*|__)(.+?)\1|(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_)|`([^`]+)`|~~([^~]+)~~|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = tokenPattern.exec(value)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parent.appendChild(document.createTextNode(value.slice(lastIndex, match.index)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = match[7]
|
||||||
|
? document.createElement("a")
|
||||||
|
: document.createElement(match[1] ? "strong" : match[3] || match[4] ? "em" : match[5] ? "code" : "del");
|
||||||
|
|
||||||
|
element.textContent = match[7] || match[2] || match[3] || match[4] || match[5] || match[6];
|
||||||
|
|
||||||
|
if (match[7]) {
|
||||||
|
element.href = match[8];
|
||||||
|
element.target = "_blank";
|
||||||
|
element.rel = "noopener noreferrer";
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.appendChild(element);
|
||||||
|
lastIndex = tokenPattern.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < value.length) {
|
||||||
|
parent.appendChild(document.createTextNode(value.slice(lastIndex)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses block-level Markdown such as headings, lists, quotes and code blocks.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} parent - Element receiving the parsed content
|
||||||
|
* @param {string} markdown - Release body in Markdown format
|
||||||
|
*/
|
||||||
|
function parseBlock(parent, markdown) {
|
||||||
|
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
|
||||||
|
let paragraph = [];
|
||||||
|
let list = null;
|
||||||
|
let codeBlock = null;
|
||||||
|
|
||||||
|
const flushParagraph = () => {
|
||||||
|
if (!paragraph.length) return;
|
||||||
|
const element = document.createElement("p");
|
||||||
|
paragraph.forEach((line, index) => {
|
||||||
|
if (index) element.appendChild(document.createElement("br"));
|
||||||
|
parseInline(element, line);
|
||||||
|
});
|
||||||
|
parent.appendChild(element);
|
||||||
|
paragraph = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeList = () => {
|
||||||
|
if (list) {
|
||||||
|
parent.appendChild(list);
|
||||||
|
list = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.forEach((line) => {
|
||||||
|
if (line.trim().startsWith("```")) {
|
||||||
|
flushParagraph();
|
||||||
|
closeList();
|
||||||
|
if (codeBlock) {
|
||||||
|
const code = document.createElement("code");
|
||||||
|
code.textContent = codeBlock.join("\n");
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.appendChild(code);
|
||||||
|
parent.appendChild(pre);
|
||||||
|
codeBlock = null;
|
||||||
|
} else {
|
||||||
|
codeBlock = [];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codeBlock) {
|
||||||
|
codeBlock.push(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||||
|
const unorderedItem = line.match(/^\s*[-*+]\s+(.+)$/);
|
||||||
|
const orderedItem = line.match(/^\s*\d+[.]\s+(.+)$/);
|
||||||
|
const quote = line.match(/^\s*>\s?(.*)$/);
|
||||||
|
|
||||||
|
if (!line.trim()) {
|
||||||
|
flushParagraph();
|
||||||
|
closeList();
|
||||||
|
} else if (heading) {
|
||||||
|
flushParagraph();
|
||||||
|
closeList();
|
||||||
|
const element = document.createElement(`h${heading[1].length}`);
|
||||||
|
parseInline(element, heading[2]);
|
||||||
|
parent.appendChild(element);
|
||||||
|
} else if (unorderedItem || orderedItem) {
|
||||||
|
flushParagraph();
|
||||||
|
const isOrdered = Boolean(orderedItem);
|
||||||
|
if (!list || list.tagName.toLowerCase() !== (isOrdered ? "ol" : "ul")) {
|
||||||
|
closeList();
|
||||||
|
list = document.createElement(isOrdered ? "ol" : "ul");
|
||||||
|
}
|
||||||
|
const item = document.createElement("li");
|
||||||
|
parseInline(item, (unorderedItem || orderedItem)[1]);
|
||||||
|
list.appendChild(item);
|
||||||
|
} else if (quote) {
|
||||||
|
flushParagraph();
|
||||||
|
closeList();
|
||||||
|
const element = document.createElement("blockquote");
|
||||||
|
parseInline(element, quote[1]);
|
||||||
|
parent.appendChild(element);
|
||||||
|
} else if (/^\s*([-*_])\s*\1\s*\1\s*$/.test(line)) {
|
||||||
|
flushParagraph();
|
||||||
|
closeList();
|
||||||
|
parent.appendChild(document.createElement("hr"));
|
||||||
|
} else {
|
||||||
|
paragraph.push(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
flushParagraph();
|
||||||
|
closeList();
|
||||||
|
if (codeBlock) {
|
||||||
|
const code = document.createElement("code");
|
||||||
|
code.textContent = codeBlock.join("\n");
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.appendChild(code);
|
||||||
|
parent.appendChild(pre);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders the empty state when no releases are available. */
|
||||||
|
function renderEmpty() {
|
||||||
|
const emptyMessage = document.createElement("p");
|
||||||
|
emptyMessage.className = "changelog-empty";
|
||||||
|
emptyMessage.textContent = "No changelog available.";
|
||||||
|
UI.list.appendChild(emptyMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one release entry in the changelog list.
|
||||||
|
*
|
||||||
|
* @param {object} release - Release data from the backend
|
||||||
|
*/
|
||||||
|
function renderEntry(release) {
|
||||||
|
const article = document.createElement("article");
|
||||||
|
article.className = "changelog-entry";
|
||||||
|
|
||||||
|
const heading = document.createElement("div");
|
||||||
|
heading.className = "changelog-entry-heading";
|
||||||
|
|
||||||
|
const title = document.createElement("h3");
|
||||||
|
title.textContent = release.name;
|
||||||
|
|
||||||
|
const metadata = document.createElement("span");
|
||||||
|
metadata.textContent = [release.version, formatDate(release.date)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
|
heading.append(title, metadata);
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "changelog-body";
|
||||||
|
parseBlock(body, release.body);
|
||||||
|
|
||||||
|
article.append(heading, body);
|
||||||
|
UI.list.appendChild(article);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders all releases, replacing the current modal contents. */
|
||||||
|
function renderReleases(releases) {
|
||||||
|
UI.list.replaceChildren();
|
||||||
|
if (!releases.length) {
|
||||||
|
renderEmpty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
releases.forEach((release) => renderEntry(release));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the changelog modal and moves focus to its close button. */
|
||||||
|
function openChangelog(releases) {
|
||||||
|
renderReleases(releases);
|
||||||
|
UI.overlay.hidden = false;
|
||||||
|
UI.closeBtn?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the modal and records the current version as seen. */
|
||||||
|
async function closeChangelog() {
|
||||||
|
UI.overlay.hidden = true;
|
||||||
|
await window.electronAPI.markChangelogSeen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads changelog data and opens the modal when unread releases exist. */
|
||||||
|
async function loadChangelog() {
|
||||||
|
try {
|
||||||
|
changelogData = await window.electronAPI.getChangelog();
|
||||||
|
if (!changelogData.error && changelogData.hasUnread) {
|
||||||
|
openChangelog(changelogData.unreadReleases);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Unable to load changelog:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles a click on the button that opens the changelog. */
|
||||||
|
async function handleOpenClick() {
|
||||||
|
if (!changelogData || changelogData.error) {
|
||||||
|
changelogData = await window.electronAPI.getChangelog();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changelogData.error) {
|
||||||
|
window.showError("Unable to load changelog. Please check your network connection.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openChangelog(changelogData.releases);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the modal when the user clicks on the overlay background. */
|
||||||
|
function handleOverlayClick(event) {
|
||||||
|
if (event.target === UI.overlay) closeChangelog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the modal when Escape is pressed. */
|
||||||
|
function handleKeydown(event) {
|
||||||
|
if (event.key === "Escape" && !UI.overlay.hidden) closeChangelog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers all changelog modal event listeners. */
|
||||||
|
function bindEvents() {
|
||||||
|
UI.openBtn?.addEventListener("click", handleOpenClick);
|
||||||
|
UI.closeBtn?.addEventListener("click", closeChangelog);
|
||||||
|
UI.overlay?.addEventListener("click", handleOverlayClick);
|
||||||
|
document.addEventListener("keydown", handleKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initializes the changelog data flow and modal interactions. */
|
||||||
|
function init() {
|
||||||
|
bindEvents();
|
||||||
|
loadChangelog();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
229
public/styles/components/changelog.css
Normal file
229
public/styles/components/changelog.css
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
.changelog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(5, 10, 16, 0.72);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-overlay[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(680px, 100%);
|
||||||
|
max-height: min(720px, 88vh);
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--settings-text-color, #f8fafc);
|
||||||
|
background: var(--settings-modal-bg-color, #18212b);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px 28px 18px;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-eyebrow {
|
||||||
|
color: var(--fill-accent, #0ea5e9);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-header h2 {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-close {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-close:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 28px 28px;
|
||||||
|
scrollbar-color: var(--fill-accent, #0ea5e9) transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-list::-webkit-scrollbar,
|
||||||
|
.changelog-body pre::-webkit-scrollbar {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-list::-webkit-scrollbar-track,
|
||||||
|
.changelog-body pre::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-list::-webkit-scrollbar-thumb,
|
||||||
|
.changelog-body pre::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, var(--fill-accent, #0ea5e9) 70%, transparent);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-list::-webkit-scrollbar-thumb:hover,
|
||||||
|
.changelog-body pre::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--fill-accent, #0ea5e9);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-entry {
|
||||||
|
padding: 18px 0;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-entry:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-entry:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-entry-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-entry-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-entry-heading span {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--settings-subtitle-color, #94a3b8);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-body {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--settings-subtitle-color, #cbd5e1);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-body p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-body p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-body h1,
|
||||||
|
.changelog-body h2,
|
||||||
|
.changelog-body h3,
|
||||||
|
.changelog-body h4,
|
||||||
|
.changelog-body h5,
|
||||||
|
.changelog-body h6 {
|
||||||
|
margin: 14px 0 8px;
|
||||||
|
color: var(--settings-text-color, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-body h1 { font-size: 20px; }
|
||||||
|
.changelog-body h2 { font-size: 18px; }
|
||||||
|
.changelog-body h3 { font-size: 16px; }
|
||||||
|
.changelog-body h4,
|
||||||
|
.changelog-body h5,
|
||||||
|
.changelog-body h6 { font-size: 14px; }
|
||||||
|
|
||||||
|
.changelog-body ul,
|
||||||
|
.changelog-body ol {
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-body li + li {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-body code {
|
||||||
|
padding: 2px 5px;
|
||||||
|
color: var(--settings-text-color, #f8fafc);
|
||||||
|
background: rgba(148, 163, 184, 0.16);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-body pre {
|
||||||
|
padding: 10px 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
background: rgba(15, 23, 42, 0.45);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-body pre code {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-body blockquote {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-left: 12px;
|
||||||
|
color: var(--settings-subtitle-color, #cbd5e1);
|
||||||
|
border-left: 3px solid var(--fill-accent, #0ea5e9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-body a {
|
||||||
|
color: var(--fill-accent, #0ea5e9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-loading,
|
||||||
|
.changelog-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--settings-subtitle-color, #cbd5e1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 780px) {
|
||||||
|
.changelog-overlay {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-header,
|
||||||
|
.changelog-list {
|
||||||
|
padding-left: 18px;
|
||||||
|
padding-right: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-entry-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
@import url("components/progressBar.css");
|
@import url("components/progressBar.css");
|
||||||
@import url("components/toast.css");
|
@import url("components/toast.css");
|
||||||
@import url("components/autoUpdate.css");
|
@import url("components/autoUpdate.css");
|
||||||
|
@import url("components/changelog.css");
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user