rewrite ui with vite+react
This commit is contained in:
1
Justfile
1
Justfile
@@ -1,4 +1,5 @@
|
||||
dev:
|
||||
cd web && pnpm build
|
||||
PODCASTS_DIRPATH=./podcasts go run .
|
||||
|
||||
build tag="latest":
|
||||
|
||||
94
main.go
94
main.go
@@ -5,13 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -229,13 +227,11 @@ func deletePodcastById(db *sqlx.DB, podcastId int64) error {
|
||||
}
|
||||
|
||||
var (
|
||||
//go:embed views
|
||||
viewsFS embed.FS
|
||||
//go:embed web/dist/*
|
||||
webFS embed.FS
|
||||
)
|
||||
|
||||
func main() {
|
||||
tmpl := template.Must(template.ParseFS(viewsFS, "**/*.html"))
|
||||
|
||||
podcastsDirPath := os.Getenv("PODCASTS_DIRPATH")
|
||||
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
@@ -337,20 +333,21 @@ func main() {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
podcasts, err := getPodcasts(db)
|
||||
path := r.URL.Path
|
||||
if path == "/" {
|
||||
path = "/index.html"
|
||||
}
|
||||
path = "web/dist" + path
|
||||
|
||||
_, err := webFS.Open(path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
path = "web/dist/index.html"
|
||||
}
|
||||
|
||||
tmpl.ExecuteTemplate(w, "index.html", struct {
|
||||
Podcasts []*Podcast
|
||||
}{
|
||||
Podcasts: podcasts,
|
||||
})
|
||||
http.ServeFileFS(w, r, webFS, path)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /podcasts", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("GET /api/podcasts", func(w http.ResponseWriter, r *http.Request) {
|
||||
podcasts, err := getPodcasts(db)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
@@ -360,52 +357,7 @@ func main() {
|
||||
sendJSON(w, podcasts, 200)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /podcasts/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
|
||||
podcast, err := getPodcastById(db, int64(id))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
|
||||
episodes, err := getPodcastEpisodes(db, podcast.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation("Europe/Moscow")
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to load time location: %v", err), 500)
|
||||
return
|
||||
}
|
||||
for _, episode := range episodes {
|
||||
episode.CreatedAt = episode.CreatedAt.In(loc)
|
||||
}
|
||||
|
||||
slices.SortFunc(episodes, func(a, b *Episode) int {
|
||||
if a.CreatedAt.Before(b.CreatedAt) {
|
||||
return 1
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
})
|
||||
|
||||
tmpl.ExecuteTemplate(w, "podcast.html", struct {
|
||||
Podcast *Podcast
|
||||
Episodes []*Episode
|
||||
}{
|
||||
Podcast: podcast,
|
||||
Episodes: episodes,
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /podcasts", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("POST /api/podcasts", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Feed string `json:"feed"`
|
||||
}
|
||||
@@ -450,7 +402,23 @@ func main() {
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /podcasts/{id}/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("GET /api/podcasts/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
|
||||
podcast, err := getPodcastById(db, int64(id))
|
||||
if err != nil {
|
||||
http.Error(w, "podcast not found", 404)
|
||||
return
|
||||
}
|
||||
|
||||
sendJSON(w, podcast, 200)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/podcasts/{id}/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
@@ -466,7 +434,7 @@ func main() {
|
||||
sendJSON(w, episodes, 200)
|
||||
})
|
||||
|
||||
mux.HandleFunc("DELETE /podcasts/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("DELETE /api/podcasts/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
|
||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
23
web/eslint.config.js
Normal file
23
web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
]);
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
web/package.json
Normal file
37
web/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"dayjs": "^1.11.19",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-router": "^7.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
2475
web/pnpm-lock.yaml
generated
Normal file
2475
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
web/src/api/podcasts.ts
Normal file
73
web/src/api/podcasts.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
};
|
||||
44
web/src/components/NewPodcastForm.tsx
Normal file
44
web/src/components/NewPodcastForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
17
web/src/icons/fastforward.tsx
Normal file
17
web/src/icons/fastforward.tsx
Normal 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
17
web/src/icons/pause.tsx
Normal 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
17
web/src/icons/play.tsx
Normal 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
17
web/src/icons/rewind.tsx
Normal 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
1
web/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
36
web/src/main.tsx
Normal file
36
web/src/main.tsx
Normal 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
30
web/src/pages/home.tsx
Normal 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
61
web/src/pages/podcast.tsx
Normal 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
21
web/src/player/context.ts
Normal 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
128
web/src/player/player.tsx
Normal 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")}`;
|
||||
};
|
||||
23
web/src/player/provider.tsx
Normal file
23
web/src/player/provider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
web/tsconfig.app.json
Normal file
28
web/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
web/vite.config.ts
Normal file
15
web/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
react({
|
||||
babel: {
|
||||
plugins: [["babel-plugin-react-compiler"]],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user