Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7e7e903
fix: support YouTube Shorts path redirects
Priveetee Aug 6, 2026
4d983a7
chore: prepare frontend 1.4.0
Priveetee Aug 6, 2026
88ed482
feat: add notification toast preview
Priveetee Aug 7, 2026
8441f2e
style: compact notification toast preview
Priveetee Aug 7, 2026
233b5c8
feat: track unseen notification uploads
Priveetee Aug 7, 2026
9b5d7d4
feat: show new subscription upload toasts
Priveetee Aug 7, 2026
227ea12
fix: preserve resumed sabr playback position
Priveetee Aug 7, 2026
e3677e5
fix: stabilize safari sabr resume playback
Priveetee Aug 7, 2026
634fee3
fix: update vulnerable frontend build dependencies
Priveetee Aug 8, 2026
2ef95cc
chore: update frontend dependencies
Priveetee Aug 8, 2026
7bba449
feat: add RSS frontend API
Priveetee Aug 9, 2026
868a9d8
feat: add RSS feed editor fields
Priveetee Aug 9, 2026
90cc53c
feat: add RSS feed editor dialogs
Priveetee Aug 9, 2026
d3760d3
feat: add RSS feed controls
Priveetee Aug 9, 2026
51251ca
feat: add RSS account settings
Priveetee Aug 9, 2026
484c311
feat: add RSS administration controls
Priveetee Aug 9, 2026
cee70fc
feat: add RSS administration section
Priveetee Aug 9, 2026
a103f4c
feat: add RSS creation shortcuts
Priveetee Aug 9, 2026
564367b
test: cover RSS frontend contracts
Priveetee Aug 9, 2026
b3a0670
fix: proxy RSS feed collection
Priveetee Aug 9, 2026
2ea43d4
feat: add subscription live stream visibility setting
Priveetee Aug 9, 2026
6115928
chore: reconcile main into dev
Priveetee Aug 10, 2026
c44051d
chore: prepare frontend 1.5.0
Priveetee Aug 10, 2026
fcbe3c1
chore: update mse player to 0.1.44
Priveetee Aug 10, 2026
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
4 changes: 2 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@typetype/web",
"private": true,
"version": "1.4.0",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -13,7 +13,7 @@
"dependencies": {
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
"@typetype/mse": "0.1.43",
"@typetype/mse": "0.1.44",
"@vidstack/react": "1.12.13",
"dashjs": "^5.2.0",
"hls.js": "1.6.16",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/admin-console-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const TITLES: Record<AdminSection, string> = {
"allow-list": "Allow List",
users: "User Management",
sessions: "Active Sessions",
rss: "Private RSS",
issues: "Issue Triage",
};

Expand All @@ -17,6 +18,7 @@ const DESCRIPTIONS: Record<AdminSection, string> = {
"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.",
rss: "Availability, resource limits, account access, and private feeds.",
issues: "Bug reports, diagnostics, status updates, and GitHub sync.",
};

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/admin-console-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type Props = {
export function AdminConsoleNav({ items, active, onSelect }: Props) {
return (
<nav className="min-w-0 overflow-x-auto pt-3" aria-label="Admin sections">
<div className="flex min-w-max gap-3 sm:grid sm:min-w-0 sm:grid-cols-5 sm:gap-1">
<div className="flex min-w-max gap-3 sm:grid sm:min-w-0 sm:grid-flow-col sm:auto-cols-fr sm:gap-1">
{items.map((item) => {
const isActive = item.key === active;
return (
Expand Down
157 changes: 157 additions & 0 deletions apps/web/src/components/admin-rss-inventory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { Power, ShieldCheck, ShieldOff, Trash2 } from "lucide-react";
import type { ReactNode } from "react";
import type { AdminRssFeedItem } from "../types/rss";

type FeedRowProps = {
item: AdminRssFeedItem;
pending: boolean;
onToggleFeed: () => void;
onToggleUser: () => void;
onRevoke: () => void;
};

export function AdminRssFeedRow({
item,
pending,
onToggleFeed,
onToggleUser,
onRevoke,
}: FeedRowProps) {
const scope =
item.feed.scope === "channels"
? `${item.feed.channelUrls.length} selected ${item.feed.channelUrls.length === 1 ? "channel" : "channels"}`
: "All subscriptions";
const status = item.userSuspended
? "Account suspended"
: !item.userRssEnabled
? "Account RSS off"
: item.feed.enabled
? "Active"
: "Feed off";
const active = status === "Active";

return (
<article
className={`grid gap-3 px-4 py-3.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto] md:items-center ${active ? "" : "opacity-65"}`}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-medium text-fg">{item.feed.name}</h3>
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${active ? "bg-emerald-500" : "bg-fg-soft"}`}
/>
<span className="text-[11px] text-fg-soft">{status}</span>
</div>
<p className="mt-1 truncate text-xs text-fg-muted">
{item.userName} · {item.userEmail}
</p>
</div>
<div className="min-w-0 text-xs text-fg-soft">
<p className="truncate">{scope}</p>
<p className="mt-1">Last read: {formatLastUsed(item.feed.lastUsedAt)}</p>
</div>
<div className="flex items-center justify-end gap-1">
<ActionButton
label={item.feed.enabled ? "Disable feed" : "Enable feed"}
disabled={pending}
onClick={onToggleFeed}
>
<Power size={14} />
</ActionButton>
<ActionButton
label={item.userRssEnabled ? "Disable RSS for account" : "Enable RSS for account"}
disabled={pending}
onClick={onToggleUser}
>
{item.userRssEnabled ? <ShieldOff size={14} /> : <ShieldCheck size={14} />}
</ActionButton>
<ActionButton label="Revoke feed" disabled={pending} onClick={onRevoke} danger>
<Trash2 size={14} />
</ActionButton>
</div>
</article>
);
}

export function AdminRssPagination({
page,
totalPages,
total,
pageSize,
pending,
onPage,
}: {
page: number;
totalPages: number;
total: number;
pageSize: number;
pending: boolean;
onPage: (page: number) => void;
}) {
if (total <= pageSize) return null;
return (
<div className="flex items-center justify-end gap-3 px-1 text-xs text-fg-muted">
<button
type="button"
disabled={pending || page <= 1}
onClick={() => onPage(page - 1)}
className="h-8 rounded-md border border-border px-3 disabled:opacity-40"
>
Prev
</button>
<span>
{page} / {totalPages}
</span>
<button
type="button"
disabled={pending || page >= totalPages}
onClick={() => onPage(page + 1)}
className="h-8 rounded-md border border-border px-3 disabled:opacity-40"
>
Next
</button>
</div>
);
}

export function AdminRssStatus({ children, danger }: { children: ReactNode; danger?: boolean }) {
return (
<div
className={`rounded-lg border px-4 py-5 text-sm ${danger ? "border-danger/40 bg-danger/10 text-danger-strong" : "border-border bg-surface text-fg-muted"}`}
>
{children}
</div>
);
}

function ActionButton({
label,
disabled,
onClick,
danger,
children,
}: {
label: string;
disabled: boolean;
onClick: () => void;
danger?: boolean;
children: ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className={`flex h-8 w-8 items-center justify-center rounded-md disabled:opacity-40 ${danger ? "text-fg-soft hover:bg-danger/10 hover:text-danger" : "text-fg-soft hover:bg-surface-strong hover:text-fg"}`}
>
{children}
</button>
);
}

function formatLastUsed(timestamp: number | null): string {
if (timestamp === null) return "Never";
return new Date(timestamp).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
110 changes: 110 additions & 0 deletions apps/web/src/components/admin-rss-policy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useMemo } from "react";
import type { AdminSettings } from "../types/admin";

type Props = {
value: AdminSettings;
saved: AdminSettings;
pending: boolean;
onChange: (value: AdminSettings) => void;
onSave: () => void;
};

const NUMBER_FIELDS = [
["rssMaxFeedsPerUser", "Feeds per account", 1, 100],
["rssMaxItems", "Items per feed", 1, 200],
["rssMinimumPollMinutes", "Minimum poll (minutes)", 1, 1440],
["rssRateLimitPerMinute", "Requests per minute", 1, 600],
] as const;

export function AdminRssPolicy({ value, saved, pending, onChange, onSave }: Props) {
const changed = useMemo(() => JSON.stringify(value) !== JSON.stringify(saved), [saved, value]);
const validUrl = !value.rssEnabled || isPublicHttpUrl(value.rssPublicBaseUrl);
const validNumbers = NUMBER_FIELDS.every(
([key, , min, max]) => Number.isInteger(value[key]) && value[key] >= min && value[key] <= max,
);

return (
<section className="space-y-4 border-y border-border py-4">
<div className="flex items-center justify-between gap-4 px-1">
<div>
<h2 className="text-sm font-semibold text-fg">Instance policy</h2>
<p className="mt-1 text-xs text-fg-soft">
Disabling RSS keeps existing feed configuration intact.
</p>
</div>
<button
type="button"
role="switch"
aria-label="Enable private RSS feeds"
aria-checked={value.rssEnabled}
onClick={() => onChange({ ...value, rssEnabled: !value.rssEnabled })}
className={`relative h-5 w-10 shrink-0 rounded-full transition-colors ${value.rssEnabled ? "bg-fg" : "bg-surface-soft"}`}
>
<span
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-surface transition-transform ${value.rssEnabled ? "translate-x-5" : "translate-x-0"}`}
/>
</button>
</div>
<label className="block space-y-1.5">
<span className="text-xs font-medium text-fg-muted">Public instance URL</span>
<input
type="url"
value={value.rssPublicBaseUrl ?? ""}
placeholder="https://watch.example.com"
onChange={(event) => onChange({ ...value, rssPublicBaseUrl: event.target.value || null })}
className="h-10 w-full rounded-md border border-border-strong bg-surface px-3 text-sm text-fg outline-none focus:border-fg-soft"
/>
{!validUrl && (
<span className="block text-xs text-danger-strong">
Enter the public HTTP or HTTPS URL of this instance.
</span>
)}
</label>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{NUMBER_FIELDS.map(([key, label, min, max]) => (
<label key={key} className="space-y-1.5">
<span className="text-xs font-medium text-fg-muted">{label}</span>
<input
type="number"
min={min}
max={max}
value={value[key]}
onChange={(event) => {
const next = event.target.valueAsNumber;
if (!Number.isNaN(next)) onChange({ ...value, [key]: next });
}}
className="h-10 w-full rounded-md border border-border-strong bg-surface px-3 text-sm text-fg outline-none focus:border-fg-soft"
/>
</label>
))}
</div>
<div className="flex justify-end">
<button
type="button"
disabled={!changed || !validUrl || !validNumbers || pending}
onClick={onSave}
className="h-9 rounded-md bg-fg px-4 text-sm font-medium text-app hover:bg-fg-strong disabled:cursor-not-allowed disabled:opacity-40"
>
{pending ? "Saving..." : "Save policy"}
</button>
</div>
</section>
);
}

function isPublicHttpUrl(value: string | null): boolean {
if (!value) return false;
try {
const url = new URL(value);
return (
(url.protocol === "http:" || url.protocol === "https:") &&
Boolean(url.hostname) &&
!url.username &&
!url.password &&
!url.search &&
!url.hash
);
} catch {
return false;
}
}
Loading