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
38 changes: 23 additions & 15 deletions src/components/Canvas/PaginationControl.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { useState } from "react";
import { TrashIcon } from "@heroicons/react/16/solid";
import { useLabelStore } from "../../store/labelStore";
import { useT } from "../../lib/useT";
import { ConfirmDialog } from "../ui/ConfirmDialog";

export function PaginationControl() {
const t = useT();
const [confirmOpen, setConfirmOpen] = useState(false);
const pageCount = useLabelStore((s) => s.pages.length);
const currentPageIndex = useLabelStore((s) => s.currentPageIndex);
const setCurrentPage = useLabelStore((s) => s.setCurrentPage);
const addPage = useLabelStore((s) => s.addPage);
const removePage = useLabelStore((s) => s.removePage);

// Hide entirely on single-page documents; "Add page" lives in the File menu.
// Hide entirely on single-page documents; adding pages lives in the File menu.
if (pageCount <= 1) return null;

const canPrev = currentPageIndex > 0;
const canNext = currentPageIndex < pageCount - 1;
const canRemove = pageCount > 1;

return (
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1 bg-surface border border-border rounded px-1 py-0.5">
Expand All @@ -39,22 +43,26 @@ export function PaginationControl() {
</button>
<div className="w-px h-3.5 bg-border mx-0.5" />
<button
onClick={addPage}
title="Add page"
aria-label="Add page"
className="w-6 h-6 flex items-center justify-center text-muted hover:text-text font-mono text-sm transition-colors"
>
+
</button>
<button
onClick={() => canRemove && removePage(currentPageIndex)}
disabled={!canRemove}
onClick={() => setConfirmOpen(true)}
title="Delete current page"
aria-label="Delete current page"
className="w-6 h-6 flex items-center justify-center text-muted hover:text-text disabled:opacity-25 disabled:cursor-not-allowed font-mono text-sm transition-colors"
className="w-6 h-6 flex items-center justify-center text-muted hover:text-red-400 transition-colors"
>
<TrashIcon className="w-3.5 h-3.5" />
</button>
{confirmOpen && (
<ConfirmDialog
message={t.app.deletePageConfirm}
confirmLabel={t.app.deletePage}
cancelLabel={t.app.cancel}
destructive
onConfirm={() => {
removePage(currentPageIndex);
setConfirmOpen(false);
}}
onCancel={() => setConfirmOpen(false)}
/>
)}
</div>
);
}
12 changes: 7 additions & 5 deletions src/components/Palette/ObjectPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useT } from '../../lib/useT';
import { useLabelStore } from '../../store/labelStore';
import { mmToDots } from '../../lib/coordinates';
import { DragHandleIcon } from '../ui/DragHandleIcon';
import { CollapsibleSection } from '../ui/CollapsibleSection';
import type { PaletteDragData } from '../../dnd/types';

