Merge: promote dev to prod
This commit is contained in:
commit
6d2042d8ae
11 changed files with 231 additions and 112 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.4.0
|
||||
0.4.1
|
||||
|
|
|
|||
|
|
@ -275,10 +275,13 @@ SORTS = {
|
|||
"newest": Video.published_at.desc().nulls_last(),
|
||||
"oldest": Video.published_at.asc().nulls_last(),
|
||||
"views": Video.view_count.desc().nulls_last(),
|
||||
"views_asc": Video.view_count.asc().nulls_last(),
|
||||
"duration_desc": Video.duration_seconds.desc().nulls_last(),
|
||||
"duration_asc": Video.duration_seconds.asc().nulls_last(),
|
||||
"title": func.lower(Video.title).asc().nulls_last(),
|
||||
"title_desc": func.lower(Video.title).desc().nulls_last(),
|
||||
"subscribers": Channel.subscriber_count.desc().nulls_last(),
|
||||
"subscribers_asc": Channel.subscriber_count.asc().nulls_last(),
|
||||
# Your per-channel priority (set in the channel manager), newest first within a tier.
|
||||
# coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row.
|
||||
"priority": func.coalesce(Subscription.priority, 0).desc(),
|
||||
|
|
@ -296,9 +299,10 @@ def get_feed(
|
|||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
query, _status = _filtered_query(db, user, **params)
|
||||
if sort == "priority":
|
||||
if sort in ("priority", "priority_asc"):
|
||||
prio = func.coalesce(Subscription.priority, 0)
|
||||
query = query.order_by(
|
||||
func.coalesce(Subscription.priority, 0).desc(),
|
||||
prio.asc() if sort == "priority_asc" else prio.desc(),
|
||||
Video.published_at.desc().nulls_last(),
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowDown, ArrowUp, RefreshCw } from "lucide-react";
|
||||
import { api, type FeedFilters, type Video } from "../lib/api";
|
||||
import i18n from "../i18n";
|
||||
import { notify } from "../lib/notifications";
|
||||
|
|
@ -9,6 +10,43 @@ import PlayerModal from "./PlayerModal";
|
|||
|
||||
const PAGE = 60;
|
||||
|
||||
// The "Show" view filter, content-type filter and ordering live in the feed toolbar
|
||||
// (above the cards), not the filter sidebar.
|
||||
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
|
||||
const CONTENT = [
|
||||
{ key: "includeNormal", label: "sidebar.content.normal" },
|
||||
{ key: "includeShorts", label: "sidebar.content.shorts" },
|
||||
{ key: "includeLive", label: "sidebar.content.live" },
|
||||
] as const;
|
||||
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
|
||||
|
||||
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
|
||||
// (which encode both). One entry per concept; a single arrow flips the direction.
|
||||
type SortKey = "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle";
|
||||
const SORT_KEYS: SortKey[] = ["date", "popular", "duration", "title", "subscribers", "priority", "shuffle"];
|
||||
const SORT_MAP: Record<Exclude<SortKey, "shuffle">, { asc: string; desc: string }> = {
|
||||
date: { desc: "newest", asc: "oldest" },
|
||||
popular: { desc: "views", asc: "views_asc" },
|
||||
duration: { desc: "duration_desc", asc: "duration_asc" },
|
||||
title: { asc: "title", desc: "title_desc" },
|
||||
subscribers: { desc: "subscribers", asc: "subscribers_asc" },
|
||||
priority: { desc: "priority", asc: "priority_asc" },
|
||||
};
|
||||
const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle">, "asc" | "desc"> = {
|
||||
date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc",
|
||||
};
|
||||
function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
|
||||
if (s === "shuffle") return { key: "shuffle", dir: "desc" };
|
||||
for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) {
|
||||
if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" };
|
||||
if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" };
|
||||
}
|
||||
return { key: "date", dir: "desc" };
|
||||
}
|
||||
function buildSort(key: SortKey, dir: "asc" | "desc"): string {
|
||||
return key === "shuffle" ? "shuffle" : SORT_MAP[key][dir];
|
||||
}
|
||||
|
||||
function matchesView(status: string, show: string): boolean {
|
||||
switch (show) {
|
||||
case "hidden":
|
||||
|
|
@ -166,10 +204,9 @@ export default function Feed({
|
|||
|
||||
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
|
||||
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
|
||||
if (items.length === 0) {
|
||||
// No YouTube connection and looking at their own (empty) subscriptions: offer both
|
||||
// connecting YouTube and just browsing the shared library (no read scope needed).
|
||||
if (!canRead && filters.scope === "my")
|
||||
// Onboarding empty state (no YouTube + own subscriptions): a dedicated full-page prompt,
|
||||
// with no toolbar (content-type/sort would be meaningless here).
|
||||
if (items.length === 0 && !canRead && filters.scope === "my")
|
||||
return (
|
||||
<div className="p-8 grid place-items-center text-center">
|
||||
<div className="max-w-sm">
|
||||
|
|
@ -190,20 +227,107 @@ export default function Feed({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="p-8 text-muted">{t("feed.noMatches")}</div>;
|
||||
}
|
||||
|
||||
const { key: sortKey, dir: sortDir } = parseSort(filters.sort);
|
||||
|
||||
const toolbar = (
|
||||
<div className="pb-3">
|
||||
<div className="flex items-center gap-1.5 flex-wrap mb-2.5">
|
||||
{SHOW_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setFilters({ ...filters, show: id })}
|
||||
aria-pressed={filters.show === id}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition ${
|
||||
filters.show === id
|
||||
? "bg-accent text-accent-fg border-accent"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{t("sidebar.show." + id)}
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
{CONTENT.map((c) => {
|
||||
const on = filters[c.key];
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="pb-3 text-sm text-muted">
|
||||
<button
|
||||
key={c.key}
|
||||
onClick={() => setFilters({ ...filters, [c.key]: !on })}
|
||||
aria-pressed={on}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition ${
|
||||
on
|
||||
? "bg-accent text-accent-fg border-accent"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{t(c.label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-t border-border pt-2.5">
|
||||
<span className="text-sm text-muted">
|
||||
{countQuery.data
|
||||
? t("feed.videoCount", {
|
||||
count: countQuery.data.count,
|
||||
formattedCount: countQuery.data.count.toLocaleString(),
|
||||
})
|
||||
: " "}
|
||||
: " "}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={(e) => {
|
||||
const key = e.target.value as SortKey;
|
||||
const dir = key === "shuffle" ? "desc" : SORT_DEFAULT_DIR[key];
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: buildSort(key, dir),
|
||||
seed: key === "shuffle" ? rollSeed() : undefined,
|
||||
});
|
||||
}}
|
||||
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||
>
|
||||
{SORT_KEYS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{t("feed.sortKey." + k)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{sortKey !== "shuffle" && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setFilters({ ...filters, sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc") })
|
||||
}
|
||||
title={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
|
||||
aria-label={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
{sortDir === "asc" ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
{sortKey === "shuffle" && (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||
title={t("sidebar.reshuffle")}
|
||||
aria-label={t("sidebar.reshuffle")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{view === "grid" ? (
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
{toolbar}
|
||||
{items.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted">{t("feed.noMatches")}</div>
|
||||
) : view === "grid" ? (
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
|
||||
{items.map((v) => (
|
||||
<VideoCard
|
||||
|
|
|
|||
|
|
@ -201,8 +201,17 @@ export default function PlayerModal({
|
|||
onClose: () => void;
|
||||
onState: (id: string, status: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
|
||||
const fullDate = (d: string | null | undefined) =>
|
||||
d
|
||||
? new Date(d).toLocaleDateString(i18n.language, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "";
|
||||
// Track the playing item by id (not a frozen index) so it survives the queue changing
|
||||
// under us — e.g. an item removed in another tab. The index is derived from the *live*
|
||||
// queue, so the N / M counter and prev/next neighbours stay correct without disrupting
|
||||
|
|
@ -550,7 +559,10 @@ export default function PlayerModal({
|
|||
{active.view_count != null && (
|
||||
<span>· {t("player.views", { count: active.view_count, formattedCount: formatViews(active.view_count) })}</span>
|
||||
)}
|
||||
<span>· {relativeTime(active.published_at)}</span>
|
||||
<span>
|
||||
· {relativeTime(active.published_at)}
|
||||
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
||||
</span>
|
||||
{active.duration_seconds != null && (
|
||||
<span>· {formatDuration(active.duration_seconds)}</span>
|
||||
)}
|
||||
|
|
@ -566,7 +578,11 @@ export default function PlayerModal({
|
|||
{detail.data?.view_count != null && (
|
||||
<span>· {t("player.views", { count: detail.data.view_count, formattedCount: formatViews(detail.data.view_count) })}</span>
|
||||
)}
|
||||
{detail.data?.published_at && <span>· {relativeTime(detail.data.published_at)}</span>}
|
||||
{detail.data?.published_at && (
|
||||
<span>
|
||||
· {relativeTime(detail.data.published_at)} · {fullDate(detail.data.published_at)}
|
||||
</span>
|
||||
)}
|
||||
{detail.data?.duration_seconds != null && (
|
||||
<span>· {formatDuration(detail.data.duration_seconds)}</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -214,10 +214,7 @@ export default function Sidebar({
|
|||
}
|
||||
|
||||
const available: Record<WidgetId, boolean> = {
|
||||
show: true,
|
||||
sort: true,
|
||||
date: true,
|
||||
content: true,
|
||||
language: languages.length > 0,
|
||||
topic: topics.length > 0,
|
||||
};
|
||||
|
|
@ -239,59 +236,6 @@ export default function Sidebar({
|
|||
|
||||
function widgetBody(id: WidgetId): React.ReactNode {
|
||||
switch (id) {
|
||||
case "show":
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{SHOW_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setFilters({ ...filters, show: id })}
|
||||
className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
|
||||
filters.show === id
|
||||
? "bg-accent text-accent-fg border-accent"
|
||||
: "bg-card border-border hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{t("sidebar.show." + id)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "sort":
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
value={filters.sort}
|
||||
onChange={(e) => {
|
||||
const sort = e.target.value;
|
||||
// Seed shuffle on selection so it lands on a fresh order, not the
|
||||
// deterministic seed-0 one every time.
|
||||
setFilters({
|
||||
...filters,
|
||||
sort,
|
||||
seed: sort === "shuffle" ? rollSeed() : undefined,
|
||||
});
|
||||
}}
|
||||
className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||
>
|
||||
{SORT_IDS.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{t("sidebar.sort." + id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{filters.sort === "shuffle" && (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||
title={t("sidebar.reshuffle")}
|
||||
aria-label={t("sidebar.reshuffle")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg shadow-sm hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "date":
|
||||
return (
|
||||
<>
|
||||
|
|
@ -381,26 +325,6 @@ export default function Sidebar({
|
|||
)}
|
||||
</>
|
||||
);
|
||||
case "content":
|
||||
return (
|
||||
<>
|
||||
<Toggle
|
||||
label={t("sidebar.content.normal")}
|
||||
checked={filters.includeNormal}
|
||||
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("sidebar.content.shorts")}
|
||||
checked={filters.includeShorts}
|
||||
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("sidebar.content.live")}
|
||||
checked={filters.includeLive}
|
||||
onChange={(v) => setFilters({ ...filters, includeLive: v })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "language": {
|
||||
const chips = visibleChips(languages);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -227,16 +227,29 @@ function VideoCard({
|
|||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const watched = video.status === "watched";
|
||||
const meta = (
|
||||
<>
|
||||
{video.view_count != null && (
|
||||
<>
|
||||
{formatViews(video.view_count)} {t("card.views")} ·{" "}
|
||||
<span title={`${video.view_count.toLocaleString()} ${t("card.views")}`}>
|
||||
{formatViews(video.view_count)} {t("card.views")}
|
||||
</span>{" "}
|
||||
·{" "}
|
||||
</>
|
||||
)}
|
||||
{relativeTime(video.published_at)}
|
||||
{video.published_at && (
|
||||
<>
|
||||
{" · "}
|
||||
{new Date(video.published_at).toLocaleDateString(i18n.language, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const title = (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@
|
|||
"noMatches": "Keine Videos entsprechen diesen Filtern.",
|
||||
"videoCount_one": "{{formattedCount}} Video",
|
||||
"videoCount_other": "{{formattedCount}} Videos",
|
||||
"sortLabel": "Sortierung",
|
||||
"dirAsc": "Aufsteigend",
|
||||
"dirDesc": "Absteigend",
|
||||
"sortKey": {
|
||||
"date": "Datum",
|
||||
"popular": "Beliebt",
|
||||
"duration": "Dauer",
|
||||
"title": "Name",
|
||||
"subscribers": "Kanal-Abonnenten",
|
||||
"priority": "Kanal-Priorität",
|
||||
"shuffle": "Überraschung"
|
||||
},
|
||||
"loadingMore": "Mehr wird geladen…",
|
||||
"hiddenNamed": "Ausgeblendet: „{{title}}”",
|
||||
"hidden": "Video ausgeblendet",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@
|
|||
"noMatches": "No videos match these filters.",
|
||||
"videoCount_one": "{{formattedCount}} video",
|
||||
"videoCount_other": "{{formattedCount}} videos",
|
||||
"sortLabel": "Sort",
|
||||
"dirAsc": "Ascending",
|
||||
"dirDesc": "Descending",
|
||||
"sortKey": {
|
||||
"date": "Date",
|
||||
"popular": "Popular",
|
||||
"duration": "Duration",
|
||||
"title": "Name",
|
||||
"subscribers": "Channel subscribers",
|
||||
"priority": "Channel priority",
|
||||
"shuffle": "Surprise me"
|
||||
},
|
||||
"loadingMore": "Loading more…",
|
||||
"hiddenNamed": "Hidden “{{title}}”",
|
||||
"hidden": "Video hidden",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@
|
|||
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
|
||||
"videoCount_one": "{{formattedCount}} videó",
|
||||
"videoCount_other": "{{formattedCount}} videó",
|
||||
"sortLabel": "Rendezés",
|
||||
"dirAsc": "Növekvő",
|
||||
"dirDesc": "Csökkenő",
|
||||
"sortKey": {
|
||||
"date": "Dátum",
|
||||
"popular": "Népszerű",
|
||||
"duration": "Hossz",
|
||||
"title": "Név",
|
||||
"subscribers": "Csatorna feliratkozók",
|
||||
"priority": "Csatorna prioritás",
|
||||
"shuffle": "Meglepetés"
|
||||
},
|
||||
"loadingMore": "Továbbiak betöltése…",
|
||||
"hiddenNamed": "Elrejtve: „{{title}}”",
|
||||
"hidden": "Videó elrejtve",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ export interface ReleaseEntry {
|
|||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.4.1",
|
||||
date: "2026-06-16",
|
||||
summary: "Feed controls above the videos, and clearer view counts and dates.",
|
||||
features: [
|
||||
"The view filter (Unwatched / In progress / All / …) and the content type (Normal / Shorts / Live) are now quick chips right above the videos.",
|
||||
"Cleaner ordering: pick what to sort by, then flip ascending/descending with one arrow (and 'Most viewed' is now 'Popular').",
|
||||
"Video cards and the player show the exact upload date next to '… ago', and the full view count on hover.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.4.0",
|
||||
date: "2026-06-16",
|
||||
|
|
|
|||
|
|
@ -2,22 +2,14 @@
|
|||
// collapsed, and which are hidden. Persisted to localStorage and the server-side
|
||||
// `preferences` blob so it follows the account.
|
||||
|
||||
export type WidgetId = "show" | "sort" | "date" | "content" | "language" | "topic";
|
||||
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
|
||||
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
|
||||
export type WidgetId = "date" | "language" | "topic";
|
||||
|
||||
export const ALL_WIDGETS: WidgetId[] = [
|
||||
"show",
|
||||
"sort",
|
||||
"date",
|
||||
"content",
|
||||
"language",
|
||||
"topic",
|
||||
];
|
||||
export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic"];
|
||||
|
||||
export const WIDGET_TITLES: Record<WidgetId, string> = {
|
||||
show: "Show",
|
||||
sort: "Sort",
|
||||
date: "Upload date",
|
||||
content: "Content type",
|
||||
language: "Language",
|
||||
topic: "Topic",
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue