Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 308 additions & 2 deletions frontend/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"dexie": "4.4.2",
"dompurify": "^3.4.0",
"lucide-react": "^1.8.0",
"qrcode": "^1.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
Expand All @@ -71,6 +72,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.12.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0",
Expand Down
102 changes: 102 additions & 0 deletions frontend/src/components/QrCodeModal.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
.backdrop {
position: fixed;
inset: 0;
z-index: 9998;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.4); /* token-exempt: scrim over arbitrary page content */
}

.modal {
width: min(20rem, calc(100vw - 2rem));
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem 1.25rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}

.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}

.title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}

.close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
color: var(--text-muted);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}

.close:hover {
color: var(--text-primary);
}

.qr {
width: 240px;
height: 240px;
background: #ffffff; /* token-exempt: QR must stay black-on-white to scan */
border-radius: var(--radius-sm);
}

.qrPlaceholder {
width: 240px;
height: 240px;
background: var(--bg-hover, var(--border));
border-radius: var(--radius-sm);
}

.url {
width: 100%;
margin: 0;
color: var(--text-secondary);
font-size: 0.8125rem;
text-align: center;
word-break: break-all;
}

.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}

.action {
display: inline-flex;
align-items: center;
gap: 0.375rem;
min-height: 36px;
padding: 0.375rem 0.75rem;
background: transparent;
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
cursor: pointer;
}

.action:hover {
border-color: var(--accent);
}
47 changes: 47 additions & 0 deletions frontend/src/components/QrCodeModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {afterEach, describe, expect, it, vi} from "vitest";
import {cleanup, fireEvent, render, screen, waitFor} from "@testing-library/react";

const h = vi.hoisted(() => ({copy: vi.fn(async () => true), notifySuccess: vi.fn()}));

vi.mock("qrcode", () => ({
default: {toDataURL: vi.fn(async () => "data:image/png;base64,QR")},
}));
vi.mock("../hooks/useI18n", () => ({useI18n: () => ({t: (_k: string, f: string) => f})}));
vi.mock("../utils/clipboard", () => ({copyToClipboard: h.copy}));
vi.mock("../utils/notify", () => ({notify: {success: h.notifySuccess, error: vi.fn()}}));

import QrCodeModal from "./QrCodeModal";

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

