mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(favicon): show dedicated icon on 404 pages
Add favicon-404.ico and switch tab favicon on not-found routes while keeping static app pages and larger crawler icons unchanged.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useLocation, useParams } from 'react-router-dom';
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
import { useAccountComment } from '@bitsocial/bitsocial-react-hooks';
|
import { useAccountComment } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import { isAllView, isModView, isSubscriptionsView } from '../lib/utils/view-utils';
|
import { isAllView, isModView, isNotFoundView, isSubscriptionsView } from '../lib/utils/view-utils';
|
||||||
import useThemeStore from '../stores/use-theme-store';
|
import useThemeStore from '../stores/use-theme-store';
|
||||||
import { useDirectories } from './use-directories';
|
import { useDirectories } from './use-directories';
|
||||||
import { useResolvedCommunityAddress } from './use-resolved-community-address';
|
import { useResolvedCommunityAddress } from './use-resolved-community-address';
|
||||||
@@ -22,9 +22,8 @@ const updateThemeClass = (newTheme: string) => {
|
|||||||
|
|
||||||
const useTheme = (): [string, (theme: string) => void] => {
|
const useTheme = (): [string, (theme: string) => void] => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const params = useParams<{ boardIdentifier?: string }>();
|
const params = useParams<{ accountCommentIndex?: string; boardIdentifier?: string; commentCid?: string }>();
|
||||||
const pendingPostParams = useParams<{ accountCommentIndex?: string }>();
|
const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
|
||||||
const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(pendingPostParams?.accountCommentIndex) });
|
|
||||||
const pendingPostCommunityAddress = getCommentCommunityAddress(pendingPost);
|
const pendingPostCommunityAddress = getCommentCommunityAddress(pendingPost);
|
||||||
|
|
||||||
const { isEnabled, setIsEnabled } = useSpecialThemeStore();
|
const { isEnabled, setIsEnabled } = useSpecialThemeStore();
|
||||||
@@ -36,6 +35,7 @@ const useTheme = (): [string, (theme: string) => void] => {
|
|||||||
const isInAllView = isAllView(location.pathname);
|
const isInAllView = isAllView(location.pathname);
|
||||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
||||||
const isInModView = isModView(location.pathname);
|
const isInModView = isModView(location.pathname);
|
||||||
|
const isInNotFoundView = isNotFoundView(location.pathname, params);
|
||||||
const routeIdentifier = params.boardIdentifier;
|
const routeIdentifier = params.boardIdentifier;
|
||||||
const resolvedAddress = useResolvedCommunityAddress();
|
const resolvedAddress = useResolvedCommunityAddress();
|
||||||
const communityAddress = resolvedAddress || pendingPostCommunityAddress || routeIdentifier;
|
const communityAddress = resolvedAddress || pendingPostCommunityAddress || routeIdentifier;
|
||||||
@@ -92,8 +92,8 @@ const useTheme = (): [string, (theme: string) => void] => {
|
|||||||
}, [currentTheme]);
|
}, [currentTheme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateFavicon(sfw);
|
updateFavicon(isInNotFoundView ? 'not-found' : sfw);
|
||||||
}, [sfw]);
|
}, [isInNotFoundView, sfw]);
|
||||||
|
|
||||||
const setCommunityTheme = useCallback(
|
const setCommunityTheme = useCallback(
|
||||||
async (newTheme: string) => {
|
async (newTheme: string) => {
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ describe('update-favicon', () => {
|
|||||||
expect(document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href')).toBe('/apple-touch-icon.png');
|
expect(document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href')).toBe('/apple-touch-icon.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can switch to the not-found ico favicon without removing larger crawler icons', async () => {
|
||||||
|
const { updateFavicon } = await import('../update-favicon');
|
||||||
|
|
||||||
|
document.head.innerHTML =
|
||||||
|
'<link rel="icon" sizes="16x16" href="/favicon.ico"><link rel="shortcut icon" sizes="16x16" href="/favicon.ico"><link rel="icon" sizes="192x192" href="/manifest-icon-192x192.png"><link rel="apple-touch-icon" sizes="256x256" href="/apple-touch-icon.png">';
|
||||||
|
|
||||||
|
updateFavicon('not-found');
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]')).toHaveLength(4);
|
||||||
|
expect(document.querySelector('link[rel="icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon-404.ico?variant=404');
|
||||||
|
expect(document.querySelector('link[rel="shortcut icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon-404.ico?variant=404');
|
||||||
|
expect(document.querySelector('link[rel="icon"][sizes="16x16"]')?.getAttribute('type')).toBe('image/x-icon');
|
||||||
|
expect(document.querySelector('link[rel="shortcut icon"][sizes="16x16"]')?.getAttribute('type')).toBe('image/x-icon');
|
||||||
|
expect(document.querySelector('link[rel="icon"][sizes="192x192"]')?.getAttribute('href')).toBe('/manifest-icon-192x192.png');
|
||||||
|
expect(document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href')).toBe('/apple-touch-icon.png');
|
||||||
|
});
|
||||||
|
|
||||||
it('marks only non-special, non-routing aggregate sfw boards as sfw', async () => {
|
it('marks only non-special, non-routing aggregate sfw boards as sfw', async () => {
|
||||||
const { isSfwBoard } = await import('../update-favicon');
|
const { isSfwBoard } = await import('../update-favicon');
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,50 @@
|
|||||||
const DEFAULT_FAVICON = '/favicon.ico?variant=nsfw';
|
const DEFAULT_FAVICON = '/favicon.ico?variant=nsfw';
|
||||||
const SFW_FAVICON = '/favicon2.ico?variant=sfw';
|
const SFW_FAVICON = '/favicon2.ico?variant=sfw';
|
||||||
|
const NOT_FOUND_FAVICON = '/favicon-404.ico?variant=404';
|
||||||
const FAVICON_RELS = ['icon', 'shortcut icon'] as const;
|
const FAVICON_RELS = ['icon', 'shortcut icon'] as const;
|
||||||
const FAVICON_SELECTOR = ['link[data-fivechan-tab-favicon="true"]', ...FAVICON_RELS.map((rel) => `link[rel="${rel}"][sizes="16x16"]`)].join(', ');
|
const FAVICON_SELECTOR = ['link[data-fivechan-tab-favicon="true"]', ...FAVICON_RELS.map((rel) => `link[rel="${rel}"][sizes="16x16"]`)].join(', ');
|
||||||
|
|
||||||
|
type FaviconVariant = 'default' | 'sfw' | 'not-found';
|
||||||
|
|
||||||
|
const FAVICONS: Record<FaviconVariant, { href: string; type: string }> = {
|
||||||
|
default: { href: DEFAULT_FAVICON, type: 'image/png' },
|
||||||
|
sfw: { href: SFW_FAVICON, type: 'image/png' },
|
||||||
|
'not-found': { href: NOT_FOUND_FAVICON, type: 'image/x-icon' },
|
||||||
|
};
|
||||||
|
|
||||||
let currentHref: string | null = null;
|
let currentHref: string | null = null;
|
||||||
|
|
||||||
const hasExpectedFaviconLinks = (href: string): boolean =>
|
const hasExpectedFaviconLinks = (href: string): boolean =>
|
||||||
FAVICON_RELS.every((rel) => document.querySelector(`link[rel="${rel}"][href="${href}"][data-fivechan-tab-favicon="true"]`));
|
FAVICON_RELS.every((rel) => document.querySelector(`link[rel="${rel}"][href="${href}"][data-fivechan-tab-favicon="true"]`));
|
||||||
|
|
||||||
const createFaviconLink = (rel: (typeof FAVICON_RELS)[number], href: string): HTMLLinkElement => {
|
const createFaviconLink = (rel: (typeof FAVICON_RELS)[number], favicon: (typeof FAVICONS)[FaviconVariant]): HTMLLinkElement => {
|
||||||
const link = document.createElement('link');
|
const link = document.createElement('link');
|
||||||
link.rel = rel;
|
link.rel = rel;
|
||||||
link.type = 'image/png';
|
link.type = favicon.type;
|
||||||
link.setAttribute('sizes', '16x16');
|
link.setAttribute('sizes', '16x16');
|
||||||
link.href = href;
|
link.href = favicon.href;
|
||||||
link.dataset.fivechanTabFavicon = 'true';
|
link.dataset.fivechanTabFavicon = 'true';
|
||||||
return link;
|
return link;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFaviconVariant = (variant: boolean | FaviconVariant): FaviconVariant => {
|
||||||
|
if (typeof variant === 'boolean') return variant ? 'sfw' : 'default';
|
||||||
|
return variant;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Swap the tab favicon between the default (NSFW/home) and SFW variants.
|
* Swap the tab favicon between the default (NSFW/home), SFW, and 404 variants.
|
||||||
* Uses remove-and-recreate plus cache-busted URLs to bypass sticky favicon caching.
|
* Uses remove-and-recreate plus cache-busted URLs to bypass sticky favicon caching.
|
||||||
*/
|
*/
|
||||||
export const updateFavicon = (isSfw: boolean): void => {
|
export const updateFavicon = (variant: boolean | FaviconVariant): void => {
|
||||||
const href = isSfw ? SFW_FAVICON : DEFAULT_FAVICON;
|
const favicon = FAVICONS[getFaviconVariant(variant)];
|
||||||
|
const { href } = favicon;
|
||||||
if (href === currentHref && hasExpectedFaviconLinks(href)) return;
|
if (href === currentHref && hasExpectedFaviconLinks(href)) return;
|
||||||
currentHref = href;
|
currentHref = href;
|
||||||
|
|
||||||
document.querySelectorAll<HTMLLinkElement>(FAVICON_SELECTOR).forEach((link) => link.remove());
|
document.querySelectorAll<HTMLLinkElement>(FAVICON_SELECTOR).forEach((link) => link.remove());
|
||||||
FAVICON_RELS.forEach((rel) => {
|
FAVICON_RELS.forEach((rel) => {
|
||||||
document.head.appendChild(createFaviconLink(rel, href));
|
document.head.appendChild(createFaviconLink(rel, favicon));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ describe('view-utils', () => {
|
|||||||
expect(isModQueueView('/music.eth/mod/queue')).toBe(true);
|
expect(isModQueueView('/music.eth/mod/queue')).toBe(true);
|
||||||
expect(isSubscriptionsView('/subs/catalog/settings', {})).toBe(true);
|
expect(isSubscriptionsView('/subs/catalog/settings', {})).toBe(true);
|
||||||
expect(isPendingPostView('/pending/42/settings', { accountCommentIndex: '42' })).toBe(true);
|
expect(isPendingPostView('/pending/42/settings', { accountCommentIndex: '42' })).toBe(true);
|
||||||
|
expect(isNotFoundView('/faq', {})).toBe(false);
|
||||||
|
expect(isNotFoundView('/pass', {})).toBe(false);
|
||||||
|
expect(isNotFoundView('/rules', {})).toBe(false);
|
||||||
|
expect(isNotFoundView('/blotter', {})).toBe(false);
|
||||||
|
expect(isNotFoundView('/settings/account-data', {})).toBe(false);
|
||||||
|
expect(isNotFoundView('/not-allowed', {})).toBe(false);
|
||||||
|
expect(isNotFoundView('/not-found', {})).toBe(true);
|
||||||
|
expect(isNotFoundView('/faq/missing', {})).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects board, catalog, post, and settings routes using board params', () => {
|
it('detects board, catalog, post, and settings routes using board params', () => {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ type ParamsType = {
|
|||||||
commentCid?: string;
|
commentCid?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATIC_APP_ROUTES = new Set(['/faq', '/pass', '/rules', '/blotter', '/settings/account-data', '/not-allowed']);
|
||||||
|
|
||||||
|
const isStaticAppRoute = (pathname: string): boolean => STATIC_APP_ROUTES.has(pathname);
|
||||||
|
|
||||||
export const isAllView = (pathname: string): boolean => {
|
export const isAllView = (pathname: string): boolean => {
|
||||||
return pathname.startsWith('/all');
|
return pathname.startsWith('/all');
|
||||||
};
|
};
|
||||||
@@ -97,6 +101,7 @@ export const isNotFoundView = (pathname: string, params: ParamsType): boolean =>
|
|||||||
!isArchiveView(pathname, params) &&
|
!isArchiveView(pathname, params) &&
|
||||||
!isCatalogView(pathname, params) &&
|
!isCatalogView(pathname, params) &&
|
||||||
!isHomeView(pathname) &&
|
!isHomeView(pathname) &&
|
||||||
|
!isStaticAppRoute(pathname) &&
|
||||||
!isPendingPostView(pathname, params) &&
|
!isPendingPostView(pathname, params) &&
|
||||||
!isPostPageView(pathname, params) &&
|
!isPostPageView(pathname, params) &&
|
||||||
!isSettingsView(pathname, params) &&
|
!isSettingsView(pathname, params) &&
|
||||||
|
|||||||
+3
-1
@@ -39,6 +39,7 @@ const neverPrecacheUrls = new Set(['index.html', 'version.json']);
|
|||||||
const vitePwaManagedAssetUrls = new Set([
|
const vitePwaManagedAssetUrls = new Set([
|
||||||
'manifest.webmanifest',
|
'manifest.webmanifest',
|
||||||
'favicon.ico',
|
'favicon.ico',
|
||||||
|
'favicon-404.ico',
|
||||||
'favicon2.ico',
|
'favicon2.ico',
|
||||||
'robots.txt',
|
'robots.txt',
|
||||||
'apple-touch-icon.png',
|
'apple-touch-icon.png',
|
||||||
@@ -50,6 +51,7 @@ const baselineAppShellUrls = new Set([
|
|||||||
'manifest.json',
|
'manifest.json',
|
||||||
'manifest.webmanifest',
|
'manifest.webmanifest',
|
||||||
'favicon.ico',
|
'favicon.ico',
|
||||||
|
'favicon-404.ico',
|
||||||
'favicon2.ico',
|
'favicon2.ico',
|
||||||
'robots.txt',
|
'robots.txt',
|
||||||
'apple-touch-icon.png',
|
'apple-touch-icon.png',
|
||||||
@@ -393,7 +395,7 @@ export default defineConfig({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
type: 'module',
|
type: 'module',
|
||||||
},
|
},
|
||||||
includeAssets: ['favicon.ico', 'favicon2.ico', 'robots.txt', 'apple-touch-icon.png'],
|
includeAssets: ['favicon.ico', 'favicon-404.ico', 'favicon2.ico', 'robots.txt', 'apple-touch-icon.png'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: '5chan',
|
name: '5chan',
|
||||||
short_name: '5chan',
|
short_name: '5chan',
|
||||||
|
|||||||
Reference in New Issue
Block a user