Compare commits

...

2 Commits

Author SHA1 Message Date
khannurien
dde3fe8fe2 v3: type checking and lint pass clean — thumbnail placeholder takes file-dump props, no manual memo the compiler can't keep, no setState straight in effect bodies
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:42:32 +00:00
khannurien
6026286bbf v3: journal becomes the default feed tab for visitors and for users who haven't picked one
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:39:17 +00:00
12 changed files with 49 additions and 39 deletions

View File

@@ -12,6 +12,7 @@ import { RestrictedGuest } from "./pages/RestrictedGuest.tsx";
import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx"; import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx";
import { useAuth } from "./hooks/useAuth.ts"; import { useAuth } from "./hooks/useAuth.ts";
import { useDefaultFeedTab } from "./hooks/useDefaultFeedTab.ts"; import { useDefaultFeedTab } from "./hooks/useDefaultFeedTab.ts";
import { DEFAULT_FEED_TAB } from "./config/feedTabs.ts";
import { AuthProvider } from "./contexts/AuthProvider.tsx"; import { AuthProvider } from "./contexts/AuthProvider.tsx";
import { PlayerProvider } from "./contexts/PlayerProvider.tsx"; import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
@@ -83,7 +84,7 @@ const NotFound = lazy(() =>
function useResolvedDefaultTab() { function useResolvedDefaultTab() {
const { user } = useAuth(); const { user } = useAuth();
const [preferredTab] = useDefaultFeedTab(); const [preferredTab] = useDefaultFeedTab();
return preferredTab === "followed" && !user ? "hot" : preferredTab; return preferredTab === "followed" && !user ? DEFAULT_FEED_TAB : preferredTab;
} }
// The query string has to survive the hop: the Web Share Target posts to "/", // The query string has to survive the hop: the Web Share Target posts to "/",

View File

@@ -9,6 +9,7 @@ import { createPortal } from "react-dom";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useCategories } from "../hooks/useCategories.ts"; import { useCategories } from "../hooks/useCategories.ts";
import { DEFAULT_FEED_TAB } from "../config/feedTabs.ts";
/** /**
* Header dropdown that switches the feed scope between "All" and a category, * Header dropdown that switches the feed scope between "All" and a category,
@@ -73,7 +74,7 @@ export function CategorySwitcher() {
if (categories.length === 0) return null; if (categories.length === 0) return null;
const value = categorySlug ?? "~"; const value = categorySlug ?? "~";
const tab = feedTab ?? "hot"; const tab = feedTab ?? DEFAULT_FEED_TAB;
const currentLabel = categorySlug const currentLabel = categorySlug
? categories.find((c) => c.slug === categorySlug)?.name ?? categorySlug ? categories.find((c) => c.slug === categorySlug)?.name ?? categorySlug
: t`All`; : t`All`;

View File

@@ -527,12 +527,12 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
return out; return out;
}, [messages]); }, [messages]);
const typingLabel = useMemo(() => { const typingNames = typingOthers.map((u) => u.username);
const names = typingOthers.map((u) => u.username); const typingLabel = typingNames.length === 1
if (names.length === 1) return t`${names[0]} is typing…`; ? t`${typingNames[0]} is typing…`
if (names.length === 2) return t`${names[0]} and ${names[1]} are typing…`; : typingNames.length === 2
return t`Several people are typing…`; ? t`${typingNames[0]} and ${typingNames[1]} are typing…`
}, [typingOthers]); : t`Several people are typing…`;
return ( return (
<Modal title={t`Chat`} onClose={onClose} wide> <Modal title={t`Chat`} onClose={onClose} wide>

View File

@@ -333,7 +333,8 @@ export function DumpCreateModal(
const toggleCategory = (id: string) => const toggleCategory = (id: string) =>
setSelectedCategoryIds((prev) => { setSelectedCategoryIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id); if (next.has(id)) next.delete(id);
else next.add(id);
return next; return next;
}); });

View File

@@ -1,7 +1,7 @@
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts"; import { DEFAULT_FEED_TAB, FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts"; import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { TabBar } from "./TabBar.tsx"; import { TabBar } from "./TabBar.tsx";
import { CategorySwitcher } from "./CategorySwitcher.tsx"; import { CategorySwitcher } from "./CategorySwitcher.tsx";
@@ -18,7 +18,7 @@ export function FeedTabBar() {
const [preferredTab] = useDefaultFeedTab(); const [preferredTab] = useDefaultFeedTab();
const defaultTab: FeedTab = preferredTab === "followed" && !user const defaultTab: FeedTab = preferredTab === "followed" && !user
? "hot" ? DEFAULT_FEED_TAB
: preferredTab; : preferredTab;
const active: FeedTab = (FEED_TABS as readonly string[]).includes(feedTab ?? "") const active: FeedTab = (FEED_TABS as readonly string[]).includes(feedTab ?? "")
? (feedTab as FeedTab) ? (feedTab as FeedTab)

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { type ComponentProps, useState } from "react";
import { canProxyImage, proxyImageUrl } from "../utils/proxyImage.ts"; import { canProxyImage, proxyImageUrl } from "../utils/proxyImage.ts";
import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx"; import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx";
@@ -9,12 +9,7 @@ interface ThumbnailProps {
className?: string; className?: string;
loading?: "lazy" | "eager"; loading?: "lazy" | "eager";
/** Passed through to the placeholder when there's nothing to show. */ /** Passed through to the placeholder when there's nothing to show. */
placeholder: { placeholder: Omit<ComponentProps<typeof ThumbnailPlaceholder>, "className">;
url?: string;
accentColor?: string;
faviconUrl?: string;
siteName?: string;
};
/** Extra class for the placeholder element only. */ /** Extra class for the placeholder element only. */
placeholderClassName?: string; placeholderClassName?: string;
} }