interface PaletteEntryProps {
Expand Down Expand Up @@ -67,14 +68,15 @@ export function ObjectPalette() {
);
if (entries.length === 0) return null;
return (
<div key={group.key} className="flex flex-col gap-0.5">
<p className="font-mono text-[10px] font-medium text-muted uppercase tracking-widest px-1 pt-1 pb-1.5">
{t.palette[group.labelKey]}
</p>
<CollapsibleSection
key={group.key}
id={`palette-${group.key}`}
title={t.palette[group.labelKey]}
>
{entries.map(([type, def]) => (
<PaletteEntry key={type} type={type} def={def} />
))}
</div>
</CollapsibleSection>
);
})}
</div>
Expand Down
37 changes: 27 additions & 10 deletions src/components/Properties/PropertiesPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { InformationCircleIcon } from "@heroicons/react/16/solid";
import { useLabelStore, useCurrentObjects } from "../../store/labelStore";
import { ObjectRegistry } from "../../registry";
import { stripZplCommandChars } from "../../registry/zplHelpers";
Expand All @@ -12,6 +13,7 @@ import {
import type { Unit } from "../../lib/units";
import { useT } from "../../lib/useT";
import { parseIntOrUndef } from "../../lib/inputParse";
import { CollapsibleSection } from "../ui/CollapsibleSection";
import { inputCls, labelCls } from "./styles";
import type { LabelConfig } from "../../types/ObjectType";

Expand Down Expand Up @@ -303,8 +305,10 @@ function LabelConfigPanel({
</select>
</div>

<div className="border-t border-border" />

<CollapsibleSection
id="label-output"
title={t.label.outputHeading}
>
<div className="flex flex-col gap-1">
<label className={labelCls}>{t.label.mediaMode}</label>
<select
Expand Down Expand Up @@ -354,13 +358,21 @@ function LabelConfigPanel({
}
/>
</div>
</CollapsibleSection>

<div className="border-t border-border" />

<p className={labelCls}>{t.label.printerSettingsHeading}</p>

<CollapsibleSection
id="label-printer-settings"
title={t.label.printerSettingsHeading}
defaultOpen={false}
>
<div className="flex flex-col gap-1">
<label className={labelCls}>{t.label.printSpeed}</label>
<label className={labelCls}>
{t.label.printSpeed}
<InformationCircleIcon
className="w-3.5 h-3.5 ml-1 inline-block align-text-bottom text-muted cursor-help"
title={t.label.printSpeedHint}
/>
</label>
<input
type="number"
className={inputCls}
Expand All @@ -371,11 +383,16 @@ function LabelConfigPanel({
onUpdate({ printSpeed: parseIntOrUndef(e.target.value) })
}
/>
<p className="text-[10px] text-muted">{t.label.printSpeedHint}</p>
</div>

<div className="flex flex-col gap-1">
<label className={labelCls}>{t.label.darkness}</label>
<label className={labelCls}>
{t.label.darkness}
<InformationCircleIcon
className="w-3.5 h-3.5 ml-1 inline-block align-text-bottom text-muted cursor-help"
title={t.label.darknessHint}
/>
</label>
<input
type="number"
className={inputCls}
Expand All @@ -386,7 +403,6 @@ function LabelConfigPanel({
onUpdate({ darkness: parseIntOrUndef(e.target.value) })
}
/>
<p className="text-[10px] text-muted">{t.label.darknessHint}</p>
</div>

<div className="flex flex-col gap-1">
Expand Down Expand Up @@ -461,6 +477,7 @@ function LabelConfigPanel({
</div>
</div>
</div>
</CollapsibleSection>
</div>
</div>
);
Expand Down
72 changes: 72 additions & 0 deletions src/components/ui/CollapsibleSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useEffect, useState, type ReactNode } from 'react';
import { ChevronDownIcon } from '@heroicons/react/16/solid';

interface CollapsibleSectionProps {
/** Stable identifier, used as the localStorage key for the open state. */
id: string;
title: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
}

const LS_PREFIX = 'zpl:section:';

function readStored(id: string, fallback: boolean): boolean {
const saved = localStorage.getItem(LS_PREFIX + id);
return saved === null ? fallback : saved === '1';
}

/**
* Section with a clickable header that toggles its body. Independent of
* sibling sections — multiple can be open at once. Open state is persisted
* per `id` in localStorage so the UI feels stable across reloads.
*/
export function CollapsibleSection({
id,
title,
defaultOpen = true,
children,
}: CollapsibleSectionProps) {
const [open, setOpen] = useState(() => readStored(id, defaultOpen));

// Re-sync open state when `id` changes so the component can be reused for
// a different section without leaking the previous open state into the new
// id's storage slot. React's blessed pattern for deriving state from
// props: setState during render under a prev-vs-current guard.
// https://react.dev/reference/react/useState#storing-information-from-previous-renders
const [prevId, setPrevId] = useState(id);
if (prevId !== id) {
setPrevId(id);
setOpen(readStored(id, defaultOpen));
}

useEffect(() => {
localStorage.setItem(LS_PREFIX + id, open ? '1' : '0');
}, [id, open]);

const contentId = `section-content-${id}`;

return (
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls={contentId}
className="flex items-center justify-between gap-2 px-1 pt-1 pb-1.5 text-muted hover:text-text transition-colors"
>
<span className="font-mono text-[10px] font-medium uppercase tracking-widest">
{title}
</span>
<ChevronDownIcon
className={`w-3 h-3 shrink-0 transition-transform ${open ? '' : '-rotate-90'}`}
/>
</button>
{open && (
<div id={contentId} className="flex flex-col gap-0.5">
{children}
</div>
)}
</div>
);
}
90 changes: 90 additions & 0 deletions src/components/ui/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';

interface ConfirmDialogProps {
message: string;
confirmLabel: string;
cancelLabel: string;
/** Renders the confirm button in red. Use for irreversible operations. */
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}

/**
* Minimal confirm dialog matching the project's modal aesthetic.
*
* Mount it conditionally (`{open && <ConfirmDialog … />}`); the parent owns
* visibility state. Backdrop click and Escape both fire `onCancel`.
*/
export function ConfirmDialog({
message,
confirmLabel,
cancelLabel,
destructive,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
window.addEventListener('keydown', onKey);
// Lock background scroll while the modal is open so the dialog stays
// visually anchored and the user cannot drift past it.
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = originalOverflow;
};
}, [onCancel]);
Comment thread
u8array marked this conversation as resolved.

const confirmCls = destructive
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-accent text-bg hover:opacity-90';

// Portal so the fixed-position backdrop is anchored to the viewport even
// when an ancestor has a CSS transform (which would otherwise contain
// `position: fixed` and miscentre the modal).
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onCancel}
>
<div
role="alertdialog"
aria-modal="true"
aria-describedby="confirm-dialog-message"
className="bg-surface border border-border rounded shadow-lg flex flex-col w-[400px] max-w-[95vw]"
onClick={(e) => e.stopPropagation()}
>
<p
id="confirm-dialog-message"
className="px-5 py-5 text-xs text-text leading-relaxed"
>
{message}
</p>
<div className="flex justify-end gap-2 px-4 py-3 border-t border-border">
<button
type="button"
onClick={onCancel}
autoFocus={destructive}
className="px-4 py-1.5 rounded text-xs font-mono whitespace-nowrap border border-border text-text hover:bg-surface-2 transition-colors"
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
autoFocus={!destructive}
className={`px-4 py-1.5 rounded text-xs font-mono whitespace-nowrap ${confirmCls} transition`}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
6 changes: 5 additions & 1 deletion src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,14 @@ const ar = {
dpmm24: '24 نقطة/مم — 600 dpi',
printQuantity: 'كمية الطباعة',
mediaMode: 'وضع الوسائط',
outputHeading: 'الإخراج',
mediaModeT: 'T — تمزيق',
mediaModeV: 'V — تقشير',
mediaModeD: 'D — قاطع',
mediaModeK: 'K — كشك',
labelShift: 'إزاحة الملصق (dots)',
printerDefault: 'الإعداد الافتراضي للطابعة',
printerSettingsHeading: 'إعدادات الطابعة (اختياري)',
printerSettingsHeading: 'الطابعة (اختياري)',
printSpeed: 'سرعة الطباعة (ips، 2-14)',
printSpeedHint: 'خاص بالطابعة. اتركه فارغًا لاستخدام القيمة الافتراضية.',
darkness: 'الكثافة (-30 إلى +30)',
Expand All @@ -93,6 +94,9 @@ const ar = {
exportZpl: 'تصدير ZPL',
newDesign: 'تصميم جديد',
addPage: 'إضافة صفحة',
cancel: 'إلغاء',
deletePage: 'حذف الصفحة',
deletePageConfirm: 'حذف الصفحة الحالية؟',
openDesign: 'فتح تصميم',
saveDesign: 'حفظ تصميم',
print: 'طباعة كصورة (المتصفح)',
Expand Down
6 changes: 5 additions & 1 deletion src/locales/bg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,14 @@ const bg = {
dpmm24: '24 dpmm — 600 dpi',
printQuantity: 'Количество за печат',
mediaMode: 'Режим на носителя',
outputHeading: 'Изход',
mediaModeT: 'T — Откъсване',
mediaModeV: 'V — Отлепване',
mediaModeD: 'D — Резач',
mediaModeK: 'K — Киоск',
labelShift: 'Отместване на етикета (dots)',
printerDefault: 'По подразбиране на принтера',
printerSettingsHeading: 'Настройки на принтера (по избор)',
printerSettingsHeading: 'Принтер (по избор)',
printSpeed: 'Скорост на печат (ips, 2-14)',
printSpeedHint: 'Специфично за принтера. Оставете празно за стойност по подразбиране.',
darkness: 'Плътност (-30 до +30)',
Expand All @@ -93,6 +94,9 @@ const bg = {
exportZpl: 'Export ZPL',
newDesign: 'Нов дизайн',
addPage: 'Добавяне на страница',
cancel: 'Отказ',
deletePage: 'Изтриване на страница',
deletePageConfirm: 'Изтриване на текущата страница?',
openDesign: 'Отвори дизайн',
saveDesign: 'Запази дизайн',
print: 'Печат като изображение (браузър)',
Expand Down
Loading