Files
ntspi-app/docs/page-visual-customization.md
T
2026-04-07 17:54:26 +05:00

26 KiB
Raw Blame History

Визуальная кастомизация страниц — Анализ и план реализации

Дата: 7 апреля 2026 г. Контекст: Запрос на функционал изменения визуала страниц через админку (отступы, шрифты, размеры, цвета и т.д.)


📊 Текущая архитектура страниц

В проекте существуют два типа страниц:

Тип A: Database-driven страницы (Page Builder)

Параметр Значение
Создаются Через Filament админку
Хранение JSON в колонке content таблицы pages
Рендеринг Универсальный Page.vueBuilder компонент → динамический рендер блоков
Стилизация Hardcoded Tailwind классы в блоках (HeadingBlock.vue, ParagraphBlock.vue, и т.д.)
Настройки Колонка settings (JSON) — только логические флаги (скрыть breadcrumbs, навигацию и т.д.)

Ключевые файлы:

  • Model: app/Containers/AppStructure/Models/Page.php
  • Controller: app/Containers/AppStructure/UI/WEB/Controllers/PageController.php
  • Action: app/Containers/AppStructure/Actions/RenderPageAction.php
  • Vue: resources/js/Pages/Page.vue
  • Builder: resources/js/componentss/shared/builder/pageBuilder/Builder.vue
  • Filament Form: app/Filament/Components/Forms/PageForm.php
  • Filament Builder: app/Filament/Components/Forms/ItemForm/Pages/ContentBuilderItem.php

Тип B: Code-driven страницы (Hardcoded Vue компоненты)

Параметр Значение
Создаются Как .vue файлы в resources/js/Pages/
Примеры Main.vue (главная), страницы Dashboard
Стилизация Tailwind классы напрямую в template
Авто-регистрация RegisterApplicationRoutesTask создаёт запись в pages с is_registered = true

Ключевые файлы:

  • Task: app/Containers/AppStructure/Tasks/RegisterApplicationRoutesTask.php
  • Middleware: app/Ship/Middleware/AccessCheck.php
  • Пример: resources/js/Pages/Main.vue

Текущая структура pages.settings

{
  "hide_page_sub_section_links": false,
  "hide_page_navigate_links": false,
  "hide_breadcrumbs": false,
  "form": {
    "id": "form_id",
    "title": "Заголовок",
    "description": "Описание",
    "button": "Текст кнопки"
  }
}

Текущая структура pages таблицы

Колонка Тип Описание
id bigint Primary key
title string(255) Заголовок страницы
content longText (JSON) Builder блоки
slug string(255) URL сегмент
path string Полный URL путь
is_registered boolean true = авто-регистрация из кода
is_visible boolean Видимость
searchable boolean Индексация в поиске
is_url boolean Редирект на внешний URL
code integer HTTP статус (200, 404, 500)
sub_section_id bigint FK Связь с подразделом
settings longText (JSON) Настройки отображения
icon string Heroicon для навигации
search_data longText Текст для поиска

🎯 Проблема

Сейчас нет возможности через админку менять визуальные параметры страниц:

  • Отступы (padding, margin)
  • Размер шрифта
  • Шрифт (font-family)
  • Цвета
  • Максимальная ширина контента
  • И другие CSS-свойства

Все стили захардкожены в Vue компонентах и блоках билдера.


💡 Решения (от простого к сложному)

Решение 1: Расширение settings JSON (Рекомендуемое)

Концепция: Добавить в существующую колонку pages.settings секцию visual с визуальными настройками.

Структура данных:

{
  "hide_page_sub_section_links": false,
  "hide_page_navigate_links": false,
  "hide_breadcrumbs": false,
  "form": { ... },
  "visual": {
    "typography": {
      "font_family": "Inter",
      "title_size": "2xl",
      "body_size": "base",
      "line_height": "normal"
    },
    "spacing": {
      "container_padding_top": "10",
      "container_padding_bottom": "10",
      "container_padding_x": "4",
      "content_gap": "5"
    },
    "layout": {
      "max_width": "screen-xl",
      "sidebar_position": "left"
    },
    "colors": {
      "background": "white",
      "text": "gray-900"
    }
  }
}

Backend (Filament Form) — пример добавления в PageForm.php:

