diff --git a/README.md b/README.md index b99d2d2..5eb0f5b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,13 @@ TypeType is a self-hostable video app for YouTube, NicoNico and BiliBili. It is not only a web UI. This repository contains the TypeType web client and the deployment files for running the full stack: frontend, Kotlin API backend, PostgreSQL, Dragonfly, media proxying, token service, downloader service and Garage-backed download storage. +## Documentation + +Full documentation lives at **[priveetee.github.io/Docs-TypeType](https://priveetee.github.io/Docs-TypeType/)**: + +- [Self-hosting guide](https://priveetee.github.io/Docs-TypeType/self-hosting/introduction), set up and operate the stack, including a fully script-free Docker Compose setup. +- [User guide](https://priveetee.github.io/Docs-TypeType/guide/), everything the app can do. + ## Start Here Install and start the stack with one command: @@ -201,7 +208,7 @@ Service IDs: ## Manual Install -The installer is recommended. If you want to run from a cloned repository instead: +The installer is recommended. For a fully **script-free** Docker Compose setup (no bootstrap scripts), follow the [manual setup guide](https://priveetee.github.io/Docs-TypeType/self-hosting/docker-compose). If you want to run from a cloned repository with the helper scripts instead: ```sh git clone https://github.com/Priveetee/TypeType.git diff --git a/apps/web/public/family-list-blocked.gif b/apps/web/public/family-list-blocked.gif new file mode 100644 index 0000000..fdc0fe9 Binary files /dev/null and b/apps/web/public/family-list-blocked.gif differ diff --git a/apps/web/src/components/admin-allow-list-channel-list.tsx b/apps/web/src/components/admin-allow-list-channel-list.tsx new file mode 100644 index 0000000..f58e0ee --- /dev/null +++ b/apps/web/src/components/admin-allow-list-channel-list.tsx @@ -0,0 +1,98 @@ +import { channelRoutePath } from "../lib/channel-route-url"; +import type { AllowedChannelItem } from "../types/user"; +import { ChannelAvatar } from "./channel-avatar"; +import { ChannelRouteLink } from "./channel-route-link"; + +function XIcon() { + return ( + + ); +} + +type Props = { + title?: string; + channels: AllowedChannelItem[]; + onRemove?: (url: string) => void; +}; + +export function AdminAllowListChannelList({ + title = "Allowed channels", + channels, + onRemove, +}: Props) { + return ( +
+
+
+

{title}

+

+ Channels available when allow-list mode is enabled. +

