diff --git a/app/app/components/bottom-nav.tsx b/app/app/components/bottom-nav.tsx index bbef0f3..3523827 100644 --- a/app/app/components/bottom-nav.tsx +++ b/app/app/components/bottom-nav.tsx @@ -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 ( - + + setRegisterOpen(true)} + /> + setLoginOpen(true)} + /> + ); } diff --git a/app/app/components/login-sheet.tsx b/app/app/components/login-sheet.tsx new file mode 100644 index 0000000..11123b6 --- /dev/null +++ b/app/app/components/login-sheet.tsx @@ -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 ( + + + + Sign in + +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ + +
+
+
+ ); +} diff --git a/app/app/components/register-sheet.tsx b/app/app/components/register-sheet.tsx new file mode 100644 index 0000000..9007cbd --- /dev/null +++ b/app/app/components/register-sheet.tsx @@ -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 ( + + + + Create account + +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setUsername(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + minLength={8} + /> +
+ + +
+
+
+ ); +} diff --git a/app/app/lib/api.ts b/app/app/lib/api.ts index f712fed..b86ac86 100644 --- a/app/app/lib/api.ts +++ b/app/app/lib/api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { const params = new URLSearchParams(); @@ -23,11 +104,10 @@ export async function getSong(id: string, applyCapo = false): Promise { - const res = await fetch(`${API_BASE}/songs`, { +// --- Songs (mutations — auth required) --- + +export async function createSong(body: { source?: string; html?: string }): Promise { + 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 { - 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 { - 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), diff --git a/app/app/lib/auth.tsx b/app/app/lib/auth.tsx new file mode 100644 index 0000000..5293dc2 --- /dev/null +++ b/app/app/lib/auth.tsx @@ -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; + register: (email: string, username: string, password: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [userId, setUserId] = useState(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( + () => ({ + userId, + isAuthenticated: !!userId, + isLoading, + login, + register, + logout, + }), + [userId, isLoading, login, register, logout], + ); + + return {children}; +} + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/app/app/lib/types.ts b/app/app/lib/types.ts index bb25c19..dd5c2ac 100644 --- a/app/app/lib/types.ts +++ b/app/app/lib/types.ts @@ -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; +} diff --git a/app/app/root.tsx b/app/app/root.tsx index 9db297b..04cd395 100644 --- a/app/app/root.tsx +++ b/app/app/root.tsx @@ -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 }) { - - {children} - - - + + + {children} + + + + diff --git a/app/app/routes/home.tsx b/app/app/routes/home.tsx index ebd8985..bc2a7af 100644 --- a/app/app/routes/home.tsx +++ b/app/app/routes/home.tsx @@ -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([]); const [localSongs, setLocalSongs] = useState([]); - 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 | null>(null); - const handleSearch = useCallback((value: string) => { - setInputValue(value); - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - const next: Record = {}; - 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 = {}; + 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 (
-

PocketChords

- + {isAuthenticated && ( + + )}
-
- {([["date", "Date"], ["title", "Title"], ["artist", "Artist"]] as const).map(([val, label]) => ( + {( + [ + ["date", "Date"], + ["title", "Title"], + ["artist", "Artist"], + ] as const + ).map(([val, label]) => ( ))}
@@ -110,33 +137,50 @@ export default function Home({ loaderData }: Route.ComponentProps) {
)} -
- {!error && allSongs.length === 0 && ( + {loading && !error && ( +
+ +
+ )} + {!loading && !error && allSongs.length === 0 && (

- {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."}

)} -
- {allSongs.map((song) => ( - - ))} - setSheetOpen(true)} - > - - - - -
+ {!loading && ( +
+ {allSongs.map((song) => ( + + ))} + {isAuthenticated && ( + setSheetOpen(true)} + > + + + + + )} +
+ )}
(initialSong ?? null); - const [displayedSong, setDisplayedSong] = useState(initialSong ?? null); +export function meta() { + return [{ title: "PocketChords" }]; +} + +export default function SongDetail() { + const { id = "" } = useParams(); + const { isAuthenticated } = useAuth(); + + const [baseSong, setBaseSong] = useState(null); + const [displayedSong, setDisplayedSong] = useState(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(initInstrument); const scrollRef = useRef(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 ( +
+ +
+ ); + } + if (!baseSong || !displayedSong) { return (
-

Song not found or unavailable.

- +

+ Song not found or unavailable. +

+ ← Back to library
@@ -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 */}
- {/* Left / main column */}
- {/* Mobile bottom chord grid (hidden on desktop) */}
- {/* Desktop side column (hidden on mobile) */}
- {/* Mobile inline popup — fixed bottom, dismissed on scroll */} {activeChord && (