Section::make('Визуальные настройки')
    ->description('Настройка внешнего вида страницы')
    ->collapsible()
    ->schema([
        Select::make('visual.typography.font_family')
            ->label('Шрифт')
            ->options([
                'Inter' => 'Inter (по умолчанию)',
                'Roboto' => 'Roboto',
                'Open Sans' => 'Open Sans',
                'Montserrat' => 'Montserrat',
            ])
            ->default('Inter'),
        
        Select::make('visual.typography.title_size')
            ->label('Размер заголовка')
            ->options([
                'xl' => 'XL (маленький)',
                '2xl' => '2XL (стандарт)',
                '3xl' => '3XL (большой)',
                '4xl' => '4XL (очень большой)',
            ])
            ->default('2xl'),
        
        Select::make('visual.spacing.container_padding_top')
            ->label('Отступ сверху')
            ->options([
                '0' => '0',
                '4' => '16px',
                '6' => '24px',
                '10' => '40px',
            ])
            ->default('10'),
    ]);

Frontend (Page.vue) — пример применения:

<template>
  <BasicPageWrapper>
    <div 
      :class="[
        'relative mx-auto max-w-screen-xl w-full px-4 md:flex md:flex-row',
        visualClasses.containerPadding,
        visualClasses.backgroundColor
      ]"
      :style="customFontStyle"
    >
      <BasicTitle 
        :header="page.data.title" 
        :size="settings?.visual?.typography?.title_size || '2xl'" 
      />
      <div id="page-area" :class="contentGapClass">
        <Builder :blocks="page.data.content" />
      </div>
    </div>
  </BasicPageWrapper>
</template>

<script>
export default {
  computed: {
    visualClasses() {
      const v = this.settings?.visual || {};
      return {
        containerPadding: `pt-${v.spacing?.container_padding_top || 10} pb-${v.spacing?.container_padding_bottom || 10}`,
        backgroundColor: `bg-${v.colors?.background || 'white'}`,
      };
    },
    customFontStyle() {
      const typography = this.settings?.visual?.typography || {};
      return {
        fontFamily: typography.font_family || 'Inter',
        fontSize: typography.body_size || '16px',
        lineHeight: typography.line_height === 'relaxed' ? '1.75' : 'normal',
      };
    },
  },
};
</script>

Плюсы:

  • Минимальные изменения в архитектуре
  • Использует существующую инфраструктуру settings
  • Легко масштабировать (добавить новые поля в JSON)
  • Не требует миграций БД
  • Работает для обоих типов страниц (нужно только передать settings в Inertia)

Минусы:

  • Ограниченный набор стилей (только то, что предусмотрено в UI)
  • Нужно менять все Builder-блоки для поддержки наследования стилей

Решение 2: CSS Custom Properties (CSS Variables)

Концепция: Хранить CSS-переменные в settings, применять через inline <style> тег.

Структура данных:

{
  "visual": {
    "css_variables": {
      "--page-font-family": "Inter",
      "--page-title-size": "2rem",
      "--page-content-padding-top": "40px",
      "--page-max-width": "1280px",
      "--page-background": "#FFFFFF",
      "--page-text-color": "#0F172A"
    }
  }
}

Frontend (Page.vue):

