Перейти до вмісту

Інтерфейс нашого блогу: рішення UX для авторів і читачів

Компоненти Vue для повторного використання, фільтрація, режим попереднього перегляду для авторів, адаптивна верстка на Tailwind і доступність: вибір UX, що стоїть за блогом, і що змінилося після запуску.

АвторВідділ ІТ
  • 8 хв читання
  • 186 переглядів
Інтерфейс нашого блогу: рішення UX для авторів і читачів

У перших трьох статтях я писав про технічний бік блогу. Навіть найкраща архітектура не врятує, якщо продуктом незручно користуватися. Ця стаття про інтерфейс, і для авторів, і для читачів.

Філософія проєкту: простота і функціональність

Наш підхід до UX тримається на трьох принципах:

  1. Мінімум кліків. Будь-яка дія має займати щонайбільше три кліки.

  2. Візуальна ієрархія. Важливе має бути помітним.

  3. Відгукливість. Інтерфейс реагує на дії користувача одразу.

Компонентна архітектура Vue.js

Ми побудували набір компонентів для повторного використання. Цей відповідає за мультимовний вибір категорій:

vue

<template>
  <div class="space-y-2">
    <div class="flex mb-3 content-center">
      <FormLabel class="block !mb-0 font-medium text-slate-700 dark:text-slate-300">
        {{ props.label }}
      </FormLabel>
    </div>
    <div class="relative multiselect-container">
      <button 
        type="button" 
        @click="isOpen = !isOpen" 
        class="relative w-full min-h-[42px] px-3 py-2 text-left 
               bg-white dark:bg-darkmode-700 border rounded-md shadow-sm"
        :class="{ 
          'ring-4 ring-primary ring-opacity-20': isOpen,
          'border-red-300': error 
        }"
      >
        <div class="flex items-center justify-between">
          <div class="flex-1 min-w-0">
            <div v-if="selectedValues.length === 0" class="text-slate-400/90">
              {{ $trans('main.shared.select') }}
            </div>
            <div v-else class="flex flex-wrap gap-1">
              <span 
                v-for="(categoryId, index) in displayedValues" 
                :key="`${categoryId}-${index}`"
                class="inline-flex items-center gap-1 px-2 py-1 text-xs 
                       font-medium bg-primary/10 text-primary rounded-md"
              >
                {{ getCategoryName(categoryId) }}
                <button 
                  type="button" 
                  @click.stop="removeCategory(categoryId)"
                  class="ml-1 h-3 w-3 hover:bg-primary/20 rounded-sm"
                >
                  <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path d="M6 18L18 6M6 6l12 12" />
                  </svg>
                </button>
              </span>
              <span v-if="hiddenCount > 0" class="inline-flex items-center px-2 py-1 text-xs">
                +{{ hiddenCount }} {{ $trans('main.shared.more') }}
              </span>
            </div>
          </div>
          <svg 
            class="h-5 w-5 text-slate-400 transition-transform duration-200" 
            :class="{ 'rotate-180': isOpen }"
          >
            <path d="M19 9l-7 7-7-7" />
          </svg>
        </div>
      </button>
      
      <Transition 
        enter-active-class="transition duration-200 ease-out"
        enter-from-class="transform scale-95 opacity-0"
        enter-to-class="transform scale-100 opacity-100"
      >
        <div v-if="isOpen" class="absolute z-50 w-full mt-1 bg-white rounded-md shadow-lg">
          <!-- Пошук -->
          <div class="p-3 border-b">
            <input
              ref="searchInput"
              v-model="searchQuery"
              type="text"
              :placeholder="$trans('main.shared.search')"
              class="w-full pl-9 pr-3 py-2 text-sm border rounded-md"
            />
          </div>
          
          <!-- Список опцій -->
          <div class="max-h-48 overflow-y-auto">
            <label 
              v-for="category in filteredLabels" 
              :key="category.id"
              class="flex items-center px-3 py-2 hover:bg-slate-50 cursor-pointer"
            >
              <input
                type="checkbox"
                :checked="isSelected(category.id)"
                @change="toggleSelection(category.id)"
                class="h-4 w-4 rounded"
              />
              <span class="ml-3 text-sm font-medium">
                {{ category.name }}
              </span>
            </label>
          </div>
        </div>
      </Transition>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue';

const props = defineProps<{
  label: string;
  modelValue: number[];
  availableLabels: Label[];
  error?: string;
  maxVisible?: number;
}>();

const emit = defineEmits<{
  (e: 'update:modelValue', value: number[]): void;
}>();

