Compare commits

..

2 Commits

12 changed files with 267 additions and 65 deletions

View File

@@ -6,6 +6,7 @@ const MAX_WIDTH = 38;
interface Props { interface Props {
sections: Section[]; sections: Section[];
fontSize?: 'sm' | 'base' | 'lg';
} }
function buildChordRow(chords: { offset: number; chord: string }[]): string { function buildChordRow(chords: { offset: number; chord: string }[]): string {
@@ -54,16 +55,16 @@ function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] {
return segments; return segments;
} }
function LineBlock({ line }: { line: LyricLine }) { function LineBlock({ line, sizeClass }: { line: LyricLine; sizeClass: string }) {
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"> <pre className={`text-primary ${sizeClass} font-mono whitespace-pre`}>
{buildChordRow(line.chords)} {buildChordRow(line.chords)}
</pre> </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 +72,7 @@ function LineBlock({ line }: { line: LyricLine }) {
); );
} }
function SectionBlock({ section }: { section: Section }) { function SectionBlock({ section, sizeClass }: { section: Section; sizeClass: string }) {
return ( return (
<div className="mb-6"> <div className="mb-6">
{section.label && ( {section.label && (
@@ -79,18 +80,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} />
)) ))
)} )}
</div> </div>
); );
} }
export function ChordChart({ sections }: Props) { export function ChordChart({ sections, fontSize }: 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} />
))} ))}
</div> </div>
); );

View File

