refactor(app): extract previewChords to song-utils, simplify API_BASE, fix useEffect deps and cleanup
This commit is contained in:
4
app/.dockerignore
Normal file
4
app/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.react-router
|
||||
build
|
||||
node_modules
|
||||
README.md
|
||||
7
app/.gitignore
vendored
Normal file
7
app/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
.env
|
||||
/node_modules/
|
||||
|
||||
# React Router
|
||||
/.react-router/
|
||||
/build/
|
||||
22
app/Dockerfile
Normal file
22
app/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS development-dependencies-env
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS production-dependencies-env
|
||||
COPY ./package.json package-lock.json /app/
|
||||
WORKDIR /app
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:20-alpine AS build-env
|
||||
COPY . /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
COPY ./package.json package-lock.json /app/
|
||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||
COPY --from=build-env /app/build /app/build
|
||||
WORKDIR /app
|
||||
CMD ["npm", "run", "start"]
|
||||
87
app/README.md
Normal file
87
app/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Welcome to React Router!
|
||||
|
||||
A modern, production-ready template for building full-stack React applications using React Router.
|
||||
|
||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Server-side rendering
|
||||
- ⚡️ Hot Module Replacement (HMR)
|
||||
- 📦 Asset bundling and optimization
|
||||
- 🔄 Data loading and mutations
|
||||
- 🔒 TypeScript by default
|
||||
- 🎉 TailwindCSS for styling
|
||||
- 📖 [React Router docs](https://reactrouter.com/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
Install the dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server with HMR:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Your application will be available at `http://localhost:5173`.
|
||||
|
||||
## Building for Production
|
||||
|
||||
Create a production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
To build and run using Docker:
|
||||
|
||||
```bash
|
||||
docker build -t my-app .
|
||||
|
||||
# Run the container
|
||||
docker run -p 3000:3000 my-app
|
||||
```
|
||||
|
||||
The containerized application can be deployed to any platform that supports Docker, including:
|
||||
|
||||
- AWS ECS
|
||||
- Google Cloud Run
|
||||
- Azure Container Apps
|
||||
- Digital Ocean App Platform
|
||||
- Fly.io
|
||||
- Railway
|
||||
|
||||
### DIY Deployment
|
||||
|
||||
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
||||
|
||||
Make sure to deploy the output of `npm run build`
|
||||
|
||||
```
|
||||
├── package.json
|
||||
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
||||
├── build/
|
||||
│ ├── client/ # Static assets
|
||||
│ └── server/ # Server-side code
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ using React Router.
|
||||
144
app/app/app.css
Normal file
144
app/app/app.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
19
app/app/hooks/use-mobile.ts
Normal file
19
app/app/hooks/use-mobile.ts
Normal 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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
18
app/app/lib/song-utils.ts
Normal 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
6
app/app/lib/utils.ts
Normal 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
78
app/app/root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1420
app/bun.lock
Normal file
1420
app/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
25
app/components.json
Normal file
25
app/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-maia",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/app.css",
|
||||
"baseColor": "mist",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "~/components",
|
||||
"utils": "~/lib/utils",
|
||||
"ui": "~/components/ui",
|
||||
"lib": "~/lib",
|
||||
"hooks": "~/hooks"
|
||||
},
|
||||
"menuColor": "default-translucent",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
48
app/package.json
Normal file
48
app/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "app",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@react-router/node": "7.14.0",
|
||||
"@react-router/serve": "7.14.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"isbot": "^5.1.36",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-resizable-panels": "^4.9.0",
|
||||
"react-router": "7.14.0",
|
||||
"recharts": "3.8.0",
|
||||
"shadcn": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "7.14.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.3"
|
||||
}
|
||||
}
|
||||
BIN
app/public/favicon.ico
Normal file
BIN
app/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
7
app/react-router.config.ts
Normal file
7
app/react-router.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
// Config options...
|
||||
// Server-side render by default, to enable SPA mode set this to `false`
|
||||
ssr: true,
|
||||
} satisfies Config;
|
||||
26
app/tsconfig.json
Normal file
26
app/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"include": [
|
||||
"**/*",
|
||||
"**/.server/**/*",
|
||||
"**/.client/**/*",
|
||||
".react-router/types/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["node", "vite/client"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"rootDirs": [".", "./.react-router/types"],
|
||||
"paths": {
|
||||
"~/*": ["./app/*"]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
10
app/vite.config.ts
Normal file
10
app/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter()],
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user