allow to mark episodes as completed

This commit is contained in:
2026-03-12 14:53:44 +03:00
parent 618c034b05
commit 5409167a96
6 changed files with 152 additions and 28 deletions

43
main.go
View File

@@ -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)

41
web/src/api/episodes.ts Normal file
View 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();
},
});
};

View File

@@ -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;
};

View File

@@ -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 (
<div>
<Link to="/">
@@ -52,7 +92,31 @@ export const PodcastPage = () => {
)}
<span>{episode.title}</span>
</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>

View File

@@ -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";

View File

@@ -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<PlayerStatus>("stopped");