v3: added index default tab selection setting, fixed notification pages not loading, reduced invite token length, global player state survives page reloads, many visual fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s
This commit is contained in:
93
src/components/DumpFab.tsx
Normal file
93
src/components/DumpFab.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
|
||||
const DumpCreateModal = lazy(() =>
|
||||
import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal }))
|
||||
);
|
||||
|
||||
/**
|
||||
* Floating "new dump" button shown once the header has scrolled out of view.
|
||||
* Sits in the bottom-right corner and lifts above the global player (which is
|
||||
* full-width on mobile) so the two never overlap. Only rendered for signed-in
|
||||
* users — dump creation requires an account.
|
||||
*
|
||||
* Mounted once at the app root (not per-page) since some pages render their own
|
||||
* header instead of going through PageShell. Visibility is derived from a live
|
||||
* read of the current page's `.app-header`, so it survives client-side
|
||||
* navigation and lazily-mounted route headers.
|
||||
*/
|
||||
export function DumpFab() {
|
||||
const { user } = useAuth();
|
||||
const location = useLocation();
|
||||
const [headerHidden, setHeaderHidden] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// Reserve clearance at the end of the scroll so the floating button never
|
||||
// hides the last content when scrolled all the way down.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
document.body.classList.add("has-fab");
|
||||
return () => document.body.classList.remove("has-fab");
|
||||
}, [user]);
|
||||
|
||||
// Reveal the button once the header's bottom edge scrolls above the viewport.
|
||||
// Re-reading the header on every tick keeps this correct across navigation
|
||||
// and lazily-mounted pages, where the header element is replaced.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const update = () => {
|
||||
const header = document.querySelector(".app-header");
|
||||
setHeaderHidden(!!header && header.getBoundingClientRect().bottom <= 0);
|
||||
};
|
||||
update();
|
||||
globalThis.addEventListener("scroll", update, { passive: true });
|
||||
globalThis.addEventListener("resize", update);
|
||||
// Body height changes when a lazy route finishes mounting — re-check then,
|
||||
// since no scroll/resize event fires in that case.
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(document.body);
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", update);
|
||||
globalThis.removeEventListener("resize", update);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [user, location.pathname]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`dump-fab${headerHidden ? " dump-fab--visible" : ""}`}
|
||||
aria-label={t`New dump`}
|
||||
title={t`New dump`}
|
||||
aria-hidden={!headerHidden}
|
||||
tabIndex={headerHidden ? 0 : -1}
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
{/* Symmetric SVG plus — centers exactly via flex, regardless of the
|
||||
theme's font metrics (a text "+" sits slightly off the box center). */}
|
||||
<svg
|
||||
className="dump-fab-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
{modalOpen && (
|
||||
<Suspense>
|
||||
<DumpCreateModal onClose={() => setModalOpen(false)} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,16 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
|
||||
import { useTabParam } from "../hooks/useTabParam.ts";
|
||||
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
|
||||
import { TabBar } from "./TabBar.tsx";
|
||||
|
||||
export function FeedTabBar() {
|
||||
const { user } = useAuth();
|
||||
const [tab, setTab] = useTabParam<FeedTab>(FEED_TABS, "hot");
|
||||
const [preferredTab] = useDefaultFeedTab();
|
||||
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
||||
? "hot"
|
||||
: preferredTab;
|
||||
const [tab, setTab] = useTabParam<FeedTab>(FEED_TABS, defaultTab);
|
||||
|
||||
const tabs = [
|
||||
{ key: "hot" as FeedTab, label: <Trans>Hot</Trans> },
|
||||
|
||||
@@ -10,8 +10,16 @@ function itemKey(
|
||||
}
|
||||
|
||||
export function GlobalPlayer() {
|
||||
const { current, stop, seekRef, toggleRef, onPlayStateChange, onTimeUpdate } =
|
||||
useContext(PlayerContext);
|
||||
const {
|
||||
current,
|
||||
startTime,
|
||||
autoplay,
|
||||
stop,
|
||||
seekRef,
|
||||
toggleRef,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
} = useContext(PlayerContext);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [reduced, setReduced] = useState(false);
|
||||
const [prevKey, setPrevKey] = useState(itemKey(current));
|
||||
@@ -98,7 +106,8 @@ export function GlobalPlayer() {
|
||||
src={current.fileUrl}
|
||||
kind={current.mimeType.startsWith("video/") ? "video" : "audio"}
|
||||
mime={current.mimeType}
|
||||
autoplay
|
||||
autoplay={autoplay}
|
||||
startTime={startTime}
|
||||
onPlayStateChange={onPlayStateChange}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
seekRef={seekRef}
|
||||
|
||||
@@ -129,6 +129,7 @@ interface MediaPlayerProps {
|
||||
mime?: string;
|
||||
poster?: string;
|
||||
autoplay?: boolean;
|
||||
startTime?: number;
|
||||
onPlayStateChange?: (playing: boolean) => void;
|
||||
onTimeUpdate?: (time: number, duration: number) => void;
|
||||
seekRef?: { current: ((t: number) => void) | null };
|
||||
@@ -142,6 +143,7 @@ export function MediaPlayer(
|
||||
mime,
|
||||
poster,
|
||||
autoplay,
|
||||
startTime,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
seekRef,
|
||||
@@ -209,6 +211,21 @@ export function MediaPlayer(
|
||||
|
||||
// ── Effects ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Resume to a restored offset once metadata (and thus seekability) is available.
|
||||
// Runs only on mount for the initial offset — later seeks go through seekTo.
|
||||
useEffect(() => {
|
||||
if (!startTime) return;
|
||||
const a = mediaRef.current!;
|
||||
const apply = () => {
|
||||
a.currentTime = startTime;
|
||||
setCurrent(startTime);
|
||||
};
|
||||
if (a.readyState >= 1) apply();
|
||||
else a.addEventListener("loadedmetadata", apply, { once: true });
|
||||
return () => a.removeEventListener("loadedmetadata", apply);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Autoplay on mount (e.g. triggered by play() in PlayerContext)
|
||||
useEffect(() => {
|
||||
if (!autoplay) return;
|
||||
|
||||
Reference in New Issue
Block a user