Compare commits
4 Commits
8e226e9ea1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e0a323ff91 | |||
| 3f418669f3 | |||
| 5409167a96 | |||
| 618c034b05 |
43
main.go
43
main.go
@@ -57,6 +57,7 @@ type Episode struct {
|
|||||||
Url string `json:"url" db:"url"`
|
Url string `json:"url" db:"url"`
|
||||||
PodcastId int64 `json:"podcastId" db:"podcast_id"`
|
PodcastId int64 `json:"podcastId" db:"podcast_id"`
|
||||||
Number int `json:"number" db:"number"`
|
Number int `json:"number" db:"number"`
|
||||||
|
Listened bool `json:"listened" db:"listened"`
|
||||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +309,8 @@ func main() {
|
|||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
db.Exec(`ALTER TABLE episodes ADD COLUMN listened boolean default false;`)
|
||||||
|
|
||||||
go func(db *sqlx.DB) {
|
go func(db *sqlx.DB) {
|
||||||
for {
|
for {
|
||||||
podcasts, err := getPodcasts(db)
|
podcasts, err := getPodcasts(db)
|
||||||
@@ -496,6 +499,46 @@ func main() {
|
|||||||
}{true}, 200)
|
}{true}, 200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("PATCH /api/episodes/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Listened *bool `json:"listened"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, err.Error(), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := map[string]any{}
|
||||||
|
|
||||||
|
if body.Listened != nil {
|
||||||
|
fields["listened"] = &body.Listened
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fields) == 0 {
|
||||||
|
sendJSON(w, struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
}{true}, 200)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fields["id"] = r.PathValue("id")
|
||||||
|
|
||||||
|
f := []string{}
|
||||||
|
for field := range fields {
|
||||||
|
f = append(f, fmt.Sprintf("%s = :%s", field, field))
|
||||||
|
}
|
||||||
|
q := fmt.Sprintf("UPDATE episodes SET %s WHERE id = :id", strings.Join(f, ", "))
|
||||||
|
_, err := db.NamedExec(q, fields)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJSON(w, struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
}{true}, 200)
|
||||||
|
})
|
||||||
|
|
||||||
fmt.Println("starting http server on http://localhost:5000")
|
fmt.Println("starting http server on http://localhost:5000")
|
||||||
if err := http.ListenAndServe(":5000", mux); err != nil {
|
if err := http.ListenAndServe(":5000", mux); err != nil {
|
||||||
log.Fatalf("failed to start http server: %v\n", err)
|
log.Fatalf("failed to start http server: %v\n", err)
|
||||||
|
|||||||
41
web/src/api/episodes.ts
Normal file
41
web/src/api/episodes.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export type EpisodeDetail = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
pubDate: string;
|
||||||
|
guid: string;
|
||||||
|
url: string;
|
||||||
|
podcastId: number;
|
||||||
|
number: number;
|
||||||
|
listened: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 const useUpdateEpisodeMutation = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
id,
|
||||||
|
...data
|
||||||
|
}: Partial<EpisodeDetail> & { id: number }) => {
|
||||||
|
const resp = await fetch(`/api/episodes/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return await resp.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -11,17 +11,6 @@ export type PodcastDetail = {
|
|||||||
createdAt: 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 = () => {
|
export const usePodcastsQuery = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["podcasts"],
|
queryKey: ["podcasts"],
|
||||||
@@ -43,19 +32,6 @@ export const usePodcastQuery = (id: number | string | null | undefined) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = {
|
export type CreatePodcastData = {
|
||||||
feed: string;
|
feed: string;
|
||||||
};
|
};
|
||||||
@@ -71,3 +47,14 @@ export const useCreatePodcastMutation = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useDeletePodcastMutation = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id }: { id: number }) => {
|
||||||
|
const resp = await fetch(`/api/podcasts/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
return await resp.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { HomePage } from "./pages/home";
|
|||||||
import { PodcastPage } from "./pages/podcast";
|
import { PodcastPage } from "./pages/podcast";
|
||||||
import { PlayerProvider } from "./player/provider";
|
import { PlayerProvider } from "./player/provider";
|
||||||
import { Player } from "./player/player";
|
import { Player } from "./player/player";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -23,6 +24,9 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<PlayerProvider>
|
<PlayerProvider>
|
||||||
<div className="max-w-[1440px] w-full mx-auto p-2 relative">
|
<div className="max-w-[1440px] w-full mx-auto p-2 relative">
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<Link to="/" className="w-fit">
|
||||||
|
<h1 className="text-3xl font-semibold">podcaster</h1>
|
||||||
|
</Link>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/podcasts/:id" element={<PodcastPage />} />
|
<Route path="/podcasts/:id" element={<PodcastPage />} />
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Link } from "react-router";
|
|
||||||
import { usePodcastsQuery } from "../api/podcasts";
|
import { usePodcastsQuery } from "../api/podcasts";
|
||||||
import { NewPodcastForm } from "../components/NewPodcastForm";
|
import { NewPodcastForm } from "../components/NewPodcastForm";
|
||||||
|
|
||||||
@@ -7,10 +6,6 @@ export const HomePage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Link to="/">
|
|
||||||
<h1 className="text-3xl font-semibold">podcaster</h1>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<NewPodcastForm />
|
<NewPodcastForm />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,250px)] gap-3 mt-5">
|
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,250px)] gap-3 mt-5">
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import { usePodcastEpisodesQuery, usePodcastQuery } from "../api/podcasts";
|
import { useDeletePodcastMutation, usePodcastQuery } from "../api/podcasts";
|
||||||
import { Link } from "react-router";
|
import {
|
||||||
|
usePodcastEpisodesQuery,
|
||||||
|
useUpdateEpisodeMutation,
|
||||||
|
} from "../api/episodes";
|
||||||
import { usePlayerContext } from "../player/context";
|
import { usePlayerContext } from "../player/context";
|
||||||
import { PlayIcon } from "../icons/play";
|
import { PlayIcon } from "../icons/play";
|
||||||
import { PauseIcon } from "../icons/pause";
|
import { PauseIcon } from "../icons/pause";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
export const PodcastPage = () => {
|
export const PodcastPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
const { data: podcast } = usePodcastQuery(id);
|
const { data: podcast } = usePodcastQuery(id);
|
||||||
@@ -13,17 +20,87 @@ export const PodcastPage = () => {
|
|||||||
|
|
||||||
const player = usePlayerContext();
|
const player = usePlayerContext();
|
||||||
|
|
||||||
|
const updateEpisodeMutation = useUpdateEpisodeMutation();
|
||||||
|
const deletePodcastMutation = useDeletePodcastMutation();
|
||||||
|
|
||||||
|
const handleDeletePodcast = () => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Are you sure you want to delete this podcast with all episodes?",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (typeof id === "undefined") return;
|
||||||
|
|
||||||
|
deletePodcastMutation.mutate(
|
||||||
|
{
|
||||||
|
id: parseInt(id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["podcasts"] });
|
||||||
|
navigate("/");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkCompleted = (episodeId: number) => {
|
||||||
|
updateEpisodeMutation.mutate(
|
||||||
|
{
|
||||||
|
id: episodeId,
|
||||||
|
listened: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["podcasts", id, "episodes"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnmarkCompleted = (episodeId: number) => {
|
||||||
|
updateEpisodeMutation.mutate(
|
||||||
|
{
|
||||||
|
id: episodeId,
|
||||||
|
listened: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["podcasts", id, "episodes"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Link to="/">
|
|
||||||
<h1 className="text-3xl font-semibold">podcaster</h1>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<img src={podcast?.image} alt="" className="w-[300px] aspect-square" />
|
<img src={podcast?.image} alt="" className="w-[300px] aspect-square" />
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h2 className="text-2xl font-semibold">{podcast?.name}</h2>
|
<h2 className="text-2xl font-semibold">{podcast?.name}</h2>
|
||||||
<p>{podcast?.description}</p>
|
<p>{podcast?.description}</p>
|
||||||
|
<div className="mt-auto flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={podcast?.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block bg-blue-500 text-white py-1 px-4 rounded"
|
||||||
|
>
|
||||||
|
Website
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="bg-red-500 text-white py-1 px-4 rounded w-fit cursor-pointer"
|
||||||
|
disabled={deletePodcastMutation.isPending}
|
||||||
|
onClick={handleDeletePodcast}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,7 +129,31 @@ export const PodcastPage = () => {
|
|||||||
)}
|
)}
|
||||||
<span>{episode.title}</span>
|
<span>{episode.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<span>{new Date(episode.createdAt).toLocaleString()}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
{episode.listened ? (
|
||||||
|
<button
|
||||||
|
className="text-green-700"
|
||||||
|
disabled={
|
||||||
|
updateEpisodeMutation.isPending &&
|
||||||
|
updateEpisodeMutation.variables.id === episode.id
|
||||||
|
}
|
||||||
|
onClick={() => handleUnmarkCompleted(episode.id)}
|
||||||
|
>
|
||||||
|
Unmark completed
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
disabled={
|
||||||
|
updateEpisodeMutation.isPending &&
|
||||||
|
updateEpisodeMutation.variables.id === episode.id
|
||||||
|
}
|
||||||
|
onClick={() => handleMarkCompleted(episode.id)}
|
||||||
|
>
|
||||||
|
Mark completed
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span>{new Date(episode.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import type { EpisodeDetail } from "../api/podcasts";
|
import type { EpisodeDetail } from "../api/episodes";
|
||||||
|
|
||||||
export type PlayerStatus = "stopped" | "playing" | "paused";
|
export type PlayerStatus = "stopped" | "playing" | "paused";
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const Player = () => {
|
|||||||
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [showTimeLeft, setShowTimeLeft] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!episode) return;
|
if (!episode) return;
|
||||||
@@ -62,6 +63,7 @@ export const Player = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const progress = currentTime / duration;
|
const progress = currentTime / duration;
|
||||||
|
const timeLeft = duration - currentTime;
|
||||||
|
|
||||||
if (status === "stopped") {
|
if (status === "stopped") {
|
||||||
return null;
|
return null;
|
||||||
@@ -117,11 +119,13 @@ export const Player = () => {
|
|||||||
className="bg-red-500 h-full"
|
className="bg-red-500 h-full"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<button
|
||||||
className={`${Math.floor(duration / 3600) > 0 ? "min-w-[70px]" : "min-w-[50px]"} text-right`}
|
className="min-w-[70px] text-right cursor-pointer"
|
||||||
|
onClick={() => setShowTimeLeft((prev) => !prev)}
|
||||||
>
|
>
|
||||||
{formatTime(currentTime)}
|
{showTimeLeft ? "-" : ""}
|
||||||
</p>
|
{formatTime(showTimeLeft ? timeLeft : currentTime)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-base font-semibold mt-2">{episode?.title}</h3>
|
<h3 className="text-base font-semibold mt-2">{episode?.title}</h3>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
type PlayerStatus,
|
type PlayerStatus,
|
||||||
playerContext,
|
playerContext,
|
||||||
} from "./context";
|
} from "./context";
|
||||||
import type { EpisodeDetail } from "../api/podcasts";
|
import type { EpisodeDetail } from "../api/episodes";
|
||||||
|
|
||||||
export const PlayerProvider = ({ children }: { children: ReactNode }) => {
|
export const PlayerProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [status, setStatus] = useState<PlayerStatus>("stopped");
|
const [status, setStatus] = useState<PlayerStatus>("stopped");
|
||||||
|
|||||||
Reference in New Issue
Block a user