glanzlicht / MeinWeimar_APP

Official MeinWeimar App

Verified secure showcase
204 Files 2 MB Public size 14 Redacted Dart · PHP · XML · GITIGNORE · YAML · PLIST

Project overview

MeinWeimar_APP is a verified code showcase with 204 visible project entries across 2 top-level modules. The primary detected language is Dart; protected files remain visible in the structure while their contents stay private.

README

MeinWeimar — Flutter App & WordPress Plugin

> Working in Progress · Private portfolio repository · Active development

MeinWeimar is a location-based local platform for residents, students and visitors. This repository intentionally contains only two product parts:

  1. the Flutter consumer app;
  2. the meinweimar-core WordPress plugin that provides the editorial/admin
  3. layer and the REST contract consumed by the app.

The complete public website, WordPress core, databases, private infrastructure and unrelated platform modules are not part of this repository.

MeinWeimar consumer app preview

Why this project is interesting

MeinWeimar combines a mobile/web Flutter client with WordPress used as a structured content and administration backend. The goal is a maintainable product boundary: content, branding, navigation, categories, media and configuration are managed in WordPress and delivered through a versioned REST contract instead of being hardcoded in the Flutter UI.

The project demonstrates work across:

  • Flutter/Dart application architecture for iOS, Android and Web;
  • WordPress plugin architecture with custom post types, taxonomies, admin
  • settings and REST endpoints;

  • localized German/English UI and backend-controlled labels;
  • discovery/list/detail flows for places, services, events, classifieds, jobs
  • and real estate;

  • map, GPS, geocoding, marker and navigation contracts;
  • caching, ETag/config-version handling and offline-safe fallbacks;
  • role-aware account, claim, favorites, moderation and messaging boundaries;
  • automated Flutter tests, PHP/REST contract checks and release builds.

Repository structure

flutter-app/       Flutter consumer application
wordpress-plugin/  meinweimar-core WordPress plugin
screenshots/       Curated portfolio preview only

Current status

This is an active Working in Progress project. The core architecture and many end-to-end flows are implemented and continuously tested. Native device polish, production content, final legal/privacy review and further product modules are still being developed. The repository does not claim to be a finished production release.

Local development

Flutter app

cd flutter-app
flutter pub get
flutter analyze
flutter test
flutter run --dart-define=MW_API_BASE_URL=https://example.test/wp-json/meinweimar/v1

The API base URL and other environment-specific values are supplied at build or run time. No credentials are stored in the repository.

WordPress plugin

Copy wordpress-plugin into a WordPress installation's wp-content/plugins/meinweimar-core directory and activate it in the admin area. The plugin owns the MeinWeimar content types, editorial settings, permissions, REST responses, media/configuration contracts and frontend integration points.

Privacy and repository boundary

This repository is private and is prepared for controlled technical review. Secrets, production database exports, user data, WordPress uploads, private keys and infrastructure configuration must remain outside version control. The public-facing portfolio page uses a separate, redacted presentation layer and does not expose the private repository directly.

For technical reviewers

The most relevant entry points are:

  • flutter-app/lib/core — shared configuration, networking, localization,
  • theme and location contracts;

  • flutter-app/lib/features — consumer-facing product surfaces;
  • flutter-app/lib/repositories — API boundary and data access;
  • wordpress-plugin/includes — plugin services, REST, admin and contracts;
  • wordpress-plugin/assets — admin/frontend integration assets.

MeinWeimar is deliberately presented as an evolving product rather than a finished demo: the repository shows the architectural decisions, the working implementation and the remaining engineering work transparently.

