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";
|
} from "~/components/ui/sheet";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Button } from "~/components/ui/button";
|
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";
|
import { previewChords } from "~/lib/mock";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -58,35 +59,13 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const apiBase = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
|
const stored = await createSong(
|
||||||
const body = fileHtml
|
fileHtml ? { html: fileHtml } : { source: url.trim() }
|
||||||
? { html: fileHtml }
|
);
|
||||||
: { source: url.trim() };
|
onSongAdded({ id: stored.id, meta: stored.song.meta, preview_chords: previewChords(stored.song) });
|
||||||
|
|
||||||
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);
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
reset();
|
reset();
|
||||||
navigate(`/songs/${id}`);
|
navigate(`/songs/${stored.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||||
} finally {
|
} 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
|
// First 5 unique chord names from the song, in order of appearance
|
||||||
preview_chords: string[];
|
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 { Plus } from "lucide-react";
|
||||||
import { SongCard } from "~/components/song-card";
|
import { SongCard } from "~/components/song-card";
|
||||||
import { AddSongSheet } from "~/components/add-song-sheet";
|
import { AddSongSheet } from "~/components/add-song-sheet";
|
||||||
import { MOCK_SONGS } from "~/lib/mock";
|
import { listSongs } from "~/lib/api";
|
||||||
import type { SongSummary } from "~/lib/types";
|
import type { SongSummary } from "~/lib/types";
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
@@ -16,8 +16,13 @@ export function meta({}: Route.MetaArgs) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loader() {
|
export async function loader() {
|
||||||
return { songs: MOCK_SONGS };
|
try {
|
||||||
|
const songs = await listSongs();
|
||||||
|
return { songs };
|
||||||
|
} catch {
|
||||||
|
return { songs: [] };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
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 { TransposeBar } from "~/components/transpose-bar";
|
||||||
import { ChordChart } from "~/components/chord-chart";
|
import { ChordChart } from "~/components/chord-chart";
|
||||||
import { transposeSong } from "~/lib/transpose";
|
import { transposeSong } from "~/lib/transpose";
|
||||||
import { getMockSong } from "~/lib/mock";
|
import { getSong } from "~/lib/api";
|
||||||
import type { Song } from "~/lib/types";
|
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs) {
|
export function meta({ data }: Route.MetaArgs) {
|
||||||
if (!data?.song) return [{ title: "PocketChords" }];
|
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 ?? "";
|
const id = params.id ?? "";
|
||||||
|
const song = await getSong(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);
|
|
||||||
if (!song) throw data("Song not found", { status: 404 });
|
if (!song) throw data("Song not found", { status: 404 });
|
||||||
return { song, tempId: null };
|
return { song };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||||
const { tempId } = loaderData;
|
const { song } = 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 [offset, setOffset] = useState(0);
|
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);
|
const displayed = transposeSong(song, offset);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh max-w-lg mx-auto">
|
<div className="flex flex-col h-dvh max-w-lg mx-auto">
|
||||||
<TransposeBar
|
<TransposeBar meta={song.meta} offset={offset} onOffsetChange={setOffset} />
|
||||||
meta={song.meta}
|
|
||||||
offset={offset}
|
|
||||||
onOffsetChange={setOffset}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ChordChart sections={displayed.sections} />
|
<ChordChart sections={displayed.sections} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user