rewrite ui with vite+react

This commit is contained in:
2026-03-12 11:57:43 +03:00
parent 44c7126ac5
commit da9f4bb0ec
24 changed files with 3165 additions and 63 deletions

73
web/src/api/podcasts.ts Normal file
View File

@@ -0,0 +1,73 @@
import { useMutation, useQuery } from "@tanstack/react-query";
export type PodcastDetail = {
id: number;
name: string;
description: string;
feed: string;
language: string;
link: string;
image: string;
createdAt: string;
};
export type EpisodeDetail = {
id: number;
title: string;
pubDate: string;
guid: string;
url: string;
podcastId: number;
number: number;
createdAt: string;
};
export const usePodcastsQuery = () => {
return useQuery({
queryKey: ["podcasts"],
queryFn: async () => {
const resp = await fetch("/api/podcasts");
return (await resp.json()) as PodcastDetail[];
},
});
};
export const usePodcastQuery = (id: number | string | null | undefined) => {
return useQuery({
queryKey: ["podcasts", id],
enabled: typeof id !== "undefined" && id !== null,
queryFn: async () => {
const resp = await fetch(`/api/podcasts/${id}`);
return (await resp.json()) as PodcastDetail;
},
});
};
export const usePodcastEpisodesQuery = (
id: number | string | null | undefined,
) => {
return useQuery({
queryKey: ["podcasts", id, "episodes"],
enabled: typeof id !== "undefined" && id !== null,
queryFn: async () => {
const resp = await fetch(`/api/podcasts/${id}/episodes`);
return (await resp.json()) as EpisodeDetail[];
},
});
};
export type CreatePodcastData = {
feed: string;
};
export const useCreatePodcastMutation = () => {
return useMutation({
mutationFn: async (data: CreatePodcastData) => {
const resp = await fetch("/api/podcasts", {
method: "POST",
body: JSON.stringify(data),
});
return await resp.json();
},
});
};

View File

@@ -0,0 +1,44 @@
import { useForm } from "react-hook-form";
import {
useCreatePodcastMutation,
type CreatePodcastData,
} from "../api/podcasts";
import { useQueryClient } from "@tanstack/react-query";
export const NewPodcastForm = () => {
const queryClient = useQueryClient();
const form = useForm<CreatePodcastData>({
defaultValues: {
feed: "",
},
});
const mutation = useCreatePodcastMutation();
const onSubmit = form.handleSubmit((data) => {
mutation.mutate(data, {
onSuccess() {
queryClient.invalidateQueries({ queryKey: ["podcasts"] });
form.reset();
},
});
});
return (
<form className="mt-3 flex gap-1" onSubmit={onSubmit}>
<input
type="text"
inputMode="url"
placeholder="rss feed"
className="w-full"
{...form.register("feed", {
required: true,
})}
/>
<button type="submit" className="whitespace-nowrap">
Add podcast
</button>
</form>
);
};

View File