@@ -4,6 +4,7 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { ChevronUp, ChevronDown, Minus, Plus, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; 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 {
@@ -12,9 +13,14 @@ interface Props {
onOffsetChange: (offset: number) => void; onOffsetChange: (offset: number) => void;
onEdit?: () => void; onEdit?: () => void;
onDelete?: () => 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, onEdit, onDelete }: 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 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`; const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`;
@@ -75,10 +81,40 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete }:
<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">
{onFontSizeChange && (
<div className="flex items-center gap-1">
{(['sm', 'base', 'lg'] as const).map((s) => (
<button
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" <Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}> onClick={() => onOffsetChange(Math.max(-11, offset - 1))}>
<Minus className="w-4 h-4" /> <Minus className="w-4 h-4" />

View File

@@ -2,17 +2,22 @@ import type { Song, SongSummary, StoredSong, UpdateSongRequest } from "./types";
const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
export async function listSongs(q = ""): Promise<SongSummary[]> { export async function listSongs(q = "", sort = "date", order = "desc"): Promise<SongSummary[]> {
const url = q.trim() const params = new URLSearchParams();
? `${API_BASE}/songs?q=${encodeURIComponent(q.trim())}` if (q.trim()) params.set("q", q.trim());
: `${API_BASE}/songs`; 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); 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(`${API_BASE}/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();

View File

@@ -23,6 +23,8 @@ export const links: Route.LinksFunction = () => [
rel: "stylesheet", 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", 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 }) { export function Layout({ children }: { children: React.ReactNode }) {
@@ -32,6 +34,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta /> <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 /> <Links />
</head> </head>
<body> <body>

View File

@@ -8,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) {
@@ -18,17 +19,20 @@ export function meta({}: Route.MetaArgs) {
} }
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const q = new URL(request.url).searchParams.get("q") ?? ""; 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(q); const songs = await listSongs(q, sort, order);
return { songs, q, error: false }; return { songs, q, sort, order, error: false };
} catch { } catch {
return { songs: [], q, error: true }; return { songs: [], q, sort, order, error: true };
} }
} }
export default function Home({ loaderData }: Route.ComponentProps) { export default function Home({ loaderData }: Route.ComponentProps) {
const { songs, q: initialQ, error } = loaderData; const { songs, q: initialQ, sort: initialSort, order: initialOrder, error } = loaderData;
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
const [localSongs, setLocalSongs] = useState<SongSummary[]>([]); const [localSongs, setLocalSongs] = useState<SongSummary[]>([]);
@@ -41,9 +45,13 @@ export default function Home({ loaderData }: Route.ComponentProps) {
setInputValue(value); setInputValue(value);
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
setSearchParams(value.trim() ? { q: value.trim() } : {}, { replace: true }); 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); }, 300);
}, [setSearchParams]); }, [setSearchParams, initialSort, initialOrder]);
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []); useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []);
@@ -70,6 +78,29 @@ export default function Home({ loaderData }: Route.ComponentProps) {
/> />
</div> </div>
<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 && ( {error && (
<div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center"> <div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center">

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { data, Link } 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";
@@ -31,14 +31,57 @@ export async function loader({ params }: Route.LoaderArgs) {
} }
} }
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';
}
export default function SongDetail({ loaderData }: Route.ComponentProps) { export default function SongDetail({ loaderData }: Route.ComponentProps) {
const { song: initialSong, id } = loaderData; const { song: initialSong, id } = loaderData;
const [song, setSong] = useState<Song | null>(initialSong ?? null); const [baseSong, setBaseSong] = useState<Song | null>(initialSong ?? null);
const [offset, setOffset] = useState(0); 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 [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
if (!song) { 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 */ }
}
if (!baseSong || !displayedSong) {
return ( return (
<div className="flex flex-col items-center justify-center h-full gap-4"> <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> <p className="text-muted-foreground text-sm">Song not found or unavailable.</p>
@@ -49,34 +92,40 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
); );
} }
const displayed = transposeSong(song, offset); const displayed = transposeSong(displayedSong, offset);
function handleUpdated(summary: SongSummary) { function handleUpdated(summary: SongSummary) {
setSong((prev) => prev ? { ...prev, meta: summary.meta } : prev); setBaseSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
setDisplayedSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
} }
return ( return (
<div className="flex flex-col h-full max-w-lg mx-auto"> <div className="flex flex-col h-full max-w-lg mx-auto">
<TransposeBar <TransposeBar
meta={song.meta} meta={baseSong.meta}
offset={offset} offset={offset}
onOffsetChange={setOffset} onOffsetChange={handleOffsetChange}
onEdit={() => setEditOpen(true)} onEdit={() => setEditOpen(true)}
onDelete={() => setDeleteOpen(true)} onDelete={() => setDeleteOpen(true)}
fontSize={fontSize}
onFontSizeChange={handleFontSizeChange}
capo={baseSong.meta.capo ?? undefined}
applyCapo={applyCapo}
onToggleCapo={() => setApplyCapo((v) => !v)}
/> />
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<ChordChart sections={displayed.sections} /> <ChordChart sections={displayed.sections} fontSize={fontSize} />
</div> </div>
<EditSongSheet <EditSongSheet
id={id} id={id}
meta={song.meta} meta={baseSong.meta}
open={editOpen} open={editOpen}
onOpenChange={setEditOpen} onOpenChange={setEditOpen}
onUpdated={handleUpdated} onUpdated={handleUpdated}
/> />
<DeleteSongDialog <DeleteSongDialog
id={id} id={id}
title={song.meta.title} title={baseSong.meta.title}
open={deleteOpen} open={deleteOpen}
onOpenChange={setDeleteOpen} onOpenChange={setDeleteOpen}
/> />

12
app/public/manifest.json Normal file
View 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" }
]
}

View File

@@ -3,7 +3,7 @@ use axum::{
http::StatusCode, http::StatusCode,
Json, Json,
}; };
use domain::RepositoryError; use domain::{ChordTransposer, RepositoryError, SortField, SortOrder};
use serde::Deserialize; use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@@ -11,6 +11,8 @@ use uuid::Uuid;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ListQuery { pub struct ListQuery {
pub q: Option<String>, 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};
@@ -38,10 +40,19 @@ pub async fn list_songs(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Query(params): Query<ListQuery>, Query(params): Query<ListQuery>,
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
let sort = match params.sort.as_deref() {
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()) { let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) {
state.search.search(&q).await state.search.search(&q, sort, order).await
} else { } else {
state.songs.list().await state.songs.list(sort, order).await
}; };
result result
.map(Json) .map(Json)
@@ -80,19 +91,37 @@ pub async fn update_song(
}) })
} }
#[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(

View File

@@ -1,4 +1,4 @@
use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, 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> {
@@ -46,7 +46,7 @@ impl SongSearchService {
Self { search } Self { search }
} }
pub async fn search(&self, query: &str) -> Result<Vec<domain::SongSummary>, domain::RepositoryError> { pub async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<domain::SongSummary>, domain::RepositoryError> {
self.search.search(query).await self.search.search(query, sort, order).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, SongSearchPort}; pub use ports::{RepositoryError, SongRepositoryPort, SongSearchPort, SortField, SortOrder};
pub use transposer::{ChordTransposer, TransposeError}; pub use transposer::{ChordTransposer, TransposeError};

View File

@@ -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,7 @@ 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( async fn update_meta(
@@ -64,5 +79,5 @@ pub trait SongRepositoryPort: Send + Sync {
#[async_trait] #[async_trait]
pub trait SongSearchPort: Send + Sync { pub trait SongSearchPort: Send + Sync {
async fn search(&self, query: &str) -> Result<Vec<SongSummary>, RepositoryError>; async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError>;
} }

View File

@@ -1,11 +1,23 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
RepositoryError, Song, SongMeta, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong, RepositoryError, Song, SongMeta, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong,
SortField, SortOrder,
song_preview_chords, song_preview_chords,
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
use uuid::Uuid; use uuid::Uuid;
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",
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct SqliteSongRepository { pub struct SqliteSongRepository {
pool: SqlitePool, pool: SqlitePool,
@@ -57,10 +69,12 @@ impl SongRepositoryPort for SqliteSongRepository {
Ok(StoredSong { id, song: song.clone() }) Ok(StoredSong { id, song: song.clone() })
} }
async fn list(&self) -> Result<Vec<SongSummary>, RepositoryError> { async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
let rows = sqlx::query_as::<_, SongRow>( let sql = format!(
"SELECT id, title, artist, original_key, preview_chords, body FROM songs ORDER BY created_at DESC" "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) .fetch_all(&self.pool)
.await .await
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| RepositoryError::Internal(e.to_string()))?;
@@ -158,13 +172,15 @@ impl SongRepositoryPort for SqliteSongRepository {
#[async_trait] #[async_trait]
impl SongSearchPort for SqliteSongRepository { impl SongSearchPort for SqliteSongRepository {
async fn search(&self, query: &str) -> Result<Vec<SongSummary>, RepositoryError> { async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{}%", escaped); let pattern = format!("%{}%", escaped);
let rows = sqlx::query_as::<_, SongRow>( let sql = format!(
"SELECT id, title, artist, original_key, preview_chords, body FROM songs \ "SELECT id, title, artist, original_key, preview_chords, body FROM songs \
WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') ORDER BY created_at DESC" WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') {}",
) sort_clause(sort, order)
);
let rows = sqlx::query_as::<_, SongRow>(&sql)
.bind(&pattern) .bind(&pattern)
.bind(&pattern) .bind(&pattern)
.fetch_all(&self.pool) .fetch_all(&self.pool)