From 5409167a96388549c1eb6955953df73b04a212fd Mon Sep 17 00:00:00 2001 From: Daniil Tsivinsky Date: Thu, 12 Mar 2026 14:53:44 +0300 Subject: [PATCH] allow to mark episodes as completed --- main.go | 43 +++++++++++++++++++++++ web/src/api/episodes.ts | 41 ++++++++++++++++++++++ web/src/api/podcasts.ts | 24 ------------- web/src/pages/podcast.tsx | 68 +++++++++++++++++++++++++++++++++++-- web/src/player/context.ts | 2 +- web/src/player/provider.tsx | 2 +- 6 files changed, 152 insertions(+), 28 deletions(-) create mode 100644 web/src/api/episodes.ts diff --git a/main.go b/main.go index 3a9d332..dc8282d 100644 --- a/main.go +++ b/main.go @@ -57,6 +57,7 @@ type Episode struct { Url string `json:"url" db:"url"` PodcastId int64 `json:"podcastId" db:"podcast_id"` Number int `json:"number" db:"number"` + Listened bool `json:"listened" db:"listened"` 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) { for { podcasts, err := getPodcasts(db) @@ -496,6 +499,46 @@ func main() { }{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") if err := http.ListenAndServe(":5000", mux); err != nil { log.Fatalf("failed to start http server: %v\n", err) diff --git a/web/src/api/episodes.ts b/web/src/api/episodes.ts new file mode 100644 index 0000000..55f9832 --- /dev/null +++ b/web/src/api/episodes.ts @@ -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 & { id: number }) => { + const resp = await fetch(`/api/episodes/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); + return await resp.json(); + }, + }); +}; diff --git a/web/src/api/podcasts.ts b/web/src/api/podcasts.ts index d6d7118..9b3bd36 100644 --- a/web/src/api/podcasts.ts +++ b/web/src/api/podcasts.ts @@ -11,17 +11,6 @@ export type PodcastDetail = { 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"], @@ -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 = { feed: string; }; diff --git a/web/src/pages/podcast.tsx b/web/src/pages/podcast.tsx index f2e98b0..a54307a 100644 --- a/web/src/pages/podcast.tsx +++ b/web/src/pages/podcast.tsx @@ -1,11 +1,17 @@ import { useParams } from "react-router"; -import { usePodcastEpisodesQuery, usePodcastQuery } from "../api/podcasts"; +import { usePodcastQuery } from "../api/podcasts"; +import { + usePodcastEpisodesQuery, + useUpdateEpisodeMutation, +} from "../api/episodes"; import { Link } from "react-router"; import { usePlayerContext } from "../player/context"; import { PlayIcon } from "../icons/play"; import { PauseIcon } from "../icons/pause"; +import { useQueryClient } from "@tanstack/react-query"; export const PodcastPage = () => { + const queryClient = useQueryClient(); const { id } = useParams<{ id: string }>(); const { data: podcast } = usePodcastQuery(id); @@ -13,6 +19,40 @@ export const PodcastPage = () => { const player = usePlayerContext(); + const updateEpisodeMutation = useUpdateEpisodeMutation(); + + 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 (
@@ -52,7 +92,31 @@ export const PodcastPage = () => { )} {episode.title}
- {new Date(episode.createdAt).toLocaleString()} +
+ {episode.listened ? ( + + ) : ( + + )} + {new Date(episode.createdAt).toLocaleString()} +
))} diff --git a/web/src/player/context.ts b/web/src/player/context.ts index 776848f..5adae5c 100644 --- a/web/src/player/context.ts +++ b/web/src/player/context.ts @@ -1,5 +1,5 @@ import { createContext, useContext } from "react"; -import type { EpisodeDetail } from "../api/podcasts"; +import type { EpisodeDetail } from "../api/episodes"; export type PlayerStatus = "stopped" | "playing" | "paused"; diff --git a/web/src/player/provider.tsx b/web/src/player/provider.tsx index 26e0f31..975c413 100644 --- a/web/src/player/provider.tsx +++ b/web/src/player/provider.tsx @@ -4,7 +4,7 @@ import { type PlayerStatus, playerContext, } from "./context"; -import type { EpisodeDetail } from "../api/podcasts"; +import type { EpisodeDetail } from "../api/episodes"; export const PlayerProvider = ({ children }: { children: ReactNode }) => { const [status, setStatus] = useState("stopped");