@@ -0,0 +1,17 @@
export const FastForwardIcon = ({
size = 32,
color = "#000000",
}: {
size?: number;
color?: string;
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill={color}
viewBox="0 0 256 256"
>
<path d="M248.67,114.66,160.48,58.5A15.91,15.91,0,0,0,136,71.84v37.3L56.48,58.5A15.91,15.91,0,0,0,32,71.84V184.16A15.92,15.92,0,0,0,56.48,197.5L136,146.86v37.3a15.92,15.92,0,0,0,24.48,13.34l88.19-56.16a15.8,15.8,0,0,0,0-26.68ZM48,183.94V72.07L135.82,128Zm104,0V72.07L239.82,128Z"></path>
</svg>
);

17
web/src/icons/pause.tsx Normal file
View File

@@ -0,0 +1,17 @@
export const PauseIcon = ({
size = 32,
color = "#000000",
}: {
size?: number;
color?: string;
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill={color}
viewBox="0 0 256 256"
>
<path d="M200,32H160a16,16,0,0,0-16,16V208a16,16,0,0,0,16,16h40a16,16,0,0,0,16-16V48A16,16,0,0,0,200,32Zm0,176H160V48h40ZM96,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16H96a16,16,0,0,0,16-16V48A16,16,0,0,0,96,32Zm0,176H56V48H96Z"></path>
</svg>
);

17
web/src/icons/play.tsx Normal file
View File

@@ -0,0 +1,17 @@
export const PlayIcon = ({
size = 32,
color = "#000000",
}: {
size?: number;
color?: string;
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill={color}
viewBox="0 0 256 256"
>
<path d="M232.4,114.49,88.32,26.35a16,16,0,0,0-16.2-.3A15.86,15.86,0,0,0,64,39.87V216.13A15.94,15.94,0,0,0,80,232a16.07,16.07,0,0,0,8.36-2.35L232.4,141.51a15.81,15.81,0,0,0,0-27ZM80,215.94V40l143.83,88Z"></path>
</svg>
);

17
web/src/icons/rewind.tsx Normal file
View File

@@ -0,0 +1,17 @@
export const RewindIcon = ({
size = 32,
color = "#000000",
}: {
size?: number;
color?: string;
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill={color}
viewBox="0 0 256 256"
>
<path d="M223.77,58a16,16,0,0,0-16.25.53L128,109.14V71.84A15.91,15.91,0,0,0,103.52,58.5L15.33,114.66a15.8,15.8,0,0,0,0,26.68l88.19,56.16A15.91,15.91,0,0,0,128,184.16v-37.3l79.52,50.64A15.91,15.91,0,0,0,232,184.16V71.84A15.83,15.83,0,0,0,223.77,58ZM112,183.93,24.18,128,112,72.06Zm104,0L128.18,128,216,72.06Z"></path>
</svg>
);

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

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

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

@@ -0,0 +1,36 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes } from "react-router";
import { Route } from "react-router";
import { HomePage } from "./pages/home";
import { PodcastPage } from "./pages/podcast";
import { PlayerProvider } from "./player/provider";
import { Player } from "./player/player";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<PlayerProvider>
<div className="max-w-[1440px] w-full mx-auto p-2 relative">
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/podcasts/:id" element={<PodcastPage />} />
</Routes>
</BrowserRouter>
</div>
<Player />
</PlayerProvider>
</QueryClientProvider>
</StrictMode>,
);

30
web/src/pages/home.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { Link } from "react-router";
import { usePodcastsQuery } from "../api/podcasts";
import { NewPodcastForm } from "../components/NewPodcastForm";
export const HomePage = () => {
const { data: podcasts } = usePodcastsQuery();
return (
<div>
<Link to="/">
<h1 className="text-3xl font-semibold">podcaster</h1>
</Link>
<NewPodcastForm />
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,250px)] gap-3 mt-5">
{podcasts?.map((podcast) => (
<a
key={podcast.id}
href={`/podcasts/${podcast.id}`}
className="block"
>
<img src={podcast.image} alt="" className="w-full aspect-square" />
<span>{podcast.name}</span>
</a>
))}
</div>
</div>
);
};

61
web/src/pages/podcast.tsx Normal file
View File

@@ -0,0 +1,61 @@
import { useParams } from "react-router";
import { usePodcastEpisodesQuery, usePodcastQuery } from "../api/podcasts";
import { Link } from "react-router";
import { usePlayerContext } from "../player/context";
import { PlayIcon } from "../icons/play";
import { PauseIcon } from "../icons/pause";
export const PodcastPage = () => {
const { id } = useParams<{ id: string }>();
const { data: podcast } = usePodcastQuery(id);
const { data: episodes } = usePodcastEpisodesQuery(id);
const player = usePlayerContext();
return (
<div>
<Link to="/">
<h1 className="text-3xl font-semibold">podcaster</h1>
</Link>
<div className="mt-3 flex gap-2">
<img src={podcast?.image} alt="" className="w-[300px] aspect-square" />
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-semibold">{podcast?.name}</h2>
<p>{podcast?.description}</p>
</div>
</div>
<div className="mt-6 p-2.5 border-t flex flex-col gap-1">
{episodes?.map((episode) => (
<div key={episode.id} className="flex justify-between items-center">
<div className="flex items-center gap-2">
{player.status === "playing" &&
player.episode?.id === episode.id ? (
<button
onClick={() => {
player.setStatus("paused");
}}
>
<PauseIcon size={24} />
</button>
) : (
<button
onClick={() => {
player.setStatus("playing");
player.setEpisode(episode);
}}
>
<PlayIcon size={24} />
</button>
)}
<span>{episode.title}</span>
</div>
<span>Added {new Date(episode.createdAt).toLocaleString()}</span>
</div>
))}
</div>
</div>
);
};