const isOpen = ref(false);
const searchQuery = ref('');
const searchInput = ref<HTMLInputElement>();

// Автофокус на поле пошуку при відкритті
watch(isOpen, async (newValue) => {
  if (newValue) {
    await nextTick();
    searchInput.value?.focus();
  } else {
    searchQuery.value = '';
  }
});

// Фільтрація з урахуванням поточної мови
const filteredLabels = computed(() => {
  if (!searchQuery.value) return props.availableLabels;
  
  const query = searchQuery.value.toLowerCase();
  return props.availableLabels.filter(label => 
    label.name.toLowerCase().includes(query)
  );
});
</script>

Фільтрація

Фільтрація була одним із найскладніших завдань. Вона мала працювати швидко і бути зрозумілою без пояснень:

vue

<template>
  <div class="flex flex-col p-5 gap-y-4">
    <!-- Основний пошук -->
    <div class="flex gap-2">
      <div class="relative flex-1">
        <Lucide icon="Search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" />
        <FormInput
          v-model="form.search"
          type="text"
          :placeholder="$trans('main.shared.search')"
          class="pl-9"
        />
      </div>
      
      <!-- Кнопка розширених фільтрів -->
      <Button 
        @click="showAdvancedFilters = !showAdvancedFilters"
        variant="outline-secondary"
        :class="{ 'bg-slate-100': showAdvancedFilters || hasActiveFilters }"
      >
        <Lucide icon="Filter" class="w-4 h-4 mr-2" />
        {{ $trans('main.shared.buttons.filters') }}
        <div v-if="activeFiltersCount > 0" class="ml-2 px-1.5 py-0.5 bg-primary text-white text-xs rounded-full">
          {{ activeFiltersCount }}
        </div>
      </Button>
    </div>
    
    <!-- Активні фільтри -->
    <div v-if="hasActiveFilters" class="flex flex-wrap gap-2">
      <span 
        v-for="filter in activeFilterTags" 
        :key="filter.id"
        class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium"
        :class="filter.colorClass"
      >
        {{ filter.label }}: {{ filter.value }}
        <button @click="removeFilter(filter.type)" class="ml-2 p-0.5">
          <Lucide icon="X" class="w-3 h-3" />
        </button>
      </span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { reactive, computed, watch } from 'vue';
import { debounce } from 'lodash';
import { router } from '@inertiajs/vue3';

const form = reactive({
  search: filters?.search || '',
  status: filters?.status || '',
  author_id: filters?.author_id || '',
  category_id: filters?.category_id || '',
  tag_ids: filters?.tag_ids || []
});

// Debounced пошук для оптимізації
const debouncedSearch = debounce(() => {
  performSearch();
}, 500);

// Відстеження змін
watch(() => form.search, (newSearch) => {
  if (newSearch.length >= 3 || newSearch.length === 0) {
    debouncedSearch();
  }
});

// Миттєве застосування фільтрів
watch(() => [form.status, form.author_id, form.category_id], () => {
  performSearch();
});

const performSearch = () => {
  router.get(route('posts'), form, {
    preserveState: true,
    preserveScroll: true,
  });
};

// Візуалізація активних фільтрів
const activeFilterTags = computed(() => {
  const tags = [];
  
  if (form.search) {
    tags.push({
      id: 'search',
      type: 'search',
      label: $trans('main.shared.search'),
      value: `"${form.search}"`,
      colorClass: 'bg-blue-100 text-blue-800'
    });
  }
  
  if (form.status) {
    tags.push({
      id: 'status',
      type: 'status',
      label: $trans('main.blog.posts.status'),
      value: $trans(`main.blog.posts.statuses.${form.status}`),
      colorClass: 'bg-green-100 text-green-800'
    });
  }
  
  return tags;
});
</script>

Режим попереднього перегляду для авторів

Автор має побачити, як виглядатиме пост перед публікацією, тому ми зробили окремий режим перегляду:

php

// Route для preview
Route::get('/blog/{language}/{slug}/preview', [PreviewController::class, 'show'])
    ->name('blog.preview')
    ->middleware(['auth', 'can:preview,post']);

// PreviewController
class PreviewController extends Controller
{
    public function show(string $language, string $slug)
    {
        $content = Content::where('slug', $slug)
            ->where('language', $language)
            ->firstOrFail();
        
        $post = $content->post()
            ->with(['author', 'categories', 'tags'])
            ->first();
        
        // Перевірка прав доступу
        if (!auth()->user()->can('preview', $post)) {
            abort(403);
        }
        
        // Спеціальний флаг для шаблону
        $isPreview = true;
        
        return view('blog.show', compact('post', 'content', 'isPreview'));
    }
}

