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:
@@ -1,13 +1,27 @@
|
||||
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
|
||||
@@ -18,7 +32,7 @@ export function BottomNav() {
|
||||
"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"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -26,11 +40,14 @@ export function BottomNav() {
|
||||
<span>Library</span>
|
||||
</NavLink>
|
||||
|
||||
<div className="flex items-center gap-1 mr-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() =>
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{resolvedTheme === "dark" ? (
|
||||
@@ -39,7 +56,49 @@ export function BottomNav() {
|
||||
<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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
91
app/app/components/login-sheet.tsx
Normal file
91
app/app/components/login-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
app/app/components/register-sheet.tsx
Normal file
106
app/app/components/register-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
76
app/app/lib/auth.tsx
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<AuthProvider>
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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) => {
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
listSongs(q, sort, order)
|
||||
.then((data) => {
|
||||
setSongs(data);
|
||||
setLocalSongs([]);
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [q, sort, order]);
|
||||
|
||||
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;
|
||||
if (sort !== "date") next.sort = sort;
|
||||
if (order !== "desc") next.order = order;
|
||||
setSearchParams(next, { replace: true });
|
||||
}, 300);
|
||||
}, [setSearchParams, initialSort, initialOrder]);
|
||||
},
|
||||
[setSearchParams, sort, order],
|
||||
);
|
||||
|
||||
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []);
|
||||
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>
|
||||
{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,24 +137,39 @@ 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>
|
||||
)}
|
||||
{!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)}
|
||||
@@ -136,7 +178,9 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
<Plus className="w-6 h-6 text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddSongSheet
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user