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
8 changes: 3 additions & 5 deletions Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Protected user routes:
- `/history`, `/subscriptions`, `/subscriptions/feed`, `/subscriptions/shorts`
- `/playlists`, `/watch-later`, `/progress`, `/favorites`, `/settings`
- `/search-history`, `/blocked/channels`, `/blocked/videos`
- `/recommendations/events`, `/recommendations/feedback`, `/recommendations/home`
- `/recommendations/home`, `/recommendations/shorts`
- `/recommendations/home/metrics`, `/restore/pipepipe`, `/imports/youtube-takeout`, `/bug-reports`

Protected admin routes:
Expand All @@ -125,14 +125,12 @@ Protected admin routes:
- YouTube uses `/streams/native-manifest` first, then fallback to `/streams/manifest` on `422`
- NicoNico can return `422` on `/streams/manifest`; expected for non-DASH cases
- `GET /search-history` supports backend pagination: `page` and `limit`, total from `X-Total-Count`
- `settings.recommendationPersonalizationEnabled=false` disables recommendation event/feedback personalization while keeping Home recommendations available
- Shorts recommendations can emit `short_skip` with optional `watchDurationMs` for skip-depth signals
- Home and Shorts recommendations are fetched without client event reporting

## Recommendation and Privacy Flow

- Frontend stores user preference through `PUT /settings` (`recommendationPersonalizationEnabled`).
- Recommendation events are posted to `/recommendations/events` with optional fields such as `watchRatio` and `watchDurationMs`.
- Home feed requests call `/recommendations/home` with `intent` (currently default `auto`).
- Shorts feed requests call `/recommendations/shorts` with `intent` (currently default `auto`).
- Optional offline quality metrics are available via `/recommendations/home/metrics`.

## Import and Restore Flow
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/admin-console-header.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type AdminSection = "settings" | "users" | "issues";
type AdminSection = "settings" | "users" | "sessions" | "issues";

