From d9664ae5268126359dce063bfdc643fd14b8a076 Mon Sep 17 00:00:00 2001 From: Tony Prime Date: Tue, 19 May 2026 12:17:48 +0200 Subject: [PATCH 1/2] feat: migrate frontend to React components Replace the vanilla JS bundle that the three route shells used to load with proper React client components rendered through Next.js. The existing CSS, DOM structure, and behavior (WebSocket state sync, audio playback, visualizer broadcast/draw, artwork-derived primary color) are preserved as-is so the UI stays strictly identical. - Shared lib under src/lib for types, constants, helpers, the Shell wrapper, the queue list, and the useJamState / usePrimaryArtworkColor hooks - Page components now own their state, refs, and effects instead of injecting /assets/app.js - Drop public/app.js and the HomeJamPage helper Co-Authored-By: Claude Opus 4.7 (1M context) --- public/app.js | 446 ----------------------- src/app/(admin)/admin/page.tsx | 221 ++++++++++- src/app/(client)/client/page.tsx | 82 ++++- src/app/(visualizer)/visualizer/page.tsx | 92 ++++- src/app/page-shell.tsx | 8 - src/lib/QueueList.tsx | 35 ++ src/lib/Shell.tsx | 17 + src/lib/constants.ts | 14 + src/lib/helpers.ts | 9 + src/lib/types.ts | 30 ++ src/lib/useJamState.ts | 72 ++++ src/lib/usePrimaryColor.ts | 109 ++++++ 12 files changed, 675 insertions(+), 460 deletions(-) delete mode 100644 public/app.js create mode 100644 src/lib/QueueList.tsx create mode 100644 src/lib/Shell.tsx create mode 100644 src/lib/constants.ts create mode 100644 src/lib/helpers.ts create mode 100644 src/lib/types.ts create mode 100644 src/lib/useJamState.ts create mode 100644 src/lib/usePrimaryColor.ts diff --git a/public/app.js b/public/app.js deleted file mode 100644 index 30b6cb7..0000000 --- a/public/app.js +++ /dev/null @@ -1,446 +0,0 @@ -const app = document.querySelector("#app"); -const page = document.body.dataset.page || "client"; - -let state = { running: false, current: null, queue: [], downloads: {}, library: {} }; -let results = []; -let query = ""; -let audio; -let socket; -let statePoll; -let currentAudioId = null; -let adminAudioContext; -let adminAnalyser; -let adminData; -let adminSourceAudio; -let adminBroadcastFrame; -let visualLevels = []; -let visualFrame; -let primaryArtwork = ""; -const primaryColorFromArtwork = document.body.dataset.primaryColorFromArtwork === "true"; - -const placeholder = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600'%3E%3Crect width='600' height='600' fill='%2316161d'/%3E%3Cpath d='M395 145v236a78 78 0 1 1-32-63V218l-164 33v163a78 78 0 1 1-32-63V203z' fill='%2372f2a1'/%3E%3C/svg%3E"; -const visualizerBarCount = 28; - -connect(); -loadState(); -startStatePolling(); - -function connect() { - const protocol = location.protocol === "https:" ? "wss" : "ws"; - socket = new WebSocket(`${protocol}://${location.host}/ws`); - socket.addEventListener("message", (event) => { - const message = JSON.parse(event.data); - if (message.type === "state") { - state = message.state; - applyArtworkPrimaryColor(state.current?.track.artwork || ""); - render(); - syncAudio(); - } else if (message.type === "visualizer") { - visualLevels = Array.isArray(message.levels) ? message.levels.slice(0, visualizerBarCount) : []; - } - }); - socket.addEventListener("open", startAdminVisualizer); - socket.addEventListener("error", () => socket.close()); - socket.addEventListener("close", () => setTimeout(connect, 1000)); -} - -function startStatePolling() { - if (statePoll) clearInterval(statePoll); - statePoll = setInterval(() => { - if (!socket || socket.readyState !== WebSocket.OPEN) loadState(); - }, 2000); -} - -async function loadState() { - const response = await fetch("/api/state"); - state = await response.json(); - applyArtworkPrimaryColor(state.current?.track.artwork || ""); - render(); - syncAudio(); -} - -function render() { - if (page === "admin") renderAdmin(); - else if (page === "visualizer") renderVisualizer(); - else renderClient(); -} - -function shell(title, subtitle, content) { - app.innerHTML = ` -
-
- HomeJam - ${title} -
- ${content} -
`; -} - -function renderClient() { - shell( - "Invite", - "", - `
-
-
-