<template>
  <BasicPageWrapper>
    <style v-if="hasCustomStyles" scoped>
      #page-area {
        font-family: var(--page-font-family, Inter);
        font-size: var(--page-body-size, 16px);
      }
      h1 {
        font-size: var(--page-title-size, 2rem);
      }
      .page-container {
        padding-top: var(--page-content-padding-top, 40px);
        padding-bottom: var(--page-content-padding-bottom, 40px);
        max-width: var(--page-max-width, 1280px);
        background-color: var(--page-background, #FFFFFF);
        color: var(--page-text-color, #0F172A);
      }
    </style>

    <div class="page-container">
      <!-- ... -->
    </div>
  </BasicPageWrapper>
</template>

<script>
export default {
  computed: {
    cssVariables() {
      return this.settings?.visual?.css_variables || {};
    },
    hasCustomStyles() {
      return Object.keys(this.cssVariables).length > 0;
    },
  },
};
</script>

Плюсы:

  • Гибкость — можно задать любое CSS-свойство
  • Каскадное применение — переменные наследуются вниз
  • Не нужно менять все Builder-блоки (применяются глобально)
  • Легко реализовать в админке (ключ-значение форма)

Минусы:

  • Требует знания CSS у контент-менеджеров (или ограниченный набор в UI)
  • Могут быть конфликты с Tailwind классами
  • Сложнее валидировать значения

Решение 3: Page Themes / Templates System

Концепция: Создать систему тем — предустановленных наборов стилей.

Структура данных:

Таблица page_themes:

id | name | description | styles (JSON) | is_active
1  | Default | Стандартная тема | {...} | true
2  | Compact | Компактная | {...} | false
3  | Spacious | Просторная | {...} | false

pages таблица — новые колонки:

theme_id (FK -> page_themes)
custom_overrides (JSON)  индивидуальные переопределения

Пример styles в теме:

{
  "typography": {
    "font_family": "Inter",
    "title_sizes": { "h1": "3xl", "h2": "2xl", "h3": "xl" },
    "body_size": "base",
    "line_height": "relaxed"
  },
  "spacing": {
    "container": { "max_width": "screen-xl", "px": "4", "py": "10" },
    "content_gap": "space-y-5"
  },
  "colors": {
    "background": "white",
    "text": "gray-900",
    "accent": "primary"
  },
  "borders": {
    "radius": "rounded-lg",
    "shadow": "shadow-sm"
  }
}

Filament админка:

  • CRUD для PageTheme (создание/редактирование тем)
  • В PageForm: Select::make('theme_id')->relationship('theme', 'name')
  • Опционально: overrides для конкретной страницы

Frontend:

<script>
export default {
  computed: {
    theme() {
      return this.page.data.theme?.styles || {};
    },
    overrides() {
      return this.page.data.custom_overrides || {};
    },
    mergedSettings() {
      return _.merge({}, this.theme, this.overrides);
    },
  },
};
</script>

Плюсы:

  • Масштабируемость — одна тема применяется к множеству страниц
  • Безопасность — админ не сломает стили (выбирает из готовых)
  • A/B тестирование — легко менять темы
  • Разделение ответственности: дизайнер создаёт темы, контент-менеджер выбирает

Минусы:

  • Сложность реализации — новая таблица, CRUD, UI для тем
  • Требует больше времени на разработку
  • Нужно менять PageResource, RenderPageAction, Page.vue

Решение 4: Custom CSS per Page (Полная свобода)

Концепция: Позволить админу писать произвольный CSS для страницы.

Структура данных:

{
  "visual": {
    "custom_css": "#page-area h1 { font-size: 2.5rem; color: #2D4191; }\n#page-area p { line-height: 1.8; }"
  }
}

Frontend:

<template>
  <BasicPageWrapper>
    <style v-if="settings?.visual?.custom_css" v-html="settings.visual.custom_css" />
    <!-- ... -->
  </BasicPageWrapper>
</template>

Filament Form:

Textarea::make('visual.custom_css')
    ->label('Custom CSS')
    ->rows(10)
    ->monospace()
    ->helperText('Произвольный CSS для страницы. Используйте с осторожностью!')

Плюсы:

  • Полная гибкость — можно сделать что угодно
  • Минимальная реализация (одно поле + <style> тег)

Минусы:

  • Опасно — можно сломать layout, нужны ограничения
  • Требует знаний CSS у админа
  • Сложно валидировать
  • Потенциальные XSS (нужен sanitize)
  • Не рекомендуется для не-технических пользователей

🏆 Рекомендация (Консенсус комитета)

Используй комбинацию Решений 1 + 2:

  1. Основное: Расширение settings.visual с типизированными настройками (шрифты, отступы, цвета через select/dropdown)
  2. Дополнительное: CSS Custom Variables для тонкой настройки (для продвинутых пользователей)

Почему:

  • Покрывает 95% use cases (типичные настройки визуала)
  • Безопасно (админ выбирает из пресетов, не пишет CSS)
  • CSS variables дают гибкость для edge cases
  • Минимальные изменения в архитектуре (используем существующий settings)
  • Работает для обоих типов страниц (нужно только передавать settings в Inertia для hardcoded страниц)

📋 План реализации

Шаг 1: Backend — Filament Form

Файл: app/Filament/Components/Forms/PageForm.php

Добавить в Tab 'Дополнительные настройки' новую секцию:

Section::make('Визуальные настройки')
    ->description('Настройка типографики, отступов и цветов страницы')
    ->collapsible()
    ->schema([
        // Типографика
        Select::make('visual.typography.font_family')
            ->label('Шрифт')
            ->options([
                'Inter' => 'Inter (по умолчанию)',
                'Roboto' => 'Roboto',
                'Open Sans' => 'Open Sans',
                'Montserrat' => 'Montserrat',
            ])
            ->default('Inter')
            ->columnSpan(1),
        
        Select::make('visual.typography.title_size')
            ->label('Размер заголовка H1')
            ->options([
                'xl' => 'XL (маленький)',
                '2xl' => '2XL (стандарт)',
                '3xl' => '3XL (большой)',
                '4xl' => '4XL (очень большой)',
            ])
            ->default('2xl')
            ->columnSpan(1),
        
        Select::make('visual.typography.body_size')
            ->label('Размер текста')
            ->options([
                'sm' => 'SM (маленький)',
                'base' => 'Base (стандарт)',
                'lg' => 'LG (большой)',
            ])
            ->default('base')
            ->columnSpan(1),
        
        Select::make('visual.typography.line_height')
            ->label('Межстрочный интервал')
            ->options([
                'tight' => 'Tight (плотный)',
                'normal' => 'Normal (стандарт)',
                'relaxed' => 'Relaxed (просторный)',
            ])
            ->default('normal')
            ->columnSpan(1),
        
        // Отступы
        Select::make('visual.spacing.container_padding_top')
            ->label('Отступ сверху')
            ->options([
                '0' => '0',
                '4' => '16px',
                '6' => '24px',
                '10' => '40px',
                '16' => '64px',
            ])
            ->default('10')
            ->columnSpan(1),
        
        Select::make('visual.spacing.container_padding_bottom')
            ->label('Отступ снизу')
            ->options([
                '0' => '0',
                '4' => '16px',
                '6' => '24px',
                '10' => '40px',
                '16' => '64px',
            ])
            ->default('10')
            ->columnSpan(1),
        
        Select::make('visual.spacing.content_gap')
            ->label('Расстояние между блоками')
            ->options([
                '2' => '8px',
                '5' => '20px (стандарт)',
                '8' => '32px',
                '10' => '40px',
            ])
            ->default('5')
            ->columnSpan(1),
        
        // Layout
        Select::make('visual.layout.max_width')
            ->label('Максимальная ширина контента')
            ->options([
                'screen-md' => '768px',
                'screen-lg' => '1024px',
                'screen-xl' => '1280px (стандарт)',
                'screen-2xl' => '1536px',
                'full' => '100%',
            ])
            ->default('screen-xl')
            ->columnSpan(1),
    ])
    ->columns(4),

Шаг 2: Frontend — Page.vue

Файл: resources/js/Pages/Page.vue

Изменения:

  1. Добавить computed properties для визуальных настроек
  2. Применить классы к контейнеру
  3. Передать настройки в BasicTitle и Builder
<template>
  <MetaTags :seo="seo" />
  <MainPageNavBar :sections="$page.props.navigation" />

  <BasicPageWrapper>
    <div
      :class="[
        'relative mx-auto w-full px-4 md:flex md:flex-row',
        `max-w-${visualSettings.layout?.max_width || 'screen-xl'}`,
        `pt-${visualSettings.spacing?.container_padding_top || '10'}`,
        `pb-${visualSettings.spacing?.container_padding_bottom || '10'}`,
        `bg-${visualSettings.colors?.background || 'white'}`
      ]"
      :style="typographyStyle"
    >
      <PageSubSectionLinks
        v-if="!settings?.hide_page_sub_section_links"
        :sub-section-pages="subSectionPages"
        :current-section="page.data.section"
      />
      <NavigateLinks
        v-if="!settings?.hide_page_navigate_links"
        :header-navs="headerNavs"
      />
      <div class="w-full min-w-0 mt-1 max-w-6xl px-1 md:px-6">
        <div :class="`space-y-${visualSettings.spacing?.content_gap || '5'}`">
          <BaseBreadcrumbs
            v-if="!settings?.hide_breadcrumbs"
            :breadcrumbs="breadcrumbs"
          >
            <BreadcrumbsItem
              :title="breadcrumbs.page.data.title"
              :url="route('page.view', breadcrumbs.page.data.path)"
            />
          </BaseBreadcrumbs>
          
          <BasicTitle 
            :header="page.data.title" 
            :size="visualSettings.typography?.title_size || '2xl'"
          />
          
          <div id="page-area">
            <Builder 
              :blocks="page.data.content" 
              :settings="visualSettings"
            />
          </div>
        </div>
      </div>
    </div>

    <BasicFooter />
  </BasicPageWrapper>

  <!-- Floating form (без изменений) -->
</template>

<script>
export default {
  name: "Page",
  data() {
    return {
      headerNavs: (this.page?.data?.content || [])
        .filter((block) => block?.type === "heading")
        .map((block) => ({
          id: block?.data?.id,
          text: block?.data?.content,
        })),
      settings: this.page?.data?.settings || {},
    };
  },
  props: {
    page: { type: Object },
    subSectionPages: { type: Object },
    breadcrumbs: { type: Object },
    seo: { type: Object },
  },
  computed: {
    visualSettings() {
      return this.settings?.visual || {};
    },
    typographyStyle() {
      const t = this.visualSettings.typography || {};
      return {
        fontFamily: t.font_family || 'Inter',
        fontSize: this.bodySizeMap[t.body_size] || '16px',
        lineHeight: this.lineHeightMap[t.line_height] || 'normal',
      };
    },
  },
  data() {
    return {
      bodySizeMap: {
        'sm': '14px',
        'base': '16px',
        'lg': '18px',
      },
      lineHeightMap: {
        'tight': '1.25',
        'normal': '1.5',
        'relaxed': '1.75',
      },
    };
  },
  // ... components, methods (без изменений)
};
</script>

Шаг 3: Обновить Builder-блоки

Файл: resources/js/componentss/shared/builder/pageBuilder/Builder.vue

Передать settings в дочерние блоки:

<template>
  <div>
    <component
      v-for="(block, index) in blocks"
      :key="index"
      :is="blockComponents[block.type]"
      :data="block.data"
      :settings="settings"
    />
  </div>
</template>

<script>
export default {
  props: {
    blocks: { type: Array, required: true },
    settings: { type: Object, default: () => ({}) },
  },
};
</script>

Шаг 4: Обновить BasicTitle

Файл: resources/js/componentss/ui/titles/BasicTitle.vue

Добавить prop для размера:

<template>
  <div>
    <h1 :class="titleClass">{{ header }}</h1>
  </div>
</template>

<script>
export default {
  name: "BasicTitle",
  props: {
    header: { type: String, required: true },
    size: { type: String, default: '2xl' },
  },
  computed: {
    titleClass() {
      const sizeMap = {
        'xl': 'text-xl font-bold mb-5',
        '2xl': 'text-2xl font-bold mb-5 md:text-3xl',
        '3xl': 'text-3xl font-bold mb-5 md:text-4xl',
        '4xl': 'text-4xl font-bold mb-5 md:text-5xl',
      };
      return sizeMap[this.size] || sizeMap['2xl'];
    },
  },
};
</script>

Шаг 5: Hardcoded страницы

Для страниц типа Main.vue — нужно передавать settings из pages записи через Inertia.

Backend: В контроллере главной страницы найти страницу и передать settings:

$mainPage = Page::where('path', '/')->first();
return inertia('Main', [
    'posts' => $posts,
    'pageSettings' => $mainPage?->settings,
]);

Frontend: В Main.vue использовать аналогичный подход.


📝 Чеклист реализации

  • Шаг 1: Расширить PageForm.php — секция "Визуальные настройки"
  • Шаг 2: Обновить Page.vue — применение стилей из settings.visual
  • Шаг 3: Обновить Builder.vue — передача settings в блоки
  • Шаг 4: Обновить BasicTitle.vue — поддержка размера заголовка
  • Шаг 5: Обновить hardcoded страницы (Main.vue и др.) — передача settings
  • Тестирование: Проверить все комбинации настроек
  • Документация: Описать доступные настройки для контент-менеджеров

🔮 Будущие улучшения (Phase 2)

  • CSS Custom Variables для продвинутой настройки (Решение 2)
  • Система тем (Решение 3) — если потребуется масштабирование
  • Превью изменений в реальном времени (live preview в админке)
  • Наследование настроек от родительской страницы
  • Глобальные настройки сайта (дефолтная тема для всех страниц)

📚 Ссылки на ключевые файлы

Файл Путь
Page Model app/Containers/AppStructure/Models/Page.php
PageForm app/Filament/Components/Forms/PageForm.php
PageController app/Containers/AppStructure/UI/WEB/Controllers/PageController.php
RenderPageAction app/Containers/AppStructure/Actions/RenderPageAction.php
Page.vue resources/js/Pages/Page.vue
Builder.vue resources/js/componentss/shared/builder/pageBuilder/Builder.vue
BasicTitle.vue resources/js/componentss/ui/titles/BasicTitle.vue
Main.vue resources/js/Pages/Main.vue
Tailwind Config tailwind.config.js