From f0d95c6e3e43749ee98210a7e43f58f8390e241b Mon Sep 17 00:00:00 2001 From: Oksamies Date: Fri, 19 Sep 2025 17:04:19 +0300 Subject: [PATCH 01/10] Add packageVersionDetails endpoint usage Add function for using the endpoint and related schemas --- .../src/get/packageVersionDetails.ts | 24 +++++++++++++++++++ packages/thunderstore-api/src/index.ts | 1 + .../src/schemas/objectSchemas.ts | 2 +- .../src/schemas/requestSchemas.ts | 11 +++++++++ .../src/schemas/responseSchemas.ts | 23 ++++++++++++++++++ 5 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 packages/thunderstore-api/src/get/packageVersionDetails.ts diff --git a/packages/thunderstore-api/src/get/packageVersionDetails.ts b/packages/thunderstore-api/src/get/packageVersionDetails.ts new file mode 100644 index 000000000..44e57b238 --- /dev/null +++ b/packages/thunderstore-api/src/get/packageVersionDetails.ts @@ -0,0 +1,24 @@ +import { ApiEndpointProps } from "../index"; +import { apiFetch } from "../apiFetch"; +import { PackageVersionDetailsRequestParams } from "../schemas/requestSchemas"; +import { + PackageVersionDetailsResponseData, + packageVersionDetailsResponseDataSchema, +} from "../schemas/responseSchemas"; + +export async function fetchPackageVersionDetails( + props: ApiEndpointProps +): Promise { + const { config, params } = props; + const path = `api/cyberstorm/package/${params.namespace_id}/${params.package_name}/v/${params.package_version}/`; + + return await apiFetch({ + args: { + config: config, + path: path, + }, + requestSchema: undefined, + queryParamsSchema: undefined, + responseSchema: packageVersionDetailsResponseDataSchema, + }); +} diff --git a/packages/thunderstore-api/src/index.ts b/packages/thunderstore-api/src/index.ts index 4e3c60f8f..c3a169605 100644 --- a/packages/thunderstore-api/src/index.ts +++ b/packages/thunderstore-api/src/index.ts @@ -34,6 +34,7 @@ export * from "./get/packageListingDetails"; export * from "./get/packageReadme"; export * from "./get/packageSubmission"; export * from "./get/packageVersions"; +export * from "./get/packageVersionDetails"; export * from "./get/packageWiki"; export * from "./get/ratedPackages"; export * from "./get/teamDetails"; diff --git a/packages/thunderstore-api/src/schemas/objectSchemas.ts b/packages/thunderstore-api/src/schemas/objectSchemas.ts index fb20d2ebf..2e86a1e4a 100644 --- a/packages/thunderstore-api/src/schemas/objectSchemas.ts +++ b/packages/thunderstore-api/src/schemas/objectSchemas.ts @@ -135,7 +135,7 @@ export const packageListingSchema = z.object({ export type PackageListing = z.infer; -const packageTeamSchema = z.object({ +export const packageTeamSchema = z.object({ name: z.string().min(1), members: teamMemberSchema.array(), }); diff --git a/packages/thunderstore-api/src/schemas/requestSchemas.ts b/packages/thunderstore-api/src/schemas/requestSchemas.ts index 178136062..3fccdc68c 100644 --- a/packages/thunderstore-api/src/schemas/requestSchemas.ts +++ b/packages/thunderstore-api/src/schemas/requestSchemas.ts @@ -167,6 +167,17 @@ export type PackageListingDetailsRequestParams = z.infer< typeof packageListingDetailsRequestParamsSchema >; +// PackageVersionDetailsRequest +export const packageVersionDetailsRequestParamsSchema = z.object({ + namespace_id: z.string(), + package_name: z.string(), + package_version: z.string(), +}); + +export type PackageVersionDetailsRequestParams = z.infer< + typeof packageVersionDetailsRequestParamsSchema +>; + // PackageReadmeRequest export const packageReadmeRequestParamsSchema = z.object({ namespace_id: z.string(), diff --git a/packages/thunderstore-api/src/schemas/responseSchemas.ts b/packages/thunderstore-api/src/schemas/responseSchemas.ts index c4bab93b9..730d6184a 100644 --- a/packages/thunderstore-api/src/schemas/responseSchemas.ts +++ b/packages/thunderstore-api/src/schemas/responseSchemas.ts @@ -17,6 +17,7 @@ import { markdownRenderSchema, packageWikiPageSchema, packagePermissionsSchema, + packageTeamSchema, } from "../schemas/objectSchemas"; import { paginatedResults } from "../schemas/objectSchemas"; @@ -108,6 +109,28 @@ export type PackageListingDetailsResponseData = z.infer< typeof packageListingDetailsResponseDataSchema >; +// PackageVersionDetailsResponse +export const packageVersionDetailsResponseDataSchema = z.object({ + description: z.string(), + download_count: z.number().int(), + icon_url: z.string().nullable(), + install_url: z.string().nullable(), + name: z.string().min(1), + version_number: z.string().min(1), + namespace: z.string().min(1), + size: z.number().int(), + datetime_created: z.string().datetime(), + dependency_count: z.number().int(), + download_url: z.string(), + full_version_name: z.string().min(1), + team: packageTeamSchema, + website_url: z.string().nullable(), +}); + +export type PackageVersionDetailsResponseData = z.infer< + typeof packageVersionDetailsResponseDataSchema +>; + // PackagePermissionsResponse export const packagePermissionsResponseDataSchema = packagePermissionsSchema; From bee789e37198779bcb9ca7b1b11e9e761c485ab2 Mon Sep 17 00:00:00 2001 From: Oksamies Date: Fri, 19 Sep 2025 17:05:20 +0300 Subject: [PATCH 02/10] Add packageVersion method to Dapper Add packageVersion method for Dapper and DapperFake --- packages/dapper-fake/src/fakers/package.ts | 66 +++++++++++++++++++ packages/dapper-fake/src/index.ts | 2 + packages/dapper-ts/src/index.ts | 3 + .../dapper-ts/src/methods/packageVersion.ts | 23 +++++++ 4 files changed, 94 insertions(+) create mode 100644 packages/dapper-ts/src/methods/packageVersion.ts diff --git a/packages/dapper-fake/src/fakers/package.ts b/packages/dapper-fake/src/fakers/package.ts index 6e264297b..6cf0ade15 100644 --- a/packages/dapper-fake/src/fakers/package.ts +++ b/packages/dapper-fake/src/fakers/package.ts @@ -157,6 +157,72 @@ export const getFakePackageListingDetails = async ( }; }; +// Content used to render Package's detail view. +export const getFakePackageVersionDetails = async ( + namespace: string, + name: string, + version: string +) => { + const seed = `${namespace}-${name}-${version}`; + setSeed(seed); + + // Generate a base icon (nullable 10% of time) + const iconUrl = + faker.helpers.maybe(() => getFakeImg(), { + probability: 0.9, + }) ?? null; + + // Fake dependencies (reuse logic but need extra flags) + const dependencyCount = faker.number.int({ min: 0, max: 6 }); + const dependencies = await Promise.all( + range(dependencyCount).map(async () => { + const depSeed = `${seed}-dep-${faker.string.alpha(8)}`; + setSeed(depSeed); + const isActive = faker.datatype.boolean(0.8); + const removed = faker.datatype.boolean(0.1); + const unavailable = !removed && faker.datatype.boolean(0.05); + return { + description: isActive + ? faker.company.buzzPhrase() + : "This package has been removed.", + icon_url: isActive + ? faker.helpers.maybe(() => getFakeImg(256, 256), { + probability: 0.85, + }) ?? null + : null, + is_active: isActive, + name: faker.word.words(3).split(" ").join("_"), + namespace: faker.word.sample(), + version_number: getVersionNumber(), + is_removed: removed, + is_unavailable: unavailable, + }; + }) + ); + + return { + description: faker.company.buzzPhrase(), + download_count: faker.number.int({ min: 0, max: 5_000_000 }), + icon_url: iconUrl, + name, + namespace, + size: faker.number.int({ min: 20_000, max: 4_000_000_000 }), + datetime_created: faker.date.past({ years: 2 }).toISOString(), + dependencies, + dependency_count: dependencies.length, + download_url: `https://thunderstore.io/package/download/${namespace}/${name}/${version}/`, + full_version_name: `${namespace}-${name}-${version}`, + team: { + name: faker.word.words(3), + members: await getFakeTeamMembers(seed), + }, + website_url: + faker.helpers.maybe(() => faker.internet.url(), { + probability: 0.9, + }) ?? null, + }; +}; + // Shown on a tab on Package's detail view. export const getFakePackageVersions = async ( namespace: string, diff --git a/packages/dapper-fake/src/index.ts b/packages/dapper-fake/src/index.ts index c47046166..fb53f79d7 100644 --- a/packages/dapper-fake/src/index.ts +++ b/packages/dapper-fake/src/index.ts @@ -11,6 +11,7 @@ import { getFakePackageListings, getFakePackagePermissions, getFakePackageVersions, + getFakePackageVersionDetails, } from "./fakers/package"; import { getFakeServiceAccounts } from "./fakers/serviceAccount"; import { @@ -32,6 +33,7 @@ export class DapperFake implements DapperInterface { public getPackageListingDetails = getFakePackageListingDetails; public getPackageListings = getFakePackageListings; public getPackageReadme = getFakeReadme; + public getPackageVersionDetails = getFakePackageVersionDetails; public getPackageVersions = getFakePackageVersions; public getTeamDetails = getFakeTeamDetails; public getTeamMembers = getFakeTeamMembers; diff --git a/packages/dapper-ts/src/index.ts b/packages/dapper-ts/src/index.ts index 97ef4e4a7..72e1010a5 100644 --- a/packages/dapper-ts/src/index.ts +++ b/packages/dapper-ts/src/index.ts @@ -26,6 +26,7 @@ import { getTeamServiceAccounts, postTeamCreate, } from "./methods/team"; +import { getPackageVersionDetails } from "./methods/packageVersion"; export interface DapperTsInterface extends DapperInterface { config: () => RequestConfig; @@ -49,6 +50,7 @@ export class DapperTs implements DapperTsInterface { this.getPackageListings = this.getPackageListings.bind(this); this.getPackageListingDetails = this.getPackageListingDetails.bind(this); this.getPackageReadme = this.getPackageReadme.bind(this); + this.getPackageVersionDetails = this.getPackageVersionDetails.bind(this); this.getPackageVersions = this.getPackageVersions.bind(this); this.getPackageWiki = this.getPackageWiki.bind(this); this.getPackageWikiPage = this.getPackageWikiPage.bind(this); @@ -74,6 +76,7 @@ export class DapperTs implements DapperTsInterface { public getPackageListingDetails = getPackageListingDetails; public getPackageReadme = getPackageReadme; public getPackageVersions = getPackageVersions; + public getPackageVersionDetails = getPackageVersionDetails; public getPackageWiki = getPackageWiki; public getPackageWikiPage = getPackageWikiPage; public getPackagePermissions = getPackagePermissions; diff --git a/packages/dapper-ts/src/methods/packageVersion.ts b/packages/dapper-ts/src/methods/packageVersion.ts new file mode 100644 index 000000000..dc7ba7cf4 --- /dev/null +++ b/packages/dapper-ts/src/methods/packageVersion.ts @@ -0,0 +1,23 @@ +import { fetchPackageVersionDetails } from "@thunderstore/thunderstore-api"; + +import { DapperTsInterface } from "../index"; + +export async function getPackageVersionDetails( + this: DapperTsInterface, + namespaceId: string, + packageName: string, + packageVersion: string +) { + const data = await fetchPackageVersionDetails({ + config: this.config, + params: { + namespace_id: namespaceId, + package_name: packageName, + package_version: packageVersion, + }, + data: {}, + queryParams: {}, + }); + + return data; +} From 60a559ea107872e69ab3cb97beb0e51eb475db2a Mon Sep 17 00:00:00 2001 From: Oksamies Date: Fri, 19 Sep 2025 17:08:46 +0300 Subject: [PATCH 03/10] Add LinkLibrary support for PackageVersion pages This enables componens like NewLink to auto resolve links and it's used props, with the linkId prop. (the method name in LinkLibrary interface) --- .../cyberstorm/utils/LinkLibrary.tsx | 35 +++++++++++++++++++ apps/cyberstorm-storybook/LinkLibrary.tsx | 35 +++++++++++++++++++ .../src/components/Links/LinkingProvider.tsx | 21 +++++++++++ .../cyberstorm/src/components/Links/Links.tsx | 5 +++ 4 files changed, 96 insertions(+) diff --git a/apps/cyberstorm-remix/cyberstorm/utils/LinkLibrary.tsx b/apps/cyberstorm-remix/cyberstorm/utils/LinkLibrary.tsx index 517ae5120..2e6e854b1 100644 --- a/apps/cyberstorm-remix/cyberstorm/utils/LinkLibrary.tsx +++ b/apps/cyberstorm-remix/cyberstorm/utils/LinkLibrary.tsx @@ -159,6 +159,41 @@ const library: LinkLibrary = { ref={p.customRef} /> ), + PackageVersionRequired: (p) => ( + + ), + PackageVersionVersions: (p) => ( + + ), + PackageVersionWithoutCommunity: (p) => ( + + ), + PackageVersionWithoutCommunityRequired: (p) => ( + + ), + PackageVersionWithoutCommunityVersions: (p) => ( + + ), PackageFormatDocs: (p) => ( ), diff --git a/apps/cyberstorm-storybook/LinkLibrary.tsx b/apps/cyberstorm-storybook/LinkLibrary.tsx index bfb535e62..bd8bdcc11 100644 --- a/apps/cyberstorm-storybook/LinkLibrary.tsx +++ b/apps/cyberstorm-storybook/LinkLibrary.tsx @@ -164,6 +164,41 @@ const library: LinkLibrary = { ref={p.customRef} /> ), + PackageVersionRequired: (p) => ( + + ), + PackageVersionVersions: (p) => ( + + ), + PackageVersionWithoutCommunity: (p) => ( + + ), + PackageVersionWithoutCommunityRequired: (p) => ( + + ), + PackageVersionWithoutCommunityVersions: (p) => ( + + ), PackageFormatDocs: (p) => ( ), diff --git a/packages/cyberstorm/src/components/Links/LinkingProvider.tsx b/packages/cyberstorm/src/components/Links/LinkingProvider.tsx index f64380f62..bb5e71e65 100644 --- a/packages/cyberstorm/src/components/Links/LinkingProvider.tsx +++ b/packages/cyberstorm/src/components/Links/LinkingProvider.tsx @@ -110,6 +110,22 @@ export interface LinkLibrary { PackageFormatDocs: NoRequiredProps; /** PackageVersion's detail view */ PackageVersion: (props: AnyProps & PackageVersionProps) => RE | null; + /** PackageVersion's required view */ + PackageVersionRequired: (props: AnyProps & PackageVersionProps) => RE | null; + /** PackageVersion's versions view */ + PackageVersionVersions: (props: AnyProps & PackageVersionProps) => RE | null; + /** PackageVersionWithoutCommunity's detail view */ + PackageVersionWithoutCommunity: ( + props: AnyProps & Omit + ) => RE | null; + /** PackageVersionWithoutCommunity's required view */ + PackageVersionWithoutCommunityRequired: ( + props: AnyProps & Omit + ) => RE | null; + /** PackageVersionWithoutCommunity's versions view */ + PackageVersionWithoutCommunityVersions: ( + props: AnyProps & Omit + ) => RE | null; /** View for submitting new packages or versions */ PackageUpload: NoRequiredProps; /** Privacy policy */ @@ -163,6 +179,11 @@ const library: LinkLibrary = { PackageDependants: noop, PackageFormatDocs: noop, PackageVersion: noop, + PackageVersionRequired: noop, + PackageVersionVersions: noop, + PackageVersionWithoutCommunity: noop, + PackageVersionWithoutCommunityRequired: noop, + PackageVersionWithoutCommunityVersions: noop, PackageUpload: noop, PrivacyPolicy: noop, User: noop, diff --git a/packages/cyberstorm/src/components/Links/Links.tsx b/packages/cyberstorm/src/components/Links/Links.tsx index 3d5788d9f..88f22a0cc 100644 --- a/packages/cyberstorm/src/components/Links/Links.tsx +++ b/packages/cyberstorm/src/components/Links/Links.tsx @@ -40,6 +40,11 @@ export type CyberstormLinkIds = | "PackageDependants" | "PackageFormatDocs" | "PackageVersion" + | "PackageVersionRequired" + | "PackageVersionVersions" + | "PackageVersionWithoutCommunity" + | "PackageVersionWithoutCommunityRequired" + | "PackageVersionWithoutCommunityVersions" | "PackageUpload" | "PrivacyPolicy" | "Settings" From 94cd49969a77acd6b8a7dba4ac561708d8708f90 Mon Sep 17 00:00:00 2001 From: Oksamies Date: Fri, 19 Sep 2025 17:12:38 +0300 Subject: [PATCH 04/10] Add PackageVersion pages and tabs (with community) These pages are more or less the same as PackageListing pages/tabs. The biggest difference being the ommited Team and Package Listing related information as it's not relevant when viewing a package. The "Required" is mostly commented out, because the endpoint isn't yet available. (even for local development testing) --- .../cyberstorm-remix/app/p/packageVersion.tsx | 544 ++++++++++++++++++ .../p/tabs/Readme/PackageVersionReadme.tsx | 85 +++ .../tabs/Required/PackageVersionRequired.tsx | 112 ++++ .../tabs/Versions/PackageVersionVersions.tsx | 232 ++++++++ 4 files changed, 973 insertions(+) create mode 100644 apps/cyberstorm-remix/app/p/packageVersion.tsx create mode 100644 apps/cyberstorm-remix/app/p/tabs/Readme/PackageVersionReadme.tsx create mode 100644 apps/cyberstorm-remix/app/p/tabs/Required/PackageVersionRequired.tsx create mode 100644 apps/cyberstorm-remix/app/p/tabs/Versions/PackageVersionVersions.tsx diff --git a/apps/cyberstorm-remix/app/p/packageVersion.tsx b/apps/cyberstorm-remix/app/p/packageVersion.tsx new file mode 100644 index 000000000..67a79d761 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/packageVersion.tsx @@ -0,0 +1,544 @@ +import type { + LoaderFunctionArgs, + ShouldRevalidateFunctionArgs, +} from "react-router"; +import { + Await, + Outlet, + useLoaderData, + useLocation, + useOutletContext, +} from "react-router"; +import { + Drawer, + Heading, + NewAlert, + NewButton, + NewIcon, + NewLink, + SkeletonBox, + Tabs, +} from "@thunderstore/cyberstorm"; +import "./packageListing.css"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ThunderstoreLogo } from "@thunderstore/cyberstorm/src/svg/svg"; +import { + faUsers, + faHandHoldingHeart, + faDownload, + faCaretRight, +} from "@fortawesome/free-solid-svg-icons"; +import { + memo, + ReactElement, + Suspense, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useHydrated } from "remix-utils/use-hydrated"; +import { PageHeader } from "~/commonComponents/PageHeader/PageHeader"; +import { faArrowUpRight } from "@fortawesome/pro-solid-svg-icons"; +import { RelativeTime } from "@thunderstore/cyberstorm/src/components/RelativeTime/RelativeTime"; +import { + formatFileSize, + formatInteger, + formatToDisplayName, +} from "@thunderstore/cyberstorm/src/utils/utils"; +import { DapperTs } from "@thunderstore/dapper-ts"; +import { OutletContextShape } from "~/root"; +import { CopyButton } from "~/commonComponents/CopyButton/CopyButton"; +import { + getPublicEnvVariables, + getSessionTools, +} from "cyberstorm/security/publicEnvVariables"; +import { getTeamDetails } from "@thunderstore/dapper-ts/src/methods/team"; +import { isPromise } from "cyberstorm/utils/typeChecks"; +import { getPackageVersionDetails } from "@thunderstore/dapper-ts/src/methods/packageVersion"; + +export async function loader({ params }: LoaderFunctionArgs) { + if ( + params.communityId && + params.namespaceId && + params.packageId && + params.packageVersion + ) { + const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); + const dapper = new DapperTs(() => { + return { + apiHost: publicEnvVariables.VITE_API_URL, + sessionId: undefined, + }; + }); + + return { + communityId: params.communityId, + community: await dapper.getCommunity(params.communityId), + version: await dapper.getPackageVersionDetails( + params.namespaceId, + params.packageId, + params.packageVersion + ), + team: await dapper.getTeamDetails(params.namespaceId), + }; + } + throw new Response("Package not found", { status: 404 }); +} + +export async function clientLoader({ params }: LoaderFunctionArgs) { + if ( + params.communityId && + params.namespaceId && + params.packageId && + params.packageVersion + ) { + const tools = getSessionTools(); + const dapper = new DapperTs(() => { + return { + apiHost: tools?.getConfig().apiHost, + sessionId: tools?.getConfig().sessionId, + }; + }); + + return { + communityId: params.communityId, + community: dapper.getCommunity(params.communityId), + version: dapper.getPackageVersionDetails( + params.namespaceId, + params.packageId, + params.packageVersion + ), + team: dapper.getTeamDetails(params.namespaceId), + }; + } + throw new Response("Package not found", { status: 404 }); +} + +export function shouldRevalidate(arg: ShouldRevalidateFunctionArgs) { + const oldPath = arg.currentUrl.pathname.split("/"); + const newPath = arg.nextUrl.pathname.split("/"); + // If we're staying on the same package page, don't revalidate + if ( + oldPath[2] === newPath[2] && + oldPath[4] === newPath[4] && + oldPath[5] === newPath[5] && + oldPath[7] === newPath[7] + ) { + return false; + } + return arg.defaultShouldRevalidate; +} + +export default function PackageVersion() { + const { communityId, community, version, team } = useLoaderData< + typeof loader | typeof clientLoader + >(); + + const location = useLocation(); + + const outletContext = useOutletContext() as OutletContextShape; + + const isHydrated = useHydrated(); + const startsHydrated = useRef(isHydrated); + + // START: For sidebar meta dates + const [firstUploaded, setFirstUploaded] = useState< + ReactElement | undefined + >(); + + // This will be loaded 2 times in development because of: + // https://react.dev/reference/react/StrictMode + // If strict mode is removed from the entry.client.tsx, this should only run once + useEffect(() => { + if (!startsHydrated.current && isHydrated) return; + if (isPromise(version)) { + version.then((versionData) => { + setFirstUploaded( + + ); + }); + } else { + setFirstUploaded( + + ); + } + }, []); + // END: For sidebar meta dates + + const currentTab = location.pathname.split("/")[8] || "details"; + + const versionAndCommunityPromise = useMemo( + () => Promise.all([version, community]), + [] + ); + + const versionAndTeamPromise = useMemo(() => Promise.all([version, team]), []); + + return ( + <> + + + {(resolvedValue) => ( + <> + + + + + + + + + + + + )} + + +
+
+
+
+ + You are viewing a potentially older version of this package. + + } + > + + {(resolvedValue) => ( + + You are viewing a potentially older version of this + package.{" "} + + View Latest Version + + + )} + + + + } + > + + {(resolvedValue) => ( + + + + + + {resolvedValue.namespace} + + {resolvedValue.website_url ? ( + + {resolvedValue.website_url} + + + + + ) : null} + + } + > + {formatToDisplayName(resolvedValue.name)} + + )} + + + +
+ + + Details + + } + rootClasses="package-listing__drawer" + > + Loading...

}> + + {(resolvedValue) => ( + <>{packageMeta(firstUploaded, resolvedValue)} + )} + +
+
+ Loading...

}> + + {(resolvedValue) => ( + + )} + +
+
+ + } + > + + {(resolvedValue) => ( + <> + + + Details + + + Required ({resolvedValue.dependency_count}) + + + Versions + + + + )} + + +
+ +
+
+ +
+
+
+ + ); +} + +const Actions = memo(function Actions(props: { + team: Awaited>; + version: Awaited>; +}) { + const { team, version } = props; + return ( +
+ + + + + Download + + {team.donation_link ? ( + + + + + + ) : null} +
+ ); +}); + +function packageMeta( + firstUploaded: ReactElement | undefined, + version: Awaited> +) { + return ( +
+
+
Date Uploaded
+
{firstUploaded}
+
+
+
Downloads
+
+ {formatInteger(version.download_count)} +
+
+
+
Size
+
+ {formatFileSize(version.size)} +
+
+
+
Dependency string
+
+
+ + {version.full_version_name} + + +
+
+
+
+ ); +} diff --git a/apps/cyberstorm-remix/app/p/tabs/Readme/PackageVersionReadme.tsx b/apps/cyberstorm-remix/app/p/tabs/Readme/PackageVersionReadme.tsx new file mode 100644 index 000000000..84edae530 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/tabs/Readme/PackageVersionReadme.tsx @@ -0,0 +1,85 @@ +import { Await, LoaderFunctionArgs } from "react-router"; +import { useLoaderData } from "react-router"; +import { DapperTs } from "@thunderstore/dapper-ts"; +import { + getPublicEnvVariables, + getSessionTools, +} from "cyberstorm/security/publicEnvVariables"; +import { Suspense } from "react"; +import { SkeletonBox } from "@thunderstore/cyberstorm"; +import "./Readme.css"; + +export async function loader({ params }: LoaderFunctionArgs) { + if (params.namespaceId && params.packageId && params.packageVersion) { + const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); + const dapper = new DapperTs(() => { + return { + apiHost: publicEnvVariables.VITE_API_URL, + sessionId: undefined, + }; + }); + return { + readme: await dapper.getPackageReadme( + params.namespaceId, + params.packageId, + params.packageVersion + ), + }; + } + return { + status: "error", + message: "Failed to load readme", + readme: { html: "" }, + }; +} + +export async function clientLoader({ params }: LoaderFunctionArgs) { + if (params.namespaceId && params.packageId && params.packageVersion) { + const tools = getSessionTools(); + const dapper = new DapperTs(() => { + return { + apiHost: tools?.getConfig().apiHost, + sessionId: tools?.getConfig().sessionId, + }; + }); + return { + readme: dapper.getPackageReadme( + params.namespaceId, + params.packageId, + params.packageVersion + ), + }; + } + return { + status: "error", + message: "Failed to load readme", + readme: { html: "" }, + }; +} + +export default function PackageVersionReadme() { + const { status, message, readme } = useLoaderData< + typeof loader | typeof clientLoader + >(); + + if (status === "error") return
{message}
; + return ( + }> + Error occurred while loading description} + > + {(resolvedValue) => ( + <> +
+
+
+ + )} + + + ); +} diff --git a/apps/cyberstorm-remix/app/p/tabs/Required/PackageVersionRequired.tsx b/apps/cyberstorm-remix/app/p/tabs/Required/PackageVersionRequired.tsx new file mode 100644 index 000000000..bbf711efc --- /dev/null +++ b/apps/cyberstorm-remix/app/p/tabs/Required/PackageVersionRequired.tsx @@ -0,0 +1,112 @@ +import "./Required.css"; +// import { Heading, SkeletonBox } from "@thunderstore/cyberstorm"; +// import { +// Await, +// LoaderFunctionArgs +// } from "react-router"; +// import { useLoaderData, useOutletContext } from "react-router"; +// import { ListingDependency } from "~/commonComponents/ListingDependency/ListingDependency"; +// import { DapperTs } from "@thunderstore/dapper-ts"; +// import { OutletContextShape } from "~/root"; +// import { +// getPublicEnvVariables, +// getSessionTools, +// } from "cyberstorm/security/publicEnvVariables"; +// import { Suspense } from "react"; + +// LOADERS ARE WAITING FOR PACKAGE DEPENDENCIES API ENDPOINT + +// export async function loader({ params }: LoaderFunctionArgs) { +// if ( +// params.communityId && +// params.namespaceId && +// params.packageId && +// params.packageVersion +// ) { +// const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); +// const dapper = new DapperTs(() => { +// return { +// apiHost: publicEnvVariables.VITE_API_URL, +// sessionId: undefined, +// }; +// }); +// return { +// listing: dapper.getPackageListingDetails( +// params.communityId, +// params.namespaceId, +// params.packageId +// ), +// }; +// } +// throw new Response("Listing dependencies not found", { status: 404 }); +// } + +// export async function clientLoader({ params }: LoaderFunctionArgs) { +// if ( +// params.communityId && +// params.namespaceId && +// params.packageId && +// params.packageVersion +// ) { +// const tools = getSessionTools(); +// const dapper = new DapperTs(() => { +// return { +// apiHost: tools?.getConfig().apiHost, +// sessionId: tools?.getConfig().sessionId, +// }; +// }); +// return { +// listing: dapper.getPackageListingDetails( +// params.communityId, +// params.namespaceId, +// params.packageId +// ), +// }; +// } +// throw new Response("Listing dependencies not found", { status: 404 }); +// } + +export default function PackageVersionRequired() { + // const { listing } = useLoaderData(); + // const outletContext = useOutletContext() as OutletContextShape; + + return

