Compare commits
32 Commits
c3b7cb78ab
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a520251dab | |||
| ab5eb69f7a | |||
| b1d778284c | |||
| 808ce287a5 | |||
| 3605bef2b0 | |||
| dbb7cbd92f | |||
| ed669b2e3a | |||
| 2964475df9 | |||
| 544474c074 | |||
| e9c2f7a5e0 | |||
| e99b581a43 | |||
| ec7237e8c7 | |||
| 3d56b71aa9 | |||
| 5a4d0ef648 | |||
| 7653933f46 | |||
| 90df2b36f2 | |||
| ac65be1bb9 | |||
| aeb9dfff67 | |||
| bb99f5964a | |||
| df90e610ef | |||
| f80ed4e88a | |||
| 2558f19960 | |||
| 41b9cb3d4c | |||
| bef3bb8fed | |||
| 582bdbd901 | |||
| 19829a0589 | |||
| 8fc6e48aac | |||
| 3a576f0018 | |||
| 65dd30df46 | |||
| 1936ced395 | |||
| 6a3cdbe4c3 | |||
| 377fe957bc |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
/app
|
||||||
|
.superpowers/
|
||||||
|
.git/
|
||||||
|
.claude/
|
||||||
22
.env.compose
Normal file
22
.env.compose
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Copy this file and fill in your values before deploying:
|
||||||
|
# cp .env.compose .env.compose.local
|
||||||
|
#
|
||||||
|
# Then run:
|
||||||
|
# docker compose --env-file .env.compose.local up -d --build
|
||||||
|
|
||||||
|
# ── Frontend ──────────────────────────────────────────────────────────────────
|
||||||
|
# URL your browser (and the SSR server) uses to reach the API.
|
||||||
|
# Baked into the JS bundle at build time — rebuild the app image when changing.
|
||||||
|
# LAN example: http://192.168.1.100:8000
|
||||||
|
# Reverse proxy: https://pocketchords.yourdomain.com/api
|
||||||
|
VITE_API_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# ── Backend ───────────────────────────────────────────────────────────────────
|
||||||
|
# Comma-separated allowed CORS origins, or * for any.
|
||||||
|
# Lock this down when exposing publicly: https://pocketchords.yourdomain.com
|
||||||
|
CORS_ALLOWED_ORIGINS=*
|
||||||
|
|
||||||
|
# ── Ports (host-side) ─────────────────────────────────────────────────────────
|
||||||
|
# Change if something else is already using these ports on the host.
|
||||||
|
API_PORT=8000
|
||||||
|
APP_PORT=3000
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
.env
|
||||||
|
.superpowers/
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -276,6 +276,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"domain",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM rust:1.92 AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the release binary
|
||||||
|
RUN cargo build --release -p api
|
||||||
|
|
||||||
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install OpenSSL, CA certs
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libssl3 \
|
||||||
|
ca-certificates \
|
||||||
|
libsqlite3-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/release/api .
|
||||||
|
|
||||||
|
|
||||||
|
# Create data directory for SQLite
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
ENV DATABASE_URL=sqlite:///app/data/pocket-chords.db
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["./api"]
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# PocketChords
|
||||||
|
|
||||||
|
A rip-off of [TabsUltimate](https://www.tabultimateguitar.com/) with a focus on mobile users, without any ads or subscription. It is open source and free to use.
|
||||||
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/
|
||||||
25
app/Dockerfile
Normal file
25
app/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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
|
||||||
|
# VITE_API_URL is baked into the bundle at build time (both SSR and client use it)
|
||||||
|
ARG VITE_API_URL=http://localhost:8000
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
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.
|
||||||
145
app/app/app.css
Normal file
145
app/app/app.css
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
@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 { useNavigate } from "react-router";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -10,7 +10,8 @@ import { Input } from "~/components/ui/input";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import type { SongSummary } from "~/lib/types";
|
import type { SongSummary } from "~/lib/types";
|
||||||
import { createSong } from "~/lib/api";
|
import { createSong } from "~/lib/api";
|
||||||
import { previewChords } from "~/lib/mock";
|
import { previewChords } from "~/lib/song-utils";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -18,51 +19,143 @@ interface Props {
|
|||||||
onSongAdded: (summary: SongSummary) => void;
|
onSongAdded: (summary: SongSummary) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileStatus = "pending" | "importing" | "done" | "error";
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
html: string | null;
|
||||||
|
status: FileStatus;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsText(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => resolve(ev.target?.result as string);
|
||||||
|
reader.onerror = () => reject(new Error("Failed to read file."));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
|
export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [fileName, setFileName] = useState<string | null>(null);
|
const [fileItems, setFileItems] = useState<FileItem[]>([]);
|
||||||
const [fileHtml, setFileHtml] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
useEffect(() => {
|
||||||
const file = e.target.files?.[0];
|
if (!open) reset();
|
||||||
if (!file) return;
|
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
setFileName(file.name);
|
|
||||||
|
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const files = Array.from(e.target.files ?? []);
|
||||||
|
if (!files.length) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (ev) => {
|
const stubs: FileItem[] = files.map((f) => ({
|
||||||
setFileHtml(ev.target?.result as string);
|
id: crypto.randomUUID(),
|
||||||
};
|
name: f.name,
|
||||||
reader.onerror = () => setError("Failed to read file.");
|
html: null,
|
||||||
reader.readAsText(file);
|
status: "pending",
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
setFileItems((prev) => [...prev, ...stubs]);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const stub = stubs[i];
|
||||||
|
try {
|
||||||
|
const html = await readFileAsText(files[i]);
|
||||||
|
setFileItems((prev) =>
|
||||||
|
prev.map((item) => item.id === stub.id ? { ...item, html } : item)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setFileItems((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === stub.id
|
||||||
|
? { ...item, status: "error", error: "Failed to read file." }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setUrl("");
|
setUrl("");
|
||||||
setFileName(null);
|
setFileItems([]);
|
||||||
setFileHtml(null);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBulkImport() {
|
||||||
|
const toProcess = fileItems.filter((f) => f.status === "pending" && f.html !== null);
|
||||||
|
if (!toProcess.length) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
const successes: SongSummary[] = [];
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
for (const item of toProcess) {
|
||||||
|
setFileItems((prev) =>
|
||||||
|
prev.map((f) => f.id === item.id ? { ...f, status: "importing" } : f)
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const stored = await createSong({ html: item.html! });
|
||||||
|
successes.push({
|
||||||
|
id: stored.id,
|
||||||
|
meta: stored.song.meta,
|
||||||
|
preview_chords: previewChords(stored.song),
|
||||||
|
});
|
||||||
|
setFileItems((prev) =>
|
||||||
|
prev.map((f) => f.id === item.id ? { ...f, status: "done" } : f)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
hasError = true;
|
||||||
|
setFileItems((prev) =>
|
||||||
|
prev.map((f) =>
|
||||||
|
f.id === item.id
|
||||||
|
? { ...f, status: "error", error: err instanceof Error ? err.message : "Import failed." }
|
||||||
|
: f
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
for (const s of successes) onSongAdded(s);
|
||||||
|
|
||||||
|
if (!hasError) {
|
||||||
|
onOpenChange(false);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (!url.trim() && !fileHtml) {
|
if (fileItems.length > 0) {
|
||||||
|
await handleBulkImport();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.trim()) {
|
||||||
setError("Provide a URL or pick a file.");
|
setError("Provide a URL or pick a file.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const stored = await createSong(
|
const stored = await createSong({ source: url.trim() });
|
||||||
fileHtml ? { html: fileHtml } : { source: url.trim() }
|
onSongAdded({
|
||||||
);
|
id: stored.id,
|
||||||
onSongAdded({ id: stored.id, meta: stored.song.meta, preview_chords: previewChords(stored.song) });
|
meta: stored.song.meta,
|
||||||
|
preview_chords: previewChords(stored.song),
|
||||||
|
});
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
reset();
|
reset();
|
||||||
navigate(`/songs/${stored.id}`);
|
navigate(`/songs/${stored.id}`);
|
||||||
@@ -73,6 +166,10 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pendingCount = fileItems.filter((f) => f.status === "pending" && f.html !== null).length;
|
||||||
|
const doneCount = fileItems.filter((f) => f.status === "done").length;
|
||||||
|
const readableCount = fileItems.filter((f) => f.html !== null).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent side="bottom" className="rounded-t-xl">
|
<SheetContent side="bottom" className="rounded-t-xl">
|
||||||
@@ -88,7 +185,7 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
|
|||||||
placeholder="https://tabs.ultimate-guitar.com/..."
|
placeholder="https://tabs.ultimate-guitar.com/..."
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
disabled={loading || !!fileHtml}
|
disabled={loading || fileItems.length > 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,18 +204,53 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full border-dashed"
|
className="w-full border-dashed"
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
disabled={loading}
|
disabled={loading || !!url.trim()}
|
||||||
>
|
>
|
||||||
{fileName ? `📄 ${fileName}` : "📂 Choose saved UG page (.html)"}
|
{fileItems.length > 0 ? "📂 Add more files" : "📂 Choose saved UG pages (.html)"}
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".html,.htm"
|
accept=".html,.htm"
|
||||||
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{fileItems.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-0.5 max-h-48 overflow-y-auto mt-1 rounded border border-border p-2">
|
||||||
|
{fileItems.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center gap-2 text-sm py-0.5">
|
||||||
|
<span
|
||||||
|
className={cn("shrink-0 w-4 text-center font-mono text-xs", {
|
||||||
|
"text-muted-foreground": item.status === "pending",
|
||||||
|
"text-blue-500": item.status === "importing",
|
||||||
|
"text-green-600": item.status === "done",
|
||||||
|
"text-destructive": item.status === "error",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{item.status === "pending" && "•"}
|
||||||
|
{item.status === "importing" && "↻"}
|
||||||
|
{item.status === "done" && "✓"}
|
||||||
|
{item.status === "error" && "✗"}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate text-foreground">{item.name}</span>
|
||||||
|
{item.error && (
|
||||||
|
<span className="text-xs text-destructive shrink-0 max-w-[140px] truncate">
|
||||||
|
{item.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && fileItems.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
Importing {doneCount} of {readableCount}...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
@@ -127,13 +259,28 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => { onOpenChange(false); reset(); }}
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" className="flex-1" disabled={loading || (!url.trim() && !fileHtml)}>
|
<Button
|
||||||
{loading ? "Importing..." : "Import"}
|
type="submit"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={
|
||||||
|
loading ||
|
||||||
|
(fileItems.length === 0 && !url.trim()) ||
|
||||||
|
(fileItems.length > 0 && pendingCount === 0)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "Importing..."
|
||||||
|
: fileItems.length > 1
|
||||||
|
? `Import ${pendingCount} Song${pendingCount !== 1 ? "s" : ""}`
|
||||||
|
: "Import"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
45
app/app/components/bottom-nav.tsx
Normal file
45
app/app/components/bottom-nav.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NavLink } from "react-router";
|
||||||
|
import { Music, Sun, Moon } from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
||||||
|
export function BottomNav() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="border-t bg-background shrink-0">
|
||||||
|
<div className="max-w-lg mx-auto flex items-center">
|
||||||
|
<NavLink
|
||||||
|
to="/"
|
||||||
|
end
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"flex flex-col items-center gap-0.5 flex-1 py-2 text-xs transition-colors",
|
||||||
|
isActive
|
||||||
|
? "text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Music className="w-5 h-5" />
|
||||||
|
<span>Library</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mr-2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{resolvedTheme === "dark" ? (
|
||||||
|
<Sun className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,16 @@
|
|||||||
import type { LyricLine, Section } from "~/lib/types";
|
import type { LyricLine, Section } from "~/lib/types";
|
||||||
|
|
||||||
// Max characters per rendered line. 38 fits comfortably on a 375px phone
|
|
||||||
// at 14px monospace (≈8.4px per char with padding).
|
|
||||||
const MAX_WIDTH = 38;
|
const MAX_WIDTH = 38;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sections: Section[];
|
sections: Section[];
|
||||||
}
|
fontSize?: 'sm' | 'base' | 'lg';
|
||||||
|
onChordClick?: (chord: string) => void;
|
||||||
function buildChordRow(chords: { offset: number; chord: string }[]): string {
|
|
||||||
let row = "";
|
|
||||||
for (const { offset, chord } of chords) {
|
|
||||||
while (row.length < offset) row += " ";
|
|
||||||
row += chord;
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Split one LyricLine into segments that each fit within maxWidth characters. */
|
/** Split one LyricLine into segments that each fit within maxWidth characters. */
|
||||||
function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] {
|
function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] {
|
||||||
const { text, chords } = line;
|
const { text, chords } = line;
|
||||||
|
|
||||||
if (text.length <= maxWidth) return [line];
|
if (text.length <= maxWidth) return [line];
|
||||||
|
|
||||||
const segments: LyricLine[] = [];
|
const segments: LyricLine[] = [];
|
||||||
@@ -28,42 +18,64 @@ function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] {
|
|||||||
|
|
||||||
while (start < text.length) {
|
while (start < text.length) {
|
||||||
let end = start + maxWidth;
|
let end = start + maxWidth;
|
||||||
|
|
||||||
if (end < text.length) {
|
if (end < text.length) {
|
||||||
// Break at last space before maxWidth to avoid splitting mid-word
|
|
||||||
const breakAt = text.lastIndexOf(" ", end);
|
const breakAt = text.lastIndexOf(" ", end);
|
||||||
if (breakAt > start) end = breakAt + 1;
|
if (breakAt > start) end = breakAt + 1;
|
||||||
} else {
|
} else {
|
||||||
end = text.length;
|
end = text.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const segText = text.slice(start, end).trimEnd();
|
const segText = text.slice(start, end).trimEnd();
|
||||||
|
|
||||||
// Include chords whose offset falls within this segment; re-map offset
|
|
||||||
const segChords = chords
|
const segChords = chords
|
||||||
.filter((cp) => cp.offset >= start && cp.offset < end)
|
.filter((cp) => cp.offset >= start && cp.offset < end)
|
||||||
.map((cp) => ({ ...cp, offset: cp.offset - start }));
|
.map((cp) => ({ ...cp, offset: cp.offset - start }));
|
||||||
|
|
||||||
segments.push({ text: segText, chords: segChords });
|
segments.push({ text: segText, chords: segChords });
|
||||||
|
|
||||||
// Advance past the break point, skipping leading spaces for next segment
|
|
||||||
start = end;
|
start = end;
|
||||||
while (start < text.length && text[start] === " ") start++;
|
while (start < text.length && text[start] === " ") start++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LineBlock({ line }: { line: LyricLine }) {
|
function ChordRow({
|
||||||
|
chords,
|
||||||
|
sizeClass,
|
||||||
|
onChordClick,
|
||||||
|
}: {
|
||||||
|
chords: { offset: number; chord: string }[];
|
||||||
|
sizeClass: string;
|
||||||
|
onChordClick?: (chord: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`relative font-mono ${sizeClass} text-primary`} style={{ height: '1.5em' }}>
|
||||||
|
{chords.map(({ offset, chord }, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="absolute cursor-pointer hover:underline"
|
||||||
|
style={{ left: `${offset}ch` }}
|
||||||
|
onClick={() => onChordClick?.(chord)}
|
||||||
|
>
|
||||||
|
{chord}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineBlock({
|
||||||
|
line,
|
||||||
|
sizeClass,
|
||||||
|
onChordClick,
|
||||||
|
}: {
|
||||||
|
line: LyricLine;
|
||||||
|
sizeClass: string;
|
||||||
|
onChordClick?: (chord: string) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
{line.chords.length > 0 && (
|
{line.chords.length > 0 && (
|
||||||
<pre className="text-primary text-sm font-mono whitespace-pre">
|
<ChordRow chords={line.chords} sizeClass={sizeClass} onChordClick={onChordClick} />
|
||||||
{buildChordRow(line.chords)}
|
|
||||||
</pre>
|
|
||||||
)}
|
)}
|
||||||
{line.text && (
|
{line.text && (
|
||||||
<pre className="text-foreground text-sm font-mono whitespace-pre">
|
<pre className={`text-foreground ${sizeClass} font-mono whitespace-pre`}>
|
||||||
{line.text}
|
{line.text}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -71,7 +83,15 @@ function LineBlock({ line }: { line: LyricLine }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionBlock({ section }: { section: Section }) {
|
function SectionBlock({
|
||||||
|
section,
|
||||||
|
sizeClass,
|
||||||
|
onChordClick,
|
||||||
|
}: {
|
||||||
|
section: Section;
|
||||||
|
sizeClass: string;
|
||||||
|
onChordClick?: (chord: string) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
{section.label && (
|
{section.label && (
|
||||||
@@ -79,18 +99,19 @@ function SectionBlock({ section }: { section: Section }) {
|
|||||||
)}
|
)}
|
||||||
{section.lines.flatMap((line, i) =>
|
{section.lines.flatMap((line, i) =>
|
||||||
segmentLine(line, MAX_WIDTH).map((seg, j) => (
|
segmentLine(line, MAX_WIDTH).map((seg, j) => (
|
||||||
<LineBlock key={`${i}-${j}`} line={seg} />
|
<LineBlock key={`${i}-${j}`} line={seg} sizeClass={sizeClass} onChordClick={onChordClick} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChordChart({ sections }: Props) {
|
export function ChordChart({ sections, fontSize, onChordClick }: Props) {
|
||||||
|
const sizeClass = { sm: 'text-sm', base: 'text-base', lg: 'text-lg' }[fontSize ?? 'sm'];
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
{sections.map((section, i) => (
|
{sections.map((section, i) => (
|
||||||
<SectionBlock key={i} section={section} />
|
<SectionBlock key={i} section={section} sizeClass={sizeClass} onChordClick={onChordClick} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
23
app/app/components/chord-diagram/chord-diagram.tsx
Normal file
23
app/app/components/chord-diagram/chord-diagram.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { getPianoNotes, getGuitarVoicing } from '~/lib/chord-voicing';
|
||||||
|
import { PianoKeys } from './piano-keys';
|
||||||
|
import { GuitarFretboard } from './guitar-fretboard';
|
||||||
|
|
||||||
|
export type Instrument = 'piano' | 'guitar';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chord: string;
|
||||||
|
instrument: Instrument;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChordDiagram({ chord, instrument }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2 p-2">
|
||||||
|
<span className="text-xs font-mono font-semibold text-primary">{chord}</span>
|
||||||
|
{instrument === 'piano' ? (
|
||||||
|
<PianoKeys notes={getPianoNotes(chord)} />
|
||||||
|
) : (
|
||||||
|
<GuitarFretboard voicing={getGuitarVoicing(chord)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
app/app/components/chord-diagram/chord-grid.tsx
Normal file
47
app/app/components/chord-diagram/chord-grid.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { ChordDiagram } from './chord-diagram';
|
||||||
|
import type { Instrument } from './chord-diagram';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chords: string[];
|
||||||
|
instrument: Instrument;
|
||||||
|
onInstrumentChange: (i: Instrument) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChordGrid({ chords, instrument, onInstrumentChange }: Props) {
|
||||||
|
if (chords.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3">
|
||||||
|
{/* Instrument toggle */}
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="text-xs text-muted-foreground">Chords</span>
|
||||||
|
<div className="flex rounded-md border border-border overflow-hidden ml-auto">
|
||||||
|
<button
|
||||||
|
className={`px-2 py-1 text-xs transition-colors ${instrument === 'piano' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent'}`}
|
||||||
|
onClick={() => onInstrumentChange('piano')}
|
||||||
|
>
|
||||||
|
Piano
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-2 py-1 text-xs transition-colors ${instrument === 'guitar' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent'}`}
|
||||||
|
onClick={() => onInstrumentChange('guitar')}
|
||||||
|
>
|
||||||
|
Guitar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chord cards */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{chords.map((chord) => (
|
||||||
|
<div
|
||||||
|
key={chord}
|
||||||
|
className="rounded-md border border-border bg-card"
|
||||||
|
>
|
||||||
|
<ChordDiagram chord={chord} instrument={instrument} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
app/app/components/chord-diagram/guitar-fretboard.tsx
Normal file
155
app/app/components/chord-diagram/guitar-fretboard.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import type { GuitarVoicing } from '~/lib/chord-voicing';
|
||||||
|
|
||||||
|
const STRING_COUNT = 6;
|
||||||
|
const FRET_COUNT = 4;
|
||||||
|
const STRING_SPACING = 13; // px between adjacent string lines
|
||||||
|
const FRET_HEIGHT = 18; // px per fret
|
||||||
|
const DOT_R = 5; // finger dot radius
|
||||||
|
|
||||||
|
const GRID_W = STRING_SPACING * (STRING_COUNT - 1); // 65px
|
||||||
|
const GRID_H = FRET_HEIGHT * FRET_COUNT; // 72px
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
voicing: GuitarVoicing | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GuitarFretboard({ voicing }: Props) {
|
||||||
|
if (!voicing) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-muted-foreground text-xs"
|
||||||
|
style={{ height: GRID_H + 20, width: GRID_W + 20 }}
|
||||||
|
>
|
||||||
|
no voicing
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { frets, baseFret, barre } = voicing;
|
||||||
|
const showFretLabel = baseFret > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
{/* O/X string indicators above nut */}
|
||||||
|
<div className="relative" style={{ width: GRID_W, height: 16 }}>
|
||||||
|
{frets.map((f, i) =>
|
||||||
|
f === null ? (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="absolute font-mono text-destructive"
|
||||||
|
style={{ fontSize: 10, top: 2, left: i * STRING_SPACING - 4 }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</span>
|
||||||
|
) : f === 0 ? (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="absolute font-mono text-muted-foreground"
|
||||||
|
style={{ fontSize: 10, top: 2, left: i * STRING_SPACING - 5 }}
|
||||||
|
>
|
||||||
|
○
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fret number + grid */}
|
||||||
|
<div className="flex items-start" style={{ gap: 4 }}>
|
||||||
|
{/* Fret number label (barre position) */}
|
||||||
|
<div style={{ width: 12, textAlign: 'right' }}>
|
||||||
|
{showFretLabel && (
|
||||||
|
<span
|
||||||
|
className="font-mono text-muted-foreground"
|
||||||
|
style={{ fontSize: 9, lineHeight: `${FRET_HEIGHT}px` }}
|
||||||
|
>
|
||||||
|
{baseFret}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fretboard grid */}
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
width: GRID_W,
|
||||||
|
height: GRID_H,
|
||||||
|
// Nut: thick when open position, thin when barre
|
||||||
|
borderTop: showFretLabel
|
||||||
|
? `1px solid var(--border)`
|
||||||
|
: `4px solid var(--foreground)`,
|
||||||
|
borderBottom: `1px solid var(--border)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* String lines (6 vertical lines) */}
|
||||||
|
{Array.from({ length: STRING_COUNT }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: i * STRING_SPACING,
|
||||||
|
width: 1,
|
||||||
|
background: 'var(--foreground)',
|
||||||
|
opacity: 0.35,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Fret lines (horizontal, between frets) */}
|
||||||
|
{Array.from({ length: FRET_COUNT - 1 }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: (i + 1) * FRET_HEIGHT,
|
||||||
|
height: 1,
|
||||||
|
background: 'var(--border)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Barre bar — only for transposed barre chords */}
|
||||||
|
{barre !== null && barre > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute rounded-full"
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: DOT_R * 2,
|
||||||
|
top: (barre - baseFret) * FRET_HEIGHT + FRET_HEIGHT / 2 - DOT_R,
|
||||||
|
background: 'var(--primary)',
|
||||||
|
zIndex: 5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Finger dots */}
|
||||||
|
{frets.map((fret, stringIdx) => {
|
||||||
|
if (fret === null || fret === 0) return null;
|
||||||
|
// Skip strings covered by the barre bar
|
||||||
|
if (barre !== null && fret === barre) return null;
|
||||||
|
const fretIdx = baseFret === 0 ? fret - 1 : fret - baseFret;
|
||||||
|
if (fretIdx < 0 || fretIdx >= FRET_COUNT) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stringIdx}
|
||||||
|
className="absolute rounded-full"
|
||||||
|
style={{
|
||||||
|
width: DOT_R * 2,
|
||||||
|
height: DOT_R * 2,
|
||||||
|
left: stringIdx * STRING_SPACING - DOT_R,
|
||||||
|
top: fretIdx * FRET_HEIGHT + FRET_HEIGHT / 2 - DOT_R,
|
||||||
|
background: 'var(--primary)',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
app/app/components/chord-diagram/piano-keys.tsx
Normal file
161
app/app/components/chord-diagram/piano-keys.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/** Chroma value (0=C … 11=B) for every note name tonal might return */
|
||||||
|
const NOTE_CHROMA: Record<string, number> = {
|
||||||
|
'C': 0, 'C#': 1, 'Db': 1,
|
||||||
|
'D': 2, 'D#': 3, 'Eb': 3,
|
||||||
|
'E': 4, 'Fb': 4,
|
||||||
|
'F': 5, 'E#': 5, 'F#': 6, 'Gb': 6,
|
||||||
|
'G': 7, 'G#': 8, 'Ab': 8,
|
||||||
|
'A': 9, 'A#': 10, 'Bb': 10,
|
||||||
|
'B': 11, 'Cb': 11, 'B#': 0,
|
||||||
|
// Double-sharps tonal may return (e.g. Baug → F##=G)
|
||||||
|
'C##': 2, 'D##': 4, 'F##': 7, 'G##': 9, 'A##': 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
const WHITE_KEYS = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
|
||||||
|
const WHITE_KEY_W = 16; // px
|
||||||
|
const WHITE_KEY_H = 62; // px
|
||||||
|
const BLACK_KEY_W = 10; // px
|
||||||
|
const BLACK_KEY_H = 38; // px
|
||||||
|
|
||||||
|
/** Left offset (px) of each black key from the left edge of the keyboard */
|
||||||
|
const BLACK_KEY_LEFT: Record<string, number> = {
|
||||||
|
'C#': 1 * WHITE_KEY_W - BLACK_KEY_W / 2,
|
||||||
|
'D#': 2 * WHITE_KEY_W - BLACK_KEY_W / 2,
|
||||||
|
'F#': 4 * WHITE_KEY_W - BLACK_KEY_W / 2,
|
||||||
|
'G#': 5 * WHITE_KEY_W - BLACK_KEY_W / 2,
|
||||||
|
'A#': 6 * WHITE_KEY_W - BLACK_KEY_W / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLACK_KEY_NAMES = Object.keys(BLACK_KEY_LEFT);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Note names from tonal, e.g. ["C","E","G"] or ["Ab","C","Eb"] */
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PianoKeys({ notes }: Props) {
|
||||||
|
const activeChroma = new Set(
|
||||||
|
notes.map((n) => NOTE_CHROMA[n]).filter((c) => c !== undefined)
|
||||||
|
);
|
||||||
|
const totalWidth = WHITE_KEY_W * 7;
|
||||||
|
|
||||||
|
if (notes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-muted-foreground text-xs"
|
||||||
|
style={{ width: totalWidth, height: WHITE_KEY_H + 26 }}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: totalWidth }}>
|
||||||
|
{/* Keyboard — gray container makes keys visible in any theme */}
|
||||||
|
<div
|
||||||
|
className="relative rounded-sm"
|
||||||
|
style={{ width: totalWidth, height: WHITE_KEY_H, background: '#d0d0d0' }}
|
||||||
|
>
|
||||||
|
{/* White keys */}
|
||||||
|
{WHITE_KEYS.map((note, i) => {
|
||||||
|
const active = activeChroma.has(NOTE_CHROMA[note]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={note}
|
||||||
|
className="absolute rounded-b-sm"
|
||||||
|
style={{
|
||||||
|
left: i * WHITE_KEY_W + 1,
|
||||||
|
top: 0,
|
||||||
|
width: WHITE_KEY_W - 2,
|
||||||
|
height: WHITE_KEY_H - 1,
|
||||||
|
background: 'white',
|
||||||
|
borderBottom: active ? '3px solid var(--primary)' : '1px solid #bbb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<div
|
||||||
|
className="absolute rounded-full"
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
bottom: 10,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: 'var(--primary)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Black keys (rendered on top) */}
|
||||||
|
{BLACK_KEY_NAMES.map((note) => {
|
||||||
|
const active = activeChroma.has(NOTE_CHROMA[note]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={note}
|
||||||
|
className="absolute z-10 rounded-b-sm"
|
||||||
|
style={{
|
||||||
|
left: BLACK_KEY_LEFT[note],
|
||||||
|
top: 0,
|
||||||
|
width: BLACK_KEY_W,
|
||||||
|
height: BLACK_KEY_H,
|
||||||
|
background: active ? 'var(--primary)' : '#1c1c1c',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<div
|
||||||
|
className="absolute rounded-full bg-white"
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
bottom: 5,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* White key note labels */}
|
||||||
|
<div className="flex" style={{ width: totalWidth, marginTop: 2 }}>
|
||||||
|
{WHITE_KEYS.map((note) => {
|
||||||
|
const active = activeChroma.has(NOTE_CHROMA[note]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={note}
|
||||||
|
className="text-center font-mono select-none"
|
||||||
|
style={{
|
||||||
|
width: WHITE_KEY_W,
|
||||||
|
fontSize: 8,
|
||||||
|
lineHeight: '12px',
|
||||||
|
color: active ? 'var(--primary)' : undefined,
|
||||||
|
fontWeight: active ? 700 : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{note}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active note names — clearly lists all notes including black keys */}
|
||||||
|
<div className="flex flex-wrap justify-center gap-1" style={{ marginTop: 2 }}>
|
||||||
|
{notes.map((note) => (
|
||||||
|
<span
|
||||||
|
key={note}
|
||||||
|
className="font-mono font-semibold"
|
||||||
|
style={{ fontSize: 9, color: 'var(--primary)' }}
|
||||||
|
>
|
||||||
|
{note}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
app/app/components/delete-song-dialog.tsx
Normal file
56
app/app/components/delete-song-dialog.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
||||||
|
AlertDialogContent, AlertDialogDescription, AlertDialogFooter,
|
||||||
|
AlertDialogHeader, AlertDialogTitle,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { deleteSong } from "~/lib/api";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteSongDialog({ id, title, open, onOpenChange }: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await deleteSong(id);
|
||||||
|
navigate("/");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete song");
|
||||||
|
setLoading(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete "{title}"?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This cannot be undone. The song will be permanently removed.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{loading ? "Deleting..." : "Delete"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
app/app/components/edit-song-sheet.tsx
Normal file
90
app/app/components/edit-song-sheet.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
|
} from "~/components/ui/sheet";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { updateSong } from "~/lib/api";
|
||||||
|
import type { SongMeta, SongSummary } from "~/lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
meta: SongMeta;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onUpdated: (summary: SongSummary) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditSongSheet({ id, meta, open, onOpenChange, onUpdated }: Props) {
|
||||||
|
const [title, setTitle] = useState(meta.title);
|
||||||
|
const [artist, setArtist] = useState(meta.artist);
|
||||||
|
const [key, setKey] = useState(meta.original_key ?? "");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTitle(meta.title);
|
||||||
|
setArtist(meta.artist);
|
||||||
|
setKey(meta.original_key ?? "");
|
||||||
|
}
|
||||||
|
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const updated = await updateSong(id, {
|
||||||
|
title: title.trim() || undefined,
|
||||||
|
artist: artist.trim() || undefined,
|
||||||
|
original_key: key.trim() || undefined,
|
||||||
|
});
|
||||||
|
onUpdated(updated);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to save changes", {
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="bottom" className="rounded-t-xl">
|
||||||
|
<SheetHeader className="mb-4">
|
||||||
|
<SheetTitle>Edit Song</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-muted-foreground uppercase tracking-wide">Title</label>
|
||||||
|
<Input value={title} onChange={(e) => setTitle(e.target.value)} disabled={loading} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-muted-foreground uppercase tracking-wide">Artist</label>
|
||||||
|
<Input value={artist} onChange={(e) => setArtist(e.target.value)} disabled={loading} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-muted-foreground uppercase tracking-wide">Key</label>
|
||||||
|
<Input
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
placeholder="e.g. Em, G, Bb"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button type="button" variant="outline" className="flex-1"
|
||||||
|
onClick={() => onOpenChange(false)} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="flex-1" disabled={loading}>
|
||||||
|
{loading ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,36 +1,65 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { ChevronUp, ChevronDown, Minus, Plus } from "lucide-react";
|
import {
|
||||||
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||||
|
} from "~/components/ui/dropdown-menu";
|
||||||
|
import { ChevronUp, ChevronDown, Minus, Plus, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
import type { SongMeta } from "~/lib/types";
|
import type { SongMeta } from "~/lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
meta: SongMeta;
|
meta: SongMeta;
|
||||||
offset: number;
|
offset: number;
|
||||||
onOffsetChange: (offset: number) => void;
|
onOffsetChange: (offset: number) => void;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
fontSize?: 'sm' | 'base' | 'lg';
|
||||||
|
onFontSizeChange?: (size: 'sm' | 'base' | 'lg') => void;
|
||||||
|
capo?: number;
|
||||||
|
applyCapo?: boolean;
|
||||||
|
onToggleCapo?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransposeBar({ meta, offset, onOffsetChange }: Props) {
|
export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, fontSize, onFontSizeChange, capo, applyCapo, onToggleCapo }: Props) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
const label = offset === 0
|
const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`;
|
||||||
? "±0"
|
|
||||||
: offset > 0
|
const menuButton = (onEdit || onDelete) ? (
|
||||||
? `+${offset}`
|
<DropdownMenu>
|
||||||
: `${offset}`;
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{onEdit && (
|
||||||
|
<DropdownMenuItem onClick={onEdit}>
|
||||||
|
<Pencil className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-background sticky top-0">
|
<div className="flex items-center justify-between px-4 py-2 border-b bg-background sticky top-0">
|
||||||
<span className="text-sm font-semibold truncate">{meta.title}</span>
|
<span className="text-sm font-semibold truncate">{meta.title}</span>
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
variant="ghost"
|
{menuButton}
|
||||||
size="icon"
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setExpanded(true)}>
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => setExpanded(true)}
|
|
||||||
>
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,41 +70,58 @@ export function TransposeBar({ meta, offset, onOffsetChange }: Props) {
|
|||||||
<span className="font-bold text-base">{meta.title}</span>
|
<span className="font-bold text-base">{meta.title}</span>
|
||||||
<span className="text-sm text-muted-foreground">{meta.artist}</span>
|
<span className="text-sm text-muted-foreground">{meta.artist}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
variant="ghost"
|
{menuButton}
|
||||||
size="icon"
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setExpanded(false)}>
|
||||||
className="h-7 w-7 shrink-0"
|
|
||||||
onClick={() => setExpanded(false)}
|
|
||||||
>
|
|
||||||
<ChevronUp className="w-4 h-4" />
|
<ChevronUp className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-3 text-xs text-muted-foreground">
|
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||||
{meta.original_key && <span>Key: {meta.original_key}</span>}
|
{meta.original_key && <span>Key: {meta.original_key}</span>}
|
||||||
{meta.capo != null && <span>Capo: {meta.capo}</span>}
|
{capo != null && onToggleCapo ? (
|
||||||
|
<button
|
||||||
|
onClick={onToggleCapo}
|
||||||
|
className={cn(
|
||||||
|
"text-xs transition-colors",
|
||||||
|
applyCapo ? "text-primary" : "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Capo {capo}{applyCapo ? " · sounding" : ""}
|
||||||
|
</button>
|
||||||
|
) : meta.capo != null ? (
|
||||||
|
<span>Capo: {meta.capo}</span>
|
||||||
|
) : null}
|
||||||
{meta.tuning && <span>{meta.tuning}</span>}
|
{meta.tuning && <span>{meta.tuning}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
{onFontSizeChange && (
|
||||||
variant="ghost"
|
<div className="flex items-center gap-1">
|
||||||
size="icon"
|
{(['sm', 'base', 'lg'] as const).map((s) => (
|
||||||
className="h-8 w-8"
|
<button
|
||||||
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}
|
key={s}
|
||||||
|
onClick={() => onFontSizeChange(s)}
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-1.5 py-0.5 rounded transition-colors",
|
||||||
|
fontSize === s
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
{s === 'sm' ? 'S' : s === 'base' ? 'M' : 'L'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||||
|
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}>
|
||||||
<Minus className="w-4 h-4" />
|
<Minus className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="w-8 text-center text-sm font-mono font-semibold">
|
<span className="w-8 text-center text-sm font-mono font-semibold">{label}</span>
|
||||||
{label}
|
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||||
</span>
|
onClick={() => onOffsetChange(Math.min(11, offset + 1))}>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => onOffsetChange(Math.min(11, offset + 1))}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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,24 +1,23 @@
|
|||||||
import type { Song, SongSummary, StoredSong } from "./types";
|
import type { Song, SongSummary, StoredSong, UpdateSongRequest } from "./types";
|
||||||
|
|
||||||
function getApiBase(): string {
|
const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
|
||||||
// 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";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listSongs(): Promise<SongSummary[]> {
|
export async function listSongs(q = "", sort = "date", order = "desc"): Promise<SongSummary[]> {
|
||||||
const res = await fetch(`${getApiBase()}/songs`);
|
const params = new URLSearchParams();
|
||||||
|
if (q.trim()) params.set("q", q.trim());
|
||||||
|
if (sort !== "date") params.set("sort", sort);
|
||||||
|
if (order !== "desc") params.set("order", order);
|
||||||
|
const url = params.size ? `${API_BASE}/songs?${params}` : `${API_BASE}/songs`;
|
||||||
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error(`Failed to load songs: ${res.status}`);
|
if (!res.ok) throw new Error(`Failed to load songs: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSong(id: string): Promise<Song | null> {
|
export async function getSong(id: string, applyCapo = false): Promise<Song | null> {
|
||||||
const res = await fetch(`${getApiBase()}/songs/${id}`);
|
const url = applyCapo
|
||||||
|
? `${API_BASE}/songs/${id}?apply_capo=true`
|
||||||
|
: `${API_BASE}/songs/${id}`;
|
||||||
|
const res = await fetch(url);
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) throw new Error(`Failed to load song: ${res.status}`);
|
if (!res.ok) throw new Error(`Failed to load song: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -28,7 +27,7 @@ export async function createSong(body: {
|
|||||||
source?: string;
|
source?: string;
|
||||||
html?: string;
|
html?: string;
|
||||||
}): Promise<StoredSong> {
|
}): Promise<StoredSong> {
|
||||||
const res = await fetch(`${getApiBase()}/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),
|
||||||
@@ -41,5 +40,19 @@ export async function createSong(body: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSong(id: string): Promise<void> {
|
export async function deleteSong(id: string): Promise<void> {
|
||||||
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(`${API_BASE}/songs/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((data as { error?: string }).error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
73
app/app/lib/chord-voicing.test.ts
Normal file
73
app/app/lib/chord-voicing.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getPianoNotes, getGuitarVoicing } from './chord-voicing';
|
||||||
|
|
||||||
|
describe('getPianoNotes', () => {
|
||||||
|
it('returns note names for a major chord', () => {
|
||||||
|
expect(getPianoNotes('C')).toEqual(['C', 'E', 'G']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns note names for Cmaj7', () => {
|
||||||
|
expect(getPianoNotes('Cmaj7')).toEqual(['C', 'E', 'G', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns note names for Am', () => {
|
||||||
|
expect(getPianoNotes('Am')).toEqual(['A', 'C', 'E']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for unparseable chord', () => {
|
||||||
|
expect(getPianoNotes('???')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for empty string', () => {
|
||||||
|
expect(getPianoNotes('')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGuitarVoicing', () => {
|
||||||
|
it('returns voicing for E major (open position, baseFret=0)', () => {
|
||||||
|
const v = getGuitarVoicing('E');
|
||||||
|
expect(v).not.toBeNull();
|
||||||
|
expect(v!.baseFret).toBe(0);
|
||||||
|
expect(v!.frets).toEqual([0, 2, 2, 1, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns voicing for Am (open position, baseFret=0)', () => {
|
||||||
|
const v = getGuitarVoicing('Am');
|
||||||
|
expect(v).not.toBeNull();
|
||||||
|
expect(v!.baseFret).toBe(0);
|
||||||
|
expect(v!.frets).toEqual([null, 0, 2, 2, 1, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transposes Bm correctly (A-shape, shift=2)', () => {
|
||||||
|
const v = getGuitarVoicing('Bm');
|
||||||
|
expect(v).not.toBeNull();
|
||||||
|
// Am shifted up 2: [null,2,4,4,3,2], baseFret=2
|
||||||
|
expect(v!.baseFret).toBe(2);
|
||||||
|
expect(v!.frets).toEqual([null, 2, 4, 4, 3, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns open G major voicing from named table', () => {
|
||||||
|
const v = getGuitarVoicing('G');
|
||||||
|
expect(v).not.toBeNull();
|
||||||
|
// Named open G: [3,2,0,0,0,3], baseFret=0
|
||||||
|
expect(v!.baseFret).toBe(0);
|
||||||
|
expect(v!.frets).toEqual([3, 2, 0, 0, 0, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to transposition for F# major (not in named table)', () => {
|
||||||
|
const v = getGuitarVoicing('F#');
|
||||||
|
expect(v).not.toBeNull();
|
||||||
|
// E-shape shift=2: [2,4,4,3,2,2], baseFret=2
|
||||||
|
expect(v!.baseFret).toBe(2);
|
||||||
|
expect(v!.frets).toEqual([2, 4, 4, 3, 2, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown quality', () => {
|
||||||
|
// 'add9' is not in the voicing map
|
||||||
|
expect(getGuitarVoicing('Cadd9')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unparseable chord', () => {
|
||||||
|
expect(getGuitarVoicing('???')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
122
app/app/lib/chord-voicing.ts
Normal file
122
app/app/lib/chord-voicing.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Chord, Note } from 'tonal';
|
||||||
|
import { GUITAR_VOICINGS } from './guitar-voicings';
|
||||||
|
|
||||||
|
export interface GuitarVoicing {
|
||||||
|
/** Absolute fret numbers per string (low→high); null = muted, 0 = open */
|
||||||
|
frets: (number | null)[];
|
||||||
|
/** Lowest fret displayed on the diagram (0 = show nut) */
|
||||||
|
baseFret: number;
|
||||||
|
/** Absolute fret to draw a barre bar across, or null */
|
||||||
|
barre: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named open (and common barre) chord voicings for specific chord strings.
|
||||||
|
* Checked before the algorithmic transposition, so G major shows the open
|
||||||
|
* G chord [3,2,0,0,0,3] instead of a barre at fret 3.
|
||||||
|
*/
|
||||||
|
const GUITAR_NAMED_VOICINGS: Record<string, GuitarVoicing> = {
|
||||||
|
// ── Major ──────────────────────────────────────────────────────────────
|
||||||
|
'E': { frets: [0, 2, 2, 1, 0, 0], baseFret: 0, barre: null },
|
||||||
|
'A': { frets: [null, 0, 2, 2, 2, 0], baseFret: 0, barre: null },
|
||||||
|
'D': { frets: [null, null, 0, 2, 3, 2], baseFret: 0, barre: null },
|
||||||
|
'G': { frets: [3, 2, 0, 0, 0, 3], baseFret: 0, barre: null },
|
||||||
|
'C': { frets: [null, 3, 2, 0, 1, 0], baseFret: 0, barre: null },
|
||||||
|
'F': { frets: [1, 3, 3, 2, 1, 1], baseFret: 1, barre: 1 },
|
||||||
|
'B': { frets: [null, 2, 4, 4, 4, 2], baseFret: 2, barre: 2 },
|
||||||
|
'Bb': { frets: [null, 1, 3, 3, 3, 1], baseFret: 1, barre: 1 },
|
||||||
|
'Ab': { frets: [null, null, 6, 5, 4, 4], baseFret: 4, barre: null },
|
||||||
|
|
||||||
|
// ── Minor ───────────────────────────────────────────────────────────────
|
||||||
|
'Em': { frets: [0, 2, 2, 0, 0, 0], baseFret: 0, barre: null },
|
||||||
|
'Am': { frets: [null, 0, 2, 2, 1, 0], baseFret: 0, barre: null },
|
||||||
|
'Dm': { frets: [null, null, 0, 2, 3, 1], baseFret: 0, barre: null },
|
||||||
|
'Gm': { frets: [3, 5, 5, 3, 3, 3], baseFret: 3, barre: 3 },
|
||||||
|
'Cm': { frets: [null, 3, 5, 5, 4, 3], baseFret: 3, barre: 3 },
|
||||||
|
'Fm': { frets: [1, 3, 3, 1, 1, 1], baseFret: 1, barre: 1 },
|
||||||
|
'Bm': { frets: [null, 2, 4, 4, 3, 2], baseFret: 2, barre: 2 },
|
||||||
|
'Bbm': { frets: [null, 1, 3, 3, 2, 1], baseFret: 1, barre: 1 },
|
||||||
|
|
||||||
|
// ── Dominant 7th ────────────────────────────────────────────────────────
|
||||||
|
'E7': { frets: [0, 2, 0, 1, 0, 0], baseFret: 0, barre: null },
|
||||||
|
'A7': { frets: [null, 0, 2, 0, 2, 0], baseFret: 0, barre: null },
|
||||||
|
'D7': { frets: [null, null, 0, 2, 1, 2], baseFret: 0, barre: null },
|
||||||
|
'G7': { frets: [3, 2, 0, 0, 0, 1], baseFret: 0, barre: null },
|
||||||
|
'C7': { frets: [null, 3, 2, 3, 1, 0], baseFret: 0, barre: null },
|
||||||
|
'B7': { frets: [null, 2, 1, 2, 0, 2], baseFret: 0, barre: null },
|
||||||
|
'F7': { frets: [1, 3, 1, 2, 1, 1], baseFret: 1, barre: 1 },
|
||||||
|
|
||||||
|
// ── Major 7th ────────────────────────────────────────────────────────────
|
||||||
|
'Emaj7': { frets: [0, 2, 1, 1, 0, 0], baseFret: 0, barre: null },
|
||||||
|
'Amaj7': { frets: [null, 0, 2, 1, 2, 0], baseFret: 0, barre: null },
|
||||||
|
'Dmaj7': { frets: [null, null, 0, 2, 2, 2], baseFret: 0, barre: null },
|
||||||
|
'Gmaj7': { frets: [3, 2, 0, 0, 0, 2], baseFret: 0, barre: null },
|
||||||
|
'Cmaj7': { frets: [null, 3, 2, 0, 0, 0], baseFret: 0, barre: null },
|
||||||
|
'Fmaj7': { frets: [null, null, 3, 2, 1, 0], baseFret: 0, barre: null },
|
||||||
|
|
||||||
|
// ── Minor 7th ────────────────────────────────────────────────────────────
|
||||||
|
'Em7': { frets: [0, 2, 0, 0, 0, 0], baseFret: 0, barre: null },
|
||||||
|
'Am7': { frets: [null, 0, 2, 0, 1, 0], baseFret: 0, barre: null },
|
||||||
|
'Dm7': { frets: [null, null, 0, 2, 1, 1], baseFret: 0, barre: null },
|
||||||
|
'Gm7': { frets: [3, 5, 3, 3, 3, 3], baseFret: 3, barre: 3 },
|
||||||
|
'Bm7': { frets: [null, 2, 4, 2, 3, 2], baseFret: 2, barre: 2 },
|
||||||
|
|
||||||
|
// ── Suspended 4th ────────────────────────────────────────────────────────
|
||||||
|
'Esus4': { frets: [0, 2, 2, 2, 0, 0], baseFret: 0, barre: null },
|
||||||
|
'Asus4': { frets: [null, 0, 2, 2, 3, 0], baseFret: 0, barre: null },
|
||||||
|
'Dsus4': { frets: [null, null, 0, 2, 3, 3], baseFret: 0, barre: null },
|
||||||
|
'Gsus4': { frets: [3, 3, 0, 0, 1, 3], baseFret: 0, barre: null },
|
||||||
|
|
||||||
|
// ── Suspended 2nd ────────────────────────────────────────────────────────
|
||||||
|
'Esus2': { frets: [0, 2, 4, 4, 0, 0], baseFret: 0, barre: null },
|
||||||
|
'Asus2': { frets: [null, 0, 2, 2, 0, 0], baseFret: 0, barre: null },
|
||||||
|
'Dsus2': { frets: [null, null, 0, 2, 3, 0], baseFret: 0, barre: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROOT_STRING_CHROMA: Record<'E' | 'A', number> = {
|
||||||
|
E: Note.chroma('E')!, // 4
|
||||||
|
A: Note.chroma('A')!, // 9
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the note names (e.g. ["C","E","G"]) for a chord string.
|
||||||
|
* Returns [] if the chord cannot be parsed.
|
||||||
|
*/
|
||||||
|
export function getPianoNotes(chord: string): string[] {
|
||||||
|
if (!chord) return [];
|
||||||
|
const parsed = Chord.get(chord);
|
||||||
|
if (!parsed.tonic || parsed.empty) return [];
|
||||||
|
return parsed.notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a GuitarVoicing for a chord string, or null if unavailable.
|
||||||
|
* Checks named open-chord voicings first, then falls back to algorithmic
|
||||||
|
* barre-chord transposition via quality templates.
|
||||||
|
*/
|
||||||
|
export function getGuitarVoicing(chord: string): GuitarVoicing | null {
|
||||||
|
if (!chord) return null;
|
||||||
|
|
||||||
|
// 1. Named lookup — preferred open-position voicings for common chords
|
||||||
|
const named = GUITAR_NAMED_VOICINGS[chord];
|
||||||
|
if (named) return named;
|
||||||
|
|
||||||
|
// 2. Algorithmic fallback — transpose quality template by chord root
|
||||||
|
const parsed = Chord.get(chord);
|
||||||
|
if (!parsed.tonic || parsed.empty) return null;
|
||||||
|
|
||||||
|
const template = GUITAR_VOICINGS[parsed.type];
|
||||||
|
if (!template) return null;
|
||||||
|
|
||||||
|
const rootChroma = ROOT_STRING_CHROMA[template.rootString];
|
||||||
|
const tonicChroma = Note.chroma(parsed.tonic);
|
||||||
|
if (tonicChroma === undefined) return null;
|
||||||
|
|
||||||
|
const shift = (tonicChroma - rootChroma + 12) % 12;
|
||||||
|
|
||||||
|
return {
|
||||||
|
frets: template.frets.map((f) => (f === null ? null : f + shift)),
|
||||||
|
baseFret: shift,
|
||||||
|
barre: template.barre === null ? null : template.barre + shift,
|
||||||
|
};
|
||||||
|
}
|
||||||
88
app/app/lib/guitar-voicings.ts
Normal file
88
app/app/lib/guitar-voicings.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
export interface GuitarVoicingTemplate {
|
||||||
|
/** 6 strings low→high; 0 = root position, 1 = one fret above root, null = muted */
|
||||||
|
frets: (number | null)[];
|
||||||
|
/** Fret (0-based relative) where a full barre is drawn, or null */
|
||||||
|
barre: number | null;
|
||||||
|
/** Which open string carries the root — determines transposition offset */
|
||||||
|
rootString: 'E' | 'A';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moveable barre-chord templates keyed by tonal chord type name.
|
||||||
|
* Verified fingerings at root = E (E-shape) or root = A (A-shape).
|
||||||
|
* To add a new quality: look up `Chord.get('<example>').type` in tonal,
|
||||||
|
* then define the fingering at root E or A and add it here.
|
||||||
|
*/
|
||||||
|
export const GUITAR_VOICINGS: Record<string, GuitarVoicingTemplate> = {
|
||||||
|
// ── E-shape (root on 6th string) ──────────────────────────────────────
|
||||||
|
// E major open: [0,2,2,1,0,0] E B E G# B E
|
||||||
|
'major': {
|
||||||
|
frets: [0, 2, 2, 1, 0, 0],
|
||||||
|
barre: 0,
|
||||||
|
rootString: 'E',
|
||||||
|
},
|
||||||
|
// E7: [0,2,0,1,0,0] E B D G# B E
|
||||||
|
'dominant seventh': {
|
||||||
|
frets: [0, 2, 0, 1, 0, 0],
|
||||||
|
barre: 0,
|
||||||
|
rootString: 'E',
|
||||||
|
},
|
||||||
|
// Emaj7: [0,2,1,1,0,0] E B D# G# B E
|
||||||
|
'major seventh': {
|
||||||
|
frets: [0, 2, 1, 1, 0, 0],
|
||||||
|
barre: 0,
|
||||||
|
rootString: 'E',
|
||||||
|
},
|
||||||
|
// Eaug: [0,3,2,1,1,0] E C(=B#) E G# C E
|
||||||
|
'augmented': {
|
||||||
|
frets: [0, 3, 2, 1, 1, 0],
|
||||||
|
barre: 0,
|
||||||
|
rootString: 'E',
|
||||||
|
},
|
||||||
|
// Esus4: [0,2,2,2,0,0] E B E A B E
|
||||||
|
'suspended fourth': {
|
||||||
|
frets: [0, 2, 2, 2, 0, 0],
|
||||||
|
barre: 0,
|
||||||
|
rootString: 'E',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── A-shape (root on 5th string) ──────────────────────────────────────
|
||||||
|
// Am open: [x,0,2,2,1,0] A E A C E
|
||||||
|
// barre: 0 so that transposed versions (Bm, Cm, etc.) draw the barre bar;
|
||||||
|
// the renderer suppresses the barre when baseFret===0 (open position = no barre needed)
|
||||||
|
'minor': {
|
||||||
|
frets: [null, 0, 2, 2, 1, 0],
|
||||||
|
barre: 0,
|
||||||
|
rootString: 'A',
|
||||||
|
},
|
||||||
|
// Am7: [x,0,2,0,1,0] A E G C E
|
||||||
|
'minor seventh': {
|
||||||
|
frets: [null, 0, 2, 0, 1, 0],
|
||||||
|
barre: null,
|
||||||
|
rootString: 'A',
|
||||||
|
},
|
||||||
|
// AmMaj7: [x,0,2,1,1,0] A E G# C E
|
||||||
|
'minor major seventh': {
|
||||||
|
frets: [null, 0, 2, 1, 1, 0],
|
||||||
|
barre: null,
|
||||||
|
rootString: 'A',
|
||||||
|
},
|
||||||
|
// Adim: [x,0,1,2,1,x] A Eb A C (string 1 muted)
|
||||||
|
'diminished': {
|
||||||
|
frets: [null, 0, 1, 2, 1, null],
|
||||||
|
barre: null,
|
||||||
|
rootString: 'A',
|
||||||
|
},
|
||||||
|
// Am7b5 (half-dim): [x,0,1,0,1,x] A Eb G C
|
||||||
|
'half-diminished': {
|
||||||
|
frets: [null, 0, 1, 0, 1, null],
|
||||||
|
barre: null,
|
||||||
|
rootString: 'A',
|
||||||
|
},
|
||||||
|
// Asus2: [x,0,2,2,0,0] A E A B E
|
||||||
|
'suspended second': {
|
||||||
|
frets: [null, 0, 2, 2, 0, 0],
|
||||||
|
barre: null,
|
||||||
|
rootString: 'A',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import type { Song, SongSummary } from "./types";
|
|
||||||
|
|
||||||
const OCEAN: Song = {
|
|
||||||
meta: {
|
|
||||||
title: "A Drop In The Ocean",
|
|
||||||
artist: "Ron Pope",
|
|
||||||
capo: null,
|
|
||||||
original_key: "Em",
|
|
||||||
tuning: null,
|
|
||||||
tempo: null,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
kind: "chorus",
|
|
||||||
label: "Chorus",
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
text: "A drop in the ocean,",
|
|
||||||
chords: [{ offset: 0, chord: "Em" }, { offset: 12, chord: "C" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "A change in the weather,",
|
|
||||||
chords: [{ offset: 2, chord: "G" }, { offset: 16, chord: "D" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "I was praying that you and me might end up together.",
|
|
||||||
chords: [
|
|
||||||
{ offset: 6, chord: "Em" },
|
|
||||||
{ offset: 17, chord: "C" },
|
|
||||||
{ offset: 33, chord: "G" },
|
|
||||||
{ offset: 44, chord: "D" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "verse",
|
|
||||||
label: "Verse",
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
text: "I don't wanna waste the weekend,",
|
|
||||||
chords: [{ offset: 0, chord: "C" }, { offset: 15, chord: "G" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "If you don't love me, pretend",
|
|
||||||
chords: [{ offset: 3, chord: "D" }, { offset: 21, chord: "Em" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "A few more hours, then it's time to go.",
|
|
||||||
chords: [{ offset: 2, chord: "C" }, { offset: 18, chord: "G" }, { offset: 30, chord: "D" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "bridge",
|
|
||||||
label: "Bridge",
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
text: "Still I can't let you be,",
|
|
||||||
chords: [{ offset: 0, chord: "Am" }, { offset: 11, chord: "G" }, { offset: 13, chord: "D" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const NAKED: Song = {
|
|
||||||
meta: {
|
|
||||||
title: "Naked",
|
|
||||||
artist: "James Arthur",
|
|
||||||
capo: null,
|
|
||||||
original_key: "G",
|
|
||||||
tuning: null,
|
|
||||||
tempo: null,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
kind: "chorus",
|
|
||||||
label: "Chorus",
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
text: "I'm not going to wait until you're done",
|
|
||||||
chords: [{ offset: 0, chord: "G" }, { offset: 18, chord: "Bm" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Pretending you don't need anyone",
|
|
||||||
chords: [{ offset: 16, chord: "Em" }, { offset: 27, chord: "C" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "I'm standing here naked",
|
|
||||||
chords: [{ offset: 19, chord: "G" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "verse",
|
|
||||||
label: "Verse",
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
text: "I lay awake thinking of all I wasted",
|
|
||||||
chords: [{ offset: 0, chord: "G" }, { offset: 22, chord: "Bm" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "All of the time we had I took for granted",
|
|
||||||
chords: [{ offset: 11, chord: "Em" }, { offset: 32, chord: "C" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const SONGS_MAP: Record<string, Song> = {
|
|
||||||
"song-ocean": OCEAN,
|
|
||||||
"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,
|
|
||||||
meta: song.meta,
|
|
||||||
preview_chords: previewChords(song),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export function getMockSong(id: string): Song | null {
|
|
||||||
return SONGS_MAP[id] ?? null;
|
|
||||||
}
|
|
||||||
35
app/app/lib/song-utils.ts
Normal file
35
app/app/lib/song-utils.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Song, Section } 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All unique chord names in order of first appearance across all sections. */
|
||||||
|
export function extractUniqueChords(sections: Section[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const section of 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -40,3 +40,9 @@ export interface StoredSong {
|
|||||||
id: string;
|
id: string;
|
||||||
song: Song;
|
song: Song;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateSongRequest {
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
original_key?: string;
|
||||||
|
}
|
||||||
|
|||||||
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))
|
||||||
|
}
|
||||||
88
app/app/root.tsx
Normal file
88
app/app/root.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
isRouteErrorResponse,
|
||||||
|
Links,
|
||||||
|
Meta,
|
||||||
|
Outlet,
|
||||||
|
Scripts,
|
||||||
|
ScrollRestoration,
|
||||||
|
} from "react-router";
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
{ rel: "manifest", href: "/manifest.json" },
|
||||||
|
{ rel: "apple-touch-icon", href: "/favicon.ico" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="PocketChords" />
|
||||||
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<Links />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
|
<TooltipProvider>
|
||||||
|
{children}
|
||||||
|
<ScrollRestoration />
|
||||||
|
<Scripts />
|
||||||
|
</TooltipProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</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,6 +1,8 @@
|
|||||||
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
import { type RouteConfig, index, layout, route } from "@react-router/dev/routes";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
|
layout("routes/layout.tsx", [
|
||||||
index("routes/home.tsx"),
|
index("routes/home.tsx"),
|
||||||
route("songs/:id", "routes/songs.$id.tsx"),
|
route("songs/:id", "routes/songs.$id.tsx"),
|
||||||
|
]),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useSearchParams, useRevalidator } from "react-router";
|
||||||
import type { Route } from "./+types/home";
|
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";
|
||||||
@@ -7,6 +8,7 @@ import { 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 { cn } from "~/lib/utils";
|
||||||
import type { SongSummary } from "~/lib/types";
|
import type { SongSummary } from "~/lib/types";
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
@@ -16,33 +18,48 @@ export function meta({}: Route.MetaArgs) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader() {
|
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 {
|
try {
|
||||||
const songs = await listSongs();
|
const songs = await listSongs(q, sort, order);
|
||||||
return { songs };
|
return { songs, q, sort, order, error: false };
|
||||||
} catch {
|
} catch {
|
||||||
return { songs: [] };
|
return { songs: [], q, sort, order, error: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
const { songs } = loaderData;
|
const { songs, q: initialQ, sort: initialSort, order: initialOrder, error } = loaderData;
|
||||||
const [query, setQuery] = useState("");
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [localSongs, setLocalSongs] = useState<SongSummary[]>([]);
|
const [localSongs, setLocalSongs] = useState<SongSummary[]>([]);
|
||||||
|
const revalidator = useRevalidator();
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = useState(initialQ);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const handleSearch = useCallback((value: string) => {
|
||||||
|
setInputValue(value);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
if (value.trim()) next.q = value.trim();
|
||||||
|
if (initialSort !== "date") next.sort = initialSort;
|
||||||
|
if (initialOrder !== "desc") next.order = initialOrder;
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
}, 300);
|
||||||
|
}, [setSearchParams, initialSort, initialOrder]);
|
||||||
|
|
||||||
|
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []);
|
||||||
|
|
||||||
const allSongs = [...songs, ...localSongs];
|
const allSongs = [...songs, ...localSongs];
|
||||||
const filtered = query.trim()
|
|
||||||
? allSongs.filter(
|
|
||||||
(s) =>
|
|
||||||
s.meta.title.toLowerCase().includes(query.toLowerCase()) ||
|
|
||||||
s.meta.artist.toLowerCase().includes(query.toLowerCase())
|
|
||||||
)
|
|
||||||
: allSongs;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh max-w-lg mx-auto">
|
<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">
|
<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>
|
||||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||||
@@ -51,28 +68,66 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div className="px-4 pb-3">
|
<div className="px-4 pb-3">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search songs..."
|
placeholder="Search songs..."
|
||||||
value={query}
|
value={inputValue}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
<div className="flex gap-1 px-4 pb-2">
|
||||||
|
{([["date", "Date"], ["title", "Title"], ["artist", "Artist"]] as const).map(([val, label]) => (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
onClick={() => {
|
||||||
|
const newOrder = initialSort === val ? (initialOrder === "asc" ? "desc" : "asc") : (val === "date" ? "desc" : "asc");
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
if (inputValue.trim()) next.q = inputValue.trim();
|
||||||
|
next.sort = val;
|
||||||
|
next.order = newOrder;
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-1 rounded-full border transition-colors",
|
||||||
|
initialSort === val
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "text-muted-foreground border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}{initialSort === val ? (initialOrder === "asc" ? " ↑" : " ↓") : ""}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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">
|
||||||
|
Couldn't load your songs. Is the API running?
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => revalidator.revalidate()}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||||
{filtered.length === 0 && (
|
{!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">
|
||||||
{query ? "No songs match your search." : "No songs yet. Tap Add to get started."}
|
{initialQ ? "No songs match your search." : "No songs yet. Tap Add to get started."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{filtered.map((song) => (
|
{allSongs.map((song) => (
|
||||||
<SongCard key={song.id} song={song} />
|
<SongCard key={song.id} song={song} />
|
||||||
))}
|
))}
|
||||||
{/* Add card */}
|
|
||||||
<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)}
|
||||||
|
|||||||
15
app/app/routes/layout.tsx
Normal file
15
app/app/routes/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Outlet } from "react-router";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
import { BottomNav } from "~/components/bottom-nav";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-dvh">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
<BottomNav />
|
||||||
|
<Toaster position="top-center" richColors />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { data } from "react-router";
|
import { data, Link } from "react-router";
|
||||||
import type { Route } from "./+types/songs.$id";
|
import type { Route } from "./+types/songs.$id";
|
||||||
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 { ChordDiagram } from "~/components/chord-diagram/chord-diagram";
|
||||||
|
import type { Instrument } from "~/components/chord-diagram/chord-diagram";
|
||||||
|
import { EditSongSheet } from "~/components/edit-song-sheet";
|
||||||
|
import { DeleteSongDialog } from "~/components/delete-song-dialog";
|
||||||
import { transposeSong } from "~/lib/transpose";
|
import { transposeSong } from "~/lib/transpose";
|
||||||
|
import { extractUniqueChords } from "~/lib/song-utils";
|
||||||
import { getSong } from "~/lib/api";
|
import { getSong } from "~/lib/api";
|
||||||
|
import type { Song, SongSummary } from "~/lib/types";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs) {
|
export function meta({ data }: Route.MetaArgs) {
|
||||||
if (!data?.song) return [{ title: "PocketChords" }];
|
if (!data?.song) return [{ title: "PocketChords" }];
|
||||||
@@ -16,22 +23,183 @@ export function meta({ data }: Route.MetaArgs) {
|
|||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
const id = params.id ?? "";
|
const id = params.id ?? "";
|
||||||
|
try {
|
||||||
const song = await getSong(id);
|
const song = await getSong(id);
|
||||||
if (!song) throw data("Song not found", { status: 404 });
|
if (!song) throw data("Song not found", { status: 404 });
|
||||||
return { song };
|
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 {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem('fontSize');
|
||||||
|
if (v === 'sm' || v === 'base' || v === 'lg') return v;
|
||||||
|
} catch { /* noop */ }
|
||||||
|
return 'sm';
|
||||||
|
}
|
||||||
|
|
||||||
|
function initInstrument(): Instrument {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem('chordDiagramInstrument');
|
||||||
|
if (v === 'piano' || v === 'guitar') return v;
|
||||||
|
} catch { /* noop */ }
|
||||||
|
return 'piano';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||||
const { song } = loaderData;
|
const { song: initialSong, id } = loaderData;
|
||||||
const [offset, setOffset] = useState(0);
|
const [baseSong, setBaseSong] = useState<Song | null>(initialSong ?? null);
|
||||||
const displayed = transposeSong(song, offset);
|
const [displayedSong, setDisplayedSong] = useState<Song | null>(initialSong ?? null);
|
||||||
|
const [applyCapo, setApplyCapo] = useState(false);
|
||||||
|
|
||||||
|
const initOffset = (() => {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(`transpose:${id}`);
|
||||||
|
if (v !== null) {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
if (!isNaN(n)) return n;
|
||||||
|
}
|
||||||
|
} catch { /* noop */ }
|
||||||
|
return 0;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const [offset, setOffset] = useState(initOffset);
|
||||||
|
const [fontSize, setFontSize] = useState<FontSize>(initFontSize);
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [activeChord, setActiveChord] = useState<string | null>(null);
|
||||||
|
const [instrument, setInstrument] = useState<Instrument>(initInstrument);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (applyCapo && baseSong?.meta.capo) {
|
||||||
|
getSong(id, true).then((s) => { if (s) setDisplayedSong(s); });
|
||||||
|
} else {
|
||||||
|
setDisplayedSong(baseSong);
|
||||||
|
}
|
||||||
|
}, [applyCapo]); // eslint-disable-line
|
||||||
|
|
||||||
|
function handleOffsetChange(newOffset: number) {
|
||||||
|
setOffset(newOffset);
|
||||||
|
try { localStorage.setItem(`transpose:${id}`, String(newOffset)); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFontSizeChange(size: FontSize) {
|
||||||
|
setFontSize(size);
|
||||||
|
try { localStorage.setItem('fontSize', size); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInstrumentChange(i: Instrument) {
|
||||||
|
setInstrument(i);
|
||||||
|
try { localStorage.setItem('chordDiagramInstrument', i); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => setActiveChord(null), []);
|
||||||
|
|
||||||
|
if (!baseSong || !displayedSong) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh max-w-lg mx-auto">
|
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||||
<TransposeBar meta={song.meta} offset={offset} onOffsetChange={setOffset} />
|
<p className="text-muted-foreground text-sm">Song not found or unavailable.</p>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<Link to="/" className="text-sm text-primary underline-offset-4 hover:underline">
|
||||||
<ChordChart sections={displayed.sections} />
|
← Back to library
|
||||||
</div>
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayed = transposeSong(displayedSong, offset);
|
||||||
|
const uniqueChords = extractUniqueChords(displayed.sections);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<TransposeBar
|
||||||
|
meta={baseSong.meta}
|
||||||
|
offset={offset}
|
||||||
|
onOffsetChange={handleOffsetChange}
|
||||||
|
onEdit={() => setEditOpen(true)}
|
||||||
|
onDelete={() => setDeleteOpen(true)}
|
||||||
|
fontSize={fontSize}
|
||||||
|
onFontSizeChange={handleFontSizeChange}
|
||||||
|
capo={baseSong.meta.capo ?? undefined}
|
||||||
|
applyCapo={applyCapo}
|
||||||
|
onToggleCapo={() => setApplyCapo((v) => !v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Body: single column on mobile, two columns on desktop */}
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
|
||||||
|
{/* Left / main column */}
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-y-auto"
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
<div className="max-w-lg mx-auto lg:max-w-none">
|
||||||
|
<ChordChart
|
||||||
|
sections={displayed.sections}
|
||||||
|
fontSize={fontSize}
|
||||||
|
onChordClick={handleChordClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mobile bottom chord grid (hidden on desktop) */}
|
||||||
|
<div className="lg:hidden border-t border-border">
|
||||||
|
<ChordGrid
|
||||||
|
chords={uniqueChords}
|
||||||
|
instrument={instrument}
|
||||||
|
onInstrumentChange={handleInstrumentChange}
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<ChordGrid
|
||||||
|
chords={uniqueChords}
|
||||||
|
instrument={instrument}
|
||||||
|
onInstrumentChange={handleInstrumentChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile inline popup — fixed bottom, dismissed on scroll */}
|
||||||
|
{activeChord && (
|
||||||
|
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background shadow-lg p-3 flex items-center gap-3">
|
||||||
|
<ChordDiagram chord={activeChord} instrument={instrument} />
|
||||||
|
<button
|
||||||
|
className="ml-auto text-muted-foreground text-xs underline-offset-4 hover:underline"
|
||||||
|
onClick={() => setActiveChord(null)}
|
||||||
|
>
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EditSongSheet
|
||||||
|
id={id}
|
||||||
|
meta={baseSong.meta}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
onUpdated={handleUpdated}
|
||||||
|
/>
|
||||||
|
<DeleteSongDialog
|
||||||
|
id={id}
|
||||||
|
title={baseSong.meta.title}
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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": {}
|
||||||
|
}
|
||||||
9335
app/package-lock.json
generated
Normal file
9335
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
app/package.json
Normal file
51
app/package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "react-router build",
|
||||||
|
"dev": "react-router dev",
|
||||||
|
"start": "react-router-serve ./build/server/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"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",
|
||||||
|
"tonal": "^6.4.3",
|
||||||
|
"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",
|
||||||
|
"vitest": "^4.1.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 |
12
app/public/manifest.json
Normal file
12
app/public/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "PocketChords",
|
||||||
|
"short_name": "Chords",
|
||||||
|
"description": "Personal chord chart viewer",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#09090b",
|
||||||
|
"theme_color": "#09090b",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/favicon.ico", "sizes": "48x48", "type": "image/x-icon" }
|
||||||
|
]
|
||||||
|
}
|
||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
13
app/vitest.config.ts
Normal file
13
app/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~': resolve(__dirname, './app'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
6
crates/api/.gitignore
vendored
Normal file
6
crates/api/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
*.sqlite
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
52
crates/api/src/config.rs
Normal file
52
crates/api/src/config.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub database_url: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
/// Parsed CORS origin policy
|
||||||
|
pub cors_origins: CorsOrigins,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CorsOrigins {
|
||||||
|
/// Allow any origin (`CORS_ALLOWED_ORIGINS=*`)
|
||||||
|
Any,
|
||||||
|
/// Allow specific origins (`CORS_ALLOWED_ORIGINS=https://a.com,https://b.com`)
|
||||||
|
List(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let database_url = env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "sqlite://./pocket-chords.db".into());
|
||||||
|
|
||||||
|
let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into());
|
||||||
|
|
||||||
|
let port = env::var("PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse::<u16>().ok())
|
||||||
|
.unwrap_or(8000);
|
||||||
|
|
||||||
|
let cors_origins = match env::var("CORS_ALLOWED_ORIGINS")
|
||||||
|
.unwrap_or_else(|_| "*".into())
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
{
|
||||||
|
s if s == "*" => CorsOrigins::Any,
|
||||||
|
s => CorsOrigins::List(
|
||||||
|
s.split(',')
|
||||||
|
.map(|o| o.trim().to_string())
|
||||||
|
.filter(|o| !o.is_empty())
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self { database_url, host, port, cors_origins }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind_addr(&self) -> String {
|
||||||
|
format!("{}:{}", self.host, self.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
mod config;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
use axum::{Router, routing::{get, post}};
|
use axum::{Router, http::HeaderValue, routing::{get, post}};
|
||||||
use common::SongService;
|
use common::{SongSearchService, SongService};
|
||||||
|
use config::{Config, CorsOrigins};
|
||||||
use persistence::SqliteRepositoryFactory;
|
use persistence::SqliteRepositoryFactory;
|
||||||
use routes::songs::{create_song, delete_song, get_song, list_songs};
|
use routes::songs::{create_song, delete_song, get_song, list_songs, update_song};
|
||||||
use routes::tabs::{AppState, parse_tab};
|
use routes::tabs::{AppState, parse_tab};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
@@ -13,32 +15,49 @@ use ug_parser::{UgHtmlParser, UgTabFetcher};
|
|||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let database_url = std::env::var("DATABASE_URL")
|
let config = Config::from_env();
|
||||||
.unwrap_or_else(|_| "sqlite://./pocket-chords.db".into());
|
tracing::info!(?config, "starting with config");
|
||||||
let repo = SqliteRepositoryFactory::create(&database_url)
|
|
||||||
|
let repo = SqliteRepositoryFactory::create(&config.database_url)
|
||||||
.await
|
.await
|
||||||
.expect("failed to connect to database");
|
.expect("failed to connect to database");
|
||||||
let songs = SongService::new(Box::new(repo));
|
let songs = SongService::new(Box::new(repo.clone()));
|
||||||
|
let search = SongSearchService::new(Box::new(repo));
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
fetcher: Box::new(UgTabFetcher::new()),
|
fetcher: Box::new(UgTabFetcher::new()),
|
||||||
parser: Box::new(UgHtmlParser),
|
parser: Box::new(UgHtmlParser),
|
||||||
songs,
|
songs,
|
||||||
|
search,
|
||||||
});
|
});
|
||||||
|
|
||||||
let cors = CorsLayer::new()
|
let cors = match config.cors_origins {
|
||||||
|
CorsOrigins::Any => CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
.allow_methods(Any)
|
.allow_methods(Any)
|
||||||
.allow_headers(Any);
|
.allow_headers(Any),
|
||||||
|
CorsOrigins::List(ref origins) => {
|
||||||
|
let parsed: Vec<HeaderValue> = origins
|
||||||
|
.iter()
|
||||||
|
.map(|o| o.parse().unwrap_or_else(|_| panic!("invalid CORS origin: {o}")))
|
||||||
|
.collect();
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(parsed)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/tabs/parse", post(parse_tab))
|
.route("/tabs/parse", post(parse_tab))
|
||||||
.route("/songs", post(create_song).get(list_songs))
|
.route("/songs", post(create_song).get(list_songs))
|
||||||
.route("/songs/{id}", get(get_song).delete(delete_song))
|
.route("/songs/{id}", get(get_song).delete(delete_song).patch(update_song))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
|
let addr = config.bind_addr();
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await
|
||||||
|
.unwrap_or_else(|e| panic!("failed to bind {addr}: {e}"));
|
||||||
tracing::info!("listening on {}", listener.local_addr().unwrap());
|
tracing::info!("listening on {}", listener.local_addr().unwrap());
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use domain::RepositoryError;
|
use domain::{ChordTransposer, RepositoryError, SortField, SortOrder};
|
||||||
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ListQuery {
|
||||||
|
pub q: Option<String>,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub order: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html};
|
use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html};
|
||||||
|
|
||||||
pub async fn create_song(
|
pub async fn create_song(
|
||||||
@@ -30,26 +38,90 @@ pub async fn create_song(
|
|||||||
|
|
||||||
pub async fn list_songs(
|
pub async fn list_songs(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
Query(params): Query<ListQuery>,
|
||||||
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let songs = state.songs.list().await.map_err(|e| {
|
let sort = match params.sort.as_deref() {
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))
|
Some("title") => SortField::Title,
|
||||||
|
Some("artist") => SortField::Artist,
|
||||||
|
_ => SortField::Date,
|
||||||
|
};
|
||||||
|
let order = match params.order.as_deref() {
|
||||||
|
Some("asc") => SortOrder::Asc,
|
||||||
|
_ => SortOrder::Desc,
|
||||||
|
};
|
||||||
|
let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) {
|
||||||
|
state.search.search(&q, sort, order).await
|
||||||
|
} else {
|
||||||
|
state.songs.list(sort, order).await
|
||||||
|
};
|
||||||
|
result
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct UpdateSongRequest {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub original_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_song(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(body): Json<UpdateSongRequest>,
|
||||||
|
) -> Result<Json<domain::SongSummary>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
||||||
|
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
|
||||||
})?;
|
})?;
|
||||||
Ok(Json(songs))
|
|
||||||
|
state.songs
|
||||||
|
.update_meta(
|
||||||
|
uuid,
|
||||||
|
body.title.as_deref(),
|
||||||
|
body.artist.as_deref(),
|
||||||
|
body.original_key.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|e| match e {
|
||||||
|
domain::RepositoryError::NotFound =>
|
||||||
|
(StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() })),
|
||||||
|
e => (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct GetSongQuery {
|
||||||
|
pub apply_capo: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_song(
|
pub async fn get_song(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
|
Query(params): Query<GetSongQuery>,
|
||||||
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
||||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
|
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
match state.songs.get(uuid).await {
|
let song = match state.songs.get(uuid).await {
|
||||||
Ok(Some(song)) => Ok(Json(song)),
|
Ok(Some(s)) => s,
|
||||||
Ok(None) => Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() }))),
|
Ok(None) => return Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() }))),
|
||||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))),
|
Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let song = if params.apply_capo.unwrap_or(false) {
|
||||||
|
if let Some(capo) = song.meta.capo {
|
||||||
|
ChordTransposer.transpose_song(&song, capo as i8)
|
||||||
|
} else {
|
||||||
|
song
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
song
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(song))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_song(
|
pub async fn delete_song(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub struct AppState {
|
|||||||
pub fetcher: Box<dyn TabFetcherPort>,
|
pub fetcher: Box<dyn TabFetcherPort>,
|
||||||
pub parser: Box<dyn TabParserPort>,
|
pub parser: Box<dyn TabParserPort>,
|
||||||
pub songs: common::SongService,
|
pub songs: common::SongService,
|
||||||
|
pub search: common::SongSearchService,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
1
crates/common/.gitignore
vendored
Normal file
1
crates/common/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Cargo.lock
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use domain::{RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong};
|
use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong, SortField, SortOrder};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct SongService {
|
pub struct SongService {
|
||||||
@@ -14,8 +14,8 @@ impl SongService {
|
|||||||
self.repo.save(song).await
|
self.repo.save(song).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list(&self) -> Result<Vec<SongSummary>, RepositoryError> {
|
pub async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
||||||
self.repo.list().await
|
self.repo.list(sort, order).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
|
pub async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
|
||||||
@@ -25,4 +25,28 @@ impl SongService {
|
|||||||
pub async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> {
|
pub async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> {
|
||||||
self.repo.delete(id).await
|
self.repo.delete(id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_meta(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
title: Option<&str>,
|
||||||
|
artist: Option<&str>,
|
||||||
|
original_key: Option<&str>,
|
||||||
|
) -> Result<domain::SongSummary, domain::RepositoryError> {
|
||||||
|
self.repo.update_meta(id, title, artist, original_key).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SongSearchService {
|
||||||
|
search: Box<dyn SongSearchPort>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SongSearchService {
|
||||||
|
pub fn new(search: Box<dyn SongSearchPort>) -> Self {
|
||||||
|
Self { search }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<domain::SongSummary>, domain::RepositoryError> {
|
||||||
|
self.search.search(query, sort, order).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ pub use chord::Chord;
|
|||||||
pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song};
|
pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song};
|
||||||
pub use song::{song_preview_chords, StoredSong, SongSummary};
|
pub use song::{song_preview_chords, StoredSong, SongSummary};
|
||||||
pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource};
|
pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource};
|
||||||
pub use ports::{RepositoryError, SongRepositoryPort};
|
pub use ports::{RepositoryError, SongRepositoryPort, SongSearchPort, SortField, SortOrder};
|
||||||
pub use transposer::{ChordTransposer, TransposeError};
|
pub use transposer::{ChordTransposer, TransposeError};
|
||||||
|
|||||||
@@ -39,6 +39,21 @@ pub trait TabParserPort: Send + Sync {
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use crate::song::{StoredSong, SongSummary};
|
use crate::song::{StoredSong, SongSummary};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum SortField {
|
||||||
|
#[default]
|
||||||
|
Date,
|
||||||
|
Title,
|
||||||
|
Artist,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum SortOrder {
|
||||||
|
#[default]
|
||||||
|
Desc,
|
||||||
|
Asc,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum RepositoryError {
|
pub enum RepositoryError {
|
||||||
#[error("Song not found")]
|
#[error("Song not found")]
|
||||||
@@ -50,7 +65,19 @@ pub enum RepositoryError {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SongRepositoryPort: Send + Sync {
|
pub trait SongRepositoryPort: Send + Sync {
|
||||||
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError>;
|
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError>;
|
||||||
async fn list(&self) -> Result<Vec<SongSummary>, RepositoryError>;
|
async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError>;
|
||||||
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError>;
|
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError>;
|
||||||
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>;
|
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>;
|
||||||
|
async fn update_meta(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
title: Option<&str>,
|
||||||
|
artist: Option<&str>,
|
||||||
|
original_key: Option<&str>,
|
||||||
|
) -> Result<SongSummary, RepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SongSearchPort: Send + Sync {
|
||||||
|
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,131 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
mod row;
|
||||||
use domain::{
|
pub mod repository;
|
||||||
RepositoryError, Song, SongMeta, SongRepositoryPort, SongSummary, StoredSong,
|
mod search;
|
||||||
song_preview_chords,
|
|
||||||
};
|
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
pub struct SqliteSongRepository {
|
pub use repository::{SqliteSongRepository, SqliteRepositoryFactory};
|
||||||
pool: SqlitePool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SqliteSongRepository {
|
|
||||||
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
|
|
||||||
let pool = SqlitePool::connect(database_url).await?;
|
|
||||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
|
||||||
Ok(Self { pool })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct SongRow {
|
|
||||||
id: String,
|
|
||||||
title: String,
|
|
||||||
artist: String,
|
|
||||||
original_key: Option<String>,
|
|
||||||
preview_chords: String,
|
|
||||||
body: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl SongRepositoryPort for SqliteSongRepository {
|
|
||||||
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError> {
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
let id_str = id.to_string();
|
|
||||||
let body = serde_json::to_string(song)
|
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
|
||||||
let preview = song_preview_chords(song);
|
|
||||||
let preview_json = serde_json::to_string(&preview)
|
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
|
||||||
let original_key = song.meta.original_key.as_deref();
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO songs (id, title, artist, original_key, preview_chords, body) VALUES (?, ?, ?, ?, ?, ?)"
|
|
||||||
)
|
|
||||||
.bind(&id_str)
|
|
||||||
.bind(&song.meta.title)
|
|
||||||
.bind(&song.meta.artist)
|
|
||||||
.bind(original_key)
|
|
||||||
.bind(&preview_json)
|
|
||||||
.bind(&body)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(StoredSong { id, song: song.clone() })
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list(&self) -> Result<Vec<SongSummary>, RepositoryError> {
|
|
||||||
let rows = sqlx::query_as::<_, SongRow>(
|
|
||||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs ORDER BY created_at DESC"
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
rows.into_iter()
|
|
||||||
.map(|row| {
|
|
||||||
let id = Uuid::parse_str(&row.id)
|
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
|
||||||
let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords)
|
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
|
||||||
Ok(SongSummary {
|
|
||||||
id,
|
|
||||||
meta: SongMeta {
|
|
||||||
title: row.title,
|
|
||||||
artist: row.artist,
|
|
||||||
original_key: row.original_key,
|
|
||||||
capo: None,
|
|
||||||
tuning: None,
|
|
||||||
tempo: None,
|
|
||||||
},
|
|
||||||
preview_chords,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
|
|
||||||
let id_str = id.to_string();
|
|
||||||
let row = sqlx::query_as::<_, SongRow>(
|
|
||||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?"
|
|
||||||
)
|
|
||||||
.bind(&id_str)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
match row {
|
|
||||||
None => Ok(None),
|
|
||||||
Some(r) => {
|
|
||||||
let song: Song = serde_json::from_str(&r.body)
|
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
|
||||||
Ok(Some(song))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> {
|
|
||||||
let id_str = id.to_string();
|
|
||||||
let result = sqlx::query("DELETE FROM songs WHERE id = ?")
|
|
||||||
.bind(&id_str)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
|
||||||
Err(RepositoryError::NotFound)
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SqliteRepositoryFactory;
|
|
||||||
|
|
||||||
impl SqliteRepositoryFactory {
|
|
||||||
pub async fn create(database_url: &str) -> Result<SqliteSongRepository, sqlx::Error> {
|
|
||||||
SqliteSongRepository::new(database_url).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
159
crates/infrastructure/persistence/src/repository.rs
Normal file
159
crates/infrastructure/persistence/src/repository.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong,
|
||||||
|
SortField, SortOrder, song_preview_chords,
|
||||||
|
};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::row::{SongRow, sort_clause, row_to_summary};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SqliteSongRepository {
|
||||||
|
pub(crate) pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteSongRepository {
|
||||||
|
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
|
||||||
|
let pool = SqlitePool::connect(database_url).await?;
|
||||||
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SongRepositoryPort for SqliteSongRepository {
|
||||||
|
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let body = serde_json::to_string(song)
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
let preview = song_preview_chords(song);
|
||||||
|
let preview_json = serde_json::to_string(&preview)
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
let original_key = song.meta.original_key.as_deref();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO songs (id, title, artist, original_key, preview_chords, body) VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
|
)
|
||||||
|
.bind(&id_str)
|
||||||
|
.bind(&song.meta.title)
|
||||||
|
.bind(&song.meta.artist)
|
||||||
|
.bind(original_key)
|
||||||
|
.bind(&preview_json)
|
||||||
|
.bind(&body)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(StoredSong { id, song: song.clone() })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT id, title, artist, original_key, preview_chords, body FROM songs {}",
|
||||||
|
sort_clause(sort, order)
|
||||||
|
);
|
||||||
|
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
rows.into_iter().map(row_to_summary).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let row = sqlx::query_as::<_, SongRow>(
|
||||||
|
"SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?"
|
||||||
|
)
|
||||||
|
.bind(&id_str)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(r) => {
|
||||||
|
let song: Song = serde_json::from_str(&r.body)
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
Ok(Some(song))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let result = sqlx::query("DELETE FROM songs WHERE id = ?")
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
Err(RepositoryError::NotFound)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_meta(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
title: Option<&str>,
|
||||||
|
artist: Option<&str>,
|
||||||
|
original_key: Option<&str>,
|
||||||
|
) -> Result<SongSummary, RepositoryError> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
|
||||||
|
let row = sqlx::query_as::<_, SongRow>(
|
||||||
|
"SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?"
|
||||||
|
)
|
||||||
|
.bind(&id_str)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?
|
||||||
|
.ok_or(RepositoryError::NotFound)?;
|
||||||
|
|
||||||
|
let mut song: Song = serde_json::from_str(&row.body)
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
if let Some(t) = title { song.meta.title = t.to_string(); }
|
||||||
|
if let Some(a) = artist { song.meta.artist = a.to_string(); }
|
||||||
|
if let Some(k) = original_key { song.meta.original_key = Some(k.to_string()); }
|
||||||
|
let new_body = serde_json::to_string(&song)
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let new_title = title.unwrap_or(&row.title);
|
||||||
|
let new_artist = artist.unwrap_or(&row.artist);
|
||||||
|
let new_key: Option<&str> = original_key.or(row.original_key.as_deref());
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?"
|
||||||
|
)
|
||||||
|
.bind(new_title)
|
||||||
|
.bind(new_artist)
|
||||||
|
.bind(new_key)
|
||||||
|
.bind(&new_body)
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords)
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(SongSummary {
|
||||||
|
id,
|
||||||
|
meta: song.meta,
|
||||||
|
preview_chords,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SqliteRepositoryFactory;
|
||||||
|
|
||||||
|
impl SqliteRepositoryFactory {
|
||||||
|
pub async fn create(database_url: &str) -> Result<SqliteSongRepository, sqlx::Error> {
|
||||||
|
SqliteSongRepository::new(database_url).await
|
||||||
|
}
|
||||||
|
}
|
||||||
42
crates/infrastructure/persistence/src/row.rs
Normal file
42
crates/infrastructure/persistence/src/row.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use domain::{RepositoryError, SongMeta, SongSummary, SortField, SortOrder};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct SongRow {
|
||||||
|
pub(crate) id: String,
|
||||||
|
pub(crate) title: String,
|
||||||
|
pub(crate) artist: String,
|
||||||
|
pub(crate) original_key: Option<String>,
|
||||||
|
pub(crate) preview_chords: String,
|
||||||
|
pub(crate) body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sort_clause(field: SortField, order: SortOrder) -> &'static str {
|
||||||
|
match (field, order) {
|
||||||
|
(SortField::Title, SortOrder::Asc) => "ORDER BY title ASC",
|
||||||
|
(SortField::Title, SortOrder::Desc) => "ORDER BY title DESC",
|
||||||
|
(SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC",
|
||||||
|
(SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC",
|
||||||
|
(SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC",
|
||||||
|
(SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn row_to_summary(row: SongRow) -> Result<SongSummary, RepositoryError> {
|
||||||
|
let id = Uuid::parse_str(&row.id)
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords)
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
Ok(SongSummary {
|
||||||
|
id,
|
||||||
|
meta: SongMeta {
|
||||||
|
title: row.title,
|
||||||
|
artist: row.artist,
|
||||||
|
original_key: row.original_key,
|
||||||
|
capo: None,
|
||||||
|
tuning: None,
|
||||||
|
tempo: None,
|
||||||
|
},
|
||||||
|
preview_chords,
|
||||||
|
})
|
||||||
|
}
|
||||||
26
crates/infrastructure/persistence/src/search.rs
Normal file
26
crates/infrastructure/persistence/src/search.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{RepositoryError, SongSearchPort, SongSummary, SortField, SortOrder};
|
||||||
|
|
||||||
|
use crate::repository::SqliteSongRepository;
|
||||||
|
use crate::row::{SongRow, sort_clause, row_to_summary};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SongSearchPort for SqliteSongRepository {
|
||||||
|
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
||||||
|
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
||||||
|
let pattern = format!("%{}%", escaped);
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT id, title, artist, original_key, preview_chords, body FROM songs \
|
||||||
|
WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') {}",
|
||||||
|
sort_clause(sort, order)
|
||||||
|
);
|
||||||
|
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||||
|
.bind(&pattern)
|
||||||
|
.bind(&pattern)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
rows.into_iter().map(row_to_summary).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# PocketChords – homeserver deployment template
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# cp .env.compose .env.compose.local # fill in your values
|
||||||
|
# docker compose --env-file .env.compose.local up -d --build
|
||||||
|
#
|
||||||
|
# VITE_API_URL is baked into the JS bundle at build time.
|
||||||
|
# Set it to the URL your BROWSER (and SSR server) will use to reach the API.
|
||||||
|
# On a LAN homeserver: http://192.168.x.x:8000
|
||||||
|
# Behind a reverse proxy: https://pocketchords.example.com/api
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-8000}:8000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: sqlite:///app/data/pocket-chords.db
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 8000
|
||||||
|
# Comma-separated allowed origins, or * for any.
|
||||||
|
# Lock this down in production: https://pocketchords.yourdomain.com
|
||||||
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-*}
|
||||||
|
volumes:
|
||||||
|
- api-data:/app/data
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-http://localhost:8000}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
PORT: 3000
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
api-data:
|
||||||
|
driver: local
|
||||||
1107
docs/superpowers/plans/2026-04-09-chord-diagram.md
Normal file
1107
docs/superpowers/plans/2026-04-09-chord-diagram.md
Normal file
File diff suppressed because it is too large
Load Diff
134
docs/superpowers/specs/2026-04-09-chord-diagram-design.md
Normal file
134
docs/superpowers/specs/2026-04-09-chord-diagram-design.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Chord Diagram Feature Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-09
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A chord diagram feature for the song detail page that shows users how to play each chord on piano or guitar. The core component is dumb — it receives only a chord name string and renders the diagram. All music theory and voicing logic lives in a separate library layer.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three cleanly separated layers:
|
||||||
|
|
||||||
|
```
|
||||||
|
chord name string ("Cmaj7")
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[theory layer] — tonal parses name → root + note set {C, E, G, B}
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[voicing layer] — maps note set → renderable positions
|
||||||
|
├── Piano: note set → highlight keys on 1-octave keyboard
|
||||||
|
└── Guitar: chord quality + root → transpose moveable shape template
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[render layer] — dumb components, no music theory knowledge
|
||||||
|
├── <PianoKeys notes={["C","E","G","B"]} />
|
||||||
|
└── <GuitarFretboard frets={[x,3,2,0,1,0]} baseFret={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```
|
||||||
|
app/app/
|
||||||
|
lib/
|
||||||
|
chord-voicing.ts # theory layer: tonal → notes + guitar voicing
|
||||||
|
guitar-voicings.ts # data: ~25 quality templates
|
||||||
|
components/
|
||||||
|
chord-diagram/
|
||||||
|
piano-keys.tsx # dumb renderer: string[] of note names → keyboard
|
||||||
|
guitar-fretboard.tsx # dumb renderer: frets[] + baseFret → fretboard grid
|
||||||
|
chord-diagram.tsx # entry point: chord+instrument → voicing → renderer
|
||||||
|
chord-grid.tsx # wrapped grid of ChordDiagram cards for all song chords
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component API
|
||||||
|
|
||||||
|
### `<ChordDiagram>`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ChordDiagram chord="Cmaj7" instrument="piano" />
|
||||||
|
<ChordDiagram chord="Am" instrument="guitar" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Renders nothing (graceful empty) if the chord cannot be parsed or has no voicing.
|
||||||
|
|
||||||
|
### `<ChordGrid>`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ChordGrid chords={["Em7", "G", "Dsus4", "Am7"]} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Owns the `instrument` state (`"piano" | "guitar"`), persisted to `localStorage` as `chordDiagramInstrument`. Renders a global piano/guitar toggle and a `flex-wrap` grid of `<ChordDiagram>` cards.
|
||||||
|
|
||||||
|
## Diagram Styles
|
||||||
|
|
||||||
|
- **Piano:** dot notation — white keys with filled circles on pressed keys, black keys overlaid. 1 fixed octave shown (C to B); notes are matched by name regardless of octave.
|
||||||
|
- **Guitar:** standard vertical fretboard — nut at top, 4 frets shown, dots on finger positions, O/X above strings for open/muted. Barre indicator where applicable.
|
||||||
|
|
||||||
|
## Theory Layer (`chord-voicing.ts`)
|
||||||
|
|
||||||
|
Uses `@tonaljs/tonal` (already in npm, tree-shakeable):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function getPianoNotes(chord: string): string[]
|
||||||
|
// "Cmaj7" → ["C", "E", "G", "B"]
|
||||||
|
// Returns [] if unparseable
|
||||||
|
|
||||||
|
export function getGuitarVoicing(chord: string): GuitarVoicing | null
|
||||||
|
// "Am" → { frets: [0,0,2,2,1,0], baseFret: 1, barre: null }
|
||||||
|
// Returns null if quality not in voicing map
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guitar Voicing Data (`guitar-voicings.ts`)
|
||||||
|
|
||||||
|
~25 moveable barre-chord templates keyed by `tonal` chord type name. Each template is a barre shape (no open strings) so it can be transposed by shifting `baseFret`. Two shape families are used: E-shapes (root on 6th string) and A-shapes (root on 5th string). `baseFret` is computed as the semitone distance from the template shape's root string pitch (E or A) to the target chord root.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface GuitarVoicingTemplate {
|
||||||
|
frets: (number | null)[] // 6 strings; null = muted; fret numbers relative to baseFret
|
||||||
|
baseFret: number // 1 in template; shifted when transposing to target root
|
||||||
|
barre: number | null // fret (relative to baseFret) to draw barre, or null
|
||||||
|
rootString: 'E' | 'A' // which string carries the root (determines transposition offset)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Quality names match `tonal`'s `Chord.get(name).type` output (e.g. `"major"`, `"minor"`, `"major seventh"`, `"dominant seventh"`, `"minor seventh"`, `"diminished"`, `"augmented"`, `"suspended fourth"`, `"suspended second"`, `"half-diminished"`, `"dominant seventh flat five"`, etc.). ~25 entries total.
|
||||||
|
|
||||||
|
If `tonal` returns a quality name not in the map, `getGuitarVoicing` returns `null` and the diagram renders a "no guitar voicing" placeholder.
|
||||||
|
|
||||||
|
## Layout & Integration
|
||||||
|
|
||||||
|
### Breakpoint
|
||||||
|
|
||||||
|
`lg` (Tailwind) divides mobile from desktop layout.
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
|
||||||
|
- Below `lg`: lyrics and diagrams in a single column.
|
||||||
|
- **Inline popup:** tapping a chord name in `chord-chart.tsx` sets `activeChord` state in `songs.$id.tsx`. An inline `<ChordDiagram>` panel appears immediately below the tapped line. It closes when the scroll container fires a `scroll` event.
|
||||||
|
- **Bottom grid:** `<ChordGrid>` rendered after `<ChordChart>` in the scroll column. Not sticky — scrolls with content.
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
|
||||||
|
- At `lg` and above: `songs.$id.tsx` switches to a two-column layout.
|
||||||
|
- Left column: `<ChordChart>` (existing).
|
||||||
|
- Right column: `<ChordGrid>` showing all unique chords in the song, wrapped. No inline popup on desktop (side column is always visible).
|
||||||
|
|
||||||
|
### Chord list source
|
||||||
|
|
||||||
|
`songs.$id.tsx` derives `uniqueChords: string[]` from `displayed.sections` — all unique chord names in order of first appearance, deduplicated. This list is passed to `<ChordGrid>` and also used to determine which chord names in `<ChordChart>` are tappable.
|
||||||
|
|
||||||
|
### Instrument toggle
|
||||||
|
|
||||||
|
Global piano/guitar toggle lives in `<ChordGrid>`. State persisted to `localStorage` as `chordDiagramInstrument`. Switching updates all visible diagrams at once.
|
||||||
|
|
||||||
|
## Error / Unknown Chord Handling
|
||||||
|
|
||||||
|
- `getPianoNotes` returns `[]` → `<PianoKeys>` renders with no dots highlighted and a subtle "?" label.
|
||||||
|
- `getGuitarVoicing` returns `null` → `<GuitarFretboard>` renders an empty fretboard with a "no voicing" label.
|
||||||
|
- Unparseable chord name (garbage string) → same fallback as above.
|
||||||
|
|
||||||
|
## Dependency
|
||||||
|
|
||||||
|
Add `tonal` to `app/package.json`. It is tree-shakeable; only chord parsing and note utilities will be bundled.
|
||||||
Reference in New Issue
Block a user