@@ -90,37 +99,16 @@ export function ChannelPageContent({ sourceUrl, sort, searchQuery, tab, onNaviga
return (
{meta && (
-
- {meta.bannerUrl && (
-
- )}
-
-
-
-
-
- {meta.name}
- {meta.isVerified && }
-
-
- {formatViews(meta.subscriberCount)} subscribers
-
-
-
-
- {subscribed ? "Subscribed" : "Subscribe"}
-
-
-
+
)}
{tab === "videos" && (
diff --git a/apps/web/src/components/channel-page-header.tsx b/apps/web/src/components/channel-page-header.tsx
new file mode 100644
index 0000000..b367cd3
--- /dev/null
+++ b/apps/web/src/components/channel-page-header.tsx
@@ -0,0 +1,59 @@
+import { formatViews } from "../lib/format";
+import { AllowChannelButton } from "./allow-channel-button";
+import { ChannelAvatar } from "./channel-avatar";
+import { VerifiedBadgeIcon } from "./watch-icons";
+
+type Props = {
+ sourceUrl: string;
+ name: string;
+ avatarUrl: string;
+ bannerUrl: string;
+ subscriberCount: number;
+ isVerified: boolean;
+ subscribed: boolean;
+ onSubscribe: () => void;
+};
+
+export function ChannelPageHeader({
+ sourceUrl,
+ name,
+ avatarUrl,
+ bannerUrl,
+ subscriberCount,
+ isVerified,
+ subscribed,
+ onSubscribe,
+}: Props) {
+ return (
+
+ {bannerUrl &&
}
+
+
+
+
+
+ {name}
+ {isVerified && }
+
+
{formatViews(subscriberCount)} subscribers
+
+
+
+
+
+ {subscribed ? "Subscribed" : "Subscribe"}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/channel-route-link.tsx b/apps/web/src/components/channel-route-link.tsx
index 1dc4417..e490ba7 100644
--- a/apps/web/src/components/channel-route-link.tsx
+++ b/apps/web/src/components/channel-route-link.tsx
@@ -1,6 +1,6 @@
import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react";
-import type { ChannelSort } from "../lib/api";
+import type { ChannelSort } from "../lib/api-discovery";
import {
channelLegacySearch,
channelPathSearch,
diff --git a/apps/web/src/components/family-list-empty-state.tsx b/apps/web/src/components/family-list-empty-state.tsx
new file mode 100644
index 0000000..a5e4bb8
--- /dev/null
+++ b/apps/web/src/components/family-list-empty-state.tsx
@@ -0,0 +1,39 @@
+import { Link } from "@tanstack/react-router";
+import { useAuth } from "../hooks/use-auth";
+
+type Props = {
+ title?: string;
+ description?: string;
+ showSettingsAction?: boolean;
+};
+
+export function FamilyListEmptyState({
+ title = "Nothing from the family list yet",
+ description = "Add trusted channels so this page can stay focused on videos you picked for your family.",
+ showSettingsAction = true,
+}: Props) {
+ const { canGlobalBlock } = useAuth();
+ const showAdminAction = showSettingsAction && canGlobalBlock;
+ return (
+
+
+
+ Family list
+
+
+
{title}
+
{description}
+
+ {showAdminAction && (
+
+ Open allow list
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/home-recommendations-section.tsx b/apps/web/src/components/home-recommendations-section.tsx
index 8d327d0..7554c90 100644
--- a/apps/web/src/components/home-recommendations-section.tsx
+++ b/apps/web/src/components/home-recommendations-section.tsx
@@ -1,6 +1,8 @@
import { useMemo } from "react";
import { useBlockedFilter } from "../hooks/use-blocked-filter";
import { useHomeRecommendations } from "../hooks/use-home-recommendations";
+import { useSettings } from "../hooks/use-settings";
+import { FamilyListEmptyState } from "./family-list-empty-state";
import { HomeFallbackSection } from "./home-fallback-section";
import { ScrollSentinel } from "./scroll-sentinel";
import { VideoGrid } from "./video-grid";
@@ -9,11 +11,17 @@ import { VideoGridSkeleton } from "./video-grid-skeleton";
export function HomeRecommendationsSection() {
const { streams, isLoading, isError, hasNextPage, isFetchingNextPage, fetchNextPage } =
useHomeRecommendations();
+ const { settings } = useSettings();
const { filter } = useBlockedFilter();
const filtered = useMemo(() => filter(streams), [filter, streams]);
if (isLoading) return
;
- if (isError || filtered.length === 0) return
;
+ if (isError || filtered.length === 0) {
+ if (settings.accessMode === "allow_list") {
+ return
;
+ }
+ return
;
+ }
return (
<>
diff --git a/apps/web/src/components/navbar-account-controls.tsx b/apps/web/src/components/navbar-account-controls.tsx
index 4aa09d9..e705ac0 100644
--- a/apps/web/src/components/navbar-account-controls.tsx
+++ b/apps/web/src/components/navbar-account-controls.tsx
@@ -1,6 +1,8 @@
import { Link } from "@tanstack/react-router";
+import { getStoredAdminSection } from "../lib/admin-console-section";
import { logoutSession } from "../lib/auth-session";
import { goto } from "../lib/route-redirect";
+import { getStoredSettingsSection } from "../lib/settings-section";
import type { AuthMe, AuthStatus } from "../types/auth";
import { ProfileAvatar } from "./profile-avatar";
import { ThemeToggleButton } from "./theme-toggle-button";
@@ -140,6 +142,7 @@ export function NavbarAccountControls({
{!isGuest && !isAdmin && (
Account
@@ -148,7 +151,7 @@ export function NavbarAccountControls({
{isAdmin && (
Admin
diff --git a/apps/web/src/components/search-channel-card.tsx b/apps/web/src/components/search-channel-card.tsx
index 46f9423..c76dcd9 100644
--- a/apps/web/src/components/search-channel-card.tsx
+++ b/apps/web/src/components/search-channel-card.tsx
@@ -2,6 +2,7 @@ import { BadgeCheck } from "lucide-react";
import { formatSubscribers } from "../lib/format";
import { proxyImage } from "../lib/proxy";
import type { ChannelResultItem } from "../types/api";
+import { AllowChannelButton } from "./allow-channel-button";
import { ChannelAvatar } from "./channel-avatar";
import { ChannelRouteLink } from "./channel-route-link";
@@ -11,26 +12,33 @@ type Props = {
export function SearchChannelCard({ channel }: Props) {
return (
-
-
-
+
+
+
+
+
+
+ {channel.name}
+ {channel.isVerified && (
+
+ )}
+
+
{formatSubscribers(channel.subscriberCount)}
+
+
+
-
-
- {channel.name}
- {channel.isVerified && (
-
- )}
-
-
{formatSubscribers(channel.subscriberCount)}
-
-
+
);
}
diff --git a/apps/web/src/components/sidebar.tsx b/apps/web/src/components/sidebar.tsx
index 79d81a1..3ce64d6 100644
--- a/apps/web/src/components/sidebar.tsx
+++ b/apps/web/src/components/sidebar.tsx
@@ -3,6 +3,7 @@ import { siBilibili, siNiconico, siYoutube } from "simple-icons";
import { useAuth } from "../hooks/use-auth";
import { useMobile } from "../hooks/use-mobile";
import { useSettings } from "../hooks/use-settings";
+import { getStoredAdminSection } from "../lib/admin-console-section";
import { logoutSession } from "../lib/auth-session";
import { useUiStore } from "../stores/ui-store";
import type { ServiceId } from "../types/user";
@@ -71,7 +72,7 @@ export function Sidebar({ overlay = false }: Props) {
navigate({ to: "/search", search: { q, service: id } });
}
- const adminSearch = { section: "issues" as const };
+ const adminSearch = { section: getStoredAdminSection() };
const navItems = NAV_ITEMS.filter((item) => {
if (item.adminOnly && !isAdmin) return false;
if (item.to === "/shorts" && settings.hideShorts) return false;
@@ -80,14 +81,14 @@ export function Sidebar({ overlay = false }: Props) {
const desktopShell = overlay
? "z-50 border-border border-r bg-app/95 shadow-2xl backdrop-blur"
- : "z-40 bg-app";
+ : "z-40 border-r border-border bg-app";
const desktopMotion = overlay
? collapsed
? "pointer-events-none -translate-x-full"
: "translate-x-0"
: "";
const baseClasses = isMobile
- ? `fixed top-14 left-0 bottom-0 z-50 w-72 max-w-[85vw] bg-app flex flex-col py-4 pb-[calc(env(safe-area-inset-bottom)+1rem)] transition-transform duration-200 ${
+ ? `fixed top-14 left-0 bottom-0 z-50 w-72 max-w-[85vw] border-r border-border bg-app flex flex-col py-4 pb-[calc(env(safe-area-inset-bottom)+1rem)] transition-transform duration-200 ${
mobileOpen ? "translate-x-0" : "-translate-x-full"
}`
: `fixed top-14 left-0 bottom-0 ${desktopShell} ${desktopMotion} flex flex-col py-4 transition-all duration-200 ${
diff --git a/apps/web/src/components/stream-error.tsx b/apps/web/src/components/stream-error.tsx
index 6d2b40a..f7b5851 100644
--- a/apps/web/src/components/stream-error.tsx
+++ b/apps/web/src/components/stream-error.tsx
@@ -1,4 +1,6 @@
import { Link, useRouter } from "@tanstack/react-router";
+import { useAuth } from "../hooks/use-auth";
+import { FAMILY_LIST_BLOCKED_MESSAGE } from "../lib/allow-list-error";
import { parseGeoRestriction } from "../lib/geo-restriction";
import { isMemberOnlyMessage } from "../lib/member-only";
import { FlagIcon } from "./flag-icon";
@@ -12,15 +14,22 @@ type Props = {
export function StreamError({ message, onRetry, youtubeSessionReturnTo }: Props) {
const router = useRouter();
+ const { canGlobalBlock } = useAuth();
const countryCode = parseGeoRestriction(message);
const isMemberOnly = isMemberOnlyMessage(message);
+ const familyListBlocked = message === FAMILY_LIST_BLOCKED_MESSAGE;
+ const imageSrc = familyListBlocked
+ ? "/family-list-blocked.gif"
+ : isMemberOnly
+ ? "/member-only-source.gif"
+ : "/error-cat.gif";
return (
@@ -53,6 +62,15 @@ export function StreamError({ message, onRetry, youtubeSessionReturnTo }: Props)
Connect with YouTube
)}
+ {familyListBlocked && canGlobalBlock && (
+
+ Open allow list
+
+ )}
router.history.back()}
diff --git a/apps/web/src/components/watch-info.tsx b/apps/web/src/components/watch-info.tsx
index 3946219..68e3db1 100644
--- a/apps/web/src/components/watch-info.tsx
+++ b/apps/web/src/components/watch-info.tsx
@@ -3,6 +3,7 @@ import { useClientLocale } from "../hooks/use-client-locale";
import { useSubscriptions } from "../hooks/use-subscriptions";
import { formatPublishedDate, formatSubscribers, formatViews } from "../lib/format";
import type { VideoStream } from "../types/stream";
+import { AllowChannelButton } from "./allow-channel-button";
import { ChannelAvatar } from "./channel-avatar";
import { ChannelRouteLink } from "./channel-route-link";
import { Toast } from "./toast";
@@ -103,19 +104,27 @@ export function WatchInfo({ stream }: Props) {
{stream.channelUrl && (
-
- {subscribed ? "Subscribed" : "Subscribe"}
-
+ <>
+
+
+ {subscribed ? "Subscribed" : "Subscribe"}
+
+ >
)}
diff --git a/apps/web/src/hooks/use-admin-granular-allow-list.ts b/apps/web/src/hooks/use-admin-granular-allow-list.ts
new file mode 100644
index 0000000..bf0934f
--- /dev/null
+++ b/apps/web/src/hooks/use-admin-granular-allow-list.ts
@@ -0,0 +1,96 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ addAdminAllowedPlaylist,
+ addAdminUserAllowedChannel,
+ addAdminUserAllowedPlaylist,
+ fetchAdminAllowedPlaylists,
+ fetchAdminUserAllowList,
+ removeAdminAllowedPlaylist,
+ removeAdminUserAllowedChannel,
+ removeAdminUserAllowedPlaylist,
+ searchAdminUsers,
+ updateAdminUserAccessMode,
+} from "../lib/api-admin-allow-list";
+import type { AllowPlaylistInput } from "../types/allow-list";
+import type { ChannelResultItem } from "../types/api";
+import type { AccessMode } from "../types/user";
+
+const ADMIN_ALLOWED_PLAYLISTS_KEY = ["admin-allowed-playlists"];
+const adminUserAllowListKey = (id: string) => ["admin-user-allow-list", id];
+
+export function useAdminAllowedPlaylists(enabled: boolean) {
+ return useQuery({
+ queryKey: ADMIN_ALLOWED_PLAYLISTS_KEY,
+ queryFn: fetchAdminAllowedPlaylists,
+ enabled,
+ });
+}
+
+export function useAdminUserSearch(query: string, enabled: boolean) {
+ const q = query.trim();
+ return useQuery({
+ queryKey: ["admin-users-search", q],
+ queryFn: () => searchAdminUsers(q, 20),
+ enabled: enabled && q.length >= 2,
+ staleTime: 30 * 1000,
+ });
+}
+
+export function useAdminUserAllowList(userId: string | null) {
+ return useQuery({
+ queryKey: userId ? adminUserAllowListKey(userId) : ["admin-user-allow-list", "none"],
+ queryFn: () => fetchAdminUserAllowList(userId ?? ""),
+ enabled: Boolean(userId),
+ });
+}
+
+export function useAdminAllowListMutations(selectedUserId: string | null) {
+ const qc = useQueryClient();
+ const refreshUser = () => {
+ if (selectedUserId) qc.invalidateQueries({ queryKey: adminUserAllowListKey(selectedUserId) });
+ };
+ const refreshGlobalPlaylists = () =>
+ qc.invalidateQueries({ queryKey: ADMIN_ALLOWED_PLAYLISTS_KEY });
+ const userMode = useMutation({
+ mutationFn: ({ id, accessMode }: { id: string; accessMode: AccessMode }) =>
+ updateAdminUserAccessMode(id, accessMode),
+ onSuccess: refreshUser,
+ });
+ const addUserChannel = useMutation({
+ mutationFn: ({ id, channel }: { id: string; channel: ChannelResultItem }) =>
+ addAdminUserAllowedChannel(id, channel.url, channel.name, channel.thumbnailUrl),
+ onSuccess: refreshUser,
+ });
+ const removeUserChannel = useMutation({
+ mutationFn: ({ id, url }: { id: string; url: string }) =>
+ removeAdminUserAllowedChannel(id, url),
+ onSuccess: refreshUser,
+ });
+ const addGlobalPlaylist = useMutation({
+ mutationFn: addAdminAllowedPlaylist,
+ onSuccess: refreshGlobalPlaylists,
+ });
+ const removeGlobalPlaylist = useMutation({
+ mutationFn: removeAdminAllowedPlaylist,
+ onSuccess: refreshGlobalPlaylists,
+ });
+ const addUserPlaylist = useMutation({
+ mutationFn: ({ id, playlist }: { id: string; playlist: AllowPlaylistInput }) =>
+ addAdminUserAllowedPlaylist(id, playlist),
+ onSuccess: refreshUser,
+ });
+ const removeUserPlaylist = useMutation({
+ mutationFn: ({ id, url }: { id: string; url: string }) =>
+ removeAdminUserAllowedPlaylist(id, url),
+ onSuccess: refreshUser,
+ });
+ return {
+ userMode,
+ addUserChannel,
+ removeUserChannel,
+ addGlobalPlaylist,
+ removeGlobalPlaylist,
+ addUserPlaylist,
+ removeUserPlaylist,
+ };
+}
diff --git a/apps/web/src/hooks/use-allowed-channels.ts b/apps/web/src/hooks/use-allowed-channels.ts
new file mode 100644
index 0000000..5fad7b8
--- /dev/null
+++ b/apps/web/src/hooks/use-allowed-channels.ts
@@ -0,0 +1,33 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { allowChannel, disallowChannel, fetchAllowedChannels } from "../lib/api-collections";
+import { useAuth } from "./use-auth";
+
+const ALLOWED_CHANNELS_KEY = ["allowed-channels"];
+
+type AllowChannelArgs = {
+ url: string;
+ name?: string | null;
+ thumbnailUrl?: string | null;
+ global?: boolean;
+};
+
+export function useAllowedChannels() {
+ const qc = useQueryClient();
+ const { authReady, isAuthed } = useAuth();
+ const query = useQuery({
+ queryKey: ALLOWED_CHANNELS_KEY,
+ queryFn: fetchAllowedChannels,
+ enabled: authReady && isAuthed,
+ staleTime: 5 * 60 * 1000,
+ });
+ const add = useMutation({
+ mutationFn: ({ url, name, thumbnailUrl, global }: AllowChannelArgs) =>
+ isAuthed ? allowChannel(url, name, thumbnailUrl, global) : Promise.resolve(null),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ALLOWED_CHANNELS_KEY }),
+ });
+ const remove = useMutation({
+ mutationFn: (url: string) => (isAuthed ? disallowChannel(url) : Promise.resolve()),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ALLOWED_CHANNELS_KEY }),
+ });
+ return { query, add, remove };
+}
diff --git a/apps/web/src/hooks/use-channel.ts b/apps/web/src/hooks/use-channel.ts
index b820071..434ccc1 100644
--- a/apps/web/src/hooks/use-channel.ts
+++ b/apps/web/src/hooks/use-channel.ts
@@ -1,7 +1,6 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
-import type { ChannelSort } from "../lib/api";
-import { fetchChannel } from "../lib/api";
+import { type ChannelSort, fetchChannel } from "../lib/api-discovery";
import { buildChannelRequestUrl } from "../lib/channel-search-url";
import { mapVideoItem } from "../lib/mappers";
import { proxyImage } from "../lib/proxy";
diff --git a/apps/web/src/hooks/use-search-filters.ts b/apps/web/src/hooks/use-search-filters.ts
index c3f1227..5d1acc4 100644
--- a/apps/web/src/hooks/use-search-filters.ts
+++ b/apps/web/src/hooks/use-search-filters.ts
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
-import { fetchSearchFilters } from "../lib/api";
+import { fetchSearchFilters } from "../lib/api-discovery";
export function useSearchFilters(service: number) {
return useQuery({
diff --git a/apps/web/src/hooks/use-search.ts b/apps/web/src/hooks/use-search.ts
index ce94f79..a8c3d99 100644
--- a/apps/web/src/hooks/use-search.ts
+++ b/apps/web/src/hooks/use-search.ts
@@ -1,5 +1,5 @@
import { useInfiniteQuery } from "@tanstack/react-query";
-import { fetchSearch } from "../lib/api";
+import { fetchSearch } from "../lib/api-discovery";
import { mapVideoItem } from "../lib/mappers";
import type { ChannelResultItem } from "../types/api";
import type { PublicPlaylistInfo } from "../types/playlist";
diff --git a/apps/web/src/hooks/use-settings.ts b/apps/web/src/hooks/use-settings.ts
index 568ba7d..bc07ab0 100644
--- a/apps/web/src/hooks/use-settings.ts
+++ b/apps/web/src/hooks/use-settings.ts
@@ -33,6 +33,7 @@ const DEFAULTS: SettingsItem = {
hideRelatedVideos: false,
hideComments: false,
hideShorts: false,
+ accessMode: "unrestricted",
captionStyles: EMPTY_CAPTION_STYLES,
};
diff --git a/apps/web/src/hooks/use-shorts-feed.ts b/apps/web/src/hooks/use-shorts-feed.ts
index a1c98dd..47415c7 100644
--- a/apps/web/src/hooks/use-shorts-feed.ts
+++ b/apps/web/src/hooks/use-shorts-feed.ts
@@ -1,6 +1,6 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo } from "react";
-import { fetchSearch } from "../lib/api";
+import { fetchSearch } from "../lib/api-discovery";
import { fetchShortsRecommendations, type RecommendationIntent } from "../lib/api-recommendations";
import { fetchSubscriptionShorts } from "../lib/api-user";
import type { VideoStream } from "../types/stream";
diff --git a/apps/web/src/hooks/use-stream.ts b/apps/web/src/hooks/use-stream.ts
index e01d745..c4857b4 100644
--- a/apps/web/src/hooks/use-stream.ts
+++ b/apps/web/src/hooks/use-stream.ts
@@ -9,17 +9,23 @@ import {
export { MEMBER_ONLY_MESSAGE };
-export function streamQueryOptions(url: string) {
+export function streamQueryOptions(url: string, useAuthenticatedStream = false, enabled = true) {
return queryOptions({
- queryKey: ["stream", url],
- queryFn: () => fetchStream(url).then((r) => mapStreamResponse(r, url)),
- enabled: url.startsWith("http"),
+ queryKey: ["stream", url, useAuthenticatedStream ? "auth" : "anon"],
+ queryFn: () =>
+ fetchStream(url, useAuthenticatedStream ? "authenticated_first" : "anonymous_first").then(
+ (r) => mapStreamResponse(r, url),
+ ),
+ enabled: enabled && url.startsWith("http"),
staleTime: 3 * 60 * 1000,
gcTime: 30 * 60 * 1000,
retry: (count, error) => {
if (
error instanceof ApiError &&
- (error.status === 400 || error.status === 404 || error.status === 422)
+ (error.status === 400 ||
+ error.status === 403 ||
+ error.status === 404 ||
+ error.status === 422)
) {
return false;
}
@@ -42,6 +48,9 @@ export function isMemberOnlyApiError(error: unknown): boolean {
return isMemberOnlyApiResponse(error);
}
-export function useStream(url: string) {
- return useQuery({ ...streamQueryOptions(url), placeholderData: keepPreviousData });
+export function useStream(url: string, useAuthenticatedStream = false, enabled = true) {
+ return useQuery({
+ ...streamQueryOptions(url, useAuthenticatedStream, enabled),
+ placeholderData: keepPreviousData,
+ });
}
diff --git a/apps/web/src/lib/admin-console-section.ts b/apps/web/src/lib/admin-console-section.ts
new file mode 100644
index 0000000..73d4fa2
--- /dev/null
+++ b/apps/web/src/lib/admin-console-section.ts
@@ -0,0 +1,24 @@
+export type AdminSection = "settings" | "allow-list" | "users" | "sessions" | "issues";
+
+const ADMIN_SECTION_KEY = "typetype-admin-section";
+
+export function isAdminSection(value: unknown): value is AdminSection {
+ return (
+ value === "settings" ||
+ value === "allow-list" ||
+ value === "users" ||
+ value === "sessions" ||
+ value === "issues"
+ );
+}
+
+export function getStoredAdminSection(): AdminSection {
+ if (typeof window === "undefined") return "issues";
+ const stored = window.localStorage.getItem(ADMIN_SECTION_KEY);
+ return isAdminSection(stored) ? stored : "issues";
+}
+
+export function rememberAdminSection(section: AdminSection) {
+ if (typeof window === "undefined") return;
+ window.localStorage.setItem(ADMIN_SECTION_KEY, section);
+}
diff --git a/apps/web/src/lib/allow-list-error.ts b/apps/web/src/lib/allow-list-error.ts
new file mode 100644
index 0000000..69e8115
--- /dev/null
+++ b/apps/web/src/lib/allow-list-error.ts
@@ -0,0 +1,12 @@
+import { ApiError } from "./api";
+
+export const FAMILY_LIST_BLOCKED_MESSAGE =
+ "This channel is not on your family list. A parent can add it from the allow list.";
+
+export function isChannelNotAllowedError(error: unknown): boolean {
+ return (
+ error instanceof ApiError &&
+ error.status === 403 &&
+ error.message.toLowerCase().includes("channel is not allowed")
+ );
+}
diff --git a/apps/web/src/lib/api-admin-allow-list.ts b/apps/web/src/lib/api-admin-allow-list.ts
new file mode 100644
index 0000000..c5bc1fe
--- /dev/null
+++ b/apps/web/src/lib/api-admin-allow-list.ts
@@ -0,0 +1,91 @@
+import type {
+ AdminAllowListUser,
+ AdminUserAllowList,
+ AllowedPlaylistItem,
+ AllowPlaylistInput,
+} from "../types/allow-list";
+import type { AccessMode } from "../types/user";
+import { ApiError } from "./api";
+import { authed, authedJson } from "./authed";
+import { API_BASE as BASE } from "./env";
+
+export function searchAdminUsers(q: string, limit = 20): Promise {
+ const params = new URLSearchParams({ q, limit: String(limit) });
+ return authedJson(`${BASE}/admin/users/search?${params}`);
+}
+
+export function fetchAdminUserAllowList(id: string): Promise {
+ return authedJson(`${BASE}/admin/users/${encodeURIComponent(id)}/allow-list`);
+}
+
+export async function updateAdminUserAccessMode(
+ id: string,
+ accessMode: AccessMode,
+): Promise {
+ const res = await authed(`${BASE}/admin/users/${encodeURIComponent(id)}/access-mode`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ accessMode }),
+ });
+ const body = (await res.json().catch(() => ({ accessMode }))) as Partial<{
+ accessMode: AccessMode;
+ }>;
+ if (!res.ok) throw new ApiError("Failed to update access mode", res.status);
+ return body.accessMode === "allow_list" ? "allow_list" : "unrestricted";
+}
+
+export function addAdminUserAllowedChannel(
+ id: string,
+ url: string,
+ name?: string | null,
+ thumbnailUrl?: string | null,
+) {
+ return authedJson(`${BASE}/admin/users/${encodeURIComponent(id)}/allowed/channels`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url, name, thumbnailUrl }),
+ });
+}
+
+export async function removeAdminUserAllowedChannel(id: string, channelUrl: string): Promise {
+ const res = await authed(
+ `${BASE}/admin/users/${encodeURIComponent(id)}/allowed/channels/${encodeURIComponent(channelUrl)}`,
+ { method: "DELETE" },
+ );
+ if (!res.ok && res.status !== 404) throw new ApiError("Failed to remove channel", res.status);
+}
+
+export function fetchAdminAllowedPlaylists(): Promise {
+ return authedJson(`${BASE}/admin/allowed/playlists`);
+}
+
+export function addAdminAllowedPlaylist(input: AllowPlaylistInput): Promise {
+ return authedJson(`${BASE}/admin/allowed/playlists`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ });
+}
+
+export async function removeAdminAllowedPlaylist(url: string): Promise {
+ const res = await authed(`${BASE}/admin/allowed/playlists/${encodeURIComponent(url)}`, {
+ method: "DELETE",
+ });
+ if (!res.ok && res.status !== 404) throw new ApiError("Failed to remove playlist", res.status);
+}
+
+export function addAdminUserAllowedPlaylist(id: string, input: AllowPlaylistInput) {
+ return authedJson(`${BASE}/admin/users/${encodeURIComponent(id)}/allowed/playlists`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ });
+}
+
+export async function removeAdminUserAllowedPlaylist(id: string, url: string): Promise {
+ const res = await authed(
+ `${BASE}/admin/users/${encodeURIComponent(id)}/allowed/playlists/${encodeURIComponent(url)}`,
+ { method: "DELETE" },
+ );
+ if (!res.ok && res.status !== 404) throw new ApiError("Failed to remove playlist", res.status);
+}
diff --git a/apps/web/src/lib/api-admin.ts b/apps/web/src/lib/api-admin.ts
index 737de1b..b16393d 100644
--- a/apps/web/src/lib/api-admin.ts
+++ b/apps/web/src/lib/api-admin.ts
@@ -27,7 +27,8 @@ function isAdminSettings(value: unknown): value is AdminSettings {
typeof record.activeSessionsEnabled === "boolean" &&
typeof record.localLoginEnabled === "boolean" &&
typeof record.oidcAutoRedirect === "boolean" &&
- typeof record.youtubeRemoteLoginEnabled === "boolean"
+ typeof record.youtubeRemoteLoginEnabled === "boolean" &&
+ (record.accessMode === "unrestricted" || record.accessMode === "allow_list")
);
}
@@ -39,6 +40,7 @@ function normalizeAdminUser(user: AuthUser): AuthUser {
avatarUrl: user.avatarUrl ?? null,
avatarType: user.avatarType ?? null,
avatarCode: user.avatarCode ?? null,
+ accessMode: user.accessMode === "allow_list" ? "allow_list" : "unrestricted",
};
}
diff --git a/apps/web/src/lib/api-channel-playlists.ts b/apps/web/src/lib/api-channel-playlists.ts
index 39ab348..361f709 100644
--- a/apps/web/src/lib/api-channel-playlists.ts
+++ b/apps/web/src/lib/api-channel-playlists.ts
@@ -1,6 +1,7 @@
import type { ChannelPlaylistsResponse } from "../types/playlist";
import { request } from "./api";
import { API_BASE as BASE } from "./env";
+import { optionalBearer } from "./optional-bearer";
export function fetchChannelPlaylists(
url: string,
@@ -8,5 +9,5 @@ export function fetchChannelPlaylists(
): Promise {
const params = new URLSearchParams({ url });
if (nextpage) params.set("nextpage", nextpage);
- return request(`${BASE}/channel/playlists?${params}`);
+ return request(`${BASE}/channel/playlists?${params}`, optionalBearer());
}
diff --git a/apps/web/src/lib/api-collections.ts b/apps/web/src/lib/api-collections.ts
index 76070ba..75986f6 100644
--- a/apps/web/src/lib/api-collections.ts
+++ b/apps/web/src/lib/api-collections.ts
@@ -1,4 +1,10 @@
-import type { BlockedItem, FavoriteItem, ProgressItem, WatchLaterItem } from "../types/user";
+import type {
+ AllowedChannelItem,
+ BlockedItem,
+ FavoriteItem,
+ ProgressItem,
+ WatchLaterItem,
+} from "../types/user";
import { ApiError } from "./api";
import { authed, authedJson } from "./authed";
@@ -75,6 +81,30 @@ export async function unblockVideo(url: string): Promise {
await throwIfFailed(res, "unblock failed");
}
+export function fetchAllowedChannels(): Promise {
+ return authedJson(`${BASE}/allowed/channels`);
+}
+
+export function allowChannel(
+ url: string,
+ name?: string | null,
+ thumbnailUrl?: string | null,
+ global = false,
+): Promise {
+ return authedJson(`${BASE}/allowed/channels`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url, name, thumbnailUrl, global }),
+ });
+}
+
+export async function disallowChannel(url: string): Promise {
+ const res = await authed(`${BASE}/allowed/channels/${encodeURIComponent(url)}`, {
+ method: "DELETE",
+ });
+ await throwIfFailed(res, "remove allowed channel failed");
+}
+
export function fetchFavorites(): Promise {
return authedJson(`${BASE}/favorites`);
}
diff --git a/apps/web/src/lib/api-discovery.ts b/apps/web/src/lib/api-discovery.ts
new file mode 100644
index 0000000..aaa7f87
--- /dev/null
+++ b/apps/web/src/lib/api-discovery.ts
@@ -0,0 +1,39 @@
+import type { ChannelResponse, SearchFiltersResponse, SearchPageResponse } from "../types/api";
+import { request } from "./api";
+import { API_BASE as BASE } from "./env";
+import { optionalBearer } from "./optional-bearer";
+
+export type ChannelSort = "latest" | "popular" | "oldest";
+
+export function fetchSearchFilters(service: number): Promise {
+ return request(`${BASE}/search/filters?service=${service}`);
+}
+
+export function fetchSearch(
+ q: string,
+ service: number,
+ nextpage?: string,
+ contentFilter?: string,
+ sortFilter?: string,
+): Promise {
+ const params = new URLSearchParams({ q, service: String(service) });
+ if (nextpage) params.set("nextpage", nextpage);
+ if (contentFilter) params.set("contentFilter", contentFilter);
+ if (sortFilter) params.set("sortFilter", sortFilter);
+ return request(`${BASE}/search?${params}`, optionalBearer());
+}
+
+export function fetchChannel(
+ url: string,
+ nextpage?: string,
+ sort?: ChannelSort,
+): Promise {
+ return request(
+ `${BASE}/channel/page`,
+ optionalBearer({
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url, nextpage: nextpage ?? null, sort: sort ?? null }),
+ }),
+ );
+}
diff --git a/apps/web/src/lib/api-public-playlist.ts b/apps/web/src/lib/api-public-playlist.ts
index 37d5729..9185a47 100644
--- a/apps/web/src/lib/api-public-playlist.ts
+++ b/apps/web/src/lib/api-public-playlist.ts
@@ -1,6 +1,7 @@
import type { PublicPlaylistResponse } from "../types/playlist";
import { request } from "./api";
import { API_BASE as BASE } from "./env";
+import { optionalBearer } from "./optional-bearer";
export function fetchPublicPlaylist(
url: string,
@@ -8,5 +9,5 @@ export function fetchPublicPlaylist(
): Promise {
const params = new URLSearchParams({ url });
if (nextpage) params.set("nextpage", nextpage);
- return request(`${BASE}/playlist?${params}`);
+ return request(`${BASE}/playlist?${params}`, optionalBearer());
}
diff --git a/apps/web/src/lib/api-recommendations.ts b/apps/web/src/lib/api-recommendations.ts
index 2835fe8..4399c14 100644
--- a/apps/web/src/lib/api-recommendations.ts
+++ b/apps/web/src/lib/api-recommendations.ts
@@ -1,6 +1,7 @@
import type { HomeRecommendationsResponse } from "../types/api";
-import { authedJson } from "./authed";
+import { request } from "./api";
import { API_BASE as BASE } from "./env";
+import { optionalBearer } from "./optional-bearer";
export type RecommendationIntent = "quick" | "deep" | "auto";
@@ -16,7 +17,7 @@ export async function fetchHomeRecommendations(
intent,
});
if (cursor) search.set("cursor", cursor);
- return authedJson(`${BASE}/recommendations/home?${search.toString()}`);
+ return request(`${BASE}/recommendations/home?${search.toString()}`, optionalBearer());
}
export async function fetchShortsRecommendations(
@@ -31,5 +32,5 @@ export async function fetchShortsRecommendations(
intent,
});
if (cursor) search.set("cursor", cursor);
- return authedJson(`${BASE}/recommendations/shorts?${search.toString()}`);
+ return request(`${BASE}/recommendations/shorts?${search.toString()}`, optionalBearer());
}
diff --git a/apps/web/src/lib/api-stream.ts b/apps/web/src/lib/api-stream.ts
index 67b200b..287fdb2 100644
--- a/apps/web/src/lib/api-stream.ts
+++ b/apps/web/src/lib/api-stream.ts
@@ -5,6 +5,8 @@ import { recordClientEvent } from "./client-debug-log";
import { sanitizeVideoContext } from "./debug-sanitize";
import { API_BASE as BASE } from "./env";
+type StreamFetchMode = "anonymous_first" | "authenticated_first";
+
function streamInit(token: string | null): RequestInit {
if (!token) return { cache: "no-store" };
return {
@@ -13,10 +15,17 @@ function streamInit(token: string | null): RequestInit {
};
}
-export async function fetchStream(url: string): Promise {
+export async function fetchStream(
+ url: string,
+ mode: StreamFetchMode = "anonymous_first",
+): Promise {
const endpoint = `${BASE}/streams?url=${encodeURIComponent(url)}`;
const token = useAuthStore.getState().token;
const video = sanitizeVideoContext(url) ?? "unknown";
+ if (token && mode === "authenticated_first") {
+ recordClientEvent("stream.fetch_start", { authed: true, video });
+ return request(endpoint, streamInit(token));
+ }
recordClientEvent("stream.fetch_start", { authed: false, video });
try {
const result = await request(endpoint, streamInit(null));
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index e45af8d..1dd9f11 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -1,10 +1,7 @@
import type {
- ChannelResponse,
CommentsPageResponse,
PodcastEpisodesResponse,
PodcastPageResponse,
- SearchFiltersResponse,
- SearchPageResponse,
} from "../types/api";
import { recordApiError } from "./api-error-log";
import { extractRequestId, recordClientEvent } from "./client-debug-log";
@@ -23,8 +20,6 @@ export class ApiError extends Error {
}
}
-export type ChannelSort = "latest" | "popular" | "oldest";
-
type ErrorLikeBody = {
code?: string;
error?: string;
@@ -111,24 +106,6 @@ export async function request(url: string, init?: RequestInit): Promise {
return body as T;
}
-export function fetchSearchFilters(service: number): Promise {
- return request(`${BASE}/search/filters?service=${service}`);
-}
-
-export function fetchSearch(
- q: string,
- service: number,
- nextpage?: string,
- contentFilter?: string,
- sortFilter?: string,
-): Promise {
- const params = new URLSearchParams({ q, service: String(service) });
- if (nextpage) params.set("nextpage", nextpage);
- if (contentFilter) params.set("contentFilter", contentFilter);
- if (sortFilter) params.set("sortFilter", sortFilter);
- return request(`${BASE}/search?${params}`);
-}
-
export function fetchComments(url: string, nextpage?: string): Promise {
const params = new URLSearchParams({ url });
if (nextpage) params.set("nextpage", nextpage);
@@ -143,18 +120,6 @@ export function fetchCommentReplies(
return request(`${BASE}/comments/replies?${params}`);
}
-export function fetchChannel(
- url: string,
- nextpage?: string,
- sort?: ChannelSort,
-): Promise {
- return request(`${BASE}/channel/page`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ url, nextpage: nextpage ?? null, sort: sort ?? null }),
- });
-}
-
export function fetchPodcasts(url: string, nextpage?: string): Promise {
const params = new URLSearchParams({ url });
if (nextpage) params.set("nextpage", nextpage);
diff --git a/apps/web/src/lib/channel-route-url.ts b/apps/web/src/lib/channel-route-url.ts
index 79aa556..95148ba 100644
--- a/apps/web/src/lib/channel-route-url.ts
+++ b/apps/web/src/lib/channel-route-url.ts
@@ -1,4 +1,4 @@
-import type { ChannelSort } from "./api";
+import type { ChannelSort } from "./api-discovery";
const YOUTUBE_CHANNEL_ID_PATTERN = /^UC[A-Za-z0-9_-]{22}$/;
const YOUTUBE_HANDLE_PATTERN = /^@[A-Za-z0-9._-]{2,48}$/;
@@ -78,3 +78,10 @@ export function channelLegacySearch(
): ChannelLegacySearch {
return { url: toPublicChannelParam(sourceUrl), ...channelPathSearch(sort, query, tab) };
}
+
+export function channelRoutePath(sourceUrl: string): string {
+ const pathParam = toChannelPathParam(sourceUrl);
+ if (pathParam) return `/channel/${encodeURIComponent(pathParam)}`;
+ const params = new URLSearchParams({ url: toPublicChannelParam(sourceUrl) });
+ return `/channel?${params.toString()}`;
+}
diff --git a/apps/web/src/lib/channel-sort.ts b/apps/web/src/lib/channel-sort.ts
index 187b30a..82ccecb 100644
--- a/apps/web/src/lib/channel-sort.ts
+++ b/apps/web/src/lib/channel-sort.ts
@@ -1,4 +1,4 @@
-import type { ChannelSort } from "./api";
+import type { ChannelSort } from "./api-discovery";
export const CHANNEL_SORT_OPTIONS: { value: ChannelSort; label: string }[] = [
{ value: "latest", label: "Newest" },
diff --git a/apps/web/src/lib/optional-bearer.ts b/apps/web/src/lib/optional-bearer.ts
new file mode 100644
index 0000000..cc2fbd4
--- /dev/null
+++ b/apps/web/src/lib/optional-bearer.ts
@@ -0,0 +1,9 @@
+import { useAuthStore } from "../stores/auth-store";
+
+export function optionalBearer(init?: RequestInit): RequestInit | undefined {
+ const token = useAuthStore.getState().token;
+ if (!token) return init;
+ const headers = new Headers(init?.headers);
+ headers.set("Authorization", `Bearer ${token}`);
+ return { ...init, headers };
+}
diff --git a/apps/web/src/lib/settings-section.ts b/apps/web/src/lib/settings-section.ts
new file mode 100644
index 0000000..473e1a9
--- /dev/null
+++ b/apps/web/src/lib/settings-section.ts
@@ -0,0 +1,35 @@
+export type SettingsSection =
+ | "playback"
+ | "video"
+ | "home"
+ | "language"
+ | "service"
+ | "import"
+ | "privacy"
+ | "blocked";
+
+const SETTINGS_SECTION_KEY = "typetype-settings-section";
+
+export function isSettingsSection(value: unknown): value is SettingsSection {
+ return (
+ value === "playback" ||
+ value === "video" ||
+ value === "home" ||
+ value === "language" ||
+ value === "service" ||
+ value === "import" ||
+ value === "privacy" ||
+ value === "blocked"
+ );
+}
+
+export function getStoredSettingsSection(): SettingsSection {
+ if (typeof window === "undefined") return "playback";
+ const stored = window.localStorage.getItem(SETTINGS_SECTION_KEY);
+ return isSettingsSection(stored) ? stored : "playback";
+}
+
+export function rememberSettingsSection(section: SettingsSection) {
+ if (typeof window === "undefined") return;
+ window.localStorage.setItem(SETTINGS_SECTION_KEY, section);
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 49650b0..71e3f02 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -10,7 +10,11 @@ import { routeTree } from "./routeTree.gen";
initErrorCapture();
-const router = createRouter({ routeTree, defaultPendingComponent: PageSpinner });
+const router = createRouter({
+ routeTree,
+ defaultPendingComponent: PageSpinner,
+ scrollRestoration: true,
+});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
diff --git a/apps/web/src/routes/admin-console.tsx b/apps/web/src/routes/admin-console.tsx
index 9541cd0..0d69205 100644
--- a/apps/web/src/routes/admin-console.tsx
+++ b/apps/web/src/routes/admin-console.tsx
@@ -1,5 +1,6 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
+import { AdminAllowListSection } from "../components/admin-allow-list-section";
import { AdminBugReportsSection } from "../components/admin-bug-reports-section";
import { AdminConsoleHeader } from "../components/admin-console-header";
import { AdminConsoleNav } from "../components/admin-console-nav";
@@ -8,21 +9,22 @@ import { AdminSettingsSection } from "../components/admin-settings-section";
import { AdminUsersSection } from "../components/admin-users-section";
import { Toast } from "../components/toast";
import { useAuth } from "../hooks/use-auth";
+import {
+ type AdminSection,
+ getStoredAdminSection,
+ isAdminSection,
+ rememberAdminSection,
+} from "../lib/admin-console-section";
import { goto } from "../lib/route-redirect";
-type AdminSection = "settings" | "users" | "sessions" | "issues";
-
-function isSection(value: unknown): value is AdminSection {
- return value === "settings" || value === "users" || value === "sessions" || value === "issues";
-}
-
function availableSections(isAdmin: boolean, isModerator: boolean): AdminSection[] {
- if (isAdmin) return ["settings", "users", "sessions", "issues"];
+ if (isAdmin) return ["settings", "allow-list", "users", "sessions", "issues"];
if (isModerator) return ["issues"];
return [];
}
function sectionLabel(section: AdminSection): string {
+ if (section === "allow-list") return "Allow list";
if (section === "issues") return "Issues";
if (section === "users") return "Users";
if (section === "sessions") return "Sessions";
@@ -43,6 +45,11 @@ function AdminConsolePage() {
navigate({ search: { section: activeSection }, replace: true });
}, [activeSection, canAccessAdmin, navigate, section]);
+ useEffect(() => {
+ if (!canAccessAdmin) return;
+ rememberAdminSection(activeSection);
+ }, [activeSection, canAccessAdmin]);
+
useEffect(() => {
if (!toast) return;
const timer = setTimeout(() => setToast(null), 3200);
@@ -68,6 +75,9 @@ function AdminConsolePage() {
{activeSection === "settings" && isAdmin && (
)}
+ {activeSection === "allow-list" && isAdmin && (
+
+ )}
{activeSection === "users" && isAdmin && (
)}
@@ -82,7 +92,7 @@ function AdminConsolePage() {
export const Route = createFileRoute("/admin-console")({
validateSearch: (search: Record) => ({
- section: isSection(search.section) ? search.section : "issues",
+ section: isAdminSection(search.section) ? search.section : getStoredAdminSection(),
}),
component: AdminConsolePage,
});
diff --git a/apps/web/src/routes/channel.tsx b/apps/web/src/routes/channel.tsx
index 81e86c8..4fe59e9 100644
--- a/apps/web/src/routes/channel.tsx
+++ b/apps/web/src/routes/channel.tsx
@@ -2,7 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { ChannelPageContent } from "../components/channel-page-content";
import { PageSpinner } from "../components/page-spinner";
-import type { ChannelSort } from "../lib/api";
+import type { ChannelSort } from "../lib/api-discovery";
import {
channelLegacySearch,
channelPathSearch,
diff --git a/apps/web/src/routes/channel_.$channelId.tsx b/apps/web/src/routes/channel_.$channelId.tsx
index 8452020..d4d0c56 100644
--- a/apps/web/src/routes/channel_.$channelId.tsx
+++ b/apps/web/src/routes/channel_.$channelId.tsx
@@ -1,6 +1,6 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { ChannelPageContent } from "../components/channel-page-content";
-import type { ChannelSort } from "../lib/api";
+import type { ChannelSort } from "../lib/api-discovery";
import {
channelPathSearch,
channelTabOrDefault,
diff --git a/apps/web/src/routes/search.tsx b/apps/web/src/routes/search.tsx
index e9c06a0..7e383c8 100644
--- a/apps/web/src/routes/search.tsx
+++ b/apps/web/src/routes/search.tsx
@@ -1,5 +1,6 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useCallback } from "react";
+import { FamilyListEmptyState } from "../components/family-list-empty-state";
import { ScrollSentinel } from "../components/scroll-sentinel";
import { SearchFilterBar } from "../components/search-filter-bar";
import { type SearchResultItem, SearchResultsGrid } from "../components/search-results-grid";
@@ -7,11 +8,13 @@ import { VideoGridSkeleton } from "../components/video-grid-skeleton";
import { useBlockedFilter } from "../hooks/use-blocked-filter";
import { useSearch } from "../hooks/use-search";
import { useSearchFilters } from "../hooks/use-search-filters";
+import { useSettings } from "../hooks/use-settings";
function SearchPage() {
const { q, service, contentFilter, sortFilter } = Route.useSearch();
const navigate = useNavigate();
const filters = useSearchFilters(service);
+ const { settings } = useSettings();
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useSearch(
q,
service,
@@ -108,7 +111,14 @@ function SearchPage() {
)}
{items.length === 0 ? (
- No results for “{q}”
+ settings.accessMode === "allow_list" ? (
+
+ ) : (
+ No results for “{q}”
+ )
) : (
)}
diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx
index 3dc4dd8..7236071 100644
--- a/apps/web/src/routes/settings.tsx
+++ b/apps/web/src/routes/settings.tsx
@@ -1,45 +1,108 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { useEffect } from "react";
import { useSettings } from "../hooks/use-settings";
import { goto } from "../lib/route-redirect";
+import {
+ getStoredSettingsSection,
+ isSettingsSection,
+ rememberSettingsSection,
+ type SettingsSection,
+} from "../lib/settings-section";
import { SettingsBlocked } from "../settings/settings-blocked";
import { SettingsLandingPage } from "../settings/settings-landing-page";
import { SettingsLanguage } from "../settings/settings-language";
+import { SettingsNav } from "../settings/settings-nav";
import { SettingsPlayback } from "../settings/settings-playback";
import { SettingsPrivacy } from "../settings/settings-privacy";
import { SettingsService } from "../settings/settings-service";
import { SettingsVideoPreferences } from "../settings/settings-video-preferences";
+type Item = {
+ key: SettingsSection;
+ label: string;
+};
+
+const BASE_ITEMS: Item[] = [
+ { key: "playback", label: "Playback" },
+ { key: "video", label: "Video" },
+ { key: "home", label: "Home" },
+ { key: "service", label: "Service" },
+ { key: "import", label: "Import" },
+ { key: "privacy", label: "Privacy" },
+ { key: "blocked", label: "Blocked" },
+];
+
+function settingsItems(showLanguage: boolean): Item[] {
+ if (!showLanguage) return BASE_ITEMS;
+ return [
+ BASE_ITEMS[0],
+ BASE_ITEMS[1],
+ BASE_ITEMS[2],
+ { key: "language", label: "Language" },
+ ...BASE_ITEMS.slice(3),
+ ];
+}
+
+function SettingsImport() {
+ return (
+
+ Migration
+
+
+ Import from YouTube or PipePipe
+ Open the dedicated import page.
+
+
goto("/import")}
+ className="h-9 w-full rounded-md bg-surface px-2.5 text-xs text-fg-muted transition-colors hover:text-fg sm:h-8 sm:w-auto"
+ >
+ Open import
+
+
+
+ );
+}
+
function SettingsPage() {
const { settings } = useSettings();
+ const { section } = Route.useSearch();
+ const navigate = useNavigate({ from: "/settings" });
+ const items = settingsItems(settings.defaultService === 0);
+ const activeSection = items.some((item) => item.key === section) ? section : items[0].key;
+
+ useEffect(() => {
+ if (section === activeSection) return;
+ navigate({ search: { section: activeSection }, replace: true });
+ }, [activeSection, navigate, section]);
+
+ useEffect(() => {
+ rememberSettingsSection(activeSection);
+ }, [activeSection]);
return (
-
+
Settings
-
-
-
- {settings.defaultService === 0 &&
}
-
-
- Migration
-
-
- Import from YouTube or PipePipe
- Open the dedicated import page.
-
-
goto("/import")}
- className="h-9 w-full rounded-md bg-surface px-2.5 text-xs text-fg-muted transition-colors hover:text-fg sm:h-8 sm:w-auto"
- >
- Open import
-
-
-
-
-
+
navigate({ search: { section: next } })}
+ />
+ {activeSection === "playback" && }
+ {activeSection === "video" && }
+ {activeSection === "home" && }
+ {activeSection === "language" && settings.defaultService === 0 && }
+ {activeSection === "service" && }
+ {activeSection === "import" && }
+ {activeSection === "privacy" && }
+ {activeSection === "blocked" && }
);
}
-export const Route = createFileRoute("/settings")({ component: SettingsPage });
+export const Route = createFileRoute("/settings")({
+ validateSearch: (search: Record
) => ({
+ section: isSettingsSection(search.section) ? search.section : getStoredSettingsSection(),
+ }),
+ component: SettingsPage,
+});
diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx
index 81a35db..5808a16 100644
--- a/apps/web/src/routes/watch.tsx
+++ b/apps/web/src/routes/watch.tsx
@@ -6,12 +6,14 @@ import { useAuth } from "../hooks/use-auth";
import { useDocumentTitle } from "../hooks/use-document-title";
import { useHistory } from "../hooks/use-history";
import { useProgress } from "../hooks/use-progress";
+import { useSettings } from "../hooks/use-settings";
import {
isMemberOnlyApiError,
isStreamUnavailableError,
MEMBER_ONLY_MESSAGE,
useStream,
} from "../hooks/use-stream";
+import { FAMILY_LIST_BLOCKED_MESSAGE, isChannelNotAllowedError } from "../lib/allow-list-error";
import { ApiError } from "../lib/api";
import { isYoutubeSessionReconnectError } from "../lib/api-youtube-session";
import { toPublicWatchParam, toWatchSourceUrl } from "../lib/watch-url";
@@ -40,7 +42,16 @@ function WatchPage() {
const sourceUrl = toWatchSourceUrl(v);
const publicParam = toPublicWatchParam(sourceUrl);
const { authReady, isAuthed } = useAuth();
- const { data: stream, isLoading, isError, error, refetch } = useStream(sourceUrl);
+ const { settings, settingsReady } = useSettings();
+ const useAuthenticatedStream = isAuthed && settings.accessMode === "allow_list";
+ const streamEnabled = authReady && (!isAuthed || settingsReady);
+ const {
+ data: stream,
+ isLoading,
+ isError,
+ error,
+ refetch,
+ } = useStream(sourceUrl, useAuthenticatedStream, streamEnabled);
const { add } = useHistory();
const progressFetch = useProgress(sourceUrl);
useDocumentTitle(stream?.title);
@@ -86,23 +97,26 @@ function WatchPage() {
"Error occurs when fetching the page. Try increase the loading timeout in Settings.";
const isMemberOnlyError = isMemberOnlyApiError(error) || genericExtractorError;
const needsYoutubeSession = isYoutubeSessionReconnectError(error);
+ const familyListBlocked = isChannelNotAllowedError(error);
const youtubeSessionReturnTo = needsYoutubeSession
? youtubeSessionReturnToForWatch(publicParam, list, shuffle)
: undefined;
const message = isMemberOnlyError
? MEMBER_ONLY_MESSAGE
- : needsYoutubeSession
- ? "Connect YouTube to load this browser-only video."
- : error instanceof ApiError && (error.status === 400 || error.status === 422)
- ? error.message
- : isStreamUnavailableError(error)
- ? "This video is currently unavailable"
- : "Failed to load stream.";
+ : familyListBlocked
+ ? FAMILY_LIST_BLOCKED_MESSAGE
+ : needsYoutubeSession
+ ? "Connect YouTube to load this browser-only video."
+ : error instanceof ApiError && (error.status === 400 || error.status === 422)
+ ? error.message
+ : isStreamUnavailableError(error)
+ ? "This video is currently unavailable"
+ : "Failed to load stream.";
return (
{
void refetch();
diff --git a/apps/web/src/settings/settings-nav.tsx b/apps/web/src/settings/settings-nav.tsx
new file mode 100644
index 0000000..eed8b60
--- /dev/null
+++ b/apps/web/src/settings/settings-nav.tsx
@@ -0,0 +1,39 @@
+import type { SettingsSection } from "../lib/settings-section";
+
+type Item = {
+ key: SettingsSection;
+ label: string;
+};
+
+type Props = {
+ items: Item[];
+ active: SettingsSection;
+ onSelect: (section: SettingsSection) => void;
+};
+
+export function SettingsNav({ items, active, onSelect }: Props) {
+ return (
+
+
+ {items.map((item) => {
+ const isActive = item.key === active;
+ return (
+ onSelect(item.key)}
+ className={`shrink-0 border-b px-1 py-2 text-left font-mono text-xs uppercase tracking-[0.16em] transition-colors ${
+ isActive
+ ? "border-border text-fg"
+ : "border-border text-fg-soft hover:border-border-strong hover:text-fg-muted"
+ }`}
+ >
+ {item.label}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/web/src/types/admin.ts b/apps/web/src/types/admin.ts
index 34c93a2..0da7bff 100644
--- a/apps/web/src/types/admin.ts
+++ b/apps/web/src/types/admin.ts
@@ -1,3 +1,5 @@
+import type { AccessMode } from "./user";
+
export type AdminSettings = {
name: string;
tagline: string | null;
@@ -11,6 +13,7 @@ export type AdminSettings = {
localLoginEnabled: boolean;
oidcAutoRedirect: boolean;
youtubeRemoteLoginEnabled: boolean;
+ accessMode: AccessMode;
};
type AdminSessionNowPlaying = {
diff --git a/apps/web/src/types/allow-list.ts b/apps/web/src/types/allow-list.ts
new file mode 100644
index 0000000..a605640
--- /dev/null
+++ b/apps/web/src/types/allow-list.ts
@@ -0,0 +1,32 @@
+import type { AccessMode, AllowedChannelItem } from "./user";
+
+export type AdminAllowListUser = {
+ id: string;
+ email: string;
+ name: string;
+ accessMode: AccessMode;
+};
+
+export type AllowedPlaylistItem = {
+ url: string;
+ title: string | null;
+ thumbnailUrl: string | null;
+ uploaderName: string | null;
+ allowedAt: number;
+ global: boolean | null;
+};
+
+export type AdminUserAllowList = {
+ user: AdminAllowListUser;
+ globalChannels: AllowedChannelItem[];
+ userChannels: AllowedChannelItem[];
+ globalPlaylists: AllowedPlaylistItem[];
+ userPlaylists: AllowedPlaylistItem[];
+};
+
+export type AllowPlaylistInput = {
+ url: string;
+ title?: string | null;
+ thumbnailUrl?: string | null;
+ uploaderName?: string | null;
+};
diff --git a/apps/web/src/types/auth.ts b/apps/web/src/types/auth.ts
index 6c1eff5..6b0f711 100644
--- a/apps/web/src/types/auth.ts
+++ b/apps/web/src/types/auth.ts
@@ -1,3 +1,5 @@
+import type { AccessMode } from "./user";
+
export type AuthRole = "admin" | "moderator" | "user";
export type AuthMe = {
@@ -35,6 +37,7 @@ export type AuthUser = {
avatarCode: string | null;
suspended: boolean;
verified: boolean;
+ accessMode: AccessMode;
createdAt: number | string;
};
diff --git a/apps/web/src/types/user.ts b/apps/web/src/types/user.ts
index 3bab67b..f9a76f5 100644
--- a/apps/web/src/types/user.ts
+++ b/apps/web/src/types/user.ts
@@ -1,6 +1,7 @@
export type ServiceId = 0 | 5 | 6;
export type SponsorBlockMode = "auto_skip" | "mark_only" | "disabled";
export type SponsorBlockCategoryAction = "auto_skip" | "mark_only" | "disabled";
+export type AccessMode = "unrestricted" | "allow_list";
type SponsorBlockCategoryActions = Record;
export type HistoryItem = {
@@ -107,6 +108,7 @@ export type SettingsItem = {
hideRelatedVideos: boolean;
hideComments: boolean;
hideShorts: boolean;
+ accessMode: AccessMode;
captionStyles: CaptionStyles;
};
@@ -123,3 +125,11 @@ export type BlockedItem = {
blockedAt: number;
global?: boolean;
};
+
+export type AllowedChannelItem = {
+ url: string;
+ name: string | null;
+ thumbnailUrl: string | null;
+ allowedAt: number;
+ global?: boolean;
+};