Recherche

- iTunes metadata -
- -
${results.map(resultCard).join("") || empty("Lance une recherche pour proposer un morceau.")}
-
-
-

Queue

${state.queue.length} titre${state.queue.length > 1 ? "s" : ""}
- ${queueList(false)} -
-
` - ); - bindSearch(); - app.querySelectorAll("[data-add]").forEach((button) => { - button.addEventListener("click", async () => { - const track = results[Number(button.dataset.add)]; - await fetch("/api/queue", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(track) }); - }); - }); -} - -function renderAdmin() { - const current = state.current; - const existingAudio = audio; - const shouldKeepAudio = existingAudio && currentAudioId === current?.id; - shell( - "Admin", - "", - `
-
-
-
-

${state.running ? "Jam active" : "Jam arretee"}

-

${escapeHtml(current?.track.title || "Aucun morceau")}

-

${escapeHtml(current ? `${current.track.artist} - ${current.track.album}` : "Le prochain morceau pret partira automatiquement.")}

-
- - - -
- -

-
-
-
-

Downloads

yt-dlp
- ${downloadList()} -
-
-

Queue

${state.queue.length} titre${state.queue.length > 1 ? "s" : ""}
- ${queueList(true)} -
-
` - ); - const renderedAudio = app.querySelector("#player"); - if (shouldKeepAudio) { - renderedAudio.replaceWith(existingAudio); - audio = existingAudio; - } else { - audio = renderedAudio; - audio.addEventListener("ended", () => fetch("/api/player/ended", { method: "POST" })); - } - app.querySelector("#toggleJam").addEventListener("click", () => { - fetch(`/api/jam/${state.running ? "stop" : "start"}`, { method: "POST" }); - }); - app.querySelector("#skipTrack").addEventListener("click", () => fetch("/api/player/skip", { method: "POST" })); - app.querySelector("#clearQueue").addEventListener("click", () => fetch("/api/queue", { method: "DELETE" })); - app.querySelectorAll("[data-remove]").forEach((button) => button.addEventListener("click", () => fetch(`/api/queue/${button.dataset.remove}`, { method: "DELETE" }))); - audio.addEventListener("play", startAdminVisualizer); -} - -function renderVisualizer() { - const current = state.current; - const track = current?.track; - shell( - "Visualizer", - "", - `
-
-
- ${track ? `Pochette de ${escapeHtml(track.album)}` : -
-
-

${escapeHtml(track?.title || "En attente")}

-
- ${escapeHtml(track?.artist || "HomeJam")} - ${escapeHtml(track?.album || "En attente")} -
-
- ${waveform()} -
- -
` - ); - drawVisualizer(); -} - -function waveform() { - return ``; -} - -function visualQueueList() { - const items = state.queue; - if (!items.length) return empty("Aucun morceau en attente."); - return `
${items.map((item, index) => `
- ${String(index + 1).padStart(2, "0")} - -
- ${escapeHtml(item.track.title)} - ${escapeHtml(item.track.artist)} -
-
`).join("")}
`; -} - -function startAdminVisualizer() { - if (page !== "admin" || !audio) return; - if (!audio.currentSrc || audio.paused) return; - const AudioContextClass = window.AudioContext || window.webkitAudioContext; - if (!AudioContextClass) return; - - if (!adminAudioContext) { - adminAudioContext = new AudioContextClass(); - adminAnalyser = adminAudioContext.createAnalyser(); - adminAnalyser.fftSize = 128; - adminAnalyser.smoothingTimeConstant = 0.74; - adminData = new Uint8Array(adminAnalyser.frequencyBinCount); - } - - if (adminSourceAudio !== audio) { - const source = adminAudioContext.createMediaElementSource(audio); - source.connect(adminAnalyser); - adminAnalyser.connect(adminAudioContext.destination); - adminSourceAudio = audio; - } - - if (adminAudioContext.state === "suspended") adminAudioContext.resume().catch(() => undefined); - broadcastVisualizer(); -} - -function broadcastVisualizer() { - if (adminBroadcastFrame) cancelAnimationFrame(adminBroadcastFrame); - const send = () => { - if (adminAnalyser && adminData && socket?.readyState === WebSocket.OPEN) { - adminAnalyser.getByteFrequencyData(adminData); - const levels = Array.from({ length: visualizerBarCount }, (_, index) => { - const start = Math.floor(index * adminData.length / visualizerBarCount); - const end = Math.max(start + 1, Math.floor((index + 1) * adminData.length / visualizerBarCount)); - let total = 0; - for (let cursor = start; cursor < end; cursor += 1) total += adminData[cursor]; - return Math.min(1, total / (end - start) / 255); - }); - socket.send(JSON.stringify({ type: "visualizer", levels })); - } - adminBroadcastFrame = requestAnimationFrame(send); - }; - send(); -} - -function drawVisualizer() { - if (visualFrame) cancelAnimationFrame(visualFrame); - const bars = Array.from(app.querySelectorAll(".wave span")); - if (!bars.length) return; - - const draw = () => { - bars.forEach((bar, index) => { - const level = Math.max(0.06, Number(visualLevels[index] || 0)); - bar.style.transform = `scaleY(${level})`; - bar.style.opacity = String(0.34 + level * 0.66); - }); - visualFrame = requestAnimationFrame(draw); - }; - draw(); -} - -function applyArtworkPrimaryColor(artwork) { - if (!primaryColorFromArtwork || artwork === primaryArtwork) return; - primaryArtwork = artwork; - if (!artwork) return; - getDominantColor(artwork).then((color) => { - if (!color || artwork !== primaryArtwork) return; - document.body.style.setProperty("--primary-color", color.hex); - document.body.style.setProperty("--primary-color-rgb", `${color.red}, ${color.green}, ${color.blue}`); - }); -} - -function getDominantColor(source) { - return new Promise((resolve) => { - const image = new Image(); - image.crossOrigin = "anonymous"; - image.onload = () => resolve(extractDominantColor(image)); - image.onerror = () => resolve(null); - image.src = source; - }); -} - -function extractDominantColor(image) { - const canvas = document.createElement("canvas"); - const size = 48; - canvas.width = size; - canvas.height = size; - const context = canvas.getContext("2d", { willReadFrequently: true }); - if (!context) return null; - let pixels; - try { - context.drawImage(image, 0, 0, size, size); - pixels = context.getImageData(0, 0, size, size).data; - } catch { - return null; - } - const buckets = new Map(); - - for (let index = 0; index < pixels.length; index += 16) { - const red = pixels[index]; - const green = pixels[index + 1]; - const blue = pixels[index + 2]; - const alpha = pixels[index + 3]; - if (alpha < 180) continue; - - const max = Math.max(red, green, blue); - const min = Math.min(red, green, blue); - const saturation = max === 0 ? 0 : (max - min) / max; - const lightness = (max + min) / 510; - if (saturation < 0.18 || lightness < 0.16 || lightness > 0.88) continue; - - const key = `${Math.round(red / 24)},${Math.round(green / 24)},${Math.round(blue / 24)}`; - const bucket = buckets.get(key) || { red: 0, green: 0, blue: 0, score: 0, count: 0 }; - const score = 1 + saturation * 2; - bucket.red += red; - bucket.green += green; - bucket.blue += blue; - bucket.score += score; - bucket.count += 1; - buckets.set(key, bucket); - } - - const best = Array.from(buckets.values()).sort((left, right) => right.score - left.score)[0]; - if (!best) return null; - const color = brightenForVisibility( - Math.round(best.red / best.count), - Math.round(best.green / best.count), - Math.round(best.blue / best.count) - ); - const { red, green, blue } = color; - return { red, green, blue, hex: rgbToHex(red, green, blue) }; -} - -function brightenForVisibility(red, green, blue) { - let color = { red, green, blue }; - while (relativeLuminance(color.red, color.green, color.blue) < 0.42) { - color = { - red: mixChannel(color.red, 255, 0.22), - green: mixChannel(color.green, 255, 0.22), - blue: mixChannel(color.blue, 255, 0.22), - }; - } - return color; -} - -function mixChannel(value, target, amount) { - return Math.round(value + (target - value) * amount); -} - -function relativeLuminance(red, green, blue) { - return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255; -} - -function rgbToHex(red, green, blue) { - return `#${[red, green, blue].map((value) => value.toString(16).padStart(2, "0")).join("")}`; -} - -function bindSearch() { - const form = app.querySelector("#searchForm"); - const input = app.querySelector("#searchInput"); - form.addEventListener("submit", async (event) => { - event.preventDefault(); - query = input.value.trim(); - if (query.length < 2) return; - const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`); - results = await response.json(); - render(); - }); -} - -function syncAudio() { - if (page !== "admin" || !audio) return; - const current = state.current; - if (!current?.track.localPath) { - currentAudioId = null; - audio.removeAttribute("src"); - return; - } - if (currentAudioId !== current.id) { - currentAudioId = current.id; - audio.src = current.track.localPath; - audio.load(); - } - if (state.running) { - audio.play().then(startAdminVisualizer).catch(() => { - const hint = app.querySelector("#autoplayHint"); - if (hint) hint.textContent = "Le navigateur bloque le premier demarrage automatique: autorise l'audio pour cette page, puis l'enchainement sera automatique."; - }); - } else { - audio.pause(); - } -} - -function resultCard(track, index) { - return `
- -
-

${escapeHtml(track.title)}

-

${escapeHtml(track.artist)} - ${escapeHtml(track.album)}

-
- -
`; -} - -function queueList(admin) { - const items = state.queue; - if (!items.length) return empty("Aucun morceau en attente."); - return `
${items.map((item) => `
- -
- ${escapeHtml(item.track.title)} - ${escapeHtml(item.track.artist)} - ${label(item)} -
- ${admin ? `` : ""} -
`).join("")}
`; -} - -function downloadList() { - const items = state.queue.filter((item) => item.status === "downloading" || item.status === "failed"); - if (!items.length) return empty("Aucun telechargement actif."); - return `
${items.map((item) => `
-
${escapeHtml(item.track.title)}${label(item)}
-
- ${item.error ? `

${escapeHtml(item.error)}

` : ""} -
`).join("")}
`; -} - -function label(item) { - if (item.status === "downloading") return `telechargement ${Math.round(item.progress)}%`; - if (item.status === "ready") return "pret"; - if (item.status === "playing") return "en lecture"; - if (item.status === "failed") return "echec"; - return "en file"; -} - -function empty(text) { - return `

${text}

`; -} - -function escapeHtml(value) { - return String(value).replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[char]); -} diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx index 50e7f2e..7a81d8e 100644 --- a/src/app/(admin)/admin/page.tsx +++ b/src/app/(admin)/admin/page.tsx @@ -1,5 +1,222 @@ -import { HomeJamPage } from "../../page-shell"; +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Shell } from "../../../lib/Shell"; +import { QueueList } from "../../../lib/QueueList"; +import { useJamState } from "../../../lib/useJamState"; +import { usePrimaryArtworkColor } from "../../../lib/usePrimaryColor"; +import { placeholder, visualizerBarCount } from "../../../lib/constants"; +import { label } from "../../../lib/helpers"; + +type WebkitWindow = Window & { webkitAudioContext?: typeof AudioContext }; export default function AdminPage() { - return ; + const { state, socketRef } = useJamState(); + const audioRef = useRef(null); + const audioContextRef = useRef(null); + const analyserRef = useRef(null); + const sourceAudioRef = useRef(null); + const dataRef = useRef | null>(null); + const broadcastFrameRef = useRef(null); + const currentAudioIdRef = useRef(null); + const [autoplayHint, setAutoplayHint] = useState(""); + + usePrimaryArtworkColor(state.current?.track.artwork || ""); + + function startVisualizer() { + const audio = audioRef.current; + if (!audio || !audio.currentSrc || audio.paused) return; + const win = window as WebkitWindow; + const AudioContextClass = window.AudioContext || win.webkitAudioContext; + if (!AudioContextClass) return; + + if (!audioContextRef.current) { + const ctx = new AudioContextClass(); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 128; + analyser.smoothingTimeConstant = 0.74; + audioContextRef.current = ctx; + analyserRef.current = analyser; + dataRef.current = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount)); + } + + if (sourceAudioRef.current !== audio) { + try { + const source = audioContextRef.current.createMediaElementSource(audio); + source.connect(analyserRef.current!); + analyserRef.current!.connect(audioContextRef.current.destination); + } catch { + // Already connected for this element — ignore. + } + sourceAudioRef.current = audio; + } + + if (audioContextRef.current.state === "suspended") { + audioContextRef.current.resume().catch(() => undefined); + } + + broadcastVisualizer(); + } + + function broadcastVisualizer() { + if (broadcastFrameRef.current) cancelAnimationFrame(broadcastFrameRef.current); + const send = () => { + const analyser = analyserRef.current; + const data = dataRef.current; + const socket = socketRef.current; + if (analyser && data && socket?.readyState === WebSocket.OPEN) { + analyser.getByteFrequencyData(data); + const levels = Array.from({ length: visualizerBarCount }, (_, index) => { + const start = Math.floor((index * data.length) / visualizerBarCount); + const end = Math.max(start + 1, Math.floor(((index + 1) * data.length) / visualizerBarCount)); + let total = 0; + for (let cursor = start; cursor < end; cursor += 1) total += data[cursor]; + return Math.min(1, total / (end - start) / 255); + }); + socket.send(JSON.stringify({ type: "visualizer", levels })); + } + broadcastFrameRef.current = requestAnimationFrame(send); + }; + send(); + } + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + const onEnded = () => { + fetch("/api/player/ended", { method: "POST" }); + }; + const onPlay = () => startVisualizer(); + audio.addEventListener("ended", onEnded); + audio.addEventListener("play", onPlay); + return () => { + audio.removeEventListener("ended", onEnded); + audio.removeEventListener("play", onPlay); + if (broadcastFrameRef.current) { + cancelAnimationFrame(broadcastFrameRef.current); + broadcastFrameRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + const current = state.current; + if (!current?.track.localPath) { + currentAudioIdRef.current = null; + audio.removeAttribute("src"); + return; + } + if (currentAudioIdRef.current !== current.id) { + currentAudioIdRef.current = current.id; + audio.src = current.track.localPath; + audio.load(); + } + if (state.running) { + audio + .play() + .then(() => startVisualizer()) + .catch(() => { + setAutoplayHint( + "Le navigateur bloque le premier demarrage automatique: autorise l'audio pour cette page, puis l'enchainement sera automatique.", + ); + }); + } else { + audio.pause(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); + + const current = state.current; + const downloads = state.queue.filter((item) => item.status === "downloading" || item.status === "failed"); + + return ( + +
+
+
+ +
+
+

{state.running ? "Jam active" : "Jam arretee"}

+

{current?.track.title || "Aucun morceau"}

+

+ {current + ? `${current.track.artist} - ${current.track.album}` + : "Le prochain morceau pret partira automatiquement."} +

+
+ + + +
+
+
+
+
+

Downloads

+ yt-dlp +
+ {downloads.length ? ( +
+ {downloads.map((item) => ( +
+
+ {item.track.title} + {label(item)} +
+
+ +
+ {item.error ?

{item.error}

: null} +
+ ))} +
+ ) : ( +

Aucun telechargement actif.

+ )} +
+
+
+

Queue

+ + {state.queue.length} titre{state.queue.length > 1 ? "s" : ""} + +
+ { + fetch(`/api/queue/${id}`, { method: "DELETE" }); + }} + /> +
+
+
+ ); } diff --git a/src/app/(client)/client/page.tsx b/src/app/(client)/client/page.tsx index f23349a..dbcac7e 100644 --- a/src/app/(client)/client/page.tsx +++ b/src/app/(client)/client/page.tsx @@ -1,5 +1,83 @@ -import { HomeJamPage } from "../../page-shell"; +"use client"; + +import { useState, type FormEvent } from "react"; +import { Shell } from "../../../lib/Shell"; +import { QueueList } from "../../../lib/QueueList"; +import { useJamState } from "../../../lib/useJamState"; +import { usePrimaryArtworkColor } from "../../../lib/usePrimaryColor"; +import { placeholder } from "../../../lib/constants"; +import type { Track } from "../../../lib/types"; export default function ClientPage() { - return ; + const { state } = useJamState(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + + usePrimaryArtworkColor(state.current?.track.artwork || ""); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + const trimmed = query.trim(); + if (trimmed.length < 2) return; + const response = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`); + setResults(await response.json()); + } + + async function addTrack(track: Track) { + await fetch("/api/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(track), + }); + } + + return ( + +
+
+
+

Recherche

+ iTunes metadata +
+
+ setQuery(event.target.value)} + placeholder="Titre, artiste, album" + autoComplete="off" + /> + +
+
+ {results.length ? ( + results.map((track) => ( +
+ +
+

{track.title}

+

+ {track.artist} - {track.album} +

+
+ +
+ )) + ) : ( +

Lance une recherche pour proposer un morceau.

+ )} +
+
+
+
+

Queue

+ + {state.queue.length} titre{state.queue.length > 1 ? "s" : ""} + +
+ +
+
+
+ ); } diff --git a/src/app/(visualizer)/visualizer/page.tsx b/src/app/(visualizer)/visualizer/page.tsx index 46eb97b..1cd862c 100644 --- a/src/app/(visualizer)/visualizer/page.tsx +++ b/src/app/(visualizer)/visualizer/page.tsx @@ -1,5 +1,93 @@ -import { HomeJamPage } from "../../page-shell"; +"use client"; + +import { useEffect, useRef, type CSSProperties } from "react"; +import { Shell } from "../../../lib/Shell"; +import { useJamState } from "../../../lib/useJamState"; +import { usePrimaryArtworkColor } from "../../../lib/usePrimaryColor"; +import { placeholder, visualizerBarCount } from "../../../lib/constants"; export default function VisualizerPage() { - return ; + const levelsRef = useRef([]); + const waveRef = useRef(null); + const frameRef = useRef(null); + + const { state } = useJamState({ + onVisualizerMessage: (levels) => { + levelsRef.current = levels.slice(0, visualizerBarCount); + }, + }); + + usePrimaryArtworkColor(state.current?.track.artwork || ""); + + useEffect(() => { + const wave = waveRef.current; + if (!wave) return; + const bars = Array.from(wave.querySelectorAll("span")); + if (!bars.length) return; + const draw = () => { + bars.forEach((bar, index) => { + const level = Math.max(0.06, Number(levelsRef.current[index] || 0)); + bar.style.transform = `scaleY(${level})`; + bar.style.opacity = String(0.34 + level * 0.66); + }); + frameRef.current = requestAnimationFrame(draw); + }; + draw(); + return () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current); + frameRef.current = null; + }; + }, []); + + const current = state.current; + const track = current?.track; + const artwork = track?.artwork || placeholder; + const visualStyle = { "--ambient-art": `url('${artwork}')` } as CSSProperties; + const visibleQueue = state.queue; + + return ( + +
+
+
+ {track +
+
+

{track?.title || "En attente"}

+
+ {track?.artist || "HomeJam"} + {track?.album || "En attente"} +
+
+ +
+ +
+
+ ); } diff --git a/src/app/page-shell.tsx b/src/app/page-shell.tsx index ed14d5b..74518a8 100644 --- a/src/app/page-shell.tsx +++ b/src/app/page-shell.tsx @@ -45,11 +45,3 @@ export function HomeJamDocument({ page, children }: { page: Page; children: Reac ); } -export function HomeJamPage() { - return ( - <> -
-