feat(app): PWA manifest, persistent transpose, font size, capo toggle, library sort
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user