Compare commits

...

9 Commits

11 changed files with 317 additions and 259 deletions

83
main.go
View File

@@ -11,6 +11,7 @@ import (
"os" "os"
"path" "path"
"strconv" "strconv"
"strings"
"time" "time"
_ "time/tzdata" _ "time/tzdata"
@@ -19,6 +20,24 @@ import (
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
type OrderType = string
const (
OrderTypeAsc OrderType = "ASC"
OrderTypeDesc OrderType = "DESC"
)
func validateOrderType(orderType string) OrderType {
switch strings.ToLower(orderType) {
case "asc":
return OrderTypeAsc
case "desc":
return OrderTypeDesc
default:
return OrderTypeAsc
}
}
type Podcast struct { type Podcast struct {
ID int64 `json:"id" db:"id"` ID int64 `json:"id" db:"id"`
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
@@ -38,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"`
} }
@@ -198,8 +218,23 @@ func downloadEpisodeAudioFile(url string) ([]byte, error) {
return data, nil return data, nil
} }
func getPodcastEpisodes(db *sqlx.DB, podcastId int64) ([]*Episode, error) { func getPodcastEpisodes(db *sqlx.DB, podcastId int64, orderBy string, orderType OrderType) ([]*Episode, error) {
rows, err := db.Queryx("SELECT * FROM episodes WHERE podcast_id = ?", podcastId) allowedColumns := map[string]bool{
"title": true,
"pubdate": true,
"number": true,
"created_at": true,
}
if !allowedColumns[orderBy] {
orderBy = "pubdate"
}
orderType = validateOrderType(orderType)
query := fmt.Sprintf("SELECT * FROM episodes WHERE podcast_id = :podcast_id ORDER BY %s %s", orderBy, orderType)
rows, err := db.NamedQuery(query, map[string]any{
"podcast_id": podcastId,
})
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query db: %v", err) return nil, fmt.Errorf("failed to query db: %v", err)
} }
@@ -274,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)
@@ -425,7 +462,7 @@ func main() {
return return
} }
episodes, err := getPodcastEpisodes(db, int64(id)) episodes, err := getPodcastEpisodes(db, int64(id), "pubdate", OrderTypeDesc)
if err != nil { if err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
@@ -462,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)

View File

@@ -1,94 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>podcaster</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
max-width: 1440px;
width: 100%;
margin: 0 auto;
padding: 8px;
}
.podcast-form {
margin-top: 12px;
display: flex;
gap: 4px;
}
.podcast-form__input {
width: 100%;
}
.podcast-form__btn {
white-space: nowrap;
}
.podcasts {
display: grid;
grid-template-columns: repeat(auto-fit, 250px);
gap: 12px;
margin-top: 20px;
}
.podcast {
display: block;
}
.podcast__cover {
width: 100%;
aspect-ratio: 1/1;
}
@media screen and (max-width: 1024px) {
.podcast-form {
flex-wrap: wrap;
}
.podcast-form__btn {
width: 100%;
}
}
@media screen and (max-width: 528px) {
.podcasts {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<h1>podcaster</h1>
<form method="POST" action="/podcasts" class="podcast-form">
<input
type="text"
inputmode="url"
placeholder="rss feed"
name="feed"
class="podcast-form__input"
/>
<button type="submit" class="podcast-form__btn">Add podcast</button>
</form>
<div class="podcasts">
{{range .Podcasts}}
<a href="/podcasts/{{.ID}}" class="podcast">
<img src="{{.Image}}" alt="" class="podcast__cover" />
<span>{{.Name}}</span>
</a>
{{end}}
</div>
</body>
</html>

View File

@@ -1,74 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Podcast.Name}}</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
max-width: 1440px;
width: 100%;
margin: 0 auto;
padding: 8px;
}
.header {
margin-top: 12px;
display: flex;
gap: 8px;
}
.header img {
width: 300px;
aspect-ration: 1/1;
}
.header .info {
display: flex;
flex-direction: column;
gap: 4px;
}
.episodes {
margin-top: 24px;
padding: 10px;
border-top: 1px solid;
}
.episode {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
</head>
<body>
<h1><a href="/">podcaster</a></h1>
<div class="header">
<img src="{{.Podcast.Image}}" alt="" />
<div class="info">
<h2>{{.Podcast.Name}}</h2>
<p>{{.Podcast.Description}}</p>
</div>
</div>
<div class="episodes">
{{range .Episodes}}
<div class="episode">
<span>{{.Title}}</span>
<span>Added {{.CreatedAt.Format "Mon, Jan 2, 2006 15:04"}}</span>
</div>
{{end}}
</div>
</body>
</html>

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; 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();
},
});
};

View File

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

View File

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

View File

@@ -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>Added {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>

View File

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

View File

@@ -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,14 +63,17 @@ 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;
} }
return ( return (
<div className="fixed bottom-0 left-0 right-0 z-50 max-w-[1440px] w-full mx-auto"> <div className="fixed bottom-0 left-0 right-0 z-50">
<div className="bg-white py-2 px-4 flex items-center gap-4"> <div className="max-w-[1440px] w-full mx-auto">
<div className="bg-white py-2 px-4 flex flex-col">
<div className="w-full flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{status === "playing" ? ( {status === "playing" ? (
<button onClick={() => setStatus("paused")}> <button onClick={() => setStatus("paused")}>
@@ -115,14 +119,31 @@ export const Player = () => {
className="bg-red-500 h-full" className="bg-red-500 h-full"
></div> ></div>
</div> </div>
<p className="min-w-[50px] text-right">{formatTime(currentTime)}</p> <button
className="min-w-[70px] text-right cursor-pointer"
onClick={() => setShowTimeLeft((prev) => !prev)}
>
{showTimeLeft ? "-" : ""}
{formatTime(showTimeLeft ? timeLeft : currentTime)}
</button>
</div>
<h3 className="text-base font-semibold mt-2">{episode?.title}</h3>
<p className="opacity-50 mt-1">{formatTime(duration)}</p>
</div>
</div> </div>
</div> </div>
); );
}; };
const formatTime = (seconds: number): string => { const formatTime = (time: number): string => {
const m = Math.floor(seconds / 60); let hours = Math.floor(time / 3600);
const s = Math.floor(seconds % 60); let minutes = Math.floor((time % 3600) / 60);
return `${m}:${String(s).padStart(2, "0")}`; let seconds = Math.floor(time % 60);
const h = String(hours).padStart(2, "0");
const m = String(minutes).padStart(2, "0");
const s = String(seconds).padStart(2, "0");
return [h, m, s].join(":");
}; };

View File

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