add web app & add concurrency

This commit is contained in:
2026-02-20 20:22:06 +03:00
parent b42a556ec5
commit 1f65301a05
19 changed files with 2732 additions and 97 deletions

104
web/src/App.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { useState } from "react";
import { useSearchQuery, type Album } from "./api/useSearchQuery";
import { useDebouncedValue } from "./hooks/useDebouncedValue";
import { getContrastYIQ } from "./getContrastYIQ";
import { useDownloadAlbumMutation } from "./api/useDownloadAlbumMutation";
import { useDownloadsQuery } from "./api/useDownloadsQuery";
export default function App() {
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data } = useSearchQuery({
query: debouncedSearch,
});
const { data: downloads } = useDownloadsQuery();
const downloadAlbumMutation = useDownloadAlbumMutation();
const handleDownloadAlbum = (album: Album) => {
downloadAlbumMutation.mutate(
{ albumId: album.id },
{
onError(error) {
console.log({ error });
},
},
);
};
return (
<div className="max-w-300 w-full mx-auto p-2">
<h1 className="text-3xl font-semibold">music-downloader</h1>
<div className="mt-4 flex flex-col gap-2">
{downloads?.map((item) => (
<div key={item.id} className="flex gap-2 p-2">
<img
src={`/cover/${item.album.cover}`}
alt=""
width={64}
height={64}
/>
<div>
<p>{item.album.title}</p>
<p>
Downloaded {item.installedFiles}/{item.totalFiles}
</p>
</div>
</div>
))}
</div>
<div className="mt-4">
<input
type="text"
placeholder="Search album..."
className="w-full p-1 border border-neutral-900"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="mt-2 flex flex-col gap-2">
{data?.map((album) => {
const contrastColor = getContrastYIQ(album.vibrantColor);
const oppositeColor = contrastColor === "black" ? "white" : "black";
return (
<div
key={album.id}
className="flex gap-2 p-2"
style={{
backgroundColor: album.vibrantColor,
color: contrastColor,
}}
>
<img
src={`/cover/${album.cover}`}
alt=""
width={128}
height={128}
/>
<div className="flex flex-col">
<p>{album.title}</p>
<p>{album.releaseDate}</p>
<p>{album.numberOfTracks} tracks</p>
<p>{album.audioQuality} quality</p>
{album.explicit && <p>Explicit</p>}
</div>
<button
type="button"
className="cursor-pointer ml-auto mt-auto py-1 px-2"
style={{ backgroundColor: oppositeColor, color: contrastColor }}
onClick={() => handleDownloadAlbum(album)}
>
Download
</button>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { useMutation } from "@tanstack/react-query";
export const useDownloadAlbumMutation = () => {
return useMutation({
mutationFn: async ({ albumId }: { albumId: number }) => {
const resp = await fetch(`/download-album/${albumId}`);
if (!resp.ok) {
const data = (await resp.json()) as { error: string };
throw new Error(data.error);
}
return await resp.json();
},
});
};

View File

@@ -0,0 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import type { Album } from "./useSearchQuery";
export type DownloadItem = {
id: number;
album: Album;
installedFiles: number;
totalFiles: number;
};
export const useDownloadsQuery = () => {
return useQuery({
queryKey: ["downloads"],
queryFn: async () => {
const resp = await fetch("/downloads");
return (await resp.json()) as DownloadItem[];
},
});
};

View File

@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
export type Album = {
id: number;
title: string;
duration: number;
numberOfTracks: number;
releaseDate: string;
type: "ALBUM";
url: string;
cover: string;
vibrantColor: string;
explicit: boolean;
audioQuality: "LOSSLESS" | "LOW";
};
export type SearchQueryParams = {
query: string;
};
export const useSearchQuery = (params: SearchQueryParams) => {
return useQuery({
queryKey: ["search", params],
enabled: !!params.query,
queryFn: async () => {
const q = new URLSearchParams();
q.set("q", params.query);
const resp = await fetch(`/search?${q.toString()}`);
return (await resp.json()) as Album[];
},
});
};

25
web/src/getContrastYIQ.ts Normal file
View File

@@ -0,0 +1,25 @@
export function getContrastYIQ(hexcolor: string) {
// Remove leading # if present
if (hexcolor.startsWith("#")) {
hexcolor = hexcolor.slice(1);
}
// Handle 3-digit hex codes (e.g., #abc → #aabbcc)
if (hexcolor.length === 3) {
hexcolor = hexcolor
.split("")
.map((c) => c + c)
.join("");
}
// Parse RGB values
const r = parseInt(hexcolor.substr(0, 2), 16);
const g = parseInt(hexcolor.substr(2, 2), 16);
const b = parseInt(hexcolor.substr(4, 2), 16);
// Calculate YIQ brightness
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
// Return black or white based on contrast
return yiq >= 128 ? "black" : "white";
}

View File

@@ -0,0 +1,14 @@
import { useState, useEffect } from "react";
export function useDebouncedValue<T>(value: T, wait: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, wait);
return () => {
clearTimeout(timer);
};
}, [value, wait]);
return debouncedValue;
}

1
web/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

21
web/src/main.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);