qr code layouts im mobile admin perfektioniert.

This commit is contained in:
Codex Agent
2025-12-14 22:14:30 +01:00
parent c8b149d887
commit a8b6e5d9c4
7 changed files with 369 additions and 230 deletions

View File

@@ -1,11 +1,14 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, RefreshCcw, Plus } from 'lucide-react';
import { ArrowLeft, RefreshCcw, Plus, ChevronDown } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { Input, TextArea } from 'tamagui';
import { Accordion } from '@tamagui/accordion';
import { HexColorPicker } from 'react-colorful';
import { Portal } from '@tamagui/portal';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import {
@@ -394,11 +397,11 @@ function renderEventName(name: TenantEvent['name'] | null | undefined): string |
function getDefaultSlots(): Record<string, SlotDefinition> {
return {
headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: 'Playfair Display', align: 'center' },
headline: { x: 0.08, y: 0.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: 'Playfair Display', align: 'center' },
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: 'Montserrat', align: 'center' },
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: 'Lora', lineHeight: 1.4, align: 'center' },
instructions: { x: 0.1, y: 0.34, w: 0.8, fontSize: 14, fontFamily: 'Lora', lineHeight: 1.35 },
qr: { x: 0.3, y: 0.3, w: 0.28 },
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: 'Lora', lineHeight: 1.4, align: 'center' },
instructions: { x: 0.1, y: 0.7, w: 0.8, fontSize: 24, fontFamily: 'Lora', lineHeight: 1.5 },
qr: { x: 0.39, y: 0.37, w: 0.27 },
};
}
@@ -521,26 +524,28 @@ function buildFabricOptions({
const widthPx = localWidth * scaleX;
const heightPx = localHeight * scaleY;
const baseCenterX = opts.panelOffset + slot.x * panelWidth + localWidth / 2;
const baseCenterY = slot.y * panelHeight + localHeight / 2;
let targetCenterX = baseCenterX;
let targetCenterY = baseCenterY;
// Default: top-left positioning for non-rotated panels
let targetX = opts.panelOffset + slot.x * panelWidth;
let targetY = slot.y * panelHeight;
let rotation = 0;
// Foldable layouts rotate each panel around its own center; Fabric expects center-based coords when rotated.
if (isFoldable) {
const baseCenterX = opts.panelOffset + slot.x * panelWidth + localWidth / 2;
const baseCenterY = slot.y * panelHeight + localHeight / 2;
const panelCenterX = opts.panelOffset + panelWidth / 2;
const panelCenterY = panelHeight / 2;
rotation = opts.panelOffset === 0 ? 90 : -90;
const rotated = rotatePoint(panelCenterX, panelCenterY, baseCenterX, baseCenterY, rotation);
targetCenterX = rotated.x;
targetCenterY = rotated.y;
targetX = rotated.x;
targetY = rotated.y;
}
elements.push({
id: `${type}-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`,
type,
x: targetCenterX * scaleX,
y: targetCenterY * scaleY,
x: targetX * scaleX,
y: targetY * scaleY,
width: widthPx,
height: heightPx,
fontSize: slot.fontSize ?? (type === 'headline' ? 30 : type === 'subtitle' ? 18 : 16),
@@ -557,26 +562,26 @@ function buildFabricOptions({
if (!qrUrl) return;
const localSize = slot.w * panelWidth;
const sizePx = localSize * scaleX;
const baseCenterX = opts.panelOffset + slot.x * panelWidth + localSize / 2;
const baseCenterY = slot.y * panelHeight + localSize / 2;
let targetCenterX = baseCenterX;
let targetCenterY = baseCenterY;
let targetX = opts.panelOffset + slot.x * panelWidth;
let targetY = slot.y * panelHeight;
let rotation = 0;
if (isFoldable) {
const baseCenterX = opts.panelOffset + slot.x * panelWidth + localSize / 2;
const baseCenterY = slot.y * panelHeight + localSize / 2;
const panelCenterX = opts.panelOffset + panelWidth / 2;
const panelCenterY = panelHeight / 2;
rotation = opts.panelOffset === 0 ? 90 : -90;
const rotated = rotatePoint(panelCenterX, panelCenterY, baseCenterX, baseCenterY, rotation);
targetCenterX = rotated.x;
targetCenterY = rotated.y;
targetX = rotated.x;
targetY = rotated.y;
}
elements.push({
id: `qr-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`,
type: 'qr',
x: targetCenterX * scaleX,
y: targetCenterY * scaleY,
x: targetX * scaleX,
y: targetY * scaleY,
width: sizePx,
height: sizePx,
rotation,
@@ -858,7 +863,7 @@ function TextStep({
size="$4"
/>
<TextArea
placeholder={t('events.qr.description', 'Beschreibung')}
placeholder={t('events.qr.bottomNote', 'Unterer Hinweistext')}
value={textFields.description}
onChangeText={(val) => updateField('description', val)}
size="$4"
@@ -1082,7 +1087,7 @@ function PreviewStep({
<LayoutControls slots={resolvedSlots} slotOverrides={slotOverrides} onUpdateSlot={onUpdateSlot} tenantFonts={tenantFonts} qrUrl={qrImageSrc} />
<XStack space="$2">
<XStack space="$2" width="100%" flexWrap="wrap">
<CTAButton
label={t('events.qr.exportPdf', 'Export PDF')}
onPress={async () => {
@@ -1096,6 +1101,7 @@ function PreviewStep({
console.error(err);
}
}}
style={{ flex: 1, minWidth: 0 }}
/>
<CTAButton
label={t('events.qr.exportPng', 'Export PNG')}
@@ -1109,6 +1115,7 @@ function PreviewStep({
console.error(err);
}
}}
style={{ flex: 1, minWidth: 0 }}
/>
</XStack>
</YStack>
@@ -1129,6 +1136,7 @@ function LayoutControls({
qrUrl: string | null;
}) {
const { t } = useTranslation('management');
const [openColorSlot, setOpenColorSlot] = React.useState<string | null>(null);
const fontOptions = React.useMemo(() => {
const preset = ['Playfair Display', 'Lora', 'Montserrat', 'Inter', 'Roboto'];
const tenant = tenantFonts.map((font) => font.family);
@@ -1169,6 +1177,8 @@ function LayoutControls({
}) => {
const dec = () => onChange(clampValue(value - step, min, max));
const inc = () => onChange(clampValue(value + step, min, max));
const decimals = step % 1 === 0 ? 0 : Math.min(4, `${step}`.split('.')[1]?.length ?? 1);
const formatValue = (val: number) => val.toFixed(decimals);
return (
<XStack space="$1" alignItems="center">
<Pressable onPress={dec}>
@@ -1183,8 +1193,13 @@ function LayoutControls({
inputMode="decimal"
min={min}
max={max}
value={value.toFixed(step < 1 ? 1 : 0)}
onChange={(event) => onChange(Number(event.target.value))}
value={formatValue(value)}
onChange={(event) => {
const next = Number(event.target.value);
if (Number.isFinite(next)) {
onChange(next);
}
}}
style={numberInputStyle}
/>
<Pressable onPress={inc}>
@@ -1222,140 +1237,221 @@ function LayoutControls({
onUpdateSlot(slotKey, { fontFamily: value || undefined });
};
return (
<YStack key={slotKey} space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{label}
</Text>
<XStack space="$3">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
X (%)
</Text>
<XStack space="$1" alignItems="center">
<Pressable onPress={() => onPercentChange('x')(((override.x ?? slot.x) * 100) - 0.5)}>
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor="#e5e7eb" backgroundColor="#fff">
<Text fontSize="$md" fontWeight="800" color="#111827">
</Text>
</XStack>
</Pressable>
<input
type="text"
inputMode="decimal"
step="0.5"
min="0"
max="100"
value={((override.x ?? slot.x) * 100).toFixed(1)}
onChange={(event) => onPercentChange('x')(Number(event.target.value))}
style={numberInputStyle}
/>
<Pressable onPress={() => onPercentChange('x')(((override.x ?? slot.x) * 100) + 0.5)}>
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor="#e5e7eb" backgroundColor="#fff">
<Text fontSize="$md" fontWeight="800" color="#111827">
+
</Text>
</XStack>
</Pressable>
</XStack>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Y (%)
</Text>
<StepperInput
value={(override.y ?? slot.y) * 100}
min={0}
max={100}
step={0.5}
onChange={(val) => onPercentChange('y')(val)}
/>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Breite (%)
</Text>
<StepperInput
value={(override.w ?? slot.w) * 100}
min={10}
max={100}
step={0.5}
onChange={(val) => onPercentChange('w')(val)}
/>
</YStack>
</XStack>
const currentX = override.x ?? slot.x;
const currentY = override.y ?? slot.y;
const currentW = override.w ?? slot.w;
const centerX = () => {
const centered = clampValue((1 - currentW) / 2, 0, 1);
onUpdateSlot(slotKey, { x: centered });
};
<XStack space="$3">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Font Size (px)
return (
<Accordion.Item value={slotKey} key={slotKey}>
<Accordion.Trigger padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12} backgroundColor="#f8fafc">
<XStack justifyContent="space-between" alignItems="center" flex={1}>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{label}
</Text>
<StepperInput value={override.fontSize ?? slot.fontSize ?? 16} min={8} max={200} step={1} onChange={(val) => onFontSizeChange(val)} />
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Font Family
</Text>
<select
value={override.fontFamily ?? slot.fontFamily ?? ''}
onChange={(event) => onFontFamilyChange(event.target.value)}
style={selectStyle}
>
<option value="">{t('events.qr.defaultFont', 'Standard')}</option>
{fontOptions.map((family) => (
<option key={family} value={family}>
{family}
</option>
))}
</select>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
{t('events.qr.fontColor', 'Schriftfarbe')}
</Text>
<XStack space="$2" alignItems="center">
<input
type="color"
value={override.color ?? slot.color ?? '#0f172a'}
onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })}
style={{ width: 48, height: 36, border: '1px solid #e5e7eb', borderRadius: 8, padding: 0, background: '#fff' }}
/>
<input
type="text"
value={override.color ?? slot.color ?? ''}
placeholder="#0f172a"
onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })}
style={numberInputStyle}
/>
<ChevronDown size={16} color="#6b7280" />
</XStack>
</Accordion.Trigger>
<Accordion.Content paddingTop="$2">
<YStack space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}>
<XStack space="$3">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
X (%)
</Text>
<XStack space="$2" alignItems="center">
<StepperInput
value={currentX * 100}
min={0}
max={100}
step={0.5}
onChange={(val) => onPercentChange('x')(val)}
/>
<Pressable onPress={centerX}>
<XStack
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={10}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#fff"
>
<Text fontSize="$xs" color="#111827">
{t('common.center', 'Zentrieren')}
</Text>
</XStack>
</Pressable>
</XStack>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Y (%)
</Text>
<StepperInput
value={currentY * 100}
min={0}
max={100}
step={0.5}
onChange={(val) => onPercentChange('y')(val)}
/>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Breite (%)
</Text>
<StepperInput
value={currentW * 100}
min={10}
max={100}
step={0.5}
onChange={(val) => onPercentChange('w')(val)}
/>
</YStack>
</XStack>
<XStack space="$3">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Font Size (px)
</Text>
<StepperInput value={override.fontSize ?? slot.fontSize ?? 16} min={8} max={200} step={1} onChange={(val) => onFontSizeChange(val)} />
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Font Family
</Text>
<select
value={override.fontFamily ?? slot.fontFamily ?? ''}
onChange={(event) => onFontFamilyChange(event.target.value)}
style={selectStyle}
>
<option value="">{t('events.qr.defaultFont', 'Standard')}</option>
{fontOptions.map((family) => (
<option key={family} value={family}>
{family}
</option>
))}
</select>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
{t('events.qr.fontColor', 'Schriftfarbe')}
</Text>
<YStack space="$2">
<XStack space="$2" alignItems="center">
<Pressable onPress={() => setOpenColorSlot(openColorSlot === slotKey ? null : slotKey)}>
<XStack
width={48}
height={36}
borderRadius={10}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor={override.color ?? slot.color ?? '#0f172a'}
/>
</Pressable>
<input
type="text"
value={override.color ?? slot.color ?? ''}
placeholder="#0f172a"
onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })}
style={{ ...numberInputStyle, width: 110 }}
/>
</XStack>
{openColorSlot === slotKey ? (
<Portal>
<YStack
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
alignItems="center"
justifyContent="center"
backgroundColor="rgba(0,0,0,0.35)"
zIndex={9999}
onPress={() => setOpenColorSlot(null)}
>
<YStack
padding="$3"
borderRadius={16}
backgroundColor="#fff"
borderWidth={1}
borderColor="#e5e7eb"
elevation="$4"
shadowColor="rgba(0,0,0,0.08)"
shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.2}
shadowRadius={12}
gap="$2"
>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.fontColor', 'Schriftfarbe')}
</Text>
<HexColorPicker
color={override.color ?? slot.color ?? '#0f172a'}
onChange={(val) => onUpdateSlot(slotKey, { color: val })}
style={{ width: 240, height: 200 }}
/>
<XStack space="$2" justifyContent="flex-end">
<Pressable onPress={() => setOpenColorSlot(null)}>
<XStack
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={10}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#fff"
>
<Text fontSize="$xs" color="#111827">
{t('common.close', 'Schließen')}
</Text>
</XStack>
</Pressable>
</XStack>
</YStack>
</YStack>
</Portal>
) : null}
</YStack>
</YStack>
</XStack>
<XStack space="$2">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Align
</Text>
<select
value={override.align ?? slot.align ?? 'left'}
onChange={(event) => onUpdateSlot(slotKey, { align: event.target.value as SlotDefinition['align'] })}
style={selectStyle}
>
<option value="left">{t('common.left', 'Links')}</option>
<option value="center">{t('common.center', 'Zentriert')}</option>
<option value="right">{t('common.right', 'Rechts')}</option>
</select>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Line Height
</Text>
<StepperInput
value={override.lineHeight ?? slot.lineHeight ?? 1.35}
min={0.8}
max={3}
step={0.05}
onChange={(val) => onLineHeightChange(val)}
/>
</YStack>
<YStack flex={1} />
<YStack flex={1} />
</XStack>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Line Height
</Text>
<StepperInput value={override.lineHeight ?? slot.lineHeight ?? 1.35} min={0.8} max={3} step={0.05} onChange={(val) => onLineHeightChange(val)} />
</YStack>
</XStack>
<XStack space="$2">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Align
</Text>
<select
value={override.align ?? slot.align ?? 'left'}
onChange={(event) => onUpdateSlot(slotKey, { align: event.target.value as SlotDefinition['align'] })}
style={selectStyle}
>
<option value="left">{t('common.left', 'Links')}</option>
<option value="center">{t('common.center', 'Zentriert')}</option>
<option value="right">{t('common.right', 'Rechts')}</option>
</select>
</YStack>
<YStack flex={1} />
<YStack flex={1} />
<YStack flex={1} />
</XStack>
</YStack>
</Accordion.Content>
</Accordion.Item>
);
};
@@ -1367,74 +1463,79 @@ function LayoutControls({
onUpdateSlot('qr', { [field]: pct / 100 });
};
const accordionDefaults = ['headline'];
return (
<YStack space="$3" marginTop="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.layoutControls', 'Layout & Schrift')}
</Text>
<YStack space="$2">
<Accordion type="multiple" defaultValue={accordionDefaults}>
{renderTextSlot('headline', t('events.qr.headline', 'Headline'))}
{renderTextSlot('subtitle', t('events.qr.subtitle', 'Subtitle'))}
{renderTextSlot('description', t('events.qr.description', 'Beschreibung'))}
{renderTextSlot('description', t('events.qr.bottomNote', 'Unterer Hinweistext'))}
{renderTextSlot('instructions', t('events.qr.instructions', 'Anleitung'))}
</YStack>
{qrSlot ? (
<YStack space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.qr_code_label', 'QRCode')}
</Text>
<XStack space="$2">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
X (%)
</Text>
<input
type="number"
step="0.5"
min="0"
max="100"
value={((qrOverride.x ?? qrSlot.x) * 100).toFixed(1)}
onChange={(event) => onQrPercentChange('x')(Number(event.target.value))}
style={numberInputStyle}
/>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Y (%)
</Text>
<input
type="number"
step="0.5"
min="0"
max="100"
value={((qrOverride.y ?? qrSlot.y) * 100).toFixed(1)}
onChange={(event) => onQrPercentChange('y')(Number(event.target.value))}
style={numberInputStyle}
/>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Größe (%)
</Text>
<input
type="number"
step="0.5"
min="10"
max="100"
value={((qrOverride.w ?? qrSlot.w) * 100).toFixed(1)}
onChange={(event) => onQrPercentChange('w')(Number(event.target.value))}
style={numberInputStyle}
/>
</YStack>
</XStack>
{!qrUrl ? (
<Text fontSize="$xs" color="#b91c1c">
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
</Text>
) : null}
</YStack>
) : null}
{qrSlot ? (
<Accordion.Item value="qr">
<Accordion.Trigger padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12} backgroundColor="#f8fafc">
<XStack justifyContent="space-between" alignItems="center" flex={1}>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.qr_code_label', 'QRCode')}
</Text>
<ChevronDown size={16} color="#6b7280" />
</XStack>
</Accordion.Trigger>
<Accordion.Content paddingTop="$2">
<YStack space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}>
<XStack space="$2">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
X (%)
</Text>
<StepperInput
value={(qrOverride.x ?? qrSlot.x) * 100}
min={0}
max={100}
step={0.5}
onChange={(val) => onQrPercentChange('x')(val)}
/>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Y (%)
</Text>
<StepperInput
value={(qrOverride.y ?? qrSlot.y) * 100}
min={0}
max={100}
step={0.5}
onChange={(val) => onQrPercentChange('y')(val)}
/>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Größe (%)
</Text>
<StepperInput
value={(qrOverride.w ?? qrSlot.w) * 100}
min={10}
max={100}
step={0.5}
onChange={(val) => onQrPercentChange('w')(val)}
/>
</YStack>
</XStack>
{!qrUrl ? (
<Text fontSize="$xs" color="#b91c1c">
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
</Text>
) : null}
</YStack>
</Accordion.Content>
</Accordion.Item>
) : null}
</Accordion>
</YStack>
);
}