feat: add christmas theme

This commit is contained in:
Tom (plebeius.eth)
2024-12-24 17:13:12 +01:00
parent 5a498cbcb6
commit eb3a630cc3
15 changed files with 300 additions and 53 deletions
+13
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Outlet, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useAccountComment, useAccountComments } from '@plebbit/plebbit-react-hooks';
import { initSnow, removeSnow, shouldShowSnow } from './lib/snow';
import { isAllView, isSubscriptionsView } from './lib/utils/view-utils';
import useIsMobile from './hooks/use-is-mobile';
import useTheme from './hooks/use-theme';
@@ -19,6 +20,7 @@ import ChallengeModal from './components/challenge-modal';
import PostForm from './components/post-form';
import SubplebbitStats from './components/subplebbit-stats';
import TopBar from './components/topbar';
import useSpecialThemeStore from './stores/use-special-theme-store';
const ValidateRouteParams = () => {
const { accountCommentIndex, timeFilterName } = useParams();
@@ -51,6 +53,17 @@ const BoardLayout = () => {
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
const pendingPost = useAccountComment({ commentIndex: accountCommentIndex ? parseInt(accountCommentIndex) : undefined });
// Christmas theme
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
useEffect(() => {
if (isSpecialEnabled) {
initSnow({ flakeCount: 150 });
}
return () => {
removeSnow();
};
}, [isSpecialEnabled]);
// force rerender of post form when navigating between pages, except when opening settings modal in current view
const key = location.pathname.endsWith('/settings')
? `${subplebbitAddress}-${location.pathname.replace(/\/settings$/, '')}`
@@ -39,8 +39,8 @@
}
@media (max-width: 640px) {
.boardTitle {
padding-top: 40px;
.content {
margin-top: 40px;
}
}
@@ -48,4 +48,14 @@
.bannerCnt {
padding: 5px 0 2px 0;
}
}
.garland {
border-image-slice: 50 0 50 0;
border-image-width: 40px 0px 0px 0px;
border-image-outset: 0px 0px 0px 0px;
border-image-repeat: repeat repeat;
border-image-source: url('/public/assets/garland.png');
border-style: solid;
padding-top: 50px;
}
+2 -1
View File
@@ -6,6 +6,7 @@ import styles from './board-header.module.css';
import { useMultisubMetadata } from '../../hooks/use-default-subplebbits';
import useIsMobile from '../../hooks/use-is-mobile';
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
import { shouldShowSnow } from '../../lib/snow';
const totalBanners = 61;
@@ -37,7 +38,7 @@ const BoardHeader = () => {
const { isOffline, isOnlineStatusLoading, offlineIconClass, offlineTitle } = useIsSubplebbitOffline(subplebbit);
return (
<div className={styles.content}>
<div className={`${styles.content} ${shouldShowSnow() ? styles.garland : ''}`}>
{!useIsMobile() && (
<div className={styles.bannerCnt}>
<ImageBanner key={isInAllView ? 'all' : isInSubscriptionsView ? 'subscriptions' : address} />
+10 -6
View File
@@ -28,6 +28,7 @@ import Tooltip from '../tooltip';
import { PostProps } from '../../views/post/post';
import { create } from 'zustand';
import _ from 'lodash';
import { shouldShowSnow } from '../../lib/snow';
interface ShowOmittedRepliesState {
showOmittedReplies: Record<string, boolean>;
@@ -213,15 +214,15 @@ const PostInfo = ({ openReplyModal, post, postReplyCount = 0, roles, isHidden }:
);
};
const PostMedia = ({ post }: PostProps) => {
const PostMedia = ({ post, hasThumbnail }: PostProps) => {
const { t } = useTranslation();
const { link, spoiler, cid } = post || {};
// Reset state by remounting component when post changes
return <PostMediaContent key={cid} post={post} link={link} spoiler={spoiler} t={t} />;
return <PostMediaContent key={cid} post={post} hasThumbnail={hasThumbnail} spoiler={spoiler} t={t} />;
};
const PostMediaContent = ({ post, link, spoiler, t }: { post: any; link: string; spoiler: boolean; t: any }) => {
const PostMediaContent = ({ post, hasThumbnail, spoiler, t }: { post: any; hasThumbnail: boolean | undefined; spoiler: boolean; t: any }) => {
const { isDescription, isRules } = post || {}; // custom properties, not from api
const commentMediaInfo = useCommentMediaInfo(post);
const { url } = commentMediaInfo || {};
@@ -235,7 +236,6 @@ const PostMediaContent = ({ post, link, spoiler, t }: { post: any; link: string;
}
const embedUrl = url && new URL(url);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
const [showThumbnail, setShowThumbnail] = useState(true);
const mediaDimensions = getMediaDimensions(commentMediaInfo);
@@ -451,6 +451,9 @@ const PostDesktop = ({ openReplyModal, post, roles, showAllReplies, showReplies
replyCount: 0,
};
const commentMediaInfo = useCommentMediaInfo(post);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
return (
<div className={styles.postDesktop}>
{showReplies ? (
@@ -466,8 +469,9 @@ const PostDesktop = ({ openReplyModal, post, roles, showAllReplies, showReplies
<span className={`${styles.hideButton} ${hidden ? styles.unhideThread : styles.hideThread}`} onClick={hidden ? unhide : hide} />
</span>
)}
<div data-cid={cid} data-author-address={author?.shortAddress} data-post-cid={postCid}>
{link && !isHidden && !(deleted || removed) && isValidURL(link) && <PostMedia post={post} />}
<div data-cid={cid} data-author-address={author?.shortAddress} data-post-cid={postCid} className={shouldShowSnow() && hasThumbnail ? styles.xmasHatWrapper : ''}>
{shouldShowSnow() && hasThumbnail && <img src={`${process.env.PUBLIC_URL}/assets/xmashat.gif`} className={styles.xmasHat} />}
{link && !isHidden && !(deleted || removed) && isValidURL(link) && <PostMedia post={post} hasThumbnail={hasThumbnail} />}
<PostInfo isHidden={hidden} openReplyModal={openReplyModal} post={post} postReplyCount={replyCount} roles={roles} />
{!isHidden && !content && !(deleted || removed) && <div className={styles.spacer} />}
{!isHidden && <PostMessage post={post} />}
+8 -1
View File
@@ -23,6 +23,7 @@ import ReplyQuotePreview from '../reply-quote-preview';
import Tooltip from '../tooltip';
import { PostProps } from '../../views/post/post';
import _ from 'lodash';
import { shouldShowSnow } from '../../lib/snow';
const PostInfoAndMedia = ({ openReplyModal, post, postReplyCount = 0, roles }: PostProps) => {
const { t } = useTranslation();
@@ -400,7 +401,13 @@ const PostMobile = ({ openReplyModal, post, roles, showAllReplies, showReplies =
)}
<div className={showReplies ? styles.thread : styles.quotePreview}>
<div className={styles.postContainer}>
<div className={styles.postOp} data-cid={cid} data-author-address={author?.shortAddress} data-post-cid={postCid}>
<div
className={`${styles.postOp} ${shouldShowSnow() ? styles.xmasHatWrapper : ''}`}
data-cid={cid}
data-author-address={author?.shortAddress}
data-post-cid={postCid}
>
{shouldShowSnow() && <img src={`${process.env.PUBLIC_URL}/assets/xmashat.gif`} className={styles.xmasHat} />}
<PostInfoAndMedia openReplyModal={openReplyModal} post={post} postReplyCount={replyCount} roles={roles} />
<PostMessageMobile post={post} />
</div>
@@ -8,6 +8,7 @@ import _ from 'lodash';
import useInterfaceSettingsStore from '../../../stores/use-interface-settings-store';
import useCatalogFiltersStore from '../../../stores/use-catalog-filters-store';
import useExpandedMediaStore from '../../../stores/use-expanded-media-store';
import useSpecialThemeStore from '../../../stores/use-special-theme-store';
const commitRef = process.env.REACT_APP_COMMIT_REF;
const isElectron = window.isElectron === true;
@@ -69,19 +70,33 @@ const CheckForUpdates = () => {
const Style = () => {
const [theme, setTheme] = useTheme();
const { isEnabled, setIsEnabled } = useSpecialThemeStore();
const today = new Date();
const month = today.getMonth();
const day = today.getDate();
const isChristmas = month === 11 && (day === 24 || day === 25);
const handleThemeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setTheme(e.target.value);
const newTheme = e.target.value;
if (newTheme === 'special') {
setIsEnabled(true);
setTheme('tomorrow');
} else {
setIsEnabled(false);
setTheme(newTheme);
}
};
return (
<select className={styles.themeSettings} value={theme} onChange={handleThemeChange}>
<select className={styles.themeSettings} value={isEnabled ? 'special' : theme} onChange={handleThemeChange}>
<option value='yotsuba'>Yotsuba</option>
<option value='yotsuba-b'>Yotsuba B</option>
<option value='futaba'>Futaba</option>
<option value='burichan'>Burichan</option>
<option value='tomorrow'>Tomorrow</option>
<option value='photon'>Photon</option>
{isChristmas && <option value='special'>Special</option>}
</select>
);
};
+31
View File
@@ -6,6 +6,7 @@ import useDefaultSubplebbits from './use-default-subplebbits';
import useInitialTheme from './use-initial-theme';
import { nsfwTags } from '../views/home/home';
import { useAccountComment } from '@plebbit/plebbit-react-hooks';
import useSpecialThemeStore from '../stores/use-special-theme-store';
const themeClasses = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'tomorrow', 'photon'];
@@ -23,6 +24,7 @@ const useTheme = (): [string, (theme: string) => void] => {
const pendingPostCommentIndex = pendingPostParams?.accountCommentIndex ? parseInt(pendingPostParams.accountCommentIndex) : undefined;
const pendingPost = useAccountComment({ commentIndex: pendingPostCommentIndex });
const pendingPostSubplebbitAddress = pendingPost?.subplebbitAddress;
const { isEnabled, setIsEnabled } = useSpecialThemeStore();
const setThemeStore = useThemeStore((state) => state.setTheme);
const getTheme = useThemeStore((state) => state.getTheme);
@@ -32,11 +34,40 @@ const useTheme = (): [string, (theme: string) => void] => {
const initialTheme = useInitialTheme(pendingPostSubplebbitAddress);
const [currentTheme, setCurrentTheme] = useState(initialTheme);
const isInAllView = isAllView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
// Check for Christmas and initialize special theme if needed
useEffect(() => {
const today = new Date();
const month = today.getMonth();
const day = today.getDate();
const isChristmas = month === 11 && (day === 24 || day === 25);
const subplebbitAddress = params?.subplebbitAddress || pendingPostSubplebbitAddress;
if (isChristmas && isEnabled === null && subplebbitAddress && !isInAllView && !isInSubscriptionsView) {
setIsEnabled(true);
setCurrentTheme('tomorrow');
updateThemeClass('tomorrow');
}
}, [isEnabled, setIsEnabled, params, pendingPostSubplebbitAddress, location.pathname]);
const getCurrentTheme = useCallback(() => {
const { isEnabled } = useSpecialThemeStore.getState();
const subplebbitAddress = params?.subplebbitAddress || pendingPostSubplebbitAddress;
const isInAllView = isAllView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
// Always use yotsuba for home page
if (location.pathname === '/') {
return 'yotsuba';
}
// If special theme is enabled, use tomorrow
if (isEnabled) {
return 'tomorrow';
}
let storedTheme = null;
if (isInAllView || isInSubscriptionsView) {
storedTheme = getTheme('sfw', false);
+106
View File
@@ -0,0 +1,106 @@
import useSpecialThemeStore from '../stores/use-special-theme-store';
interface SnowOptions {
flakeCount: number;
}
// Add this new variable to track manual override
let manualSnowOverride: boolean | null = null;
export const setManualSnowOverride = (value: boolean): void => {
manualSnowOverride = value;
};
export const initSnow = ({ flakeCount }: SnowOptions): void => {
const randomRange = (min: number, max: number): number => {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
};
const snowflakeName = 'js-snowflake';
let rule = `.${snowflakeName} {
position: absolute;
width: 10px;
height: 10px;
background: linear-gradient(white, white);
border-radius: 50%;
filter: drop-shadow(0 0 10px white);
}`;
for (let i = 1; i < flakeCount; i++) {
const randomX = Math.random() * 100;
const randomOffset = randomRange(-100000, 100000) * 0.0001;
const randomXEnd = randomX + randomOffset;
const randomXEndYoyo = randomX + randomOffset / 2;
const randomYoyoTime = randomRange(30000, 80000) / 100000;
const randomYoyoY = randomYoyoTime * 100;
const randomScale = Math.random() * (1.0 - 0.2) + 0.2;
const fallDuration = randomRange(10, 30);
const fallDelay = (Math.floor(Math.random() * 30) + 1) * -1;
const alpha = Math.random() * (1.0 - 0.1) + 0.1;
rule += `
.${snowflakeName}:nth-child(${i}) {
opacity: ${alpha};
transform: translate(${randomX}vw, -10px) scale(${randomScale});
animation: fall-${i} ${fallDuration}s ${fallDelay}s linear infinite;
}
@keyframes fall-${i} {
${randomYoyoTime * 100}% {
transform: translate(${randomXEnd}vw, ${randomYoyoY}vh) scale(${randomScale});
}
to {
transform: translate(${randomXEndYoyo}vw, 100vh) scale(${randomScale});
}
}`;
}
const container = document.createElement('div');
container.id = 'js-snowfield';
container.style.position = 'fixed';
container.style.top = '0';
container.style.pointerEvents = 'none';
container.style.zIndex = '9999';
for (let i = 1; i < flakeCount; i++) {
const flake = document.createElement('div');
flake.className = snowflakeName;
container.appendChild(flake);
}
const css = document.createElement('style');
css.type = 'text/css';
css.textContent = rule;
document.getElementsByTagName('head')[0].appendChild(css);
document.body.appendChild(container);
};
export const removeSnow = (): void => {
const element = document.getElementById('js-snowfield');
if (element) {
element.parentNode?.removeChild(element);
}
};
export const shouldShowSnow = (): boolean => {
const isEnabled = useSpecialThemeStore.getState().isEnabled;
// Check store value first
if (isEnabled !== null) {
return isEnabled;
}
// Check manual override second
if (manualSnowOverride !== null) {
return manualSnowOverride;
}
const today = new Date();
const month = today.getMonth();
const day = today.getDate();
return month === 11 && (day === 24 || day === 25);
};
+21
View File
@@ -0,0 +1,21 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface SpecialThemeStore {
isEnabled: boolean | null;
setIsEnabled: (value: boolean) => void;
}
const useSpecialThemeStore = create(
persist<SpecialThemeStore>(
(set) => ({
isEnabled: null,
setIsEnabled: (value: boolean) => set({ isEnabled: value }),
}),
{
name: 'Special-theme-storage',
},
),
);
export default useSpecialThemeStore;
+10
View File
@@ -37,4 +37,14 @@
.footer {
padding-left: 5px;
}
}
.garland {
border-image-slice: 50 0 50 0;
border-image-width: 40px 0px 0px 0px;
border-image-outset: 0px 0px 0px 0px;
border-image-repeat: repeat repeat;
border-image-source: url('/public/assets/garland.png');
border-style: solid;
padding-top: 50px;
}
+45 -41
View File
@@ -19,6 +19,7 @@ import SubplebbitDescription from '../../components/subplebbit-description';
import SubplebbitRules from '../../components/subplebbit-rules';
import useInterfaceSettingsStore from '../../stores/use-interface-settings-store';
import { getCommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
import { shouldShowSnow } from '../../lib/snow';
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
@@ -262,48 +263,51 @@ const Board = () => {
}, [title, shortAddress, subplebbitAddress]);
return (
<div className={styles.content}>
{location.pathname.endsWith('/settings') && <SettingsModal />}
{activeCid && threadCid && postSubplebbitAddress && (
<ReplyModal
closeModal={closeModal}
parentCid={activeCid}
postCid={threadCid}
scrollY={scrollY}
showReplyModal={showReplyModal}
subplebbitAddress={postSubplebbitAddress}
/>
)}
{((description && description.length > 0) || isInAllView) && (
<SubplebbitDescription
avatarUrl={suggested?.avatarUrl}
subplebbitAddress={subplebbitAddress}
createdAt={createdAt}
description={description}
replyCount={isInAllView ? 0 : rules?.length > 0 ? 1 : 0}
shortAddress={shortAddress}
title={title}
/>
)}
{rules && !description && rules.length > 0 && <SubplebbitRules subplebbitAddress={subplebbitAddress} createdAt={createdAt} rules={rules} />}
<Virtuoso
increaseViewportBy={{ bottom: 1200, top: 1200 }}
totalCount={combinedFeed.length}
data={combinedFeed}
itemContent={(index, post) => {
const { deleted, locked, removed } = post || {};
const isThreadClosed = deleted || locked || removed;
<>
{shouldShowSnow() && <hr />}
<div className={`${styles.content} ${shouldShowSnow() ? styles.garland : ''}`}>
{location.pathname.endsWith('/settings') && <SettingsModal />}
{activeCid && threadCid && postSubplebbitAddress && (
<ReplyModal
closeModal={closeModal}
parentCid={activeCid}
postCid={threadCid}
scrollY={scrollY}
showReplyModal={showReplyModal}
subplebbitAddress={postSubplebbitAddress}
/>
)}
{((description && description.length > 0) || isInAllView) && (
<SubplebbitDescription
avatarUrl={suggested?.avatarUrl}
subplebbitAddress={subplebbitAddress}
createdAt={createdAt}
description={description}
replyCount={isInAllView ? 0 : rules?.length > 0 ? 1 : 0}
shortAddress={shortAddress}
title={title}
/>
)}
{rules && !description && rules.length > 0 && <SubplebbitRules subplebbitAddress={subplebbitAddress} createdAt={createdAt} rules={rules} />}
<Virtuoso
increaseViewportBy={{ bottom: 1200, top: 1200 }}
totalCount={combinedFeed.length}
data={combinedFeed}
itemContent={(index, post) => {
const { deleted, locked, removed } = post || {};
const isThreadClosed = deleted || locked || removed;
return <Post index={index} post={post} openReplyModal={isThreadClosed ? () => alert(t('thread_closed_alert')) : openReplyModal} />;
}}
useWindowScroll={true}
components={{ Footer }}
endReached={loadMore}
ref={virtuosoRef}
restoreStateFrom={lastVirtuosoState}
initialScrollTop={lastVirtuosoState?.scrollTop}
/>
</div>
return <Post index={index} post={post} openReplyModal={isThreadClosed ? () => alert(t('thread_closed_alert')) : openReplyModal} />;
}}
useWindowScroll={true}
components={{ Footer }}
endReached={loadMore}
ref={virtuosoRef}
restoreStateFrom={lastVirtuosoState}
initialScrollTop={lastVirtuosoState?.scrollTop}
/>
</div>
</>
);
};
+24
View File
@@ -43,6 +43,30 @@
opacity: 0.5;
}
.xmasHatWrapper {
position: relative;
}
.xmasHat {
position: absolute;
pointer-events: none;
top: -80px;
left: 0;
z-index: 9999;
}
@media (max-width: 640px) {
.xmasHat {
width: 100px;
top: -20px;
left: -15px;
}
}
.xmasHat img {
border: none;
}
/* clearfix to contain float elements */
.postDesktop::after {
content: "";
+1
View File
@@ -16,6 +16,7 @@ import styles from './post.module.css';
export interface PostProps {
index?: number;
isHidden?: boolean;
hasThumbnail?: boolean;
post?: any;
postReplyCount?: number;
reply?: any;