wordpress-plugin/includes/class-settings.phpPHP
<?php
declare(strict_types=1);
namespace MeinWeimar;
final class Settings
{
public const OPTION = 'meinweimar_core_settings';
private const HERO_COPY_DEFAULTS_OPTION = 'meinweimar_core_hero_copy_defaults_version';
private const HERO_COPY_DEFAULTS_VERSION = '1';
private const HERO_COPY_KEYS = [
'hero_eyebrow',
'hero_title',
'hero_body',
'hero_primary_label',
'hero_primary_url',
'hero_secondary_label',
'hero_secondary_url',
];
public static function hooks(): void
{
add_action('admin_menu', [self::class, 'admin_menu']);
add_action('admin_init', [self::class, 'maybe_seed_hero_copy_defaults'], 1);
add_action('admin_init', [self::class, 'register']);
add_action('admin_enqueue_scripts', [self::class, 'enqueue_admin_assets']);
}
public static function defaults(): array
{
return [
'brand_logo_id' => 0,
'brand_logo_dark_id' => 0,
'navigation_center_logo_id' => 0,
'brand_hero_image_id' => 0,
'brand_wordmark' => 'MeinWeimar',
'primary_color' => '#ff1e6e',
'secondary_color' => '#20242e',
'accent_color' => '#ff1e6e',
'ink_color' => '#20242e',
'surface_color' => '#ffffff',
'muted_color' => '#64748b',
'brand_surface_color' => '#ffe5ee',
'success_color' => '#159570',
'warning_color' => '#b7791f',
'hero_eyebrow' => [],
'hero_title' => [
'de_DE' => 'Entdecke Weimar',
'en_US' => 'Discover Weimar',
],
'hero_body' => [
'de_DE' => 'Finde Orte, Events und Empfehlungen rund um Weimar.',
'en_US' => 'Find places, events and recommendations around Weimar.',
],
'hero_primary_label' => [
'de_DE' => 'Jetzt entdecken',
'en_US' => 'Start exploring',
],
'hero_primary_url' => [
'de_DE' => '#discover',
'en_US' => '#discover',
],
'hero_secondary_label' => [],
'hero_secondary_url' => [],
'hero_logo_id' => 0,
'hero_icon_id' => 0,
'hero_campaigns' => [],
// One shared surface-hero contract for every public module. The
// app and Website consume the same localized copy/media/colors;
// no module may invent its own presentation source.
'surface_heroes' => self::surface_hero_defaults(),
'home_sections' => self::home_section_defaults(),
'hero_height_mode' => 'content',
'hero_image_fit' => 'cover',
'hero_title_color' => '#ffffff',
'hero_body_color' => '#ffffff',
'hero_eyebrow_color' => '',
'hero_button_color' => '',
'hero_button_text_color' => '#ffffff',
'hero_secondary_button_color' => '',
'hero_secondary_button_text_color' => '',
'default_radius_km' => 10.0,
'default_map_zoom' => 13,
'geo_default_latitude' => 50.9795,
'geo_default_longitude' => 11.3235,
// A provider must be explicitly configured before either the
// Website or the app requests map tiles.
'geo_tiles_enabled' => false,
'geo_tile_url' => '',
'geo_tile_styles' => [
'road' => '',
'light' => '',
'dark' => '',
'satellite' => '',
],
'geo_routing_url' => '',
'geo_routing_profile' => 'driving',
// External navigation is intentionally keyless: Google Maps URLs
// hand off to the installed app and fall back to the browser.
'geo_navigation_provider' => 'google_maps',
'geo_external_navigation_url' => '',
// The Website JS API key is never included in the public REST
// config. It is localized only for the frontend map adapter and
// may alternatively come from wp-config.
'geo_google_maps_api_key' => '',
'geo_geocoding_enabled' => false,
'geo_geocoding_url' => '',
'geo_attribution' => '',
'geo_user_agent_contact' => '',
'geo_fallback_marker_id' => 0,
'geo_user_marker_id' => 0,
'geo_resource_markers' => [
'places' => 0,
'events' => 0,
'services' => 0,
'classifieds' => 0,
'jobs' => 0,
'real-estate' => 0,
],
'locales' => ['de_DE', 'en_US'],
'default_locale' => 'de_DE',
'fallback_locale' => 'de_DE',
'navigation_surfaces_configured' => false,
'navigation_bottom' => [],
'navigation_fullscreen' => [],
'navigation_icons' => [],
'resource_icons' => [],
'field_icons' => [],
'app_texts' => [],
'navigation' => [
['id' => 'start', 'label' => 'Start', 'label_key' => 'navStart', 'icon' => 'explore', 'route' => '/', 'enabled' => true, 'primary' => true],
['id' => 'map', 'label' => 'Karte', 'label_key' => 'navMap', 'icon' => 'map', 'route' => '/karte/', 'enabled' => true, 'primary' => true],
['id' => 'events', 'label' => 'Events', 'label_key' => 'navEvents', 'icon' => 'event', 'route' => '/events/', 'enabled' => true, 'primary' => true],
['id' => 'saved', 'label' => 'Gespeichert', 'label_key' => 'navSaved', 'icon' => 'bookmark', 'route' => '/gespeichert/', 'enabled' => true, 'primary' => true],
['id' => 'community', 'label' => 'Community', 'label_key' => 'navCommunity', 'icon' => 'people', 'route' => '/community/', 'enabled' => true, 'primary' => false],
['id' => 'services', 'label' => 'Services und Einrichtungen', 'label_key' => 'moduleServices', 'icon' => 'account_balance', 'route' => '/services/', 'enabled' => true, 'primary' => false],
['id' => 'classifieds', 'label' => 'Kleinanzeigen', 'label_key' => 'moduleClassifieds', 'icon' => 'sell', 'route' => '/kleinanzeigen/', 'enabled' => true, 'primary' => false],
['id' => 'jobs', 'label' => 'Jobs', 'label_key' => 'moduleJobs', 'icon' => 'work', 'route' => '/jobs/', 'enabled' => true, 'primary' => false],
['id' => 'real-estate', 'label' => 'Immobilien', 'label_key' => 'moduleRealEstate', 'icon' => 'home_work', 'route' => '/immobilien/', 'enabled' => true, 'primary' => false],
['id' => 'news', 'label' => 'News', 'label_key' => 'moduleNews', 'icon' => 'article', 'route' => '/news/', 'enabled' => true, 'primary' => false],
['id' => 'guides', 'label' => 'Ratgeber', 'label_key' => 'moduleGuides', 'icon' => 'menu_book', 'route' => '/guides/', 'enabled' => true, 'primary' => false],
],
];
}
public static function install_defaults(): void
{
if (false === get_option(self::OPTION, false)) {
add_option(self::OPTION, self::defaults(), '', false);
}
}
public static function get(): array
{
$settings = wp_parse_args((array) get_option(self::OPTION, []), self::defaults());
// The previous plugin default was the light-gray app canvas. Treat
// that untouched legacy default as the new white canvas, while still
// leaving every other explicitly chosen color under admin control.
if ('#f5f6f8' === strtolower((string) ($settings['surface_color'] ?? ''))) {
$settings['surface_color'] = '#ffffff';
}
return $settings;
}
/**
* Seed the initial Hero copy into the settings UI once on a controlled
* admin request. Public API requests never write configuration options.
*/
public static function maybe_seed_hero_copy_defaults(): void
{
if (self::HERO_COPY_DEFAULTS_VERSION === (string) get_option(self::HERO_COPY_DEFAULTS_OPTION, '')) {
return;
}
$stored = get_option(self::OPTION, null);
if (!is_array($stored)) {
return;
}
$defaults = self::defaults();
$changed = false;
foreach (self::HERO_COPY_KEYS as $key) {
if (!empty($stored[$key]) || empty($defaults[$key])) {
continue;
}
$stored[$key] = $defaults[$key];
$changed = true;
}
if ($changed) {
update_option(self::OPTION, $stored, false);
}
update_option(self::HERO_COPY_DEFAULTS_OPTION, self::HERO_COPY_DEFAULTS_VERSION, false);
}
public static function sanitize_locale($value): string
{
if (!is_scalar($value)) {
return '';
}
$candidate = str_replace('-', '_', preg_replace('/[^A-Za-z0-9_-]/', '', (string) $value));
return self::resolve_locale($candidate, self::normalize_locales(self::get()['locales'] ?? []), '');
}
public static function google_maps_frontend_config(): array
{
$settings = self::get();
$api_key = trim((string) ($settings['geo_google_maps_api_key'] ?? ''));
if ('' === $api_key && defined('MEINWEIMAR_GOOGLE_MAPS_API_KEY')) {
$api_key = trim((string) constant('MEINWEIMAR_GOOGLE_MAPS_API_KEY'));
}
return [
'enabled' => '' !== $api_key,
'api_key' => $api_key,
];
}
public static function public_config(): array
{
$settings = self::get();
$hero_copy = self::hero_copy($settings);
$logo_light = self::logo_config(absint($settings['brand_logo_id']));
$logo_dark = self::logo_config(absint($settings['brand_logo_dark_id'] ?? 0));
$navigation_center_logo = self::logo_config(absint($settings['navigation_center_logo_id'] ?? 0));
$hero_image = self::logo_config(absint($settings['brand_hero_image_id'] ?? 0));
$hero_logo = self::logo_config(absint($settings['hero_logo_id'] ?? 0));
$hero_icon = self::logo_config(absint($settings['hero_icon_id'] ?? 0));
$hero = [
'eyebrow' => $hero_copy['hero_eyebrow'],
'title' => $hero_copy['hero_title'],
'body' => $hero_copy['hero_body'],
'primary_label' => $hero_copy['hero_primary_label'],
'primary_url' => $hero_copy['hero_primary_url'],
'secondary_label' => $hero_copy['hero_secondary_label'],
'secondary_url' => $hero_copy['hero_secondary_url'],
'image' => $hero_image,
'image_url' => $hero_image['url'],
'image_app_url' => $hero_image['app_url'],
'logo' => $hero_logo,
'icon' => $hero_icon,
'height_mode' => self::hero_height_mode($settings['hero_height_mode'] ?? 'content'),
'image_fit' => self::hero_image_fit($settings['hero_image_fit'] ?? 'cover'),
'colors' => self::hero_colors($settings),
'campaign' => null,
];
$active_campaign = self::active_hero_campaign($settings);
if (null !== $active_campaign) {
$hero['eyebrow'] = self::merge_localized_values($hero['eyebrow'], $active_campaign['eyebrow']);
$hero['title'] = self::merge_localized_values($hero['title'], $active_campaign['title']);
$hero['body'] = self::merge_localized_values($hero['body'], $active_campaign['body']);
$hero['primary_label'] = self::merge_localized_values($hero['primary_label'], $active_campaign['primary_label']);
$hero['primary_url'] = self::merge_localized_values($hero['primary_url'], $active_campaign['primary_url']);
$hero['secondary_label'] = self::merge_localized_values($hero['secondary_label'], $active_campaign['secondary_label']);
$hero['secondary_url'] = self::merge_localized_values($hero['secondary_url'], $active_campaign['secondary_url']);
$campaign_image = $active_campaign['image_id']
? self::logo_config($active_campaign['image_id'])
: $hero_image;
$campaign_logo = $active_campaign['logo_id']
? self::logo_config($active_campaign['logo_id'])
: $hero_logo;
$campaign_icon = $active_campaign['icon_id']
? self::logo_config($active_campaign['icon_id'])
: $hero_icon;
$hero['image'] = $campaign_image;
$hero['image_url'] = $campaign_image['url'];
$hero['image_app_url'] = $campaign_image['app_url'];
$hero['logo'] = $campaign_logo;
$hero['icon'] = $campaign_icon;
$hero['height_mode'] = 'inherit' === $active_campaign['height_mode']
? $hero['height_mode']
: $active_campaign['height_mode'];
$hero['image_fit'] = 'inherit' === $active_campaign['image_fit']
? $hero['image_fit']
: $active_campaign['image_fit'];
$hero['colors'] = self::merge_hero_colors($hero['colors'], $active_campaign['colors']);
$hero['campaign'] = [
'id' => $active_campaign['id'],
'label' => $active_campaign['label'],
'start_at' => $active_campaign['start_at'],
'end_at' => $active_campaign['end_at'],
'priority' => $active_campaign['priority'],
];
}
$locales = self::normalize_locales($settings['locales']);
$default_locale = sanitize_text_field((string) ($settings['default_locale'] ?? 'de_DE'));
$fallback_locale = sanitize_text_field((string) ($settings['fallback_locale'] ?? 'de_DE'));
$default_locale = in_array($default_locale, $locales, true) ? $default_locale : $locales[0];
$fallback_locale = in_array($fallback_locale, $locales, true) ? $fallback_locale : $locales[0];
// Legacy provider-neutral values remain stored for compatibility but
// are no longer projected to clients. Only the future, explicitly
// approved Google Maps Tiles/Session adapter may populate this part
// of the public contract.
$tiles_enabled = false;
$tile_url = '';
$routing_url = '';
// Google Maps is the product decision for external navigation. The
// old selectable provider value is intentionally ignored in the
// public contract; local maps are only the platform fallback.
$navigation_provider = 'google_maps';
$external_navigation_url = '';
$geocoding_enabled = false;
$geocoding_url = '';
$tile_styles = [];
$attribution = '';
$user_agent_contact = '';
$map_configured = false;
$legacy_navigation = self::normalize_navigation($settings['navigation']);
$bottom_navigation = self::normalize_navigation(
!empty($settings['navigation_bottom']) ? $settings['navigation_bottom'] : $legacy_navigation
);
$fullscreen_navigation = self::normalize_navigation(
!empty($settings['navigation_fullscreen']) ? $settings['navigation_fullscreen'] : $legacy_navigation
);
$navigation_icons = self::sanitize_image_map($settings['navigation_icons'] ?? []);
$resource_icons = self::sanitize_image_map($settings['resource_icons'] ?? []);
$field_icons = self::sanitize_image_map($settings['field_icons'] ?? []);
$bottom_navigation = self::public_navigation($bottom_navigation, $navigation_icons);
$fullscreen_navigation = self::public_navigation($fullscreen_navigation, $navigation_icons);
return [
'brand' => [
'wordmark' => sanitize_text_field((string) $settings['brand_wordmark']),
// Keep the original keys as the light-surface default for existing clients.
'logo_url' => $logo_light['url'],
'logo_srcset' => $logo_light['srcset'],
'logo_alt' => $logo_light['alt'],
'logo_light' => $logo_light,
'logo_dark' => $logo_dark,
'navigation_logo' => $navigation_center_logo,
'hero_image' => $hero_image,
'hero_image_url' => $hero_image['url'],
'hero_image_app_url' => $hero_image['app_url'],
'primary_color' => self::color_value('primary_color', $settings['primary_color'] ?? '', '#ff1e6e'),
'secondary_color' => self::color_value('secondary_color', $settings['secondary_color'] ?? '', '#20242e'),
],
'colors' => self::public_colors($settings),
'hero' => $hero,
'home_sections' => self::public_home_sections($settings['home_sections'] ?? []),
'surface_heroes' => self::public_surface_heroes($settings['surface_heroes'] ?? []),
'geo' => [
'default_radius_km' => self::decimal($settings['default_radius_km'] ?? 10, 0.01, 50, 2),
'default_map_zoom' => max(1, min(20, (int) $settings['default_map_zoom'])),
'default_latitude' => self::decimal($settings['geo_default_latitude'] ?? 50.9795, -90, 90, 6),
'default_longitude' => self::decimal($settings['geo_default_longitude'] ?? 11.3235, -180, 180, 6),
'data_basis' => 'wordpress-geo-config',
// Expose the actual provider state. Do not report a provider
// as configured while the operator has not supplied a usable
// tile source; no fallback provider is invented here.
'provider' => $map_configured ? 'configured' : 'unconfigured',
'map_configured' => $map_configured,
'tile_url_template' => $tile_url,
'tile_styles' => $tile_styles,
'routing_url' => $routing_url,
'routing_profile' => sanitize_key((string) ($settings['geo_routing_profile'] ?? 'driving')) ?: 'driving',
'navigation_provider' => $navigation_provider,
'external_navigation_url_template' => $external_navigation_url,
'geocoding_url' => $geocoding_url,
'attribution' => $attribution,
'user_agent_contact' => $user_agent_contact,
'capabilities' => [
'tiles' => $map_configured,
'routing' => '' !== $routing_url,
'geocoding' => $geocoding_enabled && '' !== $geocoding_url,
],
'markers' => MapMarkers::public_config(),
],
'locales' => $locales,
'localization' => [
'available_locales' => $locales,
'default_locale' => $default_locale,
'fallback_locale' => $fallback_locale,
'ui_source' => 'wordpress-api-with-client-fallback',
'content_source' => 'wordpress-api',
],
'app_texts' => self::normalize_app_texts($settings['app_texts'] ?? []),
// Clients use this to select the correct field groups and render
// only fields supported by the current content type. It contains
// no post-meta names or legacy-only implementation details.
'content_schemas' => FieldSchemas::public_contract(),
// Category profiles are emitted from the same FieldSchemas
// registry. Clients must not recreate category/field rules.
'content_categories' => FieldSchemas::category_contract(),
'navigation' => array_values(array_filter($legacy_navigation, static fn (array $item): bool => $item['enabled'])),
'app_navigation_configured' => !empty($settings['navigation_surfaces_configured']),
'app_navigation' => [
'bottom' => array_values(array_filter($bottom_navigation, static fn (array $item): bool => $item['enabled'])),
'fullscreen' => array_values(array_filter($fullscreen_navigation, static fn (array $item): bool => $item['enabled'])),
],
'resource_icons' => self::public_image_map($resource_icons),
// Visual field icons are deliberately separate from FieldSchemas:
// FieldSchemas owns data/validation, while this map only controls
// the optional media used when a fixed field is rendered.
'field_icons' => self::public_image_map($field_icons),
];
}
public static function admin_menu(): void
{
add_options_page(
__('MeinWeimar', 'meinweimar-core'),
__('MeinWeimar', 'meinweimar-core'),
'manage_options',
'meinweimar-core',
[self::class, 'render_page']
);
}
public static function register(): void
{
register_setting('meinweimar_core', self::OPTION, [
'type' => 'array',
'sanitize_callback' => [self::class, 'sanitize'],
'default' => self::defaults(),
]);
add_settings_section(
'meinweimar_core_brand',
__('Globale Marke und Plattformwerte', 'meinweimar-core'),
static function (): void {
echo '<p>' . esc_html__('Diese Werte werden später von Website, Elementor und API-Clients gemeinsam verwendet.', 'meinweimar-core') . '</p>';
},
'meinweimar-core'
);
add_settings_field('brand_logo_id', __('Logo für helle Hintergründe', 'meinweimar-core'), [self::class, 'logo_field'], 'meinweimar-core', 'meinweimar_core_brand', ['variant' => 'light']);
add_settings_field('brand_logo_dark_id', __('Logo für dunkle Hintergründe', 'meinweimar-core'), [self::class, 'logo_field'], 'meinweimar-core', 'meinweimar_core_brand', ['variant' => 'dark']);
add_settings_field('brand_hero_image_id', __('Headerbild für die Home', 'meinweimar-core'), [self::class, 'image_field'], 'meinweimar-core', 'meinweimar_core_brand');
add_settings_field('brand_wordmark', __('Wordmark', 'meinweimar-core'), [self::class, 'text_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'brand_wordmark']);
add_settings_field('primary_color', __('Primärfarbe', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'primary_color']);
add_settings_field('secondary_color', __('Sekundärfarbe', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'secondary_color']);
add_settings_field('accent_color', __('Alternative Akzentfarbe', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'accent_color']);
add_settings_field('ink_color', __('Anthrazit / dunkle Flächen', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'ink_color']);
add_settings_field('surface_color', __('App-Hintergrund', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'surface_color']);
add_settings_field('muted_color', __('Sekundärtext', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'muted_color']);
add_settings_field('brand_surface_color', __('Helle Akzentfläche', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'brand_surface_color']);
add_settings_field('success_color', __('Erfolgsfarbe', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'success_color']);
add_settings_field('warning_color', __('Warnfarbe', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'warning_color']);
add_settings_field('default_radius_km', __('Standardradius (km)', 'meinweimar-core'), [self::class, 'number_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'default_radius_km', 'min' => 0.01, 'max' => 50, 'step' => '0.01']);
add_settings_field('default_map_zoom', __('Standard-Kartenzoom', 'meinweimar-core'), [self::class, 'number_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'default_map_zoom', 'min' => 1, 'max' => 20]);
add_settings_field('default_locale', __('Standard-Locale', 'meinweimar-core'), [self::class, 'locale_select_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'default_locale']);
add_settings_field('fallback_locale', __('Fallback-Locale', 'meinweimar-core'), [self::class, 'locale_select_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'fallback_locale']);
add_settings_field('locales', __('Verfügbare Locales', 'meinweimar-core'), [self::class, 'locales_field'], 'meinweimar-core', 'meinweimar_core_brand');
add_settings_section(
'meinweimar_core_hero',
__('Home-Hero und Einstiege', 'meinweimar-core'),
static function (): void {
echo '<p>' . esc_html__('Texte, Medien und Links werden als lokalisierte Konfiguration an Website und App ausgegeben. Leere Locale-Werte fallen innerhalb der konfigurierten Hero-Texte zurück; nicht konfigurierte Hero-Texte werden nicht aus Flutter ergänzt.', 'meinweimar-core') . '</p>';
},
'meinweimar-core'
);
foreach (['hero_eyebrow' => 'Hero-Eyebrow', 'hero_title' => 'Hero-Titel', 'hero_body' => 'Hero-Text', 'hero_primary_label' => 'Primärbutton-Text', 'hero_primary_url' => 'Primärbutton-Link', 'hero_secondary_label' => 'Sekundärbutton-Text', 'hero_secondary_url' => 'Sekundärbutton-Link'] as $key => $label) {
add_settings_field($key, __($label, 'meinweimar-core'), [self::class, 'localized_text_field'], 'meinweimar-core', 'meinweimar_core_hero', ['key' => $key, 'link' => str_ends_with($key, '_url')]);
}
add_settings_field('hero_height_mode', __('Hero-Höhenmodus', 'meinweimar-core'), [self::class, 'select_field'], 'meinweimar-core', 'meinweimar_core_hero', ['key' => 'hero_height_mode', 'options' => ['content' => __('Inhaltsabhängig – Bild darf beschnitten werden', 'meinweimar-core'), 'image' => __('Bildproportion – gesamte Bildfläche verwenden', 'meinweimar-core')]]);
add_settings_field('hero_image_fit', __('Hero-Bilddarstellung', 'meinweimar-core'), [self::class, 'select_field'], 'meinweimar-core', 'meinweimar_core_hero', ['key' => 'hero_image_fit', 'options' => ['cover' => __('Ausfüllen / Zuschneiden', 'meinweimar-core'), 'contain' => __('Vollständig anzeigen', 'meinweimar-core')]]);
foreach ([
'hero_title_color' => 'Hero-Titelfarbe',
'hero_body_color' => 'Hero-Textfarbe',
'hero_eyebrow_color' => 'Hero-Eyebrow-Farbe',
'hero_button_color' => 'Primärbutton-Farbe',
'hero_button_text_color' => 'Primärbutton-Textfarbe',
'hero_secondary_button_color' => 'Sekundärbutton-Farbe',
'hero_secondary_button_text_color' => 'Sekundärbutton-Textfarbe',
] as $key => $label) {
add_settings_field($key, __($label, 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_hero', ['key' => $key]);
}
add_settings_field('hero_logo_id', __('Hero-Logo', 'meinweimar-core'), [self::class, 'media_field'], 'meinweimar-core', 'meinweimar_core_hero', ['key' => 'hero_logo_id', 'slug' => 'hero-logo', 'label' => __('Hero-Logo aus Mediathek wählen', 'meinweimar-core')]);
add_settings_field('hero_icon_id', __('Hero-Icon', 'meinweimar-core'), [self::class, 'media_field'], 'meinweimar-core', 'meinweimar_core_hero', ['key' => 'hero_icon_id', 'slug' => 'hero-icon', 'label' => __('Hero-Icon aus Mediathek wählen', 'meinweimar-core')]);
add_settings_field('hero_campaigns', __('Weitere Hero-Kampagnen', 'meinweimar-core'), [self::class, 'hero_campaigns_field'], 'meinweimar-core', 'meinweimar_core_hero');
add_settings_section(
'meinweimar_core_home',
__('Home-Startseite und Sektionen', 'meinweimar-core'),
static function (): void {
echo '<p>' . esc_html__('Ordne die sichtbaren Home-Sektionen per Drag-and-drop oder mit den Auf-/Ab-Schaltflächen. Überschriften und Icons werden zentral an Website und App ausgegeben.', 'meinweimar-core') . '</p>';
},
'meinweimar-core'
);
add_settings_field('home_sections', __('Home-Sektionen', 'meinweimar-core'), [self::class, 'home_sections_field'], 'meinweimar-core', 'meinweimar_core_home');
add_settings_section(
'meinweimar_core_surfaces',
__('Modul-Header und Hero-Flächen', 'meinweimar-core'),
static function (): void {
echo '<p>' . esc_html__('Pflege Headerbild, Überschrift, Unterzeile und Farben der öffentlichen Modulflächen zentral. Die Werte werden lokalisiert an Website und App ausgegeben; leere Medienfelder verwenden den jeweiligen Inhalts-Fallback.', 'meinweimar-core') . '</p>';
},
'meinweimar-core'
);
add_settings_field('surface_heroes', __('Öffentliche Modul-Header', 'meinweimar-core'), [self::class, 'surface_heroes_field'], 'meinweimar-core', 'meinweimar_core_surfaces');
add_settings_section(
'meinweimar_core_app',
__('App-Menüs, Icons und Texte', 'meinweimar-core'),
static function (): void {
echo '<p>' . esc_html__('Die Reihenfolge der JSON-Arrays ist die sichtbare Reihenfolge. Bottom-Menü und Vollbild-Menü werden getrennt an die App ausgegeben. Leere Icon-Felder verwenden die Flutter-Icons als Fallback.', 'meinweimar-core') . '</p>';
},
'meinweimar-core'
);
add_settings_field('navigation_center_logo_id', __('Center-Logo im Bottom-Menü', 'meinweimar-core'), [self::class, 'media_field'], 'meinweimar-core', 'meinweimar_core_app', ['key' => 'navigation_center_logo_id', 'slug' => 'navigation-center-logo', 'label' => __('Center-Logo aus Mediathek wählen', 'meinweimar-core')]);
add_settings_field('navigation_bottom', __('Bottom-Menü (JSON)', 'meinweimar-core'), [self::class, 'navigation_surface_field'], 'meinweimar-core', 'meinweimar_core_app', ['key' => 'navigation_bottom']);
add_settings_field('navigation_fullscreen', __('Vollbild-Menü (JSON)', 'meinweimar-core'), [self::class, 'navigation_surface_field'], 'meinweimar-core', 'meinweimar_core_app', ['key' => 'navigation_fullscreen']);
foreach (self::defaults()['navigation'] as $item) {
add_settings_field('navigation_icon_' . $item['id'], sprintf(__('%s – eigenes App-Icon', 'meinweimar-core'), $item['label']), [self::class, 'navigation_icon_field'], 'meinweimar-core', 'meinweimar_core_app', ['id' => $item['id'], 'label' => $item['label']]);
}
foreach (ContentTypes::definitions() as $route => $definition) {
add_settings_field('resource_icon_' . $route, sprintf(__('%s – eigenes CPT-Icon', 'meinweimar-core'), $definition['label']), [self::class, 'resource_icon_field'], 'meinweimar-core', 'meinweimar_core_app', ['route' => $route, 'label' => $definition['label']]);
}
foreach (self::field_icon_definitions() as $field => $label) {
add_settings_field('field_icon_' . $field, sprintf(__('%s – eigenes Feld-Icon', 'meinweimar-core'), $label), [self::class, 'field_icon_field'], 'meinweimar-core', 'meinweimar_core_app', ['field' => $field, 'label' => $label]);
}
add_settings_field('app_texts', __('Alle App-Texte (lokalisiertes JSON)', 'meinweimar-core'), [self::class, 'app_texts_field'], 'meinweimar-core', 'meinweimar_core_app');
add_settings_section(
'meinweimar_core_geo',
__('Google Maps und Standort', 'meinweimar-core'),
static function (): void {
echo '<p>' . esc_html__('MeinWeimar verwendet Google Maps für die Website-Karte und den externen Navigieren-Button. Die installierte Google-Maps-App wird zuerst geöffnet; fehlt sie, übernimmt die lokale Karten-App, danach der Browser-Link. Der Browser-Key muss auf deine Website-Domain eingeschränkt sein.', 'meinweimar-core') . '</p>';
},
'meinweimar-core'
);
add_settings_field('geo_google_maps_status', __('Google-Maps-Status', 'meinweimar-core'), [self::class, 'google_maps_status_field'], 'meinweimar-core', 'meinweimar_core_geo');
add_settings_field('geo_google_maps_api_key', __('Google-Maps-Platform-Key (Website)', 'meinweimar-core'), [self::class, 'google_maps_api_key_field'], 'meinweimar-core', 'meinweimar_core_geo');
add_settings_field('geo_default_latitude', __('Standardzentrum Breitengrad', 'meinweimar-core'), [self::class, 'number_field'], 'meinweimar-core', 'meinweimar_core_geo', ['key' => 'geo_default_latitude', 'min' => -90, 'max' => 90, 'step' => '0.000001']);
add_settings_field('geo_default_longitude', __('Standardzentrum Längengrad', 'meinweimar-core'), [self::class, 'number_field'], 'meinweimar-core', 'meinweimar_core_geo', ['key' => 'geo_default_longitude', 'min' => -180, 'max' => 180, 'step' => '0.000001']);
add_settings_field('geo_fallback_marker_id', __('Globaler Fallback-Marker', 'meinweimar-core'), [self::class, 'marker_field'], 'meinweimar-core', 'meinweimar_core_geo', ['key' => 'geo_fallback_marker_id', 'label' => __('Globaler Fallback-Marker', 'meinweimar-core')]);
add_settings_field('geo_user_marker_id', __('GPS-Standortmarker', 'meinweimar-core'), [self::class, 'marker_field'], 'meinweimar-core', 'meinweimar_core_geo', ['user' => true, 'label' => __('GPS-Standortmarker', 'meinweimar-core')]);
foreach (ContentTypes::definitions() as $route => $definition) {
if (empty($definition['geo'])) {
continue;
}
add_settings_field(
'geo_resource_marker_' . $route,
sprintf(__('%s-Marker', 'meinweimar-core'), $definition['label']),
[self::class, 'marker_field'],
'meinweimar-core',
'meinweimar_core_geo',
['route' => $route, 'label' => sprintf(__('%s-Marker', 'meinweimar-core'), $definition['label'])]
);
}
}
public static function sanitize($input): array
{
$input = is_array($input) ? $input : [];
$defaults = self::defaults();
$brand_logo_id = self::image_id($input['brand_logo_id'] ?? $defaults['brand_logo_id']);
$brand_logo_dark_id = self::image_id($input['brand_logo_dark_id'] ?? $defaults['brand_logo_dark_id']);
$navigation_center_logo_id = self::image_id($input['navigation_center_logo_id'] ?? $defaults['navigation_center_logo_id']);
$brand_hero_image_id = self::image_id($input['brand_hero_image_id'] ?? $defaults['brand_hero_image_id']);
$hero_logo_id = self::image_id($input['hero_logo_id'] ?? $defaults['hero_logo_id']);
$hero_icon_id = self::image_id($input['hero_icon_id'] ?? $defaults['hero_icon_id']);
$locales = self::normalize_locales($input['locales_json'] ?? $defaults['locales']);
$navigation_surface_input = array_key_exists('navigation_bottom_json', $input) || array_key_exists('navigation_fullscreen_json', $input) || array_key_exists('navigation_bottom', $input) || array_key_exists('navigation_fullscreen', $input);
return [
'brand_logo_id' => $brand_logo_id,
'brand_logo_dark_id' => $brand_logo_dark_id,
'navigation_center_logo_id' => $navigation_center_logo_id,
'brand_hero_image_id' => $brand_hero_image_id,
'brand_wordmark' => sanitize_text_field((string) ($input['brand_wordmark'] ?? $defaults['brand_wordmark'])),
'primary_color' => self::color_value('primary_color', $input['primary_color'] ?? '', $defaults['primary_color']),
'secondary_color' => self::color_value('secondary_color', $input['secondary_color'] ?? '', $defaults['secondary_color']),
'accent_color' => self::color_value('accent_color', $input['accent_color'] ?? '', $defaults['accent_color']),
'ink_color' => self::color_value('ink_color', $input['ink_color'] ?? '', $defaults['ink_color']),
'surface_color' => self::color_value('surface_color', $input['surface_color'] ?? '', $defaults['surface_color']),
'muted_color' => self::color_value('muted_color', $input['muted_color'] ?? '', $defaults['muted_color']),
'brand_surface_color' => self::color_value('brand_surface_color', $input['brand_surface_color'] ?? '', $defaults['brand_surface_color']),
'success_color' => self::color_value('success_color', $input['success_color'] ?? '', $defaults['success_color']),
'warning_color' => self::color_value('warning_color', $input['warning_color'] ?? '', $defaults['warning_color']),
'hero_eyebrow' => self::normalize_localized_text($input['hero_eyebrow_json'] ?? $input['hero_eyebrow'] ?? $defaults['hero_eyebrow']),
'hero_title' => self::normalize_localized_text($input['hero_title_json'] ?? $input['hero_title'] ?? $defaults['hero_title']),
'hero_body' => self::normalize_localized_text($input['hero_body_json'] ?? $input['hero_body'] ?? $defaults['hero_body']),
'hero_primary_label' => self::normalize_localized_text($input['hero_primary_label_json'] ?? $input['hero_primary_label'] ?? $defaults['hero_primary_label']),
'hero_primary_url' => self::normalize_localized_links($input['hero_primary_url_json'] ?? $input['hero_primary_url'] ?? $defaults['hero_primary_url']),
'hero_secondary_label' => self::normalize_localized_text($input['hero_secondary_label_json'] ?? $input['hero_secondary_label'] ?? $defaults['hero_secondary_label']),
'hero_secondary_url' => self::normalize_localized_links($input['hero_secondary_url_json'] ?? $input['hero_secondary_url'] ?? $defaults['hero_secondary_url']),
'hero_logo_id' => $hero_logo_id,
'hero_icon_id' => $hero_icon_id,
'hero_campaigns' => self::sanitize_hero_campaigns($input['hero_campaigns'] ?? (self::get()['hero_campaigns'] ?? $defaults['hero_campaigns'])),
'home_sections' => self::normalize_home_sections(
array_key_exists('home_sections_json', $input)
? $input['home_sections_json']
: (self::get()['home_sections'] ?? $defaults['home_sections'])
),
'hero_height_mode' => self::hero_height_mode($input['hero_height_mode'] ?? $defaults['hero_height_mode']),
'hero_image_fit' => self::hero_image_fit($input['hero_image_fit'] ?? $defaults['hero_image_fit']),
'hero_title_color' => self::color_value('hero_title_color', $input['hero_title_color'] ?? '', $defaults['hero_title_color']),
'hero_body_color' => self::color_value('hero_body_color', $input['hero_body_color'] ?? '', $defaults['hero_body_color']),
'hero_eyebrow_color' => self::color_value('hero_eyebrow_color', $input['hero_eyebrow_color'] ?? '', $defaults['hero_eyebrow_color']),
'hero_button_color' => self::color_value('hero_button_color', $input['hero_button_color'] ?? '', $defaults['hero_button_color']),
'hero_button_text_color' => self::color_value('hero_button_text_color', $input['hero_button_text_color'] ?? '', $defaults['hero_button_text_color']),
'hero_secondary_button_color' => self::color_value('hero_secondary_button_color', $input['hero_secondary_button_color'] ?? '', $defaults['hero_secondary_button_color']),
'hero_secondary_button_text_color' => self::color_value('hero_secondary_button_text_color', $input['hero_secondary_button_text_color'] ?? '', $defaults['hero_secondary_button_text_color']),
'surface_heroes' => self::sanitize_surface_heroes(
$input['surface_heroes'] ?? (self::get()['surface_heroes'] ?? $defaults['surface_heroes'])
),
'default_radius_km' => self::decimal($input['default_radius_km'] ?? $defaults['default_radius_km'], 0.01, 50, 2),
'default_map_zoom' => max(1, min(20, absint($input['default_map_zoom'] ?? $defaults['default_map_zoom']))),
'geo_default_latitude' => self::decimal($input['geo_default_latitude'] ?? $defaults['geo_default_latitude'], -90, 90, 6),
'geo_default_longitude' => self::decimal($input['geo_default_longitude'] ?? $defaults['geo_default_longitude'], -180, 180, 6),
'geo_tiles_enabled' => !empty($input['geo_tiles_enabled']),
'geo_tile_url' => self::service_url($input['geo_tile_url'] ?? $defaults['geo_tile_url']),
'geo_tile_styles' => self::sanitize_tile_styles($input['geo_tile_styles_json'] ?? $input['geo_tile_styles'] ?? $defaults['geo_tile_styles']),
'geo_routing_url' => self::service_url($input['geo_routing_url'] ?? $defaults['geo_routing_url']),
'geo_routing_profile' => sanitize_key((string) ($input['geo_routing_profile'] ?? $defaults['geo_routing_profile'])) ?: $defaults['geo_routing_profile'],
'geo_navigation_provider' => self::navigation_provider($input['geo_navigation_provider'] ?? $defaults['geo_navigation_provider']),
'geo_external_navigation_url' => self::service_url($input['geo_external_navigation_url'] ?? $defaults['geo_external_navigation_url']),
'geo_google_maps_api_key' => self::google_maps_api_key_value(
array_key_exists('geo_google_maps_api_key', $input)
? $input['geo_google_maps_api_key']
: ($defaults['geo_google_maps_api_key'] ?? '')
),
'geo_geocoding_enabled' => !empty($input['geo_geocoding_enabled']),
'geo_geocoding_url' => self::service_url($input['geo_geocoding_url'] ?? $defaults['geo_geocoding_url']),
'geo_attribution' => sanitize_text_field((string) ($input['geo_attribution'] ?? $defaults['geo_attribution'])),
'geo_user_agent_contact' => sanitize_text_field((string) ($input['geo_user_agent_contact'] ?? $defaults['geo_user_agent_contact'])),
'geo_fallback_marker_id' => self::image_id($input['geo_fallback_marker_id'] ?? $defaults['geo_fallback_marker_id']),
'geo_user_marker_id' => self::image_id($input['geo_user_marker_id'] ?? $defaults['geo_user_marker_id']),
'geo_resource_markers' => self::sanitize_resource_markers($input['geo_resource_markers'] ?? $defaults['geo_resource_markers']),
'locales' => $locales,
'default_locale' => self::resolve_locale($input['default_locale'] ?? $defaults['default_locale'], $locales, $locales[0]),
'fallback_locale' => self::resolve_locale($input['fallback_locale'] ?? $defaults['fallback_locale'], $locales, $locales[0]),
'navigation_surfaces_configured' => $navigation_surface_input || !empty($input['navigation_surfaces_configured']),
'navigation_bottom' => self::normalize_navigation($input['navigation_bottom_json'] ?? $input['navigation_bottom'] ?? $input['navigation'] ?? $defaults['navigation']),
'navigation_fullscreen' => self::normalize_navigation($input['navigation_fullscreen_json'] ?? $input['navigation_fullscreen'] ?? $input['navigation'] ?? $defaults['navigation']),
'navigation_icons' => self::sanitize_image_map($input['navigation_icons'] ?? $defaults['navigation_icons']),
'resource_icons' => self::sanitize_image_map($input['resource_icons'] ?? $defaults['resource_icons']),
'field_icons' => self::sanitize_image_map($input['field_icons'] ?? $defaults['field_icons']),
'app_texts' => self::normalize_app_texts($input['app_texts_json'] ?? $input['app_texts'] ?? $defaults['app_texts']),
'navigation' => self::normalize_navigation($input['navigation_json'] ?? $input['navigation'] ?? $defaults['navigation']),
];
}
private static function render_settings_section(string $page, string $section_id): void
{
global $wp_settings_fields, $wp_settings_sections;
$section = $wp_settings_sections[$page][$section_id] ?? null;
if (!is_array($section)) {
return;
}
if (!empty($section['title'])) {
echo '<h2>' . wp_kses_post((string) $section['title']) . '</h2>';
}
if (!empty($section['callback']) && is_callable($section['callback'])) {
call_user_func($section['callback'], $section);
}
if (empty($wp_settings_fields[$page][$section_id])) {
return;
}
echo '<table class="form-table" role="presentation">';
do_settings_fields($page, $section_id);
echo '</table>';
}
public static function render_page(): void
{
if (!current_user_can('manage_options')) {
return;
}
$tabs = [
['id' => 'brand', 'label' => __('Marke und Farben', 'meinweimar-core'), 'section' => 'meinweimar_core_brand'],
['id' => 'hero', 'label' => __('Home-Hero', 'meinweimar-core'), 'section' => 'meinweimar_core_hero'],
['id' => 'home', 'label' => __('Home-Startseite', 'meinweimar-core'), 'section' => 'meinweimar_core_home'],
['id' => 'surfaces', 'label' => __('Modul-Header', 'meinweimar-core'), 'section' => 'meinweimar_core_surfaces'],
['id' => 'app', 'label' => __('App-Menüs und Texte', 'meinweimar-core'), 'section' => 'meinweimar_core_app'],
['id' => 'geo', 'label' => __('Karte und Standort', 'meinweimar-core'), 'section' => 'meinweimar_core_geo'],
];
echo '<div class="wrap mw-settings-page"><h1>' . esc_html__('MeinWeimar', 'meinweimar-core') . '</h1>';
echo '<p>' . esc_html__('Zentrale Konfiguration für Marke, Geo-Discovery und gemeinsame Clients.', 'meinweimar-core') . '</p>';
echo '<form method="post" action="options.php">';
settings_fields('meinweimar_core');
echo '<nav class="nav-tab-wrapper mw-settings-tabs" role="tablist" aria-label="' . esc_attr__('MeinWeimar-Einstellungen', 'meinweimar-core') . '">';
foreach ($tabs as $index => $tab) {
$tab_id = 'mw-tab-' . $tab['id'];
$panel_id = 'mw-panel-' . $tab['id'];
echo '<button type="button" class="nav-tab' . (0 === $index ? ' nav-tab-active' : '') . '" id="' . esc_attr($tab_id) . '" role="tab" aria-controls="' . esc_attr($panel_id) . '" aria-selected="' . (0 === $index ? 'true' : 'false') . '" data-mw-settings-tab="' . esc_attr($tab['id']) . '">' . esc_html($tab['label']) . '</button>';
}
echo '</nav>';
foreach ($tabs as $index => $tab) {
$panel_id = 'mw-panel-' . $tab['id'];
echo '<section class="mw-settings-panel" id="' . esc_attr($panel_id) . '" role="tabpanel" aria-labelledby="mw-tab-' . esc_attr($tab['id']) . '" data-mw-settings-panel="' . esc_attr($tab['id']) . '"' . (0 === $index ? '' : ' hidden') . '>';
self::render_settings_section('meinweimar-core', $tab['section']);
echo '</section>';
}
submit_button();
echo '</form></div>';
}
public static function logo_field(array $args = []): void
{
$variant = ('dark' === ($args['variant'] ?? '')) ? 'dark' : 'light';
$key = 'dark' === $variant ? 'brand_logo_dark_id' : 'brand_logo_id';
$slug = 'brand-logo-' . $variant;
$settings = self::get();
$value = absint($settings[$key] ?? 0);
$title = 'dark' === $variant
? __('MeinWeimar-Logo für dunkle Hintergründe auswählen', 'meinweimar-core')
: __('MeinWeimar-Logo für helle Hintergründe auswählen', 'meinweimar-core');
echo '<input type="hidden" id="mw-' . esc_attr($slug) . '-id" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '">';
echo '<button type="button" class="button" data-mw-logo-picker data-input="mw-' . esc_attr($slug) . '-id" data-preview="mw-' . esc_attr($slug) . '-preview" data-title="' . esc_attr($title) . '" data-button-text="' . esc_attr__('Logo verwenden', 'meinweimar-core') . '">' . esc_html__('Logo aus Mediathek wählen', 'meinweimar-core') . '</button>';
echo '<span id="mw-' . esc_attr($slug) . '-preview" style="margin-left:12px;">' . ($value ? esc_html__('Logo ausgewählt', 'meinweimar-core') : esc_html__('Noch kein Logo ausgewählt', 'meinweimar-core')) . '</span>';
}
public static function image_field(array $args = []): void
{
$key = 'brand_hero_image_id';
$slug = 'brand-hero-image';
$settings = self::get();
$value = absint($settings[$key] ?? 0);
echo '<input type="hidden" id="mw-' . esc_attr($slug) . '-id" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '">';
echo '<button type="button" class="button" data-mw-logo-picker data-input="mw-' . esc_attr($slug) . '-id" data-preview="mw-' . esc_attr($slug) . '-preview" data-title="' . esc_attr__('Headerbild für die MeinWeimar-Home auswählen', 'meinweimar-core') . '" data-button-text="' . esc_attr__('Headerbild verwenden', 'meinweimar-core') . '" data-preview-label="' . esc_attr__('Headerbild ausgewählt', 'meinweimar-core') . '">' . esc_html__('Headerbild aus Mediathek wählen', 'meinweimar-core') . '</button>';
echo '<span id="mw-' . esc_attr($slug) . '-preview" style="margin-left:12px;">' . ($value ? esc_html__('Headerbild ausgewählt', 'meinweimar-core') : esc_html__('Noch kein Headerbild ausgewählt', 'meinweimar-core')) . '</span>';
}
public static function media_field(array $args): void
{
$key = sanitize_key((string) ($args['key'] ?? ''));
$slug = sanitize_key((string) ($args['slug'] ?? $key));
$settings = self::get();
$value = absint($settings[$key] ?? 0);
$label = (string) ($args['label'] ?? __('Bild aus Mediathek wählen', 'meinweimar-core'));
echo '<input type="hidden" id="mw-' . esc_attr($slug) . '-id" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '">';
echo '<button type="button" class="button" data-mw-logo-picker data-input="mw-' . esc_attr($slug) . '-id" data-preview="mw-' . esc_attr($slug) . '-preview" data-title="' . esc_attr($label) . '" data-button-text="' . esc_attr__('Auswählen', 'meinweimar-core') . '" data-preview-label="' . esc_attr__('Ausgewählt', 'meinweimar-core') . '">' . esc_html($label) . '</button>';
echo '<span id="mw-' . esc_attr($slug) . '-preview" style="margin-left:12px;">' . ($value ? esc_html__('Ausgewählt', 'meinweimar-core') : esc_html__('Noch nichts ausgewählt', 'meinweimar-core')) . '</span>';
}
public static function localized_text_field(array $args): void
{
$key = sanitize_key((string) ($args['key'] ?? ''));
$settings = self::get();
$values = self::normalize_localized_text($settings[$key] ?? []);
$name = self::OPTION . '[' . $key . '_json]';
$description = !empty($args['link'])
? __('Erlaubt https/http, interne Pfade sowie mailto:, tel: und #-Anker. Leere Locale fällt zurück.', 'meinweimar-core')
: __('Objekt mit Locale-Schlüsseln, zum Beispiel {"de_DE":"…","en_US":"…"}.', 'meinweimar-core');
echo '<textarea class="large-text code" rows="3" name="' . esc_attr($name) . '">' . esc_textarea((string) wp_json_encode($values, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '</textarea>';
echo '<p class="description">' . esc_html($description) . '</p>';
}
public static function hero_campaigns_field(): void
{
$campaigns = self::sanitize_hero_campaigns(self::get()['hero_campaigns'] ?? []);
echo '<div class="mw-hero-campaigns" data-mw-hero-campaigns>';
echo '<div class="mw-hero-campaigns__intro">';
echo '<p>' . esc_html__('Lege zeitgesteuerte Hero-Varianten für Kampagnen, bezahlte Platzierungen oder besondere Aktionen an. Der Standard-Hero bleibt der sichere Fallback.', 'meinweimar-core') . '</p>';
echo '<p class="description">' . esc_html__('Eine Kampagne wird nur verwendet, wenn sie aktiv ist und das optionale Start-/Enddatum passt. Bei mehreren passenden Kampagnen gewinnt die höchste Priorität; leere Kampagnenfelder übernehmen den Standard-Hero.', 'meinweimar-core') . '</p>';
echo '</div>';
echo '<input type="hidden" name="' . esc_attr(self::OPTION) . '[hero_campaigns][__empty]" value="1">';
echo '<div data-mw-hero-campaign-list>';
foreach ($campaigns as $index => $campaign) {
self::render_hero_campaign_card($campaign, (string) $index);
}
echo '</div>';
echo '<button type="button" class="button button-secondary mw-hero-campaigns__add" data-mw-hero-add>' . esc_html__('Weiteren Hero anlegen', 'meinweimar-core') . '</button>';
echo '<template data-mw-hero-template>';
self::render_hero_campaign_card([], '__INDEX__');
echo '</template>';
echo '</div>';
}
private static function render_hero_campaign_card(array $campaign, string $index): void
{
$campaign = self::hero_campaign_defaults($campaign, $index);
$prefix = self::OPTION . '[hero_campaigns][' . $index . ']';
$slug = 'hero-campaign-' . sanitize_key($index);
echo '<article class="mw-hero-campaign" data-mw-hero-campaign-card>';
echo '<div class="mw-hero-campaign__header">';
echo '<div><strong>' . esc_html__('Hero-Kampagne', 'meinweimar-core') . '</strong><span class="mw-hero-campaign__status" data-mw-hero-status></span></div>';
echo '<button type="button" class="button-link-delete" data-mw-hero-remove>' . esc_html__('Löschen', 'meinweimar-core') . '</button>';
echo '</div>';
echo '<input type="hidden" name="' . esc_attr($prefix . '[id]') . '" value="' . esc_attr($campaign['id']) . '">';
echo '<div class="mw-hero-campaign__meta">';
echo '<label><span>' . esc_html__('Interner Name', 'meinweimar-core') . '</span><input type="text" class="regular-text" name="' . esc_attr($prefix . '[label]') . '" value="' . esc_attr($campaign['label']) . '" placeholder="' . esc_attr__('z. B. Zwiebelmarkt', 'meinweimar-core') . '"></label>';
echo '<label class="mw-hero-campaign__active"><span>' . esc_html__('Aktiv', 'meinweimar-core') . '</span><input type="checkbox" name="' . esc_attr($prefix . '[active]') . '" value="1"' . checked($campaign['active'], true, false) . ' data-mw-hero-active></label>';
echo '<label><span>' . esc_html__('Priorität', 'meinweimar-core') . '</span><input type="number" name="' . esc_attr($prefix . '[priority]') . '" value="' . esc_attr((string) $campaign['priority']) . '" min="0" max="100" step="1"></label>';
echo '<label><span>' . esc_html__('Startdatum optional', 'meinweimar-core') . '</span><input type="date" name="' . esc_attr($prefix . '[start_at]') . '" value="' . esc_attr($campaign['start_at']) . '"></label>';
echo '<label><span>' . esc_html__('Enddatum optional', 'meinweimar-core') . '</span><input type="date" name="' . esc_attr($prefix . '[end_at]') . '" value="' . esc_attr($campaign['end_at']) . '"></label>';
echo '<label><span>' . esc_html__('Höhenmodus', 'meinweimar-core') . '</span><select name="' . esc_attr($prefix . '[height_mode]') . '"><option value="inherit"' . selected($campaign['height_mode'], 'inherit', false) . '>' . esc_html__('Standard verwenden', 'meinweimar-core') . '</option><option value="content"' . selected($campaign['height_mode'], 'content', false) . '>' . esc_html__('Inhaltsabhängig', 'meinweimar-core') . '</option><option value="image"' . selected($campaign['height_mode'], 'image', false) . '>' . esc_html__('Bildproportion', 'meinweimar-core') . '</option></select></label>';
echo '<label><span>' . esc_html__('Bilddarstellung', 'meinweimar-core') . '</span><select name="' . esc_attr($prefix . '[image_fit]') . '"><option value="inherit"' . selected($campaign['image_fit'], 'inherit', false) . '>' . esc_html__('Standard verwenden', 'meinweimar-core') . '</option><option value="cover"' . selected($campaign['image_fit'], 'cover', false) . '>' . esc_html__('Ausfüllen / Zuschneiden', 'meinweimar-core') . '</option><option value="contain"' . selected($campaign['image_fit'], 'contain', false) . '>' . esc_html__('Vollständig anzeigen', 'meinweimar-core') . '</option></select></label>';
echo '</div>';
echo '<div class="mw-hero-campaign__copy">';
foreach ([
'eyebrow' => ['Hero-Eyebrow', false],
'title' => ['Hero-Titel', false],
'body' => ['Hero-Text', false],
'primary_label' => ['Primärbutton-Text', false],
'primary_url' => ['Primärbutton-Link', true],
'secondary_label' => ['Sekundärbutton-Text', false],
'secondary_url' => ['Sekundärbutton-Link', true],
] as $key => [$label, $link]) {
$values = $campaign[$key] ?? [];
echo '<label><span>' . esc_html__($label, 'meinweimar-core') . '</span><textarea class="large-text code" rows="2" name="' . esc_attr($prefix . '[' . $key . '_json]') . '" placeholder="' . esc_attr__('Leer = Standard-Hero übernehmen', 'meinweimar-core') . '">' . esc_textarea((string) wp_json_encode($values, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '</textarea><small>' . esc_html($link ? __('Erlaubt https/http, Pfade, mailto:, tel: und #-Anker.', 'meinweimar-core') : __('Locale-Objekt, z. B. {"de_DE":"…","en_US":"…"}.', 'meinweimar-core')) . '</small></label>';
}
echo '</div>';
echo '<div class="mw-hero-campaign__colors">';
foreach ([
'title' => 'Hero-Titelfarbe',
'body' => 'Hero-Textfarbe',
'eyebrow' => 'Hero-Eyebrow-Farbe',
'button' => 'Primärbutton-Farbe',
'button_text' => 'Primärbutton-Textfarbe',
'secondary_button' => 'Sekundärbutton-Farbe',
'secondary_button_text' => 'Sekundärbutton-Textfarbe',
] as $key => $label) {
self::hero_campaign_color_field($prefix, $key, $label, (string) ($campaign['colors'][$key] ?? ''));
}
echo '</div>';
echo '<div class="mw-hero-campaign__media">';
self::hero_campaign_media_field($prefix, $slug . '-image', 'image_id', absint($campaign['image_id']), __('Hero-Bild aus Mediathek wählen', 'meinweimar-core'));
self::hero_campaign_media_field($prefix, $slug . '-logo', 'logo_id', absint($campaign['logo_id']), __('Hero-Logo aus Mediathek wählen', 'meinweimar-core'));
self::hero_campaign_media_field($prefix, $slug . '-icon', 'icon_id', absint($campaign['icon_id']), __('Hero-Icon aus Mediathek wählen', 'meinweimar-core'));
echo '</div>';
echo '</article>';
}
private static function hero_campaign_media_field(string $prefix, string $slug, string $key, int $value, string $label): void
{
$input_id = 'mw-' . sanitize_key($slug) . '-id';
$preview_id = $input_id . '-preview';
echo '<label><span>' . esc_html($label) . '</span><input type="hidden" id="' . esc_attr($input_id) . '" name="' . esc_attr($prefix . '[' . $key . ']') . '" value="' . esc_attr((string) $value) . '"><button type="button" class="button" data-mw-logo-picker data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($preview_id) . '" data-title="' . esc_attr($label) . '" data-button-text="' . esc_attr__('Auswählen', 'meinweimar-core') . '" data-preview-label="' . esc_attr__('Ausgewählt', 'meinweimar-core') . '">' . esc_html__('Auswählen', 'meinweimar-core') . '</button> <span id="' . esc_attr($preview_id) . '">' . ($value ? esc_html__('Ausgewählt', 'meinweimar-core') : esc_html__('Standard verwenden', 'meinweimar-core')) . '</span></label>';
}
private static function hero_campaign_color_field(string $prefix, string $key, string $label, string $value): void
{
$input_key = 'colors][' . $key;
$enabled_key = $prefix . '[colors][' . $key . '_enabled]';
$color_key = $prefix . '[' . $input_key . ']';
$input_id = 'mw-hero-color-' . sanitize_key($key) . '-' . substr(md5($prefix), 0, 8);
echo '<label><span>' . esc_html__($label, 'meinweimar-core') . '</span><span class="mw-hero-campaign__color-control"><input type="hidden" name="' . esc_attr($enabled_key) . '" value="0"><input type="checkbox" name="' . esc_attr($enabled_key) . '" value="1"' . checked($value !== '', true, false) . '> ' . esc_html__('Eigene Farbe', 'meinweimar-core') . '</span><input type="color" id="' . esc_attr($input_id) . '" name="' . esc_attr($color_key) . '" value="' . esc_attr($value ?: '#ffffff') . '"></label>';
}
/**
* Shared editable header configuration for public content surfaces.
* This deliberately sits beside the Home Hero contract rather than in a
* second content-field registry. Website and app receive the same shape.
*/
private static function surface_hero_defaults(): array
{
$defaults = [
'places' => ['label' => 'Orte', 'title' => ['de_DE' => 'Orte in Weimar', 'en_US' => 'Places in Weimar'], 'body' => ['de_DE' => 'Entdecke besondere Orte, Gastro und Empfehlungen in Weimar und der Region.', 'en_US' => 'Discover places, food and recommendations in Weimar and the region.']],
'events' => ['label' => 'Events', 'title' => ['de_DE' => 'Events in Weimar', 'en_US' => 'Events in Weimar'], 'body' => ['de_DE' => 'Finde Veranstaltungen, Termine und Erlebnisse rund um Weimar.', 'en_US' => 'Find events, dates and experiences around Weimar.']],
'services' => ['label' => 'Services und Einrichtungen', 'title' => ['de_DE' => 'Services in Weimar', 'en_US' => 'Services in Weimar'], 'body' => ['de_DE' => 'Wichtige Services, Einrichtungen und Anlaufstellen in deiner Nähe.', 'en_US' => 'Important services, facilities and contact points near you.']],
'classifieds' => ['label' => 'Kleinanzeigen', 'title' => ['de_DE' => 'Kleinanzeigen in Weimar', 'en_US' => 'Classifieds in Weimar'], 'body' => ['de_DE' => 'Finde Angebote und Gesuche aus Weimar und der Region.', 'en_US' => 'Find offers and requests from Weimar and the region.']],
'jobs' => ['label' => 'Jobs', 'title' => ['de_DE' => 'Jobs in Weimar', 'en_US' => 'Jobs in Weimar'], 'body' => ['de_DE' => 'Lokale Stellenangebote aus Weimar und der Region.', 'en_US' => 'Local jobs from Weimar and the surrounding region.']],
'real-estate' => ['label' => 'Immobilien', 'title' => ['de_DE' => 'Immobilien in Weimar', 'en_US' => 'Real estate in Weimar'], 'body' => ['de_DE' => 'Wohnungen, Häuser und Grundstücke in Weimar und Umgebung.', 'en_US' => 'Apartments, houses and land in Weimar and the surrounding area.']],
'news' => ['label' => 'News', 'title' => ['de_DE' => 'News aus Weimar', 'en_US' => 'News from Weimar'], 'body' => ['de_DE' => 'Aktuelle Nachrichten und redaktionelle Empfehlungen aus Weimar.', 'en_US' => 'Latest news and editorial recommendations from Weimar.']],
'guides' => ['label' => 'Ratgeber', 'title' => ['de_DE' => 'Ratgeber für Weimar', 'en_US' => 'Weimar guides'], 'body' => ['de_DE' => 'Tipps, Hintergründe und hilfreiche Informationen für deinen Alltag.', 'en_US' => 'Tips, background and useful information for everyday life.']],
];
foreach ($defaults as $route => $copy) {
$defaults[$route] = array_merge([
'id' => $route,
'label' => $route,
'eyebrow' => [],
'title' => [],
'body' => [],
'image_id' => 0,
'logo_id' => 0,
'icon_id' => 0,
'height_mode' => 'content',
'image_fit' => 'cover',
'colors' => [
'title' => '#ffffff',
'body' => '#ffffff',
'eyebrow' => '',
'button' => '',
'button_text' => '#ffffff',
'secondary_button' => '',
'secondary_button_text' => '',
],
], $copy);
}
return $defaults;
}
public static function surface_heroes_field(): void
{
$settings = self::get();
$heroes = self::sanitize_surface_heroes($settings['surface_heroes'] ?? []);
$locales = self::normalize_locales($settings['locales'] ?? self::defaults()['locales']);
echo '<div class="mw-surface-heroes">';
echo '<div class="mw-hero-campaigns__intro"><p>' . esc_html__('Jedes öffentliche Modul erhält hier dieselbe steuerbare Header-Struktur: lokalisierte Texte, Mediathek-Bild, Darstellung und Text-/Buttonfarben. Die App ergänzt keine eigenen Modultexte.', 'meinweimar-core') . '</p><p class="description">' . esc_html__('Ein leeres Bild verwendet den Inhalts-Fallback. Eigene Farben werden nur übernommen, wenn sie aktiviert sind; sonst greifen die globalen Theme-Farben.', 'meinweimar-core') . '</p></div>';
foreach ($heroes as $route => $hero) {
self::render_surface_hero_card((string) $route, $hero, $locales);
}
echo '</div>';
}
private static function render_surface_hero_card(string $route, array $hero, array $locales): void
{
$prefix = self::OPTION . '[surface_heroes][' . $route . ']';
$slug = 'surface-hero-' . sanitize_key($route);
echo '<article class="mw-hero-campaign mw-surface-hero" data-mw-surface-hero="' . esc_attr($route) . '">';
echo '<div class="mw-hero-campaign__header"><div><strong>' . esc_html((string) ($hero['label'] ?? $route)) . '</strong><span class="mw-surface-hero__route">' . esc_html($route) . '</span></div></div>';
echo '<input type="hidden" name="' . esc_attr($prefix . '[id]') . '" value="' . esc_attr($route) . '">';
echo '<div class="mw-hero-campaign__meta">';
echo '<label><span>' . esc_html__('Höhenmodus', 'meinweimar-core') . '</span><select name="' . esc_attr($prefix . '[height_mode]') . '"><option value="content"' . selected($hero['height_mode'], 'content', false) . '>' . esc_html__('Inhaltsabhängig', 'meinweimar-core') . '</option><option value="image"' . selected($hero['height_mode'], 'image', false) . '>' . esc_html__('Bildproportion', 'meinweimar-core') . '</option></select></label>';
echo '<label><span>' . esc_html__('Bilddarstellung', 'meinweimar-core') . '</span><select name="' . esc_attr($prefix . '[image_fit]') . '"><option value="cover"' . selected($hero['image_fit'], 'cover', false) . '>' . esc_html__('Ausfüllen / Zuschneiden', 'meinweimar-core') . '</option><option value="contain"' . selected($hero['image_fit'], 'contain', false) . '>' . esc_html__('Vollständig anzeigen', 'meinweimar-core') . '</option></select></label>';
echo '</div>';
echo '<div class="mw-hero-campaign__copy">';
foreach (['eyebrow' => 'Hero-Eyebrow', 'title' => 'Überschrift', 'body' => 'Unterzeile'] as $key => $label) {
$values = self::normalize_localized_text($hero[$key] ?? []);
echo '<label><span>' . esc_html__($label, 'meinweimar-core') . '</span><textarea class="large-text code" rows="2" name="' . esc_attr($prefix . '[' . $key . '_json]') . '">' . esc_textarea((string) wp_json_encode($values, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '</textarea><small>' . esc_html__('Locale-Objekt, z. B. {"de_DE":"…","en_US":"…"}.', 'meinweimar-core') . '</small></label>';
}
echo '</div>';
echo '<div class="mw-hero-campaign__colors">';
foreach (['title' => 'Überschriftenfarbe', 'body' => 'Unterzeilenfarbe', 'eyebrow' => 'Eyebrow-Farbe', 'button' => 'Suchbutton-Farbe', 'button_text' => 'Button-Textfarbe'] as $key => $label) {
self::hero_campaign_color_field($prefix, $key, $label, (string) ($hero['colors'][$key] ?? ''));
}
echo '</div>';
echo '<div class="mw-hero-campaign__media">';
self::hero_campaign_media_field($prefix, $slug . '-image', 'image_id', absint($hero['image_id'] ?? 0), __('Modul-Headerbild aus Mediathek wählen', 'meinweimar-core'));
self::hero_campaign_media_field($prefix, $slug . '-logo', 'logo_id', absint($hero['logo_id'] ?? 0), __('Optionales Modul-Logo aus Mediathek wählen', 'meinweimar-core'));
self::hero_campaign_media_field($prefix, $slug . '-icon', 'icon_id', absint($hero['icon_id'] ?? 0), __('Optionales Modul-Icon aus Mediathek wählen', 'meinweimar-core'));
echo '</div>';
echo '</article>';
}
public static function home_sections_field(): void
{
$settings = self::get();
$sections = self::normalize_home_sections($settings['home_sections'] ?? []);
$locales = self::normalize_locales($settings['locales'] ?? self::defaults()['locales']);
echo '<div class="mw-home-sections" data-mw-home-sections>';
echo '<div class="mw-home-sections__intro">';
echo '<p>' . esc_html__('Ziehe die Karten an die gewünschte Position. Die Reihenfolge wird für die vertikale Home-Startseite in Website und App übernommen.', 'meinweimar-core') . '</p>';
echo '<p class="description">' . esc_html__('Überschriften werden je Locale gepflegt. Ein eigenes Icon aus der Mediathek überschreibt das Flutter-Icon; bleibt es leer, greift der Icon-Fallback.', 'meinweimar-core') . '</p>';
echo '<p class="description" data-mw-home-save-status>' . esc_html__('Änderungen werden beim Speichern der Seite übernommen.', 'meinweimar-core') . '</p>';
echo '</div>';
echo '<input type="hidden" data-mw-home-sections-json name="' . esc_attr(self::OPTION) . '[home_sections_json]" value="' . esc_attr((string) wp_json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '">';
echo '<div class="mw-home-sections__list" data-mw-home-section-list>';
foreach ($sections as $section) {
self::render_home_section_card($section, $locales);
}
echo '</div>';
echo '</div>';
}
private static function render_home_section_card(array $section, array $locales): void
{
$id = sanitize_key((string) ($section['id'] ?? ''));
$defaults = [];
foreach (self::home_section_defaults() as $default) {
$defaults[$default['id']] = $default;
}
$default = $defaults[$id] ?? [];
$section = array_merge($default, $section);
$title_values = self::normalize_localized_text($section['title'] ?? []);
$icon_id = absint($section['icon_image_id'] ?? 0);
$input_id = 'mw-home-section-icon-' . $id;
$label = (string) ($section['admin_label'] ?? $id);
echo '<article class="mw-home-section-card" data-mw-home-section-card data-section-id="' . esc_attr($id) . '">';
echo '<header class="mw-home-section-card__header">';
echo '<span class="mw-home-section-card__handle" data-mw-home-section-handle aria-hidden="true">⋮⋮</span>';
echo '<div class="mw-home-section-card__heading"><strong>' . esc_html__($label, 'meinweimar-core') . '</strong><small>' . esc_html($id) . '</small></div>';
echo '<label class="mw-home-section-card__enabled"><input type="checkbox" data-mw-home-enabled value="1"' . checked(!empty($section['enabled']), true, false) . '> ' . esc_html__('Anzeigen', 'meinweimar-core') . '</label>';
echo '<button type="button" class="button-link mw-home-section-card__move" data-mw-home-move="up" aria-label="' . esc_attr__('Eine Position nach oben', 'meinweimar-core') . '">↑</button>';
echo '<button type="button" class="button-link mw-home-section-card__move" data-mw-home-move="down" aria-label="' . esc_attr__('Eine Position nach unten', 'meinweimar-core') . '">↓</button>';
echo '</header>';
echo '<div class="mw-home-section-card__body">';
echo '<div class="mw-home-section-card__titles">';
echo '<strong>' . esc_html__('Abschnittsüberschrift', 'meinweimar-core') . '</strong>';
foreach ($locales as $locale) {
echo '<label><span>' . esc_html($locale) . '</span><input type="text" data-mw-home-title-locale="' . esc_attr($locale) . '" value="' . esc_attr((string) ($title_values[$locale] ?? '')) . '"></label>';
}
echo '</div>';
echo '<div class="mw-home-section-card__icon">';
echo '<label><span>' . esc_html__('Flutter-Icon-Fallback', 'meinweimar-core') . '</span><input type="text" data-mw-home-icon value="' . esc_attr((string) ($section['icon'] ?? 'grid_view')) . '" placeholder="' . esc_attr__('z. B. event, bolt, people', 'meinweimar-core') . '"></label>';
echo '<label><span>' . esc_html__('Eigenes Icon aus der Mediathek', 'meinweimar-core') . '</span><input type="hidden" id="' . esc_attr($input_id) . '" data-mw-home-icon-image value="' . esc_attr((string) $icon_id) . '"><span class="mw-home-section-card__media-actions"><button type="button" class="button" data-mw-logo-picker data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($input_id . '-preview') . '" data-title="' . esc_attr__('Home-Icon auswählen', 'meinweimar-core') . '" data-button-text="' . esc_attr__('Icon verwenden', 'meinweimar-core') . '" data-preview-label="' . esc_attr__('Icon ausgewählt', 'meinweimar-core') . '">' . esc_html__('Icon aus Mediathek wählen', 'meinweimar-core') . '</button><button type="button" class="button-link-delete" data-mw-home-icon-clear data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($input_id . '-preview') . '" data-clear-label="' . esc_attr__('Flutter-Icon-Fallback aktiv', 'meinweimar-core') . '">' . esc_html__('Eigenes Icon entfernen', 'meinweimar-core') . '</button></span><span id="' . esc_attr($input_id . '-preview') . '">' . ($icon_id ? esc_html__('Ausgewählt', 'meinweimar-core') : esc_html__('Flutter-Icon-Fallback aktiv', 'meinweimar-core')) . '</span></label>';
echo '</div>';
if ('quick_access' === $id) {
echo '<div class="mw-home-section-card__quick-access" data-mw-home-quick-access>';
echo '<div class="mw-home-section-card__quick-heading"><strong>' . esc_html__('Runde Schnellzugriff-Kategorien', 'meinweimar-core') . '</strong><small>' . esc_html__('Auswahl kommt ausschließlich aus den registrierten WordPress-Taxonomien. Ein Bild ist optional; ohne Bild verwendet die App das Modul-Icon.', 'meinweimar-core') . '</small></div>';
echo '<div class="mw-home-quick-items" data-mw-home-quick-items>';
foreach ((array) ($section['items'] ?? []) as $index => $quick_item) {
self::render_home_quick_item((array) $quick_item, $locales, (int) $index);
}
echo '</div>';
ob_start();
self::render_home_quick_item(
['route' => 'places', 'category' => '', 'label' => [], 'image_id' => 0],
$locales,
0
);
$quick_template = ob_get_clean();
echo '<template data-mw-home-quick-template>' . $quick_template . '</template>';
echo '<button type="button" class="button" data-mw-home-quick-add>' . esc_html__('Weitere Kategorie hinzufügen', 'meinweimar-core') . '</button>';
echo '</div>';
}
echo '</div>';
echo '</article>';
}
private static function render_home_quick_item(array $item, array $locales, int $index): void
{
$route = sanitize_key((string) ($item['route'] ?? 'places'));
$category = sanitize_title((string) ($item['category'] ?? ''));
$selected = $route . '|' . $category;
$labels = self::normalize_localized_text($item['label'] ?? []);
$input_id = 'mw-home-quick-image-' . $index . '-' . wp_rand(1000, 9999);
$image_id = absint($item['image_id'] ?? 0);
echo '<div class="mw-home-quick-item" data-mw-home-quick-item>';
echo '<div class="mw-home-quick-item__controls">';
echo '<label><span>' . esc_html__('Modul und Kategorie', 'meinweimar-core') . '</span><select data-mw-home-quick-route>' . self::home_quick_access_term_options($selected) . '</select></label>';
foreach ($locales as $locale) {
echo '<label><span>' . esc_html($locale) . '</span><input type="text" data-mw-home-quick-label-locale="' . esc_attr($locale) . '" value="' . esc_attr((string) ($labels[$locale] ?? '')) . '" placeholder="' . esc_attr__('Optional: eigene Bezeichnung', 'meinweimar-core') . '"></label>';
}
echo '</div>';
echo '<div class="mw-home-quick-item__media">';
echo '<input type="hidden" id="' . esc_attr($input_id) . '" data-mw-home-quick-image value="' . esc_attr((string) $image_id) . '">';
echo '<button type="button" class="button" data-mw-logo-picker data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($input_id . '-preview') . '" data-title="' . esc_attr__('Kategorie-Bild auswählen', 'meinweimar-core') . '" data-button-text="' . esc_attr__('Bild verwenden', 'meinweimar-core') . '" data-preview-label="' . esc_attr__('Bild ausgewählt', 'meinweimar-core') . '">' . esc_html__('Bild auswählen', 'meinweimar-core') . '</button>';
echo '<span id="' . esc_attr($input_id . '-preview') . '">' . ($image_id ? esc_html__('Bild ausgewählt', 'meinweimar-core') : esc_html__('Modul-Icon als Fallback', 'meinweimar-core')) . '</span>';
echo '<button type="button" class="button-link-delete" data-mw-home-quick-image-clear data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($input_id . '-preview') . '">' . esc_html__('Eigenes Bild entfernen', 'meinweimar-core') . '</button>';
echo '<button type="button" class="button-link-delete" data-mw-home-quick-remove>' . esc_html__('Eintrag entfernen', 'meinweimar-core') . '</button>';
echo '</div>';
echo '</div>';
}
private static function home_quick_access_term_options(string $selected): string
{
$html = '';
foreach (ContentTypes::definitions() as $route => $definition) {
$route = sanitize_key((string) $route);
$taxonomy = sanitize_key((string) ($definition['category'] ?? ''));
if (!$route || !$taxonomy) continue;
$html .= '<option value="' . esc_attr($route . '|') . '"' . selected($selected, $route . '|', false) . '>' . esc_html((string) ($definition['label'] ?? $route)) . ' — ' . esc_html__('alle Kategorien', 'meinweimar-core') . '</option>';
if (!function_exists('get_terms') || !function_exists('taxonomy_exists') || !taxonomy_exists($taxonomy)) continue;
$terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false, 'parent' => 0, 'orderby' => 'name', 'order' => 'ASC']);
if (is_wp_error($terms)) continue;
foreach ($terms as $term) {
$html .= self::home_quick_access_term_option_tree($route, $taxonomy, $term, $selected, ' ');
}
}
return $html;
}
private static function home_quick_access_term_option_tree(string $route, string $taxonomy, \WP_Term $term, string $selected, string $prefix): string
{
$value = $route . '|' . $term->slug;
$html = '<option value="' . esc_attr($value) . '"' . selected($selected, $value, false) . '>' . esc_html($prefix . $term->name) . '</option>';
$children = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false, 'parent' => (int) $term->term_id, 'orderby' => 'name', 'order' => 'ASC']);
if (is_wp_error($children)) return $html;
foreach ($children as $child) {
$html .= self::home_quick_access_term_option_tree($route, $taxonomy, $child, $selected, $prefix . ' ');
}
return $html;
}
public static function navigation_surface_field(array $args): void
{
$key = sanitize_key((string) ($args['key'] ?? 'navigation_bottom'));
$settings = self::get();
$fallback = $settings['navigation'] ?? self::defaults()['navigation'];
$value = !empty($settings[$key]) ? $settings[$key] : $fallback;
echo '<textarea class="large-text code" rows="14" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . '_json]">' . esc_textarea((string) wp_json_encode(self::normalize_navigation($value), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '</textarea>';
echo '<p class="description">' . esc_html__('Array-Reihenfolge = sichtbare Reihenfolge. Felder: id, label_key, label, route, icon, enabled, primary. Eigene Medien werden über die Icon-Felder darunter zugeordnet.', 'meinweimar-core') . '</p>';
}
public static function navigation_icon_field(array $args): void
{
$id = sanitize_key((string) ($args['id'] ?? ''));
$settings = self::get();
$value = absint(((array) ($settings['navigation_icons'] ?? []))[$id] ?? 0);
self::image_map_field('navigation_icons', $id, $value, (string) ($args['label'] ?? $id), 'App-Icon aus Mediathek wählen');
}
public static function resource_icon_field(array $args): void
{
$route = sanitize_key((string) ($args['route'] ?? ''));
$settings = self::get();
$value = absint(((array) ($settings['resource_icons'] ?? []))[$route] ?? 0);
self::image_map_field('resource_icons', $route, $value, (string) ($args['label'] ?? $route), 'CPT-Icon aus Mediathek wählen');
}
public static function field_icon_field(array $args): void
{
$field = sanitize_key((string) ($args['field'] ?? ''));
$settings = self::get();
$value = absint(((array) ($settings['field_icons'] ?? []))[$field] ?? 0);
self::image_map_field('field_icons', $field, $value, (string) ($args['label'] ?? $field), 'Feld-Icon aus Mediathek wählen');
}
private static function field_icon_definitions(): array
{
return [
'company_name' => __('Organisation / Unternehmen', 'meinweimar-core'),
'department' => __('Abteilung / Team', 'meinweimar-core'),
'work_hours' => __('Arbeitszeit', 'meinweimar-core'),
'start_date' => __('Arbeitsbeginn', 'meinweimar-core'),
'application_deadline' => __('Bewerbungsfrist', 'meinweimar-core'),
'languages' => __('Sprachen', 'meinweimar-core'),
'education' => __('Ausbildung / Abschluss', 'meinweimar-core'),
'experience_years' => __('Erfahrung', 'meinweimar-core'),
];
}
private static function image_map_field(string $map, string $key, int $value, string $label, string $button_label): void
{
$input_id = 'mw-' . sanitize_key($map) . '-' . sanitize_key($key) . '-id';
$preview_id = $input_id . '-preview';
$name = self::OPTION . '[' . $map . '][' . $key . ']';
echo '<input type="hidden" id="' . esc_attr($input_id) . '" name="' . esc_attr($name) . '" value="' . esc_attr((string) $value) . '">';
echo '<button type="button" class="button" data-mw-logo-picker data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($preview_id) . '" data-title="' . esc_attr($label) . '" data-button-text="' . esc_attr__($button_label, 'meinweimar-core') . '" data-preview-label="' . esc_attr__('Ausgewählt', 'meinweimar-core') . '">' . esc_html__($button_label, 'meinweimar-core') . '</button> ';
echo '<button type="button" class="button-link-delete" data-mw-image-map-clear data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($preview_id) . '" data-clear-label="' . esc_attr__('Flutter-Icon-Fallback aktiv', 'meinweimar-core') . '">' . esc_html__('Eigenes Icon entfernen', 'meinweimar-core') . '</button> ';
echo '<span id="' . esc_attr($preview_id) . '">' . ($value ? esc_html__('Ausgewählt', 'meinweimar-core') : esc_html__('Flutter-Icon-Fallback aktiv', 'meinweimar-core')) . '</span>';
}
public static function app_texts_field(): void
{
$value = self::normalize_app_texts(self::get()['app_texts'] ?? []);
echo '<textarea class="large-text code" rows="18" name="' . esc_attr(self::OPTION) . '[app_texts_json]">' . esc_textarea((string) wp_json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '</textarea>';
echo '<p class="description">' . esc_html__('Schlüssel entsprechen den Flutter-Locale-Schlüsseln. Nicht gesetzte Schlüssel fallen auf die gebündelte Sprachdatei zurück. Parameter können als {count}, {title}, {status} usw. angegeben werden.', 'meinweimar-core') . '</p>';
}
public static function text_field(array $args): void
{
$key = $args['key'];
$value = self::get()[$key] ?? '';
echo '<input class="regular-text" type="text" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '">';
}
public static function select_field(array $args): void
{
$key = sanitize_key((string) ($args['key'] ?? ''));
$options = is_array($args['options'] ?? null) ? $args['options'] : [];
$value = (string) (self::get()[$key] ?? array_key_first($options));
echo '<select name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']">';
foreach ($options as $option_value => $label) {
echo '<option value="' . esc_attr((string) $option_value) . '"' . selected($value, (string) $option_value, false) . '>' . esc_html((string) $label) . '</option>';
}
echo '</select>';
}
public static function checkbox_field(array $args): void
{
$key = (string) ($args['key'] ?? '');
$value = filter_var(self::get()[$key] ?? false, FILTER_VALIDATE_BOOLEAN);
echo '<label><input type="checkbox" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="1"' . checked($value, true, false) . '> ' . esc_html((string) ($args['label'] ?? '')) . '</label>';
}
public static function locale_select_field(array $args): void
{
$key = (string) $args['key'];
$settings = self::get();
$locales = self::normalize_locales($settings['locales'] ?? []);
$value = self::resolve_locale($settings[$key] ?? '', $locales, $locales[0]);
echo '<select name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']">';
foreach ($locales as $locale) {
echo '<option value="' . esc_attr($locale) . '"' . selected($value, $locale, false) . '>' . esc_html($locale) . '</option>';
}
echo '</select>';
}
public static function color_field(array $args): void
{
$key = (string) $args['key'];
$defaults = self::defaults();
$settings = self::get();
$hero_fallbacks = [
'hero_title_color' => '#ffffff',
'hero_body_color' => '#ffffff',
'hero_eyebrow_color' => self::color_value('accent_color', $settings['accent_color'] ?? '', $defaults['accent_color']),
'hero_button_color' => self::color_value('primary_color', $settings['primary_color'] ?? '', $defaults['primary_color']),
'hero_button_text_color' => '#ffffff',
'hero_secondary_button_color' => self::color_value('secondary_color', $settings['secondary_color'] ?? '', $defaults['secondary_color']),
'hero_secondary_button_text_color' => '#ffffff',
];
$fallback = $hero_fallbacks[$key] ?? (string) ($defaults[$key] ?? '#20242e');
$value = self::color_value($key, $settings[$key] ?? '', $fallback);
echo '<input type="color" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '">';
}
public static function url_field(array $args): void
{
$key = (string) $args['key'];
$settings = self::get();
$value = $settings[$key] ?? '';
echo '<input class="regular-text code" type="url" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '" placeholder="https://…">';
if ('geo_routing_url' === $key) {
echo '<p class="description">' . esc_html__('Optional: wird nicht für flutter_map benötigt, sondern nur für einen zentralen Route-/In-App-Navigationsdienst.', 'meinweimar-core') . '</p>';
} elseif ('geo_external_navigation_url' === $key) {
echo '<p class="description">' . esc_html__('Optionales HTTPS-Template für einen eigenen Web-Fallback. Bei Google Maps bleibt das Feld leer: App/Google-Maps-Link und lokale System-Navigation werden automatisch versucht. Unterstützte Platzhalter: {lat}, {lng}, {coordinates}, {destination}, {address}.', 'meinweimar-core') . '</p>';
} elseif ('geo_geocoding_url' === $key) {
echo '<p class="description">' . esc_html__('Nur erforderlich, wenn freie Adresssuche über die gemeinsame API angeboten werden soll.', 'meinweimar-core') . '</p>';
}
}
public static function google_maps_api_key_field(): void
{
$configured = self::google_maps_frontend_config()['enabled'];
echo '<input class="regular-text code" type="password" autocomplete="new-password" name="' . esc_attr(self::OPTION) . '[geo_google_maps_api_key]" value="" placeholder="AIza…">';
echo '<p class="description">' . esc_html($configured ? __('Ein Key ist aktiv. Leer lassen, um ihn beizubehalten.', 'meinweimar-core') : __('Key aus der Google Cloud Console einfügen. Leer lassen, um eine optionale wp-config-Konstante zu verwenden.', 'meinweimar-core')) . '</p>';
}
public static function google_maps_status_field(): void
{
$geo = (array) (self::public_config()['geo'] ?? []);
$navigation = 'google_maps' === (string) ($geo['navigation_provider'] ?? '')
? __('aktiv', 'meinweimar-core')
: __('nicht aktiv', 'meinweimar-core');
$website = self::google_maps_frontend_config()['enabled']
? __('konfiguriert', 'meinweimar-core')
: __('noch nicht konfiguriert', 'meinweimar-core');
$tiles = !empty($geo['map_configured'])
? __('konfiguriert', 'meinweimar-core')
: __('noch nicht konfiguriert', 'meinweimar-core');
echo '<div class="mw-google-maps-status">';
echo '<p><strong>' . esc_html__('Externe Navigation:', 'meinweimar-core') . '</strong> ' . esc_html(sprintf(__('Google Maps (%s)', 'meinweimar-core'), $navigation)) . '</p>';
echo '<p><strong>' . esc_html__('Website-Karte:', 'meinweimar-core') . '</strong> ' . esc_html(sprintf(__('Google Maps JavaScript (%s)', 'meinweimar-core'), $website)) . '</p>';
echo '<p><strong>' . esc_html__('Google-Tiles-/Session-Quelle:', 'meinweimar-core') . '</strong> ' . esc_html($tiles) . '</p>';
echo '<p class="description">' . esc_html__('Es gibt hier absichtlich keine OpenStreetMap-, Nominatim-, Routing- oder freien Kachel-URL-Felder mehr. Der separate Tiles-/Session-Gate bleibt für die alte providerneutrale API geschlossen; die Website-Karte verwendet den eingeschränkten Google-Maps-Platform-Key.', 'meinweimar-core') . '</p>';
echo '</div>';
}
public static function marker_field(array $args): void
{
$route = isset($args['route']) ? (string) $args['route'] : '';
$user = !empty($args['user']);
$key = $route
? 'geo_resource_markers][' . $route
: ($user ? 'geo_user_marker_id' : 'geo_fallback_marker_id');
$settings = self::get();
$value = $route
? absint(((array) ($settings['geo_resource_markers'] ?? []))[$route] ?? 0)
: absint($settings[$user ? 'geo_user_marker_id' : 'geo_fallback_marker_id'] ?? 0);
$input_id = 'mw-marker-' . ($route ?: ($user ? 'user' : 'fallback'));
$preview_id = $input_id . '-preview';
$name = self::OPTION . '[' . $key . ']';
$label = $value ? sprintf(__('Bild #%d ausgewählt', 'meinweimar-core'), $value) : __('Noch kein Marker ausgewählt', 'meinweimar-core');
echo '<input type="hidden" id="' . esc_attr($input_id) . '" name="' . esc_attr($name) . '" value="' . esc_attr((string) $value) . '">';
echo '<button type="button" class="button" data-mw-marker-picker data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($preview_id) . '">' . esc_html__('Marker aus Mediathek wählen', 'meinweimar-core') . '</button> ';
echo '<span id="' . esc_attr($preview_id) . '">' . esc_html($label) . '</span>';
if ($user) {
echo '<p class="description">' . esc_html__('Dieser Marker wird für den privaten Live-GPS-Standort verwendet. Wenn kein Bild gewählt ist, verwendet die App ihren gebündelten Standortmarker als Fallback.', 'meinweimar-core') . '</p>';
}
}
public static function number_field(array $args): void
{
$key = $args['key'];
$value = self::get()[$key] ?? '';
echo '<input type="number" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '" min="' . esc_attr((string) $args['min']) . '" max="' . esc_attr((string) $args['max']) . '" step="' . esc_attr((string) ($args['step'] ?? '1')) . '">';
}
public static function tile_styles_field(): void
{
$styles = wp_json_encode(self::sanitize_tile_styles(self::get()['geo_tile_styles'] ?? []), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
echo '<textarea class="large-text code" rows="7" name="' . esc_attr(self::OPTION) . '[geo_tile_styles_json]">' . esc_textarea((string) $styles) . '</textarea>';
echo '<p class="description">' . esc_html__('Optionale road/light/dark/satellite-Templates. Nur vollständig konfigurierte Provider werden im Kartenadapter angeboten; Satellit wird nicht aus OSM abgeleitet.', 'meinweimar-core') . '</p>';
}
public static function navigation_field(): void
{
$json = wp_json_encode(self::normalize_navigation(self::get()['navigation']), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
echo '<textarea class="large-text code" rows="14" name="' . esc_attr(self::OPTION) . '[navigation_json]">' . esc_textarea((string) $json) . '</textarea>';
echo '<p class="description">' . esc_html__('JSON-Konfiguration für Website und App. Jede Position kann id, label, label_key, icon, route, enabled und primary enthalten. Änderungen werden über die zentrale Config-API ausgegeben.', 'meinweimar-core') . '</p>';
}
public static function locales_field(): void
{
$locales = wp_json_encode(self::normalize_locales(self::get()['locales']), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
echo '<textarea class="large-text code" rows="4" name="' . esc_attr(self::OPTION) . '[locales_json]">' . esc_textarea((string) $locales) . '</textarea>';
echo '<p class="description">' . esc_html__('JSON-Liste für App-Sprachdateien und lokalisierte WordPress-Inhalte, zum Beispiel ["de_DE", "en_US"]. Keine sichtbaren UI-Texte in Flutter hardcodieren.', 'meinweimar-core') . '</p>';
}
private static function color_value(string $key, $value, string $fallback): string
{
$color = sanitize_hex_color((string) $value);
// #0070e4 was the old accidental app fallback. Treat it as legacy
// only for this setting so an existing option cannot reintroduce the
// unapproved blue without an explicit new configuration.
if ('secondary_color' === $key && '#0070e4' === strtolower((string) $color)) {
return $fallback;
}
return $color ?: $fallback;
}
private static function google_maps_api_key_value($value): string
{
$candidate = trim(sanitize_text_field((string) wp_unslash($value)));
if ('' === $candidate) {
$candidate = trim((string) (self::get()['geo_google_maps_api_key'] ?? ''));
}
return preg_replace('/[^A-Za-z0-9_-]/', '', $candidate) ?: '';
}
private static function public_colors(array $settings): array
{
$fallbacks = self::defaults();
$keys = ['primary_color', 'secondary_color', 'accent_color', 'ink_color', 'surface_color', 'muted_color', 'brand_surface_color', 'success_color', 'warning_color'];
$colors = [];
foreach ($keys as $key) {
$colors[$key] = self::color_value($key, $settings[$key] ?? '', $fallbacks[$key]);
}
return $colors;
}
private static function normalize_localized_text($value): array
{
if (is_string($value)) {
$decoded = json_decode(wp_unslash($value), true);
$value = is_array($decoded) ? $decoded : [];
}
$normalized = [];
foreach ((array) $value as $locale => $text) {
if (!is_scalar($text)) {
continue;
}
$locale = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $locale);
if (!$locale || strlen($locale) > 20) {
continue;
}
$normalized[$locale] = sanitize_textarea_field((string) $text);
}
return $normalized;
}
/**
* Return the configured Hero copy. During the one-time admin seed window,
* expose the plugin defaults read-only so the public contract is already
* usable before the settings screen has been opened once.
*/
private static function hero_copy(array $settings): array
{
$defaults = self::defaults();
$seeded = self::HERO_COPY_DEFAULTS_VERSION === (string) get_option(self::HERO_COPY_DEFAULTS_OPTION, '');
$copy = [];
foreach (self::HERO_COPY_KEYS as $key) {
$value = str_ends_with($key, '_url')
? self::normalize_localized_links($settings[$key] ?? [])
: self::normalize_localized_text($settings[$key] ?? []);
if (!$seeded && empty($value) && !empty($defaults[$key])) {
$value = str_ends_with($key, '_url')
? self::normalize_localized_links($defaults[$key])
: self::normalize_localized_text($defaults[$key]);
}
$copy[$key] = $value;
}
return $copy;
}
private static function hero_campaign_defaults(array $campaign, string $index = '0'): array
{
return array_merge([
'id' => 'hero-' . sanitize_key($index),
'label' => '',
'active' => false,
'priority' => 0,
'start_at' => '',
'end_at' => '',
'eyebrow' => [],
'title' => [],
'body' => [],
'primary_label' => [],
'primary_url' => [],
'secondary_label' => [],
'secondary_url' => [],
'image_id' => 0,
'logo_id' => 0,
'icon_id' => 0,
'height_mode' => 'inherit',
'image_fit' => 'inherit',
'colors' => [
'title' => '',
'body' => '',
'eyebrow' => '',
'button' => '',
'button_text' => '',
'secondary_button' => '',
'secondary_button_text' => '',
],
], $campaign);
}
private static function sanitize_hero_campaigns($value): array
{
if (is_string($value)) {
$decoded = json_decode(wp_unslash($value), true);
$value = is_array($decoded) ? $decoded : [];
}
$normalized = [];
$used_ids = [];
foreach ((array) $value as $index => $campaign) {
if (!is_array($campaign)) {
continue;
}
$campaign = self::hero_campaign_defaults($campaign, (string) $index);
$id = sanitize_key((string) $campaign['id']);
if (!$id) {
$id = 'hero-' . sanitize_key((string) $index);
}
$base_id = $id;
$suffix = 2;
while (isset($used_ids[$id])) {
$id = $base_id . '-' . $suffix;
$suffix++;
}
$used_ids[$id] = true;
$normalized[] = [
'id' => $id,
'label' => sanitize_text_field((string) $campaign['label']),
'active' => filter_var($campaign['active'], FILTER_VALIDATE_BOOLEAN),
'priority' => max(0, min(100, absint($campaign['priority']))),
'start_at' => self::date_value($campaign['start_at']),
'end_at' => self::date_value($campaign['end_at']),
'eyebrow' => self::normalize_localized_text($campaign['eyebrow_json'] ?? $campaign['eyebrow']),
'title' => self::normalize_localized_text($campaign['title_json'] ?? $campaign['title']),
'body' => self::normalize_localized_text($campaign['body_json'] ?? $campaign['body']),
'primary_label' => self::normalize_localized_text($campaign['primary_label_json'] ?? $campaign['primary_label']),
'primary_url' => self::normalize_localized_links($campaign['primary_url_json'] ?? $campaign['primary_url']),
'secondary_label' => self::normalize_localized_text($campaign['secondary_label_json'] ?? $campaign['secondary_label']),
'secondary_url' => self::normalize_localized_links($campaign['secondary_url_json'] ?? $campaign['secondary_url']),
'image_id' => self::image_id($campaign['image_id']),
'logo_id' => self::image_id($campaign['logo_id']),
'icon_id' => self::image_id($campaign['icon_id']),
'height_mode' => self::hero_campaign_height_mode($campaign['height_mode']),
'image_fit' => self::hero_campaign_image_fit($campaign['image_fit']),
'colors' => self::sanitize_hero_campaign_colors($campaign['colors'] ?? []),
];
}
return $normalized;
}
private static function date_value($value): string
{
$value = sanitize_text_field((string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) ? $value : '';
}
private static function hero_height_mode($value): string
{
return 'image' === sanitize_key((string) $value) ? 'image' : 'content';
}
private static function hero_image_fit($value): string
{
return 'contain' === sanitize_key((string) $value) ? 'contain' : 'cover';
}
private static function hero_campaign_height_mode($value): string
{
$value = sanitize_key((string) $value);
return in_array($value, ['inherit', 'content', 'image'], true) ? $value : 'inherit';
}
private static function hero_campaign_image_fit($value): string
{
$value = sanitize_key((string) $value);
return in_array($value, ['inherit', 'cover', 'contain'], true) ? $value : 'inherit';
}
private static function hero_colors(array $settings): array
{
$defaults = self::defaults();
return [
'title' => self::color_value('hero_title_color', $settings['hero_title_color'] ?? '', $defaults['hero_title_color']),
'body' => self::color_value('hero_body_color', $settings['hero_body_color'] ?? '', $defaults['hero_body_color']),
'eyebrow' => self::color_value('hero_eyebrow_color', $settings['hero_eyebrow_color'] ?? '', self::color_value('accent_color', $settings['accent_color'] ?? '', $defaults['accent_color'])),
'button' => self::color_value('hero_button_color', $settings['hero_button_color'] ?? '', $defaults['hero_button_color'] ?: ($settings['primary_color'] ?? $defaults['primary_color'])),
'button_text' => self::color_value('hero_button_text_color', $settings['hero_button_text_color'] ?? '', $defaults['hero_button_text_color']),
'secondary_button' => self::color_value('hero_secondary_button_color', $settings['hero_secondary_button_color'] ?? '', $defaults['hero_secondary_button_color'] ?: ($settings['secondary_color'] ?? $defaults['secondary_color'])),
'secondary_button_text' => self::color_value('hero_secondary_button_text_color', $settings['hero_secondary_button_text_color'] ?? '', $defaults['hero_secondary_button_text_color'] ?: $defaults['hero_button_text_color']),
];
}
private static function sanitize_hero_campaign_colors($value): array
{
$value = is_array($value) ? $value : [];
$colors = [];
foreach (['title', 'body', 'eyebrow', 'button', 'button_text', 'secondary_button', 'secondary_button_text'] as $key) {
$enabled = !empty($value[$key . '_enabled']);
$colors[$key] = $enabled ? (sanitize_hex_color((string) ($value[$key] ?? '')) ?: '') : '';
}
return $colors;
}
private static function merge_hero_colors(array $base, array $override): array
{
foreach ($override as $key => $value) {
if (is_string($value) && '' !== $value) {
$base[$key] = $value;
}
}
return $base;
}
private static function active_hero_campaign(array $settings): ?array
{
$today = current_time('Y-m-d');
$eligible = [];
foreach (self::sanitize_hero_campaigns($settings['hero_campaigns'] ?? []) as $index => $campaign) {
if (!$campaign['active']) {
continue;
}
if ($campaign['start_at'] && $today < $campaign['start_at']) {
continue;
}
if ($campaign['end_at'] && $today > $campaign['end_at']) {
continue;
}
$campaign['_index'] = $index;
$eligible[] = $campaign;
}
usort($eligible, static function (array $left, array $right): int {
if ($left['priority'] !== $right['priority']) {
return $right['priority'] <=> $left['priority'];
}
if ($left['start_at'] !== $right['start_at']) {
return strcmp($right['start_at'], $left['start_at']);
}
return $left['_index'] <=> $right['_index'];
});
if (!$eligible) {
return null;
}
unset($eligible[0]['_index']);
return $eligible[0];
}
private static function merge_localized_values(array $base, array $override): array
{
foreach ($override as $locale => $value) {
if (trim((string) $value) !== '') {
$base[$locale] = $value;
}
}
return $base;
}
private static function normalize_localized_links($value): array
{
$texts = self::normalize_localized_text($value);
$links = [];
foreach ($texts as $locale => $link) {
$links[$locale] = self::link_value($link);
}
return $links;
}
private static function normalize_app_texts($value): array
{
if (is_string($value)) {
$decoded = json_decode(wp_unslash($value), true);
$value = is_array($decoded) ? $decoded : [];
}
$normalized = [];
foreach ((array) $value as $locale => $messages) {
if (!is_array($messages)) {
continue;
}
$locale = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $locale);
if (!$locale || strlen($locale) > 20) {
continue;
}
foreach ($messages as $key => $text) {
if (!is_scalar($text)) {
continue;
}
$key = preg_replace('/[^A-Za-z0-9_.-]/', '', (string) $key);
if ($key) {
$normalized[$locale][$key] = sanitize_textarea_field((string) $text);
}
}
}
return $normalized;
}
private static function link_value(string $value): string
{
$value = trim($value);
if (preg_match('~^(https?://|/|#|mailto:|tel:)~i', $value)) {
return str_starts_with($value, '/') || str_starts_with($value, '#') || str_starts_with(strtolower($value), 'mailto:') || str_starts_with(strtolower($value), 'tel:')
? sanitize_text_field($value)
: esc_url_raw($value, ['http', 'https']);
}
return '';
}
private static function sanitize_image_map($value): array
{
$value = is_array($value) ? $value : [];
$normalized = [];
foreach ($value as $key => $attachment_id) {
$key = sanitize_key((string) $key);
if ($key) {
$normalized[$key] = self::image_id($attachment_id);
}
}
return $normalized;
}
private static function public_navigation(array $items, array $icon_map): array
{
return array_map(static function (array $item) use ($icon_map): array {
$icon_id = absint($icon_map[$item['id']] ?? 0);
$icon_id = $icon_id ?: absint($item['icon_image_id'] ?? 0);
if ($icon_id) {
$item['icon_image'] = self::logo_config($icon_id);
}
unset($item['icon_image_id']);
return $item;
}, $items);
}
private static function public_image_map(array $image_map): array
{
$public = [];
foreach ($image_map as $key => $attachment_id) {
$public[(string) $key] = self::logo_config(absint($attachment_id));
}
return $public;
}
private static function public_home_sections($sections): array
{
$public = [];
foreach (self::normalize_home_sections($sections) as $section) {
$item = [
'id' => $section['id'],
'enabled' => !empty($section['enabled']),
'title' => self::normalize_localized_text($section['title'] ?? []),
'title_key' => preg_replace('/[^A-Za-z0-9_.-]/', '', (string) ($section['title_key'] ?? '')),
'icon' => sanitize_key((string) ($section['icon'] ?? 'grid_view')) ?: 'grid_view',
];
$icon_id = absint($section['icon_image_id'] ?? 0);
if ($icon_id) {
$item['icon_image'] = self::logo_config($icon_id);
}
if ('quick_access' === $section['id']) {
$item['items'] = array_map(
static function (array $quick_item): array {
$image = self::logo_config(absint($quick_item['image_id'] ?? 0));
return [
'route' => sanitize_key((string) ($quick_item['route'] ?? '')),
'category' => sanitize_title((string) ($quick_item['category'] ?? '')),
'label' => self::normalize_localized_text($quick_item['label'] ?? []),
'image' => $image,
];
},
(array) ($section['items'] ?? [])
);
}
$public[] = $item;
}
return $public;
}
private static function public_surface_heroes($heroes): array
{
$public = [];
foreach (self::sanitize_surface_heroes($heroes) as $route => $hero) {
$image = self::logo_config(absint($hero['image_id'] ?? 0));
$logo = self::logo_config(absint($hero['logo_id'] ?? 0));
$icon = self::logo_config(absint($hero['icon_id'] ?? 0));
$public[(string) $route] = [
'id' => (string) $route,
'eyebrow' => self::normalize_localized_text($hero['eyebrow'] ?? []),
'title' => self::normalize_localized_text($hero['title'] ?? []),
'body' => self::normalize_localized_text($hero['body'] ?? []),
'image' => $image,
'image_url' => $image['url'],
'image_app_url' => $image['app_url'],
'logo' => $logo,
'icon' => $icon,
'height_mode' => self::hero_height_mode($hero['height_mode'] ?? 'content'),
'image_fit' => self::hero_image_fit($hero['image_fit'] ?? 'cover'),
'colors' => self::surface_hero_colors($hero['colors'] ?? []),
];
}
return $public;
}
private static function sanitize_surface_heroes($value): array
{
$defaults = self::surface_hero_defaults();
$value = is_array($value) ? $value : [];
$normalized = [];
foreach ($defaults as $route => $default) {
$hero = is_array($value[$route] ?? null) ? $value[$route] : [];
$base = array_merge($default, $hero);
$default_colors = (array) ($default['colors'] ?? []);
$colors = is_array($hero['colors'] ?? null) ? $hero['colors'] : [];
$normalized[$route] = [
'id' => $route,
'label' => sanitize_text_field((string) ($base['label'] ?? $route)),
'eyebrow' => self::normalize_localized_text($hero['eyebrow_json'] ?? $base['eyebrow'] ?? []),
'title' => self::normalize_localized_text($hero['title_json'] ?? $base['title'] ?? []),
'body' => self::normalize_localized_text($hero['body_json'] ?? $base['body'] ?? []),
'image_id' => self::image_id($base['image_id'] ?? 0),
'logo_id' => self::image_id($base['logo_id'] ?? 0),
'icon_id' => self::image_id($base['icon_id'] ?? 0),
'height_mode' => self::hero_height_mode($base['height_mode'] ?? 'content'),
'image_fit' => self::hero_image_fit($base['image_fit'] ?? 'cover'),
'colors' => self::sanitize_surface_hero_colors(array_merge($default_colors, $colors), $default_colors),
];
}
return $normalized;
}
private static function sanitize_surface_hero_colors(array $value, array $defaults = []): array
{
$colors = [];
foreach (['title', 'body', 'eyebrow', 'button', 'button_text', 'secondary_button', 'secondary_button_text'] as $key) {
$enabled_key = $key . '_enabled';
if (array_key_exists($enabled_key, $value) && empty($value[$enabled_key])) {
$colors[$key] = '';
continue;
}
$candidate = sanitize_hex_color((string) ($value[$key] ?? ''));
$colors[$key] = $candidate ?: (string) ($defaults[$key] ?? '');
}
return $colors;
}
private static function surface_hero_colors(array $colors): array
{
$public = [];
foreach (['title', 'body', 'eyebrow', 'button', 'button_text', 'secondary_button', 'secondary_button_text'] as $key) {
$public[$key] = sanitize_hex_color((string) ($colors[$key] ?? '')) ?: '';
}
return $public;
}
private static function home_section_defaults(): array
{
return [
[
'id' => 'hero',
'admin_label' => 'Hero',
'enabled' => true,
'title' => [],
'title_key' => '',
'icon' => 'grid_view',
'icon_image_id' => 0,
],
[
'id' => 'quick_access',
'admin_label' => 'Schnellzugriff',
'enabled' => true,
'title' => ['de_DE' => 'Schnellzugriff', 'en_US' => 'Quick access'],
'title_key' => 'homeQuickAccessTitle',
'icon' => 'bolt',
'icon_image_id' => 0,
'items' => self::home_quick_access_defaults(),
],
[
'id' => 'events',
'admin_label' => 'Aktuelle Events',
'enabled' => true,
'title' => ['de_DE' => 'Aktuelle Events', 'en_US' => 'Upcoming events'],
'title_key' => 'homeEventsTitle',
'icon' => 'event',
'icon_image_id' => 0,
],
[
'id' => 'news',
'admin_label' => 'Neuigkeiten aus Weimar',
'enabled' => true,
'title' => ['de_DE' => 'Neuigkeiten aus Weimar', 'en_US' => 'News from Weimar'],
'title_key' => 'homeNewsTitle',
'icon' => 'auto_stories',
'icon_image_id' => 0,
],
[
'id' => 'nearby',
'admin_label' => 'In deiner Nähe',
'enabled' => true,
'title' => ['de_DE' => 'In deiner Nähe', 'en_US' => 'Near you'],
'title_key' => 'homeNearbyTitle',
'icon' => 'near_me',
'icon_image_id' => 0,
],
[
'id' => 'listings',
'admin_label' => 'Neueste Angebote',
'enabled' => true,
'title' => ['de_DE' => 'Neueste Angebote', 'en_US' => 'Latest listings'],
'title_key' => 'homeListingsTitle',
'icon' => 'new_releases',
'icon_image_id' => 0,
],
[
'id' => 'community',
'admin_label' => 'Mit Weimar verbunden',
'enabled' => true,
'title' => ['de_DE' => 'Mit Weimar verbunden', 'en_US' => 'Connected to Weimar'],
'title_key' => 'homeCommunityTitle',
'icon' => 'people',
'icon_image_id' => 0,
],
];
}
private static function home_quick_access_defaults(): array
{
return [
['route' => 'places', 'category' => '', 'label' => ['de_DE' => 'Orte', 'en_US' => 'Places'], 'image_id' => 0],
['route' => 'events', 'category' => '', 'label' => ['de_DE' => 'Events', 'en_US' => 'Events'], 'image_id' => 0],
['route' => 'classifieds', 'category' => '', 'label' => ['de_DE' => 'Kleinanzeigen', 'en_US' => 'Classifieds'], 'image_id' => 0],
['route' => 'jobs', 'category' => '', 'label' => ['de_DE' => 'Jobs', 'en_US' => 'Jobs'], 'image_id' => 0],
['route' => 'real-estate', 'category' => '', 'label' => ['de_DE' => 'Immobilien', 'en_US' => 'Real estate'], 'image_id' => 0],
];
}
private static function normalize_home_quick_access_items($items): array
{
if (is_string($items)) {
$decoded = json_decode(wp_unslash($items), true);
$items = is_array($decoded) ? $decoded : [];
}
$normalized = [];
$definitions = ContentTypes::definitions();
foreach ((array) $items as $item) {
if (!is_array($item)) continue;
$route = sanitize_key((string) ($item['route'] ?? ''));
if (!$route || !isset($definitions[$route])) continue;
$category = sanitize_title((string) ($item['category'] ?? ''));
$taxonomy = sanitize_key((string) ($definitions[$route]['category'] ?? ''));
// A configured category must be an actual term of the selected
// route. This keeps the public contract free of invented slugs.
if ($category && function_exists('term_exists') && taxonomy_exists($taxonomy) && !term_exists($category, $taxonomy)) {
$category = '';
}
$normalized[] = [
'route' => $route,
'category' => $category,
'label' => self::normalize_localized_text($item['label'] ?? []),
'image_id' => self::image_id($item['image_id'] ?? 0),
];
}
return $normalized;
}
private static function normalize_home_sections($sections): array
{
if (is_string($sections)) {
$decoded = json_decode(wp_unslash($sections), true);
$sections = is_array($decoded) ? $decoded : [];
}
$defaults = [];
foreach (self::home_section_defaults() as $default) {
$defaults[$default['id']] = $default;
}
if (empty($sections)) {
return array_values($defaults);
}
$normalized = [];
$seen = [];
foreach ((array) $sections as $section) {
if (!is_array($section)) {
continue;
}
$id = sanitize_key((string) ($section['id'] ?? ''));
if (!$id || !isset($defaults[$id]) || isset($seen[$id])) {
continue;
}
$default = $defaults[$id];
$title = array_key_exists('title', $section)
? self::normalize_localized_text($section['title'])
: $default['title'];
$normalized[] = [
'id' => $id,
'admin_label' => $default['admin_label'],
'enabled' => filter_var($section['enabled'] ?? $default['enabled'], FILTER_VALIDATE_BOOLEAN),
'title' => $title,
'title_key' => preg_replace('/[^A-Za-z0-9_.-]/', '', (string) ($section['title_key'] ?? $default['title_key'])),
'icon' => sanitize_key((string) ($section['icon'] ?? $default['icon'])) ?: $default['icon'],
'icon_image_id' => self::image_id($section['icon_image_id'] ?? $default['icon_image_id']),
'items' => 'quick_access' === $id
? self::normalize_home_quick_access_items(
array_key_exists('items', $section) ? $section['items'] : ($default['items'] ?? [])
)
: [],
];
$seen[$id] = true;
}
foreach ($defaults as $id => $default) {
if (isset($seen[$id])) {
continue;
}
$normalized[] = $default;
}
return $normalized ?: array_values($defaults);
}
private static function normalize_navigation($navigation): array
{
$default_navigation = [];
foreach (self::defaults()['navigation'] as $default_item) {
$default_navigation[sanitize_key((string) $default_item['id'])] = $default_item;
$default_navigation[sanitize_text_field((string) $default_item['label'])] = $default_item;
}
if (is_string($navigation)) {
$decoded = json_decode($navigation, true);
$navigation = is_array($decoded) ? $decoded : [];
}
$normalized = [];
foreach ((array) $navigation as $index => $item) {
if (is_string($item)) {
$item = ['label' => $item];
}
if (!is_array($item)) {
continue;
}
$label = sanitize_text_field((string) ($item['label'] ?? ''));
if (!$label) {
continue;
}
$id_candidate = sanitize_key((string) ($item['id'] ?? ''));
$default_item = $default_navigation[$id_candidate] ?? $default_navigation[$label] ?? [];
$id = $id_candidate ?: sanitize_key((string) ($default_item['id'] ?? $label));
$route = sanitize_text_field((string) ($item['route'] ?? $default_item['route'] ?? '/'));
$label_key = preg_replace('/[^A-Za-z0-9_.-]/', '', (string) ($item['label_key'] ?? $default_item['label_key'] ?? ''));
// Older installs stored the pre-localized English default for
// the guide module. Normalize only that exact legacy default at
// the projection boundary; custom backend labels remain intact.
if ('guides' === $id && 'moduleGuides' === $label_key && in_array(strtolower($label), ['guides', 'guide'], true)) {
$label = __('Ratgeber', 'meinweimar-core');
}
$icon = sanitize_key((string) ($item['icon'] ?? $default_item['icon'] ?? 'grid_view')) ?: 'grid_view';
$icon_image_id = self::image_id($item['icon_image_id'] ?? $default_item['icon_image_id'] ?? 0);
$normalized[] = [
'id' => $id ?: 'item-' . (int) $index,
'label' => $label,
'label_key' => is_string($label_key) ? $label_key : '',
'icon' => $icon,
'icon_image_id' => $icon_image_id,
'route' => $route ?: '/',
'enabled' => filter_var($item['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN),
'primary' => filter_var($item['primary'] ?? ($default_item['primary'] ?? false), FILTER_VALIDATE_BOOLEAN),
];
}
return array_values($normalized);
}
private static function normalize_locales($locales): array
{
if (is_string($locales)) {
$decoded = json_decode($locales, true);
$locales = is_array($decoded) ? $decoded : preg_split('/[,\s]+/', $locales);
}
$normalized = [];
foreach ((array) $locales as $locale) {
if (!is_scalar($locale)) {
continue;
}
$locale = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $locale);
if ($locale && strlen($locale) <= 20) {
$normalized[] = $locale;
}
}
$normalized = array_values(array_unique($normalized));
return $normalized ?: self::defaults()['locales'];
}
private static function resolve_locale($value, array $locales, string $fallback): string
{
$candidate = str_replace('-', '_', preg_replace('/[^A-Za-z0-9_-]/', '', (string) $value));
foreach ($locales as $locale) {
if (0 === strcasecmp($candidate, (string) $locale)) {
return (string) $locale;
}
}
return $fallback;
}
private static function service_url($value): string
{
$value = trim((string) $value);
if (!preg_match('#^https?://#i', $value)) {
return '';
}
// WordPress URL sanitization strips curly-brace placeholders. Preserve
// the placeholders used by the provider-neutral tile/routing adapters
// while still passing the complete URL through esc_url_raw().
$placeholder_tokens = [
'{z}' => '__MEINWEIMAR_TILE_Z__',
'{x}' => '__MEINWEIMAR_TILE_X__',
'{y}' => '__MEINWEIMAR_TILE_Y__',
'{profile}' => '__MEINWEIMAR_ROUTE_PROFILE__',
'{coordinates}' => '__MEINWEIMAR_ROUTE_COORDINATES__',
'{lat}' => '__MEINWEIMAR_NAV_LAT__',
'{lng}' => '__MEINWEIMAR_NAV_LNG__',
'{destination}' => '__MEINWEIMAR_NAV_DESTINATION__',
'{address}' => '__MEINWEIMAR_NAV_ADDRESS__',
];
$sanitized_value = strtr($value, $placeholder_tokens);
$url = esc_url_raw($sanitized_value, ['http', 'https']);
$url = strtr($url, array_flip($placeholder_tokens));
return preg_match('#^https?://#i', $url) ? $url : '';
}
private static function navigation_provider($value): string
{
$provider = sanitize_key((string) $value);
return in_array($provider, ['google_maps', 'system'], true) ? $provider : 'google_maps';
}
private static function decimal($value, float $min, float $max, int $precision = 1): float
{
if (!is_numeric($value)) {
$value = $min;
}
return round(max($min, min($max, (float) $value)), $precision);
}
private static function sanitize_tile_styles($value): array
{
if (is_string($value)) {
$decoded = json_decode(wp_unslash($value), true);
$value = is_array($decoded) ? $decoded : [];
}
$styles = [];
foreach (['road', 'light', 'dark', 'satellite'] as $style) {
$styles[$style] = self::service_url(is_array($value) ? ($value[$style] ?? '') : '');
}
return $styles;
}
private static function image_id($value): int
{
$id = absint($value);
return $id && wp_attachment_is_image($id) ? $id : 0;
}
private static function logo_config(int $attachment_id): array
{
$url = $attachment_id ? (string) wp_get_attachment_image_url($attachment_id, 'full') : '';
if ('' === $url && $attachment_id) {
$url = (string) wp_get_attachment_url($attachment_id);
}
$dimensions = $attachment_id ? wp_get_attachment_image_src($attachment_id, 'full') : false;
return [
'id' => $attachment_id,
'url' => $url,
'app_url' => $attachment_id && is_string($url) && '' !== $url
? rest_url('meinweimar/v1/media/' . $attachment_id)
: '',
'srcset' => $attachment_id ? (string) wp_get_attachment_image_srcset($attachment_id, 'full') : '',
'alt' => $attachment_id ? (string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true) : '',
'width' => is_array($dimensions) ? absint($dimensions[1] ?? 0) : 0,
'height' => is_array($dimensions) ? absint($dimensions[2] ?? 0) : 0,
];
}
private static function sanitize_resource_markers($value): array
{
$value = is_array($value) ? $value : [];
$markers = [];
foreach (ContentTypes::definitions() as $route => $definition) {
if (!empty($definition['geo'])) {
$markers[$route] = self::image_id($value[$route] ?? 0);
}
}
return $markers;
}
public static function enqueue_admin_assets(string $hook): void
{
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
$marker_taxonomy = $screen && in_array((string) $screen->taxonomy, MapMarkers::taxonomies(), true);
if ('settings_page_meinweimar-core' !== $hook && !$marker_taxonomy) {
return;
}
wp_enqueue_media();
if ('settings_page_meinweimar-core' === $hook) {
wp_enqueue_style('meinweimar-admin-settings', URL . 'assets/css/admin-settings.css', [], VERSION);
}
wp_enqueue_script('meinweimar-admin-settings', URL . 'assets/js/admin-settings.js', ['jquery', 'jquery-ui-sortable'], VERSION, true);
}
}

Architecture

High-level modules detected from the verified public snapshot. Protected implementation details are intentionally omitted.

flutter-appmodule
wordpress-pluginmodule

Technology stack

DartPHPJavaScriptARBLOCKPBXPROJFlutter / Dart
Dart56.1%
PHP29.4%
JavaScript4.0%
ARB2.9%
LOCK1.7%
PBXPROJ1.7%
CSS1.5%
Markdown0.4%