Compare commits

...

10 Commits

41 changed files with 2538 additions and 270 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
.env

1
Cargo.lock generated
View File

@@ -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",

1
LICENSE Normal file
View File

@@ -0,0 +1 @@
MIT

3
README.md Normal file
View 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
View File

@@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md

7
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.DS_Store
.env
/node_modules/
# React Router
/.react-router/
/build/

22
app/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]

87
app/README.md Normal file
View File

@@ -0,0 +1,87 @@
# Welcome to React Router!
A modern, production-ready template for building full-stack React applications using React Router.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](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
View 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;
}
}

View File

@@ -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,7 @@ 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";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -27,6 +27,10 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) reset();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -60,9 +64,13 @@ export function AddSongSheet({ open, onOpenChange, onSongAdded }: Props) {
setLoading(true); setLoading(true);
try { try {
const stored = await createSong( const stored = await createSong(
fileHtml ? { html: fileHtml } : { source: url.trim() } fileHtml ? { html: fileHtml } : { source: url.trim() },
); );
onSongAdded({ id: stored.id, meta: stored.song.meta, preview_chords: previewChords(stored.song) }); onSongAdded({
id: stored.id,
meta: stored.song.meta,
preview_chords: previewChords(stored.song),
});
onOpenChange(false); onOpenChange(false);
reset(); reset();
navigate(`/songs/${stored.id}`); navigate(`/songs/${stored.id}`);
@@ -127,12 +135,19 @@ 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
type="submit"
className="flex-1"
disabled={loading || (!url.trim() && !fileHtml)}
>
{loading ? "Importing..." : "Import"} {loading ? "Importing..." : "Import"}
</Button> </Button>
</div> </div>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,35 +1,58 @@
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 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;
} }
export function TransposeBar({ meta, offset, onOffsetChange }: Props) { export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete }: 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" <ChevronDown className="w-4 h-4" />
onClick={() => setExpanded(true)} </Button>
> </div>
<ChevronDown className="w-4 h-4" />
</Button>
</div> </div>
); );
} }
@@ -41,14 +64,12 @@ 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" <ChevronUp className="w-4 h-4" />
onClick={() => setExpanded(false)} </Button>
> </div>
<ChevronUp className="w-4 h-4" />
</Button>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -57,25 +78,14 @@ export function TransposeBar({ meta, offset, onOffsetChange }: Props) {
{meta.capo != null && <span>Capo: {meta.capo}</span>} {meta.capo != null && <span>Capo: {meta.capo}</span>}
{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 <Button variant="ghost" size="icon" className="h-8 w-8"
variant="ghost" onClick={() => onOffsetChange(Math.max(-11, offset - 1))}>
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>

View File

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

View File

@@ -1,24 +1,18 @@
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 = ""): Promise<SongSummary[]> {
const res = await fetch(`${getApiBase()}/songs`); const url = q.trim()
? `${API_BASE}/songs?q=${encodeURIComponent(q.trim())}`
: `${API_BASE}/songs`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load songs: ${res.status}`); 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): Promise<Song | null> {
const res = await fetch(`${getApiBase()}/songs/${id}`); const res = await fetch(`${API_BASE}/songs/${id}`);
if (res.status === 404) return null; if (res.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 +22,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 +35,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();
} }

View File

@@ -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;
}

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

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

View File

@@ -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
View File

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

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

@@ -0,0 +1,81 @@
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",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<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>
);
}

View File

@@ -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 [
index("routes/home.tsx"), layout("routes/layout.tsx", [
route("songs/:id", "routes/songs.$id.tsx"), index("routes/home.tsx"),
route("songs/:id", "routes/songs.$id.tsx"),
]),
] satisfies RouteConfig; ] satisfies RouteConfig;

View File

@@ -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";
@@ -16,33 +17,41 @@ export function meta({}: Route.MetaArgs) {
]; ];
} }
export async function loader() { export async function loader({ request }: Route.LoaderArgs) {
const q = new URL(request.url).searchParams.get("q") ?? "";
try { try {
const songs = await listSongs(); const songs = await listSongs(q);
return { songs }; return { songs, q, error: false };
} catch { } catch {
return { songs: [] }; return { songs: [], q, error: true };
} }
} }
export default function Home({ loaderData }: Route.ComponentProps) { export default function Home({ loaderData }: Route.ComponentProps) {
const { songs } = loaderData; const { songs, q: initialQ, 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(() => {
setSearchParams(value.trim() ? { q: value.trim() } : {}, { replace: true });
}, 300);
}, [setSearchParams]);
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 +60,43 @@ 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 */}
{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
View 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>
);
}

View File

@@ -1,10 +1,13 @@
import { useState } from "react"; import { useState } 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 { EditSongSheet } from "~/components/edit-song-sheet";
import { DeleteSongDialog } from "~/components/delete-song-dialog";
import { transposeSong } from "~/lib/transpose"; import { transposeSong } from "~/lib/transpose";
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 +19,67 @@ 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 ?? "";
const song = await getSong(id); try {
if (!song) throw data("Song not found", { status: 404 }); const song = await getSong(id);
return { song }; if (!song) throw data("Song not found", { status: 404 });
return { song, id };
} catch (err: unknown) {
if (err && typeof err === "object" && "status" in err && (err as { status: number }).status === 404) {
throw err;
}
return { song: null as Song | null, id };
}
} }
export default function SongDetail({ loaderData }: Route.ComponentProps) { export default function SongDetail({ loaderData }: Route.ComponentProps) {
const { song } = loaderData; const { song: initialSong, id } = loaderData;
const [song, setSong] = useState<Song | null>(initialSong ?? null);
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
if (!song) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-muted-foreground text-sm">Song not found or unavailable.</p>
<Link to="/" className="text-sm text-primary underline-offset-4 hover:underline">
Back to library
</Link>
</div>
);
}
const displayed = transposeSong(song, offset); const displayed = transposeSong(song, offset);
function handleUpdated(summary: SongSummary) {
setSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
}
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">
<TransposeBar meta={song.meta} offset={offset} onOffsetChange={setOffset} /> <TransposeBar
meta={song.meta}
offset={offset}
onOffsetChange={setOffset}
onEdit={() => setEditOpen(true)}
onDelete={() => setDeleteOpen(true)}
/>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<ChordChart sections={displayed.sections} /> <ChordChart sections={displayed.sections} />
</div> </div>
<EditSongSheet
id={id}
meta={song.meta}
open={editOpen}
onOpenChange={setEditOpen}
onUpdated={handleUpdated}
/>
<DeleteSongDialog
id={id}
title={song.meta.title}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</div> </div>
); );
} }

1420
app/bun.lock Normal file

File diff suppressed because it is too large Load Diff

25
app/components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-maia",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/app.css",
"baseColor": "mist",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
},
"menuColor": "default-translucent",
"menuAccent": "subtle",
"registries": {}
}

48
app/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "app",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/inter": "^5.2.8",
"@react-router/node": "7.14.0",
"@react-router/serve": "7.14.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"isbot": "^5.1.36",
"lucide-react": "^1.7.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.4",
"react-resizable-panels": "^4.9.0",
"react-router": "7.14.0",
"recharts": "3.8.0",
"shadcn": "^4.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2"
},
"devDependencies": {
"@react-router/dev": "7.14.0",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^22",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^8.0.3"
}
}

BIN
app/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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
View 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
View 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,
},
});

6
crates/api/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
*.sqlite
*.log
.env
*.db
*.db-shm
*.db-wal

View File

@@ -1,9 +1,9 @@
mod routes; mod routes;
use axum::{Router, routing::{get, post}}; use axum::{Router, routing::{get, post}};
use common::SongService; use common::{SongSearchService, SongService};
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};
@@ -18,12 +18,14 @@ async fn main() {
let repo = SqliteRepositoryFactory::create(&database_url) let repo = SqliteRepositoryFactory::create(&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 = CorsLayer::new()
@@ -34,7 +36,7 @@ async fn main() {
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);

View File

@@ -1,12 +1,18 @@
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
Json, Json,
}; };
use domain::RepositoryError; use domain::RepositoryError;
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>,
}
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,11 +36,48 @@ 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 result = if let Some(q) = params.q.filter(|s| !s.is_empty()) {
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })) state.search.search(&q).await
} else {
state.songs.list().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() })),
})
} }
pub async fn get_song( pub async fn get_song(

View File

@@ -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
View File

@@ -0,0 +1 @@
Cargo.lock

View File

@@ -1,4 +1,4 @@
use domain::{RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong}; use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong};
use uuid::Uuid; use uuid::Uuid;
pub struct SongService { pub struct SongService {
@@ -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) -> Result<Vec<domain::SongSummary>, domain::RepositoryError> {
self.search.search(query).await
}
} }

View File

@@ -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};
pub use transposer::{ChordTransposer, TransposeError}; pub use transposer::{ChordTransposer, TransposeError};

View File

@@ -53,4 +53,16 @@ pub trait SongRepositoryPort: Send + Sync {
async fn list(&self) -> Result<Vec<SongSummary>, RepositoryError>; async fn list(&self) -> 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) -> Result<Vec<SongSummary>, RepositoryError>;
} }

View File

@@ -1,11 +1,12 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
RepositoryError, Song, SongMeta, SongRepositoryPort, SongSummary, StoredSong, RepositoryError, Song, SongMeta, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong,
song_preview_chords, song_preview_chords,
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone)]
pub struct SqliteSongRepository { pub struct SqliteSongRepository {
pool: SqlitePool, pool: SqlitePool,
} }
@@ -64,26 +65,7 @@ impl SongRepositoryPort for SqliteSongRepository {
.await .await
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| RepositoryError::Internal(e.to_string()))?;
rows.into_iter() rows.into_iter().map(row_to_summary).collect()
.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> { async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
@@ -120,6 +102,96 @@ impl SongRepositoryPort for SqliteSongRepository {
Ok(()) 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,
})
}
}
#[async_trait]
impl SongSearchPort for SqliteSongRepository {
async fn search(&self, query: &str) -> Result<Vec<SongSummary>, RepositoryError> {
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{}%", escaped);
let rows = sqlx::query_as::<_, SongRow>(
"SELECT id, title, artist, original_key, preview_chords, body FROM songs \
WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') ORDER BY created_at DESC"
)
.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()
}
}
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,
})
} }
pub struct SqliteRepositoryFactory; pub struct SqliteRepositoryFactory;