feat: show what's new after an update

The first time the backend starts on a newer version than it last ran, every
user gets a system notification that opens a What's new dialog; a fresh
install isn't told. The dialog is also in the user menu.

Its content is fetched from main, so it can be written after a release:
release-notes/latest-release-notes.json when its version matches the
instance's, otherwise the backend's CHANGELOG.md entry with the frontend
release that went out with it. An instance that skipped versions gets every
release since the one it ran, up to five. Curated items can be highlighted,
carry screenshots and a command to copy. RELEASE_NOTES_URL points at another
copy, or turns fetching off when empty.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-21 21:15:29 +02:00
1 parent f7870d0067
commit aebc0ac97e
18 files changed
+1351 -3

No files matched your search

+2
View File
@@ -25,6 +25,7 @@ import providerCollectionsRoutes from "./routes/providerCollections";
import bookmarksRoutes from "./routes/bookmarks";
import searchRoutes from "./routes/search";
import notificationsRoutes from "./routes/notifications";
import releaseNotesRoutes from "./routes/releaseNotes";
import adminRoutes from "./routes/admin";
import dashboardRoutes from "./routes/dashboard";
import activityRoutes from "./routes/activity";
@@ -71,6 +72,7 @@ export function createApp(): Express {
app.use("/api", bookmarksRoutes);
app.use("/api", searchRoutes);
app.use("/api", notificationsRoutes);
app.use("/api", releaseNotesRoutes);
// Own prefix: on bare /api, its requireAdmin would also run for every route mounted after it.
app.use("/api/admin", adminRoutes);
app.use("/api", dashboardRoutes);
+7
View File
@@ -144,4 +144,11 @@ export const SYSTEM_NOTIFICATIONS_URL = (
process.env.SYSTEM_NOTIFICATIONS_URL ?? DEFAULT_SYSTEM_NOTIFICATIONS_URL
).trim();
export const DEFAULT_RELEASE_NOTES_URL = "https://raw.githubusercontent.com/TautvydasDerzinskas/Thingport/main";
// The repo files "What's new" reads: the CHANGELOG.md files and release-notes/latest-release-notes.json. Set to an
// empty value to stop fetching them.
export const RELEASE_NOTES_URL = (process.env.RELEASE_NOTES_URL ?? DEFAULT_RELEASE_NOTES_URL)
.trim()
.replace(/\/+$/, "");
export const API_PORT = envInt("API_PORT", 8000);
+35
View File
@@ -0,0 +1,35 @@
import { Router } from "express";
import { z } from "zod";
import { requireAuth } from "../auth";
import { asyncHandler } from "../utils/asyncHandler";
import { HttpError } from "../utils/fileUtils";
import { parseBody } from "../utils/validate";
import { getReleaseNotes } from "../services/releaseNotesService";
const router = Router();
const semver = z
.string()
.trim()
.regex(/^\d+\.\d+\.\d+$/);
const querySchema = z.object({
// This backend's version when omitted.
version: semver.optional(),
// The version updated from: the notes then cover every release since.
since: semver.optional(),
// The frontend is versioned apart from the backend, and only it knows its own.
frontend: semver.optional(),
});
router.get(
"/release-notes",
requireAuth,
asyncHandler(async (req, res) => {
const { version, since, frontend } = parseBody(querySchema, req.query);
const notes = await getReleaseNotes({ version, since, frontendVersion: frontend });
if (!notes) throw new HttpError(404, "This instance's version is unknown");
res.json(notes);
}),
);
export default router;
+2
View File
@@ -7,6 +7,7 @@ import { SYNC_JOB_INTERRUPTED } from "./services/importJobRunner";
import { seedFlaresolverrUrl, seedPublicUrl } from "./services/settingsService";
import { startNotificationCleanup } from "./services/notificationService";
import { startSystemNotificationSync } from "./services/systemNotificationService";
import { announceVersionUpdate } from "./services/releaseNotesService";
import { resumeStorageMove } from "./services/storageMoveService";
const app = createApp();
@@ -33,5 +34,6 @@ app.listen(API_PORT, () => {
startCollectionSyncScheduler();
startNotificationCleanup();
startSystemNotificationSync();
announceVersionUpdate().catch((err) => console.error("Failed to announce the version update:", err));
resumeStorageMove().catch((err) => console.error("Failed to resume moving model files:", err));
});
+310
View File
@@ -0,0 +1,310 @@
import { z } from "zod";
import { prisma } from "../db";
import { RELEASE_NOTES_URL } from "../config";
import { compareVersions } from "./systemNotificationService";
import { GITHUB_REPO, getBackendVersion } from "./versionService";
const FETCH_TIMEOUT_MS = 15_000;
const CACHE_TTL_MS = 30 * 60 * 1000;
// Someone skipping many releases gets the most recent ones, not a wall of every fix since.
const MAX_VERSIONS = 5;
const LAST_RUN_VERSION_KEY = "last_run_version";
const SEMVER = /^\d+\.\d+\.\d+$/;
export const CURATED_NOTES_PATH = "release-notes/latest-release-notes.json";
export type ReleaseNoteKind = "breaking" | "feature" | "improvement" | "fix";
const KIND_ORDER: ReleaseNoteKind[] = ["breaking", "feature", "improvement", "fix"];
export type ReleaseNoteItem = {
kind: ReleaseNoteKind;
title: string;
description: string | null;
/** The conventional-commit scope, e.g. "i18n". */
scope: string | null;
images: { url: string; caption: string | null }[];
/** A command to copy, e.g. how to connect an AI assistant. */
code: string | null;
/** Shown as a large card above the rest. */
highlight: boolean;
url: string | null;
};
export type ReleaseNotes = {
version: string;
/** "curated" from the hand-written file, "changelog" from CHANGELOG.md, "none" when neither could be read. */
source: "curated" | "changelog" | "none";
title: string | null;
summary: string | null;
items: ReleaseNoteItem[];
releases_url: string;
};
/** Where a notification opens the What's new dialog; a bare query keeps whichever page is open. */
export function whatsNewPath(version: string, since: string | null): string {
const params = new URLSearchParams({ "whats-new": version });
if (since) params.set("since", since);
return `?${params}`;
}
/** At startup: when this version is newer than the one that ran last, every user gets a notification that opens
* What's new. A fresh install has no users yet, so only an instance that ran before this check is told. */
export async function announceVersionUpdate(version = getBackendVersion(), now = new Date()): Promise<boolean> {
if (!version || !SEMVER.test(version)) return false;
const row = await prisma.setting.findUnique({ where: { key: LAST_RUN_VERSION_KEY } });
const previous = typeof row?.value === "string" && SEMVER.test(row.value) ? row.value : null;
if (previous === version) return false;
const updated = previous ? compareVersions(version, previous) > 0 : (await prisma.user.count()) > 0;
const users = updated ? await prisma.user.findMany({ select: { id: true } }) : [];
await prisma.$transaction([
prisma.setting.upsert({
where: { key: LAST_RUN_VERSION_KEY },
create: { key: LAST_RUN_VERSION_KEY, value: version },
update: { value: version },
}),
prisma.notification.createMany({
data: users.map((user) => ({
userId: user.id,
type: "system",
title: `Thanks for updating to Thingport ${version}`,
body: "This release brings a lot of new things. Click here to see what's new.",
internalPath: whatsNewPath(version, previous),
createdAt: now,
})),
}),
]);
return updated;
}
const curatedSchema = z.object({
version: z.string().trim().regex(SEMVER),
title: z.string().trim().max(200).nullish(),
summary: z.string().trim().max(2000).nullish(),
items: z
.array(
z.object({
type: z.enum(["breaking", "feature", "improvement", "fix"]),
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(2000).nullish(),
highlight: z.boolean().default(false),
code: z.string().trim().max(1000).nullish(),
// Relative to the JSON file, e.g. "screenshots/1.2.0-dashboard.png", or a full https URL.
images: z
.array(z.object({ src: z.string().trim().min(1).max(500), caption: z.string().trim().max(200).nullish() }))
.max(6)
.default([]),
url: z
.string()
.url()
.refine((value) => value.startsWith("https://"))
.nullish(),
}),
)
.min(1)
.max(100),
});
/** Null when the file is malformed: the changelog is shown instead. */
export function parseCuratedNotes(data: unknown, fileUrl: string): Omit<ReleaseNotes, "releases_url"> | null {
const parsed = curatedSchema.safeParse(data);
if (!parsed.success) return null;
const allowed = new Set(["https:", new URL(fileUrl).protocol]);
const resolve = (src: string): string | null => {
try {
const url = new URL(src, fileUrl);
return allowed.has(url.protocol) ? url.toString() : null;
} catch {
return null;
}
};
const { version, title, summary, items } = parsed.data;
return {
version,
source: "curated",
title: title || null,
summary: summary || null,
items: sortItems(
items.map((item) => ({
kind: item.type,
title: item.title,
description: item.description || null,
scope: null,
images: item.images.flatMap((image) => {
const url = resolve(image.src);
return url ? [{ url, caption: image.caption || null }] : [];
}),
code: item.code || null,
highlight: item.highlight,
url: item.url ?? null,
})),
),
};
}
export type ChangelogRelease = {
version: string;
/** YYYY-MM-DD */
date: string | null;
items: (ReleaseNoteItem & { sha: string | null })[];
};
// "# [thingport-backend-v1.1.0](...compare...) (2026-10-20)" or "# thingport-backend-v1.0.0 (2026-10-17)";
// semantic-release gives patch releases "##".
const RELEASE_HEADING = /^#{1,2} \[?[\w.-]*?v?(\d+\.\d+\.\d+)\]?(?:\([^)]*\))?(?: \((\d{4}-\d{2}-\d{2})\))?\s*$/;
const SECTION_HEADING = /^#{3,4} (.+?)\s*$/;
const SECTION_KINDS: Record<string, ReleaseNoteKind> = {
Features: "feature",
"Bug Fixes": "fix",
"Performance Improvements": "improvement",
"BREAKING CHANGES": "breaking",
};
// "* **i18n:** count plural forms ([993c75c](https://.../commit/993c...)), closes [#91](...)"
const ITEM = /^[*-] (?:\*\*([^*]+?):\*\* )?(.+?)(?: \(\[([0-9a-f]{7,40})\]\((https:\/\/[^)\s]+)\)\))?(?:, closes .*)?$/;
/** Commit subjects can carry markdown, e.g. "([#16](https://...))"; the dialog shows plain text. */
function plainTitle(text: string): string {
const plain = text
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[*`]/g, "")
.trim();
return plain.charAt(0).toUpperCase() + plain.slice(1);
}
/** The releases in a semantic-release CHANGELOG.md, newest first as written. */
export function parseChangelog(markdown: string): ChangelogRelease[] {
const releases: ChangelogRelease[] = [];
let release: ChangelogRelease | null = null;
let kind: ReleaseNoteKind | null = null;
for (const rawLine of markdown.split(/\r?\n/)) {
const line = rawLine.trimEnd();
const heading = line.match(RELEASE_HEADING);
if (heading) {
release = { version: heading[1], date: heading[2] ?? null, items: [] };
releases.push(release);
kind = null;
continue;
}
const section = line.match(SECTION_HEADING);
if (section) {
kind = SECTION_KINDS[section[1]] ?? null;
continue;
}
if (!release || !kind) continue;
const item = line.match(ITEM);
if (!item) continue;
release.items.push({
kind,
title: plainTitle(item[2]),
description: null,
scope: item[1]?.trim() || null,
images: [],
code: null,
highlight: false,
url: item[4] ?? null,
sha: item[3] ?? null,
});
}
return releases;
}
function sortItems<T extends ReleaseNoteItem>(items: T[]): T[] {
return items
.map((item, index) => ({ item, index }))
.toSorted((a, b) => KIND_ORDER.indexOf(a.item.kind) - KIND_ORDER.indexOf(b.item.kind) || a.index - b.index)
.map(({ item }) => item);
}
/** The backend's releases after `since` up to `version` (just `version` without it), plus the frontend's releases
* out by then since the oldest of those: the two are versioned apart but released together. One change touching
* both shows once. */
export function notesFromChangelogs(
backend: ChangelogRelease[],
frontend: ChangelogRelease[],
options: { version: string; since?: string | null; frontendVersion?: string | null },
): ReleaseNoteItem[] {
const { version, since, frontendVersion } = options;
const inRange = (release: ChangelogRelease) =>
compareVersions(release.version, version) <= 0 &&
(since ? compareVersions(release.version, since) > 0 : release.version === version);
const backendReleases = backend
.filter(inRange)
.toSorted((a, b) => compareVersions(b.version, a.version))
.slice(0, MAX_VERSIONS);
const dates = backendReleases.map((r) => r.date).filter((d): d is string => Boolean(d));
const from = dates.length ? dates.reduce((a, b) => (a < b ? a : b)) : null;
const frontendReleases = from
? frontend
.filter(
(r) => r.date && r.date >= from && (!frontendVersion || compareVersions(r.version, frontendVersion) <= 0),
)
.slice(0, MAX_VERSIONS)
: [];
const seen = new Set<string>();
const items: ReleaseNoteItem[] = [];
for (const release of [...backendReleases, ...frontendReleases]) {
for (const { sha, ...item } of release.items) {
const key = sha ?? `${item.kind}\n${item.title.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
items.push(item);
}
}
return sortItems(items);
}
const cache = new Map<string, { at: number; text: string | null }>();
/** A failed fetch keeps the last good copy; misses are cached too, so a dialog opened offline doesn't wait each time. */
async function fetchText(url: string, now = Date.now()): Promise<string | null> {
const hit = cache.get(url);
if (hit && now - hit.at < CACHE_TTL_MS) return hit.text;
let text: string | null = null;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (res.ok) text = await res.text();
} catch {
// offline or blocked
}
cache.set(url, { at: now, text: text ?? hit?.text ?? null });
return text ?? hit?.text ?? null;
}
export function clearReleaseNotesCache(): void {
cache.clear();
}
export async function getReleaseNotes(
options: { version?: string | null; since?: string | null; frontendVersion?: string | null } = {},
baseUrl = RELEASE_NOTES_URL,
): Promise<ReleaseNotes | null> {
const version = options.version ?? getBackendVersion();
if (!version || !SEMVER.test(version)) return null;
const releases_url = `https://github.com/${GITHUB_REPO}/releases`;
const empty: ReleaseNotes = { version, source: "none", title: null, summary: null, items: [], releases_url };
if (!baseUrl) return empty;
const curatedUrl = `${baseUrl}/${CURATED_NOTES_PATH}`;
const [curatedText, backendText, frontendText] = await Promise.all([
fetchText(curatedUrl),
fetchText(`${baseUrl}/backend/CHANGELOG.md`),
fetchText(`${baseUrl}/frontend/CHANGELOG.md`),
]);
if (curatedText) {
try {
const curated = parseCuratedNotes(JSON.parse(curatedText), curatedUrl);
if (curated?.version === version) return { ...curated, releases_url };
} catch {
// not JSON: fall through to the changelog
}
}
if (!backendText) return empty;
const items = notesFromChangelogs(parseChangelog(backendText), parseChangelog(frontendText ?? ""), {
version,
since: options.since && SEMVER.test(options.since) ? options.since : null,
frontendVersion: options.frontendVersion && SEMVER.test(options.frontendVersion) ? options.frontendVersion : null,
});
return { ...empty, source: "changelog", items };
}
@@ -39,7 +39,7 @@ const entrySchema = z.object({
});
export type SystemNotificationEntry = z.infer<typeof entrySchema>;
function compareVersions(a: string, b: string): number {
export function compareVersions(a: string, b: string): number {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i];
+1 -1
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
const GITHUB_REPO = "TautvydasDerzinskas/Thingport";
export const GITHUB_REPO = "TautvydasDerzinskas/Thingport";
export type VersionCheckResult = {
backend_version: string | null;
+184
View File
@@ -0,0 +1,184 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import {
announceVersionUpdate,
notesFromChangelogs,
parseChangelog,
parseCuratedNotes,
} from "../src/services/releaseNotesService";
const BACKEND_CHANGELOG = `# Thingport backend changelog
# [thingport-backend-v1.2.0](https://github.com/o/r/compare/a...b) (2026-11-02)
### Bug Fixes
* **zip:** keep non-ASCII names readable ([060be2f](https://github.com/o/r/commit/060be2f1)), closes [#9](https://github.com/o/r/issues/9)
* shared fix ([aaaaaaa](https://github.com/o/r/commit/aaaaaaa1))
### Features
* add MCP endpoint, closing ([#16](https://github.com/o/r/issues/16)) ([bbbbbbb](https://github.com/o/r/commit/bbbbbbb1))
## [thingport-backend-v1.1.1](https://github.com/o/r/compare/a...b) (2026-10-25)
### Performance Improvements
* load listings faster ([ccccccc](https://github.com/o/r/commit/ccccccc1))
# thingport-backend-v1.1.0 (2026-10-20)
### Features
* old feature ([ddddddd](https://github.com/o/r/commit/ddddddd1))
`;
const FRONTEND_CHANGELOG = `# Thingport frontend changelog
# [thingport-frontend-v1.3.0](https://github.com/o/r/compare/a...b) (2026-11-02)
### Bug Fixes
* shared fix ([aaaaaaa](https://github.com/o/r/commit/aaaaaaa1))
### Features
* pick an accent colour ([eeeeeee](https://github.com/o/r/commit/eeeeeee1))
# [thingport-frontend-v1.2.0](https://github.com/o/r/compare/a...b) (2026-10-20)
### Features
* old frontend feature ([fffffff](https://github.com/o/r/commit/fffffff1))
`;
describe("release notes", () => {
it("parses a semantic-release changelog", () => {
const releases = parseChangelog(BACKEND_CHANGELOG);
expect(releases.map((r) => [r.version, r.date])).toEqual([
["1.2.0", "2026-11-02"],
["1.1.1", "2026-10-25"],
["1.1.0", "2026-10-20"],
]);
expect(releases[0].items[0]).toMatchObject({
kind: "fix",
scope: "zip",
title: "Keep non-ASCII names readable",
url: "https://github.com/o/r/commit/060be2f1",
sha: "060be2f",
});
expect(releases[1].items[0].kind).toBe("improvement");
});
it("takes one release, features first, with the frontend's from the same day and shared commits once", () => {
const items = notesFromChangelogs(parseChangelog(BACKEND_CHANGELOG), parseChangelog(FRONTEND_CHANGELOG), {
version: "1.2.0",
frontendVersion: "1.3.0",
});
expect(items.map((i) => [i.kind, i.title])).toEqual([
["feature", "Add MCP endpoint, closing (#16)"],
["feature", "Pick an accent colour"],
["fix", "Keep non-ASCII names readable"],
["fix", "Shared fix"],
]);
});
it("covers every release since the version updated from", () => {
const items = notesFromChangelogs(parseChangelog(BACKEND_CHANGELOG), parseChangelog(FRONTEND_CHANGELOG), {
version: "1.2.0",
since: "1.1.0",
});
expect(items.map((i) => i.title)).toContain("Load listings faster");
expect(items.map((i) => i.title)).not.toContain("Old feature");
expect(items.map((i) => i.title)).not.toContain("Old frontend feature");
});
it("leaves out a frontend release newer than the one running", () => {
const items = notesFromChangelogs(parseChangelog(BACKEND_CHANGELOG), parseChangelog(FRONTEND_CHANGELOG), {
version: "1.2.0",
frontendVersion: "1.2.0",
});
expect(items.map((i) => i.title)).not.toContain("Pick an accent colour");
});
it("resolves curated screenshots against the file and drops unsafe ones", () => {
const notes = parseCuratedNotes(
{
version: "1.2.0",
items: [
{
type: "feature",
title: "Accents",
images: [
{ src: "screenshots/accent.png", caption: "Graphite" },
{ src: "https://example.com/a.png" },
{ src: "javascript:alert(1)" },
],
},
{ type: "fix", title: "A fix", highlight: true, code: "claude mcp add thingport" },
],
},
"https://raw.example.com/repo/main/release-notes/latest-release-notes.json",
);
expect(notes?.items[0].images).toEqual([
{ url: "https://raw.example.com/repo/main/release-notes/screenshots/accent.png", caption: "Graphite" },
{ url: "https://example.com/a.png", caption: null },
]);
expect(notes?.items[1]).toMatchObject({ highlight: true, code: "claude mcp add thingport" });
expect(notes?.items[0]).toMatchObject({ highlight: false, code: null });
expect(parseCuratedNotes({ version: "next", items: [] }, "https://x.example/a.json")).toBeNull();
});
});
describe("version update notification", () => {
const app = createApp();
const run = Date.now();
let user: { id: string; token: string };
const updates = () => prisma.notification.findMany({ where: { userId: user.id, type: "system" } });
beforeAll(async () => {
const email = `release-notes-${run}@example.com`;
const res = await request(app).post("/api/register").send({ displayName: "R", email, password: "password123" });
const login = await request(app).post("/api/login").send({ email, password: "password123" });
user = { id: res.body.user.id as string, token: login.body.token as string };
});
beforeEach(async () => {
await prisma.notification.deleteMany({ where: { userId: user.id } });
});
afterAll(async () => {
await prisma.setting.deleteMany({ where: { key: "last_run_version" } });
await prisma.user.deleteMany({ where: { id: user.id } });
});
it("tells users once when a newer version starts, but not on a restart or a downgrade", async () => {
await prisma.setting.upsert({
where: { key: "last_run_version" },
create: { key: "last_run_version", value: "1.1.0" },
update: { value: "1.1.0" },
});
expect(await announceVersionUpdate("1.2.0")).toBe(true);
const [notification] = await updates();
expect(notification.internalPath).toBe("?whats-new=1.2.0&since=1.1.0");
expect(await announceVersionUpdate("1.2.0")).toBe(false);
expect(await announceVersionUpdate("1.1.5")).toBe(false);
expect(await updates()).toHaveLength(1);
});
it("tells existing users when the version that ran before isn't known", async () => {
await prisma.setting.deleteMany({ where: { key: "last_run_version" } });
expect(await announceVersionUpdate("1.2.0")).toBe(true);
expect((await updates())[0].internalPath).toBe("?whats-new=1.2.0");
});
});
+44
View File
@@ -256,6 +256,50 @@ ordinary notification (the System tab). To post one, add an entry to `entries` a
- Entries older than 90 days aren't sent, so old ones can stay in the file or be removed.
- `SYSTEM_NOTIFICATIONS_URL` points a backend at another file; an empty value turns fetching off.
## Release notes (What's new)
The first time a backend starts on a newer version than it last ran, every user gets a notification that opens
the What's new dialog (also in the user menu). A fresh install isn't told. There's no need to add a "Welcome to
X.Y.Z" entry to `system-notifications.json` for this any more.
The dialog fetches its content from `main`, so it can be written after the release is out:
1. [`release-notes/latest-release-notes.json`](../release-notes/latest-release-notes.json) when its `version` is the
instance's backend version.
2. Otherwise `backend/CHANGELOG.md` for that version, with the `frontend/CHANGELOG.md` release that went out with
it. An instance that skipped versions gets every release since the one it ran before, up to five.
To write the notes for a release, replace the file's contents:
```json
{
"version": "1.2.0",
"title": "Optional headline",
"summary": "Optional sentence or two under it.",
"items": [
{
"type": "feature",
"highlight": true,
"title": "Pick an accent colour",
"description": "Optional detail.",
"code": "claude mcp add ...",
"images": [{ "src": "screenshots/1.2.0-accent.webp", "caption": "Optional caption" }],
"url": "https://github.com/TautvydasDerzinskas/Thingport/pull/123"
}
]
}
```
- `type` is `breaking`, `feature`, `improvement` or `fix`; the dialog groups them in that order.
- `highlight: true` shows the item as a large card above the grouped list; keep it to the release's headline
features.
- `code` is a command shown in a box with a copy button, e.g. how to connect something.
- `images` are relative to the file (put them in `release-notes/screenshots/`, named after the version) or full
`https://` URLs; up to six per item. Crop them to the control being shown rather than the whole page; WebP keeps them
small. Everything but `type` and `title` is optional.
- A file that doesn't parse, or names another version, falls back to the changelog.
- `RELEASE_NOTES_URL` points a backend at another copy of the repo files; an empty value turns fetching off.
## Related documents
- [CONTRIBUTING.md](../CONTRIBUTING.md) -- branch, commit and pull request workflow
+41
View File
@@ -0,0 +1,41 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk } from "./client";
import { FRONTEND_VERSION } from "./settings";
export type ReleaseNoteKind = "breaking" | "feature" | "improvement" | "fix";
export type ReleaseNoteItem = {
kind: ReleaseNoteKind;
title: string;
description: string | null;
scope: string | null;
images: { url: string; caption: string | null }[];
/** A command to copy. */
code: string | null;
/** Shown as a large card above the rest. */
highlight: boolean;
url: string | null;
};
export type ReleaseNotes = {
version: string;
/** "curated" from release-notes/latest-release-notes.json, "changelog" from CHANGELOG.md, "none" when unreachable. */
source: "curated" | "changelog" | "none";
title: string | null;
summary: string | null;
items: ReleaseNoteItem[];
releases_url: string;
};
export const releaseNotesApi = {
/** The backend's own version without `version`; `since` widens the notes to every release after it. */
get: async (options: { version?: string | null; since?: string | null } = {}): Promise<ReleaseNotes> => {
const params = new URLSearchParams();
if (options.version) params.set("version", options.version);
if (options.since) params.set("since", options.since);
if (FRONTEND_VERSION) params.set("frontend", FRONTEND_VERSION);
const res = await fetch(`${apiBase()}/release-notes?${params}`, { headers: authHeaders() });
assertOk(res, "Failed to load what's new");
return res.json();
},
};
+4 -1
View File
@@ -16,6 +16,7 @@ import BackToTopButton from "./BackToTopButton";
import { ConfirmProvider } from "../ConfirmProvider";
import { ToastProvider } from "../ToastProvider";
import { ProviderAboutProvider } from "../providerAbout/ProviderAboutProvider";
import { WhatsNewProvider } from "../whatsNew/WhatsNewProvider";
import { PageHeaderContext, type PageHeader } from "./PageHeaderContext";
import { NavigationHistoryProvider, useSmartBack } from "./NavigationHistoryContext";
import { ImportJobProvider } from "./ImportJobContext";
@@ -238,7 +239,9 @@ export default function AppLayout({ muiTheme, onUnauthorized, ...shellProps }: A
<ToastProvider>
<NotificationsProvider onUnauthorized={onUnauthorized}>
<ProviderAboutProvider>
<AppLayoutShell onUnauthorized={onUnauthorized} {...shellProps} />
<WhatsNewProvider>
<AppLayoutShell onUnauthorized={onUnauthorized} {...shellProps} />
</WhatsNewProvider>
</ProviderAboutProvider>
</NotificationsProvider>
</ToastProvider>
@@ -15,6 +15,7 @@ import PersonIcon from "@mui/icons-material/Person";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import SettingsIcon from "@mui/icons-material/Settings";
import LogoutIcon from "@mui/icons-material/Logout";
import NewReleasesOutlinedIcon from "@mui/icons-material/NewReleasesOutlined";
import type { AuthUser } from "../../api/auth";
import type { ConfigurationTab } from "./ConfigurationDialog";
import { IMPORT_PROVIDER_INFO } from "../../constants/importProviders";
@@ -24,6 +25,7 @@ import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import { settingsApi, type Cults3dSettings } from "../../api/settings";
import { cults3dReady, cults3dStatus } from "./ConfigurationDialog/cults3dStatus";
import { useDisabledProviders } from "../../hooks/useDisabledProviders";
import { useWhatsNew } from "../whatsNew/WhatsNewProvider";
type ServiceChipDef = {
key:
@@ -55,6 +57,7 @@ type Props = {
export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }: Props) {
const { t } = useTranslation(["app", "common"]);
const navigate = useNavigate();
const openWhatsNew = useWhatsNew();
const avatarUrl = useGravatarUrl(user?.email, 128);
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const [makerworldConfigured, setMakerworldConfigured] = React.useState(false);
@@ -309,6 +312,17 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
</ListItemIcon>
<ListItemText>{t("configuration.title")}</ListItemText>
</MenuItem>
<MenuItem
onClick={() => {
closeMenu();
openWhatsNew();
}}
>
<ListItemIcon>
<NewReleasesOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("whatsNew.label")}</ListItemText>
</MenuItem>
<Divider />
<Box sx={{ px: 1.5, py: 1, display: "flex", flexWrap: "wrap", gap: 0.5, maxWidth: 260 }}>
{services.map((svc) => (
@@ -0,0 +1,577 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { keyframes } from "@emotion/react";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import ButtonBase from "@mui/material/ButtonBase";
import Dialog from "@mui/material/Dialog";
import IconButton from "@mui/material/IconButton";
import Link from "@mui/material/Link";
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { alpha, useTheme, type Theme } from "@mui/material/styles";
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import NewReleasesOutlinedIcon from "@mui/icons-material/NewReleasesOutlined";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
import { releaseNotesApi, type ReleaseNoteItem, type ReleaseNoteKind, type ReleaseNotes } from "../../api/releaseNotes";
import { ListRowSkeletons } from "../ListSkeleton";
export type WhatsNewRequest = {
/** This instance's version when null. */
version: string | null;
/** The version updated from, to cover every release since. */
since: string | null;
};
const KINDS: ReleaseNoteKind[] = ["breaking", "feature", "improvement", "fix"];
const KIND_STYLE: Record<ReleaseNoteKind, { Icon: typeof BoltOutlinedIcon; color: (theme: Theme) => string }> = {
breaking: { Icon: WarningAmberRoundedIcon, color: (theme) => theme.palette.error.main },
feature: { Icon: AutoAwesomeOutlinedIcon, color: (theme) => theme.palette.primary.main },
improvement: { Icon: BoltOutlinedIcon, color: (theme) => theme.palette.info.main },
fix: { Icon: BuildOutlinedIcon, color: (theme) => theme.palette.success.main },
};
const SHOT_HEIGHT = 280;
// A long list of one-line fixes is cut short until asked for.
const FIXES_SHOWN = 6;
const fadeUp = keyframes`
from { transform: translateY(6px); opacity: 0; }
to { transform: none; opacity: 1; }
`;
const enter = (index: number) => ({
animation: `${fadeUp} 220ms ease-out both`,
animationDelay: `${Math.min(index, 10) * 30}ms`,
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
});
function Screenshots({
item,
onZoom,
}: {
item: ReleaseNoteItem;
onZoom: (image: ReleaseNoteItem["images"][0]) => void;
}) {
const { t } = useTranslation("app");
if (!item.images.length) return null;
return (
<Box
sx={{
display: "grid",
alignItems: "start",
gap: 1,
mt: 1,
// One picture keeps to its own width, so a dialog cut-out isn't blown up to fill the row.
gridTemplateColumns:
item.images.length === 1 ? "minmax(0, max-content)" : "repeat(auto-fit, minmax(220px, 1fr))",
}}
>
{item.images.map((image) => (
<ButtonBase
key={image.url}
aria-label={image.caption ?? t("whatsNew.zoom")}
onClick={() => onZoom(image)}
sx={{
display: "block",
borderRadius: 1.5,
overflow: "hidden",
border: 1,
borderColor: "divider",
textAlign: "left",
"& img": { transition: "transform 200ms ease-out" },
"&:hover img": { transform: "scale(1.02)" },
"&:focus-visible": { outline: (theme) => `2px solid ${theme.palette.primary.main}`, outlineOffset: 2 },
"@media (prefers-reduced-motion: reduce)": { "&:hover img": { transform: "none" } },
}}
>
<Box
component="img"
src={image.url}
alt={image.caption ?? item.title}
loading="lazy"
sx={{
display: "block",
width: item.images.length === 1 ? "auto" : "100%",
maxWidth: "100%",
height: "auto",
maxHeight: SHOT_HEIGHT,
objectFit: "contain",
bgcolor: "action.hover",
}}
/>
{image.caption && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", px: 1, py: 0.5 }}>
{image.caption}
</Typography>
)}
</ButtonBase>
))}
</Box>
);
}
function CodeBlock({ code }: { code: string }) {
const { t } = useTranslation("app");
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) return;
const timer = window.setTimeout(() => setCopied(false), 1500);
return () => window.clearTimeout(timer);
}, [copied]);
const copy = () =>
navigator.clipboard
?.writeText(code)
.then(() => setCopied(true))
.catch(() => undefined);
return (
<Box
sx={{
position: "relative",
mt: 1,
borderRadius: 1.5,
border: 1,
borderColor: "divider",
bgcolor: "action.hover",
}}
>
<Box
component="pre"
sx={{
m: 0,
p: 1.25,
pr: 5,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
fontSize: 12,
lineHeight: 1.6,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
{code}
</Box>
<Tooltip title={copied ? t("whatsNew.copied") : t("whatsNew.copy")}>
<IconButton
size="small"
aria-label={t("whatsNew.copy")}
onClick={copy}
sx={{ position: "absolute", top: 4, right: 4 }}
>
{copied ? <CheckIcon sx={{ fontSize: 16 }} color="success" /> : <ContentCopyIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
</Box>
);
}
/** A release's headline change, above the grouped list. */
function HighlightCard({
item,
index,
onZoom,
}: {
item: ReleaseNoteItem;
index: number;
onZoom: (image: ReleaseNoteItem["images"][0]) => void;
}) {
const { t } = useTranslation("app");
const { Icon, color } = KIND_STYLE[item.kind];
return (
<Box
component="article"
sx={(theme) => ({
...enter(index),
p: 2,
borderRadius: 2,
border: `1px solid ${alpha(color(theme), 0.35)}`,
background: `linear-gradient(150deg, ${alpha(color(theme), 0.12)} 0%, ${alpha(color(theme), 0.02)} 60%)`,
})}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.75 }}>
<Box
sx={(theme) => ({
display: "inline-flex",
alignItems: "center",
gap: 0.5,
px: 1,
py: 0.25,
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: color(theme),
bgcolor: alpha(color(theme), 0.14),
})}
>
<Icon sx={{ fontSize: 13 }} />
{t("whatsNew.highlight")}
</Box>
</Stack>
<Typography
variant="subtitle1"
fontWeight={700}
sx={{ lineHeight: 1.3, color: (muiTheme) => muiTheme.thingport.headingText }}
>
{item.title}
</Typography>
{item.description && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{item.description}
</Typography>
)}
{item.code && <CodeBlock code={item.code} />}
<Screenshots item={item} onZoom={onZoom} />
{item.url && (
<Link
href={item.url}
target="_blank"
rel="noopener noreferrer"
variant="caption"
underline="hover"
sx={{ display: "inline-flex", alignItems: "center", gap: 0.25, mt: 1 }}
>
{t("whatsNew.learnMore")}
<OpenInNewIcon sx={{ fontSize: 12 }} />
</Link>
)}
</Box>
);
}
function NoteRow({
item,
index,
curated,
onZoom,
}: {
item: ReleaseNoteItem;
index: number;
curated: boolean;
onZoom: (image: ReleaseNoteItem["images"][0]) => void;
}) {
const { t } = useTranslation("app");
const { color } = KIND_STYLE[item.kind];
return (
<Box
component="li"
sx={{
...enter(index),
position: "relative",
listStyle: "none",
pl: 2,
py: 0.5,
"&::before": {
content: '""',
position: "absolute",
left: 2,
top: 13,
width: 6,
height: 6,
borderRadius: "50%",
bgcolor: color,
},
}}
>
<Typography variant="body2" fontWeight={item.description || item.images.length ? 600 : 400}>
{item.scope && (
<Box component="span" sx={{ color: "text.secondary", fontWeight: 600, mr: 0.75 }}>
{item.scope}
</Box>
)}
{item.title}
</Typography>
{item.description && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{item.description}
</Typography>
)}
{curated && item.url && (
<Link
href={item.url}
target="_blank"
rel="noopener noreferrer"
variant="caption"
underline="hover"
sx={{ display: "inline-flex", alignItems: "center", gap: 0.25, mt: 0.25 }}
>
{t("whatsNew.learnMore")}
<OpenInNewIcon sx={{ fontSize: 12 }} />
</Link>
)}
{item.code && <CodeBlock code={item.code} />}
<Screenshots item={item} onZoom={onZoom} />
</Box>
);
}
function Section({
kind,
items,
curated,
offset,
onZoom,
}: {
kind: ReleaseNoteKind;
items: ReleaseNoteItem[];
curated: boolean;
offset: number;
onZoom: (image: ReleaseNoteItem["images"][0]) => void;
}) {
const { t } = useTranslation("app");
const [expanded, setExpanded] = React.useState(false);
const { Icon, color } = KIND_STYLE[kind];
const cut = kind === "fix" && !expanded && items.length > FIXES_SHOWN + 1;
const shown = cut ? items.slice(0, FIXES_SHOWN) : items;
return (
<Box component="section" aria-labelledby={`whats-new-${kind}`}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
<Box
sx={(theme) => ({
width: 24,
height: 24,
borderRadius: "7px",
display: "grid",
placeItems: "center",
color: color(theme),
bgcolor: alpha(color(theme), 0.14),
})}
>
<Icon sx={{ fontSize: 15 }} />
</Box>
<Typography id={`whats-new-${kind}`} variant="subtitle2" fontWeight={700}>
{t(`whatsNew.kinds.${kind}`)}
</Typography>
<Typography variant="caption" color="text.disabled">
{items.length}
</Typography>
</Stack>
<Box component="ul" sx={{ m: 0, p: 0 }}>
{shown.map((item, index) => (
<NoteRow key={item.url ?? item.title} item={item} index={offset + index} curated={curated} onZoom={onZoom} />
))}
</Box>
{kind === "fix" && items.length > FIXES_SHOWN + 1 && (
<Button size="small" onClick={() => setExpanded((value) => !value)} sx={{ ml: 1, mt: 0.25 }}>
{expanded ? t("whatsNew.showFewer") : t("whatsNew.showAll", { total: items.length })}
</Button>
)}
</Box>
);
}
type Props = {
/** What to show, or null when closed. */
request: WhatsNewRequest | null;
onClose: () => void;
};
/** The changes in this version: the hand-written notes when there are some for it, otherwise its changelog. */
export default function WhatsNewDialog({ request, onClose }: Props) {
const { t } = useTranslation(["app", "common"]);
const theme = useTheme();
const [notes, setNotes] = React.useState<ReleaseNotes | null>(null);
const [failed, setFailed] = React.useState(false);
const [zoomed, setZoomed] = React.useState<ReleaseNoteItem["images"][0] | null>(null);
React.useEffect(() => {
if (!request) return;
let active = true;
setNotes(null);
setFailed(false);
releaseNotesApi
.get(request)
.then((result) => active && setNotes(result))
.catch(() => active && setFailed(true));
return () => {
active = false;
};
}, [request]);
const accent = theme.palette.primary.main;
const loading = !notes && !failed;
const empty = failed || (notes !== null && notes.items.length === 0);
const highlights = notes?.items.filter((item) => item.highlight) ?? [];
const sections = KINDS.map((kind) => ({
kind,
items: notes?.items.filter((item) => item.kind === kind && !item.highlight) ?? [],
})).filter((section) => section.items.length > 0);
const offsets = sections.map((_, index) =>
sections
.slice(0, index)
.reduce((sum, section) => sum + Math.min(section.items.length, FIXES_SHOWN), highlights.length),
);
// Screenshots need the room; a plain list of changes reads better narrow.
const wide = notes?.items.some((item) => item.images.length > 0) ?? false;
const releasesUrl = notes?.releases_url ?? "https://github.com/TautvydasDerzinskas/Thingport/releases";
return (
<>
<Dialog
open={Boolean(request)}
onClose={onClose}
fullWidth
maxWidth={wide ? "md" : "sm"}
aria-labelledby="whats-new-title"
slotProps={{ paper: { sx: { overflow: "hidden" } } }}
>
<Box
sx={{
position: "relative",
flexShrink: 0,
px: 3,
pt: 2.5,
pb: 2,
background: `linear-gradient(160deg, ${alpha(accent, 0.2)} 0%, ${alpha(accent, 0.03)} 70%)`,
borderBottom: `1px solid ${alpha(accent, 0.25)}`,
}}
>
<IconButton
aria-label={t("common:close")}
onClick={onClose}
size="small"
sx={{ position: "absolute", right: 12, top: 12 }}
>
<CloseIcon fontSize="small" />
</IconButton>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ pr: 5 }}>
<Box
sx={{
width: 40,
height: 40,
flexShrink: 0,
borderRadius: "11px",
display: "grid",
placeItems: "center",
color: "primary.contrastText",
bgcolor: "primary.main",
boxShadow: `0 2px 8px ${alpha(accent, 0.35)}`,
}}
>
<NewReleasesOutlinedIcon />
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="overline" color="text.secondary" sx={{ lineHeight: 1.6, letterSpacing: "0.08em" }}>
{notes ? t("whatsNew.versionLabel", { version: notes.version }) : t("whatsNew.label")}
</Typography>
<Typography
id="whats-new-title"
variant="h6"
fontWeight={700}
sx={{ lineHeight: 1.25, color: (muiTheme) => muiTheme.thingport.headingText }}
>
{notes?.title ?? t("whatsNew.label")}
</Typography>
</Box>
</Stack>
</Box>
<Box sx={{ px: 3, pt: 2, pb: 2, overflowY: "auto", flex: "1 1 auto", minHeight: 0 }}>
{loading && <ListRowSkeletons count={5} lead="circle" leadSize={24} twoLines />}
{empty && (
<Stack alignItems="center" spacing={1.5} sx={{ py: 3, textAlign: "center", ...enter(0) }}>
<Typography variant="body2" color="text.secondary">
{failed || notes?.source === "none" ? t("whatsNew.unavailable") : t("whatsNew.empty")}
</Typography>
<Button
size="small"
variant="outlined"
href={releasesUrl}
target="_blank"
rel="noopener noreferrer"
endIcon={<OpenInNewIcon sx={{ fontSize: 14 }} />}
>
{t("whatsNew.openGithub")}
</Button>
</Stack>
)}
{notes && !empty && (
<Stack spacing={2}>
{notes.summary && (
<Typography variant="body2" color="text.secondary" sx={enter(0)}>
{notes.summary}
</Typography>
)}
{highlights.map((item, index) => (
<HighlightCard key={`${notes.version}-${item.title}`} item={item} index={index} onZoom={setZoomed} />
))}
{sections.map((section, index) => (
<Section
key={`${notes.version}-${section.kind}`}
kind={section.kind}
items={section.items}
curated={notes.source === "curated"}
offset={offsets[index]}
onZoom={setZoomed}
/>
))}
</Stack>
)}
</Box>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ px: 3, py: 1.5, borderTop: 1, borderColor: "divider", flexShrink: 0 }}
>
<Link
href={releasesUrl}
target="_blank"
rel="noopener noreferrer"
variant="body2"
underline="hover"
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
{t("whatsNew.allReleases")}
<OpenInNewIcon sx={{ fontSize: 14 }} />
</Link>
<Button variant="contained" onClick={onClose}>
{t("whatsNew.gotIt")}
</Button>
</Stack>
</Dialog>
<Dialog
open={Boolean(zoomed)}
onClose={() => setZoomed(null)}
maxWidth="lg"
aria-label={zoomed?.caption ?? t("whatsNew.zoom")}
slotProps={{ paper: { sx: { overflow: "hidden", position: "relative" } } }}
>
<IconButton
aria-label={t("common:close")}
onClick={() => setZoomed(null)}
size="small"
sx={{
position: "absolute",
right: 8,
top: 8,
bgcolor: (muiTheme) => alpha(muiTheme.palette.background.paper, 0.8),
"&:hover": { bgcolor: "background.paper" },
}}
>
<CloseIcon fontSize="small" />
</IconButton>
{zoomed && (
<Box
component="img"
src={zoomed.url}
alt={zoomed.caption ?? ""}
sx={{ display: "block", maxWidth: "100%", maxHeight: "85vh", objectFit: "contain" }}
/>
)}
{zoomed?.caption && (
<Typography variant="body2" color="text.secondary" sx={{ px: 2, py: 1 }}>
{zoomed.caption}
</Typography>
)}
</Dialog>
</>
);
}
@@ -0,0 +1,41 @@
import React from "react";
import { useSearchParams } from "react-router-dom";
import WhatsNewDialog, { type WhatsNewRequest } from "./WhatsNewDialog";
type OpenWhatsNew = (request?: Partial<WhatsNewRequest>) => void;
const WhatsNewContext = React.createContext<OpenWhatsNew | null>(null);
/** One What's new dialog for the app. The update notification opens it with "?whats-new=<version>&since=<old>",
* which works on any page and is taken off the URL once read, so going back doesn't reopen it. */
export function WhatsNewProvider({ children }: { children: React.ReactNode }) {
const [request, setRequest] = React.useState<WhatsNewRequest | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
React.useEffect(() => {
const version = searchParams.get("whats-new");
if (version === null) return;
setRequest({ version: version || null, since: searchParams.get("since") });
const rest = new URLSearchParams(searchParams);
rest.delete("whats-new");
rest.delete("since");
setSearchParams(rest, { replace: true });
}, [searchParams, setSearchParams]);
const open = React.useCallback<OpenWhatsNew>(
(next) => setRequest({ version: next?.version ?? null, since: next?.since ?? null }),
[],
);
return (
<WhatsNewContext.Provider value={open}>
{children}
<WhatsNewDialog request={request} onClose={() => setRequest(null)} />
</WhatsNewContext.Provider>
);
}
/** Opens What's new; this instance's version unless told another. A no-op outside the provider. */
export function useWhatsNew(): OpenWhatsNew {
return React.useContext(WhatsNewContext) ?? (() => undefined);
}
+22
View File
@@ -2176,6 +2176,28 @@
]
}
}
},
"whatsNew": {
"label": "What's new",
"versionLabel": "Thingport {{version}}",
"kinds": {
"breaking": "Breaking changes",
"feature": "New features",
"improvement": "Improvements",
"fix": "Fixes"
},
"showAll": "Show all {{total}}",
"showFewer": "Show fewer",
"learnMore": "Learn more",
"allReleases": "All releases",
"gotIt": "Got it",
"unavailable": "Couldn't load the release notes.",
"empty": "No release notes for this version yet.",
"openGithub": "Open on GitHub",
"zoom": "Enlarge screenshot",
"highlight": "Highlight",
"copy": "Copy",
"copied": "Copied"
}
},
"common": {
+22
View File
@@ -2176,6 +2176,28 @@
]
}
}
},
"whatsNew": {
"label": "Novedades",
"versionLabel": "Thingport {{version}}",
"kinds": {
"breaking": "Cambios incompatibles",
"feature": "Nuevas funciones",
"improvement": "Mejoras",
"fix": "Correcciones"
},
"showAll": "Mostrar todas ({{total}})",
"showFewer": "Mostrar menos",
"learnMore": "Más información",
"allReleases": "Todas las versiones",
"gotIt": "Entendido",
"unavailable": "No se pudieron cargar las notas de la versión.",
"empty": "Aún no hay notas para esta versión.",
"openGithub": "Abrir en GitHub",
"zoom": "Ampliar captura",
"highlight": "Destacado",
"copy": "Copiar",
"copied": "Copiado"
}
},
"common": {
+22
View File
@@ -2189,6 +2189,28 @@
]
}
}
},
"whatsNew": {
"label": "Nouveautés",
"versionLabel": "Thingport {{version}}",
"kinds": {
"breaking": "Changements incompatibles",
"feature": "Nouvelles fonctionnalités",
"improvement": "Améliorations",
"fix": "Corrections"
},
"showAll": "Tout afficher ({{total}})",
"showFewer": "Afficher moins",
"learnMore": "En savoir plus",
"allReleases": "Toutes les versions",
"gotIt": "Compris",
"unavailable": "Impossible de charger les notes de version.",
"empty": "Pas encore de notes pour cette version.",
"openGithub": "Ouvrir sur GitHub",
"zoom": "Agrandir la capture",
"highlight": "À la une",
"copy": "Copier",
"copied": "Copié"
}
},
"common": {
+22
View File
@@ -2242,6 +2242,28 @@
]
}
}
},
"whatsNew": {
"label": "Kas naujo",
"versionLabel": "Thingport {{version}}",
"kinds": {
"breaking": "Nesuderinami pakeitimai",
"feature": "Naujos funkcijos",
"improvement": "Patobulinimai",
"fix": "Pataisymai"
},
"showAll": "Rodyti visus ({{total}})",
"showFewer": "Rodyti mažiau",
"learnMore": "Daugiau",
"allReleases": "Visos versijos",
"gotIt": "Supratau",
"unavailable": "Nepavyko įkelti versijos naujienų.",
"empty": "Šiai versijai naujienų dar nėra.",
"openGithub": "Atidaryti GitHub",
"zoom": "Padidinti ekrano vaizdą",
"highlight": "Svarbiausia",
"copy": "Kopijuoti",
"copied": "Nukopijuota"
}
},
"common": {