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:
parent
1b9e4c56a2
commit
8b944504a9
2 changed files with 211 additions and 72 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue