feat(app): wire frontend to real API, remove mock data and sessionStorage
This commit is contained in:
@@ -8,7 +8,8 @@ import {
|
||||
} from "~/components/ui/sheet";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import type { Song, SongSummary } from "~/lib/types";
|
||||
import type { SongSummary } from "~/lib/types";
|
||||
import { createSong } from "~/lib/api";
|
||||
import { previewChords } from "~/lib/mock";
|
||||
|
||||
interface Props {
|
||||
@@ -58,35 +59,13 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const apiBase = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
|
||||
const body = fileHtml
|
||||
? { html: fileHtml }
|
||||
: { source: url.trim() };
|
||||
|
||||
const resp = await fetch(`${apiBase}/tabs/parse`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error((data as { error?: string }).error ?? `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
const song: Song = await resp.json();
|
||||
const id = `new-${Date.now()}`;
|
||||
sessionStorage.setItem(id, JSON.stringify(song));
|
||||
|
||||
const summary: SongSummary = {
|
||||
id,
|
||||
meta: song.meta,
|
||||
preview_chords: previewChords(song),
|
||||
};
|
||||
onSongAdded(summary);
|
||||
const stored = await createSong(
|
||||
fileHtml ? { html: fileHtml } : { source: url.trim() }
|
||||
);
|
||||
onSongAdded({ id: stored.id, meta: stored.song.meta, preview_chords: previewChords(stored.song) });
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
navigate(`/songs/${id}`);
|
||||
navigate(`/songs/${stored.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
|
||||
45
app/app/lib/api.ts
Normal file
45
app/app/lib/api.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Song, SongSummary, StoredSong } from "./types";
|
||||
|
||||
function getApiBase(): string {
|
||||
// Works in both SSR (Node/process.env) and client (import.meta.env)
|
||||
if (typeof process !== "undefined" && process.env?.API_URL) {
|
||||
return process.env.API_URL;
|
||||
}
|
||||
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_API_URL) {
|
||||
return (import.meta as any).env.VITE_API_URL;
|
||||
}
|
||||
return "http://localhost:8000";
|
||||
}
|
||||
|
||||
export async function listSongs(): Promise<SongSummary[]> {
|
||||
const res = await fetch(`${getApiBase()}/songs`);
|
||||
if (!res.ok) throw new Error(`Failed to load songs: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getSong(id: string): Promise<Song | null> {
|
||||
const res = await fetch(`${getApiBase()}/songs/${id}`);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`Failed to load song: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createSong(body: {
|
||||
source?: string;
|
||||
html?: string;
|
||||
}): Promise<StoredSong> {
|
||||
const res = await fetch(`${getApiBase()}/songs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error((data as { error?: string }).error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteSong(id: string): Promise<void> {
|
||||
await fetch(`${getApiBase()}/songs/${id}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -35,3 +35,8 @@ export interface SongSummary {
|
||||
// First 5 unique chord names from the song, in order of appearance
|
||||
preview_chords: string[];
|
||||
}
|
||||
|
||||
export interface StoredSong {
|
||||
id: string;
|
||||
song: Song;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Plus } from "lucide-react";
|
||||
import { SongCard } from "~/components/song-card";
|
||||
import { AddSongSheet } from "~/components/add-song-sheet";
|
||||
import { MOCK_SONGS } from "~/lib/mock";
|
||||
import { listSongs } from "~/lib/api";
|
||||
import type { SongSummary } from "~/lib/types";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
@@ -16,8 +16,13 @@ export function meta({}: Route.MetaArgs) {
|
||||
];
|
||||
}
|
||||
|
||||
export function loader() {
|
||||
return { songs: MOCK_SONGS };
|
||||
export async function loader() {
|
||||
try {
|
||||
const songs = await listSongs();
|
||||
return { songs };
|
||||
} catch {
|
||||
return { songs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
@@ -4,8 +4,7 @@ import type { Route } from "./+types/songs.$id";
|
||||
import { TransposeBar } from "~/components/transpose-bar";
|
||||
import { ChordChart } from "~/components/chord-chart";
|
||||
import { transposeSong } from "~/lib/transpose";
|
||||
import { getMockSong } from "~/lib/mock";
|
||||
import type { Song } from "~/lib/types";
|
||||
import { getSong } from "~/lib/api";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs) {
|
||||
if (!data?.song) return [{ title: "PocketChords" }];
|
||||
@@ -15,53 +14,21 @@ export function meta({ data }: Route.MetaArgs) {
|
||||
];
|
||||
}
|
||||
|
||||
export function loader({ params }: Route.LoaderArgs) {
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const id = params.id ?? "";
|
||||
|
||||
// Temporary songs are stored in sessionStorage on the client.
|
||||
// During SSR, we can't access sessionStorage — return a placeholder
|
||||
// that the client will hydrate from sessionStorage.
|
||||
if (id.startsWith("new-")) {
|
||||
return { song: null as unknown as Song, tempId: id };
|
||||
}
|
||||
|
||||
const song = getMockSong(id);
|
||||
const song = await getSong(id);
|
||||
if (!song) throw data("Song not found", { status: 404 });
|
||||
return { song, tempId: null };
|
||||
return { song };
|
||||
}
|
||||
|
||||
export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||
const { tempId } = loaderData;
|
||||
|
||||
// Resolve song — either from loader or from sessionStorage (temp songs)
|
||||
const [song] = useState<Song | null>(() => {
|
||||
if (loaderData.song) return loaderData.song;
|
||||
if (tempId && typeof window !== "undefined") {
|
||||
const stored = sessionStorage.getItem(tempId);
|
||||
if (stored) return JSON.parse(stored) as Song;
|
||||
}
|
||||
return loaderData.song;
|
||||
});
|
||||
|
||||
const { song } = loaderData;
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
if (!song) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-dvh">
|
||||
<p className="text-muted-foreground">Song not found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayed = transposeSong(song, offset);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh max-w-lg mx-auto">
|
||||
<TransposeBar
|
||||
meta={song.meta}
|
||||
offset={offset}
|
||||
onOffsetChange={setOffset}
|
||||
/>
|
||||
<TransposeBar meta={song.meta} offset={offset} onOffsetChange={setOffset} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ChordChart sections={displayed.sections} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user