feat(app): wire frontend to real API, remove mock data and sessionStorage

This commit is contained in:
2026-04-08 03:09:12 +02:00
parent 8c0824c67c
commit 5390fe39e6
5 changed files with 71 additions and 70 deletions

View File

@@ -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
View 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" });
}

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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>