Compare commits

...

5 Commits

Author SHA1 Message Date
8e226e9ea1 update podcast page 2026-03-12 13:25:42 +03:00
2c84d3d818 sort episodes by pubdate 2026-03-12 13:20:25 +03:00
c7dc944264 show episode title & duration in player 2026-03-12 12:52:30 +03:00
286a01c4d6 delete old views ui 2026-03-12 12:42:47 +03:00
3d92096bb8 fix player time 2026-03-12 12:42:29 +03:00
5 changed files with 105 additions and 222 deletions

40
main.go
View File

@@ -11,6 +11,7 @@ import (
"os"
"path"
"strconv"
"strings"
"time"
_ "time/tzdata"
@@ -19,6 +20,24 @@ import (
_ "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 {
ID int64 `json:"id" db:"id"`
Name string `json:"name" db:"name"`
@@ -198,8 +217,23 @@ func downloadEpisodeAudioFile(url string) ([]byte, error) {
return data, nil
}
func getPodcastEpisodes(db *sqlx.DB, podcastId int64) ([]*Episode, error) {
rows, err := db.Queryx("SELECT * FROM episodes WHERE podcast_id = ?", podcastId)
func getPodcastEpisodes(db *sqlx.DB, podcastId int64, orderBy string, orderType OrderType) ([]*Episode, error) {
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 {
return nil, fmt.Errorf("failed to query db: %v", err)
}
@@ -425,7 +459,7 @@ func main() {
return
}
episodes, err := getPodcastEpisodes(db, int64(id))
episodes, err := getPodcastEpisodes(db, int64(id), "pubdate", OrderTypeDesc)
if err != nil {
http.Error(w, err.Error(), 500)
return

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>

View File

@@ -52,7 +52,7 @@ export const PodcastPage = () => {
)}
<span>{episode.title}</span>
</div>
<span>Added {new Date(episode.createdAt).toLocaleString()}</span>
<span>{new Date(episode.createdAt).toLocaleString()}</span>
</div>
))}
</div>

View File

@@ -68,61 +68,78 @@ export const Player = () => {
}
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;
<div className="fixed bottom-0 left-0 right-0 z-50">
<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">
{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>
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={`${Math.floor(duration / 3600) > 0 ? "min-w-[70px]" : "min-w-[50px]"} text-right`}
>
{formatTime(currentTime)}
</p>
</div>
<h3 className="text-base font-semibold mt-2">{episode?.title}</h3>
<p className="opacity-50 mt-1">{formatTime(duration)}</p>
</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")}`;
const formatTime = (time: number): string => {
let hours = Math.floor(time / 3600);
let minutes = Math.floor((time % 3600) / 60);
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(":");
};