refactor(app): extract previewChords to song-utils, simplify API_BASE, fix useEffect deps and cleanup

This commit is contained in:
2026-04-08 03:53:44 +02:00
parent 19829a0589
commit 582bdbd901
22 changed files with 1944 additions and 42 deletions

144
app/app/app.css Normal file
View File

@@ -0,0 +1,144 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
html,
body {
@apply bg-white dark:bg-gray-950;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Inter Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.148 0.004 228.8);
--card: oklch(1 0 0);
--card-foreground: oklch(0.148 0.004 228.8);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.148 0.004 228.8);
--primary: oklch(0.553 0.195 38.402);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.963 0.002 197.1);
--muted-foreground: oklch(0.56 0.021 213.5);
--accent: oklch(0.963 0.002 197.1);
--accent-foreground: oklch(0.218 0.008 223.9);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.925 0.005 214.3);
--input: oklch(0.925 0.005 214.3);
--ring: oklch(0.723 0.014 214.4);
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
--radius: 0;
--sidebar: oklch(0.987 0.002 197.1);
--sidebar-foreground: oklch(0.148 0.004 228.8);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.963 0.002 197.1);
--sidebar-accent-foreground: oklch(0.218 0.008 223.9);
--sidebar-border: oklch(0.925 0.005 214.3);
--sidebar-ring: oklch(0.723 0.014 214.4);
}
.dark {
--background: oklch(0.148 0.004 228.8);
--foreground: oklch(0.987 0.002 197.1);
--card: oklch(0.218 0.008 223.9);
--card-foreground: oklch(0.987 0.002 197.1);
--popover: oklch(0.218 0.008 223.9);
--popover-foreground: oklch(0.987 0.002 197.1);
--primary: oklch(0.47 0.157 37.304);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.275 0.011 216.9);
--muted-foreground: oklch(0.723 0.014 214.4);
--accent: oklch(0.275 0.011 216.9);
--accent-foreground: oklch(0.987 0.002 197.1);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.56 0.021 213.5);
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
--sidebar: oklch(0.218 0.008 223.9);
--sidebar-foreground: oklch(0.987 0.002 197.1);
--sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.275 0.011 216.9);
--sidebar-accent-foreground: oklch(0.987 0.002 197.1);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.56 0.021 213.5);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import {
Sheet,
@@ -10,7 +10,7 @@ import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import type { SongSummary } from "~/lib/types";
import { createSong } from "~/lib/api";
import { previewChords } from "~/lib/mock";
import { previewChords } from "~/lib/song-utils";
interface Props {
open: boolean;
@@ -27,6 +27,10 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) reset();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;

View File

@@ -28,7 +28,7 @@ export function EditSongSheet({ id, meta, open, onOpenChange, onUpdated }: Props
setArtist(meta.artist);
setKey(meta.original_key ?? "");
}
}, [open, meta.title, meta.artist, meta.original_key]);
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -1,27 +1,18 @@
import type { Song, SongSummary, StoredSong, UpdateSongRequest } from "./types";
function getApiBase(): string {
// Works in both SSR (Node/process.env) and client (import.meta.env)
if (typeof process !== "undefined" && process.env?.API_URL) {
return process.env.API_URL;
}
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_API_URL) {
return (import.meta as any).env.VITE_API_URL;
}
return "http://localhost:8000";
}
const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
export async function listSongs(q = ""): Promise<SongSummary[]> {
const url = q.trim()
? `${getApiBase()}/songs?q=${encodeURIComponent(q.trim())}`
: `${getApiBase()}/songs`;
? `${API_BASE}/songs?q=${encodeURIComponent(q.trim())}`
: `${API_BASE}/songs`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load songs: ${res.status}`);
return res.json();
}
export async function getSong(id: string): Promise<Song | null> {
const res = await fetch(`${getApiBase()}/songs/${id}`);
const res = await fetch(`${API_BASE}/songs/${id}`);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Failed to load song: ${res.status}`);
return res.json();
@@ -31,7 +22,7 @@ export async function createSong(body: {
source?: string;
html?: string;
}): Promise<StoredSong> {
const res = await fetch(`${getApiBase()}/songs`, {
const res = await fetch(`${API_BASE}/songs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -44,12 +35,12 @@ export async function createSong(body: {
}
export async function deleteSong(id: string): Promise<void> {
const res = await fetch(`${getApiBase()}/songs/${id}`, { method: "DELETE" });
const res = await fetch(`${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(`${getApiBase()}/songs/${id}`, {
const res = await fetch(`${API_BASE}/songs/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),

View File

@@ -1,4 +1,5 @@
import type { Song, SongSummary } from "./types";
import { previewChords } from "./song-utils";
const OCEAN: Song = {
meta: {
@@ -114,23 +115,6 @@ const SONGS_MAP: Record<string, Song> = {
"song-naked": NAKED,
};
export function previewChords(song: Song): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const section of song.sections) {
for (const line of section.lines) {
for (const cp of line.chords) {
if (!seen.has(cp.chord)) {
seen.add(cp.chord);
result.push(cp.chord);
}
}
}
if (result.length >= 5) break;
}
return result.slice(0, 5);
}
export const MOCK_SONGS: SongSummary[] = Object.entries(SONGS_MAP).map(
([id, song]) => ({
id,

18
app/app/lib/song-utils.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Song } from "./types";
export function previewChords(song: Song): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const section of song.sections) {
for (const line of section.lines) {
for (const cp of line.chords) {
if (!seen.has(cp.chord)) {
seen.add(cp.chord);
result.push(cp.chord);
}
}
}
if (result.length >= 5) break;
}
return result.slice(0, 5);
}

6
app/app/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

78
app/app/root.tsx Normal file
View File

@@ -0,0 +1,78 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
import { TooltipProvider } from "./components/ui/tooltip";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<TooltipProvider>
{children}
<ScrollRestoration />
<Scripts />
</TooltipProvider>
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams, useRevalidator } from "react-router";
import type { Route } from "./+types/home";
import { Button } from "~/components/ui/button";
@@ -45,11 +45,13 @@ export default function Home({ loaderData }: Route.ComponentProps) {
}, 300);
}, [setSearchParams]);
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []);
const allSongs = [...songs, ...localSongs];
return (
<div className="flex flex-col h-full max-w-lg mx-auto">
{/* Header */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<h1 className="text-lg font-bold">PocketChords</h1>
<Button size="sm" onClick={() => setSheetOpen(true)}>
@@ -58,7 +60,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</Button>
</div>
{/* Search */}
<div className="px-4 pb-3">
<Input
placeholder="Search songs..."
@@ -68,7 +70,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
/>
</div>
{/* Error state */}
{error && (
<div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center">
<p className="text-sm text-muted-foreground">
@@ -84,7 +86,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</div>
)}
{/* Grid */}
<div className="flex-1 overflow-y-auto px-4 pb-4">
{!error && allSongs.length === 0 && (
<p className="text-sm text-muted-foreground text-center pt-8 pb-4">

View File

@@ -27,7 +27,7 @@ export async function loader({ params }: Route.LoaderArgs) {
if (err && typeof err === "object" && "status" in err && (err as { status: number }).status === 404) {
throw err;
}
return { song: null as unknown as Song, id };
return { song: null as Song | null, id };
}
}