diff --git a/apps/studio/src/pages/home/releases/ReleaseList.tsx b/apps/studio/src/pages/home/releases/ReleaseList.tsx
new file mode 100644
index 00000000..f2dd66b1
--- /dev/null
+++ b/apps/studio/src/pages/home/releases/ReleaseList.tsx
@@ -0,0 +1,447 @@
+import React, { MouseEvent, useEffect, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+import {
+ Box,
+ Link,
+ Table,
+ TableBody,
+ TableContainer,
+ TableRow,
+ Typography,
+} from "@mui/material";
+import EditIcon from "@mui/icons-material/Edit";
+import VisibilityIcon from "@mui/icons-material/Visibility";
+import LinkIcon from "@mui/icons-material/Link";
+import DeleteIcon from "@mui/icons-material/Delete";
+
+import theme from "@newm-web/theme";
+import {
+ ActionMenu,
+ type ActionMenuItem,
+ ActionMenuTrigger,
+} from "@newm-web/components";
+import { TableCell, TablePagination, Tooltip } from "@newm-web/elements";
+import {
+ getImageSrc,
+ resizeCloudinaryImage,
+ useWindowDimensions,
+} from "@newm-web/utils";
+import { MintingStatus as MintingStatusType } from "@newm-web/types";
+import { NEWMCoverArtPlaceholderMin } from "@newm-web/assets";
+
+import NoSongsYet from "./NoSongsYet";
+import { ErrorOccurredMintingStatuses, MintingStatus } from "./MintingStatus";
+import TableHead from "./Table/TableHead";
+import ReleaseDeletionHelp from "./ReleaseDeletionHelp";
+import { convertMillisecondsToSongFormat } from "../../../modules/song";
+import { NEWM_SUPPORT_LINK } from "../../../common";
+
+// ! DO NOT USE IN PRODUCTION YET <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
+
+// * Tentative type definition for releases.
+// eslint-disable-next-line max-len
+// * based on: https://github.com/projectNEWM/newm-server/blob/master/newm-server/src/main/kotlin/io/newm/server/features/song/model/Release.kt
+// TODO: Move to a shared types file once we have the real data.
+export interface Release {
+ archived?: boolean | null;
+ barcodeNumber?: string | null;
+ barcodeType?: string | null;
+ coverArtUrl?: string | null;
+ createdAt?: string | null;
+ errorMessage?: string | null;
+ genres?: string[] | null;
+ hasSubmittedForDistribution?: boolean | null;
+ id: string;
+ locked?: boolean | null;
+ mintingStatus?: MintingStatusType | null;
+ ownerId?: string | null;
+ publicationDate?: string | null;
+ releaseDate?: string | null;
+ releaseType?: string | null;
+ title?: string | null;
+ totalTracksLength?: number | null;
+}
+
+interface ReleaseListProps {
+ query: string;
+ releaseData?: Release[];
+ rowHeight?: number;
+ totalCountOfSongs?: number;
+}
+
+// TODO: REmove once we have the real data.
+const mockReleases: Release[] = [
+ {
+ coverArtUrl:
+ // eslint-disable-next-line max-len
+ "https://res.cloudinary.com/newm/image/upload/w_40,h_40,c_fill,q_auto,f_auto/v1770039488/czjfak8zxdsspxf3ihfj.jpg",
+ genres: ["Pop"],
+ hasSubmittedForDistribution: false,
+ id: "release-1",
+ locked: false,
+ mintingStatus: MintingStatusType.SubmittedForDistribution,
+ title: "Indoor Kites",
+ },
+ {
+ coverArtUrl: "",
+ genres: ["Rock"],
+ hasSubmittedForDistribution: true,
+ id: "release-2",
+ locked: false,
+ mintingStatus: MintingStatusType.Undistributed,
+ title: "The Major League",
+ },
+ {
+ coverArtUrl:
+ // eslint-disable-next-line max-len
+ "https://res.cloudinary.com/newm/image/upload/w_40,h_40,c_fill,q_auto,f_auto/v1770039488/czjfak8zxdsspxf3ihfj.jpg",
+ genres: ["Alternative Rock", "Indie Rock", "Rock", "Alternative", "Indie"],
+ hasSubmittedForDistribution: true,
+ id: "release-3",
+ locked: true,
+ mintingStatus: MintingStatusType.Minted,
+ title: "Go!",
+ totalTracksLength: 460000,
+ },
+ {
+ coverArtUrl: "",
+ errorMessage: "Distribution error",
+ genres: ["Pop"],
+ id: "release-4",
+ locked: true,
+ mintingStatus: MintingStatusType.DistributionException,
+ title: "All I Want",
+ },
+ {
+ coverArtUrl: "",
+ errorMessage: "Declined",
+ genres: ["Electronic", "Instrumental"],
+ id: "release-5",
+ locked: true,
+ mintingStatus: MintingStatusType.Declined,
+ title: "Lafayette Blvd",
+ totalTracksLength: 220000,
+ },
+];
+
+const getTooltipContent = (mintingStatus: MintingStatusType) => {
+ const isErrorMintingStatus =
+ ErrorOccurredMintingStatuses.includes(mintingStatus);
+
+ let content: JSX.Element | string = "";
+
+ if (isErrorMintingStatus) {
+ content = (
+
+ An error has occurred. Please reach out via the{ " " }
+ event.stopPropagation() }
+ >
+ NEWM Support Portal
+ { " " }
+ for assistance distributing your release.
+
+ );
+ } else if (mintingStatus === MintingStatusType.Declined) {
+ content =
+ "One or more issues occurred resulting in your " +
+ "distribution being declined. Please check your " +
+ "email for information on how to correct and resubmit your release.";
+ }
+
+ return content;
+};
+
+export default function ReleaseList({
+ totalCountOfSongs,
+ query = "",
+ // TODO: Remove mock data once we have the real data.
+ releaseData = mockReleases,
+}: ReleaseListProps) {
+ const navigate = useNavigate();
+ const viewportHeight = useWindowDimensions()?.height;
+
+ const [rowsPerPage, setRowsPerPage] = useState(1);
+ const [page, setPage] = useState(1);
+ const [menuAnchorEl, setMenuAnchorEl] = useState(null);
+ const [menuRelease, setMenuRelease] = useState(null);
+
+ const headerHeight = 245;
+ const footerHeight = 40;
+ const bottomPadding = 30;
+ const rowHeight = 65;
+ let releasesToRequest = rowsPerPage;
+
+ const filteredReleases = useMemo(() => {
+ if (!query) return releaseData;
+ const lowerQuery = query.toLowerCase();
+
+ return releaseData.filter((release) =>
+ release.title?.toLowerCase().includes(lowerQuery)
+ );
+ }, [query, releaseData]);
+
+ // TODO(webStudioAlbumPhaseTwo): Use total count from the RTK query response.
+ const totalCountOfReleases = totalCountOfSongs ?? filteredReleases.length;
+ const lastRowOnPage = (page - 1) * rowsPerPage + rowsPerPage;
+ const totalPagesCount = Math.ceil(totalCountOfReleases / rowsPerPage);
+ const remainingRowsOnLastPage = totalCountOfReleases % rowsPerPage;
+
+ // * Determines how many releases to request for the last page.
+ if (page === totalPagesCount) {
+ releasesToRequest =
+ remainingRowsOnLastPage > 0 ? remainingRowsOnLastPage : rowsPerPage;
+ }
+
+ const pagedReleases = filteredReleases.slice(
+ (page - 1) * rowsPerPage,
+ (page - 1) * rowsPerPage + releasesToRequest
+ );
+
+ const handleRowClick = (
+ event: MouseEvent,
+ releaseId: string
+ ) => {
+ event.stopPropagation();
+ navigate(`/home/releases/${releaseId}`);
+ };
+
+ const handlePageChange = (
+ _event: React.ChangeEvent,
+ page: number
+ ) => {
+ setPage(page);
+ };
+
+ const handleMenuOpen =
+ (release: Release) => (event: MouseEvent) => {
+ event.stopPropagation();
+ setMenuAnchorEl(event.currentTarget);
+ setMenuRelease(release);
+ };
+
+ const handleMenuClose = () => {
+ setMenuAnchorEl(null);
+ setMenuRelease(null);
+ };
+
+ const actionMenuItems = useMemo>(() => {
+ if (!menuRelease) return [];
+
+ const isReleaseEditable = !menuRelease.locked;
+ const isReleaseDeletable = !menuRelease.locked;
+
+ const items: ActionMenuItem[] = [
+ {
+ icon: isReleaseEditable ? (
+
+ ) : (
+
+ ),
+ id: "view-edit",
+ label: isReleaseEditable ? "Edit" : "View",
+ onClick: () => navigate(`/home/releases/${menuRelease.id}`),
+ },
+ {
+ icon: ,
+ id: "view-outlet-urls",
+ label: "View outlet URLs",
+ onClick: () => null,
+ },
+ {
+ color: "danger",
+ disabled: !isReleaseDeletable,
+ icon: ,
+ id: "delete",
+ label: "Delete",
+ // TODO: Implement delete functionality | Render the DeleteReleaseModal.
+ onClick: () => null,
+ tooltip: !isReleaseDeletable ? : undefined,
+ tooltipPlacement: "left",
+ },
+ ];
+
+ return items;
+ }, [menuRelease, navigate]);
+
+ useEffect(() => {
+ setPage(1);
+ }, [query]);
+
+ useEffect(() => {
+ setMenuAnchorEl(null);
+ setMenuRelease(null);
+ }, [page, query, releaseData]);
+
+ // sets the # of rows per page depending on viewport height
+ useEffect(() => {
+ if (viewportHeight) {
+ const rowsWithCurrentHeight = Math.abs(
+ Math.floor(
+ (viewportHeight - headerHeight - footerHeight - bottomPadding) /
+ rowHeight
+ )
+ );
+
+ setRowsPerPage(rowsWithCurrentHeight ? rowsWithCurrentHeight : 1);
+ setPage(1);
+ }
+ }, [viewportHeight]);
+
+ if (!filteredReleases.length && !query) {
+ return ;
+ }
+
+ if (!pagedReleases.length) {
+ return No releases matched your search.;
+ }
+
+ return (
+
+
+
+
+ { pagedReleases.map((release) => {
+ return (
+ handleRowClick(event, release.id) }
+ >
+
+
+ { release.coverArtUrl ? (
+
+ ) : (
+
+ ) }
+
+ { release.title ?? "--" }
+
+
+
+
+ { release.mintingStatus ? (
+
+
+
+
+
+ ) : (
+ "--"
+ ) }
+
+
+ { release.genres?.length ? (
+
+
+ { release.genres.join(", ") }
+
+
+ ) : (
+ "--"
+ ) }
+
+
+ { /* // ! Phase 1 → NO. OF TRACKS = 1; single-track until /v1/releases supports 'trackCount' */ }
+ 1
+
+
+ { release.totalTracksLength
+ ? convertMillisecondsToSongFormat(release.totalTracksLength)
+ : "--:--" }
+
+
+
+
+
+ );
+ }) }
+
+
+ { totalCountOfReleases > pagedReleases.length && (
+
+ ) }
+
+
+
+
+ );
+}
diff --git a/packages/assets/src/index.ts b/packages/assets/src/index.ts
index f1fa5c0b..1790ab99 100644
--- a/packages/assets/src/index.ts
+++ b/packages/assets/src/index.ts
@@ -41,6 +41,7 @@ import referralBannerBackground from "./lib/images/referral-banner-background.we
import { default as NEWMMonsterPercussion } from "./lib/images/NEWMMonsterPercussion.png";
import { default as NEWMMonsterRock } from "./lib/images/NEWMMonsterRock.png";
import { default as NEWMMonsterGuitar } from "./lib/images/NEWMMonsterGuitar.png";
+import { default as NEWMCoverArtPlaceholderMin } from "./lib/images/cover-art-placeholder-min.png";
export {
AddImage,
@@ -64,6 +65,7 @@ export {
InfoCircleLine,
InstagramLogo,
ItunesLogo,
+ NEWMCoverArtPlaceholderMin,
NEWMLogo,
NEWMLogoSmInverse,
NEWMMarketplaceLogo,
diff --git a/packages/assets/src/lib/images/cover-art-placeholder-min.png b/packages/assets/src/lib/images/cover-art-placeholder-min.png
new file mode 100644
index 00000000..78df285b
Binary files /dev/null and b/packages/assets/src/lib/images/cover-art-placeholder-min.png differ
diff --git a/packages/assets/src/lib/images/cover-art-placeholder.png b/packages/assets/src/lib/images/cover-art-placeholder.png
new file mode 100644
index 00000000..328184e0
Binary files /dev/null and b/packages/assets/src/lib/images/cover-art-placeholder.png differ