Files
dredgenews/resources/js/components/CategorySection.vue
T
dredgy 439139a057
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Done
2026-07-04 23:09:00 +10:00

182 lines
4.4 KiB
Vue

<script setup lang="ts">
import ArticleCard from './ArticleCard.vue';
interface Story {
source: string;
time: string | null;
headline: string;
deck?: string | null;
image?: string | null;
link: string;
}
interface Category {
label: string;
hero: boolean;
items: Story[];
}
const props = withDefaults(
defineProps<{
category: Category;
index?: number;
}>(),
{
index: 0,
},
);
type Variant = 'hero' | 'banner' | 'feature' | 'standard' | 'brief';
interface Slot {
variant: Variant;
span: number;
}
// A hero category always gets the same big-lead treatment. Non-hero
// categories rotate through a few distinct shapes by position, so
// adjacent sections don't all read as the same grid: one leans on two
// medium photos, one leans on a single wide banner, one is mostly
// small thumbnails with more falling through to the text-only list.
const nonHeroLayouts: Slot[][] = [
// gallery: two medium features side by side, three small below
[
{ variant: 'feature', span: 3 },
{ variant: 'feature', span: 3 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
],
// lead: one wide banner feature, three small below
[
{ variant: 'banner', span: 6 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
],
// digest: small thumbnails only, text-heavy section
[
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
],
];
const heroLayout: Slot[] = [
{ variant: 'hero', span: 6 },
{ variant: 'feature', span: 3 },
{ variant: 'feature', span: 3 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
{ variant: 'standard', span: 2 },
];
const slots = props.category.hero
? heroLayout
: nonHeroLayouts[props.index % nonHeroLayouts.length];
const gridItems = props.category.items
.slice(0, slots.length)
.map((story, i) => ({
story,
variant: slots[i].variant,
span: slots[i].span,
}));
const briefItems = props.category.items.slice(slots.length);
</script>
<template>
<section class="category">
<div class="category-header">
<span class="category-label">{{ category.label }}</span>
<a href="#" class="category-more">More</a>
</div>
<div v-if="category.items.length === 0" class="category-empty">
No stories yet. Feeds may still be fetching.
</div>
<template v-else>
<div class="section-grid">
<ArticleCard
v-for="entry in gridItems"
:key="entry.story.link"
:story="entry.story"
:variant="entry.variant"
:style="{ gridColumn: `span ${entry.span}` }"
/>
</div>
<div v-if="briefItems.length" class="brief-section">
<div class="brief-label">In brief</div>
<div class="brief-grid">
<ArticleCard
v-for="item in briefItems"
:key="item.link"
:story="item"
variant="brief"
/>
</div>
</div>
</template>
</section>
</template>
<style scoped lang="sass">
.category
border-bottom: 1px solid #E8E8E8
padding: 1.5rem 0
&:last-child
border-bottom: none
.category-header
display: flex
align-items: baseline
justify-content: space-between
margin-bottom: 1rem
.category-label
font-size: 10px
font-weight: 600
letter-spacing: 2px
text-transform: uppercase
color: #111
.category-more
font-size: 12px
color: #C0392B
text-decoration: none
&:hover
text-decoration: underline
.category-empty
font-size: 12px
color: #999
padding: 1rem 0
.section-grid
display: grid
grid-template-columns: repeat(6, 1fr)
gap: 1px
background: #E8E8E8
.brief-section
margin-top: 4px
.brief-label
font-size: 10px
font-weight: 600
letter-spacing: 1px
text-transform: uppercase
color: #999
padding: 0.9rem 0 0.3rem
.brief-grid
display: grid
grid-template-columns: repeat(2, 1fr)
column-gap: 2rem
</style>