feat(settings): Open Source Licenses section in System tab (#477)

* feat(licenses): tooling and initial data generation

Adds the maintainer-only generator that produces src/data/licenses.json:
- src-tauri/about.toml + about.hbs: cargo-about config + handlebars template
  for the Rust-side license enumeration
- scripts/generate-licenses.mjs: orchestrator that runs cargo-about and
  license-checker-rseidelsohn (via npx, no devDep), merges the outputs into
  a single per-crate JSON with full license texts, and writes the result to
  src/data/licenses.json

The script is invoked directly with `node scripts/generate-licenses.mjs` —
no npm script wrapper on purpose, since adding one to package.json would
trigger the nix-npm-deps-hash-sync workflow on every push.

Initial generation covers 575 cargo crates + 71 npm packages (646 entries
total, all with full license text bundled, ~1.4 MB JSON).

* feat(licenses): Settings panel UI

Adds a new Open Source Licenses section under Settings → System,
sitting below Contributors. Components:

- LicensesPanel.tsx: search input, curated highlight block of ~10 key
  dependencies (Tauri, React, rodio, symphonia, etc.), TanStack-Virtual
  list of all 600+ entries
- LicenseTextModal.tsx: full-screen-ish modal showing the bundled license
  text plus name/version/license-id badges + repository link
- licensesData.ts: lazy dynamic-import loader (Vite emits the JSON as a
  separate chunk, so the heavy ~1.4 MB payload is only loaded when the
  user actually opens the panel — no runtime fetch, the data is fixed
  into the build artifact)

The panel registers itself in the Settings in-page search index under
the System tab.

* feat(licenses): i18n in 8 locales

Adds the `licenses` namespace (title, intro, highlights, search
placeholder, no-results, loading / load error, no-license-text,
view-source, total line, generated-at) across en, de, fr, nl, zh, nb,
ru, es. License names themselves (MIT, Apache-2.0, GPL-3.0, …) stay
universal and are rendered as-is.

* docs(release): document licenses regeneration step

Adds a Step A.3 to the release SOP describing the maintainer-only
`node scripts/generate-licenses.mjs` workflow, the cargo-about
prerequisite, and explicitly notes why no npm script wrapper exists
(would trigger the nix-npm-deps-hash-sync workflow).
This commit is contained in:
Frank Stellmacher
2026-05-06 14:17:13 +02:00
committed by GitHub
parent d48ea819c1
commit 8692e50603
17 changed files with 963 additions and 1 deletions
+132
View File
@@ -0,0 +1,132 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, ExternalLink } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { open as openExternal } from '@tauri-apps/plugin-shell';
import type { LicenseEntry } from '../utils/licensesData';
interface Props {
entry: LicenseEntry | null;
onClose: () => void;
}
export default function LicenseTextModal({ entry, onClose }: Props) {
const { t } = useTranslation();
useEffect(() => {
if (!entry) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [entry, onClose]);
if (!entry) return null;
const repoLink = entry.repository || entry.homepage;
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: '760px',
width: '92vw',
maxHeight: '82vh',
display: 'flex',
flexDirection: 'column',
}}
>
<button className="modal-close" onClick={onClose} aria-label={t('common.close')}>
<X size={18} />
</button>
<div style={{ marginBottom: '0.5rem', paddingRight: '2rem' }}>
<h3 style={{ margin: 0, fontFamily: 'var(--font-display)', fontSize: '1.05rem' }}>
{entry.name} <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>{entry.version}</span>
</h3>
<div
style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
flexWrap: 'wrap',
marginTop: '0.4rem',
fontSize: '0.78rem',
}}
>
{entry.licenses.map((lid) => (
<span
key={lid}
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '10px',
padding: '1px 8px',
color: 'var(--text-primary)',
}}
>
{lid}
</span>
))}
<span
style={{
color: 'var(--text-muted)',
textTransform: 'uppercase',
fontSize: '0.7rem',
letterSpacing: '0.04em',
}}
>
{entry.source}
</span>
{repoLink && (
<button
onClick={() => openExternal(repoLink)}
className="btn btn-ghost"
style={{
padding: '2px 8px',
fontSize: '0.78rem',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
}}
>
<ExternalLink size={12} /> {t('licenses.viewSource')}
</button>
)}
</div>
{entry.description && (
<p style={{ color: 'var(--text-muted)', marginTop: '0.6rem', marginBottom: 0, fontSize: '0.85rem' }}>
{entry.description}
</p>
)}
</div>
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
padding: '12px 14px',
overflow: 'auto',
flex: 1,
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
fontSize: '0.78rem',
whiteSpace: 'pre-wrap',
color: 'var(--text-primary)',
lineHeight: 1.5,
}}
>
{entry.licenseText || t('licenses.noLicenseText')}
</div>
</div>
</div>,
document.body,
);
}