TODO; Waiting for package dependencies API endpoint

; + + // return ( + // }> + // Error occurred while loading required dependencies
+ // } + // > + // {(resolvedValue) => ( + // <> + //
+ //
+ // + // Required mods ({resolvedValue.dependencies.length}) + // + // + // This package requires the following packages to work. + // + //
+ //
+ // {resolvedValue.dependencies.map((dep, key) => { + // return ( + // + // ); + // })} + //
+ //
+ // + // )} + //
+ //
+ // ); +} diff --git a/apps/cyberstorm-remix/app/p/tabs/Versions/PackageVersionVersions.tsx b/apps/cyberstorm-remix/app/p/tabs/Versions/PackageVersionVersions.tsx new file mode 100644 index 000000000..7c404fe04 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/tabs/Versions/PackageVersionVersions.tsx @@ -0,0 +1,232 @@ +import { faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import "./Versions.css"; +import { + NewTableSort, + NewButton, + NewIcon, + NewTable, + NewTableLabels, + Heading, + NewAlert, + SkeletonBox, + NewLink, +} from "@thunderstore/cyberstorm"; +import { Await, LoaderFunctionArgs } from "react-router"; +import { useLoaderData } from "react-router"; +import { versionsSchema } from "@thunderstore/dapper-ts/src/methods/package"; +import { DapperTs } from "@thunderstore/dapper-ts"; +import semverGt from "semver/functions/gt"; +import semverLt from "semver/functions/lt"; +import semverValid from "semver/functions/valid"; +import { + TableCompareColumnMeta, + TableRow, +} from "@thunderstore/cyberstorm/src/newComponents/Table/Table"; +import { ThunderstoreLogo } from "@thunderstore/cyberstorm/src/svg/svg"; +import { + getPublicEnvVariables, + getSessionTools, +} from "cyberstorm/security/publicEnvVariables"; +import { Suspense } from "react"; + +export async function loader({ params }: LoaderFunctionArgs) { + if (params.communityId && params.namespaceId && params.packageId) { + const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); + const dapper = new DapperTs(() => { + return { + apiHost: publicEnvVariables.VITE_API_URL, + sessionId: undefined, + }; + }); + return { + communityId: params.communityId, + namespaceId: params.namespaceId, + packageId: params.packageId, + versions: dapper.getPackageVersions(params.namespaceId, params.packageId), + }; + } + return { + status: "error", + message: "Failed to load versions", + versions: versionsSchema.parse({}), + }; +} + +export async function clientLoader({ params }: LoaderFunctionArgs) { + if (params.communityId && params.namespaceId && params.packageId) { + const tools = getSessionTools(); + const dapper = new DapperTs(() => { + return { + apiHost: tools?.getConfig().apiHost, + sessionId: tools?.getConfig().sessionId, + }; + }); + return { + communityId: params.communityId, + namespaceId: params.namespaceId, + packageId: params.packageId, + versions: dapper.getPackageVersions(params.namespaceId, params.packageId), + }; + } + return { + status: "error", + message: "Failed to load versions", + versions: versionsSchema.parse({}), + }; +} + +type ConfirmedSemverStringType = string; + +export const isSemver = (s: string): s is ConfirmedSemverStringType => { + if (semverValid(s)) { + return true; + } else { + return false; + } +}; + +function rowSemverCompare( + a: TableRow, + b: TableRow, + columnMeta: TableCompareColumnMeta +) { + if (isSemver(String(a[0].sortValue)) && isSemver(String(b[0].sortValue))) { + if (semverLt(String(a[0].sortValue), String(b[0].sortValue))) { + return columnMeta.direction; + } + if (semverGt(String(a[0].sortValue), String(b[0].sortValue))) { + return -columnMeta.direction; + } + } + return 0; +} + +export default function Versions() { + const { communityId, namespaceId, packageId, status, message, versions } = + useLoaderData(); + + if (status === "error") { + return
{message}
; + } + + return ( + }> + + {(resolvedValue) => ( +
+ +
+ + Versions + + } + headers={columns} + rows={resolvedValue.map((v) => [ + { + value: ( + + {v.version_number} + + ), + sortValue: v.version_number, + }, + { + value: new Date(v.datetime_created).toUTCString(), + sortValue: v.datetime_created, + }, + { + value: v.download_count.toLocaleString(), + sortValue: v.download_count, + }, + { + value: ( +
+ + +
+ ), + sortValue: 0, + }, + ])} + sortDirection={NewTableSort.ASC} + csModifiers={["alignLastColumnRight"]} + customSortCompare={{ 0: rowSemverCompare }} + /> +
+
+ )} +
+
+ ); +} + +const columns: NewTableLabels = [ + { + value: "Version", + disableSort: false, + columnClasses: "package-versions__version", + }, + { + value: "Upload date", + disableSort: false, + columnClasses: "package-versions__upload-date", + }, + { + value: "Downloads", + disableSort: false, + columnClasses: "package-versions__downloads", + }, + { value: "Actions", disableSort: true }, +]; + +const ModManagerBanner = () => ( + + Please note that the install buttons only work if you have compatible client + software installed, such as the{" "} + + Thunderstore Mod Manager. + {" "} + Otherwise use the zip download links instead. + +); + +const DownloadLink = (props: { download_url: string }) => ( + + + + + Download + +); + +const InstallLink = (props: { install_url: string }) => ( + + + + + Install + +); From ba3bb80fad21f1095ca9a0250185e3492eb4aae9 Mon Sep 17 00:00:00 2001 From: Oksamies Date: Fri, 19 Sep 2025 17:14:33 +0300 Subject: [PATCH 05/10] Add PackageVersion pages and tabs (without community) These pages are and should be exactly the same, not including community related features and functionalities. The reason these have to be their own files is that, react-router doesn't support using the same files for two routes at the same time. The "Required" is mostly commented out, because the endpoint isn't yet available. (even for local development testing) --- .../app/p/packageVersionWithoutCommunity.tsx | 491 ++++++++++++++++++ .../PackageVersionWithoutCommunityReadme.tsx | 85 +++ ...PackageVersionWithoutCommunityRequired.tsx | 112 ++++ ...PackageVersionWithoutCommunityVersions.tsx | 232 +++++++++ 4 files changed, 920 insertions(+) create mode 100644 apps/cyberstorm-remix/app/p/packageVersionWithoutCommunity.tsx create mode 100644 apps/cyberstorm-remix/app/p/tabs/Readme/PackageVersionWithoutCommunityReadme.tsx create mode 100644 apps/cyberstorm-remix/app/p/tabs/Required/PackageVersionWithoutCommunityRequired.tsx create mode 100644 apps/cyberstorm-remix/app/p/tabs/Versions/PackageVersionWithoutCommunityVersions.tsx diff --git a/apps/cyberstorm-remix/app/p/packageVersionWithoutCommunity.tsx b/apps/cyberstorm-remix/app/p/packageVersionWithoutCommunity.tsx new file mode 100644 index 000000000..fce75dc31 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/packageVersionWithoutCommunity.tsx @@ -0,0 +1,491 @@ +import type { + LoaderFunctionArgs, + ShouldRevalidateFunctionArgs, +} from "react-router"; +import { + Await, + Outlet, + useLoaderData, + useLocation, + useOutletContext, +} from "react-router"; +import { + Drawer, + Heading, + NewAlert, + NewButton, + NewIcon, + NewLink, + SkeletonBox, + Tabs, +} from "@thunderstore/cyberstorm"; +import "./packageListing.css"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ThunderstoreLogo } from "@thunderstore/cyberstorm/src/svg/svg"; +import { + faUsers, + faHandHoldingHeart, + faDownload, + faCaretRight, +} from "@fortawesome/free-solid-svg-icons"; +import { + memo, + ReactElement, + Suspense, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useHydrated } from "remix-utils/use-hydrated"; +import { PageHeader } from "~/commonComponents/PageHeader/PageHeader"; +import { faArrowUpRight } from "@fortawesome/pro-solid-svg-icons"; +import { RelativeTime } from "@thunderstore/cyberstorm/src/components/RelativeTime/RelativeTime"; +import { + formatFileSize, + formatInteger, + formatToDisplayName, +} from "@thunderstore/cyberstorm/src/utils/utils"; +import { DapperTs } from "@thunderstore/dapper-ts"; +import { OutletContextShape } from "~/root"; +import { CopyButton } from "~/commonComponents/CopyButton/CopyButton"; +import { + getPublicEnvVariables, + getSessionTools, +} from "cyberstorm/security/publicEnvVariables"; +import { getTeamDetails } from "@thunderstore/dapper-ts/src/methods/team"; +import { isPromise } from "cyberstorm/utils/typeChecks"; +import { getPackageVersionDetails } from "@thunderstore/dapper-ts/src/methods/packageVersion"; + +export async function loader({ params }: LoaderFunctionArgs) { + if (params.namespaceId && params.packageId && params.packageVersion) { + const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); + const dapper = new DapperTs(() => { + return { + apiHost: publicEnvVariables.VITE_API_URL, + sessionId: undefined, + }; + }); + + return { + version: await dapper.getPackageVersionDetails( + params.namespaceId, + params.packageId, + params.packageVersion + ), + team: await dapper.getTeamDetails(params.namespaceId), + }; + } + throw new Response("Package not found", { status: 404 }); +} + +export async function clientLoader({ params }: LoaderFunctionArgs) { + if (params.namespaceId && params.packageId && params.packageVersion) { + const tools = getSessionTools(); + const dapper = new DapperTs(() => { + return { + apiHost: tools?.getConfig().apiHost, + sessionId: tools?.getConfig().sessionId, + }; + }); + + return { + version: dapper.getPackageVersionDetails( + params.namespaceId, + params.packageId, + params.packageVersion + ), + team: dapper.getTeamDetails(params.namespaceId), + }; + } + throw new Response("Package not found", { status: 404 }); +} + +export function shouldRevalidate(arg: ShouldRevalidateFunctionArgs) { + const oldPath = arg.currentUrl.pathname.split("/"); + const newPath = arg.nextUrl.pathname.split("/"); + // If we're staying on the same package page, don't revalidate + if ( + oldPath[2] === newPath[2] && + oldPath[4] === newPath[4] && + oldPath[5] === newPath[5] + ) { + return false; + } + return arg.defaultShouldRevalidate; +} + +export default function PackageVersion() { + const { version, team } = useLoaderData< + typeof loader | typeof clientLoader + >(); + + const location = useLocation(); + + const outletContext = useOutletContext() as OutletContextShape; + + const isHydrated = useHydrated(); + const startsHydrated = useRef(isHydrated); + + // START: For sidebar meta dates + const [firstUploaded, setFirstUploaded] = useState< + ReactElement | undefined + >(); + + // This will be loaded 2 times in development because of: + // https://react.dev/reference/react/StrictMode + // If strict mode is removed from the entry.client.tsx, this should only run once + useEffect(() => { + if (!startsHydrated.current && isHydrated) return; + if (isPromise(version)) { + version.then((versionData) => { + setFirstUploaded( + + ); + }); + } else { + setFirstUploaded( + + ); + } + }, []); + // END: For sidebar meta dates + + const currentTab = location.pathname.split("/")[6] || "details"; + + const versionAndTeamPromise = useMemo(() => Promise.all([version, team]), []); + + return ( + <> + + + {(resolvedValue) => ( + <> + + + + + + + + + + + + )} + + +
+
+
+
+ + You are viewing a potentially older version of this package. + + + } + > + + {(resolvedValue) => ( + + + + + + {resolvedValue.namespace} + + {resolvedValue.website_url ? ( + + {resolvedValue.website_url} + + + + + ) : null} + + } + > + {formatToDisplayName(resolvedValue.name)} + + )} + + + +
+ + + Details + + } + rootClasses="package-listing__drawer" + > + Loading...

}> + + {(resolvedValue) => ( + <>{packageMeta(firstUploaded, resolvedValue)} + )} + +
+
+ Loading...

}> + + {(resolvedValue) => ( + + )} + +
+
+ + } + > + + {(resolvedValue) => ( + <> + + + Details + + + Required ({resolvedValue.dependency_count}) + + + Versions + + + + )} + + +
+ +
+
+ +
+
+
+ + ); +} + +const Actions = memo(function Actions(props: { + team: Awaited>; + version: Awaited>; +}) { + const { team, version } = props; + return ( +
+ + + + + Download + + {team.donation_link ? ( + + + + + + ) : null} +
+ ); +}); + +function packageMeta( + firstUploaded: ReactElement | undefined, + version: Awaited> +) { + return ( +
+
+
Date Uploaded
+
{firstUploaded}
+
+
+
Downloads
+
+ {formatInteger(version.download_count)} +
+
+
+
Size
+
+ {formatFileSize(version.size)} +
+
+
+
Dependency string
+
+
+ + {version.full_version_name} + + +
+
+
+
+ ); +} diff --git a/apps/cyberstorm-remix/app/p/tabs/Readme/PackageVersionWithoutCommunityReadme.tsx b/apps/cyberstorm-remix/app/p/tabs/Readme/PackageVersionWithoutCommunityReadme.tsx new file mode 100644 index 000000000..84edae530 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/tabs/Readme/PackageVersionWithoutCommunityReadme.tsx @@ -0,0 +1,85 @@ +import { Await, LoaderFunctionArgs } from "react-router"; +import { useLoaderData } from "react-router"; +import { DapperTs } from "@thunderstore/dapper-ts"; +import { + getPublicEnvVariables, + getSessionTools, +} from "cyberstorm/security/publicEnvVariables"; +import { Suspense } from "react"; +import { SkeletonBox } from "@thunderstore/cyberstorm"; +import "./Readme.css"; + +export async function loader({ params }: LoaderFunctionArgs) { + if (params.namespaceId && params.packageId && params.packageVersion) { + const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); + const dapper = new DapperTs(() => { + return { + apiHost: publicEnvVariables.VITE_API_URL, + sessionId: undefined, + }; + }); + return { + readme: await dapper.getPackageReadme( + params.namespaceId, + params.packageId, + params.packageVersion + ), + }; + } + return { + status: "error", + message: "Failed to load readme", + readme: { html: "" }, + }; +} + +export async function clientLoader({ params }: LoaderFunctionArgs) { + if (params.namespaceId && params.packageId && params.packageVersion) { + const tools = getSessionTools(); + const dapper = new DapperTs(() => { + return { + apiHost: tools?.getConfig().apiHost, + sessionId: tools?.getConfig().sessionId, + }; + }); + return { + readme: dapper.getPackageReadme( + params.namespaceId, + params.packageId, + params.packageVersion + ), + }; + } + return { + status: "error", + message: "Failed to load readme", + readme: { html: "" }, + }; +} + +export default function PackageVersionReadme() { + const { status, message, readme } = useLoaderData< + typeof loader | typeof clientLoader + >(); + + if (status === "error") return
{message}
; + return ( + }> + Error occurred while loading description} + > + {(resolvedValue) => ( + <> +
+
+
+ + )} + + + ); +} diff --git a/apps/cyberstorm-remix/app/p/tabs/Required/PackageVersionWithoutCommunityRequired.tsx b/apps/cyberstorm-remix/app/p/tabs/Required/PackageVersionWithoutCommunityRequired.tsx new file mode 100644 index 000000000..bbf711efc --- /dev/null +++ b/apps/cyberstorm-remix/app/p/tabs/Required/PackageVersionWithoutCommunityRequired.tsx @@ -0,0 +1,112 @@ +import "./Required.css"; +// import { Heading, SkeletonBox } from "@thunderstore/cyberstorm"; +// import { +// Await, +// LoaderFunctionArgs +// } from "react-router"; +// import { useLoaderData, useOutletContext } from "react-router"; +// import { ListingDependency } from "~/commonComponents/ListingDependency/ListingDependency"; +// import { DapperTs } from "@thunderstore/dapper-ts"; +// import { OutletContextShape } from "~/root"; +// import { +// getPublicEnvVariables, +// getSessionTools, +// } from "cyberstorm/security/publicEnvVariables"; +// import { Suspense } from "react"; + +// LOADERS ARE WAITING FOR PACKAGE DEPENDENCIES API ENDPOINT + +// export async function loader({ params }: LoaderFunctionArgs) { +// if ( +// params.communityId && +// params.namespaceId && +// params.packageId && +// params.packageVersion +// ) { +// const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); +// const dapper = new DapperTs(() => { +// return { +// apiHost: publicEnvVariables.VITE_API_URL, +// sessionId: undefined, +// }; +// }); +// return { +// listing: dapper.getPackageListingDetails( +// params.communityId, +// params.namespaceId, +// params.packageId +// ), +// }; +// } +// throw new Response("Listing dependencies not found", { status: 404 }); +// } + +// export async function clientLoader({ params }: LoaderFunctionArgs) { +// if ( +// params.communityId && +// params.namespaceId && +// params.packageId && +// params.packageVersion +// ) { +// const tools = getSessionTools(); +// const dapper = new DapperTs(() => { +// return { +// apiHost: tools?.getConfig().apiHost, +// sessionId: tools?.getConfig().sessionId, +// }; +// }); +// return { +// listing: dapper.getPackageListingDetails( +// params.communityId, +// params.namespaceId, +// params.packageId +// ), +// }; +// } +// throw new Response("Listing dependencies not found", { status: 404 }); +// } + +export default function PackageVersionRequired() { + // const { listing } = useLoaderData(); + // const outletContext = useOutletContext() as OutletContextShape; + + return

