diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 10af4de..2fc5044 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -208,6 +208,8 @@ SORTS = { "duration_asc": Video.duration_seconds.asc().nulls_last(), "title": func.lower(Video.title).asc().nulls_last(), "subscribers": Channel.subscriber_count.desc().nulls_last(), + # Your per-channel priority (set in the channel manager), newest first within a tier. + "priority": Subscription.priority.desc(), } @@ -222,10 +224,15 @@ def get_feed( db: Session = Depends(get_db), ) -> dict: query, _status = _filtered_query(db, user, **params) - order = SORTS.get(sort) - if order is None and sort == "shuffle": - order = func.md5(func.concat(Video.id, str(seed))) - query = query.order_by(order if order is not None else SORTS["newest"]) + if sort == "priority": + query = query.order_by( + Subscription.priority.desc(), Video.published_at.desc().nulls_last() + ) + else: + order = SORTS.get(sort) + if order is None and sort == "shuffle": + order = func.md5(func.concat(Video.id, str(seed))) + query = query.order_by(order if order is not None else SORTS["newest"]) rows = db.execute(query.offset(offset).limit(limit + 1)).all() has_more = len(rows) > limit diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 61f811b..4540535 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -108,19 +108,34 @@ export default function Channels({ className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" /> - + + +

+ Set a channel's priority to push its videos up when you sort + by “Channel priority”, attach your own tags to filter the feed, + or hide a channel to drop it from the feed without unsubscribing. +

+ {/* Your tags */}
- Your tags + + + Your tags + + {userTags.map((t) => ( -
- - {c.priority} - -
+ +
+ + {c.priority} + +
+
{c.thumbnail_url ? ( @@ -286,9 +303,17 @@ function ChannelRow({
- + + + ); } diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 0959912..a401812 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -60,7 +60,7 @@ export default function SettingsPanel({ onClick={close} />
- {/* Wrapping pill tabs — no horizontal scrollbar, prominent active state. */} -
- {TABS.map((t) => { - const active = tab === t.id; - return ( - + ); + })} + + + {/* All tabs stacked in one grid cell so the panel sizes to the tallest tab + (stable height, no jump on switch); the active one is shown on top. */} +
+ {TABS.map((t) => ( +
setTab(t.id)} - className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition ${ - active - ? "bg-accent text-accent-fg shadow-md shadow-accent/20 font-medium" - : "text-muted hover:text-fg hover:bg-card/60" + className={`[grid-area:1/1] p-4 ${ + tab === t.id ? "" : "invisible pointer-events-none" }`} > - - {t.label} - - ); - })} -
- -
- {tab === "appearance" && ( - - )} - {tab === "notifications" && } - {tab === "sync" && } - {tab === "account" && } + {t.id === "appearance" && ( + + )} + {t.id === "notifications" && } + {t.id === "sync" && } + {t.id === "account" && } +
+ ))} +
@@ -294,7 +307,7 @@ function Notifications() { level: "info", title: "Test notification", message: "This is what a notification looks like.", - requiresInteraction: true, + sound: true, }) } className="mt-2 text-sm text-accent hover:underline" diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 8f01561..af58adb 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -41,6 +41,7 @@ const SORTS = [ { id: "duration_asc", label: "Shortest" }, { id: "title", label: "Name (A–Z)" }, { id: "subscribers", label: "Channel subscribers" }, + { id: "priority", label: "Channel priority" }, { id: "shuffle", label: "Surprise me" }, ]; diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx index dfc3b75..3c77373 100644 --- a/frontend/src/components/Tooltip.tsx +++ b/frontend/src/components/Tooltip.tsx @@ -1,10 +1,13 @@ -import { useSyncExternalStore } from "react"; +import { useRef, useState, useSyncExternalStore } from "react"; +import { createPortal } from "react-dom"; import { hintsEnabled, subscribeHints } from "../lib/hints"; -type Side = "top" | "bottom" | "left"; +type Side = "top" | "bottom"; +type Coords = { left: number; top: number; placement: Side }; /** Wrap any element to show a short glass hint caption on hover — but only while the - * app-wide hints toggle (Settings → Appearance) is on. */ + * app-wide hints toggle (Settings → Appearance) is on. Rendered in a portal with + * fixed positioning so it is never clipped by an overflow/stacking ancestor. */ export default function Tooltip({ hint, side = "top", @@ -15,24 +18,53 @@ export default function Tooltip({ children: React.ReactNode; }) { const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); + const ref = useRef(null); + const [coords, setCoords] = useState(null); + + function show() { + const el = ref.current; + if (!el) return; + const r = el.getBoundingClientRect(); + // Prefer the requested side; flip to bottom if there's no room above. + const placement: Side = side === "bottom" || r.top < 90 ? "bottom" : "top"; + setCoords({ + left: Math.min(Math.max(r.left + r.width / 2, 92), window.innerWidth - 92), + top: placement === "top" ? r.top - 8 : r.bottom + 8, + placement, + }); + } + function hide() { + setCoords(null); + } + if (!enabled || !hint) return <>{children}; - const place = - side === "bottom" - ? "top-full mt-2 left-1/2 -translate-x-1/2" - : side === "left" - ? "right-full mr-2 top-1/2 -translate-y-1/2" - : "bottom-full mb-2 left-1/2 -translate-x-1/2"; - return ( - + {children} - - {hint} - + {coords && + createPortal( +
+ {hint} +
, + document.body + )}
); } diff --git a/frontend/src/index.css b/frontend/src/index.css index e831bde..c26a98f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -32,19 +32,21 @@ body { /* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */ .glass { - background: color-mix(in srgb, var(--surface) 72%, transparent); - backdrop-filter: blur(18px) saturate(1.6); - -webkit-backdrop-filter: blur(18px) saturate(1.6); - border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); + /* Mostly opaque so overlay menus/panels stay readable over arbitrary content; + the blur + slight translucency keep the glassy feel. */ + background: color-mix(in srgb, var(--surface) 90%, transparent); + backdrop-filter: blur(24px) saturate(1.7); + -webkit-backdrop-filter: blur(24px) saturate(1.7); + border: 1px solid color-mix(in srgb, var(--border) 75%, transparent); box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent), - 0 18px 40px -16px rgba(0, 0, 0, 0.55); + inset 0 1px 0 color-mix(in srgb, #fff 15%, transparent), + 0 18px 44px -16px rgba(0, 0, 0, 0.6); } .glass-card { - background: color-mix(in srgb, var(--card) 58%, transparent); - backdrop-filter: blur(10px) saturate(1.3); - -webkit-backdrop-filter: blur(10px) saturate(1.3); - border: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + background: color-mix(in srgb, var(--card) 78%, transparent); + backdrop-filter: blur(12px) saturate(1.3); + -webkit-backdrop-filter: blur(12px) saturate(1.3); + border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 9%, transparent), 0 8px 22px -14px rgba(0, 0, 0, 0.45); diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 09aa4b8..f131d39 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -41,6 +41,7 @@ export interface NotifyInput { action?: NotifAction; meta?: NotifMeta; requiresInteraction?: boolean; + sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast } const HISTORY_KEY = "subfeed.notifications"; @@ -177,7 +178,7 @@ export function notify(input: NotifyInput): number { }, ]; emit(); - if (config.sound && (requiresInteraction || level === "error" || level === "fatal")) { + if (config.sound && (input.sound || requiresInteraction || level === "error" || level === "fatal")) { beep(); } if (duration) setTimeout(() => dismiss(id), duration);