feat(ui): account hover-popup, upload-date chips, top clear-filters bar

- Header: avatar opens an account popover (hover + click) with identity, admin
  badge and Sign out; removes the standalone logout icon.
- Sidebar: relative upload-date chips (24h/1w/1m/6m/1y) backed by max_age_days,
  with a Custom toggle revealing the from/to range; mutually exclusive.
- Sidebar: 'Filters · N active · Clear all' moved to the top; drop bottom button.
This commit is contained in:
npeter83 2026-06-11 17:39:24 +02:00
parent 3afb284d46
commit 3e1823b08b
2 changed files with 211 additions and 72 deletions

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useRef, useState } from "react";
import {
LayoutGrid,
List,
@ -6,6 +6,7 @@ import {
Moon,
Palette,
Search,
Shield,
Sun,
} from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
@ -129,19 +130,78 @@ export default function Header({
)}
</div>
<div className="flex items-center gap-2 pl-2">
{me.avatar_url ? (
<img src={me.avatar_url} alt="" className="w-8 h-8 rounded-full" />
) : (
<div className="w-8 h-8 rounded-full bg-card grid place-items-center text-xs">
{me.email[0]?.toUpperCase()}
</div>
)}
<IconBtn onClick={logout} title="Sign out">
<LogOut className="w-5 h-5" />
</IconBtn>
<div className="pl-1">
<AccountMenu me={me} logout={logout} />
</div>
</div>
</header>
);
}
function Avatar({ me, className = "" }: { me: Me; className?: string }) {
return me.avatar_url ? (
<img src={me.avatar_url} alt="" className={`rounded-full ${className}`} />
) : (
<div className={`rounded-full bg-card grid place-items-center font-semibold ${className}`}>
{(me.display_name?.[0] ?? me.email[0] ?? "?").toUpperCase()}
</div>
);
}
function AccountMenu({ me, logout }: { me: Me; logout: () => void }) {
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
function openNow() {
if (closeTimer.current) clearTimeout(closeTimer.current);
setOpen(true);
}
function closeSoon() {
closeTimer.current = setTimeout(() => setOpen(false), 220);
}
return (
<div
className="relative"
onMouseEnter={openNow}
onMouseLeave={closeSoon}
>
<button
onClick={() => setOpen((o) => !o)}
title={me.email}
className="block rounded-full ring-2 ring-transparent hover:ring-border focus:outline-none focus:ring-accent transition"
>
<Avatar me={me} className="w-8 h-8 text-xs" />
</button>
{open && (
<div className="absolute right-0 mt-2 w-64 rounded-xl border border-border bg-surface shadow-2xl p-3 z-30">
<div className="flex items-center gap-3 pb-3 border-b border-border">
<Avatar me={me} className="w-10 h-10 text-sm shrink-0" />
<div className="min-w-0">
<div className="text-sm font-semibold truncate">
{me.display_name ?? me.email.split("@")[0]}
</div>
<div className="text-xs text-muted truncate">{me.email}</div>
</div>
</div>
{me.role === "admin" && (
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
<Shield className="w-3.5 h-3.5" />
Admin
</div>
)}
<button
onClick={logout}
className="mt-2 w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-4 h-4" />
Sign out
</button>
</div>
)}
</div>
);
}

View file

@ -1,7 +1,27 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { X } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api";
const DATE_PRESETS: { days: number; label: string }[] = [
{ days: 1, label: "24h" },
{ days: 7, label: "1 week" },
{ days: 30, label: "1 month" },
{ days: 180, label: "6 months" },
{ days: 365, label: "1 year" },
];
// Filter values owned by the sidebar (everything except the header search `q`).
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q"> = {
tags: [],
tagMode: "or",
sort: "newest",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const SORTS = [
{ id: "newest", label: "Newest" },
{ id: "oldest", label: "Oldest" },
@ -56,6 +76,7 @@ export default function Sidebar({
const tags = tagsQuery.data ?? [];
const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic");
const [customDates, setCustomDates] = useState(false);
function toggleTag(id: number) {
const has = filters.tags.includes(id);
@ -65,19 +86,44 @@ export default function Sidebar({
});
}
const active =
filters.tags.length > 0 ||
!filters.includeNormal ||
filters.includeShorts ||
filters.includeLive ||
filters.show !== "unwatched" ||
filters.sort !== "newest" ||
!!filters.channelId ||
!!filters.dateFrom ||
!!filters.dateTo;
const dateActive = !!filters.maxAgeDays || !!filters.dateFrom || !!filters.dateTo;
const contentChanged =
!filters.includeNormal || filters.includeShorts || filters.includeLive;
const activeCount =
filters.tags.length +
(filters.show !== "unwatched" ? 1 : 0) +
(filters.sort !== "newest" ? 1 : 0) +
(contentChanged ? 1 : 0) +
(filters.channelId ? 1 : 0) +
(dateActive ? 1 : 0);
const active = activeCount > 0;
function clearAll() {
setCustomDates(false);
setFilters({
...DEFAULT_SIDEBAR_FILTERS,
q: filters.q,
});
}
return (
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-5 hidden md:block">
<div className="flex items-center justify-between -mb-1">
<div className="text-sm font-semibold">
Filters
{activeCount > 0 && (
<span className="ml-1.5 text-xs font-medium text-accent">{activeCount} active</span>
)}
</div>
<button
onClick={clearAll}
disabled={!active}
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition"
>
Clear all
</button>
</div>
{filters.channelId && (
<Section title="Channel">
<button
@ -123,14 +169,59 @@ export default function Sidebar({
</Section>
<Section title="Upload date">
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap gap-1.5">
{DATE_PRESETS.map((p) => {
const activePreset = filters.maxAgeDays === p.days && !customDates;
return (
<button
key={p.days}
onClick={() =>
setFilters({
...filters,
maxAgeDays: activePreset ? undefined : p.days,
dateFrom: undefined,
dateTo: undefined,
})
}
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${
activePreset
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border hover:border-accent"
}`}
>
{p.label}
</button>
);
})}
<button
onClick={() => {
const next = !customDates;
setCustomDates(next);
if (next) setFilters({ ...filters, maxAgeDays: undefined });
}}
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${
customDates || filters.dateFrom || filters.dateTo
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border hover:border-accent"
}`}
>
Custom
</button>
</div>
{(customDates || filters.dateFrom || filters.dateTo) && (
<div className="flex flex-col gap-1.5 mt-2">
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">From</span>
<input
type="date"
value={filters.dateFrom ?? ""}
onChange={(e) =>
setFilters({ ...filters, dateFrom: e.target.value || undefined })
setFilters({
...filters,
dateFrom: e.target.value || undefined,
maxAgeDays: undefined,
})
}
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
@ -141,20 +232,27 @@ export default function Sidebar({
type="date"
value={filters.dateTo ?? ""}
onChange={(e) =>
setFilters({ ...filters, dateTo: e.target.value || undefined })
setFilters({
...filters,
dateTo: e.target.value || undefined,
maxAgeDays: undefined,
})
}
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
</label>
{(filters.dateFrom || filters.dateTo) && (
<button
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
onClick={() =>
setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })
}
className="text-[11px] text-muted hover:text-accent self-end"
>
clear dates
</button>
)}
</div>
)}
</Section>
<Section title="Content type">
@ -218,25 +316,6 @@ export default function Sidebar({
</Section>
)}
{active && (
<button
onClick={() =>
setFilters({
tags: [],
tagMode: "or",
q: filters.q,
sort: "newest",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
})
}
className="w-full text-xs py-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
>
Clear filters
</button>
)}
</aside>
);
}