Фронтенд-компонент з індикатором перегляду:

vue

<template>
  <div class="relative">
    <!-- Preview індикатор -->
    <div v-if="isPreview" class="fixed top-0 left-0 right-0 z-50 bg-yellow-500 text-white">
      <div class="container mx-auto px-4 py-2 flex items-center justify-between">
        <div class="flex items-center gap-2">
          <Lucide icon="Eye" class="w-4 h-4" />
          <span class="font-medium">{{ $trans('main.blog.preview_mode') }}</span>
        </div>
        <Button 
          @click="exitPreview" 
          variant="white" 
          size="sm"
        >
          {{ $trans('main.blog.exit_preview') }}
        </Button>
      </div>
    </div>
    
    <!-- Контент поста -->
    <article :class="{ 'pt-12': isPreview }">
      <!-- Звичайний контент -->
    </article>
  </div>
</template>

Адаптивна верстка на Tailwind CSS

Розмітку сторінки ми збудували на utility-first підході Tailwind:

vue

<template>
  <div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
    <!-- Основний контент -->
    <div class="lg:col-span-8">
      <div class="bg-white dark:bg-darkmode-700 rounded-lg shadow-sm p-4 md:p-6">
        <!-- Мобільна версія заголовка -->
        <div class="lg:hidden mb-4">
          <h1 class="text-2xl font-bold">{{ post.title }}</h1>
        </div>
        
        <!-- Десктопна версія -->
        <div class="hidden lg:block">
          <h1 class="text-4xl font-bold mb-6">{{ post.title }}</h1>
        </div>
        
        <!-- Контент -->
        <div class="prose prose-sm md:prose-base lg:prose-lg max-w-none">
          <div v-html="post.content_html"></div>
        </div>
      </div>
    </div>
    
    <!-- Сайдбар -->
    <aside class="lg:col-span-4">
      <!-- Мобільна версія - згортається -->
      <div class="lg:hidden">
        <button 
          @click="sidebarOpen = !sidebarOpen"
          class="w-full flex items-center justify-between p-4 bg-white rounded-lg"
        >
          <span>{{ $trans('main.blog.more_info') }}</span>
          <Lucide :icon="sidebarOpen ? 'ChevronUp' : 'ChevronDown'" />
        </button>
        
        <Transition>
          <div v-if="sidebarOpen" class="mt-2">
            <SidebarContent />
          </div>
        </Transition>
      </div>
      
      <!-- Десктопна версія - завжди видима -->
      <div class="hidden lg:block sticky top-24">
        <SidebarContent />
      </div>
    </aside>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { useBreakpoints } from '@/mixins';

const { isMd, isLg } = useBreakpoints();
const sidebarOpen = ref(false);

// Автоматично відкриваємо сайдбар на планшетах
watch(isMd, (newValue) => {
  if (newValue) {
    sidebarOpen.value = true;
  }
});
</script>

Composables для повторного використання логіки

Vue 3 Composition API дозволив винести спільну логіку в composables:

typescript

// composables/useInfiniteScroll.ts
export function useInfiniteScroll(
  callback: () => void,
  options: { threshold?: number; rootMargin?: string } = {}
) {
  const target = ref<HTMLElement>();
  const isLoading = ref(false);
  
  const observer = new IntersectionObserver(
    async ([entry]) => {
      if (entry.isIntersecting && !isLoading.value) {
        isLoading.value = true;
        await callback();
        isLoading.value = false;
      }
    },
    {
      threshold: options.threshold || 0.1,
      rootMargin: options.rootMargin || '100px'
    }
  );
  
  onMounted(() => {
    if (target.value) {
      observer.observe(target.value);
    }
  });
  
  onUnmounted(() => {
    observer.disconnect();
  });
  
  return {
    target,
    isLoading
  };
}

// Використання в компоненті
const { target, isLoading } = useInfiniteScroll(async () => {
  const nextPage = posts.value.current_page + 1;
  const response = await loadPosts(nextPage);
  posts.value.data.push(...response.data);
});

Інтерактивні елементи

Ми додали дрібні анімації, щоб інтерфейс здавався жвавішим:

vue

<template>
  <button
    @click="toggleLike"
    class="group relative p-2 rounded-full transition-all duration-200"
    :class="{ 'bg-red-50': isLiked }"
  >
    <Heart
      :class="[
        'w-5 h-5 transition-all duration-300',
        isLiked 
          ? 'fill-red-500 text-red-500 scale-110' 
          : 'text-gray-400 group-hover:text-red-400'
      ]"
    />
    
    <!-- Анімація при кліку -->
    <Transition
      enter-active-class="transition-all duration-300"
      enter-from-class="scale-0 opacity-0"
      enter-to-class="scale-150 opacity-0"
    >
      <div 
        v-if="showAnimation" 
        class="absolute inset-0 rounded-full bg-red-400"
      />
    </Transition>
  </button>
