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"
/>
- syncSubs.mutate()}
- disabled={syncSubs.isPending}
- className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm hover:border-accent disabled:opacity-50 transition"
+
-
- Sync subscriptions
-
+ syncSubs.mutate()}
+ disabled={syncSubs.isPending}
+ className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
+ >
+
+ Sync subscriptions
+
+
+
+ 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) => (
-
-
onPriority(1)} className="text-muted hover:text-accent" title="Raise priority">
-
-
-
{c.priority}
-
onPriority(-1)} className="text-muted hover:text-accent" title="Lower priority">
-
-
-
+
+
+
onPriority(1)} className="text-muted hover:text-accent" aria-label="Raise priority">
+
+
+
{c.priority}
+
onPriority(-1)} className="text-muted hover:text-accent" aria-label="Lower priority">
+
+
+
+
{c.thumbnail_url ? (
@@ -286,9 +303,17 @@ function ChannelRow({
-
- {c.hidden ? : }
-
+
+
+ {c.hidden ? : }
+
+
);
}
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 (
-
+ {/* Vertical tab rail — never wraps; active is a clear accent pill. */}
+
+ {TABS.map((t) => {
+ const active = tab === t.id;
+ return (
+ setTab(t.id)}
+ className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
+ active
+ ? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
+ : "text-muted hover:text-fg hover:bg-card/60"
+ }`}
+ >
+
+ {t.label}
+
+ );
+ })}
+
+
+ {/* 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);