TODO; Waiting for package dependencies API endpoint

; + + // return ( + // }> + // Error occurred while loading required dependencies
+ // } + // > + // {(resolvedValue) => ( + // <> + //
+ //
+ // + // Required mods ({resolvedValue.dependencies.length}) + // + // + // This package requires the following packages to work. + // + //
+ //
+ // {resolvedValue.dependencies.map((dep, key) => { + // return ( + // + // ); + // })} + //
+ //
+ // + // )} + //
+ //
+ // ); +} diff --git a/apps/cyberstorm-remix/app/p/tabs/Versions/PackageVersionWithoutCommunityVersions.tsx b/apps/cyberstorm-remix/app/p/tabs/Versions/PackageVersionWithoutCommunityVersions.tsx new file mode 100644 index 000000000..84770f7ea --- /dev/null +++ b/apps/cyberstorm-remix/app/p/tabs/Versions/PackageVersionWithoutCommunityVersions.tsx @@ -0,0 +1,232 @@ +import { faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import "./Versions.css"; +import { + NewTableSort, + NewButton, + NewIcon, + NewTable, + NewTableLabels, + Heading, + NewAlert, + SkeletonBox, + NewLink, +} from "@thunderstore/cyberstorm"; +import { Await, LoaderFunctionArgs } from "react-router"; +import { useLoaderData } from "react-router"; +import { versionsSchema } from "@thunderstore/dapper-ts/src/methods/package"; +import { DapperTs } from "@thunderstore/dapper-ts"; +import semverGt from "semver/functions/gt"; +import semverLt from "semver/functions/lt"; +import semverValid from "semver/functions/valid"; +import { + TableCompareColumnMeta, + TableRow, +} from "@thunderstore/cyberstorm/src/newComponents/Table/Table"; +import { ThunderstoreLogo } from "@thunderstore/cyberstorm/src/svg/svg"; +import { + getPublicEnvVariables, + getSessionTools, +} from "cyberstorm/security/publicEnvVariables"; +import { Suspense } from "react"; + +export async function loader({ params }: LoaderFunctionArgs) { + if (params.namespaceId && params.packageId) { + const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); + const dapper = new DapperTs(() => { + return { + apiHost: publicEnvVariables.VITE_API_URL, + sessionId: undefined, + }; + }); + return { + communityId: params.communityId, + namespaceId: params.namespaceId, + packageId: params.packageId, + versions: dapper.getPackageVersions(params.namespaceId, params.packageId), + }; + } + return { + status: "error", + message: "Failed to load versions", + versions: versionsSchema.parse({}), + }; +} + +export async function clientLoader({ params }: LoaderFunctionArgs) { + if (params.namespaceId && params.packageId) { + const tools = getSessionTools(); + const dapper = new DapperTs(() => { + return { + apiHost: tools?.getConfig().apiHost, + sessionId: tools?.getConfig().sessionId, + }; + }); + return { + communityId: params.communityId, + namespaceId: params.namespaceId, + packageId: params.packageId, + versions: dapper.getPackageVersions(params.namespaceId, params.packageId), + }; + } + return { + status: "error", + message: "Failed to load versions", + versions: versionsSchema.parse({}), + }; +} + +type ConfirmedSemverStringType = string; + +export const isSemver = (s: string): s is ConfirmedSemverStringType => { + if (semverValid(s)) { + return true; + } else { + return false; + } +}; + +function rowSemverCompare( + a: TableRow, + b: TableRow, + columnMeta: TableCompareColumnMeta +) { + if (isSemver(String(a[0].sortValue)) && isSemver(String(b[0].sortValue))) { + if (semverLt(String(a[0].sortValue), String(b[0].sortValue))) { + return columnMeta.direction; + } + if (semverGt(String(a[0].sortValue), String(b[0].sortValue))) { + return -columnMeta.direction; + } + } + return 0; +} + +export default function Versions() { + const { namespaceId, packageId, status, message, versions } = useLoaderData< + typeof loader | typeof clientLoader + >(); + + if (status === "error") { + return
{message}
; + } + + return ( + }> + + {(resolvedValue) => ( +
+ +
+ + Versions + + } + headers={columns} + rows={resolvedValue.map((v) => [ + { + value: ( + + {v.version_number} + + ), + sortValue: v.version_number, + }, + { + value: new Date(v.datetime_created).toUTCString(), + sortValue: v.datetime_created, + }, + { + value: v.download_count.toLocaleString(), + sortValue: v.download_count, + }, + { + value: ( +
+ + +
+ ), + sortValue: 0, + }, + ])} + sortDirection={NewTableSort.ASC} + csModifiers={["alignLastColumnRight"]} + customSortCompare={{ 0: rowSemverCompare }} + /> +
+
+ )} +
+
+ ); +} + +const columns: NewTableLabels = [ + { + value: "Version", + disableSort: false, + columnClasses: "package-versions__version", + }, + { + value: "Upload date", + disableSort: false, + columnClasses: "package-versions__upload-date", + }, + { + value: "Downloads", + disableSort: false, + columnClasses: "package-versions__downloads", + }, + { value: "Actions", disableSort: true }, +]; + +const ModManagerBanner = () => ( + + Please note that the install buttons only work if you have compatible client + software installed, such as the{" "} + + Thunderstore Mod Manager. + {" "} + Otherwise use the zip download links instead. + +); + +const DownloadLink = (props: { download_url: string }) => ( + + + + + Download + +); + +const InstallLink = (props: { install_url: string }) => ( + + + + + Install + +); From bb1523dfba8896d164768b0b40b850ea8fc76c8e Mon Sep 17 00:00:00 2001 From: Oksamies Date: Fri, 19 Sep 2025 17:15:31 +0300 Subject: [PATCH 06/10] Add Breadcrumbs for Package Version pages --- apps/cyberstorm-remix/app/root.tsx | 59 +++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/cyberstorm-remix/app/root.tsx b/apps/cyberstorm-remix/app/root.tsx index aff3784cd..20e1049d0 100644 --- a/apps/cyberstorm-remix/app/root.tsx +++ b/apps/cyberstorm-remix/app/root.tsx @@ -271,6 +271,10 @@ export function Layout({ children }: { children: React.ReactNode }) { const uploadPage = matches.find((m) => m.id === "upload/upload"); const communityPage = matches.find((m) => m.id === "c/community"); const packageListingPage = matches.find((m) => m.id === "p/packageListing"); + const packageVersionPage = matches.find((m) => m.id === "p/packageVersion"); + const packageVersionWithoutCommunityPage = matches.find( + (m) => m.id === "p/packageVersionWithoutCommunity" + ); const packageEditPage = matches.find((m) => m.id === "p/packageEdit"); const packageDependantsPage = matches.find( (m) => m.id === "p/dependants/Dependants" @@ -447,10 +451,12 @@ export function Layout({ children }: { children: React.ReactNode }) { communityPage || packageListingPage || packageDependantsPage || - packageTeamPage, + packageTeamPage || + packageVersionPage, Boolean(packageListingPage) || Boolean(packageDependantsPage) || - Boolean(packageTeamPage) + Boolean(packageTeamPage) || + Boolean(packageVersionPage) )} {/* Package listing page */} {getPackageListingBreadcrumb( @@ -458,6 +464,55 @@ export function Layout({ children }: { children: React.ReactNode }) { packageEditPage, packageDependantsPage )} + {/* Package Version Page */} + {packageVersionPage ? ( + <> + + {packageVersionPage.params.packageId} + + + + {packageVersionPage.params.packageVersion} + + + + ) : null} + {/* Package version without community Page */} + {packageVersionWithoutCommunityPage ? ( + <> + + + { + packageVersionWithoutCommunityPage.params + .namespaceId + } + + + + + { + packageVersionWithoutCommunityPage.params + .packageId + } + + + + + { + packageVersionWithoutCommunityPage.params + .packageVersion + } + + + + ) : null} {packageEditPage ? ( Edit package From 3b47761bf3fa71e0f825420217a11a6d08eecc22 Mon Sep 17 00:00:00 2001 From: Oksamies Date: Fri, 19 Sep 2025 17:16:14 +0300 Subject: [PATCH 07/10] Enhance formatToDisplayName to support full version display names --- packages/cyberstorm/src/utils/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cyberstorm/src/utils/utils.ts b/packages/cyberstorm/src/utils/utils.ts index efa6ba16a..c0833facb 100644 --- a/packages/cyberstorm/src/utils/utils.ts +++ b/packages/cyberstorm/src/utils/utils.ts @@ -58,4 +58,5 @@ export const componentClasses = ( return listOfClasses; }; -export const formatToDisplayName = (name: string) => name.replaceAll("_", " "); +export const formatToDisplayName = (name: string) => + name.replaceAll("-", " ").replaceAll("_", " "); From 7a4c9847a99375b9aed8daf4789bdc3b5d13156c Mon Sep 17 00:00:00 2001 From: Oksamies Date: Fri, 19 Sep 2025 17:44:56 +0300 Subject: [PATCH 08/10] Add Package Version pages into routes.ts to enable the pages Note that the ones that do not require a community to fetch the package version data, are not under the /c/ path --- apps/cyberstorm-remix/app/routes.ts | 30 +++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/cyberstorm-remix/app/routes.ts b/apps/cyberstorm-remix/app/routes.ts index b6ec10913..d8b87385b 100644 --- a/apps/cyberstorm-remix/app/routes.ts +++ b/apps/cyberstorm-remix/app/routes.ts @@ -32,9 +32,39 @@ export default [ ]), ]), ]), + route( + ":namespaceId/:packageId/v/:packageVersion", + "p/packageVersion.tsx", + [ + route( + "/c/:communityId/p/:namespaceId/:packageId/v/:packageVersion/", + "p/tabs/Readme/PackageVersionReadme.tsx" + ), + route("required", "p/tabs/Required/PackageVersionRequired.tsx"), + route("versions", "p/tabs/Versions/PackageVersionVersions.tsx"), + ] + ), route(":namespaceId/:packageId/edit", "p/packageEdit.tsx"), ]), ]), + route( + "/p/:namespaceId/:packageId/v/:packageVersion", + "p/packageVersionWithoutCommunity.tsx", + [ + route( + "/p/:namespaceId/:packageId/v/:packageVersion/", + "p/tabs/Readme/PackageVersionWithoutCommunityReadme.tsx" + ), + route( + "required", + "p/tabs/Required/PackageVersionWithoutCommunityRequired.tsx" + ), + route( + "versions", + "p/tabs/Versions/PackageVersionWithoutCommunityVersions.tsx" + ), + ] + ), route( "/c/:communityId/p/:namespaceId/:packageId/dependants", "p/dependants/Dependants.tsx" From b357c41c7c5e3d0f12838cc22855fdbe16fef649 Mon Sep 17 00:00:00 2001 From: Oksamies Date: Fri, 19 Sep 2025 17:45:48 +0300 Subject: [PATCH 09/10] Package Pages Versions tabs version numbers to be links to the specific version --- .../app/p/tabs/Versions/Versions.tsx | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/cyberstorm-remix/app/p/tabs/Versions/Versions.tsx b/apps/cyberstorm-remix/app/p/tabs/Versions/Versions.tsx index 2e95aded0..7c404fe04 100644 --- a/apps/cyberstorm-remix/app/p/tabs/Versions/Versions.tsx +++ b/apps/cyberstorm-remix/app/p/tabs/Versions/Versions.tsx @@ -11,6 +11,7 @@ import { Heading, NewAlert, SkeletonBox, + NewLink, } from "@thunderstore/cyberstorm"; import { Await, LoaderFunctionArgs } from "react-router"; import { useLoaderData } from "react-router"; @@ -31,7 +32,7 @@ import { import { Suspense } from "react"; export async function loader({ params }: LoaderFunctionArgs) { - if (params.namespaceId && params.packageId) { + if (params.communityId && params.namespaceId && params.packageId) { const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); const dapper = new DapperTs(() => { return { @@ -40,6 +41,9 @@ export async function loader({ params }: LoaderFunctionArgs) { }; }); return { + communityId: params.communityId, + namespaceId: params.namespaceId, + packageId: params.packageId, versions: dapper.getPackageVersions(params.namespaceId, params.packageId), }; } @@ -51,7 +55,7 @@ export async function loader({ params }: LoaderFunctionArgs) { } export async function clientLoader({ params }: LoaderFunctionArgs) { - if (params.namespaceId && params.packageId) { + if (params.communityId && params.namespaceId && params.packageId) { const tools = getSessionTools(); const dapper = new DapperTs(() => { return { @@ -60,6 +64,9 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { }; }); return { + communityId: params.communityId, + namespaceId: params.namespaceId, + packageId: params.packageId, versions: dapper.getPackageVersions(params.namespaceId, params.packageId), }; } @@ -97,9 +104,8 @@ function rowSemverCompare( } export default function Versions() { - const { status, message, versions } = useLoaderData< - typeof loader | typeof clientLoader - >(); + const { communityId, namespaceId, packageId, status, message, versions } = + useLoaderData(); if (status === "error") { return
{message}
; @@ -120,7 +126,22 @@ export default function Versions() { } headers={columns} rows={resolvedValue.map((v) => [ - { value: v.version_number, sortValue: v.version_number }, + { + value: ( + + {v.version_number} + + ), + sortValue: v.version_number, + }, { value: new Date(v.datetime_created).toUTCString(), sortValue: v.datetime_created, From 9f6ad82b4af7123ab4afe7e5356acbd627ee778d Mon Sep 17 00:00:00 2001 From: Oksamies Date: Fri, 19 Sep 2025 17:47:05 +0300 Subject: [PATCH 10/10] Add /p location to the ts-dev-proxy So that no-community-needing package pages are accessible through the proxy --- tools/ts-dev-proxy/nginx.conf | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/ts-dev-proxy/nginx.conf b/tools/ts-dev-proxy/nginx.conf index 7ba839c68..83b4aaefb 100644 --- a/tools/ts-dev-proxy/nginx.conf +++ b/tools/ts-dev-proxy/nginx.conf @@ -65,6 +65,11 @@ http { proxy_set_header Host new.thunderstore.temp; } + location /p { + proxy_pass http://host.docker.internal:3000/p; + proxy_set_header Host new.thunderstore.temp; + } + location /settings { proxy_pass http://host.docker.internal:3000/settings; proxy_set_header Host new.thunderstore.temp;