White-Label Phase 3: Branding-Verwaltung
- BrandingAdminController: GET/PUT /api/my-branding (Farben, Tagline), Logo-Upload/-Löschen, öffentliche Logo-Auslieferung /api/branding/logo/... - BrandingService liefert logoUrl aus S3-logoKey (Firma → Reseller → Default) - BrandingView (Reseller-Topnav + Firmen-Sidebar): Farbwähler, Slogan, Logo-Upload mit Live-Vorschau, Anzeige der eigenen Adresse Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
bcd8ba969a
commit
a233c34599
194
backend/src/Controller/BrandingAdminController.php
Normal file
194
backend/src/Controller/BrandingAdminController.php
Normal file
@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Company;
|
||||
use App\Entity\Reseller;
|
||||
use App\Security\TenantContext;
|
||||
use App\Service\BrandingService;
|
||||
use App\Service\TenantResolver;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* White-Label-Branding pflegen: Reseller-Admin brandet seinen Reseller,
|
||||
* Firmen-Admin seine Firma (Farben, Tagline, Logo). Plus öffentliche Logo-Auslieferung.
|
||||
*/
|
||||
final class BrandingAdminController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly TenantContext $tenant,
|
||||
private readonly TenantResolver $resolver,
|
||||
private readonly BrandingService $branding,
|
||||
#[Autowire(service: 'card_assets.storage')]
|
||||
private readonly FilesystemOperator $cardAssets,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/api/my-branding', name: 'my_branding_get', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_COMPANY_ADMIN')]
|
||||
public function get(): JsonResponse
|
||||
{
|
||||
return new JsonResponse($this->serialize());
|
||||
}
|
||||
|
||||
#[Route('/api/my-branding', name: 'my_branding_put', methods: ['PUT'])]
|
||||
#[IsGranted('ROLE_COMPANY_ADMIN')]
|
||||
public function put(Request $request): JsonResponse
|
||||
{
|
||||
[, $entity] = $this->target();
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$cfg = $entity->getBrandingConfig();
|
||||
|
||||
foreach (['primaryColor', 'primaryColorDark', 'primaryColorSoft'] as $key) {
|
||||
if (\array_key_exists($key, $data)) {
|
||||
$hex = $this->hex($data[$key] ?? null);
|
||||
if (null !== $hex) {
|
||||
$cfg[$key] = $hex;
|
||||
} else {
|
||||
unset($cfg[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (\array_key_exists('tagline', $data)) {
|
||||
$tag = trim((string) ($data['tagline'] ?? ''));
|
||||
if ('' !== $tag) {
|
||||
$cfg['tagline'] = mb_substr($tag, 0, 120);
|
||||
} else {
|
||||
unset($cfg['tagline']);
|
||||
}
|
||||
}
|
||||
|
||||
$entity->setBrandingConfig($cfg);
|
||||
$this->em->flush();
|
||||
|
||||
return new JsonResponse($this->serialize());
|
||||
}
|
||||
|
||||
#[Route('/api/my-branding/logo', name: 'my_branding_logo_post', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_COMPANY_ADMIN')]
|
||||
public function uploadLogo(Request $request): JsonResponse
|
||||
{
|
||||
[$scope, $entity] = $this->target();
|
||||
$file = $request->files->get('file');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new BadRequestHttpException('Keine Datei (Feld "file").');
|
||||
}
|
||||
$ext = strtolower((string) $file->getClientOriginalExtension());
|
||||
if (!\in_array($ext, ['png', 'jpg', 'jpeg', 'svg'], true)) {
|
||||
throw new BadRequestHttpException('Nur PNG, JPG oder SVG erlaubt.');
|
||||
}
|
||||
|
||||
$cfg = $entity->getBrandingConfig();
|
||||
$old = \is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null;
|
||||
$key = sprintf('branding/%s-%s/logo-%s.%s', $scope, $entity->getId()->toRfc4122(), bin2hex(random_bytes(4)), $ext);
|
||||
$this->cardAssets->write($key, (string) file_get_contents($file->getPathname()));
|
||||
if (null !== $old && $old !== $key && $this->cardAssets->fileExists($old)) {
|
||||
$this->cardAssets->delete($old);
|
||||
}
|
||||
$cfg['logoKey'] = $key;
|
||||
$entity->setBrandingConfig($cfg);
|
||||
$this->em->flush();
|
||||
|
||||
return new JsonResponse($this->serialize(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/my-branding/logo', name: 'my_branding_logo_delete', methods: ['DELETE'])]
|
||||
#[IsGranted('ROLE_COMPANY_ADMIN')]
|
||||
public function deleteLogo(): JsonResponse
|
||||
{
|
||||
[, $entity] = $this->target();
|
||||
$cfg = $entity->getBrandingConfig();
|
||||
$key = \is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null;
|
||||
if (null !== $key && $this->cardAssets->fileExists($key)) {
|
||||
$this->cardAssets->delete($key);
|
||||
}
|
||||
unset($cfg['logoKey']);
|
||||
$entity->setBrandingConfig($cfg);
|
||||
$this->em->flush();
|
||||
|
||||
return new JsonResponse($this->serialize());
|
||||
}
|
||||
|
||||
/** Öffentliche Logo-Auslieferung (vom Branding-Endpoint verlinkt). */
|
||||
#[Route('/api/branding/logo/{scope}/{id}.png', name: 'branding_logo', methods: ['GET'])]
|
||||
public function logo(string $scope, string $id): Response
|
||||
{
|
||||
$entity = $this->load($scope, $id);
|
||||
$key = \is_string($entity->getBrandingConfig()['logoKey'] ?? null) ? $entity->getBrandingConfig()['logoKey'] : null;
|
||||
if (null === $key || !$this->cardAssets->fileExists($key)) {
|
||||
throw new NotFoundHttpException('Kein Logo.');
|
||||
}
|
||||
$bytes = $this->cardAssets->read($key);
|
||||
$mime = str_ends_with($key, '.svg') ? 'image/svg+xml' : (str_ends_with($key, '.png') ? 'image/png' : 'image/jpeg');
|
||||
|
||||
return new Response($bytes, 200, ['Content-Type' => $mime, 'Cache-Control' => 'public, max-age=86400']);
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:Company|Reseller} */
|
||||
private function target(): array
|
||||
{
|
||||
$company = $this->tenant->getCompany();
|
||||
if ($company instanceof Company) {
|
||||
return [BrandingService::SCOPE_COMPANY, $company];
|
||||
}
|
||||
$reseller = $this->tenant->getReseller();
|
||||
if ($reseller instanceof Reseller) {
|
||||
return [BrandingService::SCOPE_RESELLER, $reseller];
|
||||
}
|
||||
throw new AccessDeniedHttpException('Kein Branding-Kontext.');
|
||||
}
|
||||
|
||||
private function load(string $scope, string $id): Company|Reseller
|
||||
{
|
||||
$uuid = Uuid::fromString($id);
|
||||
$entity = BrandingService::SCOPE_COMPANY === $scope
|
||||
? $this->em->getRepository(Company::class)->find($uuid)
|
||||
: $this->em->getRepository(Reseller::class)->find($uuid);
|
||||
if (!$entity instanceof Company && !$entity instanceof Reseller) {
|
||||
throw new NotFoundHttpException('Nicht gefunden.');
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function serialize(): array
|
||||
{
|
||||
[$scope, $entity] = $this->target();
|
||||
$cfg = $entity->getBrandingConfig();
|
||||
$tenant = $entity instanceof Company
|
||||
? new \App\Service\ResolvedTenant(\App\Service\ResolvedTenant::KIND_COMPANY, $entity->getReseller(), $entity)
|
||||
: new \App\Service\ResolvedTenant(\App\Service\ResolvedTenant::KIND_RESELLER, $entity);
|
||||
$host = $entity instanceof Company ? $this->resolver->companyHost($entity) : $this->resolver->resellerHost($entity);
|
||||
|
||||
return [
|
||||
'scope' => $scope,
|
||||
'name' => $entity->getName(),
|
||||
'address' => $host,
|
||||
'primaryColor' => $cfg['primaryColor'] ?? '#f58220',
|
||||
'primaryColorDark' => $cfg['primaryColorDark'] ?? '#d96500',
|
||||
'primaryColorSoft' => $cfg['primaryColorSoft'] ?? '#fff2e7',
|
||||
'tagline' => $cfg['tagline'] ?? '',
|
||||
'hasLogo' => '' !== (string) ($cfg['logoKey'] ?? ''),
|
||||
'logoUrl' => $this->branding->logoUrl($tenant),
|
||||
];
|
||||
}
|
||||
|
||||
private function hex(mixed $value): ?string
|
||||
{
|
||||
return (\is_string($value) && preg_match('/^#[0-9a-fA-F]{6}$/', $value)) ? strtolower($value) : null;
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,10 @@
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Company;
|
||||
use App\Entity\Reseller;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Baut aus einem aufgelösten Tenant das Branding (Name, Farben, Logo) für die SPA.
|
||||
* Fallback-Kette: Firma → Reseller → Plattform-Standard. So erbt eine Firma ohne
|
||||
@ -9,37 +13,35 @@ namespace App\Service;
|
||||
*/
|
||||
final class BrandingService
|
||||
{
|
||||
public const SCOPE_RESELLER = 'reseller';
|
||||
public const SCOPE_COMPANY = 'company';
|
||||
|
||||
/** Plattform-Standard (vcard4reseller-Orange). */
|
||||
private const DEFAULTS = [
|
||||
private const COLOR_DEFAULTS = [
|
||||
'primaryColor' => '#f58220',
|
||||
'primaryColorDark' => '#d96500',
|
||||
'primaryColorSoft' => '#fff2e7',
|
||||
'logoUrl' => null,
|
||||
'tagline' => null,
|
||||
];
|
||||
|
||||
public function __construct(private readonly UrlGeneratorInterface $urls)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{level:string,name:string,reseller:?string,customDomain:bool,branding:array<string,mixed>}
|
||||
*/
|
||||
public function forTenant(ResolvedTenant $tenant): array
|
||||
{
|
||||
// Branding-Ebenen von allgemein nach speziell zusammenführen
|
||||
$configs = [];
|
||||
if (null !== $tenant->reseller) {
|
||||
$configs[] = $tenant->reseller->getBrandingConfig();
|
||||
}
|
||||
if (null !== $tenant->company) {
|
||||
$configs[] = $tenant->company->getBrandingConfig();
|
||||
}
|
||||
|
||||
$branding = self::DEFAULTS;
|
||||
foreach ($configs as $config) {
|
||||
foreach (self::DEFAULTS as $key => $_) {
|
||||
$branding = self::COLOR_DEFAULTS;
|
||||
foreach ($this->configChain($tenant) as $config) {
|
||||
foreach (self::COLOR_DEFAULTS as $key => $_) {
|
||||
if (isset($config[$key]) && '' !== $config[$key]) {
|
||||
$branding[$key] = $config[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
$branding['logoUrl'] = $this->logoUrl($tenant);
|
||||
|
||||
return [
|
||||
'level' => $tenant->kind,
|
||||
@ -50,12 +52,45 @@ final class BrandingService
|
||||
];
|
||||
}
|
||||
|
||||
/** Öffentliche Logo-URL eines Tenants (am spezifischsten zuerst), oder null. */
|
||||
public function logoUrl(ResolvedTenant $tenant): ?string
|
||||
{
|
||||
if (null !== $tenant->company && '' !== (string) ($tenant->company->getBrandingConfig()['logoKey'] ?? '')) {
|
||||
return $this->logoRoute(self::SCOPE_COMPANY, (string) $tenant->company->getId(), $tenant->company->getBrandingConfig()['logoKey']);
|
||||
}
|
||||
if (null !== $tenant->reseller && '' !== (string) ($tenant->reseller->getBrandingConfig()['logoKey'] ?? '')) {
|
||||
return $this->logoRoute(self::SCOPE_RESELLER, (string) $tenant->reseller->getId(), $tenant->reseller->getBrandingConfig()['logoKey']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return array<int, array<string,mixed>> */
|
||||
private function configChain(ResolvedTenant $tenant): array
|
||||
{
|
||||
$chain = [];
|
||||
if (null !== $tenant->reseller) {
|
||||
$chain[] = $tenant->reseller->getBrandingConfig();
|
||||
}
|
||||
if (null !== $tenant->company) {
|
||||
$chain[] = $tenant->company->getBrandingConfig();
|
||||
}
|
||||
|
||||
return $chain;
|
||||
}
|
||||
|
||||
private function logoRoute(string $scope, string $id, mixed $logoKey): string
|
||||
{
|
||||
return $this->urls->generate('branding_logo', ['scope' => $scope, 'id' => $id])
|
||||
.'?v='.substr(sha1((string) $logoKey), 0, 8);
|
||||
}
|
||||
|
||||
private function name(ResolvedTenant $tenant): string
|
||||
{
|
||||
if ($tenant->isCompany() && null !== $tenant->company) {
|
||||
if ($tenant->isCompany() && $tenant->company instanceof Company) {
|
||||
return $tenant->company->getName();
|
||||
}
|
||||
if ($tenant->isReseller() && null !== $tenant->reseller) {
|
||||
if ($tenant->isReseller() && $tenant->reseller instanceof Reseller) {
|
||||
return $tenant->reseller->getName();
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ const topNav = computed<NavItem[]>(() => {
|
||||
{ label: 'Einloggen als', to: '/app/companies', icon: 'M3 7h18v13H3zM8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', show: auth.isResellerAdmin },
|
||||
{ label: 'Produkte', to: '/app/products', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12', show: auth.isResellerAdmin },
|
||||
{ label: 'Bestellungen', to: '/app/orders', icon: 'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4zM3 6h18M16 10a4 4 0 0 1-8 0', show: auth.isResellerAdmin },
|
||||
{ label: 'Branding', to: '/app/branding', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: auth.isResellerAdmin },
|
||||
{ label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: above },
|
||||
].filter((i) => i.show)
|
||||
})
|
||||
@ -35,6 +36,7 @@ const leftNav = computed<NavItem[]>(() => {
|
||||
{ label: 'Standorte', to: '/app/locations', icon: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', show: inCompany },
|
||||
{ label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: inCompany },
|
||||
{ label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: inCompany },
|
||||
{ label: 'Branding', to: '/app/branding', icon: 'M2 12a10 10 0 1 0 20 0 10 10 0 0 0-20 0zM12 2a15 15 0 0 1 0 20M2 12h20', show: inCompany },
|
||||
{ label: 'Wallet', to: '/app/wallet', icon: 'M3 7h18v12H3zM3 10h18M16 14h2', show: inCompany },
|
||||
{ label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: inCompany },
|
||||
].filter((i) => i.show)
|
||||
|
||||
@ -25,6 +25,7 @@ const router = createRouter({
|
||||
{ path: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') },
|
||||
{ path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') },
|
||||
{ path: 'design', name: 'design', component: () => import('@/views/DesignView.vue') },
|
||||
{ path: 'branding', name: 'branding', component: () => import('@/views/BrandingView.vue') },
|
||||
{ path: 'wallet', name: 'wallet', component: () => import('@/views/WalletDesignView.vue') },
|
||||
{ path: 'settings', name: 'settings', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Einstellungen' } },
|
||||
],
|
||||
|
||||
184
frontend/src/views/BrandingView.vue
Normal file
184
frontend/src/views/BrandingView.vue
Normal file
@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import client from '@/api/client'
|
||||
import { useBrandingStore } from '@/stores/branding'
|
||||
|
||||
interface BrandingDto {
|
||||
scope: string
|
||||
name: string
|
||||
address: string
|
||||
primaryColor: string
|
||||
primaryColorDark: string
|
||||
primaryColorSoft: string
|
||||
tagline: string
|
||||
hasLogo: boolean
|
||||
logoUrl: string | null
|
||||
}
|
||||
|
||||
const brandingStore = useBrandingStore()
|
||||
const data = ref<BrandingDto | null>(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
const error = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = (await client.get<BrandingDto>('/my-branding')).data
|
||||
} catch {
|
||||
error.value = 'Branding konnte nicht geladen werden.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!data.value) return
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
error.value = ''
|
||||
try {
|
||||
const { data: res } = await client.put<BrandingDto>('/my-branding', {
|
||||
primaryColor: data.value.primaryColor,
|
||||
primaryColorDark: data.value.primaryColorDark,
|
||||
primaryColorSoft: data.value.primaryColorSoft,
|
||||
tagline: data.value.tagline,
|
||||
})
|
||||
data.value = res
|
||||
saved.value = true
|
||||
await brandingStore.load()
|
||||
} catch {
|
||||
error.value = 'Speichern fehlgeschlagen.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogoChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
error.value = ''
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
try {
|
||||
data.value = (await client.post<BrandingDto>('/my-branding/logo', form)).data
|
||||
await brandingStore.load()
|
||||
} catch {
|
||||
error.value = 'Logo-Upload fehlgeschlagen (PNG, JPG oder SVG).'
|
||||
} finally {
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLogo() {
|
||||
try {
|
||||
data.value = (await client.delete<BrandingDto>('/my-branding/logo')).data
|
||||
await brandingStore.load()
|
||||
} catch {
|
||||
error.value = 'Logo konnte nicht entfernt werden.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Branding</h1>
|
||||
<p class="muted">So erscheint Ihr Auftritt für Kunden auf Ihrer Adresse.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" :disabled="saving || loading" @click="save">
|
||||
{{ saving ? 'Speichern…' : 'Speichern' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="muted">Lädt…</p>
|
||||
|
||||
<div v-else-if="data" class="grid">
|
||||
<div class="card">
|
||||
<h3>Logo</h3>
|
||||
<div class="logo-row">
|
||||
<div class="logo-preview">
|
||||
<img v-if="data.logoUrl" :src="data.logoUrl" alt="Logo" />
|
||||
<span v-else class="muted sm">Kein Logo</span>
|
||||
</div>
|
||||
<div class="logo-actions">
|
||||
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/svg+xml" hidden @change="onLogoChange" />
|
||||
<button class="btn btn-soft btn-sm" @click="fileInput?.click()">{{ data.hasLogo ? 'Logo ersetzen' : 'Logo hochladen' }}</button>
|
||||
<button v-if="data.hasLogo" class="btn btn-ghost btn-sm" @click="removeLogo">Entfernen</button>
|
||||
<p class="muted sm">PNG, JPG oder SVG. Wird in Login & Kopfzeile angezeigt.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:1.6rem">Farben</h3>
|
||||
<div class="colors">
|
||||
<label class="color">
|
||||
<span>Primärfarbe</span>
|
||||
<span class="swatch"><input type="color" v-model="data.primaryColor" /><code>{{ data.primaryColor }}</code></span>
|
||||
</label>
|
||||
<label class="color">
|
||||
<span>Primär dunkel (Hover)</span>
|
||||
<span class="swatch"><input type="color" v-model="data.primaryColorDark" /><code>{{ data.primaryColorDark }}</code></span>
|
||||
</label>
|
||||
<label class="color">
|
||||
<span>Primär hell (Flächen)</span>
|
||||
<span class="swatch"><input type="color" v-model="data.primaryColorSoft" /><code>{{ data.primaryColorSoft }}</code></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:1.6rem">Slogan</h3>
|
||||
<input class="input" v-model="data.tagline" maxlength="120" placeholder="z. B. Ihre Druckerei vor Ort" />
|
||||
|
||||
<p v-if="error" class="error" style="margin-top:1rem">{{ error }}</p>
|
||||
<p v-if="saved" class="ok" style="margin-top:1rem">Gespeichert.</p>
|
||||
</div>
|
||||
|
||||
<div class="card preview" :style="{ '--p': data.primaryColor, '--pd': data.primaryColorDark, '--ps': data.primaryColorSoft }">
|
||||
<div class="muted sm" style="margin-bottom:.6rem">Vorschau</div>
|
||||
<div class="prev-bar">
|
||||
<img v-if="data.logoUrl" :src="data.logoUrl" alt="Logo" class="prev-logo" />
|
||||
<strong v-else>{{ data.name }}</strong>
|
||||
</div>
|
||||
<p class="prev-tag">{{ data.tagline || '—' }}</p>
|
||||
<button class="prev-btn">Primär-Button</button>
|
||||
<span class="prev-chip">Soft-Fläche</span>
|
||||
<p class="muted sm" style="margin-top:1.2rem">Adresse: <code>{{ data.address }}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1.4rem; }
|
||||
.page-head .muted { margin: .2rem 0 0; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 320px; gap: 1.2rem; align-items: start; }
|
||||
.card h3 { font-size: .95rem; margin-bottom: .7rem; }
|
||||
.sm { font-size: .8rem; }
|
||||
.error { color: var(--danger); font-size: .88rem; }
|
||||
.ok { color: var(--success); font-size: .88rem; }
|
||||
|
||||
.logo-row { display: flex; gap: 1.2rem; align-items: center; }
|
||||
.logo-preview { width: 96px; height: 96px; border: 1px dashed var(--line); border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; background: #fafafa; flex-shrink: 0; }
|
||||
.logo-preview img { max-width: 84px; max-height: 84px; }
|
||||
.logo-actions { display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; }
|
||||
.logo-actions .muted { margin: .2rem 0 0; }
|
||||
|
||||
.colors { display: flex; flex-direction: column; gap: .7rem; }
|
||||
.color { display: flex; align-items: center; justify-content: space-between; font-size: .9rem; font-weight: 600; }
|
||||
.swatch { display: inline-flex; align-items: center; gap: .6rem; }
|
||||
.swatch input[type=color] { width: 38px; height: 30px; border: 1px solid var(--line); border-radius: 8px; background: none; cursor: pointer; padding: 0; }
|
||||
.swatch code { color: var(--muted); font-size: .82rem; }
|
||||
|
||||
.preview { position: sticky; top: 80px; }
|
||||
.prev-bar { display: flex; align-items: center; min-height: 34px; }
|
||||
.prev-logo { max-height: 30px; max-width: 160px; }
|
||||
.prev-tag { color: var(--muted); font-size: .85rem; margin: .3rem 0 1rem; }
|
||||
.prev-btn { background: var(--p); color: #fff; border: none; border-radius: var(--radius-sm); padding: .55rem 1rem; font-weight: 700; font-family: var(--font); cursor: default; }
|
||||
.prev-btn:hover { background: var(--pd); }
|
||||
.prev-chip { display: inline-block; margin-top: .7rem; background: var(--ps); color: var(--pd); padding: .35rem .8rem; border-radius: 999px; font-size: .82rem; font-weight: 700; }
|
||||
@media (max-width: 820px) { .grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user