UIとUXの目標
公開された投稿のすべてのカテゴリを表示する概要ページでの私の主な目標は、実際に利用可能なカテゴリの発見可能性を高めることでした。また、この変更は、ページの視覚的な美しさを向上させ、UXをこのプログレッシブウェブアプリの他のページに合わせる機会と見なしました。
次のスクリーンショットは、再設計前のページの外観を示しています。
何を変えなければならなかったのか
古い実装には2つの大きな問題がありました。まず、レイアウトが実際には無効でした。要素が5つ未満のカテゴリでは、利用可能なプレビュー後のカバー画像が幅全体に収まるように拡大縮小されたためです。次に、ページの上部にプライマリアクションバーが表示されていませんでした。ユーザーに主要なナビゲーション要素を提供するため、確実にレンダリングされるはずです。
さらに、カテゴリの概要ページは、実際にはカテゴリを表示するのに優れた役割を果たしていなかったことが明らかになりました。代わりに、ユーザーは主に各カテゴリに関連する投稿のカバー画像を見ました。もう一度見ると、アイコンの付いた行と各カテゴリのタイトルが認識されます。見た目に美しくないことは別として、このUIは単にひどく設計されていました。
間違いを訂正する
これは私の個人的なWebアプリであるため、カテゴリの概要ページで最初に物事を台無しにしたのも私でした。そのため、言い訳をすることなく、私は座ってページ全体を作り直しました。
新しいバージョンでは、1列の行が表示され、各アイテムにはカテゴリの大きなプレビュー画像が表示されます。カスタム画像は、許可された幅全体を取ります。両方の軸の中央にあるカテゴリタイトルは中央にあり、すぐに表示して認識できるようになっています。読みやすさを向上させるために、テキストの下に黒から透明への微妙なグラデーションを追加しました。
画像とテキストにカーソルを合わせると、追加の情報テキストが表示され、このカテゴリの投稿の総数が表示されます。これは、以前は利用できなかった情報です。
ナビゲーションの問題を修正するために、ユーザーが利用できるすべてのメインルートで既存のアクションバーも再利用しました。
反復による改善
より快適なユーザーエクスペリエンスを提供するために、カテゴリ行にカーソルを合わせると、画像に微妙なスケーリングアニメーションも追加しました。画像が元の境界内で拡大縮小されるため、ビューが不必要に拡大することはありません。
さらに、カテゴリ画像の下にある新しい行にカーソルを合わせると、アニメーションでその行が表示されます。この行には、カテゴリの最新の投稿の選択が表示されます。各ポストプレビューは、投稿へのリンクです。カテゴリ行の他のすべてのインタラクティブ要素は、カテゴリの詳細ページを開きます。
実際よりも多くのコンテンツの錯覚を与えるために、Daisy-UIの「スタック」クラスを使用して、各ポストプレビューに偽のスタックを与え、現在のものの下にあるより多くのプレビューアイテムの印象を暗示しました。
小さなデバイスでは、プレビューはレンダリングされません。ユーザーは大きなカテゴリ画像をクリックするだけで、すべての投稿が一覧表示されている詳細ページに移動できます。
レビューと展望
ご覧のとおり、要素の概要を提供するだけでよい単純なページでさえ、視覚的エラーと概念的エラーの両方の原因となる可能性があります。
ユーザーがテキスト入力でフィルタリングするためのローカルテキスト検索を追加しませんでした。今のところ、スクロールとホバリングによってページの探索を促進するつもりです。これが将来問題になる場合(たとえば、ユーザーが探しているコンテンツを見つけることができないため)、いくつかのフィルタリングオプションを追加します。投稿数やカテゴリが増え続けるにつれて、これも必要になる可能性があります。もちろん、ぜひこのページをお試しください。
ソースコード
次のテキストには、ページコンテナとカテゴリグループの両方の完全な未変更のコードが含まれています。すべてがReact.jsとTailwind.cssを使用してTypescriptで記述されています。一部の部品を再利用できるとお考えの場合は、お手伝いさせていただきます。
//
// Page container.
//
import React from "react";
import { CMSCategoryResolution } from "../../cms/entities/cms.entity.catgory";
import Reveal from "../Reveal/Reveal";
import { BogPostCategoryPostsGroup } from "./BlogPostCategoryPostsGroup";
import BlogPostCategoriesOverviewHero from "./BlogPostCategoriesOverviewHero";
import PrimaryActionBar from "../PrimaryActionsBar/PrimaryActionsBar";
/*
*
* Interfaces.
*
*/
interface Props {
categories: CMSCategoryResolution[];
}
/*
*
* Components.
*
*/
export default function BlogPostCategoriesOverview(props: Props) {
const { categories } = props;
return (
<div className="max-w-6xl mx-auto">
<BlogPostCategoriesOverviewHero />
<div className="mt-10 mb-20">
<PrimaryActionBar isCentered color="white" />
</div>
<div className="px-6 mx-auto space-y-6 lg:px-0 md:space-y-16 lg:space-y-20">
{categories.map((c, i) => (
<Reveal key={i}>
<BogPostCategoryPostsGroup category={c} maxPostsEachThreshold={20} />
</Reveal>
))}
</div>
</div>
);
}
//
// BogPostCategoryPostsGroup.
//
import React from "react";
import { CMSCategoryResolution } from "../../cms/entities/cms.entity.catgory";
import { FLPath } from "../../models/route/model.route";
import { CMSCategoryIconFactory } from "../CMS/CMSCategoryIcon";
import Link from "next/link";
import RiMore from "remixicon-react/MoreLineIcon";
/*
*
* Interfaces.
*
*/
interface Props {
category: CMSCategoryResolution;
maxPostsEachThreshold: number;
}
/*
*
* Components.
*
*/
export const BogPostCategoryPostsGroup = (props: Props) => {
const { category, maxPostsEachThreshold } = props;
const { posts } = category;
if (posts.length === 0) {
return null;
}
const url = `${FLPath.Categories}/${category.slug.current}`;
const Icon = CMSCategoryIconFactory({ variant: category.slug.current });
return (
<div className="flex flex-col items-center group">
<div className="relative max-w-4xl overflow-visible rounded-md">
<div className="absolute items-end justify-start hidden w-full h-full px-6 space-x-4 overflow-x-scroll duration-500 ease-in-out translate-y-12 opacity-0 md:flex pb-14 group-hover:opacity-100 group-hover:translate-y-20">
{posts.slice(0, 4).map((p, i) => (
<span className="stack">
<Link
href={`${FLPath.Posts}/${p.slug.current}`}
aria-label={p.title}
prefetch={false}>
<a className="duration-200 ease-out hover:scale-105">
<img
src={p.imageUrl.jpg}
alt={p.title}
loading="lazy"
className="object-contain h-20 rounded "
/>
</a>
</Link>
{Array.from({ length: 2 }, (_, i) => (
<span key={i} style={{ width: 152, height: 80 }} className="bg-gray-800 rounded" />
))}
</span>
))}
{posts.length >= 5 && (
<Link passHref href={url} aria-label={`Link to ${category.title}`} prefetch={false}>
<a className="flex flex-col items-center justify-center h-20 w-14">
<RiMore />
</a>
</Link>
)}
</div>
<div className="relative flex flex-col items-center overflow-hidden duration-500 ease-in-out rounded-md shadow-xl md:group-hover:-translate-y-20">
<img
className="object-cover w-full duration-500 ease-in-out group-hover:scale-105"
width={960}
height={200}
src={category.imageUrl.webp}
alt={category.title}
loading="lazy"
/>
<Link passHref href={url} aria-label={`Link to ${category.title}`} prefetch={false}>
<a className="absolute flex flex-col items-center justify-center w-full h-full bg-gradient-to-t from-primary">
<span className="flex items-center space-x-2 duration-300 ease-in-out translate-y-2 group-hover:-translate-y-3">
<Icon size="2em" />
<h2 className="text-sm md:text-xl">{category.title}</h2>
</span>
<span className="text-sm font-light text-gray-200 transition duration-500 ease-in-out translate-y-2 opacity-0 group-hover:opacity-100 group-hover:-translate-y-2">
{category.posts.length}
{category.posts.length === maxPostsEachThreshold ? "+" : ""}{" "}
{category.posts.length === 1 ? "post" : "posts"}
</span>
</a>
</Link>
</div>
</div>
</div>
);
};