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
27 changes: 19 additions & 8 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import {
SunIcon,
MoonIcon,
} from "@heroicons/react/16/solid";
import { useLabelStore, useHistory, canCallLabelary } from "../store/labelStore";
import { useLabelStore, useHistory } from "../store/labelStore";
import { LabelaryNoticeModal } from "./Output/LabelaryNoticeModal";
import { localeNames } from "../locales";
import type { LocaleCode } from "../locales";
import { mmToUnit } from "../lib/units";
Expand All @@ -50,7 +51,9 @@ export function AppShell() {
const setLocale = useLabelStore((s) => s.setLocale);
const theme = useLabelStore((s) => s.theme);
const setTheme = useLabelStore((s) => s.setTheme);
const labelaryReady = useLabelStore(canCallLabelary);
const labelaryEnabled = useLabelStore((s) => s.thirdParty.labelary);
const noticeAcknowledged = useLabelStore((s) => s.labelaryNoticeAcknowledged);
const [showPrintNotice, setShowPrintNotice] = useState(false);

// Bridge the theme preference to <html data-theme> so the CSS variables in
// index.css pick it up.
Expand Down Expand Up @@ -202,14 +205,13 @@ export function AppShell() {
{t.app.saveDesign}
</DropdownItem>
<DropdownSeparator />
{/* Print also routes through Labelary. Until the user has seen
the privacy notice (shown only via the Preview modal), Print
is hidden so the very first Labelary call cannot bypass the
disclosure. After acknowledgement Print stays available. */}
{labelaryReady && (
{/* Print routes through Labelary. The button is shown whenever
the Labelary gate is on; clicking it before the notice has
been acknowledged opens the disclosure first, then prints. */}
{labelaryEnabled && (
<DropdownItem
icon={PrinterIcon}
onClick={handlePrint}
onClick={() => (noticeAcknowledged ? handlePrint() : setShowPrintNotice(true))}
disabled={!hasObjects}
>
{t.app.print}
Expand Down Expand Up @@ -344,6 +346,15 @@ export function AppShell() {
{showZebraPrint && (
<PrintToZebraDialog zpl={currentZpl()} onClose={closeZebraPrint} />
)}
{showPrintNotice && (
<LabelaryNoticeModal
onClose={() => setShowPrintNotice(false)}
onContinue={() => {
setShowPrintNotice(false);
handlePrint();
}}
/>
)}
</div>
);
}
52 changes: 15 additions & 37 deletions src/components/Output/LabelPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { XMarkIcon, ArrowDownTrayIcon } from '@heroicons/react/16/solid';
import { useLabelStore, useCurrentObjects, canCallLabelary } from '../../store/labelStore';
import { useLabelStore, useCurrentObjects } from '../../store/labelStore';
import { generateZPL } from '../../lib/zplGenerator';
import { fetchPreview, labelaryErrorMessage } from '../../lib/labelary';
import { triggerDownload } from '../../lib/triggerDownload';
Expand All @@ -10,28 +10,22 @@ interface Props {
onClose: () => void;
}


/** Preview modal — assumes the privacy notice has already been
* acknowledged. Callers (ZPLOutput) gate the modal behind
* LabelaryNoticeModal so this component never has to handle the
* pre-ack state. */
export function LabelPreviewModal({ onClose }: Props) {
const t = useT();
const label = useLabelStore((s) => s.label);
const objects = useCurrentObjects();
const noticeAcknowledged = useLabelStore((s) => s.labelaryNoticeAcknowledged);
const acknowledgeLabelaryNotice = useLabelStore((s) => s.acknowledgeLabelaryNotice);
// Same gate as every other Labelary consumer (Print, …) — single source
// of truth in the store. The modal opens only when the gate is on, so in
// practice this resolves to noticeAcknowledged here, but we route through
// the shared selector to stay in lockstep with future call sites.
const canFetch = useLabelStore(canCallLabelary);

const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const urlRef = useRef<string | null>(null);
const zplRef = useRef<string>(generateZPL(label, objects));
// Derived: fetch is in flight while we have neither result nor error yet.
const loading = canFetch && !previewUrl && !error;
const loading = !previewUrl && !error;

useEffect(() => {
if (!canFetch) return;
let cancelled = false;
fetchPreview(zplRef.current, label)
.then((url) => {
Expand All @@ -50,7 +44,7 @@ export function LabelPreviewModal({ onClose }: Props) {
// `label` and the generated ZPL are intentionally captured once at mount
// (via zplRef): the preview should reflect the snapshot the user saw when
// they opened the modal, not refetch when the canvas changes underneath.
}, [canFetch]); // eslint-disable-line react-hooks/exhaustive-deps
}, []); // eslint-disable-line react-hooks/exhaustive-deps

const handleDownloadFallback = () => {
triggerDownload(new Blob([zplRef.current], { type: 'text/plain' }), 'label.zpl');
Expand All @@ -60,17 +54,21 @@ export function LabelPreviewModal({ onClose }: Props) {
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="label-preview-title"
>
<div
className="bg-surface border border-border-2 rounded shadow-lg flex flex-col overflow-hidden max-w-[90vw] max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border-2 shrink-0">
<span className="font-mono text-[10px] text-muted uppercase tracking-widest">
<span id="label-preview-title" className="font-mono text-[10px] text-muted uppercase tracking-widest">
{t.output.previewHeading}
</span>
<button
onClick={onClose}
aria-label={t.app.close}
className="p-0.5 rounded text-muted hover:text-text hover:bg-surface-2 transition-colors ml-6"
>
<XMarkIcon className="w-4 h-4" />
Expand All @@ -83,30 +81,10 @@ export function LabelPreviewModal({ onClose }: Props) {
viewport so small previews are still centered. */}
<div className="flex-1 overflow-auto bg-bg min-h-24 min-w-48">
<div className="min-h-full min-w-full flex items-center justify-center p-4">
{!noticeAcknowledged && (
<div className="flex flex-col gap-3 max-w-80 text-center font-mono text-[10px] text-muted leading-relaxed">
<span className="text-text uppercase tracking-widest">{t.output.previewNoticeTitle}</span>
<span>{t.output.previewNoticeBody}</span>
<a
href="https://labelary.com/privacy.html"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
>
{t.output.previewNoticePrivacyLink}
</a>
<button
onClick={acknowledgeLabelaryNotice}
className="self-center mt-1 px-3 py-1.5 rounded text-[10px] font-mono bg-surface-2 border border-border text-text hover:border-accent transition-colors"
>
{t.output.previewNoticeAcknowledge}
</button>
</div>
)}
{canFetch && loading && (
{loading && (
<span className="font-mono text-[10px] text-muted animate-pulse">{t.output.loading}</span>
)}
{canFetch && !loading && error && (
{!loading && error && (
<div className="flex flex-col items-center gap-3 max-w-64 text-center">
<span className="font-mono text-[10px] text-amber-400 leading-relaxed">{error}</span>
<button
Expand All @@ -118,7 +96,7 @@ export function LabelPreviewModal({ onClose }: Props) {
</button>
</div>
)}
{canFetch && !loading && !error && previewUrl && (
{!loading && !error && previewUrl && (
<img
src={previewUrl}
alt="Label preview"
Expand Down
72 changes: 72 additions & 0 deletions src/components/Output/LabelaryNoticeModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { XMarkIcon } from '@heroicons/react/16/solid';
import { useLabelStore } from '../../store/labelStore';
import { useT } from '../../lib/useT';

interface Props {
/** Called after the modal flipped the store flag, so callers only need
* to react (start the fetch / open the next modal). Acknowledgement
* itself is the modal's responsibility. */
onContinue: () => void;
onClose: () => void;
}

/** Privacy notice shown the first time the user invokes a Labelary-backed
* feature (Preview, Print). After acknowledgement the gate
* (`canCallLabelary`) opens permanently and this modal is no longer
* rendered. Shared between Preview and Print so the disclosure wording
* stays in lockstep. */
export function LabelaryNoticeModal({ onContinue, onClose }: Props) {
const t = useT();
const acknowledgeLabelaryNotice = useLabelStore((s) => s.acknowledgeLabelaryNotice);

const handleContinue = () => {
acknowledgeLabelaryNotice();
onContinue();
};

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="labelary-notice-title"
>
Comment thread
u8array marked this conversation as resolved.
<div
className="bg-surface border border-border-2 rounded shadow-lg flex flex-col overflow-hidden max-w-[90vw]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border-2 shrink-0">
<span id="labelary-notice-title" className="font-mono text-[10px] text-muted uppercase tracking-widest">
{t.output.previewNoticeTitle}
</span>
<button
onClick={onClose}
aria-label={t.app.close}
className="p-0.5 rounded text-muted hover:text-text hover:bg-surface-2 transition-colors ml-6"
>
<XMarkIcon className="w-4 h-4" />
</button>
Comment thread
u8array marked this conversation as resolved.
</div>

<div className="flex flex-col gap-3 p-6 max-w-80 text-center font-mono text-[10px] text-muted leading-relaxed">
<span>{t.output.previewNoticeBody}</span>
<a
href="https://labelary.com/privacy.html"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
>
{t.output.previewNoticePrivacyLink}
</a>
<button
onClick={handleContinue}
className="self-center mt-1 px-3 py-1.5 rounded text-[10px] font-mono bg-surface-2 border border-border text-text hover:border-accent transition-colors"
>
{t.output.previewNoticeAcknowledge}
</button>
</div>
</div>
</div>
);
}
17 changes: 13 additions & 4 deletions src/components/Output/ZPLOutput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useLabelStore } from '../../store/labelStore';
import { generateMultiPageZPL } from '../../lib/zplGenerator';
import { useT } from '../../lib/useT';
import { LabelPreviewModal } from './LabelPreview';
import { LabelaryNoticeModal } from './LabelaryNoticeModal';

interface Props {
collapsed?: boolean;
Expand All @@ -15,12 +16,11 @@ export function ZPLOutput({ collapsed, onCollapse, onExpand }: Props) {
const t = useT();
const label = useLabelStore((s) => s.label);
const pages = useLabelStore((s) => s.pages);
// Direct gate check — Preview is the path to the privacy notice, so the
// button must be reachable before acknowledgement. Other Labelary callers
// (AppShell.Print, LabelPreview.fetch) use the stricter canCallLabelary.
const labelaryEnabled = useLabelStore((s) => s.thirdParty.labelary);
const noticeAcknowledged = useLabelStore((s) => s.labelaryNoticeAcknowledged);
const [copied, setCopied] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [showNotice, setShowNotice] = useState(false);

const hasObjects = pages.some((p) => p.objects.length > 0);
const zpl = hasObjects ? generateMultiPageZPL(label, pages) : '';
Expand Down Expand Up @@ -49,7 +49,7 @@ export function ZPLOutput({ collapsed, onCollapse, onExpand }: Props) {
<div className="flex items-center gap-3">
{labelaryEnabled && (
<button
onClick={() => setShowPreview(true)}
onClick={() => (noticeAcknowledged ? setShowPreview(true) : setShowNotice(true))}
disabled={!zpl}
title={t.output.previewHeading}
className="flex items-center gap-1 font-mono text-[10px] text-muted hover:text-accent disabled:opacity-25 disabled:cursor-not-allowed transition-colors"
Expand Down Expand Up @@ -79,6 +79,15 @@ export function ZPLOutput({ collapsed, onCollapse, onExpand }: Props) {
: <span className="text-muted">{t.output.noObjects}</span>
}
</pre>}
{showNotice && (
<LabelaryNoticeModal
onClose={() => setShowNotice(false)}
onContinue={() => {
setShowNotice(false);
setShowPreview(true);
}}
/>
)}
{showPreview && <LabelPreviewModal onClose={() => setShowPreview(false)} />}
</div>
);
Expand Down
3 changes: 2 additions & 1 deletion src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const ar = {
newDesign: 'تصميم جديد',
addPage: 'إضافة صفحة',
cancel: 'إلغاء',
close: 'إغلاق',
deletePage: 'حذف الصفحة',
deletePageConfirm: 'حذف الصفحة الحالية؟',
openDesign: 'فتح تصميم',
Expand All @@ -122,7 +123,7 @@ const ar = {
previewEmpty: 'تظهر المعاينة\nبعد إجراء تغييرات',
previewProvider: 'معاينة عبر api.labelary.com',
previewNoticeTitle: 'إشعار الخصوصية',
previewNoticeBody: 'يتم إنشاء المعاينة بواسطة الخدمة الخارجية api.labelary.com. يتم إرسال ZPL الكامل للملصق، بما في ذلك أي بيانات حساسة، عبر الشبكة.',
previewNoticeBody: 'يتم إنشاء المعاينة والطباعة بواسطة الخدمة الخارجية api.labelary.com. يتم إرسال ZPL الكامل للملصق، بما في ذلك أي بيانات حساسة، عبر الشبكة.',
previewNoticePrivacyLink: 'معلومات خصوصية Labelary',
previewNoticeAcknowledge: 'فهمت، تابع',
},
Expand Down
3 changes: 2 additions & 1 deletion src/locales/bg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const bg = {
newDesign: 'Нов дизайн',
addPage: 'Добавяне на страница',
cancel: 'Отказ',
close: 'Затваряне',
deletePage: 'Изтриване на страница',
deletePageConfirm: 'Изтриване на текущата страница?',
openDesign: 'Отвори дизайн',
Expand All @@ -122,7 +123,7 @@ const bg = {
previewEmpty: 'Преглед се появява\nслед промени',
previewProvider: 'Преглед чрез api.labelary.com',
previewNoticeTitle: 'Известие за поверителност',
previewNoticeBody: 'Прегледът се изобразява от външната услуга api.labelary.com. Целият ZPL на етикета, включително чувствителните данни, се изпраща през мрежата.',
previewNoticeBody: 'Прегледът и отпечатването се изобразяват от външната услуга api.labelary.com. Целият ZPL на етикета, включително чувствителните данни, се изпраща през мрежата.',
previewNoticePrivacyLink: 'Информация за поверителност на Labelary',
previewNoticeAcknowledge: 'Разбрах, продължи',
},
Expand Down
3 changes: 2 additions & 1 deletion src/locales/cs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const cs = {
newDesign: 'Nový návrh',
addPage: 'Přidat stránku',
cancel: 'Zrušit',
close: 'Zavřít',
deletePage: 'Smazat stránku',
deletePageConfirm: 'Smazat aktuální stránku?',
openDesign: 'Otevřít návrh',
Expand All @@ -122,7 +123,7 @@ const cs = {
previewEmpty: 'Náhled se zobrazí\npo úpravách',
previewProvider: 'Náhled přes api.labelary.com',
previewNoticeTitle: 'Upozornění na ochranu údajů',
previewNoticeBody: 'Náhled se generuje pomocí externí služby api.labelary.com. Celý ZPL štítku, včetně citlivých údajů, je odesílán přes síť.',
previewNoticeBody: 'Náhled a tisk se generují pomocí externí služby api.labelary.com. Celý ZPL štítku, včetně citlivých údajů, je odesílán přes síť.',
previewNoticePrivacyLink: 'Informace o ochraně údajů Labelary',
previewNoticeAcknowledge: 'Rozumím, pokračovat',
},
Expand Down
3 changes: 2 additions & 1 deletion src/locales/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const da = {
newDesign: 'Nyt design',
addPage: 'Tilføj side',
cancel: 'Annuller',
close: 'Luk',
deletePage: 'Slet side',
deletePageConfirm: 'Slet den aktuelle side?',
openDesign: 'Åbn design',
Expand All @@ -122,7 +123,7 @@ const da = {
previewEmpty: 'Forhåndsvisning vises\nefter ændringer',
previewProvider: 'Forhåndsvisning via api.labelary.com',
previewNoticeTitle: 'Privatlivsmeddelelse',
previewNoticeBody: 'Forhåndsvisningen genereres af den eksterne tjeneste api.labelary.com. Hele etikettens ZPL, inklusive følsomme data, sendes over netværket.',
previewNoticeBody: 'Forhåndsvisning og udskrivning genereres af den eksterne tjeneste api.labelary.com. Hele etikettens ZPL, inklusive følsomme data, sendes over netværket.',
previewNoticePrivacyLink: 'Labelary-privatlivsoplysninger',
previewNoticeAcknowledge: 'Forstået, fortsæt',
},
Expand Down
3 changes: 2 additions & 1 deletion src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const de = {
newDesign: 'Neues Design',
addPage: 'Seite hinzufügen',
cancel: 'Abbrechen',
close: 'Schließen',
deletePage: 'Seite löschen',
deletePageConfirm: 'Aktuelle Seite löschen?',
openDesign: 'Design öffnen',
Expand Down Expand Up @@ -142,7 +143,7 @@ const de = {
previewEmpty: 'Vorschau erscheint\nnach Änderungen',
previewProvider: 'Vorschau über api.labelary.com',
previewNoticeTitle: 'Datenschutzhinweis',
previewNoticeBody: 'Die Vorschau wird vom externen Dienst api.labelary.com erstellt. Dabei wird das vollständige Label-ZPL inklusive sensibler Daten übertragen.',
previewNoticeBody: 'Vorschau und Druck werden vom externen Dienst api.labelary.com erstellt. Dabei wird das vollständige Label-ZPL inklusive sensibler Daten übertragen.',
previewNoticePrivacyLink: 'Labelary-Datenschutzhinweise',
previewNoticeAcknowledge: 'Verstanden, fortfahren',
},
Expand Down
3 changes: 2 additions & 1 deletion src/locales/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const el = {
newDesign: 'Νέο σχέδιο',
addPage: 'Προσθήκη σελίδας',
cancel: 'Ακύρωση',
close: 'Κλείσιμο',
deletePage: 'Διαγραφή σελίδας',
deletePageConfirm: 'Διαγραφή της τρέχουσας σελίδας;',
openDesign: 'Άνοιγμα σχεδίου',
Expand All @@ -122,7 +123,7 @@ const el = {
previewEmpty: 'Η προεπισκόπηση εμφανίζεται\nμετά τις αλλαγές',
previewProvider: 'Προεπισκόπηση μέσω api.labelary.com',
previewNoticeTitle: 'Ειδοποίηση απορρήτου',
previewNoticeBody: 'Η προεπισκόπηση δημιουργείται από την εξωτερική υπηρεσία api.labelary.com. Ολόκληρο το ZPL της ετικέτας, συμπεριλαμβανομένων ευαίσθητων δεδομένων, αποστέλλεται μέσω δικτύου.',
previewNoticeBody: 'Η προεπισκόπηση και η εκτύπωση δημιουργούνται από την εξωτερική υπηρεσία api.labelary.com. Ολόκληρο το ZPL της ετικέτας, συμπεριλαμβανομένων ευαίσθητων δεδομένων, αποστέλλεται μέσω δικτύου.',
previewNoticePrivacyLink: 'Πληροφορίες απορρήτου Labelary',
previewNoticeAcknowledge: 'Κατάλαβα, συνέχεια',
},
Expand Down
Loading