Przejdź do treści

Interfejs naszego bloga: decyzje UX dla autorów i czytelników

Komponenty Vue do ponownego użycia, filtrowanie, tryb podglądu dla autorów, responsywny układ w Tailwindzie i dostępność: wybory UX, które stoją za blogiem, i co zmieniło się po starcie.

AutorDział IT
  • 8 min czytania
  • 186 wyświetleń
Interfejs naszego bloga: decyzje UX dla autorów i czytelników

W trzech pierwszych artykułach pisałem o technicznej stronie bloga. Nawet najlepsza architektura się nie obroni, jeśli z produktu niewygodnie się korzysta. Ten tekst dotyczy interfejsu, zarówno dla autorów, jak i dla czytelników.

Filozofia projektu: prostota i funkcjonalność

Nasze podejście do UX opiera się na trzech zasadach:

  1. Minimum kliknięć. Każda akcja powinna zająć najwyżej trzy kliknięcia.

  2. Hierarchia wizualna. Ważne rzeczy mają być widoczne.

  3. Responsywność. Interfejs reaguje na działania użytkownika od razu.

Architektura komponentowa Vue.js

Zbudowaliśmy zestaw komponentów do ponownego użycia. Ten odpowiada za wielojęzyczny wybór kategorii:

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">
          <!-- Wyszukiwanie -->
          <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>
          
          <!-- Lista opcji -->
          <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>();

// Autofokus na pole wyszukiwania przy otwarciu
watch(isOpen, async (newValue) => {
  if (newValue) {
    await nextTick();
    searchInput.value?.focus();
  } else {
    searchQuery.value = '';
  }
});

// Filtrowanie z uwzględnieniem bieżącego języka
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>

Filtrowanie

Filtrowanie było jednym z trudniejszych zadań. Musiało działać szybko i być zrozumiałe bez instrukcji:

vue

<template>
  <div class="flex flex-col p-5 gap-y-4">
    <!-- Główne wyszukiwanie -->
    <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>
      
      <!-- Przycisk zaawansowanych filtrów -->
      <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>
    
    <!-- Aktywne filtry -->
    <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 wyszukiwanie dla optymalizacji
const debouncedSearch = debounce(() => {
  performSearch();
}, 500);

// Śledzenie zmian
watch(() => form.search, (newSearch) => {
  if (newSearch.length >= 3 || newSearch.length === 0) {
    debouncedSearch();
  }
});

// Natychmiastowe zastosowanie filtrów
watch(() => [form.status, form.author_id, form.category_id], () => {
  performSearch();
});

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

// Wizualizacja aktywnych filtrów
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>

Tryb podglądu dla autorów

Autor musi zobaczyć, jak wpis będzie wyglądał przed publikacją, dlatego zbudowaliśmy osobny tryb podglądu:

php

// Route dla podglądu
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();
        
        // Sprawdzenie uprawnień dostępu
        if (!auth()->user()->can('preview', $post)) {
            abort(403);
        }
        
        // Specjalna flaga dla szablonu
        $isPreview = true;
        
        return view('blog.show', compact('post', 'content', 'isPreview'));
    }
}

Komponent frontendowy ze wskaźnikiem podglądu:

vue

<template>
  <div class="relative">
    <!-- Wskaźnik podglądu -->
    <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>
    
    <!-- Treść postu -->
    <article :class="{ 'pt-12': isPreview }">
      <!-- Zwykła treść -->
    </article>
  </div>
</template>

Responsywny layout w Tailwind CSS

Układ strony oparliśmy na podejściu utility-first z Tailwinda:

vue

<template>
  <div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
    <!-- Główna treść -->
    <div class="lg:col-span-8">
      <div class="bg-white dark:bg-darkmode-700 rounded-lg shadow-sm p-4 md:p-6">
        <!-- Mobilna wersja nagłówka -->
        <div class="lg:hidden mb-4">
          <h1 class="text-2xl font-bold">{{ post.title }}</h1>
        </div>
        
        <!-- Wersja desktopowa -->
        <div class="hidden lg:block">
          <h1 class="text-4xl font-bold mb-6">{{ post.title }}</h1>
        </div>
        
        <!-- Treść -->
        <div class="prose prose-sm md:prose-base lg:prose-lg max-w-none">
          <div v-html="post.content_html"></div>
        </div>
      </div>
    </div>
    
    <!-- Sidebar -->
    <aside class="lg:col-span-4">
      <!-- Wersja mobilna - zwijana -->
      <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>
      
      <!-- Wersja desktopowa - zawsze widoczna -->
      <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);

// Automatycznie otwieramy sidebar na tabletach
watch(isMd, (newValue) => {
  if (newValue) {
    sidebarOpen.value = true;
  }
});
</script>

Composables do ponownego użycia logiki

Vue 3 Composition API pozwoliło nam wydzielić wspólną logikę do 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
  };
}

// Użycie w komponencie
const { target, isLoading } = useInfiniteScroll(async () => {
  const nextPage = posts.value.current_page + 1;
  const response = await loadPosts(nextPage);
  posts.value.data.push(...response.data);
});

Elementy interaktywne

Dodaliśmy drobne animacje, żeby interfejs sprawiał wrażenie żywszego:

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'
      ]"
    />
    
    <!-- Animacja przy kliknięciu -->
    <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>

Dostępność (a11y)

Dostępności poświęciliśmy szczególną uwagę:

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>

Optymistyczne aktualizacje

Żeby interfejs sprawiał wrażenie szybszego, używamy optymistycznych aktualizacji:

typescript

const updatePost = async (data: UpdateData) => {
  // Optymistyczna aktualizacja 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) {
    // Wycofanie przy błędzie
    post.value = oldData;
    toast.error($trans('main.blog.update_failed'));
  }
};

Wyniki

Po uruchomieniu zaktualizowanego interfejsu zaobserwowaliśmy:

  • Współczynnik odrzuceń (bounce rate) niższy o 35%

  • Czas spędzony na stronie dłuższy o 45%

  • Konwersję na subskrybentów wyższą o 60%

  • NPS autorów wzrósł z 6 do 9

Czego się nauczyliśmy

  1. Spójność wygrywa z oryginalnością. Użytkownicy cenią przewidywalność.

  2. Drobne interakcje budują poczucie jakości. Szczegóły mają znaczenie.

  3. Wydajność to część UX. Szybki interfejs jest odbierany jako wygodny.

  4. Dostępność poszerza grono odbiorców. Nie zapominajmy o użytkownikach z ograniczeniami.

Wnioski

Dobry UX to mniej ładne przyciski, a bardziej pomoc w załatwieniu sprawy jak najmniejszym wysiłkiem. Interfejs, który powstał:

  • Działa na wszystkich urządzeniach

  • Daje natychmiastową informację zwrotną

  • Przewiduje potrzeby użytkownika

  • Jest dostępny dla wszystkich

W ostatnim artykule napiszę o bezpieczeństwie i SEO: jak zabezpieczyliśmy system i sprawiliśmy, że treść jest widoczna dla wyszukiwarek.

Ciąg dalszy nastąpi.

Tagi

Udostępnij ten artykuł

Wróć do bloga

Pliki cookies

Używamy niezbędnych plików cookies i jednego funkcjonalnego, tego tylko za Twoją zgodą. Statystyki odwiedzin liczymy na serwerze, bez cookies. Polityka cookies