a tool for shared writing and social publishing

Fix/accent contrast on page (#251)

* inital tweaking of the page and leaflet background logic

* Added some helptext to page color picker when it's defining containers

* moved menu into its own file

* Create Menu.tsx

* unifed block layout

* little fix

* minor adjustments to unify hover and focus states

* adjusting buttons to match better with blocks

authored by cozylittle.house and committed by

GitHub d290f340 d742da94

+926 -713
+1 -1
app/(home-pages)/home/Actions/CreateNewButton.tsx
··· 5 5 import { AddTiny } from "components/Icons/AddTiny"; 6 6 import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; 7 7 import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 8 - import { Menu, MenuItem } from "components/Layout"; 8 + import { Menu, MenuItem } from "components/Menu"; 9 9 import { useIsMobile } from "src/hooks/isMobile"; 10 10 11 11 export const CreateNewLeafletButton = (props: {}) => {
+1 -1
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
··· 1 1 "use client"; 2 2 3 - import { Menu, MenuItem } from "components/Layout"; 3 + import { Menu, MenuItem } from "components/Menu"; 4 4 import { useState } from "react"; 5 5 import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 6 6 import { useToaster } from "components/Toast";
+1 -1
app/[leaflet_id]/actions/HelpButton.tsx
··· 161 161 className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline" 162 162 style={{ 163 163 backgroundColor: isHovered 164 - ? "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)" 164 + ? "rgb(var(--accent-light))" 165 165 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 166 166 }} 167 167 onMouseEnter={handleMouseEnter}
+1 -1
app/[leaflet_id]/actions/PublishButton.tsx
··· 13 13 import { PublishSmall } from "components/Icons/PublishSmall"; 14 14 import { useIdentityData } from "components/IdentityProvider"; 15 15 import { InputWithLabel } from "components/Input"; 16 - import { Menu, MenuItem } from "components/Layout"; 16 + import { Menu, MenuItem } from "components/Menu"; 17 17 import { 18 18 useLeafletDomains, 19 19 useLeafletPublicationData,
+1 -1
app/[leaflet_id]/actions/ShareOptions/index.tsx
··· 3 3 import { getShareLink } from "./getShareLink"; 4 4 import { useEntitySetContext } from "components/EntitySetProvider"; 5 5 import { useSmoker } from "components/Toast"; 6 - import { Menu, MenuItem } from "components/Layout"; 6 + import { Menu, MenuItem } from "components/Menu"; 7 7 import { ActionButton } from "components/ActionBar/ActionButton"; 8 8 import useSWR from "swr"; 9 9 import LoginForm from "app/login/LoginForm";
+5
app/globals.css
··· 270 270 } 271 271 272 272 pre.shiki { 273 + @apply sm:p-3; 273 274 @apply p-2; 274 275 @apply rounded-md; 275 276 @apply overflow-auto; 277 + 278 + @media (min-width: 640px) { 279 + @apply p-3; 280 + } 276 281 } 277 282 278 283 .highlight:has(+ .highlight) {
+1 -1
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 374 374 return ( 375 375 <div 376 376 className={`pageOptions w-fit z-10 377 - absolute sm:-right-[20px] right-3 sm:top-3 top-0 377 + absolute sm:-right-[19px] right-3 sm:top-3 top-0 378 378 flex sm:flex-col flex-row-reverse gap-1 items-start`} 379 379 > 380 380 <PageOptionButton onClick={props.onClick}>
+1 -2
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 4 4 import { PublicationSettingsButton } from "./PublicationSettings"; 5 5 import { ActionButton } from "components/ActionBar/ActionButton"; 6 6 import { ShareSmall } from "components/Icons/ShareSmall"; 7 - import { Menu } from "components/Layout"; 8 - import { MenuItem } from "components/Layout"; 7 + import { Menu, MenuItem } from "components/Menu"; 9 8 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 9 import { usePublicationData } from "./PublicationSWRProvider"; 11 10 import { useSmoker } from "components/Toast";
+2 -1
app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
··· 4 4 import { ButtonPrimary } from "components/Buttons"; 5 5 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 6 6 import { useSmoker } from "components/Toast"; 7 - import { Menu, MenuItem, Separator } from "components/Layout"; 7 + import { Menu, MenuItem } from "components/Menu"; 8 + import { Separator } from "components/Layout"; 8 9 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 9 10 import { Checkbox } from "components/Checkbox"; 10 11 import { useEffect, useState } from "react";
+1 -1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 7 7 import { Fragment, useState } from "react"; 8 8 import { useParams } from "next/navigation"; 9 9 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 - import { Menu, MenuItem } from "components/Layout"; 10 + import { Menu, MenuItem } from "components/Menu"; 11 11 import { deletePost } from "./deletePost"; 12 12 import { ButtonPrimary } from "components/Buttons"; 13 13 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
+26
components/Blocks/Block.tsx
··· 383 383 ); 384 384 }; 385 385 386 + export const BlockLayout = (props: { 387 + isSelected?: boolean; 388 + children: React.ReactNode; 389 + className?: string; 390 + hasBackground?: "accent" | "page"; 391 + borderOnHover?: boolean; 392 + }) => { 393 + return ( 394 + <div 395 + className={`block ${props.className} p-2 sm:p-3 w-full overflow-hidden 396 + ${props.isSelected ? "block-border-selected " : "block-border"} 397 + ${props.borderOnHover && "hover:border-accent-contrast! hover:outline-accent-contrast! focus-within:border-accent-contrast! focus-within:outline-accent-contrast!"}`} 398 + style={{ 399 + backgroundColor: 400 + props.hasBackground === "accent" 401 + ? "var(--accent-light)" 402 + : props.hasBackground === "page" 403 + ? "rgb(var(--bg-page))" 404 + : "transparent", 405 + }} 406 + > 407 + {props.children} 408 + </div> 409 + ); 410 + }; 411 + 386 412 export const ListMarker = ( 387 413 props: Block & { 388 414 previousBlock?: Block | null;
+7 -5
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 148 148 } 149 149 return ( 150 150 <div 151 - className={`flex flex-col gap-1 relative w-full overflow-hidden sm:p-3 p-2 text-xs block-border`} 151 + className={`flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`} 152 152 > 153 - <div className="bskyAuthor w-full flex items-center gap-1"> 153 + <div className="bskyAuthor w-full flex items-center "> 154 154 {record.author.avatar && ( 155 155 <img 156 156 src={record.author?.avatar} 157 157 alt={`${record.author?.displayName}'s avatar`} 158 - className="shink-0 w-6 h-6 rounded-full border border-border-light" 158 + className="shink-0 w-6 h-6 rounded-full border border-border-light mr-[6px]" 159 159 /> 160 160 )} 161 - <div className=" font-bold text-secondary"> 161 + <div className=" font-bold text-secondary mr-1"> 162 162 {record.author?.displayName} 163 163 </div> 164 164 <a ··· 171 171 </div> 172 172 173 173 <div className="flex flex-col gap-2 "> 174 - {text && <pre className="whitespace-pre-wrap">{text}</pre>} 174 + {text && ( 175 + <pre className="whitespace-pre-wrap text-secondary">{text}</pre> 176 + )} 175 177 {record.embeds !== undefined 176 178 ? record.embeds.map((embed, index) => ( 177 179 <BlueskyEmbed embed={embed} key={index} />
+8 -11
components/Blocks/BlueskyPostBlock/index.tsx
··· 2 2 import { useEffect, useState } from "react"; 3 3 import { useEntity } from "src/replicache"; 4 4 import { useUIState } from "src/useUIState"; 5 - import { BlockProps } from "../Block"; 5 + import { BlockProps, BlockLayout } from "../Block"; 6 6 import { elementId } from "src/utils/elementId"; 7 7 import { focusBlock } from "src/utils/focusBlock"; 8 8 import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; ··· 56 56 AppBskyFeedDefs.isBlockedAuthor(post) || 57 57 AppBskyFeedDefs.isNotFoundPost(post): 58 58 return ( 59 - <div 60 - className={`w-full ${isSelected ? "block-border-selected" : "block-border"}`} 61 - > 59 + <BlockLayout isSelected={!!isSelected} className="w-full"> 62 60 <PostNotAvailable /> 63 - </div> 61 + </BlockLayout> 64 62 ); 65 63 66 64 case AppBskyFeedDefs.isThreadViewPost(post): ··· 81 79 let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; 82 80 83 81 return ( 84 - <div 85 - className={` 86 - flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page 87 - ${isSelected ? "block-border-selected " : "block-border"} 88 - `} 82 + <BlockLayout 83 + isSelected={!!isSelected} 84 + hasBackground="page" 85 + className="flex flex-col gap-2 relative overflow-hidden group/blueskyPostBlock text-sm text-secondary" 89 86 > 90 87 {post.post.author && record && ( 91 88 <> ··· 149 146 </a> 150 147 </div> 151 148 </div> 152 - </div> 149 + </BlockLayout> 153 150 ); 154 151 } 155 152 };
+103 -103
components/Blocks/ButtonBlock.tsx
··· 3 3 import { useCallback, useEffect, useState } from "react"; 4 4 import { useEntity, useReplicache } from "src/replicache"; 5 5 import { useUIState } from "src/useUIState"; 6 - import { BlockProps } from "./Block"; 6 + import { BlockProps, BlockLayout } from "./Block"; 7 7 import { v7 } from "uuid"; 8 8 import { useSmoker } from "components/Toast"; 9 9 ··· 106 106 }; 107 107 108 108 return ( 109 - <div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full"> 109 + <div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full "> 110 110 <ButtonPrimary className="mx-auto"> 111 111 {text !== "" ? text : "Button"} 112 112 </ButtonPrimary> 113 - 114 - <form 115 - className={` 116 - buttonBlockSettingsBorder 117 - w-full bg-bg-page 118 - text-tertiary hover:text-accent-contrast hover:cursor-pointer hover:p-0 119 - flex flex-col gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg 120 - ${isSelected ? "border-2 border-tertiary p-0" : "border border-border p-px"} 121 - `} 122 - onSubmit={(e) => { 123 - e.preventDefault(); 124 - let rect = document 125 - .getElementById("button-block-settings") 126 - ?.getBoundingClientRect(); 127 - if (!textValue) { 128 - smoker({ 129 - error: true, 130 - text: "missing button text!", 131 - position: { 132 - y: rect ? rect.top : 0, 133 - x: rect ? rect.left + 12 : 0, 134 - }, 135 - }); 136 - return; 137 - } 138 - if (!urlValue) { 139 - smoker({ 140 - error: true, 141 - text: "missing url!", 142 - position: { 143 - y: rect ? rect.top : 0, 144 - x: rect ? rect.left + 12 : 0, 145 - }, 146 - }); 147 - return; 148 - } 149 - if (!isUrl(urlValue)) { 150 - smoker({ 151 - error: true, 152 - text: "invalid url!", 153 - position: { 154 - y: rect ? rect.top : 0, 155 - x: rect ? rect.left + 12 : 0, 156 - }, 157 - }); 158 - return; 159 - } 160 - submit(); 161 - }} 113 + <BlockLayout 114 + isSelected={!!isSelected} 115 + borderOnHover 116 + hasBackground="accent" 117 + className="buttonBlockSettings text-tertiar hover:cursor-pointer border-dashed! p-0!" 162 118 > 163 - <div className="buttonBlockSettingsContent w-full flex flex-col sm:flex-row gap-2 text-secondary px-2 py-3 sm:pb-3 pb-1"> 164 - <div className="buttonBlockSettingsTitleInput flex gap-2 w-full sm:w-52"> 165 - <BlockButtonSmall 166 - className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 167 - /> 168 - <Separator /> 169 - <Input 170 - type="text" 171 - autoFocus 172 - className="w-full grow border-none outline-hidden bg-transparent" 173 - placeholder="button text" 174 - value={textValue} 175 - disabled={isLocked} 176 - onChange={(e) => setTextValue(e.target.value)} 177 - onKeyDown={(e) => { 178 - if ( 179 - e.key === "Backspace" && 180 - !e.currentTarget.value && 181 - urlValue !== "" 182 - ) 183 - e.preventDefault(); 184 - }} 185 - /> 186 - </div> 187 - <div className="buttonBlockSettingsLinkInput grow flex gap-2 w-full"> 188 - <LinkSmall 189 - className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 190 - /> 191 - <Separator /> 192 - <Input 193 - type="text" 194 - id="button-block-url-input" 195 - className="w-full grow border-none outline-hidden bg-transparent" 196 - placeholder="www.example.com" 197 - value={urlValue} 198 - disabled={isLocked} 199 - onChange={(e) => setUrlValue(e.target.value)} 200 - onKeyDown={(e) => { 201 - if (e.key === "Backspace" && !e.currentTarget.value) 202 - e.preventDefault(); 203 - }} 204 - /> 119 + <form 120 + className={`w-full`} 121 + onSubmit={(e) => { 122 + e.preventDefault(); 123 + let rect = document 124 + .getElementById("button-block-settings") 125 + ?.getBoundingClientRect(); 126 + if (!textValue) { 127 + smoker({ 128 + error: true, 129 + text: "missing button text!", 130 + position: { 131 + y: rect ? rect.top : 0, 132 + x: rect ? rect.left + 12 : 0, 133 + }, 134 + }); 135 + return; 136 + } 137 + if (!urlValue) { 138 + smoker({ 139 + error: true, 140 + text: "missing url!", 141 + position: { 142 + y: rect ? rect.top : 0, 143 + x: rect ? rect.left + 12 : 0, 144 + }, 145 + }); 146 + return; 147 + } 148 + if (!isUrl(urlValue)) { 149 + smoker({ 150 + error: true, 151 + text: "invalid url!", 152 + position: { 153 + y: rect ? rect.top : 0, 154 + x: rect ? rect.left + 12 : 0, 155 + }, 156 + }); 157 + return; 158 + } 159 + submit(); 160 + }} 161 + > 162 + <div className="buttonBlockSettingsContent w-full flex flex-col sm:flex-row gap-2 text-secondary px-2 py-3 sm:pb-3 pb-1"> 163 + <div className="buttonBlockSettingsTitleInput flex gap-2 w-full sm:w-52"> 164 + <BlockButtonSmall 165 + className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 166 + /> 167 + <Separator /> 168 + <Input 169 + type="text" 170 + autoFocus 171 + className="w-full grow border-none outline-hidden bg-transparent" 172 + placeholder="button text" 173 + value={textValue} 174 + disabled={isLocked} 175 + onChange={(e) => setTextValue(e.target.value)} 176 + onKeyDown={(e) => { 177 + if ( 178 + e.key === "Backspace" && 179 + !e.currentTarget.value && 180 + urlValue !== "" 181 + ) 182 + e.preventDefault(); 183 + }} 184 + /> 185 + </div> 186 + <div className="buttonBlockSettingsLinkInput grow flex gap-2 w-full"> 187 + <LinkSmall 188 + className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 189 + /> 190 + <Separator /> 191 + <Input 192 + type="text" 193 + id="button-block-url-input" 194 + className="w-full grow border-none outline-hidden bg-transparent" 195 + placeholder="www.example.com" 196 + value={urlValue} 197 + disabled={isLocked} 198 + onChange={(e) => setUrlValue(e.target.value)} 199 + onKeyDown={(e) => { 200 + if (e.key === "Backspace" && !e.currentTarget.value) 201 + e.preventDefault(); 202 + }} 203 + /> 204 + </div> 205 + <button 206 + id="button-block-settings" 207 + type="submit" 208 + className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 209 + > 210 + <div className="sm:hidden block">Save</div> 211 + <CheckTiny /> 212 + </button> 205 213 </div> 206 - <button 207 - id="button-block-settings" 208 - type="submit" 209 - className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 210 - > 211 - <div className="sm:hidden block">Save</div> 212 - <CheckTiny /> 213 - </button> 214 - </div> 215 - </form> 214 + </form> 215 + </BlockLayout> 216 216 </div> 217 217 ); 218 218 };
+17 -6
components/Blocks/CodeBlock.tsx
··· 6 6 } from "shiki"; 7 7 import { useEntity, useReplicache } from "src/replicache"; 8 8 import "katex/dist/katex.min.css"; 9 - import { BlockProps } from "./Block"; 9 + import { BlockLayout, BlockProps } from "./Block"; 10 10 import { useCallback, useLayoutEffect, useMemo, useState } from "react"; 11 11 import { useUIState } from "src/useUIState"; 12 12 import { BaseTextareaBlock } from "./BaseTextareaBlock"; ··· 119 119 </select> 120 120 </div> 121 121 )} 122 - <div className="w-full min-h-[42px] rounded-md border-border-light outline-border-light selected-outline"> 122 + 123 + <BlockLayout 124 + isSelected={focusedBlock} 125 + hasBackground="accent" 126 + borderOnHover 127 + className="p-0! min-h-[48px]" 128 + > 123 129 {focusedBlock && permissions.write ? ( 124 130 <BaseTextareaBlock 131 + placeholder="write some code…" 125 132 data-editable-block 126 133 data-entityid={props.entityID} 127 134 id={elementId.block(props.entityID).input} ··· 131 138 spellCheck={false} 132 139 autoCapitalize="none" 133 140 autoCorrect="off" 134 - className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2" 141 + className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2 sm:p-3" 135 142 value={content?.data.value} 136 143 onChange={async (e) => { 137 144 // Update the entity with the new value ··· 146 153 <pre 147 154 onClick={onClick} 148 155 onMouseDown={(e) => e.stopPropagation()} 149 - className="codeBlockRendered overflow-auto! font-mono p-2 w-full h-full" 156 + className="codeBlockRendered overflow-auto! font-mono p-2 sm:p-3 w-full h-full" 150 157 > 151 - {content?.data.value} 158 + {content?.data.value === "" || content?.data.value === undefined ? ( 159 + <div className="text-tertiary italic">write some code…</div> 160 + ) : ( 161 + content?.data.value 162 + )} 152 163 </pre> 153 164 ) : ( 154 165 <div ··· 159 170 dangerouslySetInnerHTML={{ __html: html || "" }} 160 171 /> 161 172 )} 162 - </div> 173 + </BlockLayout> 163 174 </div> 164 175 ); 165 176 }
+5 -5
components/Blocks/DateTimeBlock.tsx
··· 1 1 import { useEntity, useReplicache } from "src/replicache"; 2 - import { BlockProps } from "./Block"; 2 + import { BlockProps, BlockLayout } from "./Block"; 3 3 import { ChevronProps, DayPicker } from "react-day-picker"; 4 4 import { Popover } from "components/Popover"; 5 5 import { useEffect, useMemo, useState } from "react"; ··· 121 121 disabled={isLocked || !permissions.write} 122 122 className="w-64 z-10 px-2!" 123 123 trigger={ 124 - <div 125 - className={`flex flex-row gap-2 group/date w-64 z-1 126 - ${isSelected ? "block-border-selected border-transparent!" : "border border-transparent"} 124 + <BlockLayout 125 + isSelected={!!isSelected} 126 + className={`flex flex-row gap-2 group/date w-64 z-1 border-transparent! 127 127 ${alignment === "center" ? "justify-center" : alignment === "right" ? "justify-end" : "justify-start"} 128 128 `} 129 129 > ··· 163 163 </div> 164 164 )} 165 165 </FadeIn> 166 - </div> 166 + </BlockLayout> 167 167 } 168 168 > 169 169 <div className="flex flex-col gap-3 ">
+13 -16
components/Blocks/EmbedBlock.tsx
··· 3 3 import { useCallback, useEffect, useState } from "react"; 4 4 import { useEntity, useReplicache } from "src/replicache"; 5 5 import { useUIState } from "src/useUIState"; 6 - import { BlockProps } from "./Block"; 6 + import { BlockProps, BlockLayout } from "./Block"; 7 7 import { v7 } from "uuid"; 8 8 import { useSmoker } from "components/Toast"; 9 9 import { Separator } from "components/Layout"; ··· 84 84 <div 85 85 className={`w-full ${heightHandle.dragDelta ? "pointer-events-none" : ""}`} 86 86 > 87 - {/* 88 - the iframe! 89 - can also add 'allow' and 'referrerpolicy' attributes later if needed 90 - */} 91 - <iframe 92 - className={` 93 - flex flex-col relative w-full overflow-hidden group/embedBlock 94 - ${isSelected ? "block-border-selected " : "block-border"} 95 - `} 96 - width="100%" 97 - height={height + (heightHandle.dragDelta?.y || 0)} 98 - src={url?.data.value} 99 - allow="fullscreen" 100 - loading="lazy" 101 - ></iframe> 87 + <BlockLayout 88 + isSelected={!!isSelected} 89 + className="flex flex-col relative w-full overflow-hidden group/embedBlock p-0!" 90 + > 91 + <iframe 92 + width="100%" 93 + height={height + (heightHandle.dragDelta?.y || 0)} 94 + src={url?.data.value} 95 + allow="fullscreen" 96 + loading="lazy" 97 + ></iframe> 98 + </BlockLayout> 102 99 {/* <div className="w-full overflow-x-hidden truncate text-xs italic text-accent-contrast"> 103 100 <a 104 101 href={url?.data.value}
+43 -42
components/Blocks/ExternalLinkBlock.tsx
··· 4 4 import { useEntity, useReplicache } from "src/replicache"; 5 5 import { useUIState } from "src/useUIState"; 6 6 import { addLinkBlock } from "src/utils/addLinkBlock"; 7 - import { BlockProps } from "./Block"; 7 + import { BlockProps, BlockLayout } from "./Block"; 8 8 import { v7 } from "uuid"; 9 9 import { useSmoker } from "components/Toast"; 10 10 import { Separator } from "components/Layout"; ··· 64 64 } 65 65 66 66 return ( 67 - <a 68 - href={url?.data.value} 69 - target="_blank" 70 - className={` 71 - externalLinkBlock flex relative group/linkBlock 72 - h-[104px] w-full bg-bg-page overflow-hidden text-primary hover:no-underline no-underline 73 - hover:border-accent-contrast shadow-sm 74 - ${isSelected ? "block-border-selected outline-accent-contrast! border-accent-contrast!" : "block-border"} 75 - 76 - `} 67 + <BlockLayout 68 + isSelected={!!isSelected} 69 + hasBackground="page" 70 + borderOnHover 71 + className="externalLinkBlock flex relative group/linkBlock h-[104px] p-0!" 77 72 > 78 - <div className="pt-2 pb-2 px-3 grow min-w-0"> 79 - <div className="flex flex-col w-full min-w-0 h-full grow "> 80 - <div 81 - className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 82 - style={{ 83 - overflow: "hidden", 84 - textOverflow: "ellipsis", 85 - wordBreak: "break-all", 86 - }} 87 - > 88 - {title?.data.value} 89 - </div> 73 + <a 74 + href={url?.data.value} 75 + target="_blank" 76 + className="flex w-full h-full text-primary hover:no-underline no-underline" 77 + > 78 + <div className="pt-2 pb-2 px-3 grow min-w-0"> 79 + <div className="flex flex-col w-full min-w-0 h-full grow "> 80 + <div 81 + className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 82 + style={{ 83 + overflow: "hidden", 84 + textOverflow: "ellipsis", 85 + wordBreak: "break-all", 86 + }} 87 + > 88 + {title?.data.value} 89 + </div> 90 90 91 - <div 92 - className={`linkBlockDescription text-sm bg-transparent border-none outline-hidden resize-none align-top grow line-clamp-2`} 93 - > 94 - {description?.data.value} 95 - </div> 96 - <div 97 - style={{ wordBreak: "break-word" }} // better than tailwind break-all! 98 - className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${isSelected ? "text-accent-contrast" : "text-tertiary"}`} 99 - > 100 - {url?.data.value} 91 + <div 92 + className={`linkBlockDescription text-sm bg-transparent border-none outline-hidden resize-none align-top grow line-clamp-2`} 93 + > 94 + {description?.data.value} 95 + </div> 96 + <div 97 + style={{ wordBreak: "break-word" }} // better than tailwind break-all! 98 + className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${isSelected ? "text-accent-contrast" : "text-tertiary"}`} 99 + > 100 + {url?.data.value} 101 + </div> 101 102 </div> 102 103 </div> 103 - </div> 104 104 105 - <div 106 - className={`linkBlockPreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center`} 107 - style={{ 108 - backgroundImage: `url(${previewImage?.data.src})`, 109 - backgroundPosition: "center", 110 - }} 111 - /> 112 - </a> 105 + <div 106 + className={`linkBlockPreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center`} 107 + style={{ 108 + backgroundImage: `url(${previewImage?.data.src})`, 109 + backgroundPosition: "center", 110 + }} 111 + /> 112 + </a> 113 + </BlockLayout> 113 114 ); 114 115 }; 115 116
+33 -26
components/Blocks/ImageBlock.tsx
··· 1 1 "use client"; 2 2 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 - import { BlockProps } from "./Block"; 4 + import { BlockProps, BlockLayout } from "./Block"; 5 5 import { useUIState } from "src/useUIState"; 6 6 import Image from "next/image"; 7 7 import { v7 } from "uuid"; ··· 64 64 factID: v7(), 65 65 permission_set: entity_set.set, 66 66 type: "text", 67 - position: generateKeyBetween( 68 - props.position, 69 - props.nextPosition, 70 - ), 67 + position: generateKeyBetween(props.position, props.nextPosition), 71 68 newEntityID: entity, 72 69 }); 73 70 } ··· 85 82 if (!image) { 86 83 if (!entity_set.permissions.write) return null; 87 84 return ( 88 - <div className="grow w-full"> 85 + <BlockLayout 86 + hasBackground="accent" 87 + isSelected={!!isSelected && !isLocked} 88 + borderOnHover 89 + className=" group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg" 90 + > 89 91 <label 90 92 className={` 91 - group/image-block 92 - w-full h-[104px] hover:cursor-pointer p-2 93 - text-tertiary hover:text-accent-contrast hover:font-bold 93 + 94 + w-full h-full hover:cursor-pointer 94 95 flex flex-col items-center justify-center 95 - hover:border-2 border-dashed hover:border-accent-contrast rounded-lg 96 - ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"} 97 96 ${props.pageType === "canvas" && "bg-bg-page"}`} 98 97 onMouseDown={(e) => e.preventDefault()} 99 98 onDragOver={(e) => { ··· 107 106 const files = e.dataTransfer.files; 108 107 if (files && files.length > 0) { 109 108 const file = files[0]; 110 - if (file.type.startsWith('image/')) { 109 + if (file.type.startsWith("image/")) { 111 110 await handleImageUpload(file); 112 111 } 113 112 } ··· 131 130 }} 132 131 /> 133 132 </label> 134 - </div> 133 + </BlockLayout> 135 134 ); 136 135 } 137 136 138 - let className = isFullBleed 137 + let imageClassName = isFullBleed 139 138 ? "" 140 139 : isSelected 141 140 ? "block-border-selected border-transparent! " ··· 143 142 144 143 let isLocalUpload = localImages.get(image.data.src); 145 144 145 + let blockClassName = ` 146 + relative group/image border-transparent! p-0! w-fit! 147 + ${isFullBleed && "-mx-3 sm:-mx-4"} 148 + ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 149 + ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} 150 + `; 151 + 146 152 return ( 147 - <div 148 - className={`relative group/image 149 - ${className} 150 - ${isFullBleed && "-mx-3 sm:-mx-4"} 151 - ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 152 - ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} `} 153 - > 154 - {isFullBleed && isSelected ? <FullBleedSelectionIndicator /> : null} 153 + <BlockLayout isSelected={!!isSelected} className={blockClassName}> 155 154 {isLocalUpload || image.data.local ? ( 156 155 <img 157 156 loading="lazy" ··· 169 168 } 170 169 height={image?.data.height} 171 170 width={image?.data.width} 172 - className={className} 171 + className={imageClassName} 173 172 /> 174 173 )} 175 174 {altText !== undefined && !props.preview ? ( 176 175 <ImageAlt entityID={props.value} /> 177 176 ) : null} 178 177 {!props.preview ? <CoverImageButton entityID={props.value} /> : null} 179 - </div> 178 + </BlockLayout> 180 179 ); 181 180 } 182 181 ··· 200 199 let coverImage = useSubscribe(rep, (tx) => 201 200 tx.get<string | null>("publication_cover_image"), 202 201 ); 203 - let isFocused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 202 + let isFocused = useUIState( 203 + (s) => s.focusedEntity?.entityID === props.entityID, 204 + ); 204 205 205 206 // Only show if focused, in a publication, has write permissions, and no cover image is set 206 - if (!isFocused || !pubData?.publications || !entity_set.permissions.write || coverImage) return null; 207 + if ( 208 + !isFocused || 209 + !pubData?.publications || 210 + !entity_set.permissions.write || 211 + coverImage 212 + ) 213 + return null; 207 214 208 215 return ( 209 216 <div className="absolute top-2 left-2">
+80 -94
components/Blocks/MailboxBlock.tsx
··· 1 1 import { ButtonPrimary } from "components/Buttons"; 2 2 import { Popover } from "components/Popover"; 3 - import { Menu, MenuItem, Separator } from "components/Layout"; 3 + import { MenuItem } from "components/Menu"; 4 + import { Separator } from "components/Layout"; 4 5 import { useUIState } from "src/useUIState"; 5 6 import { useState } from "react"; 6 7 import { useSmoker, useToaster } from "components/Toast"; 7 - import { BlockProps } from "./Block"; 8 + import { BlockProps, BlockLayout } from "./Block"; 8 9 import { useEntity, useReplicache } from "src/replicache"; 9 10 import { useEntitySetContext } from "components/EntitySetProvider"; 10 11 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; ··· 45 46 46 47 return ( 47 48 <div className={`mailboxContent relative w-full flex flex-col gap-1`}> 48 - <div 49 - className={`flex flex-col gap-2 items-center justify-center w-full 50 - ${isSelected ? "block-border-selected " : "block-border"} `} 51 - style={{ 52 - backgroundColor: 53 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 54 - }} 49 + <BlockLayout 50 + isSelected={!!isSelected} 51 + hasBackground={"accent"} 52 + className="flex gap-2 items-center justify-center" 55 53 > 56 - <div className="flex gap-2 p-4"> 57 - <ButtonPrimary 58 - onClick={async () => { 59 - let entity; 60 - if (draft) { 61 - entity = draft.data.value; 62 - } else { 63 - entity = v7(); 64 - await rep?.mutate.createDraft({ 65 - mailboxEntity: props.entityID, 66 - permission_set: entity_set.set, 67 - newEntity: entity, 68 - firstBlockEntity: v7(), 69 - firstBlockFactID: v7(), 70 - }); 71 - } 72 - useUIState.getState().openPage(props.parent, entity); 73 - if (rep) focusPage(entity, rep, "focusFirstBlock"); 74 - return; 75 - }} 76 - > 77 - {draft ? "Edit Draft" : "Write a Post"} 78 - </ButtonPrimary> 79 - <MailboxInfo /> 80 - </div> 81 - </div> 54 + <ButtonPrimary 55 + onClick={async () => { 56 + let entity; 57 + if (draft) { 58 + entity = draft.data.value; 59 + } else { 60 + entity = v7(); 61 + await rep?.mutate.createDraft({ 62 + mailboxEntity: props.entityID, 63 + permission_set: entity_set.set, 64 + newEntity: entity, 65 + firstBlockEntity: v7(), 66 + firstBlockFactID: v7(), 67 + }); 68 + } 69 + useUIState.getState().openPage(props.parent, entity); 70 + if (rep) focusPage(entity, rep, "focusFirstBlock"); 71 + return; 72 + }} 73 + > 74 + {draft ? "Edit Draft" : "Write a Post"} 75 + </ButtonPrimary> 76 + <MailboxInfo /> 77 + </BlockLayout> 82 78 <div className="flex gap-3 items-center justify-between"> 83 79 { 84 80 <> ··· 134 130 let { rep } = useReplicache(); 135 131 return ( 136 132 <div className={`mailboxContent relative w-full flex flex-col gap-1 h-32`}> 137 - <div 138 - className={`h-full flex flex-col gap-2 items-center justify-center w-full rounded-md border outline ${ 139 - isSelected 140 - ? "border-border outline-border" 141 - : "border-border-light outline-transparent" 142 - }`} 143 - style={{ 144 - backgroundColor: 145 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 146 - }} 133 + <BlockLayout 134 + isSelected={!!isSelected} 135 + hasBackground={"accent"} 136 + className="`h-full flex flex-col gap-2 items-center justify-center" 147 137 > 148 - <div className="flex flex-col w-full gap-2 p-4"> 149 - {!isSubscribed?.confirmed ? ( 150 - <> 151 - <SubscribeForm 152 - entityID={props.entityID} 153 - role={"reader"} 154 - parent={props.parent} 155 - /> 156 - </> 157 - ) : ( 158 - <div className="flex flex-col gap-2 items-center place-self-center"> 159 - <div className=" font-bold text-secondary "> 160 - You&apos;re Subscribed! 161 - </div> 162 - <div className="flex flex-col gap-1 items-center place-self-center"> 163 - {archive ? ( 164 - <ButtonPrimary 165 - onMouseDown={(e) => { 166 - e.preventDefault(); 167 - if (rep) { 168 - useUIState 169 - .getState() 170 - .openPage(props.parent, archive.data.value); 171 - focusPage(archive.data.value, rep); 172 - } 173 - }} 174 - > 175 - See All Posts 176 - </ButtonPrimary> 177 - ) : ( 178 - <div className="text-tertiary"> 179 - Nothing has been posted yet 180 - </div> 181 - )} 182 - <button 183 - className="text-accent-contrast hover:underline text-sm" 184 - onClick={(e) => { 185 - let rect = e.currentTarget.getBoundingClientRect(); 186 - unsubscribe(isSubscribed); 187 - smoke({ 188 - text: "unsubscribed!", 189 - position: { x: rect.left, y: rect.top - 8 }, 190 - }); 138 + {!isSubscribed?.confirmed ? ( 139 + <> 140 + <SubscribeForm 141 + entityID={props.entityID} 142 + role={"reader"} 143 + parent={props.parent} 144 + /> 145 + </> 146 + ) : ( 147 + <div className="flex flex-col gap-2 items-center place-self-center"> 148 + <div className=" font-bold text-secondary "> 149 + You&apos;re Subscribed! 150 + </div> 151 + <div className="flex flex-col gap-1 items-center place-self-center"> 152 + {archive ? ( 153 + <ButtonPrimary 154 + onMouseDown={(e) => { 155 + e.preventDefault(); 156 + if (rep) { 157 + useUIState 158 + .getState() 159 + .openPage(props.parent, archive.data.value); 160 + focusPage(archive.data.value, rep); 161 + } 191 162 }} 192 163 > 193 - unsubscribe 194 - </button> 195 - </div> 164 + See All Posts 165 + </ButtonPrimary> 166 + ) : ( 167 + <div className="text-tertiary">Nothing has been posted yet</div> 168 + )} 169 + <button 170 + className="text-accent-contrast hover:underline text-sm" 171 + onClick={(e) => { 172 + let rect = e.currentTarget.getBoundingClientRect(); 173 + unsubscribe(isSubscribed); 174 + smoke({ 175 + text: "unsubscribed!", 176 + position: { x: rect.left, y: rect.top - 8 }, 177 + }); 178 + }} 179 + > 180 + unsubscribe 181 + </button> 196 182 </div> 197 - )} 198 - </div> 199 - </div> 183 + </div> 184 + )} 185 + </BlockLayout> 200 186 </div> 201 187 ); 202 188 };
+33 -23
components/Blocks/MathBlock.tsx
··· 1 1 import { useEntity, useReplicache } from "src/replicache"; 2 2 import "katex/dist/katex.min.css"; 3 - import { BlockProps } from "./Block"; 3 + import { BlockLayout, BlockProps } from "./Block"; 4 4 import Katex from "katex"; 5 5 import { useMemo } from "react"; 6 6 import { useUIState } from "src/useUIState"; ··· 32 32 } 33 33 }, [content?.data.value]); 34 34 return focusedBlock ? ( 35 - <BaseTextareaBlock 36 - id={elementId.block(props.entityID).input} 37 - block={props} 38 - spellCheck={false} 39 - autoCapitalize="none" 40 - autoCorrect="off" 41 - className="bg-border-light rounded-md p-2 w-full min-h-[48px] whitespace-nowrap overflow-auto! border-border-light outline-border-light selected-outline" 42 - placeholder="write some Tex here..." 43 - value={content?.data.value} 44 - onChange={async (e) => { 45 - // Update the entity with the new value 46 - await rep?.mutate.assertFact({ 47 - attribute: "block/math", 48 - entity: props.entityID, 49 - data: { type: "string", value: e.target.value }, 50 - }); 51 - }} 52 - /> 35 + <BlockLayout 36 + isSelected={focusedBlock} 37 + hasBackground="accent" 38 + className="min-h-[48px]" 39 + > 40 + <BaseTextareaBlock 41 + id={elementId.block(props.entityID).input} 42 + block={props} 43 + spellCheck={false} 44 + autoCapitalize="none" 45 + autoCorrect="off" 46 + className="h-full w-full whitespace-nowrap overflow-auto!" 47 + placeholder="write some Tex here..." 48 + value={content?.data.value} 49 + onChange={async (e) => { 50 + // Update the entity with the new value 51 + await rep?.mutate.assertFact({ 52 + attribute: "block/math", 53 + entity: props.entityID, 54 + data: { type: "string", value: e.target.value }, 55 + }); 56 + }} 57 + /> 58 + </BlockLayout> 53 59 ) : html && content?.data.value ? ( 54 60 <div 55 - className="text-lg min-h-[66px] w-full border border-transparent" 61 + className="text-lg min-h-[48px] w-full border border-transparent" 56 62 dangerouslySetInnerHTML={{ __html: html }} 57 63 /> 58 64 ) : ( 59 - <div className="text-tertiary italic rounded-md p-2 w-full min-h-16"> 60 - write some Tex here... 61 - </div> 65 + <BlockLayout 66 + isSelected={focusedBlock} 67 + hasBackground="accent" 68 + className="min-h-[48px]" 69 + > 70 + <div className="text-tertiary italic w-full ">write some Tex here...</div> 71 + </BlockLayout> 62 72 ); 63 73 }
+26 -22
components/Blocks/PageLinkBlock.tsx
··· 1 1 "use client"; 2 - import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 2 + import { BlockProps, ListMarker, Block, BlockLayout } from "./Block"; 3 3 import { focusBlock } from "src/utils/focusBlock"; 4 4 5 5 import { focusPage } from "src/utils/focusPage"; ··· 29 29 30 30 return ( 31 31 <CardThemeProvider entityID={page?.data.value}> 32 - <div 33 - className={`w-full cursor-pointer 32 + <BlockLayout 33 + hasBackground="page" 34 + isSelected={!!isSelected} 35 + className={`cursor-pointer 34 36 pageLinkBlockWrapper relative group/pageLinkBlock 35 - bg-bg-page shadow-sm 36 - flex overflow-clip 37 - ${isSelected ? "block-border-selected " : "block-border"} 38 - ${isOpen && "border-tertiary!"} 37 + flex overflow-clip p-0! 38 + ${isOpen && "border-accent-contrast! outline-accent-contrast!"} 39 39 `} 40 - onClick={(e) => { 41 - if (!page) return; 42 - if (e.isDefaultPrevented()) return; 43 - if (e.shiftKey) return; 44 - e.preventDefault(); 45 - e.stopPropagation(); 46 - useUIState.getState().openPage(props.parent, page.data.value); 47 - if (rep) focusPage(page.data.value, rep); 48 - }} 49 40 > 50 - {type === "canvas" && page ? ( 51 - <CanvasLinkBlock entityID={page?.data.value} /> 52 - ) : ( 53 - <DocLinkBlock {...props} /> 54 - )} 55 - </div> 41 + <div 42 + className="w-full h-full" 43 + onClick={(e) => { 44 + if (!page) return; 45 + if (e.isDefaultPrevented()) return; 46 + if (e.shiftKey) return; 47 + e.preventDefault(); 48 + e.stopPropagation(); 49 + useUIState.getState().openPage(props.parent, page.data.value); 50 + if (rep) focusPage(page.data.value, rep); 51 + }} 52 + > 53 + {type === "canvas" && page ? ( 54 + <CanvasLinkBlock entityID={page?.data.value} /> 55 + ) : ( 56 + <DocLinkBlock {...props} /> 57 + )} 58 + </div> 59 + </BlockLayout> 56 60 </CardThemeProvider> 57 61 ); 58 62 }
+7 -10
components/Blocks/PollBlock/index.tsx
··· 1 1 import { useUIState } from "src/useUIState"; 2 - import { BlockProps } from "../Block"; 2 + import { BlockProps, BlockLayout } from "../Block"; 3 3 import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 4 4 import { useCallback, useEffect, useState } from "react"; 5 5 import { Input } from "components/Input"; ··· 61 61 let totalVotes = votes.length; 62 62 63 63 return ( 64 - <div 65 - className={`poll flex flex-col gap-2 p-3 w-full 66 - ${isSelected ? "block-border-selected " : "block-border"}`} 67 - style={{ 68 - backgroundColor: 69 - "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 70 - }} 64 + <BlockLayout 65 + isSelected={!!isSelected} 66 + hasBackground={"accent"} 67 + className="poll flex flex-col gap-2 w-full" 71 68 > 72 69 {pollState === "editing" ? ( 73 70 <EditPoll ··· 95 92 hasVoted={!!hasVoted} 96 93 /> 97 94 )} 98 - </div> 95 + </BlockLayout> 99 96 ); 100 97 }; 101 98 ··· 486 483 }) => { 487 484 return ( 488 485 <button 489 - className="text-sm text-accent-contrast sm:hover:underline" 486 + className="text-sm text-accent-contrast " 490 487 onClick={() => { 491 488 props.setPollState(props.pollState === "voting" ? "results" : "voting"); 492 489 }}
+6 -9
components/Blocks/PublicationPollBlock.tsx
··· 1 1 import { useUIState } from "src/useUIState"; 2 - import { BlockProps } from "./Block"; 2 + import { BlockLayout, BlockProps } from "./Block"; 3 3 import { useMemo } from "react"; 4 4 import { AsyncValueInput } from "components/Input"; 5 5 import { focusElement } from "src/utils/focusElement"; ··· 53 53 }, [publicationData, props.entityID]); 54 54 55 55 return ( 56 - <div 57 - className={`poll flex flex-col gap-2 p-3 w-full 58 - ${isSelected ? "block-border-selected " : "block-border"}`} 59 - style={{ 60 - backgroundColor: 61 - "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 62 - }} 56 + <BlockLayout 57 + className="poll flex flex-col gap-2" 58 + hasBackground={"accent"} 59 + isSelected={!!isSelected} 63 60 > 64 61 <EditPollForPublication 65 62 entityID={props.entityID} 66 63 isPublished={isPublished} 67 64 /> 68 - </div> 65 + </BlockLayout> 69 66 ); 70 67 }; 71 68
+6 -8
components/Blocks/RSVPBlock/index.tsx
··· 1 1 "use client"; 2 2 import { Database } from "supabase/database.types"; 3 - import { BlockProps } from "components/Blocks/Block"; 3 + import { BlockProps, BlockLayout } from "components/Blocks/Block"; 4 4 import { useState } from "react"; 5 5 import { submitRSVP } from "actions/phone_rsvp_to_event"; 6 6 import { useRSVPData } from "components/PageSWRDataProvider"; ··· 29 29 s.selectedBlocks.find((b) => b.value === props.entityID), 30 30 ); 31 31 return ( 32 - <div 33 - className={`rsvp relative flex flex-col gap-1 border p-3 w-full rounded-lg place-items-center justify-center ${isSelected ? "block-border-selected " : "block-border"}`} 34 - style={{ 35 - backgroundColor: 36 - "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 37 - }} 32 + <BlockLayout 33 + isSelected={!!isSelected} 34 + hasBackground={"accent"} 35 + className="rsvp relative flex flex-col gap-1 w-full rounded-lg place-items-center justify-center" 38 36 > 39 37 <RSVPForm entityID={props.entityID} /> 40 - </div> 38 + </BlockLayout> 41 39 ); 42 40 } 43 41
+11 -5
components/Buttons.tsx
··· 38 38 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 39 39 bg-accent-1 disabled:bg-border-light 40 40 border border-accent-1 rounded-md disabled:border-border-light 41 - outline outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1 41 + outline-2 outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1 42 42 text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border 43 43 flex gap-2 items-center justify-center shrink-0 44 44 ${className} ··· 77 77 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 78 78 bg-bg-page disabled:bg-border-light 79 79 border border-accent-contrast rounded-md 80 - outline outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 80 + outline-2 outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 81 81 text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border 82 82 flex gap-2 items-center justify-center shrink-0 83 83 ${props.className} ··· 116 116 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 117 117 bg-transparent hover:bg-[var(--accent-light)] 118 118 border border-transparent rounded-md hover:border-[var(--accent-light)] 119 - outline outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 119 + outline-2 outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 120 120 text-base font-bold text-accent-contrast disabled:text-border 121 121 flex gap-2 items-center justify-center shrink-0 122 122 ${props.className} ··· 165 165 side={props.side ? props.side : undefined} 166 166 sideOffset={6} 167 167 alignOffset={12} 168 - className="z-10 bg-border rounded-md py-1 px-[6px] font-bold text-secondary text-sm" 168 + className="z-10 rounded-md py-1 px-[6px] font-bold text-secondary text-sm" 169 + style={{ 170 + backgroundColor: 171 + "color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 85%)", 172 + }} 169 173 > 170 174 {props.tooltipContent} 171 175 <RadixTooltip.Arrow ··· 175 179 viewBox="0 0 16 8" 176 180 > 177 181 <PopoverArrow 178 - arrowFill={theme.colors["border"]} 182 + arrowFill={ 183 + "color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 85%)" 184 + } 179 185 arrowStroke="transparent" 180 186 /> 181 187 </RadixTooltip.Arrow>
-94
components/Layout.tsx
··· 1 - "use client"; 2 - import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 3 - import { theme } from "tailwind.config"; 4 - import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 - import { PopoverArrow } from "./Icons/PopoverArrow"; 6 - import { PopoverOpenContext } from "./Popover/PopoverContext"; 7 - import { useState } from "react"; 8 - 9 1 export const Separator = (props: { classname?: string }) => { 10 2 return <div className={`h-full border-r border-border ${props.classname}`} />; 11 - }; 12 - 13 - export const Menu = (props: { 14 - open?: boolean; 15 - trigger: React.ReactNode; 16 - children: React.ReactNode; 17 - align?: "start" | "end" | "center" | undefined; 18 - alignOffset?: number; 19 - side?: "top" | "bottom" | "right" | "left" | undefined; 20 - background?: string; 21 - border?: string; 22 - className?: string; 23 - onOpenChange?: (o: boolean) => void; 24 - asChild?: boolean; 25 - }) => { 26 - let [open, setOpen] = useState(props.open || false); 27 - return ( 28 - <DropdownMenu.Root 29 - onOpenChange={(o) => { 30 - setOpen(o); 31 - props.onOpenChange?.(o); 32 - }} 33 - open={props.open} 34 - > 35 - <PopoverOpenContext value={open}> 36 - <DropdownMenu.Trigger asChild={props.asChild}> 37 - {props.trigger} 38 - </DropdownMenu.Trigger> 39 - <DropdownMenu.Portal> 40 - <NestedCardThemeProvider> 41 - <DropdownMenu.Content 42 - side={props.side ? props.side : "bottom"} 43 - align={props.align ? props.align : "center"} 44 - alignOffset={props.alignOffset ? props.alignOffset : undefined} 45 - sideOffset={4} 46 - collisionPadding={16} 47 - className={`dropdownMenu z-20 bg-bg-page flex flex-col p-1 gap-0.5 border border-border rounded-md shadow-md ${props.className}`} 48 - > 49 - {props.children} 50 - <DropdownMenu.Arrow 51 - asChild 52 - width={16} 53 - height={8} 54 - viewBox="0 0 16 8" 55 - > 56 - <PopoverArrow 57 - arrowFill={ 58 - props.background 59 - ? props.background 60 - : theme.colors["bg-page"] 61 - } 62 - arrowStroke={ 63 - props.border ? props.border : theme.colors["border"] 64 - } 65 - /> 66 - </DropdownMenu.Arrow> 67 - </DropdownMenu.Content> 68 - </NestedCardThemeProvider> 69 - </DropdownMenu.Portal> 70 - </PopoverOpenContext> 71 - </DropdownMenu.Root> 72 - ); 73 - }; 74 - 75 - export const MenuItem = (props: { 76 - children?: React.ReactNode; 77 - className?: string; 78 - onSelect: (e: Event) => void; 79 - id?: string; 80 - }) => { 81 - return ( 82 - <DropdownMenu.Item 83 - id={props.id} 84 - onSelect={(event) => { 85 - props.onSelect(event); 86 - }} 87 - className={` 88 - menuItem 89 - z-10 py-1! px-2! 90 - flex gap-2 91 - ${props.className} 92 - `} 93 - > 94 - {props.children} 95 - </DropdownMenu.Item> 96 - ); 97 3 }; 98 4 99 5 export const ShortcutKey = (props: { children: React.ReactNode }) => {
+97
components/Menu.tsx
··· 1 + "use client"; 2 + import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 3 + import { theme } from "tailwind.config"; 4 + import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 + import { PopoverArrow } from "./Icons/PopoverArrow"; 6 + import { PopoverOpenContext } from "./Popover/PopoverContext"; 7 + import { useState } from "react"; 8 + 9 + export const Menu = (props: { 10 + open?: boolean; 11 + trigger: React.ReactNode; 12 + children: React.ReactNode; 13 + align?: "start" | "end" | "center" | undefined; 14 + alignOffset?: number; 15 + side?: "top" | "bottom" | "right" | "left" | undefined; 16 + background?: string; 17 + border?: string; 18 + className?: string; 19 + onOpenChange?: (o: boolean) => void; 20 + asChild?: boolean; 21 + }) => { 22 + let [open, setOpen] = useState(props.open || false); 23 + 24 + return ( 25 + <DropdownMenu.Root 26 + onOpenChange={(o) => { 27 + setOpen(o); 28 + props.onOpenChange?.(o); 29 + }} 30 + open={props.open} 31 + > 32 + <PopoverOpenContext value={open}> 33 + <DropdownMenu.Trigger asChild={props.asChild}> 34 + {props.trigger} 35 + </DropdownMenu.Trigger> 36 + <DropdownMenu.Portal> 37 + <NestedCardThemeProvider> 38 + <DropdownMenu.Content 39 + side={props.side ? props.side : "bottom"} 40 + align={props.align ? props.align : "center"} 41 + alignOffset={props.alignOffset ? props.alignOffset : undefined} 42 + sideOffset={4} 43 + collisionPadding={16} 44 + className={` 45 + dropdownMenu z-20 p-1 46 + flex flex-col gap-0.5 47 + bg-bg-page 48 + border border-border rounded-md shadow-md 49 + ${props.className}`} 50 + > 51 + {props.children} 52 + <DropdownMenu.Arrow 53 + asChild 54 + width={16} 55 + height={8} 56 + viewBox="0 0 16 8" 57 + > 58 + <PopoverArrow 59 + arrowFill={ 60 + props.background 61 + ? props.background 62 + : theme.colors["bg-page"] 63 + } 64 + arrowStroke={ 65 + props.border ? props.border : theme.colors["border"] 66 + } 67 + /> 68 + </DropdownMenu.Arrow> 69 + </DropdownMenu.Content> 70 + </NestedCardThemeProvider> 71 + </DropdownMenu.Portal> 72 + </PopoverOpenContext> 73 + </DropdownMenu.Root> 74 + ); 75 + }; 76 + 77 + export const MenuItem = (props: { 78 + children?: React.ReactNode; 79 + className?: string; 80 + onSelect: (e: Event) => void; 81 + id?: string; 82 + }) => { 83 + return ( 84 + <DropdownMenu.Item 85 + id={props.id} 86 + onSelect={(event) => { 87 + props.onSelect(event); 88 + }} 89 + className={` 90 + menuItem 91 + ${props.className} 92 + `} 93 + > 94 + {props.children} 95 + </DropdownMenu.Item> 96 + ); 97 + };
+2 -2
components/Pages/PageOptions.tsx
··· 7 7 import { useReplicache } from "src/replicache"; 8 8 9 9 import { Media } from "../Media"; 10 - import { MenuItem, Menu } from "../Layout"; 10 + import { MenuItem, Menu } from "../Menu"; 11 11 import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; 12 12 import { PageShareMenu } from "./PageShareMenu"; 13 13 import { useUndoState } from "src/undoManager"; ··· 61 61 <div 62 62 className={`pageOptions w-fit z-10 63 63 ${props.isFocused ? "block" : "sm:hidden block"} 64 - absolute sm:-right-[20px] right-3 sm:top-3 top-0 64 + absolute sm:-right-[19px] right-3 sm:top-3 top-0 65 65 flex sm:flex-col flex-row-reverse gap-1 items-start`} 66 66 > 67 67 {!props.first && (
+4 -5
components/ThemeManager/PageThemeSetter.tsx
··· 3 3 import { pickers, SectionArrow, setColorAttribute } from "./ThemeSetter"; 4 4 5 5 import { 6 - PageBackgroundPicker, 6 + SubpageBackgroundPicker, 7 7 PageThemePickers, 8 8 } from "./Pickers/PageThemePickers"; 9 9 import { useMemo, useState } from "react"; ··· 54 54 className="pageThemeBG flex flex-col gap-2 h-full text-primary bg-bg-leaflet p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))]" 55 55 style={{ backgroundColor: "rgba(var(--bg-page), 0.6)" }} 56 56 > 57 - <PageBackgroundPicker 57 + <SubpageBackgroundPicker 58 58 entityID={props.entityID} 59 59 openPicker={openPicker} 60 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 61 - setValue={set("theme/card-background")} 60 + setOpenPicker={setOpenPicker} 62 61 /> 63 62 </div> 64 63 ··· 147 146 <div 148 147 className={ 149 148 pageBorderHidden 150 - ? "py-2 px-0 border border-transparent" 149 + ? "relative py-2 px-0 border border-transparent" 151 150 : `relative rounded-t-lg p-2 shadow-md text-primary border border-border border-b-transparent` 152 151 } 153 152 style={
+6
components/ThemeManager/Pickers/ColorPicker.tsx
··· 21 21 22 22 export const ColorPicker = (props: { 23 23 label?: string; 24 + helpText?: string; 24 25 value: Color | undefined; 25 26 alpha?: boolean; 26 27 image?: boolean; ··· 116 117 <div className="w-full flex flex-col gap-2 px-1 pb-2"> 117 118 { 118 119 <> 120 + {props.helpText && ( 121 + <div className="text-sm leading-tight text-tertiary pl-7 -mt-2.5"> 122 + {props.helpText} 123 + </div> 124 + )} 119 125 <ColorArea 120 126 className="w-full h-[128px] rounded-md" 121 127 colorSpace="hsb"
-162
components/ThemeManager/Pickers/LeafletBGPicker.tsx
··· 1 - "use client"; 2 - 3 - import { 4 - ColorPicker as SpectrumColorPicker, 5 - parseColor, 6 - Color, 7 - ColorArea, 8 - ColorThumb, 9 - ColorSlider, 10 - Input, 11 - ColorField, 12 - SliderTrack, 13 - ColorSwatch, 14 - } from "react-aria-components"; 15 - import { pickers, setColorAttribute } from "../ThemeSetter"; 16 - import { thumbStyle } from "./ColorPicker"; 17 - import { ImageInput, ImageSettings } from "./ImagePicker"; 18 - import { useEntity, useReplicache } from "src/replicache"; 19 - import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 20 - import { Separator } from "components/Layout"; 21 - import { onMouseDown } from "src/utils/iosInputMouseDown"; 22 - import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 23 - import { DeleteSmall } from "components/Icons/DeleteSmall"; 24 - 25 - export const LeafletBGPicker = (props: { 26 - entityID: string; 27 - openPicker: pickers; 28 - thisPicker: pickers; 29 - setOpenPicker: (thisPicker: pickers) => void; 30 - closePicker: () => void; 31 - setValue: (c: Color) => void; 32 - }) => { 33 - let bgImage = useEntity(props.entityID, "theme/background-image"); 34 - let bgRepeat = useEntity(props.entityID, "theme/background-image-repeat"); 35 - let bgColor = useColorAttribute(props.entityID, "theme/page-background"); 36 - let open = props.openPicker == props.thisPicker; 37 - let { rep } = useReplicache(); 38 - 39 - return ( 40 - <> 41 - <div className="bgPickerLabel flex justify-between place-items-center "> 42 - <div className="bgPickerColorLabel flex gap-2 items-center"> 43 - <button 44 - onClick={() => { 45 - if (props.openPicker === props.thisPicker) { 46 - props.setOpenPicker("null"); 47 - } else { 48 - props.setOpenPicker(props.thisPicker); 49 - } 50 - }} 51 - className="flex gap-2 items-center" 52 - > 53 - <ColorSwatch 54 - color={bgColor} 55 - className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]`} 56 - style={{ 57 - backgroundImage: bgImage?.data.src 58 - ? `url(${bgImage.data.src})` 59 - : undefined, 60 - backgroundSize: "cover", 61 - }} 62 - /> 63 - <strong className={` "text-[#595959]`}>{"Background"}</strong> 64 - </button> 65 - 66 - <div className="flex"> 67 - {bgImage ? ( 68 - <div className={`"text-[#969696]`}>Image</div> 69 - ) : ( 70 - <> 71 - <ColorField className="w-fit gap-1" value={bgColor}> 72 - <Input 73 - onMouseDown={onMouseDown} 74 - onFocus={(e) => { 75 - e.currentTarget.setSelectionRange( 76 - 1, 77 - e.currentTarget.value.length, 78 - ); 79 - }} 80 - onPaste={(e) => { 81 - console.log(e); 82 - }} 83 - onKeyDown={(e) => { 84 - if (e.key === "Enter") { 85 - e.currentTarget.blur(); 86 - } else return; 87 - }} 88 - onBlur={(e) => { 89 - props.setValue(parseColor(e.currentTarget.value)); 90 - }} 91 - className={`w-[72px] bg-transparent outline-nonetext-[#595959]`} 92 - /> 93 - </ColorField> 94 - </> 95 - )} 96 - </div> 97 - </div> 98 - <div className="flex gap-1 justify-end grow text-[#969696]"> 99 - {bgImage && ( 100 - <button 101 - onClick={() => { 102 - if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id }); 103 - if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id }); 104 - }} 105 - > 106 - <DeleteSmall /> 107 - </button> 108 - )} 109 - <label> 110 - <BlockImageSmall /> 111 - <div className="hidden"> 112 - <ImageInput 113 - {...props} 114 - onChange={() => { 115 - props.setOpenPicker(props.thisPicker); 116 - }} 117 - /> 118 - </div> 119 - </label> 120 - </div> 121 - </div> 122 - {open && ( 123 - <div className="bgImageAndColorPicker w-full flex flex-col gap-2 "> 124 - <SpectrumColorPicker 125 - value={bgColor} 126 - onChange={setColorAttribute( 127 - rep, 128 - props.entityID, 129 - )("theme/page-background")} 130 - > 131 - {bgImage ? ( 132 - <ImageSettings 133 - entityID={props.entityID} 134 - setValue={props.setValue} 135 - /> 136 - ) : ( 137 - <> 138 - <ColorArea 139 - className="w-full h-[128px] rounded-md" 140 - colorSpace="hsb" 141 - xChannel="saturation" 142 - yChannel="brightness" 143 - > 144 - <ColorThumb className={thumbStyle} /> 145 - </ColorArea> 146 - <ColorSlider 147 - colorSpace="hsb" 148 - className="w-full " 149 - channel="hue" 150 - > 151 - <SliderTrack className="h-2 w-full rounded-md"> 152 - <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 153 - </SliderTrack> 154 - </ColorSlider> 155 - </> 156 - )} 157 - </SpectrumColorPicker> 158 - </div> 159 - )} 160 - </> 161 - ); 162 - };
+339 -23
components/ThemeManager/Pickers/PageThemePickers.tsx
··· 61 61 ); 62 62 }; 63 63 64 - export const PageBackgroundPicker = (props: { 64 + // Page background picker for subpages - shows Page/Containers color with optional background image 65 + export const SubpageBackgroundPicker = (props: { 65 66 entityID: string; 66 - setValue: (c: Color) => void; 67 67 openPicker: pickers; 68 68 setOpenPicker: (p: pickers) => void; 69 - home?: boolean; 70 69 }) => { 70 + let { rep, rootEntity } = useReplicache(); 71 + let set = useMemo(() => { 72 + return setColorAttribute(rep, props.entityID); 73 + }, [rep, props.entityID]); 74 + 71 75 let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 72 76 let pageBGImage = useEntity(props.entityID, "theme/card-background-image"); 73 - let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden"); 77 + let rootPageBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 78 + let entityPageBorderHidden = useEntity( 79 + props.entityID, 80 + "theme/card-border-hidden", 81 + ); 82 + let pageBorderHidden = 83 + (entityPageBorderHidden || rootPageBorderHidden)?.data.value || false; 84 + let hasPageBackground = !pageBorderHidden; 85 + 86 + // Label is "Page" when page background is visible, "Containers" when hidden 87 + let label = hasPageBackground ? "Page" : "Containers"; 88 + 89 + // If root page border is hidden, only show color picker (no image support) 90 + if (!hasPageBackground) { 91 + return ( 92 + <ColorPicker 93 + label={label} 94 + helpText={"Affects menus, tooltips and some block backgrounds"} 95 + value={pageValue} 96 + setValue={set("theme/card-background")} 97 + thisPicker="page" 98 + openPicker={props.openPicker} 99 + setOpenPicker={props.setOpenPicker} 100 + closePicker={() => props.setOpenPicker("null")} 101 + alpha 102 + /> 103 + ); 104 + } 74 105 75 106 return ( 76 107 <> 77 - {pageBGImage && pageBGImage !== null && ( 78 - <PageBackgroundImagePicker 79 - disabled={pageBorderHidden?.data.value} 108 + {pageBGImage && ( 109 + <SubpageBackgroundImagePicker 80 110 entityID={props.entityID} 81 - thisPicker={"page-background-image"} 82 111 openPicker={props.openPicker} 83 112 setOpenPicker={props.setOpenPicker} 84 - closePicker={() => props.setOpenPicker("null")} 85 - setValue={props.setValue} 86 - home={props.home} 113 + setValue={set("theme/card-background")} 87 114 /> 88 115 )} 89 116 <div className="relative"> 90 - <PageBackgroundColorPicker 91 - label={pageBorderHidden?.data.value ? "Menus" : "Page"} 117 + <ColorPicker 118 + label={label} 92 119 value={pageValue} 93 - setValue={props.setValue} 94 - thisPicker={"page"} 120 + setValue={set("theme/card-background")} 121 + thisPicker="page" 95 122 openPicker={props.openPicker} 96 123 setOpenPicker={props.setOpenPicker} 124 + closePicker={() => props.setOpenPicker("null")} 97 125 alpha 98 126 /> 99 - {(pageBGImage === null || 100 - (!pageBGImage && !pageBorderHidden?.data.value && !props.home)) && ( 101 - <label 102 - className={` 103 - hover:cursor-pointer text-[#969696] shrink-0 104 - absolute top-0 right-0 105 - `} 106 - > 127 + {!pageBGImage && ( 128 + <label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0"> 107 129 <BlockImageSmall /> 108 130 <div className="hidden"> 109 131 <ImageInput ··· 119 141 ); 120 142 }; 121 143 144 + const SubpageBackgroundImagePicker = (props: { 145 + entityID: string; 146 + openPicker: pickers; 147 + setOpenPicker: (p: pickers) => void; 148 + setValue: (c: Color) => void; 149 + }) => { 150 + let { rep } = useReplicache(); 151 + let bgImage = useEntity(props.entityID, "theme/card-background-image"); 152 + let bgRepeat = useEntity( 153 + props.entityID, 154 + "theme/card-background-image-repeat", 155 + ); 156 + let bgColor = useColorAttribute(props.entityID, "theme/card-background"); 157 + let bgAlpha = 158 + useEntity(props.entityID, "theme/card-background-image-opacity")?.data 159 + .value || 1; 160 + let alphaColor = useMemo(() => { 161 + return parseColor(`rgba(0,0,0,${bgAlpha})`); 162 + }, [bgAlpha]); 163 + let open = props.openPicker === "page-background-image"; 164 + 165 + return ( 166 + <> 167 + <div className="bgPickerColorLabel flex gap-2 items-center"> 168 + <button 169 + onClick={() => { 170 + props.setOpenPicker(open ? "null" : "page-background-image"); 171 + }} 172 + className="flex gap-2 items-center grow" 173 + > 174 + <ColorSwatch 175 + color={bgColor} 176 + className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]" 177 + style={{ 178 + backgroundImage: bgImage?.data.src 179 + ? `url(${bgImage.data.src})` 180 + : undefined, 181 + backgroundPosition: "center", 182 + backgroundSize: "cover", 183 + }} 184 + /> 185 + <strong className="text-[#595959]">Page</strong> 186 + <div className="italic text-[#8C8C8C]">image</div> 187 + </button> 188 + 189 + <SpectrumColorPicker 190 + value={alphaColor} 191 + onChange={(c) => { 192 + let alpha = c.getChannelValue("alpha"); 193 + rep?.mutate.assertFact({ 194 + entity: props.entityID, 195 + attribute: "theme/card-background-image-opacity", 196 + data: { type: "number", value: alpha }, 197 + }); 198 + }} 199 + > 200 + <Separator classname="h-4! my-1 border-[#C3C3C3]!" /> 201 + <ColorField className="w-fit pl-[6px]" channel="alpha"> 202 + <Input 203 + onMouseDown={onMouseDown} 204 + onFocus={(e) => { 205 + e.currentTarget.setSelectionRange( 206 + 0, 207 + e.currentTarget.value.length - 1, 208 + ); 209 + }} 210 + onKeyDown={(e) => { 211 + if (e.key === "Enter") { 212 + e.currentTarget.blur(); 213 + } else return; 214 + }} 215 + className="w-[48px] bg-transparent outline-hidden" 216 + /> 217 + </ColorField> 218 + </SpectrumColorPicker> 219 + 220 + <div className="flex gap-1 text-[#8C8C8C]"> 221 + <button 222 + onClick={() => { 223 + if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id }); 224 + if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id }); 225 + }} 226 + > 227 + <DeleteSmall /> 228 + </button> 229 + <label className="hover:cursor-pointer"> 230 + <BlockImageSmall /> 231 + <div className="hidden"> 232 + <ImageInput 233 + entityID={props.entityID} 234 + onChange={() => props.setOpenPicker("page-background-image")} 235 + card 236 + /> 237 + </div> 238 + </label> 239 + </div> 240 + </div> 241 + {open && ( 242 + <div className="pageImagePicker flex flex-col gap-2"> 243 + <ImageSettings 244 + entityID={props.entityID} 245 + card 246 + setValue={props.setValue} 247 + /> 248 + <div className="flex flex-col gap-2 pr-2 pl-8 -mt-2 mb-2"> 249 + <hr className="border-[#DBDBDB]" /> 250 + <SpectrumColorPicker 251 + value={alphaColor} 252 + onChange={(c) => { 253 + let alpha = c.getChannelValue("alpha"); 254 + rep?.mutate.assertFact({ 255 + entity: props.entityID, 256 + attribute: "theme/card-background-image-opacity", 257 + data: { type: "number", value: alpha }, 258 + }); 259 + }} 260 + > 261 + <ColorSlider 262 + colorSpace="hsb" 263 + className="w-full mt-1 rounded-full" 264 + style={{ 265 + backgroundImage: `url(/transparent-bg.png)`, 266 + backgroundRepeat: "repeat", 267 + backgroundSize: "8px", 268 + }} 269 + channel="alpha" 270 + > 271 + <SliderTrack className="h-2 w-full rounded-md"> 272 + <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 273 + </SliderTrack> 274 + </ColorSlider> 275 + </SpectrumColorPicker> 276 + </div> 277 + </div> 278 + )} 279 + </> 280 + ); 281 + }; 282 + 283 + // Unified background picker for leaflets - matches structure of BackgroundPicker for publications 284 + export const LeafletBackgroundPicker = (props: { 285 + entityID: string; 286 + openPicker: pickers; 287 + setOpenPicker: (p: pickers) => void; 288 + }) => { 289 + let { rep } = useReplicache(); 290 + let set = useMemo(() => { 291 + return setColorAttribute(rep, props.entityID); 292 + }, [rep, props.entityID]); 293 + 294 + let leafletBgValue = useColorAttribute( 295 + props.entityID, 296 + "theme/page-background", 297 + ); 298 + let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 299 + let leafletBGImage = useEntity(props.entityID, "theme/background-image"); 300 + let leafletBGRepeat = useEntity( 301 + props.entityID, 302 + "theme/background-image-repeat", 303 + ); 304 + let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden"); 305 + let hasPageBackground = !pageBorderHidden?.data.value; 306 + 307 + // When page background is hidden and no background image, only show the Background picker 308 + let showPagePicker = hasPageBackground || !!leafletBGImage; 309 + 310 + return ( 311 + <> 312 + {/* Background color/image picker */} 313 + {leafletBGImage ? ( 314 + <LeafletBackgroundImagePicker 315 + entityID={props.entityID} 316 + openPicker={props.openPicker} 317 + setOpenPicker={props.setOpenPicker} 318 + /> 319 + ) : ( 320 + <div className="relative"> 321 + <ColorPicker 322 + label="Background" 323 + value={leafletBgValue} 324 + setValue={set("theme/page-background")} 325 + thisPicker="leaflet" 326 + openPicker={props.openPicker} 327 + setOpenPicker={props.setOpenPicker} 328 + closePicker={() => props.setOpenPicker("null")} 329 + /> 330 + <label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0"> 331 + <BlockImageSmall /> 332 + <div className="hidden"> 333 + <ImageInput 334 + entityID={props.entityID} 335 + onChange={() => props.setOpenPicker("leaflet")} 336 + /> 337 + </div> 338 + </label> 339 + </div> 340 + )} 341 + 342 + {/* Page/Containers color picker - only shown when page background is visible OR there's a bg image */} 343 + {showPagePicker && ( 344 + <ColorPicker 345 + label={hasPageBackground ? "Page" : "Containers"} 346 + helpText={ 347 + hasPageBackground 348 + ? undefined 349 + : "Affects menus, tooltips and some block backgrounds" 350 + } 351 + value={pageValue} 352 + setValue={set("theme/card-background")} 353 + thisPicker="page" 354 + openPicker={props.openPicker} 355 + setOpenPicker={props.setOpenPicker} 356 + closePicker={() => props.setOpenPicker("null")} 357 + alpha 358 + /> 359 + )} 360 + 361 + <hr className="border-[#CCCCCC]" /> 362 + 363 + {/* Page Background toggle */} 364 + <PageBorderHider 365 + entityID={props.entityID} 366 + openPicker={props.openPicker} 367 + setOpenPicker={props.setOpenPicker} 368 + /> 369 + </> 370 + ); 371 + }; 372 + 373 + const LeafletBackgroundImagePicker = (props: { 374 + entityID: string; 375 + openPicker: pickers; 376 + setOpenPicker: (p: pickers) => void; 377 + }) => { 378 + let { rep } = useReplicache(); 379 + let bgImage = useEntity(props.entityID, "theme/background-image"); 380 + let bgRepeat = useEntity(props.entityID, "theme/background-image-repeat"); 381 + let bgColor = useColorAttribute(props.entityID, "theme/page-background"); 382 + let open = props.openPicker === "leaflet"; 383 + 384 + return ( 385 + <> 386 + <div className="bgPickerColorLabel flex gap-2 items-center"> 387 + <button 388 + onClick={() => { 389 + props.setOpenPicker(open ? "null" : "leaflet"); 390 + }} 391 + className="flex gap-2 items-center grow" 392 + > 393 + <ColorSwatch 394 + color={bgColor} 395 + className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]" 396 + style={{ 397 + backgroundImage: bgImage?.data.src 398 + ? `url(${bgImage.data.src})` 399 + : undefined, 400 + backgroundPosition: "center", 401 + backgroundSize: "cover", 402 + }} 403 + /> 404 + <strong className="text-[#595959]">Background</strong> 405 + <div className="italic text-[#8C8C8C]">image</div> 406 + </button> 407 + <div className="flex gap-1 text-[#8C8C8C]"> 408 + <button 409 + onClick={() => { 410 + if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id }); 411 + if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id }); 412 + }} 413 + > 414 + <DeleteSmall /> 415 + </button> 416 + <label className="hover:cursor-pointer"> 417 + <BlockImageSmall /> 418 + <div className="hidden"> 419 + <ImageInput 420 + entityID={props.entityID} 421 + onChange={() => props.setOpenPicker("leaflet")} 422 + /> 423 + </div> 424 + </label> 425 + </div> 426 + </div> 427 + {open && ( 428 + <div className="pageImagePicker flex flex-col gap-2"> 429 + <ImageSettings entityID={props.entityID} setValue={() => {}} /> 430 + </div> 431 + )} 432 + </> 433 + ); 434 + }; 435 + 122 436 export const PageBackgroundColorPicker = (props: { 123 437 disabled?: boolean; 124 438 label: string; ··· 128 442 setValue: (c: Color) => void; 129 443 value: Color; 130 444 alpha?: boolean; 445 + helpText?: string; 131 446 }) => { 132 447 return ( 133 448 <ColorPicker 134 449 disabled={props.disabled} 135 450 label={props.label} 451 + helpText={props.helpText} 136 452 value={props.value} 137 453 setValue={props.setValue} 138 454 thisPicker={"page"}
+20 -9
components/ThemeManager/PubPickers/PubBackgroundPickers.tsx
··· 24 24 hasPageBackground: boolean; 25 25 setHasPageBackground: (s: boolean) => void; 26 26 }) => { 27 + // When showPageBackground is false (hasPageBackground=false) and no background image, show leafletBg picker 28 + let showLeafletBgPicker = !props.hasPageBackground && !props.bgImage; 29 + 27 30 return ( 28 31 <> 29 32 {props.bgImage && props.bgImage !== null ? ( ··· 83 86 )} 84 87 </div> 85 88 )} 86 - <PageBackgroundColorPicker 87 - label={"Containers"} 88 - value={props.pageBackground} 89 - setValue={props.setPageBackground} 90 - thisPicker={"page"} 91 - openPicker={props.openPicker} 92 - setOpenPicker={props.setOpenPicker} 93 - alpha={props.hasPageBackground ? true : false} 94 - /> 89 + {!showLeafletBgPicker && ( 90 + // When there's a background image and page background hidden, label should say "Containers" 91 + <PageBackgroundColorPicker 92 + label={props.hasPageBackground ? "Page" : "Containers"} 93 + helpText={ 94 + props.hasPageBackground 95 + ? undefined 96 + : "Affects menus, tooltips and some block backgrounds" 97 + } 98 + value={props.pageBackground} 99 + setValue={props.setPageBackground} 100 + thisPicker={"page"} 101 + openPicker={props.openPicker} 102 + setOpenPicker={props.setOpenPicker} 103 + alpha={props.hasPageBackground ? true : false} 104 + /> 105 + )} 95 106 <hr className="border-border-light" /> 96 107 <div className="flex gap-2 items-center"> 97 108 <Toggle
+1 -1
components/ThemeManager/PubThemeSetter.tsx
··· 62 62 let toaster = useToaster(); 63 63 64 64 return ( 65 - <BaseThemeProvider local {...localPubTheme}> 65 + <BaseThemeProvider local {...localPubTheme} hasBackgroundImage={!!image}> 66 66 <form 67 67 onSubmit={async (e) => { 68 68 e.preventDefault();
+6 -1
components/ThemeManager/PublicationThemeProvider.tsx
··· 104 104 }) { 105 105 let colors = usePubTheme(props.theme, props.isStandalone); 106 106 let cardBorderHidden = !colors.showPageBackground; 107 + let hasBackgroundImage = !!props.theme?.backgroundImage?.image?.ref; 107 108 return ( 108 109 <CardBorderHiddenContext.Provider value={cardBorderHidden}> 109 - <BaseThemeProvider local={props.local} {...colors}> 110 + <BaseThemeProvider 111 + local={props.local} 112 + {...colors} 113 + hasBackgroundImage={hasBackgroundImage} 114 + > 110 115 {props.children} 111 116 </BaseThemeProvider> 112 117 </CardBorderHiddenContext.Provider>
+10 -1
components/ThemeManager/ThemeProvider.tsx
··· 65 65 "theme/card-border-hidden", 66 66 )?.data.value; 67 67 let showPageBackground = !cardBorderHiddenValue; 68 + let backgroundImage = useEntity(props.entityID, "theme/background-image"); 69 + let hasBackgroundImage = !!backgroundImage; 68 70 let primary = useColorAttribute(props.entityID, "theme/primary"); 69 71 70 72 let highlight1 = useEntity(props.entityID, "theme/highlight-1"); ··· 87 89 accent1={accent1} 88 90 accent2={accent2} 89 91 showPageBackground={showPageBackground} 92 + hasBackgroundImage={hasBackgroundImage} 90 93 > 91 94 {props.children} 92 95 </BaseThemeProvider> ··· 98 101 export const BaseThemeProvider = ({ 99 102 local, 100 103 bgLeaflet, 101 - bgPage, 104 + bgPage: bgPageProp, 102 105 primary, 103 106 accent1, 104 107 accent2, ··· 106 109 highlight2, 107 110 highlight3, 108 111 showPageBackground, 112 + hasBackgroundImage, 109 113 children, 110 114 }: { 111 115 local?: boolean; 112 116 showPageBackground?: boolean; 117 + hasBackgroundImage?: boolean; 113 118 bgLeaflet: AriaColor; 114 119 bgPage: AriaColor; 115 120 primary: AriaColor; ··· 120 125 highlight3: AriaColor; 121 126 children: React.ReactNode; 122 127 }) => { 128 + // When showPageBackground is false and there's no background image, 129 + // pageBg should inherit from leafletBg 130 + const bgPage = 131 + !showPageBackground && !hasBackgroundImage ? bgLeaflet : bgPageProp; 123 132 // set accent contrast to the accent color that has the highest contrast with the page background 124 133 let accentContrast; 125 134
+2 -20
components/ThemeManager/ThemeSetter.tsx
··· 4 4 5 5 import { Color } from "react-aria-components"; 6 6 7 - import { LeafletBGPicker } from "./Pickers/LeafletBGPicker"; 8 7 import { 9 - PageBackgroundPicker, 10 - PageBorderHider, 8 + LeafletBackgroundPicker, 11 9 PageThemePickers, 12 10 } from "./Pickers/PageThemePickers"; 13 11 import { useMemo, useState } from "react"; ··· 117 115 <div className="themeBGLeaflet flex"> 118 116 <div className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}> 119 117 <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md"> 120 - <LeafletBGPicker 121 - entityID={props.entityID} 122 - thisPicker={"leaflet"} 123 - openPicker={openPicker} 124 - setOpenPicker={setOpenPicker} 125 - closePicker={() => setOpenPicker("null")} 126 - setValue={set("theme/page-background")} 127 - /> 128 - <PageBackgroundPicker 129 - entityID={props.entityID} 130 - setValue={set("theme/card-background")} 131 - openPicker={openPicker} 132 - setOpenPicker={setOpenPicker} 133 - home={props.home} 134 - /> 135 - <hr className=" border-[#CCCCCC]" /> 136 - <PageBorderHider 118 + <LeafletBackgroundPicker 137 119 entityID={props.entityID} 138 120 openPicker={openPicker} 139 121 setOpenPicker={setOpenPicker}