a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add theming!

+778 -48
+10 -3
app/[doc_id]/page.tsx
··· 1 1 import { Fact, ReplicacheProvider } from "src/replicache"; 2 2 import { Database } from "../../supabase/database.types"; 3 - import { Blocks } from "components/Blocks"; 4 3 import { Attributes } from "src/replicache/attributes"; 5 4 import { createServerClient } from "@supabase/ssr"; 6 5 import { SelectionManager } from "components/SelectionManager"; 7 6 import { Cards } from "components/Cards"; 7 + import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 8 8 9 9 export const preferredRegion = ["sfo1"]; 10 10 export const dynamic = "force-dynamic"; ··· 22 22 let initialFacts = (data as unknown as Fact<keyof typeof Attributes>[]) || []; 23 23 return ( 24 24 <ReplicacheProvider name={props.params.doc_id} initialFacts={initialFacts}> 25 - <SelectionManager /> 26 - <Cards rootCard={props.params.doc_id} /> 25 + <ThemeProvider entityID={props.params.doc_id}> 26 + <SelectionManager /> 27 + <div 28 + className="pageContentWrapper w-full relative overflow-x-scroll snap-x snap-mandatory no-scrollbar grow items-stretch flex h-full" 29 + id="card-carousel" 30 + > 31 + <Cards rootCard={props.params.doc_id} /> 32 + </div> 33 + </ThemeProvider> 27 34 </ReplicacheProvider> 28 35 ); 29 36 }
+32 -18
components/Cards.tsx
··· 3 3 import { Blocks } from "./Blocks"; 4 4 import useMeasure from "react-use-measure"; 5 5 import { elementId } from "src/utils/elementId"; 6 + import { ThemePopover } from "./ThemeManager/ThemeSetter"; 6 7 7 8 export function Cards(props: { rootCard: string }) { 8 9 let cards = useUIState((s) => s.openCards); 9 10 let [cardRef, { width: cardWidth }] = useMeasure(); 10 11 11 12 return ( 12 - <div 13 - className="pageContentWrapper w-full relative overflow-x-scroll snap-x snap-mandatory no-scrollbar grow items-stretch flex h-full" 14 - id="card-carousel" 15 - > 16 - <div className="pageContent flex py-4"> 17 - <div style={{ width: `calc((100vw - ${cardWidth}px)/2)` }} /> 18 - <Card entityID={props.rootCard} first /> 19 - {cards.map((card, index) => ( 20 - <div 21 - className="flex items-stretch" 22 - key={card} 23 - ref={index === 0 ? cardRef : null} 24 - > 25 - <Card entityID={card} /> 26 - </div> 27 - ))} 28 - <div style={{ width: `calc((100vw - ${cardWidth}px)/2)` }} /> 13 + <div className="pageContent flex py-4"> 14 + <div 15 + className="flex justify-end items-start" 16 + style={{ width: `calc((100vw - ${cardWidth}px)/2)` }} 17 + > 18 + <div className="flex flex-col gap-2 mr-4 mt-2"> 19 + <PageOptions entityID={props.rootCard} /> 20 + </div> 29 21 </div> 22 + <Card entityID={props.rootCard} first /> 23 + {cards.map((card, index) => ( 24 + <div 25 + className="flex items-stretch" 26 + key={card} 27 + ref={index === 0 ? cardRef : null} 28 + > 29 + <Card entityID={card} /> 30 + </div> 31 + ))} 32 + <div style={{ width: `calc((100vw - ${cardWidth}px)/2)` }} /> 30 33 </div> 31 34 ); 32 35 } 33 36 37 + export const PageOptions = (props: { entityID: string }) => { 38 + return ( 39 + <> 40 + <ThemePopover entityID={props.entityID} /> 41 + </> 42 + ); 43 + }; 44 + 34 45 function Card(props: { entityID: string; first?: boolean }) { 35 46 return ( 36 47 <> 37 48 {!props.first && <div className="w-6 md:snap-center" />} 38 49 <div 39 50 id={elementId.card(props.entityID).container} 51 + style={{ 52 + backgroundColor: "rgba(var(--bg-card), var(--bg-card-alpha))", 53 + }} 40 54 className={` 41 55 cardWrapper w-[calc(100vw-12px)] md:w-[calc(50vw-32px)] max-w-prose 42 56 relative 43 57 grow flex flex-col 44 58 overflow-y-scroll no-scrollbar 45 59 snap-center 46 - bg-bg-card rounded-lg border 60 + rounded-lg border 47 61 ${false ? "shadow-md border-border" : "border-border-light"} 48 62 49 63 `}
+5 -1
components/TextBlock/index.tsx
··· 184 184 }); 185 185 } 186 186 addImage(file, rep.rep, { 187 + attribute: "block/image", 187 188 entityID: entity, 188 189 }); 189 190 } ··· 278 279 attribute: "block/type", 279 280 data: { type: "block-type-union", value: "image" }, 280 281 }); 281 - await addImage(file, rep, { entityID: entity }); 282 + await addImage(file, rep, { 283 + entityID: entity, 284 + attribute: "block/image", 285 + }); 282 286 }} 283 287 /> 284 288 </div>
+1 -1
components/TextBlock/schema.ts
··· 1 - import { Schema } from "prosemirror-model"; 1 + import { Schema, Node } from "prosemirror-model"; 2 2 import { marks } from "prosemirror-schema-basic"; 3 3 4 4 let baseSchema = {
+77
components/ThemeManager/ThemeProvider.tsx
··· 1 + "use client"; 2 + 3 + import { CSSProperties, useEffect } from "react"; 4 + import { colorToString, useColorAttribute } from "./useColorAttribute"; 5 + import { Color } from "react-aria-components"; 6 + import { useEntity } from "src/replicache"; 7 + 8 + type CSSVariables = { 9 + "--bg-page": string; 10 + "--bg-card": string; 11 + "--primary": string; 12 + "--accent": string; 13 + "--accent-text": string; 14 + }; 15 + 16 + export const ThemeDefaults = { 17 + "theme/page-background": "#F0F7FA", 18 + "theme/card-background": "#FFFFFF", 19 + "theme/primary": "#272727", 20 + "theme/accent-background": "#0000FF", 21 + "theme/accent-text": "#FFFFFF", 22 + }; 23 + 24 + function setCSSVariableToColor(el: HTMLElement, name: string, value: Color) { 25 + el?.style.setProperty(name, colorToString(value)); 26 + } 27 + export function ThemeProvider(props: { 28 + entityID: string; 29 + children: React.ReactNode; 30 + }) { 31 + let bgPage = useColorAttribute(props.entityID, "theme/page-background"); 32 + let bgCard = useColorAttribute(props.entityID, "theme/card-background"); 33 + let bgCardAlpha = useEntity(props.entityID, "theme/card-background-alpha"); 34 + let primary = useColorAttribute(props.entityID, "theme/primary"); 35 + let accentBG = useColorAttribute(props.entityID, "theme/accent-background"); 36 + let accentText = useColorAttribute(props.entityID, "theme/accent-text"); 37 + let backgroundImage = useEntity(props.entityID, "theme/background-image"); 38 + let backgroundImageRepeat = useEntity( 39 + props.entityID, 40 + "theme/background-image-repeat", 41 + ); 42 + useEffect(() => { 43 + let el = document.querySelector(":root") as HTMLElement; 44 + if (!el) return; 45 + setCSSVariableToColor(el, "--bg-page", bgPage); 46 + setCSSVariableToColor(el, "--bg-card", bgCard); 47 + el?.style.setProperty( 48 + "--bg-card-alpha", 49 + (bgCardAlpha?.data.value || 1).toString(), 50 + ); 51 + setCSSVariableToColor(el, "--primary", primary); 52 + setCSSVariableToColor(el, "--accent", accentBG); 53 + setCSSVariableToColor(el, "--accent-text", accentText); 54 + }, [bgPage, bgCard, primary, accentBG, accentText, bgCardAlpha]); 55 + return ( 56 + <div 57 + className="pageWrapper w-full bg-bg-page text-primary h-screen flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 58 + style={ 59 + { 60 + backgroundImage: `url(${backgroundImage?.data.src})`, 61 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 62 + backgroundSize: !backgroundImageRepeat 63 + ? "cover" 64 + : backgroundImageRepeat?.data.value, 65 + "--bg-page": colorToString(bgPage), 66 + "--bg-card": colorToString(bgCard), 67 + "--bg-card-alpha": bgCardAlpha?.data.value || 1, 68 + "--primary": colorToString(primary), 69 + "--accent": colorToString(accentBG), 70 + "--accent-text": colorToString(accentText), 71 + } as CSSProperties 72 + } 73 + > 74 + {props.children} 75 + </div> 76 + ); 77 + }
+556
components/ThemeManager/ThemeSetter.tsx
··· 1 + import * as Popover from "@radix-ui/react-popover"; 2 + import * as Slider from "@radix-ui/react-slider"; 3 + import { theme } from "../../tailwind.config"; 4 + 5 + import { 6 + ColorPicker as SpectrumColorPicker, 7 + parseColor, 8 + Color, 9 + ColorArea, 10 + ColorThumb, 11 + ColorSlider, 12 + Input, 13 + ColorField, 14 + SliderTrack, 15 + ColorSwatch, 16 + } from "react-aria-components"; 17 + 18 + import { useMemo, useState } from "react"; 19 + import { BlockImageSmall, CloseConstrastSmall } from "components/Icons"; 20 + import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 21 + import { Replicache } from "replicache"; 22 + import { FilterAttributes } from "src/replicache/attributes"; 23 + import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 24 + import { addImage } from "src/utils/addImage"; 25 + 26 + function colorToString(value: Color) { 27 + return value.toString("rgb").slice(4, -1); 28 + } 29 + 30 + export type pickers = 31 + | "null" 32 + | "page" 33 + | "card" 34 + | "accent" 35 + | "accentText" 36 + | "text"; 37 + 38 + function setColorAttribute( 39 + rep: Replicache<ReplicacheMutators> | null, 40 + entity: string, 41 + ) { 42 + return (attribute: keyof FilterAttributes<{ type: "color" }>) => 43 + (color: Color) => 44 + rep?.mutate.assertFact({ 45 + entity, 46 + attribute, 47 + data: { type: "color", value: colorToString(color) }, 48 + }); 49 + } 50 + export const ThemePopover = (props: { entityID: string }) => { 51 + let { rep } = useReplicache(); 52 + // I need to get these variables from replicache and then write them to the DB. I also need to parse them into a state that can be used here. 53 + let pageValue = useColorAttribute(props.entityID, "theme/page-background"); 54 + let cardValue = useColorAttribute(props.entityID, "theme/card-background"); 55 + let cardBGAlpha = useEntity(props.entityID, "theme/card-background-alpha"); 56 + let primaryValue = useColorAttribute(props.entityID, "theme/primary"); 57 + let accentBGValue = useColorAttribute( 58 + props.entityID, 59 + "theme/accent-background", 60 + ); 61 + let backgroundImage = useEntity(props.entityID, "theme/background-image"); 62 + let backgroundRepeat = useEntity( 63 + props.entityID, 64 + "theme/background-image-repeat", 65 + ); 66 + let accentTextValue = useColorAttribute(props.entityID, "theme/accent-text"); 67 + let [openPicker, setOpenPicker] = useState<pickers>("page"); 68 + let set = useMemo(() => { 69 + return setColorAttribute(rep, props.entityID); 70 + }, [rep, props.entityID]); 71 + 72 + let randomPositions = useMemo(() => { 73 + let values = [] as string[]; 74 + for (let i = 0; i < 3; i++) { 75 + values.push( 76 + `${Math.floor(Math.random() * 100)}% ${Math.floor(Math.random() * 100)}%`, 77 + ); 78 + } 79 + return values; 80 + }, []); 81 + 82 + let gradient = [ 83 + `radial-gradient(at ${randomPositions[0]}, ${accentBGValue.toString("hex")}80 2px, transparent 70%)`, 84 + `radial-gradient(at ${randomPositions[1]}, ${cardValue.toString("hex")}66 2px, transparent 60%)`, 85 + `radial-gradient(at ${randomPositions[2]}, ${primaryValue.toString("hex")}B3 2px, transparent 100%)`, 86 + ].join(", "); 87 + 88 + return ( 89 + <> 90 + <Popover.Root> 91 + <Popover.Trigger> 92 + <div 93 + className="rounded-full w-7 h-7 border border-border" 94 + style={{ 95 + backgroundColor: pageValue.toString("hex"), 96 + backgroundImage: gradient, 97 + }} 98 + /> 99 + 100 + <div className="relative z-10"></div> 101 + </Popover.Trigger> 102 + <Popover.Portal> 103 + <Popover.Content 104 + className="w-80 max-h-[800px] p-2 overflow-y-scroll bg-white rounded-md border border-border flex" 105 + align="center" 106 + sideOffset={4} 107 + collisionPadding={16} 108 + > 109 + <div className="flex flex-col w-full overflow-hidden "> 110 + <div className="themeBGPage flex pt-1 pr-2 pl-0 items-start"> 111 + <BGPicker 112 + entityID={props.entityID} 113 + thisPicker={"page"} 114 + openPicker={openPicker} 115 + setOpenPicker={setOpenPicker} 116 + closePicker={() => setOpenPicker("null")} 117 + /> 118 + <div className="w-2 h-full border-t-2 border-r-2 border-border rounded-tr-md mt-[13px]" /> 119 + </div> 120 + <div 121 + style={{ 122 + backgroundImage: `url(${backgroundImage?.data.src})`, 123 + backgroundRepeat: backgroundRepeat ? "repeat" : "no-repeat", 124 + backgroundSize: !backgroundRepeat 125 + ? "cover" 126 + : `calc(${backgroundRepeat.data.value}px / 2 )`, 127 + }} 128 + className="bg-bg-page p-3 pb-0 flex flex-col rounded-md" 129 + > 130 + <div className="themeAccentControls flex flex-col h-full"> 131 + <div className="themeAccentColor flex w-full pr-2 items-start "> 132 + <ColorPicker 133 + label="Accent" 134 + value={accentBGValue} 135 + setValue={set("theme/accent-background")} 136 + thisPicker={"accent"} 137 + openPicker={openPicker} 138 + setOpenPicker={setOpenPicker} 139 + closePicker={() => setOpenPicker("null")} 140 + /> 141 + <div className="w-4 h-full border-t-2 border-r-2 border-accent rounded-tr-md mt-[13px] pb-[52px] -mb-[52px]" /> 142 + </div> 143 + <div className="themeTextAccentColor w-full flex pr-2 pb-1 items-start "> 144 + <div className="flex w-full gap-2 items-center place-self-end"> 145 + <ColorPicker 146 + label="Text on Accent" 147 + value={accentTextValue} 148 + setValue={set("theme/accent-text")} 149 + thisPicker={"accentText"} 150 + openPicker={openPicker} 151 + setOpenPicker={setOpenPicker} 152 + closePicker={() => setOpenPicker("null")} 153 + /> 154 + </div> 155 + <div className="w-2 h-full border-r-2 border-t-2 border-border rounded-tr-md mt-[13px] z-10" /> 156 + <div className="w-2 h-full border-r-2 border-accent " /> 157 + </div> 158 + <div className="font-bold relative text-center text-lg py-2 rounded-md bg-accent text-accentText shadow-md"> 159 + Button 160 + <div className="absolute h-[26px] w-[92px] top-0 right-[15.5px] border-b-2 border-r-2 rounded-br-md border-border" /> 161 + </div> 162 + </div> 163 + <hr className="my-3" /> 164 + 165 + <div className="themePageControls flex flex-col h-full pb-1"> 166 + <div className="themePageColor flex pr-2 items-start "> 167 + <ColorPicker 168 + label="Page" 169 + value={cardValue} 170 + alpha={{ 171 + value: cardBGAlpha?.data.value || 1, 172 + onChange: (a) => { 173 + if (!rep) return; 174 + rep.mutate.assertFact({ 175 + entity: props.entityID, 176 + attribute: "theme/card-background-alpha", 177 + data: { type: "number", value: a }, 178 + }); 179 + }, 180 + }} 181 + setValue={set("theme/card-background")} 182 + thisPicker={"card"} 183 + openPicker={openPicker} 184 + setOpenPicker={setOpenPicker} 185 + closePicker={() => setOpenPicker("null")} 186 + /> 187 + <div className="w-4 h-full border-t border-r border-border rounded-tr-md mt-3" /> 188 + </div> 189 + <div className="themePageTextColor w-full flex pr-2 items-start"> 190 + <div className="flex w-full gap-2 items-center place-self-end"> 191 + <ColorPicker 192 + label="Text" 193 + value={primaryValue} 194 + setValue={set("theme/primary")} 195 + thisPicker={"text"} 196 + openPicker={openPicker} 197 + setOpenPicker={setOpenPicker} 198 + closePicker={() => setOpenPicker("null")} 199 + /> 200 + </div> 201 + <div className="w-2 h-full border-b border-r border-t border-border rounded-r-md mt-3 z-10 " /> 202 + <div className="w-2 h-full border-r border-border " /> 203 + </div> 204 + </div> 205 + 206 + <div 207 + className="rounded-t-lg p-2 border border-border border-b-transparent shadow-md text-primary" 208 + style={{ 209 + backgroundColor: 210 + "rgba(var(--bg-card), var(--bg-card-alpha))", 211 + }} 212 + > 213 + <p className="font-bold">Hello!</p> 214 + <small className=""> 215 + Welcome to Leaflet. It&apos;s a super easy and fun way to 216 + make, share, and collab on little bits of paper 217 + </small> 218 + </div> 219 + </div> 220 + </div> 221 + 222 + <Popover.Arrow /> 223 + </Popover.Content> 224 + </Popover.Portal> 225 + </Popover.Root> 226 + </> 227 + ); 228 + }; 229 + 230 + let thumbStyle = 231 + "w-4 h-4 rounded-full border-2 border-white shadow-[0_0_0_1px_black,_inset_0_0_0_1px_black]"; 232 + const ColorPicker = (props: { 233 + label?: string; 234 + value: Color; 235 + alpha?: { value: number; onChange: (alpha: number) => void }; 236 + setValue: (c: Color) => void; 237 + openPicker: pickers; 238 + thisPicker: pickers; 239 + setOpenPicker: (thisPicker: pickers) => void; 240 + closePicker: () => void; 241 + }) => { 242 + let value = useMemo(() => { 243 + if (!props.alpha) return props.value; 244 + return props.value 245 + .toFormat("rgba") 246 + .withChannelValue("alpha", props.alpha.value); 247 + }, [props.value, props.alpha]); 248 + return ( 249 + <SpectrumColorPicker value={value} onChange={props.setValue}> 250 + <div className="flex flex-col w-full"> 251 + <button 252 + onClick={() => { 253 + props.setOpenPicker(props.thisPicker); 254 + }} 255 + className="colorPickerLabel flex gap-1 items-center place-self-end py-[2px] pl-1 mb-1 rounded-md" 256 + > 257 + <strong className="text-primary">{props.label}</strong> 258 + <div className="flex"> 259 + <ColorField className="w-fit" defaultValue={value}> 260 + <Input 261 + onFocus={(e) => { 262 + e.currentTarget.setSelectionRange( 263 + 1, 264 + e.currentTarget.value.length, 265 + ); 266 + props.setOpenPicker(props.thisPicker); 267 + }} 268 + onKeyDown={(e) => { 269 + if (e.key === "Enter") { 270 + e.currentTarget.blur(); 271 + } else return; 272 + }} 273 + onBlur={(e) => { 274 + props.setValue(parseColor(e.currentTarget.value)); 275 + }} 276 + className="w-[72px] bg-transparent outline-none text-primary" 277 + /> 278 + </ColorField> 279 + </div> 280 + <div className="flex items-center"> 281 + <ColorSwatch 282 + color={value} 283 + className={`w-6 h-6 rounded-full border-2 border-border`} 284 + style={{ 285 + backgroundSize: "cover", 286 + }} 287 + /> 288 + <div className="border border-border w-1" /> 289 + </div> 290 + </button> 291 + {props.openPicker === props.thisPicker && ( 292 + <div className="w-full flex flex-col gap-2 pb-3"> 293 + { 294 + <> 295 + <ColorArea 296 + className="w-full h-[128px] rounded-md" 297 + colorSpace="hsb" 298 + xChannel="saturation" 299 + yChannel="brightness" 300 + > 301 + <ColorThumb className={thumbStyle} /> 302 + </ColorArea> 303 + <ColorSlider 304 + colorSpace="hsb" 305 + className="w-full " 306 + channel="hue" 307 + > 308 + <SliderTrack className="h-2 w-full rounded-md"> 309 + <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 310 + </SliderTrack> 311 + </ColorSlider> 312 + {props.alpha && ( 313 + <ColorSlider 314 + value={value} 315 + colorSpace="hsb" 316 + className="w-full pt-1" 317 + channel="alpha" 318 + onChange={(value) => { 319 + props.alpha?.onChange(value.getChannelValue("alpha")); 320 + }} 321 + > 322 + <SliderTrack className="h-2 w-full rounded-md"> 323 + <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 324 + </SliderTrack> 325 + </ColorSlider> 326 + )} 327 + </> 328 + } 329 + </div> 330 + )} 331 + </div> 332 + </SpectrumColorPicker> 333 + ); 334 + }; 335 + 336 + const BGPicker = (props: { 337 + entityID: string; 338 + openPicker: pickers; 339 + thisPicker: pickers; 340 + setOpenPicker: (thisPicker: pickers) => void; 341 + closePicker: () => void; 342 + }) => { 343 + let bgImage = useEntity(props.entityID, "theme/background-image"); 344 + let bgColor = useColorAttribute(props.entityID, "theme/page-background"); 345 + let open = props.openPicker == props.thisPicker; 346 + let { rep } = useReplicache(); 347 + 348 + return ( 349 + <div> 350 + <button 351 + onClick={() => { 352 + props.setOpenPicker(props.thisPicker); 353 + }} 354 + className="colorPickerLabel flex gap-1 items-center place-self-end py-[2px] pl-1 mb-1 rounded-md" 355 + > 356 + <strong className="text-primary">Background</strong> 357 + <div className="flex"> 358 + <ColorField className="w-fit" defaultValue={bgColor}> 359 + <Input 360 + onFocus={(e) => { 361 + e.currentTarget.setSelectionRange( 362 + 1, 363 + e.currentTarget.value.length, 364 + ); 365 + props.setOpenPicker(props.thisPicker); 366 + }} 367 + onKeyDown={(e) => { 368 + if (e.key === "Enter") { 369 + e.currentTarget.blur(); 370 + } else return; 371 + }} 372 + className="w-[72px] bg-transparent outline-none text-primary" 373 + /> 374 + </ColorField> 375 + 376 + <label className="hover:cursor-pointer "> 377 + <div className="opacity-30 hover:opacity-100 hover:text-accent"> 378 + <BlockImageSmall /> 379 + </div> 380 + <div className="hidden"> 381 + <input 382 + type="file" 383 + accept="image/*" 384 + onChange={async (e) => { 385 + let file = e.currentTarget.files?.[0]; 386 + if (!file || !rep) return; 387 + await addImage(file, rep, { 388 + entityID: props.entityID, 389 + attribute: "theme/background-image", 390 + }); 391 + }} 392 + /> 393 + </div> 394 + </label> 395 + </div> 396 + <div className="flex items-center"> 397 + <ColorSwatch 398 + color={bgColor} 399 + className={`w-6 h-6 rounded-full border-2 border-border`} 400 + style={{ 401 + backgroundImage: `url(${bgImage?.data.src})`, 402 + backgroundSize: "cover", 403 + }} 404 + /> 405 + <div className="border border-border w-1" /> 406 + </div> 407 + </button> 408 + {open && ( 409 + <div className="w-full flex flex-col gap-2 pb-3"> 410 + {props.thisPicker === "page" && bgImage ? ( 411 + <ImageSettings entityID={props.entityID} /> 412 + ) : ( 413 + <SpectrumColorPicker 414 + value={bgColor} 415 + onChange={setColorAttribute( 416 + rep, 417 + props.entityID, 418 + )("theme/page-background")} 419 + > 420 + <ColorArea 421 + className="w-full h-[128px] rounded-md" 422 + colorSpace="hsb" 423 + xChannel="saturation" 424 + yChannel="brightness" 425 + > 426 + <ColorThumb className={thumbStyle} /> 427 + </ColorArea> 428 + <ColorSlider colorSpace="hsb" className="w-full " channel="hue"> 429 + <SliderTrack className="h-2 w-full rounded-md"> 430 + <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 431 + </SliderTrack> 432 + </ColorSlider> 433 + </SpectrumColorPicker> 434 + )} 435 + </div> 436 + )} 437 + </div> 438 + ); 439 + }; 440 + 441 + const ImageSettings = (props: { entityID: string }) => { 442 + let image = useEntity(props.entityID, "theme/background-image"); 443 + let repeat = useEntity(props.entityID, "theme/background-image-repeat"); 444 + let { rep } = useReplicache(); 445 + return ( 446 + <> 447 + <div 448 + style={{ 449 + backgroundImage: `url(${image?.data.src})`, 450 + }} 451 + className="themeBGImagePreview flex gap-2 place-items-center justify-center w-full h-[128px] bg-cover bg-center bg-no-repeat" 452 + > 453 + <label className="hover:cursor-pointer "> 454 + <div className="opacity-30 hover:opacity-100 hover:text-accent"> 455 + <BlockImageSmall /> 456 + </div> 457 + <div className="hidden"> 458 + <input 459 + type="file" 460 + accept="image/*" 461 + onChange={async (e) => { 462 + let file = e.currentTarget.files?.[0]; 463 + if (!file || !rep) return; 464 + await addImage(file, rep, { 465 + entityID: props.entityID, 466 + attribute: "theme/background-image", 467 + }); 468 + }} 469 + /> 470 + </div> 471 + </label> 472 + <button 473 + onClick={() => { 474 + if (image) rep?.mutate.retractFact({ factID: image.id }); 475 + if (repeat) rep?.mutate.retractFact({ factID: repeat.id }); 476 + }} 477 + > 478 + <CloseConstrastSmall 479 + fill={theme.colors.primary} 480 + outline={theme.colors["bg-card"]} 481 + /> 482 + </button> 483 + </div> 484 + <div className="themeBGImageControls p-2 flex gap-2 items-center"> 485 + <label htmlFor="cover" className="flex shrink-0"> 486 + <input 487 + className="appearance-none" 488 + type="radio" 489 + id="cover" 490 + name="cover" 491 + value="cover" 492 + checked={!repeat} 493 + onChange={async (e) => { 494 + if (!e.currentTarget.checked) return; 495 + if (!repeat) return; 496 + await rep?.mutate.retractFact({ factID: repeat.id }); 497 + }} 498 + /> 499 + <div 500 + className={`border border-accent rounded-md px-1 py-0.5 cursor-pointer ${!repeat ? "bg-accent text-accentText" : "bg-transparent text-accent"}`} 501 + > 502 + cover 503 + </div> 504 + </label> 505 + <label htmlFor="repeat" className="flex shrink-0"> 506 + <input 507 + className={`appearance-none `} 508 + type="radio" 509 + id="repeat" 510 + name="repeat" 511 + value="repeat" 512 + checked={!!repeat} 513 + onChange={async (e) => { 514 + if (!e.currentTarget.checked) return; 515 + if (repeat) return; 516 + await rep?.mutate.assertFact({ 517 + entity: props.entityID, 518 + attribute: "theme/background-image-repeat", 519 + data: { type: "number", value: 500 }, 520 + }); 521 + }} 522 + /> 523 + <div 524 + className={`border border-accent rounded-md px-1 py-0.5 cursor-pointer ${repeat ? "bg-accent text-accentText" : "bg-transparent text-accent"}`} 525 + > 526 + repeat 527 + </div> 528 + </label> 529 + {repeat && ( 530 + <Slider.Root 531 + className="relative grow flex items-center select-none touch-none w-full h-fit" 532 + value={[repeat.data.value]} 533 + max={3000} 534 + min={10} 535 + step={10} 536 + onValueChange={(value) => { 537 + rep?.mutate.assertFact({ 538 + entity: props.entityID, 539 + attribute: "theme/background-image-repeat", 540 + data: { type: "number", value: value[0] }, 541 + }); 542 + }} 543 + > 544 + <Slider.Track className="bg-accent relative grow rounded-full h-[3px]"> 545 + {/* <Slider.Range className="absolute bg-accentText rounded-full h-full" /> */} 546 + </Slider.Track> 547 + <Slider.Thumb 548 + className="flex w-4 h-4 rounded-full border-2 border-white bg-accent shadow-[0_0_0_1px_black,_inset_0_0_0_1px_black] cursor-pointer" 549 + aria-label="Volume" 550 + /> 551 + </Slider.Root> 552 + )} 553 + </div> 554 + </> 555 + ); 556 + };
+21
components/ThemeManager/useColorAttribute.ts
··· 1 + import { useMemo } from "react"; 2 + import { Color, parseColor } from "react-aria-components"; 3 + import { useEntity } from "src/replicache"; 4 + import { FilterAttributes } from "src/replicache/attributes"; 5 + import { ThemeDefaults } from "./ThemeProvider"; 6 + 7 + export function useColorAttribute( 8 + entity: string, 9 + attribute: keyof FilterAttributes<{ type: "color"; cardinality: "one" }>, 10 + ) { 11 + let color = useEntity(entity, attribute); 12 + return useMemo(() => { 13 + return parseColor( 14 + color ? `rgb(${color.data.value})` : ThemeDefaults[attribute], 15 + ); 16 + }, [color, attribute]); 17 + } 18 + 19 + export function colorToString(value: Color) { 20 + return value.toString("rgb").slice(4, -1); 21 + }
+69 -1
src/replicache/attributes.ts
··· 1 - export const Attributes = { 1 + const CardAttributes = { 2 2 "card/block": { 3 3 type: "ordered-reference", 4 4 cardinality: "many", 5 5 }, 6 + } as const; 7 + 8 + const BlockAttributes = { 6 9 "block/type": { 7 10 type: "block-type-union", 8 11 cardinality: "one", ··· 25 28 }, 26 29 } as const; 27 30 31 + const ThemeAttributes = { 32 + "theme/page-background": { 33 + type: "color", 34 + cardinality: "one", 35 + }, 36 + "theme/card-background": { 37 + type: "color", 38 + cardinality: "one", 39 + }, 40 + "theme/card-background-alpha": { 41 + type: "number", 42 + cardinality: "one", 43 + }, 44 + "theme/primary": { 45 + type: "color", 46 + cardinality: "one", 47 + }, 48 + "theme/accent-background": { 49 + type: "color", 50 + cardinality: "one", 51 + }, 52 + "theme/accent-text": { 53 + type: "color", 54 + cardinality: "one", 55 + }, 56 + "theme/background-image": { 57 + type: "image", 58 + cardinality: "one", 59 + }, 60 + "theme/background-image-repeat": { 61 + type: "number", 62 + cardinality: "one", 63 + }, 64 + } as const; 65 + 66 + export const Attributes = { 67 + ...CardAttributes, 68 + ...BlockAttributes, 69 + ...ThemeAttributes, 70 + }; 28 71 type Attribute = typeof Attributes; 72 + export type Data<A extends keyof typeof Attributes> = { 73 + text: { type: "text"; value: string }; 74 + "ordered-reference": { 75 + type: "ordered-reference"; 76 + position: string; 77 + value: string; 78 + }; 79 + image: { 80 + type: "image"; 81 + src: string; 82 + height: number; 83 + width: number; 84 + local?: string; 85 + }; 86 + number: { 87 + type: "number"; 88 + value: number; 89 + }; 90 + reference: { type: "reference"; value: string }; 91 + "block-type-union": { 92 + type: "block-type-union"; 93 + value: "text" | "image" | "card"; 94 + }; 95 + color: { type: "color"; value: string }; 96 + }[(typeof Attributes)[A]["type"]]; 29 97 export type FilterAttributes<F extends Partial<Attribute[keyof Attribute]>> = { 30 98 [A in keyof Attribute as Attribute[A] extends F ? A : never]: Attribute[A]; 31 99 };
+1 -22
src/replicache/index.tsx
··· 4 4 import { DeepReadonlyObject, Replicache, WriteTransaction } from "replicache"; 5 5 import { Pull } from "./pull"; 6 6 import { mutations } from "./mutations"; 7 - import { Attributes } from "./attributes"; 7 + import { Attributes, Data } from "./attributes"; 8 8 import { Push } from "./push"; 9 9 import { clientMutationContext } from "./clientMutationContext"; 10 10 import { supabaseBrowserClient } from "supabase/browserClient"; ··· 15 15 attribute: A; 16 16 data: Data<A>; 17 17 }; 18 - 19 - type Data<A extends keyof typeof Attributes> = { 20 - text: { type: "text"; value: string }; 21 - "ordered-reference": { 22 - type: "ordered-reference"; 23 - position: string; 24 - value: string; 25 - }; 26 - image: { 27 - type: "image"; 28 - src: string; 29 - height: number; 30 - width: number; 31 - local?: string; 32 - }; 33 - reference: { type: "reference"; value: string }; 34 - "block-type-union": { 35 - type: "block-type-union"; 36 - value: "text" | "image" | "card"; 37 - }; 38 - }[(typeof Attributes)[A]["type"]]; 39 18 40 19 let ReplicacheContext = createContext({ 41 20 rep: null as null | Replicache<ReplicacheMutators>,
+6 -2
src/utils/addImage.ts
··· 1 1 import { Replicache } from "replicache"; 2 2 import { ReplicacheMutators } from "../replicache"; 3 3 import { supabaseBrowserClient } from "supabase/browserClient"; 4 + import { FilterAttributes } from "src/replicache/attributes"; 4 5 5 6 export async function addImage( 6 7 file: File, 7 8 rep: Replicache<ReplicacheMutators>, 8 - args: { entityID: string }, 9 + args: { 10 + entityID: string; 11 + attribute: keyof FilterAttributes<{ type: "image" }>; 12 + }, 9 13 ) { 10 14 let client = supabaseBrowserClient(); 11 15 let cache = await caches.open("minilink-user-assets"); ··· 39 43 await client.storage.from("minilink-user-assets").upload(fileID, file); 40 44 await rep.mutate.assertFact({ 41 45 entity: args.entityID, 42 - attribute: "block/image", 46 + attribute: args.attribute, 43 47 data: { 44 48 type: "image", 45 49 src: url,