type Props = {
section: AdminSection;
Expand All @@ -7,12 +7,14 @@ type Props = {
const TITLES: Record<AdminSection, string> = {
settings: "Admin Settings",
users: "User Management",
sessions: "Active Sessions",
issues: "Issue Triage",
};

const DESCRIPTIONS: Record<AdminSection, string> = {
settings: "Global moderation and platform switches.",
users: "Roles, suspension, and account recovery tools.",
sessions: "Connected clients, playback state, and recent activity.",
issues: "Bug reports, diagnostics, status updates, and GitHub sync.",
};

Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/admin-console-nav.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type AdminSection = "settings" | "users" | "issues";
type AdminSection = "settings" | "users" | "sessions" | "issues";

type Item = {
key: AdminSection;
Expand All @@ -13,8 +13,8 @@ type Props = {

export function AdminConsoleNav({ items, active, onSelect }: Props) {
return (
<nav className="pt-3" aria-label="Admin sections">
<div className="grid grid-cols-1 gap-1 sm:grid-cols-3">
<nav className="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-4 sm:gap-1">
{items.map((item) => {
const isActive = item.key === active;
return (
Expand All @@ -23,7 +23,7 @@ export function AdminConsoleNav({ items, active, onSelect }: Props) {
type="button"
aria-current={isActive ? "page" : undefined}
onClick={() => onSelect(item.key)}
className={`border-b px-1 py-2 text-left font-mono text-xs uppercase tracking-[0.16em] transition-colors ${
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"
Expand Down
125 changes: 125 additions & 0 deletions apps/web/src/components/admin-session-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { toApiUrl } from "../lib/env";
import { getOpenMojiUrl, pickOpenMojiCode } from "../lib/openmoji";
import type { AdminSession } from "../types/admin";
import type { AuthUser } from "../types/auth";

type Props = {
session: AdminSession;
user?: AuthUser;
};

function formatDuration(value: number | null | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return "0:00";
const totalSeconds = Math.floor(value / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}

function initials(value: string): string {
return value
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}

function avatarUrl(user: AuthUser | undefined, session: AdminSession): string | null {
if (user?.avatarType === "emoji" && user.avatarCode) return getOpenMojiUrl(user.avatarCode);
if (user?.avatarUrl) return toApiUrl(user.avatarUrl);
if (user) return getOpenMojiUrl(pickOpenMojiCode(`${user.id}:${user.email}`));
if (session.userId) return getOpenMojiUrl(pickOpenMojiCode(session.userId));
return null;
}

export function AdminSessionCard({ session, user }: Props) {
const name =
user?.publicUsername ?? user?.name ?? session.username ?? session.userId ?? "Unknown user";
const device = [session.deviceName, session.deviceType].filter(Boolean).join(" - ") || "Browser";
const nowPlaying = session.nowPlaying;
const stateLabel = nowPlaying ? (nowPlaying.paused ? "Paused" : "Playing") : "Online";
const imageUrl = avatarUrl(user, session);
const progress = nowPlaying?.durationMs
? Math.min(100, Math.max(0, (nowPlaying.positionMs / nowPlaying.durationMs) * 100))
: 0;

return (
<article className="group overflow-hidden rounded-2xl border border-border bg-[#10151f] shadow-sm transition-colors hover:border-border-strong">
<div className="relative aspect-video overflow-hidden bg-surface-soft">
{nowPlaying?.thumbnail ? (
<img
src={nowPlaying.thumbnail}
alt=""
className="h-full w-full object-cover opacity-85 transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
/>
) : (
<div className="relative h-full w-full overflow-hidden bg-[#0b1220]">
<div className="absolute -left-12 top-4 h-40 w-40 rounded-full bg-[#00a4dc]/25 blur-3xl" />
<div className="absolute bottom-0 right-0 h-44 w-44 rounded-full bg-sky-500/15 blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.12),transparent_32%)]" />
<div className="absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-[#10151f] via-[#10151f]/45 to-transparent" />
<div className="absolute left-3 top-3 flex items-center gap-2 rounded-full bg-black/55 px-3 py-1 text-xs font-medium text-white backdrop-blur sm:left-4 sm:top-4">
<span
className={`h-2 w-2 rounded-full ${nowPlaying && !nowPlaying.paused ? "bg-emerald-400" : "bg-fg-soft"}`}
/>
{stateLabel}
</div>
<div className="absolute bottom-0 left-0 right-0 p-3 sm:p-4">
<div className="flex items-end gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/15 bg-white/10 font-mono text-sm font-semibold text-white shadow-lg backdrop-blur sm:h-12 sm:w-12">
{imageUrl ? (
<img
src={imageUrl}
alt={name}
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
initials(name) || "U"
)}
</div>
<div className="min-w-0 pb-0.5">
<h2 className="truncate text-base font-semibold text-white">{name}</h2>
<p className="truncate text-xs text-white/70">{device}</p>
</div>
</div>
</div>
</div>

<div className="space-y-4 p-3 sm:p-4">
{nowPlaying ? (
<div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-white">{nowPlaying.title}</p>
<p className="mt-1 truncate text-xs text-white/55">
{nowPlaying.channelName ?? "Video"}
</p>
</div>
<span className="self-start rounded-full bg-white/10 px-2 py-1 text-[11px] font-medium text-white/70">
{formatDuration(nowPlaying.positionMs)}
</span>
</div>
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/10">
<div className="h-full rounded-full bg-[#00a4dc]" style={{ width: `${progress}%` }} />
</div>
<div className="mt-2 flex items-center justify-between text-xs text-white/50">
<span>{formatDuration(nowPlaying.positionMs)}</span>
<span>{formatDuration(nowPlaying.durationMs)}</span>
</div>
</div>
) : (
<div className="rounded-xl border border-white/10 bg-[#0c1524] p-4">
<p className="text-sm font-medium text-white/85">No active playback</p>
<p className="mt-1 text-xs text-sky-200/55">The client is connected and ready.</p>
</div>
)}
</div>
</article>
);
}
50 changes: 50 additions & 0 deletions apps/web/src/components/admin-sessions-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useAdminSessions } from "../hooks/use-admin-sessions";
import { useAdminUsers } from "../hooks/use-admin-users";
import { AdminSessionCard } from "./admin-session-card";

type Props = {
enabled: boolean;
};

export function AdminSessionsSection({ enabled }: Props) {
const query = useAdminSessions(enabled);
const usersQuery = useAdminUsers(enabled, 1, 200).query;
const sessions = query.data ?? [];
const users = usersQuery.data?.items ?? [];

if (query.isPending) {
return (
<section className="rounded-lg border border-border bg-surface/70 p-4 text-center text-sm text-fg-muted sm:p-6">
Loading active sessions...
</section>
);
}

if (query.isError) {
return (
<section className="rounded-lg border border-danger bg-danger/30 p-4 text-center text-sm text-danger-strong sm:p-6">
Unable to load active sessions.
</section>
);
}

if (sessions.length === 0) {
return (
<section className="rounded-lg border border-border bg-surface/70 p-4 text-center text-sm text-fg-muted sm:p-6">
No active sessions are currently reported.
</section>
);
}

return (
<section className="grid grid-cols-[minmax(0,1fr)] gap-4 md:grid-cols-2 2xl:grid-cols-3">
{sessions.map((session) => (
<AdminSessionCard
key={session.id}
session={session}
user={users.find((item) => item.id === session.userId)}
/>
))}
</section>
);
}
6 changes: 6 additions & 0 deletions apps/web/src/components/admin-settings-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export function AdminSettingsPanel({ settings, pending, onToggle }: Props) {
pending={pending}
onClick={() => onToggle("forceEmailVerification")}
/>
<SettingToggle
label="Track active sessions"
value={settings.activeSessionsEnabled}
pending={pending}
onClick={() => onToggle("activeSessionsEnabled")}
/>
</div>
</section>
);
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/components/continue-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { Link } from "@tanstack/react-router";
import { useWatchPrefetch } from "../hooks/use-watch-prefetch";
import { formatDuration } from "../lib/format";
import type { HistoryItem } from "../types/user";
import { VideoProgressBar } from "./video-progress-bar";

type ContinueCardProps = {
item: HistoryItem;
};

export function ContinueCard({ item }: ContinueCardProps) {
const pct = Math.min(100, Math.round((item.progress / item.duration) * 100));
const prefetch = useWatchPrefetch(item.url);

return (
Expand All @@ -30,9 +30,7 @@ export function ContinueCard({ item }: ContinueCardProps) {
<span className="absolute bottom-1 right-1 bg-black/80 text-white text-[10px] font-medium px-1 py-0.5 rounded">
{formatDuration(item.duration)}
</span>
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-surface-soft">
<div className="h-full bg-danger-strong" style={{ width: `${pct}%` }} />
</div>
<VideoProgressBar progress={item.progress} duration={item.duration} />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-fg line-clamp-2 leading-snug group-hover:text-white">
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/history-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { formatDuration } from "../lib/format";
import { proxyImage } from "../lib/proxy";
import type { HistoryItem } from "../types/user";
import { HistoryChannelAvatar } from "./history-channel-avatar";
import { VideoProgressBar } from "./video-progress-bar";

function XIcon() {
return (
Expand Down Expand Up @@ -67,6 +68,7 @@ export function HistoryCard({ item, onRemove, index }: HistoryCardProps) {
{formatDuration(item.duration)}
</span>
)}
<VideoProgressBar progress={item.progress} duration={item.duration} alwaysVisible />
<button
type="button"
onClick={(e) => {
Expand Down
23 changes: 3 additions & 20 deletions apps/web/src/components/home-recommendations-section.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useBlockedFilter } from "../hooks/use-blocked-filter";
import { useHomeRecommendations } from "../hooks/use-home-recommendations";
import { trackRecommendationEvent } from "../lib/recommendation-tracker";
import { HomeFallbackSection } from "./home-fallback-section";
import { ScrollSentinel } from "./scroll-sentinel";
import { VideoCardSkeleton } from "./video-card-skeleton";
Expand All @@ -19,32 +18,16 @@ function SkeletonGrid() {
}

export function HomeRecommendationsSection() {
const {
streams,
serviceId,
intent,
isLoading,
isError,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useHomeRecommendations();
const { streams, isLoading, isError, hasNextPage, isFetchingNextPage, fetchNextPage } =
useHomeRecommendations();
const { filter } = useBlockedFilter();
const filtered = filter(streams);

if (isLoading) return <SkeletonGrid />;
if (isError || filtered.length === 0) return <HomeFallbackSection />;
return (
<>
<VideoGrid
streams={filtered}
onCardOpen={(stream) => {
trackRecommendationEvent("click", stream, { serviceId, intent });
}}
onCardImpression={(stream) => {
trackRecommendationEvent("impression", stream, { serviceId, intent });
}}
/>
<VideoGrid streams={filtered} />
<ScrollSentinel onIntersect={fetchNextPage} enabled={hasNextPage && !isFetchingNextPage} />
</>
);
Expand Down
10 changes: 0 additions & 10 deletions apps/web/src/components/nav-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,6 @@ export const NAV_ITEMS: NavItem[] = [
</>
),
},
{
label: "Profile",
to: "/profile",
icon: (
<>
<circle cx="12" cy="8" r="3" />
<path d="M5 21v-2a4 4 0 0 1 4-4h6a4 4 0 0 1 4 4v2" />
</>
),
},
{
label: "Privacy",
to: "/privacy",
Expand Down
Loading