feat(app): auth UI, SPA mode, /api base URL

- auth: login/register bottom sheets, token storage, authFetch
- AuthProvider context w/ auto-refresh on mount
- hide add/edit/delete for guests, sign-in button in nav
- SPA mode (ssr:false), loaders→client-side useEffect
- API base URL /api (same-origin), loading spinners
This commit is contained in:
2026-07-11 21:40:57 +02:00
parent 7bd27d9b9c
commit c4e9076b11
9 changed files with 659 additions and 163 deletions

View File

@@ -1,45 +1,104 @@
import { useState } from "react";
import { NavLink } from "react-router";
import { Music, Sun, Moon } from "lucide-react";
import { LogOut, Music, Sun, Moon, User } from "lucide-react";
import { useTheme } from "next-themes";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { useAuth } from "~/lib/auth";
import { LoginSheet } from "~/components/login-sheet";
import { RegisterSheet } from "~/components/register-sheet";
export function BottomNav() {
const { resolvedTheme, setTheme } = useTheme();
const { isAuthenticated, logout } = useAuth();
const [loginOpen, setLoginOpen] = useState(false);
const [registerOpen, setRegisterOpen] = useState(false);
return (
<nav className="border-t bg-background shrink-0">
<div className="max-w-lg mx-auto flex items-center">
<NavLink
to="/"
end
className={({ isActive }) =>
cn(
"flex flex-col items-center gap-0.5 flex-1 py-2 text-xs transition-colors",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)
}
>
<Music className="w-5 h-5" />
<span>Library</span>
</NavLink>
<>
<nav className="border-t bg-background shrink-0">
<div className="max-w-lg mx-auto flex items-center">
<NavLink
to="/"
end
className={({ isActive }) =>
cn(
"flex flex-col items-center gap-0.5 flex-1 py-2 text-xs transition-colors",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)
}
>
<Music className="w-5 h-5" />
<span>Library</span>
</NavLink>
<Button
variant="ghost"
size="icon"
className="mr-2 text-muted-foreground hover:text-foreground"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
>
{resolvedTheme === "dark" ? (
<Sun className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</Button>
</div>
</nav>
<div className="flex items-center gap-1 mr-2">
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={() =>
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
aria-label="Toggle theme"
>
{resolvedTheme === "dark" ? (
<Sun className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</Button>
{isAuthenticated ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<User className="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => logout()}>
<LogOut className="w-4 h-4 mr-2" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => setLoginOpen(true)}
>
Sign in
</Button>
)}
</div>
</div>
</nav>
<LoginSheet
open={loginOpen}
onOpenChange={setLoginOpen}
onSwitchToRegister={() => setRegisterOpen(true)}
/>
<RegisterSheet
open={registerOpen}
onOpenChange={setRegisterOpen}
onSwitchToLogin={() => setLoginOpen(true)}
/>
</>
);
}

View File

@@ -0,0 +1,91 @@
import { useState } from "react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "~/components/ui/sheet";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Button } from "~/components/ui/button";
import { useAuth } from "~/lib/auth";
import { toast } from "sonner";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSwitchToRegister: () => void;
}
export function LoginSheet({ open, onOpenChange, onSwitchToRegister }: Props) {
const { login } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
onOpenChange(false);
setEmail("");
setPassword("");
toast.success("Logged in");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="rounded-t-xl">
<SheetHeader>
<SheetTitle>Sign in</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4 pb-6">
<div className="flex flex-col gap-2">
<Label htmlFor="login-email">Email</Label>
<Input
id="login-email"
type="email"
autoComplete="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="login-password">Password</Label>
<Input
id="login-password"
type="password"
autoComplete="current-password"
placeholder="********"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? "Signing in..." : "Sign in"}
</Button>
<Button
type="button"
variant="ghost"
className="w-full text-sm text-muted-foreground"
onClick={() => {
onOpenChange(false);
onSwitchToRegister();
}}
>
Don't have an account? Register
</Button>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,106 @@
import { useState } from "react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "~/components/ui/sheet";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Button } from "~/components/ui/button";
import { useAuth } from "~/lib/auth";
import { toast } from "sonner";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSwitchToLogin: () => void;
}
export function RegisterSheet({ open, onOpenChange, onSwitchToLogin }: Props) {
const { register } = useAuth();
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
await register(email, username, password);
onOpenChange(false);
setEmail("");
setUsername("");
setPassword("");
toast.success("Account created");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Registration failed");
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="rounded-t-xl">
<SheetHeader>
<SheetTitle>Create account</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4 pb-6">
<div className="flex flex-col gap-2">
<Label htmlFor="register-email">Email</Label>
<Input
id="register-email"
type="email"
autoComplete="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="register-username">Username</Label>
<Input
id="register-username"
type="text"
autoComplete="username"
placeholder="johndoe"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="register-password">Password</Label>
<Input
id="register-password"
type="password"
autoComplete="new-password"
placeholder="min. 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? "Creating account..." : "Register"}
</Button>
<Button
type="button"
variant="ghost"
className="w-full text-sm text-muted-foreground"
onClick={() => {
onOpenChange(false);
onSwitchToLogin();
}}
>
Already have an account? Sign in
</Button>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -1,6 +1,87 @@
import type { Song, SongSummary, StoredSong, UpdateSongRequest } from "./types";
import type { LoginResponse, RefreshResponse, Song, SongSummary, StoredSong, UpdateSongRequest } from "./types";
const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
const API_BASE = import.meta.env.VITE_API_URL ?? "/api";
const TOKEN_KEY = "pocket_chords_token";
const REFRESH_KEY = "pocket_chords_refresh_token";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_KEY);
}
export function setTokens(token: string, refreshToken: string) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(REFRESH_KEY, refreshToken);
}
export function clearTokens() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
}
async function authFetch(url: string, init: RequestInit = {}): Promise<Response> {
const token = getToken();
const headers = new Headers(init.headers);
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(url, { ...init, headers });
if (res.status === 401) clearTokens();
return res;
}
// --- Auth ---
export async function apiLogin(email: string, password: string): Promise<LoginResponse> {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as { error?: string }).error ?? `Login failed: ${res.status}`);
}
return res.json();
}
export async function apiRegister(email: string, username: string, password: string): Promise<void> {
const res = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, username, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as { error?: string }).error ?? `Registration failed: ${res.status}`);
}
}
export async function apiRefresh(refreshToken: string): Promise<RefreshResponse> {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) {
clearTokens();
throw new Error("Session expired");
}
return res.json();
}
export async function apiLogout(refreshToken: string): Promise<void> {
await fetch(`${API_BASE}/auth/logout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
}).catch(() => {});
clearTokens();
}
// --- Songs (read — public) ---
export async function listSongs(q = "", sort = "date", order = "desc"): Promise<SongSummary[]> {
const params = new URLSearchParams();
@@ -23,11 +104,10 @@ export async function getSong(id: string, applyCapo = false): Promise<Song | nul
return res.json();
}
export async function createSong(body: {
source?: string;
html?: string;
}): Promise<StoredSong> {
const res = await fetch(`${API_BASE}/songs`, {
// --- Songs (mutations — auth required) ---
export async function createSong(body: { source?: string; html?: string }): Promise<StoredSong> {
const res = await authFetch(`${API_BASE}/songs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -40,12 +120,12 @@ export async function createSong(body: {
}
export async function deleteSong(id: string): Promise<void> {
const res = await fetch(`${API_BASE}/songs/${id}`, { method: "DELETE" });
const res = await authFetch(`${API_BASE}/songs/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`Failed to delete song: HTTP ${res.status}`);
}
export async function updateSong(id: string, patch: UpdateSongRequest): Promise<SongSummary> {
const res = await fetch(`${API_BASE}/songs/${id}`, {
const res = await authFetch(`${API_BASE}/songs/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),

76
app/app/lib/auth.tsx Normal file
View File

@@ -0,0 +1,76 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { apiLogin, apiLogout, apiRefresh, apiRegister, clearTokens, getRefreshToken, getToken, setTokens } from "./api";
interface AuthState {
userId: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [userId, setUserId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const token = getToken();
const refresh = getRefreshToken();
if (!token || !refresh) {
setIsLoading(false);
return;
}
apiRefresh(refresh)
.then((res) => {
setTokens(res.token, res.refresh_token);
const payload = JSON.parse(atob(res.token.split(".")[1]));
setUserId(payload.sub);
})
.catch(() => {
clearTokens();
setUserId(null);
})
.finally(() => setIsLoading(false));
}, []);
const login = useCallback(async (email: string, password: string) => {
const res = await apiLogin(email, password);
setTokens(res.token, res.refresh_token);
setUserId(res.user_id);
}, []);
const register = useCallback(async (email: string, username: string, password: string) => {
await apiRegister(email, username, password);
await login(email, password);
}, [login]);
const logout = useCallback(async () => {
const refresh = getRefreshToken();
if (refresh) await apiLogout(refresh);
setUserId(null);
}, []);
const value = useMemo<AuthState>(
() => ({
userId,
isAuthenticated: !!userId,
isLoading,
login,
register,
logout,
}),
[userId, isLoading, login, register, logout],
);
return <AuthContext value={value}>{children}</AuthContext>;
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@@ -28,11 +28,9 @@ export interface Song {
sections: Section[];
}
// Trimmed version used in the library grid
export interface SongSummary {
id: string;
meta: SongMeta;
// First 5 unique chord names from the song, in order of appearance
preview_chords: string[];
}
@@ -46,3 +44,16 @@ export interface UpdateSongRequest {
artist?: string;
original_key?: string;
}
export interface LoginResponse {
token: string;
refresh_token: string;
user_id: string;
expires_at: string;
}
export interface RefreshResponse {
token: string;
refresh_token: string;
expires_at: string;
}

View File

@@ -10,6 +10,7 @@ import { ThemeProvider } from "next-themes";
import type { Route } from "./+types/root";
import "./app.css";
import { AuthProvider } from "./lib/auth";
import { TooltipProvider } from "./components/ui/tooltip";
export const links: Route.LinksFunction = () => [
@@ -43,11 +44,13 @@ export function Layout({ children }: { children: React.ReactNode }) {
</head>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
{children}
<ScrollRestoration />
<Scripts />
</TooltipProvider>
<AuthProvider>
<TooltipProvider>
{children}
<ScrollRestoration />
<Scripts />
</TooltipProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>

View File

@@ -1,74 +1,87 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams, useRevalidator } from "react-router";
import type { Route } from "./+types/home";
import { useSearchParams } from "react-router";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Card, CardContent } from "~/components/ui/card";
import { Plus } from "lucide-react";
import { Loader2, Plus } from "lucide-react";
import { SongCard } from "~/components/song-card";
import { AddSongSheet } from "~/components/add-song-sheet";
import { listSongs } from "~/lib/api";
import { useAuth } from "~/lib/auth";
import { cn } from "~/lib/utils";
import type { SongSummary } from "~/lib/types";
export function meta({}: Route.MetaArgs) {
export function meta() {
return [
{ title: "PocketChords" },
{ name: "description", content: "Your personal chord chart library" },
];
}
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const q = url.searchParams.get("q") ?? "";
const sort = url.searchParams.get("sort") ?? "date";
const order = url.searchParams.get("order") ?? "desc";
try {
const songs = await listSongs(q, sort, order);
return { songs, q, sort, order, error: false };
} catch {
return { songs: [], q, sort, order, error: true };
}
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { songs, q: initialQ, sort: initialSort, order: initialOrder, error } = loaderData;
export default function Home() {
const { isAuthenticated } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const [sheetOpen, setSheetOpen] = useState(false);
const [songs, setSongs] = useState<SongSummary[]>([]);
const [localSongs, setLocalSongs] = useState<SongSummary[]>([]);
const revalidator = useRevalidator();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [inputValue, setInputValue] = useState(initialQ);
const q = searchParams.get("q") ?? "";
const sort = searchParams.get("sort") ?? "date";
const order = searchParams.get("order") ?? "desc";
const [inputValue, setInputValue] = useState(q);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleSearch = useCallback((value: string) => {
setInputValue(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const next: Record<string, string> = {};
if (value.trim()) next.q = value.trim();
if (initialSort !== "date") next.sort = initialSort;
if (initialOrder !== "desc") next.order = initialOrder;
setSearchParams(next, { replace: true });
}, 300);
}, [setSearchParams, initialSort, initialOrder]);
useEffect(() => {
setLoading(true);
setError(false);
listSongs(q, sort, order)
.then((data) => {
setSongs(data);
setLocalSongs([]);
})
.catch(() => setError(true))
.finally(() => setLoading(false));
}, [q, sort, order]);
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []);
const handleSearch = useCallback(
(value: string) => {
setInputValue(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const next: Record<string, string> = {};
if (value.trim()) next.q = value.trim();
if (sort !== "date") next.sort = sort;
if (order !== "desc") next.order = order;
setSearchParams(next, { replace: true });
}, 300);
},
[setSearchParams, sort, order],
);
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
},
[],
);
const allSongs = [...songs, ...localSongs];
return (
<div className="flex flex-col h-full max-w-lg mx-auto">
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<h1 className="text-lg font-bold">PocketChords</h1>
<Button size="sm" onClick={() => setSheetOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
Add
</Button>
{isAuthenticated && (
<Button size="sm" onClick={() => setSheetOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
Add
</Button>
)}
</div>
<div className="px-4 pb-3">
<Input
placeholder="Search songs..."
@@ -79,11 +92,24 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</div>
<div className="flex gap-1 px-4 pb-2">
{([["date", "Date"], ["title", "Title"], ["artist", "Artist"]] as const).map(([val, label]) => (
{(
[
["date", "Date"],
["title", "Title"],
["artist", "Artist"],
] as const
).map(([val, label]) => (
<button
key={val}
onClick={() => {
const newOrder = initialSort === val ? (initialOrder === "asc" ? "desc" : "asc") : (val === "date" ? "desc" : "asc");
const newOrder =
sort === val
? order === "asc"
? "desc"
: "asc"
: val === "date"
? "desc"
: "asc";
const next: Record<string, string> = {};
if (inputValue.trim()) next.q = inputValue.trim();
next.sort = val;
@@ -92,12 +118,13 @@ export default function Home({ loaderData }: Route.ComponentProps) {
}}
className={cn(
"text-xs px-2 py-1 rounded-full border transition-colors",
initialSort === val
sort === val
? "bg-primary text-primary-foreground border-primary"
: "text-muted-foreground border-border"
: "text-muted-foreground border-border",
)}
>
{label}{initialSort === val ? (initialOrder === "asc" ? " ↑" : " ↓") : ""}
{label}
{sort === val ? (order === "asc" ? " ↑" : " ↓") : ""}
</button>
))}
</div>
@@ -110,33 +137,50 @@ export default function Home({ loaderData }: Route.ComponentProps) {
<Button
variant="outline"
size="sm"
onClick={() => revalidator.revalidate()}
onClick={() => {
setError(false);
setLoading(true);
listSongs(q, sort, order)
.then(setSongs)
.catch(() => setError(true))
.finally(() => setLoading(false));
}}
>
Retry
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto px-4 pb-4">
{!error && allSongs.length === 0 && (
{loading && !error && (
<div className="flex justify-center pt-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
)}
{!loading && !error && allSongs.length === 0 && (
<p className="text-sm text-muted-foreground text-center pt-8 pb-4">
{initialQ ? "No songs match your search." : "No songs yet. Tap Add to get started."}
{q
? "No songs match your search."
: "No songs yet. Tap Add to get started."}
</p>
)}
<div className="grid grid-cols-2 gap-3">
{allSongs.map((song) => (
<SongCard key={song.id} song={song} />
))}
<Card
className="h-full border-dashed cursor-pointer hover:bg-accent transition-colors"
onClick={() => setSheetOpen(true)}
>
<CardContent className="p-3 flex items-center justify-center h-full min-h-[80px]">
<Plus className="w-6 h-6 text-muted-foreground" />
</CardContent>
</Card>
</div>
{!loading && (
<div className="grid grid-cols-2 gap-3">
{allSongs.map((song) => (
<SongCard key={song.id} song={song} />
))}
{isAuthenticated && (
<Card
className="h-full border-dashed cursor-pointer hover:bg-accent transition-colors"
onClick={() => setSheetOpen(true)}
>
<CardContent className="p-3 flex items-center justify-center h-full min-h-[80px]">
<Plus className="w-6 h-6 text-muted-foreground" />
</CardContent>
</Card>
)}
</div>
)}
</div>
<AddSongSheet

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { data, Link } from "react-router";
import type { Route } from "./+types/songs.$id";
import { Link, useParams } from "react-router";
import { Loader2 } from "lucide-react";
import { TransposeBar } from "~/components/transpose-bar";
import { ChordChart } from "~/components/chord-chart";
import { ChordGrid } from "~/components/chord-diagram/chord-grid";
@@ -11,52 +11,42 @@ import { DeleteSongDialog } from "~/components/delete-song-dialog";
import { transposeSong } from "~/lib/transpose";
import { extractUniqueChords } from "~/lib/song-utils";
import { getSong } from "~/lib/api";
import { useAuth } from "~/lib/auth";
import type { Song, SongSummary } from "~/lib/types";
export function meta({ data }: Route.MetaArgs) {
if (!data?.song) return [{ title: "PocketChords" }];
return [
{ title: `${data.song.meta.title} — PocketChords` },
{ name: "description", content: data.song.meta.artist },
];
}
export async function loader({ params }: Route.LoaderArgs) {
const id = params.id ?? "";
try {
const song = await getSong(id);
if (!song) throw data("Song not found", { status: 404 });
return { song, id };
} catch (err: unknown) {
if (err && typeof err === "object" && "status" in err && (err as { status: number }).status === 404) {
throw err;
}
return { song: null as Song | null, id };
}
}
type FontSize = 'sm' | 'base' | 'lg';
type FontSize = "sm" | "base" | "lg";
function initFontSize(): FontSize {
try {
const v = localStorage.getItem('fontSize');
if (v === 'sm' || v === 'base' || v === 'lg') return v;
} catch { /* noop */ }
return 'sm';
const v = localStorage.getItem("fontSize");
if (v === "sm" || v === "base" || v === "lg") return v;
} catch {
/* noop */
}
return "sm";
}
function initInstrument(): Instrument {
try {
const v = localStorage.getItem('chordDiagramInstrument');
if (v === 'piano' || v === 'guitar') return v;
} catch { /* noop */ }
return 'piano';
const v = localStorage.getItem("chordDiagramInstrument");
if (v === "piano" || v === "guitar") return v;
} catch {
/* noop */
}
return "piano";
}
export default function SongDetail({ loaderData }: Route.ComponentProps) {
const { song: initialSong, id } = loaderData;
const [baseSong, setBaseSong] = useState<Song | null>(initialSong ?? null);
const [displayedSong, setDisplayedSong] = useState<Song | null>(initialSong ?? null);
export function meta() {
return [{ title: "PocketChords" }];
}
export default function SongDetail() {
const { id = "" } = useParams();
const { isAuthenticated } = useAuth();
const [baseSong, setBaseSong] = useState<Song | null>(null);
const [displayedSong, setDisplayedSong] = useState<Song | null>(null);
const [loading, setLoading] = useState(true);
const [applyCapo, setApplyCapo] = useState(false);
const initOffset = (() => {
@@ -66,7 +56,9 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
const n = parseInt(v, 10);
if (!isNaN(n)) return n;
}
} catch { /* noop */ }
} catch {
/* noop */
}
return 0;
})();
@@ -78,9 +70,21 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
const [instrument, setInstrument] = useState<Instrument>(initInstrument);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setLoading(true);
getSong(id)
.then((s) => {
setBaseSong(s);
setDisplayedSong(s);
})
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
if (applyCapo && baseSong?.meta.capo) {
getSong(id, true).then((s) => { if (s) setDisplayedSong(s); });
getSong(id, true).then((s) => {
if (s) setDisplayedSong(s);
});
} else {
setDisplayedSong(baseSong);
}
@@ -88,26 +92,51 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
function handleOffsetChange(newOffset: number) {
setOffset(newOffset);
try { localStorage.setItem(`transpose:${id}`, String(newOffset)); } catch { /* noop */ }
try {
localStorage.setItem(`transpose:${id}`, String(newOffset));
} catch {
/* noop */
}
}
function handleFontSizeChange(size: FontSize) {
setFontSize(size);
try { localStorage.setItem('fontSize', size); } catch { /* noop */ }
try {
localStorage.setItem("fontSize", size);
} catch {
/* noop */
}
}
function handleInstrumentChange(i: Instrument) {
setInstrument(i);
try { localStorage.setItem('chordDiagramInstrument', i); } catch { /* noop */ }
try {
localStorage.setItem("chordDiagramInstrument", i);
} catch {
/* noop */
}
}
const handleScroll = useCallback(() => setActiveChord(null), []);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!baseSong || !displayedSong) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-muted-foreground text-sm">Song not found or unavailable.</p>
<Link to="/" className="text-sm text-primary underline-offset-4 hover:underline">
<p className="text-muted-foreground text-sm">
Song not found or unavailable.
</p>
<Link
to="/"
className="text-sm text-primary underline-offset-4 hover:underline"
>
Back to library
</Link>
</div>
@@ -119,8 +148,10 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
const handleChordClick = (chord: string) => setActiveChord(chord);
function handleUpdated(summary: SongSummary) {
setBaseSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
setDisplayedSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
setBaseSong((prev) => (prev ? { ...prev, meta: summary.meta } : prev));
setDisplayedSong((prev) =>
prev ? { ...prev, meta: summary.meta } : prev,
);
}
return (
@@ -129,8 +160,8 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
meta={baseSong.meta}
offset={offset}
onOffsetChange={handleOffsetChange}
onEdit={() => setEditOpen(true)}
onDelete={() => setDeleteOpen(true)}
onEdit={isAuthenticated ? () => setEditOpen(true) : undefined}
onDelete={isAuthenticated ? () => setDeleteOpen(true) : undefined}
fontSize={fontSize}
onFontSizeChange={handleFontSizeChange}
capo={baseSong.meta.capo ?? undefined}
@@ -138,9 +169,7 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
onToggleCapo={() => setApplyCapo((v) => !v)}
/>
{/* Body: single column on mobile, two columns on desktop */}
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
{/* Left / main column */}
<div
className="flex-1 overflow-y-auto"
ref={scrollRef}
@@ -153,7 +182,6 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
onChordClick={handleChordClick}
/>
{/* Mobile bottom chord grid (hidden on desktop) */}
<div className="lg:hidden border-t border-border">
<ChordGrid
chords={uniqueChords}
@@ -164,7 +192,6 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
</div>
</div>
{/* Desktop side column (hidden on mobile) */}
<div className="hidden lg:block w-72 overflow-y-auto border-l border-border shrink-0">
<ChordGrid
chords={uniqueChords}
@@ -174,7 +201,6 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
</div>
</div>
{/* Mobile inline popup — fixed bottom, dismissed on scroll */}
{activeChord && (
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background shadow-lg p-3 flex items-center gap-3">
<ChordDiagram chord={activeChord} instrument={instrument} />