View File

@@ -1,2 +1,5 @@
export const FEED_TABS = ["hot", "new", "journal", "followed"] as const; export const FEED_TABS = ["hot", "new", "journal", "followed"] as const;
export type FeedTab = (typeof FEED_TABS)[number]; export type FeedTab = (typeof FEED_TABS)[number];
// Tab the index opens on for visitors and for users who haven't picked one.
export const DEFAULT_FEED_TAB: FeedTab = "journal";

View File

@@ -10,22 +10,30 @@ import {
type RawCategory, type RawCategory,
} from "../model.ts"; } from "../model.ts";
async function fetchCategories(
signal?: AbortSignal,
): Promise<Category[] | null> {
const res = await fetch(`${API_URL}/api/categories`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (!body.success) return null;
return (body.data as RawCategory[]).map(deserializeCategory);
}
export function CategoriesProvider({ children }: { children: ReactNode }) { export function CategoriesProvider({ children }: { children: ReactNode }) {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const load = useCallback(async (signal?: AbortSignal) => { const load = useCallback((signal?: AbortSignal) =>
try { fetchCategories(signal)
const res = await fetch(`${API_URL}/api/categories`, { signal }); .then((data) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!data) return;
const body = await res.json(); setCategories(data);
if (!body.success) return; setIsLoaded(true);
setCategories((body.data as RawCategory[]).map(deserializeCategory)); })
setIsLoaded(true); .catch((err) => {
} catch (err) { if ((err as Error).name !== "AbortError") setIsLoaded(true);
if ((err as Error).name !== "AbortError") setIsLoaded(true); }), []);
}
}, []);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts"; import { DEFAULT_FEED_TAB, FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
const STORAGE_KEY = "default-feed-tab"; const STORAGE_KEY = "default-feed-tab";
@@ -7,12 +7,12 @@ function readStored(): FeedTab {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
return stored && (FEED_TABS as readonly string[]).includes(stored) return stored && (FEED_TABS as readonly string[]).includes(stored)
? stored as FeedTab ? stored as FeedTab
: "hot"; : DEFAULT_FEED_TAB;
} }
/** /**
* User preference for which feed tab the index opens on, persisted to * User preference for which feed tab the index opens on, persisted to
* localStorage (defaults to "hot"). Returns the stored tab and a setter that * localStorage (defaults to DEFAULT_FEED_TAB). Returns the stored tab and a setter that
* persists the choice. Consumers that only need to honour the preference (e.g. * persists the choice. Consumers that only need to honour the preference (e.g.
* the index default) can ignore the setter. * the index default) can ignore the setter.
*/ */

View File

@@ -304,7 +304,8 @@ function DumpEditForm({
const toggleCategory = (id: string) => const toggleCategory = (id: string) =>
setSelectedCategoryIds((prev) => { setSelectedCategoryIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id); if (next.has(id)) next.delete(id);
else next.add(id);
return next; return next;
}); });
const form = useApiForm<EditValues>({ const form = useApiForm<EditValues>({

View File

@@ -11,7 +11,7 @@ import { useLocation, useParams } from "react-router";
import { AppHeader } from "../components/AppHeader.tsx"; import { AppHeader } from "../components/AppHeader.tsx";
import { PresenceRow } from "../components/PresenceRow.tsx"; import { PresenceRow } from "../components/PresenceRow.tsx";
import { FeedTabBar } from "../components/FeedTabBar.tsx"; import { FeedTabBar } from "../components/FeedTabBar.tsx";
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts"; import { DEFAULT_FEED_TAB, FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts"; import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
@@ -89,7 +89,7 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
const { feedTab: rawTab } = useParams(); const { feedTab: rawTab } = useParams();
const [preferredTab] = useDefaultFeedTab(); const [preferredTab] = useDefaultFeedTab();
const defaultTab: FeedTab = preferredTab === "followed" && !user const defaultTab: FeedTab = preferredTab === "followed" && !user
? "hot" ? DEFAULT_FEED_TAB
: preferredTab; : preferredTab;
const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "") const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "")
? (rawTab as FeedTab) ? (rawTab as FeedTab)

View File

@@ -241,7 +241,7 @@ export function FollowedFeed({
}) })
); );
} }
}, [token, usersState.status, playlistsState.status]); }, [token, usersState.status, playlistsState.status, catQuery]);
// Scroll save // Scroll save
useScrollSave( useScrollSave(
@@ -298,7 +298,7 @@ export function FollowedFeed({
s.status === "loaded" ? { ...s, loadingMore: false } : s s.status === "loaded" ? { ...s, loadingMore: false } : s
) )
); );
}, [usersState, token]); }, [usersState, token, catQuery]);
const loadMorePlaylists = useCallback(() => { const loadMorePlaylists = useCallback(() => {
if ( if (
@@ -333,7 +333,7 @@ export function FollowedFeed({
s.status === "loaded" ? { ...s, loadingMore: false } : s s.status === "loaded" ? { ...s, loadingMore: false } : s
) )
); );
}, [playlistsState, token]); }, [playlistsState, token, catQuery]);
return ( return (
<div className="followed-feed"> <div className="followed-feed">