commit 2409fecfef9c9e7168881d51fb56e33c7da0493a Author: Brendon Heinst Date: Tue Feb 24 17:14:07 2026 +0100 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..79c56cd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.next +.env* +.git diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c0c43b --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +/.vscode/ +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..587193f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 100, + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindStylesheet": "./app/globals.css" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..54a6600 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM node:20-alpine AS builder +WORKDIR /app + +# Only NEXT_PUBLIC_ vars are needed at build time (inlined into client bundle) +ARG NEXT_PUBLIC_APP_URL +ARG NEXT_PUBLIC_SANITY_PROJECT_ID +ARG NEXT_PUBLIC_SANITY_DATASET +ARG NEXT_PUBLIC_SANITY_API_VERSION + +ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL +ENV NEXT_PUBLIC_SANITY_PROJECT_ID=$NEXT_PUBLIC_SANITY_PROJECT_ID +ENV NEXT_PUBLIC_SANITY_DATASET=$NEXT_PUBLIC_SANITY_DATASET +ENV NEXT_PUBLIC_SANITY_API_VERSION=$NEXT_PUBLIC_SANITY_API_VERSION + +COPY package.json package-lock.json* ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:20-alpine +WORKDIR /app + +RUN addgroup -S nextjs && adduser -S nextjs -G nextjs + +COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nextjs /app/public ./public + +USER nextjs +ENV NODE_ENV=production +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/app/[slug]/layout.tsx b/app/[slug]/layout.tsx new file mode 100644 index 0000000..b966d03 --- /dev/null +++ b/app/[slug]/layout.tsx @@ -0,0 +1,12 @@ +import { ThemeToggleButton } from "@/components/header/ThemeToggleButton"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <> +
+ +
+ {children} + + ); +} diff --git a/app/[slug]/page.tsx b/app/[slug]/page.tsx new file mode 100644 index 0000000..0e2a116 --- /dev/null +++ b/app/[slug]/page.tsx @@ -0,0 +1,135 @@ +import { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; +import { cache } from "react"; +import { defineQuery } from "next-sanity"; +import { sanity } from "@/lib/sanity"; + +import { AnimatedText } from "@/components/AnimatedText"; +import { ReleaseCover } from "@/components/release/ReleaseCover"; +import { StreamingLinks } from "@/components/release/StreamingLinks"; +import { urlFor } from "@/lib/sanityImage"; +import { ARTIST_PLACEHOLDER_SRC } from "@/lib/constants"; +import { buildLinks, type Release } from "@/lib/release"; + +export const dynamicParams = true; +export const revalidate = 86400; + +const RELEASE_BY_CATALOG_NO_QUERY = defineQuery(` +*[_type == "release" && lower(catalogNo) == $slug][0]{ + name, + albumArtist, + label, + catalogNo, + albumCover, + officialUrl, + spotifyUrl, + appleMusicUrl, + deezerUrl, + amazonMusicUrl, + tidalUrl, + qobuzUrl, +} +`); + +const getRelease = cache(async (slug: string) => { + try { + const release = await sanity.fetch(RELEASE_BY_CATALOG_NO_QUERY, { slug }); + return release; + } catch (error) { + console.error("Failed to fetch release:", error); + return null; + } +}); + +const RELEASE_CATALOG_SLUGS_QUERY = defineQuery( + `*[_type == "release" && defined(catalogNo)][]{ "slug": lower(catalogNo) }`, +); + +export async function generateStaticParams() { + const releases = await sanity.fetch<{ slug: string }[]>(RELEASE_CATALOG_SLUGS_QUERY); + + return releases.map(({ slug }) => ({ slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + + if (!slug) return { title: "TRPTK" }; + + const release = await getRelease(slug.toLowerCase()); + + if (!release) return { title: "TRPTK" }; + + const description = `Listen to ${release.name}${release.albumArtist ? ` by ${release.albumArtist}` : ""} on all major streaming platforms.`; + + const ogImage = release.albumCover + ? urlFor(release.albumCover).width(1200).height(1200).url() + : undefined; + + return { + title: `${release.albumArtist ? `${release.albumArtist} • ` : ""}${release.name}`, + description, + openGraph: { + title: release.name, + description: `${release.albumArtist || "TRPTK"}${release.label ? ` - ${release.label}` : ""}`, + type: "music.album", + ...(ogImage && { images: [{ url: ogImage, width: 1200, height: 1200, alt: `${release.name} by ${release.albumArtist}` }] }), + }, + twitter: { + card: "summary_large_image", + title: release.name, + description, + ...(ogImage && { images: [ogImage] }), + }, + }; +} + +export default async function ReleasePage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + if (!slug) notFound(); + + const normalizedSlug = slug.toLowerCase(); + if (slug !== normalizedSlug) { + redirect(`/${normalizedSlug}`); + } + + const release = await getRelease(slug); + if (!release) notFound(); + + const links = buildLinks(release); + const title = release.name ?? slug; + const albumArtist = release.albumArtist ?? ""; + + return ( +
+
+
+ + +
+ + +
+
+ + +
+
+ ); +} diff --git a/app/account/layout.tsx b/app/account/layout.tsx new file mode 100644 index 0000000..fe3828a --- /dev/null +++ b/app/account/layout.tsx @@ -0,0 +1,18 @@ +import { Header } from "@/components/header/Header"; +import { Footer } from "@/components/footer/Footer"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "My Account", + robots: { index: false, follow: false }, +}; + +export default function AccountLayout({ children }: { children: React.ReactNode }) { + return ( + <> +
+ {children} +