From 669bb5b1815b403a4343eb507429ebfb159c44e2 Mon Sep 17 00:00:00 2001 From: plebeius Date: Fri, 7 Nov 2025 18:20:11 +0100 Subject: [PATCH] feat(topbar): add customizable visibility controls for directories and subscriptions --- src/app.tsx | 6 + .../directory-modal.module.css | 22 +-- .../directory-modal/directory-modal.tsx | 5 +- .../disclaimer-modal.module.css | 12 +- .../disclaimer-modal/disclaimer-modal.tsx | 12 +- src/components/topbar-edit-modal/index.ts | 1 + .../topbar-edit-modal.module.css | 142 +++++++++++++++++ .../topbar-edit-modal/topbar-edit-modal.tsx | 139 +++++++++++++++++ .../topbar-edit-modal.types.ts | 13 ++ src/components/topbar/topbar.module.css | 5 + src/components/topbar/topbar.tsx | 109 ++++++++++--- src/constants/board-codes.ts | 147 ++++++++++++++++++ src/stores/use-disclaimer-modal-store.ts | 13 +- src/stores/use-topbar-edit-modal-store.ts | 25 +++ src/stores/use-topbar-visibility-store.ts | 117 ++++++++++++++ src/themes.css | 45 +++++- 16 files changed, 767 insertions(+), 46 deletions(-) create mode 100644 src/components/topbar-edit-modal/index.ts create mode 100644 src/components/topbar-edit-modal/topbar-edit-modal.module.css create mode 100644 src/components/topbar-edit-modal/topbar-edit-modal.tsx create mode 100644 src/components/topbar-edit-modal/topbar-edit-modal.types.ts create mode 100644 src/constants/board-codes.ts create mode 100644 src/stores/use-topbar-edit-modal-store.ts create mode 100644 src/stores/use-topbar-visibility-store.ts diff --git a/src/app.tsx b/src/app.tsx index 2bcfee62..391f1e14 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -26,6 +26,9 @@ import ReplyModal from './components/reply-modal'; import PostForm from './components/post-form'; import SubplebbitStats from './components/subplebbit-stats'; import TopBar from './components/topbar'; +import TopbarEditModal from './components/topbar-edit-modal'; +import DirectoryModal from './components/directory-modal'; +import DisclaimerModal from './components/disclaimer-modal'; import SettingsModal from './components/settings-modal'; const BoardLayout = () => { @@ -65,6 +68,9 @@ const BoardLayout = () => {
+ + + {isMobile ? (subplebbitAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPost?.subplebbitAddress) && ( diff --git a/src/components/directory-modal/directory-modal.module.css b/src/components/directory-modal/directory-modal.module.css index c220b20f..407b1f1c 100644 --- a/src/components/directory-modal/directory-modal.module.css +++ b/src/components/directory-modal/directory-modal.module.css @@ -4,19 +4,23 @@ left: 0; right: 0; bottom: 0; - background-color: rgba(128, 128, 128, 0.5); + background-color: rgba(0, 0, 0, 0.25); z-index: 999; } +.backdropHome { + background-color: rgba(128, 128, 128, 0.5); +} + .directoryDialog { position: absolute; width: 500px; top: 50px; left: 50%; margin-left: -251px; - background-color: rgb(255, 255, 255); - border: 1px solid rgb(136, 0, 0); - color: rgb(136, 0, 0); + background-color: var(--directory-modal-background-color, #f0e0d6); + border: var(--directory-modal-border, 1px solid #d9bfb7); + color: var(--directory-modal-text-color, #800); box-shadow: rgba(0, 0, 0, 0.1) 2px 2px 0px 1px; font-family: arial, helvetica, clean, sans-serif; font-size: 13px; @@ -35,8 +39,8 @@ .hd { position: relative; - background-color: rgb(136, 0, 0); - color: rgb(255, 255, 255); + background-color: var(--directory-modal-header-background-color, #ea8); + color: var(--directory-modal-header-text-color, #800); padding: 4px 6px; display: flex; justify-content: space-between; @@ -58,7 +62,7 @@ position: absolute; top: 4px; right: 4px; - background-image: var(--disclaimer-modal-close-button-background-image); + background-image: var(--directory-modal-close-button-background-image, url("/assets/buttons/cross-red.png")); background-size: 100%; background-position: center; background-repeat: no-repeat; @@ -91,7 +95,7 @@ padding: 0; font-size: 14px; font-weight: bold; - color: rgb(136, 0, 0); + color: var(--directory-modal-text-color, #800); letter-spacing: 0.3px; } @@ -106,7 +110,7 @@ } .bd a { - color: rgb(136, 0, 0); + color: var(--directory-modal-text-color, #800); text-decoration: underline; } diff --git a/src/components/directory-modal/directory-modal.tsx b/src/components/directory-modal/directory-modal.tsx index 29b81933..4b3c6ac9 100644 --- a/src/components/directory-modal/directory-modal.tsx +++ b/src/components/directory-modal/directory-modal.tsx @@ -1,8 +1,11 @@ +import { useLocation } from 'react-router-dom'; import useDirectoryModalStore from '../../stores/use-directory-modal-store'; import styles from './directory-modal.module.css'; const DirectoryModal = () => { const { showModal, closeDirectoryModal } = useDirectoryModalStore(); + const location = useLocation(); + const isHomeView = location.pathname === '/'; if (!showModal) { return null; @@ -15,7 +18,7 @@ const DirectoryModal = () => { }; return ( -
+

Submit a Board to a Directory

diff --git a/src/components/disclaimer-modal/disclaimer-modal.module.css b/src/components/disclaimer-modal/disclaimer-modal.module.css index 168b75b2..3666c35d 100644 --- a/src/components/disclaimer-modal/disclaimer-modal.module.css +++ b/src/components/disclaimer-modal/disclaimer-modal.module.css @@ -14,9 +14,9 @@ top: 50px; left: 50%; margin-left: -151px; - background-color: rgb(255, 255, 255); - border: 1px solid rgb(136, 0, 0); - color: rgb(136, 0, 0); + background-color: var(--disclaimer-modal-background-color, rgb(255, 255, 255)); + border: var(--disclaimer-modal-border, 1px solid rgb(136, 0, 0)); + color: var(--disclaimer-modal-text-color, rgb(136, 0, 0)); box-shadow: rgba(0, 0, 0, 0.1) 2px 2px 0px 1px; font-family: arial, helvetica, clean, sans-serif; font-size: 13px; @@ -26,8 +26,8 @@ .hd { position: relative; - background-color: rgb(136, 0, 0); - color: rgb(255, 255, 255); + background-color: var(--disclaimer-modal-header-background-color, rgb(136, 0, 0)); + color: var(--disclaimer-modal-header-text-color, rgb(255, 255, 255)); padding: 4px 6px; display: flex; justify-content: space-between; @@ -70,7 +70,7 @@ } .bd a { - color: rgb(136, 0, 0); + color: var(--disclaimer-modal-text-color, rgb(136, 0, 0)); text-decoration: underline; } diff --git a/src/components/disclaimer-modal/disclaimer-modal.tsx b/src/components/disclaimer-modal/disclaimer-modal.tsx index eb5aad88..a39d6993 100644 --- a/src/components/disclaimer-modal/disclaimer-modal.tsx +++ b/src/components/disclaimer-modal/disclaimer-modal.tsx @@ -1,10 +1,12 @@ import { useNavigate } from 'react-router-dom'; import useDisclaimerModalStore from '../../stores/use-disclaimer-modal-store'; +import useDirectoryModalStore from '../../stores/use-directory-modal-store'; import styles from './disclaimer-modal.module.css'; const DisclaimerModal = () => { const navigate = useNavigate(); - const { showModal, closeDisclaimerModal, acceptDisclaimer } = useDisclaimerModalStore(); + const { showModal, closeDisclaimerModal, acceptDisclaimer, targetBoardPath } = useDisclaimerModalStore(); + const { openDirectoryModal } = useDirectoryModalStore(); if (!showModal) { return null; @@ -17,7 +19,13 @@ const DisclaimerModal = () => { }; const handleAccept = () => { - acceptDisclaimer(navigate); + // If there's no board path (placeholder), show directory modal instead + if (!targetBoardPath) { + closeDisclaimerModal(); + openDirectoryModal(); + } else { + acceptDisclaimer(navigate); + } }; return ( diff --git a/src/components/topbar-edit-modal/index.ts b/src/components/topbar-edit-modal/index.ts new file mode 100644 index 00000000..48862555 --- /dev/null +++ b/src/components/topbar-edit-modal/index.ts @@ -0,0 +1 @@ +export { default } from './topbar-edit-modal'; diff --git a/src/components/topbar-edit-modal/topbar-edit-modal.module.css b/src/components/topbar-edit-modal/topbar-edit-modal.module.css new file mode 100644 index 00000000..60610e80 --- /dev/null +++ b/src/components/topbar-edit-modal/topbar-edit-modal.module.css @@ -0,0 +1,142 @@ +.backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.25); + z-index: 999; +} + +.topbarEditDialog { + position: absolute; + width: 600px; + max-height: 80vh; + top: 50px; + left: 50%; + margin-left: -300px; + background-color: var(--settings-modal-background-color); + border-top: var(--settings-modal-border-top); + border-right: var(--settings-modal-border-right); + border-bottom: var(--settings-modal-border-bottom); + border-left: var(--settings-modal-border-left); + box-shadow: rgba(0, 0, 0, 0.1) 2px 2px 0px 1px; + font-size: var(--body-font-size); + line-height: 16.003px; + z-index: 1000; + display: flex; + flex-direction: column; +} + +@media (max-width: 600px) { + .topbarEditDialog { + width: calc(100% - 20px); + margin-left: -50%; + left: 50%; + max-width: 600px; + } +} + +.hd { + position: relative; + padding: 4px 6px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: var(--settings-modal-header-border-bottom); +} + +.hd h2 { + margin: 0; + padding: 0; + font-size: 131%; + font-weight: bold; +} + +.closeButton { + all: unset; + cursor: pointer; + width: 16px; + height: 16px; + position: absolute; + top: 4px; + right: 4px; + background-image: var(--settings-modal-close-button-background-image); + background-size: 100%; + background-position: center; + background-repeat: no-repeat; + image-rendering: pixelated; +} + +.bd { + padding: 10px; + overflow-y: auto; + flex: 1; +} + +.section { + margin-bottom: 20px; +} + +.section:last-child { + margin-bottom: 0; +} + +.section h3 { + margin: 0 0 8px 0; + padding: 0; + font-size: calc(var(--body-font-size) * 1.077); + font-weight: bold; + letter-spacing: 0.3px; +} + +.directoryInput { + width: 100%; + padding: 4px; + font-size: var(--body-font-size); + border: var(--reply-modal-field-input-border, 1px solid #aaa); + outline: none; + font-family: var(--body-font-family, arial, helvetica, sans-serif); +} + +.directoryInput:focus { + border: var(--reply-modal-field-input-border-focus, 1px solid #ea8); +} + +.checkboxGroup { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.checkboxItem { + display: flex; + align-items: center; + gap: 4px; +} + +.checkboxItem input[type='checkbox'] { + cursor: pointer; +} + +.checkboxItem label { + cursor: pointer; + font-size: var(--body-font-size); + user-select: none; +} + +.topbarEditFooter { + padding: 10px; + display: flex; + justify-content: center; + gap: 10px; + border-top: var(--settings-modal-header-border-bottom); +} + +.topbarEditFooter button { + padding: 4px 12px; + cursor: pointer; + font-size: var(--body-font-size); +} + diff --git a/src/components/topbar-edit-modal/topbar-edit-modal.tsx b/src/components/topbar-edit-modal/topbar-edit-modal.tsx new file mode 100644 index 00000000..3c1d005a --- /dev/null +++ b/src/components/topbar-edit-modal/topbar-edit-modal.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from 'react'; +import { useAccount } from '@plebbit/plebbit-react-hooks'; +import Plebbit from '@plebbit/plebbit-js'; +import useTopbarEditModalStore from '../../stores/use-topbar-edit-modal-store'; +import useTopbarVisibilityStore from '../../stores/use-topbar-visibility-store'; +import { getAllBoardCodes } from '../../constants/board-codes'; +import { getBoardPath } from '../../lib/utils/route-utils'; +import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits'; +import styles from './topbar-edit-modal.module.css'; + +const TopbarEditModal = () => { + const { showModal, closeTopbarEditModal } = useTopbarEditModalStore(); + const { visibleDirectories, visibleSubscriptions, setDirectoryVisibility, setSubscriptionVisibility } = useTopbarVisibilityStore(); + const account = useAccount(); + const subscriptions = account?.subscriptions || []; + const defaultSubplebbits = useDefaultSubplebbits(); + + // Convert visible directories set to space-separated string for input + const directoriesToString = (dirs: Set): string => { + return Array.from(dirs).sort().join(' '); + }; + + // Convert space-separated string to set of directory codes + const stringToDirectories = (str: string): Set => { + const codes = str + .trim() + .split(/\s+/) + .filter((code) => code.length > 0) + .map((code) => code.toLowerCase()); + return new Set(codes); + }; + + // Local state for text input (will be saved on Save click) + const [localDirectoryInput, setLocalDirectoryInput] = useState(directoriesToString(visibleDirectories)); + const [localSubscriptionVisibility, setLocalSubscriptionVisibility] = useState>(visibleSubscriptions); + + // Update local state when modal opens or store changes + useEffect(() => { + if (showModal) { + setLocalDirectoryInput(directoriesToString(visibleDirectories)); + setLocalSubscriptionVisibility(new Set(visibleSubscriptions)); + } + }, [showModal, visibleDirectories, visibleSubscriptions]); + + if (!showModal) { + return null; + } + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + closeTopbarEditModal(); + } + }; + + const handleSubscriptionToggle = (address: string) => { + const newSet = new Set(localSubscriptionVisibility); + if (newSet.has(address)) { + newSet.delete(address); + } else { + newSet.add(address); + } + setLocalSubscriptionVisibility(newSet); + }; + + const handleSave = () => { + // Parse directory input and apply changes + const inputDirectories = stringToDirectories(localDirectoryInput); + const allCodes = getAllBoardCodes(); + + // Hide all directories first, then show only the ones in the input + allCodes.forEach((code) => { + setDirectoryVisibility(code, inputDirectories.has(code)); + }); + + // Apply subscription visibility changes + subscriptions.forEach((address: string) => { + setSubscriptionVisibility(address, localSubscriptionVisibility.has(address)); + }); + + closeTopbarEditModal(); + }; + + const handleCancel = () => { + // Reset local state to match store + setLocalDirectoryInput(directoriesToString(visibleDirectories)); + setLocalSubscriptionVisibility(new Set(visibleSubscriptions)); + closeTopbarEditModal(); + }; + + return ( +
+
+
+

Custom Board List

+
+
+
+

Directory Boards

+

Enter directory codes separated by spaces (e.g., "jp tg mu"):

+ setLocalDirectoryInput(e.target.value)} + /> +
+ + {subscriptions.length > 0 && ( +
+

Subscriptions

+

Select which subscriptions to show in the topbar:

+
+ {subscriptions.map((address: string) => { + const boardPath = getBoardPath(address, defaultSubplebbits); + const displayText = address.endsWith('.eth') || address.endsWith('.sol') ? address : Plebbit.getShortAddress(address); + const isChecked = localSubscriptionVisibility.has(address); + return ( +
+ handleSubscriptionToggle(address)} /> + +
+ ); + })} +
+
+ )} +
+
+ + +
+
+
+ ); +}; + +export default TopbarEditModal; diff --git a/src/components/topbar-edit-modal/topbar-edit-modal.types.ts b/src/components/topbar-edit-modal/topbar-edit-modal.types.ts new file mode 100644 index 00000000..f215e160 --- /dev/null +++ b/src/components/topbar-edit-modal/topbar-edit-modal.types.ts @@ -0,0 +1,13 @@ +// Types for topbar edit modal component + +export interface BoardCodeCheckbox { + code: string; + label: string; + checked: boolean; +} + +export interface SubscriptionCheckbox { + address: string; + displayName: string; + checked: boolean; +} diff --git a/src/components/topbar/topbar.module.css b/src/components/topbar/topbar.module.css index d989114d..0990c96f 100644 --- a/src/components/topbar/topbar.module.css +++ b/src/components/topbar/topbar.module.css @@ -22,6 +22,11 @@ cursor: pointer; } +.placeholder { + text-decoration: line-through; + color: var(--topbar-desktop-link-text-color); +} + .navTopRight { float: right; text-transform: capitalize; diff --git a/src/components/topbar/topbar.tsx b/src/components/topbar/topbar.tsx index dbd488f7..c9a3d746 100644 --- a/src/components/topbar/topbar.tsx +++ b/src/components/topbar/topbar.tsx @@ -4,11 +4,15 @@ import { useTranslation } from 'react-i18next'; import Plebbit from '@plebbit/plebbit-js'; import { useAccount, useAccountComment, useAccountSubplebbits } from '@plebbit/plebbit-react-hooks'; import { isAllView, isCatalogView, isSubscriptionsView } from '../../lib/utils/view-utils'; -import { useDefaultSubplebbitAddresses, useDefaultSubplebbits } from '../../hooks/use-default-subplebbits'; +import { useDefaultSubplebbitAddresses, useDefaultSubplebbits, MultisubSubplebbit } from '../../hooks/use-default-subplebbits'; import { useBoardPath, useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; -import { getBoardPath } from '../../lib/utils/route-utils'; +import { getBoardPath, extractDirectoryFromTitle } from '../../lib/utils/route-utils'; import { TimeFilter } from '../board-buttons'; import useCreateBoardModalStore from '../../stores/use-create-board-modal-store'; +import useTopbarEditModalStore from '../../stores/use-topbar-edit-modal-store'; +import useTopbarVisibilityStore from '../../stores/use-topbar-visibility-store'; +import useDirectoryModalStore from '../../stores/use-directory-modal-store'; +import { BOARD_CODE_GROUPS } from '../../constants/board-codes'; import styles from './topbar.module.css'; import _, { debounce } from 'lodash'; @@ -71,6 +75,16 @@ const SearchBar = ({ setShowSearchBar }: { setShowSearchBar: (show: boolean) => ); }; +// Helper function to find board address by directory code +const findBoardAddressByCode = (code: string, defaultSubplebbits: MultisubSubplebbit[]): string | null => { + const entry = defaultSubplebbits.find((subplebbit) => { + if (!subplebbit.title) return false; + const directory = extractDirectoryFromTitle(subplebbit.title); + return directory === code; + }); + return entry?.address || null; +}; + const TopBarDesktop = () => { const { t } = useTranslation(); const account = useAccount(); @@ -79,13 +93,72 @@ const TopBarDesktop = () => { const isInCatalogView = isCatalogView(location.pathname, params); const [showSearchBar, setShowSearchBar] = useState(false); const { openCreateBoardModal } = useCreateBoardModalStore(); + const { openTopbarEditModal } = useTopbarEditModalStore(); + const { openDirectoryModal } = useDirectoryModalStore(); + const { visibleDirectories, visibleSubscriptions } = useTopbarVisibilityStore(); const defaultSubplebbits = useDefaultSubplebbits(); - const subscriptions = account?.subscriptions; - + const subscriptions = account?.subscriptions || []; const { accountSubplebbits } = useAccountSubplebbits(); const accountSubplebbitAddresses = Object.keys(accountSubplebbits); + // Filter subscriptions to only show visible ones + const visibleSubscriptionAddresses = subscriptions.filter((address: string) => visibleSubscriptions.has(address)); + + // Initialize visibility store on mount + useEffect(() => { + useTopbarVisibilityStore.getState().initialize(); + }, []); + + // Render a board code link or placeholder + const renderBoardCode = (code: string, isLastInGroup: boolean) => { + const address = findBoardAddressByCode(code, defaultSubplebbits); + const isPlaceholder = !address; + + const handleClick = (e: React.MouseEvent) => { + // If no address exists, prevent navigation and open directory modal + if (!address) { + e.preventDefault(); + e.stopPropagation(); + openDirectoryModal(); + } + }; + + const linkContent = ( + <> + {isPlaceholder ? ( + + {code} + + ) : ( + + {code} + + )} + + ); + + return ( + + {linkContent} + {!isLastInGroup && ' / '} + + ); + }; + + // Render a subscription link + const renderSubscription = (address: string, index: number, total: number) => { + const boardPath = getBoardPath(address, defaultSubplebbits); + const displayText = address.endsWith('.eth') || address.endsWith('.sol') ? address : Plebbit.getShortAddress(address); + + return ( + + {boardPath && boardPath.trim() ? {displayText} : {displayText}} + {index !== total - 1 && ' / '} + + ); + }; + return (
@@ -97,24 +170,20 @@ const TopBarDesktop = () => { )} ]{' '} - {subscriptions?.length > 0 && ( - <> - [ - {subscriptions.map((address: any, index: any) => { - const boardPath = getBoardPath(address, defaultSubplebbits); - const displayText = address.endsWith('.eth') || address.endsWith('.sol') ? address : address.slice(0, 10).concat('...'); - return ( - - {index === 0 ? null : ' '} - {boardPath && boardPath.trim() ? {displayText} : {displayText}} - {index !== subscriptions?.length - 1 ? ' /' : null} - - ); - })} - ]{' '} - + {BOARD_CODE_GROUPS.map((group, groupIndex) => { + const visibleCodes = group.filter((code) => visibleDirectories.has(code)); + if (visibleCodes.length === 0) return null; + + return [{visibleCodes.map((code, codeIndex) => renderBoardCode(code, codeIndex === visibleCodes.length - 1))}] ; + })} + {visibleSubscriptionAddresses.length > 0 && ( + <>[{visibleSubscriptionAddresses.map((address: string, index: number) => renderSubscription(address, index, visibleSubscriptionAddresses.length))}] )} [ + openTopbarEditModal()} style={{ cursor: 'pointer' }}> + {_.capitalize(t('edit'))} + + ] [ openCreateBoardModal()} style={{ cursor: 'pointer' }}> {t('create_board')} diff --git a/src/constants/board-codes.ts b/src/constants/board-codes.ts new file mode 100644 index 00000000..64b18b1d --- /dev/null +++ b/src/constants/board-codes.ts @@ -0,0 +1,147 @@ +/** + * Mapping of 4chan board codes to 5chan directory names + * Used for displaying board links in the topbar + */ +export const BOARD_CODE_TO_DIRECTORY: Record = { + a: 'Anime & Manga', + b: 'Random', + c: 'Anime/Cute', + d: 'Mecha', + e: 'Ecchi', + f: 'Flash', + g: 'Technology', + gif: 'Adult GIF', + h: 'Hentai', + hr: 'High Resolution', + k: 'Traditional Games', + m: 'Mecha', + o: 'Auto', + p: 'Photography', + r: 'Request', + s: 'Sexy Beautiful Women', + t: 'Torrents', + u: 'Yuri', + v: 'Video Games', + vg: 'Video Game Generals', + vm: 'Video Games/Multiplayer', + vmg: 'Video Games/Mobile', + vr: 'Retro Games', + vrpg: 'Video Games/RPG', + vst: 'Video Games/Strategy', + w: 'Anime/Wallpapers', + wg: 'Worksafe GIF', + i: 'Oekaki', + ic: 'Artwork/Critique', + r9k: 'ROBOT9001', + s4s: 'Shit 4chan Says', + vip: 'Very Important Posts', + cm: 'Cute/Male', + hm: 'Handsome Men', + lgbt: 'LGBT', + y: 'Yaoi', + '3': '3DCG', + aco: 'Anime/Cute', + adv: 'Advertisement', + an: 'Animals & Nature', + bant: 'International/Random', + biz: 'Business & Finance', + cgl: 'Cosplay & EGL', + ck: 'Food & Cooking', + co: 'Comics & Cartoons', + diy: 'Do-It-Yourself', + fa: 'Fashion', + fit: 'Fitness', + gd: 'Graphic Design', + hc: 'Hardcore', + his: 'History & Humanities', + int: 'International', + jp: 'Otaku Culture', + lit: 'Literature', + mlp: 'Pony', + mu: 'Music', + n: 'Transportation', + news: 'Current News', + out: 'Outdoors', + po: 'Papercraft & Origami', + pol: 'Politically Incorrect', + pw: 'Professional Wrestling', + qst: 'Quests', + sci: 'Science & Math', + soc: 'Cams & Meetups', + sp: 'Sports', + tg: 'Traditional Games', + toy: 'Toys', + trv: 'Travel', + tv: 'Television & Film', + vp: 'Pokémon', + vt: 'Virtual YouTubers', + wsg: 'Worksafe Requests', + wsr: 'Worksafe Requests', + x: 'Adult Cartoons', + xs: 'Extreme Sports', +}; + +/** + * Board code groups matching 4chan's bracket structure + * Each group represents boards displayed together in brackets + */ +export const BOARD_CODE_GROUPS: string[][] = [ + // Group 1: [a / b / c / d / e / f / g / gif / h / hr / k / m / o / p / r / s / t / u / v / vg / vm / vmg / vr / vrpg / vst / w / wg] + ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'gif', 'h', 'hr', 'k', 'm', 'o', 'p', 'r', 's', 't', 'u', 'v', 'vg', 'vm', 'vmg', 'vr', 'vrpg', 'vst', 'w', 'wg'], + // Group 2: [i / ic] + ['i', 'ic'], + // Group 3: [r9k / s4s / vip] + ['r9k', 's4s', 'vip'], + // Group 4: [cm / hm / lgbt / y] + ['cm', 'hm', 'lgbt', 'y'], + // Group 5: [3 / aco / adv / an / bant / biz / cgl / ck / co / diy / fa / fit / gd / hc / his / int / jp / lit / mlp / mu / n / news / out / po / pol / pw / qst / sci / soc / sp / tg / toy / trv / tv / vp / vt / wsg / wsr / x / xs] + [ + '3', + 'aco', + 'adv', + 'an', + 'bant', + 'biz', + 'cgl', + 'ck', + 'co', + 'diy', + 'fa', + 'fit', + 'gd', + 'hc', + 'his', + 'int', + 'jp', + 'lit', + 'mlp', + 'mu', + 'n', + 'news', + 'out', + 'po', + 'pol', + 'pw', + 'qst', + 'sci', + 'soc', + 'sp', + 'tg', + 'toy', + 'trv', + 'tv', + 'vp', + 'vt', + 'wsg', + 'wsr', + 'x', + 'xs', + ], +]; + +/** + * Get all board codes as a flat array + */ +export const getAllBoardCodes = (): string[] => { + return BOARD_CODE_GROUPS.flat(); +}; diff --git a/src/stores/use-disclaimer-modal-store.ts b/src/stores/use-disclaimer-modal-store.ts index cc0be158..3a63e61e 100644 --- a/src/stores/use-disclaimer-modal-store.ts +++ b/src/stores/use-disclaimer-modal-store.ts @@ -33,12 +33,17 @@ const useDisclaimerModalStore = create((set) => ({ targetAddress: null, targetBoardPath: null, - showDisclaimerModal: (address: string, navigate: NavigateFunction, boardPath?: string) => { + showDisclaimerModal: (address: string, navigate: NavigateFunction, boardPath?: string | null) => { // Check if user has already accepted the disclaimer if (hasAcceptedDisclaimer()) { - // Navigate directly without showing modal - const path = boardPath || address; - navigate(`/${path}`); + // If there's a valid board path, navigate directly + if (boardPath) { + navigate(`/${boardPath}`); + return; + } + // If no board path (placeholder), don't navigate - we'll handle this separately + // Don't show disclaimer modal either since it's already accepted + // Caller should handle showing directory modal or other UI return; } diff --git a/src/stores/use-topbar-edit-modal-store.ts b/src/stores/use-topbar-edit-modal-store.ts new file mode 100644 index 00000000..3e2e6272 --- /dev/null +++ b/src/stores/use-topbar-edit-modal-store.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand'; + +interface TopbarEditModalState { + showModal: boolean; + openTopbarEditModal: () => void; + closeTopbarEditModal: () => void; +} + +const useTopbarEditModalStore = create((set) => ({ + showModal: false, + + openTopbarEditModal: () => { + set({ + showModal: true, + }); + }, + + closeTopbarEditModal: () => { + set({ + showModal: false, + }); + }, +})); + +export default useTopbarEditModalStore; diff --git a/src/stores/use-topbar-visibility-store.ts b/src/stores/use-topbar-visibility-store.ts new file mode 100644 index 00000000..5feb1a7c --- /dev/null +++ b/src/stores/use-topbar-visibility-store.ts @@ -0,0 +1,117 @@ +import { create } from 'zustand'; +import { getAllBoardCodes } from '../constants/board-codes'; + +const LOCALSTORAGE_KEY_DIRECTORIES = '5chan-topbar-directories-visible'; +const LOCALSTORAGE_KEY_SUBSCRIPTIONS = '5chan-topbar-subscriptions-visible'; + +interface TopbarVisibilityState { + // Directory codes that are visible (all visible by default) + visibleDirectories: Set; + // Subscription addresses that are visible in topbar (all hidden by default) + visibleSubscriptions: Set; + // Actions + toggleDirectory: (code: string) => void; + toggleSubscription: (address: string) => void; + setDirectoryVisibility: (code: string, visible: boolean) => void; + setSubscriptionVisibility: (address: string, visible: boolean) => void; + // Initialize from localStorage + initialize: () => void; +} + +const loadFromLocalStorage = (key: string, defaultValue: Set): Set => { + try { + const stored = localStorage.getItem(key); + if (stored) { + const array = JSON.parse(stored); + return new Set(array); + } + } catch (e) { + console.warn(`Failed to load ${key} from localStorage:`, e); + } + return defaultValue; +}; + +const saveToLocalStorage = (key: string, set: Set) => { + try { + const array = Array.from(set); + localStorage.setItem(key, JSON.stringify(array)); + } catch (e) { + console.warn(`Failed to save ${key} to localStorage:`, e); + } +}; + +const useTopbarVisibilityStore = create((set, get) => { + // Initialize with all directories visible by default + const allBoardCodes = getAllBoardCodes(); + const defaultVisibleDirectories = new Set(allBoardCodes); + const defaultVisibleSubscriptions = new Set(); + + return { + visibleDirectories: loadFromLocalStorage(LOCALSTORAGE_KEY_DIRECTORIES, defaultVisibleDirectories), + visibleSubscriptions: loadFromLocalStorage(LOCALSTORAGE_KEY_SUBSCRIPTIONS, defaultVisibleSubscriptions), + + toggleDirectory: (code: string) => { + set((state) => { + const newSet = new Set(state.visibleDirectories); + if (newSet.has(code)) { + newSet.delete(code); + } else { + newSet.add(code); + } + saveToLocalStorage(LOCALSTORAGE_KEY_DIRECTORIES, newSet); + return { visibleDirectories: newSet }; + }); + }, + + toggleSubscription: (address: string) => { + set((state) => { + const newSet = new Set(state.visibleSubscriptions); + if (newSet.has(address)) { + newSet.delete(address); + } else { + newSet.add(address); + } + saveToLocalStorage(LOCALSTORAGE_KEY_SUBSCRIPTIONS, newSet); + return { visibleSubscriptions: newSet }; + }); + }, + + setDirectoryVisibility: (code: string, visible: boolean) => { + set((state) => { + const newSet = new Set(state.visibleDirectories); + if (visible) { + newSet.add(code); + } else { + newSet.delete(code); + } + saveToLocalStorage(LOCALSTORAGE_KEY_DIRECTORIES, newSet); + return { visibleDirectories: newSet }; + }); + }, + + setSubscriptionVisibility: (address: string, visible: boolean) => { + set((state) => { + const newSet = new Set(state.visibleSubscriptions); + if (visible) { + newSet.add(address); + } else { + newSet.delete(address); + } + saveToLocalStorage(LOCALSTORAGE_KEY_SUBSCRIPTIONS, newSet); + return { visibleSubscriptions: newSet }; + }); + }, + + initialize: () => { + // Load from localStorage on initialization + const directories = loadFromLocalStorage(LOCALSTORAGE_KEY_DIRECTORIES, defaultVisibleDirectories); + const subscriptions = loadFromLocalStorage(LOCALSTORAGE_KEY_SUBSCRIPTIONS, defaultVisibleSubscriptions); + set({ + visibleDirectories: directories, + visibleSubscriptions: subscriptions, + }); + }, + }; +}); + +export default useTopbarVisibilityStore; diff --git a/src/themes.css b/src/themes.css index a9a20642..eb6b8394 100644 --- a/src/themes.css +++ b/src/themes.css @@ -72,7 +72,13 @@ --homepage-box-link-text-color-hover: #e00; --homepage-box-link-text-decoration: none; --homepage-box-link-text-decoration-hover: underline; - --disclaimer-modal-close-button-background-image: url("/assets/buttons/icon-close-red.png"); + --directory-modal-background-color: rgb(255, 255, 255); + --directory-modal-border: 1px solid rgb(136, 0, 0); + --directory-modal-text-color: rgb(136, 0, 0); + --directory-modal-header-background-color: rgb(136, 0, 0); + --directory-modal-header-text-color: rgb(255, 255, 255); + --directory-modal-close-button-background-image: url("/assets/buttons/cross-red.png"); + /* footer */ --footer-item-background-color-desktop: #fed; @@ -285,7 +291,12 @@ --homepage-box-link-text-color-hover: #d00; --homepage-box-link-text-decoration: none; --homepage-box-link-text-decoration-hover: underline; - --disclaimer-modal-close-button-background-image: url("/assets/buttons/icon-close-red.png"); + --directory-modal-background-color: #d6daf0; + --directory-modal-border: 1px solid #b7c5d9; + --directory-modal-text-color: #000; + --directory-modal-header-background-color: #98e; + --directory-modal-header-text-color: #000; + --directory-modal-close-button-background-image: url("/assets/buttons/cross-blue.png"); /* horizontal rule */ --hr-border: none; @@ -485,6 +496,14 @@ --close-button-background-image: url("/assets/buttons/cross-red.png"); --open-help-button-background-image: url("/assets/buttons/help-red.png"); + /* directory modal */ + --directory-modal-background-color: #f0e0d6; + --directory-modal-border: 1px solid rgba(0, 0, 0, 0.20); + --directory-modal-text-color: #800; + --directory-modal-header-background-color: #ea8; + --directory-modal-header-text-color: #800; + --directory-modal-close-button-background-image: url("/assets/buttons/cross-red.png"); + /* footer */ --footer-item-background-color-desktop: #fed; @@ -675,7 +694,12 @@ --homepage-box-bar-text-color: #000; --homepage-box-bar-background-color: #98e; --homepage-box-bar-text-color-hover: #fff; - --disclaimer-modal-close-button-background-image: url("/assets/buttons/icon-close-red.png"); + --directory-modal-background-color: #d6daf0; + --directory-modal-border: 1px solid rgba(0, 0, 0, 0.20); + --directory-modal-text-color: #000; + --directory-modal-header-background-color: #98e; + --directory-modal-header-text-color: #000; + --directory-modal-close-button-background-image: url("/assets/buttons/cross-blue.png"); /* media */ --media-thumbnail-background-color: rgba(0, 0, 0, 0.05); @@ -867,7 +891,12 @@ --homepage-box-border-color: rgb(45, 47, 51); --homepage-box-bar-text-color: #c5c8c6; --homepage-box-bar-text-color-hover: #c5c8c6; - --disclaimer-modal-close-button-background-image: url("/assets/buttons/icon-close-red.png"); + --directory-modal-background-color: #282a2e; + --directory-modal-border: 1px solid #111; + --directory-modal-text-color: #c5c8c6; + --directory-modal-header-background-color: #282a2e; + --directory-modal-header-text-color: #c5c8c6; + --directory-modal-close-button-background-image: url("/assets/buttons/cross-dark.png"); /* horizontal rule */ --hr-border: none; @@ -1207,6 +1236,14 @@ --settings-modal-show-button-background-image: url("/assets/buttons/plus-photon.png"); --settings-modal-hide-button-background-image: url("/assets/buttons/minus-photon.png"); + /* directory modal */ + --directory-modal-background-color: #ddd; + --directory-modal-border: 1px solid #ccc; + --directory-modal-text-color: #333; + --directory-modal-header-background-color: #ddd; + --directory-modal-header-text-color: #333; + --directory-modal-close-button-background-image: url("/assets/buttons/cross-photon.png"); + /* stats */ --stats-font-size: 11px;