+
+ + {channels.length} {channels.length === 1 ? "channel" : "channels"} + +
+ {channels.length === 0 ? ( +

No channels added.

+ ) : ( +
+ {channels.map((item) => { + const label = item.name ?? item.url; + const typeTypeUrl = channelRoutePath(item.url); + return ( +
+ +
+ + {label} + + + {typeTypeUrl} + +
+ {onRemove && ( + + )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/admin-allow-list-form.tsx b/apps/web/src/components/admin-allow-list-form.tsx new file mode 100644 index 0000000..64c87b5 --- /dev/null +++ b/apps/web/src/components/admin-allow-list-form.tsx @@ -0,0 +1,101 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useDebouncedValue } from "../hooks/use-debounced-value"; +import { fetchSearch } from "../lib/api-discovery"; +import { normalizeChannelUrl } from "../lib/channel-url"; +import { formatSubscribers } from "../lib/format"; +import { proxyImage } from "../lib/proxy"; +import type { ChannelResultItem } from "../types/api"; +import { ChannelAvatar } from "./channel-avatar"; +import { ChannelRouteLink } from "./channel-route-link"; + +function isTrusted(channel: ChannelResultItem, trustedUrls: Set): boolean { + return trustedUrls.has(normalizeChannelUrl(channel.url)); +} + +type Props = { + title: string; + description: string; + trustedUrls: string[]; + pending: boolean; + onAdd: (channel: ChannelResultItem) => void; +}; + +export function AdminAllowListForm({ title, description, trustedUrls, pending, onAdd }: Props) { + const [term, setTerm] = useState(""); + const debounced = useDebouncedValue(term.trim(), 300); + const trusted = new Set(trustedUrls.map(normalizeChannelUrl)); + const search = useQuery({ + queryKey: ["admin-allow-list-channel-search", debounced], + queryFn: () => fetchSearch(debounced, 0), + enabled: debounced.length >= 2, + staleTime: 60 * 1000, + }); + const channels = search.data?.channels ?? []; + + return ( +
+
+

{title}

+

{description}

+
+ setTerm(event.target.value)} + placeholder="Channel name or @handle" + className="h-10 w-full border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong" + /> +
+ {debounced.length < 2 ? ( +
+ Type at least two characters to search channels. +
+ ) : search.isLoading ? ( +
Searching channels...
+ ) : channels.length === 0 ? ( +
+ No channels found. Try the exact channel name or handle. +
+ ) : ( +
+ {channels.slice(0, 8).map((channel) => { + const alreadyAdded = isTrusted(channel, trusted); + return ( +
+ +
+ + {channel.name} + +

+ {formatSubscribers(channel.subscriberCount)} +

+
+ +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/admin-allow-list-playlist-list.tsx b/apps/web/src/components/admin-allow-list-playlist-list.tsx new file mode 100644 index 0000000..6597c57 --- /dev/null +++ b/apps/web/src/components/admin-allow-list-playlist-list.tsx @@ -0,0 +1,96 @@ +import { Link } from "@tanstack/react-router"; +import { proxyImage } from "../lib/proxy"; +import type { AllowedPlaylistItem } from "../types/allow-list"; + +type Props = { + title: string; + playlists: AllowedPlaylistItem[]; + onRemove?: (url: string) => void; +}; + +function playlistPath(url: string): string { + const params = new URLSearchParams({ url }); + return `/playlist?${params.toString()}`; +} + +function XIcon() { + return ( + + ); +} + +export function AdminAllowListPlaylistList({ title, playlists, onRemove }: Props) { + return ( +
+
+

{title}

+ + {playlists.length} {playlists.length === 1 ? "playlist" : "playlists"} + +
+ {playlists.length === 0 ? ( +

No playlists added.

+ ) : ( +
+ {playlists.map((playlist) => { + const label = playlist.title ?? playlist.url; + return ( +
+ +
+ + {label} + + + {playlistPath(playlist.url)} + +
+ {onRemove && ( + + )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/admin-allow-list-playlist-search.tsx b/apps/web/src/components/admin-allow-list-playlist-search.tsx new file mode 100644 index 0000000..071862d --- /dev/null +++ b/apps/web/src/components/admin-allow-list-playlist-search.tsx @@ -0,0 +1,149 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { useDebouncedValue } from "../hooks/use-debounced-value"; +import { fetchSearch } from "../lib/api-discovery"; +import { proxyImage } from "../lib/proxy"; +import type { AllowPlaylistInput } from "../types/allow-list"; +import type { PublicPlaylistInfo } from "../types/playlist"; + +type Props = { + title: string; + description: string; + addedUrls: string[]; + pending: boolean; + onAdd: (playlist: AllowPlaylistInput) => void; +}; + +function playlistPath(url: string): string { + const params = new URLSearchParams({ url }); + return `/playlist?${params.toString()}`; +} + +function playlistSourceUrl(input: string): string { + const trimmed = input.trim(); + if (trimmed.length === 0) return ""; + try { + const parsed = new URL(trimmed, "http://typetype.local"); + const sourceUrl = parsed.pathname === "/playlist" ? parsed.searchParams.get("url") : null; + return sourceUrl?.trim() || trimmed; + } catch { + return trimmed; + } +} + +export function AdminAllowListPlaylistSearch({ + title, + description, + addedUrls, + pending, + onAdd, +}: Props) { + const [term, setTerm] = useState(""); + const [url, setUrl] = useState(""); + const debounced = useDebouncedValue(term.trim(), 300); + const added = new Set(addedUrls); + const search = useQuery({ + queryKey: ["admin-allow-list-playlist-search", debounced], + queryFn: () => fetchSearch(debounced, 0), + enabled: debounced.length >= 2, + staleTime: 60 * 1000, + }); + const playlists = search.data?.playlists ?? []; + const playlistUrl = playlistSourceUrl(url); + const urlAlreadyAdded = added.has(playlistUrl); + + function addUrl() { + if (playlistUrl.length === 0 || urlAlreadyAdded || pending) return; + onAdd({ url: playlistUrl, title: null, thumbnailUrl: null, uploaderName: null }); + setUrl(""); + } + + function addPlaylist(playlist: PublicPlaylistInfo) { + onAdd({ + url: playlist.url, + title: playlist.title, + thumbnailUrl: playlist.thumbnailUrl, + uploaderName: playlist.uploaderName, + }); + } + + return ( +
+
+

{title}

+

{description}

+
+
+ setUrl(event.target.value)} + placeholder="Playlist URL" + className="h-10 flex-1 border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong" + /> + +
+ setTerm(event.target.value)} + placeholder="Playlist name" + className="h-10 w-full border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong" + /> +
+ {debounced.length < 2 ? ( +
Type at least two characters.
+ ) : search.isLoading ? ( +
Searching playlists...
+ ) : playlists.length === 0 ? ( +
No playlists found.
+ ) : ( +
+ {playlists.slice(0, 8).map((playlist) => { + const alreadyAdded = added.has(playlist.url); + return ( +
+ +
+ + {playlist.title} + +

{playlistPath(playlist.url)}

+
+ +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/admin-allow-list-section.tsx b/apps/web/src/components/admin-allow-list-section.tsx new file mode 100644 index 0000000..e33f26e --- /dev/null +++ b/apps/web/src/components/admin-allow-list-section.tsx @@ -0,0 +1,119 @@ +import { + useAdminAllowedPlaylists, + useAdminAllowListMutations, +} from "../hooks/use-admin-granular-allow-list"; +import { useAdminSettings } from "../hooks/use-admin-settings"; +import { useAllowedChannels } from "../hooks/use-allowed-channels"; +import { AdminAllowListChannelList } from "./admin-allow-list-channel-list"; +import { AdminAllowListForm } from "./admin-allow-list-form"; +import { AdminAllowListPlaylistList } from "./admin-allow-list-playlist-list"; +import { AdminAllowListPlaylistSearch } from "./admin-allow-list-playlist-search"; +import { AdminAllowListUsers } from "./admin-allow-list-users"; + +const MODE_BUTTON = + "h-8 border border-border px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-60"; + +type Props = { + enabled: boolean; + onToast: (message: string) => void; +}; + +export function AdminAllowListSection({ enabled, onToast }: Props) { + const adminSettings = useAdminSettings(enabled); + const { query, add, remove } = useAllowedChannels(); + const globalPlaylists = useAdminAllowedPlaylists(enabled); + const mutations = useAdminAllowListMutations(null); + const settings = adminSettings.query.data; + const channels = (query.data ?? []).filter((item) => item.global === true); + const playlists = globalPlaylists.data ?? []; + const instanceRestricted = settings?.accessMode === "allow_list"; + + function setMode(accessMode: "unrestricted" | "allow_list") { + if (!settings) return; + adminSettings.update.mutate( + { ...settings, accessMode }, + { + onSuccess: () => onToast("Allow list mode updated"), + onError: (error) => + onToast(error instanceof Error ? error.message : "Unable to update mode"), + }, + ); + } + + if (adminSettings.query.isPending) { + return ( +
+ Loading allow list... +
+ ); + } + + if (!settings || adminSettings.query.isError) { + return ( +
+ Unable to load allow list settings. +
+ ); + } + + return ( +
+
+
+
+

Entire instance

+

+ Restrict everyone to the admin allow list, including guests. +

+
+
+ + +
+
+
+ + item.url)} + pending={add.isPending} + onAdd={(channel) => + add.mutate({ + url: channel.url, + name: channel.name, + thumbnailUrl: channel.thumbnailUrl, + global: true, + }) + } + /> + + item.url)} + pending={mutations.addGlobalPlaylist.isPending} + onAdd={(playlist) => mutations.addGlobalPlaylist.mutate(playlist)} + /> + +
+ ); +} diff --git a/apps/web/src/components/admin-allow-list-user-detail.tsx b/apps/web/src/components/admin-allow-list-user-detail.tsx new file mode 100644 index 0000000..f8935e5 --- /dev/null +++ b/apps/web/src/components/admin-allow-list-user-detail.tsx @@ -0,0 +1,118 @@ +import { + useAdminAllowListMutations, + useAdminUserAllowList, +} from "../hooks/use-admin-granular-allow-list"; +import type { AdminAllowListUser } from "../types/allow-list"; +import { AdminAllowListChannelList } from "./admin-allow-list-channel-list"; +import { AdminAllowListForm } from "./admin-allow-list-form"; +import { AdminAllowListPlaylistList } from "./admin-allow-list-playlist-list"; +import { AdminAllowListPlaylistSearch } from "./admin-allow-list-playlist-search"; +import { AdminUserAvatar } from "./admin-user-avatar"; + +type Props = { + user: AdminAllowListUser; + onToast: (message: string) => void; +}; + +export function AdminAllowListUserDetail({ user, onToast }: Props) { + const detail = useAdminUserAllowList(user.id); + const mutations = useAdminAllowListMutations(user.id); + const data = detail.data; + const selected = data?.user ?? user; + const restricted = selected.accessMode === "allow_list"; + + function toggleMode() { + const accessMode = restricted ? "unrestricted" : "allow_list"; + mutations.userMode.mutate( + { id: selected.id, accessMode }, + { + onSuccess: () => + onToast(accessMode === "allow_list" ? "User restricted" : "User unrestricted"), + onError: (error) => + onToast(error instanceof Error ? error.message : "Unable to update user"), + }, + ); + } + + if (detail.isLoading || !data) { + return ( +
+ Loading user allow list... +
+ ); + } + + return ( +
+
+
+
+ +
+

+ {selected.name || selected.email} +

+

{selected.email}

+
+
+ +
+
+ + + + + item.url)} + pending={mutations.addUserChannel.isPending} + onAdd={(channel) => mutations.addUserChannel.mutate({ id: selected.id, channel })} + /> + mutations.removeUserChannel.mutate({ id: selected.id, url })} + /> + + item.url)} + pending={mutations.addUserPlaylist.isPending} + onAdd={(playlist) => mutations.addUserPlaylist.mutate({ id: selected.id, playlist })} + /> + mutations.removeUserPlaylist.mutate({ id: selected.id, url })} + /> +
+ ); +} diff --git a/apps/web/src/components/admin-allow-list-users.tsx b/apps/web/src/components/admin-allow-list-users.tsx new file mode 100644 index 0000000..14606e8 --- /dev/null +++ b/apps/web/src/components/admin-allow-list-users.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import { useAdminUserSearch } from "../hooks/use-admin-granular-allow-list"; +import type { AdminAllowListUser } from "../types/allow-list"; +import { AdminAllowListUserDetail } from "./admin-allow-list-user-detail"; + +type Props = { + enabled: boolean; + onToast: (message: string) => void; +}; + +export function AdminAllowListUsers({ enabled, onToast }: Props) { + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState(null); + const query = useAdminUserSearch(search, enabled); + const users = query.data ?? []; + + useEffect(() => { + if (!selected) return; + if (users.some((user) => user.id === selected.id)) return; + setSelected(null); + }, [selected, users]); + + return ( +
+
+

Specific users

+

Search a user, then configure only that account.

+
+ setSearch(event.target.value)} + placeholder="Search by email or name" + className="h-10 w-full border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong" + /> +
+ {search.trim().length < 2 ? ( +
Type at least two characters.
+ ) : query.isLoading ? ( +
Searching users...
+ ) : users.length === 0 ? ( +
No users found.
+ ) : ( +
+ {users.map((user) => ( + + ))} +
+ )} +
+ {selected && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/components/admin-console-header.tsx b/apps/web/src/components/admin-console-header.tsx index dab936f..5e95cc0 100644 --- a/apps/web/src/components/admin-console-header.tsx +++ b/apps/web/src/components/admin-console-header.tsx @@ -1,4 +1,4 @@ -type AdminSection = "settings" | "users" | "sessions" | "issues"; +import type { AdminSection } from "../lib/admin-console-section"; type Props = { section: AdminSection; @@ -6,6 +6,7 @@ type Props = { const TITLES: Record = { settings: "Admin Settings", + "allow-list": "Allow List", users: "User Management", sessions: "Active Sessions", issues: "Issue Triage", @@ -13,6 +14,7 @@ const TITLES: Record = { const DESCRIPTIONS: Record = { settings: "Global moderation and platform switches.", + "allow-list": "Control which channels are available in allow-list mode.", users: "Roles, suspension, and account recovery tools.", sessions: "Connected clients, playback state, and recent activity.", issues: "Bug reports, diagnostics, status updates, and GitHub sync.", diff --git a/apps/web/src/components/admin-console-nav.tsx b/apps/web/src/components/admin-console-nav.tsx index 60f89a0..f0a6e6d 100644 --- a/apps/web/src/components/admin-console-nav.tsx +++ b/apps/web/src/components/admin-console-nav.tsx @@ -1,4 +1,4 @@ -type AdminSection = "settings" | "users" | "sessions" | "issues"; +import type { AdminSection } from "../lib/admin-console-section"; type Item = { key: AdminSection; @@ -14,7 +14,7 @@ type Props = { export function AdminConsoleNav({ items, active, onSelect }: Props) { return (