diff --git a/backend/app/app.py b/backend/app/app.py index a838dc7a8..b6d54df7f 100644 --- a/backend/app/app.py +++ b/backend/app/app.py @@ -22,7 +22,7 @@ def get_categories() -> list[str]: @router.get("/category/{category}", tags=["app"]) def get_category( category: schemas.MainCategory, - filter_subcategories: list[str] = Query(None), + exclude_subcategories: list[str] = Query(None), page: int | None = None, per_page: int | None = None, locale: str = "en", @@ -35,7 +35,7 @@ def get_category( return response result = search.get_by_selected_categories( - [category], filter_subcategories, page, per_page, locale + [category], exclude_subcategories, page, per_page, locale ) return result @@ -45,7 +45,7 @@ def get_category( def get_subcategory( category: schemas.MainCategory, subcategory: str, - filter_subcategories: list[str] = Query(None), + exclude_subcategories: list[str] = Query(None), page: int | None = None, per_page: int | None = None, locale: str = "en", @@ -58,7 +58,7 @@ def get_subcategory( return response result = search.get_by_selected_category_and_subcategory( - category, subcategory, filter_subcategories, page, per_page, locale + category, subcategory, exclude_subcategories, page, per_page, locale ) return result diff --git a/backend/app/search.py b/backend/app/search.py index 50006802d..6e8d14ddb 100644 --- a/backend/app/search.py +++ b/backend/app/search.py @@ -96,7 +96,7 @@ def delete_apps(app_id_list): def get_by_selected_categories( selected_categories: list[schemas.MainCategory], - filter_subcategories: list[str], + exclude_subcategories: list[str], page: int | None, hits_per_page: int | None, locale: str, @@ -105,6 +105,12 @@ def get_by_selected_categories( f"categories = {category.value}" for category in selected_categories ] + exclude_subcategories_list = [ + f"sub_categories NOT IN [{exclude_subcategory}]" + for exclude_subcategory in exclude_subcategories + if exclude_subcategory is not None + ] + return _translate_name_and_summary( locale, client.index("apps").search( @@ -112,11 +118,7 @@ def get_by_selected_categories( { "filter": [ category_list, - ( - f"sub_categories NOT IN {filter_subcategories}" - if filter_subcategories is not None - else "" - ), + exclude_subcategories_list, "type IN [console-application, desktop-application]", "NOT icon IS NULL", ], @@ -131,11 +133,18 @@ def get_by_selected_categories( def get_by_selected_category_and_subcategory( selected_category: schemas.MainCategory, selected_subcategory: str, - filter_subcategories: list[str], + exclude_subcategories: list[str], page: int | None, hits_per_page: int | None, locale: str, ): + + exclude_subcategories_list = [ + f"sub_categories NOT IN [{exclude_subcategory}]" + for exclude_subcategory in exclude_subcategories + if exclude_subcategory is not None + ] + return _translate_name_and_summary( locale, client.index("apps").search( @@ -144,11 +153,7 @@ def get_by_selected_category_and_subcategory( "filter": [ f"main_categories = {selected_category.value}", f"sub_categories = {selected_subcategory}", - ( - f"sub_categories NOT IN {filter_subcategories}" - if filter_subcategories is not None - else "" - ), + exclude_subcategories_list, "type IN [console-application, desktop-application]", "NOT icon IS NULL", ], diff --git a/frontend/pages/index-new.tsx b/frontend/pages/index-new.tsx new file mode 100644 index 000000000..7119f3479 --- /dev/null +++ b/frontend/pages/index-new.tsx @@ -0,0 +1,476 @@ +import { GetStaticProps } from "next" +import { serverSideTranslations } from "next-i18next/serverSideTranslations" + +import fetchCollection, { + fetchAppOfTheDay, + fetchAppsOfTheWeek, + fetchAppstream, + fetchCategory, + fetchSubcategory, +} from "../src/fetchers" +import { APPS_IN_PREVIEW_COUNT, IS_PRODUCTION } from "../src/env" +import { NextSeo } from "next-seo" +import { useTranslation } from "next-i18next" +import { + AppsIndex, + MeilisearchResponse, + mapAppsIndexToAppstreamListItem, +} from "src/meilisearch" +import { tryParseCategory } from "src/types/Category" +import ApplicationSection from "src/components/application/ApplicationSection" +import { HeroBanner } from "src/components/application/HeroBanner" +import { DesktopAppstream } from "src/types/Appstream" +import clsx from "clsx" +import { AppOfTheDay } from "src/components/application/AppOfTheDay" +import { formatISO, sub } from "date-fns" +import { useEffect, useState } from "react" +import MultiToggle from "src/components/MultiToggle" +import { useRouter } from "next/router" +import { Button } from "@/components/ui/button" +import Link from "next/link" +import { MainCategory } from "src/codegen" + +interface CategoryItem { + displayName: string + category: keyof typeof MainCategory + subcategory?: string + apps: MeilisearchResponse +} + +// const categoryOrder = [ +// "office", +// "graphics", +// "audiovideo", +// "education", +// "game", +// "network", +// "development", +// "science", +// "system", +// "utility", +// ] + +const homepageCategories = [ + { + displayName: "Office", + category: MainCategory.office, + subcategory_exclude: [], + }, + { + displayName: "Graphics", + category: MainCategory.graphics, + subcategory_exclude: [], + }, + { + displayName: "AudioVideo", + category: MainCategory.audiovideo, + subcategory_exclude: [], + }, + { + displayName: "Education", + category: MainCategory.education, + subcategory_exclude: [], + }, + { + displayName: "Emulator", + category: MainCategory.game, + subcategory: "Emulator", + subcategory_exclude: [], + }, + { + displayName: "Game Launcher", + category: MainCategory.game, + subcategory: "PackageManager", + subcategory_exclude: [], + }, + { + displayName: "Game", + category: MainCategory.game, + subcategory_exclude: ["Emulator", "PackageManager"], + }, + { + displayName: "Game Tools", + category: MainCategory.game, + subcategory_exclude: ["Emulator", "PackageManager"], + }, + { + displayName: "Network", + category: MainCategory.network, + subcategory_exclude: [], + }, + { + displayName: "Development", + category: MainCategory.development, + subcategory_exclude: [], + }, + { + displayName: "Science", + category: MainCategory.science, + subcategory_exclude: [], + }, + { + displayName: "System", + category: MainCategory.system, + subcategory_exclude: [], + }, + { + displayName: "Utility", + category: MainCategory.utility, + subcategory_exclude: [], + }, +] + +const CategorySection = ({ + topAppsByCategory, +}: { + topAppsByCategory: CategoryItem[] +}) => { + const { t } = useTranslation() + + return ( + <> + {topAppsByCategory.map((sectionData) => ( + + mapAppsIndexToAppstreamListItem(app), + )} + numberOfApps={6} + customHeader={ + <> +
+

+ {tryParseCategory(sectionData.displayName, t) ?? + t(sectionData.displayName)} +

+
+ + } + showMore={true} + moreText={t(`more-x`, { + category: + tryParseCategory(sectionData.displayName, t) ?? + t(sectionData.displayName), + })} + /> + ))} + + ) +} + +const TopSection = ({ + topApps, +}: { + topApps: { + name: string + apps: MeilisearchResponse + moreLink: string + }[] +}) => { + const { t } = useTranslation() + + const router = useRouter() + + const [selectedName, setSelectedName] = useState( + router?.query?.category?.toString() || topApps[0].name, + ) + + const [selectedApps, setSelectedApps] = useState<{ + name: string + apps: MeilisearchResponse + moreLink: string + }>(topApps.find((x) => x.name === selectedName) || topApps[0]) + + useEffect(() => { + if (router?.query?.category) { + setSelectedName(router.query.category.toString()) + } + }, [router?.query?.category]) + + useEffect(() => { + const foundApps = topApps.find( + (sectionData) => sectionData.name === selectedName, + ) + setSelectedApps(foundApps) + }, [selectedName, topApps]) + + return ( + + mapAppsIndexToAppstreamListItem(app), + )} + numberOfApps={APPS_IN_PREVIEW_COUNT} + customHeader={ + <> + ({ + id: x.name, + content: ( +
{t(x.name)}
+ ), + selected: x.name === selectedName, + onClick: () => { + const newQuery = { ...router.query } + newQuery.category = x.name + router.push({ query: newQuery }, undefined, { + scroll: false, + }) + }, + }))} + size={"lg"} + variant="secondary" + /> + + } + showMore={true} + moreText={t(`more-${selectedApps.name}`)} + /> + ) +} + +export default function Home({ + recentlyUpdated, + recentlyAdded, + trending, + popular, + topAppsByCategory, + heroBannerData, + appOfTheDayAppstream, + mobile, +}: { + recentlyUpdated: MeilisearchResponse + recentlyAdded: MeilisearchResponse + trending: MeilisearchResponse + popular: MeilisearchResponse + topAppsByCategory: CategoryItem[] + heroBannerData: { + app: { position: number; app_id: string; isFullscreen: boolean } + appstream: DesktopAppstream + }[] + appOfTheDayAppstream: DesktopAppstream + mobile: MeilisearchResponse +}) { + const { t } = useTranslation() + + return ( + <> + +
+
+ {heroBannerData.length > 0 && ( + + )} +
+ +
+
+
+
+ {t("flathub-the-linux-app-store")} +
+

+ {t("flathub-index-description")} +

+
+ + {!IS_PRODUCTION && ( + + )} +
+
+
+
+
+
+ + + + +
+ + ) +} + +export const getStaticProps: GetStaticProps = async ({ + locale, +}: { + locale: string +}) => { + const recentlyUpdated = await fetchCollection( + "recently-updated", + 1, + APPS_IN_PREVIEW_COUNT * 2, + locale, + ) + const popular = await fetchCollection( + "popular", + 1, + APPS_IN_PREVIEW_COUNT, + locale, + ) + const recentlyAdded = await fetchCollection( + "recently-added", + 1, + APPS_IN_PREVIEW_COUNT, + locale, + ) + const trending = await fetchCollection( + "trending", + 1, + APPS_IN_PREVIEW_COUNT, + locale, + ) + + const mobile = await fetchCollection( + "mobile", + 1, + APPS_IN_PREVIEW_COUNT, + locale, + ) + + let topAppsByCategory: CategoryItem[] = [] + + const categoryPromise = homepageCategories.map(async (category) => { + if (!category.subcategory) { + return { + displayName: category.displayName, + category: category.category, + apps: await fetchCategory( + category.category, + locale, + 1, + 6, + category.subcategory_exclude, + ), + } + } else { + return { + displayName: category.displayName, + category: category.category, + subcategory: category.subcategory, + apps: await fetchSubcategory( + category.category, + category.subcategory, + locale, + 1, + 6, + category.subcategory_exclude, + ), + } + } + }) + + topAppsByCategory = await Promise.all(categoryPromise) + + // remove duplicated apps + recentlyUpdated.hits = recentlyUpdated.hits + .filter( + (app) => !recentlyAdded.hits.some((addedApp) => addedApp.id === app.id), + ) + .slice(0, APPS_IN_PREVIEW_COUNT) + + const heroBannerApps = await fetchAppsOfTheWeek( + formatISO(new Date(), { representation: "date" }), + ) + const appOfTheDay = await fetchAppOfTheDay( + formatISO(new Date(), { representation: "date" }), + ) + + const heroBannerAppstreams = await Promise.all( + heroBannerApps.apps.map(async (app) => fetchAppstream(app.app_id, locale)), + ) + + const heroBannerData = heroBannerApps.apps.map((app) => { + return { + app: app, + appstream: heroBannerAppstreams.find((a) => a.id === app.app_id), + } + }) + + const appOfTheDayAppstream = await fetchAppstream(appOfTheDay.app_id, locale) + + return { + props: { + ...(await serverSideTranslations(locale, ["common"])), + recentlyUpdated, + recentlyAdded, + trending, + popular, + topAppsByCategory, + heroBannerData, + appOfTheDayAppstream, + mobile, + }, + revalidate: 900, + } +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 89f68b397..96465b3cb 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -272,20 +272,7 @@ "open-user-menu": "Open User Menu", "show-more": "Show More", "show-less": "Show Less", - "more-trending": "More Trending", - "more-popular": "More Popular", - "more-new": "More New", - "more-updated": "More Updated", - "more-office": "More Productivity", - "more-graphics": "More Graphics & Photography", - "more-audiovideo": "More Audio & Video", - "more-education": "More Education", - "more-game": "More Games", - "more-network": "More Networking", - "more-development": "More Developer Tools", - "more-science": "More Science", - "more-system": "More System", - "more-utility": "More Utilities", + "more-x": "More {{category}}", "verified": "Verified", "app-is-verified": "This app is verified", "verification-instructions": "Verify your app ID to indicate that your Flathub upload is approved by the app developer. You'll need to prove that you have control of the app via a website on the app's domain or by checking access to the app's account on a source code hosting site. The verified badge will be shown alongside the app name, publisher name, and the way that the app ID was verified.", @@ -626,4 +613,4 @@ "hide-branding-preview": "Hide branding preview", "show-branding-preview": "Show branding preview" } -} +} \ No newline at end of file diff --git a/frontend/src/codegen/model/getCategoryCategoryCategoryGetParams.ts b/frontend/src/codegen/model/getCategoryCategoryCategoryGetParams.ts index 5132e77e6..66bd74bf5 100644 --- a/frontend/src/codegen/model/getCategoryCategoryCategoryGetParams.ts +++ b/frontend/src/codegen/model/getCategoryCategoryCategoryGetParams.ts @@ -6,7 +6,7 @@ */ export type GetCategoryCategoryCategoryGetParams = { - filter_subcategories?: string[] + exclude_subcategories?: string[] page?: number | null per_page?: number | null locale?: string diff --git a/frontend/src/codegen/model/getSubcategoryCategoryCategorySubcategoriesSubcategoryGetParams.ts b/frontend/src/codegen/model/getSubcategoryCategoryCategorySubcategoriesSubcategoryGetParams.ts index 23788a43e..8309519b8 100644 --- a/frontend/src/codegen/model/getSubcategoryCategoryCategorySubcategoriesSubcategoryGetParams.ts +++ b/frontend/src/codegen/model/getSubcategoryCategoryCategorySubcategoriesSubcategoryGetParams.ts @@ -6,7 +6,7 @@ */ export type GetSubcategoryCategoryCategorySubcategoriesSubcategoryGetParams = { - filter_subcategories?: string[] + exclude_subcategories?: string[] page?: number | null per_page?: number | null locale?: string diff --git a/frontend/src/env.ts b/frontend/src/env.ts index 5faf46b8d..12926b3de 100644 --- a/frontend/src/env.ts +++ b/frontend/src/env.ts @@ -144,6 +144,7 @@ export const CATEGORY_URL = ( page?: number, per_page?: number, locale?: string, + exclude_subcategories?: string[], ): string => { const result = new URLSearchParams() @@ -158,6 +159,10 @@ export const CATEGORY_URL = ( if (locale) { result.append("locale", locale) } + + if (exclude_subcategories) { + result.append("exclude_subcategories", exclude_subcategories.join(",")) + } return `${BASE_URI}/category/${category}?${result.toString()}` } @@ -167,6 +172,7 @@ export const SUBCATEGORY_URL = ( page?: number, per_page?: number, locale?: string, + exclude_subcategories?: string[], ): string => { const result = new URLSearchParams() @@ -181,6 +187,10 @@ export const SUBCATEGORY_URL = ( if (locale) { result.append("locale", locale) } + + if (exclude_subcategories) { + result.append("exclude_subcategories", exclude_subcategories.join(",")) + } return `${BASE_URI}/category/${category}/subcategories/${subcategory}?${result.toString()}` } diff --git a/frontend/src/fetchers.ts b/frontend/src/fetchers.ts index c6565ef9b..c33edea6b 100644 --- a/frontend/src/fetchers.ts +++ b/frontend/src/fetchers.ts @@ -167,8 +167,11 @@ export async function fetchCategory( locale: string, page?: number, per_page?: number, + exclude_subcategories?: string[], ): Promise> { - const appListRes = await fetch(CATEGORY_URL(category, page, per_page, locale)) + const appListRes = await fetch( + CATEGORY_URL(category, page, per_page, locale, exclude_subcategories), + ) const response: MeilisearchResponse = await appListRes.json() console.log( @@ -184,9 +187,17 @@ export async function fetchSubcategory( locale: string, page?: number, per_page?: number, + exclude_subcategories?: string[], ): Promise> { const appListRes = await fetch( - SUBCATEGORY_URL(category, subcategory, page, per_page, locale), + SUBCATEGORY_URL( + category, + subcategory, + page, + per_page, + locale, + exclude_subcategories, + ), ) const response: MeilisearchResponse = await appListRes.json()