21
web/src/player/context.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createContext, useContext } from "react";
import type { EpisodeDetail } from "../api/podcasts";
export type PlayerStatus = "stopped" | "playing" | "paused";
export type PlayerContext = {
status: PlayerStatus;
setStatus: (status: PlayerStatus) => void;
episode: EpisodeDetail | null;
setEpisode: (episode: EpisodeDetail | null) => void;
};
export const playerContext = createContext<PlayerContext | null>(null);
export const usePlayerContext = () => {
const ctx = useContext(playerContext);
if (!ctx) {
throw new Error("No PlayerProvider in component tree");
}
return ctx;
};

128
web/src/player/player.tsx Normal file
View File

@@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from "react";
import { usePlayerContext } from "./context";
import { RewindIcon } from "../icons/rewind";
import { FastForwardIcon } from "../icons/fastforward";
import { PauseIcon } from "../icons/pause";
import { PlayIcon } from "../icons/play";
export const Player = () => {
const { status, episode, setStatus } = usePlayerContext();
const audioRef = useRef<HTMLAudioElement>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
if (!episode) return;
audioRef.current = new Audio(episode.url);
audioRef.current.addEventListener("timeupdate", (e) => {
const t = e.target as HTMLAudioElement;
setCurrentTime(t.currentTime);
});
audioRef.current.addEventListener("loadeddata", (e) => {
const t = e.target as HTMLAudioElement;
setDuration(t.duration);
});
}, [episode]);
useEffect(() => {
if (!audioRef.current) return;
if (status === "playing") {
audioRef.current.play();
} else {
audioRef.current.pause();
}
}, [status, audioRef]);
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowLeft":
if (audioRef.current) {
audioRef.current.currentTime -= 10;
}
break;
case "ArrowRight":
if (audioRef.current) {
audioRef.current.currentTime += 10;
}
break;
}
};
window.addEventListener("keydown", handleKeyPress);
return () => {
window.addEventListener("keydown", handleKeyPress);
};
}, []);
const progress = currentTime / duration;
if (status === "stopped") {
return null;
}
return (
<div className="fixed bottom-0 left-0 right-0 z-50 max-w-[1440px] w-full mx-auto">
<div className="bg-white py-2 px-4 flex items-center gap-4">
<div className="flex items-center gap-2">
{status === "playing" ? (
<button onClick={() => setStatus("paused")}>
<PauseIcon size={24} />
</button>
) : (
<button onClick={() => setStatus("playing")}>
<PlayIcon size={24} />
</button>
)}
<button
onClick={() => {
if (!audioRef.current) return;
audioRef.current.currentTime -= 10;
}}
>
<RewindIcon size={24} />
</button>
<button
onClick={() => {
if (!audioRef.current) return;
audioRef.current.currentTime += 10;
}}
>
<FastForwardIcon size={24} />
</button>
</div>
<div
className="w-full h-2 flex items-center rounded-lg bg-neutral-200 overflow-hidden hover:h-5 transition-[height] ease-linear"
onClick={(e) => {
if (!audioRef.current) return;
const rect = e.currentTarget.getBoundingClientRect();
const left = e.clientX - rect.x;
const percent = Math.floor((left / rect.width) * 100);
const newTime = (duration * percent) / 100;
audioRef.current.currentTime = newTime;
setCurrentTime(newTime);
}}
>
<div
style={{ width: `${progress * 100}%` }}
className="bg-red-500 h-full"
></div>
</div>
<p className="min-w-[50px] text-right">{formatTime(currentTime)}</p>
</div>
</div>
);
};
const formatTime = (seconds: number): string => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
};

View File

@@ -0,0 +1,23 @@
import { useState, type ReactNode } from "react";
import {
type PlayerContext,
type PlayerStatus,
playerContext,
} from "./context";
import type { EpisodeDetail } from "../api/podcasts";
export const PlayerProvider = ({ children }: { children: ReactNode }) => {
const [status, setStatus] = useState<PlayerStatus>("stopped");
const [episode, setEpisode] = useState<EpisodeDetail | null>(null);
const value = {
status,
setStatus,
episode,
setEpisode,
} satisfies PlayerContext;
return (
<playerContext.Provider value={value}>{children}</playerContext.Provider>
);
};