Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function makeVersion(overrides: Partial<ModelVersionEntity> = {}): ModelVersionE
previewImageFileKey: null,
netlogoFileKey: '2026/04/17/abcd-model.nlogox',
netlogoVersion: null,
changeSummary: null,
infoTab: null,
createdAt: new Date(),
finalizedAt: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function makeVersion(overrides: Partial<ModelVersionEntity> = {}): ModelVersionE
previewImageFileKey: null,
netlogoFileKey: 'files/key-1',
netlogoVersion: null,
changeSummary: null,
infoTab: null,
createdAt: new Date(),
finalizedAt: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe('modelVersionDomain', () => {
previewImageFileKey: null,
netlogoFileKey: 'f1',
netlogoVersion: null,
changeSummary: null,
infoTab: null,
createdAt: new Date(),
finalizedAt: null,
Expand All @@ -46,6 +47,7 @@ describe('modelVersionDomain', () => {
previewImageFileKey: null,
netlogoFileKey: 'f1',
netlogoVersion: null,
changeSummary: null,
infoTab: null,
createdAt: new Date(),
finalizedAt: new Date(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default function modelVersionDomain() {
previewImageFileKey: props.previewImageFileKey ?? null,
netlogoFileKey: props.netlogoFileKey,
netlogoVersion: null,
changeSummary: null,
infoTab: null,
createdAt: new Date(),
finalizedAt: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function makeVersion(overrides: Partial<ModelVersionEntity> = {}): ModelVersionE
previewImageFileKey: null,
netlogoFileKey: '2026/04/17/abcd-model.nlogox',
netlogoVersion: null,
changeSummary: null,
infoTab: null,
createdAt: new Date(),
finalizedAt: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { createTagPath } from "~/utils/formatters";
const props = defineProps<{
name: string;
displayName?: string;
description: string;
description?: string;
}>();

const label = computed(() =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export type PopularTag = ResponseSuccessData<"GET", "/api/v1/tags/popular">["data"][number];

export default function usePopularTags(limit = 24) {
const { GET } = useApi();

const { data, error, pending, loadNextPage, canLoadMore, count } =
useApiPagination<PopularTag>(`popular-tags-${limit}`, async (page: number) => {
const { data, error } = await GET("/api/v1/tags/popular", {
params: { query: { limit, page } },
});

return handleApiError(data, error, "fetching popular tags");
});

return {
tags: data,
error,
pending,
loadNextPage,
canLoadMore,
count,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default function useTags() {
count,
} = useApiPagination(key, async (page: number) => {
const { data, error } = await GET("/api/v1/tags", {
params: { query: { limit: 20, offset: (page - 1) * 20, q: debouncedQuery.value } },
params: { query: { limit: 20, page, q: debouncedQuery.value } },
});

const parsed = handleApiError(data, error, "fetching tags");
Expand Down
166 changes: 60 additions & 106 deletions apps/modeling-commons-frontend/app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,13 @@
</Error>

<div v-else-if="data" class="flex flex-col gap-25">
<template v-for="(section, idx) in visibleSections" :key="section.key">
<template v-for="section in visibleSections" :key="section.key">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
<section
class="space-y-6"
:class="{
'col-span-3': idx === 1,
'col-span-4': idx !== 1,
'col-span-3': section.hasSidebar,
'col-span-4': !section.hasSidebar,
}"
>
<div class="flex items-center justify-between">
Expand All @@ -110,38 +110,38 @@
<div
class="grid grid-cols-1 sm:grid-cols-2 gap-8"
:class="{
'lg:grid-cols-3': idx === 1,
'lg:grid-cols-4': idx !== 1,
'lg:grid-cols-3': section.hasSidebar,
'lg:grid-cols-4': !section.hasSidebar,
}"
>
<ModelCard v-for="card in section.cards" :key="card.model.id" :card="card" />
<template v-if="section.pending">
<ModelCardSkeleton v-for="j in section.query.limit ?? 4" :key="j" />
</template>
<template v-else>
<ModelCard v-for="card in section.cards" :key="card.model.id" :card="card" />
</template>
</div>
</section>
<!-- @extract -->
<section v-if="idx === 1" class="space-y-6 h-full col-span-1 mb-20">
<section v-if="section.hasSidebar" class="space-y-6 h-full col-span-1 mb-20">
<div>
<h5 class="tracking-tight">Trending Tags</h5>
<p class="text-sm text-muted mt-1">(in the past 2 weeks)</p>
</div>
<UCard variant="soft">
<div v-if="tagsSummary?.data" class="flex flex-col gap-8 h-full">
<div v-if="feedTags.length" class="flex flex-col gap-8 h-full">
<TagCard
v-for="$data in tagsSummary?.data"
:key="$data.tag.id"
:name="$data.tag.displayName"
:description="`tagged ${$data.modelCount} times`"
v-for="entry in feedTags"
:key="entry.tag.id"
:name="entry.tag.name"
:display-name="entry.tag.displayName"
:description="`tagged ${pluralizeWithCount(entry.modelCount, 'time')}`"
/>

<UButton variant="link" size="sm" class="w-full mt-4" to="/tags">
See all tags
</UButton>
</div>
<div v-else-if="tagsStatus === 'pending'" class="flex flex-col gap-8 h-full">
<TagCardSkeleton v-for="i in 6" :key="i" />
</div>
<div v-else-if="tagsError" class="text-center py-8">
<Error :error="tagsError" title="Something went wrong" />
</div>
</UCard>
</section>
</div>
Expand All @@ -159,18 +159,16 @@
</template>

<script setup lang="ts">
import type { ModelCard } from "~/composables/model/useModelCard";
import NetlogoLogo from "@repo/vue-ui/assets/brands/NetLogoOrgLogo.svg?url";

type SortBy = "recent" | "views" | "downloads" | "runs" | "likes";

interface SectionConfig {
key: string;
title: string;
subtitle: string;
query: QueryParams<"GET", "/api/v1/models/card">;
viewAllTo: string;
}
import {
homeFeedPath,
homeRecentPath,
homeSections,
type HomeFeed,
type HomeModelCard,
type HomePopularTag,
type HomeRecentFeed,
} from "~~/shared/home";

const meta = useWebsite();

Expand All @@ -181,105 +179,61 @@ useSeoMeta({
ogDescription: meta.value.description,
});

const sectionConfigs: SectionConfig[] = [
{
key: "featured",
title: "Featured Models",
subtitle: "Community-endorsed simulations",
query: { limit: 8, isEndorsed: true },
viewAllTo: "/featured-models",
},
{
key: "recent",
title: "Recent Models",
subtitle: "Latest uploads from the community",
query: { limit: 8 },
viewAllTo: "/new-models",
},
{
key: "most-viewed",
title: "Most Viewed Models",
subtitle: "What the community keeps coming back to",
query: { limit: 6, sortBy: "views" satisfies SortBy },
viewAllTo: "/models?sortBy=views",
},
{
key: "most-downloaded",
title: "Most Downloaded Models",
subtitle: "Top picks people are taking offline",
query: { limit: 4, sortBy: "downloads" satisfies SortBy },
viewAllTo: "/models?sortBy=downloads",
},
{
key: "most-liked",
title: "Most Liked Models",
subtitle: "Crowd favorites",
query: { limit: 4, sortBy: "likes" satisfies SortBy },
viewAllTo: "/models?sortBy=likes",
},
];

const api = useApi();
const { data, error, status, refresh } = await useAsyncData<Record<string, ModelCard[]>>(
"home-models",
async () => {
const responses = await Promise.all(
sectionConfigs.map((s) => api.GET("/api/v1/models/card", { params: { query: s.query } })),
);
// Every section here is public and identical for all visitors, so the queries
// are collapsed into one server-cached feed instead of six per-request calls.
const { data, error, status, refresh } = await useAsyncData<HomeFeed>("home-feed", () =>
$fetch<HomeFeed>(homeFeedPath),
);

return Object.fromEntries(
sectionConfigs
.map((s, i) => [s.key, (responses[i]?.data?.data ?? []) as ModelCard[]] as const)
.filter(([, cards]) => cards.length > 0),
);
},
// Recents carry a much shorter TTL than the rest of the feed, so they load on
// their own and never block the sections around them.
const { data: recent, status: recentStatus } = useAsyncData<HomeRecentFeed>(
"home-recent",
() => $fetch<HomeRecentFeed>(homeRecentPath),
{ lazy: true },
);

const TWO_WEEKS_MS = 1000 * 60 * 60 * 24 * 14;
const {
data: tagsSummary,
error: tagsError,
status: tagsStatus,
} = await useAsyncData("home-tags-summary", () => {
return getPopularTagsSummary(api, {
pagination: { limit: 6 },
date: { fromDate: new Date(Date.now() - TWO_WEEKS_MS) }, // past 2 weeks
});
});
const feedTags = computed<HomePopularTag[]>(() => data.value?.tags ?? []);

const visibleSections = computed(() =>
sectionConfigs
.map((s) => ({ ...s, cards: data.value?.[s.key] ?? [] }))
.filter((s) => s.cards.length > 0),
homeSections
.map((s) => ({
...s,
cards: s.deferred ? (recent.value?.cards ?? []) : (data.value?.sections?.[s.key] ?? []),
pending: Boolean(s.deferred) && recentStatus.value === "pending",
hasSidebar: Boolean(s.deferred),
}))
.filter((s) => s.cards.length > 0 || s.pending),
);

const MARQUEE_COLS = 3;

const randomSeed = useState("seed", () => Math.random());
const rand = ref(mulberry32(randomSeed.value));
const marqueeColumns = computed(() => {
// Flatten all section cards with their section metadata
const allCards = visibleSections.value.flatMap((section) =>
section.cards.map((card) => ({ card, sectionTitle: section.title, kind: "model" as const })),
);
// Deferred sections are excluded: they arrive after the feed, and folding them
// in later would reshuffle every column under the reader.
const allCards = visibleSections.value
.filter((section) => !section.deferred)
.flatMap((section) =>
section.cards.map((card) => ({ card, sectionTitle: section.title, kind: "model" as const })),
);

const tagsCards = tagsSummary.value?.data.map((tagData: TagsSummary["data"][number]) => ({
const tagsCards = feedTags.value.map((entry) => ({
kind: "tag" as const,
tag: tagData.tag,
description: `tagged ${tagData.modelCount} times`,
tag: entry.tag,
description: `tagged ${pluralizeWithCount(entry.modelCount, "time")}`,
}));

// Round-robin distribute across columns
const cols: Array<
Array<
| { card: ModelCard; sectionTitle: string; kind: "model" }
| { kind: "tag"; tag: TagsSummary["data"][number]["tag"]; description: string }
| { card: HomeModelCard; sectionTitle: string; kind: "model" }
| { kind: "tag"; tag: HomePopularTag["tag"]; description: string }
>
> = Array.from({ length: MARQUEE_COLS }, () => []);

const mixedCards = (tagsCards ? [...allCards, ...tagsCards] : allCards).sort(
() => rand.value() - 0.5,
); // Shuffle to mix tags and models
const mixedCards = [...allCards, ...tagsCards].sort(() => rand.value() - 0.5); // Shuffle to mix tags and models

mixedCards.forEach((item, i) => {
cols[i % MARQUEE_COLS]!.push(item);
Expand Down
Loading