feat (v1.0.0): initial refactor and redesign
This commit is contained in:
parent
3058aa1ab4
commit
fe9b50b30e
134 changed files with 17792 additions and 3670 deletions
283
components/device/DevicePageShell.tsx
Normal file
283
components/device/DevicePageShell.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { ArrowUpRight, Star, StarHalf, StarOff } from 'lucide-react';
|
||||
|
||||
import Link from '@/components/objects/Link';
|
||||
import type {
|
||||
DevicePageShellProps,
|
||||
DeviceStatGroup,
|
||||
StatsGridProps,
|
||||
StatItemProps,
|
||||
SectionsGridProps,
|
||||
SectionCardProps,
|
||||
SectionRowProps,
|
||||
RatingProps,
|
||||
StarState,
|
||||
} from '@/lib/types';
|
||||
import { isExternalHref, externalLinkProps } from '@/lib/utils/styles';
|
||||
import { iconSizes } from '@/lib/devices/config';
|
||||
|
||||
import DeviceHero from './DeviceHero';
|
||||
|
||||
export default function DevicePageShell({ device }: DevicePageShellProps): ReactElement {
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<DeviceHero device={device} />
|
||||
|
||||
{device.stats.length ? <StatsGrid stats={device.stats} /> : null}
|
||||
|
||||
{device.sections.length ? <SectionsGrid sections={device.sections} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsGrid({ stats }: StatsGridProps): ReactElement {
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<h2 className="text-xl font-semibold text-gray-100">At a glance</h2>
|
||||
<div className="grid gap-5 lg:grid-cols-2 xl:grid-cols-3 auto-rows-fr">
|
||||
{stats.map((group) => (
|
||||
<StatCard key={group.title} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ group }: { group: DeviceStatGroup }): ReactElement {
|
||||
const Icon = group.icon;
|
||||
|
||||
return (
|
||||
<article className="flex h-full flex-col gap-4 rounded-2xl border border-gray-800 bg-gray-900/60 p-5 backdrop-blur-sm">
|
||||
<header className="flex items-center gap-3">
|
||||
{Icon ? (
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gray-800 text-gray-300">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
) : null}
|
||||
<h3 className="text-lg font-semibold text-gray-100">{group.title}</h3>
|
||||
</header>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{group.items.map((item) => (
|
||||
<StatItem
|
||||
key={`${group.title}-${item.label ?? item.value}`}
|
||||
item={item}
|
||||
groupIcon={group.icon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ item, groupIcon }: StatItemProps): ReactElement {
|
||||
const isExternal = isExternalHref(item.href);
|
||||
const linkProps = isExternal ? externalLinkProps : {};
|
||||
const baseClasses =
|
||||
'relative overflow-hidden rounded-2xl border border-gray-800 bg-gray-900/70 px-4 py-5 text-gray-100 transition';
|
||||
const GroupIcon = groupIcon;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{GroupIcon ? (
|
||||
<GroupIcon
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -top-4 -right-4 text-gray-800/70"
|
||||
size={iconSizes.stat}
|
||||
/>
|
||||
) : null}
|
||||
{item.href && isExternal ? (
|
||||
<ArrowUpRight
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute bottom-4 right-4 z-20 text-gray-500"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative z-10 space-y-2 pr-10">
|
||||
{item.label ? (
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">{item.label}</p>
|
||||
) : null}
|
||||
<div className="text-lg font-semibold leading-snug text-gray-100">{item.value}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`${baseClasses} block hover:text-white hover:no-underline`}
|
||||
{...linkProps}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={baseClasses}>{content}</div>;
|
||||
}
|
||||
|
||||
function SectionsGrid({ sections }: SectionsGridProps): ReactElement {
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<h2 className="text-xl font-semibold text-gray-100">Deep dive</h2>
|
||||
<div className="grid gap-5 lg:grid-cols-2 xl:grid-cols-3 auto-rows-fr">
|
||||
{sections.map((section) => (
|
||||
<SectionCard key={section.id} section={section} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ section }: SectionCardProps): ReactElement {
|
||||
const Icon = section.icon;
|
||||
const shouldSpanWide =
|
||||
!!section.paragraphs?.length && (!section.rows || section.paragraphs.length > 1);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`rounded-2xl border border-gray-800 bg-gray-900/60 p-5 backdrop-blur-sm flex flex-col gap-4 ${
|
||||
shouldSpanWide ? 'lg:col-span-2 xl:col-span-2' : ''
|
||||
}`}
|
||||
>
|
||||
<header className="flex items-center gap-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gray-800 text-gray-300">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100">{section.title}</h3>
|
||||
{section.rating ? <Rating rating={section.rating} /> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{section.rows?.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{section.rows.map((row) => (
|
||||
<SectionRow key={row.label} row={row} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{section.listItems?.length ? (
|
||||
<ul className="grid gap-2 text-sm text-gray-300">
|
||||
{section.listItems.map((item) => {
|
||||
const isExternal = isExternalHref(item.href);
|
||||
const linkProps = isExternal ? externalLinkProps : {};
|
||||
return (
|
||||
<li key={item.label}>
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="relative block rounded-xl border border-gray-800 bg-gray-900/70 px-3 py-2 text-gray-100 transition hover:text-white hover:no-underline"
|
||||
{...linkProps}
|
||||
>
|
||||
<span className="block pr-10 font-medium">{item.label}</span>
|
||||
{isExternal ? (
|
||||
<ArrowUpRight
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute bottom-2.5 right-3 text-gray-500"
|
||||
/>
|
||||
) : null}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900/70 px-3 py-2 text-gray-100">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.description ? (
|
||||
<p className="mt-1 text-xs text-gray-500">{item.description}</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{section.paragraphs?.length ? (
|
||||
<div className="space-y-3 text-sm leading-relaxed text-gray-400">
|
||||
{section.paragraphs.map((paragraph) => (
|
||||
<p key={`${section.id}-${paragraph}`}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionRow({ row }: SectionRowProps): ReactElement {
|
||||
const { icon: RowIcon } = row;
|
||||
const isExternal = isExternalHref(row.href);
|
||||
const linkProps = isExternal ? externalLinkProps : {};
|
||||
const baseClasses =
|
||||
'relative overflow-hidden rounded-2xl border border-gray-800 bg-gray-900/70 px-4 py-5 text-gray-100 transition';
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{RowIcon ? (
|
||||
<RowIcon className="pointer-events-none absolute -top-4 -right-4 text-gray-800/70" size={iconSizes.section} />
|
||||
) : null}
|
||||
{row.href && isExternal ? (
|
||||
<ArrowUpRight
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute bottom-4 right-4 z-20 h-4 w-4 text-gray-500"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative z-10 space-y-2 pr-10">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">{row.label}</p>
|
||||
<div className="text-lg font-semibold leading-snug text-gray-100">{row.value}</div>
|
||||
{row.note ? <p className="text-xs text-gray-500">{row.note}</p> : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (row.href) {
|
||||
return (
|
||||
<Link
|
||||
href={row.href}
|
||||
className={`${baseClasses} block hover:text-white hover:no-underline`}
|
||||
{...linkProps}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={baseClasses}>{content}</div>;
|
||||
}
|
||||
|
||||
function Rating({ rating }: RatingProps): ReactElement {
|
||||
const stars = buildStars(rating.value, rating.scale ?? 5);
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-gray-400">
|
||||
<span className="flex items-center text-gray-200">
|
||||
{stars.map((state, idx) => {
|
||||
const key = `${rating.label ?? rating.value}-${idx}`;
|
||||
if (state === 'full') {
|
||||
return <Star key={key} className="fill-current" />;
|
||||
}
|
||||
if (state === 'half') {
|
||||
return <StarHalf key={key} className="fill-current" />;
|
||||
}
|
||||
return <StarOff key={key} className="text-gray-600" />;
|
||||
})}
|
||||
</span>
|
||||
<span className="text-gray-300">{rating.value.toFixed(1)}</span>
|
||||
{rating.label ? <span className="text-xs uppercase tracking-wide text-gray-600">{rating.label}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildStars(value: number, scale: number): StarState[] {
|
||||
const stars: StarState[] = [];
|
||||
const normalized = Math.max(0, Math.min(value, scale));
|
||||
for (let i = 1; i <= scale; i += 1) {
|
||||
if (normalized >= i) {
|
||||
stars.push('full');
|
||||
} else if (normalized > i - 1 && normalized < i) {
|
||||
stars.push('half');
|
||||
} else {
|
||||
stars.push('empty');
|
||||
}
|
||||
}
|
||||
return stars;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue