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

This commit is contained in:
khannurien
2026-06-27 19:44:35 +00:00
parent 88f1eb3d03
commit c29f45fbc8
25 changed files with 733 additions and 278 deletions

View 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>
)}
</>
);
}

View File

@@ -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> },

View File

@@ -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}

View File

@@ -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;