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 { 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 { useTheme } from "next-themes";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Button } from "~/components/ui/button";
|
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() {
|
export function BottomNav() {
|
||||||
const { resolvedTheme, setTheme } = useTheme();
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const { isAuthenticated, logout } = useAuth();
|
||||||
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
|
const [registerOpen, setRegisterOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<nav className="border-t bg-background shrink-0">
|
<nav className="border-t bg-background shrink-0">
|
||||||
<div className="max-w-lg mx-auto flex items-center">
|
<div className="max-w-lg mx-auto flex items-center">
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -18,7 +32,7 @@ export function BottomNav() {
|
|||||||
"flex flex-col items-center gap-0.5 flex-1 py-2 text-xs transition-colors",
|
"flex flex-col items-center gap-0.5 flex-1 py-2 text-xs transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "text-primary"
|
? "text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -26,11 +40,14 @@ export function BottomNav() {
|
|||||||
<span>Library</span>
|
<span>Library</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 mr-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="mr-2 text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
|
onClick={() =>
|
||||||
|
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||||
|
}
|
||||||
aria-label="Toggle theme"
|
aria-label="Toggle theme"
|
||||||
>
|
>
|
||||||
{resolvedTheme === "dark" ? (
|
{resolvedTheme === "dark" ? (
|
||||||
@@ -39,7 +56,49 @@ export function BottomNav() {
|
|||||||
<Moon className="w-5 h-5" />
|
<Moon className="w-5 h-5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</nav>
|
</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[]> {
|
export async function listSongs(q = "", sort = "date", order = "desc"): Promise<SongSummary[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -23,11 +104,10 @@ export async function getSong(id: string, applyCapo = false): Promise<Song | nul
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSong(body: {
|
// --- Songs (mutations — auth required) ---
|
||||||
source?: string;
|
|
||||||
html?: string;
|
export async function createSong(body: { source?: string; html?: string }): Promise<StoredSong> {
|
||||||
}): Promise<StoredSong> {
|
const res = await authFetch(`${API_BASE}/songs`, {
|
||||||
const res = await fetch(`${API_BASE}/songs`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -40,12 +120,12 @@ export async function createSong(body: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSong(id: string): Promise<void> {
|
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}`);
|
if (!res.ok) throw new Error(`Failed to delete song: HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSong(id: string, patch: UpdateSongRequest): Promise<SongSummary> {
|
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",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(patch),
|
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[];
|
sections: Section[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trimmed version used in the library grid
|
|
||||||
export interface SongSummary {
|
export interface SongSummary {
|
||||||
id: string;
|
id: string;
|
||||||
meta: SongMeta;
|
meta: SongMeta;
|
||||||
// First 5 unique chord names from the song, in order of appearance
|
|
||||||
preview_chords: string[];
|
preview_chords: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,3 +44,16 @@ export interface UpdateSongRequest {
|
|||||||
artist?: string;
|
artist?: string;
|
||||||
original_key?: 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 type { Route } from "./+types/root";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
import { AuthProvider } from "./lib/auth";
|
||||||
import { TooltipProvider } from "./components/ui/tooltip";
|
import { TooltipProvider } from "./components/ui/tooltip";
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
@@ -43,11 +44,13 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
|
<AuthProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{children}
|
{children}
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,74 +1,87 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useSearchParams, useRevalidator } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import type { Route } from "./+types/home";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
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 { SongCard } from "~/components/song-card";
|
||||||
import { AddSongSheet } from "~/components/add-song-sheet";
|
import { AddSongSheet } from "~/components/add-song-sheet";
|
||||||
import { listSongs } from "~/lib/api";
|
import { listSongs } from "~/lib/api";
|
||||||
|
import { useAuth } from "~/lib/auth";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import type { SongSummary } from "~/lib/types";
|
import type { SongSummary } from "~/lib/types";
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta() {
|
||||||
return [
|
return [
|
||||||
{ title: "PocketChords" },
|
{ title: "PocketChords" },
|
||||||
{ name: "description", content: "Your personal chord chart library" },
|
{ name: "description", content: "Your personal chord chart library" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export default function Home() {
|
||||||
const url = new URL(request.url);
|
const { isAuthenticated } = useAuth();
|
||||||
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;
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
const [songs, setSongs] = useState<SongSummary[]>([]);
|
||||||
const [localSongs, setLocalSongs] = 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 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);
|
setInputValue(value);
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
const next: Record<string, string> = {};
|
const next: Record<string, string> = {};
|
||||||
if (value.trim()) next.q = value.trim();
|
if (value.trim()) next.q = value.trim();
|
||||||
if (initialSort !== "date") next.sort = initialSort;
|
if (sort !== "date") next.sort = sort;
|
||||||
if (initialOrder !== "desc") next.order = initialOrder;
|
if (order !== "desc") next.order = order;
|
||||||
setSearchParams(next, { replace: true });
|
setSearchParams(next, { replace: true });
|
||||||
}, 300);
|
}, 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];
|
const allSongs = [...songs, ...localSongs];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full max-w-lg mx-auto">
|
<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">
|
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
||||||
<h1 className="text-lg font-bold">PocketChords</h1>
|
<h1 className="text-lg font-bold">PocketChords</h1>
|
||||||
|
{isAuthenticated && (
|
||||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="px-4 pb-3">
|
<div className="px-4 pb-3">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search songs..."
|
placeholder="Search songs..."
|
||||||
@@ -79,11 +92,24 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 px-4 pb-2">
|
<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
|
<button
|
||||||
key={val}
|
key={val}
|
||||||
onClick={() => {
|
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> = {};
|
const next: Record<string, string> = {};
|
||||||
if (inputValue.trim()) next.q = inputValue.trim();
|
if (inputValue.trim()) next.q = inputValue.trim();
|
||||||
next.sort = val;
|
next.sort = val;
|
||||||
@@ -92,12 +118,13 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs px-2 py-1 rounded-full border transition-colors",
|
"text-xs px-2 py-1 rounded-full border transition-colors",
|
||||||
initialSort === val
|
sort === val
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
? "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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -110,24 +137,39 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => revalidator.revalidate()}
|
onClick={() => {
|
||||||
|
setError(false);
|
||||||
|
setLoading(true);
|
||||||
|
listSongs(q, sort, order)
|
||||||
|
.then(setSongs)
|
||||||
|
.catch(() => setError(true))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
<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">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{!loading && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{allSongs.map((song) => (
|
{allSongs.map((song) => (
|
||||||
<SongCard key={song.id} song={song} />
|
<SongCard key={song.id} song={song} />
|
||||||
))}
|
))}
|
||||||
|
{isAuthenticated && (
|
||||||
<Card
|
<Card
|
||||||
className="h-full border-dashed cursor-pointer hover:bg-accent transition-colors"
|
className="h-full border-dashed cursor-pointer hover:bg-accent transition-colors"
|
||||||
onClick={() => setSheetOpen(true)}
|
onClick={() => setSheetOpen(true)}
|
||||||
@@ -136,7 +178,9 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
<Plus className="w-6 h-6 text-muted-foreground" />
|
<Plus className="w-6 h-6 text-muted-foreground" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddSongSheet
|
<AddSongSheet
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { data, Link } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
import type { Route } from "./+types/songs.$id";
|
import { Loader2 } from "lucide-react";
|
||||||
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 { ChordGrid } from "~/components/chord-diagram/chord-grid";
|
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 { transposeSong } from "~/lib/transpose";
|
||||||
import { extractUniqueChords } from "~/lib/song-utils";
|
import { extractUniqueChords } from "~/lib/song-utils";
|
||||||
import { getSong } from "~/lib/api";
|
import { getSong } from "~/lib/api";
|
||||||
|
import { useAuth } from "~/lib/auth";
|
||||||
import type { Song, SongSummary } from "~/lib/types";
|
import type { Song, SongSummary } from "~/lib/types";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs) {
|
type FontSize = "sm" | "base" | "lg";
|
||||||
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';
|
|
||||||
|
|
||||||
function initFontSize(): FontSize {
|
function initFontSize(): FontSize {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem('fontSize');
|
const v = localStorage.getItem("fontSize");
|
||||||
if (v === 'sm' || v === 'base' || v === 'lg') return v;
|
if (v === "sm" || v === "base" || v === "lg") return v;
|
||||||
} catch { /* noop */ }
|
} catch {
|
||||||
return 'sm';
|
/* noop */
|
||||||
|
}
|
||||||
|
return "sm";
|
||||||
}
|
}
|
||||||
|
|
||||||
function initInstrument(): Instrument {
|
function initInstrument(): Instrument {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem('chordDiagramInstrument');
|
const v = localStorage.getItem("chordDiagramInstrument");
|
||||||
if (v === 'piano' || v === 'guitar') return v;
|
if (v === "piano" || v === "guitar") return v;
|
||||||
} catch { /* noop */ }
|
} catch {
|
||||||
return 'piano';
|
/* noop */
|
||||||
|
}
|
||||||
|
return "piano";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
export function meta() {
|
||||||
const { song: initialSong, id } = loaderData;
|
return [{ title: "PocketChords" }];
|
||||||
const [baseSong, setBaseSong] = useState<Song | null>(initialSong ?? null);
|
}
|
||||||
const [displayedSong, setDisplayedSong] = useState<Song | null>(initialSong ?? null);
|
|
||||||
|
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 [applyCapo, setApplyCapo] = useState(false);
|
||||||
|
|
||||||
const initOffset = (() => {
|
const initOffset = (() => {
|
||||||
@@ -66,7 +56,9 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
const n = parseInt(v, 10);
|
const n = parseInt(v, 10);
|
||||||
if (!isNaN(n)) return n;
|
if (!isNaN(n)) return n;
|
||||||
}
|
}
|
||||||
} catch { /* noop */ }
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -78,9 +70,21 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
const [instrument, setInstrument] = useState<Instrument>(initInstrument);
|
const [instrument, setInstrument] = useState<Instrument>(initInstrument);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
getSong(id)
|
||||||
|
.then((s) => {
|
||||||
|
setBaseSong(s);
|
||||||
|
setDisplayedSong(s);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (applyCapo && baseSong?.meta.capo) {
|
if (applyCapo && baseSong?.meta.capo) {
|
||||||
getSong(id, true).then((s) => { if (s) setDisplayedSong(s); });
|
getSong(id, true).then((s) => {
|
||||||
|
if (s) setDisplayedSong(s);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setDisplayedSong(baseSong);
|
setDisplayedSong(baseSong);
|
||||||
}
|
}
|
||||||
@@ -88,26 +92,51 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
|
|
||||||
function handleOffsetChange(newOffset: number) {
|
function handleOffsetChange(newOffset: number) {
|
||||||
setOffset(newOffset);
|
setOffset(newOffset);
|
||||||
try { localStorage.setItem(`transpose:${id}`, String(newOffset)); } catch { /* noop */ }
|
try {
|
||||||
|
localStorage.setItem(`transpose:${id}`, String(newOffset));
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFontSizeChange(size: FontSize) {
|
function handleFontSizeChange(size: FontSize) {
|
||||||
setFontSize(size);
|
setFontSize(size);
|
||||||
try { localStorage.setItem('fontSize', size); } catch { /* noop */ }
|
try {
|
||||||
|
localStorage.setItem("fontSize", size);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInstrumentChange(i: Instrument) {
|
function handleInstrumentChange(i: Instrument) {
|
||||||
setInstrument(i);
|
setInstrument(i);
|
||||||
try { localStorage.setItem('chordDiagramInstrument', i); } catch { /* noop */ }
|
try {
|
||||||
|
localStorage.setItem("chordDiagramInstrument", i);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = useCallback(() => setActiveChord(null), []);
|
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) {
|
if (!baseSong || !displayedSong) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
<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>
|
<p className="text-muted-foreground text-sm">
|
||||||
<Link to="/" className="text-sm text-primary underline-offset-4 hover:underline">
|
Song not found or unavailable.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
← Back to library
|
← Back to library
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,8 +148,10 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
const handleChordClick = (chord: string) => setActiveChord(chord);
|
const handleChordClick = (chord: string) => setActiveChord(chord);
|
||||||
|
|
||||||
function handleUpdated(summary: SongSummary) {
|
function handleUpdated(summary: SongSummary) {
|
||||||
setBaseSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
|
setBaseSong((prev) => (prev ? { ...prev, meta: summary.meta } : prev));
|
||||||
setDisplayedSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
|
setDisplayedSong((prev) =>
|
||||||
|
prev ? { ...prev, meta: summary.meta } : prev,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,8 +160,8 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
meta={baseSong.meta}
|
meta={baseSong.meta}
|
||||||
offset={offset}
|
offset={offset}
|
||||||
onOffsetChange={handleOffsetChange}
|
onOffsetChange={handleOffsetChange}
|
||||||
onEdit={() => setEditOpen(true)}
|
onEdit={isAuthenticated ? () => setEditOpen(true) : undefined}
|
||||||
onDelete={() => setDeleteOpen(true)}
|
onDelete={isAuthenticated ? () => setDeleteOpen(true) : undefined}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
onFontSizeChange={handleFontSizeChange}
|
onFontSizeChange={handleFontSizeChange}
|
||||||
capo={baseSong.meta.capo ?? undefined}
|
capo={baseSong.meta.capo ?? undefined}
|
||||||
@@ -138,9 +169,7 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
onToggleCapo={() => setApplyCapo((v) => !v)}
|
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">
|
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
|
||||||
{/* Left / main column */}
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-auto"
|
className="flex-1 overflow-y-auto"
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
@@ -153,7 +182,6 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
onChordClick={handleChordClick}
|
onChordClick={handleChordClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Mobile bottom chord grid (hidden on desktop) */}
|
|
||||||
<div className="lg:hidden border-t border-border">
|
<div className="lg:hidden border-t border-border">
|
||||||
<ChordGrid
|
<ChordGrid
|
||||||
chords={uniqueChords}
|
chords={uniqueChords}
|
||||||
@@ -164,7 +192,6 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop side column (hidden on mobile) */}
|
|
||||||
<div className="hidden lg:block w-72 overflow-y-auto border-l border-border shrink-0">
|
<div className="hidden lg:block w-72 overflow-y-auto border-l border-border shrink-0">
|
||||||
<ChordGrid
|
<ChordGrid
|
||||||
chords={uniqueChords}
|
chords={uniqueChords}
|
||||||
@@ -174,7 +201,6 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile inline popup — fixed bottom, dismissed on scroll */}
|
|
||||||
{activeChord && (
|
{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">
|
<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} />
|
<ChordDiagram chord={activeChord} instrument={instrument} />
|
||||||
|
|||||||
Reference in New Issue
Block a user