describe("QrCodeModal", () => {
it("renders nothing when closed", () => {
render(<QrCodeModal open={false} onClose={() => undefined} url="https://myapp.example" />);
expect(screen.queryByTestId("qr-modal")).toBeNull();
});

it("renders the QR image + url when open", async () => {
render(<QrCodeModal open onClose={() => undefined} url="https://myapp.example" />);
expect(screen.getByTestId("qr-modal-url").textContent).toBe("https://myapp.example");
await waitFor(() =>
expect(screen.getByTestId("qr-modal-image").getAttribute("src")).toContain("data:image/png"),
);
});

it("copies the link", async () => {
render(<QrCodeModal open onClose={() => undefined} url="https://myapp.example" />);
fireEvent.click(screen.getByTestId("qr-modal-copy"));
await waitFor(() => expect(h.copy).toHaveBeenCalledWith("https://myapp.example"));
expect(h.notifySuccess).toHaveBeenCalled();
});

it("closes on the close button", () => {
const onClose = vi.fn();
render(<QrCodeModal open onClose={onClose} url="https://myapp.example" />);
fireEvent.click(screen.getByTestId("qr-modal-close"));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
119 changes: 119 additions & 0 deletions frontend/src/components/QrCodeModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* QR-code modal.
*
* Renders a scannable QR code of a URL with copy-link, download-PNG and
* (where supported) native-share actions. Lightweight custom overlay rather
* than a Radix Dialog (lessons-learned: Radix portals are brittle under
* happy-dom). Generic - pass any URL/title.
*/
import {useEffect, useState} from "react";
import QRCode from "qrcode";
import {Copy, Download, Share2, X} from "lucide-react";
import {useI18n} from "../hooks/useI18n";
import {copyToClipboard} from "../utils/clipboard";
import {notify} from "../utils/notify";
import styles from "./QrCodeModal.module.css";

export interface QrCodeModalProps {
open: boolean;
onClose: () => void;
url: string;
title?: string;
}

export default function QrCodeModal({open, onClose, url, title}: QrCodeModalProps) {
const {t} = useI18n();
const [dataUrl, setDataUrl] = useState<string>("");

useEffect(() => {
if (!open) return;
let cancelled = false;
QRCode.toDataURL(url, {width: 240, margin: 1})
.then((generated) => {
if (!cancelled) setDataUrl(generated);
})
.catch(() => {
if (!cancelled) setDataUrl("");
});
return () => {
cancelled = true;
};
}, [open, url]);

if (!open) return null;

const copyLink = async () => {
const ok = await copyToClipboard(url);
if (ok) notify.success(t("ui.share.link_copied", "Link kopiert"));
};

const downloadPng = () => {
if (!dataUrl) return;
const anchor = document.createElement("a");
anchor.href = dataUrl;
anchor.download = "qr-code.png";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
};

const share = async () => {
if (typeof navigator !== "undefined" && navigator.share) {
await navigator.share({title: title ?? document.title, url}).catch(() => undefined);
}
};

const canShare = typeof navigator !== "undefined" && typeof navigator.share === "function";

return (
<div className={styles.backdrop} onMouseDown={onClose} data-testid="qr-modal-backdrop">
<div
className={styles.modal}
role="dialog"
aria-modal="true"
aria-label={title ?? t("ui.share.title", "App teilen")}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => event.key === "Escape" && onClose()}
data-testid="qr-modal"
>
<div className={styles.header}>
<h3 className={styles.title}>{title ?? t("ui.share.title", "App teilen")}</h3>
<button
type="button"
className={styles.close}
onClick={onClose}
aria-label={t("ui.common.close", "Schließen")}
data-testid="qr-modal-close"
>
<X size={18} aria-hidden />
</button>
</div>

{dataUrl ? (
<img className={styles.qr} src={dataUrl} alt={t("ui.share.qr_alt", "QR-Code")} data-testid="qr-modal-image" />
) : (
<div className={styles.qrPlaceholder} data-testid="qr-modal-placeholder" />
)}

<p className={styles.url} data-testid="qr-modal-url">{url}</p>

<div className={styles.actions}>
<button type="button" className={styles.action} onClick={copyLink} data-testid="qr-modal-copy">
<Copy size={16} aria-hidden />
{t("ui.share.copy_link", "Link kopieren")}
</button>
<button type="button" className={styles.action} onClick={downloadPng} data-testid="qr-modal-download">
<Download size={16} aria-hidden />
{t("ui.share.download", "PNG speichern")}
</button>
{canShare ? (
<button type="button" className={styles.action} onClick={() => void share()} data-testid="qr-modal-share">
<Share2 size={16} aria-hidden />
{t("ui.share.share", "Teilen")}
</button>
) : null}
</div>
</div>
</div>
);
}
13 changes: 13 additions & 0 deletions frontend/src/components/about/AboutTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@
import {BookOpen, Code2, ExternalLink, Scale} from "lucide-react";
import {useI18n} from "../../hooks/useI18n";
import VersionSection from "./VersionSection";
import ShareAppSection from "./ShareAppSection";
import UpdateCheckControl from "../UpdateCheckControl";
import styles from "./About.module.css";

// Replace with your app's GitHub repo (used by the update check).
const REPO_OWNER = "astrapi69";
const REPO_NAME = "pluginforge-app-template";

interface AppLink {
key: string;
href: string;
Expand Down Expand Up @@ -75,6 +81,13 @@ export default function AboutTab() {
</ul>
</section>

<section className={styles.section} data-testid="about-updates">
<h3 className={styles.heading}>{t("ui.about.updates_title", "Updates")}</h3>
<UpdateCheckControl owner={REPO_OWNER} repo={REPO_NAME} />
</section>

<ShareAppSection />

<section className={styles.section} data-testid="about-credits">
<h3 className={styles.heading}>{t("ui.about.credits_title", "Credits")}</h3>
<p className={styles.credits}>
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/about/ShareAppSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {afterEach, describe, expect, it, vi} from "vitest";
import {cleanup, fireEvent, render, screen, waitFor} from "@testing-library/react";

vi.mock("qrcode", () => ({
default: {toDataURL: vi.fn(async () => "data:image/png;base64,QR")},
}));
vi.mock("../../hooks/useI18n", () => ({useI18n: () => ({t: (_k: string, f: string) => f})}));
vi.mock("../../utils/clipboard", () => ({copyToClipboard: vi.fn(async () => true)}));
vi.mock("../../utils/notify", () => ({notify: {success: vi.fn(), error: vi.fn()}}));

import ShareAppSection from "./ShareAppSection";

afterEach(() => {
cleanup();
});

describe("ShareAppSection", () => {
it("renders a share button and opens the QR modal on click", async () => {
render(<ShareAppSection appUrl="https://myapp.example" />);
expect(screen.queryByTestId("qr-modal")).toBeNull();
fireEvent.click(screen.getByTestId("about-share-button"));
await waitFor(() => expect(screen.getByTestId("qr-modal")).toBeTruthy());
expect(screen.getByTestId("qr-modal-url").textContent).toBe("https://myapp.example");
});
});
41 changes: 41 additions & 0 deletions frontend/src/components/about/ShareAppSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* "Share this app" section for the About tab: a button that opens a QR-code
* modal of the app URL. Defaults to the current origin; pass `appUrl` to
* share a fixed public URL instead.
*/
import {useState} from "react";
import {QrCode} from "lucide-react";
import {useI18n} from "../../hooks/useI18n";
import QrCodeModal from "../QrCodeModal";
import styles from "./About.module.css";

export interface ShareAppSectionProps {
appUrl?: string;
}

export default function ShareAppSection({appUrl}: ShareAppSectionProps) {
const {t} = useI18n();
const [open, setOpen] = useState(false);
const url = appUrl ?? (typeof window !== "undefined" ? window.location.origin : "");

return (
<section className={styles.section} data-testid="about-share">
<h3 className={styles.heading}>{t("ui.about.share_title", "App teilen")}</h3>
<button
type="button"
className={styles.link}
onClick={() => setOpen(true)}
data-testid="about-share-button"
>
<QrCode size={16} aria-hidden />
<span>{t("ui.about.share_action", "QR-Code anzeigen")}</span>
</button>
<QrCodeModal
open={open}
onClose={() => setOpen(false)}
url={url}
title={t("ui.about.share_title", "App teilen")}
/>
</section>
);
}