</template>

<script setup>
const isLiked = ref(false);
const showAnimation = ref(false);

const toggleLike = () => {
  isLiked.value = !isLiked.value;
  
  if (isLiked.value) {
    showAnimation.value = true;
    setTimeout(() => {
      showAnimation.value = false;
    }, 300);
  }
};
</script>

Доступність (a11y)

Доступності ми приділили особливу увагу:

vue

<template>
  <nav role="navigation" aria-label="Pagination">
    <ul class="flex items-center gap-1">
      <li>
        <button
          @click="goToPage(currentPage - 1)"
          :disabled="currentPage === 1"
          :aria-label="$trans('pagination.previous')"
          class="p-2 rounded-md hover:bg-gray-100 disabled:opacity-50"
        >
          <ChevronLeft class="w-5 h-5" />
        </button>
      </li>
      
      <li v-for="page in visiblePages" :key="page">
        <button
          @click="goToPage(page)"
          :aria-current="page === currentPage ? 'page' : undefined"
          :aria-label="`${$trans('pagination.go_to_page')} ${page}`"
          class="px-3 py-1 rounded-md"
          :class="{ 
            'bg-primary text-white': page === currentPage,
            'hover:bg-gray-100': page !== currentPage 
          }"
        >
          {{ page }}
        </button>
      </li>
    </ul>
  </nav>
</template>

Оптимістичні оновлення

Щоб інтерфейс здавався швидшим, ми використовуємо оптимістичні оновлення:

typescript

const updatePost = async (data: UpdateData) => {
  // Оптимістичне оновлення UI
  const oldData = { ...post.value };
  post.value = { ...post.value, ...data };
  
  try {
    await api.updatePost(post.value.id, data);
    toast.success($trans('main.blog.post_updated'));
  } catch (error) {
    // Відкат при помилці
    post.value = oldData;
    toast.error($trans('main.blog.update_failed'));
  }
};

Результати

Після запуску оновленого інтерфейсу ми побачили таке:

  • Показник відмов (bounce rate) нижчий на 35%

  • Час на сайті довший на 45%

  • Конверсія в підписники вища на 60%

  • NPS авторів виріс з 6 до 9

Чого ми навчилися

  1. Послідовність перемагає оригінальність. Користувачі цінують передбачуваність.

  2. Дрібні взаємодії створюють відчуття якості. Деталі мають значення.

  3. Продуктивність є частиною UX. Швидкий інтерфейс сприймається як зручний.

  4. Доступність розширює аудиторію. Не забуваймо про користувачів з обмеженнями.

Висновки

Добрий UX менше про гарні кнопки, а більше про те, щоб допомогти людині виконати завдання з мінімумом зусиль. Інтерфейс, який у нас вийшов:

  • Працює на всіх пристроях

  • Дає миттєвий зворотний зв'язок

  • Передбачає потреби користувача

  • Доступний для всіх

У фінальній статті напишу про безпеку та SEO: як ми захистили систему і зробили контент видимим для пошукових систем.

Продовження буде.

Теги

Поділитися статтею

Яку LLM обрати для бізнесу: GPT, Claude, Gemini чи open-source

Сильні сторони GPT, Claude, Gemini та open-source моделей, підказки, коли обирати кожну, і шість кроків вибору з урахуванням вартості, безпеки даних та інтеграції.

  • 10 хв читання
  • 219 переглядів

AI-агенти в компанії: де вони окупаються, а де вистачить скрипта

Чим AI-агент відрізняється від чат-бота й RPA, у яких процесах він дає вимірювану віддачу і чому більшість пілотів не доходить до продакшну. У кінці тест із п'яти запитань перед стартом проєкту.

  • 9 хв читання
  • 202 перегляди

Чому ШІ посилює хаос у невпорядкованих компаніях

ШІ примножує все, що знаходить у компанії, зокрема безлад у даних і незадокументовані процеси. Розбираємо, чому впровадження провалюються і в якій послідовності діяти, щоб воно окупилося.

  • 9 хв читання
  • 143 перегляди
Повернутися до блогу

Файли cookies

Ми використовуємо необхідні файли cookies та один функціональний, який вмикаємо лише за вашою згодою. Статистику відвідувань рахуємо на сервері, без cookies. Політика cookies