Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Binary file added apps/web/public/family-list-blocked.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
98 changes: 98 additions & 0 deletions apps/web/src/components/admin-allow-list-channel-list.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="8"
height="8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
}

type Props = {
title?: string;
channels: AllowedChannelItem[];
onRemove?: (url: string) => void;
};

export function AdminAllowListChannelList({
title = "Allowed channels",
channels,
onRemove,
}: Props) {
return (
<section className="border-t border-border pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-fg">{title}</h2>
<p className="mt-1 text-xs text-fg-soft">
Channels available when allow-list mode is enabled.
</p>
</div>
<span className="text-xs text-fg-soft">
{channels.length} {channels.length === 1 ? "channel" : "channels"}
</span>
</div>
{channels.length === 0 ? (
<p className="py-3 text-sm text-fg-soft">No channels added.</p>
) : (
<div className="border-y border-border">
{channels.map((item) => {
const label = item.name ?? item.url;
const typeTypeUrl = channelRoutePath(item.url);
return (
<div
key={item.url}
className="group relative flex items-center gap-3 border-b border-border px-0 py-3 last:border-b-0"
>
<ChannelAvatar
src={item.thumbnailUrl ?? ""}
name={label}
className="h-9 w-9 shrink-0 opacity-90"
/>
<div className="min-w-0 flex-1">
<ChannelRouteLink
url={item.url}
className="block truncate text-xs font-medium text-fg hover:underline"
>
{label}
</ChannelRouteLink>
<ChannelRouteLink
url={item.url}
className="block truncate text-[10px] text-fg-soft hover:text-fg-muted"
>
{typeTypeUrl}
</ChannelRouteLink>
</div>
{onRemove && (
<button
type="button"
onClick={() => onRemove(item.url)}
aria-label={`Remove ${label}`}
className="flex h-7 w-7 shrink-0 items-center justify-center text-fg-soft transition-colors hover:text-fg"
>
<XIcon />
</button>
)}
</div>
);
})}
</div>
)}
</section>
);
}
101 changes: 101 additions & 0 deletions apps/web/src/components/admin-allow-list-form.tsx
Original file line number Diff line number Diff line change
@@ -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<string>): 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 (
<section className="border-t border-border pt-4">
<div className="mb-3 flex flex-col gap-1">
<h2 className="text-sm font-semibold text-fg">{title}</h2>
<p className="text-xs text-fg-soft">{description}</p>
</div>
<input
value={term}
onChange={(event) => 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"
/>
<div className="mt-3 border-y border-border">
{debounced.length < 2 ? (
<div className="px-4 py-5 text-sm text-fg-soft">
Type at least two characters to search channels.
</div>
) : search.isLoading ? (
<div className="px-4 py-5 text-sm text-fg-soft">Searching channels...</div>
) : channels.length === 0 ? (
<div className="px-4 py-5 text-sm text-fg-soft">
No channels found. Try the exact channel name or handle.
</div>
) : (
<div className="divide-y divide-border">
{channels.slice(0, 8).map((channel) => {
const alreadyAdded = isTrusted(channel, trusted);
return (
<div key={channel.url} className="flex items-center gap-3 px-3 py-2.5">
<ChannelAvatar
src={proxyImage(channel.thumbnailUrl)}
name={channel.name}
className="h-10 w-10"
/>
<div className="min-w-0 flex-1">
<ChannelRouteLink
url={channel.url}
className="truncate text-sm font-medium text-fg hover:underline"
>
{channel.name}
</ChannelRouteLink>
<p className="truncate text-xs text-fg-soft">
{formatSubscribers(channel.subscriberCount)}
</p>
</div>
<button
type="button"
disabled={alreadyAdded || pending}
onClick={() => onAdd(channel)}
className={`h-8 shrink-0 border px-3 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
alreadyAdded
? "border-border text-fg-soft"
: "border-fg bg-fg text-app hover:bg-fg-strong"
}`}
>
{alreadyAdded ? "Added" : "Add"}
</button>
</div>
);
})}
</div>
)}
</div>
</section>
);
}
96 changes: 96 additions & 0 deletions apps/web/src/components/admin-allow-list-playlist-list.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="8"
height="8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
}

export function AdminAllowListPlaylistList({ title, playlists, onRemove }: Props) {
return (
<section className="border-t border-border pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-fg">{title}</h2>
<span className="text-xs text-fg-soft">
{playlists.length} {playlists.length === 1 ? "playlist" : "playlists"}
</span>
</div>
{playlists.length === 0 ? (
<p className="py-3 text-sm text-fg-soft">No playlists added.</p>
) : (
<div className="border-y border-border">
{playlists.map((playlist) => {
const label = playlist.title ?? playlist.url;
return (
<div
key={playlist.url}
className="flex items-center gap-3 border-b border-border px-0 py-3 last:border-b-0"
>
<img
src={proxyImage(playlist.thumbnailUrl ?? "")}
alt=""
className="h-10 w-16 shrink-0 object-cover"
loading="lazy"
decoding="async"
/>
<div className="min-w-0 flex-1">
<Link
to="/playlist"
search={{ list: undefined, url: playlist.url }}
className="block truncate text-sm font-medium text-fg hover:underline"
>
{label}
</Link>
<Link
to="/playlist"
search={{ list: undefined, url: playlist.url }}
className="block truncate text-xs text-fg-soft hover:text-fg-muted"
>
{playlistPath(playlist.url)}
</Link>
</div>
{onRemove && (
<button
type="button"
onClick={() => onRemove(playlist.url)}
aria-label={`Remove ${label}`}
className="flex h-7 w-7 shrink-0 items-center justify-center text-fg-soft transition-colors hover:text-fg"
>
<XIcon />
</button>
)}
</div>
);
})}
</div>
)}
</section>
);
}
Loading